Compare commits

..

4 Commits

Author SHA1 Message Date
Deluan
af4b2bb4c9 go mod tidy 2023-05-13 21:30:05 -04:00
Deluan
4773adba00 Add aliases for playlists and service commands 2023-05-13 21:28:13 -04:00
Deluan
7bbf4cbaea Fix lint errors 2023-05-13 21:28:13 -04:00
Deluan
cff19445ba Add service management 2023-05-13 21:28:11 -04:00
255 changed files with 1717 additions and 6066 deletions

View File

@@ -2,7 +2,7 @@
# [Choice] Go version: 1, 1.15, 1.14
ARG VARIANT="1"
FROM mcr.microsoft.com/vscode/devcontainers/go:${VARIANT}
FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT}
# [Option] Install Node.js
ARG INSTALL_NODE="true"
@@ -17,4 +17,4 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# RUN go get -x <your-dependency-or-tool>
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1

View File

@@ -4,10 +4,10 @@
"dockerfile": "Dockerfile",
"args": {
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
"VARIANT": "1.21",
"VARIANT": "1.19",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v18"
"NODE_VERSION": "v16"
}
},
"workspaceMount": "",

View File

@@ -16,10 +16,10 @@ jobs:
- name: Install taglib
run: sudo apt-get install libtag1-dev
- name: Set up Go 1.21
- name: Set up Go 1.20
uses: actions/setup-go@v3
with:
go-version: 1.21.x
go-version: 1.20.x
- uses: actions/checkout@v3
@@ -39,7 +39,7 @@ jobs:
run: |
git status --porcelain
if [ -n "$(git status --porcelain)" ]; then
echo 'To fix this check, run "make format" and commit the changes'
echo 'To fix this check, run "goimports -w $(find . -name '*.go' | grep -v '_gen.go$') && go mod tidy"'
exit 1
fi
@@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go_version: [1.21.x,1.20.x]
go_version: [1.20.x,1.19.x]
steps:
- name: Install taglib
run: sudo apt-get install libtag1-dev
@@ -80,7 +80,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
node-version: 16
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -126,7 +126,7 @@ jobs:
path: ui/build
- name: Config /github/workspace folder as trusted
uses: docker://deluan/ci-goreleaser:1.21.0-1
uses: docker://deluan/ci-goreleaser:1.20.3-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.0-1
uses: docker://deluan/ci-goreleaser:1.20.3-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.0-1
uses: docker://deluan/ci-goreleaser:1.20.3-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -206,7 +206,7 @@ jobs:
labels: |
maintainer=deluan
images: |
name=${{secrets.DOCKER_IMAGE}}
name=${{secrets.DOCKER_IMAGE}},enable=${{env.GITHUB_REF_TYPE == 'tag' || github.ref == format('refs/heads/{0}', 'master')}}
name=ghcr.io/${{ github.repository }}
tags: |
type=ref,event=pr

View File

@@ -6,7 +6,6 @@ on:
jobs:
update-translations:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v3
- name: Get updated translations

View File

@@ -1,5 +1,5 @@
run:
go: "1.20"
go: "1.19"
linters:
enable:
@@ -7,6 +7,7 @@ linters:
- asciicheck
- bidichk
- bodyclose
- depguard
- dogsled
- durationcheck
- errcheck

View File

@@ -116,6 +116,12 @@ archives:
- format_overrides:
- goos: windows
format: zip
replacements:
darwin: macOS
linux: Linux
windows: Windows
386: i386
amd64: x86_64
checksum:
name_template: "{{ .ProjectName }}_checksums.txt"

2
.nvmrc
View File

@@ -1 +1 @@
v18
v16

View File

@@ -9,7 +9,7 @@ GIT_SHA=source_archive
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
endif
CI_RELEASER_VERSION=1.21.0-1 ## https://github.com/navidrome/ci-goreleaser
CI_RELEASER_VERSION=1.20.3-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...
@@ -45,12 +45,6 @@ lintall: lint ##@Development Lint Go and JS code
@(cd ./ui && npm run lint)
.PHONY: lintall
format: ##@Development Format code
@(cd ./ui && npm run prettier)
@go run golang.org/x/tools/cmd/goimports -w `find . -name '*.go' | grep -v _gen.go$$`
@go mod tidy
.PHONY: format
wire: check_go_env ##@Development Update Dependency Injection
go run github.com/google/wire/cmd/wire@latest ./...
.PHONY: wire
@@ -85,10 +79,6 @@ build: warning-noui-build check_go_env ##@Build Build only backend
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
.PHONY: build
debug-build: warning-noui-build check_go_env ##@Build Build only backend (with remote debug on)
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
.PHONY: debug-build
buildjs: check_node_env ##@Build Build only frontend
@(cd ./ui && npm run build)
.PHONY: buildjs
@@ -118,9 +108,9 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
mkdir -p music
( cd music; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=ec2093ec4801402f1e17cc462195cdbb" > brock.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \
for file in *.zip; do unzip -n $${file}; done )
@echo "Done. Remember to set your MusicFolder to ./music"
.PHONY: get-music

View File

@@ -27,9 +27,10 @@ func init() {
}
var plsCmd = &cobra.Command{
Use: "pls",
Short: "Export playlists",
Long: "Export Navidrome playlists to M3U files",
Use: "playlists",
Aliases: []string{"pls", "playlist"},
Short: "Export playlists",
Long: "Export Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
runExporter()
},

View File

@@ -12,7 +12,6 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/resources"
@@ -40,7 +39,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
preRun()
},
Run: func(cmd *cobra.Command, args []string) {
runNavidrome()
runNavidrome(context.Background())
},
Version: consts.Version,
}
@@ -61,7 +60,7 @@ func preRun() {
conf.Load()
}
func runNavidrome() {
func runNavidrome(ctx context.Context) {
db.Init()
defer func() {
if err := db.Close(); err != nil {
@@ -70,16 +69,12 @@ func runNavidrome() {
log.Info("Navidrome stopped, bye.")
}()
g, ctx := errgroup.WithContext(context.Background())
g, ctx := errgroup.WithContext(ctx)
g.Go(startServer(ctx))
g.Go(startSignaler(ctx))
g.Go(startScheduler(ctx))
g.Go(schedulePeriodicScan(ctx))
if conf.Server.Jukebox.Enabled {
g.Go(startPlaybackServer(ctx))
}
if err := g.Wait(); err != nil && !errors.Is(err, interrupted) {
log.Error("Fatal error in Navidrome. Aborting", err)
}
@@ -151,16 +146,6 @@ func startScheduler(ctx context.Context) func() error {
}
}
func startPlaybackServer(ctx context.Context) func() error {
log.Info(ctx, "Starting playback server")
playbackInstance := playback.GetInstance()
return func() error {
return playbackInstance.Run(ctx)
}
}
// TODO: Implement some struct tags to map flags to viper
func init() {
cobra.OnInitialize(func() {
@@ -170,13 +155,11 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`)
rootCmd.PersistentFlags().BoolVarP(&noBanner, "nobanner", "n", false, `don't show banner`)
rootCmd.PersistentFlags().String("musicfolder", viper.GetString("musicfolder"), "folder where your music is stored")
rootCmd.PersistentFlags().String("datafolder", viper.GetString("datafolder"), "folder to store application data (DB), needs write access")
rootCmd.PersistentFlags().String("cachefolder", viper.GetString("cachefolder"), "folder to store cache data (transcoding, images...), needs write access")
rootCmd.PersistentFlags().String("datafolder", viper.GetString("datafolder"), "folder to store application data (DB, cache...), needs write access")
rootCmd.PersistentFlags().StringP("loglevel", "l", viper.GetString("loglevel"), "log level, possible values: error, info, debug, trace")
_ = viper.BindPFlag("musicfolder", rootCmd.PersistentFlags().Lookup("musicfolder"))
_ = viper.BindPFlag("datafolder", rootCmd.PersistentFlags().Lookup("datafolder"))
_ = viper.BindPFlag("cachefolder", rootCmd.PersistentFlags().Lookup("cachefolder"))
_ = viper.BindPFlag("loglevel", rootCmd.PersistentFlags().Lookup("loglevel"))
rootCmd.Flags().StringP("address", "a", viper.GetString("address"), "IP address to bind to")

191
cmd/svc.go Normal file
View File

@@ -0,0 +1,191 @@
package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/kardianos/service"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/spf13/cobra"
)
var (
svcStatusLabels = map[service.Status]string{
service.StatusUnknown: "Unknown",
service.StatusStopped: "Stopped",
service.StatusRunning: "Running",
}
)
func init() {
svcCmd.AddCommand(buildInstallCmd())
svcCmd.AddCommand(buildUninstallCmd())
svcCmd.AddCommand(buildStartCmd())
svcCmd.AddCommand(buildStopCmd())
svcCmd.AddCommand(buildStatusCmd())
rootCmd.AddCommand(svcCmd)
}
var svcCmd = &cobra.Command{
Use: "service",
Aliases: []string{"svc"},
Short: "Manage Navidrome as a service",
Long: fmt.Sprintf("Manage Navidrome as a service, using the OS service manager (%s)", service.Platform()),
Run: runServiceCmd,
}
type svcControl struct {
ctx context.Context
cancel context.CancelFunc
}
func (p *svcControl) Start(_ service.Service) error {
p.ctx, p.cancel = context.WithCancel(context.Background())
go p.run()
return nil
}
func (p *svcControl) run() {
runNavidrome(p.ctx)
}
func (p *svcControl) Stop(_ service.Service) error {
log.Info("Stopping service")
p.cancel()
return nil
}
var (
svc service.Service
svcOnce = sync.Once{}
)
func svcInstance() service.Service {
svcOnce.Do(func() {
options := make(service.KeyValue)
options["Restart"] = "on-success"
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
options["UserService"] = true
options["LogDirectory"] = conf.Server.DataFolder
svcConfig := &service.Config{
Name: "Navidrome",
DisplayName: "Navidrome",
Description: "Navidrome is a self-hosted music server and streamer",
Dependencies: []string{
"Requires=network.target",
"After=network-online.target syslog.target"},
WorkingDirectory: executablePath(),
Option: options,
}
if conf.Server.ConfigFile != "" {
svcConfig.Arguments = []string{"-c", conf.Server.ConfigFile}
}
prg := &svcControl{}
var err error
svc, err = service.New(prg, svcConfig)
if err != nil {
log.Fatal(err)
}
})
return svc
}
func runServiceCmd(cmd *cobra.Command, _ []string) {
_ = cmd.Help()
}
func executablePath() string {
ex, err := os.Executable()
if err != nil {
log.Fatal(err)
}
return filepath.Dir(ex)
}
func buildInstallCmd() *cobra.Command {
runInstallCmd := func(_ *cobra.Command, _ []string) {
var err error
println("Installing service with:")
println(" working directory: " + executablePath())
println(" music folder: " + conf.Server.MusicFolder)
println(" data folder: " + conf.Server.DataFolder)
if cfgFile != "" {
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
if err != nil {
log.Fatal(err)
}
println(" config file: " + conf.Server.ConfigFile)
}
err = svcInstance().Install()
if err != nil {
log.Fatal(err)
}
println("Service installed. Use 'navidrome svc start' to start it.")
}
return &cobra.Command{
Use: "install",
Short: "Install Navidrome service.",
Run: runInstallCmd,
}
}
func buildUninstallCmd() *cobra.Command {
return &cobra.Command{
Use: "uninstall",
Short: "Uninstall Navidrome service. Does not delete the music or data folders",
Run: func(cmd *cobra.Command, args []string) {
err := svcInstance().Uninstall()
if err != nil {
log.Fatal(err)
}
println("Service uninstalled. Music and data folders are still intact.")
},
}
}
func buildStartCmd() *cobra.Command {
return &cobra.Command{
Use: "start",
Short: "Start Navidrome service",
Run: func(cmd *cobra.Command, args []string) {
err := svcInstance().Start()
if err != nil {
log.Fatal(err)
}
println("Service started. Use 'navidrome svc status' to check its status.")
},
}
}
func buildStopCmd() *cobra.Command {
return &cobra.Command{
Use: "stop",
Short: "Stop Navidrome service",
Run: func(cmd *cobra.Command, args []string) {
err := svcInstance().Stop()
if err != nil {
log.Fatal(err)
}
println("Service stopped. Use 'navidrome svc status' to check its status.")
},
}
}
func buildStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show Navidrome service status",
Run: func(cmd *cobra.Command, args []string) {
status, err := svcInstance().Status()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Navidrome is %s.\n", svcStatusLabels[status])
},
}
}

View File

@@ -40,8 +40,7 @@ func CreateNativeAPIRouter() *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore)
playlists := core.NewPlaylists(dataStore)
router := nativeapi.New(dataStore, share, playlists)
router := nativeapi.New(dataStore, share)
return router
}

View File

@@ -23,7 +23,6 @@ type configOptions struct {
Port int
MusicFolder string
DataFolder string
CacheFolder string
DbPath string
LogLevel string
ScanInterval time.Duration
@@ -55,7 +54,6 @@ type configOptions struct {
IndexGroups string
SubsonicArtistParticipations bool
FFmpegPath string
MPVPath string
CoverArtPriority string
CoverJpegQuality int
ArtistArtPriority string
@@ -79,7 +77,6 @@ type configOptions struct {
ReverseProxyWhitelist string
Prometheus prometheusOptions
Scanner scannerOptions
Jukebox jukeboxOptions
Agents string
LastFM lastfmOptions
@@ -104,9 +101,8 @@ type configOptions struct {
}
type scannerOptions struct {
Extractor string
GenreSeparators string
GroupAlbumReleases bool
Extractor string
GenreSeparators string
}
type lastfmOptions struct {
@@ -131,14 +127,6 @@ type prometheusOptions struct {
MetricsPath string
}
type AudioDeviceDefinition []string
type jukeboxOptions struct {
Enabled bool
Devices []AudioDeviceDefinition
Default string
}
var (
Server = &configOptions{}
hooks []func()
@@ -146,11 +134,6 @@ var (
func LoadFromFile(confFile string) {
viper.SetConfigFile(confFile)
err := viper.ReadInConfig()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
os.Exit(1)
}
Load()
}
@@ -165,16 +148,6 @@ func Load() {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", "path", Server.DataFolder, err)
os.Exit(1)
}
if Server.CacheFolder == "" {
Server.CacheFolder = filepath.Join(Server.DataFolder, "cache")
}
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", "path", Server.CacheFolder, err)
os.Exit(1)
}
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
if Server.DbPath == "" {
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
@@ -268,7 +241,6 @@ func AddHook(hook func()) {
func init() {
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
viper.SetDefault("cachefolder", "")
viper.SetDefault("datafolder", ".")
viper.SetDefault("loglevel", "info")
viper.SetDefault("address", "0.0.0.0")
@@ -290,7 +262,7 @@ func init() {
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
viper.SetDefault("enabledownloads", true)
viper.SetDefault("enableexternalservices", true)
viper.SetDefault("enablemediafilecoverart", true)
viper.SetDefault("enableMediaFileCoverArt", true)
viper.SetDefault("autotranscodedownload", false)
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
viper.SetDefault("searchfullstring", false)
@@ -323,13 +295,8 @@ func init() {
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", "/metrics")
viper.SetDefault("jukebox.enabled", false)
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
viper.SetDefault("jukebox.default", "")
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
viper.SetDefault("scanner.genreseparators", ";/,")
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("agents", "lastfm,spotify")
viper.SetDefault("lastfm.enabled", true)

View File

@@ -71,10 +71,10 @@ const (
// Cache options
const (
TranscodingCacheDir = "transcoding"
TranscodingCacheDir = "cache/transcoding"
DefaultTranscodingCacheMaxItems = 0 // Unlimited
ImageCacheDir = "images"
ImageCacheDir = "cache/images"
DefaultImageCacheMaxItems = 0 // Unlimited
DefaultCacheSize = 100 * 1024 * 1024 // 100MB
@@ -94,19 +94,19 @@ var (
"name": "mp3 audio",
"targetFormat": "mp3",
"defaultBitRate": 192,
"command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -",
},
{
"name": "opus audio",
"targetFormat": "opus",
"defaultBitRate": 128,
"command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -c:a libopus -f opus -",
},
{
"name": "aac audio",
"targetFormat": "aac",
"defaultBitRate": 256,
"command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
"command": "ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -c:a aac -f adts -",
},
}

View File

@@ -257,7 +257,7 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
track: track.Title,
album: track.Album,
trackNumber: track.TrackNumber,
mbid: track.MbzRecordingID,
mbid: track.MbzTrackID,
duration: int(track.Duration),
albumArtist: track.AlbumArtist,
})
@@ -283,7 +283,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
track: s.Title,
album: s.Album,
trackNumber: s.TrackNumber,
mbid: s.MbzRecordingID,
mbid: s.MbzTrackID,
duration: int(s.Duration),
albumArtist: s.AlbumArtist,
timestamp: s.TimeStamp,

View File

@@ -234,14 +234,14 @@ var _ = Describe("lastfmAgent", func() {
agent = lastFMConstructor(ds)
agent.client = client
track = &model.MediaFile{
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
AlbumArtist: "Track AlbumArtist",
TrackNumber: 1,
Duration: 180,
MbzRecordingID: "mbz-123",
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
AlbumArtist: "Track AlbumArtist",
TrackNumber: 1,
Duration: 180,
MbzTrackID: "mbz-123",
}
})
@@ -262,7 +262,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
Expect(sentParams.Get("mbid")).To(Equal(track.MbzTrackID))
})
It("returns ErrNotAuthorized if user is not linked", func() {
@@ -289,7 +289,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
Expect(sentParams.Get("mbid")).To(Equal(track.MbzTrackID))
Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
})

View File

@@ -55,9 +55,8 @@ func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
SubmissionClientVersion: consts.Version,
TrackNumber: track.TrackNumber,
ArtistMbzIDs: []string{track.MbzArtistID},
RecordingMbzID: track.MbzRecordingID,
TrackMbzID: track.MbzTrackID,
ReleaseMbID: track.MbzAlbumID,
DurationMs: int(track.Duration * 1000),
},
},
}

View File

@@ -32,15 +32,14 @@ var _ = Describe("listenBrainzAgent", func() {
agent = listenBrainzConstructor(ds)
agent.client = newClient("http://localhost:8080", httpClient)
track = &model.MediaFile{
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
TrackNumber: 1,
MbzRecordingID: "mbz-123",
MbzAlbumID: "mbz-456",
MbzArtistID: "mbz-789",
Duration: 142.2,
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
TrackNumber: 1,
MbzTrackID: "mbz-123",
MbzAlbumID: "mbz-456",
MbzArtistID: "mbz-789",
}
})
@@ -61,12 +60,11 @@ var _ = Describe("listenBrainzAgent", func() {
"SubmissionClient": Equal(consts.AppName),
"SubmissionClientVersion": Equal(consts.Version),
"TrackNumber": Equal(track.TrackNumber),
"RecordingMbzID": Equal(track.MbzRecordingID),
"TrackMbzID": Equal(track.MbzTrackID),
"ReleaseMbID": Equal(track.MbzAlbumID),
"ArtistMbzIDs": MatchAllElements(idArtistId, Elements{
"mbz-789": Equal(track.MbzArtistID),
}),
"DurationMs": Equal(142200),
}),
}),
}))

View File

@@ -76,10 +76,9 @@ type additionalInfo struct {
SubmissionClient string `json:"submission_client,omitempty"`
SubmissionClientVersion string `json:"submission_client_version,omitempty"`
TrackNumber int `json:"tracknumber,omitempty"`
RecordingMbzID string `json:"recording_mbid,omitempty"`
TrackMbzID string `json:"track_mbid,omitempty"`
ArtistMbzIDs []string `json:"artist_mbids,omitempty"`
ReleaseMbID string `json:"release_mbid,omitempty"`
DurationMs int `json:"duration_ms,omitempty"`
}
func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {

View File

@@ -74,11 +74,10 @@ var _ = Describe("client", func() {
TrackName: "Track Title",
ReleaseName: "Track Album",
AdditionalInfo: additionalInfo{
TrackNumber: 1,
RecordingMbzID: "mbz-123",
ArtistMbzIDs: []string{"mbz-789"},
ReleaseMbID: "mbz-456",
DurationMs: 142200,
TrackNumber: 1,
TrackMbzID: "mbz-123",
ArtistMbzIDs: []string{"mbz-789"},
ReleaseMbID: "mbz-456",
},
},
}

View File

@@ -86,7 +86,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultDisc b
if isMultDisc {
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
}
return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file)
return fmt.Sprintf("%s/%s", mf.Album, file)
}
func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error {
@@ -130,11 +130,8 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
if format != "" && format != "raw" {
ext = format
}
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext)
}
func sanitizeName(target string) string {
return strings.ReplaceAll(target, "/", "_")
file := fmt.Sprintf("%02d - %s - %s.%s", idx+1, mf.Artist, mf.Title, ext)
return file
}
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {

View File

@@ -33,8 +33,8 @@ var _ = Describe("Archiver", func() {
Context("ZipAlbum", func() {
It("zips an album correctly", func() {
mfs := model.MediaFiles{
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
}
mfRepo := &mockMediaFileRepository{}
@@ -44,7 +44,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
@@ -54,8 +54,8 @@ var _ = Describe("Archiver", func() {
Expect(err).To(BeNil())
Expect(len(zr.File)).To(Equal(2))
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
})
})
@@ -123,7 +123,7 @@ var _ = Describe("Archiver", func() {
Context("ZipPlaylist", func() {
It("zips a playlist correctly", func() {
tracks := []model.PlaylistTrack{
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "Artist 1", Title: "track1"}},
{MediaFile: model.MediaFile{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "Artist 2", Title: "track2"}},
}
@@ -146,7 +146,7 @@ var _ = Describe("Archiver", func() {
Expect(err).To(BeNil())
Expect(len(zr.File)).To(Equal(2))
Expect(zr.File[0].Name).To(Equal("01 - AC_DC - track1.mp3"))
Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
})
})

View File

@@ -399,7 +399,7 @@ func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents
func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) {
if mbid != "" {
mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"mbz_recording_id": mbid},
Filters: squirrel.Eq{"mbz_track_id": mbid},
})
if err == nil && len(mfs) > 0 {
return &mfs[0], nil
@@ -414,7 +414,7 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
},
squirrel.Like{"order_title": strings.TrimSpace(sanitize.Accents(title))},
},
Sort: "starred desc, rating desc, year asc, compilation asc ",
Sort: "starred desc, rating desc, year asc",
Max: 1,
})
if err != nil || len(mfs) == 0 {

View File

@@ -18,8 +18,6 @@ import (
type FFmpeg interface {
Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error)
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
ConvertToWAV(ctx context.Context, path string) (io.ReadCloser, error)
ConvertToFLAC(ctx context.Context, path string) (io.ReadCloser, error)
Probe(ctx context.Context, files []string) (string, error)
CmdPath() (string, error)
}
@@ -31,8 +29,6 @@ func New() FFmpeg {
const (
extractImageCmd = "ffmpeg -i %s -an -vcodec copy -f image2pipe -"
probeCmd = "ffmpeg %s -f ffmetadata"
createWavCmd = "ffmpeg -i %s -c:a pcm_s16le -f wav -"
createFLACCmd = "ffmpeg -i %s -f flac -"
)
type ffmpeg struct{}
@@ -53,16 +49,6 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser,
return e.start(ctx, args)
}
func (e *ffmpeg) ConvertToWAV(ctx context.Context, path string) (io.ReadCloser, error) {
args := createFFmpegCommand(createWavCmd, path, 0)
return e.start(ctx, args)
}
func (e *ffmpeg) ConvertToFLAC(ctx context.Context, path string) (io.ReadCloser, error) {
args := createFFmpegCommand(createFLACCmd, path, 0)
return e.start(ctx, args)
}
func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) {
if _, err := ffmpegCmd(); err != nil {
return "", err

View File

@@ -129,11 +129,11 @@ func (s *Stream) EstimatedContentLength() int {
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
format = "raw"
if reqFormat == "raw" {
return format, 0
return
}
if reqFormat == mf.Suffix && reqBitRate == 0 {
bitRate = mf.BitRate
return format, bitRate
return
}
trc, hasDefault := request.TranscodingFrom(ctx)
var cFormat string
@@ -159,7 +159,7 @@ func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model
cBitRate = reqBitRate
}
if cBitRate == 0 && cFormat == "" {
return format, bitRate
return
}
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
if err == nil {
@@ -184,26 +184,22 @@ var (
func GetTranscodingCache() TranscodingCache {
onceTranscodingCache.Do(func() {
instanceTranscodingCache = NewTranscodingCache()
instanceTranscodingCache = cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
job := arg.(*streamJob)
t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
if err != nil {
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
return nil, os.ErrInvalid
}
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.mf.Path, job.bitRate)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid
}
return out, nil
})
})
return instanceTranscodingCache
}
func NewTranscodingCache() TranscodingCache {
return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
job := arg.(*streamJob)
t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format)
if err != nil {
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
return nil, os.ErrInvalid
}
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.mf.Path, job.bitRate)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid
}
return out, nil
})
}

