Merge branch 'master' into os-lyrics

This commit is contained in:
Deluan Quintão 2023-12-27 16:59:28 -05:00 • committed by GitHub
commit 86d2c0fdf7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
151 changed files with 1418 additions and 1267 deletions

View file

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

View file

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

99
cmd/inspect.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

17
go.mod
View file

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

51
go.sum
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,6 +31,11 @@
"title": "disc 2"
}
],
"originalReleaseDate": {
"year": 1994,
"month": 2,
"day": 4
},
"song": [
{
"id": "1",

View file

@ -4,6 +4,7 @@
<genres name="progressive"></genres>
<discTitles disc="1" title="disc 1"></discTitles>
<discTitles disc="2" title="disc 2"></discTitles>
<originalReleaseDate year="1994" month="2" day="4"></originalReleaseDate>
<song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321">
<genres name="rock"></genres>
<genres name="progressive"></genres>

View file

@ -12,6 +12,7 @@
"musicBrainzId": "",
"isCompilation": false,
"sortName": "",
"discTitles": []
"discTitles": [],
"originalReleaseDate": {}
}
}

View file

@ -1,3 +1,5 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
<album id="" name="" userRating="0" musicBrainzId="" isCompilation="false" sortName=""></album>
<album id="" name="" userRating="0" musicBrainzId="" isCompilation="false" sortName="">
<originalReleaseDate></originalReleaseDate>
</album>
</subsonic-response>

View file

@ -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"`
}

View file

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

View file

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

View file

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

View file

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

BIN
tests/fixtures/test.tak vendored Normal file

Binary file not shown.

168
ui/package-lock.json generated
View file

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

View file

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

View file

@ -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,
)}
>
<Typography variant={'body1'} onClick={handleExpandClick}>
@ -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(<DurationField source={'duration'} />)
!isXsmall && addDetail(<SizeField source="size" />)
@ -299,7 +299,7 @@ const AlbumDetails = (props) => {
const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), [])
const handleCloseLightbox = React.useCallback(
() => setLightboxOpen(false),
[]
[],
)
return (
<Card className={classes.root}>

View file

@ -35,7 +35,7 @@ const AlbumExternalLinks = (props) => {
encodeURIComponent(record.name)
}`,
'message.openIn.lastfm',
<ImLastfm2 className="lastfm-icon" />
<ImLastfm2 className="lastfm-icon" />,
)
}
@ -43,7 +43,7 @@ const AlbumExternalLinks = (props) => {
addLink(
`https://musicbrainz.org/release/${record.mbzAlbumId}`,
'message.openIn.musicbrainz',
<MusicBrainz className="musicbrainz-icon" />
<MusicBrainz className="musicbrainz-icon" />,
)
return <div className={className}>{intersperse(links, ' ')}</div>

View file

@ -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 (
<div ref={measureRef}>
<div ref={dragAlbumRef}>
<img
src={subsonic.getCoverArtUrl(record, 300)}
alt={record.name}
className={classes.cover}
/>
</div>
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 (
<div ref={measureRef}>
<div ref={dragAlbumRef}>
<img
src={subsonic.getCoverArtUrl(record, 300)}
alt={record.name}
className={classes.cover}
/>
</div>
)
}
)
</div>
)
})
const AlbumGridTile = ({ showArtist, record, basePath, ...props }) => {
const classes = useStyles()

View file

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

View file

@ -64,7 +64,7 @@ const AlbumViewToggler = React.forwardRef(
</ButtonGroup>
</div>
)
}
},
)
const AlbumListActions = ({

View file

@ -18,7 +18,7 @@ const useStyles = makeStyles(
}),
{
name: 'NDAlbumShow',
}
},
)
const AlbumShowLayout = (props) => {

View file

@ -83,7 +83,7 @@ const useStyles = makeStyles(
visibility: 'hidden',
},
}),
{ name: 'RaList' }
{ name: 'RaList' },
)
const AlbumSongs = (props) => {

View file

@ -59,7 +59,7 @@ const AlbumDatagridRow = (props) => {
item: { albumIds: [record?.id] },
options: { dropEffect: 'copy' },
}),
[record]
[record],
)
return <DatagridRow ref={dragAlbumRef} {...props} />
}

View file

@ -11,7 +11,7 @@ const ArtistExternalLinks = ({ artistInfo, record }) => {
const translate = useTranslate()
let linkButtons = []
const lastFMlink = artistInfo?.biography?.match(
/<a\s+(?:[^>]*?\s+)?href=(["'])(.*?)\1/
/<a\s+(?:[^>]*?\s+)?href=(["'])(.*?)\1/,
)
const addLink = (url, title, icon) => {
@ -34,13 +34,13 @@ const ArtistExternalLinks = ({ artistInfo, record }) => {
addLink(
lastFMlink[2],
'message.openIn.lastfm',
<ImLastfm2 className="lastfm-icon" />
<ImLastfm2 className="lastfm-icon" />,
)
} else if (artistInfo?.lastFmUrl) {
addLink(
artistInfo?.lastFmUrl,
'message.openIn.lastfm',
<ImLastfm2 className="lastfm-icon" />
<ImLastfm2 className="lastfm-icon" />,
)
}
}
@ -49,7 +49,7 @@ const ArtistExternalLinks = ({ artistInfo, record }) => {
addLink(
`https://musicbrainz.org/artist/${artistInfo.musicBrainzId}`,
'message.openIn.musicbrainz',
<MusicBrainz className="musicbrainz-icon" />
<MusicBrainz className="musicbrainz-icon" />,
)
return <div>{intersperse(linkButtons, ' ')}</div>

View file

@ -90,7 +90,7 @@ const ArtistDatagridRow = (props) => {
item: { artistIds: [record?.id] },
options: { dropEffect: 'copy' },
}),
[record]
[record],
)
return <DatagridRow ref={dragArtistRef} {...props} />
}
@ -132,7 +132,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
resource: 'artist',
columns: toggleableFields,
},
['size']
['size'],
)
return isXsmall ? (

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

@ -87,7 +87,7 @@ const useStyle = makeStyles(
},
},
}),
{ name: 'NDAudioPlayer' }
{ name: 'NDAudioPlayer' },
)
export default useStyle

