diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index a8c35c9e6..f234aebe7 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -126,7 +126,7 @@ jobs: path: ui/build - name: Config /github/workspace folder as trusted - uses: docker://deluan/ci-goreleaser:1.21.4-2 + uses: docker://deluan/ci-goreleaser:1.21.5-1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -134,7 +134,7 @@ jobs: - name: Run GoReleaser - SNAPSHOT if: startsWith(github.ref, 'refs/tags/') != true - uses: docker://deluan/ci-goreleaser:1.21.4-2 + uses: docker://deluan/ci-goreleaser:1.21.5-1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -142,7 +142,7 @@ jobs: - name: Run GoReleaser - RELEASE if: startsWith(github.ref, 'refs/tags/') - uses: docker://deluan/ci-goreleaser:1.21.4-2 + uses: docker://deluan/ci-goreleaser:1.21.5-1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/Makefile b/Makefile index 8b0b07f5e..09f46c554 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ GIT_SHA=source_archive GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD))) endif -CI_RELEASER_VERSION=1.21.4-2 ## https://github.com/navidrome/ci-goreleaser +CI_RELEASER_VERSION=1.21.5-1 ## https://github.com/navidrome/ci-goreleaser setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment @echo Downloading Node dependencies... diff --git a/cmd/inspect.go b/cmd/inspect.go new file mode 100644 index 000000000..f53145e79 --- /dev/null +++ b/cmd/inspect.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/scanner/metadata" + "github.com/navidrome/navidrome/tests" + "github.com/pelletier/go-toml/v2" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var ( + extractor string + format string +) + +func init() { + inspectCmd.Flags().StringVarP(&extractor, "extractor", "x", "", "extractor to use (ffmpeg or taglib, default: auto)") + inspectCmd.Flags().StringVarP(&format, "format", "f", "pretty", "output format (pretty, toml, yaml, json, jsonindent)") + rootCmd.AddCommand(inspectCmd) +} + +var inspectCmd = &cobra.Command{ + Use: "inspect [files to inspect]", + Short: "Inspect tags", + Long: "Show file tags as seen by Navidrome", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runInspector(args) + }, +} + +var marshalers = map[string]func(interface{}) ([]byte, error){ + "pretty": prettyMarshal, + "toml": toml.Marshal, + "yaml": yaml.Marshal, + "json": json.Marshal, + "jsonindent": func(v interface{}) ([]byte, error) { + return json.MarshalIndent(v, "", " ") + }, +} + +func prettyMarshal(v interface{}) ([]byte, error) { + out := v.([]inspectorOutput) + var res strings.Builder + for i := range out { + res.WriteString(fmt.Sprintf("====================\nFile: %s\n\n", out[i].File)) + t, _ := toml.Marshal(out[i].RawTags) + res.WriteString(fmt.Sprintf("Raw tags:\n%s\n\n", t)) + t, _ = toml.Marshal(out[i].MappedTags) + res.WriteString(fmt.Sprintf("Mapped tags:\n%s\n\n", t)) + } + return []byte(res.String()), nil +} + +type inspectorOutput struct { + File string + RawTags metadata.ParsedTags + MappedTags model.MediaFile +} + +func runInspector(args []string) { + if extractor != "" { + conf.Server.Scanner.Extractor = extractor + } + log.Info("Using extractor", "extractor", conf.Server.Scanner.Extractor) + md, err := metadata.Extract(args...) + if err != nil { + log.Fatal("Error extracting tags", err) + } + mapper := scanner.NewMediaFileMapper(conf.Server.MusicFolder, &tests.MockedGenreRepo{}) + marshal := marshalers[format] + if marshal == nil { + log.Fatal("Invalid format", "format", format) + } + var out []inspectorOutput + for k, v := range md { + if !model.IsAudioFile(k) { + continue + } + if len(v.Tags) == 0 { + continue + } + out = append(out, inspectorOutput{ + File: k, + RawTags: v.Tags, + MappedTags: mapper.ToMediaFile(v), + }) + } + data, _ := marshal(out) + fmt.Println(string(data)) +} diff --git a/conf/configuration.go b/conf/configuration.go index 1af859558..eb0222abe 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -205,7 +205,7 @@ func Load() { } // Print current configuration if log level is Debug - if log.CurrentLevel() >= log.LevelDebug { + if log.IsGreaterOrEqualTo(log.LevelDebug) { prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server) if Server.EnableLogRedacting { prettyConf = log.Redact(prettyConf) diff --git a/consts/mime_types.go b/consts/mime_types.go index c05a08638..225e1857e 100644 --- a/consts/mime_types.go +++ b/consts/mime_types.go @@ -33,6 +33,7 @@ var audioFormats = map[string]format{ ".dsf": {typ: "audio/dsd", lossless: true}, ".wv": {typ: "audio/x-wavpack", lossless: true}, ".wvp": {typ: "audio/x-wavpack", lossless: true}, + ".tak": {typ: "audio/tak", lossless: true}, ".mka": {typ: "audio/x-matroska"}, } var imageFormats = map[string]string{ diff --git a/core/agents/agents.go b/core/agents/agents.go index 1f9b8c0dd..0a11297c3 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -134,7 +134,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l } similar, err := agent.GetSimilarArtists(ctx, id, name, mbid, limit) if len(similar) > 0 && err == nil { - if log.CurrentLevel() >= log.LevelTrace { + if log.IsGreaterOrEqualTo(log.LevelTrace) { log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start)) } else { log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similarReceived", len(similar), "elapsed", time.Since(start)) diff --git a/core/agents/lastfm/auth_router.go b/core/agents/lastfm/auth_router.go index 372b5b632..ebcf7bcb7 100644 --- a/core/agents/lastfm/auth_router.go +++ b/core/agents/lastfm/auth_router.go @@ -18,7 +18,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/req" ) //go:embed token_received.html @@ -89,13 +89,14 @@ func (s *Router) unlink(w http.ResponseWriter, r *http.Request) { } func (s *Router) callback(w http.ResponseWriter, r *http.Request) { - token := utils.ParamString(r, "token") - if token == "" { + p := req.Params(r) + token, err := p.String("token") + if err != nil { _ = rest.RespondWithError(w, http.StatusBadRequest, "token not received") return } - uid := utils.ParamString(r, "uid") - if uid == "" { + uid, err := p.String("uid") + if err != nil { _ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received") return } @@ -103,7 +104,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) { // Need to add user to context, as this is a non-authenticated endpoint, so it does not // automatically contain any user info ctx := request.WithUser(r.Context(), model.User{ID: uid}) - err := s.fetchSessionKey(ctx, uid, token) + err = s.fetchSessionKey(ctx, uid, token) if err != nil { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusBadRequest) diff --git a/core/archiver.go b/core/archiver.go index 2ca155be2..c48f292f9 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -160,7 +160,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med } defer func() { - if err := r.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug { + if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { log.Error(ctx, "Error closing stream", "id", mf.ID, "file", mf.Path, err) } }() diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 628f41986..f81435e09 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -100,7 +100,7 @@ type ffCmd struct { func (j *ffCmd) start() error { cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec cmd.Stdout = j.out - if log.CurrentLevel() >= log.LevelTrace { + if log.IsGreaterOrEqualTo(log.LevelTrace) { cmd.Stderr = os.Stderr } else { cmd.Stderr = io.Discard diff --git a/core/playback/beepaudio/decoder.go b/core/playback/beepaudio/decoder.go deleted file mode 100644 index 17ce803f8..000000000 --- a/core/playback/beepaudio/decoder.go +++ /dev/null @@ -1,66 +0,0 @@ -//go:build beep - -package beepaudio - -import ( - "context" - "io" - "os" - - "github.com/faiface/beep" - "github.com/faiface/beep/flac" - "github.com/faiface/beep/mp3" - "github.com/faiface/beep/wav" - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/log" -) - -func DecodeMp3(path string) (s beep.StreamSeekCloser, format beep.Format, err error) { - f, err := os.Open(path) - if err != nil { - return nil, beep.Format{}, err - } - return mp3.Decode(f) -} - -func DecodeWAV(path string) (s beep.StreamSeekCloser, format beep.Format, err error) { - f, err := os.Open(path) - if err != nil { - return nil, beep.Format{}, err - } - return wav.Decode(f) -} - -func DecodeFLAC(path string) (s beep.StreamSeekCloser, format beep.Format, fileToCleanup string, err error) { - // TODO: Turn this into a semi-parallel operation: start playing while still transcoding/copying - log.Debug("decode to FLAC", "filename", path) - fFmpeg := ffmpeg.New() - readCloser, err := fFmpeg.ConvertToFLAC(context.TODO(), path) - if err != nil { - log.Error("error converting file to FLAC", path, err) - return nil, beep.Format{}, "", err - } - - tempFile, err := os.CreateTemp("", "*.flac") - - if err != nil { - log.Error("error creating temp file", err) - return nil, beep.Format{}, "", err - } - log.Debug("created tempfile", "filename", tempFile.Name()) - - written, err := io.Copy(tempFile, readCloser) - if err != nil { - log.Error("error coping file", "dest", tempFile.Name()) - } - log.Debug("copy pipe into tempfile", "bytes written", written, "filename", tempFile.Name()) - - f, err := os.Open(tempFile.Name()) - if err != nil { - log.Error("could not re-open tempfile", "filename", tempFile.Name()) - return nil, beep.Format{}, "", err - } - - s, format, err = flac.Decode(f) - return s, format, tempFile.Name(), err -} diff --git a/core/playback/beepaudio/track.go b/core/playback/beepaudio/track.go deleted file mode 100644 index 693607e86..000000000 --- a/core/playback/beepaudio/track.go +++ /dev/null @@ -1,162 +0,0 @@ -//go:build beep - -package beepaudio - -import ( - "fmt" - "os" - "time" - - "github.com/faiface/beep" - "github.com/faiface/beep/effects" - "github.com/faiface/beep/speaker" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -type BeepTrack struct { - MediaFile model.MediaFile - Ctrl *beep.Ctrl - Volume *effects.Volume - ActiveStream beep.StreamSeekCloser - TempfileToCleanup string - SampleRate beep.SampleRate - PlaybackDone chan bool -} - -func NewTrack(playbackDoneChannel chan bool, mf model.MediaFile) (*BeepTrack, error) { - t := BeepTrack{} - - contentType := mf.ContentType() - log.Debug("loading track", "trackname", mf.Path, "mediatype", contentType) - - var streamer beep.StreamSeekCloser - var format beep.Format - var err error - var tmpfileToCleanup = "" - - switch contentType { - case "audio/mpeg": - streamer, format, err = DecodeMp3(mf.Path) - case "audio/x-wav": - streamer, format, err = DecodeWAV(mf.Path) - case "audio/mp4": - streamer, format, tmpfileToCleanup, err = DecodeFLAC(mf.Path) - default: - return nil, fmt.Errorf("unsupported content type: %s", contentType) - } - - if err != nil { - log.Error(err) - return nil, err - } - - // save running stream for closing when switching tracks - t.ActiveStream = streamer - t.TempfileToCleanup = tmpfileToCleanup - - log.Debug("Setting up audio device") - t.Ctrl = &beep.Ctrl{Streamer: streamer, Paused: true} - t.Volume = &effects.Volume{Streamer: t.Ctrl, Base: 2} - t.SampleRate = format.SampleRate - t.PlaybackDone = playbackDoneChannel - t.MediaFile = mf - - err = speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/10)) - if err != nil { - log.Error(err) - } - log.Debug("speaker.Init() finished") - - go func() { - speaker.Play(beep.Seq(t.Volume, beep.Callback(func() { - log.Info("Hitting end-of-stream, signalling on channel") - t.PlaybackDone <- true - log.Debug("Signalling finished") - }))) - log.Debug("dropping out of speaker.Play()") - }() - return &t, nil -} - -func (t *BeepTrack) String() string { - return fmt.Sprintf("Name: %s", t.MediaFile.Path) -} - -func (t *BeepTrack) SetVolume(value float64) { - speaker.Lock() - t.Volume.Volume += value - speaker.Unlock() -} - -func (t *BeepTrack) Unpause() { - speaker.Lock() - if t.Ctrl.Paused { - t.Ctrl.Paused = false - } else { - log.Debug("tried to unpause while not paused") - } - speaker.Unlock() -} - -func (t *BeepTrack) Pause() { - speaker.Lock() - if t.Ctrl.Paused { - log.Debug("tried to pause while already paused") - } else { - t.Ctrl.Paused = true - } - speaker.Unlock() -} - -func (t *BeepTrack) Close() { - if t.ActiveStream != nil { - log.Debug("closing activ stream") - t.ActiveStream.Close() - t.ActiveStream = nil - } - - speaker.Close() - - if t.TempfileToCleanup != "" { - log.Debug("Removing tempfile", "tmpfilename", t.TempfileToCleanup) - err := os.Remove(t.TempfileToCleanup) - if err != nil { - log.Error("error cleaning up tempfile: ", t.TempfileToCleanup) - } - } -} - -// Position returns the playback position in seconds -func (t *BeepTrack) Position() int { - if t.Ctrl.Streamer == nil { - log.Debug("streamer is not setup (nil), could not get position") - return 0 - } - - streamer, ok := t.Ctrl.Streamer.(beep.StreamSeeker) - if ok { - position := t.SampleRate.D(streamer.Position()) - posSecs := position.Round(time.Second).Seconds() - return int(posSecs) - } else { - log.Debug("streamer is no beep.StreamSeeker, could not get position") - return 0 - } -} - -// offset = pd.PlaybackQueue.Offset -func (t *BeepTrack) SetPosition(offset int) error { - streamer, ok := t.Ctrl.Streamer.(beep.StreamSeeker) - if ok { - sampleRatePerSecond := t.SampleRate.N(time.Second) - nextPosition := sampleRatePerSecond * offset - log.Debug("SetPosition", "samplerate", sampleRatePerSecond, "nextPosition", nextPosition) - return streamer.Seek(nextPosition) - } - return fmt.Errorf("streamer is not seekable") -} - -func (t *BeepTrack) IsPlaying() bool { - return t.Ctrl != nil && !t.Ctrl.Paused -} diff --git a/core/playback/device.go b/core/playback/device.go index d7c7355f0..2ece8906e 100644 --- a/core/playback/device.go +++ b/core/playback/device.go @@ -2,6 +2,7 @@ package playback import ( "context" + "errors" "fmt" "github.com/navidrome/navidrome/core/playback/mpv" @@ -17,9 +18,10 @@ type Track interface { Position() int SetPosition(offset int) error Close() + String() string } -type PlaybackDevice struct { +type playbackDevice struct { ParentPlaybackServer PlaybackServer Default bool User string @@ -43,7 +45,7 @@ const DefaultGain float32 = 1.0 var EmptyStatus = DeviceStatus{CurrentIndex: -1, Playing: false, Gain: DefaultGain, Position: 0} -func (pd *PlaybackDevice) getStatus() DeviceStatus { +func (pd *playbackDevice) getStatus() DeviceStatus { pos := 0 if pd.ActiveTrack != nil { pos = pd.ActiveTrack.Position() @@ -59,8 +61,8 @@ func (pd *PlaybackDevice) getStatus() DeviceStatus { // NewPlaybackDevice creates a new playback device which implements all the basic Jukebox mode commands defined here: // http://www.subsonic.org/pages/api.jsp#jukeboxControl // Starts the trackSwitcher goroutine for the device. -func NewPlaybackDevice(playbackServer PlaybackServer, name string, deviceName string) *PlaybackDevice { - return &PlaybackDevice{ +func NewPlaybackDevice(playbackServer PlaybackServer, name string, deviceName string) *playbackDevice { + return &playbackDevice{ ParentPlaybackServer: playbackServer, User: "", Name: name, @@ -72,22 +74,24 @@ func NewPlaybackDevice(playbackServer PlaybackServer, name string, deviceName st } } -func (pd *PlaybackDevice) String() string { +func (pd *playbackDevice) String() string { return fmt.Sprintf("Name: %s, Gain: %.4f, Loaded track: %s", pd.Name, pd.Gain, pd.ActiveTrack) } -func (pd *PlaybackDevice) Get(ctx context.Context) (model.MediaFiles, DeviceStatus, error) { - log.Debug(ctx, "processing Get action") +func (pd *playbackDevice) Get(ctx context.Context) (model.MediaFiles, DeviceStatus, error) { + log.Debug(ctx, "Processing Get action", "device", pd) return pd.PlaybackQueue.Get(), pd.getStatus(), nil } -func (pd *PlaybackDevice) Status(ctx context.Context) (DeviceStatus, error) { +func (pd *playbackDevice) Status(ctx context.Context) (DeviceStatus, error) { log.Debug(ctx, fmt.Sprintf("processing Status action on: %s, queue: %s", pd, pd.PlaybackQueue)) return pd.getStatus(), nil } -// set is similar to a clear followed by a add, but will not change the currently playing track. -func (pd *PlaybackDevice) Set(ctx context.Context, ids []string) (DeviceStatus, error) { +// Set is similar to a clear followed by a add, but will not change the currently playing track. +func (pd *playbackDevice) Set(ctx context.Context, ids []string) (DeviceStatus, error) { + log.Debug(ctx, "Processing Set action", "ids", ids, "device", pd) + _, err := pd.Clear(ctx) if err != nil { log.Error(ctx, "error setting tracks", ids) @@ -96,8 +100,8 @@ func (pd *PlaybackDevice) Set(ctx context.Context, ids []string) (DeviceStatus, return pd.Add(ctx, ids) } -func (pd *PlaybackDevice) Start(ctx context.Context) (DeviceStatus, error) { - log.Debug(ctx, "processing Start action") +func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) { + log.Debug(ctx, "Processing Start action", "device", pd) if !pd.TrackSwitcherStarted { log.Info(ctx, "Starting trackSwitcher goroutine") @@ -127,16 +131,16 @@ func (pd *PlaybackDevice) Start(ctx context.Context) (DeviceStatus, error) { return pd.getStatus(), nil } -func (pd *PlaybackDevice) Stop(ctx context.Context) (DeviceStatus, error) { - log.Debug(ctx, "processing Stop action") +func (pd *playbackDevice) Stop(ctx context.Context) (DeviceStatus, error) { + log.Debug(ctx, "Processing Stop action", "device", pd) if pd.ActiveTrack != nil { pd.ActiveTrack.Pause() } return pd.getStatus(), nil } -func (pd *PlaybackDevice) Skip(ctx context.Context, index int, offset int) (DeviceStatus, error) { - log.Debug(ctx, "processing Skip action", "index", index, "offset", offset) +func (pd *playbackDevice) Skip(ctx context.Context, index int, offset int) (DeviceStatus, error) { + log.Debug(ctx, "Processing Skip action", "index", index, "offset", offset, "device", pd) wasPlaying := pd.isPlaying() @@ -173,8 +177,11 @@ func (pd *PlaybackDevice) Skip(ctx context.Context, index int, offset int) (Devi return pd.getStatus(), nil } -func (pd *PlaybackDevice) Add(ctx context.Context, ids []string) (DeviceStatus, error) { - log.Debug(ctx, "processing Add action") +func (pd *playbackDevice) Add(ctx context.Context, ids []string) (DeviceStatus, error) { + log.Debug(ctx, "Processing Add action", "ids", ids, "device", pd) + if len(ids) < 1 { + return pd.getStatus(), nil + } items := model.MediaFiles{} @@ -191,8 +198,8 @@ func (pd *PlaybackDevice) Add(ctx context.Context, ids []string) (DeviceStatus, return pd.getStatus(), nil } -func (pd *PlaybackDevice) Clear(ctx context.Context) (DeviceStatus, error) { - log.Debug(ctx, fmt.Sprintf("processing Clear action on: %s", pd)) +func (pd *playbackDevice) Clear(ctx context.Context) (DeviceStatus, error) { + log.Debug(ctx, "Processing Clear action", "device", pd) if pd.ActiveTrack != nil { pd.ActiveTrack.Pause() pd.ActiveTrack.Close() @@ -202,8 +209,8 @@ func (pd *PlaybackDevice) Clear(ctx context.Context) (DeviceStatus, error) { return pd.getStatus(), nil } -func (pd *PlaybackDevice) Remove(ctx context.Context, index int) (DeviceStatus, error) { - log.Debug(ctx, "processing Remove action") +func (pd *playbackDevice) Remove(ctx context.Context, index int) (DeviceStatus, error) { + log.Debug(ctx, "Processing Remove action", "index", index, "device", pd) // pausing if attempting to remove running track if pd.isPlaying() && pd.PlaybackQueue.Index == index { _, err := pd.Stop(ctx) @@ -221,17 +228,17 @@ func (pd *PlaybackDevice) Remove(ctx context.Context, index int) (DeviceStatus, return pd.getStatus(), nil } -func (pd *PlaybackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) { - log.Debug(ctx, "processing Shuffle action") +func (pd *playbackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) { + log.Debug(ctx, "Processing Shuffle action", "device", pd) if pd.PlaybackQueue.Size() > 1 { pd.PlaybackQueue.Shuffle() } return pd.getStatus(), nil } -// Used to control the playback volume. A float value between 0.0 and 1.0. -func (pd *PlaybackDevice) SetGain(ctx context.Context, gain float32) (DeviceStatus, error) { - log.Debug(ctx, fmt.Sprintf("processing SetGain action. Actual gain: %f, gain to set: %f", pd.Gain, gain)) +// SetGain is used to control the playback volume. A float value between 0.0 and 1.0. +func (pd *playbackDevice) SetGain(ctx context.Context, gain float32) (DeviceStatus, error) { + log.Debug(ctx, "Processing SetGain action", "newGain", gain, "device", pd) if pd.ActiveTrack != nil { pd.ActiveTrack.SetVolume(gain) @@ -241,15 +248,15 @@ func (pd *PlaybackDevice) SetGain(ctx context.Context, gain float32) (DeviceStat return pd.getStatus(), nil } -func (pd *PlaybackDevice) isPlaying() bool { +func (pd *playbackDevice) isPlaying() bool { return pd.ActiveTrack != nil && pd.ActiveTrack.IsPlaying() } -func (pd *PlaybackDevice) trackSwitcherGoroutine() { - log.Info("Starting trackSwitcher goroutine") +func (pd *playbackDevice) trackSwitcherGoroutine() { + log.Debug("Started trackSwitcher goroutine", "device", pd) for { <-pd.PlaybackDone - log.Info("track switching detected") + log.Debug("Track switching detected") if pd.ActiveTrack != nil { pd.ActiveTrack.Close() pd.ActiveTrack = nil @@ -260,7 +267,7 @@ func (pd *PlaybackDevice) trackSwitcherGoroutine() { log.Debug("Switching to next song", "queue", pd.PlaybackQueue.String()) err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index) if err != nil { - log.Error("error switching track", "error", err) + log.Error("Error switching track", err) } pd.ActiveTrack.Unpause() } else { @@ -269,11 +276,11 @@ func (pd *PlaybackDevice) trackSwitcherGoroutine() { } } -func (pd *PlaybackDevice) switchActiveTrackByIndex(index int) error { +func (pd *playbackDevice) switchActiveTrackByIndex(index int) error { pd.PlaybackQueue.SetIndex(index) currentTrack := pd.PlaybackQueue.Current() if currentTrack == nil { - return fmt.Errorf("could not get current track") + return errors.New("could not get current track") } track, err := mpv.NewTrack(pd.PlaybackDone, pd.DeviceName, *currentTrack) diff --git a/core/playback/mpv/mpv.go b/core/playback/mpv/mpv.go index 85505f11d..957d779ce 100644 --- a/core/playback/mpv/mpv.go +++ b/core/playback/mpv/mpv.go @@ -54,7 +54,7 @@ func (j *Executor) start() error { j.ctx = ctx cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec cmd.Stdout = j.out - if log.CurrentLevel() >= log.LevelTrace { + if log.IsGreaterOrEqualTo(log.LevelTrace) { cmd.Stderr = os.Stderr } else { cmd.Stderr = io.Discard diff --git a/core/playback/mpv/track.go b/core/playback/mpv/track.go index 1008debc6..b94f907d7 100644 --- a/core/playback/mpv/track.go +++ b/core/playback/mpv/track.go @@ -10,7 +10,7 @@ import ( "os" "time" - "github.com/DexterLB/mpvipc" + "github.com/dexterlb/mpvipc" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) @@ -25,7 +25,7 @@ type MpvTrack struct { } func NewTrack(playbackDoneChannel chan bool, deviceName string, mf model.MediaFile) (*MpvTrack, error) { - log.Debug("loading track", "trackname", mf.Path, "mediatype", mf.ContentType()) + log.Debug("Loading track", "trackPath", mf.Path, "mediaType", mf.ContentType()) if _, err := mpvCommand(); err != nil { return nil, err @@ -36,14 +36,14 @@ func NewTrack(playbackDoneChannel chan bool, deviceName string, mf model.MediaFi args := createMPVCommand(mpvComdTemplate, deviceName, mf.Path, tmpSocketName) exe, err := start(args) if err != nil { - log.Error("error starting mpv process", "error", err) + log.Error("Error starting mpv process", err) return nil, err } // wait for socket to show up - err = waitForFile(tmpSocketName, 3*time.Second, 100*time.Millisecond) + err = waitForSocket(tmpSocketName, 3*time.Second, 100*time.Millisecond) if err != nil { - log.Error("error or timeout waiting for control socket", "socketname", tmpSocketName, "error", err) + log.Error("Error or timeout waiting for control socket", "socketname", tmpSocketName, err) return nil, err } @@ -51,7 +51,7 @@ func NewTrack(playbackDoneChannel chan bool, deviceName string, mf model.MediaFi err = conn.Open() if err != nil { - log.Error("error opening new connection", "error", err) + log.Error("Error opening new connection", err) return nil, err } @@ -92,7 +92,7 @@ func (t *MpvTrack) Unpause() { if err != nil { log.Error(err) } - log.Info("unpaused track") + log.Debug("Unpaused track", "track", t) } func (t *MpvTrack) Pause() { @@ -111,7 +111,7 @@ func (t *MpvTrack) Close() { log.Debug("sending shutdown command") _, err := t.Conn.Call("quit") if err != nil { - log.Error("error sending quit command to mpv-ipc socket", "error", err) + log.Error("Error sending quit command to mpv-ipc socket", err) if t.Exe != nil { log.Debug("cancelling executor") @@ -141,16 +141,16 @@ func (t *MpvTrack) isSocketfilePresent() bool { return err == nil && fileInfo != nil && !fileInfo.IsDir() } -// Position returns the playback position in seconds -// every now and then the mpv IPC interface returns "mpv error: property unavailable" +// Position returns the playback position in seconds. +// Every now and then the mpv IPC interface returns "mpv error: property unavailable" // in this case we have to retry func (t *MpvTrack) Position() int { retryCount := 0 for { position, err := t.Conn.Get("time-pos") if err != nil && err.Error() == "mpv error: property unavailable" { - log.Debug("got the mpv error: property unavailable error, retry ...") retryCount += 1 + log.Debug("Got mpv error, retrying...", "retries", retryCount, err) if retryCount > 5 { return 0 } @@ -158,13 +158,13 @@ func (t *MpvTrack) Position() int { } if err != nil { - log.Error("error getting position in track", "error", err) + log.Error("Error getting position in track", err) return 0 } pos, ok := position.(float64) if !ok { - log.Error("could not cast position from mpv into float64") + log.Error("Could not cast position from mpv into float64", "position", position) return 0 } else { return int(pos) @@ -181,7 +181,7 @@ func (t *MpvTrack) SetPosition(offset int) error { } err := t.Conn.Set("time-pos", float64(offset)) if err != nil { - log.Error("could not set the position in track", "offset", offset, "error", err) + log.Error("could not set the position in track", "offset", offset, err) return err } log.Info("set position", "offset", offset) @@ -191,7 +191,7 @@ func (t *MpvTrack) SetPosition(offset int) error { func (t *MpvTrack) IsPlaying() bool { pausing, err := t.Conn.Get("pause") if err != nil { - log.Error("problem getting paused status", "error", err) + log.Error("problem getting paused status", err) return false } @@ -203,7 +203,7 @@ func (t *MpvTrack) IsPlaying() bool { return !pause } -func waitForFile(path string, timeout time.Duration, pause time.Duration) error { +func waitForSocket(path string, timeout time.Duration, pause time.Duration) error { start := time.Now() end := start.Add(timeout) var retries int = 0 @@ -211,7 +211,7 @@ func waitForFile(path string, timeout time.Duration, pause time.Duration) error for { fileInfo, err := os.Stat(path) if err == nil && fileInfo != nil && !fileInfo.IsDir() { - log.Debug("file found", "retries", retries, "waittime", time.Since(start).Microseconds()) + log.Debug("Socket found", "retries", retries, "waitTime", time.Since(start)) return nil } if time.Now().After(end) { diff --git a/core/playback/playbackserver.go b/core/playback/playbackserver.go index 4281700a3..3fbe3ced5 100644 --- a/core/playback/playbackserver.go +++ b/core/playback/playbackserver.go @@ -1,5 +1,5 @@ // Package playback implements audio playback using PlaybackDevices. It is used to implement the Jukebox mode in turn. -// It makes use of the BEEP library to do the playback. Major parts are: +// It makes use of the MPV library to do the playback. Major parts are: // - decoder which includes decoding and transcoding of various audio file formats // - device implementing the basic functions to work with audio devices like set, play, stop, skip, ... // - queue a simple playlist @@ -19,7 +19,7 @@ import ( type PlaybackServer interface { Run(ctx context.Context) error - GetDeviceForUser(user string) (*PlaybackDevice, error) + GetDeviceForUser(user string) (*playbackDevice, error) GetMediaFile(id string) (*model.MediaFile, error) GetCtx() *context.Context } @@ -27,7 +27,7 @@ type PlaybackServer interface { type playbackServer struct { ctx *context.Context datastore model.DataStore - playbackDevices []PlaybackDevice + playbackDevices []playbackDevice } // GetInstance returns the playback-server singleton @@ -63,8 +63,8 @@ func (ps *playbackServer) GetCtx() *context.Context { return ps.ctx } -func (ps *playbackServer) initDeviceStatus(devices []conf.AudioDeviceDefinition, defaultDevice string) ([]PlaybackDevice, error) { - pbDevices := make([]PlaybackDevice, max(1, len(devices))) +func (ps *playbackServer) initDeviceStatus(devices []conf.AudioDeviceDefinition, defaultDevice string) ([]playbackDevice, error) { + pbDevices := make([]playbackDevice, max(1, len(devices))) defaultDeviceFound := false if defaultDevice == "" { @@ -76,13 +76,13 @@ func (ps *playbackServer) initDeviceStatus(devices []conf.AudioDeviceDefinition, // if there is but only one entry and no default given, just use that. if len(devices) == 1 { if len(devices[0]) != 2 { - return []PlaybackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(devices[0])) + return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(devices[0])) } pbDevices[0] = *NewPlaybackDevice(ps, devices[0][0], devices[0][1]) } if len(devices) > 1 { - return []PlaybackDevice{}, fmt.Errorf("number of audio device found is %d, but no default device defined. Set Jukebox.Default", len(devices)) + return []playbackDevice{}, fmt.Errorf("number of audio device found is %d, but no default device defined. Set Jukebox.Default", len(devices)) } pbDevices[0].Default = true @@ -91,7 +91,7 @@ func (ps *playbackServer) initDeviceStatus(devices []conf.AudioDeviceDefinition, for idx, audioDevice := range devices { if len(audioDevice) != 2 { - return []PlaybackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(audioDevice)) + return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(audioDevice)) } pbDevices[idx] = *NewPlaybackDevice(ps, audioDevice[0], audioDevice[1]) @@ -103,18 +103,18 @@ func (ps *playbackServer) initDeviceStatus(devices []conf.AudioDeviceDefinition, } if !defaultDeviceFound { - return []PlaybackDevice{}, fmt.Errorf("default device name not found: %s ", defaultDevice) + return []playbackDevice{}, fmt.Errorf("default device name not found: %s ", defaultDevice) } return pbDevices, nil } -func (ps *playbackServer) getDefaultDevice() (*PlaybackDevice, error) { +func (ps *playbackServer) getDefaultDevice() (*playbackDevice, error) { for idx, audioDevice := range ps.playbackDevices { if audioDevice.Default { return &ps.playbackDevices[idx], nil } } - return &PlaybackDevice{}, fmt.Errorf("no default device found") + return &playbackDevice{}, fmt.Errorf("no default device found") } // GetMediaFile retrieves the MediaFile given by the id parameter @@ -123,12 +123,12 @@ func (ps *playbackServer) GetMediaFile(id string) (*model.MediaFile, error) { } // GetDeviceForUser returns the audio playback device for the given user. As of now this is but only the default device. -func (ps *playbackServer) GetDeviceForUser(user string) (*PlaybackDevice, error) { - log.Debug("processing GetDevice") +func (ps *playbackServer) GetDeviceForUser(user string) (*playbackDevice, error) { + log.Debug("Processing GetDevice", "user", user) // README: here we might plug-in the user-device mapping one fine day device, err := ps.getDefaultDevice() if err != nil { - return &PlaybackDevice{}, err + return &playbackDevice{}, err } device.User = user return device, nil diff --git a/go.mod b/go.mod index 1a56daab8..15c238a7d 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,12 @@ module github.com/navidrome/navidrome go 1.21 require ( - github.com/DexterLB/mpvipc v0.0.0-20230829142118-145d6eabdc37 github.com/Masterminds/squirrel v1.5.4 github.com/ReneKroon/ttlcache/v2 v2.11.0 github.com/bradleyjkemp/cupaloy/v2 v2.8.0 github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 + github.com/dexterlb/mpvipc v0.0.0-20230829142118-145d6eabdc37 github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25 github.com/disintegration/imaging v1.6.2 github.com/djherbis/atime v1.1.0 @@ -16,7 +16,6 @@ require ( github.com/djherbis/stream v1.4.0 github.com/djherbis/times v1.6.0 github.com/dustin/go-humanize v1.0.1 - github.com/faiface/beep v1.1.0 github.com/fatih/structs v1.1.0 github.com/go-chi/chi/v5 v5.0.10 github.com/go-chi/cors v1.2.1 @@ -28,12 +27,13 @@ require ( github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v2 v2.0.18 github.com/matoous/go-nanoid/v2 v2.0.0 - github.com/mattn/go-sqlite3 v1.14.18 + github.com/mattn/go-sqlite3 v1.14.19 github.com/mattn/go-zglob v0.0.4 github.com/microcosm-cc/bluemonday v1.0.26 github.com/mileusna/useragent v1.3.4 github.com/onsi/ginkgo/v2 v2.13.2 github.com/onsi/gomega v1.30.0 + github.com/pelletier/go-toml/v2 v2.0.6 github.com/pocketbase/dbx v1.10.1 github.com/pressly/goose/v3 v3.15.1 github.com/prometheus/client_golang v1.17.0 @@ -65,11 +65,8 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/google/pprof v0.0.0-20230323073829-e72429f035bd // indirect github.com/gorilla/css v1.0.0 // indirect - github.com/hajimehoshi/go-mp3 v0.3.4 // indirect - github.com/hajimehoshi/oto v1.0.1 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect - github.com/icza/bitio v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect @@ -81,11 +78,7 @@ require ( github.com/lestrrat-go/option v1.0.1 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect - github.com/mewkiz/flac v1.0.7 // indirect - github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/pelletier/go-toml/v2 v2.0.6 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect github.com/prometheus/common v0.44.0 // indirect @@ -99,9 +92,7 @@ require ( github.com/stretchr/objx v0.5.0 // indirect github.com/subosito/gotenv v1.4.2 // indirect go.uber.org/goleak v1.1.11 // indirect - golang.org/x/crypto v0.16.0 // indirect - golang.org/x/exp/shiny v0.0.0-20230515195305-f3d0a9c9a5cc // indirect - golang.org/x/mobile v0.0.0-20230427221453-e8d11dd0ba41 // indirect + golang.org/x/crypto v0.17.0 // indirect golang.org/x/net v0.18.0 // indirect golang.org/x/sys v0.15.0 // indirect golang.org/x/tools v0.15.0 // indirect diff --git a/go.sum b/go.sum index d396d3aa1..81be4a826 100644 --- a/go.sum +++ b/go.sum @@ -38,9 +38,6 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= -github.com/DexterLB/mpvipc v0.0.0-20230829142118-145d6eabdc37 h1:/oQBAuySCcme0DLhicWkr7FaAT5nh1XbbbnCMR2WdPA= -github.com/DexterLB/mpvipc v0.0.0-20230829142118-145d6eabdc37/go.mod h1:nMVB54ifXmC1hpgfq7gTpotbv891pd2wAX/whuUj1q4= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/ReneKroon/ttlcache/v2 v2.11.0 h1:OvlcYFYi941SBN3v9dsDcC2N8vRxyHcCmJb3Vl4QMoM= @@ -63,7 +60,6 @@ github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnht github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -74,6 +70,8 @@ github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcH github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 h1:mGvOb3zxl4vCLv+dbf7JA6CAaM2UH/AGP1KX4DsJmTI= github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E= +github.com/dexterlb/mpvipc v0.0.0-20230829142118-145d6eabdc37 h1:s+qNFsO3VsdsKroqapcogQxcQBHrRPDK1nVxGc+HBbg= +github.com/dexterlb/mpvipc v0.0.0-20230829142118-145d6eabdc37/go.mod h1:CXCwawNJCtFDip7gvbaQVgw0cGjldpyHDIp7oA5prOg= github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25 h1:simG0vMYFvNriGhaaat7QVVkaVkXzvqcohaBoLZl9Hg= github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25/go.mod h1:Z3Lomva4pyMWYezjMAU5QWRh0p1VvO4199OHlFnyKkM= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= @@ -94,19 +92,12 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c= -github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= -github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= -github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= -github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= -github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= -github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE= github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= @@ -198,14 +189,6 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= -github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= -github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= -github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= -github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= -github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= -github.com/hajimehoshi/oto v1.0.1 h1:8AMnq0Yr2YmzaiqTg/k1Yzd6IygUGk2we9nmjgbgPn4= -github.com/hajimehoshi/oto v1.0.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= -github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= @@ -216,14 +199,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/icza/bitio v1.0.0 h1:squ/m1SHyFeCA6+6Gyol1AxV9nmPPlJFT8c2vKdj3U8= -github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= -github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= -github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= -github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= @@ -257,7 +234,6 @@ github.com/lestrrat-go/jwx/v2 v2.0.18/go.mod h1:fAJ+k5eTgKdDqanzCuK6DAt3W7n3cs2/ github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= -github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U= @@ -265,17 +241,12 @@ github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= -github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= +github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-zglob v0.0.4 h1:LQi2iOm0/fGgu80AioIJ/1j9w9Oh+9DZ39J4VAGzHQM= github.com/mattn/go-zglob v0.0.4/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= -github.com/mewkiz/flac v1.0.7 h1:uIXEjnuXqdRaZttmSFM5v5Ukp4U6orrZsnYGGR3yow8= -github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= -github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2 h1:EyTNMdePWaoWsRSGQnXiSoQu0r6RS1eA557AwJhlzHU= -github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA= github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= github.com/mileusna/useragent v1.3.4 h1:MiuRRuvGjEie1+yZHO88UBYg8YBC/ddF6T7F56i3PCk= @@ -289,8 +260,6 @@ github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8P github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -382,8 +351,9 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -396,9 +366,6 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= -golang.org/x/exp/shiny v0.0.0-20230515195305-f3d0a9c9a5cc h1:JMi0oO0NoPZTAzHSdkdUoHbdcLfo9nPtK37kzE6I3Hk= -golang.org/x/exp/shiny v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:UH99kUObWAZkDnWqppdQe5ZhPYESUw8I0zVV1uWBR+0= -golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -416,10 +383,7 @@ golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPI golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mobile v0.0.0-20230427221453-e8d11dd0ba41 h1:539vykMVJsmdiucRtMmdeLLZaTVhWhaAHFcPabj2lws= -golang.org/x/mobile v0.0.0-20230427221453-e8d11dd0ba41/go.mod h1:aAjjkJNdrh3PMckS4B10TGS2nag27cbKR1y2BpUxsiY= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= @@ -499,12 +463,10 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -536,7 +498,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/log/log.go b/log/log.go index f4b58aae8..c1d058453 100644 --- a/log/log.go +++ b/log/log.go @@ -106,6 +106,7 @@ func levelFromString(l string) Level { return level } +// SetLogLevels sets the log levels for specific paths in the codebase. func SetLogLevels(levels map[string]string) { for k, v := range levels { logLevels = append(logLevels, levelPath{path: k, level: levelFromString(v)}) @@ -154,6 +155,11 @@ func CurrentLevel() Level { return currentLevel } +// IsGreaterOrEqualTo returns true if the caller's current log level is equal or greater than the provided level. +func IsGreaterOrEqualTo(level Level) bool { + return shouldLog(level) +} + func Fatal(args ...interface{}) { log(LevelFatal, args...) os.Exit(1) diff --git a/log/log_test.go b/log/log_test.go index 545803a79..e54fa95fe 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -137,6 +137,37 @@ var _ = Describe("Logger", func() { }) }) + Describe("IsGreaterOrEqualTo", func() { + It("returns false if log level is below provided level", func() { + SetLevel(LevelError) + Expect(IsGreaterOrEqualTo(LevelWarn)).To(BeFalse()) + }) + + It("returns true if log level is equal to provided level", func() { + SetLevel(LevelWarn) + Expect(IsGreaterOrEqualTo(LevelWarn)).To(BeTrue()) + }) + + It("returns true if log level is above provided level", func() { + SetLevel(LevelTrace) + Expect(IsGreaterOrEqualTo(LevelDebug)).To(BeTrue()) + }) + + It("returns true if log level for the current code path is equal provided level", func() { + SetLevel(LevelError) + SetLogLevels(map[string]string{ + "log/log_test": "debug", + }) + + // Need to nest it in a function to get the correct code path + var result = func() bool { + return IsGreaterOrEqualTo(LevelDebug) + }() + + Expect(result).To(BeTrue()) + }) + }) + Describe("extractLogger", func() { It("returns an error if the context is nil", func() { _, err := extractLogger(nil) diff --git a/model/genre.go b/model/genre.go index 3c3d6e559..f55c9953c 100644 --- a/model/genre.go +++ b/model/genre.go @@ -1,10 +1,10 @@ package model type Genre struct { - ID string `structs:"id" json:"id"` + ID string `structs:"id" json:"id,omitempty" toml:"id,omitempty" yaml:"id,omitempty"` Name string `structs:"name" json:"name"` - SongCount int `structs:"-" json:"-"` - AlbumCount int `structs:"-" json:"-"` + SongCount int `structs:"-" json:"-" toml:"-" yaml:"-"` + AlbumCount int `structs:"-" json:"-" toml:"-" yaml:"-"` } type Genres []Genre diff --git a/scanner/mapping.go b/scanner/mapping.go index b94ad8cb8..f59ea9cc1 100644 --- a/scanner/mapping.go +++ b/scanner/mapping.go @@ -15,20 +15,20 @@ import ( "github.com/navidrome/navidrome/utils" ) -type mediaFileMapper struct { +type MediaFileMapper struct { rootFolder string genres model.GenreRepository } -func newMediaFileMapper(rootFolder string, genres model.GenreRepository) *mediaFileMapper { - return &mediaFileMapper{ +func NewMediaFileMapper(rootFolder string, genres model.GenreRepository) *MediaFileMapper { + return &MediaFileMapper{ rootFolder: rootFolder, genres: genres, } } // TODO Move most of these mapping functions to setters in the model.MediaFile -func (s mediaFileMapper) toMediaFile(md metadata.Tags) model.MediaFile { +func (s MediaFileMapper) ToMediaFile(md metadata.Tags) model.MediaFile { mf := &model.MediaFile{} mf.ID = s.trackID(md) mf.Year, mf.Date, mf.OriginalYear, mf.OriginalDate, mf.ReleaseYear, mf.ReleaseDate = s.mapDates(md) @@ -86,7 +86,7 @@ func sanitizeFieldForSorting(originalValue string) string { return utils.NoArticle(v) } -func (s mediaFileMapper) mapTrackTitle(md metadata.Tags) string { +func (s MediaFileMapper) mapTrackTitle(md metadata.Tags) string { if md.Title() == "" { s := strings.TrimPrefix(md.FilePath(), s.rootFolder+string(os.PathSeparator)) e := filepath.Ext(s) @@ -95,7 +95,7 @@ func (s mediaFileMapper) mapTrackTitle(md metadata.Tags) string { return md.Title() } -func (s mediaFileMapper) mapAlbumArtistName(md metadata.Tags) string { +func (s MediaFileMapper) mapAlbumArtistName(md metadata.Tags) string { switch { case md.AlbumArtist() != "": return md.AlbumArtist() @@ -108,14 +108,14 @@ func (s mediaFileMapper) mapAlbumArtistName(md metadata.Tags) string { } } -func (s mediaFileMapper) mapArtistName(md metadata.Tags) string { +func (s MediaFileMapper) mapArtistName(md metadata.Tags) string { if md.Artist() != "" { return md.Artist() } return consts.UnknownArtist } -func (s mediaFileMapper) mapAlbumName(md metadata.Tags) string { +func (s MediaFileMapper) mapAlbumName(md metadata.Tags) string { name := md.Album() if name == "" { return consts.UnknownAlbum @@ -123,11 +123,11 @@ func (s mediaFileMapper) mapAlbumName(md metadata.Tags) string { return name } -func (s mediaFileMapper) trackID(md metadata.Tags) string { +func (s MediaFileMapper) trackID(md metadata.Tags) string { return fmt.Sprintf("%x", md5.Sum([]byte(md.FilePath()))) } -func (s mediaFileMapper) albumID(md metadata.Tags, releaseDate string) string { +func (s MediaFileMapper) albumID(md metadata.Tags, releaseDate string) string { albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", s.mapAlbumArtistName(md), s.mapAlbumName(md))) if !conf.Server.Scanner.GroupAlbumReleases { if len(releaseDate) != 0 { @@ -137,15 +137,15 @@ func (s mediaFileMapper) albumID(md metadata.Tags, releaseDate string) string { return fmt.Sprintf("%x", md5.Sum([]byte(albumPath))) } -func (s mediaFileMapper) artistID(md metadata.Tags) string { +func (s MediaFileMapper) artistID(md metadata.Tags) string { return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapArtistName(md))))) } -func (s mediaFileMapper) albumArtistID(md metadata.Tags) string { +func (s MediaFileMapper) albumArtistID(md metadata.Tags) string { return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapAlbumArtistName(md))))) } -func (s mediaFileMapper) mapGenres(genres []string) (string, model.Genres) { +func (s MediaFileMapper) mapGenres(genres []string) (string, model.Genres) { var result model.Genres unique := map[string]struct{}{} var all []string @@ -174,10 +174,13 @@ func (s mediaFileMapper) mapGenres(genres []string) (string, model.Genres) { return result[0].Name, result } -func (s mediaFileMapper) mapDates(md metadata.Tags) (int, string, int, string, int, string) { - year, date := md.Date() - originalYear, originalDate := md.OriginalDate() - releaseYear, releaseDate := md.ReleaseDate() +func (s MediaFileMapper) mapDates(md metadata.Tags) (year int, date string, + originalYear int, originalDate string, + releaseYear int, releaseDate string) { + // Start with defaults + year, date = md.Date() + originalYear, originalDate = md.OriginalDate() + releaseYear, releaseDate = md.ReleaseDate() // MusicBrainz Picard writes the Release Date of an album to the Date tag, and leaves the Release Date tag empty taggedLikePicard := (originalYear != 0) && diff --git a/scanner/mapping_internal_test.go b/scanner/mapping_internal_test.go index 4687efeab..b44f2bcfb 100644 --- a/scanner/mapping_internal_test.go +++ b/scanner/mapping_internal_test.go @@ -12,11 +12,11 @@ import ( ) var _ = Describe("mapping", func() { - Describe("mediaFileMapper", func() { - var mapper *mediaFileMapper + Describe("MediaFileMapper", func() { + var mapper *MediaFileMapper Describe("mapTrackTitle", func() { BeforeEach(func() { - mapper = newMediaFileMapper("/music", nil) + mapper = NewMediaFileMapper("/music", nil) }) It("returns the Title when it is available", func() { md := metadata.NewTag("/music/artist/album01/Song.mp3", nil, metadata.ParsedTags{"title": []string{"This is not a love song"}}) @@ -37,7 +37,7 @@ var _ = Describe("mapping", func() { ds := &tests.MockDataStore{} gr = ds.Genre(ctx) gr = newCachedGenreRepository(ctx, gr) - mapper = newMediaFileMapper("/", gr) + mapper = NewMediaFileMapper("/", gr) }) It("returns empty if no genres are available", func() { @@ -79,7 +79,7 @@ var _ = Describe("mapping", func() { Describe("mapDates", func() { var md metadata.Tags BeforeEach(func() { - mapper = newMediaFileMapper("/", nil) + mapper = NewMediaFileMapper("/", nil) }) Context("when all date fields are provided", func() { BeforeEach(func() { diff --git a/scanner/metadata/metadata.go b/scanner/metadata/metadata.go index 7d7035150..6ca810132 100644 --- a/scanner/metadata/metadata.go +++ b/scanner/metadata/metadata.go @@ -59,13 +59,39 @@ func Extract(files ...string) (map[string]Tags, error) { } func NewTag(filePath string, fileInfo os.FileInfo, tags ParsedTags) Tags { + for t, values := range tags { + values = removeDuplicatesAndEmpty(values) + if len(values) == 0 { + delete(tags, t) + continue + } + tags[t] = values + } return Tags{ filePath: filePath, fileInfo: fileInfo, - tags: tags, + Tags: tags, } } +func removeDuplicatesAndEmpty(values []string) []string { + encountered := map[string]struct{}{} + empty := true + var result []string + for _, v := range values { + if _, ok := encountered[v]; ok { + continue + } + encountered[v] = struct{}{} + empty = empty && v == "" + result = append(result, v) + } + if empty { + return nil + } + return result +} + type ParsedTags map[string][]string func (p ParsedTags) Map(customMappings ParsedTags) ParsedTags { @@ -86,7 +112,7 @@ func (p ParsedTags) Map(customMappings ParsedTags) ParsedTags { type Tags struct { filePath string fileInfo os.FileInfo - tags ParsedTags + Tags ParsedTags } // Common tags @@ -232,7 +258,7 @@ func (t Tags) getPeakValue(tagName string) float64 { func (t Tags) getTags(tagNames ...string) []string { for _, tag := range tagNames { - if v, ok := t.tags[tag]; ok { + if v, ok := t.Tags[tag]; ok { return v } } @@ -250,7 +276,7 @@ func (t Tags) getFirstTagValue(tagNames ...string) string { func (t Tags) getAllTagValues(tagNames ...string) []string { var values []string for _, tag := range tagNames { - if v, ok := t.tags[tag]; ok { + if v, ok := t.Tags[tag]; ok { values = append(values, v...) } } diff --git a/scanner/metadata/metadata_internal_test.go b/scanner/metadata/metadata_internal_test.go index 54b81b0b9..44967f9fe 100644 --- a/scanner/metadata/metadata_internal_test.go +++ b/scanner/metadata/metadata_internal_test.go @@ -9,7 +9,7 @@ var _ = Describe("Tags", func() { DescribeTable("getDate", func(tag string, expectedYear int, expectedDate string) { md := &Tags{} - md.tags = map[string][]string{"date": {tag}} + md.Tags = map[string][]string{"date": {tag}} testYear, testDate := md.Date() Expect(testYear).To(Equal(expectedYear)) Expect(testDate).To(Equal(expectedDate)) @@ -29,7 +29,7 @@ var _ = Describe("Tags", func() { Describe("getMbzID", func() { It("return a valid MBID", func() { md := &Tags{} - md.tags = map[string][]string{ + md.Tags = map[string][]string{ "musicbrainz_trackid": {"8f84da07-09a0-477b-b216-cc982dabcde1"}, "musicbrainz_releasetrackid": {"6caf16d3-0b20-3fe6-8020-52e31831bc11"}, "musicbrainz_albumid": {"f68c985d-f18b-4f4a-b7f0-87837cf3fbf9"}, @@ -44,7 +44,7 @@ var _ = Describe("Tags", func() { }) It("return empty string for invalid MBID", func() { md := &Tags{} - md.tags = map[string][]string{ + md.Tags = map[string][]string{ "musicbrainz_trackid": {"11406732-6"}, "musicbrainz_albumid": {"11406732"}, "musicbrainz_artistid": {"200455"}, @@ -60,7 +60,7 @@ var _ = Describe("Tags", func() { Describe("getAllTagValues", func() { It("returns values from all tag names", func() { md := &Tags{} - md.tags = map[string][]string{ + md.Tags = map[string][]string{ "genre": {"Rock", "Pop", "New Wave"}, } @@ -68,10 +68,31 @@ var _ = Describe("Tags", func() { }) }) + Describe("removeDuplicatesAndEmpty", func() { + It("removes duplicates", func() { + md := NewTag("/music/artist/album01/Song.mp3", nil, ParsedTags{ + "genre": []string{"pop", "rock", "pop"}, + "date": []string{"2023-03-01", "2023-03-01"}, + "mood": []string{"happy", "sad"}, + }) + Expect(md.Tags).To(HaveKeyWithValue("genre", []string{"pop", "rock"})) + Expect(md.Tags).To(HaveKeyWithValue("date", []string{"2023-03-01"})) + Expect(md.Tags).To(HaveKeyWithValue("mood", []string{"happy", "sad"})) + }) + It("removes empty tags", func() { + md := NewTag("/music/artist/album01/Song.mp3", nil, ParsedTags{ + "genre": []string{"pop", "rock", "pop"}, + "mood": []string{"", ""}, + }) + Expect(md.Tags).To(HaveKeyWithValue("genre", []string{"pop", "rock"})) + Expect(md.Tags).ToNot(HaveKey("mood")) + }) + }) + Describe("Bpm", func() { var t *Tags BeforeEach(func() { - t = &Tags{tags: map[string][]string{ + t = &Tags{Tags: map[string][]string{ "fbpm": []string{"141.7"}, }} }) diff --git a/scanner/metadata/taglib/taglib_wrapper.go b/scanner/metadata/taglib/taglib_wrapper.go index b1e856e9c..ec196ab6b 100644 --- a/scanner/metadata/taglib/taglib_wrapper.go +++ b/scanner/metadata/taglib/taglib_wrapper.go @@ -11,6 +11,7 @@ package taglib */ import "C" import ( + "encoding/json" "fmt" "os" "runtime/debug" @@ -57,7 +58,13 @@ func Read(filename string) (tags map[string][]string, err error) { case C.TAGLIB_ERR_AUDIO_PROPS: return nil, fmt.Errorf("can't get audio properties from file") } - log.Trace("TagLib: read tags", "tags", m, "filename", filename, "id", id) + if log.IsGreaterOrEqualTo(log.LevelDebug) { + j, _ := json.Marshal(m) + log.Trace("TagLib: read tags", "tags", string(j), "filename", filename, "id", id) + } else { + log.Trace("TagLib: read tags", "tags", m, "filename", filename, "id", id) + } + return m, nil } @@ -114,6 +121,10 @@ func do_put_map(id C.ulong, key string, val *C.char) { m[key] = append(m[key], v) } +/* +As I'm working on the new scanner, I see that the `properties` from TagLib is ill-suited to extract multi-valued ID3 frames. I'll have to change the way we do it for ID3, probably by sending the raw frames to Go and mapping there, instead of relying on the auto-mapped `properties`. I think this would reduce our reliance on C++, while also giving us more flexibility, including parsing the USLT / SYLT frames in Go +*/ + //export go_map_put_int func go_map_put_int(id C.ulong, key *C.char, val C.int) { valStr := strconv.Itoa(int(val)) diff --git a/scanner/tag_scanner.go b/scanner/tag_scanner.go index 5a719acd4..9195544fd 100644 --- a/scanner/tag_scanner.go +++ b/scanner/tag_scanner.go @@ -27,7 +27,7 @@ type TagScanner struct { ds model.DataStore plsSync *playlistImporter cnt *counters - mapper *mediaFileMapper + mapper *MediaFileMapper cacheWarmer artwork.CacheWarmer } @@ -100,7 +100,7 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time, prog var changedDirs []string s.cnt = &counters{} genres := newCachedGenreRepository(ctx, s.ds.Genre(ctx)) - s.mapper = newMediaFileMapper(s.rootFolder, genres) + s.mapper = NewMediaFileMapper(s.rootFolder, genres) refresher := newRefresher(s.ds, s.cacheWarmer, allFSDirs) log.Trace(ctx, "Loading directory tree from music folder", "folder", s.rootFolder) @@ -386,7 +386,7 @@ func (s *TagScanner) loadTracks(filePaths []string) (model.MediaFiles, error) { var mfs model.MediaFiles for _, md := range mds { - mf := s.mapper.toMediaFile(md) + mf := s.mapper.ToMediaFile(md) mfs = append(mfs, mf) } return mfs, nil diff --git a/scanner/tag_scanner_test.go b/scanner/tag_scanner_test.go index 5629d2219..dc1e4575e 100644 --- a/scanner/tag_scanner_test.go +++ b/scanner/tag_scanner_test.go @@ -10,11 +10,12 @@ var _ = Describe("TagScanner", func() { It("return all audio files from the folder", func() { files, err := loadAllAudioFiles("tests/fixtures") Expect(err).ToNot(HaveOccurred()) - Expect(files).To(HaveLen(11)) + Expect(files).To(HaveLen(12)) Expect(files).To(HaveKey("tests/fixtures/test.aiff")) Expect(files).To(HaveKey("tests/fixtures/test.flac")) Expect(files).To(HaveKey("tests/fixtures/test.m4a")) Expect(files).To(HaveKey("tests/fixtures/test.mp3")) + Expect(files).To(HaveKey("tests/fixtures/test.tak")) Expect(files).To(HaveKey("tests/fixtures/test.ogg")) Expect(files).To(HaveKey("tests/fixtures/test.wav")) Expect(files).To(HaveKey("tests/fixtures/test.wma")) diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 45b0dff56..bfe94300d 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -34,7 +34,7 @@ var _ = Describe("walk_dir_tree", func() { Expect(collected[baseDir]).To(MatchFields(IgnoreExtras, Fields{ "Images": BeEmpty(), "HasPlaylist": BeFalse(), - "AudioFilesCount": BeNumerically("==", 12), + "AudioFilesCount": BeNumerically("==", 13), })) Expect(collected[filepath.Join(baseDir, "artist", "an-album")]).To(MatchFields(IgnoreExtras, Fields{ "Images": ConsistOf("cover.jpg", "front.png", "artist.png"), diff --git a/server/events/sse.go b/server/events/sse.go index 23720169f..b9285b27c 100644 --- a/server/events/sse.go +++ b/server/events/sse.go @@ -42,15 +42,13 @@ type ( username string userAgent string clientUniqueId string + displayString string msgC chan message } ) func (c client) String() string { - if log.CurrentLevel() >= log.LevelTrace { - return fmt.Sprintf("%s (%s - %s - %s - %s)", c.id, c.username, c.address, c.clientUniqueId, c.userAgent) - } - return fmt.Sprintf("%s (%s - %s - %s)", c.id, c.username, c.address, c.clientUniqueId) + return c.displayString } type broker struct { @@ -172,6 +170,12 @@ func (b *broker) subscribe(r *http.Request) client { userAgent: r.UserAgent(), clientUniqueId: clientUniqueId, } + if log.IsGreaterOrEqualTo(log.LevelTrace) { + c.displayString = fmt.Sprintf("%s (%s - %s - %s - %s)", c.id, c.username, c.address, c.clientUniqueId, c.userAgent) + } else { + c.displayString = fmt.Sprintf("%s (%s - %s - %s)", c.id, c.username, c.address, c.clientUniqueId) + } + c.msgC = make(chan message, bufferSize) // Signal the broker that we have a new client @@ -260,7 +264,7 @@ func sendOrDrop(client client, msg message) { select { case client.msgC <- msg: default: - if log.CurrentLevel() >= log.LevelTrace { + if log.IsGreaterOrEqualTo(log.LevelTrace) { log.Trace("Event dropped because client's channel is full", "event", msg, "client", client.String()) } } diff --git a/server/middlewares.go b/server/middlewares.go index cdbefacb8..7f912770b 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -42,7 +42,7 @@ func requestLogger(next http.Handler) http.Handler { "httpStatus", ww.Status(), "responseSize", ww.BytesWritten(), } - if log.CurrentLevel() >= log.LevelDebug { + if log.IsGreaterOrEqualTo(log.LevelDebug) { logArgs = append(logArgs, "userAgent", r.UserAgent()) } diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 9abf80e4a..8f625aba1 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -14,7 +14,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/req" ) type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc @@ -95,8 +95,9 @@ func handleExportPlaylist(ds model.DataStore) http.HandlerFunc { func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - playlistId := utils.ParamString(r, ":playlistId") - ids := r.URL.Query()["id"] + p := req.Params(r) + playlistId, _ := p.String(":playlistId") + ids, _ := p.Strings("id") err := ds.WithTx(func(tx model.DataStore) error { tracksRepo := tx.Playlist(r.Context()).Tracks(playlistId, true) return tracksRepo.Delete(ids...) @@ -139,7 +140,8 @@ func addToPlaylist(ds model.DataStore) http.HandlerFunc { } return func(w http.ResponseWriter, r *http.Request) { - playlistId := utils.ParamString(r, ":playlistId") + p := req.Params(r) + playlistId, _ := p.String(":playlistId") var payload addTracksPayload err := json.NewDecoder(r.Body).Decode(&payload) if err != nil { @@ -183,8 +185,9 @@ func reorderItem(ds model.DataStore) http.HandlerFunc { } return func(w http.ResponseWriter, r *http.Request) { - playlistId := utils.ParamString(r, ":playlistId") - id := utils.ParamInt(r, ":id", 0) + p := req.Params(r) + playlistId, _ := p.String(":playlistId") + id := p.IntOr(":id", 0) if id == 0 { http.Error(w, "invalid id", http.StatusBadRequest) return diff --git a/server/public/handle_downloads.go b/server/public/handle_downloads.go index c6cf5a52f..6aa35c341 100644 --- a/server/public/handle_downloads.go +++ b/server/public/handle_downloads.go @@ -2,15 +2,17 @@ package public import ( "net/http" + + "github.com/navidrome/navidrome/utils/req" ) -func (p *Router) handleDownloads(w http.ResponseWriter, r *http.Request) { - id := r.URL.Query().Get(":id") - if id == "" { - http.Error(w, "invalid id", http.StatusBadRequest) +func (pub *Router) handleDownloads(w http.ResponseWriter, r *http.Request) { + id, err := req.Params(r).String(":id") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } - err := p.archiver.ZipShare(r.Context(), id, w) + err = pub.archiver.ZipShare(r.Context(), id, w) checkShareError(r.Context(), w, err, id) } diff --git a/server/public/handle_images.go b/server/public/handle_images.go index 7d73c8b95..2e6ee31a7 100644 --- a/server/public/handle_images.go +++ b/server/public/handle_images.go @@ -10,10 +10,10 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/req" ) -func (p *Router) handleImages(w http.ResponseWriter, r *http.Request) { +func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { // If context is already canceled, discard request without further processing if r.Context().Err() != nil { return @@ -21,7 +21,9 @@ func (p *Router) handleImages(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() - id := r.URL.Query().Get(":id") + + p := req.Params(r) + id, _ := p.String(":id") if id == "" { http.Error(w, "invalid id", http.StatusBadRequest) return @@ -32,9 +34,9 @@ func (p *Router) handleImages(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - size := utils.ParamInt(r, "size", 0) + size := p.IntOr("size", 0) - imgReader, lastUpdate, err := p.artwork.Get(ctx, artId, size) + imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size) switch { case errors.Is(err, context.Canceled): return diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index aa09cfa17..a4fa99d82 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -10,31 +10,32 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/ui" + "github.com/navidrome/navidrome/utils/req" ) -func (p *Router) handleShares(w http.ResponseWriter, r *http.Request) { - id := r.URL.Query().Get(":id") - if id == "" { - http.Error(w, "invalid id", http.StatusBadRequest) +func (pub *Router) handleShares(w http.ResponseWriter, r *http.Request) { + id, err := req.Params(r).String(":id") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } // If requested file is a UI asset, just serve it - _, err := ui.BuildAssets().Open(id) + _, err = ui.BuildAssets().Open(id) if err == nil { - p.assetsHandler.ServeHTTP(w, r) + pub.assetsHandler.ServeHTTP(w, r) return } // If it is not, consider it a share ID - s, err := p.share.Load(r.Context(), id) + s, err := pub.share.Load(r.Context(), id) if err != nil { checkShareError(r.Context(), w, err, id) return } - s = p.mapShareInfo(r, *s) - server.IndexWithShare(p.ds, ui.BuildAssets(), s)(w, r) + s = pub.mapShareInfo(r, *s) + server.IndexWithShare(pub.ds, ui.BuildAssets(), s)(w, r) } func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id string) { @@ -54,7 +55,7 @@ func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id s } } -func (p *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { +func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { s.URL = ShareURL(r, s.ID) s.ImageURL = ImageURL(r, s.CoverArtID(), consts.UICoverArtSize) for i := range s.Tracks { diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index e9f60d7a5..cf120f0b5 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -10,12 +10,13 @@ import ( "github.com/lestrrat-go/jwx/v2/jwt" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/req" ) -func (p *Router) handleStream(w http.ResponseWriter, r *http.Request) { +func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - tokenId := r.URL.Query().Get(":id") + p := req.Params(r) + tokenId, _ := p.String(":id") info, err := decodeStreamInfo(tokenId) if err != nil { log.Error(ctx, "Error parsing shared stream info", err) @@ -23,7 +24,7 @@ func (p *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - stream, err := p.streamer.NewStream(ctx, info.id, info.format, info.bitrate, 0) + stream, err := pub.streamer.NewStream(ctx, info.id, info.format, info.bitrate, 0) if err != nil { log.Error(ctx, "Error starting shared stream", err) http.Error(w, "invalid request", http.StatusInternalServerError) @@ -31,7 +32,7 @@ func (p *Router) handleStream(w http.ResponseWriter, r *http.Request) { // Make sure the stream will be closed at the end, to avoid leakage defer func() { - if err := stream.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug { + if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { log.Error("Error closing shared stream", "id", info.id, "file", stream.Name(), err) } }() @@ -46,7 +47,7 @@ func (p *Router) handleStream(w http.ResponseWriter, r *http.Request) { w.Header().Set("Accept-Ranges", "none") w.Header().Set("Content-Type", stream.ContentType()) - estimateContentLength := utils.ParamBool(r, "estimateContentLength", false) + estimateContentLength := p.BoolOr("estimateContentLength", false) // if Client requests the estimated content-length, send it if estimateContentLength { @@ -59,7 +60,7 @@ func (p *Router) handleStream(w http.ResponseWriter, r *http.Request) { go func() { _, _ = io.Copy(io.Discard, stream) }() } else { c, err := io.Copy(w, stream) - if log.CurrentLevel() >= log.LevelDebug { + if log.IsGreaterOrEqualTo(log.LevelDebug) { if err != nil { log.Error(ctx, "Error sending shared transcoded file", "id", info.id, err) } else { diff --git a/server/public/public.go b/server/public/public.go index 5ead6b320..825e76335 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -35,7 +35,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreame return p } -func (p *Router) routes() http.Handler { +func (pub *Router) routes() http.Handler { r := chi.NewRouter() r.Group(func(r chi.Router) { @@ -48,16 +48,16 @@ func (p *Router) routes() http.Handler { r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, conf.Server.DevArtworkThrottleBacklogTimeout)) } - r.HandleFunc("/img/{id}", p.handleImages) + r.HandleFunc("/img/{id}", pub.handleImages) }) if conf.Server.EnableSharing { - r.HandleFunc("/s/{id}", p.handleStream) + r.HandleFunc("/s/{id}", pub.handleStream) if conf.Server.EnableDownloads { - r.HandleFunc("/d/{id}", p.handleDownloads) + r.HandleFunc("/d/{id}", pub.handleDownloads) } - r.HandleFunc("/{id}", p.handleShares) - r.HandleFunc("/", p.handleShares) - r.Handle("/*", p.assetsHandler) + r.HandleFunc("/{id}", pub.handleShares) + r.HandleFunc("/", pub.handleShares) + r.Handle("/*", pub.assetsHandler) } }) return r diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 7f1ecea6b..792553d42 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -10,12 +10,13 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/number" + "github.com/navidrome/navidrome/utils/req" ) func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { - typ, err := requiredParamString(r, "type") + p := req.Params(r) + typ, err := p.String("type") if err != nil { return nil, 0, err } @@ -39,17 +40,17 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { case "highest": opts = filter.AlbumsByRating() case "byGenre": - genre, err := requiredParamString(r, "genre") + genre, err := p.String("genre") if err != nil { return nil, 0, err } opts = filter.AlbumsByGenre(genre) case "byYear": - fromYear, err := requiredParamInt(r, "fromYear") + fromYear, err := p.Int("fromYear") if err != nil { return nil, 0, err } - toYear, err := requiredParamInt(r, "toYear") + toYear, err := p.Int("toYear") if err != nil { return nil, 0, err } @@ -59,18 +60,18 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { return nil, 0, newError(responses.ErrorGeneric, "type '%s' not implemented", typ) } - opts.Offset = utils.ParamInt(r, "offset", 0) - opts.Max = number.Min(utils.ParamInt(r, "size", 10), 500) + opts.Offset = p.IntOr("offset", 0) + opts.Max = number.Min(p.IntOr("size", 10), 500) albums, err := api.ds.Album(r.Context()).GetAllWithoutGenres(opts) if err != nil { - log.Error(r, "Error retrieving albums", "error", err) + log.Error(r, "Error retrieving albums", err) return nil, 0, newError(responses.ErrorGeneric, "internal error") } count, err := api.ds.Album(r.Context()).CountAll(opts) if err != nil { - log.Error(r, "Error counting albums", "error", err) + log.Error(r, "Error counting albums", err) return nil, 0, newError(responses.ErrorGeneric, "internal error") } @@ -108,17 +109,17 @@ func (api *Router) GetStarred(r *http.Request) (*responses.Subsonic, error) { options := filter.Starred() artists, err := api.ds.Artist(ctx).GetAll(options) if err != nil { - log.Error(r, "Error retrieving starred artists", "error", err) + log.Error(r, "Error retrieving starred artists", err) return nil, err } albums, err := api.ds.Album(ctx).GetAllWithoutGenres(options) if err != nil { - log.Error(r, "Error retrieving starred albums", "error", err) + log.Error(r, "Error retrieving starred albums", err) return nil, err } mediaFiles, err := api.ds.MediaFile(ctx).GetAll(options) if err != nil { - log.Error(r, "Error retrieving starred mediaFiles", "error", err) + log.Error(r, "Error retrieving starred mediaFiles", err) return nil, err } @@ -145,7 +146,7 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() npInfo, err := api.scrobbler.GetNowPlaying(ctx) if err != nil { - log.Error(r, "Error retrieving now playing list", "error", err) + log.Error(r, "Error retrieving now playing list", err) return nil, err } @@ -163,14 +164,15 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error) { - size := number.Min(utils.ParamInt(r, "size", 10), 500) - genre := utils.ParamString(r, "genre") - fromYear := utils.ParamInt(r, "fromYear", 0) - toYear := utils.ParamInt(r, "toYear", 0) + p := req.Params(r) + size := number.Min(p.IntOr("size", 10), 500) + genre, _ := p.String("genre") + fromYear := p.IntOr("fromYear", 0) + toYear := p.IntOr("toYear", 0) songs, err := api.getSongs(r.Context(), 0, size, filter.SongsByRandom(genre, fromYear, toYear)) if err != nil { - log.Error(r, "Error retrieving random songs", "error", err) + log.Error(r, "Error retrieving random songs", err) return nil, err } @@ -181,13 +183,14 @@ func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error) } func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error) { - count := number.Min(utils.ParamInt(r, "count", 10), 500) - offset := utils.ParamInt(r, "offset", 0) - genre := utils.ParamString(r, "genre") + p := req.Params(r) + count := number.Min(p.IntOr("count", 10), 500) + offset := p.IntOr("offset", 0) + genre, _ := p.String("genre") songs, err := api.getSongs(r.Context(), offset, count, filter.SongsByGenre(genre)) if err != nil { - log.Error(r, "Error retrieving random songs", "error", err) + log.Error(r, "Error retrieving random songs", err) return nil, err } diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index 85a994486..88f2e0aca 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -36,7 +37,7 @@ var _ = Describe("Album Lists", func() { }) resp, err := router.GetAlbumList(w, r) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(resp.AlbumList.Album[0].Id).To(Equal("1")) Expect(resp.AlbumList.Album[1].Id).To(Equal("2")) Expect(w.Header().Get("x-total-count")).To(Equal("2")) @@ -47,12 +48,8 @@ var _ = Describe("Album Lists", func() { It("should fail if missing type parameter", func() { r := newGetRequest() _, err := router.GetAlbumList(w, r) - var subErr subError - isSubError := errors.As(err, &subErr) - Expect(isSubError).To(BeTrue()) - Expect(subErr).To(MatchError("required 'type' parameter is missing")) - Expect(subErr.code).To(Equal(responses.ErrorMissingParameter)) + Expect(err).To(MatchError(req.ErrMissingParam)) }) It("should return error if call fails", func() { @@ -61,7 +58,7 @@ var _ = Describe("Album Lists", func() { _, err := router.GetAlbumList(w, r) - Expect(err).ToNot(BeNil()) + Expect(err).To(MatchError(errSubsonic)) var subErr subError errors.As(err, &subErr) Expect(subErr.code).To(Equal(responses.ErrorGeneric)) @@ -76,7 +73,7 @@ var _ = Describe("Album Lists", func() { }) resp, err := router.GetAlbumList2(w, r) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(resp.AlbumList2.Album[0].Id).To(Equal("1")) Expect(resp.AlbumList2.Album[1].Id).To(Equal("2")) Expect(w.Header().Get("x-total-count")).To(Equal("2")) @@ -86,13 +83,10 @@ var _ = Describe("Album Lists", func() { It("should fail if missing type parameter", func() { r := newGetRequest() + _, err := router.GetAlbumList2(w, r) - var subErr subError - errors.As(err, &subErr) - - Expect(subErr).To(MatchError("required 'type' parameter is missing")) - Expect(subErr.code).To(Equal(responses.ErrorMissingParameter)) + Expect(err).To(MatchError(req.ErrMissingParam)) }) It("should return error if call fails", func() { @@ -101,9 +95,9 @@ var _ = Describe("Album Lists", func() { _, err := router.GetAlbumList2(w, r) + Expect(err).To(MatchError(errSubsonic)) var subErr subError errors.As(err, &subErr) - Expect(subErr).ToNot(BeNil()) Expect(subErr.code).To(Equal(responses.ErrorGeneric)) }) }) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 1a01699bf..0f8c909f8 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -18,7 +18,7 @@ import ( "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/req" ) const Version = "1.16.1" @@ -212,20 +212,11 @@ func hr(r chi.Router, path string, f handlerRaw) { handle := func(w http.ResponseWriter, r *http.Request) { res, err := f(w, r) if err != nil { - // If it is not a Subsonic error, convert it to an ErrorGeneric - var subErr subError - if !errors.As(err, &subErr) { - if errors.Is(err, model.ErrNotFound) { - err = newError(responses.ErrorDataNotFound, "data not found") - } else { - err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err)) - } - } sendError(w, r, err) return } if r.Context().Err() != nil { - if log.CurrentLevel() >= log.LevelDebug { + if log.IsGreaterOrEqualTo(log.LevelDebug) { log.Warn(r.Context(), "Request was interrupted", "endpoint", r.URL.Path, r.Context().Err()) } return @@ -265,21 +256,35 @@ func addHandler(r chi.Router, path string, handle func(w http.ResponseWriter, r r.HandleFunc("/"+path+".view", handle) } -func sendError(w http.ResponseWriter, r *http.Request, err error) { - response := newResponse() - code := responses.ErrorGeneric - var subErr subError - if errors.As(err, &subErr) { - code = subErr.code +func mapToSubsonicError(err error) subError { + switch { + case errors.Is(err, errSubsonic): // do nothing + case errors.Is(err, req.ErrMissingParam): + err = newError(responses.ErrorMissingParameter, err.Error()) + case errors.Is(err, req.ErrInvalidParam): + err = newError(responses.ErrorGeneric, err.Error()) + case errors.Is(err, model.ErrNotFound): + err = newError(responses.ErrorDataNotFound, "data not found") + default: + err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err)) } + var subErr subError + errors.As(err, &subErr) + return subErr +} + +func sendError(w http.ResponseWriter, r *http.Request, err error) { + subErr := mapToSubsonicError(err) + response := newResponse() response.Status = "failed" - response.Error = &responses.Error{Code: int32(code), Message: err.Error()} + response.Error = &responses.Error{Code: int32(subErr.code), Message: subErr.Error()} sendResponse(w, r, response) } func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic) { - f := utils.ParamString(r, "f") + p := req.Params(r) + f, _ := p.String("f") var response []byte switch f { case "json": @@ -288,7 +293,7 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub response, _ = json.Marshal(wrapper) case "jsonp": w.Header().Set("Content-Type", "application/javascript") - callback := utils.ParamString(r, "callback") + callback, _ := p.String("callback") wrapper := &responses.JsonWrapper{Subsonic: *payload} data, _ := json.Marshal(wrapper) response = []byte(fmt.Sprintf("%s(%s)", callback, data)) @@ -297,7 +302,7 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub response, _ = xml.Marshal(payload) } if payload.Status == "ok" { - if log.CurrentLevel() >= log.LevelTrace { + if log.IsGreaterOrEqualTo(log.LevelTrace) { log.Debug(r.Context(), "API: Successful response", "endpoint", r.URL.Path, "status", "OK", "body", string(response)) } else { log.Debug(r.Context(), "API: Successful response", "endpoint", r.URL.Path, "status", "OK") diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go index 86d5d768b..42fe9e176 100644 --- a/server/subsonic/bookmarks.go +++ b/server/subsonic/bookmarks.go @@ -7,7 +7,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/req" ) func (api *Router) GetBookmarks(r *http.Request) (*responses.Subsonic, error) { @@ -36,13 +36,14 @@ func (api *Router) GetBookmarks(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error) { - id, err := requiredParamString(r, "id") + p := req.Params(r) + id, err := p.String("id") if err != nil { return nil, err } - comment := utils.ParamString(r, "comment") - position := utils.ParamInt(r, "position", int64(0)) + comment, _ := p.String("comment") + position := p.Int64Or("position", 0) repo := api.ds.MediaFile(r.Context()) err = repo.AddBookmark(id, comment, position) @@ -53,7 +54,8 @@ func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error) } func (api *Router) DeleteBookmark(r *http.Request) (*responses.Subsonic, error) { - id, err := requiredParamString(r, "id") + p := req.Params(r) + id, err := p.String("id") if err != nil { return nil, err } @@ -88,13 +90,14 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) { - ids, err := requiredParamStrings(r, "id") + p := req.Params(r) + ids, err := p.Strings("id") if err != nil { return nil, err } - current := utils.ParamString(r, "current") - position := utils.ParamInt(r, "position", int64(0)) + current, _ := p.String("current") + position := p.Int64Or("position", 0) user, _ := request.UserFrom(r.Context()) client, _ := request.ClientFrom(r.Context()) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index b33715799..88afa6d6d 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/req" ) func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) { @@ -67,8 +68,9 @@ func (api *Router) getArtistIndex(r *http.Request, mediaFolderId int, ifModified } func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) { - musicFolderId := utils.ParamInt(r, "musicFolderId", 0) - ifModifiedSince := utils.ParamTime(r, "ifModifiedSince", time.Time{}) + p := req.Params(r) + musicFolderId := p.IntOr("musicFolderId", 0) + ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{}) res, err := api.getArtistIndex(r, musicFolderId, ifModifiedSince) if err != nil { @@ -81,7 +83,8 @@ func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) GetArtists(r *http.Request) (*responses.Subsonic, error) { - musicFolderId := utils.ParamInt(r, "musicFolderId", 0) + p := req.Params(r) + musicFolderId := p.IntOr("musicFolderId", 0) res, err := api.getArtistIndex(r, musicFolderId, time.Time{}) if err != nil { return nil, err @@ -93,7 +96,8 @@ func (api *Router) GetArtists(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) GetMusicDirectory(r *http.Request) (*responses.Subsonic, error) { - id := utils.ParamString(r, "id") + p := req.Params(r) + id, _ := p.String("id") ctx := r.Context() entity, err := model.GetEntityByID(ctx, api.ds, id) @@ -129,7 +133,8 @@ func (api *Router) GetMusicDirectory(r *http.Request) (*responses.Subsonic, erro } func (api *Router) GetArtist(r *http.Request) (*responses.Subsonic, error) { - id := utils.ParamString(r, "id") + p := req.Params(r) + id, _ := p.String("id") ctx := r.Context() artist, err := api.ds.Artist(ctx).Get(id) @@ -151,7 +156,8 @@ func (api *Router) GetArtist(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) { - id := utils.ParamString(r, "id") + p := req.Params(r) + id, _ := p.String("id") ctx := r.Context() @@ -177,7 +183,8 @@ func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { - id, err := requiredParamString(r, "id") + p := req.Params(r) + id, err := p.String("id") ctx := r.Context() if err != nil { @@ -204,7 +211,8 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) { - id := utils.ParamString(r, "id") + p := req.Params(r) + id, _ := p.String("id") ctx := r.Context() mf, err := api.ds.MediaFile(ctx).Get(id) @@ -243,12 +251,13 @@ func (api *Router) GetGenres(r *http.Request) (*responses.Subsonic, error) { func (api *Router) GetArtistInfo(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - id, err := requiredParamString(r, "id") + p := req.Params(r) + id, err := p.String("id") if err != nil { return nil, err } - count := utils.ParamInt(r, "count", 20) - includeNotPresent := utils.ParamBool(r, "includeNotPresent", false) + count := p.IntOr("count", 20) + includeNotPresent := p.BoolOr("includeNotPresent", false) artist, err := api.externalMetadata.UpdateArtistInfo(ctx, id, count, includeNotPresent) if err != nil { @@ -295,11 +304,12 @@ func (api *Router) GetArtistInfo2(r *http.Request) (*responses.Subsonic, error) func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - id, err := requiredParamString(r, "id") + p := req.Params(r) + id, err := p.String("id") if err != nil { return nil, err } - count := utils.ParamInt(r, "count", 50) + count := p.IntOr("count", 50) songs, err := api.externalMetadata.SimilarSongs(ctx, id, count) if err != nil { @@ -328,11 +338,12 @@ func (api *Router) GetSimilarSongs2(r *http.Request) (*responses.Subsonic, error func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - artist, err := requiredParamString(r, "artist") + p := req.Params(r) + artist, err := p.String("artist") if err != nil { return nil, err } - count := utils.ParamInt(r, "count", 50) + count := p.IntOr("count", 50) songs, err := api.externalMetadata.TopSongs(ctx, artist, count) if err != nil { diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index c5beea9e5..595572c0c 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -2,10 +2,12 @@ package subsonic import ( "context" + "errors" "fmt" "mime" "net/http" "sort" + "strconv" "strings" "github.com/navidrome/navidrome/consts" @@ -13,7 +15,6 @@ import ( "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils" ) func newResponse() *responses.Subsonic { @@ -26,30 +27,6 @@ func newResponse() *responses.Subsonic { } } -func requiredParamString(r *http.Request, param string) (string, error) { - p := utils.ParamString(r, param) - if p == "" { - return "", newError(responses.ErrorMissingParameter, "required '%s' parameter is missing", param) - } - return p, nil -} - -func requiredParamStrings(r *http.Request, param string) ([]string, error) { - ps := utils.ParamStrings(r, param) - if len(ps) == 0 { - return nil, newError(responses.ErrorMissingParameter, "required '%s' parameter is missing", param) - } - return ps, nil -} - -func requiredParamInt(r *http.Request, param string) (int, error) { - p := utils.ParamString(r, param) - if p == "" { - return 0, newError(responses.ErrorMissingParameter, "required '%s' parameter is missing", param) - } - return utils.ParamInt(r, param, 0), nil -} - type subError struct { code int messages []interface{} @@ -62,6 +39,13 @@ func newError(code int, message ...interface{}) error { } } +// errSubsonic and Unwrap are used to allow `errors.Is(err, errSubsonic)` to work +var errSubsonic = errors.New("subsonic API error") + +func (e subError) Unwrap() error { + return fmt.Errorf("%w: %d", errSubsonic, e.code) +} + func (e subError) Error() string { var msg string if len(e.messages) == 0 { @@ -261,6 +245,24 @@ func childrenFromAlbums(ctx context.Context, als model.Albums) []responses.Child return children } +// toItemDate converts a string date in the formats 'YYYY-MM-DD', 'YYYY-MM' or 'YYYY' to an OS ItemDate +func toItemDate(date string) responses.ItemDate { + itemDate := responses.ItemDate{} + if date == "" { + return itemDate + } + parts := strings.Split(date, "-") + if len(parts) > 2 { + itemDate.Day, _ = strconv.Atoi(parts[2]) + } + if len(parts) > 1 { + itemDate.Month, _ = strconv.Atoi(parts[1]) + } + itemDate.Year, _ = strconv.Atoi(parts[0]) + + return itemDate +} + func buildItemGenres(genres model.Genres) []responses.ItemGenre { itemGenres := make([]responses.ItemGenre, len(genres)) for i, g := range genres { @@ -318,6 +320,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.MusicBrainzId = album.MbzAlbumID dir.IsCompilation = album.Compilation dir.SortName = album.SortAlbumName + dir.OriginalReleaseDate = toItemDate(album.OriginalDate) return dir } diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 898ca2a6c..8b33ebda4 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -57,4 +57,15 @@ var _ = Describe("helpers", func() { Expect(buildDiscSubtitles(context.Background(), album)).To(Equal(expected)) }) }) + + DescribeTable("toItemDate", + func(date string, expected responses.ItemDate) { + Expect(toItemDate(date)).To(Equal(expected)) + }, + Entry("1994-02-04", "1994-02-04", responses.ItemDate{Year: 1994, Month: 2, Day: 4}), + Entry("1994-02", "1994-02", responses.ItemDate{Year: 1994, Month: 2}), + Entry("1994", "1994", responses.ItemDate{Year: 1994}), + Entry("19940201", "", responses.ItemDate{}), + Entry("", "", responses.ItemDate{}), + ) }) diff --git a/server/subsonic/jukebox.go b/server/subsonic/jukebox.go index f3d94b592..b61d64294 100644 --- a/server/subsonic/jukebox.go +++ b/server/subsonic/jukebox.go @@ -1,13 +1,13 @@ package subsonic import ( - "fmt" "net/http" "strconv" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" ) const ( @@ -27,8 +27,9 @@ const ( func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() user := getUser(ctx) + p := req.Params(r) - actionString, err := requiredParamString(r, "action") + actionString, err := p.String("action") if err != nil { return nil, err } @@ -38,7 +39,7 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - log.Debug(fmt.Sprintf("processing action: %s", actionString)) + log.Info(ctx, "JukeboxControl request received", "action", actionString) switch actionString { case ActionGet: @@ -58,42 +59,31 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) case ActionStatus: return createResponse(pb.Status(ctx)) case ActionSet: - ids, err := requiredParamStrings(r, "id") - if err != nil { - return nil, newError(responses.ErrorMissingParameter, "missing parameter id, err: %s", err) - } - status, err := pb.Set(ctx, ids) - if err != nil { - return nil, err - } - return statusResponse(status), nil + ids, _ := p.Strings("id") + return createResponse(pb.Set(ctx, ids)) case ActionStart: return createResponse(pb.Start(ctx)) case ActionStop: return createResponse(pb.Stop(ctx)) case ActionSkip: - index, err := requiredParamInt(r, "index") + index, err := p.Int("index") if err != nil { return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err) } - offset, err := requiredParamInt(r, "offset") + offset := p.IntOr("offset", 0) if err != nil { offset = 0 } return createResponse(pb.Skip(ctx, index, offset)) case ActionAdd: - ids, err := requiredParamStrings(r, "id") - if err != nil { - return nil, newError(responses.ErrorMissingParameter, "missing parameter id, err: %s", err) - } - + ids, _ := p.Strings("id") return createResponse(pb.Add(ctx, ids)) case ActionClear: return createResponse(pb.Clear(ctx)) case ActionRemove: - index, err := requiredParamInt(r, "index") + index, err := p.Int("index") if err != nil { return nil, err } @@ -102,14 +92,14 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) case ActionShuffle: return createResponse(pb.Shuffle(ctx)) case ActionSetGain: - gainStr, err := requiredParamString(r, "gain") + gainStr, err := p.String("gain") if err != nil { return nil, newError(responses.ErrorMissingParameter, "missing parameter gain, err: %s", err) } gain, err := strconv.ParseFloat(gainStr, 32) if err != nil { - return nil, newError(responses.ErrorMissingParameter, "error parsing gain integer value, err: %s", err) + return nil, newError(responses.ErrorMissingParameter, "error parsing gain float value, err: %s", err) } return createResponse(pb.SetGain(ctx, float32(gain))) diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go index dc0838a93..2ef75887b 100644 --- a/server/subsonic/library_scanning.go +++ b/server/subsonic/library_scanning.go @@ -8,7 +8,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/req" ) func (api *Router) GetScanStatus(r *http.Request) (*responses.Subsonic, error) { @@ -41,7 +41,8 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { return nil, newError(responses.ErrorAuthorizationFail) } - fullScan := utils.ParamBool(r, "fullScan", false) + p := req.Params(r) + fullScan := p.BoolOr("fullScan", false) go func() { start := time.Now() diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index f6576b157..c9656d065 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -2,7 +2,6 @@ package subsonic import ( "context" - "errors" "fmt" "net/http" "time" @@ -13,26 +12,22 @@ import ( "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/req" ) func (api *Router) SetRating(r *http.Request) (*responses.Subsonic, error) { - id, err := requiredParamString(r, "id") + p := req.Params(r) + id, err := p.String("id") if err != nil { return nil, err } - rating, err := requiredParamInt(r, "rating") + rating, err := p.Int("rating") if err != nil { return nil, err } log.Debug(r, "Setting rating", "rating", rating, "id", id) err = api.setRating(r.Context(), id, rating) - - if errors.Is(err, model.ErrNotFound) { - log.Error(r, err) - return nil, newError(responses.ErrorDataNotFound, "ID not found") - } if err != nil { log.Error(r, err) return nil, err @@ -70,9 +65,10 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error { } func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) { - ids := utils.ParamStrings(r, "id") - albumIds := utils.ParamStrings(r, "albumId") - artistIds := utils.ParamStrings(r, "artistId") + p := req.Params(r) + ids, _ := p.Strings("id") + albumIds, _ := p.Strings("albumId") + artistIds, _ := p.Strings("artistId") if len(ids)+len(albumIds)+len(artistIds) == 0 { return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } @@ -88,9 +84,10 @@ func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) { - ids := utils.ParamStrings(r, "id") - albumIds := utils.ParamStrings(r, "albumId") - artistIds := utils.ParamStrings(r, "artistId") + p := req.Params(r) + ids, _ := p.Strings("id") + albumIds, _ := p.Strings("albumId") + artistIds, _ := p.Strings("artistId") if len(ids)+len(albumIds)+len(artistIds) == 0 { return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } @@ -150,11 +147,6 @@ func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error api.broker.SendMessage(ctx, event) return nil }) - - if errors.Is(err, model.ErrNotFound) { - log.Error(ctx, err) - return newError(responses.ErrorDataNotFound, "ID not found") - } if err != nil { log.Error(ctx, err) return err @@ -163,15 +155,16 @@ func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error } func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) { - ids, err := requiredParamStrings(r, "id") + p := req.Params(r) + ids, err := p.Strings("id") if err != nil { return nil, err } - times := utils.ParamTimes(r, "time") + times, _ := p.Times("time") if len(times) > 0 && len(times) != len(ids) { return nil, newError(responses.ErrorGeneric, "Wrong number of timestamps: %d, should be %d", len(times), len(ids)) } - submission := utils.ParamBool(r, "submission", true) + submission := p.BoolOr("submission", true) ctx := r.Context() if submission { diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 83b664f58..562fec1f5 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -14,15 +14,16 @@ import ( "github.com/navidrome/navidrome/resources" "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/gravatar" + "github.com/navidrome/navidrome/utils/req" ) func (api *Router) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { if !conf.Server.EnableGravatar { return api.getPlaceHolderAvatar(w, r) } - username, err := requiredParamString(r, "username") + p := req.Params(r) + username, err := p.String("username") if err != nil { return nil, err } @@ -60,8 +61,9 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() - id := utils.ParamString(r, "id") - size := utils.ParamInt(r, "size", 0) + p := req.Params(r) + id, _ := p.String("id") + size := p.IntOr("size", 0) imgReader, lastUpdate, err := api.artwork.GetOrPlaceholder(ctx, id, size) w.Header().Set("cache-control", "public, max-age=315360000") @@ -88,8 +90,9 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons } func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { - artist := utils.ParamString(r, "artist") - title := utils.ParamString(r, "title") + p := req.Params(r) + artist, _ := p.String("artist") + title, _ := p.String("title") response := newResponse() lyrics := responses.Lyrics{} response.Lyrics = &lyrics diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 1efe61663..a5f368328 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -20,8 +20,8 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils" . "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/req" ) func postFormToQueryParams(next http.Handler) http.Handler { @@ -45,19 +45,18 @@ func postFormToQueryParams(next http.Handler) http.Handler { func checkRequiredParameters(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requiredParameters := []string{"u", "v", "c"} - - for _, p := range requiredParameters { - if utils.ParamString(r, p) == "" { - msg := fmt.Sprintf(`Missing required parameter "%s"`, p) - log.Warn(r, msg) - sendError(w, r, newError(responses.ErrorMissingParameter, msg)) + p := req.Params(r) + for _, param := range requiredParameters { + if _, err := p.String(param); err != nil { + log.Warn(r, err) + sendError(w, r, err) return } } - username := utils.ParamString(r, "u") - client := utils.ParamString(r, "c") - version := utils.ParamString(r, "v") + username, _ := p.String("u") + client, _ := p.String("c") + version, _ := p.String("v") ctx := r.Context() ctx = request.WithUsername(ctx, username) ctx = request.WithClient(ctx, client) @@ -73,12 +72,13 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - username := utils.ParamString(r, "u") + p := req.Params(r) + username, _ := p.String("u") - pass := utils.ParamString(r, "p") - token := utils.ParamString(r, "t") - salt := utils.ParamString(r, "s") - jwt := utils.ParamString(r, "jwt") + pass, _ := p.String("p") + token, _ := p.String("t") + salt, _ := p.String("s") + jwt, _ := p.String("jwt") usr, err := validateUser(ctx, ds, username, pass, token, salt, jwt) if errors.Is(err, model.ErrInvalidAuth) { diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index 2927f2197..4b01d5e8f 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -10,7 +10,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/req" ) func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) { @@ -31,7 +31,8 @@ func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) { func (api *Router) GetPlaylist(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - id, err := requiredParamString(r, "id") + p := req.Params(r) + id, err := p.String("id") if err != nil { return nil, err } @@ -84,9 +85,10 @@ func (api *Router) create(ctx context.Context, playlistId, name string, ids []st func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - songIds := utils.ParamStrings(r, "songId") - playlistId := utils.ParamString(r, "playlistId") - name := utils.ParamString(r, "name") + p := req.Params(r) + songIds, _ := p.Strings("songId") + playlistId, _ := p.String("playlistId") + name, _ := p.String("name") if playlistId == "" && name == "" { return nil, errors.New("required parameter name is missing") } @@ -99,7 +101,8 @@ func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) } func (api *Router) DeletePlaylist(r *http.Request) (*responses.Subsonic, error) { - id, err := requiredParamString(r, "id") + p := req.Params(r) + id, err := p.String("id") if err != nil { return nil, err } @@ -115,23 +118,23 @@ func (api *Router) DeletePlaylist(r *http.Request) (*responses.Subsonic, error) } func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error) { - playlistId, err := requiredParamString(r, "playlistId") + p := req.Params(r) + playlistId, err := p.String("playlistId") if err != nil { return nil, err } - songsToAdd := utils.ParamStrings(r, "songIdToAdd") - songIndexesToRemove := utils.ParamInts(r, "songIndexToRemove") + songsToAdd, _ := p.Strings("songIdToAdd") + songIndexesToRemove, _ := p.Ints("songIndexToRemove") var plsName *string - if s, ok := r.URL.Query()["name"]; ok { - plsName = &s[0] + if s, err := p.String("name"); err == nil { + plsName = &s } var comment *string - if c, ok := r.URL.Query()["comment"]; ok { - comment = &c[0] + if s, err := p.String("comment"); err == nil { + comment = &s } var public *bool - if _, ok := r.URL.Query()["public"]; ok { - p := utils.ParamBool(r, "public", false) + if p, err := p.Bool("public"); err == nil { public = &p } diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index bc2497f54..9f2cd48f6 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -5,21 +5,22 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/req" ) func (api *Router) CreateInternetRadio(r *http.Request) (*responses.Subsonic, error) { - streamUrl, err := requiredParamString(r, "streamUrl") + p := req.Params(r) + streamUrl, err := p.String("streamUrl") if err != nil { return nil, err } - name, err := requiredParamString(r, "name") + name, err := p.String("name") if err != nil { return nil, err } - homepageUrl := utils.ParamString(r, "homepageUrl") + homepageUrl, _ := p.String("homepageUrl") ctx := r.Context() radio := &model.Radio{ @@ -36,7 +37,8 @@ func (api *Router) CreateInternetRadio(r *http.Request) (*responses.Subsonic, er } func (api *Router) DeleteInternetRadio(r *http.Request) (*responses.Subsonic, error) { - id, err := requiredParamString(r, "id") + p := req.Params(r) + id, err := p.String("id") if err != nil { return nil, err @@ -75,22 +77,23 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro } func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, error) { - id, err := requiredParamString(r, "id") + p := req.Params(r) + id, err := p.String("id") if err != nil { return nil, err } - streamUrl, err := requiredParamString(r, "streamUrl") + streamUrl, err := p.String("streamUrl") if err != nil { return nil, err } - name, err := requiredParamString(r, "name") + name, err := p.String("name") if err != nil { return nil, err } - homepageUrl := utils.ParamString(r, "homepageUrl") + homepageUrl, _ := p.String("homepageUrl") ctx := r.Context() radio := &model.Radio{ diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON index 15ddbbcea..b7ba2d1dd 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON @@ -31,6 +31,11 @@ "title": "disc 2" } ], + "originalReleaseDate": { + "year": 1994, + "month": 2, + "day": 4 + }, "song": [ { "id": "1", diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML index cfe3da99d..fd37f83f9 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML @@ -4,6 +4,7 @@ + diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON index 9508e14ba..8a5b87f24 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON @@ -12,6 +12,7 @@ "musicBrainzId": "", "isCompilation": false, "sortName": "", - "discTitles": [] + "discTitles": [], + "originalReleaseDate": {} } } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML index 58e03c04b..2b748953a 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML @@ -1,3 +1,5 @@ - + + + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index ec48b4026..f771176f0 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -219,13 +219,14 @@ type AlbumID3 struct { Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"` // OpenSubsonic extensions - Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"` - UserRating int32 `xml:"userRating,attr" json:"userRating"` - Genres ItemGenres `xml:"genres" json:"genres"` - MusicBrainzId string `xml:"musicBrainzId,attr" json:"musicBrainzId"` - IsCompilation bool `xml:"isCompilation,attr" json:"isCompilation"` - SortName string `xml:"sortName,attr" json:"sortName"` - DiscTitles DiscTitles `xml:"discTitles" json:"discTitles"` + Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"` + UserRating int32 `xml:"userRating,attr" json:"userRating"` + Genres ItemGenres `xml:"genres" json:"genres"` + MusicBrainzId string `xml:"musicBrainzId,attr" json:"musicBrainzId"` + IsCompilation bool `xml:"isCompilation,attr" json:"isCompilation"` + SortName string `xml:"sortName,attr" json:"sortName"` + DiscTitles DiscTitles `xml:"discTitles" json:"discTitles"` + OriginalReleaseDate ItemDate `xml:"originalReleaseDate" json:"originalReleaseDate"` } type ArtistWithAlbumsID3 struct { @@ -513,3 +514,9 @@ func marshalJSONArray[T any](v []T) ([]byte, error) { a := v return json.Marshal(a) } + +type ItemDate struct { + Year int `xml:"year,attr,omitempty" json:"year,omitempty"` + Month int `xml:"month,attr,omitempty" json:"month,omitempty"` + Day int `xml:"day,attr,omitempty" json:"day,omitempty"` +} diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index fb218b8e8..83838f847 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -176,7 +176,8 @@ var _ = Describe("Responses", func() { Id: "1", Name: "album", Artist: "artist", Genre: "rock", Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, MusicBrainzId: "1234", IsCompilation: true, SortName: "sorted album", - DiscTitles: DiscTitles{{Disc: 1, Title: "disc 1"}, {Disc: 2, Title: "disc 2"}}, + DiscTitles: DiscTitles{{Disc: 1, Title: "disc 1"}, {Disc: 2, Title: "disc 2"}}, + OriginalReleaseDate: ItemDate{Year: 1994, Month: 2, Day: 4}, } t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) songs := []Child{{ diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index d40d42beb..a2ba2d1cc 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -14,7 +14,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/req" ) type searchParams struct { @@ -28,18 +28,19 @@ type searchParams struct { } func (api *Router) getParams(r *http.Request) (*searchParams, error) { + p := req.Params(r) var err error sp := &searchParams{} - sp.query, err = requiredParamString(r, "query") + sp.query, err = p.String("query") if err != nil { return nil, err } - sp.artistCount = utils.ParamInt(r, "artistCount", 20) - sp.artistOffset = utils.ParamInt(r, "artistOffset", 0) - sp.albumCount = utils.ParamInt(r, "albumCount", 20) - sp.albumOffset = utils.ParamInt(r, "albumOffset", 0) - sp.songCount = utils.ParamInt(r, "songCount", 20) - sp.songOffset = utils.ParamInt(r, "songOffset", 0) + sp.artistCount = p.IntOr("artistCount", 20) + sp.artistOffset = p.IntOr("artistOffset", 0) + sp.albumCount = p.IntOr("albumCount", 20) + sp.albumOffset = p.IntOr("albumOffset", 0) + sp.songCount = p.IntOr("songCount", 20) + sp.songOffset = p.IntOr("songOffset", 0) return sp, nil } diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index 792ecfc4b..02110b084 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -9,7 +9,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/req" ) func (api *Router) GetShares(r *http.Request) (*responses.Subsonic, error) { @@ -50,13 +50,14 @@ func (api *Router) buildShare(r *http.Request, share model.Share) responses.Shar } func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { - ids := utils.ParamStrings(r, "id") - if len(ids) == 0 { - return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") + p := req.Params(r) + ids, err := p.Strings("id") + if err != nil { + return nil, err } - description := utils.ParamString(r, "description") - expires := utils.ParamTime(r, "expires", time.Time{}) + description, _ := p.String("description") + expires := p.TimeOr("expires", time.Time{}) repo := api.share.NewRepository(r.Context()) share := &model.Share{ @@ -81,13 +82,14 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) { - id := utils.ParamString(r, "id") - if id == "" { - return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err } - description := utils.ParamString(r, "description") - expires := utils.ParamTime(r, "expires", time.Time{}) + description, _ := p.String("description") + expires := p.TimeOr("expires", time.Time{}) repo := api.share.NewRepository(r.Context()) share := &model.Share{ @@ -96,7 +98,7 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) { ExpiresAt: expires, } - err := repo.(rest.Persistable).Update(id, share) + err = repo.(rest.Persistable).Update(id, share) if err != nil { return nil, err } @@ -105,13 +107,14 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) DeleteShare(r *http.Request) (*responses.Subsonic, error) { - id := utils.ParamString(r, "id") - if id == "" { - return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err } repo := api.share.NewRepository(r.Context()) - err := repo.(rest.Persistable).Delete(id) + err = repo.(rest.Persistable).Delete(id) if err != nil { return nil, err } diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index 1da20159b..d0cbe2086 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -14,7 +14,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/req" ) func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *core.Stream, id string) { @@ -25,7 +25,7 @@ func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *ht w.Header().Set("Accept-Ranges", "none") w.Header().Set("Content-Type", stream.ContentType()) - estimateContentLength := utils.ParamBool(r, "estimateContentLength", false) + estimateContentLength := req.Params(r).BoolOr("estimateContentLength", false) // if Client requests the estimated content-length, send it if estimateContentLength { @@ -38,7 +38,7 @@ func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *ht go func() { _, _ = io.Copy(io.Discard, stream) }() } else { c, err := io.Copy(w, stream) - if log.CurrentLevel() >= log.LevelDebug { + if log.IsGreaterOrEqualTo(log.LevelDebug) { if err != nil { log.Error(ctx, "Error sending transcoded file", "id", id, err) } else { @@ -51,13 +51,14 @@ func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *ht func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - id, err := requiredParamString(r, "id") + p := req.Params(r) + id, err := p.String("id") if err != nil { return nil, err } - maxBitRate := utils.ParamInt(r, "maxBitRate", 0) - format := utils.ParamString(r, "format") - timeOffset := utils.ParamInt(r, "timeOffset", 0) + maxBitRate := p.IntOr("maxBitRate", 0) + format, _ := p.String("format") + timeOffset := p.IntOr("timeOffset", 0) stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, timeOffset) if err != nil { @@ -66,7 +67,7 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su // Make sure the stream will be closed at the end, to avoid leakage defer func() { - if err := stream.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug { + if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { log.Error("Error closing stream", "id", id, "file", stream.Name(), err) } }() @@ -82,7 +83,8 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() username, _ := request.UsernameFrom(ctx) - id, err := requiredParamString(r, "id") + p := req.Params(r) + id, err := p.String("id") if err != nil { return nil, err } @@ -97,8 +99,8 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. return nil, err } - maxBitRate := utils.ParamInt(r, "bitrate", 0) - format := utils.ParamString(r, "format") + maxBitRate := p.IntOr("bitrate", 0) + format, _ := p.String("format") if format == "" { if conf.Server.AutoTranscodeDownload { @@ -134,7 +136,7 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. // Make sure the stream will be closed at the end, to avoid leakage defer func() { - if err := stream.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug { + if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { log.Error("Error closing stream", "id", id, "file", stream.Name(), err) } }() diff --git a/tests/fixtures/test.tak b/tests/fixtures/test.tak new file mode 100644 index 000000000..4ed8bb843 Binary files /dev/null and b/tests/fixtures/test.tak differ diff --git a/ui/package-lock.json b/ui/package-lock.json index 06ac9d72a..7eb693436 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -9,16 +9,16 @@ "version": "0.1.0", "dependencies": { "@material-ui/core": "^4.11.4", - "@material-ui/icons": "^4.11.2", + "@material-ui/icons": "^4.11.3", "@material-ui/lab": "^4.0.0-alpha.58", "@material-ui/styles": "^4.11.5", "blueimp-md5": "^2.19.0", - "clsx": "^1.1.1", + "clsx": "^2.0.0", "connected-react-router": "^6.9.1", - "deepmerge": "^4.2.2", + "deepmerge": "^4.3.1", "history": "^4.10.1", "inflection": "^1.13.1", - "jwt-decode": "^3.1.2", + "jwt-decode": "^4.0.0", "lodash.pick": "^4.4.0", "lodash.throttle": "^4.1.1", "navidrome-music-player": "4.25.1", @@ -46,18 +46,18 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^7.0.2", - "@testing-library/user-event": "^13.5.0", + "@testing-library/user-event": "^14.5.1", "css-mediaquery": "^0.1.2", - "prettier": "2.8.2", + "prettier": "3.1.1", "ra-test": "^3.19.12", "react-scripts": "5.0.1", "workbox-cli": "^6.3.0" } }, "node_modules/@adobe/css-tools": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.1.tgz", - "integrity": "sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.2.tgz", + "integrity": "sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==", "dev": true }, "node_modules/@ampproject/remapping": { @@ -3303,15 +3303,34 @@ } } }, + "node_modules/@material-ui/core/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/@material-ui/icons": { - "version": "4.11.2", - "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.2.tgz", - "integrity": "sha512-fQNsKX2TxBmqIGJCSi3tGTO/gZ+eJgWmMJkgDiOfyNaunNaxcklJQFaFogYcFl0qFuaEz1qaXYXboa/bUXVSOQ==", + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz", + "integrity": "sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA==", "dependencies": { "@babel/runtime": "^7.4.4" }, "engines": { "node": ">=8.0.0" + }, + "peerDependencies": { + "@material-ui/core": "^4.0.0", + "@types/react": "^16.8.6 || ^17.0.0", + "react": "^16.8.0 || ^17.0.0", + "react-dom": "^16.8.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@material-ui/lab": { @@ -3329,6 +3348,14 @@ "node": ">=8.0.0" } }, + "node_modules/@material-ui/lab/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/@material-ui/styles": { "version": "4.11.5", "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.5.tgz", @@ -3370,6 +3397,14 @@ } } }, + "node_modules/@material-ui/styles/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/@material-ui/system": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.12.2.tgz", @@ -4017,15 +4052,12 @@ } }, "node_modules/@testing-library/user-event": { - "version": "13.5.0", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", - "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==", + "version": "14.5.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.1.tgz", + "integrity": "sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg==", "dev": true, - "dependencies": { - "@babel/runtime": "^7.12.5" - }, "engines": { - "node": ">=10", + "node": ">=12", "npm": ">=6" }, "peerDependencies": { @@ -6271,9 +6303,9 @@ } }, "node_modules/clsx": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", - "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", "engines": { "node": ">=6" } @@ -7236,9 +7268,9 @@ "dev": true }, "node_modules/deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "engines": { "node": ">=0.10.0" } @@ -13289,9 +13321,12 @@ } }, "node_modules/jwt-decode": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", - "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } }, "node_modules/keyv": { "version": "3.1.0", @@ -16068,15 +16103,15 @@ } }, "node_modules/prettier": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.2.tgz", - "integrity": "sha512-BtRV9BcncDyI2tsuS19zzhzoxD8Dh8LiCx7j7tHzrkz8GFXAexeWFdi22mjE1d16dftH2qNaytVxqiRTGlMfpw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz", + "integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==", "dev": true, "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" @@ -20847,9 +20882,9 @@ }, "dependencies": { "@adobe/css-tools": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.1.tgz", - "integrity": "sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.2.tgz", + "integrity": "sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==", "dev": true }, "@ampproject/remapping": { @@ -23174,12 +23209,19 @@ "prop-types": "^15.7.2", "react-is": "^16.8.0 || ^17.0.0", "react-transition-group": "^4.4.0" + }, + "dependencies": { + "clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==" + } } }, "@material-ui/icons": { - "version": "4.11.2", - "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.2.tgz", - "integrity": "sha512-fQNsKX2TxBmqIGJCSi3tGTO/gZ+eJgWmMJkgDiOfyNaunNaxcklJQFaFogYcFl0qFuaEz1qaXYXboa/bUXVSOQ==", + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz", + "integrity": "sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA==", "requires": { "@babel/runtime": "^7.4.4" } @@ -23194,6 +23236,13 @@ "clsx": "^1.0.4", "prop-types": "^15.7.2", "react-is": "^16.8.0 || ^17.0.0" + }, + "dependencies": { + "clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==" + } } }, "@material-ui/styles": { @@ -23217,6 +23266,13 @@ "jss-plugin-rule-value-function": "^10.5.1", "jss-plugin-vendor-prefixer": "^10.5.1", "prop-types": "^15.7.2" + }, + "dependencies": { + "clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==" + } } }, "@material-ui/system": { @@ -23713,13 +23769,11 @@ } }, "@testing-library/user-event": { - "version": "13.5.0", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", - "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==", + "version": "14.5.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.1.tgz", + "integrity": "sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg==", "dev": true, - "requires": { - "@babel/runtime": "^7.12.5" - } + "requires": {} }, "@tootallnate/once": { "version": "1.1.2", @@ -25529,9 +25583,9 @@ } }, "clsx": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", - "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==" }, "co": { "version": "4.6.0", @@ -26251,9 +26305,9 @@ "dev": true }, "deepmerge": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", - "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" }, "default-gateway": { "version": "6.0.3", @@ -30944,9 +30998,9 @@ } }, "jwt-decode": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", - "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==" }, "keyv": { "version": "3.1.0", @@ -32869,9 +32923,9 @@ "dev": true }, "prettier": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.2.tgz", - "integrity": "sha512-BtRV9BcncDyI2tsuS19zzhzoxD8Dh8LiCx7j7tHzrkz8GFXAexeWFdi22mjE1d16dftH2qNaytVxqiRTGlMfpw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz", + "integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==", "dev": true }, "pretty-bytes": { diff --git a/ui/package.json b/ui/package.json index ae5f8dbca..74786c085 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,16 +4,16 @@ "private": true, "dependencies": { "@material-ui/core": "^4.11.4", - "@material-ui/icons": "^4.11.2", + "@material-ui/icons": "^4.11.3", "@material-ui/lab": "^4.0.0-alpha.58", "@material-ui/styles": "^4.11.5", "blueimp-md5": "^2.19.0", - "clsx": "^1.1.1", + "clsx": "^2.0.0", "connected-react-router": "^6.9.1", - "deepmerge": "^4.2.2", + "deepmerge": "^4.3.1", "history": "^4.10.1", "inflection": "^1.13.1", - "jwt-decode": "^3.1.2", + "jwt-decode": "^4.0.0", "lodash.pick": "^4.4.0", "lodash.throttle": "^4.1.1", "navidrome-music-player": "4.25.1", @@ -41,9 +41,9 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^7.0.2", - "@testing-library/user-event": "^13.5.0", + "@testing-library/user-event": "^14.5.1", "css-mediaquery": "^0.1.2", - "prettier": "2.8.2", + "prettier": "3.1.1", "ra-test": "^3.19.12", "react-scripts": "5.0.1", "workbox-cli": "^6.3.0" diff --git a/ui/src/album/AlbumDetails.js b/ui/src/album/AlbumDetails.js index 5e2ee9b65..e5fcb5eb7 100644 --- a/ui/src/album/AlbumDetails.js +++ b/ui/src/album/AlbumDetails.js @@ -113,7 +113,7 @@ const useStyles = makeStyles( }), { name: 'NDAlbumDetails', - } + }, ) const AlbumComment = ({ record }) => { @@ -141,7 +141,7 @@ const AlbumComment = ({ record }) => { timeout={'auto'} className={clsx( classes.commentBlock, - lines.length > 1 && classes.pointerCursor + lines.length > 1 && classes.pointerCursor, )} > @@ -216,9 +216,9 @@ const Details = (props) => { addDetail( <> {[translate('resources.album.fields.originalDate'), originalDate].join( - ' ' + ' ', )} - + , ) yearRange && addDetail(<>{['♫', !isXsmall ? date : yearRange].join(' ')}) @@ -230,7 +230,7 @@ const Details = (props) => { ? [translate('resources.album.fields.releaseDate'), releaseDate] : ['○', record.releaseDate.substring(0, 4)] ).join(' ')} - + , ) const showReleases = record.releases > 1 @@ -245,7 +245,7 @@ const Details = (props) => { }), ].join(' ') : ['(', record.releases, ')))'].join(' ')} - + , ) addDetail( @@ -255,7 +255,7 @@ const Details = (props) => { translate('resources.song.name', { smart_count: record.songCount, })} - + , ) !isXsmall && addDetail() !isXsmall && addDetail() @@ -299,7 +299,7 @@ const AlbumDetails = (props) => { const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), []) const handleCloseLightbox = React.useCallback( () => setLightboxOpen(false), - [] + [], ) return ( diff --git a/ui/src/album/AlbumExternalLinks.js b/ui/src/album/AlbumExternalLinks.js index caf4c82ea..4b956b496 100644 --- a/ui/src/album/AlbumExternalLinks.js +++ b/ui/src/album/AlbumExternalLinks.js @@ -35,7 +35,7 @@ const AlbumExternalLinks = (props) => { encodeURIComponent(record.name) }`, 'message.openIn.lastfm', - + , ) } @@ -43,7 +43,7 @@ const AlbumExternalLinks = (props) => { addLink( `https://musicbrainz.org/release/${record.mbzAlbumId}`, 'message.openIn.musicbrainz', - + , ) return
{intersperse(links, ' ')}
diff --git a/ui/src/album/AlbumGridView.js b/ui/src/album/AlbumGridView.js index 2c01e8517..765722254 100644 --- a/ui/src/album/AlbumGridView.js +++ b/ui/src/album/AlbumGridView.js @@ -78,7 +78,7 @@ const useStyles = makeStyles( albumContainer: {}, albumPlayButton: { color: 'white' }, }), - { name: 'NDAlbumGridView' } + { name: 'NDAlbumGridView' }, ) const useCoverStyles = makeStyles({ @@ -98,32 +98,34 @@ const getColsForWidth = (width) => { return 9 } -const Cover = withContentRect('bounds')( - ({ record, measureRef, contentRect }) => { - // Force height to be the same as the width determined by the GridList - // noinspection JSSuspiciousNameCombination - const classes = useCoverStyles({ height: contentRect.bounds.width }) - const [, dragAlbumRef] = useDrag( - () => ({ - type: DraggableTypes.ALBUM, - item: { albumIds: [record.id] }, - options: { dropEffect: 'copy' }, - }), - [record] - ) - return ( -
-
- {record.name} -
+const Cover = withContentRect('bounds')(({ + record, + measureRef, + contentRect, +}) => { + // Force height to be the same as the width determined by the GridList + // noinspection JSSuspiciousNameCombination + const classes = useCoverStyles({ height: contentRect.bounds.width }) + const [, dragAlbumRef] = useDrag( + () => ({ + type: DraggableTypes.ALBUM, + item: { albumIds: [record.id] }, + options: { dropEffect: 'copy' }, + }), + [record], + ) + return ( +
+
+ {record.name}
- ) - } -) +
+ ) +}) const AlbumGridTile = ({ showArtist, record, basePath, ...props }) => { const classes = useStyles() diff --git a/ui/src/album/AlbumList.js b/ui/src/album/AlbumList.js index 10ba7e100..4bae75e47 100644 --- a/ui/src/album/AlbumList.js +++ b/ui/src/album/AlbumList.js @@ -104,7 +104,7 @@ const AlbumList = (props) => { 'size', 'createdAt', ], - ['createdAt', 'size'] + ['createdAt', 'size'], ) // If it does not have filter/sort params (usually coming from Menu), diff --git a/ui/src/album/AlbumListActions.js b/ui/src/album/AlbumListActions.js index 45825dd5d..102dd6072 100644 --- a/ui/src/album/AlbumListActions.js +++ b/ui/src/album/AlbumListActions.js @@ -64,7 +64,7 @@ const AlbumViewToggler = React.forwardRef(
) - } + }, ) const AlbumListActions = ({ diff --git a/ui/src/album/AlbumShow.js b/ui/src/album/AlbumShow.js index 92a0833cd..1b706295a 100644 --- a/ui/src/album/AlbumShow.js +++ b/ui/src/album/AlbumShow.js @@ -18,7 +18,7 @@ const useStyles = makeStyles( }), { name: 'NDAlbumShow', - } + }, ) const AlbumShowLayout = (props) => { diff --git a/ui/src/album/AlbumSongs.js b/ui/src/album/AlbumSongs.js index ef81bc02c..3ad0eb318 100644 --- a/ui/src/album/AlbumSongs.js +++ b/ui/src/album/AlbumSongs.js @@ -83,7 +83,7 @@ const useStyles = makeStyles( visibility: 'hidden', }, }), - { name: 'RaList' } + { name: 'RaList' }, ) const AlbumSongs = (props) => { diff --git a/ui/src/album/AlbumTableView.js b/ui/src/album/AlbumTableView.js index b11d55f0e..be8e49725 100644 --- a/ui/src/album/AlbumTableView.js +++ b/ui/src/album/AlbumTableView.js @@ -59,7 +59,7 @@ const AlbumDatagridRow = (props) => { item: { albumIds: [record?.id] }, options: { dropEffect: 'copy' }, }), - [record] + [record], ) return } diff --git a/ui/src/artist/ArtistExternalLink.js b/ui/src/artist/ArtistExternalLink.js index c25f19ffd..9efa3925b 100644 --- a/ui/src/artist/ArtistExternalLink.js +++ b/ui/src/artist/ArtistExternalLink.js @@ -11,7 +11,7 @@ const ArtistExternalLinks = ({ artistInfo, record }) => { const translate = useTranslate() let linkButtons = [] const lastFMlink = artistInfo?.biography?.match( - /]*?\s+)?href=(["'])(.*?)\1/ + /]*?\s+)?href=(["'])(.*?)\1/, ) const addLink = (url, title, icon) => { @@ -34,13 +34,13 @@ const ArtistExternalLinks = ({ artistInfo, record }) => { addLink( lastFMlink[2], 'message.openIn.lastfm', - + , ) } else if (artistInfo?.lastFmUrl) { addLink( artistInfo?.lastFmUrl, 'message.openIn.lastfm', - + , ) } } @@ -49,7 +49,7 @@ const ArtistExternalLinks = ({ artistInfo, record }) => { addLink( `https://musicbrainz.org/artist/${artistInfo.musicBrainzId}`, 'message.openIn.musicbrainz', - + , ) return
{intersperse(linkButtons, ' ')}
diff --git a/ui/src/artist/ArtistList.js b/ui/src/artist/ArtistList.js index dbc0e0ebd..546ade21c 100644 --- a/ui/src/artist/ArtistList.js +++ b/ui/src/artist/ArtistList.js @@ -90,7 +90,7 @@ const ArtistDatagridRow = (props) => { item: { artistIds: [record?.id] }, options: { dropEffect: 'copy' }, }), - [record] + [record], ) return } @@ -132,7 +132,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => { resource: 'artist', columns: toggleableFields, }, - ['size'] + ['size'], ) return isXsmall ? ( diff --git a/ui/src/artist/DesktopArtistDetails.js b/ui/src/artist/DesktopArtistDetails.js index d49eff8fd..5d56bdef7 100644 --- a/ui/src/artist/DesktopArtistDetails.js +++ b/ui/src/artist/DesktopArtistDetails.js @@ -65,7 +65,7 @@ const useStyles = makeStyles( wordBreak: 'break-word', }, }), - { name: 'NDDesktopArtistDetails' } + { name: 'NDDesktopArtistDetails' }, ) const DesktopArtistDetails = ({ artistInfo, record, biography }) => { @@ -77,7 +77,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), []) const handleCloseLightbox = React.useCallback( () => setLightboxOpen(false), - [] + [], ) return ( diff --git a/ui/src/artist/MobileArtistDetails.js b/ui/src/artist/MobileArtistDetails.js index 683280d9e..4f1bac909 100644 --- a/ui/src/artist/MobileArtistDetails.js +++ b/ui/src/artist/MobileArtistDetails.js @@ -72,7 +72,7 @@ const useStyles = makeStyles( wordBreak: 'break-word', }, }), - { name: 'NDMobileArtistDetails' } + { name: 'NDMobileArtistDetails' }, ) const MobileArtistDetails = ({ artistInfo, biography, record }) => { @@ -85,7 +85,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), []) const handleCloseLightbox = React.useCallback( () => setLightboxOpen(false), - [] + [], ) return ( diff --git a/ui/src/audioplayer/Player.js b/ui/src/audioplayer/Player.js index 6b4720173..174940aa2 100644 --- a/ui/src/audioplayer/Player.js +++ b/ui/src/audioplayer/Player.js @@ -48,7 +48,7 @@ const Player = () => { const isDesktop = useMediaQuery('(min-width:810px)') const isMobilePlayer = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( - navigator.userAgent + navigator.userAgent, ) const { authenticated } = useAuthState() @@ -60,7 +60,7 @@ const Player = () => { enableCoverAnimation: config.enableCoverAnimation, }) const showNotifications = useSelector( - (state) => state.settings.notifications || false + (state) => state.settings.notifications || false, ) const gainInfo = useSelector((state) => state.replayGain) const [context, setContext] = useState(null) @@ -100,7 +100,7 @@ const Player = () => { numericGain = calculateReplayGain( gainInfo.preAmp, song.rgAlbumGain, - song.rgAlbumPeak + song.rgAlbumPeak, ) break } @@ -108,7 +108,7 @@ const Player = () => { numericGain = calculateReplayGain( gainInfo.preAmp, song.rgTrackGain, - song.rgTrackPeak + song.rgTrackPeak, ) break } @@ -160,7 +160,7 @@ const Player = () => { ), locale: locale(translate), }), - [gainInfo, isDesktop, playerTheme, translate] + [gainInfo, isDesktop, playerTheme, translate], ) const options = useMemo(() => { @@ -181,12 +181,12 @@ const Player = () => { const onAudioListsChange = useCallback( (_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)), - [dispatch] + [dispatch], ) const nextSong = useCallback(() => { const idx = playerState.queue.findIndex( - (item) => item.uuid === playerState.current.uuid + (item) => item.uuid === playerState.current.uuid, ) return idx !== null ? playerState.queue[idx + 1] : null }, [playerState]) @@ -221,13 +221,13 @@ const Player = () => { setScrobbled(true) } }, - [startTime, scrobbled, nextSong, preloaded] + [startTime, scrobbled, nextSong, preloaded], ) const onAudioVolumeChange = useCallback( // sqrt to compensate for the logarithmic volume (volume) => dispatch(setVolume(Math.sqrt(volume))), - [dispatch] + [dispatch], ) const onAudioPlay = useCallback( @@ -260,12 +260,12 @@ const Player = () => { sendNotification( song.title, `${song.artist} - ${song.album}`, - info.cover + info.cover, ) } } }, - [context, dispatch, showNotifications, startTime] + [context, dispatch, showNotifications, startTime], ) const onAudioPlayTrackChange = useCallback(() => { @@ -279,7 +279,7 @@ const Player = () => { const onAudioPause = useCallback( (info) => dispatch(currentPlaying(info)), - [dispatch] + [dispatch], ) const onAudioEnded = useCallback( @@ -291,7 +291,7 @@ const Player = () => { .getOne('keepalive', { id: info.trackId }) .catch((e) => console.log('Keepalive error:', e)) }, - [dispatch, dataProvider] + [dispatch, dataProvider], ) const onCoverClick = useCallback((mode, audioLists, audioInfo) => { @@ -313,7 +313,7 @@ const Player = () => { const handlers = useMemo( () => keyHandlers(audioInstance, playerState), - [audioInstance, playerState] + [audioInstance, playerState], ) useEffect(() => { diff --git a/ui/src/audioplayer/keyHandlers.js b/ui/src/audioplayer/keyHandlers.js index 444ea2c12..793276de0 100644 --- a/ui/src/audioplayer/keyHandlers.js +++ b/ui/src/audioplayer/keyHandlers.js @@ -1,14 +1,14 @@ const keyHandlers = (audioInstance, playerState) => { const nextSong = () => { const idx = playerState.queue.findIndex( - (item) => item.uuid === playerState.current.uuid + (item) => item.uuid === playerState.current.uuid, ) return idx !== null ? playerState.queue[idx + 1] : null } const prevSong = () => { const idx = playerState.queue.findIndex( - (item) => item.uuid === playerState.current.uuid + (item) => item.uuid === playerState.current.uuid, ) return idx !== null ? playerState.queue[idx - 1] : null } diff --git a/ui/src/audioplayer/styles.js b/ui/src/audioplayer/styles.js index 38b7831b9..30a14d4db 100644 --- a/ui/src/audioplayer/styles.js +++ b/ui/src/audioplayer/styles.js @@ -87,7 +87,7 @@ const useStyle = makeStyles( }, }, }), - { name: 'NDAudioPlayer' } + { name: 'NDAudioPlayer' }, ) export default useStyle diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js index 86d45b446..c22e9345d 100644 --- a/ui/src/authProvider.js +++ b/ui/src/authProvider.js @@ -1,4 +1,4 @@ -import jwtDecode from 'jwt-decode' +import { jwtDecode } from 'jwt-decode' import { baseUrl } from './utils' import config from './config' diff --git a/ui/src/common/AddToPlaylistButton.js b/ui/src/common/AddToPlaylistButton.js index 021bd308a..9cddc7499 100644 --- a/ui/src/common/AddToPlaylistButton.js +++ b/ui/src/common/AddToPlaylistButton.js @@ -12,7 +12,10 @@ export const AddToPlaylistButton = ({ resource, selectedIds, className }) => { const handleClick = () => { dispatch( - openAddToPlaylist({ selectedIds, onSuccess: () => unselectAll(resource) }) + openAddToPlaylist({ + selectedIds, + onSuccess: () => unselectAll(resource), + }), ) } diff --git a/ui/src/common/ArtistLinkField.js b/ui/src/common/ArtistLinkField.js index 26623658a..09df55521 100644 --- a/ui/src/common/ArtistLinkField.js +++ b/ui/src/common/ArtistLinkField.js @@ -14,28 +14,31 @@ export const useGetHandleArtistClick = (width) => { } } -export const ArtistLinkField = withWidth()( - ({ record, className, width, source }) => { - const artistLink = useGetHandleArtistClick(width) +export const ArtistLinkField = withWidth()(({ + record, + className, + width, + source, +}) => { + const artistLink = useGetHandleArtistClick(width) - const id = record[source + 'Id'] - return ( - <> - {id ? ( - e.stopPropagation()} - className={className} - > - {record[source]} - - ) : ( - record[source] - )} - - ) - } -) + const id = record[source + 'Id'] + return ( + <> + {id ? ( + e.stopPropagation()} + className={className} + > + {record[source]} + + ) : ( + record[source] + )} + + ) +}) ArtistLinkField.propTypes = { record: PropTypes.object, diff --git a/ui/src/common/ArtistSimpleList.js b/ui/src/common/ArtistSimpleList.js index de6994479..476da992e 100644 --- a/ui/src/common/ArtistSimpleList.js +++ b/ui/src/common/ArtistSimpleList.js @@ -23,7 +23,7 @@ const useStyles = makeStyles( top: '26px', }, }, - { name: 'RaArtistSimpleList' } + { name: 'RaArtistSimpleList' }, ) export const ArtistSimpleList = ({ @@ -69,7 +69,7 @@ export const ArtistSimpleList = ({ - ) + ), )} ) diff --git a/ui/src/common/BatchPlayButton.js b/ui/src/common/BatchPlayButton.js index f3b68f35e..f4c136922 100644 --- a/ui/src/common/BatchPlayButton.js +++ b/ui/src/common/BatchPlayButton.js @@ -30,7 +30,7 @@ export const BatchPlayButton = ({ // Add tracks to a map for easy lookup by ID, needed for the next step const tracks = response.data.reduce( (acc, cur) => ({ ...acc, [cur.id]: cur }), - {} + {}, ) // Add the tracks to the queue in the selection order dispatch(action(tracks, selectedIds)) diff --git a/ui/src/common/BatchShareButton.js b/ui/src/common/BatchShareButton.js index bcf76c3d4..8294953bb 100644 --- a/ui/src/common/BatchShareButton.js +++ b/ui/src/common/BatchShareButton.js @@ -18,8 +18,8 @@ export const BatchShareButton = ({ resource, selectedIds, className }) => { _: 'ra.action.bulk_actions', smart_count: selectedIds.length, }), - 'message.shareBatchDialogTitle' - ) + 'message.shareBatchDialogTitle', + ), ) unselectAll(resource) } diff --git a/ui/src/common/ContextMenus.js b/ui/src/common/ContextMenus.js index a492548a4..ba41b70b0 100644 --- a/ui/src/common/ContextMenus.js +++ b/ui/src/common/ContextMenus.js @@ -97,8 +97,8 @@ const ContextMenu = ({ record, record.duration !== undefined ? DOWNLOAD_MENU_ALBUM - : DOWNLOAD_MENU_ARTIST - ) + : DOWNLOAD_MENU_ARTIST, + ), ) }, }, @@ -127,7 +127,7 @@ const ContextMenu = ({ let extractSongsData = function (response) { const data = response.data.reduce( (acc, cur) => ({ ...acc, [cur.id]: cur }), - {} + {}, ) const ids = response.data.map((r) => r.id) return { data, ids } @@ -186,7 +186,7 @@ const ContextMenu = ({ {options[key].label} - ) + ), )} diff --git a/ui/src/common/Linkify.js b/ui/src/common/Linkify.js index 87947905a..0a09e0d76 100644 --- a/ui/src/common/Linkify.js +++ b/ui/src/common/Linkify.js @@ -10,7 +10,7 @@ const useStyles = makeStyles( color: theme.palette.primary.main, }, }), - { name: 'RaLink' } + { name: 'RaLink' }, ) const Linkify = ({ text, ...rest }) => { @@ -45,7 +45,7 @@ const Linkify = ({ text, ...rest }) => { href={href} > {href} - + , ) lastIndex = match.index + href.length @@ -57,7 +57,7 @@ const Linkify = ({ text, ...rest }) => { + />, ) } diff --git a/ui/src/common/LoveButton.js b/ui/src/common/LoveButton.js index eea91e826..4f89fd57b 100644 --- a/ui/src/common/LoveButton.js +++ b/ui/src/common/LoveButton.js @@ -36,7 +36,7 @@ export const LoveButton = ({ toggleLove() e.stopPropagation() }, - [toggleLove] + [toggleLove], ) if (!config.enableFavourites) { diff --git a/ui/src/common/MultiLineTextField.js b/ui/src/common/MultiLineTextField.js index 5148482dd..8f45da04f 100644 --- a/ui/src/common/MultiLineTextField.js +++ b/ui/src/common/MultiLineTextField.js @@ -39,11 +39,11 @@ export const MultiLineTextField = memo( key={md5(line + idx)} dangerouslySetInnerHTML={{ __html: line }} /> - ) + ), )}
) - } + }, ) MultiLineTextField.defaultProps = { diff --git a/ui/src/common/MultiLineTextField.test.js b/ui/src/common/MultiLineTextField.test.js index 44dede43f..8f29166a3 100644 --- a/ui/src/common/MultiLineTextField.test.js +++ b/ui/src/common/MultiLineTextField.test.js @@ -20,9 +20,9 @@ describe('', () => { record={{ id: 123, body }} emptyText="NA" source="body" - /> + />, ) expect(screen.getByText('NA')).toBeInTheDocument() - } + }, ) }) diff --git a/ui/src/common/PlayButton.js b/ui/src/common/PlayButton.js index e40927ef2..ddc676004 100644 --- a/ui/src/common/PlayButton.js +++ b/ui/src/common/PlayButton.js @@ -10,7 +10,7 @@ export const PlayButton = ({ record, size, className }) => { let extractSongsData = function (response) { const data = response.data.reduce( (acc, cur) => ({ ...acc, [cur.id]: cur }), - {} + {}, ) const ids = response.data.map((r) => r.id) return { data, ids } diff --git a/ui/src/common/QualityInfo.js b/ui/src/common/QualityInfo.js index e8beb3236..e663d3092 100644 --- a/ui/src/common/QualityInfo.js +++ b/ui/src/common/QualityInfo.js @@ -16,7 +16,7 @@ const useStyle = makeStyles( }), { name: 'NDQualityInfo', - } + }, ) export const QualityInfo = ({ record, size, gainMode, preAmp, className }) => { diff --git a/ui/src/common/QuickFilter.test.js b/ui/src/common/QuickFilter.test.js index 56426af94..36df2aa0e 100644 --- a/ui/src/common/QuickFilter.test.js +++ b/ui/src/common/QuickFilter.test.js @@ -22,7 +22,7 @@ describe('QuickFilter', () => { resource={'song'} source={'name'} label={} - /> + />, ) expect(screen.getByTestId('label-icon-test')).not.toBeNull() }) diff --git a/ui/src/common/RatingField.js b/ui/src/common/RatingField.js index fe9171527..dc8acb7b0 100644 --- a/ui/src/common/RatingField.js +++ b/ui/src/common/RatingField.js @@ -40,7 +40,7 @@ export const RatingField = ({ (e, val) => { rate(val, e.target.name) }, - [rate] + [rate], ) return ( @@ -50,7 +50,7 @@ export const RatingField = ({ className={clsx( className, classes.rating, - rating > 0 ? classes.show : classes.hide + rating > 0 ? classes.show : classes.hide, )} value={rating} size={size} diff --git a/ui/src/common/SimpleList.js b/ui/src/common/SimpleList.js index 1a4f72fba..b803993a2 100644 --- a/ui/src/common/SimpleList.js +++ b/ui/src/common/SimpleList.js @@ -19,7 +19,7 @@ const useStyles = makeStyles( }, tertiary: { float: 'right', opacity: 0.541176 }, }, - { name: 'RaSimpleList' } + { name: 'RaSimpleList' }, ) const LinkOrNot = ({ diff --git a/ui/src/common/SongContextMenu.js b/ui/src/common/SongContextMenu.js index 3b8f60835..16a1c4cad 100644 --- a/ui/src/common/SongContextMenu.js +++ b/ui/src/common/SongContextMenu.js @@ -61,7 +61,7 @@ export const SongContextMenu = ({ openAddToPlaylist({ selectedIds: [record.mediaFileId || record.id], onSuccess: (id) => onAddToPlaylist(id), - }) + }), ), }, share: { @@ -69,7 +69,11 @@ export const SongContextMenu = ({ label: translate('ra.action.share'), action: (record) => dispatch( - openShareMenu([record.mediaFileId || record.id], 'song', record.title) + openShareMenu( + [record.mediaFileId || record.id], + 'song', + record.title, + ), ), }, download: { @@ -127,7 +131,7 @@ export const SongContextMenu = ({ {options[key].label} - ) + ), )} diff --git a/ui/src/common/SongDatagrid.js b/ui/src/common/SongDatagrid.js index 42a617a73..c2ace92a8 100644 --- a/ui/src/common/SongDatagrid.js +++ b/ui/src/common/SongDatagrid.js @@ -97,7 +97,7 @@ const ReleaseRow = forwardRef( ) - } + }, ) const DiscSubtitleRow = forwardRef( @@ -141,7 +141,7 @@ const DiscSubtitleRow = forwardRef( ) - } + }, ) export const SongDatagridRow = ({ @@ -156,7 +156,7 @@ export const SongDatagridRow = ({ }) => { const classes = useStyles() const fields = React.Children.toArray(children).filter((c) => - isValidElement(c) + isValidElement(c), ) const [, dragDiscRef] = useDrag( @@ -173,7 +173,7 @@ export const SongDatagridRow = ({ }, options: { dropEffect: 'copy' }, }), - [record] + [record], ) const [, dragSongRef] = useDrag( @@ -182,7 +182,7 @@ export const SongDatagridRow = ({ item: { ids: [record?.mediaFileId || record?.id] }, options: { dropEffect: 'copy' }, }), - [record] + [record], ) if (!record || !record.title) { @@ -251,14 +251,14 @@ const SongDatagridBody = ({ idsToPlay = ids.filter( (id) => data[id].releaseDate === releaseDate && - data[id].discNumber === discNumber + data[id].discNumber === discNumber, ) } else { idsToPlay = ids.filter((id) => data[id].releaseDate === releaseDate) } dispatch(playTracks(data, idsToPlay)) }, - [dispatch, data, ids] + [dispatch, data, ids], ) const firstTracksOfDiscs = useMemo(() => { @@ -280,7 +280,7 @@ const SongDatagridBody = ({ acc.push(id) } return acc - }, []) + }, []), ) if (!showDiscSubtitles || (set.size < 2 && !foundSubtitle)) { set.clear() @@ -304,7 +304,7 @@ const SongDatagridBody = ({ acc.push(id) } return acc - }, []) + }, []), ) if (!showReleaseDivider || set.size < 2) { set.clear() diff --git a/ui/src/common/SongSimpleList.js b/ui/src/common/SongSimpleList.js index 61a2893f7..d398d3455 100644 --- a/ui/src/common/SongSimpleList.js +++ b/ui/src/common/SongSimpleList.js @@ -47,7 +47,7 @@ const useStyles = makeStyles( top: '26px', }, }, - { name: 'RaSongSimpleList' } + { name: 'RaSongSimpleList' }, ) export const SongSimpleList = ({ @@ -108,7 +108,7 @@ export const SongSimpleList = ({ - ) + ), )} ) diff --git a/ui/src/common/ToggleFieldsMenu.js b/ui/src/common/ToggleFieldsMenu.js index f8ee30d0f..32ae51def 100644 --- a/ui/src/common/ToggleFieldsMenu.js +++ b/ui/src/common/ToggleFieldsMenu.js @@ -36,7 +36,7 @@ export const ToggleFieldsMenu = ({ const dispatch = useDispatch() const translate = useTranslate() const toggleableColumns = useSelector( - (state) => state.settings.toggleableFields[resource] + (state) => state.settings.toggleableFields[resource], ) const omittedColumns = useSelector((state) => state.settings.omittedFields[resource]) || [] @@ -58,7 +58,7 @@ export const ToggleFieldsMenu = ({ ...toggleableColumns, [selectedColumn]: !toggleableColumns[selectedColumn], }, - }) + }), ) } @@ -95,7 +95,7 @@ export const ToggleFieldsMenu = ({ {translate(`resources.${resource}.fields.${key}`)} - ) : null + ) : null, )} diff --git a/ui/src/common/Writable.js b/ui/src/common/Writable.js index 27a46f9e0..0dc625460 100644 --- a/ui/src/common/Writable.js +++ b/ui/src/common/Writable.js @@ -15,7 +15,7 @@ export const Writable = (props) => { const { record = {}, children } = props if (isWritable(record.ownerId)) { return Children.map(children, (child) => - isValidElement(child) ? cloneElement(child, props) : child + isValidElement(child) ? cloneElement(child, props) : child, ) } return null diff --git a/ui/src/common/useAlbumsPerPage.js b/ui/src/common/useAlbumsPerPage.js index 6d3edf046..6a02bdeb7 100644 --- a/ui/src/common/useAlbumsPerPage.js +++ b/ui/src/common/useAlbumsPerPage.js @@ -19,7 +19,7 @@ const getPerPageOptions = (width) => { export const useAlbumsPerPage = (width) => { const perPage = useSelector( - (state) => state?.admin.resources?.album?.list?.params?.perPage + (state) => state?.admin.resources?.album?.list?.params?.perPage, ) || getPerPage(width) return [perPage, getPerPageOptions(width)] diff --git a/ui/src/common/useResourceRefresh.js b/ui/src/common/useResourceRefresh.js index 4c2008089..d9f6aee52 100644 --- a/ui/src/common/useResourceRefresh.js +++ b/ui/src/common/useResourceRefresh.js @@ -7,7 +7,7 @@ export const useResourceRefresh = (...visibleResources) => { const refresh = useRefresh() const dataProvider = useDataProvider() const refreshData = useSelector( - (state) => state.activity?.refresh || { lastReceived: lastTime } + (state) => state.activity?.refresh || { lastReceived: lastTime }, ) const { resources, lastReceived } = refreshData diff --git a/ui/src/common/useSelectedFields.js b/ui/src/common/useSelectedFields.js index 07ccc9180..0560de523 100644 --- a/ui/src/common/useSelectedFields.js +++ b/ui/src/common/useSelectedFields.js @@ -12,7 +12,7 @@ export const useSelectedFields = ({ }) => { const dispatch = useDispatch() const resourceFields = useSelector( - (state) => state.settings.toggleableFields + (state) => state.settings.toggleableFields, )?.[resource] const omittedFields = useSelector((state) => state.settings.omittedFields)?.[ resource @@ -81,7 +81,7 @@ useSelectedFields.propTypes = { export const useSetToggleableFields = ( resource, toggleableColumns, - defaultOff = [] + defaultOff = [], ) => { const current = useSelector((state) => state.settings.toggleableFields)?.album const dispatch = useDispatch() @@ -95,7 +95,7 @@ export const useSetToggleableFields = ( ...{ [cur]: true }, } }, {}), - }) + }), ) dispatch(setOmittedFields({ [resource]: defaultOff })) } diff --git a/ui/src/consts.js b/ui/src/consts.js index 20c8bac2b..b0524669e 100644 --- a/ui/src/consts.js +++ b/ui/src/consts.js @@ -16,7 +16,7 @@ DraggableTypes.ALL.push( DraggableTypes.SONG, DraggableTypes.ALBUM, DraggableTypes.DISC, - DraggableTypes.ARTIST + DraggableTypes.ARTIST, ) export const DEFAULT_SHARE_BITRATE = 128 diff --git a/ui/src/dataProvider/httpClient.js b/ui/src/dataProvider/httpClient.js index 0e9f5433a..5ade3c0ce 100644 --- a/ui/src/dataProvider/httpClient.js +++ b/ui/src/dataProvider/httpClient.js @@ -2,7 +2,7 @@ import { fetchUtils } from 'react-admin' import { v4 as uuidv4 } from 'uuid' import { baseUrl } from '../utils' import config from '../config' -import jwtDecode from 'jwt-decode' +import { jwtDecode } from 'jwt-decode' const customAuthorizationHeader = 'X-ND-Authorization' const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' diff --git a/ui/src/dialogs/AboutDialog.test.js b/ui/src/dialogs/AboutDialog.test.js index 389034fec..5c4e2f77f 100644 --- a/ui/src/dialogs/AboutDialog.test.js +++ b/ui/src/dialogs/AboutDialog.test.js @@ -30,7 +30,7 @@ describe('', () => { const link = screen.queryByRole('link') expect(link.href).toBe( - 'https://github.com/navidrome/navidrome/releases/tag/v0.40.0' + 'https://github.com/navidrome/navidrome/releases/tag/v0.40.0', ) expect(link.textContent).toBe('0.40.0') @@ -44,7 +44,7 @@ describe('', () => { const link = screen.queryByRole('link') expect(link.href).toBe( - 'https://github.com/navidrome/navidrome/compare/v0.40.0...300a0292' + 'https://github.com/navidrome/navidrome/compare/v0.40.0...300a0292', ) expect(link.textContent).toBe('0.40.0-SNAPSHOT') diff --git a/ui/src/dialogs/AddToPlaylistDialog.js b/ui/src/dialogs/AddToPlaylistDialog.js index 47098f22f..6033d1031 100644 --- a/ui/src/dialogs/AddToPlaylistDialog.js +++ b/ui/src/dialogs/AddToPlaylistDialog.js @@ -72,7 +72,7 @@ export const AddToPlaylistDialog = () => { const tracks = res.json if (tracks) { const dupSng = tracks.filter((song) => - selectedIds.some((id) => id === song.mediaFileId) + selectedIds.some((id) => id === song.mediaFileId), ) if (dupSng.length) { @@ -128,7 +128,7 @@ export const AddToPlaylistDialog = () => { } const handleSkip = () => { const distinctSongs = selectedIds.filter( - (id) => duplicateIds.indexOf(id) < 0 + (id) => duplicateIds.indexOf(id) < 0, ) value.slice(-1).pop().distinctIds = distinctSongs dispatch(closeDuplicateSongDialog()) diff --git a/ui/src/dialogs/AddToPlaylistDialog.test.js b/ui/src/dialogs/AddToPlaylistDialog.test.js index 0932af306..66f8a6687 100644 --- a/ui/src/dialogs/AddToPlaylistDialog.test.js +++ b/ui/src/dialogs/AddToPlaylistDialog.test.js @@ -59,7 +59,7 @@ const createTestUtils = (mockDataProvider) => > - + , ) jest.mock('../dataProvider', () => ({ @@ -104,7 +104,7 @@ describe('AddToPlaylistDialog', () => { { data: { ids: selectedIds }, filter: { playlist_id: 'sample-id1' }, - } + }, ) }) await waitFor(() => { @@ -114,7 +114,7 @@ describe('AddToPlaylistDialog', () => { { data: { ids: selectedIds }, filter: { playlist_id: 'sample-id2' }, - } + }, ) }) }) @@ -153,7 +153,7 @@ describe('AddToPlaylistDialog', () => { { data: { ids: selectedIds }, filter: { playlist_id: 'created-id1' }, - } + }, ) }) diff --git a/ui/src/dialogs/DownloadMenuDialog.js b/ui/src/dialogs/DownloadMenuDialog.js index 290431598..2104cbcad 100644 --- a/ui/src/dialogs/DownloadMenuDialog.js +++ b/ui/src/dialogs/DownloadMenuDialog.js @@ -14,7 +14,7 @@ import { useTranscodingOptions } from './useTranscodingOptions' const DownloadMenuDialog = () => { const { open, record, recordType } = useSelector( - (state) => state.downloadMenuDialog + (state) => state.downloadMenuDialog, ) const dispatch = useDispatch() const translate = useTranslate() diff --git a/ui/src/dialogs/HelpDialog.js b/ui/src/dialogs/HelpDialog.js index bba473813..adbce99b2 100644 --- a/ui/src/dialogs/HelpDialog.js +++ b/ui/src/dialogs/HelpDialog.js @@ -54,7 +54,7 @@ const HelpTable = (props) => { , - document.body + document.body, ) } diff --git a/ui/src/dialogs/ListenBrainzTokenDialog.js b/ui/src/dialogs/ListenBrainzTokenDialog.js index 571c5af17..8966675ff 100644 --- a/ui/src/dialogs/ListenBrainzTokenDialog.js +++ b/ui/src/dialogs/ListenBrainzTokenDialog.js @@ -58,7 +58,7 @@ export const ListenBrainzTokenDialog = ({ setLinked }) => { event.stopPropagation() }) }, - [dispatch, notify, setLinked, token] + [dispatch, notify, setLinked, token], ) const handleClickClose = (event) => { @@ -74,7 +74,7 @@ export const ListenBrainzTokenDialog = ({ setLinked }) => { handleSave(event) } }, - [token, handleSave] + [token, handleSave], ) return ( diff --git a/ui/src/dialogs/SelectPlaylistInput.js b/ui/src/dialogs/SelectPlaylistInput.js index e35af00fa..372d5f408 100644 --- a/ui/src/dialogs/SelectPlaylistInput.js +++ b/ui/src/dialogs/SelectPlaylistInput.js @@ -25,7 +25,7 @@ export const SelectPlaylistInput = ({ onChange }) => { 'playlist', { page: 1, perPage: -1 }, { field: 'name', order: 'ASC' }, - { smart: false } + { smart: false }, ) const options = diff --git a/ui/src/dialogs/SelectPlaylistInput.test.js b/ui/src/dialogs/SelectPlaylistInput.test.js index 13fab2afb..60daa90cd 100644 --- a/ui/src/dialogs/SelectPlaylistInput.test.js +++ b/ui/src/dialogs/SelectPlaylistInput.test.js @@ -65,7 +65,7 @@ describe('SelectPlaylistInput', () => { > - + , ) await waitFor(() => { diff --git a/ui/src/dialogs/ShareDialog.js b/ui/src/dialogs/ShareDialog.js index 3de15fe32..68156bf58 100644 --- a/ui/src/dialogs/ShareDialog.js +++ b/ui/src/dialogs/ShareDialog.js @@ -33,7 +33,7 @@ export const ShareDialog = () => { const translate = useTranslate() const [description, setDescription] = useState('') const [downloadable, setDownloadable] = useState( - config.defaultDownloadableShare && config.enableDownloads + config.defaultDownloadableShare && config.enableDownloads, ) useEffect(() => { setDescription('') @@ -66,7 +66,7 @@ export const ShareDialog = () => { type: 'warning', multiLine: true, duration: 0, - } + }, ) }) } else prompt(translate('message.shareCopyToClipboard'), url) @@ -75,7 +75,7 @@ export const ShareDialog = () => { notify(translate('ra.page.error') + ': ' + error.message, { type: 'warning', }), - } + }, ) const handleShare = (e) => { diff --git a/ui/src/dialogs/useTranscodingOptions.js b/ui/src/dialogs/useTranscodingOptions.js index 4a0e421d9..9019f0ab0 100644 --- a/ui/src/dialogs/useTranscodingOptions.js +++ b/ui/src/dialogs/useTranscodingOptions.js @@ -20,7 +20,7 @@ export const useTranscodingOptions = () => { page: 1, perPage: 1000, }, - { field: 'name', order: 'ASC' } + { field: 'name', order: 'ASC' }, ) const formatOptions = useMemo( @@ -30,7 +30,7 @@ export const useTranscodingOptions = () => { : Object.values(formats).map((f) => { return { id: f.targetFormat, name: f.name } }), - [formats, loadingFormats] + [formats, loadingFormats], ) const handleOriginal = useCallback( @@ -41,7 +41,7 @@ export const useTranscodingOptions = () => { setMaxBitRate(DEFAULT_SHARE_BITRATE) } }, - [setUseOriginalFormat, setFormat, setMaxBitRate] + [setUseOriginalFormat, setFormat, setMaxBitRate], ) const TranscodingOptionsInput = useMemo(() => { diff --git a/ui/src/eventStream.js b/ui/src/eventStream.js index d5df78971..481739d92 100644 --- a/ui/src/eventStream.js +++ b/ui/src/eventStream.js @@ -30,7 +30,7 @@ const startEventStream = async (dispatchFn) => { newStream.addEventListener('serverStart', eventHandler(dispatchFn)) newStream.addEventListener( 'scanStatus', - throttledEventHandler(dispatchFn) + throttledEventHandler(dispatchFn), ) newStream.addEventListener('refreshResource', eventHandler(dispatchFn)) newStream.addEventListener('keepAlive', eventHandler(dispatchFn)) diff --git a/ui/src/i18n/useGetLanguageChoices.js b/ui/src/i18n/useGetLanguageChoices.js index 0c6773ae6..0c708691f 100644 --- a/ui/src/i18n/useGetLanguageChoices.js +++ b/ui/src/i18n/useGetLanguageChoices.js @@ -6,7 +6,7 @@ const useGetLanguageChoices = () => { 'translation', { page: 1, perPage: -1 }, { field: '', order: '' }, - {} + {}, ) const choices = [{ id: 'en', name: 'English' }] diff --git a/ui/src/layout/AppBar.js b/ui/src/layout/AppBar.js index c72f36bff..47406c4c1 100644 --- a/ui/src/layout/AppBar.js +++ b/ui/src/layout/AppBar.js @@ -31,7 +31,7 @@ const useStyles = makeStyles( }), { name: 'NDAppBar', - } + }, ) const AboutMenuItem = forwardRef(({ onClick, ...rest }, ref) => { @@ -90,7 +90,7 @@ const CustomUserMenu = ({ onClick, ...rest }) => { } return renderSettingsMenuItemLink( userResource, - permissions !== 'admin' ? localStorage.getItem('userId') : null + permissions !== 'admin' ? localStorage.getItem('userId') : null, ) } diff --git a/ui/src/layout/DynamicMenuIcon.test.js b/ui/src/layout/DynamicMenuIcon.test.js index 3d8e238fb..51b0ba635 100644 --- a/ui/src/layout/DynamicMenuIcon.test.js +++ b/ui/src/layout/DynamicMenuIcon.test.js @@ -17,7 +17,7 @@ describe('', () => { render( - + , ) expect(screen.getByTestId('icon')).not.toBeNull() }) @@ -34,7 +34,7 @@ describe('', () => { activeIcon={StarBorderIcon} path={'otherpath'} /> - + , ) expect(screen.getByTestId('icon')).not.toBeNull() }) @@ -51,7 +51,7 @@ describe('', () => { activeIcon={StarBorderIcon} path={'path'} /> - + , ) expect(screen.getByTestId('activeIcon')).not.toBeNull() }) diff --git a/ui/src/layout/Login.js b/ui/src/layout/Login.js index a197b495b..aac04faf4 100644 --- a/ui/src/layout/Login.js +++ b/ui/src/layout/Login.js @@ -82,7 +82,7 @@ const useStyles = makeStyles( textDecoration: 'none', }, }), - { name: 'NDLogin' } + { name: 'NDLogin' }, ) const renderInput = ({ @@ -263,14 +263,14 @@ const Login = ({ location }) => { typeof error === 'string' ? error : typeof error === 'undefined' || !error.message - ? 'ra.auth.sign_in_error' - : error.message, - 'warning' + ? 'ra.auth.sign_in_error' + : error.message, + 'warning', ) - } + }, ) }, - [dispatch, login, notify, setLoading, location] + [dispatch, login, notify, setLoading, location], ) const validateLogin = useCallback( @@ -284,7 +284,7 @@ const Login = ({ location }) => { } return errors }, - [translate] + [translate], ) const validateSignup = useCallback( @@ -302,7 +302,7 @@ const Login = ({ location }) => { } return errors }, - [translate, validateLogin] + [translate, validateLogin], ) if (config.firstTime) { @@ -348,7 +348,7 @@ const LoginWithTheme = (props) => { }) .catch((e) => { throw new Error( - 'Cannot load language "' + config.defaultLanguage + '": ' + e + 'Cannot load language "' + config.defaultLanguage + '": ' + e, ) }) } diff --git a/ui/src/layout/Menu.js b/ui/src/layout/Menu.js index 7ed21ae8f..50670a168 100644 --- a/ui/src/layout/Menu.js +++ b/ui/src/layout/Menu.js @@ -121,7 +121,7 @@ const Menu = ({ dense = false }) => { dense={dense} > {Object.keys(albumLists).map((type) => - renderAlbumMenuItemLink(type, albumLists[type]) + renderAlbumMenuItemLink(type, albumLists[type]), )} {resources.filter(subItems(undefined)).map(renderResourceMenuItemLink)} diff --git a/ui/src/layout/PlaylistsSubMenu.js b/ui/src/layout/PlaylistsSubMenu.js index e16aa7e18..a9f70b875 100644 --- a/ui/src/layout/PlaylistsSubMenu.js +++ b/ui/src/layout/PlaylistsSubMenu.js @@ -93,7 +93,7 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { const onPlaylistConfig = useCallback( () => history.push('/playlist'), - [history] + [history], ) return ( diff --git a/ui/src/layout/SubMenu.js b/ui/src/layout/SubMenu.js index d06d61bbd..418f4c651 100644 --- a/ui/src/layout/SubMenu.js +++ b/ui/src/layout/SubMenu.js @@ -42,7 +42,7 @@ const useStyles = makeStyles( }), { name: 'NDSubMenu', - } + }, ) const SubMenu = ({ diff --git a/ui/src/layout/UserMenu.js b/ui/src/layout/UserMenu.js index 980a82b32..c7a3deaf4 100644 --- a/ui/src/layout/UserMenu.js +++ b/ui/src/layout/UserMenu.js @@ -118,7 +118,7 @@ const UserMenu = (props) => { ? cloneElement(menuItem, { onClick: handleClose, }) - : null + : null, )} {!config.auth && logout} diff --git a/ui/src/personal/LastfmScrobbleToggle.js b/ui/src/personal/LastfmScrobbleToggle.js index 92faf307f..84532de36 100644 --- a/ui/src/personal/LastfmScrobbleToggle.js +++ b/ui/src/personal/LastfmScrobbleToggle.js @@ -20,11 +20,11 @@ const Progress = (props) => { useEffect(() => { const callbackEndpoint = baseUrl( - `/api/lastfm/link/callback?uid=${localStorage.getItem('userId')}` + `/api/lastfm/link/callback?uid=${localStorage.getItem('userId')}`, ) const callbackUrl = `${window.location.origin}${callbackEndpoint}` openedTab.current = openInNewTab( - `https://www.last.fm/api/auth/?api_key=${config.lastFMApiKey}&cb=${callbackUrl}` + `https://www.last.fm/api/auth/?api_key=${config.lastFMApiKey}&cb=${callbackUrl}`, ) }, []) diff --git a/ui/src/personal/SelectTheme.js b/ui/src/personal/SelectTheme.js index e69d996ff..6ec39b09b 100644 --- a/ui/src/personal/SelectTheme.js +++ b/ui/src/personal/SelectTheme.js @@ -21,7 +21,7 @@ export const SelectTheme = (props) => { themeChoices.push( ...Object.keys(themes).map((key) => { return { id: key, name: themes[key].themeName } - }) + }), ) themeChoices.push({ id: helpKey, diff --git a/ui/src/playlist/PlaylistActions.js b/ui/src/playlist/PlaylistActions.js index d98596c3b..1e7bef9b8 100644 --- a/ui/src/playlist/PlaylistActions.js +++ b/ui/src/playlist/PlaylistActions.js @@ -59,7 +59,7 @@ const PlaylistActions = ({ className, ids, data, record, ...rest }) => { .then((res) => { const data = res.data.reduce( (acc, curr) => ({ ...acc, [curr.id]: curr }), - {} + {}, ) dispatch(action(data)) }) @@ -67,7 +67,7 @@ const PlaylistActions = ({ className, ids, data, record, ...rest }) => { notify('ra.page.error', 'warning') }) }, - [dataProvider, dispatch, record, data, ids, notify] + [dataProvider, dispatch, record, data, ids, notify], ) const handlePlay = React.useCallback(() => { @@ -108,7 +108,7 @@ const PlaylistActions = ({ className, ids, data, record, ...rest }) => { link.click() link.parentNode.removeChild(link) }), - [record] + [record], ) return ( diff --git a/ui/src/playlist/PlaylistDetails.js b/ui/src/playlist/PlaylistDetails.js index 954f0c841..4e80b666f 100644 --- a/ui/src/playlist/PlaylistDetails.js +++ b/ui/src/playlist/PlaylistDetails.js @@ -38,7 +38,7 @@ const useStyles = makeStyles( }), { name: 'NDPlaylistDetails', - } + }, ) const PlaylistDetails = (props) => { diff --git a/ui/src/playlist/PlaylistList.js b/ui/src/playlist/PlaylistList.js index 9f5667d7b..71a8c75db 100644 --- a/ui/src/playlist/PlaylistList.js +++ b/ui/src/playlist/PlaylistList.js @@ -65,7 +65,7 @@ const TogglePublicInput = ({ resource, source }) => { console.log(error) notify('ra.page.error', 'warning') }, - } + }, ) const handleClick = (e) => { @@ -108,7 +108,7 @@ const PlaylistList = (props) => { ), comment: , }), - [isDesktop, isXsmall] + [isDesktop, isXsmall], ) const columns = useSelectedFields({ diff --git a/ui/src/playlist/PlaylistShow.js b/ui/src/playlist/PlaylistShow.js index 66fb383a0..ca300bbd0 100644 --- a/ui/src/playlist/PlaylistShow.js +++ b/ui/src/playlist/PlaylistShow.js @@ -20,7 +20,7 @@ const useStyles = makeStyles( }), { name: 'NDPlaylistShow', - } + }, ) const PlaylistShowLayout = (props) => { diff --git a/ui/src/playlist/PlaylistSongs.js b/ui/src/playlist/PlaylistSongs.js index a968eefa6..f249c9793 100644 --- a/ui/src/playlist/PlaylistSongs.js +++ b/ui/src/playlist/PlaylistSongs.js @@ -72,7 +72,7 @@ const useStyles = makeStyles( visibility: (props) => (props.isDesktop ? 'hidden' : 'visible'), }, }), - { name: 'RaList' } + { name: 'RaList' }, ) const ReorderableList = ({ readOnly, children, ...rest }) => { @@ -99,7 +99,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { refetch() } }, - [playlistId, refetch] + [playlistId, refetch], ) const reorder = useCallback( @@ -117,7 +117,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { notify('ra.page.error', 'warning') }) }, - [dataProvider, notify, refetch] + [dataProvider, notify, refetch], ) const handleDragEnd = useCallback( @@ -126,7 +126,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { const fromId = ids[from] reorder(playlistId, fromId, toId) }, - [playlistId, reorder, ids] + [playlistId, reorder, ids], ) const toggleableFields = useMemo(() => { diff --git a/ui/src/radio/StreamField.js b/ui/src/radio/StreamField.js index 302a7f199..2327f3c1a 100644 --- a/ui/src/radio/StreamField.js +++ b/ui/src/radio/StreamField.js @@ -26,7 +26,7 @@ export const StreamField = (props) => { evt.preventDefault() dispatch(setTrack(await songFromRadio(record))) }, - [dispatch, record] + [dispatch, record], ) return ( diff --git a/ui/src/reducers/albumView.js b/ui/src/reducers/albumView.js index ec47a1d5e..ef6833471 100644 --- a/ui/src/reducers/albumView.js +++ b/ui/src/reducers/albumView.js @@ -4,7 +4,7 @@ export const albumViewReducer = ( previousState = { grid: true, }, - payload + payload, ) => { const { type } = payload switch (type) { diff --git a/ui/src/reducers/dialogReducer.js b/ui/src/reducers/dialogReducer.js index f80cb7350..e43f46b6f 100644 --- a/ui/src/reducers/dialogReducer.js +++ b/ui/src/reducers/dialogReducer.js @@ -24,7 +24,7 @@ export const shareDialogReducer = ( resource: '', name: '', }, - payload + payload, ) => { const { type, ids, resource, name, label } = payload switch (type) { @@ -52,7 +52,7 @@ export const addToPlaylistDialogReducer = ( open: false, duplicateSong: false, }, - payload + payload, ) => { const { type } = payload switch (type) { @@ -82,7 +82,7 @@ export const downloadMenuDialogReducer = ( previousState = { open: false, }, - payload + payload, ) => { const { type } = payload switch (type) { @@ -125,7 +125,7 @@ export const expandInfoDialogReducer = ( previousState = { open: false, }, - payload + payload, ) => { const { type } = payload switch (type) { @@ -149,7 +149,7 @@ export const listenBrainzTokenDialogReducer = ( previousState = { open: false, }, - payload + payload, ) => { const { type } = payload switch (type) { diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index 1a29e2c57..3600ec82c 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -82,7 +82,7 @@ const mapToAudioLists = (item) => { updatedAt: item.updatedAt, album: item.album, }, - 300 + 300, ), } } @@ -167,7 +167,7 @@ const reduceSyncQueue = (state, { data: { audioInfo, audioLists } }) => { const reduceCurrent = (state, { data }) => { const current = data.ended ? {} : data const savedPlayIndex = state.queue.findIndex( - (item) => item.uuid === current.uuid + (item) => item.uuid === current.uuid, ) return { ...state, diff --git a/ui/src/reducers/replayGainReducer.js b/ui/src/reducers/replayGainReducer.js index 49d9e0ba0..6d51db2bf 100644 --- a/ui/src/reducers/replayGainReducer.js +++ b/ui/src/reducers/replayGainReducer.js @@ -18,7 +18,7 @@ const initialState = { export const replayGainReducer = ( previousState = initialState, - { type, payload } + { type, payload }, ) => { switch (type) { case CHANGE_GAIN: { diff --git a/ui/src/reducers/themeReducer.js b/ui/src/reducers/themeReducer.js index ef5ccc7ce..2a5d5bac6 100644 --- a/ui/src/reducers/themeReducer.js +++ b/ui/src/reducers/themeReducer.js @@ -5,14 +5,14 @@ import themes from '../themes' const defaultTheme = () => { return ( Object.keys(themes).find( - (t) => themes[t].themeName === config.defaultTheme + (t) => themes[t].themeName === config.defaultTheme, ) || 'DarkTheme' ) } export const themeReducer = ( previousState = defaultTheme(), - { type, payload } + { type, payload }, ) => { if (type === CHANGE_THEME) { return payload diff --git a/ui/src/serviceWorker.js b/ui/src/serviceWorker.js index 420ee107e..deb3b798a 100644 --- a/ui/src/serviceWorker.js +++ b/ui/src/serviceWorker.js @@ -16,8 +16,8 @@ const isLocalhost = Boolean( window.location.hostname === '[::1]' || // 127.0.0.0/8 are considered localhost for IPv4. window.location.hostname.match( - /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ - ) + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/, + ), ) export function register(config) { @@ -43,7 +43,7 @@ export function register(config) { navigator.serviceWorker.ready.then(() => { console.log( 'This web app is being served cache-first by a service ' + - 'worker. To learn more, visit https://bit.ly/CRA-PWA' + 'worker. To learn more, visit https://bit.ly/CRA-PWA', ) }) } else { @@ -71,7 +71,7 @@ function registerValidSW(swUrl, config) { // content until all client tabs are closed. console.log( 'New content is available and will be used when all ' + - 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' + 'tabs for this page are closed. See https://bit.ly/CRA-PWA.', ) // Execute callback @@ -123,7 +123,7 @@ function checkValidServiceWorker(swUrl, config) { }) .catch(() => { console.log( - 'No internet connection found. App is running in offline mode.' + 'No internet connection found. App is running in offline mode.', ) }) } diff --git a/ui/src/share/ShareList.js b/ui/src/share/ShareList.js index 0219e49db..b7b9edb5a 100644 --- a/ui/src/share/ShareList.js +++ b/ui/src/share/ShareList.js @@ -48,7 +48,7 @@ const ShareList = (props) => { type: 'warning', multiLine: true, duration: 0, - } + }, ) }) } else prompt(translate('message.shareCopyToClipboard'), url) diff --git a/ui/src/store/createAdminStore.js b/ui/src/store/createAdminStore.js index ba8d40771..82545f634 100644 --- a/ui/src/store/createAdminStore.js +++ b/ui/src/store/createAdminStore.js @@ -48,7 +48,9 @@ const createAdminStore = ({ const store = createStore( resettableAppReducer, persistedState, - composeEnhancers(applyMiddleware(sagaMiddleware, routerMiddleware(history))) + composeEnhancers( + applyMiddleware(sagaMiddleware, routerMiddleware(history)), + ), ) store.subscribe( @@ -61,7 +63,7 @@ const createAdminStore = ({ settings: state.settings, }) }), - 1000 + 1000, ) sagaMiddleware.run(saga) diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 1616a70d5..674135a1f 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -27,7 +27,7 @@ const scrobble = (id, time, submission = true) => url('scrobble', id, { ...(submission && time && { time }), submission, - }) + }), ) const nowPlaying = (id) => scrobble(id, null, false) @@ -74,7 +74,7 @@ const streamUrl = (id, options) => { url('stream', id, { ts: true, ...options, - }) + }), ) } diff --git a/ui/src/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js index 61138d7b4..9793d1e15 100644 --- a/ui/src/themes/useCurrentTheme.js +++ b/ui/src/themes/useCurrentTheme.js @@ -14,7 +14,7 @@ const useCurrentTheme = () => { const themeName = Object.keys(themes).find((t) => t === state.theme) || Object.keys(themes).find( - (t) => themes[t].themeName === config.defaultTheme + (t) => themes[t].themeName === config.defaultTheme, ) || 'DarkTheme' return themes[themeName] diff --git a/ui/src/user/DeleteUserButton.js b/ui/src/user/DeleteUserButton.js index 11b8e7473..9bf133fb4 100644 --- a/ui/src/user/DeleteUserButton.js +++ b/ui/src/user/DeleteUserButton.js @@ -24,7 +24,7 @@ const useStyles = makeStyles( }, }, }), - { name: 'RaDeleteWithConfirmButton' } + { name: 'RaDeleteWithConfirmButton' }, ) const DeleteUserButton = (props) => { diff --git a/ui/src/user/UserCreate.js b/ui/src/user/UserCreate.js index 5d817dd01..42ea1ce94 100644 --- a/ui/src/user/UserCreate.js +++ b/ui/src/user/UserCreate.js @@ -33,7 +33,7 @@ const UserCreate = (props) => { resource: 'user', payload: { data: values }, }, - { returnPromise: true } + { returnPromise: true }, ) notify('resources.user.notifications.created', 'info', { smart_count: 1, @@ -45,7 +45,7 @@ const UserCreate = (props) => { } } }, - [mutate, notify, redirect] + [mutate, notify, redirect], ) return ( diff --git a/ui/src/user/UserEdit.js b/ui/src/user/UserEdit.js index 81883f0ce..9b3013961 100644 --- a/ui/src/user/UserEdit.js +++ b/ui/src/user/UserEdit.js @@ -85,7 +85,7 @@ const UserEdit = (props) => { resource: 'user', payload: { id: values.id, data: values }, }, - { returnPromise: true } + { returnPromise: true }, ) notify('resources.user.notifications.updated', 'info', { smart_count: 1, @@ -97,7 +97,7 @@ const UserEdit = (props) => { } } }, - [mutate, notify, permissions, redirect, refresh] + [mutate, notify, permissions, redirect, refresh], ) return ( diff --git a/ui/src/utils/formatters.test.js b/ui/src/utils/formatters.test.js index b3e898edc..59538ec32 100644 --- a/ui/src/utils/formatters.test.js +++ b/ui/src/utils/formatters.test.js @@ -25,7 +25,7 @@ describe('formatDuration', () => { it('formats days, hours and minutes', () => { expect(formatDuration(hour + minute + 1)).toEqual('01:01:01') expect(formatDuration(3 * day + 3 * hour + 7 * minute)).toEqual( - '3:03:07:00' + '3:03:07:00', ) expect(formatDuration(day)).toEqual('1:00:00:00') expect(formatDuration(day + minute + 0.6)).toEqual('1:00:01:01') diff --git a/ui/src/utils/intersperse.js b/ui/src/utils/intersperse.js index 01ff7f87c..ce48f5e86 100644 --- a/ui/src/utils/intersperse.js +++ b/ui/src/utils/intersperse.js @@ -15,6 +15,6 @@ export const intersperse = (arr, sep) => { function (xs, x, i) { return xs.concat([sep, x]) }, - [arr[0]] + [arr[0]], ) } diff --git a/ui/src/utils/urls.js b/ui/src/utils/urls.js index fe6c19c6f..e9173f089 100644 --- a/ui/src/utils/urls.js +++ b/ui/src/utils/urls.js @@ -10,7 +10,7 @@ export const baseUrl = (path) => { export const shareUrl = (id) => { const url = new URL( baseUrl(config.publicBaseUrl + '/' + id), - window.location.href + window.location.href, ) return url.href } diff --git a/utils/number/number.go b/utils/number/number.go index cc8aa7112..1ae4b9c59 100644 --- a/utils/number/number.go +++ b/utils/number/number.go @@ -7,6 +7,7 @@ import ( "golang.org/x/exp/constraints" ) +// TODO Remove on Go 1.22, in favor of builtin `min` function. func Min[T constraints.Ordered](vs ...T) T { if len(vs) == 0 { var zero T @@ -21,6 +22,7 @@ func Min[T constraints.Ordered](vs ...T) T { return min } +// TODO Remove on Go 1.22, in favor of builtin `max` function. func Max[T constraints.Ordered](vs ...T) T { if len(vs) == 0 { var zero T diff --git a/utils/req/req.go b/utils/req/req.go new file mode 100644 index 000000000..54cb7e5fc --- /dev/null +++ b/utils/req/req.go @@ -0,0 +1,154 @@ +package req + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils" +) + +type Values struct { + *http.Request +} + +func Params(r *http.Request) *Values { + return &Values{r} +} + +var ( + ErrMissingParam = errors.New("missing parameter") + ErrInvalidParam = errors.New("invalid parameter") +) + +func newError(err error, param string) error { + return fmt.Errorf("%w: '%s'", err, param) +} +func (r *Values) String(param string) (string, error) { + v := r.URL.Query().Get(param) + if v == "" { + return "", newError(ErrMissingParam, param) + } + return v, nil +} + +func (r *Values) StringOr(param, def string) string { + v, _ := r.String(param) + if v == "" { + return def + } + return v +} + +func (r *Values) Strings(param string) ([]string, error) { + values := r.URL.Query()[param] + if len(values) == 0 { + return nil, newError(ErrMissingParam, param) + } + return values, nil +} + +func (r *Values) TimeOr(param string, def time.Time) time.Time { + v, _ := r.String(param) + if v == "" || v == "-1" { + return def + } + value, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return def + } + t := utils.ToTime(value) + if t.Before(time.Date(1970, time.January, 2, 0, 0, 0, 0, time.UTC)) { + return def + } + return t +} + +func (r *Values) Times(param string) ([]time.Time, error) { + pStr, err := r.Strings(param) + if err != nil { + return nil, err + } + times := make([]time.Time, len(pStr)) + for i, t := range pStr { + ti, err := strconv.ParseInt(t, 10, 64) + if err != nil { + log.Warn(r.Context(), "Ignoring invalid time param", "time", t, err) + times[i] = time.Now() + continue + } + times[i] = utils.ToTime(ti) + } + return times, nil +} + +func (r *Values) Int64(param string) (int64, error) { + v, err := r.String(param) + if err != nil { + return 0, err + } + value, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("%w '%s': expected integer, got '%s'", ErrInvalidParam, param, v) + } + return value, nil +} + +func (r *Values) Int(param string) (int, error) { + v, err := r.Int64(param) + if err != nil { + return 0, err + } + return int(v), nil +} + +func (r *Values) IntOr(param string, def int) int { + v, err := r.Int64(param) + if err != nil { + return def + } + return int(v) +} + +func (r *Values) Int64Or(param string, def int64) int64 { + v, err := r.Int64(param) + if err != nil { + return def + } + return v +} + +func (r *Values) Ints(param string) ([]int, error) { + pStr, err := r.Strings(param) + if err != nil { + return nil, err + } + ints := make([]int, 0, len(pStr)) + for _, s := range pStr { + i, err := strconv.ParseInt(s, 10, 64) + if err == nil { + ints = append(ints, int(i)) + } + } + return ints, nil +} + +func (r *Values) Bool(param string) (bool, error) { + v, err := r.String(param) + if err != nil { + return false, err + } + return strings.Contains("/true/on/1/", "/"+strings.ToLower(v)+"/"), nil +} + +func (r *Values) BoolOr(param string, def bool) bool { + v, err := r.Bool(param) + if err != nil { + return def + } + return v +} diff --git a/utils/req/req_test.go b/utils/req/req_test.go new file mode 100644 index 000000000..5e305c413 --- /dev/null +++ b/utils/req/req_test.go @@ -0,0 +1,223 @@ +package req_test + +import ( + "fmt" + "net/http/httptest" + "testing" + "time" + + "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/req" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestUtils(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Request Helpers Suite") +} + +var _ = Describe("Request Helpers", func() { + var r *req.Values + + Describe("ParamString", func() { + BeforeEach(func() { + r = req.Params(httptest.NewRequest("GET", "/ping?a=123", nil)) + }) + + It("returns param as string", func() { + Expect(r.String("a")).To(Equal("123")) + }) + + It("returns empty string if param does not exist", func() { + v, err := r.String("NON_EXISTENT_PARAM") + Expect(err).To(MatchError(req.ErrMissingParam)) + Expect(err.Error()).To(ContainSubstring("NON_EXISTENT_PARAM")) + Expect(v).To(BeEmpty()) + }) + }) + + Describe("ParamStringDefault", func() { + BeforeEach(func() { + r = req.Params(httptest.NewRequest("GET", "/ping?a=123", nil)) + }) + + It("returns param as string", func() { + Expect(r.StringOr("a", "default_value")).To(Equal("123")) + }) + + It("returns default string if param does not exist", func() { + Expect(r.StringOr("xx", "default_value")).To(Equal("default_value")) + }) + }) + + Describe("ParamStrings", func() { + BeforeEach(func() { + r = req.Params(httptest.NewRequest("GET", "/ping?a=123&a=456", nil)) + }) + + It("returns all param occurrences as []string", func() { + Expect(r.Strings("a")).To(Equal([]string{"123", "456"})) + }) + + It("returns empty array if param does not exist", func() { + v, err := r.Strings("xx") + Expect(err).To(MatchError(req.ErrMissingParam)) + Expect(v).To(BeEmpty()) + }) + }) + + Describe("ParamTime", func() { + d := time.Date(2002, 8, 9, 12, 11, 13, 1000000, time.Local) + t := utils.ToMillis(d) + now := time.Now() + BeforeEach(func() { + r = req.Params(httptest.NewRequest("GET", fmt.Sprintf("/ping?t=%d&inv=abc", t), nil)) + }) + + It("returns parsed time", func() { + Expect(r.TimeOr("t", now)).To(Equal(d)) + }) + + It("returns default time if param does not exist", func() { + Expect(r.TimeOr("xx", now)).To(Equal(now)) + }) + + It("returns default time if param is an invalid timestamp", func() { + Expect(r.TimeOr("inv", now)).To(Equal(now)) + }) + }) + + Describe("ParamTimes", func() { + d1 := time.Date(2002, 8, 9, 12, 11, 13, 1000000, time.Local) + d2 := time.Date(2002, 8, 9, 12, 13, 56, 0000000, time.Local) + t1 := utils.ToMillis(d1) + t2 := utils.ToMillis(d2) + BeforeEach(func() { + r = req.Params(httptest.NewRequest("GET", fmt.Sprintf("/ping?t=%d&t=%d", t1, t2), nil)) + }) + + It("returns all param occurrences as []time.Time", func() { + Expect(r.Times("t")).To(Equal([]time.Time{d1, d2})) + }) + + It("returns empty string if param does not exist", func() { + v, err := r.Times("xx") + Expect(err).To(MatchError(req.ErrMissingParam)) + Expect(v).To(BeEmpty()) + }) + + It("returns current time as default if param is invalid", func() { + now := time.Now() + r = req.Params(httptest.NewRequest("GET", "/ping?t=null", nil)) + times, err := r.Times("t") + Expect(err).ToNot(HaveOccurred()) + Expect(times).To(HaveLen(1)) + Expect(times[0]).To(BeTemporally(">=", now)) + }) + }) + + Describe("ParamInt", func() { + BeforeEach(func() { + r = req.Params(httptest.NewRequest("GET", "/ping?i=123&inv=123.45", nil)) + }) + Context("int", func() { + It("returns parsed int", func() { + Expect(r.IntOr("i", 999)).To(Equal(123)) + }) + + It("returns default value if param does not exist", func() { + Expect(r.IntOr("xx", 999)).To(Equal(999)) + }) + + It("returns default value if param is an invalid int", func() { + Expect(r.IntOr("inv", 999)).To(Equal(999)) + }) + + It("returns error if param is an invalid int", func() { + _, err := r.Int("inv") + Expect(err).To(MatchError(req.ErrInvalidParam)) + }) + }) + Context("int64", func() { + It("returns parsed int64", func() { + Expect(r.Int64Or("i", 999)).To(Equal(int64(123))) + }) + + It("returns default value if param does not exist", func() { + Expect(r.Int64Or("xx", 999)).To(Equal(int64(999))) + }) + + It("returns default value if param is an invalid int", func() { + Expect(r.Int64Or("inv", 999)).To(Equal(int64(999))) + }) + + It("returns error if param is an invalid int", func() { + _, err := r.Int64("inv") + Expect(err).To(MatchError(req.ErrInvalidParam)) + }) + }) + }) + + Describe("ParamInts", func() { + BeforeEach(func() { + r = req.Params(httptest.NewRequest("GET", "/ping?i=123&i=456", nil)) + }) + + It("returns array of occurrences found", func() { + Expect(r.Ints("i")).To(Equal([]int{123, 456})) + }) + + It("returns empty array if param does not exist", func() { + v, err := r.Ints("xx") + Expect(err).To(MatchError(req.ErrMissingParam)) + Expect(v).To(BeEmpty()) + }) + }) + + Describe("ParamBool", func() { + Context("value is true", func() { + BeforeEach(func() { + r = req.Params(httptest.NewRequest("GET", "/ping?b=true&c=on&d=1&e=True", nil)) + }) + + It("parses 'true'", func() { + Expect(r.BoolOr("b", false)).To(BeTrue()) + }) + + It("parses 'on'", func() { + Expect(r.BoolOr("c", false)).To(BeTrue()) + }) + + It("parses '1'", func() { + Expect(r.BoolOr("d", false)).To(BeTrue()) + }) + + It("parses 'True'", func() { + Expect(r.BoolOr("e", false)).To(BeTrue()) + }) + }) + + Context("value is false", func() { + BeforeEach(func() { + r = req.Params(httptest.NewRequest("GET", "/ping?b=false&c=off&d=0", nil)) + }) + + It("parses 'false'", func() { + Expect(r.BoolOr("b", true)).To(BeFalse()) + }) + + It("parses 'off'", func() { + Expect(r.BoolOr("c", true)).To(BeFalse()) + }) + + It("parses '0'", func() { + Expect(r.BoolOr("d", true)).To(BeFalse()) + }) + + It("returns default value if param does not exist", func() { + Expect(r.BoolOr("xx", true)).To(BeTrue()) + }) + }) + }) +}) diff --git a/utils/request_helpers.go b/utils/request_helpers.go deleted file mode 100644 index 104260de4..000000000 --- a/utils/request_helpers.go +++ /dev/null @@ -1,90 +0,0 @@ -package utils - -import ( - "net/http" - "strconv" - "strings" - "time" - - "github.com/navidrome/navidrome/log" - "golang.org/x/exp/constraints" -) - -func ParamString(r *http.Request, param string) string { - return r.URL.Query().Get(param) -} - -func ParamStringDefault(r *http.Request, param, def string) string { - v := ParamString(r, param) - if v == "" { - return def - } - return v -} - -func ParamStrings(r *http.Request, param string) []string { - return r.URL.Query()[param] -} - -func ParamTimes(r *http.Request, param string) []time.Time { - pStr := ParamStrings(r, param) - times := make([]time.Time, len(pStr)) - for i, t := range pStr { - ti, err := strconv.ParseInt(t, 10, 64) - if err != nil { - log.Warn(r.Context(), "Ignoring invalid time param", "time", t, err) - times[i] = time.Now() - continue - } - times[i] = ToTime(ti) - } - return times -} - -func ParamTime(r *http.Request, param string, def time.Time) time.Time { - v := ParamString(r, param) - if v == "" || v == "-1" { - return def - } - value, err := strconv.ParseInt(v, 10, 64) - if err != nil { - return def - } - t := ToTime(value) - if t.Before(time.Date(1970, time.January, 2, 0, 0, 0, 0, time.UTC)) { - return def - } - return t -} - -func ParamInt[T constraints.Integer](r *http.Request, param string, def T) T { - v := ParamString(r, param) - if v == "" { - return def - } - value, err := strconv.ParseInt(v, 10, 64) - if err != nil { - return def - } - return T(value) -} - -func ParamInts(r *http.Request, param string) []int { - pStr := ParamStrings(r, param) - ints := make([]int, 0, len(pStr)) - for _, s := range pStr { - i, err := strconv.ParseInt(s, 10, 32) - if err == nil { - ints = append(ints, int(i)) - } - } - return ints -} - -func ParamBool(r *http.Request, param string, def bool) bool { - p := strings.ToLower(ParamString(r, param)) - if p == "" { - return def - } - return strings.Contains("/true/on/1/", "/"+p+"/") -} diff --git a/utils/request_helpers_test.go b/utils/request_helpers_test.go deleted file mode 100644 index a7a82911d..000000000 --- a/utils/request_helpers_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package utils - -import ( - "fmt" - "net/http" - "net/http/httptest" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Request Helpers", func() { - var r *http.Request - - Describe("ParamString", func() { - BeforeEach(func() { - r = httptest.NewRequest("GET", "/ping?a=123", nil) - }) - - It("returns empty string if param does not exist", func() { - Expect(ParamString(r, "xx")).To(Equal("")) - }) - - It("returns param as string", func() { - Expect(ParamString(r, "a")).To(Equal("123")) - }) - }) - - Describe("ParamStringDefault", func() { - BeforeEach(func() { - r = httptest.NewRequest("GET", "/ping?a=123", nil) - }) - - It("returns default string if param does not exist", func() { - Expect(ParamStringDefault(r, "xx", "default_value")).To(Equal("default_value")) - }) - - It("returns param as string", func() { - Expect(ParamStringDefault(r, "a", "default_value")).To(Equal("123")) - }) - }) - - Describe("ParamStrings", func() { - BeforeEach(func() { - r = httptest.NewRequest("GET", "/ping?a=123&a=456", nil) - }) - - It("returns empty array if param does not exist", func() { - Expect(ParamStrings(r, "xx")).To(BeEmpty()) - }) - - It("returns all param occurrences as []string", func() { - Expect(ParamStrings(r, "a")).To(Equal([]string{"123", "456"})) - }) - }) - - Describe("ParamTime", func() { - d := time.Date(2002, 8, 9, 12, 11, 13, 1000000, time.Local) - t := ToMillis(d) - now := time.Now() - BeforeEach(func() { - r = httptest.NewRequest("GET", fmt.Sprintf("/ping?t=%d&inv=abc", t), nil) - }) - - It("returns default time if param does not exist", func() { - Expect(ParamTime(r, "xx", now)).To(Equal(now)) - }) - - It("returns default time if param is an invalid timestamp", func() { - Expect(ParamTime(r, "inv", now)).To(Equal(now)) - }) - - It("returns parsed time", func() { - Expect(ParamTime(r, "t", now)).To(Equal(d)) - }) - }) - - Describe("ParamTimes", func() { - d1 := time.Date(2002, 8, 9, 12, 11, 13, 1000000, time.Local) - d2 := time.Date(2002, 8, 9, 12, 13, 56, 0000000, time.Local) - t1 := ToMillis(d1) - t2 := ToMillis(d2) - BeforeEach(func() { - r = httptest.NewRequest("GET", fmt.Sprintf("/ping?t=%d&t=%d", t1, t2), nil) - }) - - It("returns empty string if param does not exist", func() { - Expect(ParamTimes(r, "xx")).To(BeEmpty()) - }) - - It("returns all param occurrences as []time.Time", func() { - Expect(ParamTimes(r, "t")).To(Equal([]time.Time{d1, d2})) - }) - It("returns current time as default if param is invalid", func() { - now := time.Now() - r = httptest.NewRequest("GET", "/ping?t=null", nil) - times := ParamTimes(r, "t") - Expect(times).To(HaveLen(1)) - Expect(times[0]).To(BeTemporally(">=", now)) - }) - }) - - Describe("ParamInt", func() { - BeforeEach(func() { - r = httptest.NewRequest("GET", "/ping?i=123&inv=123.45", nil) - }) - Context("int", func() { - It("returns default value if param does not exist", func() { - Expect(ParamInt(r, "xx", 999)).To(Equal(999)) - }) - - It("returns default value if param is an invalid int", func() { - Expect(ParamInt(r, "inv", 999)).To(Equal(999)) - }) - - It("returns parsed time", func() { - Expect(ParamInt(r, "i", 999)).To(Equal(123)) - }) - }) - Context("int64", func() { - It("returns default value if param does not exist", func() { - Expect(ParamInt(r, "xx", int64(999))).To(Equal(int64(999))) - }) - - It("returns default value if param is an invalid int", func() { - Expect(ParamInt(r, "inv", int64(999))).To(Equal(int64(999))) - }) - - It("returns parsed time", func() { - Expect(ParamInt(r, "i", int64(999))).To(Equal(int64(123))) - }) - - }) - }) - - Describe("ParamInts", func() { - BeforeEach(func() { - r = httptest.NewRequest("GET", "/ping?i=123&i=456", nil) - }) - - It("returns empty array if param does not exist", func() { - Expect(ParamInts(r, "xx")).To(BeEmpty()) - }) - - It("returns array of occurrences found", func() { - Expect(ParamInts(r, "i")).To(Equal([]int{123, 456})) - }) - }) - - Describe("ParamBool", func() { - Context("value is true", func() { - BeforeEach(func() { - r = httptest.NewRequest("GET", "/ping?b=true&c=on&d=1&e=True", nil) - }) - - It("parses 'true'", func() { - Expect(ParamBool(r, "b", false)).To(BeTrue()) - }) - - It("parses 'on'", func() { - Expect(ParamBool(r, "c", false)).To(BeTrue()) - }) - - It("parses '1'", func() { - Expect(ParamBool(r, "d", false)).To(BeTrue()) - }) - - It("parses 'True'", func() { - Expect(ParamBool(r, "e", false)).To(BeTrue()) - }) - }) - - Context("value is false", func() { - BeforeEach(func() { - r = httptest.NewRequest("GET", "/ping?b=false&c=off&d=0", nil) - }) - - It("returns default value if param does not exist", func() { - Expect(ParamBool(r, "xx", true)).To(BeTrue()) - }) - - It("parses 'false'", func() { - Expect(ParamBool(r, "b", true)).To(BeFalse()) - }) - - It("parses 'off'", func() { - Expect(ParamBool(r, "c", true)).To(BeFalse()) - }) - - It("parses '0'", func() { - Expect(ParamBool(r, "d", true)).To(BeFalse()) - }) - }) - }) -})