mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-01 03:18:13 -05:00
Compare commits
1 Commits
kwg43w-cod
...
codex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0f1ddecbe |
53
.github/copilot-instructions.md
vendored
53
.github/copilot-instructions.md
vendored
@@ -1,53 +0,0 @@
|
||||
# Navidrome Code Guidelines
|
||||
|
||||
This is a music streaming server written in Go with a React frontend. The application manages music libraries, provides streaming capabilities, and offers various features like artist information, artwork handling, and external service integrations.
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Backend (Go)
|
||||
- Follow standard Go conventions and idioms
|
||||
- Use context propagation for cancellation signals
|
||||
- Write unit tests for new functionality using Ginkgo/Gomega
|
||||
- Use mutex appropriately for concurrent operations
|
||||
- Implement interfaces for dependencies to facilitate testing
|
||||
|
||||
### Frontend (React)
|
||||
- Use functional components with hooks
|
||||
- Follow React best practices for state management
|
||||
- Implement PropTypes for component properties
|
||||
- Prefer using React-Admin and Material-UI components
|
||||
- Icons should be imported from `react-icons` only
|
||||
- Follow existing patterns for API interaction
|
||||
|
||||
## Repository Structure
|
||||
- `core/`: Server-side business logic (artwork handling, playback, etc.)
|
||||
- `ui/`: React frontend components
|
||||
- `model/`: Data models and repository interfaces
|
||||
- `server/`: API endpoints and server implementation
|
||||
- `utils/`: Shared utility functions
|
||||
- `persistence/`: Database access layer
|
||||
- `scanner/`: Music library scanning functionality
|
||||
|
||||
## Key Guidelines
|
||||
1. Maintain cache management patterns for performance
|
||||
2. Follow the existing concurrency patterns (mutex, atomic)
|
||||
3. Use the testing framework appropriately (Ginkgo/Gomega for Go)
|
||||
4. Keep UI components focused and reusable
|
||||
5. Document configuration options in code
|
||||
6. Consider performance implications when working with music libraries
|
||||
7. Follow existing error handling patterns
|
||||
8. Ensure compatibility with external services (LastFM, Spotify)
|
||||
|
||||
## Development Workflow
|
||||
- Test changes thoroughly, especially around concurrent operations
|
||||
- Validate both backend and frontend interactions
|
||||
- Consider how changes will affect user experience and performance
|
||||
- Test with different music library sizes and configurations
|
||||
- Before committing, ALWAYS run `make format lint test`, and make sure there are no issues
|
||||
|
||||
## Important commands
|
||||
- `make build`: Build the application
|
||||
- `make test`: Run Go tests
|
||||
- To run tests for a specific package, use `make test PKG=./pkgname/...`
|
||||
- `make lintall`: Run linters
|
||||
- `make format`: Format code
|
||||
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
version: latest
|
||||
problem-matchers: true
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -24,6 +24,4 @@ docker-compose.yml
|
||||
!contrib/docker-compose.yml
|
||||
binaries
|
||||
navidrome-master
|
||||
AGENTS.md
|
||||
*.exe
|
||||
bin/
|
||||
*.exe
|
||||
110
AGENTS.md
Normal file
110
AGENTS.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Testing Instructions
|
||||
|
||||
- **No implementation task is considered complete until it includes thorough, passing tests that cover the new or
|
||||
changed functionality. All new code must be accompanied by Ginkgo/Gomega tests, and PRs/commits without tests should
|
||||
be considered incomplete.**
|
||||
- All Go tests in this project **MUST** be written using the **Ginkgo v2** and **Gomega** frameworks.
|
||||
- To run all tests, use `make test`.
|
||||
- To run tests for a specific package, use `make test PKG=./pkgname/...`
|
||||
- Do not run tests in parallel
|
||||
- Don't use `--fail-on-pending`
|
||||
|
||||
## Mocking Convention
|
||||
|
||||
- Always try to use the mocks provided in the `tests` package before creating a new mock implementation.
|
||||
- Only create a new mock if the required functionality is not covered by the existing mocks in `tests`.
|
||||
- Never mock a real implementation when testing. Remember: there is no value in testing an interface, only the real implementation.
|
||||
|
||||
## Example
|
||||
|
||||
Every package that you write tests for, should have a `*_suite_test.go` file, to hook up the Ginkgo test suite. Example:
|
||||
```
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestCore(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Core Suite")
|
||||
}
|
||||
```
|
||||
Never put a `func Test*` in regular *_test.go files, only in `*_suite_test.go` files.
|
||||
|
||||
Refer to existing test suites for examples of proper setup and usage, such as the one defined in @core_suite_test.go
|
||||
|
||||
## Exceptions
|
||||
|
||||
There should be no exceptions to this rule. If you encounter tests written with the standard `testing` package or other frameworks, they should be refactored to use Ginkgo/Gomega. If you need a new mock, first confirm that it does not already exist in the `tests` package.
|
||||
|
||||
### Configuration
|
||||
|
||||
You can set config values in the BeforeEach/BeforeAll blocks. If you do so, remember to add `DeferCleanup(configtest.SetupConfig())` to reset the values. Example:
|
||||
|
||||
```go
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableDownloads = true
|
||||
})
|
||||
```
|
||||
|
||||
# Logging System Usage Guide
|
||||
|
||||
This project uses a custom logging system built on top of logrus, `log/log.go`. Follow these conventions for all logging:
|
||||
|
||||
## Logging API
|
||||
- Use the provided functions for logging at different levels:
|
||||
- `Error(...)`, `Warn(...)`, `Info(...)`, `Debug(...)`, `Trace(...)`, `Fatal(...)`
|
||||
- These functions accept flexible arguments:
|
||||
- The first argument can be a context (`context.Context`), an HTTP request, or `nil`.
|
||||
- The next argument is the log message (string or error).
|
||||
- Additional arguments are key-value pairs (e.g., `"key", value`).
|
||||
- If the last argument is an error, it is logged under the `error` key.
|
||||
|
||||
**Examples:**
|
||||
```go
|
||||
log.Error("A message")
|
||||
log.Error(ctx, "A message with context")
|
||||
log.Error("Failed to save", "id", 123, err)
|
||||
log.Info(req, "Request received", "user", userID)
|
||||
```
|
||||
|
||||
## Logging errors
|
||||
- You don't need to add "err" key when logging an error, it is automatically added.
|
||||
- Error must always be the last parameter in the log call.
|
||||
Examples:
|
||||
```go
|
||||
log.Error("Failed to save", "id", 123, err) // GOOD
|
||||
log.Error("Failed to save", "id", 123, "err", err) // BAD
|
||||
log.Error("Failed to save", err, "id", 123) // BAD
|
||||
```
|
||||
|
||||
## Context and Request Logging
|
||||
- If a context or HTTP request is passed as the first argument, any logger fields in the context are included in the log entry.
|
||||
- Use `log.NewContext(ctx, "key", value, ...)` to add fields to a context for logging.
|
||||
|
||||
## Log Levels
|
||||
- Set the global log level with `log.SetLevel(log.LevelInfo)` or `log.SetLevelString("info")`.
|
||||
- Per-path log levels can be set with `log.SetLogLevels(map[string]string{"path": "level"})`.
|
||||
- Use `log.IsGreaterOrEqualTo(level)` to check if a log level is enabled for the current code path.
|
||||
|
||||
## Source Line Logging
|
||||
- Enable source file/line logging with `log.SetLogSourceLine(true)`.
|
||||
|
||||
## Best Practices
|
||||
- Always use the logging API, never log directly with logrus or fmt.
|
||||
- Prefer structured logging (key-value pairs) for important data.
|
||||
- Use context/request logging for traceability in web handlers.
|
||||
- For tests, use Ginkgo/Gomega and set up a test logger as in `log/log_test.go`.
|
||||
|
||||
## See Also
|
||||
- `log/log.go` for implementation details
|
||||
- `log/log_test.go` for usage examples and test patterns
|
||||
12
Makefile
12
Makefile
@@ -19,7 +19,7 @@ CROSS_TAGLIB_VERSION ?= 2.0.2-1
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First Install dependencies and prepare development environment
|
||||
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
|
||||
@echo Downloading Node dependencies...
|
||||
@(cd ./ui && npm ci)
|
||||
.PHONY: setup
|
||||
@@ -46,15 +46,11 @@ testrace: ##@Development Run Go tests with race detector
|
||||
.PHONY: test
|
||||
|
||||
testall: testrace ##@Development Run Go and JS tests
|
||||
@(cd ./ui && npm run test)
|
||||
@(cd ./ui && npm run test:ci)
|
||||
.PHONY: testall
|
||||
|
||||
install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||
@PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.1.6)
|
||||
.PHONY: install-golangci-lint
|
||||
|
||||
lint: install-golangci-lint ##@Development Lint Go code
|
||||
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
|
||||
lint: ##@Development Lint Go code
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run -v --timeout 5m
|
||||
.PHONY: lint
|
||||
|
||||
lintall: lint ##@Development Lint Go and JS code
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
@@ -68,7 +70,7 @@ func runScanner(ctx context.Context) {
|
||||
ds := persistence.New(sqlDB)
|
||||
pls := core.NewPlaylists(ds)
|
||||
|
||||
progress, err := scanner.CallScan(ctx, ds, pls, fullScan)
|
||||
progress, err := scanner.CallScan(ctx, ds, artwork.NoopCacheWarmer(), pls, metrics.NewNoopInstance(), fullScan)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to scan", err)
|
||||
}
|
||||
|
||||
@@ -66,14 +66,12 @@ type configOptions struct {
|
||||
CoverArtPriority string
|
||||
CoverJpegQuality int
|
||||
ArtistArtPriority string
|
||||
LyricsPriority string
|
||||
EnableGravatar bool
|
||||
EnableFavourites bool
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultShareExpiration time.Duration
|
||||
DefaultDownloadableShare bool
|
||||
DefaultTheme string
|
||||
DefaultLanguage string
|
||||
@@ -87,23 +85,25 @@ type configOptions struct {
|
||||
PasswordEncryptionKey string
|
||||
ReverseProxyUserHeader string
|
||||
ReverseProxyWhitelist string
|
||||
HTTPSecurityHeaders secureOptions `json:",omitzero"`
|
||||
Prometheus prometheusOptions `json:",omitzero"`
|
||||
Scanner scannerOptions `json:",omitzero"`
|
||||
Jukebox jukeboxOptions `json:",omitzero"`
|
||||
Backup backupOptions `json:",omitzero"`
|
||||
PID pidOptions `json:",omitzero"`
|
||||
Inspect inspectOptions `json:",omitzero"`
|
||||
Subsonic subsonicOptions `json:",omitzero"`
|
||||
LastFM lastfmOptions `json:",omitzero"`
|
||||
Spotify spotifyOptions `json:",omitzero"`
|
||||
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
||||
Tags map[string]TagConf `json:",omitempty"`
|
||||
Agents string
|
||||
HTTPSecurityHeaders secureOptions
|
||||
Prometheus prometheusOptions
|
||||
Scanner scannerOptions
|
||||
Jukebox jukeboxOptions
|
||||
Backup backupOptions
|
||||
PID pidOptions
|
||||
Inspect inspectOptions
|
||||
Subsonic subsonicOptions
|
||||
LyricsPriority string
|
||||
|
||||
Agents string
|
||||
LastFM lastfmOptions
|
||||
Spotify spotifyOptions
|
||||
ListenBrainz listenBrainzOptions
|
||||
Tags map[string]TagConf
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
DevLogLevels map[string]string `json:",omitempty"`
|
||||
DevLogSourceLine bool
|
||||
DevLogLevels map[string]string
|
||||
DevEnableProfiler bool
|
||||
DevAutoCreateAdminPassword string
|
||||
DevAutoLoginUsername string
|
||||
@@ -111,7 +111,6 @@ type configOptions struct {
|
||||
DevActivityPanelUpdateRate time.Duration
|
||||
DevSidebarPlaylists bool
|
||||
DevShowArtistPage bool
|
||||
DevUIShowConfig bool
|
||||
DevOffsetOptimize int
|
||||
DevArtworkMaxRequests int
|
||||
DevArtworkThrottleBacklogLimit int
|
||||
@@ -134,7 +133,6 @@ type scannerOptions struct {
|
||||
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
|
||||
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
|
||||
FollowSymlinks bool // Whether to follow symlinks when scanning directories
|
||||
PurgeMissing string // Values: "never", "always", "full"
|
||||
}
|
||||
|
||||
type subsonicOptions struct {
|
||||
@@ -145,20 +143,19 @@ type subsonicOptions struct {
|
||||
}
|
||||
|
||||
type TagConf struct {
|
||||
Ignore bool `yaml:"ignore" json:",omitempty"`
|
||||
Aliases []string `yaml:"aliases" json:",omitempty"`
|
||||
Type string `yaml:"type" json:",omitempty"`
|
||||
MaxLength int `yaml:"maxLength" json:",omitempty"`
|
||||
Split []string `yaml:"split" json:",omitempty"`
|
||||
Album bool `yaml:"album" json:",omitempty"`
|
||||
Ignore bool `yaml:"ignore"`
|
||||
Aliases []string `yaml:"aliases"`
|
||||
Type string `yaml:"type"`
|
||||
MaxLength int `yaml:"maxLength"`
|
||||
Split []string `yaml:"split"`
|
||||
Album bool `yaml:"album"`
|
||||
}
|
||||
|
||||
type lastfmOptions struct {
|
||||
Enabled bool
|
||||
ApiKey string
|
||||
Secret string
|
||||
Language string
|
||||
ScrobbleFirstArtistOnly bool
|
||||
Enabled bool
|
||||
ApiKey string
|
||||
Secret string
|
||||
Language string
|
||||
}
|
||||
|
||||
type spotifyOptions struct {
|
||||
@@ -279,7 +276,6 @@ func Load(noConfigDump bool) {
|
||||
validateScanSchedule,
|
||||
validateBackupSchedule,
|
||||
validatePlaylistsPath,
|
||||
validatePurgeMissingOption,
|
||||
)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
@@ -385,24 +381,6 @@ func validatePlaylistsPath() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePurgeMissingOption() error {
|
||||
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
|
||||
valid := false
|
||||
for _, v := range allowedValues {
|
||||
if v == Server.Scanner.PurgeMissing {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
err := fmt.Errorf("Invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
|
||||
log.Error(err.Error())
|
||||
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateScanSchedule() error {
|
||||
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
|
||||
Server.Scanner.Schedule = ""
|
||||
@@ -442,7 +420,7 @@ func AddHook(hook func()) {
|
||||
hooks = append(hooks, hook)
|
||||
}
|
||||
|
||||
func setViperDefaults() {
|
||||
func init() {
|
||||
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
|
||||
viper.SetDefault("cachefolder", "")
|
||||
viper.SetDefault("datafolder", ".")
|
||||
@@ -478,7 +456,8 @@ func setViperDefaults() {
|
||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
||||
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
||||
viper.SetDefault("ffmpegpath", "")
|
||||
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
|
||||
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s")
|
||||
|
||||
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||
viper.SetDefault("coverjpegquality", 75)
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
@@ -493,7 +472,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("enablesharing", false)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||
viper.SetDefault("defaultdownloadableshare", false)
|
||||
viper.SetDefault("gatrackingid", "")
|
||||
viper.SetDefault("enableinsightscollector", true)
|
||||
@@ -501,15 +479,19 @@ func setViperDefaults() {
|
||||
viper.SetDefault("authrequestlimit", 5)
|
||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||
viper.SetDefault("passwordencryptionkey", "")
|
||||
|
||||
viper.SetDefault("reverseproxyuserheader", "Remote-User")
|
||||
viper.SetDefault("reverseproxywhitelist", "")
|
||||
|
||||
viper.SetDefault("prometheus.enabled", false)
|
||||
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
|
||||
viper.SetDefault("prometheus.password", "")
|
||||
|
||||
viper.SetDefault("jukebox.enabled", false)
|
||||
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
|
||||
viper.SetDefault("jukebox.default", "")
|
||||
viper.SetDefault("jukebox.adminonly", true)
|
||||
|
||||
viper.SetDefault("scanner.enabled", true)
|
||||
viper.SetDefault("scanner.schedule", "0")
|
||||
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
|
||||
@@ -519,32 +501,39 @@ func setViperDefaults() {
|
||||
viper.SetDefault("scanner.genreseparators", "")
|
||||
viper.SetDefault("scanner.groupalbumreleases", false)
|
||||
viper.SetDefault("scanner.followsymlinks", true)
|
||||
viper.SetDefault("scanner.purgemissing", "never")
|
||||
|
||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||
viper.SetDefault("subsonic.artistparticipations", false)
|
||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub")
|
||||
|
||||
viper.SetDefault("agents", "lastfm,spotify")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", "en")
|
||||
viper.SetDefault("lastfm.apikey", "")
|
||||
viper.SetDefault("lastfm.secret", "")
|
||||
viper.SetDefault("lastfm.scrobblefirstartistonly", false)
|
||||
viper.SetDefault("spotify.id", "")
|
||||
viper.SetDefault("spotify.secret", "")
|
||||
viper.SetDefault("listenbrainz.enabled", true)
|
||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
||||
|
||||
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
|
||||
|
||||
viper.SetDefault("backup.path", "")
|
||||
viper.SetDefault("backup.schedule", "")
|
||||
viper.SetDefault("backup.count", 0)
|
||||
|
||||
viper.SetDefault("pid.track", consts.DefaultTrackPID)
|
||||
viper.SetDefault("pid.album", consts.DefaultAlbumPID)
|
||||
|
||||
viper.SetDefault("inspect.enabled", true)
|
||||
viper.SetDefault("inspect.maxrequests", 1)
|
||||
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
|
||||
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
viper.SetDefault("devenableprofiler", false)
|
||||
viper.SetDefault("devautocreateadminpassword", "")
|
||||
@@ -553,7 +542,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
|
||||
viper.SetDefault("devsidebarplaylists", true)
|
||||
viper.SetDefault("devshowartistpage", true)
|
||||
viper.SetDefault("devuishowconfig", true)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
|
||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||
@@ -566,10 +554,6 @@ func setViperDefaults() {
|
||||
viper.SetDefault("devenableplayerinsights", true)
|
||||
}
|
||||
|
||||
func init() {
|
||||
setViperDefaults()
|
||||
}
|
||||
|
||||
func InitConfig(cfgFile string) {
|
||||
codecRegistry := viper.NewCodecRegistry()
|
||||
_ = codecRegistry.RegisterCodec("ini", ini.Codec{})
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
. "github.com/navidrome/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/spf13/viper"
|
||||
@@ -20,10 +20,9 @@ var _ = Describe("Configuration", func() {
|
||||
BeforeEach(func() {
|
||||
// Reset viper configuration
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
ResetConf()
|
||||
})
|
||||
|
||||
DescribeTable("should load configuration from",
|
||||
@@ -31,17 +30,17 @@ var _ = Describe("Configuration", func() {
|
||||
filename := filepath.Join("testdata", "cfg."+format)
|
||||
|
||||
// Initialize config with the test file
|
||||
conf.InitConfig(filename)
|
||||
InitConfig(filename)
|
||||
// Load the configuration (with noConfigDump=true)
|
||||
conf.Load(true)
|
||||
Load(true)
|
||||
|
||||
// Execute the format-specific assertions
|
||||
Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
|
||||
Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format))
|
||||
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
|
||||
Expect(Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
|
||||
Expect(Server.UIWelcomeMessage).To(Equal("Welcome " + format))
|
||||
Expect(Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
|
||||
|
||||
// The config file used should be the one we created
|
||||
Expect(conf.Server.ConfigFile).To(Equal(filename))
|
||||
Expect(Server.ConfigFile).To(Equal(filename))
|
||||
},
|
||||
Entry("TOML format", "toml"),
|
||||
Entry("YAML format", "yaml"),
|
||||
|
||||
@@ -3,5 +3,3 @@ package conf
|
||||
func ResetConf() {
|
||||
Server = &configOptions{}
|
||||
}
|
||||
|
||||
var SetViperDefaults = setViperDefaults
|
||||
|
||||
@@ -14,9 +14,6 @@ const (
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
FullScanAfterMigrationFlagKey = "FullScanAfterMigration"
|
||||
LastScanErrorKey = "LastScanError"
|
||||
LastScanTypeKey = "LastScanType"
|
||||
LastScanStartTimeKey = "LastScanStartTime"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
|
||||
@@ -115,12 +112,6 @@ const (
|
||||
InsightsInitialDelay = 30 * time.Minute
|
||||
)
|
||||
|
||||
const (
|
||||
PurgeMissingNever = "never"
|
||||
PurgeMissingAlways = "always"
|
||||
PurgeMissingFull = "full"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultDownsamplingFormat = "opus"
|
||||
DefaultTranscodings = []struct {
|
||||
|
||||
@@ -45,7 +45,7 @@ func createAgents(ds model.DataStore) *Agents {
|
||||
continue
|
||||
}
|
||||
enabled = append(enabled, name)
|
||||
res = append(res, init(ds))
|
||||
res = append(res, agent)
|
||||
}
|
||||
log.Debug("List of agents enabled", "names", enabled)
|
||||
|
||||
|
||||
@@ -279,13 +279,6 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
|
||||
return t.Track, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile) string {
|
||||
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[model.RoleArtist]) > 0 {
|
||||
return track.Participants[model.RoleArtist][0].Name
|
||||
}
|
||||
return track.Artist
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
|
||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
||||
if err != nil || sk == "" {
|
||||
@@ -293,7 +286,7 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
|
||||
}
|
||||
|
||||
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
|
||||
artist: l.getArtistForScrobble(track),
|
||||
artist: track.Artist,
|
||||
track: track.Title,
|
||||
album: track.Album,
|
||||
trackNumber: track.TrackNumber,
|
||||
@@ -319,7 +312,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
|
||||
return nil
|
||||
}
|
||||
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
|
||||
artist: l.getArtistForScrobble(&s.MediaFile),
|
||||
artist: s.Artist,
|
||||
track: s.Title,
|
||||
album: s.Album,
|
||||
trackNumber: s.TrackNumber,
|
||||
@@ -351,22 +344,10 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*lastfmAgent)(nil)
|
||||
// See https://go.dev/doc/faq#nil_error
|
||||
a := lastFMConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
// Same as above - this is a workaround for the fact that a (Scrobbler)(nil) is not the same as a (*lastfmAgent)(nil)
|
||||
// See https://go.dev/doc/faq#nil_error
|
||||
a := lastFMConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
return lastFMConstructor(ds)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,12 +196,6 @@ var _ = Describe("lastfmAgent", func() {
|
||||
TrackNumber: 1,
|
||||
Duration: 180,
|
||||
MbzRecordingID: "mbz-123",
|
||||
Participants: map[model.Role]model.ParticipantList{
|
||||
model.RoleArtist: []model.Participant{
|
||||
{Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
|
||||
{Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -253,23 +247,6 @@ var _ = Describe("lastfmAgent", func() {
|
||||
Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
|
||||
})
|
||||
|
||||
When("ScrobbleFirstArtistOnly is true", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
|
||||
})
|
||||
|
||||
It("uses only the first artist", func() {
|
||||
ts := time.Now()
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
sentParams := httpClient.SavedRequest.URL.Query()
|
||||
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
||||
})
|
||||
})
|
||||
|
||||
It("skips songs with less than 31 seconds", func() {
|
||||
track.Duration = 29
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
|
||||
|
||||
@@ -98,7 +98,7 @@ func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
||||
return a.zipMediaFiles(ctx, id, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
|
||||
return a.zipMediaFiles(ctx, id, s.Format, s.MaxBitRate, out, s.Tracks)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
@@ -109,40 +109,15 @@ func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bi
|
||||
}
|
||||
mfs := pls.MediaFiles()
|
||||
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
||||
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true)
|
||||
return a.zipMediaFiles(ctx, id, format, bitrate, out, mfs)
|
||||
}
|
||||
|
||||
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error {
|
||||
func (a *archiver) zipMediaFiles(ctx context.Context, id string, format string, bitrate int, out io.Writer, mfs model.MediaFiles) error {
|
||||
z := createZipWriter(out, format, bitrate)
|
||||
|
||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||
for idx, mf := range mfs {
|
||||
file := a.playlistFilename(mf, format, idx)
|
||||
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
|
||||
mf.Path = file
|
||||
zippedMfs[idx] = mf
|
||||
}
|
||||
|
||||
// Add M3U file if requested
|
||||
if addM3U && len(zippedMfs) > 0 {
|
||||
plsName := sanitizeName(name)
|
||||
w, err := z.CreateHeader(&zip.FileHeader{
|
||||
Name: plsName + ".m3u",
|
||||
Modified: mfs[0].UpdatedAt,
|
||||
Method: zip.Store,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error creating playlist zip entry", err)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write([]byte(zippedMfs.ToM3U8(plsName, false)))
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error writing m3u in zip", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err := z.Close()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error closing zip file", "id", id, err)
|
||||
|
||||
@@ -145,21 +145,9 @@ var _ = Describe("Archiver", func() {
|
||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
Expect(len(zr.File)).To(Equal(3))
|
||||
Expect(len(zr.File)).To(Equal(2))
|
||||
Expect(zr.File[0].Name).To(Equal("01 - AC_DC - track1.mp3"))
|
||||
Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
|
||||
Expect(zr.File[2].Name).To(Equal("Test Playlist.m3u"))
|
||||
|
||||
// Verify M3U content
|
||||
m3uFile, err := zr.File[2].Open()
|
||||
Expect(err).To(BeNil())
|
||||
defer m3uFile.Close()
|
||||
|
||||
m3uContent, err := io.ReadAll(m3uFile)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
expectedM3U := "#EXTM3U\n#PLAYLIST:Test Playlist\n#EXTINF:0,AC/DC - track1\n01 - AC_DC - track1.mp3\n#EXTINF:0,Artist 2 - track2\n02 - Artist 2 - track2.mp3\n"
|
||||
Expect(string(m3uContent)).To(Equal(expectedM3U))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -115,7 +115,7 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
|
||||
} else {
|
||||
switch artID.Kind {
|
||||
case model.KindArtistArtwork:
|
||||
artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider)
|
||||
artReader, err = newArtistReader(ctx, a, artID, a.provider)
|
||||
case model.KindAlbumArtwork:
|
||||
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
|
||||
case model.KindMediaFileArtwork:
|
||||
|
||||
@@ -4,11 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
@@ -19,11 +15,11 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Artwork", func() {
|
||||
// TODO Fix tests
|
||||
var _ = XDescribe("Artwork", func() {
|
||||
var aw *artwork
|
||||
var ds model.DataStore
|
||||
var ffmpeg *tests.MockFFmpeg
|
||||
var folderRepo *fakeFolderRepo
|
||||
ctx := log.NewContext(context.TODO())
|
||||
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
|
||||
var arMultipleCovers model.Artist
|
||||
@@ -34,21 +30,20 @@ var _ = Describe("Artwork", func() {
|
||||
conf.Server.ImageCacheSize = "0" // Disable cache
|
||||
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
|
||||
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
MockedFolder: folderRepo,
|
||||
}
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
|
||||
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
|
||||
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}}
|
||||
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3"}
|
||||
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3"}
|
||||
//alOnlyExternal = model.Album{ID: "444", Name: "Only external", ImageFiles: "tests/fixtures/artist/an-album/front.png"}
|
||||
//alExternalNotFound = model.Album{ID: "555", Name: "External not found", ImageFiles: "tests/fixtures/NON_EXISTENT.png"}
|
||||
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
|
||||
alMultipleCovers = model.Album{
|
||||
ID: "666",
|
||||
Name: "All options",
|
||||
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
|
||||
FolderIDs: []string{"f1"},
|
||||
ID: "666",
|
||||
Name: "All options",
|
||||
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
|
||||
//Paths: []string{"tests/fixtures/artist/an-album"},
|
||||
//ImageFiles: "tests/fixtures/artist/an-album/cover.jpg" + consts.Zwsp +
|
||||
// "tests/fixtures/artist/an-album/front.png" + consts.Zwsp +
|
||||
// "tests/fixtures/artist/an-album/artist.png",
|
||||
AlbumArtistID: "777",
|
||||
}
|
||||
mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"}
|
||||
@@ -70,7 +65,6 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
Context("Embed images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = nil
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alEmbedNotFound,
|
||||
@@ -93,17 +87,12 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
Context("External images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyExternal,
|
||||
alExternalNotFound,
|
||||
})
|
||||
})
|
||||
It("returns external cover", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"front.png"},
|
||||
}}
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
@@ -111,7 +100,6 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png"))
|
||||
})
|
||||
It("returns ErrUnavailable if external file is not available", func() {
|
||||
folderRepo.result = []model.Folder{}
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, _, err = aw.Reader(ctx)
|
||||
@@ -120,10 +108,6 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg", "front.png", "artist.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
@@ -146,10 +130,6 @@ var _ = Describe("Artwork", func() {
|
||||
Describe("artistArtworkReader", func() {
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"artist.png"},
|
||||
}}
|
||||
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
|
||||
arMultipleCovers,
|
||||
})
|
||||
@@ -163,7 +143,7 @@ var _ = Describe("Artwork", func() {
|
||||
DescribeTable("ArtistArtPriority",
|
||||
func(priority string, expected string) {
|
||||
conf.Server.ArtistArtPriority = priority
|
||||
aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
aw, err := newArtistReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -177,16 +157,12 @@ var _ = Describe("Artwork", func() {
|
||||
Describe("mediafileArtworkReader", func() {
|
||||
Context("ID not found", func() {
|
||||
It("returns ErrNotFound if mediafile is not in the DB", func() {
|
||||
_, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND"))
|
||||
_, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
Context("Embed images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"front.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alOnlyExternal,
|
||||
@@ -209,17 +185,11 @@ var _ = Describe("Artwork", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
data, _ := io.ReadAll(r)
|
||||
Expect(data).ToNot(BeEmpty())
|
||||
Expect(io.ReadAll(r)).To(Equal([]byte("content from ffmpeg")))
|
||||
Expect(path).To(Equal("tests/fixtures/test.ogg"))
|
||||
})
|
||||
It("returns album cover if cannot read embed artwork", func() {
|
||||
// Force fromTag to fail
|
||||
mfCorruptedCover.Path = "tests/fixtures/DOES_NOT_EXIST.ogg"
|
||||
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfCorruptedCover)).To(Succeed())
|
||||
// Simulate ffmpeg error
|
||||
ffmpeg.Error = errors.New("not available")
|
||||
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
@@ -237,10 +207,6 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
Describe("resizedArtworkReader", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg", "front.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
@@ -275,13 +241,12 @@ var _ = Describe("Artwork", func() {
|
||||
DescribeTable("resize",
|
||||
func(format string, landscape bool, size int) {
|
||||
coverFileName := "cover." + format
|
||||
dirName := createImage(format, landscape, size)
|
||||
//dirName := createImage(format, landscape, size)
|
||||
alCover = model.Album{
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
FolderIDs: []string{"tmp"},
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
//ImageFiles: filepath.Join(dirName, coverFileName),
|
||||
}
|
||||
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alCover,
|
||||
})
|
||||
@@ -305,24 +270,24 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
})
|
||||
|
||||
func createImage(format string, landscape bool, size int) string {
|
||||
var img image.Image
|
||||
|
||||
if landscape {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size, size/2))
|
||||
} else {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size/2, size))
|
||||
}
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
|
||||
defer f.Close()
|
||||
switch format {
|
||||
case "png":
|
||||
_ = png.Encode(f, img)
|
||||
case "jpg":
|
||||
_ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
|
||||
}
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
//func createImage(format string, landscape bool, size int) string {
|
||||
// var img image.Image
|
||||
//
|
||||
// if landscape {
|
||||
// img = image.NewRGBA(image.Rect(0, 0, size, size/2))
|
||||
// } else {
|
||||
// img = image.NewRGBA(image.Rect(0, 0, size/2, size))
|
||||
// }
|
||||
//
|
||||
// tmpDir := GinkgoT().TempDir()
|
||||
// f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
|
||||
// defer f.Close()
|
||||
// switch format {
|
||||
// case "png":
|
||||
// _ = png.Encode(f, img)
|
||||
// case "jpg":
|
||||
// _ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
|
||||
// }
|
||||
//
|
||||
// return tmpDir
|
||||
//}
|
||||
|
||||
@@ -31,12 +31,6 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
// If the file cache is disabled, return a NOOP implementation
|
||||
if cache.Disabled(context.Background()) {
|
||||
log.Debug("Image cache disabled. Cache warmer will not run")
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
a := &cacheWarmer{
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
@@ -59,9 +53,6 @@ type cacheWarmer struct {
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
||||
if a.cache.Disabled(context.Background()) {
|
||||
return
|
||||
}
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
a.buffer[artID] = struct{}{}
|
||||
@@ -83,17 +74,6 @@ func (a *cacheWarmer) run(ctx context.Context) {
|
||||
break
|
||||
}
|
||||
|
||||
if a.cache.Disabled(ctx) {
|
||||
a.mutex.Lock()
|
||||
pending := len(a.buffer)
|
||||
a.buffer = make(map[model.ArtworkID]struct{})
|
||||
a.mutex.Unlock()
|
||||
if pending > 0 {
|
||||
log.Trace(ctx, "Cache disabled, discarding precache buffer", "bufferLen", pending)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If cache not available, keep waiting
|
||||
if !a.cache.Available(ctx) {
|
||||
if len(a.buffer) > 0 {
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("CacheWarmer", func() {
|
||||
var (
|
||||
fc *mockFileCache
|
||||
aw *mockArtwork
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
fc = &mockFileCache{}
|
||||
aw = &mockArtwork{}
|
||||
})
|
||||
|
||||
Context("initialization", func() {
|
||||
It("returns noop when cache is disabled", func() {
|
||||
fc.SetDisabled(true)
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns noop when ImageCacheSize is 0", func() {
|
||||
conf.Server.ImageCacheSize = "0"
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns noop when EnableArtworkPrecache is false", func() {
|
||||
conf.Server.EnableArtworkPrecache = false
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns real implementation when properly configured", func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*cacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("buffer management", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("drops buffered items when cache becomes disabled", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-test"))
|
||||
fc.SetDisabled(true)
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("adds multiple items to buffer", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-2"))
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
Expect(len(cw.buffer)).To(Equal(2))
|
||||
})
|
||||
|
||||
It("deduplicates items in buffer", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
Expect(len(cw.buffer)).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
Context("error handling", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("continues processing after artwork retrieval error", func() {
|
||||
aw.err = errors.New("artwork error")
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-error"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("continues processing after cache error", func() {
|
||||
fc.err = errors.New("cache error")
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-error"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Context("background processing", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("processes items in batches", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
for i := 0; i < 5; i++ {
|
||||
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
|
||||
}
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("wakes up on new items", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
|
||||
// Add first batch
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
|
||||
// Add second batch
|
||||
cw.PreCache(model.MustParseArtworkID("al-2"))
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockArtwork struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
if m.err != nil {
|
||||
return nil, time.Time{}, m.err
|
||||
}
|
||||
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
|
||||
}
|
||||
|
||||
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
return m.Get(ctx, model.ArtworkID{}, size, square)
|
||||
}
|
||||
|
||||
type mockFileCache struct {
|
||||
disabled atomic.Bool
|
||||
ready atomic.Bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Get(ctx context.Context, item cache.Item) (*cache.CachedStream, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return &cache.CachedStream{Reader: io.NopCloser(strings.NewReader("cached"))}, nil
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Available(ctx context.Context) bool {
|
||||
return f.ready.Load() && !f.disabled.Load()
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Disabled(ctx context.Context) bool {
|
||||
return f.disabled.Load()
|
||||
}
|
||||
|
||||
func (f *mockFileCache) SetDisabled(v bool) {
|
||||
f.disabled.Store(v)
|
||||
f.ready.Store(true)
|
||||
}
|
||||
@@ -20,12 +20,6 @@ import (
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxArtistFolderTraversalDepth defines how many directory levels to search
|
||||
// when looking for artist images (artist folder + parent directories)
|
||||
maxArtistFolderTraversalDepth = 3
|
||||
)
|
||||
|
||||
type artistReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
@@ -35,7 +29,7 @@ type artistReader struct {
|
||||
imgFiles []string
|
||||
}
|
||||
|
||||
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||
func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||
ar, err := artwork.ds.Artist(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -114,52 +108,36 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
|
||||
|
||||
func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
current := artistFolder
|
||||
for i := 0; i < maxArtistFolderTraversalDepth; i++ {
|
||||
if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil {
|
||||
return reader, path, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories`, pattern, artistFolder)
|
||||
}
|
||||
}
|
||||
|
||||
func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadCloser, string, error) {
|
||||
log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", folder)
|
||||
fsys := os.DirFS(folder)
|
||||
matches, err := fs.Glob(fsys, pattern)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", folder, err)
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
for _, m := range matches {
|
||||
if !model.IsImageFile(m) {
|
||||
continue
|
||||
}
|
||||
filePath := filepath.Join(folder, m)
|
||||
f, err := os.Open(filePath)
|
||||
fsys := os.DirFS(artistFolder)
|
||||
matches, err := fs.Glob(fsys, pattern)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
|
||||
continue
|
||||
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", artistFolder)
|
||||
return nil, "", err
|
||||
}
|
||||
return f, filePath, nil
|
||||
if len(matches) == 0 {
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, artistFolder)
|
||||
}
|
||||
for _, m := range matches {
|
||||
filePath := filepath.Join(artistFolder, m)
|
||||
if !model.IsImageFile(m) {
|
||||
continue
|
||||
}
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
|
||||
return nil, "", err
|
||||
}
|
||||
return f, filePath, nil
|
||||
}
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, folder)
|
||||
}
|
||||
|
||||
func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) {
|
||||
if len(albums) == 0 {
|
||||
return "", time.Time{}, nil
|
||||
}
|
||||
libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library - for now! TODO: Support multiple libraries
|
||||
libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library
|
||||
|
||||
folderPath := str.LongestCommonPrefix(paths)
|
||||
if !strings.HasSuffix(folderPath, string(filepath.Separator)) {
|
||||
|
||||
@@ -3,8 +3,6 @@ package artwork
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
@@ -14,7 +12,7 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("artistArtworkReader", func() {
|
||||
var _ = Describe("artistReader", func() {
|
||||
var _ = Describe("loadArtistFolder", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
@@ -110,254 +108,6 @@ var _ = Describe("artistArtworkReader", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("fromArtistFolder", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
tempDir string
|
||||
testFunc sourceFunc
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
tempDir = GinkgoT().TempDir()
|
||||
})
|
||||
|
||||
When("artist folder contains matching image", func() {
|
||||
BeforeEach(func() {
|
||||
// Create test structure: /temp/artist/artist.jpg
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
artistImagePath := filepath.Join(artistDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds and returns the image", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("artist.jpg"))
|
||||
|
||||
// Verify we can read the content
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("fake image data"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("artist folder is empty but parent contains image", func() {
|
||||
BeforeEach(func() {
|
||||
// Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/
|
||||
parentDir := filepath.Join(tempDir, "parent")
|
||||
artistDir := filepath.Join(parentDir, "artist")
|
||||
albumDir := filepath.Join(artistDir, "album")
|
||||
Expect(os.MkdirAll(albumDir, 0755)).To(Succeed())
|
||||
|
||||
// Put artist image in parent directory
|
||||
artistImagePath := filepath.Join(parentDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds image in parent directory", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("parent" + string(filepath.Separator) + "artist.jpg"))
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("parent image"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("image is two levels up", func() {
|
||||
BeforeEach(func() {
|
||||
// Create test structure: /temp/grandparent/artist.jpg and /temp/grandparent/parent/artist/
|
||||
grandparentDir := filepath.Join(tempDir, "grandparent")
|
||||
parentDir := filepath.Join(grandparentDir, "parent")
|
||||
artistDir := filepath.Join(parentDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Put artist image in grandparent directory
|
||||
artistImagePath := filepath.Join(grandparentDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds image in grandparent directory", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("grandparent" + string(filepath.Separator) + "artist.jpg"))
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("grandparent image"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("images exist at multiple levels", func() {
|
||||
BeforeEach(func() {
|
||||
// Create test structure with images at multiple levels
|
||||
grandparentDir := filepath.Join(tempDir, "grandparent")
|
||||
parentDir := filepath.Join(grandparentDir, "parent")
|
||||
artistDir := filepath.Join(parentDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Put artist images at all levels
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist level"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("prioritizes the closest (artist folder) image", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("artist" + string(filepath.Separator) + "artist.jpg"))
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("artist level"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("pattern matches multiple files", func() {
|
||||
BeforeEach(func() {
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Create multiple matching files
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "artist.txt"), []byte("text file"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("returns the first valid image file", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
|
||||
// Should return an image file, not the text file
|
||||
Expect(path).To(SatisfyAny(
|
||||
ContainSubstring("artist.jpg"),
|
||||
ContainSubstring("artist.png"),
|
||||
))
|
||||
Expect(path).ToNot(ContainSubstring("artist.txt"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
|
||||
When("no matching files exist anywhere", func() {
|
||||
BeforeEach(func() {
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Create non-matching files
|
||||
Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("returns an error", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(reader).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
Expect(err.Error()).To(ContainSubstring("no matches for 'artist.*'"))
|
||||
Expect(err.Error()).To(ContainSubstring("parent directories"))
|
||||
})
|
||||
})
|
||||
|
||||
When("directory traversal reaches filesystem root", func() {
|
||||
BeforeEach(func() {
|
||||
// Start from a shallow directory to test root boundary
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("handles root boundary gracefully", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(reader).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
// Should not panic or cause infinite loop
|
||||
})
|
||||
})
|
||||
|
||||
When("file exists but cannot be opened", func() {
|
||||
BeforeEach(func() {
|
||||
artistDir := filepath.Join(tempDir, "artist")
|
||||
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
|
||||
|
||||
// Create a file that cannot be opened (permission denied)
|
||||
restrictedFile := filepath.Join(artistDir, "artist.jpg")
|
||||
Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed())
|
||||
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("logs warning and continues searching", func() {
|
||||
// This test depends on the ability to restrict file permissions
|
||||
// For now, we'll just ensure it doesn't panic and returns appropriate error
|
||||
reader, _, err := testFunc()
|
||||
// The file should be readable in test environment, so this will succeed
|
||||
// In a real scenario with permission issues, it would continue searching
|
||||
if err == nil {
|
||||
Expect(reader).ToNot(BeNil())
|
||||
reader.Close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
When("single album artist scenario (original issue)", func() {
|
||||
BeforeEach(func() {
|
||||
// Simulate the exact folder structure from the issue:
|
||||
// /music/artist/album1/ (single album)
|
||||
// /music/artist/artist.jpg (artist image that should be found)
|
||||
artistDir := filepath.Join(tempDir, "music", "artist")
|
||||
albumDir := filepath.Join(artistDir, "album1")
|
||||
Expect(os.MkdirAll(albumDir, 0755)).To(Succeed())
|
||||
|
||||
// Create artist.jpg in the artist folder (this was not being found before)
|
||||
artistImagePath := filepath.Join(artistDir, "artist.jpg")
|
||||
Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed())
|
||||
|
||||
// The fromArtistFolder is called with the artist folder path
|
||||
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
|
||||
})
|
||||
|
||||
It("finds artist.jpg in artist folder for single album artist", func() {
|
||||
reader, path, err := testFunc()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reader).ToNot(BeNil())
|
||||
Expect(path).To(ContainSubstring("artist.jpg"))
|
||||
Expect(path).To(ContainSubstring("artist"))
|
||||
|
||||
// Verify the content
|
||||
data, err := io.ReadAll(reader)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("single album artist image"))
|
||||
reader.Close()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeFolderRepo struct {
|
||||
|
||||
@@ -10,15 +10,11 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
func start(ctx context.Context, args []string) (Executor, error) {
|
||||
if len(args) == 0 {
|
||||
return Executor{}, fmt.Errorf("no command arguments provided")
|
||||
}
|
||||
log.Debug("Executing mpv command", "cmd", args)
|
||||
j := Executor{args: args}
|
||||
j.PipeReader, j.out = io.Pipe()
|
||||
@@ -75,32 +71,28 @@ func (j *Executor) wait() {
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createMPVCommand(deviceName string, filename string, socketName string) []string {
|
||||
// Parse the template structure using shell parsing to handle quoted arguments
|
||||
templateArgs, err := shellquote.Split(conf.Server.MPVCmdTemplate)
|
||||
if err != nil {
|
||||
log.Error("Failed to parse MPV command template", "template", conf.Server.MPVCmdTemplate, err)
|
||||
return nil
|
||||
split := strings.Split(fixCmd(conf.Server.MPVCmdTemplate), " ")
|
||||
for i, s := range split {
|
||||
s = strings.ReplaceAll(s, "%d", deviceName)
|
||||
s = strings.ReplaceAll(s, "%f", filename)
|
||||
s = strings.ReplaceAll(s, "%s", socketName)
|
||||
split[i] = s
|
||||
}
|
||||
return split
|
||||
}
|
||||
|
||||
// Replace placeholders in each parsed argument to preserve spaces in substituted values
|
||||
for i, arg := range templateArgs {
|
||||
arg = strings.ReplaceAll(arg, "%d", deviceName)
|
||||
arg = strings.ReplaceAll(arg, "%f", filename)
|
||||
arg = strings.ReplaceAll(arg, "%s", socketName)
|
||||
templateArgs[i] = arg
|
||||
}
|
||||
|
||||
// Replace mpv executable references with the configured path
|
||||
if len(templateArgs) > 0 {
|
||||
cmdPath, err := mpvCommand()
|
||||
if err == nil {
|
||||
if templateArgs[0] == "mpv" || templateArgs[0] == "mpv.exe" {
|
||||
templateArgs[0] = cmdPath
|
||||
}
|
||||
func fixCmd(cmd string) string {
|
||||
split := strings.Split(cmd, " ")
|
||||
var result []string
|
||||
cmdPath, _ := mpvCommand()
|
||||
for _, s := range split {
|
||||
if s == "mpv" || s == "mpv.exe" {
|
||||
result = append(result, cmdPath)
|
||||
} else {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
|
||||
return templateArgs
|
||||
return strings.Join(result, " ")
|
||||
}
|
||||
|
||||
// This is a 1:1 copy of the stuff in ffmpeg.go, need to be unified.
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package mpv
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestMPV(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "MPV Suite")
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
package mpv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MPV", func() {
|
||||
var (
|
||||
testScript string
|
||||
tempDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
// Reset MPV cache
|
||||
mpvOnce = sync.Once{}
|
||||
mpvPath = ""
|
||||
mpvErr = nil
|
||||
|
||||
// Create temporary directory for test files
|
||||
var err error
|
||||
tempDir, err = os.MkdirTemp("", "mpv_test_*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() { os.RemoveAll(tempDir) })
|
||||
|
||||
// Create mock MPV script that outputs arguments to stdout
|
||||
testScript = createMockMPVScript(tempDir)
|
||||
|
||||
// Configure test MPV path
|
||||
conf.Server.MPVPath = testScript
|
||||
})
|
||||
|
||||
Describe("createMPVCommand", func() {
|
||||
Context("with default template", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
|
||||
})
|
||||
|
||||
It("creates correct command with simple paths", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=auto",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
}))
|
||||
})
|
||||
|
||||
It("handles paths with spaces", func() {
|
||||
args := createMPVCommand("auto", "/music/My Album/01 - Song.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=auto",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/My Album/01 - Song.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
}))
|
||||
})
|
||||
|
||||
It("handles complex device names", func() {
|
||||
deviceName := "coreaudio/AppleUSBAudioEngine:Cambridge Audio :Cambridge Audio USB Audio 1.0:0000:1"
|
||||
args := createMPVCommand(deviceName, "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=" + deviceName,
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with snapcast template (issue #3619)", func() {
|
||||
BeforeEach(func() {
|
||||
// This is the template that fails with naive space splitting
|
||||
conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
|
||||
})
|
||||
|
||||
It("creates correct command for snapcast integration", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--audio-channels=stereo",
|
||||
"--audio-samplerate=48000",
|
||||
"--audio-format=s16",
|
||||
"--ao=pcm",
|
||||
"--ao-pcm-file=/audio/snapcast_fifo",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with wrapper script template", func() {
|
||||
BeforeEach(func() {
|
||||
// Test case that would break with naive splitting due to quoted arguments
|
||||
conf.Server.MPVCmdTemplate = `/tmp/mpv.sh --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo`
|
||||
})
|
||||
|
||||
It("handles wrapper script paths", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
"/tmp/mpv.sh",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--audio-channels=stereo",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with extra spaces in template", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
|
||||
})
|
||||
|
||||
It("handles extra spaces correctly", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=auto",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
}))
|
||||
})
|
||||
})
|
||||
Context("with paths containing spaces in template arguments", func() {
|
||||
BeforeEach(func() {
|
||||
// Template with spaces in the path arguments themselves
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --ao-pcm-file="/audio/my folder/snapcast_fifo" --input-ipc-server=%s`
|
||||
})
|
||||
|
||||
It("handles spaces in quoted template argument paths", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
// This test reveals the limitation of strings.Fields() - it will split on all spaces
|
||||
// Expected behavior would be to keep the path as one argument
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--ao-pcm-file=/audio/my folder/snapcast_fifo", // This should be one argument
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with malformed template", func() {
|
||||
BeforeEach(func() {
|
||||
// Template with unmatched quotes that will cause shell parsing to fail
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote`
|
||||
})
|
||||
|
||||
It("returns nil when shell parsing fails", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with empty template", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = ""
|
||||
})
|
||||
|
||||
It("returns empty slice for empty template", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("start", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
|
||||
})
|
||||
|
||||
It("executes MPV command and captures arguments correctly", func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
deviceName := "auto"
|
||||
filename := "/music/test.mp3"
|
||||
socketName := "/tmp/test_socket"
|
||||
|
||||
args := createMPVCommand(deviceName, filename, socketName)
|
||||
executor, err := start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Read all the output from stdout (this will block until the process finishes or is canceled)
|
||||
output, err := io.ReadAll(executor)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Parse the captured arguments
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
Expect(lines).To(HaveLen(6))
|
||||
Expect(lines[0]).To(Equal(testScript))
|
||||
Expect(lines[1]).To(Equal("--audio-device=auto"))
|
||||
Expect(lines[2]).To(Equal("--no-audio-display"))
|
||||
Expect(lines[3]).To(Equal("--pause"))
|
||||
Expect(lines[4]).To(Equal("/music/test.mp3"))
|
||||
Expect(lines[5]).To(Equal("--input-ipc-server=/tmp/test_socket"))
|
||||
})
|
||||
|
||||
It("handles file paths with spaces", func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
deviceName := "auto"
|
||||
filename := "/music/My Album/01 - My Song.mp3"
|
||||
socketName := "/tmp/test socket"
|
||||
|
||||
args := createMPVCommand(deviceName, filename, socketName)
|
||||
executor, err := start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Read all the output from stdout (this will block until the process finishes or is canceled)
|
||||
output, err := io.ReadAll(executor)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Parse the captured arguments
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
Expect(lines).To(ContainElement("/music/My Album/01 - My Song.mp3"))
|
||||
Expect(lines).To(ContainElement("--input-ipc-server=/tmp/test socket"))
|
||||
})
|
||||
|
||||
Context("with complex snapcast configuration", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
|
||||
})
|
||||
|
||||
It("passes all snapcast arguments correctly", func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
deviceName := "auto"
|
||||
filename := "/music/album/track.flac"
|
||||
socketName := "/tmp/mpv-ctrl-test.socket"
|
||||
|
||||
args := createMPVCommand(deviceName, filename, socketName)
|
||||
executor, err := start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Read all the output from stdout (this will block until the process finishes or is canceled)
|
||||
output, err := io.ReadAll(executor)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Parse the captured arguments
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
|
||||
// Verify all expected arguments are present
|
||||
Expect(lines).To(ContainElement("--no-audio-display"))
|
||||
Expect(lines).To(ContainElement("--pause"))
|
||||
Expect(lines).To(ContainElement("/music/album/track.flac"))
|
||||
Expect(lines).To(ContainElement("--input-ipc-server=/tmp/mpv-ctrl-test.socket"))
|
||||
Expect(lines).To(ContainElement("--audio-channels=stereo"))
|
||||
Expect(lines).To(ContainElement("--audio-samplerate=48000"))
|
||||
Expect(lines).To(ContainElement("--audio-format=s16"))
|
||||
Expect(lines).To(ContainElement("--ao=pcm"))
|
||||
Expect(lines).To(ContainElement("--ao-pcm-file=/audio/snapcast_fifo"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with nil args", func() {
|
||||
It("returns error when args is nil", func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := start(ctx, nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(Equal("no command arguments provided"))
|
||||
})
|
||||
|
||||
It("returns error when args is empty", func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := start(ctx, []string{})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(Equal("no command arguments provided"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("mpvCommand", func() {
|
||||
BeforeEach(func() {
|
||||
// Reset the mpv command cache
|
||||
mpvOnce = sync.Once{}
|
||||
mpvPath = ""
|
||||
mpvErr = nil
|
||||
})
|
||||
|
||||
It("finds the configured MPV path", func() {
|
||||
conf.Server.MPVPath = testScript
|
||||
path, err := mpvCommand()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(path).To(Equal(testScript))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NewTrack integration", func() {
|
||||
var testMediaFile model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.MPVPath = testScript
|
||||
|
||||
// Create a test media file
|
||||
testMediaFile = model.MediaFile{
|
||||
ID: "test-id",
|
||||
Path: "/music/test.mp3",
|
||||
}
|
||||
})
|
||||
|
||||
Context("with malformed template", func() {
|
||||
BeforeEach(func() {
|
||||
// Template with unmatched quotes that will cause shell parsing to fail
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote`
|
||||
})
|
||||
|
||||
It("returns error when createMPVCommand fails", func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
playbackDone := make(chan bool, 1)
|
||||
_, err := NewTrack(ctx, playbackDone, "auto", testMediaFile)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(Equal("no mpv command arguments provided"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// createMockMPVScript creates a mock script that outputs arguments to stdout
|
||||
func createMockMPVScript(tempDir string) string {
|
||||
var scriptContent string
|
||||
var scriptExt string
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
scriptExt = ".bat"
|
||||
scriptContent = `@echo off
|
||||
echo %0
|
||||
:loop
|
||||
if "%~1"=="" goto end
|
||||
echo %~1
|
||||
shift
|
||||
goto loop
|
||||
:end
|
||||
`
|
||||
} else {
|
||||
scriptExt = ".sh"
|
||||
scriptContent = `#!/bin/bash
|
||||
echo "$0"
|
||||
for arg in "$@"; do
|
||||
echo "$arg"
|
||||
done
|
||||
`
|
||||
}
|
||||
|
||||
scriptPath := filepath.Join(tempDir, "mock_mpv"+scriptExt)
|
||||
err := os.WriteFile(scriptPath, []byte(scriptContent), 0755) // nolint:gosec
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to create mock script: %v", err))
|
||||
}
|
||||
|
||||
return scriptPath
|
||||
}
|
||||
@@ -34,10 +34,7 @@ func NewTrack(ctx context.Context, playbackDoneChannel chan bool, deviceName str
|
||||
|
||||
tmpSocketName := socketName("mpv-ctrl-", ".socket")
|
||||
|
||||
args := createMPVCommand(deviceName, mf.AbsolutePath(), tmpSocketName)
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("no mpv command arguments provided")
|
||||
}
|
||||
args := createMPVCommand(deviceName, mf.Path, tmpSocketName)
|
||||
exe, err := start(ctx, args)
|
||||
if err != nil {
|
||||
log.Error("Error starting mpv process", err)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
@@ -94,7 +93,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
}
|
||||
s.ID = id
|
||||
if V(s.ExpiresAt).IsZero() {
|
||||
s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration))
|
||||
s.ExpiresAt = P(time.Now().Add(365 * 24 * time.Hour))
|
||||
}
|
||||
|
||||
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE share_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
expires_at datetime,
|
||||
last_visited_at datetime,
|
||||
resource_ids varchar not null,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
user_id varchar(255) not null
|
||||
constraint share_user_id_fk
|
||||
references user
|
||||
on update cascade on delete cascade,
|
||||
downloadable bool not null default false,
|
||||
description varchar not null default '',
|
||||
resource_type varchar not null default '',
|
||||
contents varchar not null default '',
|
||||
format varchar not null default '',
|
||||
max_bit_rate integer not null default 0,
|
||||
visit_count integer not null default 0
|
||||
);
|
||||
|
||||
|
||||
INSERT INTO share_tmp(
|
||||
id, expires_at, last_visited_at, resource_ids, created_at, updated_at, user_id, downloadable, description, resource_type, contents, format, max_bit_rate, visit_count
|
||||
) SELECT id, expires_at, last_visited_at, resource_ids, created_at, updated_at, user_id, downloadable, description, resource_type, contents, format, max_bit_rate, visit_count
|
||||
FROM share;
|
||||
|
||||
DROP TABLE share;
|
||||
|
||||
ALTER TABLE share_tmp RENAME To share;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
35
go.mod
35
go.mod
@@ -34,19 +34,18 @@ require (
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0
|
||||
github.com/kardianos/service v1.2.2
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
github.com/mattn/go-sqlite3 v1.14.27
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.23.4
|
||||
github.com/onsi/gomega v1.37.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/pressly/goose/v3 v3.24.3
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/pressly/goose/v3 v3.24.2
|
||||
github.com/prometheus/client_golang v1.21.1
|
||||
github.com/rjeczalik/notify v0.9.3
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
|
||||
@@ -57,12 +56,12 @@ require (
|
||||
github.com/unrolled/secure v1.17.0
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6
|
||||
golang.org/x/image v0.27.0
|
||||
golang.org/x/net v0.40.0
|
||||
golang.org/x/sync v0.14.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/text v0.25.0
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
|
||||
golang.org/x/image v0.26.0
|
||||
golang.org/x/net v0.38.0
|
||||
golang.org/x/sync v0.13.0
|
||||
golang.org/x/sys v0.32.0
|
||||
golang.org/x/text v0.24.0
|
||||
golang.org/x/time v0.11.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -81,16 +80,18 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
@@ -101,23 +102,23 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.8.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/tools v0.33.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
|
||||
80
go.sum
80
go.sum
@@ -66,8 +66,8 @@ github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
|
||||
@@ -85,8 +85,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
||||
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4=
|
||||
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -130,24 +130,24 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
|
||||
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.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4 h1:uBCMmJX8oRZStmKuMMOFb0Yh9xmEMgNJLgjuKKt4/qc=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4/go.mod h1:nWRbDFR1ALG2Z6GJbBXzfQaYyvn751KuuyySN2yR6is=
|
||||
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/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
|
||||
github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
@@ -174,16 +174,16 @@ github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU
|
||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
|
||||
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
|
||||
github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
|
||||
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
||||
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
|
||||
@@ -213,8 +213,8 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
|
||||
github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
@@ -256,13 +256,13 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
||||
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||
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=
|
||||
@@ -283,8 +283,8 @@ golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
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=
|
||||
@@ -292,8 +292,8 @@ 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.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -312,8 +312,8 @@ golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
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=
|
||||
@@ -334,8 +334,8 @@ 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.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -346,8 +346,8 @@ 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.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
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.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
@@ -363,11 +363,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
|
||||
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
|
||||
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA=
|
||||
modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=
|
||||
|
||||
@@ -32,8 +32,6 @@ type Artist struct {
|
||||
SimilarArtists Artists `structs:"similar_artists" json:"-"`
|
||||
ExternalInfoUpdatedAt *time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt,omitempty"`
|
||||
|
||||
Missing bool `structs:"missing" json:"missing"`
|
||||
|
||||
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
|
||||
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
|
||||
}
|
||||
@@ -78,7 +76,7 @@ type ArtistRepository interface {
|
||||
UpdateExternalInfo(a *Artist) error
|
||||
Get(id string) (*Artist, error)
|
||||
GetAll(options ...QueryOptions) (Artists, error)
|
||||
GetIndex(includeMissing bool, roles ...Role) (ArtistIndexes, error)
|
||||
GetIndex(roles ...Role) (ArtistIndexes, error)
|
||||
|
||||
// The following methods are used exclusively by the scanner:
|
||||
RefreshPlayCounts() (int64, error)
|
||||
|
||||
@@ -4,7 +4,6 @@ package criteria
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
@@ -41,9 +40,6 @@ func (c Criteria) OrderBy() string {
|
||||
} else {
|
||||
mapped = f.field
|
||||
}
|
||||
if f.numeric {
|
||||
mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
|
||||
}
|
||||
}
|
||||
if c.Order != "" {
|
||||
if strings.EqualFold(c.Order, "asc") || strings.EqualFold(c.Order, "desc") {
|
||||
|
||||
@@ -109,15 +109,6 @@ var _ = Describe("Criteria", func() {
|
||||
)
|
||||
})
|
||||
|
||||
It("casts numeric tags when sorting", func() {
|
||||
AddTagNames([]string{"rate"})
|
||||
AddNumericTags([]string{"rate"})
|
||||
goObj.Sort = "rate"
|
||||
gomega.Expect(goObj.OrderBy()).To(
|
||||
gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"),
|
||||
)
|
||||
})
|
||||
|
||||
It("sorts by random", func() {
|
||||
newObj := goObj
|
||||
newObj.Sort = "random"
|
||||
|
||||
@@ -54,12 +54,11 @@ var fieldMap = map[string]*mappedField{
|
||||
}
|
||||
|
||||
type mappedField struct {
|
||||
field string
|
||||
order string
|
||||
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
|
||||
isTag bool // true if the field is a tag imported from the file metadata
|
||||
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
|
||||
numeric bool // true if the field/tag should be treated as numeric
|
||||
field string
|
||||
order string
|
||||
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
|
||||
isTag bool // true if the field is a tag imported from the file metadata
|
||||
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
|
||||
}
|
||||
|
||||
func mapFields(expr map[string]any) map[string]any {
|
||||
@@ -146,12 +145,6 @@ type tagCond struct {
|
||||
|
||||
func (e tagCond) ToSql() (string, []any, error) {
|
||||
cond, args, err := e.cond.ToSql()
|
||||
|
||||
// Check if this tag is marked as numeric in the fieldMap
|
||||
if fm, ok := fieldMap[e.tag]; ok && fm.numeric {
|
||||
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
|
||||
}
|
||||
|
||||
cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
|
||||
e.tag, cond)
|
||||
if e.not {
|
||||
@@ -212,16 +205,3 @@ func AddTagNames(tagNames []string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddNumericTags marks the given tag names as numeric so they can be cast
|
||||
// when used in comparisons or sorting.
|
||||
func AddNumericTags(tagNames []string) {
|
||||
for _, name := range tagNames {
|
||||
name := strings.ToLower(name)
|
||||
if fm, ok := fieldMap[name]; ok {
|
||||
fm.numeric = true
|
||||
} else {
|
||||
fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
var _ = BeforeSuite(func() {
|
||||
AddRoles([]string{"artist", "composer"})
|
||||
AddTagNames([]string{"genre"})
|
||||
AddNumericTags([]string{"rate"})
|
||||
})
|
||||
|
||||
var _ = Describe("Operators", func() {
|
||||
@@ -69,15 +68,6 @@ var _ = Describe("Operators", func() {
|
||||
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
|
||||
)
|
||||
|
||||
// TODO Validate operators that are not valid for each field type.
|
||||
XDescribeTable("ToSQL - Invalid Operators",
|
||||
func(op Expression, expectedError string) {
|
||||
_, _, err := op.ToSql()
|
||||
gomega.Expect(err).To(gomega.MatchError(expectedError))
|
||||
},
|
||||
Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"),
|
||||
)
|
||||
|
||||
Describe("Custom Tags", func() {
|
||||
It("generates valid SQL", func() {
|
||||
AddTagNames([]string{"mood"})
|
||||
@@ -87,14 +77,6 @@ var _ = Describe("Operators", func() {
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.mood') where key='value' and value LIKE ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
|
||||
})
|
||||
It("casts numeric comparisons", func() {
|
||||
AddNumericTags([]string{"rate"})
|
||||
op := Lt{"rate": 6}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements(6))
|
||||
})
|
||||
It("skips unknown tag names", func() {
|
||||
op := EndsWith{"unknown": "value"}
|
||||
sql, args, _ := op.ToSql()
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
@@ -331,23 +330,6 @@ func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int
|
||||
return currentPath, currentDisc
|
||||
}
|
||||
|
||||
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
|
||||
// https://docs.fileformat.com/audio/m3u/#extended-m3u
|
||||
func (mfs MediaFiles) ToM3U8(title string, absolutePaths bool) string {
|
||||
buf := strings.Builder{}
|
||||
buf.WriteString("#EXTM3U\n")
|
||||
buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", title))
|
||||
for _, t := range mfs {
|
||||
buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
|
||||
if absolutePaths {
|
||||
buf.WriteString(t.AbsolutePath() + "\n")
|
||||
} else {
|
||||
buf.WriteString(t.Path + "\n")
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
type MediaFileCursor iter.Seq2[MediaFile, error]
|
||||
|
||||
type MediaFileRepository interface {
|
||||
@@ -360,7 +342,6 @@ type MediaFileRepository interface {
|
||||
GetCursor(options ...QueryOptions) (MediaFileCursor, error)
|
||||
Delete(id string) error
|
||||
DeleteMissing(ids []string) error
|
||||
DeleteAllMissing() (int64, error)
|
||||
FindByPaths(paths []string) (MediaFiles, error)
|
||||
|
||||
// The following methods are used exclusively by the scanner:
|
||||
|
||||
@@ -402,72 +402,6 @@ var _ = Describe("MediaFiles", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ToM3U8", func() {
|
||||
It("returns header only for empty MediaFiles", func() {
|
||||
mfs = MediaFiles{}
|
||||
result := mfs.ToM3U8("My Playlist", false)
|
||||
Expect(result).To(Equal("#EXTM3U\n#PLAYLIST:My Playlist\n"))
|
||||
})
|
||||
|
||||
DescribeTable("duration formatting",
|
||||
func(duration float32, expected string) {
|
||||
mfs = MediaFiles{{Title: "Song", Artist: "Artist", Duration: duration, Path: "song.mp3"}}
|
||||
result := mfs.ToM3U8("Test", false)
|
||||
Expect(result).To(ContainSubstring(expected))
|
||||
},
|
||||
Entry("zero duration", float32(0.0), "#EXTINF:0,"),
|
||||
Entry("whole number", float32(120.0), "#EXTINF:120,"),
|
||||
Entry("rounds 0.5 down", float32(180.5), "#EXTINF:180,"),
|
||||
Entry("rounds 0.6 up", float32(240.6), "#EXTINF:241,"),
|
||||
)
|
||||
|
||||
Context("multiple tracks", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{Title: "Song One", Artist: "Artist A", Duration: 120, Path: "a/song1.mp3", LibraryPath: "/music"},
|
||||
{Title: "Song Two", Artist: "Artist B", Duration: 241, Path: "b/song2.mp3", LibraryPath: "/music"},
|
||||
{Title: "Song with \"quotes\" & ampersands", Artist: "Artist with Ümläuts", Duration: 90, Path: "special/file.mp3", LibraryPath: "/música"},
|
||||
}
|
||||
})
|
||||
|
||||
DescribeTable("generates correct output",
|
||||
func(absolutePaths bool, expectedContent string) {
|
||||
result := mfs.ToM3U8("Multi Track", absolutePaths)
|
||||
Expect(result).To(Equal(expectedContent))
|
||||
},
|
||||
Entry("relative paths",
|
||||
false,
|
||||
"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n",
|
||||
),
|
||||
Entry("absolute paths",
|
||||
true,
|
||||
"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\n/music/a/song1.mp3\n#EXTINF:241,Artist B - Song Two\n/music/b/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\n/música/special/file.mp3\n",
|
||||
),
|
||||
Entry("special characters",
|
||||
false,
|
||||
"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
Context("path variations", func() {
|
||||
It("handles different path structures", func() {
|
||||
mfs = MediaFiles{
|
||||
{Title: "Root", Artist: "Artist", Duration: 60, Path: "song.mp3", LibraryPath: "/lib"},
|
||||
{Title: "Nested", Artist: "Artist", Duration: 60, Path: "deep/nested/song.mp3", LibraryPath: "/lib"},
|
||||
}
|
||||
|
||||
relativeResult := mfs.ToM3U8("Test", false)
|
||||
Expect(relativeResult).To(ContainSubstring("song.mp3\n"))
|
||||
Expect(relativeResult).To(ContainSubstring("deep/nested/song.mp3\n"))
|
||||
|
||||
absoluteResult := mfs.ToM3U8("Test", true)
|
||||
Expect(absoluteResult).To(ContainSubstring("/lib/song.mp3\n"))
|
||||
Expect(absoluteResult).To(ContainSubstring("/lib/deep/nested/song.mp3\n"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("MediaFile", func() {
|
||||
|
||||
@@ -564,58 +564,6 @@ var _ = Describe("Participants", func() {
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
When("MUSICBRAINZ_PERFORMERID tag is set", func() {
|
||||
matchPerformer := func(name, orderName, subRole, mbid string) types.GomegaMatcher {
|
||||
return MatchFields(IgnoreExtras, Fields{
|
||||
"Artist": MatchFields(IgnoreExtras, Fields{
|
||||
"Name": Equal(name),
|
||||
"OrderArtistName": Equal(orderName),
|
||||
"MbzArtistID": Equal(mbid),
|
||||
}),
|
||||
"SubRole": Equal(subRole),
|
||||
})
|
||||
}
|
||||
|
||||
It("should map MBIDs to the correct performer", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"PERFORMER:GUITAR": {"Eric Clapton", "B.B. King"},
|
||||
"PERFORMER:BASS": {"Nathan East"},
|
||||
"MUSICBRAINZ_PERFORMERID:GUITAR": {"mbid1", "mbid2"},
|
||||
"MUSICBRAINZ_PERFORMERID:BASS": {"mbid3"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(3)))
|
||||
|
||||
p := participants[model.RolePerformer]
|
||||
Expect(p).To(ContainElements(
|
||||
matchPerformer("Eric Clapton", "eric clapton", "Guitar", "mbid1"),
|
||||
matchPerformer("B.B. King", "b.b. king", "Guitar", "mbid2"),
|
||||
matchPerformer("Nathan East", "nathan east", "Bass", "mbid3"),
|
||||
))
|
||||
})
|
||||
|
||||
It("should handle mismatched performer names and MBIDs for sub-roles", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"PERFORMER:VOCALS": {"Singer A", "Singer B", "Singer C"},
|
||||
"MUSICBRAINZ_PERFORMERID:VOCALS": {"mbid_vocals_a", "mbid_vocals_b"}, // Fewer MBIDs
|
||||
"PERFORMER:DRUMS": {"Drummer X"},
|
||||
"MUSICBRAINZ_PERFORMERID:DRUMS": {"mbid_drums_x", "mbid_drums_y"}, // More MBIDs
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(4))) // 3 vocalists + 1 drummer
|
||||
|
||||
p := participants[model.RolePerformer]
|
||||
Expect(p).To(ContainElements(
|
||||
matchPerformer("Singer A", "singer a", "Vocals", "mbid_vocals_a"),
|
||||
matchPerformer("Singer B", "singer b", "Vocals", "mbid_vocals_b"),
|
||||
matchPerformer("Singer C", "singer c", "Vocals", ""),
|
||||
matchPerformer("Drummer X", "drummer x", "Drums", "mbid_drums_x"),
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Other tags", func() {
|
||||
@@ -644,6 +592,7 @@ var _ = Describe("Participants", func() {
|
||||
Entry("REMIXER", model.RoleRemixer, "REMIXER"),
|
||||
Entry("DJMIXER", model.RoleDJMixer, "DJMIXER"),
|
||||
Entry("DIRECTOR", model.RoleDirector, "DIRECTOR"),
|
||||
// TODO PERFORMER
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
)
|
||||
|
||||
type Playlist struct {
|
||||
Annotations `structs:"-" hash:"ignore"`
|
||||
|
||||
ID string `structs:"id" json:"id"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
Comment string `structs:"comment" json:"comment"`
|
||||
@@ -53,9 +53,17 @@ func (pls *Playlist) RemoveTracks(idxToRemove []int) {
|
||||
pls.Tracks = newTracks
|
||||
}
|
||||
|
||||
// ToM3U8 exports the playlist to the Extended M3U8 format
|
||||
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
|
||||
// https://docs.fileformat.com/audio/m3u/#extended-m3u
|
||||
func (pls *Playlist) ToM3U8() string {
|
||||
return pls.MediaFiles().ToM3U8(pls.Name, true)
|
||||
buf := strings.Builder{}
|
||||
buf.WriteString("#EXTM3U\n")
|
||||
buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", pls.Name))
|
||||
for _, t := range pls.Tracks {
|
||||
buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
|
||||
buf.WriteString(t.AbsolutePath() + "\n")
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func (pls *Playlist) AddTracks(mediaFileIds []string) {
|
||||
@@ -94,7 +102,6 @@ type Playlists []Playlist
|
||||
|
||||
type PlaylistRepository interface {
|
||||
ResourceRepository
|
||||
AnnotatedRepository
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(pls *Playlist) error
|
||||
@@ -104,7 +111,6 @@ type PlaylistRepository interface {
|
||||
FindByPath(path string) (*Playlist, error)
|
||||
Delete(id string) error
|
||||
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
|
||||
GetPlaylists(mediaFileId string) (Playlists, error)
|
||||
}
|
||||
|
||||
type PlaylistTrack struct {
|
||||
|
||||
@@ -13,17 +13,13 @@ var _ = Describe("Playlist", func() {
|
||||
pls = model.Playlist{Name: "Mellow sunset"}
|
||||
pls.Tracks = model.PlaylistTracks{
|
||||
{MediaFile: model.MediaFile{Artist: "Morcheeba feat. Kurt Wagner", Title: "What New York Couples Fight About",
|
||||
Duration: 377.84,
|
||||
LibraryPath: "/music/library", Path: "Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}},
|
||||
Duration: 377.84, Path: "/music/library/Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}},
|
||||
{MediaFile: model.MediaFile{Artist: "A Tribe Called Quest", Title: "Description of a Fool (Groove Armada's Acoustic mix)",
|
||||
Duration: 374.49,
|
||||
LibraryPath: "/music/library", Path: "Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}},
|
||||
Duration: 374.49, Path: "/music/library/Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}},
|
||||
{MediaFile: model.MediaFile{Artist: "Lou Reed", Title: "Walk on the Wild Side",
|
||||
Duration: 253.1,
|
||||
LibraryPath: "/music/library", Path: "Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}},
|
||||
Duration: 253.1, Path: "/music/library/Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}},
|
||||
{MediaFile: model.MediaFile{Artist: "Legião Urbana", Title: "On the Way Home",
|
||||
Duration: 163.89,
|
||||
LibraryPath: "/music/library", Path: "Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}},
|
||||
Duration: 163.89, Path: "/music/library/Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}},
|
||||
}
|
||||
})
|
||||
It("generates the correct M3U format", func() {
|
||||
@@ -2,6 +2,7 @@ package model
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -49,9 +50,17 @@ func (s Share) CoverArtID() ArtworkID {
|
||||
|
||||
type Shares []Share
|
||||
|
||||
// ToM3U8 exports the share to the Extended M3U8 format.
|
||||
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
|
||||
// https://docs.fileformat.com/audio/m3u/#extended-m3u
|
||||
func (s Share) ToM3U8() string {
|
||||
return s.Tracks.ToM3U8(cmp.Or(s.Description, s.ID), false)
|
||||
buf := strings.Builder{}
|
||||
buf.WriteString("#EXTM3U\n")
|
||||
buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", cmp.Or(s.Description, s.ID)))
|
||||
for _, t := range s.Tracks {
|
||||
buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
|
||||
buf.WriteString(t.Path + "\n")
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
type ShareRepository interface {
|
||||
|
||||
@@ -191,7 +191,6 @@ const (
|
||||
TagReleaseCountry TagName = "releasecountry"
|
||||
TagMedia TagName = "media"
|
||||
TagCatalogNumber TagName = "catalognumber"
|
||||
TagISRC TagName = "isrc"
|
||||
TagBPM TagName = "bpm"
|
||||
TagExplicitStatus TagName = "explicitstatus"
|
||||
|
||||
|
||||
@@ -162,17 +162,6 @@ func tagNames() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
func numericTagNames() []string {
|
||||
mappings := TagMappings()
|
||||
names := make([]string, 0)
|
||||
for k, cfg := range mappings {
|
||||
if cfg.Type == TagTypeInteger || cfg.Type == TagTypeFloat {
|
||||
names = append(names, string(k))
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func loadTagMappings() {
|
||||
mappingsFile, err := resources.FS().Open("mappings.yaml")
|
||||
if err != nil {
|
||||
@@ -239,6 +228,5 @@ func init() {
|
||||
// used in smart playlists
|
||||
criteria.AddRoles(slices.Collect(maps.Keys(AllRoles)))
|
||||
criteria.AddTagNames(tagNames())
|
||||
criteria.AddNumericTags(numericTagNames())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -116,7 +116,6 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
"name": fullTextFilter(r.tableName),
|
||||
"starred": booleanFilter,
|
||||
"role": roleFilter,
|
||||
"missing": booleanFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "order_artist_name",
|
||||
@@ -129,12 +128,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
}
|
||||
|
||||
func roleFilter(_ string, role any) Sqlizer {
|
||||
if role, ok := role.(string); ok {
|
||||
if _, ok := model.AllRoles[role]; ok {
|
||||
return NotEq{fmt.Sprintf("stats ->> '$.%v'", role): nil}
|
||||
}
|
||||
}
|
||||
return Eq{"1": 2}
|
||||
return NotEq{fmt.Sprintf("stats ->> '$.%v'", role): nil}
|
||||
}
|
||||
|
||||
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
|
||||
@@ -208,20 +202,13 @@ func (r *artistRepository) getIndexKey(a model.Artist) string {
|
||||
}
|
||||
|
||||
// TODO Cache the index (recalculate when there are changes to the DB)
|
||||
func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (model.ArtistIndexes, error) {
|
||||
func (r *artistRepository) GetIndex(roles ...model.Role) (model.ArtistIndexes, error) {
|
||||
options := model.QueryOptions{Sort: "name"}
|
||||
if len(roles) > 0 {
|
||||
roleFilters := slice.Map(roles, func(r model.Role) Sqlizer {
|
||||
return roleFilter("role", r.String())
|
||||
return roleFilter("role", r)
|
||||
})
|
||||
options.Filters = Or(roleFilters)
|
||||
}
|
||||
if !includeMissing {
|
||||
if options.Filters == nil {
|
||||
options.Filters = Eq{"artist.missing": false}
|
||||
} else {
|
||||
options.Filters = And{options.Filters, Eq{"artist.missing": false}}
|
||||
}
|
||||
options.Filters = And(roleFilters)
|
||||
}
|
||||
artists, err := r.GetAll(options)
|
||||
if err != nil {
|
||||
@@ -249,25 +236,6 @@ func (r *artistRepository) purgeEmpty() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// markMissing marks artists as missing if all their albums are missing.
|
||||
func (r *artistRepository) markMissing() error {
|
||||
q := Expr(`
|
||||
with artists_with_non_missing_albums as (
|
||||
select distinct aa.artist_id
|
||||
from album_artists aa
|
||||
join album a on aa.album_id = a.id
|
||||
where a.missing = false
|
||||
)
|
||||
update artist
|
||||
set missing = (artist.id not in (select artist_id from artists_with_non_missing_albums));
|
||||
`)
|
||||
_, err := r.executeSQL(q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marking missing artists: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshPlayCounts updates the play count and last play date annotations for all artists, based
|
||||
// on the media files associated with them.
|
||||
func (r *artistRepository) RefreshPlayCounts() (int64, error) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -95,7 +94,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex(false)
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("F"))
|
||||
@@ -113,7 +112,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
|
||||
// BFR Empty SortArtistName is not saved in the DB anymore
|
||||
XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex(false)
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
@@ -135,7 +134,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex(false)
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
@@ -152,7 +151,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
})
|
||||
|
||||
It("returns the index when SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex(false)
|
||||
idx, err := repo.GetIndex()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
@@ -163,67 +162,6 @@ var _ = Describe("ArtistRepository", func() {
|
||||
Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name))
|
||||
})
|
||||
})
|
||||
|
||||
When("filtering by role", func() {
|
||||
var raw *artistRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
raw = repo.(*artistRepository)
|
||||
// Add stats to artists using direct SQL since Put doesn't populate stats
|
||||
composerStats := `{"composer": {"s": 1000, "m": 5, "a": 2}}`
|
||||
producerStats := `{"producer": {"s": 500, "m": 3, "a": 1}}`
|
||||
|
||||
// Set Beatles as composer
|
||||
_, err := raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", composerStats).Where(squirrel.Eq{"id": artistBeatles.ID}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Set Kraftwerk as producer
|
||||
_, err = raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", producerStats).Where(squirrel.Eq{"id": artistKraftwerk.ID}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
// Clean up stats
|
||||
_, _ = raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", "{}").Where(squirrel.Eq{"id": artistBeatles.ID}))
|
||||
_, _ = raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", "{}").Where(squirrel.Eq{"id": artistKraftwerk.ID}))
|
||||
})
|
||||
|
||||
It("returns only artists with the specified role", func() {
|
||||
idx, err := repo.GetIndex(false, model.RoleComposer)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(1))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
Expect(idx[0].Artists).To(HaveLen(1))
|
||||
Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name))
|
||||
})
|
||||
|
||||
It("returns artists with any of the specified roles", func() {
|
||||
idx, err := repo.GetIndex(false, model.RoleComposer, model.RoleProducer)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
|
||||
// Find Beatles and Kraftwerk in the results
|
||||
var beatlesFound, kraftwerkFound bool
|
||||
for _, index := range idx {
|
||||
for _, artist := range index.Artists {
|
||||
if artist.Name == artistBeatles.Name {
|
||||
beatlesFound = true
|
||||
}
|
||||
if artist.Name == artistKraftwerk.Name {
|
||||
kraftwerkFound = true
|
||||
}
|
||||
}
|
||||
}
|
||||
Expect(beatlesFound).To(BeTrue())
|
||||
Expect(kraftwerkFound).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns empty index when no artists have the specified role", func() {
|
||||
idx, err := repo.GetIndex(false, model.RoleDirector)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("dbArtist mapping", func() {
|
||||
@@ -295,113 +233,5 @@ var _ = Describe("ArtistRepository", func() {
|
||||
Expect(m).ToNot(HaveKey("mbz_artist_id"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Missing artist visibility", func() {
|
||||
var raw *artistRepository
|
||||
var missing model.Artist
|
||||
|
||||
insertMissing := func() {
|
||||
missing = model.Artist{ID: "m1", Name: "Missing", OrderArtistName: "missing"}
|
||||
Expect(repo.Put(&missing)).To(Succeed())
|
||||
raw = repo.(*artistRepository)
|
||||
_, err := raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missing.ID}))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
removeMissing := func() {
|
||||
if raw != nil {
|
||||
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missing.ID}))
|
||||
}
|
||||
}
|
||||
|
||||
Context("regular user", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u1"})
|
||||
repo = NewArtistRepository(ctx, GetDBXBuilder())
|
||||
insertMissing()
|
||||
})
|
||||
|
||||
AfterEach(func() { removeMissing() })
|
||||
|
||||
It("does not return missing artist in GetAll", func() {
|
||||
artists, err := repo.GetAll(model.QueryOptions{Filters: squirrel.Eq{"artist.missing": false}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artists).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("does not return missing artist in Search", func() {
|
||||
res, err := repo.Search("missing", 0, 10, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not return missing artist in GetIndex", func() {
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Only 2 artists should be present
|
||||
total := 0
|
||||
for _, ix := range idx {
|
||||
total += len(ix.Artists)
|
||||
}
|
||||
Expect(total).To(Equal(2))
|
||||
})
|
||||
})
|
||||
|
||||
Context("admin user", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin", IsAdmin: true})
|
||||
repo = NewArtistRepository(ctx, GetDBXBuilder())
|
||||
insertMissing()
|
||||
})
|
||||
|
||||
AfterEach(func() { removeMissing() })
|
||||
|
||||
It("returns missing artist in GetAll", func() {
|
||||
artists, err := repo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artists).To(HaveLen(3))
|
||||
})
|
||||
|
||||
It("returns missing artist in Search", func() {
|
||||
res, err := repo.Search("missing", 0, 10, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("returns missing artist in GetIndex when included", func() {
|
||||
idx, err := repo.GetIndex(true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
total := 0
|
||||
for _, ix := range idx {
|
||||
total += len(ix.Artists)
|
||||
}
|
||||
Expect(total).To(Equal(3))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("roleFilter", func() {
|
||||
It("filters out roles not present in the participants model", func() {
|
||||
Expect(roleFilter("", "artist")).To(Equal(squirrel.NotEq{"stats ->> '$.artist'": nil}))
|
||||
Expect(roleFilter("", "albumartist")).To(Equal(squirrel.NotEq{"stats ->> '$.albumartist'": nil}))
|
||||
Expect(roleFilter("", "composer")).To(Equal(squirrel.NotEq{"stats ->> '$.composer'": nil}))
|
||||
Expect(roleFilter("", "conductor")).To(Equal(squirrel.NotEq{"stats ->> '$.conductor'": nil}))
|
||||
Expect(roleFilter("", "lyricist")).To(Equal(squirrel.NotEq{"stats ->> '$.lyricist'": nil}))
|
||||
Expect(roleFilter("", "arranger")).To(Equal(squirrel.NotEq{"stats ->> '$.arranger'": nil}))
|
||||
Expect(roleFilter("", "producer")).To(Equal(squirrel.NotEq{"stats ->> '$.producer'": nil}))
|
||||
Expect(roleFilter("", "director")).To(Equal(squirrel.NotEq{"stats ->> '$.director'": nil}))
|
||||
Expect(roleFilter("", "engineer")).To(Equal(squirrel.NotEq{"stats ->> '$.engineer'": nil}))
|
||||
Expect(roleFilter("", "mixer")).To(Equal(squirrel.NotEq{"stats ->> '$.mixer'": nil}))
|
||||
Expect(roleFilter("", "remixer")).To(Equal(squirrel.NotEq{"stats ->> '$.remixer'": nil}))
|
||||
Expect(roleFilter("", "djmixer")).To(Equal(squirrel.NotEq{"stats ->> '$.djmixer'": nil}))
|
||||
Expect(roleFilter("", "performer")).To(Equal(squirrel.NotEq{"stats ->> '$.performer'": nil}))
|
||||
|
||||
Expect(roleFilter("", "wizard")).To(Equal(squirrel.Eq{"1": 2}))
|
||||
Expect(roleFilter("", "songanddanceman")).To(Equal(squirrel.Eq{"1": 2}))
|
||||
Expect(roleFilter("", "artist') SELECT LIKE(CHAR(65,66,67,68,69,70,71),UPPER(HEX(RANDOMBLOB(500000000/2))))--")).To(Equal(squirrel.Eq{"1": 2}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -192,15 +192,6 @@ func (r *mediaFileRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) DeleteAllMissing() (int64, error) {
|
||||
user := loggedUser(r.ctx)
|
||||
if !user.IsAdmin {
|
||||
return 0, rest.ErrPermissionDenied
|
||||
}
|
||||
del := Delete(r.tableName).Where(Eq{"missing": true})
|
||||
return r.executeSQL(del)
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) DeleteMissing(ids []string) error {
|
||||
user := loggedUser(r.ctx)
|
||||
if !user.IsAdmin {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
@@ -45,39 +44,14 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
It("delete tracks by id", func() {
|
||||
newID := id.NewRandom()
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(BeNil())
|
||||
|
||||
Expect(mr.Delete(newID)).To(Succeed())
|
||||
Expect(mr.Delete(newID)).To(BeNil())
|
||||
|
||||
_, err := mr.Get(newID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("deletes all missing files", func() {
|
||||
new1 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1}
|
||||
new2 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1}
|
||||
Expect(mr.Put(&new1)).To(Succeed())
|
||||
Expect(mr.Put(&new2)).To(Succeed())
|
||||
Expect(mr.MarkMissing(true, &new1, &new2)).To(Succeed())
|
||||
|
||||
adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true})
|
||||
adminRepo := NewMediaFileRepository(adminCtx, GetDBXBuilder())
|
||||
|
||||
// Ensure the files are marked as missing and we have 2 of them
|
||||
count, err := adminRepo.CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}})
|
||||
Expect(count).To(BeNumerically("==", 2))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
count, err = adminRepo.DeleteAllMissing()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeNumerically("==", 2))
|
||||
|
||||
_, err = mr.Get(new1.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = mr.Get(new2.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
Context("Annotations", func() {
|
||||
It("increments play count when the tracks does not have annotations", func() {
|
||||
id := "incplay.firsttime"
|
||||
|
||||
@@ -170,7 +170,6 @@ func (s *SQLStore) GC(ctx context.Context) error {
|
||||
err := chain.RunSequentially(
|
||||
trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty() }),
|
||||
trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }),
|
||||
trace(ctx, "mark missing artists", func() error { return s.Artist(ctx).(*artistRepository).markMissing() }),
|
||||
trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty() }),
|
||||
trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }),
|
||||
trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }),
|
||||
|
||||
@@ -51,16 +51,12 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
|
||||
r := &playlistRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "playlist"
|
||||
r.registerModel(&model.Playlist{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"q": playlistFilter,
|
||||
"smart": smartPlaylistFilter,
|
||||
"starred": booleanFilter,
|
||||
"q": playlistFilter,
|
||||
"smart": smartPlaylistFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"owner_name": "owner_name",
|
||||
"starred_at": "starred, starred_at",
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -91,14 +87,12 @@ func (r *playlistRepository) userFilter() Sqlizer {
|
||||
}
|
||||
|
||||
func (r *playlistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
sq := r.newSelect()
|
||||
sq = r.withAnnotation(sq, r.tableName+".id")
|
||||
sq = sq.Where(r.userFilter())
|
||||
sq := Select().Where(r.userFilter())
|
||||
return r.count(sq, options...)
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(And{Eq{"playlist.id": id}, r.userFilter()})
|
||||
return r.exists(And{Eq{"id": id}, r.userFilter()})
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Delete(id string) error {
|
||||
@@ -112,7 +106,7 @@ func (r *playlistRepository) Delete(id string) error {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
}
|
||||
return r.delete(And{Eq{"playlist.id": id}, r.userFilter()})
|
||||
return r.delete(And{Eq{"id": id}, r.userFilter()})
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Put(p *model.Playlist) error {
|
||||
@@ -203,29 +197,9 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli
|
||||
return playlists, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) {
|
||||
sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}).
|
||||
Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id").
|
||||
Where(And{Eq{"playlist_tracks.media_file_id": mediaFileId}, r.userFilter()})
|
||||
var res []dbPlaylist
|
||||
err := r.queryAll(sel, &res)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.Playlists{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
playlists := make(model.Playlists, len(res))
|
||||
for i, p := range res {
|
||||
playlists[i] = p.Playlist
|
||||
}
|
||||
return playlists, nil
|
||||
}
|
||||
|
||||
func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder {
|
||||
query := r.newSelect(options...).Join("user on user.id = owner_id").
|
||||
return r.newSelect(options...).Join("user on user.id = owner_id").
|
||||
Columns(r.tableName+".*", "user.user_name as owner_name")
|
||||
return r.withAnnotation(query, r.tableName+".id")
|
||||
}
|
||||
|
||||
func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -111,60 +110,6 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
Expect(all[0].ID).To(Equal(plsBest.ID))
|
||||
Expect(all[1].ID).To(Equal(plsCool.ID))
|
||||
})
|
||||
|
||||
It("filters starred playlists", func() {
|
||||
Expect(repo.SetStar(true, plsBest.ID)).To(Succeed())
|
||||
|
||||
all, err := repo.GetAll(model.QueryOptions{Filters: sq.Eq{"starred": true}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(all).To(HaveLen(1))
|
||||
Expect(all[0].ID).To(Equal(plsBest.ID))
|
||||
|
||||
Expect(repo.SetStar(false, plsBest.ID)).To(Succeed())
|
||||
})
|
||||
|
||||
It("counts starred playlists", func() {
|
||||
Expect(repo.SetStar(true, plsCool.ID)).To(Succeed())
|
||||
count, err := repo.CountAll(model.QueryOptions{Filters: sq.Eq{"starred": true}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(1)))
|
||||
|
||||
Expect(repo.SetStar(false, plsCool.ID)).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SetStar", func() {
|
||||
It("should star a playlist", func() {
|
||||
Expect(repo.SetStar(true, plsBest.ID)).To(Succeed())
|
||||
|
||||
updated, err := repo.Get(plsBest.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updated.Starred).To(BeTrue())
|
||||
Expect(updated.StarredAt).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should unstar a playlist", func() {
|
||||
Expect(repo.SetStar(false, plsBest.ID)).To(Succeed())
|
||||
|
||||
updated, err := repo.Get(plsBest.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updated.Starred).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetPlaylists", func() {
|
||||
It("returns playlists for a track", func() {
|
||||
pls, err := repo.GetPlaylists(songRadioactivity.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls).To(HaveLen(1))
|
||||
Expect(pls[0].ID).To(Equal(plsBest.ID))
|
||||
})
|
||||
|
||||
It("returns empty when none", func() {
|
||||
pls, err := repo.GetPlaylists("9999")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls).To(HaveLen(0))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Smart Playlists", func() {
|
||||
|
||||
@@ -99,10 +99,10 @@ func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
|
||||
"playlist_tracks.*",
|
||||
).
|
||||
Join("media_file f on f.id = media_file_id").
|
||||
Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}})
|
||||
Where(And{Eq{"playlist_id": r.playlistId}, Eq{"id": id}})
|
||||
var trk dbPlaylistTrack
|
||||
err := r.queryOne(sel, &trk)
|
||||
return trk.PlaylistTrack, err
|
||||
return trk.PlaylistTrack.MediaFile, err
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) {
|
||||
|
||||
@@ -65,11 +65,6 @@ func loggedUser(ctx context.Context) *model.User {
|
||||
}
|
||||
}
|
||||
|
||||
func isAdmin(ctx context.Context) bool {
|
||||
user := loggedUser(ctx)
|
||||
return user.IsAdmin
|
||||
}
|
||||
|
||||
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
|
||||
if r.tableName == "" {
|
||||
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
|
||||
|
||||
@@ -41,9 +41,6 @@ func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding,
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Put(t *model.Transcoding) error {
|
||||
if !isAdmin(r.ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
_, err := r.put(t.ID, t)
|
||||
return err
|
||||
}
|
||||
@@ -72,9 +69,6 @@ func (r *transcodingRepository) NewInstance() interface{} {
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Save(entity interface{}) (string, error) {
|
||||
if !isAdmin(r.ctx) {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
t := entity.(*model.Transcoding)
|
||||
id, err := r.put(t.ID, t)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
@@ -84,9 +78,6 @@ func (r *transcodingRepository) Save(entity interface{}) (string, error) {
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
if !isAdmin(r.ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
t := entity.(*model.Transcoding)
|
||||
t.ID = id
|
||||
_, err := r.put(id, t)
|
||||
@@ -97,9 +88,6 @@ func (r *transcodingRepository) Update(id string, entity interface{}, cols ...st
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Delete(id string) error {
|
||||
if !isAdmin(r.ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.delete(Eq{"id": id})
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("TranscodingRepository", func() {
|
||||
var repo model.TranscodingRepository
|
||||
var adminRepo model.TranscodingRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(GinkgoT().Context())
|
||||
ctx = request.WithUser(ctx, regularUser)
|
||||
repo = NewTranscodingRepository(ctx, GetDBXBuilder())
|
||||
|
||||
adminCtx := log.NewContext(GinkgoT().Context())
|
||||
adminCtx = request.WithUser(adminCtx, adminUser)
|
||||
adminRepo = NewTranscodingRepository(adminCtx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
// Clean up any transcoding created during the tests
|
||||
tc, err := adminRepo.FindByFormat("test_format")
|
||||
if err == nil {
|
||||
err = adminRepo.(*transcodingRepository).Delete(tc.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
})
|
||||
|
||||
Describe("Admin User", func() {
|
||||
It("creates a new transcoding", func() {
|
||||
base, err := adminRepo.CountAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = adminRepo.Put(&model.Transcoding{ID: "new", Name: "new", TargetFormat: "test_format", DefaultBitRate: 320, Command: "ffmpeg"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
count, err := adminRepo.CountAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(base + 1))
|
||||
})
|
||||
|
||||
It("updates an existing transcoding", func() {
|
||||
tr := &model.Transcoding{ID: "upd", Name: "old", TargetFormat: "test_format", DefaultBitRate: 100, Command: "ffmpeg"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
tr.Name = "updated"
|
||||
err := adminRepo.Put(tr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
res, err := adminRepo.FindByFormat("test_format")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Name).To(Equal("updated"))
|
||||
})
|
||||
|
||||
It("deletes a transcoding", func() {
|
||||
err := adminRepo.Put(&model.Transcoding{ID: "to-delete", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 256, Command: "ffmpeg"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = adminRepo.(*transcodingRepository).Delete("to-delete")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = adminRepo.Get("to-delete")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Regular User", func() {
|
||||
It("fails to create", func() {
|
||||
err := repo.Put(&model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"})
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("fails to update", func() {
|
||||
tr := &model.Transcoding{ID: "updreg", Name: "old", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
tr.Name = "bad"
|
||||
err := repo.Put(tr)
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
|
||||
//_ = adminRepo.(*transcodingRepository).Delete("updreg")
|
||||
})
|
||||
|
||||
It("fails to delete", func() {
|
||||
tr := &model.Transcoding{ID: "delreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
err := repo.(*transcodingRepository).Delete("delreg")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
|
||||
//_ = adminRepo.(*transcodingRepository).Delete("delreg")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -32,15 +32,12 @@
|
||||
"participants": "Weitere Beteiligte",
|
||||
"tags": "Weitere Tags",
|
||||
"mappedTags": "Gemappte Tags",
|
||||
"rawTags": "Tag Rohdaten",
|
||||
"bitDepth": "Bittiefe",
|
||||
"sampleRate": "Samplerate",
|
||||
"missing": "Fehlend"
|
||||
"rawTags": "Tag Rohdaten"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Später abspielen",
|
||||
"playNow": "Jetzt abspielen",
|
||||
"addToPlaylist": "Zu einer Wiedergabeliste hinzufügen",
|
||||
"addToPlaylist": "Zur Playlist hinzufügen",
|
||||
"shuffleAll": "Zufallswiedergabe",
|
||||
"download": "Herunterladen",
|
||||
"playNext": "Als nächstes abspielen",
|
||||
@@ -73,16 +70,14 @@
|
||||
"releaseType": "Typ",
|
||||
"grouping": "Gruppierung",
|
||||
"media": "Medium",
|
||||
"mood": "Stimmung",
|
||||
"date": "Aufnahmedatum",
|
||||
"missing": "Fehlend"
|
||||
"mood": "Stimmung"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Abspielen",
|
||||
"playNext": "Als nächstes abspielen",
|
||||
"addToQueue": "Später abspielen",
|
||||
"shuffle": "Zufallswiedergabe",
|
||||
"addToPlaylist": "Zu einer Wiedergabeliste hinzufügen",
|
||||
"addToPlaylist": "Zur Playlist hinzufügen",
|
||||
"download": "Herunterladen",
|
||||
"info": "Mehr Informationen",
|
||||
"share": "Freigabe erstellen"
|
||||
@@ -107,8 +102,7 @@
|
||||
"rating": "Bewertung",
|
||||
"genre": "Genre",
|
||||
"size": "Größe",
|
||||
"role": "Rolle",
|
||||
"missing": "Fehlend"
|
||||
"role": "Rolle"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albuminterpret |||| Albuminterpreten",
|
||||
@@ -178,7 +172,7 @@
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Wiedergabeliste |||| Wiedergabelisten",
|
||||
"name": "Playlist |||| Playlists",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"duration": "Dauer",
|
||||
@@ -192,12 +186,11 @@
|
||||
"path": "Importieren aus"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Wiedergabeliste auswählen:",
|
||||
"selectPlaylist": "Titel zur Playlist hinzufügen",
|
||||
"addNewPlaylist": "\"%{name}\" erstellen",
|
||||
"export": "Exportieren",
|
||||
"makePublic": "Öffentlich machen",
|
||||
"makePrivate": "Privat stellen",
|
||||
"saveQueue": "Warteschlange in Wiedergabeliste speichern"
|
||||
"makePrivate": "Privat stellen"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Duplikate hinzufügen",
|
||||
@@ -242,13 +235,11 @@
|
||||
"updatedAt": "Fehlt seit"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Entfernen",
|
||||
"remove_all": "alle entfernen"
|
||||
"remove": "Entfernen"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Fehlende Datei(en) entfernt"
|
||||
},
|
||||
"empty": "keine fehlenden Dateien"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -400,10 +391,10 @@
|
||||
"note": "HINWEIS",
|
||||
"transcodingDisabled": "Die Änderung der Transcodierungskonfiguration über die Web-UI ist aus Sicherheitsgründen deaktiviert. Wenn du die Transcodierungsoptionen ändern (bearbeiten oder hinzufügen) möchtest, starte den Server mit der Konfigurationsoption %{config} neu.",
|
||||
"transcodingEnabled": "Navidrome läuft derzeit mit %{config}, wodurch es möglich ist, Systembefehle aus den Transkodierungseinstellungen über die Webschnittstelle auszuführen. Wir empfehlen, es aus Sicherheitsgründen zu deaktivieren und nur bei der Konfiguration von Transkodierungsoptionen zu aktivieren.",
|
||||
"songsAddedToPlaylist": "Einen Titel zur Wiedergabeliste hinzugefügt |||| %{smart_count} Titel zur Wiedergabeliste hinzugefügt",
|
||||
"noPlaylistsAvailable": "Keine Wiedergabeliste verfügbar",
|
||||
"songsAddedToPlaylist": "Einen Titel zur Playlist hinzugefügt |||| %{smart_count} Titel zur Playlist hinzugefügt",
|
||||
"noPlaylistsAvailable": "Keine Playlist verfügbar",
|
||||
"delete_user_title": "Benutzer '%{name}' löschen",
|
||||
"delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Wiedergabelisten und Einstellungen) wirklich löschen?",
|
||||
"delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Playlisten und Einstellungen) wirklich löschen?",
|
||||
"notifications_blocked": "Sie haben Benachrichtigungen für diese Seite in den Einstellungen Ihres Browsers blockiert",
|
||||
"notifications_not_available": "Dieser Browser unterstützt keine Desktop-Benachrichtigungen",
|
||||
"lastfmLinkSuccess": "Last.fm Verbindung hergestellt und scrobbling aktiviert",
|
||||
@@ -428,9 +419,7 @@
|
||||
"downloadDialogTitle": "Download %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "In Zwischenablage kopieren: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Fehlende Dateien entfernen",
|
||||
"remove_missing_content": "Möchtest du die ausgewählten Fehlenden Dateien wirklich aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.",
|
||||
"remove_all_missing_title": "Alle fehlenden Dateien entfernen",
|
||||
"remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht."
|
||||
"remove_missing_content": "Möchtest du die ausgewählten Fehlenden Dateien wirklich aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothek",
|
||||
@@ -458,8 +447,8 @@
|
||||
},
|
||||
"albumList": "Alben",
|
||||
"about": "Über",
|
||||
"playlists": "Wiedergabelisten",
|
||||
"sharedPlaylists": "Geteilte Wiedergabelisten"
|
||||
"playlists": "Playlisten",
|
||||
"sharedPlaylists": "Geteilte Playlisten"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Wiedergabeliste abspielen",
|
||||
@@ -504,10 +493,7 @@
|
||||
"quickScan": "Schneller Scan",
|
||||
"fullScan": "Kompletter Scan",
|
||||
"serverUptime": "Server-Betriebszeit",
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Typ",
|
||||
"status": "Scan Fehler",
|
||||
"elapsedTime": "Laufzeit"
|
||||
"serverDown": "OFFLINE"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Hotkeys",
|
||||
|
||||
@@ -33,9 +33,7 @@
|
||||
"tags": "Πρόσθετες Ετικέτες",
|
||||
"mappedTags": "Χαρτογραφημένες ετικέτες",
|
||||
"rawTags": "Ακατέργαστες ετικέτες",
|
||||
"bitDepth": "Λίγο βάθος",
|
||||
"sampleRate": "Ποσοστό δειγματοληψίας",
|
||||
"missing": "Απών"
|
||||
"bitDepth": "Λίγο βάθος"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Αναπαραγωγη Μετα",
|
||||
@@ -74,8 +72,7 @@
|
||||
"grouping": "Ομαδοποίηση",
|
||||
"media": "Μέσα",
|
||||
"mood": "Διάθεση",
|
||||
"date": "Ημερομηνία Ηχογράφησης",
|
||||
"missing": "Απών"
|
||||
"date": "Ημερομηνία Ηχογράφησης"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Αναπαραγωγή",
|
||||
@@ -107,8 +104,7 @@
|
||||
"rating": "Βαθμολογια",
|
||||
"genre": "Είδος",
|
||||
"size": "Μέγεθος",
|
||||
"role": "Ρόλος",
|
||||
"missing": "Απών"
|
||||
"role": "Ρόλος"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Καλλιτέχνης Άλμπουμ |||| Καλλιτέχνες άλμπουμ",
|
||||
@@ -136,7 +132,7 @@
|
||||
"name": "Όνομα",
|
||||
"password": "Κωδικός Πρόσβασης",
|
||||
"createdAt": "Δημιουργήθηκε στις",
|
||||
"changePassword": "Αλλαγή Κωδικού Πρόσβασης?",
|
||||
"changePassword": "Αλλαγή Κωδικού Πρόσβασης;",
|
||||
"currentPassword": "Υπάρχων Κωδικός Πρόσβασης",
|
||||
"newPassword": "Νέος Κωδικός Πρόσβασης",
|
||||
"token": "Token",
|
||||
@@ -196,12 +192,11 @@
|
||||
"addNewPlaylist": "Δημιουργία \"%{name}\"",
|
||||
"export": "Εξαγωγη",
|
||||
"makePublic": "Να γίνει δημόσιο",
|
||||
"makePrivate": "Να γίνει ιδιωτικό",
|
||||
"saveQueue": "Αποθήκευση ουράς στη λίστα αναπαραγωγής"
|
||||
"makePrivate": "Να γίνει ιδιωτικό"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών",
|
||||
"song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε?"
|
||||
"song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε;"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -242,8 +237,7 @@
|
||||
"updatedAt": "Εξαφανίστηκε"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Αφαίρεση",
|
||||
"remove_all": "Αφαίρεση όλων"
|
||||
"remove": "Αφαίρεση"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Λείπει αρχείο(α) αφαιρέθηκε"
|
||||
@@ -311,7 +305,7 @@
|
||||
"skip": "Παράβλεψη",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Κοινοποίηση",
|
||||
"download": "Λήψη"
|
||||
"download": "Λήψη "
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Ναι",
|
||||
@@ -350,10 +344,10 @@
|
||||
},
|
||||
"message": {
|
||||
"about": "Σχετικά",
|
||||
"are_you_sure": "Είστε σίγουροι?",
|
||||
"bulk_delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε το %{name}? |||| Είστε σίγουροι πως θέλετε να διαγράψετε τα %{smart_count}?",
|
||||
"are_you_sure": "Είστε σίγουροι;",
|
||||
"bulk_delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε το %{name}; |||| Είστε σίγουροι πως θέλετε να διαγράψετε τα %{smart_count};",
|
||||
"bulk_delete_title": "Διαγραφή του %{name} |||| Διαγραφή του %{smart_count} %{name}",
|
||||
"delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το αντικείμενο?",
|
||||
"delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το αντικείμενο;",
|
||||
"delete_title": "Διαγραφή του %{name} #%{id}",
|
||||
"details": "Λεπτομέρειες",
|
||||
"error": "Παρουσιάστηκε ένα πρόβλημα από τη μεριά του πελάτη και το αίτημα σας δεν μπορεί να ολοκληρωθεί.",
|
||||
@@ -362,12 +356,12 @@
|
||||
"no": "Όχι",
|
||||
"not_found": "Είτε έχετε εισάγει λανθασμένο URL, είτε ακολουθήσατε έναν υπερσύνδεσμο που δεν ισχύει.",
|
||||
"yes": "Ναι",
|
||||
"unsaved_changes": "Μερικές από τις αλλαγές σας δεν έχουν αποθηκευτεί. Είστε σίγουροι πως θέλετε να τις αγνοήσετε?"
|
||||
"unsaved_changes": "Μερικές από τις αλλαγές σας δεν έχουν αποθηκευτεί. Είστε σίγουροι πως θέλετε να τις αγνοήσετε;"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Δεν βρέθηκαν αποτελέσματα",
|
||||
"no_more_results": "Η σελίδα %{page} είναι εκτός ορίων. Δοκιμάστε την προηγούμενη σελίδα.",
|
||||
"page_out_of_boundaries": "Η σελίδα %{page} είναι εκτός ορίων",
|
||||
"page_out_of_boundaries": "Η σελίδα {page} είναι εκτός ορίων",
|
||||
"page_out_from_end": "Δεν είναι δυνατή η πλοήγηση πέραν της τελευταίας σελίδας",
|
||||
"page_out_from_begin": "Δεν είναι δυνατή η πλοήγηση πριν τη σελίδα 1",
|
||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} από %{total}",
|
||||
@@ -403,7 +397,7 @@
|
||||
"songsAddedToPlaylist": "Προστέθηκε 1 τραγούδι στη λίστα αναπαραγωγής |||| Προστέθηκαν %{smart_count} τραγούδια στη λίστα αναπαραγωγής",
|
||||
"noPlaylistsAvailable": "Κανένα διαθέσιμο",
|
||||
"delete_user_title": "Διαγραφή του χρήστη '%{name}'",
|
||||
"delete_user_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το χρήστη και όλα τα δεδομένα του (συμπεριλαμβανομένων των λιστών αναπαραγωγής και προτιμήσεων)?",
|
||||
"delete_user_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το χρήστη και όλα τα δεδομένα του (συμπεριλαμβανομένων των λιστών αναπαραγωγής και προτιμήσεων);",
|
||||
"notifications_blocked": "Έχετε μπλοκάρει τις Ειδοποιήσεις από τη σελίδα, μέσω των ρυθμίσεων του περιηγητή ιστού σας",
|
||||
"notifications_not_available": "Αυτός ο περιηγητής ιστού δεν υποστηρίζει ειδοποιήσεις στην επιφάνεια εργασίας ή δεν έχετε πρόσβαση στο Navidrome μέσω https",
|
||||
"lastfmLinkSuccess": "Το Last.fm έχει διασυνδεθεί επιτυχώς και η λειτουργία scrobbling ενεργοποιήθηκε",
|
||||
@@ -428,9 +422,7 @@
|
||||
"downloadDialogTitle": "Λήψη %{resource} '%{name}'(%{size})",
|
||||
"shareCopyToClipboard": "Αντιγραφή στο πρόχειρο: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Αφαιρέστε τα αρχεία που λείπουν",
|
||||
"remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους.",
|
||||
"remove_all_missing_title": "Αφαίρεση όλων των αρχείων που λείπουν",
|
||||
"remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους."
|
||||
"remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων; Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Βιβλιοθήκη",
|
||||
@@ -504,10 +496,7 @@
|
||||
"quickScan": "Γρήγορη Σάρωση",
|
||||
"fullScan": "Πλήρης Σάρωση",
|
||||
"serverUptime": "Λειτουργία Διακομιστή",
|
||||
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ",
|
||||
"scanType": "Τύπος",
|
||||
"status": "Σφάλμα σάρωσης",
|
||||
"elapsedTime": "Χρόνος που πέρασε"
|
||||
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ"
|
||||
},
|
||||
"help": {
|
||||
"title": "Συντομεύσεις του Navidrome",
|
||||
|
||||
@@ -24,18 +24,16 @@
|
||||
"rating": "Takso",
|
||||
"quality": "Kvalito",
|
||||
"bpm": "Pulsrapideco",
|
||||
"playDate": "Laste Ludita",
|
||||
"channels": "Kanaloj",
|
||||
"createdAt": "Dato de aligo",
|
||||
"playDate": "",
|
||||
"channels": "",
|
||||
"createdAt": "",
|
||||
"grouping": "",
|
||||
"mood": "Humoro",
|
||||
"mood": "",
|
||||
"participants": "",
|
||||
"tags": "Aldonaj Etikedoj",
|
||||
"mappedTags": "Mapigitaj etikedoj",
|
||||
"rawTags": "Krudaj etikedoj",
|
||||
"bitDepth": "",
|
||||
"sampleRate": "",
|
||||
"missing": ""
|
||||
"tags": "",
|
||||
"mappedTags": "",
|
||||
"rawTags": "",
|
||||
"bitDepth": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ludi Poste",
|
||||
@@ -44,7 +42,7 @@
|
||||
"shuffleAll": "Miksu Ĉiujn",
|
||||
"download": "Elŝuti",
|
||||
"playNext": "Ludu Poste",
|
||||
"info": "Akiri Informon"
|
||||
"info": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -62,20 +60,19 @@
|
||||
"updatedAt": "Ĝisdatigita je :",
|
||||
"comment": "Komento",
|
||||
"rating": "Takso",
|
||||
"createdAt": "Dato aldonita",
|
||||
"size": "Grando",
|
||||
"originalDate": "Originala",
|
||||
"releaseDate": "Publikiĝis",
|
||||
"releases": "Publikiĝo |||| Publikiĝoj",
|
||||
"released": "Publikiĝis",
|
||||
"createdAt": "",
|
||||
"size": "",
|
||||
"originalDate": "",
|
||||
"releaseDate": "",
|
||||
"releases": "",
|
||||
"released": "",
|
||||
"recordLabel": "",
|
||||
"catalogNum": "",
|
||||
"releaseType": "Tipo",
|
||||
"releaseType": "",
|
||||
"grouping": "",
|
||||
"media": "",
|
||||
"mood": "Humoro",
|
||||
"date": "",
|
||||
"missing": ""
|
||||
"mood": "",
|
||||
"date": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Ludi",
|
||||
@@ -84,44 +81,43 @@
|
||||
"shuffle": "Miksi",
|
||||
"addToPlaylist": "Aldoni al la Ludlisto",
|
||||
"download": "Elŝuti",
|
||||
"info": "Akiri Informon",
|
||||
"share": "Diskonigi"
|
||||
"info": "",
|
||||
"share": ""
|
||||
},
|
||||
"lists": {
|
||||
"all": "Ĉiuj",
|
||||
"random": "Hazardaj",
|
||||
"recentlyAdded": "Lastatempe Aldonitaj",
|
||||
"recentlyPlayed": "Lastatempe Luditaj",
|
||||
"random": "Hazarda",
|
||||
"recentlyAdded": "Lastatempe Aldonita",
|
||||
"recentlyPlayed": "Lastatempe Ludita",
|
||||
"mostPlayed": "Plej Luditaj",
|
||||
"starred": "Stelplenaj",
|
||||
"topRated": "Plej Alte Taksitaj"
|
||||
"starred": "Stelplena",
|
||||
"topRated": "Plej Alte Taksite"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artisto |||| Artistoj",
|
||||
"fields": {
|
||||
"name": "Nomo",
|
||||
"albumCount": "Kvanto da Albumoj",
|
||||
"songCount": "Kanta Kalkulo",
|
||||
"playCount": "Ludoj",
|
||||
"albumCount": "Nombro da albumoj",
|
||||
"songCount": "Kanto kalkula",
|
||||
"playCount": "Teatraĵoj",
|
||||
"rating": "Takso",
|
||||
"genre": "Ĝenro",
|
||||
"size": "Grando",
|
||||
"role": "",
|
||||
"missing": ""
|
||||
"genre": "",
|
||||
"size": "",
|
||||
"role": ""
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albuma Artisto |||| Albumaj Artistoj",
|
||||
"artist": "Artisto |||| Artistoj",
|
||||
"composer": "Komponisto |||| Komponistoj",
|
||||
"conductor": "Dirigento |||| Dirigentoj",
|
||||
"lyricist": "Kantoteksisto |||| Kantotekstistoj",
|
||||
"arranger": "Aranĝisto |||| Aranĝistoj",
|
||||
"albumartist": "",
|
||||
"artist": "",
|
||||
"composer": "",
|
||||
"conductor": "",
|
||||
"lyricist": "",
|
||||
"arranger": "",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"mixer": "Miksisto |||| Miksistoj",
|
||||
"remixer": "Remiksisto |||| Remiksistoj",
|
||||
"mixer": "",
|
||||
"remixer": "",
|
||||
"djmixer": "",
|
||||
"performer": ""
|
||||
}
|
||||
@@ -139,8 +135,8 @@
|
||||
"changePassword": "Ĉu Ŝanĝi Pasvorton?",
|
||||
"currentPassword": "Nuna Pasvorto",
|
||||
"newPassword": "Nova Pasvorto",
|
||||
"token": "Ĵetono",
|
||||
"lastAccessAt": "Lasta Atingo"
|
||||
"token": "",
|
||||
"lastAccessAt": ""
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto"
|
||||
@@ -151,8 +147,8 @@
|
||||
"deleted": "Uzanto forigita"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Enigi vian uzantan ĵetonon de ListenBrainz.",
|
||||
"clickHereForToken": "Alkakli ĉi tie por akiri vian ĵetonon"
|
||||
"listenBrainzToken": "",
|
||||
"clickHereForToken": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -165,7 +161,7 @@
|
||||
"userName": "Uzantnomo",
|
||||
"lastSeen": "Laste Vidita Je",
|
||||
"reportRealPath": "Raporti vera pado",
|
||||
"scrobbleEnabled": "Sendi Scrobbles al eksteraj servoj"
|
||||
"scrobbleEnabled": ""
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
@@ -195,9 +191,8 @@
|
||||
"selectPlaylist": "Elektu ludliston :",
|
||||
"addNewPlaylist": "Krei \"%{name}\"",
|
||||
"export": "Eksporti",
|
||||
"makePublic": "Publikigi",
|
||||
"makePrivate": "Malpublikigi",
|
||||
"saveQueue": ""
|
||||
"makePublic": "",
|
||||
"makePrivate": ""
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Aldoni duobligitajn kantojn",
|
||||
@@ -205,33 +200,33 @@
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "Radio |||| Radioj",
|
||||
"name": "",
|
||||
"fields": {
|
||||
"name": "Nomo",
|
||||
"streamUrl": "Flua Ligilo",
|
||||
"homePageUrl": "Hejmpaĝa Ligilo",
|
||||
"updatedAt": "Ĝisdatiĝis je",
|
||||
"createdAt": "Kreiĝis je"
|
||||
"name": "",
|
||||
"streamUrl": "",
|
||||
"homePageUrl": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": ""
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "Ludi Nun"
|
||||
"playNow": ""
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Diskonigo |||| Diskonigoj",
|
||||
"name": "",
|
||||
"fields": {
|
||||
"username": "Diskonigite De",
|
||||
"url": "Ligilo",
|
||||
"description": "Priskribo",
|
||||
"contents": "Enhavo",
|
||||
"expiresAt": "Senvalidiĝas",
|
||||
"lastVisitedAt": "Laste Vizitita",
|
||||
"visitCount": "Vizitoj",
|
||||
"format": "Formato",
|
||||
"maxBitRate": "Maks. Bitrapido",
|
||||
"updatedAt": "Ĝisdatiĝis je",
|
||||
"createdAt": "Fariĝis je",
|
||||
"downloadable": "Ĉu Ebligi Elŝutojn?"
|
||||
"username": "",
|
||||
"url": "",
|
||||
"description": "",
|
||||
"contents": "",
|
||||
"expiresAt": "",
|
||||
"lastVisitedAt": "",
|
||||
"visitCount": "",
|
||||
"format": "",
|
||||
"maxBitRate": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": "",
|
||||
"downloadable": ""
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@@ -242,8 +237,7 @@
|
||||
"updatedAt": ""
|
||||
},
|
||||
"actions": {
|
||||
"remove": "",
|
||||
"remove_all": ""
|
||||
"remove": ""
|
||||
},
|
||||
"notifications": {
|
||||
"removed": ""
|
||||
@@ -264,7 +258,7 @@
|
||||
"sign_in": "Ensaluti",
|
||||
"sign_in_error": "Aŭtentikigo malsukcesis, bonvolu reprovi",
|
||||
"logout": "Elsaluti",
|
||||
"insightsCollectionNote": "Navidrome kolektas anoniman uzdatumon por helpi\nplibonigi la projekton. Alklaku [ĉi tie] por lerni pli kaj\nsupozi permeson se vi volas"
|
||||
"insightsCollectionNote": ""
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Bonvolu uzi nur literojn kaj ciferojn",
|
||||
@@ -279,7 +273,7 @@
|
||||
"oneOf": "Devas esti unu el: %{options}",
|
||||
"regex": "Devas kongrui kun specifa formato (regexp): %{pattern}",
|
||||
"unique": "Devas esti unika",
|
||||
"url": "Devas esti valida ligilo"
|
||||
"url": ""
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Aldoni filtrilon",
|
||||
@@ -309,9 +303,9 @@
|
||||
"close_menu": "Fermu menuon",
|
||||
"unselect": "Malelekti",
|
||||
"skip": "Pasigi",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Diskonigi",
|
||||
"download": "Elŝuti"
|
||||
"bulk_actions_mobile": "",
|
||||
"share": "",
|
||||
"download": ""
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Jes",
|
||||
@@ -387,13 +381,13 @@
|
||||
"i18n_error": "Ne eblas ŝargi la tradukojn por la specifa lingvo",
|
||||
"canceled": "Ago nuligita",
|
||||
"logged_out": "Via seanco finiĝis, bonvolu rekonekti.",
|
||||
"new_version": "Nova versio haveblas! Bonvolu reŝargi la fenestron."
|
||||
"new_version": ""
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Kolumnoj Por Montri",
|
||||
"columnsToDisplay": "",
|
||||
"layout": "Aranĝo",
|
||||
"grid": "Krado",
|
||||
"table": "Tabelo"
|
||||
"table": ""
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
@@ -406,31 +400,29 @@
|
||||
"delete_user_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun uzanton kaj ĉiujn iliajn datumojn (inkluzive ludlistojn kaj preferojn) ?",
|
||||
"notifications_blocked": "Vi blokis sciigojn por ĉi tiu retejo en la agordoj de via retumilo",
|
||||
"notifications_not_available": "Ĉi tiu retumilo ne subtenas labortablajn sciigojn aŭ vi ne aliras Navidrome per https",
|
||||
"lastfmLinkSuccess": "Last.fm sukcese ligiĝis kaj scrobbling ebliĝis",
|
||||
"lastfmLinkFailure": "Last.fm ne povis ligiĝi",
|
||||
"lastfmUnlinkSuccess": "Last.fm malligiĝis kaj scrobbling malebliĝis",
|
||||
"lastfmUnlinkFailure": "Last.fm ne povis malligiĝi",
|
||||
"lastfmLinkSuccess": "",
|
||||
"lastfmLinkFailure": "",
|
||||
"lastfmUnlinkSuccess": "",
|
||||
"lastfmUnlinkFailure": "",
|
||||
"openIn": {
|
||||
"lastfm": "Malfermi en Last.fm",
|
||||
"musicbrainz": "Malfermi en MusicBrainz"
|
||||
"lastfm": "",
|
||||
"musicbrainz": ""
|
||||
},
|
||||
"lastfmLink": "Legi Pli...",
|
||||
"listenBrainzLinkSuccess": "ListenBrainz sukcese ligiĝis kaj scrobbling ebliĝis kiel uzanto: %{user}",
|
||||
"listenBrainzLinkFailure": "ListenBrainz ne povis ligiĝi: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz malligiĝis kaj scrobbling malebliĝis",
|
||||
"listenBrainzUnlinkFailure": "ListenBrainz ne povis malligiĝi",
|
||||
"downloadOriginalFormat": "Elŝuti en originala formato",
|
||||
"shareOriginalFormat": "Diskonigi en originala formato",
|
||||
"shareDialogTitle": "Diskonigi %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Diskonigi 1 %{resource} |||| Diskonigi %{smart_count} %{resource}",
|
||||
"shareSuccess": "Ligilo kopiiĝis al la tondujo: %{url}",
|
||||
"shareFailure": "Eraro de kopio de ligilo %{url} al la tondujo",
|
||||
"downloadDialogTitle": "Elŝuti %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Kopii al la tondujo: Ctrl+C, Enter",
|
||||
"lastfmLink": "",
|
||||
"listenBrainzLinkSuccess": "",
|
||||
"listenBrainzLinkFailure": "",
|
||||
"listenBrainzUnlinkSuccess": "",
|
||||
"listenBrainzUnlinkFailure": "",
|
||||
"downloadOriginalFormat": "",
|
||||
"shareOriginalFormat": "",
|
||||
"shareDialogTitle": "",
|
||||
"shareBatchDialogTitle": "",
|
||||
"shareSuccess": "",
|
||||
"shareFailure": "",
|
||||
"downloadDialogTitle": "",
|
||||
"shareCopyToClipboard": "",
|
||||
"remove_missing_title": "",
|
||||
"remove_missing_content": "Ĉu vi certas, ke vi volas forigi la elektitajn mankajn dosierojn de la datumbazo? Ĉi tio forigos eterne ĉiujn referencojn de ili, inkluzive iliajn ludkvantojn kaj taksojn.",
|
||||
"remove_all_missing_title": "",
|
||||
"remove_all_missing_content": ""
|
||||
"remove_missing_content": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteko",
|
||||
@@ -444,22 +436,22 @@
|
||||
"language": "Lingvo",
|
||||
"defaultView": "Defaŭlta Vido",
|
||||
"desktop_notifications": "Labortablaj sciigoj",
|
||||
"lastfmScrobbling": "Scrobble al Last.fm",
|
||||
"listenBrainzScrobbling": "Scrobble al ListenBrainz",
|
||||
"replaygain": "ReplayGain-Reĝimo",
|
||||
"preAmp": "ReplayGain PreAmp (dB)",
|
||||
"lastfmScrobbling": "",
|
||||
"listenBrainzScrobbling": "",
|
||||
"replaygain": "",
|
||||
"preAmp": "",
|
||||
"gain": {
|
||||
"none": "Malebligita",
|
||||
"album": "Uzi Albuman Songajnon",
|
||||
"track": "Uzi Kantan Songajnon"
|
||||
"none": "",
|
||||
"album": "",
|
||||
"track": ""
|
||||
},
|
||||
"lastfmNotConfigured": ""
|
||||
}
|
||||
},
|
||||
"albumList": "Albumoj",
|
||||
"about": "Pri",
|
||||
"playlists": "Ludlistoj",
|
||||
"sharedPlaylists": "Diskonigitaj Ludistoj"
|
||||
"playlists": "",
|
||||
"sharedPlaylists": ""
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Atendovico",
|
||||
@@ -493,7 +485,7 @@
|
||||
"featureRequests": "Trajta peto",
|
||||
"lastInsightsCollection": "",
|
||||
"insights": {
|
||||
"disabled": "Malebligita",
|
||||
"disabled": "",
|
||||
"waiting": ""
|
||||
}
|
||||
}
|
||||
@@ -504,10 +496,7 @@
|
||||
"quickScan": "Rapida Skanado",
|
||||
"fullScan": "Plena Skanado",
|
||||
"serverUptime": "Servila daŭro de funkciado",
|
||||
"serverDown": "SENKONEKTA",
|
||||
"scanType": "",
|
||||
"status": "",
|
||||
"elapsedTime": ""
|
||||
"serverDown": "SENKONEKTA"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome klavkomando",
|
||||
@@ -520,7 +509,7 @@
|
||||
"vol_up": "Pli volumo",
|
||||
"vol_down": "Malpli volumo",
|
||||
"toggle_love": "Baskuli la stelon de nuna kanto",
|
||||
"current_song": "Iri al Nuna Kanto"
|
||||
"current_song": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,19 +28,16 @@
|
||||
"channels": "Canales",
|
||||
"createdAt": "Creado el",
|
||||
"grouping": "Agrupación",
|
||||
"mood": "Estado de ánimo",
|
||||
"mood": "",
|
||||
"participants": "Participantes",
|
||||
"tags": "Etiquetas",
|
||||
"mappedTags": "Etiquetas asignadas",
|
||||
"rawTags": "Etiquetas sin procesar",
|
||||
"bitDepth": "Profundidad de bits",
|
||||
"sampleRate": "Frecuencia de muestreo",
|
||||
"missing": "Faltante"
|
||||
"rawTags": "Etiquetas sin procesar"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Reproducir después",
|
||||
"playNow": "Reproducir ahora",
|
||||
"addToPlaylist": "Agregar a la playlist",
|
||||
"addToPlaylist": "Agregar a la lista de reproducción",
|
||||
"shuffleAll": "Todas aleatorias",
|
||||
"download": "Descarga",
|
||||
"playNext": "Siguiente",
|
||||
@@ -72,10 +69,8 @@
|
||||
"catalogNum": "Número de catálogo",
|
||||
"releaseType": "Tipo de lanzamiento",
|
||||
"grouping": "Agrupación",
|
||||
"media": "Medios",
|
||||
"mood": "Estado de ánimo",
|
||||
"date": "Fecha de grabación",
|
||||
"missing": "Faltante"
|
||||
"media": "",
|
||||
"mood": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Reproducir",
|
||||
@@ -94,7 +89,7 @@
|
||||
"recentlyPlayed": "Recientes",
|
||||
"mostPlayed": "Más reproducidos",
|
||||
"starred": "Favoritos",
|
||||
"topRated": "Mejor calificados"
|
||||
"topRated": "Los mejores calificados"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
@@ -107,8 +102,7 @@
|
||||
"rating": "Calificación",
|
||||
"genre": "Género",
|
||||
"size": "Tamaño",
|
||||
"role": "Rol",
|
||||
"missing": "Faltante"
|
||||
"role": "Rol"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artista del álbum",
|
||||
@@ -196,12 +190,11 @@
|
||||
"addNewPlaylist": "Creada \"%{name}\"",
|
||||
"export": "Exportar",
|
||||
"makePublic": "Hazla pública",
|
||||
"makePrivate": "Hazla privada",
|
||||
"saveQueue": "Guardar la fila de reproducción en una playlist"
|
||||
"makePrivate": "Hazla privada"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Algunas de las canciones seleccionadas están presentes en la playlist",
|
||||
"song_exist": "Se están agregando duplicados a la playlist. ¿Quieres agregar los duplicados o omitirlos?"
|
||||
"duplicate_song": "Algunas de las canciones seleccionadas están presentes en la lista de reproducción",
|
||||
"song_exist": "Se están agregando duplicados a la lista de reproducción. ¿Quieres agregar los duplicados o omitirlos?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -242,13 +235,11 @@
|
||||
"updatedAt": "Actualizado el"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Eliminar",
|
||||
"remove_all": "Eliminar todo"
|
||||
"remove": "Eliminar"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Eliminado"
|
||||
},
|
||||
"empty": "No hay archivos perdidos"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -428,9 +419,7 @@
|
||||
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro",
|
||||
"remove_missing_title": "Eliminar elemento faltante",
|
||||
"remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
|
||||
"remove_all_missing_title": "Eliminar todos los archivos perdidos",
|
||||
"remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones."
|
||||
"remove_missing_content": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
@@ -462,7 +451,7 @@
|
||||
"sharedPlaylists": "Playlists Compartidas"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Fila de reproducción",
|
||||
"playListsText": "Lista de reproducción",
|
||||
"openText": "Abrir",
|
||||
"closeText": "Cerrar",
|
||||
"notContentText": "Sin música",
|
||||
@@ -504,10 +493,7 @@
|
||||
"quickScan": "Escaneo rápido",
|
||||
"fullScan": "Escaneo completo",
|
||||
"serverUptime": "Uptime del servidor",
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Tipo",
|
||||
"status": "Error de escaneo",
|
||||
"elapsedTime": "Tiempo transcurrido"
|
||||
"serverDown": "OFFLINE"
|
||||
},
|
||||
"help": {
|
||||
"title": "Atajos de teclado de Navidrome",
|
||||
|
||||
@@ -17,10 +17,7 @@
|
||||
"year": "Urtea",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"updatedAt": "Eguneratze-data:",
|
||||
"bitRate": "Bit-tasa",
|
||||
"bitDepth": "Bit-sakonera",
|
||||
"sampleRate": "Lagin-tasa",
|
||||
"channels": "Kanalak",
|
||||
"bitRate": "Bit tasa",
|
||||
"discSubtitle": "Diskoaren azpititulua",
|
||||
"starred": "Gogokoa",
|
||||
"comment": "Iruzkina",
|
||||
@@ -28,13 +25,14 @@
|
||||
"quality": "Kalitatea",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Azkenekoz erreproduzitua:",
|
||||
"channels": "Kanalak",
|
||||
"createdAt": "Gehitu zen data:",
|
||||
"grouping": "Multzokatzea",
|
||||
"mood": "Aldartea",
|
||||
"participants": "Partaide gehiago",
|
||||
"tags": "Traola gehiago",
|
||||
"mappedTags": "Esleitutako traolak",
|
||||
"rawTags": "Traola gordinak"
|
||||
"grouping": "",
|
||||
"mood": "",
|
||||
"participants": "",
|
||||
"tags": "",
|
||||
"mappedTags": "",
|
||||
"rawTags": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Erreproduzitu ondoren",
|
||||
@@ -54,26 +52,25 @@
|
||||
"duration": "Iraupena",
|
||||
"songCount": "abesti",
|
||||
"playCount": "Erreprodukzioak",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"name": "Izena",
|
||||
"genre": "Generoa",
|
||||
"compilation": "Konpilazioa",
|
||||
"year": "Urtea",
|
||||
"date": "Recording Date",
|
||||
"originalDate": "Jatorrizkoa",
|
||||
"releaseDate": "Argitaratze-data:",
|
||||
"releases": "Argitaratzea |||| Argitaratzeak",
|
||||
"released": "Argitaratua",
|
||||
"updatedAt": "Aktualizatze-data:",
|
||||
"comment": "Iruzkina",
|
||||
"rating": "Balorazioa",
|
||||
"createdAt": "Gehitu zen data:",
|
||||
"recordLabel": "Disketxea",
|
||||
"catalogNum": "Katalogo-zenbakia",
|
||||
"releaseType": "Mota",
|
||||
"grouping": "Multzokatzea",
|
||||
"media": "Multimedia",
|
||||
"mood": "Aldartea"
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"originalDate": "Jatorrizkoa",
|
||||
"releaseDate": "Argitaratze-data:",
|
||||
"releases": "Argitaratzea |||| Argitaratzeak",
|
||||
"released": "Argitaratua",
|
||||
"recordLabel": "",
|
||||
"catalogNum": "",
|
||||
"releaseType": "",
|
||||
"grouping": "",
|
||||
"media": "",
|
||||
"mood": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Erreproduzitu",
|
||||
@@ -87,7 +84,7 @@
|
||||
},
|
||||
"lists": {
|
||||
"all": "Guztiak",
|
||||
"random": "Aleatorioki",
|
||||
"random": "Aleatorioa",
|
||||
"recentlyAdded": "Berriki gehitutakoak",
|
||||
"recentlyPlayed": "Berriki entzundakoak",
|
||||
"mostPlayed": "Gehien entzundakoak",
|
||||
@@ -101,26 +98,26 @@
|
||||
"name": "Izena",
|
||||
"albumCount": "Album kopurua",
|
||||
"songCount": "Abesti kopurua",
|
||||
"size": "Tamaina",
|
||||
"playCount": "Erreprodukzio kopurua",
|
||||
"rating": "Balorazioa",
|
||||
"genre": "Generoa",
|
||||
"role": "Rola"
|
||||
"size": "Tamaina",
|
||||
"role": ""
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albumeko egilea |||| Albumeko artistak",
|
||||
"artist": "Artista |||| Artistak",
|
||||
"composer": "Konpositorea |||| Konpositoreak",
|
||||
"conductor": "Orkestra zuzendaria |||| Orkestra zuzendariak",
|
||||
"lyricist": "Hitzen egilea |||| Hitzen egileak",
|
||||
"arranger": "Moldatzailea |||| Moldatzaileak",
|
||||
"producer": "Produktorea |||| Produktoreak",
|
||||
"director": "Zuzendaria |||| Zuzendaria",
|
||||
"engineer": "Teknikaria |||| Teknikariak",
|
||||
"mixer": "Nahaslea |||| Nahasleak",
|
||||
"remixer": "Remixerra |||| Remixerrak",
|
||||
"djmixer": "DJ nahaslea |||| DJ nahasleak",
|
||||
"performer": "Interpretatzailea |||| Interpretatzaileak"
|
||||
"albumartist": "",
|
||||
"artist": "",
|
||||
"composer": "",
|
||||
"conductor": "",
|
||||
"lyricist": "",
|
||||
"arranger": "",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"mixer": "",
|
||||
"remixer": "",
|
||||
"djmixer": "",
|
||||
"performer": ""
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -241,8 +238,7 @@
|
||||
"updatedAt": "Desagertze-data:"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Kendu",
|
||||
"remove_all": "Kendu guztia"
|
||||
"remove": "Kendu"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Faltan zeuden fitxategiak kendu dira"
|
||||
@@ -262,7 +258,7 @@
|
||||
"sign_in": "Sartu",
|
||||
"sign_in_error": "Autentifikazioak huts egin du, saiatu berriro",
|
||||
"logout": "Amaitu saioa",
|
||||
"insightsCollectionNote": "Navidromek erabilera-datu anonimoak biltzen ditu\nproiektua hobetzen laguntzeko. Egin klik [hemen]\ngehiago ikasteko, eta datuak ez biltzeko eskatzeko,\nhala nahi izanez gero."
|
||||
"insightsCollectionNote": ""
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Erabili hizkiak eta zenbakiak bakarrik",
|
||||
@@ -402,33 +398,31 @@
|
||||
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
|
||||
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",
|
||||
"delete_user_content": "Ziur zaide erabiltzaile hau eta bere datu guztiak (zerrendak eta hobespenak barne) ezabatu nahi dituzula?",
|
||||
"remove_missing_title": "Kendu faltan dauden fitxategiak",
|
||||
"remove_missing_content": "Ziur hautatutako fitxategiak datu-basetik kendu nahi dituzula? Betiko kenduko dira haiei buruzko erreferentziak, erreprodukzio-zenbaketak eta sailkapenak barne.",
|
||||
"remove_all_missing_title": "Kendu faltan dauden fitxategi guztiak",
|
||||
"remove_all_missing_content": "Ziur aurkitu ez diren fitxategi guztiak datu-basetik kendu nahi dituzula? Betiko kenduko dira haiei buruzko erreferentziak, erreprodukzio-zenbaketak eta sailkapenak barne.",
|
||||
"notifications_blocked": "Nabigatzaileak jakinarazpenak blokeatzen ditu",
|
||||
"notifications_not_available": "Nabigatzaile hau ez da jakinarazpenekin bateragarria edo Navidrome ez da HTTPS erabiltzen ari",
|
||||
"lastfmLinkSuccess": "Last.fm konektatuta dago eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea gaituta dago",
|
||||
"lastfmLinkFailure": "Ezin izan da Last.fm-rekin konektatu",
|
||||
"lastfmUnlinkSuccess": "Last.fm deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea ezgaitu da",
|
||||
"lastfmUnlinkFailure": "Ezin izan da Last.fm deskonektatu",
|
||||
"listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da",
|
||||
"listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da",
|
||||
"listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu",
|
||||
"openIn": {
|
||||
"lastfm": "Ikusi Last.fm-n",
|
||||
"musicbrainz": "Ikusi MusicBrainz-en"
|
||||
},
|
||||
"lastfmLink": "Irakurri gehiago…",
|
||||
"listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da",
|
||||
"listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da",
|
||||
"listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu",
|
||||
"downloadOriginalFormat": "Deskargatu jatorrizko formatua",
|
||||
"shareOriginalFormat": "Partekatu jatorrizko formatua",
|
||||
"shareDialogTitle": "Partekatu '%{name}' %{resource}",
|
||||
"shareBatchDialogTitle": "Partekatu %{resource} bat |||| Partekatu %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla",
|
||||
"shareSuccess": "URLa arbelera kopiatu da: %{url}",
|
||||
"shareFailure": "Errorea %{url} URLa arbelera kopiatzean",
|
||||
"downloadDialogTitle": "Deskargatu '%{name}' %{resource}, (%{size})",
|
||||
"downloadOriginalFormat": "Deskargatu jatorrizko formatua"
|
||||
"shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla",
|
||||
"remove_missing_title": "",
|
||||
"remove_missing_content": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Liburutegia",
|
||||
@@ -442,7 +436,6 @@
|
||||
"language": "Hizkuntza",
|
||||
"defaultView": "Bista, defektuz",
|
||||
"desktop_notifications": "Mahaigaineko jakinarazpenak",
|
||||
"lastfmNotConfigured": "Last.fm-ren API-gakoa ez dago konfiguratuta",
|
||||
"lastfmScrobbling": "Bidali Last.fm-ra erabiltzailearen ohiturak",
|
||||
"listenBrainzScrobbling": "Bidali ListenBrainz-era erabiltzailearen ohiturak",
|
||||
"replaygain": "ReplayGain modua",
|
||||
@@ -451,13 +444,14 @@
|
||||
"none": "Bat ere ez",
|
||||
"album": "Albuma",
|
||||
"track": "Pista"
|
||||
}
|
||||
},
|
||||
"lastfmNotConfigured": ""
|
||||
}
|
||||
},
|
||||
"albumList": "Albumak",
|
||||
"about": "Honi buruz",
|
||||
"playlists": "Zerrendak",
|
||||
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
|
||||
"about": "Honi buruz"
|
||||
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Erreprodukzio-zerrenda",
|
||||
@@ -489,10 +483,10 @@
|
||||
"homepage": "Hasierako orria",
|
||||
"source": "Iturburu kodea",
|
||||
"featureRequests": "Eskatu ezaugarria",
|
||||
"lastInsightsCollection": "Bildutako azken datuak",
|
||||
"lastInsightsCollection": "",
|
||||
"insights": {
|
||||
"disabled": "Ezgaituta",
|
||||
"waiting": "Zain"
|
||||
"disabled": "",
|
||||
"waiting": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -502,10 +496,7 @@
|
||||
"quickScan": "Arakatze azkarra",
|
||||
"fullScan": "Arakatze sakona",
|
||||
"serverUptime": "Zerbitzariak piztuta daraman denbora",
|
||||
"serverDown": "LINEAZ KANPO",
|
||||
"scanType": "Mota",
|
||||
"status": "Errorea arakatzean",
|
||||
"elapsedTime": "Igarotako denbora"
|
||||
"serverDown": "LINEAZ KANPO"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidromeren laster-teklak",
|
||||
|
||||
@@ -32,10 +32,7 @@
|
||||
"participants": "Lisäosallistujat",
|
||||
"tags": "Lisätunnisteet",
|
||||
"mappedTags": "Mäpättyt tunnisteet",
|
||||
"rawTags": "Raakatunnisteet",
|
||||
"bitDepth": "Bittisyvyys",
|
||||
"sampleRate": "Näytteenottotaajuus",
|
||||
"missing": ""
|
||||
"rawTags": "Raakatunnisteet"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lisää jonoon",
|
||||
@@ -73,9 +70,7 @@
|
||||
"releaseType": "Tyyppi",
|
||||
"grouping": "Ryhmittely",
|
||||
"media": "Media",
|
||||
"mood": "Tunnelma",
|
||||
"date": "Tallennuspäivä",
|
||||
"missing": ""
|
||||
"mood": "Tunnelma"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Soita",
|
||||
@@ -107,8 +102,7 @@
|
||||
"rating": "Arvostelu",
|
||||
"genre": "Tyylilaji",
|
||||
"size": "Koko",
|
||||
"role": "Rooli",
|
||||
"missing": ""
|
||||
"role": "Rooli"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albumitaiteilija |||| Albumitaiteilijat",
|
||||
@@ -196,8 +190,7 @@
|
||||
"addNewPlaylist": "Luo \"%{name}\"",
|
||||
"export": "Vie",
|
||||
"makePublic": "Tee julkinen",
|
||||
"makePrivate": "Tee yksityinen",
|
||||
"saveQueue": ""
|
||||
"makePrivate": "Tee yksityinen"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Lisää olemassa oleva kappale",
|
||||
@@ -242,13 +235,11 @@
|
||||
"updatedAt": "Katosi"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Poista",
|
||||
"remove_all": ""
|
||||
"remove": "Poista"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Puuttuvat tiedostot poistettu"
|
||||
},
|
||||
"empty": "Ei puuttuvia tiedostoja"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -428,9 +419,7 @@
|
||||
"downloadDialogTitle": "Lataa %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Kopio leikepöydälle: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Poista puuttuvat tiedostot",
|
||||
"remove_missing_content": "Oletko varma, että haluat poistaa valitut puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien niiden soittojen määrät ja arvostelut.",
|
||||
"remove_all_missing_title": "",
|
||||
"remove_all_missing_content": ""
|
||||
"remove_missing_content": "Oletko varma, että haluat poistaa valitut puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien niiden soittojen määrät ja arvostelut."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Kirjasto",
|
||||
@@ -504,10 +493,7 @@
|
||||
"quickScan": "Nopea tarkistus",
|
||||
"fullScan": "Täysi tarkistus",
|
||||
"serverUptime": "Palvelun käyttöaika",
|
||||
"serverDown": "SAMMUTETTU",
|
||||
"scanType": "",
|
||||
"status": "",
|
||||
"elapsedTime": ""
|
||||
"serverDown": "SAMMUTETTU"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome pikapainikkeet",
|
||||
|
||||
@@ -33,9 +33,7 @@
|
||||
"tags": "Étiquettes supplémentaires",
|
||||
"mappedTags": "Étiquettes correspondantes",
|
||||
"rawTags": "Étiquettes brutes",
|
||||
"bitDepth": "Profondeur de bits",
|
||||
"sampleRate": "Fréquence d'échantillonnage",
|
||||
"missing": "Manquant"
|
||||
"bitDepth": "Profondeur de bit"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ajouter à la file",
|
||||
@@ -73,9 +71,7 @@
|
||||
"releaseType": "Type",
|
||||
"grouping": "Regroupement",
|
||||
"media": "Média",
|
||||
"mood": "Humeur",
|
||||
"date": "Date d'enregistrement",
|
||||
"missing": "Manquant"
|
||||
"mood": "Humeur"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Lire",
|
||||
@@ -107,8 +103,7 @@
|
||||
"rating": "Classement",
|
||||
"genre": "Genre",
|
||||
"size": "Taille",
|
||||
"role": "Rôle",
|
||||
"missing": "Manquant"
|
||||
"role": "Rôle"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artiste de l'album |||| Artistes de l'album",
|
||||
@@ -196,8 +191,7 @@
|
||||
"addNewPlaylist": "Créer \"%{name}\"",
|
||||
"export": "Exporter",
|
||||
"makePublic": "Rendre publique",
|
||||
"makePrivate": "Rendre privée",
|
||||
"saveQueue": "Sauvegarder la file de lecture dans la playlist"
|
||||
"makePrivate": "Rendre privée"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Pistes déjà présentes dans la playlist",
|
||||
@@ -242,8 +236,7 @@
|
||||
"updatedAt": "A disparu le"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Supprimer",
|
||||
"remove_all": "Tout supprimer"
|
||||
"remove": "Supprimer"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Fichier(s) manquant(s) supprimé(s)"
|
||||
@@ -428,9 +421,7 @@
|
||||
"downloadDialogTitle": "Télécharger %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Copier vers le presse-papier : Ctrl+C, Enter",
|
||||
"remove_missing_title": "Supprimer les fichiers manquants",
|
||||
"remove_missing_content": "Êtes-vous sûr(e) de vouloir supprimer les fichiers manquants sélectionnés de la base de données ? Ceci supprimera définitivement toute référence à ceux-ci, y compris leurs nombres d'écoutes et leurs notations",
|
||||
"remove_all_missing_title": "Supprimer tous les fichiers manquants",
|
||||
"remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence."
|
||||
"remove_missing_content": "Êtes-vous sûr(e) de vouloir supprimer les fichiers manquants sélectionnés de la base de données ? Ceci supprimera définitivement toute référence à ceux-ci, y compris leurs nombres d'écoutes et leurs notations"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothèque",
|
||||
@@ -504,10 +495,7 @@
|
||||
"quickScan": "Scan rapide",
|
||||
"fullScan": "Scan complet",
|
||||
"serverUptime": "Disponibilité du serveur",
|
||||
"serverDown": "HORS LIGNE",
|
||||
"scanType": "Type",
|
||||
"status": "Erreur de scan",
|
||||
"elapsedTime": "Temps écoulé"
|
||||
"serverDown": "HORS LIGNE"
|
||||
},
|
||||
"help": {
|
||||
"title": "Raccourcis Navidrome",
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
"size": "Fájlméret",
|
||||
"updatedAt": "Legutóbb frissítve",
|
||||
"bitRate": "Bitráta",
|
||||
"bitDepth": "Bitmélység",
|
||||
"sampleRate": "Mintavételezési frekvencia",
|
||||
"discSubtitle": "Lemezfelirat",
|
||||
"starred": "Kedvenc",
|
||||
"comment": "Megjegyzés",
|
||||
@@ -34,8 +32,7 @@
|
||||
"participants": "További résztvevők",
|
||||
"tags": "További címkék",
|
||||
"mappedTags": "Feldolgozott címkék",
|
||||
"rawTags": "Nyers címkék",
|
||||
"missing": "Hiányzó"
|
||||
"rawTags": "Nyers címkék"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lejátszás útolsóként",
|
||||
@@ -59,7 +56,6 @@
|
||||
"genre": "Stílus",
|
||||
"compilation": "Válogatásalbum",
|
||||
"year": "Év",
|
||||
"date": "Felvétel dátuma",
|
||||
"updatedAt": "Legutóbb frissítve",
|
||||
"comment": "Megjegyzés",
|
||||
"rating": "Értékelés",
|
||||
@@ -74,8 +70,7 @@
|
||||
"releaseType": "Típus",
|
||||
"grouping": "Csoportosítás",
|
||||
"media": "Média",
|
||||
"mood": "Hangulat",
|
||||
"missing": "Hiányzó"
|
||||
"mood": "Hangulat"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Lejátszás",
|
||||
@@ -107,8 +102,7 @@
|
||||
"rating": "Értékelés",
|
||||
"genre": "Stílus",
|
||||
"size": "Méret",
|
||||
"role": "Szerep",
|
||||
"missing": "Hiányzó"
|
||||
"role": "Szerep"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Album előadó |||| Album előadók",
|
||||
@@ -195,7 +189,6 @@
|
||||
"selectPlaylist": "Válassz egy lejátszási listát:",
|
||||
"addNewPlaylist": "\"%{name}\" létrehozása",
|
||||
"export": "Exportálás",
|
||||
"saveQueue": "Műsorlista elmentése lejátszási listaként",
|
||||
"makePublic": "Publikussá tétel",
|
||||
"makePrivate": "Priváttá tétel"
|
||||
},
|
||||
@@ -236,15 +229,13 @@
|
||||
},
|
||||
"missing": {
|
||||
"name": "Hiányzó fájl|||| Hiányzó fájlok",
|
||||
"empty": "Nincsenek hiányzó fájlok",
|
||||
"fields": {
|
||||
"path": "Útvonal",
|
||||
"size": "Méret",
|
||||
"updatedAt": "Eltűnt ekkor:"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Eltávolítás",
|
||||
"remove_all": "Összes eltávolítása"
|
||||
"remove": "Eltávolítás"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Hiányzó fájl(ok) eltávolítva"
|
||||
@@ -404,8 +395,6 @@
|
||||
"noPlaylistsAvailable": "Nem áll rendelkezésre",
|
||||
"delete_user_title": "Felhasználó törlése '%{name}'",
|
||||
"delete_user_content": "Biztos, hogy törölni akarod ezt a felhasználót az adataival (beállítások és lejátszási listák) együtt?",
|
||||
"remove_all_missing_title": "Összes hiányzó fájl eltávolítása",
|
||||
"remove_all_missing_content": "Biztos, hogy minden hiányzó fájlt törölni akarsz az adatbázisból? Ez minden hozzájuk fűződő referenciát törölni fog, beleértve a lejátszásaikat és értékeléseiket.",
|
||||
"notifications_blocked": "A böngésződ beállításaiban letiltottad az értesítéseket erre az oldalra.",
|
||||
"notifications_not_available": "Ez a böngésző nem támogatja az asztali értesítéseket, vagy a Navidrome-ot nem https-en keresztül használod.",
|
||||
"lastfmLinkSuccess": "Sikeresen összekapcsolva Last.fm-el és halgatott számok küldése engedélyezve.",
|
||||
@@ -417,7 +406,7 @@
|
||||
"musicbrainz": "Megnyitás MusicBrainz-ben"
|
||||
},
|
||||
"lastfmLink": "Bővebben...",
|
||||
"listenBrainzLinkSuccess": "Sikeresen összekapcsolva ListenBrainz-el. Halgatott számok küldése %{user} felhasználónak engedélyezve.",
|
||||
"listenBrainzLinkSuccess": "Sikeresen összekapcsolva ListenBrainz-el és halgatott számok küldése %{user} felhasználónak engedélyezve.",
|
||||
"listenBrainzLinkFailure": "Nem lehet kapcsolódni a Listenbrainz-hez: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz Last.fm leválasztva és a halgatott számok küldése kikapcsolva.",
|
||||
"listenBrainzUnlinkFailure": "Nem sikerült leválasztani a ListenBrainz-et.",
|
||||
@@ -462,7 +451,7 @@
|
||||
"sharedPlaylists": "Megosztott lej. listák"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Műsorlista",
|
||||
"playListsText": "Lejátszási lista",
|
||||
"openText": "Megnyitás",
|
||||
"closeText": "Bezárás",
|
||||
"notContentText": "Nincs zene",
|
||||
@@ -504,10 +493,7 @@
|
||||
"quickScan": "Gyors beolvasás",
|
||||
"fullScan": "Teljes beolvasás",
|
||||
"serverUptime": "Szerver üzemidő",
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Típus",
|
||||
"status": "Szkennelési hiba",
|
||||
"elapsedTime": "Eltelt idő"
|
||||
"serverDown": "OFFLINE"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Gyorsbillentyűk",
|
||||
|
||||
@@ -32,10 +32,7 @@
|
||||
"participants": "Partisipan tambahan",
|
||||
"tags": "Tag tambahan",
|
||||
"mappedTags": "Tag yang dipetakan",
|
||||
"rawTags": "Tag raw",
|
||||
"bitDepth": "Bit depth",
|
||||
"sampleRate": "Sample rate",
|
||||
"missing": "Hilang"
|
||||
"rawTags": "Tag raw"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Tambah ke antrean",
|
||||
@@ -73,9 +70,7 @@
|
||||
"releaseType": "Tipe",
|
||||
"grouping": "Pengelompokkan",
|
||||
"media": "Media",
|
||||
"mood": "Mood",
|
||||
"date": "Tanggal Perekaman",
|
||||
"missing": "Hilang"
|
||||
"mood": "Mood"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Putar",
|
||||
@@ -107,8 +102,7 @@
|
||||
"rating": "Peringkat",
|
||||
"genre": "Genre",
|
||||
"size": "Ukuran",
|
||||
"role": "Peran",
|
||||
"missing": "Hilang"
|
||||
"role": "Peran"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artis Album |||| Artis Album",
|
||||
@@ -169,7 +163,7 @@
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Transkoding |||| Transkoding",
|
||||
"name": "Transkode |||| Transkode",
|
||||
"fields": {
|
||||
"name": "Nama",
|
||||
"targetFormat": "Target Format",
|
||||
@@ -196,8 +190,7 @@
|
||||
"addNewPlaylist": "Buat \"%{name}\"",
|
||||
"export": "Ekspor",
|
||||
"makePublic": "Jadikan Publik",
|
||||
"makePrivate": "Jadikan Pribadi",
|
||||
"saveQueue": "Simpan Antrean ke Playlist"
|
||||
"makePrivate": "Jadikan Pribadi"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Tambahkan lagu duplikat",
|
||||
@@ -242,13 +235,11 @@
|
||||
"updatedAt": "Tidak muncul di"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Hapus",
|
||||
"remove_all": "Hapus Semua"
|
||||
"remove": "Hapus"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "File yang hilang dihapus"
|
||||
},
|
||||
"empty": "Tidak ada File yang Hilang"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -286,7 +277,7 @@
|
||||
"add": "Tambah",
|
||||
"back": "Kembali",
|
||||
"bulk_actions": "1 item dipilih |||| %{smart_count} item dipilih",
|
||||
"cancel": "Batal",
|
||||
"cancel": "Batalkan",
|
||||
"clear_input_value": "Hapus",
|
||||
"clone": "Klon",
|
||||
"confirm": "Konfirmasi",
|
||||
@@ -301,7 +292,7 @@
|
||||
"save": "Simpan",
|
||||
"search": "Cari",
|
||||
"show": "Tampilkan",
|
||||
"sort": "Urutkan",
|
||||
"sort": "Sortir",
|
||||
"undo": "Batalkan",
|
||||
"expand": "Luaskan",
|
||||
"close": "Tutup",
|
||||
@@ -321,7 +312,7 @@
|
||||
"create": "Buat %{name}",
|
||||
"dashboard": "Dasbor",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Terjadi kesalahan",
|
||||
"error": "Ada yang tidak beres",
|
||||
"list": "%{name}",
|
||||
"loading": "Memuat",
|
||||
"not_found": "Tidak ditemukan",
|
||||
@@ -365,7 +356,7 @@
|
||||
"unsaved_changes": "Beberapa perubahan tidak disimpan. Apakah Kamu yakin ingin mengabaikannya?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Hasil tidak ditemukan",
|
||||
"no_results": "Tidak ada hasil yang ditemukan",
|
||||
"no_more_results": "Nomor halaman %{page} melampaui batas. Coba halaman sebelumnya.",
|
||||
"page_out_of_boundaries": "Nomor halaman %{page} melampaui batas",
|
||||
"page_out_from_end": "Tidak dapat menelusuri sebelum halaman terakhir",
|
||||
@@ -380,8 +371,8 @@
|
||||
"updated": "Elemen diperbarui |||| %{smart_count} elemen diperbarui",
|
||||
"created": "Elemen dibuat",
|
||||
"deleted": "Elemen dihapus |||| %{smart_count} elemen dihapus",
|
||||
"bad_item": "Kesalahan elemen",
|
||||
"item_doesnt_exist": "Elemen tidak ditemukan",
|
||||
"bad_item": "Elemen salah",
|
||||
"item_doesnt_exist": "Tidak ada elemen",
|
||||
"http_error": "Kesalahan komunikasi peladen",
|
||||
"data_provider_error": "dataProvider galat. Periksa konsol untuk detailnya.",
|
||||
"i18n_error": "Tidak dapat memuat terjemahan untuk bahasa yang diatur",
|
||||
@@ -428,9 +419,7 @@
|
||||
"downloadDialogTitle": "Unduh %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Salin ke papan klip: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Hapus file yang hilang",
|
||||
"remove_missing_content": "Apakah Anda yakin ingin menghapus file-file yang hilang dari basis data? Tindakan ini akan menghapus secara permanen semua referensi ke file-file tersebut, termasuk jumlah pemutaran dan peringkatnya.",
|
||||
"remove_all_missing_title": "Hapus semua file yang hilang",
|
||||
"remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka."
|
||||
"remove_missing_content": "Apakah Anda yakin ingin menghapus file-file yang hilang dari basis data? Tindakan ini akan menghapus secara permanen semua referensi ke file-file tersebut, termasuk jumlah pemutaran dan peringkatnya."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Pustaka",
|
||||
@@ -462,7 +451,7 @@
|
||||
"sharedPlaylists": "Playlist yang Dibagikan"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Putar Antrean",
|
||||
"playListsText": "Mainkan Antrean",
|
||||
"openText": "Buka",
|
||||
"closeText": "Tutup",
|
||||
"notContentText": "Tidak ada musik",
|
||||
@@ -482,7 +471,7 @@
|
||||
"playModeText": {
|
||||
"order": "Berurutan",
|
||||
"orderLoop": "Ulang",
|
||||
"singleLoop": "Ulangi Sekali",
|
||||
"singleLoop": "Ulangi Satu",
|
||||
"shufflePlay": "Acak"
|
||||
}
|
||||
},
|
||||
@@ -504,10 +493,7 @@
|
||||
"quickScan": "Pemindaian Cepat",
|
||||
"fullScan": "Pemindaian Penuh",
|
||||
"serverUptime": "Waktu Aktif Peladen",
|
||||
"serverDown": "LURING",
|
||||
"scanType": "Tipe",
|
||||
"status": "Kesalahan Memindai",
|
||||
"elapsedTime": "Waktu Berakhir"
|
||||
"serverDown": "LURING"
|
||||
},
|
||||
"help": {
|
||||
"title": "Tombol Pintasan Navidrome",
|
||||
|
||||
@@ -26,16 +26,7 @@
|
||||
"bpm": "BPM",
|
||||
"playDate": "Laatst afgespeeld",
|
||||
"channels": "Kanalen",
|
||||
"createdAt": "Datum toegevoegd",
|
||||
"grouping": "Groep",
|
||||
"mood": "Sfeer",
|
||||
"participants": "Extra deelnemers",
|
||||
"tags": "Extra tags",
|
||||
"mappedTags": "Gemapte tags",
|
||||
"rawTags": "Onbewerkte tags",
|
||||
"bitDepth": "Bit diepte",
|
||||
"sampleRate": "Sample waarde",
|
||||
"missing": "Ontbrekend"
|
||||
"createdAt": "Datum toegevoegd"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Voeg toe aan wachtrij",
|
||||
@@ -67,15 +58,7 @@
|
||||
"originalDate": "Origineel",
|
||||
"releaseDate": "Uitgegeven",
|
||||
"releases": "Uitgave |||| Uitgaven",
|
||||
"released": "Uitgegeven",
|
||||
"recordLabel": "Label",
|
||||
"catalogNum": "Catalogus nummer",
|
||||
"releaseType": "Type",
|
||||
"grouping": "Groep",
|
||||
"media": "Media",
|
||||
"mood": "Sfeer",
|
||||
"date": "Opnamedatum",
|
||||
"missing": "Ontbrekend"
|
||||
"released": "Uitgegeven"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Afspelen",
|
||||
@@ -106,24 +89,7 @@
|
||||
"playCount": "Afgespeeld",
|
||||
"rating": "Beoordeling",
|
||||
"genre": "Genre",
|
||||
"size": "Grootte",
|
||||
"role": "Rol",
|
||||
"missing": "Ontbrekend"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Album artiest |||| Album artiesten",
|
||||
"artist": "Artiest |||| Artiesten",
|
||||
"composer": "Componist |||| Componisten",
|
||||
"conductor": "Dirigent |||| Dirigenten",
|
||||
"lyricist": "Tekstschrijver |||| Tekstschrijvers",
|
||||
"arranger": "Arrangeur |||| Arrangeurs",
|
||||
"producer": "Producent |||| Producenten",
|
||||
"director": "Regisseur |||| Regisseurs",
|
||||
"engineer": "Opnametechnicus |||| Opnametechnici",
|
||||
"mixer": "Mixer |||| Mixers",
|
||||
"remixer": "Remixer |||| Remixers",
|
||||
"djmixer": "DJ Mixer |||| DJ Mixers",
|
||||
"performer": "Performer |||| Performers"
|
||||
"size": "Grootte"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -196,8 +162,7 @@
|
||||
"addNewPlaylist": "Creëer \"%{name}\"",
|
||||
"export": "Exporteer",
|
||||
"makePublic": "Openbaar maken",
|
||||
"makePrivate": "Privé maken",
|
||||
"saveQueue": "Bewaar wachtrij als playlist"
|
||||
"makePrivate": "Privé maken"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Dubbele nummers toevoegen",
|
||||
@@ -233,22 +198,6 @@
|
||||
"createdAt": "Gecreëerd op",
|
||||
"downloadable": "Downloads toestaan?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Ontbrekend bestand |||| Ontbrekende bestanden",
|
||||
"fields": {
|
||||
"path": "Pad",
|
||||
"size": "Grootte",
|
||||
"updatedAt": "Verdwenen op"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Verwijder",
|
||||
"remove_all": "Alles verwijderen"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Ontbrekende bestanden verwijderd"
|
||||
},
|
||||
"empty": "Geen ontbrekende bestanden"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -263,8 +212,7 @@
|
||||
"password": "Wachtwoord",
|
||||
"sign_in": "Inloggen",
|
||||
"sign_in_error": "Authenticatie mislukt, probeer opnieuw a.u.b.",
|
||||
"logout": "Uitloggen",
|
||||
"insightsCollectionNote": "Navidrome verzamelt anonieme gebruiksdata om het project te verbeteren. Klik [hier] voor meer info en de mogelijkheid om te weigeren"
|
||||
"logout": "Uitloggen"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Gebruik alleen letters en cijfers",
|
||||
@@ -426,11 +374,7 @@
|
||||
"shareSuccess": "URL gekopieeerd naar klembord: %{url}",
|
||||
"shareFailure": "Fout bij kopieren URL %{url} naar klembord",
|
||||
"downloadDialogTitle": "Download %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Kopieeer naar klembord: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Verwijder ontbrekende bestanden",
|
||||
"remove_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
|
||||
"remove_all_missing_title": "Verwijder alle ontbrekende bestanden",
|
||||
"remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen."
|
||||
"shareCopyToClipboard": "Kopieeer naar klembord: Ctrl+C, Enter"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotheek",
|
||||
@@ -452,17 +396,16 @@
|
||||
"none": "Uitgeschakeld",
|
||||
"album": "Gebruik Album Gain",
|
||||
"track": "Gebruik Track Gain"
|
||||
},
|
||||
"lastfmNotConfigured": "Last.fm API-sleutel is niet geconfigureerd"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Albums",
|
||||
"about": "Over",
|
||||
"playlists": "Afspeellijsten",
|
||||
"sharedPlaylists": "Gedeelde afspeellijsten"
|
||||
"playlists": "Playlists",
|
||||
"sharedPlaylists": "Gedeelde playlists"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Wachtrij",
|
||||
"playListsText": "Afspeellijst afspelen",
|
||||
"openText": "Openen",
|
||||
"closeText": "Sluiten",
|
||||
"notContentText": "Geen muziek",
|
||||
@@ -490,12 +433,7 @@
|
||||
"links": {
|
||||
"homepage": "Thuispagina",
|
||||
"source": "Broncode",
|
||||
"featureRequests": "Functie verzoeken",
|
||||
"lastInsightsCollection": "Laatste inzichten",
|
||||
"insights": {
|
||||
"disabled": "Uitgeschakeld",
|
||||
"waiting": "Wachten"
|
||||
}
|
||||
"featureRequests": "Functie verzoeken"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -504,10 +442,7 @@
|
||||
"quickScan": "Snelle scan",
|
||||
"fullScan": "Volledige scan",
|
||||
"serverUptime": "Server uptime",
|
||||
"serverDown": "Offline",
|
||||
"scanType": "Type",
|
||||
"status": "Scan fout",
|
||||
"elapsedTime": "Verlopen tijd"
|
||||
"serverDown": "Offline"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome sneltoetsen",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"languageName": "Português (Brasil)",
|
||||
"languageName": "Português",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Música |||| Músicas",
|
||||
@@ -18,6 +18,7 @@
|
||||
"size": "Tamanho",
|
||||
"updatedAt": "Últ. Atualização",
|
||||
"bitRate": "Bitrate",
|
||||
"bitDepth": "Profundidade de bits",
|
||||
"discSubtitle": "Sub-título do disco",
|
||||
"starred": "Favorita",
|
||||
"comment": "Comentário",
|
||||
@@ -32,10 +33,7 @@
|
||||
"participants": "Outros Participantes",
|
||||
"tags": "Outras Tags",
|
||||
"mappedTags": "Tags mapeadas",
|
||||
"rawTags": "Tags originais",
|
||||
"bitDepth": "Profundidade de bits",
|
||||
"sampleRate": "Taxa de amostragem",
|
||||
"missing": "Ausente"
|
||||
"rawTags": "Tags originais"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Adicionar à fila",
|
||||
@@ -59,6 +57,7 @@
|
||||
"genre": "Gênero",
|
||||
"compilation": "Coletânea",
|
||||
"year": "Ano",
|
||||
"date": "Data de Lançamento",
|
||||
"updatedAt": "Últ. Atualização",
|
||||
"comment": "Comentário",
|
||||
"rating": "Classificação",
|
||||
@@ -73,9 +72,7 @@
|
||||
"releaseType": "Tipo",
|
||||
"grouping": "Agrupamento",
|
||||
"media": "Mídia",
|
||||
"mood": "Mood",
|
||||
"date": "Data de Lançamento",
|
||||
"missing": "Ausente"
|
||||
"mood": "Mood"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Tocar",
|
||||
@@ -107,8 +104,7 @@
|
||||
"rating": "Classificação",
|
||||
"genre": "Gênero",
|
||||
"size": "Tamanho",
|
||||
"role": "Role",
|
||||
"missing": "Ausente"
|
||||
"role": "Role"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artista do Álbum |||| Artistas do Álbum",
|
||||
@@ -196,18 +192,11 @@
|
||||
"addNewPlaylist": "Criar \"%{name}\"",
|
||||
"export": "Exportar",
|
||||
"makePublic": "Pública",
|
||||
"makePrivate": "Pessoal",
|
||||
"saveQueue": "Salvar fila em nova Playlist",
|
||||
"searchOrCreate": "Buscar playlists ou criar nova...",
|
||||
"pressEnterToCreate": "Pressione Enter para criar nova playlist",
|
||||
"removeFromSelection": "Remover da seleção",
|
||||
"removeSymbol": "×"
|
||||
"makePrivate": "Pessoal"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Adicionar músicas duplicadas",
|
||||
"song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?",
|
||||
"noPlaylistsFound": "Nenhuma playlist encontrada",
|
||||
"noPlaylists": "Nenhuma playlist disponível"
|
||||
"song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -242,19 +231,18 @@
|
||||
},
|
||||
"missing": {
|
||||
"name": "Arquivo ausente |||| Arquivos ausentes",
|
||||
"empty": "Nenhum arquivo ausente",
|
||||
"fields": {
|
||||
"path": "Caminho",
|
||||
"size": "Tamanho",
|
||||
"updatedAt": "Desaparecido em"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Remover",
|
||||
"remove_all": "Remover todos"
|
||||
"remove": "Remover"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Arquivo(s) ausente(s) removido(s)"
|
||||
},
|
||||
"empty": "Nenhum arquivo ausente"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -434,9 +422,7 @@
|
||||
"downloadDialogTitle": "Baixar %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Copie para o clipboard: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Remover arquivos ausentes",
|
||||
"remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.",
|
||||
"remove_all_missing_title": "Remover todos os arquivos ausentes",
|
||||
"remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações."
|
||||
"remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
@@ -502,21 +488,6 @@
|
||||
"disabled": "Desligado",
|
||||
"waiting": "Aguardando"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "Sobre",
|
||||
"config": "Configuração"
|
||||
},
|
||||
"config": {
|
||||
"configName": "Nome da Configuração",
|
||||
"environmentVariable": "Variável de Ambiente",
|
||||
"currentValue": "Valor Atual",
|
||||
"configurationFile": "Arquivo de Configuração",
|
||||
"exportToml": "Exportar Configuração (TOML)",
|
||||
"exportSuccess": "Configuração exportada para o clipboard em formato TOML",
|
||||
"exportFailed": "Falha ao copiar configuração",
|
||||
"devFlagsHeader": "Flags de Desenvolvimento (sujeitas a mudança/remoção)",
|
||||
"devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -525,10 +496,7 @@
|
||||
"quickScan": "Scan rápido",
|
||||
"fullScan": "Scan completo",
|
||||
"serverUptime": "Uptime do servidor",
|
||||
"serverDown": "DESCONECTADO",
|
||||
"scanType": "Tipo",
|
||||
"status": "Erro",
|
||||
"elapsedTime": "Duração"
|
||||
"serverDown": "DESCONECTADO"
|
||||
},
|
||||
"help": {
|
||||
"title": "Teclas de atalho",
|
||||
@@ -544,4 +512,4 @@
|
||||
"current_song": "Vai para música atual"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,8 @@
|
||||
"tags": "Дополнительные теги",
|
||||
"mappedTags": "Сопоставленные теги",
|
||||
"rawTags": "Исходные теги",
|
||||
"bitDepth": "Битовая глубина (Bit)",
|
||||
"sampleRate": "Частота дискретизации (Hz)",
|
||||
"missing": "Поле отсутствует"
|
||||
"bitDepth": "Битовая глубина",
|
||||
"sampleRate": "Частота дискретизации (Гц)"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "В очередь",
|
||||
@@ -74,8 +73,7 @@
|
||||
"grouping": "Группирование",
|
||||
"media": "Медиа",
|
||||
"mood": "Настроение",
|
||||
"date": "Дата записи",
|
||||
"missing": "Поле отсутствует"
|
||||
"date": "Дата записи"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Играть",
|
||||
@@ -107,8 +105,7 @@
|
||||
"rating": "Рейтинг",
|
||||
"genre": "Жанр",
|
||||
"size": "Размер",
|
||||
"role": "Роль",
|
||||
"missing": "Поле отсутствует"
|
||||
"role": "Роль"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Исполнитель альбома |||| Исполнители альбома",
|
||||
@@ -160,7 +157,7 @@
|
||||
"fields": {
|
||||
"name": "Имя",
|
||||
"transcodingId": "Транскодирование",
|
||||
"maxBitRate": "Макс. битрейт",
|
||||
"maxBitRate": "Макс. Битрейт",
|
||||
"client": "Клиент",
|
||||
"userName": "Пользователь",
|
||||
"lastSeen": "Был на сайте",
|
||||
@@ -178,7 +175,7 @@
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Плейлист |||| Плейлисты",
|
||||
"name": "Плейлистов |||| Плейлисты",
|
||||
"fields": {
|
||||
"name": "Название",
|
||||
"duration": "Длительность",
|
||||
@@ -196,8 +193,7 @@
|
||||
"addNewPlaylist": "Создать \"%{name}\"",
|
||||
"export": "Экспорт",
|
||||
"makePublic": "Опубликовать",
|
||||
"makePrivate": "Сделать личным",
|
||||
"saveQueue": "Сохранить очередь в плейлист"
|
||||
"makePrivate": "Сделать личным"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Повторяющиеся треки",
|
||||
@@ -228,7 +224,7 @@
|
||||
"lastVisitedAt": "Последнее посещение",
|
||||
"visitCount": "Посещения",
|
||||
"format": "Формат",
|
||||
"maxBitRate": "Макс. битрейт",
|
||||
"maxBitRate": "Макс. Битрейт",
|
||||
"updatedAt": "Обновлено в",
|
||||
"createdAt": "Создано",
|
||||
"downloadable": "Разрешить загрузку?"
|
||||
@@ -242,8 +238,7 @@
|
||||
"updatedAt": "Исчез"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Удалить",
|
||||
"remove_all": "Убрать все"
|
||||
"remove": "Удалить"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Отсутствующие файлы удалены"
|
||||
@@ -279,7 +274,7 @@
|
||||
"oneOf": "Должно быть одним из: %{options}",
|
||||
"regex": "Должно быть в формате (regexp): %{pattern}",
|
||||
"unique": "Должно быть уникальным",
|
||||
"url": "Должен быть действительный URL"
|
||||
"url": "Должен быть действительным URL адрес"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Фильтр",
|
||||
@@ -296,7 +291,7 @@
|
||||
"export": "Экспорт",
|
||||
"list": "Список",
|
||||
"refresh": "Обновить",
|
||||
"remove_filter": "Убрать этот фильтр",
|
||||
"remove_filter": "Убрать фильтр",
|
||||
"remove": "Удалить",
|
||||
"save": "Сохранить",
|
||||
"search": "Поиск",
|
||||
@@ -387,7 +382,7 @@
|
||||
"i18n_error": "Не удалось загрузить перевод для указанного языка",
|
||||
"canceled": "Операция отменена",
|
||||
"logged_out": "Ваша сессия завершена, попробуйте переподключиться/войти снова",
|
||||
"new_version": "Доступна новая версия! Пожалуйста, обновите это окно."
|
||||
"new_version": "Доступна новая версия! Пожалуйста, обновите это окно"
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Отображение столбцов",
|
||||
@@ -428,9 +423,7 @@
|
||||
"downloadDialogTitle": "Скачать %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Копировать в буфер обмена: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Удалить отсутствующие файлы",
|
||||
"remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах.",
|
||||
"remove_all_missing_title": "Удалите все отсутствующие файлы",
|
||||
"remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество игр и рейтинг."
|
||||
"remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Библиотека",
|
||||
@@ -489,7 +482,7 @@
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Главная",
|
||||
"source": "Исходный код",
|
||||
"source": "Код",
|
||||
"featureRequests": "Предложения",
|
||||
"lastInsightsCollection": "Последний сбор данных",
|
||||
"insights": {
|
||||
@@ -504,10 +497,7 @@
|
||||
"quickScan": "Быстрое сканирование",
|
||||
"fullScan": "Полное сканирование",
|
||||
"serverUptime": "Время работы сервера",
|
||||
"serverDown": "Оффлайн",
|
||||
"scanType": "Тип",
|
||||
"status": "Ошибка сканирования",
|
||||
"elapsedTime": "Прошедшее время"
|
||||
"serverDown": "Оффлайн"
|
||||
},
|
||||
"help": {
|
||||
"title": "Горячие клавиши Navidrome",
|
||||
@@ -520,7 +510,7 @@
|
||||
"vol_up": "Увеличить громкость",
|
||||
"vol_down": "Уменьшить громкость",
|
||||
"toggle_love": "Добавить / удалить песню из избранного",
|
||||
"current_song": "Перейти к текущему треку"
|
||||
"current_song": "Перейти к текущей песне"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,16 +26,7 @@
|
||||
"bpm": "BPM",
|
||||
"playDate": "Senast spelad",
|
||||
"channels": "Channels",
|
||||
"createdAt": "Skapad",
|
||||
"grouping": "Gruppering",
|
||||
"mood": "Stämning",
|
||||
"participants": "Ytterligare medverkande",
|
||||
"tags": "Ytterligare taggar",
|
||||
"mappedTags": "Mappade taggar",
|
||||
"rawTags": "Omodifierade taggar",
|
||||
"bitDepth": "Bitdjup",
|
||||
"sampleRate": "Samplingsfrekvens",
|
||||
"missing": "Saknade"
|
||||
"createdAt": "Skapad"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lägg till i kön",
|
||||
@@ -67,15 +58,7 @@
|
||||
"originalDate": "Originaldatum",
|
||||
"releaseDate": "Utgivningsdatum",
|
||||
"releases": "Utgåva |||| Utgåvor",
|
||||
"released": "Utgiven",
|
||||
"recordLabel": "Skivbolag",
|
||||
"catalogNum": "Katalognummer",
|
||||
"releaseType": "Typ",
|
||||
"grouping": "Gruppering",
|
||||
"media": "Media",
|
||||
"mood": "Stämning",
|
||||
"date": "Inspelningsdatum",
|
||||
"missing": "Saknade"
|
||||
"released": "Utgiven"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Spela",
|
||||
@@ -106,24 +89,7 @@
|
||||
"playCount": "Spelningar",
|
||||
"rating": "Betyg",
|
||||
"genre": "Genre",
|
||||
"size": "Storlek",
|
||||
"role": "Roll",
|
||||
"missing": "Saknade"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albumartist |||| Albumartister",
|
||||
"artist": "Artist |||| Artister",
|
||||
"composer": "Kompositör |||| Kompositörer",
|
||||
"conductor": "Dirigent |||| Dirigenter",
|
||||
"lyricist": "Textförfattare |||| Textförfattare",
|
||||
"arranger": "Arrangör |||| Arrangörer",
|
||||
"producer": "Producent |||| Producenter",
|
||||
"director": "Inspelningsledare |||| Inspelningsledare",
|
||||
"engineer": "Ljudtekniker |||| Ljudtekniker",
|
||||
"mixer": "Mixare |||| Mixare",
|
||||
"remixer": "Remixare |||| Remixare",
|
||||
"djmixer": "DJ-mixare |||| DJ-mixare",
|
||||
"performer": "Utövande artist |||| Utövande artister"
|
||||
"size": "Storlek"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -196,8 +162,7 @@
|
||||
"addNewPlaylist": "Skapa \"%{name}\"",
|
||||
"export": "Exportera",
|
||||
"makePublic": "Gör offentlig",
|
||||
"makePrivate": "Gör privat",
|
||||
"saveQueue": "Spara kö till spellista"
|
||||
"makePrivate": "Gör privat"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Lägg till dubletter",
|
||||
@@ -233,22 +198,6 @@
|
||||
"createdAt": "Skapad",
|
||||
"downloadable": "Tillåt nedladdning?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Saknad fil |||| Saknade filer",
|
||||
"fields": {
|
||||
"path": "Sökväg",
|
||||
"size": "Storlek",
|
||||
"updatedAt": "Försvann"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Radera",
|
||||
"remove_all": "Radera alla"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Saknade fil(er) borttagna"
|
||||
},
|
||||
"empty": "Inga saknade filer"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -426,11 +375,7 @@
|
||||
"shareSuccess": "URL kopierades till urklipp: %{url}",
|
||||
"shareFailure": "Fel vid kopiering av URL %{url} till urklipp",
|
||||
"downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Ta bort saknade filer",
|
||||
"remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.",
|
||||
"remove_all_missing_title": "Ta bort alla saknade filer",
|
||||
"remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg."
|
||||
"shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotek",
|
||||
@@ -504,10 +449,7 @@
|
||||
"quickScan": "Snabbscan",
|
||||
"fullScan": "Komplett scan",
|
||||
"serverUptime": "Serverdrifttid",
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Typ",
|
||||
"status": "Fel vid scanning",
|
||||
"elapsedTime": "Spelad tid"
|
||||
"serverDown": "OFFLINE"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome kortkommandon",
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
"mappedTags": "Eşlenen etiketler",
|
||||
"rawTags": "Ham etiketler",
|
||||
"bitDepth": "Bit derinliği",
|
||||
"sampleRate": "Örnekleme Oranı",
|
||||
"missing": ""
|
||||
"sampleRate": "Örnekleme Oranı"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Oynatma Sırasına Ekle",
|
||||
@@ -74,8 +73,7 @@
|
||||
"grouping": "Gruplama",
|
||||
"media": "Medya",
|
||||
"mood": "Mod",
|
||||
"date": "Kayıt Tarihi",
|
||||
"missing": ""
|
||||
"date": "Kayıt Tarihi"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Oynat",
|
||||
@@ -107,8 +105,7 @@
|
||||
"rating": "Derecelendirme",
|
||||
"genre": "Tür",
|
||||
"size": "Boyut",
|
||||
"role": "Rol",
|
||||
"missing": ""
|
||||
"role": "Rol"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albüm Sanatçısı |||| Albüm Sanatçısı",
|
||||
@@ -196,8 +193,7 @@
|
||||
"addNewPlaylist": "Oluştur \"%{name}\"",
|
||||
"export": "Aktar",
|
||||
"makePublic": "Herkese Açık Yap",
|
||||
"makePrivate": "Özel Yap",
|
||||
"saveQueue": ""
|
||||
"makePrivate": "Özel Yap"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Yinelenen şarkıları ekle",
|
||||
@@ -242,8 +238,7 @@
|
||||
"updatedAt": "Kaybolma"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Kaldır",
|
||||
"remove_all": "Tümünü Kaldır"
|
||||
"remove": "Kaldır"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Eksik dosya(lar) kaldırıldı"
|
||||
@@ -428,9 +423,7 @@
|
||||
"downloadDialogTitle": "%{resource}: '%{name}' (%{size}) dosyasını indirin",
|
||||
"shareCopyToClipboard": "Panoya kopyala: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Eksik dosyaları kaldır",
|
||||
"remove_missing_content": "Seçili eksik dosyaları veritabanından kaldırmak istediğinizden emin misiniz? Bu, oynatma sayıları ve derecelendirmeleri dahil olmak üzere bunlara ilişkin tüm referansları kalıcı olarak kaldıracaktır.",
|
||||
"remove_all_missing_title": "Tüm eksik dosyaları kaldırın",
|
||||
"remove_all_missing_content": "Veritabanından tüm eksik dosyaları kaldırmak istediğinizden emin misiniz? Bu, oynatma sayısı ve derecelendirmelerde dahil olmak üzere bunlara ilişkili tüm değerleri kalıcı olarak kaldıracaktır."
|
||||
"remove_missing_content": "Seçili eksik dosyaları veritabanından kaldırmak istediğinizden emin misiniz? Bu, oynatma sayıları ve derecelendirmeleri dahil olmak üzere bunlara ilişkin tüm referansları kalıcı olarak kaldıracaktır."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Kütüphane",
|
||||
@@ -504,10 +497,7 @@
|
||||
"quickScan": "Hızlı Tarama",
|
||||
"fullScan": "Tam Tarama",
|
||||
"serverUptime": "Sunucu Çalışma Süresi",
|
||||
"serverDown": "ÇEVRİMDIŞI",
|
||||
"scanType": "Tür",
|
||||
"status": "Tarama Hatası",
|
||||
"elapsedTime": "Geçen Süre"
|
||||
"serverDown": "ÇEVRİMDIŞI"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Kısayolları",
|
||||
|
||||
@@ -32,10 +32,7 @@
|
||||
"participants": "Додаткові вчасники",
|
||||
"tags": "Додаткові теги",
|
||||
"mappedTags": "Зіставлені теги",
|
||||
"rawTags": "Вихідні теги",
|
||||
"bitDepth": "Глибина розрядності",
|
||||
"sampleRate": "Частота дискретизації",
|
||||
"missing": "Поле відсутнє"
|
||||
"rawTags": "Вихідні теги"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Прослухати пізніше",
|
||||
@@ -73,9 +70,7 @@
|
||||
"releaseType": "Тип",
|
||||
"grouping": "Групування",
|
||||
"media": "Медіа",
|
||||
"mood": "Настрій",
|
||||
"date": "Дата запису",
|
||||
"missing": "Поле відсутнє"
|
||||
"mood": "Настрій"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Прослухати",
|
||||
@@ -107,8 +102,7 @@
|
||||
"rating": "Рейтинг",
|
||||
"genre": "Жанр",
|
||||
"size": "Розмір",
|
||||
"role": "Роль",
|
||||
"missing": "Поле відсутнє"
|
||||
"role": "Роль"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Виконавець альбому |||| Виконавці альбому",
|
||||
@@ -196,8 +190,7 @@
|
||||
"addNewPlaylist": "Створити \"%{name}\"",
|
||||
"export": "Експортувати",
|
||||
"makePublic": "Зробити публічним",
|
||||
"makePrivate": "Зробити приватним",
|
||||
"saveQueue": "Зберегти чергу до плейлиста"
|
||||
"makePrivate": "Зробити приватним"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Додати повторювані пісні",
|
||||
@@ -242,13 +235,11 @@
|
||||
"updatedAt": "Зник"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Видалити",
|
||||
"remove_all": "Вилучити всі"
|
||||
"remove": "Видалити"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Видалено зниклі файл(и)"
|
||||
},
|
||||
"empty": "Немає відсутніх файлів"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -428,9 +419,7 @@
|
||||
"downloadDialogTitle": "Завантаження %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Скопіювати в буфер: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Видалити зниклі файли",
|
||||
"remove_missing_content": "Ви впевнені, що хочете видалити вибрані відсутні файли з бази даних? Це назавжди видалить усі посилання на них, включаючи кількість прослуховувань та рейтинги.",
|
||||
"remove_all_missing_title": "Видалити всі відсутні файли",
|
||||
"remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами."
|
||||
"remove_missing_content": "Ви впевнені, що хочете видалити вибрані відсутні файли з бази даних? Це назавжди видалить усі посилання на них, включаючи кількість прослуховувань та рейтинги."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Бібліотека",
|
||||
@@ -504,10 +493,7 @@
|
||||
"quickScan": "Швидке сканування",
|
||||
"fullScan": "Повне сканування",
|
||||
"serverUptime": "Час роботи",
|
||||
"serverDown": "Оффлайн",
|
||||
"scanType": "Тип",
|
||||
"status": "Помилка сканування",
|
||||
"elapsedTime": "Пройдений час"
|
||||
"serverDown": "Оффлайн"
|
||||
},
|
||||
"help": {
|
||||
"title": "Гарячі клавіші Navidrome",
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
@@ -38,9 +37,6 @@ type StatusInfo struct {
|
||||
LastScan time.Time
|
||||
Count uint32
|
||||
FolderCount uint32
|
||||
LastError string
|
||||
ScanType string
|
||||
ElapsedTime time.Duration
|
||||
}
|
||||
|
||||
func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker,
|
||||
@@ -63,12 +59,13 @@ func (s *controller) getScanner() scanner {
|
||||
if conf.Server.DevExternalScanner {
|
||||
return &scannerExternal{}
|
||||
}
|
||||
return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls}
|
||||
return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls, metrics: s.metrics}
|
||||
}
|
||||
|
||||
// CallScan starts an in-process scan of the music library.
|
||||
// This is meant to be called from the command line (see cmd/scan.go).
|
||||
func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool) (<-chan *ProgressInfo, error) {
|
||||
func CallScan(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, pls core.Playlists,
|
||||
metrics metrics.Metrics, fullScan bool) (<-chan *ProgressInfo, error) {
|
||||
release, err := lockScan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -79,7 +76,7 @@ func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullS
|
||||
progress := make(chan *ProgressInfo, 100)
|
||||
go func() {
|
||||
defer close(progress)
|
||||
scanner := &scannerImpl{ds: ds, cw: artwork.NoopCacheWarmer(), pls: pls}
|
||||
scanner := &scannerImpl{ds: ds, cw: cw, pls: pls, metrics: metrics}
|
||||
scanner.scanAll(ctx, fullScan, progress)
|
||||
}()
|
||||
return progress, nil
|
||||
@@ -97,7 +94,6 @@ type ProgressInfo struct {
|
||||
ChangesDetected bool
|
||||
Warning string
|
||||
Error string
|
||||
ForceUpdate bool
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
@@ -117,51 +113,20 @@ type controller struct {
|
||||
changesDetected bool
|
||||
}
|
||||
|
||||
// getScanInfo retrieves scan status from the database
|
||||
func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed time.Duration, lastErr string) {
|
||||
lastErr, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "")
|
||||
scanType, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "")
|
||||
startTimeStr, _ := s.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
|
||||
|
||||
if startTimeStr != "" {
|
||||
startTime, err := time.Parse(time.RFC3339, startTimeStr)
|
||||
if err == nil {
|
||||
if running.Load() {
|
||||
elapsed = time.Since(startTime)
|
||||
} else {
|
||||
// If scan is not running, try to get the last scan time for the library
|
||||
lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library
|
||||
if err == nil {
|
||||
elapsed = lib.LastScanAt.Sub(startTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scanType, elapsed, lastErr
|
||||
}
|
||||
|
||||
func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
|
||||
lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting library: %w", err)
|
||||
}
|
||||
|
||||
scanType, elapsed, lastErr := s.getScanInfo(ctx)
|
||||
|
||||
if running.Load() {
|
||||
status := &StatusInfo{
|
||||
Scanning: true,
|
||||
LastScan: lib.LastScanAt,
|
||||
Count: s.count.Load(),
|
||||
FolderCount: s.folderCount.Load(),
|
||||
LastError: lastErr,
|
||||
ScanType: scanType,
|
||||
ElapsedTime: elapsed,
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
count, folderCount, err := s.getCounters(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting library stats: %w", err)
|
||||
@@ -171,9 +136,6 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
|
||||
LastScan: lib.LastScanAt,
|
||||
Count: uint32(count),
|
||||
FolderCount: uint32(folderCount),
|
||||
LastError: lastErr,
|
||||
ScanType: scanType,
|
||||
ElapsedTime: elapsed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -229,18 +191,12 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin
|
||||
}
|
||||
// Send the final scan status event, with totals
|
||||
if count, folderCount, err := s.getCounters(ctx); err != nil {
|
||||
s.metrics.WriteAfterScanMetrics(ctx, false)
|
||||
return scanWarnings, err
|
||||
} else {
|
||||
scanType, elapsed, lastErr := s.getScanInfo(ctx)
|
||||
s.metrics.WriteAfterScanMetrics(ctx, true)
|
||||
s.sendMessage(ctx, &events.ScanStatus{
|
||||
Scanning: false,
|
||||
Count: count,
|
||||
FolderCount: folderCount,
|
||||
Error: lastErr,
|
||||
ScanType: scanType,
|
||||
ElapsedTime: elapsed,
|
||||
})
|
||||
}
|
||||
return scanWarnings, scanError
|
||||
@@ -284,17 +240,12 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres
|
||||
if p.FileCount > 0 {
|
||||
s.folderCount.Add(1)
|
||||
}
|
||||
|
||||
scanType, elapsed, lastErr := s.getScanInfo(ctx)
|
||||
status := &events.ScanStatus{
|
||||
Scanning: true,
|
||||
Count: int64(s.count.Load()),
|
||||
FolderCount: int64(s.folderCount.Load()),
|
||||
Error: lastErr,
|
||||
ScanType: scanType,
|
||||
ElapsedTime: elapsed,
|
||||
}
|
||||
if s.limiter != nil && !p.ForceUpdate {
|
||||
if s.limiter != nil {
|
||||
s.limiter.Do(func() { s.sendMessage(ctx, status) })
|
||||
} else {
|
||||
s.sendMessage(ctx, status)
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package scanner_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Controller", func() {
|
||||
var ctx context.Context
|
||||
var ds *tests.MockDataStore
|
||||
var ctrl scanner.Scanner
|
||||
|
||||
Describe("Status", func() {
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
db.Init(ctx)
|
||||
DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) })
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
ds.MockedProperty = &tests.MockedPropertyRepo{}
|
||||
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Name: "lib", Path: "/tmp"})).To(Succeed())
|
||||
})
|
||||
|
||||
It("includes last scan error", func() {
|
||||
Expect(ds.Property(ctx).Put(consts.LastScanErrorKey, "boom")).To(Succeed())
|
||||
status, err := ctrl.Status(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(status.LastError).To(Equal("boom"))
|
||||
})
|
||||
|
||||
It("includes scan type and error in status", func() {
|
||||
// Set up test data in property repo
|
||||
Expect(ds.Property(ctx).Put(consts.LastScanErrorKey, "test error")).To(Succeed())
|
||||
Expect(ds.Property(ctx).Put(consts.LastScanTypeKey, "full")).To(Succeed())
|
||||
|
||||
// Get status and verify basic info
|
||||
status, err := ctrl.Status(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(status.LastError).To(Equal("test error"))
|
||||
Expect(status.ScanType).To(Equal("full"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
ppl "github.com/google/go-pipeline/pkg/pipeline"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
@@ -184,35 +182,7 @@ func (p *phaseMissingTracks) finalize(err error) error {
|
||||
if matched > 0 {
|
||||
log.Info(p.ctx, "Scanner: Found moved files", "total", matched, err)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if we should purge missing items
|
||||
if conf.Server.Scanner.PurgeMissing == consts.PurgeMissingAlways || (conf.Server.Scanner.PurgeMissing == consts.PurgeMissingFull && p.state.fullScan) {
|
||||
if err = p.purgeMissing(); err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error purging missing items", err)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *phaseMissingTracks) purgeMissing() error {
|
||||
deletedCount, err := p.ds.MediaFile(p.ctx).DeleteAllMissing()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error deleting missing files: %w", err)
|
||||
}
|
||||
|
||||
if deletedCount > 0 {
|
||||
log.Info(p.ctx, "Scanner: Purged missing items from the database", "mediaFiles", deletedCount)
|
||||
// Set changesDetected to true so that garbage collection will run at the end of the scan process
|
||||
p.state.changesDetected.Store(true)
|
||||
} else {
|
||||
log.Debug(p.ctx, "Scanner: No missing items to purge")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ phase[*missingTracks] = (*phaseMissingTracks)(nil)
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -224,66 +222,4 @@ var _ = Describe("phaseMissingTracks", func() {
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("finalize", func() {
|
||||
It("should return nil if no error", func() {
|
||||
err := phase.finalize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should return the error if provided", func() {
|
||||
err := phase.finalize(context.DeadlineExceeded)
|
||||
Expect(err).To(Equal(context.DeadlineExceeded))
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
})
|
||||
|
||||
When("PurgeMissing is 'always'", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways
|
||||
mr.CountAllValue = 3
|
||||
mr.DeleteAllMissingValue = 3
|
||||
})
|
||||
It("should purge missing files", func() {
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
err := phase.finalize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(state.changesDetected.Load()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
When("PurgeMissing is 'full'", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull
|
||||
mr.CountAllValue = 2
|
||||
mr.DeleteAllMissingValue = 2
|
||||
})
|
||||
It("should not purge missing files if not a full scan", func() {
|
||||
state.fullScan = false
|
||||
err := phase.finalize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
})
|
||||
It("should purge missing files if full scan", func() {
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
state.fullScan = true
|
||||
err := phase.finalize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(state.changesDetected.Load()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
When("PurgeMissing is 'never'", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
||||
mr.CountAllValue = 1
|
||||
mr.DeleteAllMissingValue = 1
|
||||
})
|
||||
It("should not purge missing files", func() {
|
||||
err := phase.finalize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -18,9 +19,10 @@ import (
|
||||
)
|
||||
|
||||
type scannerImpl struct {
|
||||
ds model.DataStore
|
||||
cw artwork.CacheWarmer
|
||||
pls core.Playlists
|
||||
ds model.DataStore
|
||||
cw artwork.CacheWarmer
|
||||
pls core.Playlists
|
||||
metrics metrics.Metrics
|
||||
}
|
||||
|
||||
// scanState holds the state of an in-progress scan, to be passed to the various phases
|
||||
@@ -55,21 +57,12 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
|
||||
startTime := time.Now()
|
||||
log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(libs))
|
||||
|
||||
// Store scan type and start time
|
||||
scanType := "quick"
|
||||
if state.fullScan {
|
||||
scanType = "full"
|
||||
}
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, scanType)
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanStartTimeKey, startTime.Format(time.RFC3339))
|
||||
|
||||
// if there was a full scan in progress, force a full scan
|
||||
if !state.fullScan {
|
||||
for _, lib := range libs {
|
||||
if lib.FullScanInProgress {
|
||||
log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name)
|
||||
state.fullScan = true
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full")
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -107,23 +100,21 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
|
||||
)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err)
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, err.Error())
|
||||
state.sendError(err)
|
||||
s.metrics.WriteAfterScanMetrics(ctx, false)
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, "")
|
||||
|
||||
if state.changesDetected.Load() {
|
||||
state.sendProgress(&ProgressInfo{ChangesDetected: true})
|
||||
}
|
||||
|
||||
s.metrics.WriteAfterScanMetrics(ctx, err == nil)
|
||||
log.Info(ctx, "Scanner: Finished scanning all libraries", "duration", time.Since(startTime))
|
||||
}
|
||||
|
||||
func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error {
|
||||
return func() error {
|
||||
state.sendProgress(&ProgressInfo{ForceUpdate: true})
|
||||
return s.ds.WithTx(func(tx model.DataStore) error {
|
||||
if state.changesDetected.Load() {
|
||||
start := time.Now()
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
@@ -18,7 +17,6 @@ import (
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
@@ -49,15 +47,14 @@ var _ = Describe("Scanner", Ordered, func() {
|
||||
}
|
||||
|
||||
BeforeAll(func() {
|
||||
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL")
|
||||
log.Warn("Using DB at " + conf.Server.DbPath)
|
||||
//conf.Server.DbPath = ":memory:"
|
||||
db.Db().SetMaxOpenConns(1)
|
||||
})
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
db.Init(ctx)
|
||||
DeferCleanup(func() {
|
||||
Expect(tests.ClearDB()).To(Succeed())
|
||||
@@ -504,113 +501,6 @@ var _ = Describe("Scanner", Ordered, func() {
|
||||
Expect(aa[0].MbzArtistID).To(Equal(beatlesMBID))
|
||||
Expect(aa[0].SortArtistName).To(Equal("Beatles, The"))
|
||||
})
|
||||
|
||||
Context("When PurgeMissing is configured", func() {
|
||||
When("PurgeMissing is set to 'never'", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
||||
})
|
||||
|
||||
It("should mark files as missing but not delete them", func() {
|
||||
By("Running initial scan")
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
By("Removing a file")
|
||||
fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
|
||||
By("Running another scan")
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
By("Checking files are marked as missing but not deleted")
|
||||
count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"missing": true},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(1)))
|
||||
|
||||
mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mf.Missing).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
When("PurgeMissing is set to 'always'", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways
|
||||
})
|
||||
|
||||
It("should purge missing files on any scan", func() {
|
||||
By("Running initial scan")
|
||||
Expect(runScanner(ctx, false)).To(Succeed())
|
||||
|
||||
By("Removing a file")
|
||||
fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
|
||||
By("Running an incremental scan")
|
||||
Expect(runScanner(ctx, false)).To(Succeed())
|
||||
|
||||
By("Checking missing files are deleted")
|
||||
count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"missing": true},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeZero())
|
||||
|
||||
_, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
When("PurgeMissing is set to 'full'", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull
|
||||
})
|
||||
|
||||
It("should not purge missing files on incremental scans", func() {
|
||||
By("Running initial scan")
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
By("Removing a file")
|
||||
fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
|
||||
By("Running an incremental scan")
|
||||
Expect(runScanner(ctx, false)).To(Succeed())
|
||||
|
||||
By("Checking files are marked as missing but not deleted")
|
||||
count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"missing": true},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(1)))
|
||||
|
||||
mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mf.Missing).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should purge missing files only on full scans", func() {
|
||||
By("Running initial scan")
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
By("Removing a file")
|
||||
fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
|
||||
By("Running a full scan")
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
By("Checking missing files are deleted")
|
||||
count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"missing": true},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeZero())
|
||||
|
||||
_, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func JWTVerifier(next http.Handler) http.Handler {
|
||||
func jwtVerifier(next http.Handler) http.Handler {
|
||||
return jwtauth.Verify(auth.TokenAuth, tokenFromHeader, jwtauth.TokenFromCookie, jwtauth.TokenFromQuery)(next)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,12 +37,9 @@ func (e *baseEvent) Data(evt Event) string {
|
||||
|
||||
type ScanStatus struct {
|
||||
baseEvent
|
||||
Scanning bool `json:"scanning"`
|
||||
Count int64 `json:"count"`
|
||||
FolderCount int64 `json:"folderCount"`
|
||||
Error string `json:"error"`
|
||||
ScanType string `json:"scanType"`
|
||||
ElapsedTime time.Duration `json:"elapsedTime"`
|
||||
Scanning bool `json:"scanning"`
|
||||
Count int64 `json:"count"`
|
||||
FolderCount int64 `json:"folderCount"`
|
||||
}
|
||||
|
||||
type KeepAlive struct {
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
// sensitiveFieldsPartialMask contains configuration field names that should be redacted
|
||||
// using partial masking (first and last character visible, middle replaced with *).
|
||||
// For values with 7+ characters: "secretvalue123" becomes "s***********3"
|
||||
// For values with <7 characters: "short" becomes "****"
|
||||
// Add field paths using dot notation (e.g., "LastFM.ApiKey", "Spotify.Secret")
|
||||
var sensitiveFieldsPartialMask = []string{
|
||||
"LastFM.ApiKey",
|
||||
"LastFM.Secret",
|
||||
"Prometheus.MetricsPath",
|
||||
"Spotify.ID",
|
||||
"Spotify.Secret",
|
||||
"DevAutoLoginUsername",
|
||||
}
|
||||
|
||||
// sensitiveFieldsFullMask contains configuration field names that should always be
|
||||
// completely masked with "****" regardless of their length.
|
||||
// Add field paths using dot notation for any fields that should never show any content.
|
||||
var sensitiveFieldsFullMask = []string{
|
||||
"DevAutoCreateAdminPassword",
|
||||
"PasswordEncryptionKey",
|
||||
"Prometheus.Password",
|
||||
}
|
||||
|
||||
type configResponse struct {
|
||||
ID string `json:"id"`
|
||||
ConfigFile string `json:"configFile"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
|
||||
func redactValue(key string, value string) string {
|
||||
// Return empty values as-is
|
||||
if len(value) == 0 {
|
||||
return value
|
||||
}
|
||||
|
||||
// Check if this field should be fully masked
|
||||
for _, field := range sensitiveFieldsFullMask {
|
||||
if field == key {
|
||||
return "****"
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this field should be partially masked
|
||||
for _, field := range sensitiveFieldsPartialMask {
|
||||
if field == key {
|
||||
if len(value) < 7 {
|
||||
return "****"
|
||||
}
|
||||
// Show first and last character with * in between
|
||||
return string(value[0]) + strings.Repeat("*", len(value)-2) + string(value[len(value)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// Return original value if not sensitive
|
||||
return value
|
||||
}
|
||||
|
||||
// applySensitiveFieldMasking recursively applies masking to sensitive fields in the configuration map
|
||||
func applySensitiveFieldMasking(ctx context.Context, config map[string]interface{}, prefix string) {
|
||||
for key, value := range config {
|
||||
fullKey := key
|
||||
if prefix != "" {
|
||||
fullKey = prefix + "." + key
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
// Recursively process nested maps
|
||||
applySensitiveFieldMasking(ctx, v, fullKey)
|
||||
case string:
|
||||
// Apply masking to string values
|
||||
config[key] = redactValue(fullKey, v)
|
||||
default:
|
||||
// For other types (numbers, booleans, etc.), convert to string and check for masking
|
||||
if str := fmt.Sprint(v); str != "" {
|
||||
masked := redactValue(fullKey, str)
|
||||
if masked != str {
|
||||
// Only replace if masking was applied
|
||||
config[key] = masked
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getConfig(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user, _ := request.UserFrom(ctx)
|
||||
if !user.IsAdmin {
|
||||
http.Error(w, "Config endpoint is only available to admin users", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Marshal the actual configuration struct to preserve original field names
|
||||
configBytes, err := json.Marshal(*conf.Server)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error marshaling config", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Unmarshal back to map to get the structure with proper field names
|
||||
var configMap map[string]interface{}
|
||||
err = json.Unmarshal(configBytes, &configMap)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error unmarshaling config to map", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply sensitive field masking
|
||||
applySensitiveFieldMasking(ctx, configMap, "")
|
||||
|
||||
resp := configResponse{
|
||||
ID: "config",
|
||||
ConfigFile: conf.Server.ConfigFile,
|
||||
Config: configMap,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Error(ctx, "Error encoding config response", err)
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("getConfig", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
Context("when user is not admin", func() {
|
||||
It("returns unauthorized", func() {
|
||||
req := httptest.NewRequest("GET", "/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
ctx := request.WithUser(req.Context(), model.User{IsAdmin: false})
|
||||
|
||||
getConfig(w, req.WithContext(ctx))
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when user is admin", func() {
|
||||
It("returns config successfully", func() {
|
||||
req := httptest.NewRequest("GET", "/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
|
||||
|
||||
getConfig(w, req.WithContext(ctx))
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp configResponse
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
Expect(resp.ID).To(Equal("config"))
|
||||
Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile))
|
||||
Expect(resp.Config).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("redacts sensitive fields", func() {
|
||||
conf.Server.LastFM.ApiKey = "secretapikey123"
|
||||
conf.Server.Spotify.Secret = "spotifysecret456"
|
||||
conf.Server.PasswordEncryptionKey = "encryptionkey789"
|
||||
conf.Server.DevAutoCreateAdminPassword = "adminpassword123"
|
||||
conf.Server.Prometheus.Password = "prometheuspass"
|
||||
|
||||
req := httptest.NewRequest("GET", "/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
|
||||
getConfig(w, req.WithContext(ctx))
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp configResponse
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
|
||||
// Check LastFM.ApiKey (partially masked)
|
||||
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(lastfm["ApiKey"]).To(Equal("s*************3"))
|
||||
|
||||
// Check Spotify.Secret (partially masked)
|
||||
spotify, ok := resp.Config["Spotify"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(spotify["Secret"]).To(Equal("s**************6"))
|
||||
|
||||
// Check PasswordEncryptionKey (fully masked)
|
||||
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****"))
|
||||
|
||||
// Check DevAutoCreateAdminPassword (fully masked)
|
||||
Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****"))
|
||||
|
||||
// Check Prometheus.Password (fully masked)
|
||||
prometheus, ok := resp.Config["Prometheus"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(prometheus["Password"]).To(Equal("****"))
|
||||
})
|
||||
|
||||
It("handles empty sensitive values", func() {
|
||||
conf.Server.LastFM.ApiKey = ""
|
||||
conf.Server.PasswordEncryptionKey = ""
|
||||
|
||||
req := httptest.NewRequest("GET", "/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
|
||||
getConfig(w, req.WithContext(ctx))
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp configResponse
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
|
||||
// Check LastFM.ApiKey - should be preserved because it's sensitive
|
||||
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(lastfm["ApiKey"]).To(Equal(""))
|
||||
|
||||
// Empty sensitive values should remain empty - should be preserved because it's sensitive
|
||||
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal(""))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("redactValue function", func() {
|
||||
It("partially masks long sensitive values", func() {
|
||||
Expect(redactValue("LastFM.ApiKey", "ba46f0e84a")).To(Equal("b********a"))
|
||||
Expect(redactValue("Spotify.Secret", "verylongsecret123")).To(Equal("v***************3"))
|
||||
})
|
||||
|
||||
It("fully masks long sensitive values that should be completely hidden", func() {
|
||||
Expect(redactValue("PasswordEncryptionKey", "1234567890")).To(Equal("****"))
|
||||
Expect(redactValue("DevAutoCreateAdminPassword", "1234567890")).To(Equal("****"))
|
||||
Expect(redactValue("Prometheus.Password", "1234567890")).To(Equal("****"))
|
||||
})
|
||||
|
||||
It("fully masks short sensitive values", func() {
|
||||
Expect(redactValue("LastFM.Secret", "short")).To(Equal("****"))
|
||||
Expect(redactValue("Spotify.ID", "abc")).To(Equal("****"))
|
||||
Expect(redactValue("PasswordEncryptionKey", "12345")).To(Equal("****"))
|
||||
Expect(redactValue("DevAutoCreateAdminPassword", "short")).To(Equal("****"))
|
||||
Expect(redactValue("Prometheus.Password", "short")).To(Equal("****"))
|
||||
})
|
||||
|
||||
It("does not mask non-sensitive values", func() {
|
||||
Expect(redactValue("MusicFolder", "/path/to/music")).To(Equal("/path/to/music"))
|
||||
Expect(redactValue("Port", "4533")).To(Equal("4533"))
|
||||
Expect(redactValue("SomeOtherField", "secretvalue")).To(Equal("secretvalue"))
|
||||
})
|
||||
|
||||
It("handles empty values", func() {
|
||||
Expect(redactValue("LastFM.ApiKey", "")).To(Equal(""))
|
||||
Expect(redactValue("NonSensitive", "")).To(Equal(""))
|
||||
})
|
||||
|
||||
It("handles edge case values", func() {
|
||||
Expect(redactValue("LastFM.ApiKey", "a")).To(Equal("****"))
|
||||
Expect(redactValue("LastFM.ApiKey", "ab")).To(Equal("****"))
|
||||
Expect(redactValue("LastFM.ApiKey", "abcdefg")).To(Equal("a*****g"))
|
||||
})
|
||||
})
|
||||
@@ -63,29 +63,25 @@ func (r *missingRepository) EntityName() string {
|
||||
}
|
||||
|
||||
func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
repo := ds.MediaFile(r.Context())
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
err := ds.WithTx(func(tx model.DataStore) error {
|
||||
if len(ids) == 0 {
|
||||
_, err := tx.MediaFile(ctx).DeleteAllMissing()
|
||||
return err
|
||||
}
|
||||
return tx.MediaFile(ctx).DeleteMissing(ids)
|
||||
return repo.DeleteMissing(ids)
|
||||
})
|
||||
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "Missing file not found", "id", ids[0])
|
||||
log.Warn(r.Context(), "Missing file not found", "id", ids[0])
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
|
||||
log.Error(r.Context(), "Error deleting missing tracks from DB", "ids", ids, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = ds.GC(ctx)
|
||||
err = ds.GC(r.Context())
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error running GC after deleting missing tracks", err)
|
||||
log.Error(r.Context(), "Error running GC after deleting missing tracks", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -59,12 +59,23 @@ func (n *Router) routes() http.Handler {
|
||||
|
||||
n.addPlaylistRoute(r)
|
||||
n.addPlaylistTrackRoute(r)
|
||||
n.addSongPlaylistsRoute(r)
|
||||
n.addMissingFilesRoute(r)
|
||||
n.addInspectRoute(r)
|
||||
n.addConfigRoute(r)
|
||||
n.addKeepAliveRoute(r)
|
||||
n.addInsightsRoute(r)
|
||||
|
||||
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
|
||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
|
||||
})
|
||||
|
||||
// Insights status endpoint
|
||||
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
last, success := n.insights.LastRun(r.Context())
|
||||
if conf.Server.EnableInsightsCollector {
|
||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
@@ -133,9 +144,6 @@ func (n *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
})
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
getPlaylistTrack(n.ds)(w, r)
|
||||
})
|
||||
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
reorderItem(n.ds)(w, r)
|
||||
})
|
||||
@@ -146,12 +154,6 @@ func (n *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addSongPlaylistsRoute(r chi.Router) {
|
||||
r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) {
|
||||
getSongPlaylists(n.ds)(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addMissingFilesRoute(r chi.Router) {
|
||||
r.Route("/missing", func(r chi.Router) {
|
||||
n.RX(r, "/", newMissingRepository(n.ds), false)
|
||||
@@ -194,26 +196,3 @@ func (n *Router) addInspectRoute(r chi.Router) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Router) addConfigRoute(r chi.Router) {
|
||||
if conf.Server.DevUIShowConfig {
|
||||
r.Get("/config/*", getConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Router) addKeepAliveRoute(r chi.Router) {
|
||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addInsightsRoute(r chi.Router) {
|
||||
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
last, success := n.insights.LastRun(r.Context())
|
||||
if conf.Server.EnableInsightsCollector {
|
||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Simple mock implementations for missing types
|
||||
type mockShare struct {
|
||||
core.Share
|
||||
}
|
||||
|
||||
func (m *mockShare) NewRepository(ctx context.Context) rest.Repository {
|
||||
return &tests.MockShareRepo{}
|
||||
}
|
||||
|
||||
type mockPlaylists struct {
|
||||
core.Playlists
|
||||
}
|
||||
|
||||
func (m *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
|
||||
return &model.Playlist{}, nil
|
||||
}
|
||||
|
||||
type mockInsights struct {
|
||||
metrics.Insights
|
||||
}
|
||||
|
||||
func (m *mockInsights) LastRun(ctx context.Context) (time.Time, bool) {
|
||||
return time.Now(), true
|
||||
}
|
||||
|
||||
var _ = Describe("Song Endpoints", func() {
|
||||
var (
|
||||
router http.Handler
|
||||
ds *tests.MockDataStore
|
||||
mfRepo *tests.MockMediaFileRepo
|
||||
userRepo *tests.MockedUserRepo
|
||||
w *httptest.ResponseRecorder
|
||||
testUser model.User
|
||||
testSongs model.MediaFiles
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SessionTimeout = time.Minute
|
||||
|
||||
// Setup mock repositories
|
||||
mfRepo = tests.CreateMockMediaFileRepo()
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedMediaFile: mfRepo,
|
||||
MockedUser: userRepo,
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
}
|
||||
|
||||
// Initialize auth system
|
||||
auth.Init(ds)
|
||||
|
||||
// Create test user
|
||||
testUser = model.User{
|
||||
ID: "user-1",
|
||||
UserName: "testuser",
|
||||
Name: "Test User",
|
||||
IsAdmin: false,
|
||||
NewPassword: "testpass",
|
||||
}
|
||||
err := userRepo.Put(&testUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create test songs
|
||||
testSongs = model.MediaFiles{
|
||||
{
|
||||
ID: "song-1",
|
||||
Title: "Test Song 1",
|
||||
Artist: "Test Artist 1",
|
||||
Album: "Test Album 1",
|
||||
AlbumID: "album-1",
|
||||
ArtistID: "artist-1",
|
||||
Duration: 180.5,
|
||||
BitRate: 320,
|
||||
Path: "/music/song1.mp3",
|
||||
Suffix: "mp3",
|
||||
Size: 5242880,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: "song-2",
|
||||
Title: "Test Song 2",
|
||||
Artist: "Test Artist 2",
|
||||
Album: "Test Album 2",
|
||||
AlbumID: "album-2",
|
||||
ArtistID: "artist-2",
|
||||
Duration: 240.0,
|
||||
BitRate: 256,
|
||||
Path: "/music/song2.mp3",
|
||||
Suffix: "mp3",
|
||||
Size: 7340032,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
mfRepo.SetData(testSongs)
|
||||
|
||||
// Setup router with mocked dependencies
|
||||
mockShareImpl := &mockShare{}
|
||||
mockPlaylistsImpl := &mockPlaylists{}
|
||||
mockInsightsImpl := &mockInsights{}
|
||||
|
||||
// Create the native API router and wrap it with the JWTVerifier middleware
|
||||
nativeRouter := New(ds, mockShareImpl, mockPlaylistsImpl, mockInsightsImpl)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
// Helper function to create unauthenticated request
|
||||
createUnauthenticatedRequest := func(method, path string, body []byte) *http.Request {
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
req = httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// Helper function to create authenticated request with JWT token
|
||||
createAuthenticatedRequest := func(method, path string, body []byte) *http.Request {
|
||||
req := createUnauthenticatedRequest(method, path, body)
|
||||
|
||||
// Create JWT token for the test user
|
||||
token, err := auth.CreateToken(&testUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Add JWT token to Authorization header
|
||||
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
Describe("GET /song", func() {
|
||||
Context("when user is authenticated", func() {
|
||||
It("returns all songs", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(response).To(HaveLen(2))
|
||||
Expect(response[0].ID).To(Equal("song-1"))
|
||||
Expect(response[0].Title).To(Equal("Test Song 1"))
|
||||
Expect(response[1].ID).To(Equal("song-2"))
|
||||
Expect(response[1].Title).To(Equal("Test Song 2"))
|
||||
})
|
||||
|
||||
It("handles repository errors gracefully", func() {
|
||||
mfRepo.SetError(true)
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when user is not authenticated", func() {
|
||||
It("returns unauthorized", func() {
|
||||
req := createUnauthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GET /song/{id}", func() {
|
||||
Context("when user is authenticated", func() {
|
||||
It("returns the specific song", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song/song-1", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response model.MediaFile
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(response.ID).To(Equal("song-1"))
|
||||
Expect(response.Title).To(Equal("Test Song 1"))
|
||||
Expect(response.Artist).To(Equal("Test Artist 1"))
|
||||
})
|
||||
|
||||
It("returns 404 for non-existent song", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song/non-existent", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("handles repository errors gracefully", func() {
|
||||
mfRepo.SetError(true)
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/song/song-1", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when user is not authenticated", func() {
|
||||
It("returns unauthorized", func() {
|
||||
req := createUnauthenticatedRequest("GET", "/song/song-1", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Song endpoints are read-only", func() {
|
||||
Context("POST /song", func() {
|
||||
It("should not be available (songs are not persistable)", func() {
|
||||
newSong := model.MediaFile{
|
||||
Title: "New Song",
|
||||
Artist: "New Artist",
|
||||
Album: "New Album",
|
||||
Duration: 200.0,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(newSong)
|
||||
req := createAuthenticatedRequest("POST", "/song", body)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Should return 405 Method Not Allowed or 404 Not Found
|
||||
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
|
||||
})
|
||||
})
|
||||
|
||||
Context("PUT /song/{id}", func() {
|
||||
It("should not be available (songs are not persistable)", func() {
|
||||
updatedSong := model.MediaFile{
|
||||
ID: "song-1",
|
||||
Title: "Updated Song",
|
||||
Artist: "Updated Artist",
|
||||
Album: "Updated Album",
|
||||
Duration: 250.0,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(updatedSong)
|
||||
req := createAuthenticatedRequest("PUT", "/song/song-1", body)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Should return 405 Method Not Allowed or 404 Not Found
|
||||
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
|
||||
})
|
||||
})
|
||||
|
||||
Context("DELETE /song/{id}", func() {
|
||||
It("should not be available (songs are not persistable)", func() {
|
||||
req := createAuthenticatedRequest("DELETE", "/song/song-1", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Should return 405 Method Not Allowed or 404 Not Found
|
||||
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Query parameters and filtering", func() {
|
||||
Context("when using query parameters", func() {
|
||||
It("handles pagination parameters", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song?_start=0&_end=1", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Should still return all songs since our mock doesn't implement pagination
|
||||
// but the request should be processed successfully
|
||||
Expect(len(response)).To(BeNumerically(">=", 1))
|
||||
})
|
||||
|
||||
It("handles sort parameters", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song?_sort=title&_order=ASC", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(response).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("handles filter parameters", func() {
|
||||
// Properly encode the URL with query parameters
|
||||
baseURL := "/song"
|
||||
params := url.Values{}
|
||||
params.Add("title", "Test Song 1")
|
||||
fullURL := baseURL + "?" + params.Encode()
|
||||
|
||||
req := createAuthenticatedRequest("GET", fullURL, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Mock doesn't implement filtering, but request should be processed
|
||||
Expect(len(response)).To(BeNumerically(">=", 1))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Response headers and content type", func() {
|
||||
It("sets correct content type for JSON responses", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json"))
|
||||
})
|
||||
|
||||
It("includes total count header when available", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
// The X-Total-Count header might be set by the REST framework
|
||||
// We just verify the request is processed successfully
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Edge cases and error handling", func() {
|
||||
Context("when repository is unavailable", func() {
|
||||
It("handles database connection errors", func() {
|
||||
mfRepo.SetError(true)
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when no songs exist", func() {
|
||||
It("returns empty array when no songs are found", func() {
|
||||
mfRepo.SetData(model.MediaFiles{}) // Empty dataset
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(response).To(HaveLen(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Authentication middleware integration", func() {
|
||||
Context("with different user types", func() {
|
||||
It("works with admin users", func() {
|
||||
adminUser := model.User{
|
||||
ID: "admin-1",
|
||||
UserName: "admin",
|
||||
Name: "Admin User",
|
||||
IsAdmin: true,
|
||||
NewPassword: "adminpass",
|
||||
}
|
||||
err := userRepo.Put(&adminUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create JWT token for admin user
|
||||
token, err := auth.CreateToken(&adminUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := createUnauthenticatedRequest("GET", "/song", nil)
|
||||
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("works with regular users", func() {
|
||||
regularUser := model.User{
|
||||
ID: "user-2",
|
||||
UserName: "regular",
|
||||
Name: "Regular User",
|
||||
IsAdmin: false,
|
||||
NewPassword: "userpass",
|
||||
}
|
||||
err := userRepo.Put(®ularUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create JWT token for regular user
|
||||
token, err := auth.CreateToken(®ularUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
req := createUnauthenticatedRequest("GET", "/song", nil)
|
||||
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with missing authentication context", func() {
|
||||
It("rejects requests without user context", func() {
|
||||
req := createUnauthenticatedRequest("GET", "/song", nil)
|
||||
// No authentication header added
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("rejects requests with invalid JWT tokens", func() {
|
||||
req := createUnauthenticatedRequest("GET", "/song", nil)
|
||||
req.Header.Set(consts.UIAuthorizationHeader, "Bearer invalid.token.here")
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -45,23 +45,6 @@ func getPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func getPlaylistTrack(ds model.DataStore) http.HandlerFunc {
|
||||
// Add a middleware to capture the playlistId
|
||||
wrapper := func(handler restHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
plsRepo := ds.Playlist(ctx)
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
return plsRepo.Tracks(plsId, true)
|
||||
}
|
||||
|
||||
handler(constructor).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
return wrapper(rest.Get)
|
||||
}
|
||||
|
||||
func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
@@ -224,21 +207,3 @@ func reorderItem(ds model.DataStore) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getSongPlaylists(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p := req.Params(r)
|
||||
trackId, _ := p.String(":id")
|
||||
playlists, err := ds.Playlist(r.Context()).GetPlaylists(trackId)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(playlists)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +37,8 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
size := p.IntOr("size", 0)
|
||||
square := p.BoolOr("square", false)
|
||||
|
||||
imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, square)
|
||||
imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, false)
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
return
|
||||
|
||||
@@ -65,7 +65,6 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
|
||||
"devSidebarPlaylists": conf.Server.DevSidebarPlaylists,
|
||||
"lastFMEnabled": conf.Server.LastFM.Enabled,
|
||||
"devShowArtistPage": conf.Server.DevShowArtistPage,
|
||||
"devUIShowConfig": conf.Server.DevUIShowConfig,
|
||||
"listenBrainzEnabled": conf.Server.ListenBrainz.Enabled,
|
||||
"enableExternalServices": conf.Server.EnableExternalServices,
|
||||
"enableReplayGain": conf.Server.EnableReplayGain,
|
||||
|
||||
@@ -304,17 +304,6 @@ var _ = Describe("serveIndex", func() {
|
||||
Expect(config).To(HaveKeyWithValue("devShowArtistPage", true))
|
||||
})
|
||||
|
||||
It("sets the devUIShowConfig", func() {
|
||||
conf.Server.DevUIShowConfig = true
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
serveIndex(ds, fs, nil)(w, r)
|
||||
|
||||
config := extractAppConfig(w.Body.String())
|
||||
Expect(config).To(HaveKeyWithValue("devUIShowConfig", true))
|
||||
})
|
||||
|
||||
It("sets the listenBrainzEnabled", func() {
|
||||
conf.Server.ListenBrainz.Enabled = true
|
||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||
|
||||
@@ -173,7 +173,7 @@ func (s *Server) initRoutes() {
|
||||
clientUniqueIDMiddleware,
|
||||
compressMiddleware(),
|
||||
loggerInjector,
|
||||
JWTVerifier,
|
||||
jwtVerifier,
|
||||
}
|
||||
|
||||
// Mount the Native API /events endpoint with all default middlewares, adding the authentication middlewares
|
||||
|
||||
@@ -38,7 +38,7 @@ func (api *Router) getArtist(r *http.Request, libId int, ifModifiedSince time.Ti
|
||||
|
||||
var indexes model.ArtistIndexes
|
||||
if lib.LastScanAt.After(ifModifiedSince) {
|
||||
indexes, err = api.ds.Artist(ctx).GetIndex(false, model.RoleAlbumArtist)
|
||||
indexes, err = api.ds.Artist(ctx).GetIndex(model.RoleAlbumArtist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving Indexes", err)
|
||||
return nil, 0, err
|
||||
|
||||
@@ -108,19 +108,12 @@ func SongsByRandom(genre string, fromYear, toYear int) Options {
|
||||
return addDefaultFilters(options)
|
||||
}
|
||||
|
||||
func SongWithLyrics(artist, title string) Options {
|
||||
func SongWithArtistTitle(artist, title string) Options {
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "updated_at",
|
||||
Order: "desc",
|
||||
Max: 1,
|
||||
Filters: And{
|
||||
Eq{"title": title},
|
||||
NotEq{"lyrics": "[]"},
|
||||
Or{
|
||||
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}),
|
||||
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}),
|
||||
},
|
||||
},
|
||||
Sort: "updated_at",
|
||||
Order: "desc",
|
||||
Max: 1,
|
||||
Filters: And{Eq{"artist": artist, "title": title}},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -224,7 +224,6 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op
|
||||
child.BPM = int32(mf.BPM)
|
||||
child.MediaType = responses.MediaTypeSong
|
||||
child.MusicBrainzId = mf.MbzRecordingID
|
||||
child.Isrc = mf.Tags.Values(model.TagISRC)
|
||||
child.ReplayGain = responses.ReplayGain{
|
||||
TrackGain: mf.RGTrackGain,
|
||||
AlbumGain: mf.RGAlbumGain,
|
||||
|
||||
@@ -23,9 +23,6 @@ func (api *Router) GetScanStatus(r *http.Request) (*responses.Subsonic, error) {
|
||||
Count: int64(status.Count),
|
||||
FolderCount: int64(status.FolderCount),
|
||||
LastScan: &status.LastScan,
|
||||
Error: status.LastError,
|
||||
ScanType: status.ScanType,
|
||||
ElapsedTime: int64(status.ElapsedTime),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -138,20 +138,6 @@ func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error
|
||||
event = event.With("artist", id)
|
||||
continue
|
||||
}
|
||||
exist, err = tx.Playlist(ctx).Exists(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exist {
|
||||
err = tx.Playlist(ctx).SetStar(star, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event = event.With("playlist", "*")
|
||||
// Ensure the refresh event is sent to all clients, including the originator
|
||||
ctx = events.BroadcastToAll(ctx)
|
||||
continue
|
||||
}
|
||||
err = tx.MediaFile(ctx).SetStar(star, id)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -30,42 +30,6 @@ var _ = Describe("MediaAnnotationController", func() {
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil)
|
||||
})
|
||||
|
||||
Describe("Star", func() {
|
||||
It("should send refresh resource event when starring a playlist", func() {
|
||||
mockPlaylistRepo := &tests.MockPlaylistRepo{
|
||||
Entity: &model.Playlist{ID: "pls-1", Name: "Test Playlist"},
|
||||
}
|
||||
ds.(*tests.MockDataStore).MockedPlaylist = mockPlaylistRepo
|
||||
|
||||
r := newGetRequest("id=pls-1")
|
||||
_, err := router.Star(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(eventBroker.Events).To(HaveLen(1))
|
||||
|
||||
event := eventBroker.Events[0].(*events.RefreshResource)
|
||||
data := event.Data(event)
|
||||
Expect(data).To(ContainSubstring(`"playlist":["*"]`))
|
||||
})
|
||||
|
||||
It("should send refresh resource event when unstarring a playlist", func() {
|
||||
mockPlaylistRepo := &tests.MockPlaylistRepo{
|
||||
Entity: &model.Playlist{ID: "pls-1", Name: "Test Playlist"},
|
||||
}
|
||||
ds.(*tests.MockDataStore).MockedPlaylist = mockPlaylistRepo
|
||||
|
||||
r := newGetRequest("id=pls-1")
|
||||
_, err := router.Unstar(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(eventBroker.Events).To(HaveLen(1))
|
||||
|
||||
event := eventBroker.Events[0].(*events.RefreshResource)
|
||||
data := event.Data(event)
|
||||
Expect(data).To(ContainSubstring(`"playlist":["*"]`))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scrobble", func() {
|
||||
It("submit all scrobbles with only the id", func() {
|
||||
submissionTime := time.Now()
|
||||
|
||||
@@ -98,7 +98,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
||||
response := newResponse()
|
||||
lyricsResponse := responses.Lyrics{}
|
||||
response.Lyrics = &lyricsResponse
|
||||
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithLyrics(artist, title))
|
||||
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithArtistTitle(artist, title))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"sortName": "sort name",
|
||||
"mediaType": "album",
|
||||
"musicBrainzId": "00000000-0000-0000-0000-000000000000",
|
||||
"isrc": [],
|
||||
"genres": [
|
||||
{
|
||||
"name": "Genre 1"
|
||||
|
||||
@@ -99,9 +99,6 @@
|
||||
"sortName": "sorted song",
|
||||
"mediaType": "song",
|
||||
"musicBrainzId": "4321",
|
||||
"isrc": [
|
||||
"ISRC-1"
|
||||
],
|
||||
"genres": [
|
||||
{
|
||||
"name": "rock"
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
<artists id="1" name="artist1"></artists>
|
||||
<artists id="2" name="artist2"></artists>
|
||||
<song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist1 & artist2" displayAlbumArtist="album artist1 & album artist2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<isrc>ISRC-1</isrc>
|
||||
<genres name="rock"></genres>
|
||||
<genres name="progressive"></genres>
|
||||
<replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain>
|
||||
|
||||
@@ -30,10 +30,6 @@
|
||||
"sortName": "sorted title",
|
||||
"mediaType": "song",
|
||||
"musicBrainzId": "4321",
|
||||
"isrc": [
|
||||
"ISRC-1",
|
||||
"ISRC-2"
|
||||
],
|
||||
"genres": [
|
||||
{
|
||||
"name": "rock"
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<directory id="1" name="N">
|
||||
<child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted title" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist 1 & artist 2" displayAlbumArtist="album artist 1 & album artist 2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<isrc>ISRC-1</isrc>
|
||||
<isrc>ISRC-2</isrc>
|
||||
<genres name="rock"></genres>
|
||||
<genres name="progressive"></genres>
|
||||
<replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain>
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"isrc": [],
|
||||
"genres": [],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
|
||||
@@ -176,7 +176,6 @@ type OpenSubsonicChild struct {
|
||||
SortName string `xml:"sortName,attr,omitempty" json:"sortName"`
|
||||
MediaType MediaType `xml:"mediaType,attr,omitempty" json:"mediaType"`
|
||||
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
|
||||
Isrc Array[string] `xml:"isrc,omitempty" json:"isrc"`
|
||||
Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"`
|
||||
ReplayGain ReplayGain `xml:"replayGain,omitempty" json:"replayGain"`
|
||||
ChannelCount int32 `xml:"channelCount,attr,omitempty" json:"channelCount"`
|
||||
@@ -477,13 +476,10 @@ type Shares struct {
|
||||
}
|
||||
|
||||
type ScanStatus struct {
|
||||
Scanning bool `xml:"scanning,attr" json:"scanning"`
|
||||
Count int64 `xml:"count,attr" json:"count"`
|
||||
FolderCount int64 `xml:"folderCount,attr" json:"folderCount"`
|
||||
LastScan *time.Time `xml:"lastScan,attr,omitempty" json:"lastScan,omitempty"`
|
||||
Error string `xml:"error,attr,omitempty" json:"error,omitempty"`
|
||||
ScanType string `xml:"scanType,attr,omitempty" json:"scanType,omitempty"`
|
||||
ElapsedTime int64 `xml:"elapsedTime,attr,omitempty" json:"elapsedTime,omitempty"`
|
||||
Scanning bool `xml:"scanning,attr" json:"scanning"`
|
||||
Count int64 `xml:"count,attr" json:"count"`
|
||||
FolderCount int64 `xml:"folderCount,attr" json:"folderCount"`
|
||||
LastScan *time.Time `xml:"lastScan,attr,omitempty" json:"lastScan,omitempty"`
|
||||
}
|
||||
|
||||
type Lyrics struct {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user