mirror of
https://github.com/navidrome/navidrome.git
synced 2025-04-03 20:47:35 +03:00
Merge branch 'master' into fix/UppercaseRepoName
This commit is contained in:
commit
fb5d1b9cde
81 changed files with 2249 additions and 925 deletions
|
@ -11,6 +11,7 @@ navidrome
|
|||
navidrome.toml
|
||||
tmp
|
||||
!tmp/taglib
|
||||
dist/*
|
||||
dist
|
||||
binaries
|
||||
cache
|
||||
music
|
110
.github/workflows/pipeline.yml
vendored
110
.github/workflows/pipeline.yml
vendored
|
@ -102,7 +102,7 @@ jobs:
|
|||
- name: Test
|
||||
run: |
|
||||
pkg-config --define-prefix --cflags --libs taglib # for debugging
|
||||
go test -shuffle=on -race -cover ./... -v
|
||||
go test -shuffle=on -tags netgo -race -cover ./... -v
|
||||
|
||||
js:
|
||||
name: Test JS code
|
||||
|
@ -224,7 +224,6 @@ jobs:
|
|||
path: ./output
|
||||
retention-days: 7
|
||||
|
||||
# https://www.perplexity.ai/search/can-i-have-multiple-push-to-di-4P3ToaZFQtmVROuhaZMllQ
|
||||
- name: Build and push image by digest
|
||||
id: push-image
|
||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||
|
@ -320,14 +319,11 @@ jobs:
|
|||
gh api --method DELETE repos/${{ env.REPO_LOWER }}/actions/artifacts/$artifact
|
||||
done
|
||||
|
||||
|
||||
msi:
|
||||
name: Build Windows Installers
|
||||
name: Build Windows installers
|
||||
needs: [build, git-version]
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GIT_SHA: ${{ needs.git-version.outputs.git_sha }}
|
||||
GIT_TAG: ${{ needs.git-version.outputs.git_tag }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
@ -337,47 +333,36 @@ jobs:
|
|||
pattern: navidrome-windows*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Build MSI files
|
||||
- name: Install Wix
|
||||
run: sudo apt-get install -y wixl jq
|
||||
|
||||
- name: Build MSI
|
||||
env:
|
||||
GIT_TAG: ${{ needs.git-version.outputs.git_tag }}
|
||||
run: |
|
||||
sudo apt-get install -y wixl jq
|
||||
|
||||
NAVIDROME_BUILD_VERSION=$(echo $GIT_TAG | sed -e 's/^v//' -e 's/-SNAPSHOT/.1/')
|
||||
echo $NAVIDROME_BUILD_VERSION
|
||||
|
||||
mkdir -p $GITHUB_WORKSPACE/wix/386
|
||||
cp $GITHUB_WORKSPACE/LICENSE $GITHUB_WORKSPACE/wix/386
|
||||
cp $GITHUB_WORKSPACE/README.md $GITHUB_WORKSPACE/wix/386
|
||||
|
||||
cp -r $GITHUB_WORKSPACE/wix/386 $GITHUB_WORKSPACE/wix/amd64
|
||||
|
||||
cp $GITHUB_WORKSPACE/binaries/windows_386/navidrome.exe $GITHUB_WORKSPACE/wix/386
|
||||
cp $GITHUB_WORKSPACE/binaries/windows_amd64/navidrome.exe $GITHUB_WORKSPACE/wix/amd64
|
||||
|
||||
# workaround for wixl WixVariable not working to override bmp locations
|
||||
sudo cp $GITHUB_WORKSPACE/wix/bmp/banner.bmp /usr/share/wixl-*/ext/ui/bitmaps/bannrbmp.bmp
|
||||
sudo cp $GITHUB_WORKSPACE/wix/bmp/dialogue.bmp /usr/share/wixl-*/ext/ui/bitmaps/dlgbmp.bmp
|
||||
|
||||
cd $GITHUB_WORKSPACE/wix/386
|
||||
wixl ../navidrome.wxs -D Version=$NAVIDROME_BUILD_VERSION -D Platform=x86 --arch x86 --ext ui --output ../navidrome_386.msi
|
||||
|
||||
cd $GITHUB_WORKSPACE/wix/amd64
|
||||
wixl ../navidrome.wxs -D Version=$NAVIDROME_BUILD_VERSION -D Platform=x64 --arch x64 --ext ui --output ../navidrome_amd64.msi
|
||||
|
||||
ls -la $GITHUB_WORKSPACE/wix/*.msi
|
||||
rm -rf binaries/msi
|
||||
sudo GIT_TAG=$GIT_TAG release/wix/build_msi.sh ${GITHUB_WORKSPACE} 386
|
||||
sudo GIT_TAG=$GIT_TAG release/wix/build_msi.sh ${GITHUB_WORKSPACE} amd64
|
||||
du -h binaries/msi/*.msi
|
||||
|
||||
- name: Upload MSI files
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: navidrome-windows-installers
|
||||
path: wix/*.msi
|
||||
path: binaries/msi/*.msi
|
||||
retention-days: 7
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: [build, msi, push-manifest]
|
||||
name: Package/Release
|
||||
needs: [build, msi]
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
package_list: ${{ steps.set-package-list.outputs.package_list }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
|
@ -398,3 +383,56 @@ jobs:
|
|||
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Remove build artifacts
|
||||
run: |
|
||||
ls -l ./dist
|
||||
rm ./dist/*.tar.gz ./dist/*.zip
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: packages
|
||||
path: dist/navidrome_v*
|
||||
|
||||
- id: set-package-list
|
||||
name: Export list of generated packages
|
||||
run: |
|
||||
cd dist
|
||||
set +x
|
||||
ITEMS=$(ls navidrome_v* | sed 's/^navidrome_v[^_]*_linux_//' | jq -R -s -c 'split("\n")[:-1]')
|
||||
echo $ITEMS
|
||||
echo "package_list=${ITEMS}" >> $GITHUB_OUTPUT
|
||||
|
||||
upload-packages:
|
||||
name: Upload Linux PKG
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release]
|
||||
strategy:
|
||||
matrix:
|
||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||
steps:
|
||||
- name: Download all-packages artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: packages
|
||||
path: ./dist
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: navidrome_linux_${{ matrix.item }}
|
||||
path: dist/navidrome_v*_linux_${{ matrix.item }}
|
||||
|
||||
# delete-artifacts:
|
||||
# name: Delete unused artifacts
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [upload-packages]
|
||||
# steps:
|
||||
# - name: Delete all-packages artifact
|
||||
# env:
|
||||
# GH_TOKEN: ${{ github.token }}
|
||||
# run: |
|
||||
# for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("packages")) | .id'); do
|
||||
# gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
|
||||
# done
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -11,6 +11,7 @@ wiki
|
|||
TODO.md
|
||||
var
|
||||
navidrome.toml
|
||||
!release/linux/navidrome.toml
|
||||
master.zip
|
||||
testDB
|
||||
cache/*
|
||||
|
@ -22,4 +23,5 @@ music
|
|||
docker-compose.yml
|
||||
!contrib/docker-compose.yml
|
||||
binaries
|
||||
taglib
|
||||
taglib
|
||||
navidrome-master
|
|
@ -1,3 +1,7 @@
|
|||
run:
|
||||
build-tags:
|
||||
- netgo
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
|
|
25
Makefile
25
Makefile
|
@ -33,11 +33,11 @@ server: check_go_env buildjs ##@Development Start the backend in development mod
|
|||
.PHONY: server
|
||||
|
||||
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||
go run github.com/onsi/ginkgo/v2/ginkgo@latest watch -notify ./...
|
||||
go run github.com/onsi/ginkgo/v2/ginkgo@latest watch -tags netgo -notify ./...
|
||||
.PHONY: watch
|
||||
|
||||
test: ##@Development Run Go tests
|
||||
go test -race -shuffle=on ./...
|
||||
go test -tags netgo -race -shuffle=on ./...
|
||||
.PHONY: test
|
||||
|
||||
testall: test ##@Development Run Go and JS tests
|
||||
|
@ -120,7 +120,7 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che
|
|||
--build-arg GIT_TAG=${GIT_TAG} \
|
||||
--build-arg GIT_SHA=${GIT_SHA} \
|
||||
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
|
||||
--output "./dist" --target binary .
|
||||
--output "./binaries" --target binary .
|
||||
.PHONY: docker-build
|
||||
|
||||
docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
|
||||
|
@ -135,6 +135,20 @@ docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidro
|
|||
--tag $(DOCKER_TAG) .
|
||||
.PHONY: docker-image
|
||||
|
||||
docker-msi: ##@Cross_Compilation Build MSI installer for Windows
|
||||
make docker-build PLATFORMS=windows/386,windows/amd64
|
||||
DOCKER_CLI_HINTS=false docker build -q -t navidrome-msi-builder -f release/wix/msitools.dockerfile .
|
||||
@rm -rf binaries/msi
|
||||
docker run -it --rm -v $(PWD):/workspace -v $(PWD)/binaries:/workspace/binaries -e GIT_TAG=${GIT_TAG} \
|
||||
navidrome-msi-builder sh -c "release/wix/build_msi.sh /workspace 386 && release/wix/build_msi.sh /workspace amd64"
|
||||
@du -h binaries/msi/*.msi
|
||||
.PHONY: docker-msi
|
||||
|
||||
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
|
||||
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
|
||||
goreleaser release -f release/goreleaser.yml --clean --skip=publish --snapshot
|
||||
.PHONY: package
|
||||
|
||||
get-music: ##@Development Download some free music from Navidrome's demo instance
|
||||
mkdir -p music
|
||||
( cd music; \
|
||||
|
@ -150,6 +164,11 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
|
|||
##########################################
|
||||
#### Miscellaneous
|
||||
|
||||
clean:
|
||||
@rm -rf ./binaries ./dist ./ui/build/*
|
||||
@touch ./ui/build/.gitkeep
|
||||
.PHONY: clean
|
||||
|
||||
release:
|
||||
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
|
||||
go mod tidy
|
||||
|
|
|
@ -226,11 +226,13 @@ func init() {
|
|||
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().StringP("loglevel", "l", viper.GetString("loglevel"), "log level, possible values: error, info, debug, trace")
|
||||
rootCmd.PersistentFlags().String("logfile", viper.GetString("logfile"), "log file path, if not set logs will be printed to stderr")
|
||||
|
||||
_ = 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"))
|
||||
_ = viper.BindPFlag("logfile", rootCmd.PersistentFlags().Lookup("logfile"))
|
||||
|
||||
rootCmd.Flags().StringP("address", "a", viper.GetString("address"), "IP address to bind to")
|
||||
rootCmd.Flags().IntP("port", "p", viper.GetInt("port"), "HTTP port Navidrome will listen to")
|
||||
|
|
68
cmd/svc.go
68
cmd/svc.go
|
@ -20,6 +20,9 @@ var (
|
|||
service.StatusStopped: "Stopped",
|
||||
service.StatusRunning: "Running",
|
||||
}
|
||||
|
||||
installUser string
|
||||
workingDirectory string
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
@ -70,17 +73,25 @@ func (p *svcControl) Stop(service.Service) error {
|
|||
|
||||
var svcInstance = sync.OnceValue(func() service.Service {
|
||||
options := make(service.KeyValue)
|
||||
options["Restart"] = "on-success"
|
||||
options["Restart"] = "on-failure"
|
||||
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
|
||||
options["UserService"] = false
|
||||
options["LogDirectory"] = conf.Server.DataFolder
|
||||
options["SystemdScript"] = systemdScript
|
||||
if conf.Server.LogFile != "" {
|
||||
options["LogOutput"] = false
|
||||
} else {
|
||||
options["LogOutput"] = true
|
||||
options["LogDirectory"] = conf.Server.DataFolder
|
||||
}
|
||||
svcConfig := &service.Config{
|
||||
UserName: installUser,
|
||||
Name: "navidrome",
|
||||
DisplayName: "Navidrome",
|
||||
Description: "Your Personal Streaming Service",
|
||||
Dependencies: []string{
|
||||
"Requires=",
|
||||
"After="},
|
||||
"After=remote-fs.target network.target",
|
||||
},
|
||||
WorkingDirectory: executablePath(),
|
||||
Option: options,
|
||||
}
|
||||
|
@ -103,6 +114,10 @@ func runServiceCmd(cmd *cobra.Command, _ []string) {
|
|||
}
|
||||
|
||||
func executablePath() string {
|
||||
if workingDirectory != "" {
|
||||
return workingDirectory
|
||||
}
|
||||
|
||||
ex, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
@ -117,7 +132,11 @@ func buildInstallCmd() *cobra.Command {
|
|||
println(" working directory: " + executablePath())
|
||||
println(" music folder: " + conf.Server.MusicFolder)
|
||||
println(" data folder: " + conf.Server.DataFolder)
|
||||
println(" logs folder: " + conf.Server.DataFolder)
|
||||
if conf.Server.LogFile != "" {
|
||||
println(" log file: " + conf.Server.LogFile)
|
||||
} else {
|
||||
println(" logs folder: " + conf.Server.DataFolder)
|
||||
}
|
||||
if cfgFile != "" {
|
||||
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
|
||||
if err != nil {
|
||||
|
@ -132,11 +151,15 @@ func buildInstallCmd() *cobra.Command {
|
|||
println("Service installed. Use 'navidrome svc start' to start it.")
|
||||
}
|
||||
|
||||
return &cobra.Command{
|
||||
cmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install Navidrome service.",
|
||||
Run: runInstallCmd,
|
||||
}
|
||||
cmd.Flags().StringVarP(&installUser, "user", "u", "", "user to run service")
|
||||
cmd.Flags().StringVarP(&workingDirectory, "working-directory", "w", "", "working directory of service")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func buildUninstallCmd() *cobra.Command {
|
||||
|
@ -207,3 +230,38 @@ func buildExecuteCmd() *cobra.Command {
|
|||
},
|
||||
}
|
||||
}
|
||||
|
||||
const systemdScript = `[Unit]
|
||||
Description={{.Description}}
|
||||
ConditionFileIsExecutable={{.Path|cmdEscape}}
|
||||
{{range $i, $dep := .Dependencies}}
|
||||
{{$dep}} {{end}}
|
||||
|
||||
[Service]
|
||||
StartLimitInterval=5
|
||||
StartLimitBurst=10
|
||||
ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
|
||||
{{if .WorkingDirectory}}WorkingDirectory={{.WorkingDirectory|cmdEscape}}{{end}}
|
||||
{{if .UserName}}User={{.UserName}}{{end}}
|
||||
{{if .Restart}}Restart={{.Restart}}{{end}}
|
||||
{{if .SuccessExitStatus}}SuccessExitStatus={{.SuccessExitStatus}}{{end}}
|
||||
TimeoutStopSec=20
|
||||
RestartSec=120
|
||||
EnvironmentFile=-/etc/sysconfig/{{.Name}}
|
||||
|
||||
DevicePolicy=closed
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelTunables=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
|
||||
{{if .WorkingDirectory}}ReadWritePaths={{.WorkingDirectory|cmdEscape}}{{end}}
|
||||
ProtectSystem=full
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
|
|
4
conf/buildtags/buildtags.go
Normal file
4
conf/buildtags/buildtags.go
Normal file
|
@ -0,0 +1,4 @@
|
|||
package buildtags
|
||||
|
||||
// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all
|
||||
// required build tags are disabled.
|
11
conf/buildtags/netgo.go
Normal file
11
conf/buildtags/netgo.go
Normal file
|
@ -0,0 +1,11 @@
|
|||
//go:build netgo
|
||||
|
||||
package buildtags
|
||||
|
||||
// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project.
|
||||
// If the tag is not included, the compilation will fail because this variable won't be defined, and the `main.go`
|
||||
// file requires it.
|
||||
|
||||
// Why this tag is required? See https://github.com/navidrome/navidrome/issues/700
|
||||
|
||||
var NETGO = true
|
|
@ -26,6 +26,7 @@ type configOptions struct {
|
|||
CacheFolder string
|
||||
DbPath string
|
||||
LogLevel string
|
||||
LogFile string
|
||||
ScanInterval time.Duration
|
||||
ScanSchedule string
|
||||
SessionTimeout time.Duration
|
||||
|
@ -176,14 +177,17 @@ func LoadFromFile(confFile string) {
|
|||
}
|
||||
|
||||
func Load() {
|
||||
parseIniFileConfiguration()
|
||||
|
||||
err := viper.Unmarshal(&Server)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", "path", Server.DataFolder, err)
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
@ -192,7 +196,7 @@ func Load() {
|
|||
}
|
||||
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", "path", Server.CacheFolder, err)
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
@ -204,11 +208,21 @@ func Load() {
|
|||
if Server.Backup.Path != "" {
|
||||
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", "path", Server.Backup.Path, err)
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
out := os.Stderr
|
||||
if Server.LogFile != "" {
|
||||
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
log.SetOutput(out)
|
||||
}
|
||||
|
||||
log.SetLevelString(Server.LogLevel)
|
||||
log.SetLogLevels(Server.DevLogLevels)
|
||||
log.SetLogSourceLine(Server.DevLogSourceLine)
|
||||
|
@ -225,7 +239,7 @@ func Load() {
|
|||
if Server.BaseURL != "" {
|
||||
u, err := url.Parse(Server.BaseURL)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Invalid BaseURL %s: %s\n", Server.BaseURL, err.Error())
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Server.BasePath = u.Path
|
||||
|
@ -241,7 +255,7 @@ func Load() {
|
|||
if Server.EnableLogRedacting {
|
||||
prettyConf = log.Redact(prettyConf)
|
||||
}
|
||||
_, _ = fmt.Fprintln(os.Stderr, prettyConf)
|
||||
_, _ = fmt.Fprintln(out, prettyConf)
|
||||
}
|
||||
|
||||
if !Server.EnableExternalServices {
|
||||
|
@ -254,6 +268,31 @@ func Load() {
|
|||
}
|
||||
}
|
||||
|
||||
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
|
||||
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
|
||||
// section into the root level.
|
||||
func parseIniFileConfiguration() {
|
||||
cfgFile := viper.ConfigFileUsed()
|
||||
if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" {
|
||||
var iniConfig map[string]interface{}
|
||||
err := viper.Unmarshal(&iniConfig)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cfg, ok := iniConfig["default"].(map[string]any)
|
||||
if !ok {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
|
||||
os.Exit(1)
|
||||
}
|
||||
err = viper.MergeConfigMap(cfg)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disableExternalServices() {
|
||||
log.Info("All external integrations are DISABLED!")
|
||||
Server.LastFM.Enabled = false
|
||||
|
@ -324,6 +363,7 @@ func init() {
|
|||
viper.SetDefault("cachefolder", "")
|
||||
viper.SetDefault("datafolder", ".")
|
||||
viper.SetDefault("loglevel", "info")
|
||||
viper.SetDefault("logfile", "")
|
||||
viper.SetDefault("address", "0.0.0.0")
|
||||
viper.SetDefault("port", 4533)
|
||||
viper.SetDefault("unixsocketperm", "0660")
|
||||
|
|
|
@ -11,15 +11,13 @@ WantedBy=multi-user.target
|
|||
User=navidrome
|
||||
Group=navidrome
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/navidrome
|
||||
ExecStart=/usr/bin/navidrome --configfile "/etc/navidrome/navidrome.toml"
|
||||
StateDirectory=navidrome
|
||||
WorkingDirectory=/var/lib/navidrome
|
||||
TimeoutStopSec=20
|
||||
KillMode=process
|
||||
Restart=on-failure
|
||||
|
||||
EnvironmentFile=-/etc/sysconfig/navidrome
|
||||
|
||||
# See https://www.freedesktop.org/software/systemd/man/systemd.exec.html
|
||||
CapabilityBoundingSet=
|
||||
DevicePolicy=closed
|
||||
|
|
|
@ -63,7 +63,7 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff
|
|||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
ff = append(ff, fromTag(a.album.EmbedArtPath), fromFFmpegTag(ctx, ffmpeg, a.album.EmbedArtPath))
|
||||
ff = append(ff, fromTag(ctx, a.album.EmbedArtPath), fromFFmpegTag(ctx, ffmpeg, a.album.EmbedArtPath))
|
||||
case pattern == "external":
|
||||
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.em))
|
||||
case a.album.ImageFiles != "":
|
||||
|
|
|
@ -55,7 +55,7 @@ func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, str
|
|||
var ff []sourceFunc
|
||||
if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork {
|
||||
ff = []sourceFunc{
|
||||
fromTag(a.mediafile.Path),
|
||||
fromTag(ctx, a.mediafile.Path),
|
||||
fromFFmpegTag(ctx, a.a.ffmpeg, a.mediafile.Path),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -79,7 +80,14 @@ func fromExternalFile(ctx context.Context, files string, pattern string) sourceF
|
|||
}
|
||||
}
|
||||
|
||||
func fromTag(path string) sourceFunc {
|
||||
// These regexes are used to match the picture type in the file, in the order they are listed.
|
||||
var picTypeRegexes = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i).*cover.*front.*|.*front.*cover.*`),
|
||||
regexp.MustCompile(`(?i).*front.*`),
|
||||
regexp.MustCompile(`(?i).*cover.*`),
|
||||
}
|
||||
|
||||
func fromTag(ctx context.Context, path string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if path == "" {
|
||||
return nil, "", nil
|
||||
|
@ -95,10 +103,31 @@ func fromTag(path string) sourceFunc {
|
|||
return nil, "", err
|
||||
}
|
||||
|
||||
picture := m.Picture()
|
||||
if picture == nil {
|
||||
types := m.PictureTypes()
|
||||
if len(types) == 0 {
|
||||
return nil, "", fmt.Errorf("no embedded image found in %s", path)
|
||||
}
|
||||
|
||||
var picture *tag.Picture
|
||||
for _, regex := range picTypeRegexes {
|
||||
for _, t := range types {
|
||||
if regex.MatchString(t) {
|
||||
log.Trace(ctx, "Found embedded image", "type", t, "path", path)
|
||||
picture = m.Pictures(t)
|
||||
break
|
||||
}
|
||||
}
|
||||
if picture != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if picture == nil {
|
||||
log.Trace(ctx, "Could not find a front image. Getting the first one", "type", types[0], "path", path)
|
||||
picture = m.Picture()
|
||||
}
|
||||
if picture == nil {
|
||||
return nil, "", fmt.Errorf("could not load embedded image from %s", path)
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(picture.Data)), path, nil
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
@ -128,64 +127,56 @@ func (s *Stream) EstimatedContentLength() int {
|
|||
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
||||
}
|
||||
|
||||
// selectTranscodingOptions selects the appropriate transcoding options based on the requested format and bitrate.
|
||||
// If the requested format is "raw" or matches the media file's suffix and the requested bitrate is 0, it returns the
|
||||
// original format and bitrate.
|
||||
// Otherwise, it determines the format and bitrate using determineFormatAndBitRate and findTranscoding functions.
|
||||
//
|
||||
// NOTE: It is easier to follow the tests in core/media_streamer_internal_test.go to understand the different scenarios.
|
||||
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (string, int) {
|
||||
if reqFormat == "raw" || reqFormat == mf.Suffix && reqBitRate == 0 {
|
||||
return "raw", mf.BitRate
|
||||
// TODO This function deserves some love (refactoring)
|
||||
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
|
||||
}
|
||||
|
||||
format, bitRate := determineFormatAndBitRate(ctx, mf.BitRate, reqFormat, reqBitRate)
|
||||
if format == "" && bitRate == 0 {
|
||||
return "raw", 0
|
||||
if reqFormat == mf.Suffix && reqBitRate == 0 {
|
||||
bitRate = mf.BitRate
|
||||
return format, bitRate
|
||||
}
|
||||
|
||||
return findTranscoding(ctx, ds, mf, format, bitRate)
|
||||
}
|
||||
|
||||
// determineFormatAndBitRate determines the format and bitrate for transcoding based on the requested format and bitrate.
|
||||
// If the requested format is not empty, it returns the requested format and bitrate.
|
||||
// Otherwise, it checks for default transcoding settings from the context or server configuration.
|
||||
func determineFormatAndBitRate(ctx context.Context, srcBitRate int, reqFormat string, reqBitRate int) (string, int) {
|
||||
trc, hasDefault := request.TranscodingFrom(ctx)
|
||||
var cFormat string
|
||||
var cBitRate int
|
||||
if reqFormat != "" {
|
||||
return reqFormat, reqBitRate
|
||||
}
|
||||
|
||||
format, bitRate := "", 0
|
||||
if trc, hasDefault := request.TranscodingFrom(ctx); hasDefault {
|
||||
format = trc.TargetFormat
|
||||
bitRate = trc.DefaultBitRate
|
||||
|
||||
if p, ok := request.PlayerFrom(ctx); ok && p.MaxBitRate > 0 && p.MaxBitRate < bitRate {
|
||||
bitRate = p.MaxBitRate
|
||||
cFormat = reqFormat
|
||||
} else {
|
||||
if hasDefault {
|
||||
cFormat = trc.TargetFormat
|
||||
cBitRate = trc.DefaultBitRate
|
||||
if p, ok := request.PlayerFrom(ctx); ok {
|
||||
cBitRate = p.MaxBitRate
|
||||
}
|
||||
} else if reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "" {
|
||||
// If no format is specified and no transcoding associated to the player, but a bitrate is specified,
|
||||
// and there is no transcoding set for the player, we use the default downsampling format.
|
||||
// But only if the requested bitRate is lower than the original bitRate.
|
||||
log.Debug("Default Downsampling", "Using default downsampling format", conf.Server.DefaultDownsamplingFormat)
|
||||
cFormat = conf.Server.DefaultDownsamplingFormat
|
||||
}
|
||||
} else if reqBitRate > 0 && reqBitRate < srcBitRate && conf.Server.DefaultDownsamplingFormat != "" {
|
||||
// If no format is specified and no transcoding associated to the player, but a bitrate is specified,
|
||||
// and there is no transcoding set for the player, we use the default downsampling format.
|
||||
// But only if the requested bitRate is lower than the original bitRate.
|
||||
log.Debug(ctx, "Using default downsampling format", "format", conf.Server.DefaultDownsamplingFormat)
|
||||
format = conf.Server.DefaultDownsamplingFormat
|
||||
}
|
||||
|
||||
return format, cmp.Or(reqBitRate, bitRate)
|
||||
}
|
||||
|
||||
// findTranscoding finds the appropriate transcoding settings for the given format and bitrate.
|
||||
// If the format matches the media file's suffix and the bitrate is greater than or equal to the original bitrate,
|
||||
// it returns the original format and bitrate.
|
||||
// Otherwise, it returns the target format and bitrate from the
|
||||
// transcoding settings.
|
||||
func findTranscoding(ctx context.Context, ds model.DataStore, mf *model.MediaFile, format string, bitRate int) (string, int) {
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(format)
|
||||
if err != nil || t == nil || format == mf.Suffix && bitRate >= mf.BitRate {
|
||||
return "raw", 0
|
||||
if reqBitRate > 0 {
|
||||
cBitRate = reqBitRate
|
||||
}
|
||||
|
||||
return t.TargetFormat, cmp.Or(bitRate, t.DefaultBitRate)
|
||||
if cBitRate == 0 && cFormat == "" {
|
||||
return format, bitRate
|
||||
}
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
|
||||
if err == nil {
|
||||
format = t.TargetFormat
|
||||
if cBitRate != 0 {
|
||||
bitRate = cBitRate
|
||||
} else {
|
||||
bitRate = t.DefaultBitRate
|
||||
}
|
||||
}
|
||||
if format == mf.Suffix && bitRate >= mf.BitRate {
|
||||
format = "raw"
|
||||
bitRate = 0
|
||||
}
|
||||
return format, bitRate
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
|
@ -122,10 +122,11 @@ var _ = Describe("MediaStreamer", func() {
|
|||
Expect(bitRate).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Context("player has maxBitRate configured", func() {
|
||||
BeforeEach(func() {
|
||||
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
|
||||
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 80}
|
||||
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 192}
|
||||
ctx = request.WithTranscoding(ctx, t)
|
||||
ctx = request.WithPlayer(ctx, p)
|
||||
})
|
||||
|
@ -140,7 +141,7 @@ var _ = Describe("MediaStreamer", func() {
|
|||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
Expect(bitRate).To(Equal(192))
|
||||
})
|
||||
It("returns requested format", func() {
|
||||
mf.Suffix = "flac"
|
||||
|
@ -152,9 +153,9 @@ var _ = Describe("MediaStreamer", func() {
|
|||
It("returns requested bitrate", func() {
|
||||
mf.Suffix = "flac"
|
||||
mf.BitRate = 1000
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
|
||||
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160)
|
||||
Expect(format).To(Equal("oga"))
|
||||
Expect(bitRate).To(Equal(80))
|
||||
Expect(bitRate).To(Equal(160))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -0,0 +1,512 @@
|
|||
-- +goose Up
|
||||
--region Artist Table
|
||||
create table artist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
album_count integer default 0 not null,
|
||||
full_text varchar(255) default '',
|
||||
song_count integer default 0 not null,
|
||||
size integer default 0 not null,
|
||||
biography varchar(255) default '' not null,
|
||||
small_image_url varchar(255) default '' not null,
|
||||
medium_image_url varchar(255) default '' not null,
|
||||
large_image_url varchar(255) default '' not null,
|
||||
similar_artists varchar(255) default '' not null,
|
||||
external_url varchar(255) default '' not null,
|
||||
external_info_updated_at datetime,
|
||||
order_artist_name varchar collate NOCASE default '' not null,
|
||||
sort_artist_name varchar collate NOCASE default '' not null,
|
||||
mbz_artist_id varchar default '' not null
|
||||
);
|
||||
|
||||
insert into artist_dg_tmp(id, name, album_count, full_text, song_count, size, biography, small_image_url,
|
||||
medium_image_url, large_image_url, similar_artists, external_url, external_info_updated_at,
|
||||
order_artist_name, sort_artist_name, mbz_artist_id)
|
||||
select id,
|
||||
name,
|
||||
album_count,
|
||||
full_text,
|
||||
song_count,
|
||||
size,
|
||||
biography,
|
||||
small_image_url,
|
||||
medium_image_url,
|
||||
large_image_url,
|
||||
similar_artists,
|
||||
external_url,
|
||||
external_info_updated_at,
|
||||
order_artist_name,
|
||||
sort_artist_name,
|
||||
mbz_artist_id
|
||||
from artist;
|
||||
|
||||
drop table artist;
|
||||
|
||||
alter table artist_dg_tmp
|
||||
rename to artist;
|
||||
|
||||
create index artist_full_text
|
||||
on artist (full_text);
|
||||
|
||||
create index artist_name
|
||||
on artist (name);
|
||||
|
||||
create index artist_order_artist_name
|
||||
on artist (order_artist_name);
|
||||
|
||||
create index artist_size
|
||||
on artist (size);
|
||||
|
||||
create index artist_sort_name
|
||||
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
|
||||
|
||||
--endregion
|
||||
|
||||
--region Album Table
|
||||
create table album_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
artist_id varchar(255) default '' not null,
|
||||
embed_art_path varchar(255) default '' not null,
|
||||
artist varchar(255) default '' not null,
|
||||
album_artist varchar(255) default '' not null,
|
||||
min_year int default 0 not null,
|
||||
max_year integer default 0 not null,
|
||||
compilation bool default FALSE not null,
|
||||
song_count integer default 0 not null,
|
||||
duration real default 0 not null,
|
||||
genre varchar(255) default '' not null,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
full_text varchar(255) default '',
|
||||
album_artist_id varchar(255) default '',
|
||||
size integer default 0 not null,
|
||||
all_artist_ids varchar,
|
||||
description varchar(255) default '' not null,
|
||||
small_image_url varchar(255) default '' not null,
|
||||
medium_image_url varchar(255) default '' not null,
|
||||
large_image_url varchar(255) default '' not null,
|
||||
external_url varchar(255) default '' not null,
|
||||
external_info_updated_at datetime,
|
||||
date varchar(255) default '' not null,
|
||||
min_original_year int default 0 not null,
|
||||
max_original_year int default 0 not null,
|
||||
original_date varchar(255) default '' not null,
|
||||
release_date varchar(255) default '' not null,
|
||||
releases integer default 0 not null,
|
||||
image_files varchar default '' not null,
|
||||
order_album_name varchar collate NOCASE default '' not null,
|
||||
order_album_artist_name varchar collate NOCASE default '' not null,
|
||||
sort_album_name varchar collate NOCASE default '' not null,
|
||||
sort_album_artist_name varchar collate NOCASE default '' not null,
|
||||
catalog_num varchar default '' not null,
|
||||
comment varchar default '' not null,
|
||||
paths varchar default '' not null,
|
||||
mbz_album_id varchar default '' not null,
|
||||
mbz_album_artist_id varchar default '' not null,
|
||||
mbz_album_type varchar default '' not null,
|
||||
mbz_album_comment varchar default '' not null,
|
||||
discs jsonb default '{}' not null,
|
||||
library_id integer default 1 not null
|
||||
references library
|
||||
on delete cascade
|
||||
);
|
||||
|
||||
insert into album_dg_tmp(id, name, artist_id, embed_art_path, artist, album_artist, min_year, max_year, compilation,
|
||||
song_count, duration, genre, created_at, updated_at, full_text, album_artist_id, size,
|
||||
all_artist_ids, description, small_image_url, medium_image_url, large_image_url, external_url,
|
||||
external_info_updated_at, date, min_original_year, max_original_year, original_date,
|
||||
release_date, releases, image_files, order_album_name, order_album_artist_name,
|
||||
sort_album_name, sort_album_artist_name, catalog_num, comment, paths,
|
||||
mbz_album_id, mbz_album_artist_id, mbz_album_type, mbz_album_comment, discs, library_id)
|
||||
select id,
|
||||
name,
|
||||
artist_id,
|
||||
embed_art_path,
|
||||
artist,
|
||||
album_artist,
|
||||
min_year,
|
||||
max_year,
|
||||
compilation,
|
||||
song_count,
|
||||
duration,
|
||||
genre,
|
||||
created_at,
|
||||
updated_at,
|
||||
full_text,
|
||||
album_artist_id,
|
||||
size,
|
||||
all_artist_ids,
|
||||
description,
|
||||
small_image_url,
|
||||
medium_image_url,
|
||||
large_image_url,
|
||||
external_url,
|
||||
external_info_updated_at,
|
||||
date,
|
||||
min_original_year,
|
||||
max_original_year,
|
||||
original_date,
|
||||
release_date,
|
||||
releases,
|
||||
image_files,
|
||||
order_album_name,
|
||||
order_album_artist_name,
|
||||
sort_album_name,
|
||||
sort_album_artist_name,
|
||||
catalog_num,
|
||||
comment,
|
||||
paths,
|
||||
mbz_album_id,
|
||||
mbz_album_artist_id,
|
||||
mbz_album_type,
|
||||
mbz_album_comment,
|
||||
discs,
|
||||
library_id
|
||||
from album;
|
||||
|
||||
drop table album;
|
||||
|
||||
alter table album_dg_tmp
|
||||
rename to album;
|
||||
|
||||
create index album_all_artist_ids
|
||||
on album (all_artist_ids);
|
||||
|
||||
create index album_alphabetical_by_artist
|
||||
on album (compilation, order_album_artist_name, order_album_name);
|
||||
|
||||
create index album_artist
|
||||
on album (artist);
|
||||
|
||||
create index album_artist_album
|
||||
on album (artist);
|
||||
|
||||
create index album_artist_album_id
|
||||
on album (album_artist_id);
|
||||
|
||||
create index album_artist_id
|
||||
on album (artist_id);
|
||||
|
||||
create index album_created_at
|
||||
on album (created_at);
|
||||
|
||||
create index album_full_text
|
||||
on album (full_text);
|
||||
|
||||
create index album_genre
|
||||
on album (genre);
|
||||
|
||||
create index album_max_year
|
||||
on album (max_year);
|
||||
|
||||
create index album_mbz_album_type
|
||||
on album (mbz_album_type);
|
||||
|
||||
create index album_min_year
|
||||
on album (min_year);
|
||||
|
||||
create index album_name
|
||||
on album (name);
|
||||
|
||||
create index album_order_album_artist_name
|
||||
on album (order_album_artist_name);
|
||||
|
||||
create index album_order_album_name
|
||||
on album (order_album_name);
|
||||
|
||||
create index album_size
|
||||
on album (size);
|
||||
|
||||
create index album_sort_name
|
||||
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
|
||||
|
||||
create index album_sort_album_artist_name
|
||||
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NOCASE);
|
||||
|
||||
create index album_updated_at
|
||||
on album (updated_at);
|
||||
--endregion
|
||||
|
||||
--region Media File Table
|
||||
create table media_file_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
path varchar(255) default '' not null,
|
||||
title varchar(255) default '' not null,
|
||||
album varchar(255) default '' not null,
|
||||
artist varchar(255) default '' not null,
|
||||
artist_id varchar(255) default '' not null,
|
||||
album_artist varchar(255) default '' not null,
|
||||
album_id varchar(255) default '' not null,
|
||||
has_cover_art bool default FALSE not null,
|
||||
track_number integer default 0 not null,
|
||||
disc_number integer default 0 not null,
|
||||
year integer default 0 not null,
|
||||
size integer default 0 not null,
|
||||
suffix varchar(255) default '' not null,
|
||||
duration real default 0 not null,
|
||||
bit_rate integer default 0 not null,
|
||||
genre varchar(255) default '' not null,
|
||||
compilation bool default FALSE not null,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
full_text varchar(255) default '',
|
||||
album_artist_id varchar(255) default '',
|
||||
date varchar(255) default '' not null,
|
||||
original_year int default 0 not null,
|
||||
original_date varchar(255) default '' not null,
|
||||
release_year int default 0 not null,
|
||||
release_date varchar(255) default '' not null,
|
||||
order_album_name varchar collate NOCASE default '' not null,
|
||||
order_album_artist_name varchar collate NOCASE default '' not null,
|
||||
order_artist_name varchar collate NOCASE default '' not null,
|
||||
sort_album_name varchar collate NOCASE default '' not null,
|
||||
sort_artist_name varchar collate NOCASE default '' not null,
|
||||
sort_album_artist_name varchar collate NOCASE default '' not null,
|
||||
sort_title varchar collate NOCASE default '' not null,
|
||||
disc_subtitle varchar default '' not null,
|
||||
catalog_num varchar default '' not null,
|
||||
comment varchar default '' not null,
|
||||
order_title varchar collate NOCASE default '' not null,
|
||||
mbz_recording_id varchar default '' not null,
|
||||
mbz_album_id varchar default '' not null,
|
||||
mbz_artist_id varchar default '' not null,
|
||||
mbz_album_artist_id varchar default '' not null,
|
||||
mbz_album_type varchar default '' not null,
|
||||
mbz_album_comment varchar default '' not null,
|
||||
mbz_release_track_id varchar default '' not null,
|
||||
bpm integer default 0 not null,
|
||||
channels integer default 0 not null,
|
||||
rg_album_gain real default 0 not null,
|
||||
rg_album_peak real default 0 not null,
|
||||
rg_track_gain real default 0 not null,
|
||||
rg_track_peak real default 0 not null,
|
||||
lyrics jsonb default '[]' not null,
|
||||
sample_rate integer default 0 not null,
|
||||
library_id integer default 1 not null
|
||||
references library
|
||||
on delete cascade
|
||||
);
|
||||
|
||||
insert into media_file_dg_tmp(id, path, title, album, artist, artist_id, album_artist, album_id, has_cover_art,
|
||||
track_number, disc_number, year, size, suffix, duration, bit_rate, genre, compilation,
|
||||
created_at, updated_at, full_text, album_artist_id, date, original_year, original_date,
|
||||
release_year, release_date, order_album_name, order_album_artist_name, order_artist_name,
|
||||
sort_album_name, sort_artist_name, sort_album_artist_name, sort_title, disc_subtitle,
|
||||
catalog_num, comment, order_title, mbz_recording_id, mbz_album_id, mbz_artist_id,
|
||||
mbz_album_artist_id, mbz_album_type, mbz_album_comment, mbz_release_track_id, bpm,
|
||||
channels, rg_album_gain, rg_album_peak, rg_track_gain, rg_track_peak, lyrics, sample_rate,
|
||||
library_id)
|
||||
select id,
|
||||
path,
|
||||
title,
|
||||
album,
|
||||
artist,
|
||||
artist_id,
|
||||
album_artist,
|
||||
album_id,
|
||||
has_cover_art,
|
||||
track_number,
|
||||
disc_number,
|
||||
year,
|
||||
size,
|
||||
suffix,
|
||||
duration,
|
||||
bit_rate,
|
||||
genre,
|
||||
compilation,
|
||||
created_at,
|
||||
updated_at,
|
||||
full_text,
|
||||
album_artist_id,
|
||||
date,
|
||||
original_year,
|
||||
original_date,
|
||||
release_year,
|
||||
release_date,
|
||||
order_album_name,
|
||||
order_album_artist_name,
|
||||
order_artist_name,
|
||||
sort_album_name,
|
||||
sort_artist_name,
|
||||
sort_album_artist_name,
|
||||
sort_title,
|
||||
disc_subtitle,
|
||||
catalog_num,
|
||||
comment,
|
||||
order_title,
|
||||
mbz_recording_id,
|
||||
mbz_album_id,
|
||||
mbz_artist_id,
|
||||
mbz_album_artist_id,
|
||||
mbz_album_type,
|
||||
mbz_album_comment,
|
||||
mbz_release_track_id,
|
||||
bpm,
|
||||
channels,
|
||||
rg_album_gain,
|
||||
rg_album_peak,
|
||||
rg_track_gain,
|
||||
rg_track_peak,
|
||||
lyrics,
|
||||
sample_rate,
|
||||
library_id
|
||||
from media_file;
|
||||
|
||||
drop table media_file;
|
||||
|
||||
alter table media_file_dg_tmp
|
||||
rename to media_file;
|
||||
|
||||
create index media_file_album_artist
|
||||
on media_file (album_artist);
|
||||
|
||||
create index media_file_album_id
|
||||
on media_file (album_id);
|
||||
|
||||
create index media_file_artist
|
||||
on media_file (artist);
|
||||
|
||||
create index media_file_artist_album_id
|
||||
on media_file (album_artist_id);
|
||||
|
||||
create index media_file_artist_id
|
||||
on media_file (artist_id);
|
||||
|
||||
create index media_file_bpm
|
||||
on media_file (bpm);
|
||||
|
||||
create index media_file_channels
|
||||
on media_file (channels);
|
||||
|
||||
create index media_file_created_at
|
||||
on media_file (created_at);
|
||||
|
||||
create index media_file_duration
|
||||
on media_file (duration);
|
||||
|
||||
create index media_file_full_text
|
||||
on media_file (full_text);
|
||||
|
||||
create index media_file_genre
|
||||
on media_file (genre);
|
||||
|
||||
create index media_file_mbz_track_id
|
||||
on media_file (mbz_recording_id);
|
||||
|
||||
create index media_file_order_album_name
|
||||
on media_file (order_album_name);
|
||||
|
||||
create index media_file_order_artist_name
|
||||
on media_file (order_artist_name);
|
||||
|
||||
create index media_file_order_title
|
||||
on media_file (order_title);
|
||||
|
||||
create index media_file_path
|
||||
on media_file (path);
|
||||
|
||||
create index media_file_path_nocase
|
||||
on media_file (path collate NOCASE);
|
||||
|
||||
create index media_file_sample_rate
|
||||
on media_file (sample_rate);
|
||||
|
||||
create index media_file_sort_title
|
||||
on media_file (coalesce(nullif(sort_title,''),order_title) collate NOCASE);
|
||||
|
||||
create index media_file_sort_artist_name
|
||||
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
|
||||
|
||||
create index media_file_sort_album_name
|
||||
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
|
||||
|
||||
create index media_file_title
|
||||
on media_file (title);
|
||||
|
||||
create index media_file_track_number
|
||||
on media_file (disc_number, track_number);
|
||||
|
||||
create index media_file_updated_at
|
||||
on media_file (updated_at);
|
||||
|
||||
create index media_file_year
|
||||
on media_file (year);
|
||||
|
||||
--endregion
|
||||
|
||||
--region Radio Table
|
||||
create table radio_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar collate NOCASE not null
|
||||
unique,
|
||||
stream_url varchar not null,
|
||||
home_page_url varchar default '' not null,
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);
|
||||
|
||||
insert into radio_dg_tmp(id, name, stream_url, home_page_url, created_at, updated_at)
|
||||
select id, name, stream_url, home_page_url, created_at, updated_at
|
||||
from radio;
|
||||
|
||||
drop table radio;
|
||||
|
||||
alter table radio_dg_tmp
|
||||
rename to radio;
|
||||
|
||||
create index radio_name
|
||||
on radio(name);
|
||||
--endregion
|
||||
|
||||
--region users Table
|
||||
create table user_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
user_name varchar(255) default '' not null
|
||||
unique,
|
||||
name varchar(255) collate NOCASE default '' not null,
|
||||
email varchar(255) default '' not null,
|
||||
password varchar(255) default '' not null,
|
||||
is_admin bool default FALSE not null,
|
||||
last_login_at datetime,
|
||||
last_access_at datetime,
|
||||
created_at datetime not null,
|
||||
updated_at datetime not null
|
||||
);
|
||||
|
||||
insert into user_dg_tmp(id, user_name, name, email, password, is_admin, last_login_at, last_access_at, created_at,
|
||||
updated_at)
|
||||
select id,
|
||||
user_name,
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
is_admin,
|
||||
last_login_at,
|
||||
last_access_at,
|
||||
created_at,
|
||||
updated_at
|
||||
from user;
|
||||
|
||||
drop table user;
|
||||
|
||||
alter table user_dg_tmp
|
||||
rename to user;
|
||||
|
||||
create index user_username_password
|
||||
on user(user_name collate NOCASE, password);
|
||||
--endregion
|
||||
|
||||
-- +goose Down
|
||||
alter table album
|
||||
add column sort_artist_name varchar default '' not null;
|
33
go.mod
33
go.mod
|
@ -2,6 +2,9 @@ module github.com/navidrome/navidrome
|
|||
|
||||
go 1.23.2
|
||||
|
||||
// Fork to fix https://github.com/navidrome/navidrome/pull/3254
|
||||
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
|
||||
|
@ -27,14 +30,14 @@ require (
|
|||
github.com/jellydator/ttlcache/v3 v3.3.0
|
||||
github.com/kardianos/service v1.2.2
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.2
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/mattn/go-zglob v0.0.6
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.20.2
|
||||
github.com/onsi/gomega v1.34.2
|
||||
github.com/onsi/ginkgo/v2 v2.21.0
|
||||
github.com/onsi/gomega v1.35.1
|
||||
github.com/pelletier/go-toml/v2 v2.2.3
|
||||
github.com/pocketbase/dbx v1.10.1
|
||||
github.com/pressly/goose/v3 v3.22.1
|
||||
|
@ -44,13 +47,13 @@ require (
|
|||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/unrolled/secure v1.16.0
|
||||
github.com/unrolled/secure v1.17.0
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
|
||||
golang.org/x/image v0.21.0
|
||||
golang.org/x/sync v0.8.0
|
||||
golang.org/x/text v0.19.0
|
||||
golang.org/x/time v0.7.0
|
||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f
|
||||
golang.org/x/image v0.22.0
|
||||
golang.org/x/sync v0.9.0
|
||||
golang.org/x/text v0.20.0
|
||||
golang.org/x/time v0.8.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
@ -65,7 +68,7 @@ require (
|
|||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 // indirect
|
||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
|
@ -99,11 +102,11 @@ require (
|
|||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.28.0 // indirect
|
||||
golang.org/x/net v0.30.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/tools v0.26.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
golang.org/x/crypto v0.29.0 // indirect
|
||||
golang.org/x/net v0.31.0 // indirect
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
golang.org/x/tools v0.27.0 // indirect
|
||||
google.golang.org/protobuf v1.35.1 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
)
|
||||
|
|
64
go.sum
64
go.sum
|
@ -24,10 +24,10 @@ github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcH
|
|||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 h1:mGvOb3zxl4vCLv+dbf7JA6CAaM2UH/AGP1KX4DsJmTI=
|
||||
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
|
||||
github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d h1:x/R3+oPEjnisl1zBx2f2v7Gf6f11l0N0JoD6BkwcJyA=
|
||||
github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
|
||||
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 h1:r4hxcT6GBIA/j8Ox4OXI5MNgMKfR+9plcAWYi1OnmOg=
|
||||
github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc=
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
|
||||
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=
|
||||
|
@ -67,8 +67,8 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
|
|||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
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/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA=
|
||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
|
||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
|
@ -117,8 +117,8 @@ github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCG
|
|||
github.com/lestrrat-go/httprc v1.0.6/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.1.1 h1:Y2ltVl8J6izLYFs54BVcpXLv5msSW4o8eXwnzZLI32E=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.1/go.mod h1:4LvZg7oxu6Q5VJwn7Mk/UwooNRnTHUpXBj2C4j3HNx0=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.2 h1:6poete4MPsO8+LAEVhpdrNI4Xp2xdiafgl2RD89moBc=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.2/go.mod h1:pO+Gz9whn7MPdbsqSJzG8TlEpMZCwQDXnFJ+zsUVh8Y=
|
||||
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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
|
@ -143,10 +143,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
|||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/onsi/ginkgo/v2 v2.20.2 h1:7NVCeyIWROIAheY21RLS+3j2bb52W0W82tkberYytp4=
|
||||
github.com/onsi/ginkgo/v2 v2.20.2/go.mod h1:K9gyxPIlb+aIvnZ8bd9Ak+YP18w3APlR+5coaZoE2ag=
|
||||
github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8=
|
||||
github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc=
|
||||
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
||||
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
|
@ -213,8 +213,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
|
|||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/unrolled/secure v1.16.0 h1:XgdAsS/Zl50ZfZPRJK6WpicFttfrsFYFd0+ONDBJubU=
|
||||
github.com/unrolled/secure v1.16.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
|
||||
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
|
@ -226,13 +226,13 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
|||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
|
||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
|
||||
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
|
||||
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
|
||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo=
|
||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
|
||||
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
|
||||
golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g=
|
||||
golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4=
|
||||
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.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
|
@ -246,15 +246,15 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
|||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
|
||||
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/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.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
@ -268,8 +268,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.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=
|
||||
|
@ -284,10 +284,10 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
||||
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
|
@ -295,12 +295,12 @@ 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.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||
golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o=
|
||||
golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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=
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
@ -22,3 +25,37 @@ func ShortDur(d time.Duration) string {
|
|||
s = strings.TrimSuffix(s, "0s")
|
||||
return strings.TrimSuffix(s, "0m")
|
||||
}
|
||||
|
||||
func StringerValue(s fmt.Stringer) string {
|
||||
v := reflect.ValueOf(s)
|
||||
if v.Kind() == reflect.Pointer && v.IsNil() {
|
||||
return "nil"
|
||||
}
|
||||
return s.String()
|
||||
}
|
||||
|
||||
func CRLFWriter(w io.Writer) io.Writer {
|
||||
return &crlfWriter{w: w}
|
||||
}
|
||||
|
||||
type crlfWriter struct {
|
||||
w io.Writer
|
||||
lastByte byte
|
||||
}
|
||||
|
||||
func (cw *crlfWriter) Write(p []byte) (int, error) {
|
||||
var written int
|
||||
for _, b := range p {
|
||||
if b == '\n' && cw.lastByte != '\r' {
|
||||
if _, err := cw.w.Write([]byte{'\r'}); err != nil {
|
||||
return written, err
|
||||
}
|
||||
}
|
||||
if _, err := cw.w.Write([]byte{b}); err != nil {
|
||||
return written, err
|
||||
}
|
||||
written++
|
||||
cw.lastByte = b
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
package log
|
||||
package log_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = DescribeTable("ShortDur",
|
||||
func(d time.Duration, expected string) {
|
||||
Expect(ShortDur(d)).To(Equal(expected))
|
||||
Expect(log.ShortDur(d)).To(Equal(expected))
|
||||
},
|
||||
Entry("1ns", 1*time.Nanosecond, "1ns"),
|
||||
Entry("9µs", 9*time.Microsecond, "9µs"),
|
||||
|
@ -24,3 +27,44 @@ var _ = DescribeTable("ShortDur",
|
|||
Entry("4h", 4*time.Hour+2*time.Second, "4h"),
|
||||
Entry("4h2m", 4*time.Hour+2*time.Minute+5*time.Second+200*time.Millisecond, "4h2m"),
|
||||
)
|
||||
|
||||
var _ = Describe("StringerValue", func() {
|
||||
It("should return the string representation of a fmt.Stringer", func() {
|
||||
Expect(log.StringerValue(time.Second)).To(Equal("1s"))
|
||||
})
|
||||
It("should return 'nil' for a nil fmt.Stringer", func() {
|
||||
v := (*time.Time)(nil)
|
||||
Expect(log.StringerValue(v)).To(Equal("nil"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("CRLFWriter", func() {
|
||||
var (
|
||||
buffer *bytes.Buffer
|
||||
writer io.Writer
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
buffer = new(bytes.Buffer)
|
||||
writer = log.CRLFWriter(buffer)
|
||||
})
|
||||
|
||||
Describe("Write", func() {
|
||||
It("should convert all LFs to CRLFs", func() {
|
||||
n, err := writer.Write([]byte("hello\nworld\nagain\n"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(n).To(Equal(18))
|
||||
Expect(buffer.String()).To(Equal("hello\r\nworld\r\nagain\r\n"))
|
||||
})
|
||||
|
||||
It("should not convert LF to CRLF if preceded by CR", func() {
|
||||
n, err := writer.Write([]byte("hello\r"))
|
||||
Expect(n).To(Equal(6))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
n, err = writer.Write([]byte("\nworld\n"))
|
||||
Expect(n).To(Equal(7))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(buffer.String()).To(Equal("hello\r\nworld\r\n"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
16
log/log.go
16
log/log.go
|
@ -4,9 +4,9 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
|
@ -128,6 +128,13 @@ func SetRedacting(enabled bool) {
|
|||
}
|
||||
}
|
||||
|
||||
func SetOutput(w io.Writer) {
|
||||
if runtime.GOOS == "windows" {
|
||||
w = CRLFWriter(w)
|
||||
}
|
||||
defaultLogger.SetOutput(w)
|
||||
}
|
||||
|
||||
// Redact applies redaction to a single string
|
||||
func Redact(msg string) string {
|
||||
r, _ := redacted.redact(msg)
|
||||
|
@ -269,12 +276,7 @@ func addFields(logger *logrus.Entry, keyValuePairs []interface{}) *logrus.Entry
|
|||
case time.Duration:
|
||||
logger = logger.WithField(name, ShortDur(v))
|
||||
case fmt.Stringer:
|
||||
vOf := reflect.ValueOf(v)
|
||||
if vOf.Kind() == reflect.Pointer && vOf.IsNil() {
|
||||
logger = logger.WithField(name, "nil")
|
||||
} else {
|
||||
logger = logger.WithField(name, v.String())
|
||||
}
|
||||
logger = logger.WithField(name, StringerValue(v))
|
||||
default:
|
||||
logger = logger.WithField(name, v)
|
||||
}
|
||||
|
|
8
main.go
8
main.go
|
@ -4,8 +4,16 @@ import (
|
|||
_ "net/http/pprof" //nolint:gosec
|
||||
|
||||
"github.com/navidrome/navidrome/cmd"
|
||||
"github.com/navidrome/navidrome/conf/buildtags"
|
||||
)
|
||||
|
||||
//goland:noinspection GoBoolExpressions
|
||||
func main() {
|
||||
// This import is used to force the inclusion of the `netgo` tag when compiling the project.
|
||||
// If you get compilation errors like "undefined: buildtags.NETGO", this means you forgot to specify
|
||||
// the `netgo` build tag when compiling the project.
|
||||
// To avoid these kind of errors, you should use `make build` to compile the project.
|
||||
_ = buildtags.NETGO
|
||||
|
||||
cmd.Execute()
|
||||
}
|
||||
|
|
|
@ -38,7 +38,6 @@ type Album struct {
|
|||
Discs Discs `structs:"discs" json:"discs,omitempty"`
|
||||
FullText string `structs:"full_text" json:"-"`
|
||||
SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
|
||||
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
|
||||
SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"`
|
||||
OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
|
||||
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
|
||||
|
|
|
@ -140,7 +140,6 @@ func (mfs MediaFiles) ToAlbum() Album {
|
|||
a.AlbumArtist = m.AlbumArtist
|
||||
a.AlbumArtistID = m.AlbumArtistID
|
||||
a.SortAlbumName = m.SortAlbumName
|
||||
a.SortArtistName = m.SortArtistName
|
||||
a.SortAlbumArtistName = m.SortAlbumArtistName
|
||||
a.OrderAlbumName = m.OrderAlbumName
|
||||
a.OrderAlbumArtistName = m.OrderAlbumArtistName
|
||||
|
@ -261,11 +260,10 @@ type MediaFileRepository interface {
|
|||
GetAll(options ...QueryOptions) (MediaFiles, error)
|
||||
Search(q string, offset int, size int) (MediaFiles, error)
|
||||
Delete(id string) error
|
||||
FindByPaths(paths []string) (MediaFiles, error)
|
||||
|
||||
// Queries by path to support the scanner, no Annotations or Bookmarks required in the response
|
||||
FindAllByPath(path string) (MediaFiles, error)
|
||||
FindByPath(path string) (*MediaFile, error)
|
||||
FindByPaths(paths []string) (MediaFiles, error)
|
||||
FindPathsRecursively(basePath string) ([]string, error)
|
||||
DeleteByPath(path string) (int64, error)
|
||||
|
||||
|
|
|
@ -43,7 +43,6 @@ var _ = Describe("MediaFiles", func() {
|
|||
Expect(album.AlbumArtist).To(Equal("AlbumArtist"))
|
||||
Expect(album.AlbumArtistID).To(Equal("AlbumArtistID"))
|
||||
Expect(album.SortAlbumName).To(Equal("SortAlbumName"))
|
||||
Expect(album.SortArtistName).To(Equal("SortArtistName"))
|
||||
Expect(album.SortAlbumArtistName).To(Equal("SortAlbumArtistName"))
|
||||
Expect(album.OrderAlbumName).To(Equal("OrderAlbumName"))
|
||||
Expect(album.OrderAlbumArtistName).To(Equal("OrderAlbumArtistName"))
|
||||
|
|
|
@ -69,27 +69,15 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
|||
"has_rating": hasRatingFilter,
|
||||
"genre_id": eqFilter,
|
||||
})
|
||||
if conf.Server.PreferSortTags {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "COALESCE(NULLIF(sort_album_name,''),order_album_name)",
|
||||
"artist": "compilation asc, COALESCE(NULLIF(sort_album_artist_name,''),order_album_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"album_artist": "compilation asc, COALESCE(NULLIF(sort_album_artist_name,''),order_album_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
|
||||
"random": "random",
|
||||
"recently_added": recentlyAddedSort(),
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
} else {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "order_album_name asc, order_album_artist_name asc",
|
||||
"artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
|
||||
"album_artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
|
||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name, order_album_name asc",
|
||||
"random": "random",
|
||||
"recently_added": recentlyAddedSort(),
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
}
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "order_album_name, order_album_artist_name",
|
||||
"artist": "compilation, order_album_artist_name, order_album_name",
|
||||
"album_artist": "compilation, order_album_artist_name, order_album_name",
|
||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name",
|
||||
"random": "random",
|
||||
"recently_added": recentlyAddedSort(),
|
||||
"starred_at": "starred, starred_at",
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
|
@ -15,7 +15,7 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
|
@ -67,17 +67,10 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
|||
"starred": booleanFilter,
|
||||
"genre_id": eqFilter,
|
||||
})
|
||||
if conf.Server.PreferSortTags {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name)",
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
} else {
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "order_artist_name",
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
}
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "order_artist_name",
|
||||
"starred_at": "starred, starred_at",
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
|
@ -143,15 +136,14 @@ func (r *artistRepository) toModels(dba []dbArtist) model.Artists {
|
|||
return res
|
||||
}
|
||||
|
||||
func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
||||
source := a.Name
|
||||
func (r *artistRepository) getIndexKey(a model.Artist) string {
|
||||
source := a.OrderArtistName
|
||||
if conf.Server.PreferSortTags {
|
||||
source = cmp.Or(a.SortArtistName, a.OrderArtistName, source)
|
||||
source = cmp.Or(a.SortArtistName, a.OrderArtistName)
|
||||
}
|
||||
name := strings.ToLower(str.RemoveArticle(source))
|
||||
name := strings.ToLower(source)
|
||||
for k, v := range r.indexGroups {
|
||||
key := strings.ToLower(k)
|
||||
if strings.HasPrefix(name, key) {
|
||||
if strings.HasPrefix(name, strings.ToLower(k)) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
@ -160,32 +152,16 @@ func (r *artistRepository) getIndexKey(a *model.Artist) string {
|
|||
|
||||
// TODO Cache the index (recalculate when there are changes to the DB)
|
||||
func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
|
||||
sortColumn := "order_artist_name"
|
||||
if conf.Server.PreferSortTags {
|
||||
sortColumn = "sort_artist_name, order_artist_name"
|
||||
}
|
||||
all, err := r.GetAll(model.QueryOptions{Sort: sortColumn})
|
||||
artists, err := r.GetAll(model.QueryOptions{Sort: "name"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fullIdx := make(map[string]*model.ArtistIndex)
|
||||
for i := range all {
|
||||
a := all[i]
|
||||
ax := r.getIndexKey(&a)
|
||||
idx, ok := fullIdx[ax]
|
||||
if !ok {
|
||||
idx = &model.ArtistIndex{ID: ax}
|
||||
fullIdx[ax] = idx
|
||||
}
|
||||
idx.Artists = append(idx.Artists, a)
|
||||
}
|
||||
var result model.ArtistIndexes
|
||||
for _, idx := range fullIdx {
|
||||
result = append(result, *idx)
|
||||
for k, v := range slice.Group(artists, r.getIndexKey) {
|
||||
result = append(result, model.ArtistIndex{ID: k, Artists: v})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].ID < result[j].ID
|
||||
slices.SortFunc(result, func(a, b model.ArtistIndex) int {
|
||||
return cmp.Compare(a.ID, b.ID)
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
|
||||
"github.com/fatih/structs"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
@ -46,163 +47,146 @@ var _ = Describe("ArtistRepository", func() {
|
|||
})
|
||||
|
||||
Describe("GetIndexKey", func() {
|
||||
// Note: OrderArtistName should never be empty, so we don't need to test for that
|
||||
r := artistRepository{indexGroups: utils.ParseIndexGroups(conf.Server.IndexGroups)}
|
||||
It("returns the index key when PreferSortTags is true and SortArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a) // defines export_test.go
|
||||
Expect(idx).To(Equal("F"))
|
||||
|
||||
a = model.Artist{SortArtistName: "foo", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx = GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("F"))
|
||||
When("PreferSortTags is false", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig)
|
||||
conf.Server.PreferSortTags = false
|
||||
})
|
||||
It("returns the OrderArtistName key is SortArtistName is empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, a)
|
||||
Expect(idx).To(Equal("B"))
|
||||
})
|
||||
It("returns the OrderArtistName key even if SortArtistName is not empty", func() {
|
||||
a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, a)
|
||||
Expect(idx).To(Equal("B"))
|
||||
})
|
||||
})
|
||||
|
||||
It("returns the index key when PreferSortTags is true, SortArtistName is empty and OrderArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("B"))
|
||||
|
||||
a = model.Artist{SortArtistName: "", OrderArtistName: "bar", Name: "Qux"}
|
||||
idx = GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("B"))
|
||||
})
|
||||
|
||||
It("returns the index key when PreferSortTags is true, both SortArtistName, OrderArtistName are empty", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
|
||||
a = model.Artist{SortArtistName: "", OrderArtistName: "", Name: "qux"}
|
||||
idx = GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
})
|
||||
|
||||
It("returns the index key when PreferSortTags is false and SortArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
})
|
||||
|
||||
It("returns the index key when PreferSortTags is true, SortArtistName is empty and OrderArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
})
|
||||
|
||||
It("returns the index key when PreferSortTags is true, both sort_artist_name, order_artist_name are empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
|
||||
a = model.Artist{SortArtistName: "", OrderArtistName: "", Name: "qux"}
|
||||
idx = GetIndexKey(&r, &a)
|
||||
Expect(idx).To(Equal("Q"))
|
||||
When("PreferSortTags is true", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig)
|
||||
conf.Server.PreferSortTags = true
|
||||
})
|
||||
It("returns the SortArtistName key if it is not empty", func() {
|
||||
a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, a)
|
||||
Expect(idx).To(Equal("F"))
|
||||
})
|
||||
It("returns the OrderArtistName key if SortArtistName is empty", func() {
|
||||
a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"}
|
||||
idx := GetIndexKey(&r, a)
|
||||
Expect(idx).To(Equal("B"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetIndex", func() {
|
||||
It("returns the index when PreferSortTags is true and SortArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
When("PreferSortTags is true", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig)
|
||||
conf.Server.PreferSortTags = true
|
||||
})
|
||||
It("returns the index when SortArtistName is not empty", func() {
|
||||
artistBeatles.SortArtistName = "Foo"
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
artistBeatles.SortArtistName = "Foo"
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "F",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "F",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
}))
|
||||
|
||||
artistBeatles.SortArtistName = ""
|
||||
er = repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
artistBeatles.SortArtistName = ""
|
||||
er = repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the index when SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
It("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
When("PreferSortTags is false", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig)
|
||||
conf.Server.PreferSortTags = false
|
||||
})
|
||||
It("returns the index when SortArtistName is not empty", func() {
|
||||
artistBeatles.SortArtistName = "Foo"
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
It("returns the index when PreferSortTags is false and SortArtistName is not empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
|
||||
artistBeatles.SortArtistName = "Foo"
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
}))
|
||||
|
||||
artistBeatles.SortArtistName = ""
|
||||
er = repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
})
|
||||
artistBeatles.SortArtistName = ""
|
||||
er = repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the index when PreferSortTags is false and SortArtistName is empty", func() {
|
||||
conf.Server.PreferSortTags = false
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
It("returns the index when SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(idx).To(Equal(model.ArtistIndexes{
|
||||
{
|
||||
ID: "B",
|
||||
Artists: model.Artists{
|
||||
artistBeatles,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
{
|
||||
ID: "K",
|
||||
Artists: model.Artists{
|
||||
artistKraftwerk,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
122
persistence/collation_test.go
Normal file
122
persistence/collation_test.go
Normal file
|
@ -0,0 +1,122 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/navidrome/navidrome/db"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// When creating migrations that change existing columns, it is easy to miss the original collation of a column.
|
||||
// These tests enforce that the required collation of the columns and indexes in the database are kept in place.
|
||||
// This is important to ensure that the database can perform fast case-insensitive searches and sorts.
|
||||
var _ = Describe("Collation", func() {
|
||||
conn := db.Db().ReadDB()
|
||||
DescribeTable("Column collation",
|
||||
func(table, column string) {
|
||||
Expect(checkCollation(conn, table, column)).To(Succeed())
|
||||
},
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name"),
|
||||
Entry("artist.sort_artist_name", "artist", "sort_artist_name"),
|
||||
Entry("album.order_album_name", "album", "order_album_name"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name"),
|
||||
Entry("album.sort_album_name", "album", "sort_album_name"),
|
||||
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name"),
|
||||
Entry("media_file.order_title", "media_file", "order_title"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name"),
|
||||
Entry("media_file.sort_title", "media_file", "sort_title"),
|
||||
Entry("media_file.sort_album_name", "media_file", "sort_album_name"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"),
|
||||
Entry("radio.name", "radio", "name"),
|
||||
Entry("user.name", "user", "name"),
|
||||
)
|
||||
|
||||
DescribeTable("Index collation",
|
||||
func(table, column string) {
|
||||
Expect(checkIndexUsage(conn, table, column)).To(Succeed())
|
||||
},
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name collate nocase"),
|
||||
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
||||
Entry("album.order_album_name", "album", "order_album_name collate nocase"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate nocase"),
|
||||
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
||||
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate nocase"),
|
||||
Entry("media_file.order_title", "media_file", "order_title collate nocase"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name collate nocase"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate nocase"),
|
||||
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate nocase"),
|
||||
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
||||
Entry("media_file.path", "media_file", "path collate nocase"),
|
||||
Entry("radio.name", "radio", "name collate nocase"),
|
||||
Entry("user.user_name", "user", "user_name collate nocase"),
|
||||
)
|
||||
})
|
||||
|
||||
func checkIndexUsage(conn *sql.DB, table string, column string) error {
|
||||
rows, err := conn.Query(fmt.Sprintf(`
|
||||
explain query plan select * from %[1]s
|
||||
where %[2]s = 'test'
|
||||
order by %[2]s`, table, column))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rows.Next() {
|
||||
var dummy int
|
||||
var detail string
|
||||
err = rows.Scan(&dummy, &dummy, &dummy, &detail)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if ok, _ := regexp.MatchString("SEARCH.*USING INDEX", detail); ok {
|
||||
return nil
|
||||
} else {
|
||||
return fmt.Errorf("INDEX for '%s' not used: %s", column, detail)
|
||||
}
|
||||
}
|
||||
return errors.New("no rows returned")
|
||||
}
|
||||
|
||||
func checkCollation(conn *sql.DB, table string, column string) error {
|
||||
rows, err := conn.Query(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND tbl_name='%s'", table))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rows.Next() {
|
||||
var res string
|
||||
err = rows.Scan(&res)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
re := regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*varchar`, column))
|
||||
if !re.MatchString(res) {
|
||||
return fmt.Errorf("column '%s' not found in table '%s'", column, table)
|
||||
}
|
||||
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+NOCASE`, column))
|
||||
if re.MatchString(res) {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("table '%s' not found", table)
|
||||
}
|
||||
return fmt.Errorf("column '%s' in table '%s' does not have NOCASE collation", column, table)
|
||||
}
|
|
@ -81,3 +81,13 @@ func (e existsCond) ToSql() (string, []interface{}, error) {
|
|||
}
|
||||
return sql, args, err
|
||||
}
|
||||
|
||||
var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`)
|
||||
|
||||
// Convert the order_* columns to an expression using sort_* columns. Example:
|
||||
// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
|
||||
// It finds order column names anywhere in the substring
|
||||
func mapSortOrder(order string) string {
|
||||
order = strings.ToLower(order)
|
||||
return sortOrderRegex.ReplaceAllString(order, "(coalesce(nullif(sort_$1,''),order_$1) collate nocase)")
|
||||
}
|
||||
|
|
|
@ -83,4 +83,23 @@ var _ = Describe("Helpers", func() {
|
|||
Expect(err).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("mapSortOrder", func() {
|
||||
It("does not change the sort string if there are no order columns", func() {
|
||||
sort := "album_name asc"
|
||||
mapped := mapSortOrder(sort)
|
||||
Expect(mapped).To(Equal(sort))
|
||||
})
|
||||
It("changes order columns to sort expression", func() {
|
||||
sort := "ORDER_ALBUM_NAME asc"
|
||||
mapped := mapSortOrder(sort)
|
||||
Expect(mapped).To(Equal("(coalesce(nullif(sort_album_name,''),order_album_name) collate nocase) asc"))
|
||||
})
|
||||
It("changes multiple order columns to sort expressions", func() {
|
||||
sort := "compilation, order_title asc, order_album_artist_name desc, year desc"
|
||||
mapped := mapSortOrder(sort)
|
||||
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(sort_title,''),order_title) collate nocase) asc,` +
|
||||
` (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate nocase) desc, year desc`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -10,7 +10,6 @@ import (
|
|||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/pocketbase/dbx"
|
||||
|
@ -31,25 +30,14 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) *mediaFileRepos
|
|||
"starred": booleanFilter,
|
||||
"genre_id": eqFilter,
|
||||
})
|
||||
if conf.Server.PreferSortTags {
|
||||
r.sortMappings = map[string]string{
|
||||
"title": "COALESCE(NULLIF(sort_title,''),order_title)",
|
||||
"artist": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc",
|
||||
"album": "COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc, COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_title,''),title) asc",
|
||||
"random": "random",
|
||||
"created_at": "media_file.created_at",
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
} else {
|
||||
r.sortMappings = map[string]string{
|
||||
"title": "order_title",
|
||||
"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",
|
||||
"random": "random",
|
||||
"created_at": "media_file.created_at",
|
||||
"starred_at": "starred, starred_at",
|
||||
}
|
||||
}
|
||||
r.setSortMappings(map[string]string{
|
||||
"title": "order_title",
|
||||
"artist": "order_artist_name, order_album_name, release_date, disc_number, track_number",
|
||||
"album": "order_album_name, release_date, disc_number, track_number, order_artist_name, title",
|
||||
"random": "random",
|
||||
"created_at": "media_file.created_at",
|
||||
"starred_at": "starred, starred_at",
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
|
@ -115,18 +103,6 @@ func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.Media
|
|||
return res, err
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) FindByPath(path string) (*model.MediaFile, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Like{"path": path})
|
||||
var res model.MediaFiles
|
||||
if err := r.queryAll(sel, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(res) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return &res[0], nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths})
|
||||
var res model.MediaFiles
|
||||
|
|
|
@ -21,9 +21,9 @@ func NewPlayerRepository(ctx context.Context, db dbx.Builder) model.PlayerReposi
|
|||
r.registerModel(&model.Player{}, map[string]filterFunc{
|
||||
"name": containsFilter("player.name"),
|
||||
})
|
||||
r.sortMappings = map[string]string{
|
||||
r.setSortMappings(map[string]string{
|
||||
"user_name": "username", //TODO rename all user_name and userName to username
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
|
@ -55,9 +55,9 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
|
|||
"q": playlistFilter,
|
||||
"smart": smartPlaylistFilter,
|
||||
})
|
||||
r.sortMappings = map[string]string{
|
||||
r.setSortMappings(map[string]string{
|
||||
"owner_name": "owner_name",
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,6 @@ import (
|
|||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
|
@ -26,18 +25,13 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
|||
p.db = r.db
|
||||
p.tableName = "playlist_tracks"
|
||||
p.registerModel(&model.PlaylistTrack{}, nil)
|
||||
p.sortMappings = map[string]string{
|
||||
p.setSortMappings(map[string]string{
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name asc",
|
||||
"album": "order_album_name asc, order_album_artist_name asc",
|
||||
"artist": "order_artist_name",
|
||||
"album": "order_album_name, order_album_artist_name",
|
||||
"title": "order_title",
|
||||
"duration": "duration", // To make sure the field will be whitelisted
|
||||
}
|
||||
if conf.Server.PreferSortTags {
|
||||
p.sortMappings["artist"] = "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc"
|
||||
p.sortMappings["album"] = "COALESCE(NULLIF(sort_album_name,''),order_album_name)"
|
||||
p.sortMappings["title"] = "COALESCE(NULLIF(sort_title,''),title)"
|
||||
}
|
||||
})
|
||||
|
||||
pls, err := r.Get(playlistId)
|
||||
if err != nil {
|
||||
|
|
|
@ -24,9 +24,6 @@ func NewRadioRepository(ctx context.Context, db dbx.Builder) model.RadioReposito
|
|||
r.registerModel(&model.Radio{}, map[string]filterFunc{
|
||||
"name": containsFilter("name"),
|
||||
})
|
||||
r.sortMappings = map[string]string{
|
||||
"name": "(name collate nocase), name",
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
|
@ -23,10 +23,10 @@ func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareReposito
|
|||
r := &shareRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.registerModel(&model.Share{}, map[string]filterFunc{})
|
||||
r.sortMappings = map[string]string{
|
||||
r.registerModel(&model.Share{}, nil)
|
||||
r.setSortMappings(map[string]string{
|
||||
"username": "username",
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
|
@ -27,17 +27,20 @@ import (
|
|||
// - Call registerModel with the model instance and any possible filters.
|
||||
// - If the model has a different table name than the default (lowercase of the model name), it should be set manually
|
||||
// using the tableName field.
|
||||
// - Sort mappings should be set in the sortMappings field. If the sort field is not in the map, it will be used as is.
|
||||
// - Sort mappings must be set with setSortMappings method. If a sort field is not in the map, it will be used as the name of the column.
|
||||
//
|
||||
// All fields in filters and sortMappings must be in snake_case. Only sorts and filters based on real field names or
|
||||
// defined in the mappings will be allowed.
|
||||
type sqlRepository struct {
|
||||
ctx context.Context
|
||||
tableName string
|
||||
db dbx.Builder
|
||||
sortMappings map[string]string
|
||||
ctx context.Context
|
||||
tableName string
|
||||
db dbx.Builder
|
||||
|
||||
// Do not set these fields manually, they are set by the registerModel method
|
||||
filterMappings map[string]filterFunc
|
||||
isFieldWhiteListed fieldWhiteListedFunc
|
||||
// Do not set this field manually, it is set by the setSortMappings method
|
||||
sortMappings map[string]string
|
||||
}
|
||||
|
||||
const invalidUserId = "-1"
|
||||
|
@ -68,6 +71,22 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun
|
|||
r.filterMappings = filters
|
||||
}
|
||||
|
||||
// setSortMappings sets the mappings for the sort fields. If the sort field is not in the map, it will be used as is.
|
||||
//
|
||||
// If PreferSortTags is enabled, it will map the order fields to the corresponding sort expression,
|
||||
// which gives precedence to sort tags.
|
||||
// Ex: order_title => (coalesce(nullif(sort_title,”),order_title) collate nocase)
|
||||
// To avoid performance issues, indexes should be created for these sort expressions
|
||||
func (r *sqlRepository) setSortMappings(mappings map[string]string) {
|
||||
if conf.Server.PreferSortTags {
|
||||
for k, v := range mappings {
|
||||
v = mapSortOrder(v)
|
||||
mappings[k] = v
|
||||
}
|
||||
}
|
||||
r.sortMappings = mappings
|
||||
}
|
||||
|
||||
func (r sqlRepository) getTableName() string {
|
||||
return r.tableName
|
||||
}
|
||||
|
|
|
@ -25,17 +25,14 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
|
|||
filter := fullTextExpr(q)
|
||||
if filter != nil {
|
||||
sq = sq.Where(filter)
|
||||
if len(orderBys) > 0 {
|
||||
sq = sq.OrderBy(orderBys...)
|
||||
}
|
||||
sq = sq.OrderBy(orderBys...)
|
||||
} else {
|
||||
// If the filter is empty, we sort by id.
|
||||
// This is to speed up the results of `search3?query=""`, for OpenSubsonic
|
||||
sq = sq.OrderBy("id")
|
||||
}
|
||||
sq = sq.Limit(uint64(size)).Offset(uint64(offset))
|
||||
err := r.queryAll(sq, results, model.QueryOptions{Offset: offset})
|
||||
return err
|
||||
return r.queryAll(sq, results, model.QueryOptions{Offset: offset})
|
||||
}
|
||||
|
||||
func fullTextExpr(value string) Sqlizer {
|
||||
|
|
|
@ -33,6 +33,58 @@ checksum:
|
|||
snapshot:
|
||||
version_template: "{{ .Tag }}-SNAPSHOT"
|
||||
|
||||
nfpms:
|
||||
- id: navidrome
|
||||
package_name: navidrome
|
||||
|
||||
homepage: https://navidrome.org
|
||||
description: |-
|
||||
🎧☁ Your Personal Streaming Service
|
||||
|
||||
maintainer: Deluan Quintão <deluan at navidrome.org>
|
||||
|
||||
license: GPL-3.0
|
||||
formats:
|
||||
- deb
|
||||
- rpm
|
||||
|
||||
dependencies:
|
||||
- ffmpeg
|
||||
|
||||
suggests:
|
||||
- mpv
|
||||
|
||||
overrides:
|
||||
rpm:
|
||||
dependencies:
|
||||
- "(ffmpeg or ffmpeg-free)"
|
||||
|
||||
contents:
|
||||
- src: release/linux/navidrome.toml
|
||||
dst: /etc/navidrome/navidrome.toml
|
||||
type: "config|noreplace"
|
||||
file_info:
|
||||
mode: 0644
|
||||
owner: navidrome
|
||||
group: navidrome
|
||||
|
||||
- dst: /var/lib/navidrome
|
||||
type: dir
|
||||
file_info:
|
||||
owner: navidrome
|
||||
group: navidrome
|
||||
|
||||
- dst: /opt/navidrome/music
|
||||
type: dir
|
||||
file_info:
|
||||
owner: navidrome
|
||||
group: navidrome
|
||||
|
||||
scripts:
|
||||
preinstall: "release/linux/preinstall.sh"
|
||||
postinstall: "release/linux/postinstall.sh"
|
||||
preremove: "release/linux/preremove.sh"
|
||||
|
||||
release:
|
||||
draft: true
|
||||
mode: append
|
||||
|
@ -64,6 +116,7 @@ changelog:
|
|||
filters:
|
||||
exclude:
|
||||
- "^test:"
|
||||
- "^refactor:"
|
||||
- Merge pull request
|
||||
- Merge remote-tracking branch
|
||||
- Merge branch
|
||||
|
|
2
release/linux/navidrome.toml
Normal file
2
release/linux/navidrome.toml
Normal file
|
@ -0,0 +1,2 @@
|
|||
DataFolder = "/var/lib/navidrome"
|
||||
MusicFolder = "/opt/navidrome/music"
|
25
release/linux/postinstall.sh
Normal file
25
release/linux/postinstall.sh
Normal file
|
@ -0,0 +1,25 @@
|
|||
#!/bin/sh
|
||||
|
||||
# It is possible for a user to delete the configuration file in such a way that
|
||||
# the package manager (in particular, deb) thinks that the file exists, while it is
|
||||
# no longer on disk. Specifically, doing a `rm /etc/navidrome/navidrome.toml`
|
||||
# without something like `apt purge navidrome` will result in the system believing that
|
||||
# the file still exists. In this case, during isntall it will NOT extract the configuration
|
||||
# file (as to not override it). Since `navidrome service install` depends on this file existing,
|
||||
# we will create it with the defaults anyway.
|
||||
if [ ! -f /etc/navidrome/navidrome.toml ]; then
|
||||
printf "No navidrome.toml detected, creating in postinstall\n"
|
||||
printf "DataFolder = \"/var/lib/navidrome\"\n" > /etc/navidrome/navidrome.toml
|
||||
printf "MusicFolder = \"/opt/navidrome/music\"\n" >> /etc/navidrome/navidrome.toml
|
||||
fi
|
||||
|
||||
postinstall_flag="/var/lib/navidrome/.installed"
|
||||
|
||||
if [ ! -f "$postinstall_flag" ]; then
|
||||
# The primary reason why this would fail is if the service was already installed AND
|
||||
# someone manually removed the .installed flag. In this case, ignore the error
|
||||
navidrome service install --user navidrome --working-directory /var/lib/navidrome --configfile /etc/navidrome/navidrome.toml || :
|
||||
touch "$postinstall_flag"
|
||||
fi
|
||||
|
||||
|
6
release/linux/preinstall.sh
Executable file
6
release/linux/preinstall.sh
Executable file
|
@ -0,0 +1,6 @@
|
|||
#!/bin/sh
|
||||
|
||||
if ! getent passwd navidrome > /dev/null 2>&1; then
|
||||
printf "Creating default Navidrome user\n"
|
||||
useradd --home-dir /var/lib/navidrome --create-home --system --user-group navidrome
|
||||
fi
|
30
release/linux/preremove.sh
Normal file
30
release/linux/preremove.sh
Normal file
|
@ -0,0 +1,30 @@
|
|||
#!/bin/sh
|
||||
|
||||
action=$1
|
||||
|
||||
remove() {
|
||||
postinstall_flag="/var/lib/navidrome/.installed"
|
||||
|
||||
if [ -f "$postinstall_flag" ]; then
|
||||
# If this fails, ignore it
|
||||
navidrome service uninstall || :
|
||||
rm "$postinstall_flag"
|
||||
|
||||
printf "The following may still be present (especially if you have not done a purge):\n"
|
||||
printf "1. /etc/navidrome/navidrome.toml (configuration file)\n"
|
||||
printf "2. /var/lib/navidrome (database/cache)\n"
|
||||
printf "3. /opt/navidrome (default location for music)\n"
|
||||
printf "4. The Navidrome user (user name navidrome)\n"
|
||||
fi
|
||||
}
|
||||
|
||||
case "$action" in
|
||||
"1" | "upgrade")
|
||||
# For an upgrade, do nothing
|
||||
# Leave the service file untouched
|
||||
# This is relevant for RPM/DEB-based installs
|
||||
;;
|
||||
*)
|
||||
remove
|
||||
;;
|
||||
esac
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
<Publish Dialog="ExitDialog" Control="Finish" Event="EndDialog" Value="Return" Order="999" />
|
||||
|
||||
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MaintenanceTypeDlg" />
|
||||
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MyCustomPropertiesDlg" />
|
||||
|
||||
<Publish Dialog="MaintenanceWelcomeDlg" Control="Next" Event="NewDialog" Value="MaintenanceTypeDlg" />
|
||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
Before Width: | Height: | Size: 451 KiB After Width: | Height: | Size: 451 KiB |
60
release/wix/build_msi.sh
Executable file
60
release/wix/build_msi.sh
Executable file
|
@ -0,0 +1,60 @@
|
|||
#!/bin/sh
|
||||
|
||||
FFMPEG_VERSION="7.1"
|
||||
FFMPEG_REPOSITORY=navidrome/ffmpeg-windows-builds
|
||||
DOWNLOAD_FOLDER=/tmp
|
||||
|
||||
#Exit if GIT_TAG is not set
|
||||
if [ -z "$GIT_TAG" ]; then
|
||||
echo "GIT_TAG is not set, exiting..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
WORKSPACE=$1
|
||||
ARCH=$2
|
||||
NAVIDROME_BUILD_VERSION=$(echo "$GIT_TAG" | sed -e 's/^v//' -e 's/-SNAPSHOT/.1/')
|
||||
|
||||
echo "Building MSI package for $ARCH, version $NAVIDROME_BUILD_VERSION"
|
||||
|
||||
MSI_OUTPUT_DIR=$WORKSPACE/binaries/msi
|
||||
mkdir -p "$MSI_OUTPUT_DIR"
|
||||
BINARY_DIR=$WORKSPACE/binaries/windows_${ARCH}
|
||||
|
||||
if [ "$ARCH" = "386" ]; then
|
||||
PLATFORM="x86"
|
||||
WIN_ARCH="win32"
|
||||
else
|
||||
PLATFORM="x64"
|
||||
WIN_ARCH="win64"
|
||||
fi
|
||||
|
||||
BINARY=$BINARY_DIR/navidrome.exe
|
||||
if [ ! -f "$BINARY" ]; then
|
||||
echo
|
||||
echo "$BINARY not found!"
|
||||
echo "Build it with 'make single GOOS=windows GOARCH=${ARCH}'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download static compiled ffmpeg for Windows
|
||||
FFMPEG_FILE="ffmpeg-n${FFMPEG_VERSION}-latest-${WIN_ARCH}-gpl-${FFMPEG_VERSION}"
|
||||
wget --quiet --output-document="${DOWNLOAD_FOLDER}/ffmpeg.zip" \
|
||||
"https://github.com/${FFMPEG_REPOSITORY}/releases/download/latest/${FFMPEG_FILE}.zip"
|
||||
rm -rf "${DOWNLOAD_FOLDER}/extracted_ffmpeg"
|
||||
unzip -d "${DOWNLOAD_FOLDER}/extracted_ffmpeg" "${DOWNLOAD_FOLDER}/ffmpeg.zip" "*/ffmpeg.exe"
|
||||
cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffmpeg.exe "$MSI_OUTPUT_DIR"
|
||||
|
||||
cp "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR"
|
||||
cp "$BINARY" "$MSI_OUTPUT_DIR"
|
||||
|
||||
# workaround for wixl WixVariable not working to override bmp locations
|
||||
cp "$WORKSPACE"/release/wix/bmp/banner.bmp /usr/share/wixl-*/ext/ui/bitmaps/bannrbmp.bmp
|
||||
cp "$WORKSPACE"/release/wix/bmp/dialogue.bmp /usr/share/wixl-*/ext/ui/bitmaps/dlgbmp.bmp
|
||||
|
||||
cd "$MSI_OUTPUT_DIR"
|
||||
rm -f "$MSI_OUTPUT_DIR"/navidrome_"${ARCH}".msi
|
||||
wixl "$WORKSPACE"/release/wix/navidrome.wxs -D Version="$NAVIDROME_BUILD_VERSION" -D Platform=$PLATFORM --arch $PLATFORM \
|
||||
--ext ui --output "$MSI_OUTPUT_DIR"/navidrome_"${ARCH}".msi
|
||||
|
3
release/wix/msitools.dockerfile
Normal file
3
release/wix/msitools.dockerfile
Normal file
|
@ -0,0 +1,3 @@
|
|||
FROM public.ecr.aws/docker/library/alpine
|
||||
RUN apk update && apk add jq msitools
|
||||
WORKDIR /workspace
|
|
@ -29,8 +29,6 @@
|
|||
|
||||
<UIRef Id="Navidrome_UI_Flow"/>
|
||||
|
||||
<Property Id="CSCRIPT_LOCATION" Value="C:\Windows\System32\cscript.exe" />
|
||||
|
||||
<Directory Id='TARGETDIR' Name='SourceDir'>
|
||||
<Directory Id="$(var.PlatformProgramFilesFolder)">
|
||||
<Directory Id='INSTALLDIR' Name='Navidrome'>
|
||||
|
@ -43,14 +41,11 @@
|
|||
<File Id='README.md' Name='README.md' DiskId='1' Source='README.md' KeyPath='yes' />
|
||||
</Component>
|
||||
|
||||
<Component Id='convertIniToToml.vbsFile' Guid='2a5d3241-9a8b-4a8c-9edc-fbef1a030d4d' Win64="$(var.Win64)">
|
||||
<File Id='convertIniToToml.vbs' Name='convertIniToToml.vbs' DiskId='1' Source='convertIniToToml.vbs' KeyPath='yes' />
|
||||
</Component>
|
||||
|
||||
<Component Id="Configuration" Guid="9e17ed4b-ef13-44bf-a605-ed4132cff7f6" Win64="$(var.Win64)">
|
||||
<IniFile Id="ConfigurationPort" Name="navidrome-msi.ini" Action="createLine" Directory="INSTALLDIR" Key="Port" Section="MSI_PLACEHOLDER_SECTION" Value="'[ND_PORT]'" />
|
||||
<IniFile Id="ConfigurationMusicDir" Name="navidrome-msi.ini" Action="addLine" Directory="INSTALLDIR" Key="MusicFolder" Section="MSI_PLACEHOLDER_SECTION" Value="'[ND_MUSICFOLDER]'" />
|
||||
<IniFile Id="ConfigurationDataDir" Name="navidrome-msi.ini" Action="addLine" Directory="INSTALLDIR" Key="DataFolder" Section="MSI_PLACEHOLDER_SECTION" Value="'[ND_DATAFOLDER]'" />
|
||||
<IniFile Id="ConfigurationPort" Name="navidrome.ini" Action="createLine" Directory="INSTALLDIR" Key="Port" Section="default" Value="'[ND_PORT]'" />
|
||||
<IniFile Id="ConfigurationMusicDir" Name="navidrome.ini" Action="addLine" Directory="INSTALLDIR" Key="MusicFolder" Section="default" Value="'[ND_MUSICFOLDER]'" />
|
||||
<IniFile Id="ConfigurationDataDir" Name="navidrome.ini" Action="addLine" Directory="INSTALLDIR" Key="DataFolder" Section="default" Value="'[ND_DATAFOLDER]'" />
|
||||
<IniFile Id="FFmpegPath" Name="navidrome.ini" Action="addLine" Directory="INSTALLDIR" Key="FFmpegPath" Section="default" Value="'[INSTALLDIR]ffmpeg.exe'" />
|
||||
</Component>
|
||||
|
||||
<Component Id='MainExecutable' Guid='e645aa06-8bbc-40d6-8d3c-73b4f5b76fd7' Win64="$(var.Win64)">
|
||||
|
@ -63,31 +58,29 @@
|
|||
Start='auto'
|
||||
Type='ownProcess'
|
||||
Vital='yes'
|
||||
Arguments='service execute --configfile "[INSTALLDIR]navidrome.toml"'
|
||||
Arguments='service execute --configfile "[INSTALLDIR]navidrome.ini" --logfile "[ND_DATAFOLDER]\navidrome.log"'
|
||||
/>
|
||||
<ServiceControl Id='StartNavidromeService' Start='install' Stop='both' Remove='uninstall' Name='$(var.ProductName)' Wait='yes' />
|
||||
</Component>
|
||||
|
||||
<Component Id='FFMpegExecutable' Guid='d17358f7-abdc-4080-acd3-6427903a7dd8' Win64="$(var.Win64)">
|
||||
<File Id='ffmpeg.exe' Name='ffmpeg.exe' DiskId='1' Source='ffmpeg.exe' KeyPath='yes' />
|
||||
</Component>
|
||||
|
||||
</Directory>
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
<CustomAction Id="HackIniIntoTOML" Impersonate="no" Property="CSCRIPT_LOCATION" Execute="deferred" ExeCommand='"[INSTALLDIR]convertIniToToml.vbs" "[INSTALLDIR]navidrome-msi.ini" "[INSTALLDIR]navidrome.toml"' />
|
||||
|
||||
<InstallUISequence>
|
||||
<Show Dialog="MyCustomPropertiesDlg" After="WelcomeDlg">Not Installed AND NOT WIX_UPGRADE_DETECTED</Show>
|
||||
</InstallUISequence>
|
||||
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action="HackIniIntoTOML" After="WriteIniValues">NOT Installed AND NOT REMOVE</Custom>
|
||||
</InstallExecuteSequence>
|
||||
|
||||
<Feature Id='Complete' Level='1'>
|
||||
<ComponentRef Id='convertIniToToml.vbsFile' />
|
||||
<ComponentRef Id='LICENSEFile' />
|
||||
<ComponentRef Id='README.mdFile' />
|
||||
<ComponentRef Id='Configuration'/>
|
||||
<ComponentRef Id='MainExecutable' />
|
||||
<ComponentRef Id='FFMpegExecutable' />
|
||||
</Feature>
|
||||
</Product>
|
||||
</Wix>
|
|
@ -2,6 +2,7 @@ package scanner
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
|
@ -70,18 +71,14 @@ type mockedMediaFile struct {
|
|||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) {
|
||||
return &model.MediaFile{
|
||||
ID: "123",
|
||||
Path: s,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *mockedMediaFile) FindByPaths(paths []string) (model.MediaFiles, error) {
|
||||
var mfs model.MediaFiles
|
||||
for _, path := range paths {
|
||||
mf, _ := r.FindByPath(path)
|
||||
mfs = append(mfs, *mf)
|
||||
for i, path := range paths {
|
||||
mf := model.MediaFile{
|
||||
ID: strconv.Itoa(i),
|
||||
Path: path,
|
||||
}
|
||||
mfs = append(mfs, mf)
|
||||
}
|
||||
return mfs, nil
|
||||
}
|
||||
|
|
|
@ -4,43 +4,96 @@ import (
|
|||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sync"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/random"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
//imageHostingUrl = "https://unsplash.com/photos/%s/download?fm=jpg&w=1600&h=900&fit=max"
|
||||
imageHostingUrl = "https://www.navidrome.org/images/%s.jpg"
|
||||
imageListURL = "https://www.navidrome.org/images/index.yml"
|
||||
imageListTTL = 24 * time.Hour
|
||||
imageCacheDir = "backgrounds"
|
||||
imageCacheSize = "100MB"
|
||||
imageCacheMaxItems = 1000
|
||||
imageRequestTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
list []string
|
||||
lock sync.RWMutex
|
||||
httpClient *cache.HTTPClient
|
||||
cache cache.FileCache
|
||||
}
|
||||
|
||||
func NewHandler() *Handler {
|
||||
h := &Handler{}
|
||||
h.httpClient = cache.NewHTTPClient(&http.Client{Timeout: 5 * time.Second}, imageListTTL)
|
||||
h.cache = cache.NewFileCache(imageCacheDir, imageCacheSize, imageCacheDir, imageCacheMaxItems, h.serveImage)
|
||||
go func() {
|
||||
_, _ = h.getImageList(log.NewContext(context.Background()))
|
||||
}()
|
||||
return h
|
||||
}
|
||||
|
||||
const ndImageServiceURL = "https://www.navidrome.org/images"
|
||||
type cacheKey string
|
||||
|
||||
func (k cacheKey) Key() string {
|
||||
return string(k)
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
image, err := h.getRandomImage(r.Context())
|
||||
if err != nil {
|
||||
defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline)
|
||||
w.Header().Set("content-type", "image/png")
|
||||
_, _ = w.Write(defaultImage)
|
||||
h.serveDefaultImage(w)
|
||||
return
|
||||
}
|
||||
s, err := h.cache.Get(r.Context(), cacheKey(image))
|
||||
if err != nil {
|
||||
h.serveDefaultImage(w)
|
||||
return
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
http.Redirect(w, r, buildPath(ndImageServiceURL, image), http.StatusFound)
|
||||
w.Header().Set("content-type", "image/jpeg")
|
||||
_, _ = io.Copy(w, s.Reader)
|
||||
}
|
||||
|
||||
func (h *Handler) serveDefaultImage(w http.ResponseWriter) {
|
||||
defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline)
|
||||
w.Header().Set("content-type", "image/png")
|
||||
_, _ = w.Write(defaultImage)
|
||||
}
|
||||
|
||||
func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, error) {
|
||||
start := time.Now()
|
||||
image := item.Key()
|
||||
if image == "" {
|
||||
return nil, errors.New("empty image name")
|
||||
}
|
||||
c := http.Client{Timeout: imageRequestTimeout}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil)
|
||||
resp, err := c.Do(req) //nolint:bodyclose // No need to close resp.Body, it will be closed via the CachedStream wrapper
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline)
|
||||
return strings.NewReader(string(defaultImage)), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get background image from hosting service: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code getting background image from hosting service: %d", resp.StatusCode)
|
||||
}
|
||||
log.Debug(ctx, "Got background image from hosting service", "image", image, "elapsed", time.Since(start))
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func (h *Handler) getRandomImage(ctx context.Context) (string, error) {
|
||||
|
@ -56,37 +109,28 @@ func (h *Handler) getRandomImage(ctx context.Context) (string, error) {
|
|||
}
|
||||
|
||||
func (h *Handler) getImageList(ctx context.Context) ([]string, error) {
|
||||
h.lock.RLock()
|
||||
if len(h.list) > 0 {
|
||||
defer h.lock.RUnlock()
|
||||
return h.list, nil
|
||||
}
|
||||
|
||||
h.lock.RUnlock()
|
||||
h.lock.Lock()
|
||||
defer h.lock.Unlock()
|
||||
start := time.Now()
|
||||
|
||||
c := http.Client{
|
||||
Timeout: time.Minute,
|
||||
}
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, buildPath(ndImageServiceURL, "index.yml"), nil)
|
||||
resp, err := c.Do(req)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageListURL, nil)
|
||||
resp, err := h.httpClient.Do(req)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not get background images from image service", err)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var list []string
|
||||
dec := yaml.NewDecoder(resp.Body)
|
||||
err = dec.Decode(&h.list)
|
||||
log.Debug(ctx, "Loaded background images from image service", "total", len(h.list), "elapsed", time.Since(start))
|
||||
return h.list, err
|
||||
err = dec.Decode(&list)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not decode background images from image service", err)
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(ctx, "Loaded background images from image service", "total", len(list), "elapsed", time.Since(start))
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func buildPath(baseURL string, endpoint ...string) string {
|
||||
u, _ := url.Parse(baseURL)
|
||||
p := path.Join(endpoint...)
|
||||
u.Path = path.Join(u.Path, p)
|
||||
return u.String()
|
||||
func imageURL(imageName string) string {
|
||||
imageName = strings.TrimSuffix(imageName, ".jpg")
|
||||
return fmt.Sprintf(imageHostingUrl, imageName)
|
||||
}
|
||||
|
|
|
@ -83,7 +83,7 @@ func createJWTSecret(ds model.DataStore) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func checkFfmpegInstallation() {
|
||||
func checkFFmpegInstallation() {
|
||||
f := ffmpeg.New()
|
||||
_, err := f.CmdPath()
|
||||
if err == nil {
|
||||
|
|
|
@ -40,7 +40,7 @@ func New(ds model.DataStore, broker events.Broker) *Server {
|
|||
s.initRoutes()
|
||||
s.mountAuthenticationRoutes()
|
||||
s.mountRootRedirector()
|
||||
checkFfmpegInstallation()
|
||||
checkFFmpegInstallation()
|
||||
checkExternalCredentials()
|
||||
return s
|
||||
}
|
||||
|
|
|
@ -6,11 +6,13 @@ import (
|
|||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/filter"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) {
|
||||
|
@ -86,7 +88,9 @@ func (api *Router) GetAlbumList(w http.ResponseWriter, r *http.Request) (*respon
|
|||
w.Header().Set("x-total-count", strconv.Itoa(int(count)))
|
||||
|
||||
response := newResponse()
|
||||
response.AlbumList = &responses.AlbumList{Album: childrenFromAlbums(r.Context(), albums)}
|
||||
response.AlbumList = &responses.AlbumList{
|
||||
Album: slice.MapWithArg(albums, r.Context(), childFromAlbum),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
@ -99,7 +103,9 @@ func (api *Router) GetAlbumList2(w http.ResponseWriter, r *http.Request) (*respo
|
|||
w.Header().Set("x-total-count", strconv.FormatInt(pageCount, 10))
|
||||
|
||||
response := newResponse()
|
||||
response.AlbumList2 = &responses.AlbumList{Album: childrenFromAlbums(r.Context(), albums)}
|
||||
response.AlbumList2 = &responses.AlbumList{
|
||||
Album: slice.MapWithArg(albums, r.Context(), childFromAlbum),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
@ -124,9 +130,9 @@ func (api *Router) GetStarred(r *http.Request) (*responses.Subsonic, error) {
|
|||
|
||||
response := newResponse()
|
||||
response.Starred = &responses.Starred{}
|
||||
response.Starred.Artist = toArtists(r, artists)
|
||||
response.Starred.Album = childrenFromAlbums(r.Context(), albums)
|
||||
response.Starred.Song = childrenFromMediaFiles(r.Context(), mediaFiles)
|
||||
response.Starred.Artist = slice.MapWithArg(artists, r, toArtist)
|
||||
response.Starred.Album = slice.MapWithArg(albums, ctx, childFromAlbum)
|
||||
response.Starred.Song = slice.MapWithArg(mediaFiles, ctx, childFromMediaFile)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
@ -151,14 +157,16 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) {
|
|||
|
||||
response := newResponse()
|
||||
response.NowPlaying = &responses.NowPlaying{}
|
||||
response.NowPlaying.Entry = make([]responses.NowPlayingEntry, len(npInfo))
|
||||
for i, np := range npInfo {
|
||||
response.NowPlaying.Entry[i].Child = childFromMediaFile(ctx, np.MediaFile)
|
||||
response.NowPlaying.Entry[i].UserName = np.Username
|
||||
response.NowPlaying.Entry[i].MinutesAgo = int32(time.Since(np.Start).Minutes())
|
||||
response.NowPlaying.Entry[i].PlayerId = int32(i + 1) // Fake numeric playerId, it does not seem to be used for anything
|
||||
response.NowPlaying.Entry[i].PlayerName = np.PlayerName
|
||||
}
|
||||
var i int32
|
||||
response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry {
|
||||
return responses.NowPlayingEntry{
|
||||
Child: childFromMediaFile(ctx, np.MediaFile),
|
||||
UserName: np.Username,
|
||||
MinutesAgo: int32(time.Since(np.Start).Minutes()),
|
||||
PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything
|
||||
PlayerName: np.PlayerName,
|
||||
}
|
||||
})
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
@ -177,7 +185,7 @@ func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error)
|
|||
|
||||
response := newResponse()
|
||||
response.RandomSongs = &responses.Songs{}
|
||||
response.RandomSongs.Songs = childrenFromMediaFiles(r.Context(), songs)
|
||||
response.RandomSongs.Songs = slice.MapWithArg(songs, r.Context(), childFromMediaFile)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
@ -195,7 +203,7 @@ func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error)
|
|||
|
||||
response := newResponse()
|
||||
response.SongsByGenre = &responses.Songs{}
|
||||
response.SongsByGenre.Songs = childrenFromMediaFiles(r.Context(), songs)
|
||||
response.SongsByGenre.Songs = slice.MapWithArg(songs, r.Context(), childFromMediaFile)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
|
|
@ -68,141 +68,146 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreame
|
|||
|
||||
func (api *Router) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(postFormToQueryParams)
|
||||
r.Use(checkRequiredParameters)
|
||||
r.Use(authenticate(api.ds))
|
||||
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
||||
// TODO Validate API version?
|
||||
|
||||
// Subsonic endpoints, grouped by controller
|
||||
// Public
|
||||
h(r, "getOpenSubsonicExtensions", api.GetOpenSubsonicExtensions)
|
||||
|
||||
// Protected
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "ping", api.Ping)
|
||||
h(r, "getLicense", api.GetLicense)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getMusicFolders", api.GetMusicFolders)
|
||||
h(r, "getIndexes", api.GetIndexes)
|
||||
h(r, "getArtists", api.GetArtists)
|
||||
h(r, "getGenres", api.GetGenres)
|
||||
h(r, "getMusicDirectory", api.GetMusicDirectory)
|
||||
h(r, "getArtist", api.GetArtist)
|
||||
h(r, "getAlbum", api.GetAlbum)
|
||||
h(r, "getSong", api.GetSong)
|
||||
h(r, "getAlbumInfo", api.GetAlbumInfo)
|
||||
h(r, "getAlbumInfo2", api.GetAlbumInfo)
|
||||
h(r, "getArtistInfo", api.GetArtistInfo)
|
||||
h(r, "getArtistInfo2", api.GetArtistInfo2)
|
||||
h(r, "getTopSongs", api.GetTopSongs)
|
||||
h(r, "getSimilarSongs", api.GetSimilarSongs)
|
||||
h(r, "getSimilarSongs2", api.GetSimilarSongs2)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
hr(r, "getAlbumList", api.GetAlbumList)
|
||||
hr(r, "getAlbumList2", api.GetAlbumList2)
|
||||
h(r, "getStarred", api.GetStarred)
|
||||
h(r, "getStarred2", api.GetStarred2)
|
||||
h(r, "getNowPlaying", api.GetNowPlaying)
|
||||
h(r, "getRandomSongs", api.GetRandomSongs)
|
||||
h(r, "getSongsByGenre", api.GetSongsByGenre)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "setRating", api.SetRating)
|
||||
h(r, "star", api.Star)
|
||||
h(r, "unstar", api.Unstar)
|
||||
h(r, "scrobble", api.Scrobble)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getPlaylists", api.GetPlaylists)
|
||||
h(r, "getPlaylist", api.GetPlaylist)
|
||||
h(r, "createPlaylist", api.CreatePlaylist)
|
||||
h(r, "deletePlaylist", api.DeletePlaylist)
|
||||
h(r, "updatePlaylist", api.UpdatePlaylist)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getBookmarks", api.GetBookmarks)
|
||||
h(r, "createBookmark", api.CreateBookmark)
|
||||
h(r, "deleteBookmark", api.DeleteBookmark)
|
||||
h(r, "getPlayQueue", api.GetPlayQueue)
|
||||
h(r, "savePlayQueue", api.SavePlayQueue)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "search2", api.Search2)
|
||||
h(r, "search3", api.Search3)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
h(r, "getUser", api.GetUser)
|
||||
h(r, "getUsers", api.GetUsers)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
h(r, "getScanStatus", api.GetScanStatus)
|
||||
h(r, "startScan", api.StartScan)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
hr(r, "getAvatar", api.GetAvatar)
|
||||
h(r, "getLyrics", api.GetLyrics)
|
||||
h(r, "getLyricsBySongId", api.GetLyricsBySongId)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
// configure request throttling
|
||||
if conf.Server.DevArtworkMaxRequests > 0 {
|
||||
log.Debug("Throttling Subsonic getCoverArt endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests,
|
||||
"backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout",
|
||||
conf.Server.DevArtworkThrottleBacklogTimeout)
|
||||
r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
|
||||
conf.Server.DevArtworkThrottleBacklogTimeout))
|
||||
r.Use(checkRequiredParameters)
|
||||
r.Use(authenticate(api.ds))
|
||||
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
||||
|
||||
// Subsonic endpoints, grouped by controller
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "ping", api.Ping)
|
||||
h(r, "getLicense", api.GetLicense)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getMusicFolders", api.GetMusicFolders)
|
||||
h(r, "getIndexes", api.GetIndexes)
|
||||
h(r, "getArtists", api.GetArtists)
|
||||
h(r, "getGenres", api.GetGenres)
|
||||
h(r, "getMusicDirectory", api.GetMusicDirectory)
|
||||
h(r, "getArtist", api.GetArtist)
|
||||
h(r, "getAlbum", api.GetAlbum)
|
||||
h(r, "getSong", api.GetSong)
|
||||
h(r, "getAlbumInfo", api.GetAlbumInfo)
|
||||
h(r, "getAlbumInfo2", api.GetAlbumInfo)
|
||||
h(r, "getArtistInfo", api.GetArtistInfo)
|
||||
h(r, "getArtistInfo2", api.GetArtistInfo2)
|
||||
h(r, "getTopSongs", api.GetTopSongs)
|
||||
h(r, "getSimilarSongs", api.GetSimilarSongs)
|
||||
h(r, "getSimilarSongs2", api.GetSimilarSongs2)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
hr(r, "getAlbumList", api.GetAlbumList)
|
||||
hr(r, "getAlbumList2", api.GetAlbumList2)
|
||||
h(r, "getStarred", api.GetStarred)
|
||||
h(r, "getStarred2", api.GetStarred2)
|
||||
h(r, "getNowPlaying", api.GetNowPlaying)
|
||||
h(r, "getRandomSongs", api.GetRandomSongs)
|
||||
h(r, "getSongsByGenre", api.GetSongsByGenre)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "setRating", api.SetRating)
|
||||
h(r, "star", api.Star)
|
||||
h(r, "unstar", api.Unstar)
|
||||
h(r, "scrobble", api.Scrobble)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getPlaylists", api.GetPlaylists)
|
||||
h(r, "getPlaylist", api.GetPlaylist)
|
||||
h(r, "createPlaylist", api.CreatePlaylist)
|
||||
h(r, "deletePlaylist", api.DeletePlaylist)
|
||||
h(r, "updatePlaylist", api.UpdatePlaylist)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getBookmarks", api.GetBookmarks)
|
||||
h(r, "createBookmark", api.CreateBookmark)
|
||||
h(r, "deleteBookmark", api.DeleteBookmark)
|
||||
h(r, "getPlayQueue", api.GetPlayQueue)
|
||||
h(r, "savePlayQueue", api.SavePlayQueue)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "search2", api.Search2)
|
||||
h(r, "search3", api.Search3)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getUser", api.GetUser)
|
||||
h(r, "getUsers", api.GetUsers)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getScanStatus", api.GetScanStatus)
|
||||
h(r, "startScan", api.StartScan)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
hr(r, "getAvatar", api.GetAvatar)
|
||||
h(r, "getLyrics", api.GetLyrics)
|
||||
h(r, "getLyricsBySongId", api.GetLyricsBySongId)
|
||||
hr(r, "stream", api.Stream)
|
||||
hr(r, "download", api.Download)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
// configure request throttling
|
||||
if conf.Server.DevArtworkMaxRequests > 0 {
|
||||
log.Debug("Throttling Subsonic getCoverArt endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests,
|
||||
"backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout",
|
||||
conf.Server.DevArtworkThrottleBacklogTimeout)
|
||||
r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
|
||||
conf.Server.DevArtworkThrottleBacklogTimeout))
|
||||
}
|
||||
hr(r, "getCoverArt", api.GetCoverArt)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "createInternetRadioStation", api.CreateInternetRadio)
|
||||
h(r, "deleteInternetRadioStation", api.DeleteInternetRadio)
|
||||
h(r, "getInternetRadioStations", api.GetInternetRadios)
|
||||
h(r, "updateInternetRadioStation", api.UpdateInternetRadio)
|
||||
})
|
||||
if conf.Server.EnableSharing {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getShares", api.GetShares)
|
||||
h(r, "createShare", api.CreateShare)
|
||||
h(r, "updateShare", api.UpdateShare)
|
||||
h(r, "deleteShare", api.DeleteShare)
|
||||
})
|
||||
} else {
|
||||
h501(r, "getShares", "createShare", "updateShare", "deleteShare")
|
||||
}
|
||||
hr(r, "getCoverArt", api.GetCoverArt)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
hr(r, "stream", api.Stream)
|
||||
hr(r, "download", api.Download)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
h(r, "createInternetRadioStation", api.CreateInternetRadio)
|
||||
h(r, "deleteInternetRadioStation", api.DeleteInternetRadio)
|
||||
h(r, "getInternetRadioStations", api.GetInternetRadios)
|
||||
h(r, "updateInternetRadioStation", api.UpdateInternetRadio)
|
||||
})
|
||||
if conf.Server.EnableSharing {
|
||||
r.Group(func(r chi.Router) {
|
||||
h(r, "getShares", api.GetShares)
|
||||
h(r, "createShare", api.CreateShare)
|
||||
h(r, "updateShare", api.UpdateShare)
|
||||
h(r, "deleteShare", api.DeleteShare)
|
||||
})
|
||||
} else {
|
||||
h501(r, "getShares", "createShare", "updateShare", "deleteShare")
|
||||
}
|
||||
r.Group(func(r chi.Router) {
|
||||
h(r, "getOpenSubsonicExtensions", api.GetOpenSubsonicExtensions)
|
||||
})
|
||||
|
||||
if conf.Server.Jukebox.Enabled {
|
||||
r.Group(func(r chi.Router) {
|
||||
h(r, "jukeboxControl", api.JukeboxControl)
|
||||
})
|
||||
} else {
|
||||
h501(r, "jukeboxControl")
|
||||
}
|
||||
if conf.Server.Jukebox.Enabled {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "jukeboxControl", api.JukeboxControl)
|
||||
})
|
||||
} else {
|
||||
h501(r, "jukeboxControl")
|
||||
}
|
||||
|
||||
// Not Implemented (yet?)
|
||||
h501(r, "getPodcasts", "getNewestPodcasts", "refreshPodcasts", "createPodcastChannel", "deletePodcastChannel",
|
||||
"deletePodcastEpisode", "downloadPodcastEpisode")
|
||||
h501(r, "createUser", "updateUser", "deleteUser", "changePassword")
|
||||
// Not Implemented (yet?)
|
||||
h501(r, "getPodcasts", "getNewestPodcasts", "refreshPodcasts", "createPodcastChannel", "deletePodcastChannel",
|
||||
"deletePodcastEpisode", "downloadPodcastEpisode")
|
||||
h501(r, "createUser", "updateUser", "deleteUser", "changePassword")
|
||||
|
||||
// Deprecated/Won't implement/Out of scope endpoints
|
||||
h410(r, "search")
|
||||
h410(r, "getChatMessages", "addChatMessage")
|
||||
h410(r, "getVideos", "getVideoInfo", "getCaptions", "hls")
|
||||
// Deprecated/Won't implement/Out of scope endpoints
|
||||
h410(r, "search")
|
||||
h410(r, "getChatMessages", "addChatMessage")
|
||||
h410(r, "getVideos", "getVideoInfo", "getCaptions", "hls")
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
|
@ -9,21 +9,22 @@ import (
|
|||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
func (api *Router) GetBookmarks(r *http.Request) (*responses.Subsonic, error) {
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
|
||||
repo := api.ds.MediaFile(r.Context())
|
||||
bmks, err := repo.GetBookmarks()
|
||||
bookmarks, err := repo.GetBookmarks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.Bookmarks = &responses.Bookmarks{}
|
||||
for _, bmk := range bmks {
|
||||
b := responses.Bookmark{
|
||||
response.Bookmarks.Bookmark = slice.Map(bookmarks, func(bmk model.Bookmark) responses.Bookmark {
|
||||
return responses.Bookmark{
|
||||
Entry: childFromMediaFile(r.Context(), bmk.Item),
|
||||
Position: bmk.Position,
|
||||
Username: user.UserName,
|
||||
|
@ -31,8 +32,7 @@ func (api *Router) GetBookmarks(r *http.Request) (*responses.Subsonic, error) {
|
|||
Created: bmk.CreatedAt,
|
||||
Changed: bmk.UpdatedAt,
|
||||
}
|
||||
response.Bookmarks.Bookmark = append(response.Bookmarks.Bookmark, b)
|
||||
}
|
||||
})
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
@ -83,7 +83,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
|||
|
||||
response := newResponse()
|
||||
response.PlayQueue = &responses.PlayQueue{
|
||||
Entry: childrenFromMediaFiles(r.Context(), pq.Items),
|
||||
Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile),
|
||||
Current: pq.Current,
|
||||
Position: pq.Position,
|
||||
Username: user.UserName,
|
||||
|
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/navidrome/navidrome/server/subsonic/filter"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) {
|
||||
|
@ -27,12 +28,12 @@ func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error)
|
|||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) getArtistIndex(r *http.Request, libId int, ifModifiedSince time.Time) (*responses.Indexes, error) {
|
||||
func (api *Router) getArtist(r *http.Request, libId int, ifModifiedSince time.Time) (model.ArtistIndexes, int64, error) {
|
||||
ctx := r.Context()
|
||||
lib, err := api.ds.Library(ctx).Get(libId)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving Library", "id", libId, err)
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var indexes model.ArtistIndexes
|
||||
|
@ -40,19 +41,47 @@ func (api *Router) getArtistIndex(r *http.Request, libId int, ifModifiedSince ti
|
|||
indexes, err = api.ds.Artist(ctx).GetIndex()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving Indexes", err)
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return indexes, lib.LastScanAt.UnixMilli(), err
|
||||
}
|
||||
|
||||
func (api *Router) getArtistIndex(r *http.Request, libId int, ifModifiedSince time.Time) (*responses.Indexes, error) {
|
||||
indexes, modified, err := api.getArtist(r, libId, ifModifiedSince)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &responses.Indexes{
|
||||
IgnoredArticles: conf.Server.IgnoredArticles,
|
||||
LastModified: lib.LastScanAt.UnixMilli(),
|
||||
LastModified: modified,
|
||||
}
|
||||
|
||||
res.Index = make([]responses.Index, len(indexes))
|
||||
for i, idx := range indexes {
|
||||
res.Index[i].Name = idx.ID
|
||||
res.Index[i].Artists = toArtists(r, idx.Artists)
|
||||
res.Index[i].Artists = slice.MapWithArg(idx.Artists, r, toArtist)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (api *Router) getArtistIndexID3(r *http.Request, libId int, ifModifiedSince time.Time) (*responses.Artists, error) {
|
||||
indexes, modified, err := api.getArtist(r, libId, ifModifiedSince)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &responses.Artists{
|
||||
IgnoredArticles: conf.Server.IgnoredArticles,
|
||||
LastModified: modified,
|
||||
}
|
||||
|
||||
res.Index = make([]responses.IndexID3, len(indexes))
|
||||
for i, idx := range indexes {
|
||||
res.Index[i].Name = idx.ID
|
||||
res.Index[i].Artists = slice.MapWithArg(idx.Artists, r, toArtistID3)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
@ -75,7 +104,7 @@ func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) {
|
|||
func (api *Router) GetArtists(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
musicFolderId := p.IntOr("musicFolderId", 1)
|
||||
res, err := api.getArtistIndex(r, musicFolderId, time.Time{})
|
||||
res, err := api.getArtistIndexID3(r, musicFolderId, time.Time{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -308,7 +337,7 @@ func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error)
|
|||
|
||||
response := newResponse()
|
||||
response.SimilarSongs = &responses.SimilarSongs{
|
||||
Song: childrenFromMediaFiles(ctx, songs),
|
||||
Song: slice.MapWithArg(songs, ctx, childFromMediaFile),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
@ -342,7 +371,7 @@ func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) {
|
|||
|
||||
response := newResponse()
|
||||
response.TopSongs = &responses.TopSongs{
|
||||
Song: childrenFromMediaFiles(ctx, songs),
|
||||
Song: slice.MapWithArg(songs, ctx, childFromMediaFile),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
@ -366,7 +395,7 @@ func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artis
|
|||
return nil, err
|
||||
}
|
||||
|
||||
dir.Child = childrenFromAlbums(ctx, albums)
|
||||
dir.Child = slice.MapWithArg(albums, ctx, childFromAlbum)
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
|
@ -380,7 +409,7 @@ func (api *Router) buildArtist(r *http.Request, artist *model.Artist) (*response
|
|||
return nil, err
|
||||
}
|
||||
|
||||
a.Album = childrenFromAlbums(r.Context(), albums)
|
||||
a.Album = slice.MapWithArg(albums, ctx, childFromAlbum)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
|
@ -405,13 +434,13 @@ func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album)
|
|||
return nil, err
|
||||
}
|
||||
|
||||
dir.Child = childrenFromMediaFiles(ctx, mfs)
|
||||
dir.Child = slice.MapWithArg(mfs, ctx, childFromMediaFile)
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func (api *Router) buildAlbum(ctx context.Context, album *model.Album, mfs model.MediaFiles) *responses.AlbumWithSongsID3 {
|
||||
dir := &responses.AlbumWithSongsID3{}
|
||||
dir.AlbumID3 = buildAlbumID3(ctx, *album)
|
||||
dir.Song = childrenFromMediaFiles(ctx, mfs)
|
||||
dir.Song = slice.MapWithArg(mfs, ctx, childFromMediaFile)
|
||||
return dir
|
||||
}
|
||||
|
|
|
@ -64,14 +64,6 @@ func getUser(ctx context.Context) model.User {
|
|||
return model.User{}
|
||||
}
|
||||
|
||||
func toArtists(r *http.Request, artists model.Artists) []responses.Artist {
|
||||
as := make([]responses.Artist, len(artists))
|
||||
for i, artist := range artists {
|
||||
as[i] = toArtist(r, artist)
|
||||
}
|
||||
return as
|
||||
}
|
||||
|
||||
func toArtist(r *http.Request, a model.Artist) responses.Artist {
|
||||
artist := responses.Artist{
|
||||
Id: a.ID,
|
||||
|
@ -116,6 +108,14 @@ func toGenres(genres model.Genres) *responses.Genres {
|
|||
return &responses.Genres{Genre: response}
|
||||
}
|
||||
|
||||
func toItemGenres(genres model.Genres) []responses.ItemGenre {
|
||||
itemGenres := make([]responses.ItemGenre, len(genres))
|
||||
for i, g := range genres {
|
||||
itemGenres[i] = responses.ItemGenre{Name: g.Name}
|
||||
}
|
||||
return itemGenres
|
||||
}
|
||||
|
||||
func getTranscoding(ctx context.Context) (format string, bitRate int) {
|
||||
if trc, ok := request.TranscodingFrom(ctx); ok {
|
||||
format = trc.TargetFormat
|
||||
|
@ -126,8 +126,6 @@ func getTranscoding(ctx context.Context) (format string, bitRate int) {
|
|||
return
|
||||
}
|
||||
|
||||
// This seems to be duplicated, but it is an initial step into merging `engine` and the `subsonic` packages,
|
||||
// In the future there won't be any conversion to/from `engine. Entry` anymore
|
||||
func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = mf.ID
|
||||
|
@ -138,7 +136,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
|
|||
child.Year = int32(mf.Year)
|
||||
child.Artist = mf.Artist
|
||||
child.Genre = mf.Genre
|
||||
child.Genres = buildItemGenres(mf.Genres)
|
||||
child.Genres = toItemGenres(mf.Genres)
|
||||
child.Track = int32(mf.TrackNumber)
|
||||
child.Duration = int32(mf.Duration)
|
||||
child.Size = mf.Size
|
||||
|
@ -200,14 +198,6 @@ func mapSlashToDash(target string) string {
|
|||
return strings.ReplaceAll(target, "/", "_")
|
||||
}
|
||||
|
||||
func childrenFromMediaFiles(ctx context.Context, mfs model.MediaFiles) []responses.Child {
|
||||
children := make([]responses.Child, len(mfs))
|
||||
for i, mf := range mfs {
|
||||
children[i] = childFromMediaFile(ctx, mf)
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
func childFromAlbum(_ context.Context, al model.Album) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = al.ID
|
||||
|
@ -218,7 +208,7 @@ func childFromAlbum(_ context.Context, al model.Album) responses.Child {
|
|||
child.Artist = al.AlbumArtist
|
||||
child.Year = int32(al.MaxYear)
|
||||
child.Genre = al.Genre
|
||||
child.Genres = buildItemGenres(al.Genres)
|
||||
child.Genres = toItemGenres(al.Genres)
|
||||
child.CoverArt = al.CoverArtID().String()
|
||||
child.Created = &al.CreatedAt
|
||||
child.Parent = al.AlbumArtistID
|
||||
|
@ -239,14 +229,6 @@ func childFromAlbum(_ context.Context, al model.Album) responses.Child {
|
|||
return child
|
||||
}
|
||||
|
||||
func childrenFromAlbums(ctx context.Context, als model.Albums) []responses.Child {
|
||||
children := make([]responses.Child, len(als))
|
||||
for i, al := range als {
|
||||
children[i] = childFromAlbum(ctx, al)
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
// toItemDate converts a string date in the formats 'YYYY-MM-DD', 'YYYY-MM' or 'YYYY' to an OS ItemDate
|
||||
func toItemDate(date string) responses.ItemDate {
|
||||
itemDate := responses.ItemDate{}
|
||||
|
@ -265,15 +247,7 @@ func toItemDate(date string) responses.ItemDate {
|
|||
return itemDate
|
||||
}
|
||||
|
||||
func buildItemGenres(genres model.Genres) []responses.ItemGenre {
|
||||
itemGenres := make([]responses.ItemGenre, len(genres))
|
||||
for i, g := range genres {
|
||||
itemGenres[i] = responses.ItemGenre{Name: g.Name}
|
||||
}
|
||||
return itemGenres
|
||||
}
|
||||
|
||||
func buildDiscSubtitles(_ context.Context, a model.Album) responses.DiscTitles {
|
||||
func buildDiscSubtitles(a model.Album) responses.DiscTitles {
|
||||
if len(a.Discs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
@ -287,14 +261,6 @@ func buildDiscSubtitles(_ context.Context, a model.Album) responses.DiscTitles {
|
|||
return discTitles
|
||||
}
|
||||
|
||||
func buildAlbumsID3(ctx context.Context, albums model.Albums) []responses.AlbumID3 {
|
||||
res := make([]responses.AlbumID3, len(albums))
|
||||
for i, album := range albums {
|
||||
res[i] = buildAlbumID3(ctx, album)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
|
||||
dir := responses.AlbumID3{}
|
||||
dir.Id = album.ID
|
||||
|
@ -310,8 +276,8 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
|
|||
}
|
||||
dir.Year = int32(album.MaxYear)
|
||||
dir.Genre = album.Genre
|
||||
dir.Genres = buildItemGenres(album.Genres)
|
||||
dir.DiscTitles = buildDiscSubtitles(ctx, album)
|
||||
dir.Genres = toItemGenres(album.Genres)
|
||||
dir.DiscTitles = buildDiscSubtitles(album)
|
||||
dir.UserRating = int32(album.Rating)
|
||||
if !album.CreatedAt.IsZero() {
|
||||
dir.Created = &album.CreatedAt
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
|
@ -40,7 +38,7 @@ var _ = Describe("helpers", func() {
|
|||
Describe("buildDiscTitles", func() {
|
||||
It("should return nil when album has no discs", func() {
|
||||
album := model.Album{}
|
||||
Expect(buildDiscSubtitles(context.Background(), album)).To(BeNil())
|
||||
Expect(buildDiscSubtitles(album)).To(BeNil())
|
||||
})
|
||||
|
||||
It("should return correct disc titles when album has discs with valid disc numbers", func() {
|
||||
|
@ -54,7 +52,7 @@ var _ = Describe("helpers", func() {
|
|||
{Disc: 1, Title: "Disc 1"},
|
||||
{Disc: 2, Title: "Disc 2"},
|
||||
}
|
||||
Expect(buildDiscSubtitles(context.Background(), album)).To(Equal(expected))
|
||||
Expect(buildDiscSubtitles(album)).To(Equal(expected))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -58,7 +59,7 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error)
|
|||
|
||||
playlist := responses.JukeboxPlaylist{
|
||||
JukeboxStatus: *deviceStatusToJukeboxStatus(status),
|
||||
Entry: childrenFromMediaFiles(ctx, mediafiles),
|
||||
Entry: slice.MapWithArg(mediafiles, ctx, childFromMediaFile),
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
|
|
44
server/subsonic/opensubsonic_test.go
Normal file
44
server/subsonic/opensubsonic_test.go
Normal file
|
@ -0,0 +1,44 @@
|
|||
package subsonic_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("GetOpenSubsonicExtensions", func() {
|
||||
var (
|
||||
router *subsonic.Router
|
||||
w *httptest.ResponseRecorder
|
||||
r *http.Request
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
|
||||
})
|
||||
|
||||
It("should return the correct OpenSubsonicExtensions", func() {
|
||||
router.ServeHTTP(w, r)
|
||||
|
||||
// Make sure the endpoint is public, by not passing any authentication
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
|
||||
var response responses.JsonWrapper
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||
HaveLen(3),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
|
||||
))
|
||||
})
|
||||
})
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) {
|
||||
|
@ -20,12 +21,10 @@ func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) {
|
|||
log.Error(r, err)
|
||||
return nil, err
|
||||
}
|
||||
playlists := make([]responses.Playlist, len(allPls))
|
||||
for i, p := range allPls {
|
||||
playlists[i] = *api.buildPlaylist(p)
|
||||
}
|
||||
response := newResponse()
|
||||
response.Playlists = &responses.Playlists{Playlist: playlists}
|
||||
response.Playlists = &responses.Playlists{
|
||||
Playlist: slice.Map(allPls, api.buildPlaylist),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
@ -51,7 +50,10 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso
|
|||
}
|
||||
|
||||
response := newResponse()
|
||||
response.Playlist = api.buildPlaylistWithSongs(ctx, pls)
|
||||
response.Playlist = &responses.PlaylistWithSongs{
|
||||
Playlist: api.buildPlaylist(*pls),
|
||||
}
|
||||
response.Playlist.Entry = slice.MapWithArg(pls.MediaFiles(), ctx, childFromMediaFile)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
@ -156,16 +158,8 @@ func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error)
|
|||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) buildPlaylistWithSongs(ctx context.Context, p *model.Playlist) *responses.PlaylistWithSongs {
|
||||
pls := &responses.PlaylistWithSongs{
|
||||
Playlist: *api.buildPlaylist(*p),
|
||||
}
|
||||
pls.Entry = childrenFromMediaFiles(ctx, p.MediaFiles())
|
||||
return pls
|
||||
}
|
||||
|
||||
func (api *Router) buildPlaylist(p model.Playlist) *responses.Playlist {
|
||||
pls := &responses.Playlist{}
|
||||
func (api *Router) buildPlaylist(p model.Playlist) responses.Playlist {
|
||||
pls := responses.Playlist{}
|
||||
pls.Id = p.ID
|
||||
pls.Name = p.Name
|
||||
pls.Comment = p.Comment
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"status": "ok",
|
||||
"version": "1.8.0",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.0.0",
|
||||
"openSubsonic": true,
|
||||
"artists": {
|
||||
"index": [
|
||||
{
|
||||
"name": "A",
|
||||
"artist": [
|
||||
{
|
||||
"id": "111",
|
||||
"name": "aaa",
|
||||
"albumCount": 2,
|
||||
"starred": "2016-03-02T20:30:00Z",
|
||||
"userRating": 3,
|
||||
"artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
|
||||
"musicBrainzId": "1234",
|
||||
"sortName": "sort name"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastModified": 1,
|
||||
"ignoredArticles": "A"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<artists lastModified="1" ignoredArticles="A">
|
||||
<index name="A">
|
||||
<artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" musicBrainzId="1234" sortName="sort name"></artist>
|
||||
</index>
|
||||
</artists>
|
||||
</subsonic-response>
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"status": "ok",
|
||||
"version": "1.8.0",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.0.0",
|
||||
"openSubsonic": true,
|
||||
"artists": {
|
||||
"index": [
|
||||
{
|
||||
"name": "A",
|
||||
"artist": [
|
||||
{
|
||||
"id": "111",
|
||||
"name": "aaa",
|
||||
"albumCount": 2,
|
||||
"starred": "2016-03-02T20:30:00Z",
|
||||
"userRating": 3,
|
||||
"artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
|
||||
"musicBrainzId": "",
|
||||
"sortName": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastModified": 1,
|
||||
"ignoredArticles": "A"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<artists lastModified="1" ignoredArticles="A">
|
||||
<index name="A">
|
||||
<artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" musicBrainzId="" sortName=""></artist>
|
||||
</index>
|
||||
</artists>
|
||||
</subsonic-response>
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"status": "ok",
|
||||
"version": "1.8.0",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.0.0",
|
||||
"openSubsonic": true,
|
||||
"artists": {
|
||||
"lastModified": 1,
|
||||
"ignoredArticles": "A"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true">
|
||||
<artists lastModified="1" ignoredArticles="A"></artists>
|
||||
</subsonic-response>
|
|
@ -35,7 +35,7 @@ type Subsonic struct {
|
|||
Genres *Genres `xml:"genres,omitempty" json:"genres,omitempty"`
|
||||
|
||||
// ID3
|
||||
Artist *Indexes `xml:"artists,omitempty" json:"artists,omitempty"`
|
||||
Artist *Artists `xml:"artists,omitempty" json:"artists,omitempty"`
|
||||
ArtistWithAlbumsID3 *ArtistWithAlbumsID3 `xml:"artist,omitempty" json:"artist,omitempty"`
|
||||
AlbumWithSongsID3 *AlbumWithSongsID3 `xml:"album,omitempty" json:"album,omitempty"`
|
||||
|
||||
|
@ -112,6 +112,17 @@ type Indexes struct {
|
|||
IgnoredArticles string `xml:"ignoredArticles,attr" json:"ignoredArticles"`
|
||||
}
|
||||
|
||||
type IndexID3 struct {
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Artists []ArtistID3 `xml:"artist" json:"artist"`
|
||||
}
|
||||
|
||||
type Artists struct {
|
||||
Index []IndexID3 `xml:"index" json:"index,omitempty"`
|
||||
LastModified int64 `xml:"lastModified,attr" json:"lastModified"`
|
||||
IgnoredArticles string `xml:"ignoredArticles,attr" json:"ignoredArticles"`
|
||||
}
|
||||
|
||||
type MediaType string
|
||||
|
||||
const (
|
||||
|
@ -207,8 +218,8 @@ type ArtistID3 struct {
|
|||
ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"`
|
||||
|
||||
// OpenSubsonic extensions
|
||||
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId,omitempty"`
|
||||
SortName string `xml:"sortName,attr,omitempty" json:"sortName,omitempty"`
|
||||
MusicBrainzId string `xml:"musicBrainzId,attr" json:"musicBrainzId"`
|
||||
SortName string `xml:"sortName,attr" json:"sortName"`
|
||||
}
|
||||
|
||||
type AlbumID3 struct {
|
||||
|
|
|
@ -120,6 +120,73 @@ var _ = Describe("Responses", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("Artist", func() {
|
||||
BeforeEach(func() {
|
||||
response.Artist = &Artists{LastModified: 1, IgnoredArticles: "A"}
|
||||
})
|
||||
|
||||
Context("without data", func() {
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with data", func() {
|
||||
BeforeEach(func() {
|
||||
artists := make([]ArtistID3, 1)
|
||||
t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)
|
||||
artists[0] = ArtistID3{
|
||||
Id: "111",
|
||||
Name: "aaa",
|
||||
Starred: &t,
|
||||
UserRating: 3,
|
||||
AlbumCount: 2,
|
||||
ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
|
||||
}
|
||||
index := make([]IndexID3, 1)
|
||||
index[0] = IndexID3{Name: "A", Artists: artists}
|
||||
response.Artist.Index = index
|
||||
})
|
||||
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with data and MBID and Sort Name", func() {
|
||||
BeforeEach(func() {
|
||||
artists := make([]ArtistID3, 1)
|
||||
t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)
|
||||
artists[0] = ArtistID3{
|
||||
Id: "111",
|
||||
Name: "aaa",
|
||||
Starred: &t,
|
||||
UserRating: 3,
|
||||
AlbumCount: 2,
|
||||
ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png",
|
||||
MusicBrainzId: "1234",
|
||||
SortName: "sort name",
|
||||
}
|
||||
index := make([]IndexID3, 1)
|
||||
index[0] = IndexID3{Name: "A", Artists: artists}
|
||||
response.Artist.Index = index
|
||||
})
|
||||
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Child", func() {
|
||||
Context("without data", func() {
|
||||
BeforeEach(func() {
|
||||
|
@ -466,7 +533,6 @@ var _ = Describe("Responses", func() {
|
|||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/navidrome/navidrome/server/public"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
|
@ -89,9 +90,8 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) {
|
|||
|
||||
response := newResponse()
|
||||
searchResult2 := &responses.SearchResult2{}
|
||||
searchResult2.Artist = make([]responses.Artist, len(as))
|
||||
for i, artist := range as {
|
||||
searchResult2.Artist[i] = responses.Artist{
|
||||
searchResult2.Artist = slice.Map(as, func(artist model.Artist) responses.Artist {
|
||||
a := responses.Artist{
|
||||
Id: artist.ID,
|
||||
Name: artist.Name,
|
||||
AlbumCount: int32(artist.AlbumCount),
|
||||
|
@ -100,11 +100,12 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) {
|
|||
ArtistImageUrl: public.ImageURL(r, artist.CoverArtID(), 600),
|
||||
}
|
||||
if artist.Starred {
|
||||
searchResult2.Artist[i].Starred = as[i].StarredAt
|
||||
a.Starred = artist.StarredAt
|
||||
}
|
||||
}
|
||||
searchResult2.Album = childrenFromAlbums(ctx, als)
|
||||
searchResult2.Song = childrenFromMediaFiles(ctx, mfs)
|
||||
return a
|
||||
})
|
||||
searchResult2.Album = slice.MapWithArg(als, ctx, childFromAlbum)
|
||||
searchResult2.Song = slice.MapWithArg(mfs, ctx, childFromMediaFile)
|
||||
response.SearchResult2 = searchResult2
|
||||
return response, nil
|
||||
}
|
||||
|
@ -119,12 +120,9 @@ func (api *Router) Search3(r *http.Request) (*responses.Subsonic, error) {
|
|||
|
||||
response := newResponse()
|
||||
searchResult3 := &responses.SearchResult3{}
|
||||
searchResult3.Artist = make([]responses.ArtistID3, len(as))
|
||||
for i, artist := range as {
|
||||
searchResult3.Artist[i] = toArtistID3(r, artist)
|
||||
}
|
||||
searchResult3.Album = buildAlbumsID3(ctx, als)
|
||||
searchResult3.Song = childrenFromMediaFiles(ctx, mfs)
|
||||
searchResult3.Artist = slice.MapWithArg(as, r, toArtistID3)
|
||||
searchResult3.Album = slice.MapWithArg(als, ctx, buildAlbumID3)
|
||||
searchResult3.Song = slice.MapWithArg(mfs, ctx, childFromMediaFile)
|
||||
response.SearchResult3 = searchResult3
|
||||
return response, nil
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
func (api *Router) GetShares(r *http.Request) (*responses.Subsonic, error) {
|
||||
|
@ -43,9 +44,9 @@ func (api *Router) buildShare(r *http.Request, share model.Share) responses.Shar
|
|||
resp.Description = share.Contents
|
||||
}
|
||||
if len(share.Albums) > 0 {
|
||||
resp.Entry = childrenFromAlbums(r.Context(), share.Albums)
|
||||
resp.Entry = slice.MapWithArg(share.Albums, r.Context(), childFromAlbum)
|
||||
} else {
|
||||
resp.Entry = childrenFromMediaFiles(r.Context(), share.Tracks)
|
||||
resp.Entry = slice.MapWithArg(share.Tracks, r.Context(), childFromMediaFile)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
|
274
ui/package-lock.json
generated
274
ui/package-lock.json
generated
|
@ -37,32 +37,32 @@
|
|||
"react-router-dom": "^5.3.4",
|
||||
"redux": "^4.2.0",
|
||||
"redux-saga": "^1.1.3",
|
||||
"uuid": "^10.0.0"
|
||||
"uuid": "^11.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/react-hooks": "^7.0.2",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^17.0.2",
|
||||
"@types/react-dom": "^17.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
||||
"@typescript-eslint/parser": "^6.12.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@vitest/coverage-v8": "^2.1.3",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"@vitest/coverage-v8": "^2.1.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||
"eslint-plugin-react": "^7.37.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"happy-dom": "^15.7.4",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"happy-dom": "^15.11.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"prettier": "^3.3.3",
|
||||
"ra-test": "^3.19.12",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.9",
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vitest": "^2.1.1"
|
||||
}
|
||||
|
@ -3013,11 +3013,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.1.tgz",
|
||||
"integrity": "sha512-mNYIiAuP4yJwV2zBRQCV7PHoQwbb6/8TfMpPcwSUzcSVDJHWOXt6hjNtIN1v5knDmimYnjJxKhsoVd4LVGIO+w==",
|
||||
"version": "6.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz",
|
||||
"integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "^4.4.0",
|
||||
"aria-query": "^5.0.0",
|
||||
|
@ -3251,13 +3250,12 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.6.tgz",
|
||||
"integrity": "sha512-/d7Rnj0/ExXDMcioS78/kf1lMzYk4BZV8MZGTBKzTGZ6/406ukkbYlIsZmMPhcR5KlkunDHQLrtAVmSq7r+mSw==",
|
||||
"version": "22.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
|
||||
"integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.19.2"
|
||||
"undici-types": "~6.19.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
|
@ -3574,11 +3572,10 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.2.tgz",
|
||||
"integrity": "sha512-hieu+o05v4glEBucTcKMK3dlES0OeJlD9YVOAPraVMOInBCwzumaIFiUjr4bHK7NPgnAHgiskUoceKercrN8vg==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.3.tgz",
|
||||
"integrity": "sha512-NooDe9GpHGqNns1i8XDERg0Vsg5SSYRhRxxyTGogUdkdNt47jal+fbuYi+Yfq6pzRCKXyoPcWisfxE6RIM3GKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.24.7",
|
||||
|
@ -3594,21 +3591,20 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.3.tgz",
|
||||
"integrity": "sha512-2OJ3c7UPoFSmBZwqD2VEkUw6A/tzPF0LmW0ZZhhB8PFxuc+9IBG/FaSM+RLEenc7ljzFvGN+G0nGQoZnh7sy2A==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.4.tgz",
|
||||
"integrity": "sha512-FPKQuJfR6VTfcNMcGpqInmtJuVXFSCd9HQltYncfR01AzXhLucMEtQ5SinPdZxsT5x/5BK7I5qFJ5/ApGCmyTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.3.0",
|
||||
"@bcoe/v8-coverage": "^0.2.3",
|
||||
"debug": "^4.3.6",
|
||||
"debug": "^4.3.7",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-lib-source-maps": "^5.0.6",
|
||||
"istanbul-reports": "^3.1.7",
|
||||
"magic-string": "^0.30.11",
|
||||
"magicast": "^0.3.4",
|
||||
"magic-string": "^0.30.12",
|
||||
"magicast": "^0.3.5",
|
||||
"std-env": "^3.7.0",
|
||||
"test-exclude": "^7.0.1",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
|
@ -3617,8 +3613,8 @@
|
|||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "2.1.3",
|
||||
"vitest": "2.1.3"
|
||||
"@vitest/browser": "2.1.4",
|
||||
"vitest": "2.1.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
|
@ -3627,15 +3623,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.3.tgz",
|
||||
"integrity": "sha512-SNBoPubeCJhZ48agjXruCI57DvxcsivVDdWz+SSsmjTT4QN/DfHk3zB/xKsJqMs26bLZ/pNRLnCf0j679i0uWQ==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.4.tgz",
|
||||
"integrity": "sha512-DOETT0Oh1avie/D/o2sgMHGrzYUFFo3zqESB2Hn70z6QB1HrS2IQ9z5DfyTqU8sg4Bpu13zZe9V4+UTNQlUeQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "2.1.3",
|
||||
"@vitest/utils": "2.1.3",
|
||||
"chai": "^5.1.1",
|
||||
"@vitest/spy": "2.1.4",
|
||||
"@vitest/utils": "2.1.4",
|
||||
"chai": "^5.1.2",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
|
@ -3643,22 +3638,20 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.3.tgz",
|
||||
"integrity": "sha512-eSpdY/eJDuOvuTA3ASzCjdithHa+GIF1L4PqtEELl6Qa3XafdMLBpBlZCIUCX2J+Q6sNmjmxtosAG62fK4BlqQ==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.4.tgz",
|
||||
"integrity": "sha512-Ky/O1Lc0QBbutJdW0rqLeFNbuLEyS+mIPiNdlVlp2/yhJ0SbyYqObS5IHdhferJud8MbbwMnexg4jordE5cCoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "2.1.3",
|
||||
"@vitest/spy": "2.1.4",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.11"
|
||||
"magic-string": "^0.30.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/spy": "2.1.3",
|
||||
"msw": "^2.3.5",
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
@ -3671,11 +3664,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.3.tgz",
|
||||
"integrity": "sha512-XH1XdtoLZCpqV59KRbPrIhFCOO0hErxrQCMcvnQete3Vibb9UeIOX02uFPfVn3Z9ZXsq78etlfyhnkmIZSzIwQ==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.4.tgz",
|
||||
"integrity": "sha512-L95zIAkEuTDbUX1IsjRl+vyBSLh3PwLLgKpghl37aCK9Jvw0iP+wKwIFhfjdUtA2myLgjrG6VU6JCFLv8q/3Ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
|
@ -3684,13 +3676,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.3.tgz",
|
||||
"integrity": "sha512-JGzpWqmFJ4fq5ZKHtVO3Xuy1iF2rHGV4d/pdzgkYHm1+gOzNZtqjvyiaDGJytRyMU54qkxpNzCx+PErzJ1/JqQ==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.4.tgz",
|
||||
"integrity": "sha512-sKRautINI9XICAMl2bjxQM8VfCMTB0EbsBc/EDFA57V6UQevEKY/TOPOF5nzcvCALltiLfXWbq4MaAwWx/YxIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "2.1.3",
|
||||
"@vitest/utils": "2.1.4",
|
||||
"pathe": "^1.1.2"
|
||||
},
|
||||
"funding": {
|
||||
|
@ -3698,14 +3689,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.3.tgz",
|
||||
"integrity": "sha512-qWC2mWc7VAXmjAkEKxrScWHWFyCQx/cmiZtuGqMi+WwqQJ2iURsVY4ZfAK6dVo6K2smKRU6l3BPwqEBvhnpQGg==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.4.tgz",
|
||||
"integrity": "sha512-3Kab14fn/5QZRog5BPj6Rs8dc4B+mim27XaKWFWHWA87R56AKjHTGcBFKpvZKDzC4u5Wd0w/qKsUIio3KzWW4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "2.1.3",
|
||||
"magic-string": "^0.30.11",
|
||||
"@vitest/pretty-format": "2.1.4",
|
||||
"magic-string": "^0.30.12",
|
||||
"pathe": "^1.1.2"
|
||||
},
|
||||
"funding": {
|
||||
|
@ -3713,27 +3703,25 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.3.tgz",
|
||||
"integrity": "sha512-Nb2UzbcUswzeSP7JksMDaqsI43Sj5+Kry6ry6jQJT4b5gAK+NS9NED6mDb8FlMRCX8m5guaHCDZmqYMMWRy5nQ==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.4.tgz",
|
||||
"integrity": "sha512-4JOxa+UAizJgpZfaCPKK2smq9d8mmjZVPMt2kOsg/R8QkoRzydHH1qHxIYNvr1zlEaFj4SXiaaJWxq/LPLKaLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyspy": "^3.0.0"
|
||||
"tinyspy": "^3.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.3.tgz",
|
||||
"integrity": "sha512-xpiVfDSg1RrYT0tX6czgerkpcKFmFOF/gCr30+Mve5V2kewCy4Prn1/NDMSRwaSmT7PRaOF83wu+bEtsY1wrvA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.4.tgz",
|
||||
"integrity": "sha512-MXDnZn0Awl2S86PSNIim5PWXgIAx8CIkzu35mBdSApUip6RFOGXBCf3YFyeEu8n1IHk4bWD46DeYFu9mQlFIRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "2.1.3",
|
||||
"loupe": "^3.1.1",
|
||||
"@vitest/pretty-format": "2.1.4",
|
||||
"loupe": "^3.1.2",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
|
@ -3988,7 +3976,6 @@
|
|||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
@ -4227,7 +4214,6 @@
|
|||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
|
@ -4283,11 +4269,10 @@
|
|||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz",
|
||||
"integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==",
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz",
|
||||
"integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
|
@ -4321,7 +4306,6 @@
|
|||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
|
||||
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
|
@ -4663,7 +4647,6 @@
|
|||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
|
@ -5251,13 +5234,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jsx-a11y": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.0.tgz",
|
||||
"integrity": "sha512-ySOHvXX8eSN6zz8Bywacm7CvGNhUtdjvqfQDVe6020TUK34Cywkw7m0KsCCk1Qtm9G1FayfTN1/7mMYnYO2Bhg==",
|
||||
"version": "6.10.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz",
|
||||
"integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aria-query": "~5.1.3",
|
||||
"aria-query": "^5.3.2",
|
||||
"array-includes": "^3.1.8",
|
||||
"array.prototype.flatmap": "^1.3.2",
|
||||
"ast-types-flow": "^0.0.8",
|
||||
|
@ -5265,14 +5247,13 @@
|
|||
"axobject-query": "^4.1.0",
|
||||
"damerau-levenshtein": "^1.0.8",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"es-iterator-helpers": "^1.0.19",
|
||||
"hasown": "^2.0.2",
|
||||
"jsx-ast-utils": "^3.3.5",
|
||||
"language-tags": "^1.0.9",
|
||||
"minimatch": "^3.1.2",
|
||||
"object.fromentries": "^2.0.8",
|
||||
"safe-regex-test": "^1.0.3",
|
||||
"string.prototype.includes": "^2.0.0"
|
||||
"string.prototype.includes": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
|
@ -5282,13 +5263,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
|
||||
"integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"deep-equal": "^2.0.5"
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": {
|
||||
|
@ -5316,18 +5296,17 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react": {
|
||||
"version": "7.37.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.1.tgz",
|
||||
"integrity": "sha512-xwTnwDqzbDRA8uJ7BMxPs/EXRB3i8ZfnOIp8BsxEQkT0nHPp+WWceqGgo6rKb9ctNi8GJLDT4Go5HAWELa/WMg==",
|
||||
"version": "7.37.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.2.tgz",
|
||||
"integrity": "sha512-EsTAnj9fLVr/GZleBLFbj/sSuXeWmp1eXIN60ceYnZveqEaUCyW4X+Vh4WTdUhCkW4xutXYqTXCUSyqD4rB75w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-includes": "^3.1.8",
|
||||
"array.prototype.findlast": "^1.2.5",
|
||||
"array.prototype.flatmap": "^1.3.2",
|
||||
"array.prototype.tosorted": "^1.1.4",
|
||||
"doctrine": "^2.1.0",
|
||||
"es-iterator-helpers": "^1.0.19",
|
||||
"es-iterator-helpers": "^1.1.0",
|
||||
"estraverse": "^5.3.0",
|
||||
"hasown": "^2.0.2",
|
||||
"jsx-ast-utils": "^2.4.1 || ^3.0.0",
|
||||
|
@ -5362,11 +5341,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-refresh": {
|
||||
"version": "0.4.12",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.12.tgz",
|
||||
"integrity": "sha512-9neVjoGv20FwYtCP6CB1dzR1vr57ZDNOXst21wd2xJ/cTlM2xLq0GWVlSNTdMn/4BtP6cHYBMCSp1wFBJ9jBsg==",
|
||||
"version": "0.4.14",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.14.tgz",
|
||||
"integrity": "sha512-aXvzCTK7ZBv1e7fahFuR3Z/fyQQSIQ711yPgYRj+Oj64tyTgO4iQIDmYXDBqvSWQ/FA4OSCsXOStlF+noU0/NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"eslint": ">=7"
|
||||
}
|
||||
|
@ -5560,7 +5538,6 @@
|
|||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
|
@ -5587,6 +5564,15 @@
|
|||
"integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz",
|
||||
"integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
@ -6091,11 +6077,10 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/happy-dom": {
|
||||
"version": "15.7.4",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.7.4.tgz",
|
||||
"integrity": "sha512-r1vadDYGMtsHAAsqhDuk4IpPvr6N8MGKy5ntBo7tSdim+pWDxus2PNqOcOt8LuDZ4t3KJHE+gCuzupcx/GKnyQ==",
|
||||
"version": "15.11.0",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.11.0.tgz",
|
||||
"integrity": "sha512-/zyxHbXriYJ8b9Urh43ILk/jd9tC07djURnJuAimJ3tJCOLOzOUp7dEHDwJOZyzROlrrooUhr/0INZIDBj1Bjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^4.5.0",
|
||||
"webidl-conversions": "^7.0.0",
|
||||
|
@ -7395,8 +7380,7 @@
|
|||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz",
|
||||
"integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
|
@ -7916,15 +7900,13 @@
|
|||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
|
||||
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
|
||||
"integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
|
@ -9988,7 +9970,6 @@
|
|||
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
|
||||
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
|
@ -10365,16 +10346,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz",
|
||||
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/value-equal": {
|
||||
|
@ -10384,11 +10364,10 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.9",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.9.tgz",
|
||||
"integrity": "sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg==",
|
||||
"version": "5.4.11",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz",
|
||||
"integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
@ -10444,14 +10423,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.3.tgz",
|
||||
"integrity": "sha512-I1JadzO+xYX887S39Do+paRePCKoiDrWRRjp9kkG5he0t7RXNvPAJPCQSJqbGN4uCrFFeS3Kj3sLqY8NMYBEdA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.4.tgz",
|
||||
"integrity": "sha512-kqa9v+oi4HwkG6g8ufRnb5AeplcRw8jUF6/7/Qz1qRQOXHImG8YnLbB+LLszENwFnoBl9xIf9nVdCFzNd7GQEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"debug": "^4.3.6",
|
||||
"debug": "^4.3.7",
|
||||
"pathe": "^1.1.2",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
|
@ -10497,30 +10475,30 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.3.tgz",
|
||||
"integrity": "sha512-Zrxbg/WiIvUP2uEzelDNTXmEMJXuzJ1kCpbDvaKByFA9MNeO95V+7r/3ti0qzJzrxdyuUw5VduN7k+D3VmVOSA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.4.tgz",
|
||||
"integrity": "sha512-eDjxbVAJw1UJJCHr5xr/xM86Zx+YxIEXGAR+bmnEID7z9qWfoxpHw0zdobz+TQAFOLT+nEXz3+gx6nUJ7RgmlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "2.1.3",
|
||||
"@vitest/mocker": "2.1.3",
|
||||
"@vitest/pretty-format": "^2.1.3",
|
||||
"@vitest/runner": "2.1.3",
|
||||
"@vitest/snapshot": "2.1.3",
|
||||
"@vitest/spy": "2.1.3",
|
||||
"@vitest/utils": "2.1.3",
|
||||
"chai": "^5.1.1",
|
||||
"debug": "^4.3.6",
|
||||
"magic-string": "^0.30.11",
|
||||
"@vitest/expect": "2.1.4",
|
||||
"@vitest/mocker": "2.1.4",
|
||||
"@vitest/pretty-format": "^2.1.4",
|
||||
"@vitest/runner": "2.1.4",
|
||||
"@vitest/snapshot": "2.1.4",
|
||||
"@vitest/spy": "2.1.4",
|
||||
"@vitest/utils": "2.1.4",
|
||||
"chai": "^5.1.2",
|
||||
"debug": "^4.3.7",
|
||||
"expect-type": "^1.1.0",
|
||||
"magic-string": "^0.30.12",
|
||||
"pathe": "^1.1.2",
|
||||
"std-env": "^3.7.0",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^0.3.0",
|
||||
"tinypool": "^1.0.0",
|
||||
"tinyexec": "^0.3.1",
|
||||
"tinypool": "^1.0.1",
|
||||
"tinyrainbow": "^1.2.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-node": "2.1.3",
|
||||
"vite-node": "2.1.4",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
|
@ -10535,8 +10513,8 @@
|
|||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"@vitest/browser": "2.1.3",
|
||||
"@vitest/ui": "2.1.3",
|
||||
"@vitest/browser": "2.1.4",
|
||||
"@vitest/ui": "2.1.4",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
|
|
|
@ -46,32 +46,32 @@
|
|||
"react-router-dom": "^5.3.4",
|
||||
"redux": "^4.2.0",
|
||||
"redux-saga": "^1.1.3",
|
||||
"uuid": "^10.0.0"
|
||||
"uuid": "^11.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/react-hooks": "^7.0.2",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^17.0.2",
|
||||
"@types/react-dom": "^17.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
||||
"@typescript-eslint/parser": "^6.12.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@vitest/coverage-v8": "^2.1.3",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"@vitest/coverage-v8": "^2.1.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||
"eslint-plugin-react": "^7.37.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"happy-dom": "^15.7.4",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"happy-dom": "^15.11.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"prettier": "^3.3.3",
|
||||
"ra-test": "^3.19.12",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.9",
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vitest": "^2.1.1"
|
||||
},
|
||||
|
|
43
utils/cache/file_caches.go
vendored
43
utils/cache/file_caches.go
vendored
|
@ -17,17 +17,59 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
// Item represents an item that can be cached. It must implement the Key method that returns a unique key for a
|
||||
// given item.
|
||||
type Item interface {
|
||||
Key() string
|
||||
}
|
||||
|
||||
// ReadFunc is a function that retrieves the data to be cached. It receives the Item to be cached and returns
|
||||
// an io.Reader with the data and an error.
|
||||
type ReadFunc func(ctx context.Context, item Item) (io.Reader, error)
|
||||
|
||||
// FileCache is designed to cache data on the filesystem to improve performance by avoiding repeated data
|
||||
// retrieval operations.
|
||||
//
|
||||
// Errors are handled gracefully. If the cache is not initialized or an error occurs during data
|
||||
// retrieval, it will log the error and proceed without caching.
|
||||
type FileCache interface {
|
||||
|
||||
// Get retrieves data from the cache. This method checks if the data is already cached. If it is, it
|
||||
// returns the cached data. If not, it retrieves the data using the provided getReader function and caches it.
|
||||
//
|
||||
// Example Usage:
|
||||
//
|
||||
// s, err := fc.Get(context.Background(), cacheKey("testKey"))
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// defer s.Close()
|
||||
//
|
||||
// data, err := io.ReadAll(s)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// fmt.Println(string(data))
|
||||
Get(ctx context.Context, item Item) (*CachedStream, error)
|
||||
|
||||
// Available checks if the cache is available
|
||||
Available(ctx context.Context) bool
|
||||
}
|
||||
|
||||
// NewFileCache creates a new FileCache. This function initializes the cache and starts it in the background.
|
||||
//
|
||||
// name: A string representing the name of the cache.
|
||||
// cacheSize: A string representing the maximum size of the cache (e.g., "1KB", "10MB").
|
||||
// cacheFolder: A string representing the folder where the cache files will be stored.
|
||||
// maxItems: An integer representing the maximum number of items the cache can hold.
|
||||
// getReader: A function of type ReadFunc that retrieves the data to be cached.
|
||||
//
|
||||
// Example Usage:
|
||||
//
|
||||
// fc := NewFileCache("exampleCache", "10MB", "cacheFolder", 100, func(ctx context.Context, item Item) (io.Reader, error) {
|
||||
// // Implement the logic to retrieve the data for the given item
|
||||
// return strings.NewReader(item.Key()), nil
|
||||
// })
|
||||
func NewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader ReadFunc) FileCache {
|
||||
fc := &fileCache{
|
||||
name: name,
|
||||
|
@ -150,6 +192,7 @@ func (fc *fileCache) Get(ctx context.Context, arg Item) (*CachedStream, error) {
|
|||
return &CachedStream{Reader: r, Cached: cached}, nil
|
||||
}
|
||||
|
||||
// CachedStream is a wrapper around an io.ReadCloser that allows reading from a cache.
|
||||
type CachedStream struct {
|
||||
io.Reader
|
||||
io.Seeker
|
||||
|
|
|
@ -15,6 +15,12 @@ func Map[T any, R any](t []T, mapFunc func(T) R) []R {
|
|||
return r
|
||||
}
|
||||
|
||||
func MapWithArg[I any, O any, A any](t []I, arg A, mapFunc func(A, I) O) []O {
|
||||
return Map(t, func(e I) O {
|
||||
return mapFunc(arg, e)
|
||||
})
|
||||
}
|
||||
|
||||
func Group[T any, K comparable](s []T, keyFunc func(T) K) map[K][]T {
|
||||
m := map[K][]T{}
|
||||
for _, item := range s {
|
||||
|
|
|
@ -33,6 +33,20 @@ var _ = Describe("Slice Utils", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("MapWithArg", func() {
|
||||
It("returns empty slice for an empty input", func() {
|
||||
mapFunc := func(a int, v int) string { return strconv.Itoa(a + v) }
|
||||
result := slice.MapWithArg([]int{}, 10, mapFunc)
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns a new slice with elements mapped", func() {
|
||||
mapFunc := func(a int, v int) string { return strconv.Itoa(a + v) }
|
||||
result := slice.MapWithArg([]int{1, 2, 3, 4}, 10, mapFunc)
|
||||
Expect(result).To(ConsistOf("11", "12", "13", "14"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Group", func() {
|
||||
It("returns empty map for an empty input", func() {
|
||||
keyFunc := func(v int) int { return v % 2 }
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
Const ForReading = 1
|
||||
Const ForWriting = 2
|
||||
|
||||
sSourceFilename = Wscript.Arguments(0)
|
||||
sTargetFilename = Wscript.Arguments(1)
|
||||
|
||||
Set oFSO = CreateObject("Scripting.FileSystemObject")
|
||||
Set oFile = oFSO.OpenTextFile(sSourceFilename, ForReading)
|
||||
sFileContent = oFile.ReadAll
|
||||
oFile.Close
|
||||
|
||||
sNewFileContent = Replace(sFileContent, "[MSI_PLACEHOLDER_SECTION]" & vbCrLf, "")
|
||||
If Not ( oFSO.FileExists(sTargetFilename) ) Then
|
||||
Set oFile = oFSO.CreateTextFile(sTargetFilename)
|
||||
oFile.Write sNewFileContent
|
||||
oFile.Close
|
||||
End If
|
Loading…
Add table
Add a link
Reference in a new issue