View File

@@ -2,8 +2,10 @@ package core
import (
"context"
"os"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -14,10 +16,21 @@ import (
var _ = Describe("MediaStreamer", func() {
var ds model.DataStore
ctx := log.NewContext(context.Background())
ctx := log.NewContext(context.TODO())
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder, _ = os.MkdirTemp("", "file_caches")
conf.Server.TranscodingCacheSize = "100MB"
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
})
testCache := GetTranscodingCache()
Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
})
AfterEach(func() {
_ = os.RemoveAll(conf.Server.DataFolder)
})
Context("selectTranscodingOptions", func() {

View File

@@ -23,18 +23,18 @@ var _ = Describe("MediaStreamer", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches")
conf.Server.DataFolder, _ = os.MkdirTemp("", "file_caches")
conf.Server.TranscodingCacheSize = "100MB"
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
})
testCache := core.NewTranscodingCache()
testCache := core.GetTranscodingCache()
Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
streamer = core.NewMediaStreamer(ds, ffmpeg, testCache)
})
AfterEach(func() {
_ = os.RemoveAll(conf.Server.CacheFolder)
_ = os.RemoveAll(conf.Server.DataFolder)
})
Context("NewStream", func() {

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

@@ -1,285 +0,0 @@
package playback
import (
"context"
"fmt"
"github.com/navidrome/navidrome/core/playback/mpv"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type Track interface {
IsPlaying() bool
SetVolume(value float32) // Used to control the playback volume. A float value between 0.0 and 1.0.
Pause()
Unpause()
Position() int
SetPosition(offset int) error
Close()
}
type PlaybackDevice struct {
ParentPlaybackServer PlaybackServer
Default bool
User string
Name string
DeviceName string
PlaybackQueue *Queue
Gain float32
PlaybackDone chan bool
ActiveTrack Track
TrackSwitcherStarted bool
}
type DeviceStatus struct {
CurrentIndex int
Playing bool
Gain float32
Position int
}
const DefaultGain float32 = 1.0
var EmptyStatus = DeviceStatus{CurrentIndex: -1, Playing: false, Gain: DefaultGain, Position: 0}
func (pd *PlaybackDevice) getStatus() DeviceStatus {
pos := 0
if pd.ActiveTrack != nil {
pos = pd.ActiveTrack.Position()
}
return DeviceStatus{
CurrentIndex: pd.PlaybackQueue.Index,
Playing: pd.isPlaying(),
Gain: pd.Gain,
Position: pos,
}
}
// 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{
ParentPlaybackServer: playbackServer,
User: "",
Name: name,
DeviceName: deviceName,
Gain: DefaultGain,
PlaybackQueue: NewQueue(),
PlaybackDone: make(chan bool),
TrackSwitcherStarted: false,
}
}
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")
return pd.PlaybackQueue.Get(), pd.getStatus(), nil
}
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) {
_, err := pd.Clear(ctx)
if err != nil {
log.Error(ctx, "error setting tracks", ids)
return pd.getStatus(), err
}
return pd.Add(ctx, ids)
}
func (pd *PlaybackDevice) Start(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, "processing Start action")
if !pd.TrackSwitcherStarted {
log.Info(ctx, "Starting trackSwitcher goroutine")
// Start one trackSwitcher goroutine with each device
go func() {
pd.trackSwitcherGoroutine()
}()
pd.TrackSwitcherStarted = true
}
if pd.ActiveTrack != nil {
if pd.isPlaying() {
log.Debug("trying to start an already playing track")
} else {
pd.ActiveTrack.Unpause()
}
} else {
if !pd.PlaybackQueue.IsEmpty() {
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
if err != nil {
return pd.getStatus(), err
}
pd.ActiveTrack.Unpause()
}
}
return pd.getStatus(), nil
}
func (pd *PlaybackDevice) Stop(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, "processing Stop action")
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)
wasPlaying := pd.isPlaying()
if pd.ActiveTrack != nil && wasPlaying {
pd.ActiveTrack.Pause()
}
if index != pd.PlaybackQueue.Index && pd.ActiveTrack != nil {
pd.ActiveTrack.Close()
pd.ActiveTrack = nil
}
if pd.ActiveTrack == nil {
err := pd.switchActiveTrackByIndex(index)
if err != nil {
return pd.getStatus(), err
}
}
err := pd.ActiveTrack.SetPosition(offset)
if err != nil {
log.Error(ctx, "error setting position", err)
return pd.getStatus(), err
}
if wasPlaying {
_, err = pd.Start(ctx)
if err != nil {
log.Error(ctx, "error starting new track after skipping")
return pd.getStatus(), err
}
}
return pd.getStatus(), nil
}
func (pd *PlaybackDevice) Add(ctx context.Context, ids []string) (DeviceStatus, error) {
log.Debug(ctx, "processing Add action")
items := model.MediaFiles{}
for _, id := range ids {
mf, err := pd.ParentPlaybackServer.GetMediaFile(id)
if err != nil {
return DeviceStatus{}, err
}
log.Debug(ctx, "Found mediafile: "+mf.Path)
items = append(items, *mf)
}
pd.PlaybackQueue.Add(items)
return pd.getStatus(), nil
}
func (pd *PlaybackDevice) Clear(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, fmt.Sprintf("processing Clear action on: %s", pd))
if pd.ActiveTrack != nil {
pd.ActiveTrack.Pause()
pd.ActiveTrack.Close()
pd.ActiveTrack = nil
}
pd.PlaybackQueue.Clear()
return pd.getStatus(), nil
}
func (pd *PlaybackDevice) Remove(ctx context.Context, index int) (DeviceStatus, error) {
log.Debug(ctx, "processing Remove action")
// pausing if attempting to remove running track
if pd.isPlaying() && pd.PlaybackQueue.Index == index {
_, err := pd.Stop(ctx)
if err != nil {
log.Error(ctx, "error stopping running track")
return pd.getStatus(), err
}
}
if index > -1 && index < pd.PlaybackQueue.Size() {
pd.PlaybackQueue.Remove(index)
} else {
log.Error(ctx, "Index to remove out of range: "+fmt.Sprint(index))
}
return pd.getStatus(), nil
}
func (pd *PlaybackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, "processing Shuffle action")
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))
if pd.ActiveTrack != nil {
pd.ActiveTrack.SetVolume(gain)
}
pd.Gain = gain
return pd.getStatus(), nil
}
func (pd *PlaybackDevice) isPlaying() bool {
return pd.ActiveTrack != nil && pd.ActiveTrack.IsPlaying()
}
func (pd *PlaybackDevice) trackSwitcherGoroutine() {
log.Info("Starting trackSwitcher goroutine")
for {
<-pd.PlaybackDone
log.Info("track switching detected")
if pd.ActiveTrack != nil {
pd.ActiveTrack.Close()
pd.ActiveTrack = nil
}
if !pd.PlaybackQueue.IsAtLastElement() {
pd.PlaybackQueue.IncreaseIndex()
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)
}
pd.ActiveTrack.Unpause()
} else {
log.Debug("There is no song left in the playlist. Finish.")
}
}
}
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")
}
track, err := mpv.NewTrack(pd.PlaybackDone, pd.DeviceName, *currentTrack)
if err != nil {
return err
}
pd.ActiveTrack = track
return nil
}

View File

@@ -1,142 +0,0 @@
package mpv
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
// mpv --no-audio-display --pause 'Jack Johnson/On And On/01 Times Like These.m4a' --input-ipc-server=/tmp/gonzo.socket
const (
mpvComdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
)
func start(args []string) (Executor, error) {
log.Debug("Executing mpv command", "cmd", args)
j := Executor{args: args}
j.PipeReader, j.out = io.Pipe()
err := j.start()
if err != nil {
return Executor{}, err
}
go j.wait()
return j, nil
}
func (j *Executor) Cancel() error {
if j.cmd != nil {
return j.cmd.Cancel()
}
return fmt.Errorf("there is non command to cancel")
}
type Executor struct {
*io.PipeReader
out *io.PipeWriter
args []string
cmd *exec.Cmd
ctx context.Context
}
func (j *Executor) start() error {
ctx := context.Background()
j.ctx = ctx
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out
if log.CurrentLevel() >= log.LevelTrace {
cmd.Stderr = os.Stderr
} else {
cmd.Stderr = io.Discard
}
j.cmd = cmd
if err := cmd.Start(); err != nil {
return fmt.Errorf("starting cmd: %w", err)
}
return nil
}
func (j *Executor) wait() {
if err := j.cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
} else {
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
}
return
}
_ = j.out.Close()
}
// Path will always be an absolute path
func createMPVCommand(cmd, deviceName string, filename string, socketName string) []string {
split := strings.Split(fixCmd(cmd), " ")
for i, s := range split {
s = strings.ReplaceAll(s, "%d", deviceName)
s = strings.ReplaceAll(s, "%f", filename)
s = strings.ReplaceAll(s, "%s", socketName)
split[i] = s
}
return split
}
func fixCmd(cmd string) string {
split := strings.Split(cmd, " ")
var result []string
cmdPath, _ := mpvCommand()
for _, s := range split {
if s == "mpv" || s == "mpv.exe" {
result = append(result, cmdPath)
} else {
result = append(result, s)
}
}
return strings.Join(result, " ")
}
// This is a 1:1 copy of the stuff in ffmpeg.go, need to be unified.
func mpvCommand() (string, error) {
mpvOnce.Do(func() {
if conf.Server.MPVPath != "" {
mpvPath = conf.Server.MPVPath
mpvPath, mpvErr = exec.LookPath(mpvPath)
} else {
mpvPath, mpvErr = exec.LookPath("mpv")
if errors.Is(mpvErr, exec.ErrDot) {
log.Trace("mpv found in current folder '.'")
mpvPath, mpvErr = exec.LookPath("./mpv")
}
}
if mpvErr == nil {
log.Info("Found mpv", "path", mpvPath)
return
}
})
return mpvPath, mpvErr
}
var (
mpvOnce sync.Once
mpvPath string
mpvErr error
)
func TempFileName(prefix, suffix string) string {
randBytes := make([]byte, 16)
// we can savely ignore the return value since we're loading into a precreated, fixedsized buffer
_, _ = rand.Read(randBytes)
return filepath.Join(os.TempDir(), prefix+hex.EncodeToString(randBytes)+suffix)
}

View File

@@ -1,223 +0,0 @@
package mpv
// Audio-playback using mpv media-server. See mpv.io
// https://github.com/dexterlb/mpvipc
// https://mpv.io/manual/master/#json-ipc
// https://mpv.io/manual/master/#properties
import (
"fmt"
"os"
"time"
"github.com/DexterLB/mpvipc"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type MpvTrack struct {
MediaFile model.MediaFile
PlaybackDone chan bool
Conn *mpvipc.Connection
IPCSocketName string
Exe *Executor
CloseCalled bool
}
func NewTrack(playbackDoneChannel chan bool, deviceName string, mf model.MediaFile) (*MpvTrack, error) {
log.Debug("loading track", "trackname", mf.Path, "mediatype", mf.ContentType())
if _, err := mpvCommand(); err != nil {
return nil, err
}
tmpSocketName := TempFileName("mpv-ctrl-", ".socket")
args := createMPVCommand(mpvComdTemplate, deviceName, mf.Path, tmpSocketName)
exe, err := start(args)
if err != nil {
log.Error("error starting mpv process", "error", err)
return nil, err
}
// wait for socket to show up
err = waitForFile(tmpSocketName, 3*time.Second, 100*time.Millisecond)
if err != nil {
log.Error("error or timeout waiting for control socket", "socketname", tmpSocketName, "error", err)
return nil, err
}
conn := mpvipc.NewConnection(tmpSocketName)
err = conn.Open()
if err != nil {
log.Error("error opening new connection", "error", err)
return nil, err
}
theTrack := &MpvTrack{MediaFile: mf, PlaybackDone: playbackDoneChannel, Conn: conn, IPCSocketName: tmpSocketName, Exe: &exe, CloseCalled: false}
go func() {
conn.WaitUntilClosed()
log.Info("Hitting end-of-stream, signalling on channel")
if !theTrack.CloseCalled {
playbackDoneChannel <- true
}
}()
return theTrack, nil
}
func (t *MpvTrack) String() string {
return fmt.Sprintf("Name: %s, Socket: %s", t.MediaFile.Path, t.IPCSocketName)
}
// Used to control the playback volume. A float value between 0.0 and 1.0.
func (t *MpvTrack) SetVolume(value float32) {
// mpv's volume as described in the --volume parameter:
// Set the startup volume. 0 means silence, 100 means no volume reduction or amplification.
// Negative values can be passed for compatibility, but are treated as 0.
log.Debug("request for gain", "gain", value)
vol := int(value * 100)
err := t.Conn.Set("volume", vol)
if err != nil {
log.Error(err)
}
log.Debug("set volume", "volume", vol)
}
func (t *MpvTrack) Unpause() {
err := t.Conn.Set("pause", false)
if err != nil {
log.Error(err)
}
log.Info("unpaused track")
}
func (t *MpvTrack) Pause() {
err := t.Conn.Set("pause", true)
if err != nil {
log.Error(err)
}
log.Info("paused track")
}
func (t *MpvTrack) Close() {
log.Debug("closing resources")
t.CloseCalled = true
// trying to shutdown mpv process using socket
if t.isSocketfilePresent() {
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)
if t.Exe != nil {
log.Debug("cancelling executor")
err = t.Exe.Cancel()
if err != nil {
log.Error("error canceling executor")
}
}
}
}
if t.isSocketfilePresent() {
log.Debug("Removing socketfile", "socketfile", t.IPCSocketName)
err := os.Remove(t.IPCSocketName)
if err != nil {
log.Error("error cleaning up socketfile: ", t.IPCSocketName)
}
}
}
func (t *MpvTrack) isSocketfilePresent() bool {
if len(t.IPCSocketName) < 1 {
return false
}
fileInfo, err := os.Stat(t.IPCSocketName)
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"
// 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
if retryCount > 5 {
return 0
}
break
}
if err != nil {
log.Error("error getting position in track", "error", err)
return 0
}
pos, ok := position.(float64)
if !ok {
log.Error("could not cast position from mpv into float64")
return 0
} else {
return int(pos)
}
}
return 0
}
func (t *MpvTrack) SetPosition(offset int) error {
pos := t.Position()
if pos == offset {
log.Debug("no position difference, skipping operation")
return nil
}
err := t.Conn.Set("time-pos", float64(offset))
if err != nil {
log.Error("could not set the position in track", "offset", offset, "error", err)
return err
}
log.Info("set position", "offset", offset)
return nil
}
func (t *MpvTrack) IsPlaying() bool {
pausing, err := t.Conn.Get("pause")
if err != nil {
log.Error("problem getting paused status", "error", err)
return false
}
pause, ok := pausing.(bool)
if !ok {
log.Error("could not cast pausing to boolean")
return false
}
return !pause
}
func waitForFile(path string, timeout time.Duration, pause time.Duration) error {
start := time.Now()
end := start.Add(timeout)
var retries int = 0
for {
fileInfo, err := os.Stat(path)
if err == nil && fileInfo != nil && !fileInfo.IsDir() {
log.Debug("file found", "retries", retries, "waittime", time.Since(start).Microseconds())
return nil
}
if time.Now().After(end) {
return fmt.Errorf("timeout reached: %s", timeout)
}
time.Sleep(pause)
retries += 1
}
}

View File

@@ -1,17 +0,0 @@
package playback
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestPlayback(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Playback Suite")
}

View File

@@ -1,135 +0,0 @@
// 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:
// - 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
package playback
import (
"context"
"fmt"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/utils/singleton"
)
type PlaybackServer interface {
Run(ctx context.Context) error
GetDeviceForUser(user string) (*PlaybackDevice, error)
GetMediaFile(id string) (*model.MediaFile, error)
GetCtx() *context.Context
}
type playbackServer struct {
ctx *context.Context
datastore model.DataStore
playbackDevices []PlaybackDevice
}
// GetInstance returns the playback-server singleton
func GetInstance() PlaybackServer {
return singleton.GetInstance(func() *playbackServer {
return &playbackServer{}
})
}
// Run starts the playback server which serves request until canceled using the given context
func (ps *playbackServer) Run(ctx context.Context) error {
ps.datastore = persistence.New(db.Db())
devices, err := ps.initDeviceStatus(conf.Server.Jukebox.Devices, conf.Server.Jukebox.Default)
ps.playbackDevices = devices
if err != nil {
return err
}
log.Info(ctx, fmt.Sprintf("%d audio devices found", len(devices)))
defaultDevice, _ := ps.getDefaultDevice()
log.Info(ctx, "Using audio device: "+defaultDevice.DeviceName)
ps.ctx = &ctx
<-ctx.Done()
return nil
}
// GetCtx produces the context this server was started with. Used for data-retrieval and cancellation
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)))
defaultDeviceFound := false
if defaultDevice == "" {
// if there are no devices given and no default device, we create a sythetic device named "auto"
if len(devices) == 0 {
pbDevices[0] = *NewPlaybackDevice(ps, "auto", "auto")
}
// 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]))
}
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))
}
pbDevices[0].Default = true
return pbDevices, nil
}
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))
}
pbDevices[idx] = *NewPlaybackDevice(ps, audioDevice[0], audioDevice[1])
if audioDevice[0] == defaultDevice {
pbDevices[idx].Default = true
defaultDeviceFound = true
}
}
if !defaultDeviceFound {
return []PlaybackDevice{}, fmt.Errorf("default device name not found: %s ", defaultDevice)
}
return pbDevices, nil
}
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")
}
// GetMediaFile retrieves the MediaFile given by the id parameter
func (ps *playbackServer) GetMediaFile(id string) (*model.MediaFile, error) {
return ps.datastore.MediaFile(*ps.ctx).Get(id)
}
// 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")
// README: here we might plug-in the user-device mapping one fine day
device, err := ps.getDefaultDevice()
if err != nil {
return &PlaybackDevice{}, err
}
device.User = user
return device, nil
}

View File

@@ -1,150 +0,0 @@
package playback
import (
"fmt"
"math/rand"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type Queue struct {
Index int
Items model.MediaFiles
}
func NewQueue() *Queue {
return &Queue{
Index: -1,
Items: model.MediaFiles{},
}
}
func (pd *Queue) String() string {
filenames := ""
for idx, item := range pd.Items {
filenames += fmt.Sprint(idx) + ":" + item.Path + " "
}
return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames)
}
// returns the current mediafile or nil
func (pd *Queue) Current() *model.MediaFile {
if pd.Index == -1 {
return nil
}
if pd.Index >= len(pd.Items) {
log.Error("internal error: current song index out of bounds", "idx", pd.Index, "length", len(pd.Items))
return nil
}
return &pd.Items[pd.Index]
}
// returns the whole queue
func (pd *Queue) Get() model.MediaFiles {
return pd.Items
}
func (pd *Queue) Size() int {
return len(pd.Items)
}
func (pd *Queue) IsEmpty() bool {
return len(pd.Items) < 1
}
// set is similar to a clear followed by a add, but will not change the currently playing track.
func (pd *Queue) Set(items model.MediaFiles) {
pd.Clear()
pd.Items = append(pd.Items, items...)
}
// adding mediafiles to the queue
func (pd *Queue) Add(items model.MediaFiles) {
pd.Items = append(pd.Items, items...)
if pd.Index == -1 && len(pd.Items) > 0 {
pd.Index = 0
}
}
// empties whole queue
func (pd *Queue) Clear() {
pd.Index = -1
pd.Items = nil
}
// idx Zero-based index of the song to skip to or remove.
func (pd *Queue) Remove(idx int) {
current := pd.Current()
backupID := ""
if current != nil {
backupID = current.ID
}
pd.Items = append(pd.Items[:idx], pd.Items[idx+1:]...)
var err error
pd.Index, err = pd.getMediaFileIndexByID(backupID)
if err != nil {
// we seem to have deleted the current id, setting to default:
pd.Index = -1
}
}
func (pd *Queue) Shuffle() {
current := pd.Current()
backupID := ""
if current != nil {
backupID = current.ID
}
rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] })
var err error
pd.Index, err = pd.getMediaFileIndexByID(backupID)
if err != nil {
log.Error("Could not find ID while shuffling: " + backupID)
}
}
func (pd *Queue) getMediaFileIndexByID(id string) (int, error) {
for idx, item := range pd.Items {
if item.ID == id {
return idx, nil
}
}
return -1, fmt.Errorf("ID not found in playlist: " + id)
}
// Sets the index to a new, valid value inside the Items. Values lower than zero are going to be zero,
// values above will be limited by number of items.
func (pd *Queue) SetIndex(idx int) {
pd.Index = max(0, min(idx, len(pd.Items)-1))
}
// Are we at the last track?
func (pd *Queue) IsAtLastElement() bool {
return (pd.Index + 1) >= len(pd.Items)
}
// Goto next index
func (pd *Queue) IncreaseIndex() {
if !pd.IsAtLastElement() {
pd.SetIndex(pd.Index + 1)
}
}
func max(x, y int) int {
if x < y {
return y
}
return x
}
func min(x, y int) int {
if x > y {
return y
}
return x
}

View File

@@ -1,121 +0,0 @@
package playback
import (
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Queues", func() {
var queue *Queue
BeforeEach(func() {
queue = NewQueue()
})
Describe("use empty queue", func() {
It("is empty", func() {
Expect(queue.Items).To(BeEmpty())
Expect(queue.Index).To(Equal(-1))
})
})
Describe("Operate on small queue", func() {
BeforeEach(func() {
mfs := model.MediaFiles{
{
ID: "1", Artist: "Queen", Compilation: false, Path: "/music1/hammer.mp3",
},
{
ID: "2", Artist: "Vinyard Rose", Compilation: false, Path: "/music1/cassidy.mp3",
},
}
queue.Add(mfs)
})
It("contains the preloaded data", func() {
Expect(queue.Get).ToNot(BeNil())
Expect(queue.Size()).To(Equal(2))
})
It("could read data by ID", func() {
idx, err := queue.getMediaFileIndexByID("1")
Expect(err).ToNot(HaveOccurred())
Expect(idx).ToNot(BeNil())
Expect(idx).To(Equal(0))
queue.SetIndex(idx)
mf := queue.Current()
Expect(mf).ToNot(BeNil())
Expect(mf.ID).To(Equal("1"))
Expect(mf.Artist).To(Equal("Queen"))
Expect(mf.Path).To(Equal("/music1/hammer.mp3"))
})
})
Describe("Read/Write operations", func() {
BeforeEach(func() {
mfs := model.MediaFiles{
{
ID: "1", Artist: "Queen", Compilation: false, Path: "/music1/hammer.mp3",
},
{
ID: "2", Artist: "Vinyard Rose", Compilation: false, Path: "/music1/cassidy.mp3",
},
{
ID: "3", Artist: "Pink Floyd", Compilation: false, Path: "/music1/time.mp3",
},
{
ID: "4", Artist: "Mike Oldfield", Compilation: false, Path: "/music1/moonlight-shadow.mp3",
},
{
ID: "5", Artist: "Red Hot Chili Peppers", Compilation: false, Path: "/music1/californication.mp3",
},
}
queue.Add(mfs)
})
It("contains the preloaded data", func() {
Expect(queue.Get).ToNot(BeNil())
Expect(queue.Size()).To(Equal(5))
})
It("could read data by ID", func() {
idx, err := queue.getMediaFileIndexByID("5")
Expect(err).ToNot(HaveOccurred())
Expect(idx).ToNot(BeNil())
Expect(idx).To(Equal(4))
queue.SetIndex(idx)
mf := queue.Current()
Expect(mf).ToNot(BeNil())
Expect(mf.ID).To(Equal("5"))
Expect(mf.Artist).To(Equal("Red Hot Chili Peppers"))
Expect(mf.Path).To(Equal("/music1/californication.mp3"))
})
It("could shuffle the data correctly", func() {
queue.Shuffle()
Expect(queue.Size()).To(Equal(5))
})
It("could remove entries correctly", func() {
queue.Remove(0)
Expect(queue.Size()).To(Equal(4))
queue.Remove(3)
Expect(queue.Size()).To(Equal(3))
})
It("clear the whole thing on request", func() {
Expect(queue.Size()).To(Equal(5))
queue.Clear()
Expect(queue.Size()).To(Equal(0))
})
})
})

