mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-01 11:28:04 -05:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa2cf36245 | ||
|
|
b19d5f0d3e | ||
|
|
175964b17a | ||
|
|
90b095b409 | ||
|
|
821f485022 | ||
|
|
d4a053370a | ||
|
|
66926ca466 | ||
|
|
1f9cbe7345 | ||
|
|
de698918ac | ||
|
|
71851b076c | ||
|
|
85a7268192 | ||
|
|
9dd5a8c334 | ||
|
|
030710afa9 | ||
|
|
5050250902 | ||
|
|
fb32cfd7db | ||
|
|
d26e2e29a6 | ||
|
|
5c4fbdb7c1 | ||
|
|
0cb02bce06 | ||
|
|
fe1ed582bc | ||
|
|
5e2db2c673 | ||
|
|
fac9275c27 | ||
|
|
6b3afc03cc | ||
|
|
35599230ff | ||
|
|
13ea00e7f8 | ||
|
|
f7fb77054f | ||
|
|
441c9f52cc | ||
|
|
b722f0dcfc | ||
|
|
c98e4d02cb | ||
|
|
5ade9344ff | ||
|
|
d903d3f1e0 | ||
|
|
6bf6424864 | ||
|
|
a9f93c97e1 | ||
|
|
3350e6c115 | ||
|
|
514aceb785 | ||
|
|
370f8ba293 | ||
|
|
1e4c759d93 | ||
|
|
e06fbd26b7 | ||
|
|
9062f4824e | ||
|
|
2503d2dbb8 | ||
|
|
45188e710c | ||
|
|
9dd050c377 | ||
|
|
3ccc02f375 | ||
|
|
992c78376c | ||
|
|
4a2412eef7 | ||
|
|
98fdc42d09 | ||
|
|
eb944bd261 | ||
|
|
84384006a4 | ||
|
|
e5438552c6 | ||
|
|
6ac3acaaf8 | ||
|
|
3953e3217d | ||
|
|
6731787053 | ||
|
|
dd1d3907b4 | ||
|
|
924354eb4b | ||
|
|
6880cffd16 | ||
|
|
fef1739c1a | ||
|
|
453630d430 | ||
|
|
4733616d90 | ||
|
|
ba7fd13724 |
53
.github/copilot-instructions.md
vendored
Normal file
53
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
# 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@v7
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
version: latest
|
||||
problem-matchers: true
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -24,4 +24,6 @@ docker-compose.yml
|
||||
!contrib/docker-compose.yml
|
||||
binaries
|
||||
navidrome-master
|
||||
*.exe
|
||||
AGENTS.md
|
||||
*.exe
|
||||
bin/
|
||||
110
AGENTS.md
110
AGENTS.md
@@ -1,110 +0,0 @@
|
||||
# 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 setup-git ##@1_Run_First Install dependencies and prepare development environment
|
||||
setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First Install dependencies and prepare development environment
|
||||
@echo Downloading Node dependencies...
|
||||
@(cd ./ui && npm ci)
|
||||
.PHONY: setup
|
||||
@@ -46,11 +46,15 @@ testrace: ##@Development Run Go tests with race detector
|
||||
.PHONY: test
|
||||
|
||||
testall: testrace ##@Development Run Go and JS tests
|
||||
@(cd ./ui && npm run test:ci)
|
||||
@(cd ./ui && npm run test)
|
||||
.PHONY: testall
|
||||
|
||||
lint: ##@Development Lint Go code
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run -v --timeout 5m
|
||||
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
|
||||
.PHONY: lint
|
||||
|
||||
lintall: lint ##@Development Lint Go and JS code
|
||||
|
||||
@@ -72,6 +72,7 @@ type configOptions struct {
|
||||
EnableUserEditing bool
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultShareExpiration time.Duration
|
||||
DefaultDownloadableShare bool
|
||||
DefaultTheme string
|
||||
DefaultLanguage string
|
||||
@@ -133,6 +134,7 @@ 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 {
|
||||
@@ -152,10 +154,11 @@ type TagConf struct {
|
||||
}
|
||||
|
||||
type lastfmOptions struct {
|
||||
Enabled bool
|
||||
ApiKey string
|
||||
Secret string
|
||||
Language string
|
||||
Enabled bool
|
||||
ApiKey string
|
||||
Secret string
|
||||
Language string
|
||||
ScrobbleFirstArtistOnly bool
|
||||
}
|
||||
|
||||
type spotifyOptions struct {
|
||||
@@ -276,6 +279,7 @@ func Load(noConfigDump bool) {
|
||||
validateScanSchedule,
|
||||
validateBackupSchedule,
|
||||
validatePlaylistsPath,
|
||||
validatePurgeMissingOption,
|
||||
)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
@@ -381,6 +385,24 @@ 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 = ""
|
||||
@@ -420,7 +442,7 @@ func AddHook(hook func()) {
|
||||
hooks = append(hooks, hook)
|
||||
}
|
||||
|
||||
func init() {
|
||||
func setViperDefaults() {
|
||||
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
|
||||
viper.SetDefault("cachefolder", "")
|
||||
viper.SetDefault("datafolder", ".")
|
||||
@@ -457,7 +479,6 @@ func init() {
|
||||
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 --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")
|
||||
@@ -472,6 +493,7 @@ func init() {
|
||||
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)
|
||||
@@ -479,19 +501,15 @@ func init() {
|
||||
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)
|
||||
@@ -501,39 +519,32 @@ func init() {
|
||||
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", "")
|
||||
@@ -554,6 +565,10 @@ func init() {
|
||||
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,9 +20,10 @@ var _ = Describe("Configuration", func() {
|
||||
BeforeEach(func() {
|
||||
// Reset viper configuration
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
ResetConf()
|
||||
conf.ResetConf()
|
||||
})
|
||||
|
||||
DescribeTable("should load configuration from",
|
||||
@@ -30,17 +31,17 @@ var _ = Describe("Configuration", func() {
|
||||
filename := filepath.Join("testdata", "cfg."+format)
|
||||
|
||||
// Initialize config with the test file
|
||||
InitConfig(filename)
|
||||
conf.InitConfig(filename)
|
||||
// Load the configuration (with noConfigDump=true)
|
||||
Load(true)
|
||||
conf.Load(true)
|
||||
|
||||
// Execute the format-specific assertions
|
||||
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"}))
|
||||
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"}))
|
||||
|
||||
// The config file used should be the one we created
|
||||
Expect(Server.ConfigFile).To(Equal(filename))
|
||||
Expect(conf.Server.ConfigFile).To(Equal(filename))
|
||||
},
|
||||
Entry("TOML format", "toml"),
|
||||
Entry("YAML format", "yaml"),
|
||||
|
||||
@@ -3,3 +3,5 @@ package conf
|
||||
func ResetConf() {
|
||||
Server = &configOptions{}
|
||||
}
|
||||
|
||||
var SetViperDefaults = setViperDefaults
|
||||
|
||||
@@ -14,6 +14,9 @@ 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"
|
||||
@@ -112,6 +115,12 @@ 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, agent)
|
||||
res = append(res, init(ds))
|
||||
}
|
||||
log.Debug("List of agents enabled", "names", enabled)
|
||||
|
||||
|
||||
@@ -279,6 +279,13 @@ 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 == "" {
|
||||
@@ -286,7 +293,7 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
|
||||
}
|
||||
|
||||
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
|
||||
artist: track.Artist,
|
||||
artist: l.getArtistForScrobble(track),
|
||||
track: track.Title,
|
||||
album: track.Album,
|
||||
trackNumber: track.TrackNumber,
|
||||
@@ -312,7 +319,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
|
||||
return nil
|
||||
}
|
||||
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
|
||||
artist: s.Artist,
|
||||
artist: l.getArtistForScrobble(&s.MediaFile),
|
||||
track: s.Title,
|
||||
album: s.Album,
|
||||
trackNumber: s.TrackNumber,
|
||||
@@ -344,10 +351,22 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
return lastFMConstructor(ds)
|
||||
// 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
|
||||
})
|
||||
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
return lastFMConstructor(ds)
|
||||
// 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
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,6 +196,12 @@ 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"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -247,6 +253,23 @@ 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.Format, s.MaxBitRate, out, s.Tracks)
|
||||
return a.zipMediaFiles(ctx, id, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
|
||||
}
|
||||
|
||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||
@@ -109,15 +109,40 @@ 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, format, bitrate, out, mfs)
|
||||
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true)
|
||||
}
|
||||
|
||||
func (a *archiver) zipMediaFiles(ctx context.Context, id string, format string, bitrate int, out io.Writer, mfs model.MediaFiles) error {
|
||||
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) 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,9 +145,21 @@ 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(2))
|
||||
Expect(len(zr.File)).To(Equal(3))
|
||||
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 = newArtistReader(ctx, a, artID, a.provider)
|
||||
artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider)
|
||||
case model.KindAlbumArtwork:
|
||||
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
|
||||
case model.KindMediaFileArtwork:
|
||||
|
||||
@@ -4,7 +4,11 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
@@ -15,11 +19,11 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// TODO Fix tests
|
||||
var _ = XDescribe("Artwork", func() {
|
||||
var _ = Describe("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
|
||||
@@ -30,20 +34,21 @@ var _ = XDescribe("Artwork", func() {
|
||||
conf.Server.ImageCacheSize = "0" // Disable cache
|
||||
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
|
||||
|
||||
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"}
|
||||
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"}}
|
||||
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
|
||||
alMultipleCovers = model.Album{
|
||||
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",
|
||||
ID: "666",
|
||||
Name: "All options",
|
||||
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
|
||||
FolderIDs: []string{"f1"},
|
||||
AlbumArtistID: "777",
|
||||
}
|
||||
mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"}
|
||||
@@ -65,6 +70,7 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
Context("Embed images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = nil
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alEmbedNotFound,
|
||||
@@ -87,12 +93,17 @@ var _ = XDescribe("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)
|
||||
@@ -100,6 +111,7 @@ var _ = XDescribe("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)
|
||||
@@ -108,6 +120,10 @@ var _ = XDescribe("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,
|
||||
})
|
||||
@@ -130,6 +146,10 @@ var _ = XDescribe("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,
|
||||
})
|
||||
@@ -143,7 +163,7 @@ var _ = XDescribe("Artwork", func() {
|
||||
DescribeTable("ArtistArtPriority",
|
||||
func(priority string, expected string) {
|
||||
conf.Server.ArtistArtPriority = priority
|
||||
aw, err := newArtistReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -157,12 +177,16 @@ var _ = XDescribe("Artwork", func() {
|
||||
Describe("mediafileArtworkReader", func() {
|
||||
Context("ID not found", func() {
|
||||
It("returns ErrNotFound if mediafile is not in the DB", func() {
|
||||
_, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
|
||||
_, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND"))
|
||||
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,
|
||||
@@ -185,11 +209,17 @@ var _ = XDescribe("Artwork", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(io.ReadAll(r)).To(Equal([]byte("content from ffmpeg")))
|
||||
data, _ := io.ReadAll(r)
|
||||
Expect(data).ToNot(BeEmpty())
|
||||
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)
|
||||
@@ -207,6 +237,10 @@ var _ = XDescribe("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,
|
||||
})
|
||||
@@ -241,12 +275,13 @@ var _ = XDescribe("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",
|
||||
//ImageFiles: filepath.Join(dirName, coverFileName),
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
FolderIDs: []string{"tmp"},
|
||||
}
|
||||
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alCover,
|
||||
})
|
||||
@@ -270,24 +305,24 @@ var _ = XDescribe("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,6 +31,12 @@ 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,
|
||||
@@ -53,6 +59,9 @@ 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{}{}
|
||||
@@ -74,6 +83,17 @@ 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 {
|
||||
|
||||
216
core/artwork/cache_warmer_test.go
Normal file
216
core/artwork/cache_warmer_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
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)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ type artistReader struct {
|
||||
imgFiles []string
|
||||
}
|
||||
|
||||
func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||
func newArtistArtworkReader(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
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("artistReader", func() {
|
||||
var _ = Describe("artistArtworkReader", func() {
|
||||
var _ = Describe("loadArtistFolder", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
|
||||
@@ -8,6 +8,7 @@ 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"
|
||||
@@ -93,7 +94,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
}
|
||||
s.ID = id
|
||||
if V(s.ExpiresAt).IsZero() {
|
||||
s.ExpiresAt = P(time.Now().Add(365 * 24 * time.Hour))
|
||||
s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration))
|
||||
}
|
||||
|
||||
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]
|
||||
|
||||
36
db/migrations/20250522013904_share_user_id_on_delete.sql
Normal file
36
db/migrations/20250522013904_share_user_id_on_delete.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- +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
|
||||
33
go.mod
33
go.mod
@@ -35,17 +35,17 @@ require (
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0
|
||||
github.com/kardianos/service v1.2.2
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.27
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
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.2
|
||||
github.com/prometheus/client_golang v1.21.1
|
||||
github.com/pressly/goose/v3 v3.24.3
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
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
|
||||
@@ -56,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-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/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/time v0.11.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -80,18 +80,17 @@ 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-20250403155104-27863c87afa6 // indirect
|
||||
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a // 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.2 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 // 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
|
||||
@@ -102,23 +101,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.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // 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.7.1 // indirect
|
||||
github.com/spf13/cast v1.8.0 // 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.37.0 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // indirect
|
||||
golang.org/x/tools v0.33.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.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
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-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-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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/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.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
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.4 h1:uBCMmJX8oRZStmKuMMOFb0Yh9xmEMgNJLgjuKKt4/qc=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4/go.mod h1:nWRbDFR1ALG2Z6GJbBXzfQaYyvn751KuuyySN2yR6is=
|
||||
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/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.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
|
||||
github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
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/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.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/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/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.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
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.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
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/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.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/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/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||
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/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.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
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/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.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
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/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.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
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.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
|
||||
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
|
||||
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.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=
|
||||
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=
|
||||
|
||||
@@ -32,6 +32,8 @@ 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"`
|
||||
}
|
||||
@@ -76,7 +78,7 @@ type ArtistRepository interface {
|
||||
UpdateExternalInfo(a *Artist) error
|
||||
Get(id string) (*Artist, error)
|
||||
GetAll(options ...QueryOptions) (Artists, error)
|
||||
GetIndex(roles ...Role) (ArtistIndexes, error)
|
||||
GetIndex(includeMissing bool, roles ...Role) (ArtistIndexes, error)
|
||||
|
||||
// The following methods are used exclusively by the scanner:
|
||||
RefreshPlayCounts() (int64, error)
|
||||
|
||||
@@ -4,6 +4,7 @@ package criteria
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
@@ -40,6 +41,9 @@ 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,6 +109,15 @@ 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,11 +54,12 @@ 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
|
||||
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
|
||||
}
|
||||
|
||||
func mapFields(expr map[string]any) map[string]any {
|
||||
@@ -145,6 +146,12 @@ 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 {
|
||||
@@ -205,3 +212,16 @@ 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,6 +13,7 @@ import (
|
||||
var _ = BeforeSuite(func() {
|
||||
AddRoles([]string{"artist", "composer"})
|
||||
AddTagNames([]string{"genre"})
|
||||
AddNumericTags([]string{"rate"})
|
||||
})
|
||||
|
||||
var _ = Describe("Operators", func() {
|
||||
@@ -68,6 +69,15 @@ 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"})
|
||||
@@ -77,6 +87,14 @@ 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,6 +9,7 @@ import (
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
@@ -330,6 +331,23 @@ 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 {
|
||||
@@ -342,6 +360,7 @@ 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,6 +402,72 @@ 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,6 +564,58 @@ 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() {
|
||||
@@ -592,7 +644,6 @@ var _ = Describe("Participants", func() {
|
||||
Entry("REMIXER", model.RoleRemixer, "REMIXER"),
|
||||
Entry("DJMIXER", model.RoleDJMixer, "DJMIXER"),
|
||||
Entry("DIRECTOR", model.RoleDirector, "DIRECTOR"),
|
||||
// TODO PERFORMER
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
@@ -53,17 +51,9 @@ func (pls *Playlist) RemoveTracks(idxToRemove []int) {
|
||||
pls.Tracks = newTracks
|
||||
}
|
||||
|
||||
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
|
||||
// https://docs.fileformat.com/audio/m3u/#extended-m3u
|
||||
// ToM3U8 exports the playlist to the Extended M3U8 format
|
||||
func (pls *Playlist) ToM3U8() string {
|
||||
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()
|
||||
return pls.MediaFiles().ToM3U8(pls.Name, true)
|
||||
}
|
||||
|
||||
func (pls *Playlist) AddTracks(mediaFileIds []string) {
|
||||
|
||||
@@ -13,13 +13,17 @@ 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, Path: "/music/library/Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}},
|
||||
Duration: 377.84,
|
||||
LibraryPath: "/music/library", Path: "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, Path: "/music/library/Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}},
|
||||
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"}},
|
||||
{MediaFile: model.MediaFile{Artist: "Lou Reed", Title: "Walk on the Wild Side",
|
||||
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"}},
|
||||
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"}},
|
||||
{MediaFile: model.MediaFile{Artist: "Legião Urbana", Title: "On the Way Home",
|
||||
Duration: 163.89, Path: "/music/library/Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}},
|
||||
Duration: 163.89,
|
||||
LibraryPath: "/music/library", Path: "Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}},
|
||||
}
|
||||
})
|
||||
It("generates the correct M3U format", func() {
|
||||
@@ -2,7 +2,6 @@ package model
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -50,17 +49,9 @@ func (s Share) CoverArtID() ArtworkID {
|
||||
|
||||
type Shares []Share
|
||||
|
||||
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
|
||||
// https://docs.fileformat.com/audio/m3u/#extended-m3u
|
||||
// ToM3U8 exports the share to the Extended M3U8 format.
|
||||
func (s Share) ToM3U8() string {
|
||||
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()
|
||||
return s.Tracks.ToM3U8(cmp.Or(s.Description, s.ID), false)
|
||||
}
|
||||
|
||||
type ShareRepository interface {
|
||||
|
||||
@@ -191,6 +191,7 @@ const (
|
||||
TagReleaseCountry TagName = "releasecountry"
|
||||
TagMedia TagName = "media"
|
||||
TagCatalogNumber TagName = "catalognumber"
|
||||
TagISRC TagName = "isrc"
|
||||
TagBPM TagName = "bpm"
|
||||
TagExplicitStatus TagName = "explicitstatus"
|
||||
|
||||
|
||||
@@ -162,6 +162,17 @@ 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 {
|
||||
@@ -228,5 +239,6 @@ func init() {
|
||||
// used in smart playlists
|
||||
criteria.AddRoles(slices.Collect(maps.Keys(AllRoles)))
|
||||
criteria.AddTagNames(tagNames())
|
||||
criteria.AddNumericTags(numericTagNames())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ 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",
|
||||
@@ -128,7 +129,12 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
}
|
||||
|
||||
func roleFilter(_ string, role any) Sqlizer {
|
||||
return NotEq{fmt.Sprintf("stats ->> '$.%v'", role): nil}
|
||||
if role, ok := role.(string); ok {
|
||||
if _, ok := model.AllRoles[role]; ok {
|
||||
return NotEq{fmt.Sprintf("stats ->> '$.%v'", role): nil}
|
||||
}
|
||||
}
|
||||
return Eq{"1": 2}
|
||||
}
|
||||
|
||||
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
|
||||
@@ -202,13 +208,20 @@ func (r *artistRepository) getIndexKey(a model.Artist) string {
|
||||
}
|
||||
|
||||
// TODO Cache the index (recalculate when there are changes to the DB)
|
||||
func (r *artistRepository) GetIndex(roles ...model.Role) (model.ArtistIndexes, error) {
|
||||
func (r *artistRepository) GetIndex(includeMissing bool, 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)
|
||||
return roleFilter("role", r.String())
|
||||
})
|
||||
options.Filters = And(roleFilters)
|
||||
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}}
|
||||
}
|
||||
}
|
||||
artists, err := r.GetAll(options)
|
||||
if err != nil {
|
||||
@@ -236,6 +249,25 @@ 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,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -94,7 +95,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("F"))
|
||||
@@ -112,7 +113,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()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
@@ -134,7 +135,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
@@ -151,7 +152,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
})
|
||||
|
||||
It("returns the index when SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
@@ -233,5 +234,113 @@ 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,6 +192,15 @@ 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,6 +4,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
@@ -44,14 +45,39 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
It("delete tracks by id", func() {
|
||||
newID := id.NewRandom()
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed())
|
||||
|
||||
Expect(mr.Delete(newID)).To(BeNil())
|
||||
Expect(mr.Delete(newID)).To(Succeed())
|
||||
|
||||
_, 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,6 +170,7 @@ 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() }),
|
||||
|
||||
@@ -65,6 +65,11 @@ 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,6 +41,9 @@ 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
|
||||
}
|
||||
@@ -69,6 +72,9 @@ 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) {
|
||||
@@ -78,6 +84,9 @@ 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)
|
||||
@@ -88,6 +97,9 @@ 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
|
||||
|
||||
96
persistence/transcoding_repository_test.go
Normal file
96
persistence/transcoding_repository_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
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,12 +32,15 @@
|
||||
"participants": "Weitere Beteiligte",
|
||||
"tags": "Weitere Tags",
|
||||
"mappedTags": "Gemappte Tags",
|
||||
"rawTags": "Tag Rohdaten"
|
||||
"rawTags": "Tag Rohdaten",
|
||||
"bitDepth": "Bittiefe",
|
||||
"sampleRate": "Samplerate",
|
||||
"missing": "Fehlend"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Später abspielen",
|
||||
"playNow": "Jetzt abspielen",
|
||||
"addToPlaylist": "Zur Playlist hinzufügen",
|
||||
"addToPlaylist": "Zu einer Wiedergabeliste hinzufügen",
|
||||
"shuffleAll": "Zufallswiedergabe",
|
||||
"download": "Herunterladen",
|
||||
"playNext": "Als nächstes abspielen",
|
||||
@@ -70,14 +73,16 @@
|
||||
"releaseType": "Typ",
|
||||
"grouping": "Gruppierung",
|
||||
"media": "Medium",
|
||||
"mood": "Stimmung"
|
||||
"mood": "Stimmung",
|
||||
"date": "Aufnahmedatum",
|
||||
"missing": "Fehlend"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Abspielen",
|
||||
"playNext": "Als nächstes abspielen",
|
||||
"addToQueue": "Später abspielen",
|
||||
"shuffle": "Zufallswiedergabe",
|
||||
"addToPlaylist": "Zur Playlist hinzufügen",
|
||||
"addToPlaylist": "Zu einer Wiedergabeliste hinzufügen",
|
||||
"download": "Herunterladen",
|
||||
"info": "Mehr Informationen",
|
||||
"share": "Freigabe erstellen"
|
||||
@@ -102,7 +107,8 @@
|
||||
"rating": "Bewertung",
|
||||
"genre": "Genre",
|
||||
"size": "Größe",
|
||||
"role": "Rolle"
|
||||
"role": "Rolle",
|
||||
"missing": "Fehlend"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albuminterpret |||| Albuminterpreten",
|
||||
@@ -172,7 +178,7 @@
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Playlist |||| Playlists",
|
||||
"name": "Wiedergabeliste |||| Wiedergabelisten",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"duration": "Dauer",
|
||||
@@ -186,11 +192,12 @@
|
||||
"path": "Importieren aus"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Titel zur Playlist hinzufügen",
|
||||
"selectPlaylist": "Wiedergabeliste auswählen:",
|
||||
"addNewPlaylist": "\"%{name}\" erstellen",
|
||||
"export": "Exportieren",
|
||||
"makePublic": "Öffentlich machen",
|
||||
"makePrivate": "Privat stellen"
|
||||
"makePrivate": "Privat stellen",
|
||||
"saveQueue": "Warteschlange in Wiedergabeliste speichern"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Duplikate hinzufügen",
|
||||
@@ -235,11 +242,13 @@
|
||||
"updatedAt": "Fehlt seit"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Entfernen"
|
||||
"remove": "Entfernen",
|
||||
"remove_all": "alle entfernen"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Fehlende Datei(en) entfernt"
|
||||
}
|
||||
},
|
||||
"empty": "keine fehlenden Dateien"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -391,10 +400,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 Playlist hinzugefügt |||| %{smart_count} Titel zur Playlist hinzugefügt",
|
||||
"noPlaylistsAvailable": "Keine Playlist verfügbar",
|
||||
"songsAddedToPlaylist": "Einen Titel zur Wiedergabeliste hinzugefügt |||| %{smart_count} Titel zur Wiedergabeliste hinzugefügt",
|
||||
"noPlaylistsAvailable": "Keine Wiedergabeliste verfügbar",
|
||||
"delete_user_title": "Benutzer '%{name}' löschen",
|
||||
"delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Playlisten und Einstellungen) wirklich löschen?",
|
||||
"delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Wiedergabelisten 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",
|
||||
@@ -419,7 +428,9 @@
|
||||
"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_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."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothek",
|
||||
@@ -447,8 +458,8 @@
|
||||
},
|
||||
"albumList": "Alben",
|
||||
"about": "Über",
|
||||
"playlists": "Playlisten",
|
||||
"sharedPlaylists": "Geteilte Playlisten"
|
||||
"playlists": "Wiedergabelisten",
|
||||
"sharedPlaylists": "Geteilte Wiedergabelisten"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Wiedergabeliste abspielen",
|
||||
@@ -493,7 +504,10 @@
|
||||
"quickScan": "Schneller Scan",
|
||||
"fullScan": "Kompletter Scan",
|
||||
"serverUptime": "Server-Betriebszeit",
|
||||
"serverDown": "OFFLINE"
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Typ",
|
||||
"status": "Scan Fehler",
|
||||
"elapsedTime": "Laufzeit"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Hotkeys",
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
"tags": "Πρόσθετες Ετικέτες",
|
||||
"mappedTags": "Χαρτογραφημένες ετικέτες",
|
||||
"rawTags": "Ακατέργαστες ετικέτες",
|
||||
"bitDepth": "Λίγο βάθος"
|
||||
"bitDepth": "Λίγο βάθος",
|
||||
"sampleRate": "Ποσοστό δειγματοληψίας",
|
||||
"missing": "Απών"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Αναπαραγωγη Μετα",
|
||||
@@ -72,7 +74,8 @@
|
||||
"grouping": "Ομαδοποίηση",
|
||||
"media": "Μέσα",
|
||||
"mood": "Διάθεση",
|
||||
"date": "Ημερομηνία Ηχογράφησης"
|
||||
"date": "Ημερομηνία Ηχογράφησης",
|
||||
"missing": "Απών"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Αναπαραγωγή",
|
||||
@@ -104,7 +107,8 @@
|
||||
"rating": "Βαθμολογια",
|
||||
"genre": "Είδος",
|
||||
"size": "Μέγεθος",
|
||||
"role": "Ρόλος"
|
||||
"role": "Ρόλος",
|
||||
"missing": "Απών"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Καλλιτέχνης Άλμπουμ |||| Καλλιτέχνες άλμπουμ",
|
||||
@@ -132,7 +136,7 @@
|
||||
"name": "Όνομα",
|
||||
"password": "Κωδικός Πρόσβασης",
|
||||
"createdAt": "Δημιουργήθηκε στις",
|
||||
"changePassword": "Αλλαγή Κωδικού Πρόσβασης;",
|
||||
"changePassword": "Αλλαγή Κωδικού Πρόσβασης?",
|
||||
"currentPassword": "Υπάρχων Κωδικός Πρόσβασης",
|
||||
"newPassword": "Νέος Κωδικός Πρόσβασης",
|
||||
"token": "Token",
|
||||
@@ -192,11 +196,12 @@
|
||||
"addNewPlaylist": "Δημιουργία \"%{name}\"",
|
||||
"export": "Εξαγωγη",
|
||||
"makePublic": "Να γίνει δημόσιο",
|
||||
"makePrivate": "Να γίνει ιδιωτικό"
|
||||
"makePrivate": "Να γίνει ιδιωτικό",
|
||||
"saveQueue": "Αποθήκευση ουράς στη λίστα αναπαραγωγής"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών",
|
||||
"song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε;"
|
||||
"song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -237,7 +242,8 @@
|
||||
"updatedAt": "Εξαφανίστηκε"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Αφαίρεση"
|
||||
"remove": "Αφαίρεση",
|
||||
"remove_all": "Αφαίρεση όλων"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Λείπει αρχείο(α) αφαιρέθηκε"
|
||||
@@ -305,7 +311,7 @@
|
||||
"skip": "Παράβλεψη",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Κοινοποίηση",
|
||||
"download": "Λήψη "
|
||||
"download": "Λήψη"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Ναι",
|
||||
@@ -344,10 +350,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": "Παρουσιάστηκε ένα πρόβλημα από τη μεριά του πελάτη και το αίτημα σας δεν μπορεί να ολοκληρωθεί.",
|
||||
@@ -356,12 +362,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}",
|
||||
@@ -397,7 +403,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 ενεργοποιήθηκε",
|
||||
@@ -422,7 +428,9 @@
|
||||
"downloadDialogTitle": "Λήψη %{resource} '%{name}'(%{size})",
|
||||
"shareCopyToClipboard": "Αντιγραφή στο πρόχειρο: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Αφαιρέστε τα αρχεία που λείπουν",
|
||||
"remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων; Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους."
|
||||
"remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους.",
|
||||
"remove_all_missing_title": "Αφαίρεση όλων των αρχείων που λείπουν",
|
||||
"remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Βιβλιοθήκη",
|
||||
@@ -496,7 +504,10 @@
|
||||
"quickScan": "Γρήγορη Σάρωση",
|
||||
"fullScan": "Πλήρης Σάρωση",
|
||||
"serverUptime": "Λειτουργία Διακομιστή",
|
||||
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ"
|
||||
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ",
|
||||
"scanType": "Τύπος",
|
||||
"status": "Σφάλμα σάρωσης",
|
||||
"elapsedTime": "Χρόνος που πέρασε"
|
||||
},
|
||||
"help": {
|
||||
"title": "Συντομεύσεις του Navidrome",
|
||||
|
||||
@@ -24,16 +24,18 @@
|
||||
"rating": "Takso",
|
||||
"quality": "Kvalito",
|
||||
"bpm": "Pulsrapideco",
|
||||
"playDate": "",
|
||||
"channels": "",
|
||||
"createdAt": "",
|
||||
"playDate": "Laste Ludita",
|
||||
"channels": "Kanaloj",
|
||||
"createdAt": "Dato de aligo",
|
||||
"grouping": "",
|
||||
"mood": "",
|
||||
"mood": "Humoro",
|
||||
"participants": "",
|
||||
"tags": "",
|
||||
"mappedTags": "",
|
||||
"rawTags": "",
|
||||
"bitDepth": ""
|
||||
"tags": "Aldonaj Etikedoj",
|
||||
"mappedTags": "Mapigitaj etikedoj",
|
||||
"rawTags": "Krudaj etikedoj",
|
||||
"bitDepth": "",
|
||||
"sampleRate": "",
|
||||
"missing": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ludi Poste",
|
||||
@@ -42,7 +44,7 @@
|
||||
"shuffleAll": "Miksu Ĉiujn",
|
||||
"download": "Elŝuti",
|
||||
"playNext": "Ludu Poste",
|
||||
"info": ""
|
||||
"info": "Akiri Informon"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -60,19 +62,20 @@
|
||||
"updatedAt": "Ĝisdatigita je :",
|
||||
"comment": "Komento",
|
||||
"rating": "Takso",
|
||||
"createdAt": "",
|
||||
"size": "",
|
||||
"originalDate": "",
|
||||
"releaseDate": "",
|
||||
"releases": "",
|
||||
"released": "",
|
||||
"createdAt": "Dato aldonita",
|
||||
"size": "Grando",
|
||||
"originalDate": "Originala",
|
||||
"releaseDate": "Publikiĝis",
|
||||
"releases": "Publikiĝo |||| Publikiĝoj",
|
||||
"released": "Publikiĝis",
|
||||
"recordLabel": "",
|
||||
"catalogNum": "",
|
||||
"releaseType": "",
|
||||
"releaseType": "Tipo",
|
||||
"grouping": "",
|
||||
"media": "",
|
||||
"mood": "",
|
||||
"date": ""
|
||||
"mood": "Humoro",
|
||||
"date": "",
|
||||
"missing": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Ludi",
|
||||
@@ -81,43 +84,44 @@
|
||||
"shuffle": "Miksi",
|
||||
"addToPlaylist": "Aldoni al la Ludlisto",
|
||||
"download": "Elŝuti",
|
||||
"info": "",
|
||||
"share": ""
|
||||
"info": "Akiri Informon",
|
||||
"share": "Diskonigi"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Ĉiuj",
|
||||
"random": "Hazarda",
|
||||
"recentlyAdded": "Lastatempe Aldonita",
|
||||
"recentlyPlayed": "Lastatempe Ludita",
|
||||
"random": "Hazardaj",
|
||||
"recentlyAdded": "Lastatempe Aldonitaj",
|
||||
"recentlyPlayed": "Lastatempe Luditaj",
|
||||
"mostPlayed": "Plej Luditaj",
|
||||
"starred": "Stelplena",
|
||||
"topRated": "Plej Alte Taksite"
|
||||
"starred": "Stelplenaj",
|
||||
"topRated": "Plej Alte Taksitaj"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artisto |||| Artistoj",
|
||||
"fields": {
|
||||
"name": "Nomo",
|
||||
"albumCount": "Nombro da albumoj",
|
||||
"songCount": "Kanto kalkula",
|
||||
"playCount": "Teatraĵoj",
|
||||
"albumCount": "Kvanto da Albumoj",
|
||||
"songCount": "Kanta Kalkulo",
|
||||
"playCount": "Ludoj",
|
||||
"rating": "Takso",
|
||||
"genre": "",
|
||||
"size": "",
|
||||
"role": ""
|
||||
"genre": "Ĝenro",
|
||||
"size": "Grando",
|
||||
"role": "",
|
||||
"missing": ""
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "",
|
||||
"artist": "",
|
||||
"composer": "",
|
||||
"conductor": "",
|
||||
"lyricist": "",
|
||||
"arranger": "",
|
||||
"albumartist": "Albuma Artisto |||| Albumaj Artistoj",
|
||||
"artist": "Artisto |||| Artistoj",
|
||||
"composer": "Komponisto |||| Komponistoj",
|
||||
"conductor": "Dirigento |||| Dirigentoj",
|
||||
"lyricist": "Kantoteksisto |||| Kantotekstistoj",
|
||||
"arranger": "Aranĝisto |||| Aranĝistoj",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"mixer": "",
|
||||
"remixer": "",
|
||||
"mixer": "Miksisto |||| Miksistoj",
|
||||
"remixer": "Remiksisto |||| Remiksistoj",
|
||||
"djmixer": "",
|
||||
"performer": ""
|
||||
}
|
||||
@@ -135,8 +139,8 @@
|
||||
"changePassword": "Ĉu Ŝanĝi Pasvorton?",
|
||||
"currentPassword": "Nuna Pasvorto",
|
||||
"newPassword": "Nova Pasvorto",
|
||||
"token": "",
|
||||
"lastAccessAt": ""
|
||||
"token": "Ĵetono",
|
||||
"lastAccessAt": "Lasta Atingo"
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto"
|
||||
@@ -147,8 +151,8 @@
|
||||
"deleted": "Uzanto forigita"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "",
|
||||
"clickHereForToken": ""
|
||||
"listenBrainzToken": "Enigi vian uzantan ĵetonon de ListenBrainz.",
|
||||
"clickHereForToken": "Alkakli ĉi tie por akiri vian ĵetonon"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -161,7 +165,7 @@
|
||||
"userName": "Uzantnomo",
|
||||
"lastSeen": "Laste Vidita Je",
|
||||
"reportRealPath": "Raporti vera pado",
|
||||
"scrobbleEnabled": ""
|
||||
"scrobbleEnabled": "Sendi Scrobbles al eksteraj servoj"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
@@ -191,8 +195,9 @@
|
||||
"selectPlaylist": "Elektu ludliston :",
|
||||
"addNewPlaylist": "Krei \"%{name}\"",
|
||||
"export": "Eksporti",
|
||||
"makePublic": "",
|
||||
"makePrivate": ""
|
||||
"makePublic": "Publikigi",
|
||||
"makePrivate": "Malpublikigi",
|
||||
"saveQueue": ""
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Aldoni duobligitajn kantojn",
|
||||
@@ -200,33 +205,33 @@
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "",
|
||||
"name": "Radio |||| Radioj",
|
||||
"fields": {
|
||||
"name": "",
|
||||
"streamUrl": "",
|
||||
"homePageUrl": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": ""
|
||||
"name": "Nomo",
|
||||
"streamUrl": "Flua Ligilo",
|
||||
"homePageUrl": "Hejmpaĝa Ligilo",
|
||||
"updatedAt": "Ĝisdatiĝis je",
|
||||
"createdAt": "Kreiĝis je"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": ""
|
||||
"playNow": "Ludi Nun"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "",
|
||||
"name": "Diskonigo |||| Diskonigoj",
|
||||
"fields": {
|
||||
"username": "",
|
||||
"url": "",
|
||||
"description": "",
|
||||
"contents": "",
|
||||
"expiresAt": "",
|
||||
"lastVisitedAt": "",
|
||||
"visitCount": "",
|
||||
"format": "",
|
||||
"maxBitRate": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": "",
|
||||
"downloadable": ""
|
||||
"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?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@@ -237,7 +242,8 @@
|
||||
"updatedAt": ""
|
||||
},
|
||||
"actions": {
|
||||
"remove": ""
|
||||
"remove": "",
|
||||
"remove_all": ""
|
||||
},
|
||||
"notifications": {
|
||||
"removed": ""
|
||||
@@ -258,7 +264,7 @@
|
||||
"sign_in": "Ensaluti",
|
||||
"sign_in_error": "Aŭtentikigo malsukcesis, bonvolu reprovi",
|
||||
"logout": "Elsaluti",
|
||||
"insightsCollectionNote": ""
|
||||
"insightsCollectionNote": "Navidrome kolektas anoniman uzdatumon por helpi\nplibonigi la projekton. Alklaku [ĉi tie] por lerni pli kaj\nsupozi permeson se vi volas"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Bonvolu uzi nur literojn kaj ciferojn",
|
||||
@@ -273,7 +279,7 @@
|
||||
"oneOf": "Devas esti unu el: %{options}",
|
||||
"regex": "Devas kongrui kun specifa formato (regexp): %{pattern}",
|
||||
"unique": "Devas esti unika",
|
||||
"url": ""
|
||||
"url": "Devas esti valida ligilo"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Aldoni filtrilon",
|
||||
@@ -303,9 +309,9 @@
|
||||
"close_menu": "Fermu menuon",
|
||||
"unselect": "Malelekti",
|
||||
"skip": "Pasigi",
|
||||
"bulk_actions_mobile": "",
|
||||
"share": "",
|
||||
"download": ""
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Diskonigi",
|
||||
"download": "Elŝuti"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Jes",
|
||||
@@ -381,13 +387,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": ""
|
||||
"new_version": "Nova versio haveblas! Bonvolu reŝargi la fenestron."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "",
|
||||
"columnsToDisplay": "Kolumnoj Por Montri",
|
||||
"layout": "Aranĝo",
|
||||
"grid": "Krado",
|
||||
"table": ""
|
||||
"table": "Tabelo"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
@@ -400,29 +406,31 @@
|
||||
"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": "",
|
||||
"lastfmLinkFailure": "",
|
||||
"lastfmUnlinkSuccess": "",
|
||||
"lastfmUnlinkFailure": "",
|
||||
"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",
|
||||
"openIn": {
|
||||
"lastfm": "",
|
||||
"musicbrainz": ""
|
||||
"lastfm": "Malfermi en Last.fm",
|
||||
"musicbrainz": "Malfermi en MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "",
|
||||
"listenBrainzLinkSuccess": "",
|
||||
"listenBrainzLinkFailure": "",
|
||||
"listenBrainzUnlinkSuccess": "",
|
||||
"listenBrainzUnlinkFailure": "",
|
||||
"downloadOriginalFormat": "",
|
||||
"shareOriginalFormat": "",
|
||||
"shareDialogTitle": "",
|
||||
"shareBatchDialogTitle": "",
|
||||
"shareSuccess": "",
|
||||
"shareFailure": "",
|
||||
"downloadDialogTitle": "",
|
||||
"shareCopyToClipboard": "",
|
||||
"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",
|
||||
"remove_missing_title": "",
|
||||
"remove_missing_content": ""
|
||||
"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": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteko",
|
||||
@@ -436,22 +444,22 @@
|
||||
"language": "Lingvo",
|
||||
"defaultView": "Defaŭlta Vido",
|
||||
"desktop_notifications": "Labortablaj sciigoj",
|
||||
"lastfmScrobbling": "",
|
||||
"listenBrainzScrobbling": "",
|
||||
"replaygain": "",
|
||||
"preAmp": "",
|
||||
"lastfmScrobbling": "Scrobble al Last.fm",
|
||||
"listenBrainzScrobbling": "Scrobble al ListenBrainz",
|
||||
"replaygain": "ReplayGain-Reĝimo",
|
||||
"preAmp": "ReplayGain PreAmp (dB)",
|
||||
"gain": {
|
||||
"none": "",
|
||||
"album": "",
|
||||
"track": ""
|
||||
"none": "Malebligita",
|
||||
"album": "Uzi Albuman Songajnon",
|
||||
"track": "Uzi Kantan Songajnon"
|
||||
},
|
||||
"lastfmNotConfigured": ""
|
||||
}
|
||||
},
|
||||
"albumList": "Albumoj",
|
||||
"about": "Pri",
|
||||
"playlists": "",
|
||||
"sharedPlaylists": ""
|
||||
"playlists": "Ludlistoj",
|
||||
"sharedPlaylists": "Diskonigitaj Ludistoj"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Atendovico",
|
||||
@@ -485,7 +493,7 @@
|
||||
"featureRequests": "Trajta peto",
|
||||
"lastInsightsCollection": "",
|
||||
"insights": {
|
||||
"disabled": "",
|
||||
"disabled": "Malebligita",
|
||||
"waiting": ""
|
||||
}
|
||||
}
|
||||
@@ -496,7 +504,10 @@
|
||||
"quickScan": "Rapida Skanado",
|
||||
"fullScan": "Plena Skanado",
|
||||
"serverUptime": "Servila daŭro de funkciado",
|
||||
"serverDown": "SENKONEKTA"
|
||||
"serverDown": "SENKONEKTA",
|
||||
"scanType": "",
|
||||
"status": "",
|
||||
"elapsedTime": ""
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome klavkomando",
|
||||
@@ -509,7 +520,7 @@
|
||||
"vol_up": "Pli volumo",
|
||||
"vol_down": "Malpli volumo",
|
||||
"toggle_love": "Baskuli la stelon de nuna kanto",
|
||||
"current_song": ""
|
||||
"current_song": "Iri al Nuna Kanto"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,16 +28,19 @@
|
||||
"channels": "Canales",
|
||||
"createdAt": "Creado el",
|
||||
"grouping": "Agrupación",
|
||||
"mood": "",
|
||||
"mood": "Estado de ánimo",
|
||||
"participants": "Participantes",
|
||||
"tags": "Etiquetas",
|
||||
"mappedTags": "Etiquetas asignadas",
|
||||
"rawTags": "Etiquetas sin procesar"
|
||||
"rawTags": "Etiquetas sin procesar",
|
||||
"bitDepth": "Profundidad de bits",
|
||||
"sampleRate": "Frecuencia de muestreo",
|
||||
"missing": "Faltante"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Reproducir después",
|
||||
"playNow": "Reproducir ahora",
|
||||
"addToPlaylist": "Agregar a la lista de reproducción",
|
||||
"addToPlaylist": "Agregar a la playlist",
|
||||
"shuffleAll": "Todas aleatorias",
|
||||
"download": "Descarga",
|
||||
"playNext": "Siguiente",
|
||||
@@ -69,8 +72,10 @@
|
||||
"catalogNum": "Número de catálogo",
|
||||
"releaseType": "Tipo de lanzamiento",
|
||||
"grouping": "Agrupación",
|
||||
"media": "",
|
||||
"mood": ""
|
||||
"media": "Medios",
|
||||
"mood": "Estado de ánimo",
|
||||
"date": "Fecha de grabación",
|
||||
"missing": "Faltante"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Reproducir",
|
||||
@@ -102,7 +107,8 @@
|
||||
"rating": "Calificación",
|
||||
"genre": "Género",
|
||||
"size": "Tamaño",
|
||||
"role": "Rol"
|
||||
"role": "Rol",
|
||||
"missing": "Faltante"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artista del álbum",
|
||||
@@ -190,11 +196,12 @@
|
||||
"addNewPlaylist": "Creada \"%{name}\"",
|
||||
"export": "Exportar",
|
||||
"makePublic": "Hazla pública",
|
||||
"makePrivate": "Hazla privada"
|
||||
"makePrivate": "Hazla privada",
|
||||
"saveQueue": "Guardar la fila de reproducción en una playlist"
|
||||
},
|
||||
"message": {
|
||||
"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?"
|
||||
"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?"
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
@@ -235,11 +242,13 @@
|
||||
"updatedAt": "Actualizado el"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Eliminar"
|
||||
"remove": "Eliminar",
|
||||
"remove_all": "Eliminar todo"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Eliminado"
|
||||
}
|
||||
},
|
||||
"empty": "No hay archivos perdidos"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -419,7 +428,9 @@
|
||||
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro",
|
||||
"remove_missing_title": "Eliminar elemento faltante",
|
||||
"remove_missing_content": ""
|
||||
"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."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
@@ -451,7 +462,7 @@
|
||||
"sharedPlaylists": "Playlists Compartidas"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Lista de reproducción",
|
||||
"playListsText": "Fila de reproducción",
|
||||
"openText": "Abrir",
|
||||
"closeText": "Cerrar",
|
||||
"notContentText": "Sin música",
|
||||
@@ -493,7 +504,10 @@
|
||||
"quickScan": "Escaneo rápido",
|
||||
"fullScan": "Escaneo completo",
|
||||
"serverUptime": "Uptime del servidor",
|
||||
"serverDown": "OFFLINE"
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Tipo",
|
||||
"status": "Error de escaneo",
|
||||
"elapsedTime": "Tiempo transcurrido"
|
||||
},
|
||||
"help": {
|
||||
"title": "Atajos de teclado de Navidrome",
|
||||
@@ -509,4 +523,4 @@
|
||||
"current_song": "Canción actual"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,10 @@
|
||||
"year": "Urtea",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"updatedAt": "Eguneratze-data:",
|
||||
"bitRate": "Bit tasa",
|
||||
"bitRate": "Bit-tasa",
|
||||
"bitDepth": "Bit-sakonera",
|
||||
"sampleRate": "Lagin-tasa",
|
||||
"channels": "Kanalak",
|
||||
"discSubtitle": "Diskoaren azpititulua",
|
||||
"starred": "Gogokoa",
|
||||
"comment": "Iruzkina",
|
||||
@@ -25,14 +28,13 @@
|
||||
"quality": "Kalitatea",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Azkenekoz erreproduzitua:",
|
||||
"channels": "Kanalak",
|
||||
"createdAt": "Gehitu zen data:",
|
||||
"grouping": "",
|
||||
"mood": "",
|
||||
"participants": "",
|
||||
"tags": "",
|
||||
"mappedTags": "",
|
||||
"rawTags": ""
|
||||
"grouping": "Multzokatzea",
|
||||
"mood": "Aldartea",
|
||||
"participants": "Partaide gehiago",
|
||||
"tags": "Traola gehiago",
|
||||
"mappedTags": "Esleitutako traolak",
|
||||
"rawTags": "Traola gordinak"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Erreproduzitu ondoren",
|
||||
@@ -52,25 +54,26 @@
|
||||
"duration": "Iraupena",
|
||||
"songCount": "abesti",
|
||||
"playCount": "Erreprodukzioak",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"name": "Izena",
|
||||
"genre": "Generoa",
|
||||
"compilation": "Konpilazioa",
|
||||
"year": "Urtea",
|
||||
"updatedAt": "Aktualizatze-data:",
|
||||
"comment": "Iruzkina",
|
||||
"rating": "Balorazioa",
|
||||
"createdAt": "Gehitu zen data:",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"date": "Recording Date",
|
||||
"originalDate": "Jatorrizkoa",
|
||||
"releaseDate": "Argitaratze-data:",
|
||||
"releases": "Argitaratzea |||| Argitaratzeak",
|
||||
"released": "Argitaratua",
|
||||
"recordLabel": "",
|
||||
"catalogNum": "",
|
||||
"releaseType": "",
|
||||
"grouping": "",
|
||||
"media": "",
|
||||
"mood": ""
|
||||
"updatedAt": "Aktualizatze-data:",
|
||||
"comment": "Iruzkina",
|
||||
"rating": "Balorazioa",
|
||||
"createdAt": "Gehitu zen data:",
|
||||
"recordLabel": "Disketxea",
|
||||
"catalogNum": "Katalogo-zenbakia",
|
||||
"releaseType": "Mota",
|
||||
"grouping": "Multzokatzea",
|
||||
"media": "Multimedia",
|
||||
"mood": "Aldartea"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Erreproduzitu",
|
||||
@@ -84,7 +87,7 @@
|
||||
},
|
||||
"lists": {
|
||||
"all": "Guztiak",
|
||||
"random": "Aleatorioa",
|
||||
"random": "Aleatorioki",
|
||||
"recentlyAdded": "Berriki gehitutakoak",
|
||||
"recentlyPlayed": "Berriki entzundakoak",
|
||||
"mostPlayed": "Gehien entzundakoak",
|
||||
@@ -98,26 +101,26 @@
|
||||
"name": "Izena",
|
||||
"albumCount": "Album kopurua",
|
||||
"songCount": "Abesti kopurua",
|
||||
"size": "Tamaina",
|
||||
"playCount": "Erreprodukzio kopurua",
|
||||
"rating": "Balorazioa",
|
||||
"genre": "Generoa",
|
||||
"size": "Tamaina",
|
||||
"role": ""
|
||||
"role": "Rola"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "",
|
||||
"artist": "",
|
||||
"composer": "",
|
||||
"conductor": "",
|
||||
"lyricist": "",
|
||||
"arranger": "",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"mixer": "",
|
||||
"remixer": "",
|
||||
"djmixer": "",
|
||||
"performer": ""
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -238,7 +241,8 @@
|
||||
"updatedAt": "Desagertze-data:"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Kendu"
|
||||
"remove": "Kendu",
|
||||
"remove_all": "Kendu guztia"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Faltan zeuden fitxategiak kendu dira"
|
||||
@@ -258,7 +262,7 @@
|
||||
"sign_in": "Sartu",
|
||||
"sign_in_error": "Autentifikazioak huts egin du, saiatu berriro",
|
||||
"logout": "Amaitu saioa",
|
||||
"insightsCollectionNote": ""
|
||||
"insightsCollectionNote": "Navidromek erabilera-datu anonimoak biltzen ditu\nproiektua hobetzen laguntzeko. Egin klik [hemen]\ngehiago ikasteko, eta datuak ez biltzeko eskatzeko,\nhala nahi izanez gero."
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Erabili hizkiak eta zenbakiak bakarrik",
|
||||
@@ -398,31 +402,33 @@
|
||||
"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})",
|
||||
"shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla",
|
||||
"remove_missing_title": "",
|
||||
"remove_missing_content": ""
|
||||
"downloadOriginalFormat": "Deskargatu jatorrizko formatua"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Liburutegia",
|
||||
@@ -436,6 +442,7 @@
|
||||
"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",
|
||||
@@ -444,14 +451,13 @@
|
||||
"none": "Bat ere ez",
|
||||
"album": "Albuma",
|
||||
"track": "Pista"
|
||||
},
|
||||
"lastfmNotConfigured": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Albumak",
|
||||
"about": "Honi buruz",
|
||||
"playlists": "Zerrendak",
|
||||
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak"
|
||||
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
|
||||
"about": "Honi buruz"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Erreprodukzio-zerrenda",
|
||||
@@ -483,10 +489,10 @@
|
||||
"homepage": "Hasierako orria",
|
||||
"source": "Iturburu kodea",
|
||||
"featureRequests": "Eskatu ezaugarria",
|
||||
"lastInsightsCollection": "",
|
||||
"lastInsightsCollection": "Bildutako azken datuak",
|
||||
"insights": {
|
||||
"disabled": "",
|
||||
"waiting": ""
|
||||
"disabled": "Ezgaituta",
|
||||
"waiting": "Zain"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -496,7 +502,10 @@
|
||||
"quickScan": "Arakatze azkarra",
|
||||
"fullScan": "Arakatze sakona",
|
||||
"serverUptime": "Zerbitzariak piztuta daraman denbora",
|
||||
"serverDown": "LINEAZ KANPO"
|
||||
"serverDown": "LINEAZ KANPO",
|
||||
"scanType": "Mota",
|
||||
"status": "Errorea arakatzean",
|
||||
"elapsedTime": "Igarotako denbora"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidromeren laster-teklak",
|
||||
|
||||
@@ -32,7 +32,10 @@
|
||||
"participants": "Lisäosallistujat",
|
||||
"tags": "Lisätunnisteet",
|
||||
"mappedTags": "Mäpättyt tunnisteet",
|
||||
"rawTags": "Raakatunnisteet"
|
||||
"rawTags": "Raakatunnisteet",
|
||||
"bitDepth": "Bittisyvyys",
|
||||
"sampleRate": "Näytteenottotaajuus",
|
||||
"missing": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lisää jonoon",
|
||||
@@ -70,7 +73,9 @@
|
||||
"releaseType": "Tyyppi",
|
||||
"grouping": "Ryhmittely",
|
||||
"media": "Media",
|
||||
"mood": "Tunnelma"
|
||||
"mood": "Tunnelma",
|
||||
"date": "Tallennuspäivä",
|
||||
"missing": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Soita",
|
||||
@@ -102,7 +107,8 @@
|
||||
"rating": "Arvostelu",
|
||||
"genre": "Tyylilaji",
|
||||
"size": "Koko",
|
||||
"role": "Rooli"
|
||||
"role": "Rooli",
|
||||
"missing": ""
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albumitaiteilija |||| Albumitaiteilijat",
|
||||
@@ -190,7 +196,8 @@
|
||||
"addNewPlaylist": "Luo \"%{name}\"",
|
||||
"export": "Vie",
|
||||
"makePublic": "Tee julkinen",
|
||||
"makePrivate": "Tee yksityinen"
|
||||
"makePrivate": "Tee yksityinen",
|
||||
"saveQueue": ""
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Lisää olemassa oleva kappale",
|
||||
@@ -235,11 +242,13 @@
|
||||
"updatedAt": "Katosi"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Poista"
|
||||
"remove": "Poista",
|
||||
"remove_all": ""
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Puuttuvat tiedostot poistettu"
|
||||
}
|
||||
},
|
||||
"empty": "Ei puuttuvia tiedostoja"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -419,7 +428,9 @@
|
||||
"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_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": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Kirjasto",
|
||||
@@ -493,7 +504,10 @@
|
||||
"quickScan": "Nopea tarkistus",
|
||||
"fullScan": "Täysi tarkistus",
|
||||
"serverUptime": "Palvelun käyttöaika",
|
||||
"serverDown": "SAMMUTETTU"
|
||||
"serverDown": "SAMMUTETTU",
|
||||
"scanType": "",
|
||||
"status": "",
|
||||
"elapsedTime": ""
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome pikapainikkeet",
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
"tags": "Étiquettes supplémentaires",
|
||||
"mappedTags": "Étiquettes correspondantes",
|
||||
"rawTags": "Étiquettes brutes",
|
||||
"bitDepth": "Profondeur de bit"
|
||||
"bitDepth": "Profondeur de bits",
|
||||
"sampleRate": "Fréquence d'échantillonnage",
|
||||
"missing": "Manquant"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ajouter à la file",
|
||||
@@ -71,7 +73,9 @@
|
||||
"releaseType": "Type",
|
||||
"grouping": "Regroupement",
|
||||
"media": "Média",
|
||||
"mood": "Humeur"
|
||||
"mood": "Humeur",
|
||||
"date": "Date d'enregistrement",
|
||||
"missing": "Manquant"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Lire",
|
||||
@@ -103,7 +107,8 @@
|
||||
"rating": "Classement",
|
||||
"genre": "Genre",
|
||||
"size": "Taille",
|
||||
"role": "Rôle"
|
||||
"role": "Rôle",
|
||||
"missing": "Manquant"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artiste de l'album |||| Artistes de l'album",
|
||||
@@ -191,7 +196,8 @@
|
||||
"addNewPlaylist": "Créer \"%{name}\"",
|
||||
"export": "Exporter",
|
||||
"makePublic": "Rendre publique",
|
||||
"makePrivate": "Rendre privée"
|
||||
"makePrivate": "Rendre privée",
|
||||
"saveQueue": "Sauvegarder la file de lecture dans la playlist"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Pistes déjà présentes dans la playlist",
|
||||
@@ -236,7 +242,8 @@
|
||||
"updatedAt": "A disparu le"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Supprimer"
|
||||
"remove": "Supprimer",
|
||||
"remove_all": "Tout supprimer"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Fichier(s) manquant(s) supprimé(s)"
|
||||
@@ -421,7 +428,9 @@
|
||||
"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_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."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothèque",
|
||||
@@ -495,7 +504,10 @@
|
||||
"quickScan": "Scan rapide",
|
||||
"fullScan": "Scan complet",
|
||||
"serverUptime": "Disponibilité du serveur",
|
||||
"serverDown": "HORS LIGNE"
|
||||
"serverDown": "HORS LIGNE",
|
||||
"scanType": "Type",
|
||||
"status": "Erreur de scan",
|
||||
"elapsedTime": "Temps écoulé"
|
||||
},
|
||||
"help": {
|
||||
"title": "Raccourcis Navidrome",
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"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",
|
||||
@@ -32,7 +34,8 @@
|
||||
"participants": "További résztvevők",
|
||||
"tags": "További címkék",
|
||||
"mappedTags": "Feldolgozott címkék",
|
||||
"rawTags": "Nyers címkék"
|
||||
"rawTags": "Nyers címkék",
|
||||
"missing": "Hiányzó"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lejátszás útolsóként",
|
||||
@@ -56,6 +59,7 @@
|
||||
"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",
|
||||
@@ -70,7 +74,8 @@
|
||||
"releaseType": "Típus",
|
||||
"grouping": "Csoportosítás",
|
||||
"media": "Média",
|
||||
"mood": "Hangulat"
|
||||
"mood": "Hangulat",
|
||||
"missing": "Hiányzó"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Lejátszás",
|
||||
@@ -102,7 +107,8 @@
|
||||
"rating": "Értékelés",
|
||||
"genre": "Stílus",
|
||||
"size": "Méret",
|
||||
"role": "Szerep"
|
||||
"role": "Szerep",
|
||||
"missing": "Hiányzó"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Album előadó |||| Album előadók",
|
||||
@@ -189,6 +195,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -229,13 +236,15 @@
|
||||
},
|
||||
"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": "Eltávolítás",
|
||||
"remove_all": "Összes eltávolítása"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Hiányzó fájl(ok) eltávolítva"
|
||||
@@ -395,6 +404,8 @@
|
||||
"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.",
|
||||
@@ -406,7 +417,7 @@
|
||||
"musicbrainz": "Megnyitás MusicBrainz-ben"
|
||||
},
|
||||
"lastfmLink": "Bővebben...",
|
||||
"listenBrainzLinkSuccess": "Sikeresen összekapcsolva ListenBrainz-el és halgatott számok küldése %{user} felhasználónak engedélyezve.",
|
||||
"listenBrainzLinkSuccess": "Sikeresen összekapcsolva ListenBrainz-el. 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.",
|
||||
@@ -451,7 +462,7 @@
|
||||
"sharedPlaylists": "Megosztott lej. listák"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Lejátszási lista",
|
||||
"playListsText": "Műsorlista",
|
||||
"openText": "Megnyitás",
|
||||
"closeText": "Bezárás",
|
||||
"notContentText": "Nincs zene",
|
||||
@@ -493,7 +504,10 @@
|
||||
"quickScan": "Gyors beolvasás",
|
||||
"fullScan": "Teljes beolvasás",
|
||||
"serverUptime": "Szerver üzemidő",
|
||||
"serverDown": "OFFLINE"
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Típus",
|
||||
"status": "Szkennelési hiba",
|
||||
"elapsedTime": "Eltelt idő"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Gyorsbillentyűk",
|
||||
|
||||
@@ -32,7 +32,10 @@
|
||||
"participants": "Partisipan tambahan",
|
||||
"tags": "Tag tambahan",
|
||||
"mappedTags": "Tag yang dipetakan",
|
||||
"rawTags": "Tag raw"
|
||||
"rawTags": "Tag raw",
|
||||
"bitDepth": "Bit depth",
|
||||
"sampleRate": "Sample rate",
|
||||
"missing": "Hilang"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Tambah ke antrean",
|
||||
@@ -70,7 +73,9 @@
|
||||
"releaseType": "Tipe",
|
||||
"grouping": "Pengelompokkan",
|
||||
"media": "Media",
|
||||
"mood": "Mood"
|
||||
"mood": "Mood",
|
||||
"date": "Tanggal Perekaman",
|
||||
"missing": "Hilang"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Putar",
|
||||
@@ -102,7 +107,8 @@
|
||||
"rating": "Peringkat",
|
||||
"genre": "Genre",
|
||||
"size": "Ukuran",
|
||||
"role": "Peran"
|
||||
"role": "Peran",
|
||||
"missing": "Hilang"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artis Album |||| Artis Album",
|
||||
@@ -163,7 +169,7 @@
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Transkode |||| Transkode",
|
||||
"name": "Transkoding |||| Transkoding",
|
||||
"fields": {
|
||||
"name": "Nama",
|
||||
"targetFormat": "Target Format",
|
||||
@@ -190,7 +196,8 @@
|
||||
"addNewPlaylist": "Buat \"%{name}\"",
|
||||
"export": "Ekspor",
|
||||
"makePublic": "Jadikan Publik",
|
||||
"makePrivate": "Jadikan Pribadi"
|
||||
"makePrivate": "Jadikan Pribadi",
|
||||
"saveQueue": "Simpan Antrean ke Playlist"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Tambahkan lagu duplikat",
|
||||
@@ -235,11 +242,13 @@
|
||||
"updatedAt": "Tidak muncul di"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Hapus"
|
||||
"remove": "Hapus",
|
||||
"remove_all": "Hapus Semua"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "File yang hilang dihapus"
|
||||
}
|
||||
},
|
||||
"empty": "Tidak ada File yang Hilang"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -277,7 +286,7 @@
|
||||
"add": "Tambah",
|
||||
"back": "Kembali",
|
||||
"bulk_actions": "1 item dipilih |||| %{smart_count} item dipilih",
|
||||
"cancel": "Batalkan",
|
||||
"cancel": "Batal",
|
||||
"clear_input_value": "Hapus",
|
||||
"clone": "Klon",
|
||||
"confirm": "Konfirmasi",
|
||||
@@ -292,7 +301,7 @@
|
||||
"save": "Simpan",
|
||||
"search": "Cari",
|
||||
"show": "Tampilkan",
|
||||
"sort": "Sortir",
|
||||
"sort": "Urutkan",
|
||||
"undo": "Batalkan",
|
||||
"expand": "Luaskan",
|
||||
"close": "Tutup",
|
||||
@@ -312,7 +321,7 @@
|
||||
"create": "Buat %{name}",
|
||||
"dashboard": "Dasbor",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Ada yang tidak beres",
|
||||
"error": "Terjadi kesalahan",
|
||||
"list": "%{name}",
|
||||
"loading": "Memuat",
|
||||
"not_found": "Tidak ditemukan",
|
||||
@@ -356,7 +365,7 @@
|
||||
"unsaved_changes": "Beberapa perubahan tidak disimpan. Apakah Kamu yakin ingin mengabaikannya?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Tidak ada hasil yang ditemukan",
|
||||
"no_results": "Hasil tidak 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",
|
||||
@@ -371,8 +380,8 @@
|
||||
"updated": "Elemen diperbarui |||| %{smart_count} elemen diperbarui",
|
||||
"created": "Elemen dibuat",
|
||||
"deleted": "Elemen dihapus |||| %{smart_count} elemen dihapus",
|
||||
"bad_item": "Elemen salah",
|
||||
"item_doesnt_exist": "Tidak ada elemen",
|
||||
"bad_item": "Kesalahan elemen",
|
||||
"item_doesnt_exist": "Elemen tidak ditemukan",
|
||||
"http_error": "Kesalahan komunikasi peladen",
|
||||
"data_provider_error": "dataProvider galat. Periksa konsol untuk detailnya.",
|
||||
"i18n_error": "Tidak dapat memuat terjemahan untuk bahasa yang diatur",
|
||||
@@ -419,7 +428,9 @@
|
||||
"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_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."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Pustaka",
|
||||
@@ -451,7 +462,7 @@
|
||||
"sharedPlaylists": "Playlist yang Dibagikan"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Mainkan Antrean",
|
||||
"playListsText": "Putar Antrean",
|
||||
"openText": "Buka",
|
||||
"closeText": "Tutup",
|
||||
"notContentText": "Tidak ada musik",
|
||||
@@ -471,7 +482,7 @@
|
||||
"playModeText": {
|
||||
"order": "Berurutan",
|
||||
"orderLoop": "Ulang",
|
||||
"singleLoop": "Ulangi Satu",
|
||||
"singleLoop": "Ulangi Sekali",
|
||||
"shufflePlay": "Acak"
|
||||
}
|
||||
},
|
||||
@@ -493,7 +504,10 @@
|
||||
"quickScan": "Pemindaian Cepat",
|
||||
"fullScan": "Pemindaian Penuh",
|
||||
"serverUptime": "Waktu Aktif Peladen",
|
||||
"serverDown": "LURING"
|
||||
"serverDown": "LURING",
|
||||
"scanType": "Tipe",
|
||||
"status": "Kesalahan Memindai",
|
||||
"elapsedTime": "Waktu Berakhir"
|
||||
},
|
||||
"help": {
|
||||
"title": "Tombol Pintasan Navidrome",
|
||||
|
||||
@@ -26,7 +26,16 @@
|
||||
"bpm": "BPM",
|
||||
"playDate": "Laatst afgespeeld",
|
||||
"channels": "Kanalen",
|
||||
"createdAt": "Datum toegevoegd"
|
||||
"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"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Voeg toe aan wachtrij",
|
||||
@@ -58,7 +67,15 @@
|
||||
"originalDate": "Origineel",
|
||||
"releaseDate": "Uitgegeven",
|
||||
"releases": "Uitgave |||| Uitgaven",
|
||||
"released": "Uitgegeven"
|
||||
"released": "Uitgegeven",
|
||||
"recordLabel": "Label",
|
||||
"catalogNum": "Catalogus nummer",
|
||||
"releaseType": "Type",
|
||||
"grouping": "Groep",
|
||||
"media": "Media",
|
||||
"mood": "Sfeer",
|
||||
"date": "Opnamedatum",
|
||||
"missing": "Ontbrekend"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Afspelen",
|
||||
@@ -89,7 +106,24 @@
|
||||
"playCount": "Afgespeeld",
|
||||
"rating": "Beoordeling",
|
||||
"genre": "Genre",
|
||||
"size": "Grootte"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -162,7 +196,8 @@
|
||||
"addNewPlaylist": "Creëer \"%{name}\"",
|
||||
"export": "Exporteer",
|
||||
"makePublic": "Openbaar maken",
|
||||
"makePrivate": "Privé maken"
|
||||
"makePrivate": "Privé maken",
|
||||
"saveQueue": "Bewaar wachtrij als playlist"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Dubbele nummers toevoegen",
|
||||
@@ -198,6 +233,22 @@
|
||||
"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": {
|
||||
@@ -212,7 +263,8 @@
|
||||
"password": "Wachtwoord",
|
||||
"sign_in": "Inloggen",
|
||||
"sign_in_error": "Authenticatie mislukt, probeer opnieuw a.u.b.",
|
||||
"logout": "Uitloggen"
|
||||
"logout": "Uitloggen",
|
||||
"insightsCollectionNote": "Navidrome verzamelt anonieme gebruiksdata om het project te verbeteren. Klik [hier] voor meer info en de mogelijkheid om te weigeren"
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Gebruik alleen letters en cijfers",
|
||||
@@ -374,7 +426,11 @@
|
||||
"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"
|
||||
"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."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotheek",
|
||||
@@ -396,16 +452,17 @@
|
||||
"none": "Uitgeschakeld",
|
||||
"album": "Gebruik Album Gain",
|
||||
"track": "Gebruik Track Gain"
|
||||
}
|
||||
},
|
||||
"lastfmNotConfigured": "Last.fm API-sleutel is niet geconfigureerd"
|
||||
}
|
||||
},
|
||||
"albumList": "Albums",
|
||||
"about": "Over",
|
||||
"playlists": "Playlists",
|
||||
"sharedPlaylists": "Gedeelde playlists"
|
||||
"playlists": "Afspeellijsten",
|
||||
"sharedPlaylists": "Gedeelde afspeellijsten"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Afspeellijst afspelen",
|
||||
"playListsText": "Wachtrij",
|
||||
"openText": "Openen",
|
||||
"closeText": "Sluiten",
|
||||
"notContentText": "Geen muziek",
|
||||
@@ -433,7 +490,12 @@
|
||||
"links": {
|
||||
"homepage": "Thuispagina",
|
||||
"source": "Broncode",
|
||||
"featureRequests": "Functie verzoeken"
|
||||
"featureRequests": "Functie verzoeken",
|
||||
"lastInsightsCollection": "Laatste inzichten",
|
||||
"insights": {
|
||||
"disabled": "Uitgeschakeld",
|
||||
"waiting": "Wachten"
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -442,7 +504,10 @@
|
||||
"quickScan": "Snelle scan",
|
||||
"fullScan": "Volledige scan",
|
||||
"serverUptime": "Server uptime",
|
||||
"serverDown": "Offline"
|
||||
"serverDown": "Offline",
|
||||
"scanType": "Type",
|
||||
"status": "Scan fout",
|
||||
"elapsedTime": "Verlopen tijd"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome sneltoetsen",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"languageName": "Português",
|
||||
"languageName": "Português (Brasil)",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Música |||| Músicas",
|
||||
@@ -18,7 +18,6 @@
|
||||
"size": "Tamanho",
|
||||
"updatedAt": "Últ. Atualização",
|
||||
"bitRate": "Bitrate",
|
||||
"bitDepth": "Profundidade de bits",
|
||||
"discSubtitle": "Sub-título do disco",
|
||||
"starred": "Favorita",
|
||||
"comment": "Comentário",
|
||||
@@ -33,7 +32,10 @@
|
||||
"participants": "Outros Participantes",
|
||||
"tags": "Outras Tags",
|
||||
"mappedTags": "Tags mapeadas",
|
||||
"rawTags": "Tags originais"
|
||||
"rawTags": "Tags originais",
|
||||
"bitDepth": "Profundidade de bits",
|
||||
"sampleRate": "Taxa de amostragem",
|
||||
"missing": "Ausente"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Adicionar à fila",
|
||||
@@ -57,7 +59,6 @@
|
||||
"genre": "Gênero",
|
||||
"compilation": "Coletânea",
|
||||
"year": "Ano",
|
||||
"date": "Data de Lançamento",
|
||||
"updatedAt": "Últ. Atualização",
|
||||
"comment": "Comentário",
|
||||
"rating": "Classificação",
|
||||
@@ -72,7 +73,9 @@
|
||||
"releaseType": "Tipo",
|
||||
"grouping": "Agrupamento",
|
||||
"media": "Mídia",
|
||||
"mood": "Mood"
|
||||
"mood": "Mood",
|
||||
"date": "Data de Lançamento",
|
||||
"missing": "Ausente"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Tocar",
|
||||
@@ -104,7 +107,8 @@
|
||||
"rating": "Classificação",
|
||||
"genre": "Gênero",
|
||||
"size": "Tamanho",
|
||||
"role": "Role"
|
||||
"role": "Role",
|
||||
"missing": "Ausente"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artista do Álbum |||| Artistas do Álbum",
|
||||
@@ -192,7 +196,8 @@
|
||||
"addNewPlaylist": "Criar \"%{name}\"",
|
||||
"export": "Exportar",
|
||||
"makePublic": "Pública",
|
||||
"makePrivate": "Pessoal"
|
||||
"makePrivate": "Pessoal",
|
||||
"saveQueue": "Salvar fila em nova Playlist"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Adicionar músicas duplicadas",
|
||||
@@ -231,18 +236,19 @@
|
||||
},
|
||||
"missing": {
|
||||
"name": "Arquivo ausente |||| Arquivos ausentes",
|
||||
"empty": "Nenhum arquivo ausente",
|
||||
"fields": {
|
||||
"path": "Caminho",
|
||||
"size": "Tamanho",
|
||||
"updatedAt": "Desaparecido em"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Remover"
|
||||
"remove": "Remover",
|
||||
"remove_all": "Remover todos"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Arquivo(s) ausente(s) removido(s)"
|
||||
}
|
||||
},
|
||||
"empty": "Nenhum arquivo ausente"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -422,7 +428,9 @@
|
||||
"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_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."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
@@ -496,7 +504,10 @@
|
||||
"quickScan": "Scan rápido",
|
||||
"fullScan": "Scan completo",
|
||||
"serverUptime": "Uptime do servidor",
|
||||
"serverDown": "DESCONECTADO"
|
||||
"serverDown": "DESCONECTADO",
|
||||
"scanType": "Tipo",
|
||||
"status": "Erro",
|
||||
"elapsedTime": "Duração"
|
||||
},
|
||||
"help": {
|
||||
"title": "Teclas de atalho",
|
||||
@@ -33,8 +33,9 @@
|
||||
"tags": "Дополнительные теги",
|
||||
"mappedTags": "Сопоставленные теги",
|
||||
"rawTags": "Исходные теги",
|
||||
"bitDepth": "Битовая глубина",
|
||||
"sampleRate": "Частота дискретизации (Гц)"
|
||||
"bitDepth": "Битовая глубина (Bit)",
|
||||
"sampleRate": "Частота дискретизации (Hz)",
|
||||
"missing": "Поле отсутствует"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "В очередь",
|
||||
@@ -73,7 +74,8 @@
|
||||
"grouping": "Группирование",
|
||||
"media": "Медиа",
|
||||
"mood": "Настроение",
|
||||
"date": "Дата записи"
|
||||
"date": "Дата записи",
|
||||
"missing": "Поле отсутствует"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Играть",
|
||||
@@ -105,7 +107,8 @@
|
||||
"rating": "Рейтинг",
|
||||
"genre": "Жанр",
|
||||
"size": "Размер",
|
||||
"role": "Роль"
|
||||
"role": "Роль",
|
||||
"missing": "Поле отсутствует"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Исполнитель альбома |||| Исполнители альбома",
|
||||
@@ -157,7 +160,7 @@
|
||||
"fields": {
|
||||
"name": "Имя",
|
||||
"transcodingId": "Транскодирование",
|
||||
"maxBitRate": "Макс. Битрейт",
|
||||
"maxBitRate": "Макс. битрейт",
|
||||
"client": "Клиент",
|
||||
"userName": "Пользователь",
|
||||
"lastSeen": "Был на сайте",
|
||||
@@ -175,7 +178,7 @@
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Плейлистов |||| Плейлисты",
|
||||
"name": "Плейлист |||| Плейлисты",
|
||||
"fields": {
|
||||
"name": "Название",
|
||||
"duration": "Длительность",
|
||||
@@ -193,7 +196,8 @@
|
||||
"addNewPlaylist": "Создать \"%{name}\"",
|
||||
"export": "Экспорт",
|
||||
"makePublic": "Опубликовать",
|
||||
"makePrivate": "Сделать личным"
|
||||
"makePrivate": "Сделать личным",
|
||||
"saveQueue": "Сохранить очередь в плейлист"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Повторяющиеся треки",
|
||||
@@ -224,7 +228,7 @@
|
||||
"lastVisitedAt": "Последнее посещение",
|
||||
"visitCount": "Посещения",
|
||||
"format": "Формат",
|
||||
"maxBitRate": "Макс. Битрейт",
|
||||
"maxBitRate": "Макс. битрейт",
|
||||
"updatedAt": "Обновлено в",
|
||||
"createdAt": "Создано",
|
||||
"downloadable": "Разрешить загрузку?"
|
||||
@@ -238,7 +242,8 @@
|
||||
"updatedAt": "Исчез"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Удалить"
|
||||
"remove": "Удалить",
|
||||
"remove_all": "Убрать все"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Отсутствующие файлы удалены"
|
||||
@@ -274,7 +279,7 @@
|
||||
"oneOf": "Должно быть одним из: %{options}",
|
||||
"regex": "Должно быть в формате (regexp): %{pattern}",
|
||||
"unique": "Должно быть уникальным",
|
||||
"url": "Должен быть действительным URL адрес"
|
||||
"url": "Должен быть действительный URL"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Фильтр",
|
||||
@@ -291,7 +296,7 @@
|
||||
"export": "Экспорт",
|
||||
"list": "Список",
|
||||
"refresh": "Обновить",
|
||||
"remove_filter": "Убрать фильтр",
|
||||
"remove_filter": "Убрать этот фильтр",
|
||||
"remove": "Удалить",
|
||||
"save": "Сохранить",
|
||||
"search": "Поиск",
|
||||
@@ -382,7 +387,7 @@
|
||||
"i18n_error": "Не удалось загрузить перевод для указанного языка",
|
||||
"canceled": "Операция отменена",
|
||||
"logged_out": "Ваша сессия завершена, попробуйте переподключиться/войти снова",
|
||||
"new_version": "Доступна новая версия! Пожалуйста, обновите это окно"
|
||||
"new_version": "Доступна новая версия! Пожалуйста, обновите это окно."
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Отображение столбцов",
|
||||
@@ -423,7 +428,9 @@
|
||||
"downloadDialogTitle": "Скачать %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Копировать в буфер обмена: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Удалить отсутствующие файлы",
|
||||
"remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах."
|
||||
"remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах.",
|
||||
"remove_all_missing_title": "Удалите все отсутствующие файлы",
|
||||
"remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество игр и рейтинг."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Библиотека",
|
||||
@@ -482,7 +489,7 @@
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Главная",
|
||||
"source": "Код",
|
||||
"source": "Исходный код",
|
||||
"featureRequests": "Предложения",
|
||||
"lastInsightsCollection": "Последний сбор данных",
|
||||
"insights": {
|
||||
@@ -497,7 +504,10 @@
|
||||
"quickScan": "Быстрое сканирование",
|
||||
"fullScan": "Полное сканирование",
|
||||
"serverUptime": "Время работы сервера",
|
||||
"serverDown": "Оффлайн"
|
||||
"serverDown": "Оффлайн",
|
||||
"scanType": "Тип",
|
||||
"status": "Ошибка сканирования",
|
||||
"elapsedTime": "Прошедшее время"
|
||||
},
|
||||
"help": {
|
||||
"title": "Горячие клавиши Navidrome",
|
||||
@@ -510,7 +520,7 @@
|
||||
"vol_up": "Увеличить громкость",
|
||||
"vol_down": "Уменьшить громкость",
|
||||
"toggle_love": "Добавить / удалить песню из избранного",
|
||||
"current_song": "Перейти к текущей песне"
|
||||
"current_song": "Перейти к текущему треку"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,16 @@
|
||||
"bpm": "BPM",
|
||||
"playDate": "Senast spelad",
|
||||
"channels": "Channels",
|
||||
"createdAt": "Skapad"
|
||||
"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"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lägg till i kön",
|
||||
@@ -58,7 +67,15 @@
|
||||
"originalDate": "Originaldatum",
|
||||
"releaseDate": "Utgivningsdatum",
|
||||
"releases": "Utgåva |||| Utgåvor",
|
||||
"released": "Utgiven"
|
||||
"released": "Utgiven",
|
||||
"recordLabel": "Skivbolag",
|
||||
"catalogNum": "Katalognummer",
|
||||
"releaseType": "Typ",
|
||||
"grouping": "Gruppering",
|
||||
"media": "Media",
|
||||
"mood": "Stämning",
|
||||
"date": "Inspelningsdatum",
|
||||
"missing": "Saknade"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Spela",
|
||||
@@ -89,7 +106,24 @@
|
||||
"playCount": "Spelningar",
|
||||
"rating": "Betyg",
|
||||
"genre": "Genre",
|
||||
"size": "Storlek"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -162,7 +196,8 @@
|
||||
"addNewPlaylist": "Skapa \"%{name}\"",
|
||||
"export": "Exportera",
|
||||
"makePublic": "Gör offentlig",
|
||||
"makePrivate": "Gör privat"
|
||||
"makePrivate": "Gör privat",
|
||||
"saveQueue": "Spara kö till spellista"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Lägg till dubletter",
|
||||
@@ -198,6 +233,22 @@
|
||||
"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": {
|
||||
@@ -375,7 +426,11 @@
|
||||
"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"
|
||||
"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."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotek",
|
||||
@@ -449,7 +504,10 @@
|
||||
"quickScan": "Snabbscan",
|
||||
"fullScan": "Komplett scan",
|
||||
"serverUptime": "Serverdrifttid",
|
||||
"serverDown": "OFFLINE"
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Typ",
|
||||
"status": "Fel vid scanning",
|
||||
"elapsedTime": "Spelad tid"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome kortkommandon",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"mappedTags": "Eşlenen etiketler",
|
||||
"rawTags": "Ham etiketler",
|
||||
"bitDepth": "Bit derinliği",
|
||||
"sampleRate": "Örnekleme Oranı"
|
||||
"sampleRate": "Örnekleme Oranı",
|
||||
"missing": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Oynatma Sırasına Ekle",
|
||||
@@ -73,7 +74,8 @@
|
||||
"grouping": "Gruplama",
|
||||
"media": "Medya",
|
||||
"mood": "Mod",
|
||||
"date": "Kayıt Tarihi"
|
||||
"date": "Kayıt Tarihi",
|
||||
"missing": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Oynat",
|
||||
@@ -105,7 +107,8 @@
|
||||
"rating": "Derecelendirme",
|
||||
"genre": "Tür",
|
||||
"size": "Boyut",
|
||||
"role": "Rol"
|
||||
"role": "Rol",
|
||||
"missing": ""
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Albüm Sanatçısı |||| Albüm Sanatçısı",
|
||||
@@ -193,7 +196,8 @@
|
||||
"addNewPlaylist": "Oluştur \"%{name}\"",
|
||||
"export": "Aktar",
|
||||
"makePublic": "Herkese Açık Yap",
|
||||
"makePrivate": "Özel Yap"
|
||||
"makePrivate": "Özel Yap",
|
||||
"saveQueue": ""
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Yinelenen şarkıları ekle",
|
||||
@@ -238,7 +242,8 @@
|
||||
"updatedAt": "Kaybolma"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Kaldır"
|
||||
"remove": "Kaldır",
|
||||
"remove_all": "Tümünü Kaldır"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Eksik dosya(lar) kaldırıldı"
|
||||
@@ -423,7 +428,9 @@
|
||||
"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_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."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Kütüphane",
|
||||
@@ -497,7 +504,10 @@
|
||||
"quickScan": "Hızlı Tarama",
|
||||
"fullScan": "Tam Tarama",
|
||||
"serverUptime": "Sunucu Çalışma Süresi",
|
||||
"serverDown": "ÇEVRİMDIŞI"
|
||||
"serverDown": "ÇEVRİMDIŞI",
|
||||
"scanType": "Tür",
|
||||
"status": "Tarama Hatası",
|
||||
"elapsedTime": "Geçen Süre"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Kısayolları",
|
||||
|
||||
@@ -32,7 +32,10 @@
|
||||
"participants": "Додаткові вчасники",
|
||||
"tags": "Додаткові теги",
|
||||
"mappedTags": "Зіставлені теги",
|
||||
"rawTags": "Вихідні теги"
|
||||
"rawTags": "Вихідні теги",
|
||||
"bitDepth": "Глибина розрядності",
|
||||
"sampleRate": "Частота дискретизації",
|
||||
"missing": "Поле відсутнє"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Прослухати пізніше",
|
||||
@@ -70,7 +73,9 @@
|
||||
"releaseType": "Тип",
|
||||
"grouping": "Групування",
|
||||
"media": "Медіа",
|
||||
"mood": "Настрій"
|
||||
"mood": "Настрій",
|
||||
"date": "Дата запису",
|
||||
"missing": "Поле відсутнє"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Прослухати",
|
||||
@@ -102,7 +107,8 @@
|
||||
"rating": "Рейтинг",
|
||||
"genre": "Жанр",
|
||||
"size": "Розмір",
|
||||
"role": "Роль"
|
||||
"role": "Роль",
|
||||
"missing": "Поле відсутнє"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Виконавець альбому |||| Виконавці альбому",
|
||||
@@ -190,7 +196,8 @@
|
||||
"addNewPlaylist": "Створити \"%{name}\"",
|
||||
"export": "Експортувати",
|
||||
"makePublic": "Зробити публічним",
|
||||
"makePrivate": "Зробити приватним"
|
||||
"makePrivate": "Зробити приватним",
|
||||
"saveQueue": "Зберегти чергу до плейлиста"
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Додати повторювані пісні",
|
||||
@@ -235,11 +242,13 @@
|
||||
"updatedAt": "Зник"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Видалити"
|
||||
"remove": "Видалити",
|
||||
"remove_all": "Вилучити всі"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Видалено зниклі файл(и)"
|
||||
}
|
||||
},
|
||||
"empty": "Немає відсутніх файлів"
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -419,7 +428,9 @@
|
||||
"downloadDialogTitle": "Завантаження %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Скопіювати в буфер: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Видалити зниклі файли",
|
||||
"remove_missing_content": "Ви впевнені, що хочете видалити вибрані відсутні файли з бази даних? Це назавжди видалить усі посилання на них, включаючи кількість прослуховувань та рейтинги."
|
||||
"remove_missing_content": "Ви впевнені, що хочете видалити вибрані відсутні файли з бази даних? Це назавжди видалить усі посилання на них, включаючи кількість прослуховувань та рейтинги.",
|
||||
"remove_all_missing_title": "Видалити всі відсутні файли",
|
||||
"remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Бібліотека",
|
||||
@@ -493,7 +504,10 @@
|
||||
"quickScan": "Швидке сканування",
|
||||
"fullScan": "Повне сканування",
|
||||
"serverUptime": "Час роботи",
|
||||
"serverDown": "Оффлайн"
|
||||
"serverDown": "Оффлайн",
|
||||
"scanType": "Тип",
|
||||
"status": "Помилка сканування",
|
||||
"elapsedTime": "Пройдений час"
|
||||
},
|
||||
"help": {
|
||||
"title": "Гарячі клавіші Navidrome",
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
@@ -37,6 +38,9 @@ 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,
|
||||
@@ -94,6 +98,7 @@ type ProgressInfo struct {
|
||||
ChangesDetected bool
|
||||
Warning string
|
||||
Error string
|
||||
ForceUpdate bool
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
@@ -113,20 +118,51 @@ 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)
|
||||
@@ -136,6 +172,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -193,10 +232,14 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin
|
||||
if count, folderCount, err := s.getCounters(ctx); err != nil {
|
||||
return scanWarnings, err
|
||||
} else {
|
||||
scanType, elapsed, lastErr := s.getScanInfo(ctx)
|
||||
s.sendMessage(ctx, &events.ScanStatus{
|
||||
Scanning: false,
|
||||
Count: count,
|
||||
FolderCount: folderCount,
|
||||
Error: lastErr,
|
||||
ScanType: scanType,
|
||||
ElapsedTime: elapsed,
|
||||
})
|
||||
}
|
||||
return scanWarnings, scanError
|
||||
@@ -240,12 +283,17 @@ 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 {
|
||||
if s.limiter != nil && !p.ForceUpdate {
|
||||
s.limiter.Do(func() { s.sendMessage(ctx, status) })
|
||||
} else {
|
||||
s.sendMessage(ctx, status)
|
||||
|
||||
57
scanner/controller_test.go
Normal file
57
scanner/controller_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
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,6 +6,8 @@ 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"
|
||||
)
|
||||
@@ -182,7 +184,35 @@ 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,6 +4,8 @@ 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"
|
||||
@@ -222,4 +224,66 @@ 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,12 +57,21 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -100,11 +109,14 @@ 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})
|
||||
}
|
||||
@@ -115,6 +127,7 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
|
||||
|
||||
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,6 +10,7 @@ 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"
|
||||
@@ -17,6 +18,7 @@ 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"
|
||||
@@ -47,14 +49,15 @@ 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())
|
||||
@@ -501,6 +504,113 @@ 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -37,9 +37,12 @@ func (e *baseEvent) Data(evt Event) string {
|
||||
|
||||
type ScanStatus struct {
|
||||
baseEvent
|
||||
Scanning bool `json:"scanning"`
|
||||
Count int64 `json:"count"`
|
||||
FolderCount int64 `json:"folderCount"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type KeepAlive struct {
|
||||
|
||||
@@ -63,25 +63,29 @@ func (r *missingRepository) EntityName() string {
|
||||
}
|
||||
|
||||
func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) {
|
||||
repo := ds.MediaFile(r.Context())
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
err := ds.WithTx(func(tx model.DataStore) error {
|
||||
return repo.DeleteMissing(ids)
|
||||
if len(ids) == 0 {
|
||||
_, err := tx.MediaFile(ctx).DeleteAllMissing()
|
||||
return err
|
||||
}
|
||||
return tx.MediaFile(ctx).DeleteMissing(ids)
|
||||
})
|
||||
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(r.Context(), "Missing file not found", "id", ids[0])
|
||||
log.Warn(ctx, "Missing file not found", "id", ids[0])
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error deleting missing tracks from DB", "ids", ids, err)
|
||||
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = ds.GC(r.Context())
|
||||
err = ds.GC(ctx)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error running GC after deleting missing tracks", err)
|
||||
log.Error(ctx, "Error running GC after deleting missing tracks", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -37,8 +37,9 @@ 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, false)
|
||||
imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, square)
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
return
|
||||
|
||||
@@ -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(model.RoleAlbumArtist)
|
||||
indexes, err = api.ds.Artist(ctx).GetIndex(false, model.RoleAlbumArtist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving Indexes", err)
|
||||
return nil, 0, err
|
||||
|
||||
@@ -108,12 +108,19 @@ func SongsByRandom(genre string, fromYear, toYear int) Options {
|
||||
return addDefaultFilters(options)
|
||||
}
|
||||
|
||||
func SongWithArtistTitle(artist, title string) Options {
|
||||
func SongWithLyrics(artist, title string) Options {
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "updated_at",
|
||||
Order: "desc",
|
||||
Max: 1,
|
||||
Filters: And{Eq{"artist": artist, "title": title}},
|
||||
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}),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -224,6 +224,7 @@ 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,6 +23,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -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.SongWithArtistTitle(artist, title))
|
||||
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithLyrics(artist, title))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"sortName": "sort name",
|
||||
"mediaType": "album",
|
||||
"musicBrainzId": "00000000-0000-0000-0000-000000000000",
|
||||
"isrc": [],
|
||||
"genres": [
|
||||
{
|
||||
"name": "Genre 1"
|
||||
|
||||
@@ -99,6 +99,9 @@
|
||||
"sortName": "sorted song",
|
||||
"mediaType": "song",
|
||||
"musicBrainzId": "4321",
|
||||
"isrc": [
|
||||
"ISRC-1"
|
||||
],
|
||||
"genres": [
|
||||
{
|
||||
"name": "rock"
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<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,6 +30,10 @@
|
||||
"sortName": "sorted title",
|
||||
"mediaType": "song",
|
||||
"musicBrainzId": "4321",
|
||||
"isrc": [
|
||||
"ISRC-1",
|
||||
"ISRC-2"
|
||||
],
|
||||
"genres": [
|
||||
{
|
||||
"name": "rock"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<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,6 +15,7 @@
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"isrc": [],
|
||||
"genres": [],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
|
||||
@@ -176,6 +176,7 @@ 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"`
|
||||
@@ -476,10 +477,13 @@ 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type Lyrics struct {
|
||||
|
||||
@@ -224,7 +224,8 @@ var _ = Describe("Responses", func() {
|
||||
child[0].OpenSubsonicChild = &OpenSubsonicChild{
|
||||
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
|
||||
Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted title",
|
||||
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
|
||||
Isrc: []string{"ISRC-1", "ISRC-2"},
|
||||
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
|
||||
Moods: []string{"happy", "sad"},
|
||||
ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6},
|
||||
DisplayArtist: "artist 1 & artist 2",
|
||||
@@ -312,6 +313,7 @@ var _ = Describe("Responses", func() {
|
||||
songs[0].OpenSubsonicChild = &OpenSubsonicChild{
|
||||
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
|
||||
Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song",
|
||||
Isrc: []string{"ISRC-1"},
|
||||
Moods: []string{"happy", "sad"},
|
||||
ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6},
|
||||
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
|
||||
|
||||
@@ -22,6 +22,10 @@ type MockMediaFileRepo struct {
|
||||
model.MediaFileRepository
|
||||
Data map[string]*model.MediaFile
|
||||
Err bool
|
||||
// Add fields and methods for controlling CountAll and DeleteAllMissing in tests
|
||||
CountAllValue int64
|
||||
CountAllOptions model.QueryOptions
|
||||
DeleteAllMissingValue int64
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) SetError(err bool) {
|
||||
@@ -161,4 +165,35 @@ func (m *MockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCur
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) CountAll(opts ...model.QueryOptions) (int64, error) {
|
||||
if m.Err {
|
||||
return 0, errors.New("error")
|
||||
}
|
||||
if m.CountAllValue != 0 {
|
||||
if len(opts) > 0 {
|
||||
m.CountAllOptions = opts[0]
|
||||
}
|
||||
return m.CountAllValue, nil
|
||||
}
|
||||
return int64(len(m.Data)), nil
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) DeleteAllMissing() (int64, error) {
|
||||
if m.Err {
|
||||
return 0, errors.New("error")
|
||||
}
|
||||
if m.DeleteAllMissingValue != 0 {
|
||||
return m.DeleteAllMissingValue, nil
|
||||
}
|
||||
// Remove all missing files from Data
|
||||
var count int64
|
||||
for id, mf := range m.Data {
|
||||
if mf.Missing {
|
||||
delete(m.Data, id)
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil)
|
||||
|
||||
@@ -3,4 +3,5 @@ build/
|
||||
prettier.config.js
|
||||
.eslintrc
|
||||
vite.config.js
|
||||
public/3rdparty/workbox
|
||||
public/3rdparty/workbox
|
||||
coverage/
|
||||
3300
ui/package-lock.json
generated
3300
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,8 @@
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ci": "vitest --watch=false",
|
||||
"test:watch": "vitest",
|
||||
"test": "vitest --watch=false",
|
||||
"test:coverage": "vitest run --coverage --watch=false",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint . --ext ts,tsx,js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
@@ -16,7 +16,7 @@
|
||||
"postinstall": "bin/update-workbox.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@material-ui/core": "^4.11.4",
|
||||
"@material-ui/core": "^4.12.4",
|
||||
"@material-ui/icons": "^4.11.3",
|
||||
"@material-ui/lab": "^4.0.0-alpha.58",
|
||||
"@material-ui/styles": "^4.11.5",
|
||||
@@ -35,9 +35,9 @@
|
||||
"react": "^17.0.2",
|
||||
"react-admin": "^3.19.12",
|
||||
"react-dnd": "^14.0.5",
|
||||
"react-dnd-html5-backend": "^14.0.2",
|
||||
"react-dnd-html5-backend": "^14.1.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-drag-listview": "^0.1.8",
|
||||
"react-drag-listview": "^0.1.9",
|
||||
"react-ga": "^3.3.1",
|
||||
"react-hotkeys": "^2.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
@@ -46,7 +46,7 @@
|
||||
"react-redux": "^7.2.9",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"redux": "^4.2.1",
|
||||
"redux-saga": "^1.1.3",
|
||||
"redux-saga": "^1.3.0",
|
||||
"uuid": "^11.1.0",
|
||||
"workbox-cli": "^7.3.0"
|
||||
},
|
||||
@@ -55,27 +55,27 @@
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/react-hooks": "^7.0.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.13.9",
|
||||
"@types/react": "^17.0.83",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/react": "^17.0.86",
|
||||
"@types/react-dom": "^17.0.26",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.0.8",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"happy-dom": "^17.4.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"happy-dom": "^17.4.7",
|
||||
"jsdom": "^26.1.0",
|
||||
"prettier": "^3.5.3",
|
||||
"ra-test": "^3.19.12",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.1",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vitest": "^3.0.8"
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^0.21.2",
|
||||
"vitest": "^3.1.4"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
addToPlaylistDialogReducer,
|
||||
expandInfoDialogReducer,
|
||||
listenBrainzTokenDialogReducer,
|
||||
saveQueueDialogReducer,
|
||||
playerReducer,
|
||||
albumViewReducer,
|
||||
activityReducer,
|
||||
@@ -62,6 +63,7 @@ const adminStore = createAdminStore({
|
||||
downloadMenuDialog: downloadMenuDialogReducer,
|
||||
expandInfoDialog: expandInfoDialogReducer,
|
||||
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
|
||||
saveQueueDialog: saveQueueDialogReducer,
|
||||
shareDialog: shareDialogReducer,
|
||||
activity: activityReducer,
|
||||
settings: settingsReducer,
|
||||
|
||||
@@ -8,6 +8,8 @@ export const EXTENDED_INFO_OPEN = 'EXTENDED_INFO_OPEN'
|
||||
export const EXTENDED_INFO_CLOSE = 'EXTENDED_INFO_CLOSE'
|
||||
export const LISTENBRAINZ_TOKEN_OPEN = 'LISTENBRAINZ_TOKEN_OPEN'
|
||||
export const LISTENBRAINZ_TOKEN_CLOSE = 'LISTENBRAINZ_TOKEN_CLOSE'
|
||||
export const SAVE_QUEUE_OPEN = 'SAVE_QUEUE_OPEN'
|
||||
export const SAVE_QUEUE_CLOSE = 'SAVE_QUEUE_CLOSE'
|
||||
export const DOWNLOAD_MENU_ALBUM = 'album'
|
||||
export const DOWNLOAD_MENU_ARTIST = 'artist'
|
||||
export const DOWNLOAD_MENU_PLAY = 'playlist'
|
||||
@@ -76,3 +78,11 @@ export const openListenBrainzTokenDialog = () => ({
|
||||
export const closeListenBrainzTokenDialog = () => ({
|
||||
type: LISTENBRAINZ_TOKEN_CLOSE,
|
||||
})
|
||||
|
||||
export const openSaveQueueDialog = () => ({
|
||||
type: SAVE_QUEUE_OPEN,
|
||||
})
|
||||
|
||||
export const closeSaveQueueDialog = () => ({
|
||||
type: SAVE_QUEUE_CLOSE,
|
||||
})
|
||||
|
||||
@@ -72,6 +72,10 @@ const useStyles = makeStyles(
|
||||
width: '15em',
|
||||
minWidth: '15em',
|
||||
},
|
||||
backgroundColor: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cover: {
|
||||
objectFit: 'contain',
|
||||
@@ -79,6 +83,11 @@ const useStyles = makeStyles(
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
},
|
||||
coverLoading: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
loveButton: {
|
||||
top: theme.spacing(-0.2),
|
||||
@@ -213,6 +222,8 @@ const AlbumDetails = (props) => {
|
||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [albumInfo, setAlbumInfo] = useState()
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
|
||||
let notes =
|
||||
albumInfo?.notes?.replace(new RegExp('<.*>', 'g'), '') || record.notes
|
||||
@@ -236,23 +247,51 @@ const AlbumDetails = (props) => {
|
||||
})
|
||||
}, [record])
|
||||
|
||||
// Reset image state when album changes
|
||||
useEffect(() => {
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
}, [record.id])
|
||||
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, 300)
|
||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||
|
||||
const handleOpenLightbox = useCallback(() => setLightboxOpen(true), [])
|
||||
const handleImageLoad = useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(false)
|
||||
}, [])
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(true)
|
||||
}, [])
|
||||
|
||||
const handleOpenLightbox = useCallback(() => {
|
||||
if (!imageError) {
|
||||
setLightboxOpen(true)
|
||||
}
|
||||
}, [imageError])
|
||||
|
||||
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
|
||||
|
||||
return (
|
||||
<Card className={classes.root}>
|
||||
<div className={classes.cardContents}>
|
||||
<div className={classes.coverParent}>
|
||||
<CardMedia
|
||||
key={record.id}
|
||||
component={'img'}
|
||||
src={imageUrl}
|
||||
width="400"
|
||||
height="400"
|
||||
className={classes.cover}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onClick={handleOpenLightbox}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
title={record.name}
|
||||
style={{
|
||||
cursor: imageError ? 'default' : 'pointer',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.details}>
|
||||
@@ -337,7 +376,7 @@ const AlbumDetails = (props) => {
|
||||
</Collapse>
|
||||
</div>
|
||||
)}
|
||||
{isLightboxOpen && (
|
||||
{isLightboxOpen && !imageError && (
|
||||
<Lightbox
|
||||
imagePadding={50}
|
||||
animationDuration={200}
|
||||
|
||||
@@ -94,6 +94,10 @@ const useCoverStyles = makeStyles({
|
||||
width: '100%',
|
||||
objectFit: 'contain',
|
||||
height: (props) => props.height,
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
},
|
||||
coverLoading: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -113,6 +117,8 @@ const Cover = withContentRect('bounds')(({
|
||||
// Force height to be the same as the width determined by the GridList
|
||||
// noinspection JSSuspiciousNameCombination
|
||||
const classes = useCoverStyles({ height: contentRect.bounds.width })
|
||||
const [imageLoading, setImageLoading] = React.useState(true)
|
||||
const [imageError, setImageError] = React.useState(false)
|
||||
const [, dragAlbumRef] = useDrag(
|
||||
() => ({
|
||||
type: DraggableTypes.ALBUM,
|
||||
@@ -121,13 +127,33 @@ const Cover = withContentRect('bounds')(({
|
||||
}),
|
||||
[record],
|
||||
)
|
||||
|
||||
// Reset image state when record changes
|
||||
React.useEffect(() => {
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
}, [record.id])
|
||||
|
||||
const handleImageLoad = React.useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(false)
|
||||
}, [])
|
||||
|
||||
const handleImageError = React.useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={measureRef}>
|
||||
<div ref={dragAlbumRef}>
|
||||
<img
|
||||
key={record.id} // Force re-render when record changes
|
||||
src={subsonic.getCoverArtUrl(record, 300, true)}
|
||||
alt={record.name}
|
||||
className={classes.cover}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ReferenceArrayInput,
|
||||
ReferenceInput,
|
||||
SearchInput,
|
||||
usePermissions,
|
||||
useRefresh,
|
||||
useTranslate,
|
||||
useVersion,
|
||||
@@ -44,6 +45,8 @@ const useStyles = makeStyles({
|
||||
const AlbumFilter = (props) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const { permissions } = usePermissions()
|
||||
const isAdmin = permissions === 'admin'
|
||||
return (
|
||||
<Filter {...props} variant={'outlined'}>
|
||||
<SearchInput id="search" source="name" alwaysOn />
|
||||
@@ -153,6 +156,7 @@ const AlbumFilter = (props) => {
|
||||
defaultValue={true}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && <NullableBooleanInput source="missing" />}
|
||||
</Filter>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
ShowContextProvider,
|
||||
useShowContext,
|
||||
useShowController,
|
||||
Title as RaTitle,
|
||||
} from 'react-admin'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import AlbumSongs from './AlbumSongs'
|
||||
import AlbumDetails from './AlbumDetails'
|
||||
import AlbumActions from './AlbumActions'
|
||||
import { useResourceRefresh, Title } from '../common'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
@@ -25,9 +27,11 @@ const AlbumShowLayout = (props) => {
|
||||
const { loading, ...context } = useShowContext(props)
|
||||
const { record } = context
|
||||
const classes = useStyles()
|
||||
useResourceRefresh('album', 'song')
|
||||
|
||||
return (
|
||||
<>
|
||||
{record && <RaTitle title={<Title subTitle={record.name} />} />}
|
||||
{record && <AlbumDetails {...context} />}
|
||||
{record && (
|
||||
<ReferenceManyField
|
||||
|
||||
@@ -251,175 +251,3 @@ exports[`Details component > Mobile view > renders correctly with year range (st
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > renders correctly in mobile view 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
♫ Mar 15, 2018
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
○ Jun 15, 2020
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > renders correctly with all date fields 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
resources.album.fields.originalDate Mar 15, 2018
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
resources.album.fields.releaseDate Jun 15, 2020
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-6"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > renders correctly with date 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
May 1, 2020
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-2"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > renders correctly with date and originalDate 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
resources.album.fields.originalDate Mar 15, 2018
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-4"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > renders correctly with just year range 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-1"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > renders correctly with originalDate 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
resources.album.fields.originalDate Mar 15, 2018
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-3"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > renders correctly with releaseDate 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
resources.album.fields.releaseDate Jun 15, 2020
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-5"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -6,8 +6,16 @@ import { ImLastfm2 } from 'react-icons/im'
|
||||
import MusicBrainz from '../icons/MusicBrainz'
|
||||
import { intersperse } from '../utils'
|
||||
import config from '../config'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
linkBar: {
|
||||
minHeight: '1.875em',
|
||||
},
|
||||
})
|
||||
|
||||
const ArtistExternalLinks = ({ artistInfo, record }) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
let linkButtons = []
|
||||
const lastFMlink = artistInfo?.biography?.match(
|
||||
@@ -52,7 +60,7 @@ const ArtistExternalLinks = ({ artistInfo, record }) => {
|
||||
<MusicBrainz className="musicbrainz-icon" />,
|
||||
)
|
||||
|
||||
return <div>{intersperse(linkButtons, ' ')}</div>
|
||||
return <div className={classes.linkBar}>{intersperse(linkButtons, ' ')}</div>
|
||||
}
|
||||
|
||||
export default ArtistExternalLinks
|
||||
|
||||
@@ -11,12 +11,15 @@ import {
|
||||
SelectInput,
|
||||
TextField,
|
||||
useTranslate,
|
||||
NullableBooleanInput,
|
||||
usePermissions,
|
||||
} from 'react-admin'
|
||||
import { useMediaQuery, withWidth } from '@material-ui/core'
|
||||
import FavoriteIcon from '@material-ui/icons/Favorite'
|
||||
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useDrag } from 'react-dnd'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
ArtistContextMenu,
|
||||
List,
|
||||
@@ -49,6 +52,9 @@ const useStyles = makeStyles({
|
||||
},
|
||||
},
|
||||
},
|
||||
missingRow: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
contextMenu: {
|
||||
visibility: 'hidden',
|
||||
},
|
||||
@@ -59,6 +65,8 @@ const useStyles = makeStyles({
|
||||
|
||||
const ArtistFilter = (props) => {
|
||||
const translate = useTranslate()
|
||||
const { permissions } = usePermissions()
|
||||
const isAdmin = permissions === 'admin'
|
||||
const rolesObj = en?.resources?.artist?.roles
|
||||
const roles = Object.keys(rolesObj).reduce((acc, role) => {
|
||||
acc.push({
|
||||
@@ -81,6 +89,7 @@ const ArtistFilter = (props) => {
|
||||
defaultValue={true}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && <NullableBooleanInput source="missing" />}
|
||||
</Filter>
|
||||
)
|
||||
}
|
||||
@@ -95,7 +104,15 @@ const ArtistDatagridRow = (props) => {
|
||||
}),
|
||||
[record],
|
||||
)
|
||||
return <DatagridRow ref={dragArtistRef} {...props} />
|
||||
const classes = useStyles()
|
||||
const computedClasses = clsx(
|
||||
props.className,
|
||||
classes.row,
|
||||
record?.missing && classes.missingRow,
|
||||
)
|
||||
return (
|
||||
<DatagridRow ref={dragArtistRef} {...props} className={computedClasses} />
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistDatagridBody = (props) => (
|
||||
|
||||
@@ -7,12 +7,13 @@ import {
|
||||
useShowContext,
|
||||
ReferenceManyField,
|
||||
Pagination,
|
||||
Title as RaTitle,
|
||||
} from 'react-admin'
|
||||
import subsonic from '../subsonic'
|
||||
import AlbumGridView from '../album/AlbumGridView'
|
||||
import MobileArtistDetails from './MobileArtistDetails'
|
||||
import DesktopArtistDetails from './DesktopArtistDetails'
|
||||
import { useAlbumsPerPage } from '../common/index.js'
|
||||
import { useAlbumsPerPage, useResourceRefresh, Title } from '../common/index.js'
|
||||
|
||||
const ArtistDetails = (props) => {
|
||||
const record = useRecordContext(props)
|
||||
@@ -55,6 +56,7 @@ const ArtistShowLayout = (props) => {
|
||||
const record = useRecordContext()
|
||||
const { width } = props
|
||||
const [, perPageOptions] = useAlbumsPerPage(width)
|
||||
useResourceRefresh('artist', 'album')
|
||||
|
||||
const maxPerPage = 90
|
||||
let perPage = 0
|
||||
@@ -75,6 +77,7 @@ const ArtistShowLayout = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{record && <RaTitle title={<Title subTitle={record.name} />} />}
|
||||
{record && <ArtistDetails />}
|
||||
{record && (
|
||||
<ReferenceManyField
|
||||
|
||||
@@ -29,6 +29,7 @@ const useStyles = makeStyles(
|
||||
float: 'left',
|
||||
wordBreak: 'break-word',
|
||||
cursor: 'pointer',
|
||||
minHeight: '4.5em',
|
||||
},
|
||||
content: {
|
||||
flex: '1 0 auto',
|
||||
@@ -38,11 +39,22 @@ const useStyles = makeStyles(
|
||||
height: '12rem',
|
||||
borderRadius: '6em',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'transparent',
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
objectFit: 'cover',
|
||||
},
|
||||
coverLoading: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
artistImage: {
|
||||
maxHeight: '12rem',
|
||||
minHeight: '12rem',
|
||||
width: '12rem',
|
||||
minWidth: '12rem',
|
||||
backgroundColor: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
artistDetail: {
|
||||
@@ -73,8 +85,31 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
||||
const classes = useStyles()
|
||||
const title = record.name
|
||||
const [isLightboxOpen, setLightboxOpen] = React.useState(false)
|
||||
const [imageLoading, setImageLoading] = React.useState(false)
|
||||
const [imageError, setImageError] = React.useState(false)
|
||||
|
||||
// Reset image state when artist changes
|
||||
React.useEffect(() => {
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
}, [record.id])
|
||||
|
||||
const handleImageLoad = React.useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(false)
|
||||
}, [])
|
||||
|
||||
const handleImageError = React.useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(true)
|
||||
}, [])
|
||||
|
||||
const handleOpenLightbox = React.useCallback(() => {
|
||||
if (!imageError) {
|
||||
setLightboxOpen(true)
|
||||
}
|
||||
}, [imageError])
|
||||
|
||||
const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), [])
|
||||
const handleCloseLightbox = React.useCallback(
|
||||
() => setLightboxOpen(false),
|
||||
[],
|
||||
@@ -86,10 +121,17 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
||||
<Card className={classes.artistImage}>
|
||||
{artistInfo && (
|
||||
<CardMedia
|
||||
className={classes.cover}
|
||||
image={subsonic.getCoverArtUrl(record, 300)}
|
||||
key={record.id}
|
||||
component="img"
|
||||
src={subsonic.getCoverArtUrl(record, 300)}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onClick={handleOpenLightbox}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
title={title}
|
||||
style={{
|
||||
cursor: imageError ? 'default' : 'pointer',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
@@ -140,7 +182,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
||||
)}
|
||||
</Typography>
|
||||
</div>
|
||||
{isLightboxOpen && (
|
||||
{isLightboxOpen && !imageError && (
|
||||
<Lightbox
|
||||
imagePadding={50}
|
||||
animationDuration={200}
|
||||
|
||||
@@ -50,6 +50,12 @@ const useStyles = makeStyles(
|
||||
width: 151,
|
||||
boxShadow: '0px 0px 6px 0px #565656',
|
||||
borderRadius: '5px',
|
||||
backgroundColor: 'transparent',
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
objectFit: 'cover',
|
||||
},
|
||||
coverLoading: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
artistImage: {
|
||||
marginLeft: '1em',
|
||||
@@ -81,8 +87,31 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
||||
const classes = useStyles({ img, expanded })
|
||||
const title = record.name
|
||||
const [isLightboxOpen, setLightboxOpen] = React.useState(false)
|
||||
const [imageLoading, setImageLoading] = React.useState(false)
|
||||
const [imageError, setImageError] = React.useState(false)
|
||||
|
||||
// Reset image state when artist changes
|
||||
React.useEffect(() => {
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
}, [record.id])
|
||||
|
||||
const handleImageLoad = React.useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(false)
|
||||
}, [])
|
||||
|
||||
const handleImageError = React.useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(true)
|
||||
}, [])
|
||||
|
||||
const handleOpenLightbox = React.useCallback(() => {
|
||||
if (!imageError) {
|
||||
setLightboxOpen(true)
|
||||
}
|
||||
}, [imageError])
|
||||
|
||||
const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), [])
|
||||
const handleCloseLightbox = React.useCallback(
|
||||
() => setLightboxOpen(false),
|
||||
[],
|
||||
@@ -95,10 +124,17 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
||||
<Card className={classes.artistImage}>
|
||||
{artistInfo && (
|
||||
<CardMedia
|
||||
className={classes.cover}
|
||||
image={subsonic.getCoverArtUrl(record, 300)}
|
||||
key={record.id}
|
||||
component="img"
|
||||
src={subsonic.getCoverArtUrl(record, 300)}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onClick={handleOpenLightbox}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
title={title}
|
||||
style={{
|
||||
cursor: imageError ? 'default' : 'pointer',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
@@ -136,7 +172,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
||||
</Typography>
|
||||
</Collapse>
|
||||
</div>
|
||||
{isLightboxOpen && (
|
||||
{isLightboxOpen && !imageError && (
|
||||
<Lightbox
|
||||
imagePadding={50}
|
||||
animationDuration={200}
|
||||
|
||||
@@ -1,32 +1,120 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useGetOne } from 'react-admin'
|
||||
import { GlobalHotKeys } from 'react-hotkeys'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { RiSaveLine } from 'react-icons/ri'
|
||||
import { LoveButton, useToggleLove } from '../common'
|
||||
import { openSaveQueueDialog } from '../actions'
|
||||
import { keyMap } from '../hotkeys'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
const Placeholder = () => <LoveButton disabled={true} resource={'song'} />
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexGrow: 1,
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.5rem',
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
},
|
||||
mobileListItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
listStyle: 'none',
|
||||
padding: theme.spacing(0.5),
|
||||
margin: 0,
|
||||
height: 24,
|
||||
},
|
||||
button: {
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
mobileButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px',
|
||||
},
|
||||
mobileIcon: {
|
||||
fontSize: '18px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
}))
|
||||
|
||||
const Toolbar = ({ id }) => {
|
||||
const { data, loading } = useGetOne('song', id)
|
||||
const PlayerToolbar = ({ id, isRadio }) => {
|
||||
const dispatch = useDispatch()
|
||||
const { data, loading } = useGetOne('song', id, { enabled: !!id })
|
||||
const [toggleLove, toggling] = useToggleLove('song', data)
|
||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||
const classes = useStyles()
|
||||
|
||||
const handlers = {
|
||||
TOGGLE_LOVE: useCallback(() => toggleLove(), [toggleLove]),
|
||||
}
|
||||
|
||||
const handleSaveQueue = useCallback(
|
||||
(e) => {
|
||||
dispatch(openSaveQueueDialog())
|
||||
e.stopPropagation()
|
||||
},
|
||||
[dispatch],
|
||||
)
|
||||
|
||||
const buttonClass = isDesktop ? classes.button : classes.mobileButton
|
||||
const listItemClass = isDesktop ? classes.toolbar : classes.mobileListItem
|
||||
|
||||
const saveQueueButton = (
|
||||
<IconButton
|
||||
size={isDesktop ? 'small' : undefined}
|
||||
onClick={handleSaveQueue}
|
||||
disabled={isRadio}
|
||||
data-testid="save-queue-button"
|
||||
className={buttonClass}
|
||||
>
|
||||
<RiSaveLine className={!isDesktop ? classes.mobileIcon : undefined} />
|
||||
</IconButton>
|
||||
)
|
||||
|
||||
const loveButton = (
|
||||
<LoveButton
|
||||
record={data}
|
||||
resource={'song'}
|
||||
size={isDesktop ? undefined : 'inherit'}
|
||||
disabled={loading || toggling || !id || isRadio}
|
||||
className={buttonClass}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges />
|
||||
<LoveButton
|
||||
record={data}
|
||||
resource={'song'}
|
||||
disabled={loading || toggling}
|
||||
/>
|
||||
{isDesktop ? (
|
||||
<li className={`${listItemClass} item`}>
|
||||
{saveQueueButton}
|
||||
{loveButton}
|
||||
</li>
|
||||
) : (
|
||||
<>
|
||||
<li className={`${listItemClass} item`}>{saveQueueButton}</li>
|
||||
<li className={`${listItemClass} item`}>{loveButton}</li>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PlayerToolbar = ({ id, isRadio }) =>
|
||||
id && !isRadio ? <Toolbar id={id} /> : <Placeholder />
|
||||
|
||||
export default PlayerToolbar
|
||||
|
||||
166
ui/src/audioplayer/PlayerToolbar.test.jsx
Normal file
166
ui/src/audioplayer/PlayerToolbar.test.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { useGetOne } from 'react-admin'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useToggleLove } from '../common'
|
||||
import { openSaveQueueDialog } from '../actions'
|
||||
import PlayerToolbar from './PlayerToolbar'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@material-ui/core', async () => {
|
||||
const actual = await import('@material-ui/core')
|
||||
return {
|
||||
...actual,
|
||||
useMediaQuery: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-admin', () => ({
|
||||
useGetOne: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useDispatch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../common', () => ({
|
||||
LoveButton: ({ className, disabled }) => (
|
||||
<button data-testid="love-button" className={className} disabled={disabled}>
|
||||
Love
|
||||
</button>
|
||||
),
|
||||
useToggleLove: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../actions', () => ({
|
||||
openSaveQueueDialog: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-hotkeys', () => ({
|
||||
GlobalHotKeys: () => <div data-testid="global-hotkeys" />,
|
||||
}))
|
||||
|
||||
describe('<PlayerToolbar />', () => {
|
||||
const mockToggleLove = vi.fn()
|
||||
const mockDispatch = vi.fn()
|
||||
const mockSongData = { id: 'song-1', name: 'Test Song', starred: false }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: false })
|
||||
useToggleLove.mockReturnValue([mockToggleLove, false])
|
||||
useDispatch.mockReturnValue(mockDispatch)
|
||||
openSaveQueueDialog.mockReturnValue({ type: 'OPEN_SAVE_QUEUE_DIALOG' })
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Desktop layout', () => {
|
||||
beforeEach(() => {
|
||||
useMediaQuery.mockReturnValue(true) // isDesktop = true
|
||||
})
|
||||
|
||||
it('renders desktop toolbar with both buttons', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
// Both buttons should be in a single list item
|
||||
const listItems = screen.getAllByRole('listitem')
|
||||
expect(listItems).toHaveLength(1)
|
||||
|
||||
// Verify both buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
|
||||
// Verify desktop classes are applied
|
||||
expect(listItems[0].className).toContain('toolbar')
|
||||
})
|
||||
|
||||
it('disables save queue button when isRadio is true', () => {
|
||||
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
expect(saveQueueButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables love button when conditions are met', () => {
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('opens save queue dialog when save button is clicked', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
fireEvent.click(saveQueueButton)
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'OPEN_SAVE_QUEUE_DIALOG',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile layout', () => {
|
||||
beforeEach(() => {
|
||||
useMediaQuery.mockReturnValue(false) // isDesktop = false
|
||||
})
|
||||
|
||||
it('renders mobile toolbar with buttons in separate list items', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
// Each button should be in its own list item
|
||||
const listItems = screen.getAllByRole('listitem')
|
||||
expect(listItems).toHaveLength(2)
|
||||
|
||||
// Verify both buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
|
||||
// Verify mobile classes are applied
|
||||
expect(listItems[0].className).toContain('mobileListItem')
|
||||
expect(listItems[1].className).toContain('mobileListItem')
|
||||
})
|
||||
|
||||
it('disables save queue button when isRadio is true', () => {
|
||||
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
expect(saveQueueButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables love button when conditions are met', () => {
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Common behavior', () => {
|
||||
it('renders global hotkeys in both layouts', () => {
|
||||
// Test desktop layout
|
||||
useMediaQuery.mockReturnValue(true)
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
|
||||
|
||||
// Cleanup and test mobile layout
|
||||
cleanup()
|
||||
useMediaQuery.mockReturnValue(false)
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables buttons when id is not provided', () => {
|
||||
render(<PlayerToolbar />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user