View file

@ -1,4 +1,4 @@
import jwtDecode from 'jwt-decode'
import { jwtDecode } from 'jwt-decode'
import { baseUrl } from './utils'
import config from './config'

View file

@ -12,7 +12,10 @@ export const AddToPlaylistButton = ({ resource, selectedIds, className }) => {
const handleClick = () => {
dispatch(
openAddToPlaylist({ selectedIds, onSuccess: () => unselectAll(resource) })
openAddToPlaylist({
selectedIds,
onSuccess: () => unselectAll(resource),
}),
)
}

View file

@ -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 ? (
<Link
to={artistLink(id)}
onClick={(e) => e.stopPropagation()}
className={className}
>
{record[source]}
</Link>
) : (
record[source]
)}
</>
)
}
)
const id = record[source + 'Id']
return (
<>
{id ? (
<Link
to={artistLink(id)}
onClick={(e) => e.stopPropagation()}
className={className}
>
{record[source]}
</Link>
) : (
record[source]
)}
</>
)
})
ArtistLinkField.propTypes = {
record: PropTypes.object,

View file

@ -23,7 +23,7 @@ const useStyles = makeStyles(
top: '26px',
},
},
{ name: 'RaArtistSimpleList' }
{ name: 'RaArtistSimpleList' },
)
export const ArtistSimpleList = ({
@ -69,7 +69,7 @@ export const ArtistSimpleList = ({
</ListItemSecondaryAction>
</ListItem>
</span>
)
),
)}
</List>
)

View file

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

View file

@ -18,8 +18,8 @@ export const BatchShareButton = ({ resource, selectedIds, className }) => {
_: 'ra.action.bulk_actions',
smart_count: selectedIds.length,
}),
'message.shareBatchDialogTitle'
)
'message.shareBatchDialogTitle',
),
)
unselectAll(resource)
}

View file

@ -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 = ({
<MenuItem value={key} key={key} onClick={handleItemClick}>
{options[key].label}
</MenuItem>
)
),
)}
</Menu>
</span>

View file

@ -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}
</Link>
</Link>,
)
lastIndex = match.index + href.length
@ -57,7 +57,7 @@ const Linkify = ({ text, ...rest }) => {
<span
key={'last-span-key'}
dangerouslySetInnerHTML={{ __html: text.substring(lastIndex) }}
/>
/>,
)
}

View file

@ -36,7 +36,7 @@ export const LoveButton = ({
toggleLove()
e.stopPropagation()
},
[toggleLove]
[toggleLove],
)
if (!config.enableFavourites) {

View file

@ -39,11 +39,11 @@ export const MultiLineTextField = memo(
key={md5(line + idx)}
dangerouslySetInnerHTML={{ __html: line }}
/>
)
),
)}
</Typography>
)
}
},
)
MultiLineTextField.defaultProps = {

View file

@ -20,9 +20,9 @@ describe('<MultiLineTextField />', () => {
record={{ id: 123, body }}
emptyText="NA"
source="body"
/>
/>,
)
expect(screen.getByText('NA')).toBeInTheDocument()
}
},
)
})

View file

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

View file

@ -16,7 +16,7 @@ const useStyle = makeStyles(
}),
{
name: 'NDQualityInfo',
}
},
)
export const QualityInfo = ({ record, size, gainMode, preAmp, className }) => {

View file

@ -22,7 +22,7 @@ describe('QuickFilter', () => {
resource={'song'}
source={'name'}
label={<StarIcon data-testid="label-icon-test" />}
/>
/>,
)
expect(screen.getByTestId('label-icon-test')).not.toBeNull()
})

View file

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

View file

@ -19,7 +19,7 @@ const useStyles = makeStyles(
},
tertiary: { float: 'right', opacity: 0.541176 },
},
{ name: 'RaSimpleList' }
{ name: 'RaSimpleList' },
)
const LinkOrNot = ({

View file

@ -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 = ({
<MenuItem value={key} key={key} onClick={handleItemClick}>
{options[key].label}
</MenuItem>
)
),
)}
</Menu>
</span>

View file

@ -97,7 +97,7 @@ const ReleaseRow = forwardRef(
</TableCell>
</TableRow>
)
}
},
)
const DiscSubtitleRow = forwardRef(
@ -141,7 +141,7 @@ const DiscSubtitleRow = forwardRef(
</TableCell>
</TableRow>
)
}
},
)
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()

View file

@ -47,7 +47,7 @@ const useStyles = makeStyles(
top: '26px',
},
},
{ name: 'RaSongSimpleList' }
{ name: 'RaSongSimpleList' },
)
export const SongSimpleList = ({
@ -108,7 +108,7 @@ export const SongSimpleList = ({
</ListItemSecondaryAction>
</ListItem>
</span>
)
),
)}
</List>
)

View file

@ -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 = ({
<Checkbox checked={val} />
{translate(`resources.${resource}.fields.${key}`)}
</MenuItem>
) : null
) : null,
)}
</div>
</div>

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more