View File

@@ -23,7 +23,6 @@ import (
type Playlists interface {
ImportFile(ctx context.Context, dir string, fname string) (*model.Playlist, error)
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
}
type playlists struct {
@@ -48,26 +47,6 @@ func (s *playlists) ImportFile(ctx context.Context, dir string, fname string) (*
return pls, err
}
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
owner, _ := request.UserFrom(ctx)
pls := &model.Playlist{
OwnerID: owner.ID,
Public: false,
Sync: true,
}
pls, err := s.parseM3U(ctx, pls, "", reader)
if err != nil {
log.Error(ctx, "Error parsing playlist", err)
return nil, err
}
err = s.ds.Playlist(ctx).Put(pls)
if err != nil {
log.Error(ctx, "Error saving playlist", err)
return nil, err
}
return pls, nil
}
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, baseDir string) (*model.Playlist, error) {
pls, err := s.newSyncedPlaylist(baseDir, playlistFile)
if err != nil {
@@ -128,40 +107,31 @@ func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.R
return pls, nil
}
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, reader io.Reader) (*model.Playlist, error) {
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, file io.Reader) (*model.Playlist, error) {
mediaFileRepository := s.ds.MediaFile(ctx)
scanner := bufio.NewScanner(reader)
scanner := bufio.NewScanner(file)
scanner.Split(scanLines)
var mfs model.MediaFiles
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "#PLAYLIST:") {
if split := strings.Split(line, ":"); len(split) >= 2 {
pls.Name = split[1]
}
continue
}
path := strings.TrimSpace(scanner.Text())
// Skip empty lines and extended info
if line == "" || strings.HasPrefix(line, "#") {
if path == "" || strings.HasPrefix(path, "#") {
continue
}
if strings.HasPrefix(line, "file://") {
line = strings.TrimPrefix(line, "file://")
line, _ = url.QueryUnescape(line)
if strings.HasPrefix(path, "file://") {
path = strings.TrimPrefix(path, "file://")
path, _ = url.QueryUnescape(path)
}
if baseDir != "" && !filepath.IsAbs(line) {
line = filepath.Join(baseDir, line)
if !filepath.IsAbs(path) {
path = filepath.Join(baseDir, path)
}
mf, err := mediaFileRepository.FindByPath(line)
mf, err := mediaFileRepository.FindByPath(path)
if err != nil {
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", line, err)
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path, err)
continue
}
mfs = append(mfs, *mf)
}
if pls.Name == "" {
pls.Name = time.Now().Format(time.RFC3339)
}
pls.Tracks = nil
pls.AddMediaFiles(mfs)

View File

@@ -2,10 +2,6 @@ package core
import (
"context"
"os"
"time"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
@@ -16,16 +12,13 @@ import (
var _ = Describe("Playlists", func() {
var ds model.DataStore
var ps Playlists
var mp mockedPlaylist
ctx := context.Background()
BeforeEach(func() {
mp = mockedPlaylist{}
ds = &tests.MockDataStore{
MockedMediaFile: &mockedMediaFile{},
MockedPlaylist: &mp,
MockedPlaylist: &mockedPlaylist{},
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
Describe("ImportFile", func() {
@@ -36,12 +29,10 @@ var _ = Describe("Playlists", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
Expect(err).To(BeNil())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
Expect(mp.last).To(Equal(pls))
})
It("parses playlists using LF ending", func() {
@@ -57,37 +48,6 @@ var _ = Describe("Playlists", func() {
})
})
Describe("ImportM3U", func() {
BeforeEach(func() {
ps = NewPlaylists(ds)
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
It("parses well-formed playlists", func() {
f, _ := os.Open("tests/fixtures/playlists/pls-post-with-name.m3u")
defer f.Close()
pls, err := ps.ImportM3U(ctx, f)
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("playlist 1"))
Expect(err).To(BeNil())
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
Expect(mp.last).To(Equal(pls))
f.Close()
})
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
f, _ := os.Open("tests/fixtures/playlists/pls-post.m3u")
defer f.Close()
pls, err := ps.ImportM3U(ctx, f)
Expect(err).To(BeNil())
_, err = time.Parse(time.RFC3339, pls.Name)
Expect(err).To(BeNil())
})
})
})
type mockedMediaFile struct {
@@ -102,7 +62,6 @@ func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) {
}
type mockedPlaylist struct {
last *model.Playlist
model.PlaylistRepository
}
@@ -110,7 +69,6 @@ func (r *mockedPlaylist) FindByPath(string) (*model.Playlist, error) {
return nil, model.ErrNotFound
}
func (r *mockedPlaylist) Put(pls *model.Playlist) error {
r.last = pls
func (r *mockedPlaylist) Put(*model.Playlist) error {
return nil
}

View File

@@ -40,16 +40,16 @@ var _ = Describe("PlayTracker", func() {
tracker = newPlayTracker(ds, events.GetBroker())
track = model.MediaFile{
ID: "123",
Title: "Track Title",
Album: "Track Album",
AlbumID: "al-1",
Artist: "Track Artist",
ArtistID: "ar-1",
AlbumArtist: "Track AlbumArtist",
TrackNumber: 1,
Duration: 180,
MbzRecordingID: "mbz-123",
ID: "123",
Title: "Track Title",
Album: "Track Album",
AlbumID: "al-1",
Artist: "Track Artist",
ArtistID: "ar-1",
AlbumArtist: "Track AlbumArtist",
TrackNumber: 1,
Duration: 180,
MbzTrackID: "mbz-123",
}
_ = ds.MediaFile(ctx).Put(&track)
artist = model.Artist{ID: "ar-1"}

View File

@@ -1,49 +0,0 @@
package migrations
import (
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddRelRecYear, downAddRelRecYear)
}
func upAddRelRecYear(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
add date varchar(255) default '' not null;
alter table media_file
add original_year int default 0 not null;
alter table media_file
add original_date varchar(255) default '' not null;
alter table media_file
add release_year int default 0 not null;
alter table media_file
add release_date varchar(255) default '' not null;
alter table album
add date varchar(255) default '' not null;
alter table album
add min_original_year int default 0 not null;
alter table album
add max_original_year int default 0 not null;
alter table album
add original_date varchar(255) default '' not null;
alter table album
add release_date varchar(255) default '' not null;
alter table album
add releases integer default 0 not null;
`)
if err != nil {
return err
}
notice(tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(tx)
}
func downAddRelRecYear(tx *sql.Tx) error {
return nil
}

View File

@@ -1,27 +0,0 @@
package migrations
import (
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upRenameMusicbrainzRecordingId, downRenameMusicbrainzRecordingId)
}
func upRenameMusicbrainzRecordingId(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
rename column mbz_track_id to mbz_recording_id;
`)
return err
}
func downRenameMusicbrainzRecordingId(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
rename column mbz_recording_id to mbz_track_id;
`)
return err
}

80
go.mod
View File

@@ -1,55 +1,53 @@
module github.com/navidrome/navidrome
go 1.21
go 1.19
require (
code.cloudfoundry.org/go-diodes v0.0.0-20190809170250-f77fb823c7ee
github.com/DexterLB/mpvipc v0.0.0-20221227161445-38b9935eae9d
github.com/Masterminds/squirrel v1.5.4
github.com/ReneKroon/ttlcache/v2 v2.11.0
github.com/beego/beego/v2 v2.1.3
github.com/beego/beego/v2 v2.0.7
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
github.com/deluan/rest v0.0.0-20211101235434-380523c4bb47
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1
github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25
github.com/dhowden/tag v0.0.0-20220618230019-adf36e896086
github.com/disintegration/imaging v1.6.2
github.com/djherbis/atime v1.1.0
github.com/djherbis/fscache v0.10.2-0.20220222230828-2909c950912d
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/chi/v5 v5.0.8
github.com/go-chi/cors v1.2.1
github.com/go-chi/httprate v0.7.4
github.com/go-chi/jwtauth/v5 v5.1.1
github.com/google/uuid v1.4.0
github.com/go-chi/jwtauth/v5 v5.1.0
github.com/google/uuid v1.3.0
github.com/google/wire v0.5.0
github.com/hashicorp/go-multierror v1.1.1
github.com/kardianos/service v1.2.2
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.0.17
github.com/lestrrat-go/jwx/v2 v2.0.9
github.com/matoous/go-nanoid/v2 v2.0.0
github.com/mattn/go-sqlite3 v1.14.18
github.com/mattn/go-sqlite3 v1.14.16
github.com/mattn/go-zglob v0.0.3
github.com/microcosm-cc/bluemonday v1.0.26
github.com/mileusna/useragent v1.3.4
github.com/onsi/ginkgo/v2 v2.13.1
github.com/onsi/gomega v1.30.0
github.com/microcosm-cc/bluemonday v1.0.23
github.com/mileusna/useragent v1.3.2
github.com/onsi/ginkgo/v2 v2.9.4
github.com/onsi/gomega v1.27.6
github.com/pressly/goose/v3 v3.11.2
github.com/prometheus/client_golang v1.17.0
github.com/prometheus/client_golang v1.15.1
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.8.0
github.com/sirupsen/logrus v1.9.0
github.com/spf13/cobra v1.7.0
github.com/spf13/viper v1.15.0
github.com/stretchr/testify v1.8.4
github.com/stretchr/testify v1.8.2
github.com/unrolled/secure v1.13.0
github.com/xrash/smetrics v0.0.0-20200730060457-89a2a8a1fb0b
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e
golang.org/x/image v0.14.0
golang.org/x/sync v0.5.0
golang.org/x/text v0.14.0
golang.org/x/tools v0.15.0
golang.org/x/image v0.7.0
golang.org/x/sync v0.2.0
golang.org/x/text v0.9.0
golang.org/x/tools v0.9.1
gopkg.in/yaml.v3 v3.0.1
)
@@ -58,44 +56,38 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-logr/logr v1.3.0 // indirect
github.com/go-logr/logr v1.2.4 // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-cmp v0.5.9 // 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/golang-lru v0.5.4 // 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
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.4 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
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/onsi/ginkgo v1.16.5 // 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
github.com/prometheus/procfs v0.11.1 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.42.0 // indirect
github.com/prometheus/procfs v0.9.0 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 // indirect
github.com/spf13/afero v1.9.3 // indirect
github.com/spf13/cast v1.5.0 // indirect
@@ -103,15 +95,11 @@ require (
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
go.uber.org/goleak v1.1.11 // indirect
golang.org/x/crypto v0.15.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/mod v0.14.0 // indirect
golang.org/x/net v0.18.0 // indirect
golang.org/x/sys v0.14.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
golang.org/x/crypto v0.8.0 // indirect
golang.org/x/mod v0.10.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
)

206
go.sum
View File

@@ -40,17 +40,14 @@ code.cloudfoundry.org/go-diodes v0.0.0-20190809170250-f77fb823c7ee/go.mod h1:Jzi
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-20221227161445-38b9935eae9d h1:UyefntSsjbYaTDUdZF4A1vPZX3Xpnewv6JNBzQPYAzY=
github.com/DexterLB/mpvipc v0.0.0-20221227161445-38b9935eae9d/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=
github.com/ReneKroon/ttlcache/v2 v2.11.0/go.mod h1:mBxvsNY+BT8qLLd6CuAJubbKo6r0jh3nb5et22bbfGY=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beego/beego/v2 v2.1.3 h1:x436yz6jrSasYBzfOP39S097kvq5/5fBTFfEvVA456M=
github.com/beego/beego/v2 v2.1.3/go.mod h1:0J0RQVIpepnRUfu6ax+kLVVB1FcdYryHK9lpRl5wvbY=
github.com/beego/beego/v2 v2.0.7 h1:9KNnUM40tn3pbCOFfe6SJ1oOL0oTi/oBS/C/wCEdAXA=
github.com/beego/beego/v2 v2.0.7/go.mod h1:f0uOEkmJWgAuDTlTxUdgJzwG3PDSIf3UWF3NpMohbFE=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
@@ -65,21 +62,20 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
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/cpuguy83/go-md2man/v2 v2.0.2/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=
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc=
github.com/deluan/rest v0.0.0-20211101235434-380523c4bb47 h1:IhGAYGDi212gspq0XkYAI+DN5e9lfAIm8Qgu1wj9yN4=
github.com/deluan/rest v0.0.0-20211101235434-380523c4bb47/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/dhowden/tag v0.0.0-20230630033851-978a0926ee25 h1:simG0vMYFvNriGhaaat7QVVkaVkXzvqcohaBoLZl9Hg=
github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25/go.mod h1:Z3Lomva4pyMWYezjMAU5QWRh0p1VvO4199OHlFnyKkM=
github.com/dhowden/tag v0.0.0-20220618230019-adf36e896086 h1:ORubSQoKnncsBnR4zD9CuYFJCPOCuSNEpWEZrDdBXkc=
github.com/dhowden/tag v0.0.0-20220618230019-adf36e896086/go.mod h1:Z3Lomva4pyMWYezjMAU5QWRh0p1VvO4199OHlFnyKkM=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g=
@@ -88,8 +84,6 @@ github.com/djherbis/fscache v0.10.2-0.20220222230828-2909c950912d h1:eAikRiT337j
github.com/djherbis/fscache v0.10.2-0.20220222230828-2909c950912d/go.mod h1:+uJNKpxCg52qVRGr+srICjiY8QvV0riatTzCGMUuSEY=
github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE=
github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -98,36 +92,27 @@ 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.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
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/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.7.4 h1:a2GIjv8he9LRf3712zxxnRdckQCm7I8y8yQhkJ84V6M=
github.com/go-chi/httprate v0.7.4/go.mod h1:6GOYBSwnpra4CQfAKXu8sQZg+nZ0M1g9QnyFvxrAB8A=
github.com/go-chi/jwtauth/v5 v5.1.1 h1:Pjixqu5YkjE9sCLpzE01L0Q4sQzJIPdo7uz9r8ftp/c=
github.com/go-chi/jwtauth/v5 v5.1.1/go.mod h1:CYP1WSbzD4MPuKCr537EM3kfFhSQgpUEtMJFuYJjqWU=
github.com/go-chi/jwtauth/v5 v5.1.0 h1:wJyf2YZ/ohPvNJBwPOzZaQbyzwgMZZceE1m8FOzXLeA=
github.com/go-chi/jwtauth/v5 v5.1.0/go.mod h1:MA93hc1au3tAQwCKry+fI4LqJ5MIVN4XSsglOo+lSc8=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
@@ -173,8 +158,8 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -193,8 +178,8 @@ github.com/google/pprof v0.0.0-20230323073829-e72429f035bd/go.mod h1:79YE0hCXdHa
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8=
github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
@@ -204,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=
@@ -225,20 +202,15 @@ github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
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=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60=
github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
@@ -253,44 +225,36 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/blackmagic v1.0.1 h1:lS5Zts+5HIC/8og6cGHb0uCcNCa3OUt1ygh3Qz2Fe80=
github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8=
github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.0.17 h1:+WavkdKVWO90ECnIzUetOnjY+kcqqw4WXEUmil7sMCE=
github.com/lestrrat-go/jwx/v2 v2.0.17/go.mod h1:G8randPHLGAqhcNCqtt6/V/7E6fvJRl3Sf9z777eTQ0=
github.com/lestrrat-go/jwx/v2 v2.0.9 h1:TRX4Q630UXxPVLvP5vGaqVJO7S+0PE6msRZUsFSBoC8=
github.com/lestrrat-go/jwx/v2 v2.0.9/go.mod h1:K68euYaR95FnL0hIQB8VvzL70vB7pSifbJUydCTPmgM=
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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
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=
github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0=
github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/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.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-zglob v0.0.3 h1:6Ry4EYsScDyt5di4OI6xw1bYhOqfE5S33Z1OPy+d+To=
github.com/mattn/go-zglob v0.0.3/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo=
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=
github.com/mileusna/useragent v1.3.4/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/microcosm-cc/bluemonday v1.0.23 h1:SMZe2IGa0NuHvnVNAZ+6B38gsTbi5e4sViiWJyDDqFY=
github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4=
github.com/mileusna/useragent v1.3.2 h1:yGBQVNkyrlnSe4l0rlaQoH8XlG9xDkc6a7ygwPxALoU=
github.com/mileusna/useragent v1.3.2/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
@@ -300,16 +264,15 @@ github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.13.1 h1:LNGfMbR2OVGBfXjvRZIZ2YCTQdGKtPLvuI1rMCCj3OU=
github.com/onsi/ginkgo/v2 v2.13.1/go.mod h1:XStQ8QcGwLyF4HdfcZB8SFOS/MWCgDuXMSBe6zrvLgM=
github.com/onsi/ginkgo/v2 v2.9.4 h1:xR7vG4IXt5RWx6FfIjyAtsoMAtnc3C/rFXBBd2AjZwE=
github.com/onsi/ginkgo/v2 v2.9.4/go.mod h1:gCQYp2Q+kSoIj7ykSVb9nskRSsR6PUj4AiLywzIhbKM=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8=
github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
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=
@@ -317,31 +280,27 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.11.2 h1:QgTP45FhBBHdmf7hWKlbWFHtwPtxo0phSDkwDKGUrYs=
github.com/pressly/goose/v3 v3.11.2/go.mod h1:LWQzSc4vwfHA/3B8getTp8g3J5Z8tFBxgxinmGlMlJk=
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI=
github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=
github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 h1:DAYUYH5869yV94zvCES9F51oYtN5oGlwjxJJz7ZCnik=
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18/go.mod h1:nkxAfR/5quYxwPZhyDxgasBMnRtBZd0FCEpawpjMUFg=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
@@ -350,8 +309,8 @@ github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk=
github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -371,14 +330,12 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/unrolled/secure v1.13.0 h1:sdr3Phw2+f8Px8HE5sd1EHdj1aV3yUwed/uZXChLFsk=
github.com/unrolled/secure v1.13.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/xrash/smetrics v0.0.0-20200730060457-89a2a8a1fb0b h1:tnWgqoOBmInkt5pbLjagwNVjjT4RdJhFHzL1ebCSRh8=
github.com/xrash/smetrics v0.0.0-20200730060457-89a2a8a1fb0b/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -404,8 +361,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.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
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=
@@ -418,14 +376,11 @@ 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=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -438,10 +393,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=
@@ -453,8 +405,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -491,9 +443,9 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -516,20 +468,18 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -552,6 +502,7 @@ golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -562,22 +513,18 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -586,9 +533,9 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -647,8 +594,8 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8=
golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk=
golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -743,18 +690,15 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -771,25 +715,15 @@ honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.22.1 h1:P2+Dhp5FR1RlVRkQ3dDfCiv3Ok8XPxqpe70IjYVA9oE=
modernc.org/sqlite v1.22.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

View File

@@ -20,12 +20,6 @@ type Album struct {
AllArtistIDs string `structs:"all_artist_ids" json:"allArtistIds" orm:"column(all_artist_ids)"`
MaxYear int `structs:"max_year" json:"maxYear"`
MinYear int `structs:"min_year" json:"minYear"`
Date string `structs:"date" json:"date,omitempty"`
MaxOriginalYear int `structs:"max_original_year" json:"maxOriginalYear"`
MinOriginalYear int `structs:"min_original_year" json:"minOriginalYear"`
OriginalDate string `structs:"original_date" json:"originalDate,omitempty"`
ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"`
Releases int `structs:"releases" json:"releases"`
Compilation bool `structs:"compilation" json:"compilation"`
Comment string `structs:"comment" json:"comment,omitempty"`
SongCount int `structs:"song_count" json:"songCount"`
@@ -61,9 +55,8 @@ func (a Album) CoverArtID() ArtworkID {
}
type DiscID struct {
AlbumID string `json:"albumId"`
ReleaseDate string `json:"releaseDate"`
DiscNumber int `json:"discNumber"`
AlbumID string `json:"albumId"`
DiscNumber int `json:"discNumber"`
}
type Albums []Album

View File

@@ -49,7 +49,7 @@ type ArtistIndexes []ArtistIndex
type ArtistRepository interface {
CountAll(options ...QueryOptions) (int64, error)
Exists(id string) (bool, error)
Put(m *Artist, colsToUpdate ...string) error
Put(m *Artist) error
Get(id string) (*Artist, error)
GetAll(options ...QueryOptions) (Artists, error)
Search(q string, offset int, size int) (Artists, error)

View File

@@ -23,7 +23,6 @@ var _ = Describe("Criteria", func() {
All{
StartsWith{"comment": "this"},
InTheRange{"year": []int{1980, 1990}},
IsNot{"genre": "test"},
},
},
Sort: "title",
@@ -44,8 +43,7 @@ var _ = Describe("Criteria", func() {
},
{ "all": [
{ "startsWith": {"comment": "this"} },
{ "inTheRange": {"year":[1980,1990]} },
{ "isNot": { "genre": "test" }}
{ "inTheRange": {"year":[1980,1990]} }
]
}
],
@@ -64,8 +62,8 @@ var _ = Describe("Criteria", func() {
It("generates valid SQL", func() {
sql, args, err := goObj.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("(media_file.title LIKE ? AND media_file.title NOT LIKE ? AND (media_file.artist <> ? OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) AND COALESCE(genre.name, '') <> ?))"))
gomega.Expect(args).To(gomega.ConsistOf("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "test"))
gomega.Expect(sql).To(gomega.Equal("(media_file.title LIKE ? AND media_file.title NOT LIKE ? AND (media_file.artist <> ? OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?)))"))
gomega.Expect(args).To(gomega.ConsistOf("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990))
})
It("marshals to JSON", func() {

View File

@@ -15,11 +15,6 @@ var fieldMap = map[string]*mappedField{
"tracknumber": {field: "media_file.track_number"},
"discnumber": {field: "media_file.disc_number"},
"year": {field: "media_file.year"},
"date": {field: "media_file.date"},
"originalyear": {field: "media_file.original_year"},
"originaldate": {field: "media_file.original_date"},
"releaseyear": {field: "media_file.release_year"},
"releasedate": {field: "media_file.release_date"},
"size": {field: "media_file.size"},
"compilation": {field: "media_file.compilation"},
"dateadded": {field: "media_file.created_at"},
@@ -40,7 +35,7 @@ var fieldMap = map[string]*mappedField{
"bitrate": {field: "media_file.bit_rate"},
"bpm": {field: "media_file.bpm"},
"channels": {field: "media_file.channels"},
"genre": {field: "COALESCE(genre.name, '')"},
"genre": {field: "genre.name"},
"loved": {field: "COALESCE(annotation.starred, false)"},
"dateloved": {field: "annotation.starred_at"},
"lastplayed": {field: "annotation.play_date"},

View File

@@ -3,7 +3,6 @@ package model
import (
"mime"
"path/filepath"
"sort"
"strings"
"time"
@@ -33,11 +32,6 @@ type MediaFile struct {
DiscNumber int `structs:"disc_number" json:"discNumber"`
DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"`
Year int `structs:"year" json:"year"`
Date string `structs:"date" json:"date,omitempty"`
OriginalYear int `structs:"original_year" json:"originalYear"`
OriginalDate string `structs:"original_date" json:"originalDate,omitempty"`
ReleaseYear int `structs:"release_year" json:"releaseYear"`
ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"`
Size int64 `structs:"size" json:"size"`
Suffix string `structs:"suffix" json:"suffix"`
Duration float32 `structs:"duration" json:"duration"`
@@ -59,7 +53,7 @@ type MediaFile struct {
Lyrics string `structs:"lyrics" json:"lyrics,omitempty"`
Bpm int `structs:"bpm" json:"bpm,omitempty"`
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
MbzRecordingID string `structs:"mbz_recording_id" json:"mbzRecordingID,omitempty" orm:"column(mbz_recording_id)"`
MbzTrackID string `structs:"mbz_track_id" json:"mbzTrackId,omitempty" orm:"column(mbz_track_id)"`
MbzReleaseTrackID string `structs:"mbz_release_track_id" json:"mbzReleaseTrackId,omitempty" orm:"column(mbz_release_track_id)"`
MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty" orm:"column(mbz_album_id)"`
MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty" orm:"column(mbz_artist_id)"`
@@ -114,11 +108,6 @@ func (mfs MediaFiles) ToAlbum() Album {
var songArtistIds []string
var mbzAlbumIds []string
var comments []string
var years []int
var dates []string
var originalYears []int
var originalDates []string
var releaseDates []string
for _, m := range mfs {
// We assume these attributes are all the same for all songs on an album
a.ID = m.AlbumID
@@ -141,11 +130,12 @@ func (mfs MediaFiles) ToAlbum() Album {
// Calculated attributes based on aggregations
a.Duration += m.Duration
a.Size += m.Size
years = append(years, m.Year)
dates = append(dates, m.Date)
originalYears = append(originalYears, m.OriginalYear)
originalDates = append(originalDates, m.OriginalDate)
releaseDates = append(releaseDates, m.ReleaseDate)
if a.MinYear == 0 {
a.MinYear = m.Year
} else if m.Year > 0 {
a.MinYear = number.Min(a.MinYear, m.Year)
}
a.MaxYear = number.Max(a.MaxYear, m.Year)
a.UpdatedAt = newer(a.UpdatedAt, m.UpdatedAt)
a.CreatedAt = older(a.CreatedAt, m.CreatedAt)
a.Genres = append(a.Genres, m.Genres...)
@@ -161,15 +151,11 @@ func (mfs MediaFiles) ToAlbum() Album {
a.EmbedArtPath = m.Path
}
}
a.Paths = strings.Join(mfs.Dirs(), consts.Zwsp)
a.Date, _ = allOrNothing(dates)
a.OriginalDate, _ = allOrNothing(originalDates)
a.ReleaseDate, a.Releases = allOrNothing(releaseDates)
a.MinYear, a.MaxYear = minMax(years)
a.MinOriginalYear, a.MaxOriginalYear = minMax(originalYears)
a.Comment, _ = allOrNothing(comments)
a.Comment, _ = allOrNothing(comments)
comments = slices.Compact(comments)
if len(comments) == 1 {
a.Comment = comments[0]
}
a.Genre = slice.MostFrequent(a.Genres).Name
slices.SortFunc(a.Genres, func(a, b Genre) bool { return a.ID < b.ID })
a.Genres = slices.Compact(a.Genres)
@@ -183,32 +169,6 @@ func (mfs MediaFiles) ToAlbum() Album {
return a
}
func allOrNothing(items []string) (string, int) {
items = slices.Compact(items)
if len(items) == 1 {
return items[0], 1
}
if len(items) > 1 {
sort.Strings(items)
return "", len(slices.Compact(items))
}
return "", 0
}
func minMax(items []int) (int, int) {
var max = items[0]
var min = items[0]
for _, value := range items {
max = number.Max(max, value)
if min == 0 {
min = value
} else if value > 0 {
min = number.Min(min, value)
}
}
return min, max
}
func newer(t1, t2 time.Time) time.Time {
if t1.After(t2) {
return t1

View File

@@ -60,7 +60,7 @@ var _ = Describe("MediaFiles", func() {
When("we have only one song", func() {
BeforeEach(func() {
mfs = MediaFiles{
{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")},
{Duration: 100.2, Size: 1024, Year: 1985, UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")},
}
})
It("calculates the aggregates correctly", func() {
@@ -69,18 +69,17 @@ var _ = Describe("MediaFiles", func() {
Expect(album.Size).To(Equal(int64(1024)))
Expect(album.MinYear).To(Equal(1985))
Expect(album.MaxYear).To(Equal(1985))
Expect(album.Date).To(Equal("1985-01-02"))
Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:30")))
Expect(album.CreatedAt).To(Equal(t("2022-12-19 08:30")))
})
})
When("we have multiple songs with different dates", func() {
When("we have multiple songs", func() {
BeforeEach(func() {
mfs = MediaFiles{
{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")},
{Duration: 200.2, Size: 2048, Year: 0, Date: "", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 08:30")},
{Duration: 150.6, Size: 1000, Year: 1986, Date: "1986-01-02", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 07:30")},
{Duration: 100.2, Size: 1024, Year: 1985, UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")},
{Duration: 200.2, Size: 2048, Year: 0, UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 08:30")},
{Duration: 150.6, Size: 1000, Year: 1986, UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 07:30")},
}
})
It("calculates the aggregates correctly", func() {
@@ -89,7 +88,6 @@ var _ = Describe("MediaFiles", func() {
Expect(album.Size).To(Equal(int64(4072)))
Expect(album.MinYear).To(Equal(1985))
Expect(album.MaxYear).To(Equal(1986))
Expect(album.Date).To(BeEmpty())
Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:45")))
Expect(album.CreatedAt).To(Equal(t("2022-12-19 07:30")))
})
@@ -106,21 +104,6 @@ var _ = Describe("MediaFiles", func() {
})
})
})
When("we have multiple songs with same dates", func() {
BeforeEach(func() {
mfs = MediaFiles{
{Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")},
{Duration: 200.2, Size: 2048, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 08:30")},
{Duration: 150.6, Size: 1000, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 07:30")},
}
})
It("sets the date field correctly", func() {
album := mfs.ToAlbum()
Expect(album.Date).To(Equal("1985-01-02"))
Expect(album.MinYear).To(Equal(1985))
Expect(album.MaxYear).To(Equal(1985))
})
})
})
Context("Calculated attributes", func() {
Context("Genres", func() {

View File

@@ -27,7 +27,7 @@ func NewAlbumRepository(ctx context.Context, o orm.QueryExecutor) model.AlbumRep
"name": "order_album_name asc, order_album_artist_name asc",
"artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
"random": "RANDOM()",
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name, order_album_name asc",
"max_year": "max_year asc, name, order_album_name asc",
"recently_added": recentlyAddedSort(),
}
r.filterMappings = map[string]filterFunc{
@@ -154,10 +154,6 @@ func (r *albumRepository) purgeEmpty() error {
func (r *albumRepository) Search(q string, offset int, size int) (model.Albums, error) {
results := model.Albums{}
err := r.doSearch(q, offset, size, &results, "name")
if err != nil {
return nil, err
}
err = r.loadAlbumGenres(&results)
return results, err
}

View File

@@ -60,10 +60,10 @@ func (r *artistRepository) Exists(id string) (bool, error) {
return r.exists(Select().Where(Eq{"artist.id": id}))
}
func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error {
func (r *artistRepository) Put(a *model.Artist) error {
a.FullText = getFullText(a.Name, a.SortArtistName)
dba := r.fromModel(a)
_, err := r.put(dba.ID, dba, colsToUpdate...)
_, err := r.put(dba.ID, dba)
if err != nil {
return err
}

View File

@@ -26,8 +26,8 @@ func NewMediaFileRepository(ctx context.Context, o orm.QueryExecutor) *mediaFile
r.ormer = o
r.tableName = "media_file"
r.sortMappings = map[string]string{
"artist": "order_artist_name asc, order_album_name asc, release_date asc, disc_number asc, track_number asc",
"album": "order_album_name asc, release_date asc, disc_number asc, track_number asc, order_artist_name asc, title asc",
"artist": "order_artist_name asc, order_album_name asc, disc_number asc, track_number asc",
"album": "order_album_name asc, disc_number asc, track_number asc, order_artist_name asc, title asc",
"random": "RANDOM()",
}
r.filterMappings = map[string]filterFunc{
@@ -187,10 +187,6 @@ func (r *mediaFileRepository) removeNonAlbumArtistIds() error {
func (r *mediaFileRepository) Search(q string, offset int, size int) (model.MediaFiles, error) {
results := model.MediaFiles{}
err := r.doSearch(q, offset, size, &results, "title")
if err != nil {
return nil, err
}
err = r.loadMediaFileGenres(&results)
return results, err
}

View File

@@ -5,7 +5,7 @@ import (
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils"
)
type playlistTrackRepository struct {
@@ -123,7 +123,7 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
}
func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) {
sq := Select("id").From("media_file").Where(cond).OrderBy("album_artist, album, release_date, disc_number, track_number")
sq := Select("id").From("media_file").Where(cond).OrderBy("album_artist, album, disc_number, track_number")
var ids []string
err := r.queryAll(sq, &ids)
if err != nil {
@@ -147,7 +147,7 @@ func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) {
}
var clauses Or
for _, d := range discs {
clauses = append(clauses, And{Eq{"album_id": d.AlbumID}, Eq{"release_date": d.ReleaseDate}, Eq{"disc_number": d.DiscNumber}})
clauses = append(clauses, And{Eq{"album_id": d.AlbumID}, Eq{"disc_number": d.DiscNumber}})
}
return r.addMediaFileIds(clauses)
}
@@ -196,7 +196,7 @@ func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
if err != nil {
return err
}
newOrder := slice.Move(ids, pos-1, newPos-1)
newOrder := utils.MoveString(ids, pos-1, newPos-1)
return r.playlistRepo.updatePlaylist(r.playlistId, newOrder)
}

View File

@@ -97,16 +97,14 @@ func (r sqlRepository) buildSortOrder(sort, order string) string {
}
func splitFunc(delimiter rune) func(c rune) bool {
open := 0
open := false
return func(c rune) bool {
if c == '(' {
open++
if open {
open = c != ')'
return false
}
if open > 0 {
if c == ')' {
open--
}
if c == '(' {
open = true
return false
}
return c == delimiter

View File

@@ -69,10 +69,6 @@ var _ = Describe("sqlRepository", func() {
sql := r.buildSortOrder("name desc, substr(id, 7), status asc", "desc")
Expect(sql).To(Equal("name asc, substr(id, 7) desc, status desc"))
})
It("handles nested functions", func() {
sql := r.buildSortOrder("name desc, coalesce(nullif(release_date, ''), nullif(original_date, '')), status asc", "desc")
Expect(sql).To(Equal("name asc, coalesce(nullif(release_date, ''), nullif(original_date, '')) desc, status desc"))
})
})
})
})

View File

@@ -54,11 +54,7 @@
"comment": "التعليق",
"rating": "التقييم",
"createdAt": "تاريخ الإضافة",
"size": "الحجم",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"size": "الحجم"
},
"actions": {
"playAll": "شغّل",

View File

@@ -54,11 +54,7 @@
"comment": "Коментар",
"rating": "Рейтинг",
"createdAt": "Добавено на",
"size": "Размер",
"originalDate": "Оригинал",
"releaseDate": "Издаден",
"releases": "Издание |||| Издания",
"released": "Издаден"
"size": "Размер"
},
"actions": {
"playAll": "Пусни",
@@ -195,7 +191,7 @@
"maxBitRate": "Макс. Bit Rate",
"updatedAt": "Актуализирана на",
"createdAt": "Създадена на",
"downloadable": "Разреши изтегляния?"
"downloadable": ""
}
}
},

View File

@@ -54,11 +54,7 @@
"comment": "Comentari",
"rating": "Valoració",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"size": ""
},
"actions": {
"playAll": "Reprodueix",

View File

@@ -26,7 +26,7 @@
"bpm": "BPM",
"playDate": "Poslední přehravaná skladba",
"channels": "Kanály",
"createdAt": "Přidáno"
"createdAt": ""
},
"actions": {
"addToQueue": "Přehrát později",
@@ -53,12 +53,8 @@
"updatedAt": "Aktualizováno",
"comment": "Komentář",
"rating": "Hodnocení",
"createdAt": "Přidáno",
"size": "Velikost",
"originalDate": "Původní",
"releaseDate": "Vydáno",
"releases": "Vydání |||| Vydání",
"released": "Vydáno"
"createdAt": "",
"size": ""
},
"actions": {
"playAll": "Přehrát",
@@ -68,7 +64,7 @@
"addToPlaylist": "Přidat do seznamu skladeb",
"download": "Stáhnout",
"info": "Získat informace",
"share": "Sdílet"
"share": ""
},
"lists": {
"all": "Všechno",
@@ -89,7 +85,7 @@
"playCount": "Přehrání",
"rating": "Hodnocení",
"genre": "Žánr",
"size": "Velikost"
"size": ""
}
},
"user": {
@@ -169,33 +165,33 @@
}
},
"radio": {
"name": "Rádio |||| Rádia",
"name": "",
"fields": {
"name": "Název",
"streamUrl": "URL streamu",
"homePageUrl": "URL stránky",
"updatedAt": "Nahráno",
"createdAt": "Vytvořeno"
"name": "",
"streamUrl": "",
"homePageUrl": "",
"updatedAt": "",
"createdAt": ""
},
"actions": {
"playNow": "Spustit"
"playNow": ""
}
},
"share": {
"name": "Sdílení |||| Sdílení",
"name": "",
"fields": {
"username": "Sdíleno",
"url": "URL",
"description": "Popis",
"contents": "Obsah",
"expiresAt": "Vyprší",
"lastVisitedAt": "Naposledy navštíveno",
"visitCount": "Počet návšev",
"format": "Formát",
"maxBitRate": "Max. Bit Rate",
"updatedAt": "Nahráno",
"createdAt": "Vytvořeno",
"downloadable": "Povolit stahování?"
"username": "",
"url": "",
"description": "",
"contents": "",
"expiresAt": "",
"lastVisitedAt": "",
"visitCount": "",
"format": "",
"maxBitRate": "",
"updatedAt": "",
"createdAt": "",
"downloadable": ""
}
}
},
@@ -226,7 +222,7 @@
"oneOf": "Musí splňovat jedno z: %{options}",
"regex": "Musí být ve specifickém formátu (regexp): %{pattern}",
"unique": "Musí být jedinečný",
"url": "Musí být platná URL"
"url": ""
},
"action": {
"add_filter": "Přidat filtr",
@@ -256,9 +252,9 @@
"close_menu": "Zavřít nabídku",
"unselect": "Zrušit výběr",
"skip": "Přeskočit",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Sdílet",
"download": "Stáhnout"
"bulk_actions_mobile": "",
"share": "",
"download": ""
},
"boolean": {
"true": "Ano",
@@ -366,14 +362,14 @@
"listenBrainzLinkFailure": "ListenBrainz nemohlo být připojeno: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz odpojeno a scrobblování vypnuto",
"listenBrainzUnlinkFailure": "ListenBrainz nemohlo být odpojeno",
"downloadOriginalFormat": "Stáhnout v původním formátu",
"shareOriginalFormat": "Sdílet v původním formátu",
"shareDialogTitle": "Sdílet %{resource} '%{name}'",
"shareBatchDialogTitle": "Sdílet 1 %{resource} |||| Sdílet %{smart_count} %{resource}",
"shareSuccess": "URL zkopírována do schránky: %{url}",
"shareFailure": "Chyba při kopírování URL %{url} do schránky",
"downloadDialogTitle": "Stáhnout %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Zkopírovat do schránky: Ctrl+C, Enter"
"downloadOriginalFormat": "",
"shareOriginalFormat": "",
"shareDialogTitle": "",
"shareBatchDialogTitle": "",
"shareSuccess": "",
"shareFailure": "",
"downloadDialogTitle": "",
"shareCopyToClipboard": ""
},
"menu": {
"library": "Knihovna",
@@ -389,12 +385,12 @@
"desktop_notifications": "Oznámení na ploše",
"lastfmScrobbling": "Scrobblovat na Last.fm",
"listenBrainzScrobbling": "Scrobblovat na ListenBrainz",
"replaygain": "Mód ReplayGain",
"preAmp": "ReplayGain PreAmp (dB)",
"replaygain": "",
"preAmp": "",
"gain": {
"none": "Vypnuto",
"album": "Použít Album Gain",
"track": "Použít Track Gain"
"none": "",
"album": "",
"track": ""
}
}
},

View File

@@ -54,11 +54,7 @@
"comment": "Kommentar",
"rating": "",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"size": ""
},
"actions": {
"playAll": "Afspil",

View File

@@ -54,11 +54,7 @@
"comment": "Kommentar",
"rating": "Bewertung",
"createdAt": "Hinzugefügt",
"size": "Größe",
"originalDate": "Ursprünglich",
"releaseDate": "Erschienen",
"releases": "Veröffentlichung |||| Veröffentlichungen",
"released": "Erschienen"
"size": "Größe"
},
"actions": {
"playAll": "Abspielen",

View File

@@ -54,11 +54,7 @@
"comment": "Komento",
"rating": "",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"size": ""
},
"actions": {
"playAll": "Ludi",

View File

@@ -54,11 +54,7 @@
"comment": "Comentario",
"rating": "Calificación",
"createdAt": "Creado el",
"size": "Tamaño del archivo",
"originalDate": "Original",
"releaseDate": "Publicado",
"releases": "Lanzamiento |||| Lanzamientos",
"released": "Publicado"
"size": "Tamaño del archivo"
},
"actions": {
"playAll": "Reproducir",
@@ -185,7 +181,7 @@
"name": "Compartir",
"fields": {
"username": "Nombre de usuario",
"url": "URL",
"url": "",
"description": "Descripción",
"contents": "Contenido",
"expiresAt": "Caduca el",
@@ -195,7 +191,7 @@
"maxBitRate": "Tasa de bits Máx.",
"updatedAt": "Actualizado el",
"createdAt": "Creado el",
"downloadable": "¿Permitir descargas?"
"downloadable": ""
}
}
},
@@ -226,7 +222,7 @@
"oneOf": "Debe ser uno de: %{options}",
"regex": "Debe coincidir con un formato específico (regexp): %{pattern}",
"unique": "Tiene que ser único",
"url": "Debe ser una URL válida"
"url": ""
},
"action": {
"add_filter": "Añadir filtro",
@@ -256,7 +252,7 @@
"close_menu": "Cerrar menú",
"unselect": "Deseleccionado",
"skip": "Omitir",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"bulk_actions_mobile": "",
"share": "Compartir",
"download": "Descargar"
},
@@ -368,12 +364,12 @@
"listenBrainzUnlinkFailure": "No se pudo desconectar ListenBrainz",
"downloadOriginalFormat": "Descargar formato original",
"shareOriginalFormat": "Compartir formato original",
"shareDialogTitle": "Compartir %{resource} '%{name}'",
"shareBatchDialogTitle": "Compartir 1 %{resource} |||| Share %{smart_count} %{resource}",
"shareSuccess": "URL copiada al portapapeles: %{url}",
"shareFailure": "Error al copiar la URL %{url} al portapapeles",
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro"
"shareDialogTitle": "",
"shareBatchDialogTitle": "",
"shareSuccess": "",
"shareFailure": "",
"downloadDialogTitle": "",
"shareCopyToClipboard": ""
},
"menu": {
"library": "Biblioteca",
@@ -389,8 +385,8 @@
"desktop_notifications": "Notificaciones de escritorio",
"lastfmScrobbling": "Scrobble a Last.fm",
"listenBrainzScrobbling": "Scrobble a ListenBrainz",
"replaygain": "Modo de ReplayGain",
"preAmp": "ReplayGain PreAmp (dB)",
"replaygain": "",
"preAmp": "",
"gain": {
"none": "Ninguno",
"album": "Álbum",

View File

@@ -54,11 +54,7 @@
"comment": "اظهارنظر",
"rating": "رتبه بندی",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"size": ""
},
"actions": {
"playAll": "پخش",

View File

@@ -54,11 +54,7 @@
"comment": "Kommentti",
"rating": "Arvostelu",
"createdAt": "Lisätty",
"size": "Koko",
"originalDate": "Alkuperäinen",
"releaseDate": "Julkaistu",
"releases": "Julkaisu |||| Julkaisut",
"released": "Julkaistu"
"size": "Koko"
},
"actions": {
"playAll": "Soita",

View File

@@ -26,7 +26,7 @@
"bpm": "BPM",
"playDate": "Derniers joués",
"channels": "Canaux",
"createdAt": "Date d'ajout"
"createdAt": ""
},
"actions": {
"addToQueue": "Ajouter à la file",
@@ -53,12 +53,8 @@
"updatedAt": "Mis à jour le",
"comment": "Commentaire",
"rating": "Classement",
"createdAt": "Date d'ajout",
"size": "Taille",
"originalDate": "Original",
"releaseDate": "Sortie",
"releases": "Sortie |||| Sorties",
"released": "Sortie"
"createdAt": "",
"size": ""
},
"actions": {
"playAll": "Lire",
@@ -68,7 +64,7 @@
"addToPlaylist": "Ajouter à la playlist",
"download": "Télécharger",
"info": "Plus d'informations",
"share": "Partager"
"share": ""
},
"lists": {
"all": "Tous",
@@ -89,7 +85,7 @@
"playCount": "Lectures",
"rating": "Classement",
"genre": "Genre",
"size": "Taille"
"size": ""
}
},
"user": {
@@ -169,33 +165,33 @@
}
},
"radio": {
"name": "Radio |||| Radios",
"name": "",
"fields": {
"name": "Nom",
"streamUrl": "Lien du stream",
"homePageUrl": "Lien de la page d'accueil",
"updatedAt": "Mis à jour le",
"createdAt": "Créée le"
"name": "",
"streamUrl": "",
"homePageUrl": "",
"updatedAt": "",
"createdAt": ""
},
"actions": {
"playNow": "Jouer"
"playNow": ""
}
},
"share": {
"name": "Partage |||| Partages",
"name": "",
"fields": {
"username": "Partagé(e) par",
"url": "Lien URL",
"description": "Description",
"contents": "Contenu",
"expiresAt": "Expire le",
"lastVisitedAt": "Visité pour la dernière fois",
"visitCount": "Nombre de visites",
"format": "Format",
"username": "",
"url": "",
"description": "",
"contents": "",
"expiresAt": "",
"lastVisitedAt": "",
"visitCount": "",
"format": "",
"maxBitRate": "",
"updatedAt": "Mis à jour le",
"createdAt": "Créé le",
"downloadable": "Autoriser les téléchargements?"
"updatedAt": "",
"createdAt": "",
"downloadable": ""
}
}
},
@@ -226,7 +222,7 @@
"oneOf": "Doit être au choix : %{options}",
"regex": "Doit respecter un format spécifique (regexp) : %{pattern}",
"unique": "Doit être unique",
"url": "Doit être un lien URL correct"
"url": ""
},
"action": {
"add_filter": "Ajouter un filtre",
@@ -256,9 +252,9 @@
"close_menu": "Fermer le menu",
"unselect": "Désélectionner",
"skip": "Ignorer",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Partager",
"download": "Télécharger"
"bulk_actions_mobile": "",
"share": "",
"download": ""
},
"boolean": {
"true": "Oui",
@@ -366,14 +362,14 @@
"listenBrainzLinkFailure": "Échec lors de la liaison avec ListenBrainz: %{error}",
"listenBrainzUnlinkSuccess": "La liaison et le scrobble avec ListenBrainz sont maintenant désactivés",
"listenBrainzUnlinkFailure": "Échec lors de la désactivation de la liaison avec ListenBrainz",
"downloadOriginalFormat": "Télécharger au format original",
"shareOriginalFormat": "Partager avec le format original",
"shareDialogTitle": "Partager %{resource} '%{name}'",
"shareBatchDialogTitle": "Partager 1 %{resource} |||| Partager %{smart_count} %{resource}",
"shareSuccess": "Lien copié vers le presse-papier: %{url}",
"shareFailure": "Erreur en copiant le lien %{url} vers le presse-papier",
"downloadDialogTitle": "Télécharger %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Copier vers le presse-papier: Ctrl+C, Enter"
"downloadOriginalFormat": "",
"shareOriginalFormat": "",
"shareDialogTitle": "",
"shareBatchDialogTitle": "",
"shareSuccess": "",
"shareFailure": "",
"downloadDialogTitle": "",
"shareCopyToClipboard": ""
},
"menu": {
"library": "Bibliothèque",
@@ -392,7 +388,7 @@
"replaygain": "",
"preAmp": "",
"gain": {
"none": "Désactivé",
"none": "",
"album": "",
"track": ""
}
@@ -454,7 +450,7 @@
"vol_up": "Augmenter le volume",
"vol_down": "Baisser le volume",
"toggle_love": "Ajouter/Enlever le morceau des favoris",
"current_song": "Aller à la chanson en cours"
"current_song": ""
}
}
}

View File

@@ -54,11 +54,7 @@
"comment": "Comentario",
"rating": "Valoración",
"createdAt": "Engadido o",
"size": "Tamaño",
"originalDate": "Orixinal",
"releaseDate": "Publicado",
"releases": "Publicación ||| Publicacións",
"released": "Publicado"
"size": "Tamaño"
},
"actions": {
"playAll": "Reproducir",
@@ -182,7 +178,7 @@
}
},
"share": {
"name": "Compartición ||| Comparticións",
"name": "´",
"fields": {
"username": "Compartida por",
"url": "URL",

View File

@@ -1,460 +0,0 @@
{
"languageName": "Bahasa Indonesia",
"resources": {
"song": {
"name": "Lagu |||| Lagu",
"fields": {
"albumArtist": "Artis Album",
"duration": "Durasi",
"trackNumber": "#",
"playCount": "Dimainkan",
"title": "Judul",
"artist": "Artis",
"album": "Album",
"path": "Jalur file",
"genre": "Genre",
"compilation": "Kompilasi",
"year": "Tahun",
"size": "Ukuran file",
"updatedAt": "Diperbarui pada",
"bitRate": "Laju bit",
"discSubtitle": "Subtitle Disk",
"starred": "Favorit",
"comment": "Komentar",
"rating": "Peringkat",
"quality": "Kualitas",
"bpm": "BPM",
"playDate": "Terakhir Dimainkan",
"channels": "Saluran",
"createdAt": "Tgl. Ditambahkan"
},
"actions": {
"addToQueue": "Tambah ke antrean",
"playNow": "Mainkan sekarang",
"addToPlaylist": "Tambahkan ke Playlist",
"shuffleAll": "Mainkan Acak",
"download": "Unduh",
"playNext": "Mainkan selanjutnya",
"info": "Lihat Info"
}
},
"album": {
"name": "Album |||| Album",
"fields": {
"albumArtist": "Artis Album",
"artist": "Artis",
"duration": "Durasi",
"songCount": "Lagu",
"playCount": "Dimainkan",
"name": "Nama",
"genre": "Genre",
"compilation": "Kompilasi",
"year": "Tahun",
"updatedAt": "Diperbarui pada",
"comment": "Komentar",
"rating": "Peringkat",
"createdAt": "Tgl. Ditambahkan",
"size": "Ukuran",
"originalDate": "Tanggal",
"releaseDate": "Rilis",
"releases": "Rilis |||| Rilis",
"released": "Dirilis"
},
"actions": {
"playAll": "Mainkan",
"playNext": "Mainkan selanjutnya",
"addToQueue": "Tambah ke antrean",
"shuffle": "Acak",
"addToPlaylist": "Tambahkan ke Playlist",
"download": "Unduh",
"info": "Lihat Info",
"share": "Bagikan"
},
"lists": {
"all": "Semua",
"random": "Acak",
"recentlyAdded": "Terakhir Ditambahkan",
"recentlyPlayed": "Terakhir Dimainkan",
"mostPlayed": "Sering Dimainkan",
"starred": "Favorit",
"topRated": "Peringkat Teratas"
}
},
"artist": {
"name": "Artis |||| Artis",
"fields": {
"name": "Nama",
"albumCount": "Jumlah Album",
"songCount": "Jumlah Lagu",
"playCount": "Dimainkan",
"rating": "Peringkat",
"genre": "Genre",
"size": "Ukuran"
}
},
"user": {
"name": "Pengguna |||| Pengguna",
"fields": {
"userName": "Nama Pengguna",
"isAdmin": "Admin",
"lastLoginAt": "Terakhir Login",
"updatedAt": "Diperbarui pada",
"name": "Nama",
"password": "Kata Sandi",
"createdAt": "Dibuat pada",
"changePassword": "Ganti Kata Sandi?",
"currentPassword": "Kata Sandi Sebelumnya",
"newPassword": "Kata Sandi Baru",
"token": "Token"
},
"helperTexts": {
"name": "Perubahan pada nama Kamu akan terlihat pada login berikutnya"
},
"notifications": {
"created": "Pengguna dibuat",
"updated": "Pengguna diperbarui",
"deleted": "Pengguna dihapus"
},
"message": {
"listenBrainzToken": "Masukkan token pengguna ListenBrainz Kamu.",
"clickHereForToken": "Klik di sini untuk mendapatkan token ListenBrainz"
}
},
"player": {
"name": "Pemutar |||| Pemutar",
"fields": {
"name": "Nama",
"transcodingId": "Transkode",
"maxBitRate": "Maks. Laju Bit",
"client": "Klien",
"userName": "Nama Pengguna",
"lastSeen": "Terakhir Terlihat Pada",
"reportRealPath": "Laporkan Jalur Sebenarnya",
"scrobbleEnabled": "Kirim Scrobbles ke layanan eksternal"
}
},
"transcoding": {
"name": "Transkode |||| Transkode",
"fields": {
"name": "Nama",
"targetFormat": "Target Format",
"defaultBitRate": "Laju Bit Bawaan",
"command": "Perintah"
}
},
"playlist": {
"name": "Playlist |||| Playlist",
"fields": {
"name": "Nama",
"duration": "Durasi",
"ownerName": "Pemilik",
"public": "Publik",
"updatedAt": "Diperbarui pada",
"createdAt": "Dibuat pada",
"songCount": "Lagu",
"comment": "Komentar",
"sync": "Impor Otomatis",
"path": "Impor Dari"
},
"actions": {
"selectPlaylist": "Pilih playlist:",
"addNewPlaylist": "Buat \"%{name}\"",
"export": "Ekspor",
"makePublic": "Jadikan Publik",
"makePrivate": "Jadikan Pribadi"
},
"message": {
"duplicate_song": "Tambahkan lagu duplikat",
"song_exist": "Ada lagu duplikat yang ditambahkan ke daftar putar. Apakah Kamu ingin menambahkan lagu duplikat atau melewatkannya?"
}
},
"radio": {
"name": "Radio |||| Radio",
"fields": {
"name": "Nama",
"streamUrl": "URL Sumber",
"homePageUrl": "Halaman Beranda URL",
"updatedAt": "Diperbarui pada",
"createdAt": "Dibuat pada"
},
"actions": {
"playNow": "Mainkan sekarang"
}
},
"share": {
"name": "Bagikan |||| Bagikan",
"fields": {
"username": "Dibagikan Oleh",
"url": "URL",
"description": "Deskripsi",
"contents": "Konten",
"expiresAt": "Berakhir",
"lastVisitedAt": "Terakhir Dikunjungi",
"visitCount": "Pengunjung",
"format": "Format",
"maxBitRate": "Maks. Laju Bit",
"updatedAt": "Diperbarui pada",
"createdAt": "Dibuat pada",
"downloadable": "Izinkan Pengunduhan?"
}
}
},
"ra": {
"auth": {
"welcome1": "Terima kasih telah menginstal Navidrome!",
"welcome2": "Untuk memulai, buat dulu akun admin",
"confirmPassword": "Konfirmasi Kata Sandi",
"buttonCreateAdmin": "Buat Akun Admin",
"auth_check_error": "Silahkan masuk untuk melanjutkan",
"user_menu": "Profil",
"username": "Nama Pengguna",
"password": "Kata Sandi",
"sign_in": "Masuk",
"sign_in_error": "Otentikasi gagal, silakan coba lagi",
"logout": "Keluar"
},
"validation": {
"invalidChars": "Harap menggunakan huruf dan angka saja",
"passwordDoesNotMatch": "Kata sandi tidak cocok",
"required": "Wajib",
"minLength": "Setidaknya harus %{min} karakter",
"maxLength": "Harus berisi %{max} karakter atau kurang",
"minValue": "Minimal harus %{min}",
"maxValue": "Harus %{max} atau kurang",
"number": "Harus berupa angka",
"email": "Harus berupa email yang valid",
"oneOf": "Harus salah satu dari: %{options}",
"regex": "Harus cocok dengan format spesifik (regexp): %{pattern}",
"unique": "Harus unik",
"url": "Harus berupa URL yang valid"
},
"action": {
"add_filter": "Tambah filter",
"add": "Tambah",
"back": "Kembali",
"bulk_actions": "1 item dipilih |||| %{smart_count} item dipilih",
"cancel": "Batalkan",
"clear_input_value": "Hapus",
"clone": "Klon",
"confirm": "Konfirmasi",
"create": "Buat",
"delete": "Hapus",
"edit": "Edit",
"export": "Ekspor",
"list": "Daftar",
"refresh": "Refresh",
"remove_filter": "Hapus filter ini",
"remove": "Hapus",
"save": "Simpan",
"search": "Cari",
"show": "Tunjukkan",
"sort": "Sortir",
"undo": "Batalkan",
"expand": "Luaskan",
"close": "Tutup",
"open_menu": "Buka menu",
"close_menu": "Tutup menu",
"unselect": "Batalkan pilihan",
"skip": "Lewati",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Bagikan",
"download": "Unduh"
},
"boolean": {
"true": "Ya",
"false": "Tidak"
},
"page": {
"create": "Buat %{name}",
"dashboard": "Dashboard",
"edit": "%{name} #%{id}",
"error": "Ada yang tidak beres",
"list": "%{name}",
"loading": "Memuat",
"not_found": "Tidak ditemukan",
"show": "%{name} #%{id}",
"empty": "Belum ada %{name}.",
"invite": "Apakah Kamu ingin menambahkan satu?"
},
"input": {
"file": {
"upload_several": "Letakkan beberapa file untuk diunggah, atau klik untuk memilih salah satu.",
"upload_single": "Letakkan file untuk diunggah, atau klik untuk memilihnya."
},
"image": {
"upload_several": "Letakkan beberapa gambar untuk diunggah, atau klik untuk memilih salah satu.",
"upload_single": "Letakkan gambar untuk diunggah, atau klik untuk memilihnya."
},
"references": {
"all_missing": "Tidak dapat menemukan data referensi.",
"many_missing": "Tampaknya beberapa referensi tidak tersedia.",
"single_missing": "Tampaknya referensi tidak tersedia."
},
"password": {
"toggle_visible": "Sembunyikan Kata Sandi",
"toggle_hidden": "Tampilkan Kata Sandi"
}
},
"message": {
"about": "Tentang",
"are_you_sure": "Kamu Yakin?",
"bulk_delete_content": "Kamu yakin ingin menghapus %{name} ini? |||| Kamu yakin ingin menghapus %{smart_count} item ini?",
"bulk_delete_title": "Hapus %{name} |||| Hapus %{smart_count} %{name}",
"delete_content": "Kamu ingin menghapus item ini?",
"delete_title": "Hapus %{name} #%{id}",
"details": "Detail",
"error": "Terjadi kesalahan klien dan permintaan Kamu tidak dapat diselesaikan.",
"invalid_form": "Formulirnya tidak valid. Silakan periksa kesalahannya",
"loading": "Halaman sedang dimuat, mohon tunggu sebentar",
"no": "Tidak",
"not_found": "Mungkin Kamu mengetik URL yang salah, atau Kamu mengikuti tautan yang buruk.",
"yes": "Ya",
"unsaved_changes": "Beberapa perubahan tidak disimpan. Apakah Kamu yakin ingin mengabaikannya?"
},
"navigation": {
"no_results": "Tidak ada hasil yang ditemukan",
"no_more_results": "Nomor halaman %{page} melampaui batas. Coba halaman sebelumnya.",
"page_out_of_boundaries": "Nomor halaman %{page} melampaui batas",
"page_out_from_end": "Tidak dapat menelusuri sebelum halaman terakhir",
"page_out_from_begin": "Tidak dapat menelusuri sebelum halaman 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} dari %{total}",
"page_rows_per_page": "Item per halaman:",
"next": "Selanjutnya",
"prev": "Sebelumnya",
"skip_nav": "Lewati ke konten"
},
"notification": {
"updated": "Elemen diperbarui |||| %{smart_count} elemen diperbarui",
"created": "Elemen dibuat",
"deleted": "Elemen dihapus |||| %{smart_count} elemen dihapus",
"bad_item": "Elemen salah",
"item_doesnt_exist": "Tidak ada elemen",
"http_error": "Kesalahan komunikasi server",
"data_provider_error": "dataProvider galat. Periksa konsol untuk detailnya.",
"i18n_error": "Tidak dapat memuat terjemahan untuk bahasa yang diatur",
"canceled": "Tindakan dibatalkan",
"logged_out": "Sesi Kamu telah berakhir, harap sambungkan kembali.",
"new_version": "Tersedia versi baru! Silakan menyegarkan jendela ini."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Kolom Untuk Ditampilkan",
"layout": "Layout",
"grid": "Grid",
"table": "Tabel"
}
},
"message": {
"note": "CATATAN",
"transcodingDisabled": "Mengubah konfigurasi transkode melalui antarmuka web dinonaktifkan karena alasan keamanan. Jika Kamu ingin mengubah (mengedit atau menambahkan) opsi transkode, restart server dengan opsi konfigurasi %{config}.",
"transcodingEnabled": "Navidrome saat ini berjalan dengan %{config}, sehingga memungkinkan untuk menjalankan perintah sistem dari pengaturan Transkode menggunakan antarmuka web. Kami sarankan untuk menonaktifkannya demi alasan keamanan dan hanya mengaktifkannya saat mengonfigurasi opsi Transcoding.",
"songsAddedToPlaylist": "Menambahkan 1 lagu ke playlist |||| Menambahkan %{smart_count} lagu ke playlist",
"noPlaylistsAvailable": "Tidak tersedia",
"delete_user_title": "Hapus pengguna '%{name}'",
"delete_user_content": "Apakah Kamu yakin ingin menghapus pengguna ini dan semua datanya (termasuk daftar putar dan preferensi)?",
"notifications_blocked": "Kamu telah memblokir Notifikasi untuk situs ini di pengaturan browser Anda",
"notifications_not_available": "Browser ini tidak mendukung notifikasi desktop atau Kamu tidak mengakses Navidrome melalui https",
"lastfmLinkSuccess": "Last.fm berhasil ditautkan dan scrobbling diaktifkan",
"lastfmLinkFailure": "Last.fm tidak dapat ditautkan",
"lastfmUnlinkSuccess": "Tautan Last.fm dibatalkan dan scrobbling dinonaktifkan",
"lastfmUnlinkFailure": "Tautan Last.fm tidak dapat dibatalkan",
"openIn": {
"lastfm": "Lihat di Last.fm",
"musicbrainz": "Lihat di MusicBrainz"
},
"lastfmLink": "Baca selengkapnya...",
"listenBrainzLinkSuccess": "ListenBrainz berhasil ditautkan dan scrobbling diaktifkan sebagai pengguna: %{user}",
"listenBrainzLinkFailure": "ListenBrainz tidak dapat ditautkan: %{error}",
"listenBrainzUnlinkSuccess": "Tautan ListenBrainz dibatalkan dan scrobbling dinonaktifkan",
"listenBrainzUnlinkFailure": "Tautan ListenBrainz tidak dapat dibatalkan",
"downloadOriginalFormat": "Unduh dalam format asli",
"shareOriginalFormat": "Bagikan dalam format asli",
"shareDialogTitle": "Bagikan %{resource} '%{name}'",
"shareBatchDialogTitle": "Bagikan 1 %{resource} |||| Bagikan %{smart_count} %{resource}",
"shareSuccess": "URL disalin ke papan klip: %{url}",
"shareFailure": "Terjadi kesalahan saat menyalin URL %{url} ke papan klip",
"downloadDialogTitle": "Unduh %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Salin ke papan klip: Ctrl+C, Enter"
},
"menu": {
"library": "Perpustakaan",
"settings": "Pengaturan",
"version": "Versi",
"theme": "Tema",
"personal": {
"name": "Personal",
"options": {
"theme": "Tema",
"language": "Bahasa",
"defaultView": "Tampilan Bawaan",
"desktop_notifications": "Pemberitahuan Desktop",
"lastfmScrobbling": "Scrobble ke Last.fm",
"listenBrainzScrobbling": "Scrobble ke ListenBrainz",
"replaygain": "Mode ReplayGain",
"preAmp": "ReplayGain PreAmp (dB)",
"gain": {
"none": "Nonaktif",
"album": "Gunakan Gain Album",
"track": "Gunakan Gain Lagu"
}
}
},
"albumList": "Album",
"about": "Tentang",
"playlists": "Playlist",
"sharedPlaylists": "Playlist yang Dibagikan"
},
"player": {
"playListsText": "Mainkan Antrean",
"openText": "Buka text",
"closeText": "Tutup text",
"notContentText": "Tidak ada musik",
"clickToPlayText": "Klik untuk mainkan",
"clickToPauseText": "Klik untuk menjeda",
"nextTrackText": "Lagu Selanjutnya",
"previousTrackText": "Lagu Sebelumnya",
"reloadText": "Muat ulang",
"volumeText": "Volume",
"toggleLyricText": "Lirik",
"toggleMiniModeText": "Minimalkan",
"destroyText": "Tutup",
"downloadText": "Unduh",
"removeAudioListsText": "Hapus daftar audio",
"clickToDeleteText": "Klik untuk menghapus %{name}",
"emptyLyricText": "Tidak ada lirik",
"playModeText": {
"order": "Berurutan",
"orderLoop": "Ulang",
"singleLoop": "Ulangi Satu",
"shufflePlay": "Acak"
}
},
"about": {
"links": {
"homepage": "Halaman beranda",
"source": "Kode sumber",
"featureRequests": "Permintaan fitur"
}
},
"activity": {
"title": "Aktivitas",
"totalScanned": "Total Folder yang Dipindai",
"quickScan": "Pemindaian Cepat",
"fullScan": "Pemindaian Penuh",
"serverUptime": "Waktu Aktif Server",
"serverDown": "OFFLINE"
},
"help": {
"title": "Tombol Pintasan Navidrome",
"hotkeys": {
"show_help": "Tampilkan Bantuan Ini",
"toggle_menu": "Menu Samping",
"toggle_play": "Mainkan / Jeda",
"prev_song": "Lagu Sebelumnya",
"next_song": "Lagu Selanjutnya",
"vol_up": "Volume Naik",
"vol_down": "Volume Turun",
"toggle_love": "Tambahkan lagu ini ke favorit",
"current_song": "Buka Lagu Saat Ini"
}
}
}

View File

@@ -54,11 +54,7 @@
"comment": "Commento",
"rating": "Valutazione",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"size": ""
},
"actions": {
"playAll": "Riproduci",

View File

@@ -54,11 +54,7 @@
"comment": "コメント",
"rating": "レート",
"createdAt": "追加日",
"size": "サイズ",
"originalDate": "オリジナルの日付",
"releaseDate": "リリース日",
"releases": "リリース",
"released": "リリース"
"size": "サイズ"
},
"actions": {
"playAll": "再生",
@@ -195,7 +191,7 @@
"maxBitRate": "最大ビットレート",
"updatedAt": "更新日",
"createdAt": "作成日",
"downloadable": "ダウンロードを許可しますか?"
"downloadable": ""
}
}
},
@@ -373,7 +369,7 @@
"shareSuccess": "コピーしました: %{url}",
"shareFailure": "コピーに失敗しました %{url}",
"downloadDialogTitle": "ダウンロード %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "クリップボードへコピー: Ctrl+C, Enter"
"shareCopyToClipboard": ""
},
"menu": {
"library": "ライブラリ",

View File

@@ -1,460 +0,0 @@
{
"languageName": "한국어",
"resources": {
"song": {
"name": "곡",
"fields": {
"albumArtist": "앨범 아티스트",
"duration": "길이",
"trackNumber": "#",
"playCount": "재생 수",
"title": "제목",
"artist": "아티스트",
"album": "앨범",
"path": "파일 경로",
"genre": "장르",
"compilation": "Compilation",
"year": "년",
"size": "파일 크기",
"updatedAt": "업데이트 날짜",
"bitRate": "비트레이트",
"discSubtitle": "디스크 서브타이틀",
"starred": "좋아요",
"comment": "코멘트",
"rating": "평가",
"quality": "품질",
"bpm": "BPM",
"playDate": "마지막 재생",
"channels": "채널",
"createdAt": "추가 날짜"
},
"actions": {
"addToQueue": "마지막에 재생",
"playNow": "바로 재생",
"addToPlaylist": "플레이리스트에 추가",
"shuffleAll": "모든 곡 셔플",
"download": "다운로드",
"playNext": "다음에 재생",
"info": "상세 정보"
}
},
"album": {
"name": "앨범",
"fields": {
"albumArtist": "앨범 아티스트",
"artist": "아티스트",
"duration": "길이",
"songCount": "곡",
"playCount": "재생 수",
"name": "이름",
"genre": "장르",
"compilation": "Compilation",
"year": "년",
"updatedAt": "업데이트 날짜",
"comment": "코멘트",
"rating": "평가",
"createdAt": "추가 날짜",
"size": "크기",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
},
"actions": {
"playAll": "재생",
"playNext": "다음에 재생",
"addToQueue": "마지막에 재생",
"shuffle": "셔플",
"addToPlaylist": "플레이리스트에 추가",
"download": "다운로드",
"info": "상세 정보",
"share": "공유"
},
"lists": {
"all": "전체",
"random": "랜덤",
"recentlyAdded": "최근 추가",
"recentlyPlayed": "최근 재생",
"mostPlayed": "가장 많이 재생",
"starred": "좋아요",
"topRated": "높은 평가"
}
},
"artist": {
"name": "아티스트",
"fields": {
"name": "이름",
"albumCount": "앨범 수",
"songCount": "곡 수",
"playCount": "재생 수",
"rating": "평가",
"genre": "장르",
"size": "크기"
}
},
"user": {
"name": "사용자",
"fields": {
"userName": "사용자명",
"isAdmin": "관리자",
"lastLoginAt": "최종 로그인",
"updatedAt": "업데이트 날짜",
"name": "이름",
"password": "비밀번호",
"createdAt": "생성 날짜",
"changePassword": "비밀번호를 변경하시겠습니까?",
"currentPassword": "현재 비밀번호",
"newPassword": "새로운 비밀번호",
"token": "토큰"
},
"helperTexts": {
"name": "이름 변경은 다음 로그인 이후에 반영됩니다"
},
"notifications": {
"created": "사용자가 생성되었습니다",
"updated": "사용자가 업데이트되었습니다",
"deleted": "사용자가 삭제되었습니다"
},
"message": {
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요",
"clickHereForToken": "여기를 클릭하여 토큰을 얻으세요"
}
},
"player": {
"name": "플레이어",
"fields": {
"name": "이름",
"transcodingId": "트랜스코딩",
"maxBitRate": "최대 비트레이트",
"client": "클라이언트",
"userName": "사용자명",
"lastSeen": "마지막 사용",
"reportRealPath": "실제 파일 경로 반환",
"scrobbleEnabled": "다른 서비스에 scrobble"
}
},
"transcoding": {
"name": "트랜스코딩",
"fields": {
"name": "이름",
"targetFormat": "대상 포맷",
"defaultBitRate": "기본 비트레이트",
"command": "명령"
}
},
"playlist": {
"name": "플레이리스트",
"fields": {
"name": "이름",
"duration": "시간",
"ownerName": "소유자",
"public": "공개",
"updatedAt": "업데이트 날짜",
"createdAt": "생성 날짜",
"songCount": "곡",
"comment": "코멘트",
"sync": "자동 임포트",
"path": "임포트 원본"
},
"actions": {
"selectPlaylist": "플레이리스트 선택",
"addNewPlaylist": "'%{name}' 생성",
"export": "내보내기",
"makePublic": "공개하기",
"makePrivate": "비공개로 전환하기"
},
"message": {
"duplicate_song": "중복된 곡 추가",
"song_exist": "이미 플레이리스트에 존재하는 곡입니다. 추가하시겠습니까?"
}
},
"radio": {
"name": "라디오",
"fields": {
"name": "이름",
"streamUrl": "스트리밍 URL",
"homePageUrl": "홈페이지 URL",
"updatedAt": "업데이트 날짜",
"createdAt": "생성 날짜"
},
"actions": {
"playNow": "바로 재생"
}
},
"share": {
"name": "공유",
"fields": {
"username": "공유자",
"url": "URL",
"description": "설명",
"contents": "컨텐츠",
"expiresAt": "만료 날짜",
"lastVisitedAt": "최근 방문",
"visitCount": "방문 수",
"format": "포맷",
"maxBitRate": "최대 비트레이트",
"updatedAt": "업데이트 날짜",
"createdAt": "생성 날짜",
"downloadable": ""
}
}
},
"ra": {
"auth": {
"welcome1": "Navidrome을 설치해 주셔서 감사합니다!",
"welcome2": "관리자 사용자를 생성하고 시작해 보세요",
"confirmPassword": "비밀번호 확인",
"buttonCreateAdmin": "관리자 생성",
"auth_check_error": "인증에 실패했습니다. 다시 로그인하세요",
"user_menu": "프로필",
"username": "사용자명",
"password": "비밀번호",
"sign_in": "로그인",
"sign_in_error": "인증에 실패했습니다. 입력값을 확인하세요",
"logout": "로그아웃"
},
"validation": {
"invalidChars": "문자와 숫자만 사용하세요",
"passwordDoesNotMatch": "비밀번호가 일치하지 않습니다",
"required": "필수 항목입니다",
"minLength": "%{min}자 이상이어야 합니다",
"maxLength": "%{max}자 이하이어야 합니다",
"minValue": "%{min} 이상이어야 합니다",
"maxValue": "%{max} 이하이어야 합니다",
"number": "숫자여야 합니다",
"email": "유효한 이메일 주소여야 합니다",
"oneOf": "다음 중 하나여야 합니다: %{options}",
"regex": "다음과 같은 형식이어야 합니다: %{pattern}",
"unique": "고유해야 합니다",
"url": "유효한 URL을 입력하세요"
},
"action": {
"add_filter": "필터 추가",
"add": "추가",
"back": "뒤로",
"bulk_actions": "%{smart_count}개 선택",
"cancel": "취소",
"clear_input_value": "비우기",
"clone": "복제",
"confirm": "확인",
"create": "생성",
"delete": "삭제",
"edit": "편집",
"export": "내보내기",
"list": "목록",
"refresh": "새로고침",
"remove_filter": "필터 삭제",
"remove": "삭제",
"save": "저장",
"search": "검색",
"show": "상세 정보",
"sort": "정렬",
"undo": "실행 취소",
"expand": "확장",
"close": "닫기",
"open_menu": "메뉴 열기",
"close_menu": "메뉴 닫기",
"unselect": "선택 해제",
"skip": "스킵",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "공유",
"download": "다운로드"
},
"boolean": {
"true": "예",
"false": "아니요"
},
"page": {
"create": "%{name} 생성",
"dashboard": "대시보드",
"edit": "%{name} #%{id}",
"error": "문제가 발생했습니다",
"list": "%{name}",
"loading": "로딩 중입니다. 잠시 기다려주세요",
"not_found": "찾을 수 없습니다",
"show": "%{name} #%{id}",
"empty": "%{name}이(가) 없습니다",
"invite": "생성하시겠습니까?"
},
"input": {
"file": {
"upload_several": "파일을 끌어 놓거나 클릭하여 업로드하세요",
"upload_single": "파일을 끌어 놓거나 클릭하여 업로드하세요"
},
"image": {
"upload_several": "이미지를 끌어 놓거나 클릭하여 업로드하세요",
"upload_single": "이미지를 끌어 놓거나 클릭하여 업로드하세요"
},
"references": {
"all_missing": "사용 가능한 데이터가 없습니다",
"many_missing": "선택한 데이터 중 일부가 사용 가능하지 않습니다",
"single_missing": "선택한 데이터가 사용 가능하지 않습니다"
},
"password": {
"toggle_visible": "숨기기",
"toggle_hidden": "보이기"
}
},
"message": {
"about": "정보",
"are_you_sure": "정말로 이 작업을 수행하시겠습니까?",
"bulk_delete_content": "%{name}을(를) 삭제하시겠습니까? |||| %{smart_count}개의 항목을 삭제하시겠습니까?",
"bulk_delete_title": "%{name} 삭제 |||| %{name} %{smart_count}개 삭제",
"delete_content": "삭제하시겠습니까?",
"delete_title": "%{name} #%{id} 삭제",
"details": "세부 정보",
"error": "클라이언트 오류로 처리를 완료할 수 없습니다",
"invalid_form": "입력값에 오류가 있습니다. 오류 메시지를 확인하세요",
"loading": "로딩 중입니다. 잠시만 기다려주세요",
"no": "아니요",
"not_found": "잘못된 URL을 입력하거나 잘못된 링크를 따라갔습니다",
"yes": "예",
"unsaved_changes": "변경 사항이 저장되지 않았습니다. 이 페이지를 떠나시겠습니까?"
},
"navigation": {
"no_results": "결과가 없습니다",
"no_more_results": "페이지 %{page}는 최대 페이지 수를 초과했습니다. 이전 페이지로 돌아가세요",
"page_out_of_boundaries": "페이지 %{page}는 최대 페이지 수를 초과했습니다",
"page_out_from_end": "마지막 페이지 이후로 이동할 수 없습니다",
"page_out_from_begin": "첫 페이지 이전으로 이동할 수 없습니다",
"page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
"page_rows_per_page": "페이지당 항목 수:",
"next": "다음",
"prev": "이전",
"skip_nav": "메뉴 건너뛰기"
},
"notification": {
"updated": "업데이트되었습니다 |||| %{smart_count}개 업데이트되었습니다",
"created": "생성되었습니다",
"deleted": "삭제되었습니다 |||| %{smart_count}개 삭제되었습니다",
"bad_item": "잘못된 항목입니다",
"item_doesnt_exist": "항목이 존재하지 않습니다",
"http_error": "통신 오류가 발생했습니다",
"data_provider_error": "dataProvider 오류입니다. 자세한 내용은 콘솔을 확인하세요",
"i18n_error": "번역 파일을 로드할 수 없습니다",
"canceled": "취소되었습니다",
"logged_out": "인증에 실패했습니다. 다시 로그인하세요",
"new_version": "새로운 버전이 사용 가능합니다! 페이지를 새로 고쳐주세요."
},
"toggleFieldsMenu": {
"columnsToDisplay": "표시 열",
"layout": "레이아웃",
"grid": "그리드",
"table": "테이블"
}
},
"message": {
"note": "주의",
"transcodingDisabled": "보안상의 이유로 웹 인터페이스에서 트랜스코드 설정이 비활성화되어 있습니다.\n이를 설정하려면 환경 변수 %{config}를 설정하고 서버를 재시작하십시오.",
"transcodingEnabled": "Navidrome은 현재 %{config} 설정으로 실행되며, 웹 인터페이스의 트랜스코드 설정에 따라 명령을 실행할 수 있습니다.\n보안상의 이유로 이 설정은 트랜스코드 설정을 변경할 때만 활성화하는 것을 권장합니다.",
"songsAddedToPlaylist": "플레이리스트에 1곡 추가되었습니다 |||| 플레이리스트에 %{smart_count}곡 추가되었습니다",
"noPlaylistsAvailable": "사용 가능하지 않음",
"delete_user_title": "'%{name}' 삭제",
"delete_user_content": "이 사용자와 그의 모든 데이터(플레이리스트 및 설정 등)를 삭제하시겠습니까?",
"notifications_blocked": "브라우저의 설정으로 이 사이트의 알림이 차단되어 있습니다",
"notifications_not_available": "이 브라우저는 데스크톱 알림을 지원하지 않습니다",
"lastfmLinkSuccess": "Last.fm과 연결되어 scrobble이 활성화되었습니다",
"lastfmLinkFailure": "Last.fm과 연결할 수 없습니다",
"lastfmUnlinkSuccess": "설정이 해제되어 Last.fm으로의 scrobble이 비활성화되었습니다",
"lastfmUnlinkFailure": "Last.fm과 연결 해제를 실패했습니다",
"openIn": {
"lastfm": "Last.fm에서 열기",
"musicbrainz": "MusicBrainz에서 열기"
},
"lastfmLink": "계속 읽기",
"listenBrainzLinkSuccess": "%{user}에 대한 scrobbling 설정이 성공적으로 완료되었습니다",
"listenBrainzLinkFailure": "ListenBrainz와 연결에 실패했습니다: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz와의 연결과 scrobbling이 비활성화되었습니다",
"listenBrainzUnlinkFailure": "ListenBrainz와의 연결 해제를 실패했습니다",
"downloadOriginalFormat": "원본 형식으로 다운로드",
"shareOriginalFormat": "원본 형식으로 공유",
"shareDialogTitle": "%{resource} '%{name}' 공유",
"shareBatchDialogTitle": "1 %{resource} 공유 |||| %{smart_count} %{resource} 공유",
"shareSuccess": "복사되었습니다: %{url}",
"shareFailure": "복사하지 못했습니다 %{url}",
"downloadDialogTitle": "다운로드 %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": ""
},
"menu": {
"library": "라이브러리",
"settings": "설정",
"version": "버전",
"theme": "테마",
"personal": {
"name": "개인 설정",
"options": {
"theme": "테마",
"language": "언어",
"defaultView": "기본 뷰",
"desktop_notifications": "데스크톱 알림",
"lastfmScrobbling": "Last.fm으로 scrobble하기",
"listenBrainzScrobbling": "ListenBrainz로 scrobble하기",
"replaygain": "ReplayGain 모드",
"preAmp": "프리앰프",
"gain": {
"none": "비활성화",
"album": "앨범 Gain 사용",
"track": "트랙 Gain 사용"
}
}
},
"albumList": "앨범",
"about": "상세 정보",
"playlists": "플레이리스트",
"sharedPlaylists": "공유된 플레이리스트"
},
"player": {
"playListsText": "재생 목록",
"openText": "열기",
"closeText": "닫기",
"notContentText": "음악이 없습니다",
"clickToPlayText": "클릭하여 재생",
"clickToPauseText": "일시 정지",
"nextTrackText": "다음 곡",
"previousTrackText": "이전 곡",
"reloadText": "새로 고침",
"volumeText": "음량",
"toggleLyricText": "가사 전환",
"toggleMiniModeText": "최소화",
"destroyText": "삭제",
"downloadText": "다운로드",
"removeAudioListsText": "목록 비우기",
"clickToDeleteText": "클릭하여 %{name} 삭제",
"emptyLyricText": "가사가 없습니다",
"playModeText": {
"order": "순서대로",
"orderLoop": "반복",
"singleLoop": "한 곡 반복",
"shufflePlay": "셔플"
}
},
"about": {
"links": {
"homepage": "홈페이지",
"source": "소스 코드",
"featureRequests": "기능 요청"
}
},
"activity": {
"title": "활동",
"totalScanned": "스캔된 폴더",
"quickScan": "빠른 스캔",
"fullScan": "전체 스캔",
"serverUptime": "서버 가동 시간",
"serverDown": "서버 오프라인"
},
"help": {
"title": "Navidrome 단축키",
"hotkeys": {
"show_help": "도움말 표시",
"toggle_menu": "사이드바 표시/숨기기",
"toggle_play": "재생/정지",
"prev_song": "이전 곡",
"next_song": "다음 곡",
"vol_up": "음량 높이기",
"vol_down": "음량 낮추기",
"toggle_love": "별표 토글",
"current_song": "현재 곡으로 이동"
}
}
}

View File

@@ -54,11 +54,7 @@
"comment": "Commentaar",
"rating": "Beoordeling",
"createdAt": "Datum toegevoegd",
"size": "Grootte",
"originalDate": "Origineel",
"releaseDate": "Uitgegeven",
"releases": "Uitgave |||| Uitgaven",
"released": "Uitgegeven"
"size": "Grootte"
},
"actions": {
"playAll": "Afspelen",

View File

@@ -54,11 +54,7 @@
"comment": "Komentarz",
"rating": "Ocena",
"createdAt": "Data dodania",
"size": "Rozmiar",
"originalDate": "Pierwotna Data",
"releaseDate": "Data Wydania",
"releases": "Wydanie |||| Wydania",
"released": "Wydany"
"size": "Rozmiar"
},
"actions": {
"playAll": "Odtwarzaj",

View File

@@ -54,11 +54,7 @@
"comment": "Comentário",
"rating": "Classificação",
"createdAt": "Adicionado em",
"size": "Tamanho",
"originalDate": "Original",
"releaseDate": "Data de Lançamento",
"releases": "Versão||||Versões",
"released": "Lançado"
"size": "Tamanho"
},
"actions": {
"playAll": "Tocar",

View File

@@ -2,7 +2,7 @@
"languageName": "Pусский",
"resources": {
"song": {
"name": "Трек |||| Треки |||| Треков",
"name": "Трек |||| Треки",
"fields": {
"albumArtist": "Исполнитель альбома",
"duration": "Длительность",
@@ -54,11 +54,7 @@
"comment": "Комментарий",
"rating": "Рейтинг",
"createdAt": "Дата добавления",
"size": "Размер",
"originalDate": "Оригинал",
"releaseDate": "Релиз",
"releases": "Релиз |||| Релиза |||| Релизов",
"released": "Релиз"
"size": "Размер"
},
"actions": {
"playAll": "Играть",

View File

@@ -54,11 +54,7 @@
"comment": "Opomba",
"rating": "Ocena",
"createdAt": "Datum dodano",
"size": "Velikost",
"originalDate": "Original",
"releaseDate": "Izdano",
"releases": "Izdaja |||| Izdaje",
"released": "Izdano"
"size": "Velikost"
},
"actions": {
"playAll": "Predvajaj vse",
@@ -195,7 +191,7 @@
"maxBitRate": "Maks. bitna hitrost",
"updatedAt": "Posodobljeno ob",
"createdAt": "Ustvarjeno ob",
"downloadable": "Dovoli prenose?"
"downloadable": ""
}
}
},
@@ -373,7 +369,7 @@
"shareSuccess": "URL kopiran v odložišče: %{url}",
"shareFailure": "Napaka pri kopiranju URL-ja %{url} v odložišče",
"downloadDialogTitle": "Prenesi %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Kopiraj v odložišče: Ctrl+C, Enter"
"shareCopyToClipboard": ""
},
"menu": {
"library": "Knjižnica",

View File

@@ -54,11 +54,7 @@
"comment": "Kommentar",
"rating": "Betyg",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"size": ""
},
"actions": {
"playAll": "Spela",

View File

@@ -54,11 +54,7 @@
"comment": "ความคิดเห็น",
"rating": "Rating",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"size": ""
},
"actions": {
"playAll": "เล่นทั้งหมด",

View File

@@ -54,11 +54,7 @@
"comment": "",
"rating": "",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"size": ""
},
"actions": {
"playAll": "Çaldır",

View File

@@ -26,7 +26,7 @@
"bpm": "Темп",
"playDate": "Востаннє відтворено",
"channels": "Канали",
"createdAt": "Додано"
"createdAt": ""
},
"actions": {
"addToQueue": "Прослухати пізніше",
@@ -53,12 +53,8 @@
"updatedAt": "Оновлено",
"comment": "Коментар",
"rating": "Рейтинг",
"createdAt": "Додано",
"size": "Розмір",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"createdAt": "",
"size": ""
},
"actions": {
"playAll": "Прослухати",
@@ -68,7 +64,7 @@
"addToPlaylist": "Додати у список відтворення",
"download": "Завантажити",
"info": "Отримати інформацію",
"share": "Поширити"
"share": ""
},
"lists": {
"all": "Усі",
@@ -89,7 +85,7 @@
"playCount": "Відтворено",
"rating": "Рейтинг",
"genre": "Жанр",
"size": "Розмір"
"size": ""
}
},
"user": {
@@ -105,7 +101,7 @@
"changePassword": "Змінити пароль?",
"currentPassword": "Поточний пароль",
"newPassword": "Новий пароль",
"token": "Токен"
"token": ""
},
"helperTexts": {
"name": "Змінене ім'я буде відображатися при наступній авторизації"
@@ -116,8 +112,8 @@
"deleted": "Користувач видалений"
},
"message": {
"listenBrainzToken": "Введіть свій токен користувача ListenBrainz.",
"clickHereForToken": "Натисніть тут для отримання токену"
"listenBrainzToken": "",
"clickHereForToken": ""
}
},
"player": {
@@ -130,7 +126,7 @@
"userName": "Iмя користувача",
"lastSeen": "Останній візит о",
"reportRealPath": "Повідомте про реальний шлях",
"scrobbleEnabled": "Надсилайте Scrobbles до зовнішніх сервісів"
"scrobbleEnabled": ""
}
},
"transcoding": {
@@ -160,8 +156,8 @@
"selectPlaylist": "Вибрати список відтворення:",
"addNewPlaylist": "Створити \"%{name}\"",
"export": "Експортувати",
"makePublic": "Зробити публічним",
"makePrivate": "Зробити приватним"
"makePublic": "",
"makePrivate": ""
},
"message": {
"duplicate_song": "Додати повторювані пісні",
@@ -169,33 +165,33 @@
}
},
"radio": {
"name": "Радіо |||| Радіо",
"name": "",
"fields": {
"name": "Назва",
"streamUrl": "Посилання на стрім",
"homePageUrl": "Посилання на домашню сторінку",
"updatedAt": "Оновлено",
"createdAt": "Створено"
"name": "",
"streamUrl": "",
"homePageUrl": "",
"updatedAt": "",
"createdAt": ""
},
"actions": {
"playNow": "Зараз грає"
"playNow": ""
}
},
"share": {
"name": "Поширити |||| Поширення",
"name": "",
"fields": {
"username": "Поширено",
"url": "Посилання",
"description": "Опис",
"contents": "Вміст",
"expiresAt": "Дійсний",
"lastVisitedAt": "Останній візит",
"visitCount": "Відвідин",
"format": "Формат",
"maxBitRate": "Макс. Біт рейт",
"updatedAt": "Оновлено",
"createdAt": "Створено",
"downloadable": "Дозволити завантаження?"
"username": "",
"url": "",
"description": "",
"contents": "",
"expiresAt": "",
"lastVisitedAt": "",
"visitCount": "",
"format": "",
"maxBitRate": "",
"updatedAt": "",
"createdAt": "",
"downloadable": ""
}
}
},
@@ -226,7 +222,7 @@
"oneOf": "Повинен бути одним з: %{options}",
"regex": "Повинен відповідати формату (регулярний вираз): %{pattern}",
"unique": "Має бути унікальним",
"url": "Повинно бути дійсне посилання"
"url": ""
},
"action": {
"add_filter": "Додати фільтр",
@@ -256,9 +252,9 @@
"close_menu": "Закрити меню",
"unselect": "Забрати виділення",
"skip": "Пропустити",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Поширити",
"download": "Завантаження"
"bulk_actions_mobile": "",
"share": "",
"download": ""
},
"boolean": {
"true": "Так",
@@ -353,27 +349,27 @@
"delete_user_content": "Ви справді хочете видалити цього користувача і усі його данні (включаючи списки відтворення і налаштування)?",
"notifications_blocked": "У вас заблоковані Сповіщення для цього сайту у вашому браузері",
"notifications_not_available": "Ваш браузер не підтримує сповіщень або доступ до Navidrome не використовує https",
"lastfmLinkSuccess": "Last.fm успішно підключено, scrobbling увімкнено",
"lastfmLinkFailure": "Last.fm не вдалося підключити",
"lastfmUnlinkSuccess": "Last.fm від'єднано та вимкнено scrobbling",
"lastfmUnlinkFailure": "Last.fm не вдалося від'єднати",
"lastfmLinkSuccess": "",
"lastfmLinkFailure": "",
"lastfmUnlinkSuccess": "",
"lastfmUnlinkFailure": "",
"openIn": {
"lastfm": "Відкрити у Last.fm",
"musicbrainz": "Відкрити у MusicBrainz"
},
"lastfmLink": "Читати більше...",
"listenBrainzLinkSuccess": "ListenBrainz успішно підключено і scrobbling увімкнено для користувача: %{user}.",
"listenBrainzLinkFailure": "ListenBrainz не вдалося зв'язати: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz від'єднано та вимкнено scrobbling",
"listenBrainzUnlinkFailure": "ListenBrainz не вдалося від'єднати",
"downloadOriginalFormat": "Завантажити в вихідному форматі",
"shareOriginalFormat": "Поширити у вихідному форматі",
"shareDialogTitle": "Поширити %{resource} '%{name}'",
"shareBatchDialogTitle": "Поширити 1 %{resource} |||| Поширити %{smart_count} %{resource}",
"shareSuccess": "URL скопійований в буфер обміну: %{url}",
"shareFailure": "Помилка копіюваня URL %{url} в буфер обміну",
"downloadDialogTitle": "Завантаження %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Скопіювати в буфер: Ctrl+C, Enter"
"listenBrainzLinkSuccess": "",
"listenBrainzLinkFailure": "",
"listenBrainzUnlinkSuccess": "",
"listenBrainzUnlinkFailure": "",
"downloadOriginalFormat": "",
"shareOriginalFormat": "",
"shareDialogTitle": "",
"shareBatchDialogTitle": "",
"shareSuccess": "",
"shareFailure": "",
"downloadDialogTitle": "",
"shareCopyToClipboard": ""
},
"menu": {
"library": "Бібліотека",
@@ -387,14 +383,14 @@
"language": "Мова",
"defaultView": "Вигляд по замовчуванню",
"desktop_notifications": "Сповіщення",
"lastfmScrobbling": "Scrobble на Last.fm",
"listenBrainzScrobbling": "Scrobble на ListenBrainz",
"replaygain": "Режим ReplayGain",
"preAmp": "ReplayGain підсилення (дБ)",
"lastfmScrobbling": "",
"listenBrainzScrobbling": "",
"replaygain": "",
"preAmp": "",
"gain": {
"none": "Вимкнено",
"album": "Використовуйте підсилення для Альбому",
"track": "Використовуйте посилення доріжки"
"none": "",
"album": "",
"track": ""
}
}
},
@@ -454,7 +450,7 @@
"vol_up": "Гучність вгору",
"vol_down": "Гучність вниз",
"toggle_love": "Відмітити поточні пісні",
"current_song": "Перейти до поточної пісні"
"current_song": ""
}
}
}

View File

@@ -2,7 +2,7 @@
"languageName": "简体中文",
"resources": {
"song": {
"name": "歌曲",
"name": "歌曲 |||| 歌曲",
"fields": {
"albumArtist": "专辑歌手",
"duration": "时长",
@@ -39,7 +39,7 @@
}
},
"album": {
"name": "专辑",
"name": "专辑 |||| 专辑",
"fields": {
"albumArtist": "专辑歌手",
"artist": "歌手",
@@ -54,11 +54,7 @@
"comment": "注释",
"rating": "评分",
"createdAt": "创建于",
"size": "文件大小",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"size": "文件大小"
},
"actions": {
"playAll": "立即播放",
@@ -81,7 +77,7 @@
}
},
"artist": {
"name": "艺术家",
"name": "艺术家 |||| 艺术家",
"fields": {
"name": "名称",
"albumCount": "专辑数",
@@ -93,7 +89,7 @@
}
},
"user": {
"name": "用户",
"name": "用户 |||| 用户",
"fields": {
"userName": "用户名",
"isAdmin": "是否管理员",
@@ -121,7 +117,7 @@
}
},
"player": {
"name": "客户端",
"name": "客户端 |||| 客户端",
"fields": {
"name": "名称",
"transcodingId": "转码编号",
@@ -134,7 +130,7 @@
}
},
"transcoding": {
"name": "转码",
"name": "转码 |||| 转码",
"fields": {
"name": "名称",
"targetFormat": "目标格式",
@@ -143,7 +139,7 @@
}
},
"playlist": {
"name": "歌单",
"name": "歌单 |||| 歌单",
"fields": {
"name": "名称",
"duration": "时长",
@@ -169,7 +165,7 @@
}
},
"radio": {
"name": "电台",
"name": "电台 |||| 电台",
"fields": {
"name": "名称",
"streamUrl": "推流地址",
@@ -182,7 +178,7 @@
}
},
"share": {
"name": "分享",
"name": "分享 |||| 分享",
"fields": {
"username": "分享者",
"url": "链接",
@@ -256,7 +252,7 @@
"close_menu": "关闭菜单",
"unselect": "未选择",
"skip": "跳过",
"bulk_actions_mobile": "%{smart_count}",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "分享",
"download": "下载"
},
@@ -298,8 +294,8 @@
"message": {
"about": "关于",
"are_you_sure": "您确定要进行此操作?",
"bulk_delete_content": "您确定要删除 %{smart_count} 项 %{name}",
"bulk_delete_title": "删除 %{smart_count} 项 %{name}",
"bulk_delete_content": "您确定要删除 %{name} |||| 您确定要删除 %{smart_count} 项?",
"bulk_delete_title": "删除 %{name} |||| 删除 %{smart_count} 项 %{name}",
"delete_content": "您确定要删除该条目?",
"delete_title": "删除 %{name} #%{id}",
"details": "详情",
@@ -313,8 +309,8 @@
},
"navigation": {
"no_results": "无内容",
"no_more_results": "页码 %{page} 超出范围,尝试返回上一页",
"page_out_of_boundaries": "页码 %{page} 超出范围",
"no_more_results": "页码 %{page} 超出边界,尝试返回上一页",
"page_out_of_boundaries": "页码 %{page} 超出边界",
"page_out_from_end": "已经最后一页",
"page_out_from_begin": "已经是第一页",
"page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
@@ -324,11 +320,11 @@
"skip_nav": "跳过"
},
"notification": {
"updated": "已更新 %{smart_count} 项",
"updated": "已更新 1 项 |||| 已更新 %{smart_count} 项",
"created": "已新建 1 项",
"deleted": "已删除 %{smart_count} 项",
"deleted": "已删除 1 项 |||| 已删除 %{smart_count} 项",
"bad_item": "不正确的项",
"item_doesnt_exist": "项不存在",
"item_doesnt_exist": "项不存在",
"http_error": "与服务通信出错",
"data_provider_error": "数据来源错误,请检查控制台的详细信息",
"i18n_error": "加载所选语言时出错",
@@ -347,7 +343,7 @@
"note": "说明",
"transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。",
"transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过配置转码选项来执行任意命令,建议仅在配置转码选项时启用此功能。",
"songsAddedToPlaylist": "已添加 %{smart_count} 首歌到歌单",
"songsAddedToPlaylist": "已添加 1 首歌到歌单 |||| 已添加 %{smart_count} 首歌到歌单",
"noPlaylistsAvailable": "没有有效的歌单",
"delete_user_title": "删除用户 %{name}",
"delete_user_content": "您确定要删除该用户及其相关数据(包括歌单和用户配置)吗?",
@@ -369,7 +365,7 @@
"downloadOriginalFormat": "下载原始格式",
"shareOriginalFormat": "分享原始格式",
"shareDialogTitle": "分享 %{resource} '%{name}'",
"shareBatchDialogTitle": "分享 %{smart_count} 个 %{resource}",
"shareBatchDialogTitle": "分享 1 个 %{resource} |||| 分享 %{smart_count} 个 %{resource}",
"shareSuccess": "分享链接已复制: %{url}",
"shareFailure": "分享链接复制失败: %{url}",
"downloadDialogTitle": "下载 %{resource} '%{name}' (%{size})",

View File

@@ -54,11 +54,7 @@
"comment": "註釋",
"rating": "評分",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"size": ""
},
"actions": {
"playAll": "立即播放",

View File

@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/deluan/sanitize"
"github.com/navidrome/navidrome/conf"
@@ -31,10 +32,9 @@ func newMediaFileMapper(rootFolder string, genres model.GenreRepository) *mediaF
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)
mf.Title = s.mapTrackTitle(md)
mf.Album = md.Album()
mf.AlbumID = s.albumID(md, mf.ReleaseDate)
mf.AlbumID = s.albumID(md)
mf.Album = s.mapAlbumName(md)
mf.ArtistID = s.artistID(md)
mf.Artist = s.mapArtistName(md)
@@ -42,6 +42,7 @@ func (s mediaFileMapper) toMediaFile(md metadata.Tags) model.MediaFile {
mf.AlbumArtist = s.mapAlbumArtistName(md)
mf.Genre, mf.Genres = s.mapGenres(md.Genres())
mf.Compilation = md.Compilation()
mf.Year = md.Year()
mf.TrackNumber, _ = md.TrackNumber()
mf.DiscNumber, _ = md.DiscNumber()
mf.DiscSubtitle = md.DiscSubtitle()
@@ -61,7 +62,7 @@ func (s mediaFileMapper) toMediaFile(md metadata.Tags) model.MediaFile {
mf.OrderArtistName = sanitizeFieldForSorting(mf.Artist)
mf.OrderAlbumArtistName = sanitizeFieldForSorting(mf.AlbumArtist)
mf.CatalogNum = md.CatalogNum()
mf.MbzRecordingID = md.MbzRecordingID()
mf.MbzTrackID = md.MbzTrackID()
mf.MbzReleaseTrackID = md.MbzReleaseTrackID()
mf.MbzAlbumID = md.MbzAlbumID()
mf.MbzArtistID = md.MbzArtistID()
@@ -75,7 +76,7 @@ func (s mediaFileMapper) toMediaFile(md metadata.Tags) model.MediaFile {
mf.Comment = utils.SanitizeText(md.Comment())
mf.Lyrics = utils.SanitizeText(md.Lyrics())
mf.Bpm = md.Bpm()
mf.CreatedAt = md.BirthTime()
mf.CreatedAt = time.Now()
mf.UpdatedAt = md.ModificationTime()
return *mf
@@ -127,13 +128,8 @@ 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) string {
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", s.mapAlbumArtistName(md), s.mapAlbumName(md)))
if !conf.Server.Scanner.GroupAlbumReleases {
if len(releaseDate) != 0 {
albumPath = fmt.Sprintf("%s\\%s", albumPath, releaseDate)
}
}
return fmt.Sprintf("%x", md5.Sum([]byte(albumPath)))
}
@@ -173,26 +169,3 @@ 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()
// MusicBrainz Picard writes the Release Date of an album to the Date tag, and leaves the Release Date tag empty
taggedLikePicard := (originalYear != 0) &&
(releaseYear == 0) &&
(year >= originalYear)
if taggedLikePicard {
return originalYear, originalDate, originalYear, originalDate, year, date
}
// when there's no Date, first fall back to Original Date, then to Release Date.
if year == 0 {
if originalYear > 0 {
year, date = originalYear, originalDate
} else {
year, date = releaseYear, releaseDate
}
}
return year, date, originalYear, originalDate, releaseYear, releaseDate
}

View File

@@ -14,10 +14,10 @@ import (
var _ = Describe("mapping", func() {
Describe("mediaFileMapper", func() {
var mapper *mediaFileMapper
BeforeEach(func() {
mapper = newMediaFileMapper("/music", nil)
})
Describe("mapTrackTitle", func() {
BeforeEach(func() {
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"}})
Expect(mapper.mapTrackTitle(md)).To(Equal("This is not a love song"))
@@ -27,141 +27,7 @@ var _ = Describe("mapping", func() {
Expect(mapper.mapTrackTitle(md)).To(Equal("artist/album01/Song"))
})
})
Describe("mapGenres", func() {
var gr model.GenreRepository
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
ds := &tests.MockDataStore{}
gr = ds.Genre(ctx)
gr = newCachedGenreRepository(ctx, gr)
mapper = newMediaFileMapper("/", gr)
})
It("returns empty if no genres are available", func() {
g, gs := mapper.mapGenres(nil)
Expect(g).To(BeEmpty())
Expect(gs).To(BeEmpty())
})
It("returns genres", func() {
g, gs := mapper.mapGenres([]string{"Rock", "Electronic"})
Expect(g).To(Equal("Rock"))
Expect(gs).To(HaveLen(2))
Expect(gs[0].Name).To(Equal("Rock"))
Expect(gs[1].Name).To(Equal("Electronic"))
})
It("parses multi-valued genres", func() {
g, gs := mapper.mapGenres([]string{"Rock;Dance", "Electronic", "Rock"})
Expect(g).To(Equal("Rock"))
Expect(gs).To(HaveLen(3))
Expect(gs[0].Name).To(Equal("Rock"))
Expect(gs[1].Name).To(Equal("Dance"))
Expect(gs[2].Name).To(Equal("Electronic"))
})
It("trims genres names", func() {
_, gs := mapper.mapGenres([]string{"Rock ; Dance", " Electronic "})
Expect(gs).To(HaveLen(3))
Expect(gs[0].Name).To(Equal("Rock"))
Expect(gs[1].Name).To(Equal("Dance"))
Expect(gs[2].Name).To(Equal("Electronic"))
})
It("does not break on spaces", func() {
_, gs := mapper.mapGenres([]string{"New Wave"})
Expect(gs).To(HaveLen(1))
Expect(gs[0].Name).To(Equal("New Wave"))
})
})
Describe("mapDates", func() {
var md metadata.Tags
BeforeEach(func() {
mapper = newMediaFileMapper("/", nil)
})
Context("when all date fields are provided", func() {
BeforeEach(func() {
md = metadata.NewTag("/music/artist/album01/Song.mp3", nil, metadata.ParsedTags{
"date": []string{"2023-03-01"},
"originaldate": []string{"2022-05-10"},
"releasedate": []string{"2023-01-15"},
})
})
It("should map all date fields correctly", func() {
year, date, originalYear, originalDate, releaseYear, releaseDate := mapper.mapDates(md)
Expect(year).To(Equal(2023))
Expect(date).To(Equal("2023-03-01"))
Expect(originalYear).To(Equal(2022))
Expect(originalDate).To(Equal("2022-05-10"))
Expect(releaseYear).To(Equal(2023))
Expect(releaseDate).To(Equal("2023-01-15"))
})
})
Context("when date field is missing", func() {
BeforeEach(func() {
md = metadata.NewTag("/music/artist/album01/Song.mp3", nil, metadata.ParsedTags{
"originaldate": []string{"2022-05-10"},
"releasedate": []string{"2023-01-15"},
})
})
It("should fallback to original date if date is missing", func() {
year, date, _, _, _, _ := mapper.mapDates(md)
Expect(year).To(Equal(2022))
Expect(date).To(Equal("2022-05-10"))
})
})
Context("when original and release dates are missing", func() {
BeforeEach(func() {
md = metadata.NewTag("/music/artist/album01/Song.mp3", nil, metadata.ParsedTags{
"date": []string{"2023-03-01"},
})
})
It("should only map the date field", func() {
year, date, originalYear, originalDate, releaseYear, releaseDate := mapper.mapDates(md)
Expect(year).To(Equal(2023))
Expect(date).To(Equal("2023-03-01"))
Expect(originalYear).To(BeZero())
Expect(originalDate).To(BeEmpty())
Expect(releaseYear).To(BeZero())
Expect(releaseDate).To(BeEmpty())
})
})
Context("when date fields are in an incorrect format", func() {
BeforeEach(func() {
md = metadata.NewTag("/music/artist/album01/Song.mp3", nil, metadata.ParsedTags{
"date": []string{"invalid-date"},
})
})
It("should handle invalid date formats gracefully", func() {
year, date, _, _, _, _ := mapper.mapDates(md)
Expect(year).To(BeZero())
Expect(date).To(BeEmpty())
})
})
Context("when all date fields are missing", func() {
It("should return zero values for all date fields", func() {
year, date, originalYear, originalDate, releaseYear, releaseDate := mapper.mapDates(md)
Expect(year).To(BeZero())
Expect(date).To(BeEmpty())
Expect(originalYear).To(BeZero())
Expect(originalDate).To(BeEmpty())
Expect(releaseYear).To(BeZero())
Expect(releaseDate).To(BeEmpty())
})
})
})
})
Describe("sanitizeFieldForSorting", func() {
BeforeEach(func() {
conf.Server.IgnoredArticles = "The O"
@@ -176,4 +42,52 @@ var _ = Describe("mapping", func() {
Expect(sanitizeFieldForSorting("Õ Blésq Blom")).To(Equal("Blesq Blom"))
})
})
Describe("mapGenres", func() {
var mapper *mediaFileMapper
var gr model.GenreRepository
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
ds := &tests.MockDataStore{}
gr = ds.Genre(ctx)
gr = newCachedGenreRepository(ctx, gr)
mapper = newMediaFileMapper("/", gr)
})
It("returns empty if no genres are available", func() {
g, gs := mapper.mapGenres(nil)
Expect(g).To(BeEmpty())
Expect(gs).To(BeEmpty())
})
It("returns genres", func() {
g, gs := mapper.mapGenres([]string{"Rock", "Electronic"})
Expect(g).To(Equal("Rock"))
Expect(gs).To(HaveLen(2))
Expect(gs[0].Name).To(Equal("Rock"))
Expect(gs[1].Name).To(Equal("Electronic"))
})
It("parses multi-valued genres", func() {
g, gs := mapper.mapGenres([]string{"Rock;Dance", "Electronic", "Rock"})
Expect(g).To(Equal("Rock"))
Expect(gs).To(HaveLen(3))
Expect(gs[0].Name).To(Equal("Rock"))
Expect(gs[1].Name).To(Equal("Dance"))
Expect(gs[2].Name).To(Equal("Electronic"))
})
It("trims genres names", func() {
_, gs := mapper.mapGenres([]string{"Rock ; Dance", " Electronic "})
Expect(gs).To(HaveLen(3))
Expect(gs[0].Name).To(Equal("Rock"))
Expect(gs[1].Name).To(Equal("Dance"))
Expect(gs[2].Name).To(Equal("Electronic"))
})
It("does not break on spaces", func() {
_, gs := mapper.mapGenres([]string{"New Wave"})
Expect(gs).To(HaveLen(1))
Expect(gs[0].Name).To(Equal("New Wave"))
})
})
})

View File

@@ -43,9 +43,8 @@ func (e *Extractor) Parse(files ...string) (map[string]metadata.ParsedTags, erro
func (e *Extractor) CustomMappings() metadata.ParsedTags {
return metadata.ParsedTags{
"disc": {"tpa"},
"has_picture": {"metadata_block_picture"},
"originaldate": {"tdor"},
"disc": {"tpa"},
"has_picture": {"metadata_block_picture"},
}
}

View File

@@ -10,7 +10,6 @@ import (
"strings"
"time"
"github.com/djherbis/times"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
@@ -95,18 +94,14 @@ func (t Tags) Artist() string { return t.getFirstTagValue("artist", "sort_artist
func (t Tags) AlbumArtist() string {
return t.getFirstTagValue("album_artist", "album artist", "albumartist")
}
func (t Tags) SortTitle() string { return t.getSortTag("", "title", "name") }
func (t Tags) SortAlbum() string { return t.getSortTag("", "album") }
func (t Tags) SortArtist() string { return t.getSortTag("", "artist") }
func (t Tags) SortAlbumArtist() string { return t.getSortTag("tso2", "albumartist", "album_artist") }
func (t Tags) Genres() []string { return t.getAllTagValues("genre") }
func (t Tags) Date() (int, string) { return t.getDate("date") }
func (t Tags) OriginalDate() (int, string) { return t.getDate("originaldate") }
func (t Tags) ReleaseDate() (int, string) { return t.getDate("releasedate") }
func (t Tags) Comment() string { return t.getFirstTagValue("comment") }
func (t Tags) Lyrics() string {
return t.getFirstTagValue("lyrics", "lyrics-eng", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics")
}
func (t Tags) SortTitle() string { return t.getSortTag("", "title", "name") }
func (t Tags) SortAlbum() string { return t.getSortTag("", "album") }
func (t Tags) SortArtist() string { return t.getSortTag("", "artist") }
func (t Tags) SortAlbumArtist() string { return t.getSortTag("tso2", "albumartist", "album_artist") }
func (t Tags) Genres() []string { return t.getAllTagValues("genre") }
func (t Tags) Year() int { return t.getYear("date") }
func (t Tags) Comment() string { return t.getFirstTagValue("comment") }
func (t Tags) Lyrics() string { return t.getFirstTagValue("lyrics", "lyrics-eng") }
func (t Tags) Compilation() bool { return t.getBool("tcmp", "compilation") }
func (t Tags) TrackNumber() (int, int) { return t.getTuple("track", "tracknumber") }
func (t Tags) DiscNumber() (int, int) { return t.getTuple("disc", "discnumber") }
@@ -123,9 +118,7 @@ func (t Tags) MbzReleaseTrackID() string {
return t.getMbzID("musicbrainz_releasetrackid", "musicbrainz release track id")
}
func (t Tags) MbzRecordingID() string {
return t.getMbzID("musicbrainz_trackid", "musicbrainz track id")
}
func (t Tags) MbzTrackID() string { return t.getMbzID("musicbrainz_trackid", "musicbrainz track id") }
func (t Tags) MbzAlbumID() string { return t.getMbzID("musicbrainz_albumid", "musicbrainz album id") }
func (t Tags) MbzArtistID() string {
return t.getMbzID("musicbrainz_artistid", "musicbrainz artist id")
@@ -149,12 +142,6 @@ func (t Tags) ModificationTime() time.Time { return t.fileInfo.ModTime() }
func (t Tags) Size() int64 { return t.fileInfo.Size() }
func (t Tags) FilePath() string { return t.filePath }
func (t Tags) Suffix() string { return strings.ToLower(strings.TrimPrefix(path.Ext(t.filePath), ".")) }
func (t Tags) BirthTime() time.Time {
if ts := times.Get(t.fileInfo); ts.HasBirthTime() {
return ts.BirthTime()
}
return time.Now()
}
// Replaygain Properties
func (t Tags) RGAlbumGain() float64 { return t.getGainValue("replaygain_album_gain") }
@@ -230,38 +217,18 @@ func (t Tags) getSortTag(originalTag string, tagNames ...string) string {
var dateRegex = regexp.MustCompile(`([12]\d\d\d)`)
func (t Tags) getDate(tagNames ...string) (int, string) {
func (t Tags) getYear(tagNames ...string) int {
tag := t.getFirstTagValue(tagNames...)
if len(tag) < 4 {
return 0, ""
if tag == "" {
return 0
}
// first get just the year
match := dateRegex.FindStringSubmatch(tag)
if len(match) == 0 {
log.Warn("Error parsing "+tagNames[0]+" field for year", "file", t.filePath, "date", tag)
return 0, ""
log.Warn("Error parsing year date field", "file", t.filePath, "date", tag)
return 0
}
year, _ := strconv.Atoi(match[1])
if len(tag) < 5 {
return year, match[1]
}
//then try YYYY-MM-DD
if len(tag) > 10 {
tag = tag[:10]
}
layout := "2006-01-02"
_, err := time.Parse(layout, tag)
if err != nil {
layout = "2006-01"
_, err = time.Parse(layout, tag)
if err != nil {
log.Warn("Error parsing "+tagNames[0]+" field for month + day", "file", t.filePath, "date", tag)
return year, match[1]
}
}
return year, tag
return year
}
func (t Tags) getBool(tagNames ...string) bool {

View File

@@ -6,25 +6,31 @@ import (
)
var _ = Describe("Tags", func() {
DescribeTable("getDate",
func(tag string, expectedYear int, expectedDate string) {
Describe("getYear", func() {
It("parses the year correctly", func() {
var examples = map[string]int{
"1985": 1985,
"2002-01": 2002,
"1969.06": 1969,
"1980.07.25": 1980,
"2004-00-00": 2004,
"2013-May-12": 2013,
"May 12, 2016": 2016,
"01/10/1990": 1990,
}
for tag, expected := range examples {
md := &Tags{}
md.tags = map[string][]string{"date": {tag}}
Expect(md.Year()).To(Equal(expected))
}
})
It("returns 0 if year is invalid", func() {
md := &Tags{}
md.tags = map[string][]string{"date": {tag}}
testYear, testDate := md.Date()
Expect(testYear).To(Equal(expectedYear))
Expect(testDate).To(Equal(expectedDate))
},
Entry(nil, "1985", 1985, "1985"),
Entry(nil, "2002-01", 2002, "2002-01"),
Entry(nil, "1969.06", 1969, "1969"),
Entry(nil, "1980.07.25", 1980, "1980"),
Entry(nil, "2004-00-00", 2004, "2004"),
Entry(nil, "2016-12-31", 2016, "2016-12-31"),
Entry(nil, "2013-May-12", 2013, "2013"),
Entry(nil, "May 12, 2016", 2016, "2016"),
Entry(nil, "01/10/1990", 1990, "1990"),
Entry(nil, "invalid", 0, ""),
)
md.tags = map[string][]string{"date": {"invalid"}}
Expect(md.Year()).To(Equal(0))
})
})
Describe("getMbzID", func() {
It("return a valid MBID", func() {
@@ -36,7 +42,7 @@ var _ = Describe("Tags", func() {
"musicbrainz_artistid": {"89ad4ac3-39f7-470e-963a-56509c546377"},
"musicbrainz_albumartistid": {"ada7a83c-e3e1-40f1-93f9-3e73dbc9298a"},
}
Expect(md.MbzRecordingID()).To(Equal("8f84da07-09a0-477b-b216-cc982dabcde1"))
Expect(md.MbzTrackID()).To(Equal("8f84da07-09a0-477b-b216-cc982dabcde1"))
Expect(md.MbzReleaseTrackID()).To(Equal("6caf16d3-0b20-3fe6-8020-52e31831bc11"))
Expect(md.MbzAlbumID()).To(Equal("f68c985d-f18b-4f4a-b7f0-87837cf3fbf9"))
Expect(md.MbzArtistID()).To(Equal("89ad4ac3-39f7-470e-963a-56509c546377"))
@@ -50,7 +56,7 @@ var _ = Describe("Tags", func() {
"musicbrainz_artistid": {"200455"},
"musicbrainz_albumartistid": {"194"},
}
Expect(md.MbzRecordingID()).To(Equal(""))
Expect(md.MbzTrackID()).To(Equal(""))
Expect(md.MbzAlbumID()).To(Equal(""))
Expect(md.MbzArtistID()).To(Equal(""))
Expect(md.MbzAlbumArtistID()).To(Equal(""))

View File

@@ -27,15 +27,7 @@ var _ = Describe("Tags", func() {
Expect(m.AlbumArtist()).To(Equal("Album Artist"))
Expect(m.Compilation()).To(BeTrue())
Expect(m.Genres()).To(Equal([]string{"Rock"}))
y, d := m.Date()
Expect(y).To(Equal(2014))
Expect(d).To(Equal("2014-05-21"))
y, d = m.OriginalDate()
Expect(y).To(Equal(1996))
Expect(d).To(Equal("1996-11-21"))
y, d = m.ReleaseDate()
Expect(y).To(Equal(2020))
Expect(d).To(Equal("2020-12-31"))
Expect(m.Year()).To(Equal(2014))
n, t := m.TrackNumber()
Expect(n).To(Equal(2))
Expect(t).To(Equal(10))
@@ -56,15 +48,15 @@ var _ = Describe("Tags", func() {
m = mds["tests/fixtures/test.ogg"]
Expect(err).To(BeNil())
Expect(m.Title()).To(Equal("Title"))
Expect(m.Title()).To(BeEmpty())
Expect(m.HasPicture()).To(BeFalse())
Expect(m.Duration()).To(BeNumerically("~", 1.04, 0.01))
Expect(m.Suffix()).To(Equal("ogg"))
Expect(m.FilePath()).To(Equal("tests/fixtures/test.ogg"))
Expect(m.Size()).To(Equal(int64(6333)))
Expect(m.Size()).To(Equal(int64(5065)))
// TabLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m.BitRate()).To(BeElementOf(18, 39, 40, 49))
Expect(m.BitRate()).To(BeElementOf(18, 39))
})
})
})

View File

@@ -41,9 +41,7 @@ var _ = Describe("Extractor", func() {
Expect(m).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m).To(HaveKeyWithValue("tcmp", []string{"1"})) // Compilation
Expect(m).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m).To(HaveKeyWithValue("date", []string{"2014-05-21", "2014"}))
Expect(m).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
Expect(m).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"}))
Expect(m).To(HaveKeyWithValue("date", []string{"2014", "2014"}))
Expect(m).To(HaveKeyWithValue("discnumber", []string{"1/2"}))
Expect(m).To(HaveKeyWithValue("has_picture", []string{"true"}))
Expect(m).To(HaveKeyWithValue("duration", []string{"1.02"}))
@@ -52,10 +50,6 @@ var _ = Describe("Extractor", func() {
Expect(m).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
Expect(m).To(HaveKeyWithValue("lyrics", []string{"Lyrics 1\rLyrics 2"}))
Expect(m).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
Expect(m).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
Expect(m).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}))
Expect(m).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}))
Expect(m).To(HaveKeyWithValue("tracknumber", []string{"2/10"}))
m = m.Map(e.CustomMappings())
@@ -63,6 +57,7 @@ var _ = Describe("Extractor", func() {
m = mds["tests/fixtures/test.ogg"]
Expect(err).To(BeNil())
Expect(m).ToNot(HaveKey("title"))
Expect(m).ToNot(HaveKey("has_picture"))
Expect(m).To(HaveKeyWithValue("duration", []string{"1.04"}))
Expect(m).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
@@ -70,71 +65,8 @@ var _ = Describe("Extractor", func() {
// TabLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m).To(HaveKey("bitrate"))
Expect(m["bitrate"][0]).To(BeElementOf("18", "39", "40", "49"))
Expect(m["bitrate"][0]).To(BeElementOf("18", "39"))
})
DescribeTable("Format-Specific tests",
func(file, duration, channels, albumGain, albumPeak, trackGain, trackPeak string) {
file = "tests/fixtures/" + file
mds, err := e.Parse(file)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(1))
m := mds[file]
Expect(m).To(HaveKeyWithValue("replaygain_album_gain", []string{albumGain}))
Expect(m).To(HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}))
Expect(m).To(HaveKeyWithValue("replaygain_track_gain", []string{trackGain}))
Expect(m).To(HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}))
Expect(m).To(HaveKeyWithValue("title", []string{"Title", "Title"}))
Expect(m).To(HaveKeyWithValue("album", []string{"Album", "Album"}))
Expect(m).To(HaveKeyWithValue("artist", []string{"Artist", "Artist"}))
Expect(m).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m).To(HaveKeyWithValue("compilation", []string{"1"}))
Expect(m).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m).To(HaveKeyWithValue("date", []string{"2014", "2014"}))
Expect(m).To(HaveKey("discnumber"))
discno := m["discnumber"]
Expect(discno).To(HaveLen(1))
Expect(discno[0]).To(BeElementOf([]string{"1", "1/2"}))
Expect(m).NotTo(HaveKeyWithValue("has_picture", []string{"true"}))
Expect(m).To(HaveKeyWithValue("duration", []string{duration}))
Expect(m).To(HaveKeyWithValue("channels", []string{channels}))
Expect(m).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
Expect(m).To(HaveKeyWithValue("lyrics", []string{"Lyrics1\nLyrics 2"}))
Expect(m).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m).To(HaveKey("tracknumber"))
trackNo := m["tracknumber"]
Expect(trackNo).To(HaveLen(1))
Expect(trackNo[0]).To(BeElementOf([]string{"3", "3/10"}))
},
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
Entry("correctly parses flac tags", "test.flac", "1.00", "1", "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948"),
Entry("Correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04", "2", "0.37", "0.48", "0.37", "0.48"),
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04", "2", "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506"),
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
Entry("correctly parses wma/asf tags", "test.wma", "1.02", "1", "3.27 dB", "0.132914", "3.27 dB", "0.132914"),
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
Entry("correctly parses wv (wavpak) tags", "test.wv", "1.00", "1", "3.43 dB", "0.125061", "3.43 dB", "0.125061"),
// TODO - these breaks in the pipeline as it uses TabLib 1.11. Once Ubuntu 24.04 is released we can uncomment these tests
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
//Entry("correctly parses wav tags", "test.wav", "1.00", "1", "3.06 dB", "0.125056", "3.06 dB", "0.125056"),
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
//Entry("correctly parses aiff tags", "test.aiff", "1.00", "1", "2.00 dB", "0.124972", "2.00 dB", "0.124972"),
)
})
Context("Error Checking", func() {

View File

@@ -15,13 +15,6 @@
#include "taglib_wrapper.h"
// Tags necessary for M4a parsing
const char *RG_TAGS[] = {
"replaygain_album_gain",
"replaygain_album_peak",
"replaygain_track_gain",
"replaygain_track_peak"};
char has_cover(const TagLib::FileRef f);
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
@@ -77,52 +70,6 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
}
}
TagLib::MP4::File *m4afile(dynamic_cast<TagLib::MP4::File *>(f.file()));
if (m4afile != NULL)
{
const auto itemListMap = m4afile->tag();
{
char buf[200];
for (const char *key : RG_TAGS)
{
snprintf(buf, sizeof(buf), "----:com.apple.iTunes:%s", key);
const auto item = itemListMap->item(buf);
if (item.isValid())
{
char *dup = ::strdup(key);
char *val = ::strdup(item.toStringList().front().toCString(true));
go_map_put_str(id, dup, val);
free(dup);
free(val);
}
}
}
}
// WMA/ASF files may have additional tags not captured by the general iterator
TagLib::ASF::File *asfFile(dynamic_cast<TagLib::ASF::File *>(f.file()));
if (asfFile != NULL)
{
const TagLib::ASF::Tag *asfTags{asfFile->tag()};
const auto itemListMap = asfTags->attributeListMap();
for (const auto item : itemListMap) {
char *key = ::strdup(item.first.toCString(true));
char *val = ::strdup(item.second.front().toString().toCString());
go_map_put_str(id, key, val);
free(key);
free(val);
}
// Compilation tag needs to be handled differently
const auto compilation = asfTags->attribute("WM/IsCompilation");
if (!compilation.isEmpty()) {
char *val = ::strdup(compilation.front().toString().toCString());
go_map_put_str(id, (char *)"compilation", val);
free(val);
}
}
if (has_cover(f)) {
go_map_put_str(id, (char *)"has_picture", (char *)"true");
}

View File

@@ -36,9 +36,6 @@ func (s *playlistImporter) processPlaylists(ctx context.Context, dir string) int
return count
}
for _, f := range files {
if strings.HasPrefix(f.Name(), ".") {
continue
}
if !model.IsValidPlaylist(f.Name()) {
continue
}

View File

@@ -59,7 +59,7 @@ var _ = Describe("playlistImporter", func() {
conf.Server.PlaylistsPath = "."
ps = newPlaylistImporter(ds, pls, cw, "tests/fixtures/playlists")
Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists")).To(Equal(int64(5)))
Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists")).To(Equal(int64(3)))
Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists/subfolder1")).To(Equal(int64(0)))
})

View File

@@ -142,8 +142,7 @@ func (r *refresher) refreshArtists(ctx context.Context, ids ...string) error {
// Force a external metadata lookup on next access
a.ExternalInfoUpdatedAt = time.Time{}
// Do not remove old metadata
err := repo.Put(&a, "album_count", "genres", "external_info_updated_at", "mbz_artist_id", "name", "order_artist_name", "size", "sort_artist_name", "song_count")
err := repo.Put(&a)
if err != nil {
return err
}

View File

@@ -103,9 +103,7 @@ func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time, prog
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)
foldersFound, walkerError := walkDirTree(ctx, s.rootFolder)
foldersFound, walkerError := s.getRootFolderWalker(ctx)
for {
folderStats, more := <-foldersFound
if !more {
@@ -176,6 +174,22 @@ func isDirEmpty(ctx context.Context, dir string) (bool, error) {
return len(children) == 0 && stats.AudioFilesCount == 0, nil
}
func (s *TagScanner) getRootFolderWalker(ctx context.Context) (walkResults, chan error) {
start := time.Now()
log.Trace(ctx, "Loading directory tree from music folder", "folder", s.rootFolder)
results := make(chan dirStats, 5000)
walkerError := make(chan error)
go func() {
err := walkDirTree(ctx, s.rootFolder, results)
if err != nil {
log.Error("There were errors reading directories from filesystem", err)
}
walkerError <- err
log.Debug("Finished reading directories from filesystem", "elapsed", time.Since(start))
}()
return results, walkerError
}
func (s *TagScanner) getDBDirTree(ctx context.Context) (map[string]struct{}, error) {
start := time.Now()
log.Trace(ctx, "Loading directory tree from database", "folder", s.rootFolder)

View File

@@ -10,17 +10,11 @@ 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(10))
Expect(files).To(HaveKey("tests/fixtures/test.aiff"))
Expect(files).To(HaveKey("tests/fixtures/test.flac"))
Expect(files).To(HaveKey("tests/fixtures/test.mp3"))
Expect(files).To(HaveLen(4))
Expect(files).To(HaveKey("tests/fixtures/test.ogg"))
Expect(files).To(HaveKey("tests/fixtures/test.wav"))
Expect(files).To(HaveKey("tests/fixtures/test.wma"))
Expect(files).To(HaveKey("tests/fixtures/test.wv"))
Expect(files).To(HaveKey("tests/fixtures/test.mp3"))
Expect(files).To(HaveKey("tests/fixtures/test_no_read_permission.ogg"))
Expect(files).To(HaveKey("tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
Expect(files).To(HaveKey("tests/fixtures/01 Invisible (RED) Edit Version.m4a"))
Expect(files).ToNot(HaveKey("tests/fixtures/._02 Invisible.mp3"))
Expect(files).ToNot(HaveKey("tests/fixtures/playlist.m3u"))
})

View File

@@ -13,6 +13,7 @@ import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
)
type (
@@ -24,25 +25,19 @@ type (
HasPlaylist bool
AudioFilesCount uint32
}
walkResults = chan dirStats
)
func walkDirTree(ctx context.Context, rootFolder string) (<-chan dirStats, chan error) {
results := make(chan dirStats)
errC := make(chan error)
go func() {
defer close(results)
defer close(errC)
err := walkFolder(ctx, rootFolder, rootFolder, results)
if err != nil {
log.Error(ctx, "There were errors reading directories from filesystem", "path", rootFolder, err)
errC <- err
}
log.Debug(ctx, "Finished reading directories from filesystem", "path", rootFolder)
}()
return results, errC
func walkDirTree(ctx context.Context, rootFolder string, results walkResults) error {
err := walkFolder(ctx, rootFolder, rootFolder, results)
if err != nil {
log.Error(ctx, "Error loading directory tree", err)
}
close(results)
return err
}
func walkFolder(ctx context.Context, rootPath string, currentFolder string, results chan<- dirStats) error {
func walkFolder(ctx context.Context, rootPath string, currentFolder string, results walkResults) error {
children, stats, err := loadDir(ctx, currentFolder)
if err != nil {
return err
@@ -81,14 +76,15 @@ func loadDir(ctx context.Context, dirPath string) ([]string, *dirStats, error) {
}
defer dir.Close()
for _, entry := range fullReadDir(ctx, dir) {
dirEntries := fullReadDir(ctx, dir)
for _, entry := range dirEntries {
isDir, err := isDirOrSymlinkToDir(dirPath, entry)
// Skip invalid symlinks
if err != nil {
log.Error(ctx, "Invalid symlink", "dir", filepath.Join(dirPath, entry.Name()), err)
continue
}
if isDir && !isDirIgnored(dirPath, entry) && isDirReadable(ctx, dirPath, entry) {
if isDir && !isDirIgnored(dirPath, entry) && isDirReadable(dirPath, entry) {
children = append(children, filepath.Join(dirPath, entry.Name()))
} else {
fileInfo, err := entry.Info()
@@ -117,14 +113,14 @@ func loadDir(ctx context.Context, dirPath string) ([]string, *dirStats, error) {
// fullReadDir reads all files in the folder, skipping the ones with errors.
// It also detects when it is "stuck" with an error in the same directory over and over.
// In this case, it stops and returns whatever it was able to read until it got stuck.
// In this case, it and returns whatever it was able to read until it got stuck.
// See discussion here: https://github.com/navidrome/navidrome/issues/1164#issuecomment-881922850
func fullReadDir(ctx context.Context, dir fs.ReadDirFile) []os.DirEntry {
var allEntries []os.DirEntry
var allDirs []os.DirEntry
var prevErrStr = ""
for {
entries, err := dir.ReadDir(-1)
allEntries = append(allEntries, entries...)
dirs, err := dir.ReadDir(-1)
allDirs = append(allDirs, dirs...)
if err == nil {
break
}
@@ -135,8 +131,8 @@ func fullReadDir(ctx context.Context, dir fs.ReadDirFile) []os.DirEntry {
}
prevErrStr = err.Error()
}
sort.Slice(allEntries, func(i, j int) bool { return allEntries[i].Name() < allEntries[j].Name() })
return allEntries
sort.Slice(allDirs, func(i, j int) bool { return allDirs[i].Name() < allDirs[j].Name() })
return allDirs
}
// isDirOrSymlinkToDir returns true if and only if the dirEnt represents a file
@@ -161,14 +157,13 @@ func isDirOrSymlinkToDir(baseDir string, dirEnt fs.DirEntry) (bool, error) {
}
// isDirIgnored returns true if the directory represented by dirEnt contains an
// `ignore` file (named after skipScanFile)
// `ignore` file (named after consts.SkipScanFile)
func isDirIgnored(baseDir string, dirEnt fs.DirEntry) bool {
// allows Album folders for albums which eg start with ellipses
// allows Album folders for albums which e.g. start with ellipses
name := dirEnt.Name()
if strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") {
return true
}
if runtime.GOOS == "windows" && strings.EqualFold(name, "$RECYCLE.BIN") {
return true
}
@@ -177,19 +172,11 @@ func isDirIgnored(baseDir string, dirEnt fs.DirEntry) bool {
}
// isDirReadable returns true if the directory represented by dirEnt is readable
func isDirReadable(ctx context.Context, baseDir string, dirEnt os.DirEntry) bool {
func isDirReadable(baseDir string, dirEnt fs.DirEntry) bool {
path := filepath.Join(baseDir, dirEnt.Name())
dir, err := os.Open(path)
if err != nil {
res, err := utils.IsDirReadable(path)
if !res {
log.Warn("Skipping unreadable directory", "path", path, err)
return false
}
err = dir.Close()
if err != nil {
log.Warn(ctx, "Error closing directory", "path", path, err)
}
return true
return res
}

View File

@@ -2,7 +2,6 @@ package scanner
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
@@ -14,13 +13,16 @@ import (
)
var _ = Describe("walk_dir_tree", func() {
dir, _ := os.Getwd()
baseDir := filepath.Join(dir, "tests", "fixtures")
baseDir := filepath.Join("tests", "fixtures")
Describe("walkDirTree", func() {
It("reads all info correctly", func() {
var collected = dirMap{}
results, errC := walkDirTree(context.Background(), baseDir)
results := make(walkResults, 5000)
var errC = make(chan error)
go func() {
errC <- walkDirTree(context.Background(), baseDir, results)
}()
for {
stats, more := <-results
@@ -30,11 +32,11 @@ var _ = Describe("walk_dir_tree", func() {
collected[stats.Path] = stats
}
Consistently(errC).ShouldNot(Receive())
Eventually(errC).Should(Receive(nil))
Expect(collected[baseDir]).To(MatchFields(IgnoreExtras, Fields{
"Images": BeEmpty(),
"HasPlaylist": BeFalse(),
"AudioFilesCount": BeNumerically("==", 11),
"AudioFilesCount": BeNumerically("==", 5),
}))
Expect(collected[filepath.Join(baseDir, "artist", "an-album")]).To(MatchFields(IgnoreExtras, Fields{
"Images": ConsistOf("cover.jpg", "front.png", "artist.png"),
@@ -49,41 +51,41 @@ var _ = Describe("walk_dir_tree", func() {
Describe("isDirOrSymlinkToDir", func() {
It("returns true for normal dirs", func() {
dirEntry := getDirEntry("tests", "fixtures")
dirEntry, _ := getDirEntry("tests", "fixtures")
Expect(isDirOrSymlinkToDir(baseDir, dirEntry)).To(BeTrue())
})
It("returns true for symlinks to dirs", func() {
dirEntry := getDirEntry(baseDir, "symlink2dir")
dirEntry, _ := getDirEntry(baseDir, "symlink2dir")
Expect(isDirOrSymlinkToDir(baseDir, dirEntry)).To(BeTrue())
})
It("returns false for files", func() {
dirEntry := getDirEntry(baseDir, "test.mp3")
dirEntry, _ := getDirEntry(baseDir, "test.mp3")
Expect(isDirOrSymlinkToDir(baseDir, dirEntry)).To(BeFalse())
})
It("returns false for symlinks to files", func() {
dirEntry := getDirEntry(baseDir, "symlink")
dirEntry, _ := getDirEntry(baseDir, "symlink")
Expect(isDirOrSymlinkToDir(baseDir, dirEntry)).To(BeFalse())
})
})
Describe("isDirIgnored", func() {
It("returns false for normal dirs", func() {
dirEntry := getDirEntry(baseDir, "empty_folder")
dirEntry, _ := getDirEntry(baseDir, "empty_folder")
Expect(isDirIgnored(baseDir, dirEntry)).To(BeFalse())
})
It("returns true when folder contains .ndignore file", func() {
dirEntry := getDirEntry(baseDir, "ignored_folder")
dirEntry, _ := getDirEntry(baseDir, "ignored_folder")
Expect(isDirIgnored(baseDir, dirEntry)).To(BeTrue())
})
It("returns true when folder name starts with a `.`", func() {
dirEntry := getDirEntry(baseDir, ".hidden_folder")
dirEntry, _ := getDirEntry(baseDir, ".hidden_folder")
Expect(isDirIgnored(baseDir, dirEntry)).To(BeTrue())
})
It("returns false when folder name starts with ellipses", func() {
dirEntry := getDirEntry(baseDir, "...unhidden_folder")
dirEntry, _ := getDirEntry(baseDir, "...unhidden_folder")
Expect(isDirIgnored(baseDir, dirEntry)).To(BeFalse())
})
It("returns false when folder name is $Recycle.Bin", func() {
dirEntry := getDirEntry(baseDir, "$Recycle.Bin")
dirEntry, _ := getDirEntry(baseDir, "$Recycle.Bin")
Expect(isDirIgnored(baseDir, dirEntry)).To(BeFalse())
})
})
@@ -166,12 +168,12 @@ func (fd *fakeDirFile) ReadDir(n int) ([]fs.DirEntry, error) {
return dirs, nil
}
func getDirEntry(baseDir, name string) os.DirEntry {
func getDirEntry(baseDir, name string) (os.DirEntry, error) {
dirEntries, _ := os.ReadDir(baseDir)
for _, entry := range dirEntries {
if entry.Name() == name {
return entry
return entry, nil
}
}
panic(fmt.Sprintf("Could not find %s in %s", name, baseDir))
return nil, os.ErrNotExist
}

View File

@@ -14,13 +14,12 @@ import (
type Router struct {
http.Handler
ds model.DataStore
share core.Share
playlists core.Playlists
ds model.DataStore
share core.Share
}
func New(ds model.DataStore, share core.Share, playlists core.Playlists) *Router {
r := &Router{ds: ds, share: share, playlists: playlists}
func New(ds model.DataStore, share core.Share) *Router {
r := &Router{ds: ds, share: share}
r.Handler = r.routes()
return r
}
@@ -41,13 +40,13 @@ func (n *Router) routes() http.Handler {
n.R(r, "/artist", model.Artist{}, false)
n.R(r, "/genre", model.Genre{}, false)
n.R(r, "/player", model.Player{}, true)
n.R(r, "/playlist", model.Playlist{}, true)
n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
n.R(r, "/radio", model.Radio{}, true)
if conf.Server.EnableSharing {
n.RX(r, "/share", n.share.NewRepository, true)
}
n.addPlaylistRoute(r)
n.addPlaylistTrackRoute(r)
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
@@ -83,30 +82,6 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository
})
}
func (n *Router) addPlaylistRoute(r chi.Router) {
constructor := func(ctx context.Context) rest.Repository {
return n.ds.Resource(ctx, model.Playlist{})
}
r.Route("/playlist", func(r chi.Router) {
r.Get("/", rest.GetAll(constructor))
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-type") == "application/json" {
rest.Post(constructor)(w, r)
return
}
createPlaylistFromM3U(n.playlists)(w, r)
})
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", rest.Get(constructor))
r.Put("/", rest.Put(constructor))
r.Delete("/", rest.Delete(constructor))
})
})
}
func (n *Router) addPlaylistTrackRoute(r chi.Router) {
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {

View File

@@ -11,7 +11,6 @@ import (
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
@@ -43,26 +42,6 @@ func getPlaylist(ds model.DataStore) http.HandlerFunc {
}
}
func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
pls, err := playlists.ImportM3U(ctx, r.Body)
if err != nil {
log.Error(r.Context(), "Error parsing playlist", err)
// TODO: consider returning StatusBadRequest for playlists that are malformed
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
_, err = w.Write([]byte(pls.ToM3U8()))
if err != nil {
log.Error(ctx, "Error sending m3u contents", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func handleExportPlaylist(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

View File

@@ -20,7 +20,7 @@ var _ = Describe("AbsoluteURL", func() {
Expect(actual).To(Equal("https://myserver.com/share/img/123?a=xyz"))
})
It("does not override provided schema/host", func() {
r, _ := http.NewRequest("GET", "http://localhost/rest/ping?id=123", nil)
r, _ := http.NewRequest("GET", "http://127.0.0.1/rest/ping?id=123", nil)
actual := AbsoluteURL(r, "http://public.myserver.com/share/img/123", url.Values{"a": []string{"xyz"}})
Expect(actual).To(Equal("http://public.myserver.com/share/img/123?a=xyz"))
})
@@ -35,7 +35,7 @@ var _ = Describe("AbsoluteURL", func() {
Expect(actual).To(Equal("https://myserver.com/music/share/img/123?a=xyz"))
})
It("does not override provided schema/host", func() {
r, _ := http.NewRequest("GET", "http://localhost/rest/ping?id=123", nil)
r, _ := http.NewRequest("GET", "http://127.0.0.1/rest/ping?id=123", nil)
actual := AbsoluteURL(r, "http://public.myserver.com/share/img/123", url.Values{"a": []string{"xyz"}})
Expect(actual).To(Equal("http://public.myserver.com/share/img/123?a=xyz"))
})
@@ -52,7 +52,7 @@ var _ = Describe("AbsoluteURL", func() {
Expect(actual).To(Equal("https://myserver.com:8080/music/share/img/123?a=xyz"))
})
It("does not override provided schema/host", func() {
r, _ := http.NewRequest("GET", "http://localhost/rest/ping?id=123", nil)
r, _ := http.NewRequest("GET", "http://127.0.0.1/rest/ping?id=123", nil)
actual := AbsoluteURL(r, "http://public.myserver.com/share/img/123", url.Values{"a": []string{"xyz"}})
Expect(actual).To(Equal("http://public.myserver.com/share/img/123?a=xyz"))
})

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