Compare commits

..

1 Commits

Author SHA1 Message Date
Deluan
e0f1ddecbe docs: add testing and logging guidelines to AGENTS.md
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-19 23:25:24 -04:00
104 changed files with 2624 additions and 3902 deletions

View File

@@ -1,53 +0,0 @@
# Navidrome Code Guidelines
This is a music streaming server written in Go with a React frontend. The application manages music libraries, provides streaming capabilities, and offers various features like artist information, artwork handling, and external service integrations.
## Code Standards
### Backend (Go)
- Follow standard Go conventions and idioms
- Use context propagation for cancellation signals
- Write unit tests for new functionality using Ginkgo/Gomega
- Use mutex appropriately for concurrent operations
- Implement interfaces for dependencies to facilitate testing
### Frontend (React)
- Use functional components with hooks
- Follow React best practices for state management
- Implement PropTypes for component properties
- Prefer using React-Admin and Material-UI components
- Icons should be imported from `react-icons` only
- Follow existing patterns for API interaction
## Repository Structure
- `core/`: Server-side business logic (artwork handling, playback, etc.)
- `ui/`: React frontend components
- `model/`: Data models and repository interfaces
- `server/`: API endpoints and server implementation
- `utils/`: Shared utility functions
- `persistence/`: Database access layer
- `scanner/`: Music library scanning functionality
## Key Guidelines
1. Maintain cache management patterns for performance
2. Follow the existing concurrency patterns (mutex, atomic)
3. Use the testing framework appropriately (Ginkgo/Gomega for Go)
4. Keep UI components focused and reusable
5. Document configuration options in code
6. Consider performance implications when working with music libraries
7. Follow existing error handling patterns
8. Ensure compatibility with external services (LastFM, Spotify)
## Development Workflow
- Test changes thoroughly, especially around concurrent operations
- Validate both backend and frontend interactions
- Consider how changes will affect user experience and performance
- Test with different music library sizes and configurations
- Before committing, ALWAYS run `make format lint test`, and make sure there are no issues
## Important commands
- `make build`: Build the application
- `make test`: Run Go tests
- To run tests for a specific package, use `make test PKG=./pkgname/...`
- `make lintall`: Run linters
- `make format`: Format code

View File

@@ -71,7 +71,7 @@ jobs:
version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
uses: golangci/golangci-lint-action@v7
with:
version: latest
problem-matchers: true

4
.gitignore vendored
View File

@@ -24,6 +24,4 @@ docker-compose.yml
!contrib/docker-compose.yml
binaries
navidrome-master
AGENTS.md
*.exe
bin/
*.exe

110
AGENTS.md Normal file
View File

@@ -0,0 +1,110 @@
# Testing Instructions
- **No implementation task is considered complete until it includes thorough, passing tests that cover the new or
changed functionality. All new code must be accompanied by Ginkgo/Gomega tests, and PRs/commits without tests should
be considered incomplete.**
- All Go tests in this project **MUST** be written using the **Ginkgo v2** and **Gomega** frameworks.
- To run all tests, use `make test`.
- To run tests for a specific package, use `make test PKG=./pkgname/...`
- Do not run tests in parallel
- Don't use `--fail-on-pending`
## Mocking Convention
- Always try to use the mocks provided in the `tests` package before creating a new mock implementation.
- Only create a new mock if the required functionality is not covered by the existing mocks in `tests`.
- Never mock a real implementation when testing. Remember: there is no value in testing an interface, only the real implementation.
## Example
Every package that you write tests for, should have a `*_suite_test.go` file, to hook up the Ginkgo test suite. Example:
```
package core
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestCore(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Core Suite")
}
```
Never put a `func Test*` in regular *_test.go files, only in `*_suite_test.go` files.
Refer to existing test suites for examples of proper setup and usage, such as the one defined in @core_suite_test.go
## Exceptions
There should be no exceptions to this rule. If you encounter tests written with the standard `testing` package or other frameworks, they should be refactored to use Ginkgo/Gomega. If you need a new mock, first confirm that it does not already exist in the `tests` package.
### Configuration
You can set config values in the BeforeEach/BeforeAll blocks. If you do so, remember to add `DeferCleanup(configtest.SetupConfig())` to reset the values. Example:
```go
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableDownloads = true
})
```
# Logging System Usage Guide
This project uses a custom logging system built on top of logrus, `log/log.go`. Follow these conventions for all logging:
## Logging API
- Use the provided functions for logging at different levels:
- `Error(...)`, `Warn(...)`, `Info(...)`, `Debug(...)`, `Trace(...)`, `Fatal(...)`
- These functions accept flexible arguments:
- The first argument can be a context (`context.Context`), an HTTP request, or `nil`.
- The next argument is the log message (string or error).
- Additional arguments are key-value pairs (e.g., `"key", value`).
- If the last argument is an error, it is logged under the `error` key.
**Examples:**
```go
log.Error("A message")
log.Error(ctx, "A message with context")
log.Error("Failed to save", "id", 123, err)
log.Info(req, "Request received", "user", userID)
```
## Logging errors
- You don't need to add "err" key when logging an error, it is automatically added.
- Error must always be the last parameter in the log call.
Examples:
```go
log.Error("Failed to save", "id", 123, err) // GOOD
log.Error("Failed to save", "id", 123, "err", err) // BAD
log.Error("Failed to save", err, "id", 123) // BAD
```
## Context and Request Logging
- If a context or HTTP request is passed as the first argument, any logger fields in the context are included in the log entry.
- Use `log.NewContext(ctx, "key", value, ...)` to add fields to a context for logging.
## Log Levels
- Set the global log level with `log.SetLevel(log.LevelInfo)` or `log.SetLevelString("info")`.
- Per-path log levels can be set with `log.SetLogLevels(map[string]string{"path": "level"})`.
- Use `log.IsGreaterOrEqualTo(level)` to check if a log level is enabled for the current code path.
## Source Line Logging
- Enable source file/line logging with `log.SetLogSourceLine(true)`.
## Best Practices
- Always use the logging API, never log directly with logrus or fmt.
- Prefer structured logging (key-value pairs) for important data.
- Use context/request logging for traceability in web handlers.
- For tests, use Ginkgo/Gomega and set up a test logger as in `log/log_test.go`.
## See Also
- `log/log.go` for implementation details
- `log/log_test.go` for usage examples and test patterns

View File

@@ -46,15 +46,11 @@ testrace: ##@Development Run Go tests with race detector
.PHONY: test
testall: testrace ##@Development Run Go and JS tests
@(cd ./ui && npm run test)
@(cd ./ui && npm run test:ci)
.PHONY: testall
install-golangci-lint: ##@Development Install golangci-lint if not present
@PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.1.6)
.PHONY: install-golangci-lint
lint: install-golangci-lint ##@Development Lint Go code
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
lint: ##@Development Lint Go code
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run -v --timeout 5m
.PHONY: lint
lintall: lint ##@Development Lint Go and JS code

View File

@@ -72,7 +72,6 @@ type configOptions struct {
EnableUserEditing bool
EnableSharing bool
ShareURL string
DefaultShareExpiration time.Duration
DefaultDownloadableShare bool
DefaultTheme string
DefaultLanguage string
@@ -134,7 +133,6 @@ type scannerOptions struct {
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
FollowSymlinks bool // Whether to follow symlinks when scanning directories
PurgeMissing string // Values: "never", "always", "full"
}
type subsonicOptions struct {
@@ -278,7 +276,6 @@ func Load(noConfigDump bool) {
validateScanSchedule,
validateBackupSchedule,
validatePlaylistsPath,
validatePurgeMissingOption,
)
if err != nil {
os.Exit(1)
@@ -384,24 +381,6 @@ func validatePlaylistsPath() error {
return nil
}
func validatePurgeMissingOption() error {
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
valid := false
for _, v := range allowedValues {
if v == Server.Scanner.PurgeMissing {
valid = true
break
}
}
if !valid {
err := fmt.Errorf("Invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
log.Error(err.Error())
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
return err
}
return nil
}
func validateScanSchedule() error {
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
Server.Scanner.Schedule = ""
@@ -441,7 +420,7 @@ func AddHook(hook func()) {
hooks = append(hooks, hook)
}
func setViperDefaults() {
func init() {
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
viper.SetDefault("cachefolder", "")
viper.SetDefault("datafolder", ".")
@@ -478,6 +457,7 @@ func setViperDefaults() {
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")
@@ -492,7 +472,6 @@ func setViperDefaults() {
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablesharing", false)
viper.SetDefault("shareurl", "")
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
viper.SetDefault("defaultdownloadableshare", false)
viper.SetDefault("gatrackingid", "")
viper.SetDefault("enableinsightscollector", true)
@@ -500,15 +479,19 @@ func setViperDefaults() {
viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second)
viper.SetDefault("passwordencryptionkey", "")
viper.SetDefault("reverseproxyuserheader", "Remote-User")
viper.SetDefault("reverseproxywhitelist", "")
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "")
viper.SetDefault("jukebox.enabled", false)
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
viper.SetDefault("jukebox.default", "")
viper.SetDefault("jukebox.adminonly", true)
viper.SetDefault("scanner.enabled", true)
viper.SetDefault("scanner.schedule", "0")
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
@@ -518,11 +501,12 @@ func setViperDefaults() {
viper.SetDefault("scanner.genreseparators", "")
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("scanner.followsymlinks", true)
viper.SetDefault("scanner.purgemissing", "never")
viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false)
viper.SetDefault("subsonic.legacyclients", "DSub")
viper.SetDefault("agents", "lastfm,spotify")
viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", "en")
@@ -532,17 +516,24 @@ func setViperDefaults() {
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", "")
@@ -563,10 +554,6 @@ func setViperDefaults() {
viper.SetDefault("devenableplayerinsights", true)
}
func init() {
setViperDefaults()
}
func InitConfig(cfgFile string) {
codecRegistry := viper.NewCodecRegistry()
_ = codecRegistry.RegisterCodec("ini", ini.Codec{})

View File

@@ -5,7 +5,7 @@ import (
"path/filepath"
"testing"
"github.com/navidrome/navidrome/conf"
. "github.com/navidrome/navidrome/conf"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/spf13/viper"
@@ -20,10 +20,9 @@ var _ = Describe("Configuration", func() {
BeforeEach(func() {
// Reset viper configuration
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
ResetConf()
})
DescribeTable("should load configuration from",
@@ -31,17 +30,17 @@ var _ = Describe("Configuration", func() {
filename := filepath.Join("testdata", "cfg."+format)
// Initialize config with the test file
conf.InitConfig(filename)
InitConfig(filename)
// Load the configuration (with noConfigDump=true)
conf.Load(true)
Load(true)
// Execute the format-specific assertions
Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format))
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
Expect(Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
Expect(Server.UIWelcomeMessage).To(Equal("Welcome " + format))
Expect(Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
// The config file used should be the one we created
Expect(conf.Server.ConfigFile).To(Equal(filename))
Expect(Server.ConfigFile).To(Equal(filename))
},
Entry("TOML format", "toml"),
Entry("YAML format", "yaml"),

View File

@@ -3,5 +3,3 @@ package conf
func ResetConf() {
Server = &configOptions{}
}
var SetViperDefaults = setViperDefaults

View File

@@ -14,9 +14,6 @@ const (
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
InitialSetupFlagKey = "InitialSetup"
FullScanAfterMigrationFlagKey = "FullScanAfterMigration"
LastScanErrorKey = "LastScanError"
LastScanTypeKey = "LastScanType"
LastScanStartTimeKey = "LastScanStartTime"
UIAuthorizationHeader = "X-ND-Authorization"
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
@@ -115,12 +112,6 @@ const (
InsightsInitialDelay = 30 * time.Minute
)
const (
PurgeMissingNever = "never"
PurgeMissingAlways = "always"
PurgeMissingFull = "full"
)
var (
DefaultDownsamplingFormat = "opus"
DefaultTranscodings = []struct {

View File

@@ -45,7 +45,7 @@ func createAgents(ds model.DataStore) *Agents {
continue
}
enabled = append(enabled, name)
res = append(res, init(ds))
res = append(res, agent)
}
log.Debug("List of agents enabled", "names", enabled)

View File

@@ -344,22 +344,10 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
func init() {
conf.AddHook(func() {
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*lastfmAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := lastFMConstructor(ds)
if a != nil {
return a
}
return nil
return lastFMConstructor(ds)
})
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
// Same as above - this is a workaround for the fact that a (Scrobbler)(nil) is not the same as a (*lastfmAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := lastFMConstructor(ds)
if a != nil {
return a
}
return nil
return lastFMConstructor(ds)
})
})
}

View File

@@ -115,7 +115,7 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
} else {
switch artID.Kind {
case model.KindArtistArtwork:
artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider)
artReader, err = newArtistReader(ctx, a, artID, a.provider)
case model.KindAlbumArtwork:
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
case model.KindMediaFileArtwork:

View File

@@ -4,11 +4,7 @@ import (
"context"
"errors"
"image"
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@@ -19,11 +15,11 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("Artwork", func() {
// TODO Fix tests
var _ = XDescribe("Artwork", func() {
var aw *artwork
var ds model.DataStore
var ffmpeg *tests.MockFFmpeg
var folderRepo *fakeFolderRepo
ctx := log.NewContext(context.TODO())
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
var arMultipleCovers model.Artist
@@ -34,21 +30,20 @@ var _ = Describe("Artwork", func() {
conf.Server.ImageCacheSize = "0" // Disable cache
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
folderRepo = &fakeFolderRepo{}
ds = &tests.MockDataStore{
MockedTranscoding: &tests.MockTranscodingRepo{},
MockedFolder: folderRepo,
}
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}}
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3"}
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3"}
//alOnlyExternal = model.Album{ID: "444", Name: "Only external", ImageFiles: "tests/fixtures/artist/an-album/front.png"}
//alExternalNotFound = model.Album{ID: "555", Name: "External not found", ImageFiles: "tests/fixtures/NON_EXISTENT.png"}
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
alMultipleCovers = model.Album{
ID: "666",
Name: "All options",
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
FolderIDs: []string{"f1"},
ID: "666",
Name: "All options",
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
//Paths: []string{"tests/fixtures/artist/an-album"},
//ImageFiles: "tests/fixtures/artist/an-album/cover.jpg" + consts.Zwsp +
// "tests/fixtures/artist/an-album/front.png" + consts.Zwsp +
// "tests/fixtures/artist/an-album/artist.png",
AlbumArtistID: "777",
}
mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"}
@@ -70,7 +65,6 @@ var _ = Describe("Artwork", func() {
})
Context("Embed images", func() {
BeforeEach(func() {
folderRepo.result = nil
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alOnlyEmbed,
alEmbedNotFound,
@@ -93,17 +87,12 @@ var _ = Describe("Artwork", func() {
})
Context("External images", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alOnlyExternal,
alExternalNotFound,
})
})
It("returns external cover", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"front.png"},
}}
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
@@ -111,7 +100,6 @@ var _ = Describe("Artwork", func() {
Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png"))
})
It("returns ErrUnavailable if external file is not available", func() {
folderRepo.result = []model.Folder{}
aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
_, _, err = aw.Reader(ctx)
@@ -120,10 +108,6 @@ var _ = Describe("Artwork", func() {
})
Context("Multiple covers", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg", "front.png", "artist.png"},
}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alMultipleCovers,
})
@@ -146,10 +130,6 @@ var _ = Describe("Artwork", func() {
Describe("artistArtworkReader", func() {
Context("Multiple covers", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},
}}
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
arMultipleCovers,
})
@@ -163,7 +143,7 @@ var _ = Describe("Artwork", func() {
DescribeTable("ArtistArtPriority",
func(priority string, expected string) {
conf.Server.ArtistArtPriority = priority
aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
aw, err := newArtistReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
@@ -177,16 +157,12 @@ var _ = Describe("Artwork", func() {
Describe("mediafileArtworkReader", func() {
Context("ID not found", func() {
It("returns ErrNotFound if mediafile is not in the DB", func() {
_, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND"))
_, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Context("Embed images", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"front.png"},
}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alOnlyEmbed,
alOnlyExternal,
@@ -209,17 +185,11 @@ var _ = Describe("Artwork", func() {
Expect(err).ToNot(HaveOccurred())
r, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
data, _ := io.ReadAll(r)
Expect(data).ToNot(BeEmpty())
Expect(io.ReadAll(r)).To(Equal([]byte("content from ffmpeg")))
Expect(path).To(Equal("tests/fixtures/test.ogg"))
})
It("returns album cover if cannot read embed artwork", func() {
// Force fromTag to fail
mfCorruptedCover.Path = "tests/fixtures/DOES_NOT_EXIST.ogg"
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfCorruptedCover)).To(Succeed())
// Simulate ffmpeg error
ffmpeg.Error = errors.New("not available")
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
@@ -237,10 +207,6 @@ var _ = Describe("Artwork", func() {
})
Describe("resizedArtworkReader", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg", "front.png"},
}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alMultipleCovers,
})
@@ -275,13 +241,12 @@ var _ = Describe("Artwork", func() {
DescribeTable("resize",
func(format string, landscape bool, size int) {
coverFileName := "cover." + format
dirName := createImage(format, landscape, size)
//dirName := createImage(format, landscape, size)
alCover = model.Album{
ID: "444",
Name: "Only external",
FolderIDs: []string{"tmp"},
ID: "444",
Name: "Only external",
//ImageFiles: filepath.Join(dirName, coverFileName),
}
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alCover,
})
@@ -305,24 +270,24 @@ var _ = Describe("Artwork", func() {
})
})
func createImage(format string, landscape bool, size int) string {
var img image.Image
if landscape {
img = image.NewRGBA(image.Rect(0, 0, size, size/2))
} else {
img = image.NewRGBA(image.Rect(0, 0, size/2, size))
}
tmpDir := GinkgoT().TempDir()
f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
defer f.Close()
switch format {
case "png":
_ = png.Encode(f, img)
case "jpg":
_ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
}
return tmpDir
}
//func createImage(format string, landscape bool, size int) string {
// var img image.Image
//
// if landscape {
// img = image.NewRGBA(image.Rect(0, 0, size, size/2))
// } else {
// img = image.NewRGBA(image.Rect(0, 0, size/2, size))
// }
//
// tmpDir := GinkgoT().TempDir()
// f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
// defer f.Close()
// switch format {
// case "png":
// _ = png.Encode(f, img)
// case "jpg":
// _ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
// }
//
// return tmpDir
//}

View File

@@ -31,12 +31,6 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
return &noopCacheWarmer{}
}
// If the file cache is disabled, return a NOOP implementation
if cache.Disabled(context.Background()) {
log.Debug("Image cache disabled. Cache warmer will not run")
return &noopCacheWarmer{}
}
a := &cacheWarmer{
artwork: artwork,
cache: cache,
@@ -59,9 +53,6 @@ type cacheWarmer struct {
}
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
if a.cache.Disabled(context.Background()) {
return
}
a.mutex.Lock()
defer a.mutex.Unlock()
a.buffer[artID] = struct{}{}
@@ -83,17 +74,6 @@ func (a *cacheWarmer) run(ctx context.Context) {
break
}
if a.cache.Disabled(ctx) {
a.mutex.Lock()
pending := len(a.buffer)
a.buffer = make(map[model.ArtworkID]struct{})
a.mutex.Unlock()
if pending > 0 {
log.Trace(ctx, "Cache disabled, discarding precache buffer", "bufferLen", pending)
}
return
}
// If cache not available, keep waiting
if !a.cache.Available(ctx) {
if len(a.buffer) > 0 {

View File

@@ -1,216 +0,0 @@
package artwork
import (
"context"
"errors"
"fmt"
"io"
"strings"
"sync/atomic"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("CacheWarmer", func() {
var (
fc *mockFileCache
aw *mockArtwork
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
fc = &mockFileCache{}
aw = &mockArtwork{}
})
Context("initialization", func() {
It("returns noop when cache is disabled", func() {
fc.SetDisabled(true)
cw := NewCacheWarmer(aw, fc)
_, ok := cw.(*noopCacheWarmer)
Expect(ok).To(BeTrue())
})
It("returns noop when ImageCacheSize is 0", func() {
conf.Server.ImageCacheSize = "0"
cw := NewCacheWarmer(aw, fc)
_, ok := cw.(*noopCacheWarmer)
Expect(ok).To(BeTrue())
})
It("returns noop when EnableArtworkPrecache is false", func() {
conf.Server.EnableArtworkPrecache = false
cw := NewCacheWarmer(aw, fc)
_, ok := cw.(*noopCacheWarmer)
Expect(ok).To(BeTrue())
})
It("returns real implementation when properly configured", func() {
conf.Server.ImageCacheSize = "100MB"
conf.Server.EnableArtworkPrecache = true
fc.SetDisabled(false)
cw := NewCacheWarmer(aw, fc)
_, ok := cw.(*cacheWarmer)
Expect(ok).To(BeTrue())
})
})
Context("buffer management", func() {
BeforeEach(func() {
conf.Server.ImageCacheSize = "100MB"
conf.Server.EnableArtworkPrecache = true
fc.SetDisabled(false)
})
It("drops buffered items when cache becomes disabled", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-test"))
fc.SetDisabled(true)
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
})
It("adds multiple items to buffer", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-1"))
cw.PreCache(model.MustParseArtworkID("al-2"))
cw.mutex.Lock()
defer cw.mutex.Unlock()
Expect(len(cw.buffer)).To(Equal(2))
})
It("deduplicates items in buffer", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-1"))
cw.PreCache(model.MustParseArtworkID("al-1"))
cw.mutex.Lock()
defer cw.mutex.Unlock()
Expect(len(cw.buffer)).To(Equal(1))
})
})
Context("error handling", func() {
BeforeEach(func() {
conf.Server.ImageCacheSize = "100MB"
conf.Server.EnableArtworkPrecache = true
fc.SetDisabled(false)
})
It("continues processing after artwork retrieval error", func() {
aw.err = errors.New("artwork error")
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-error"))
cw.PreCache(model.MustParseArtworkID("al-1"))
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
})
It("continues processing after cache error", func() {
fc.err = errors.New("cache error")
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-error"))
cw.PreCache(model.MustParseArtworkID("al-1"))
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
})
})
Context("background processing", func() {
BeforeEach(func() {
conf.Server.ImageCacheSize = "100MB"
conf.Server.EnableArtworkPrecache = true
fc.SetDisabled(false)
})
It("processes items in batches", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
for i := 0; i < 5; i++ {
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
}
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
})
It("wakes up on new items", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
// Add first batch
cw.PreCache(model.MustParseArtworkID("al-1"))
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
// Add second batch
cw.PreCache(model.MustParseArtworkID("al-2"))
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
})
})
})
type mockArtwork struct {
err error
}
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
if m.err != nil {
return nil, time.Time{}, m.err
}
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
}
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
return m.Get(ctx, model.ArtworkID{}, size, square)
}
type mockFileCache struct {
disabled atomic.Bool
ready atomic.Bool
err error
}
func (f *mockFileCache) Get(ctx context.Context, item cache.Item) (*cache.CachedStream, error) {
if f.err != nil {
return nil, f.err
}
return &cache.CachedStream{Reader: io.NopCloser(strings.NewReader("cached"))}, nil
}
func (f *mockFileCache) Available(ctx context.Context) bool {
return f.ready.Load() && !f.disabled.Load()
}
func (f *mockFileCache) Disabled(ctx context.Context) bool {
return f.disabled.Load()
}
func (f *mockFileCache) SetDisabled(v bool) {
f.disabled.Store(v)
f.ready.Store(true)
}

View File

@@ -29,7 +29,7 @@ type artistReader struct {
imgFiles []string
}
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
ar, err := artwork.ds.Artist(ctx).Get(artID.ID)
if err != nil {
return nil, err

View File

@@ -12,7 +12,7 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("artistArtworkReader", func() {
var _ = Describe("artistReader", func() {
var _ = Describe("loadArtistFolder", func() {
var (
ctx context.Context

View File

@@ -8,7 +8,6 @@ import (
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
@@ -94,7 +93,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
}
s.ID = id
if V(s.ExpiresAt).IsZero() {
s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration))
s.ExpiresAt = P(time.Now().Add(365 * 24 * time.Hour))
}
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]

View File

@@ -1,36 +0,0 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE share_tmp
(
id varchar(255) not null
primary key,
expires_at datetime,
last_visited_at datetime,
resource_ids varchar not null,
created_at datetime,
updated_at datetime,
user_id varchar(255) not null
constraint share_user_id_fk
references user
on update cascade on delete cascade,
downloadable bool not null default false,
description varchar not null default '',
resource_type varchar not null default '',
contents varchar not null default '',
format varchar not null default '',
max_bit_rate integer not null default 0,
visit_count integer not null default 0
);
INSERT INTO share_tmp(
id, expires_at, last_visited_at, resource_ids, created_at, updated_at, user_id, downloadable, description, resource_type, contents, format, max_bit_rate, visit_count
) SELECT id, expires_at, last_visited_at, resource_ids, created_at, updated_at, user_id, downloadable, description, resource_type, contents, format, max_bit_rate, visit_count
FROM share;
DROP TABLE share;
ALTER TABLE share_tmp RENAME To share;
-- +goose StatementEnd
-- +goose Down

33
go.mod
View File

@@ -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.6
github.com/lestrrat-go/jwx/v2 v2.1.4
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mattn/go-sqlite3 v1.14.28
github.com/mattn/go-sqlite3 v1.14.27
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.23.4
github.com/onsi/gomega v1.37.0
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.11.0
github.com/pressly/goose/v3 v3.24.3
github.com/prometheus/client_golang v1.22.0
github.com/pressly/goose/v3 v3.24.2
github.com/prometheus/client_golang v1.21.1
github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.1
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
@@ -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-20250506013437-ce4c2cf36ca6
golang.org/x/image v0.27.0
golang.org/x/net v0.40.0
golang.org/x/sync v0.14.0
golang.org/x/sys v0.33.0
golang.org/x/text v0.25.0
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
golang.org/x/image v0.26.0
golang.org/x/net v0.38.0
golang.org/x/sync v0.13.0
golang.org/x/sys v0.32.0
golang.org/x/text v0.24.0
golang.org/x/time v0.11.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -80,17 +80,18 @@ require (
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
@@ -101,23 +102,23 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.8.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/tools v0.33.0 // indirect
golang.org/x/tools v0.31.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect

80
go.sum
View File

@@ -66,8 +66,8 @@ github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
@@ -85,8 +85,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4=
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -130,24 +130,24 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/jwx/v2 v2.1.4 h1:uBCMmJX8oRZStmKuMMOFb0Yh9xmEMgNJLgjuKKt4/qc=
github.com/lestrrat-go/jwx/v2 v2.1.4/go.mod h1:nWRbDFR1ALG2Z6GJbBXzfQaYyvn751KuuyySN2yR6is=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
@@ -174,16 +174,16 @@ github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
@@ -213,8 +213,8 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
@@ -256,13 +256,13 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -283,8 +283,8 @@ golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -292,8 +292,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -312,8 +312,8 @@ golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -334,8 +334,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -346,8 +346,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
@@ -363,11 +363,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA=
modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=

View File

@@ -32,8 +32,6 @@ type Artist struct {
SimilarArtists Artists `structs:"similar_artists" json:"-"`
ExternalInfoUpdatedAt *time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt,omitempty"`
Missing bool `structs:"missing" json:"missing"`
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
}
@@ -78,7 +76,7 @@ type ArtistRepository interface {
UpdateExternalInfo(a *Artist) error
Get(id string) (*Artist, error)
GetAll(options ...QueryOptions) (Artists, error)
GetIndex(includeMissing bool, roles ...Role) (ArtistIndexes, error)
GetIndex(roles ...Role) (ArtistIndexes, error)
// The following methods are used exclusively by the scanner:
RefreshPlayCounts() (int64, error)

View File

@@ -4,7 +4,6 @@ package criteria
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/Masterminds/squirrel"
@@ -41,9 +40,6 @@ func (c Criteria) OrderBy() string {
} else {
mapped = f.field
}
if f.numeric {
mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
}
}
if c.Order != "" {
if strings.EqualFold(c.Order, "asc") || strings.EqualFold(c.Order, "desc") {

View File

@@ -109,15 +109,6 @@ var _ = Describe("Criteria", func() {
)
})
It("casts numeric tags when sorting", func() {
AddTagNames([]string{"rate"})
AddNumericTags([]string{"rate"})
goObj.Sort = "rate"
gomega.Expect(goObj.OrderBy()).To(
gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"),
)
})
It("sorts by random", func() {
newObj := goObj
newObj.Sort = "random"

View File

@@ -54,12 +54,11 @@ var fieldMap = map[string]*mappedField{
}
type mappedField struct {
field string
order string
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
isTag bool // true if the field is a tag imported from the file metadata
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
numeric bool // true if the field/tag should be treated as numeric
field string
order string
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
isTag bool // true if the field is a tag imported from the file metadata
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
}
func mapFields(expr map[string]any) map[string]any {
@@ -146,12 +145,6 @@ type tagCond struct {
func (e tagCond) ToSql() (string, []any, error) {
cond, args, err := e.cond.ToSql()
// Check if this tag is marked as numeric in the fieldMap
if fm, ok := fieldMap[e.tag]; ok && fm.numeric {
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
}
cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
e.tag, cond)
if e.not {
@@ -212,16 +205,3 @@ func AddTagNames(tagNames []string) {
}
}
}
// AddNumericTags marks the given tag names as numeric so they can be cast
// when used in comparisons or sorting.
func AddNumericTags(tagNames []string) {
for _, name := range tagNames {
name := strings.ToLower(name)
if fm, ok := fieldMap[name]; ok {
fm.numeric = true
} else {
fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true}
}
}
}

View File

@@ -13,7 +13,6 @@ import (
var _ = BeforeSuite(func() {
AddRoles([]string{"artist", "composer"})
AddTagNames([]string{"genre"})
AddNumericTags([]string{"rate"})
})
var _ = Describe("Operators", func() {
@@ -69,15 +68,6 @@ var _ = Describe("Operators", func() {
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
)
// TODO Validate operators that are not valid for each field type.
XDescribeTable("ToSQL - Invalid Operators",
func(op Expression, expectedError string) {
_, _, err := op.ToSql()
gomega.Expect(err).To(gomega.MatchError(expectedError))
},
Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"),
)
Describe("Custom Tags", func() {
It("generates valid SQL", func() {
AddTagNames([]string{"mood"})
@@ -87,14 +77,6 @@ var _ = Describe("Operators", func() {
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.mood') where key='value' and value LIKE ?)"))
gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
})
It("casts numeric comparisons", func() {
AddNumericTags([]string{"rate"})
op := Lt{"rate": 6}
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
gomega.Expect(args).To(gomega.HaveExactElements(6))
})
It("skips unknown tag names", func() {
op := EndsWith{"unknown": "value"}
sql, args, _ := op.ToSql()

View File

@@ -342,7 +342,6 @@ type MediaFileRepository interface {
GetCursor(options ...QueryOptions) (MediaFileCursor, error)
Delete(id string) error
DeleteMissing(ids []string) error
DeleteAllMissing() (int64, error)
FindByPaths(paths []string) (MediaFiles, error)
// The following methods are used exclusively by the scanner:

View File

@@ -564,58 +564,6 @@ var _ = Describe("Participants", func() {
))
})
})
When("MUSICBRAINZ_PERFORMERID tag is set", func() {
matchPerformer := func(name, orderName, subRole, mbid string) types.GomegaMatcher {
return MatchFields(IgnoreExtras, Fields{
"Artist": MatchFields(IgnoreExtras, Fields{
"Name": Equal(name),
"OrderArtistName": Equal(orderName),
"MbzArtistID": Equal(mbid),
}),
"SubRole": Equal(subRole),
})
}
It("should map MBIDs to the correct performer", func() {
mf = toMediaFile(model.RawTags{
"PERFORMER:GUITAR": {"Eric Clapton", "B.B. King"},
"PERFORMER:BASS": {"Nathan East"},
"MUSICBRAINZ_PERFORMERID:GUITAR": {"mbid1", "mbid2"},
"MUSICBRAINZ_PERFORMERID:BASS": {"mbid3"},
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(3)))
p := participants[model.RolePerformer]
Expect(p).To(ContainElements(
matchPerformer("Eric Clapton", "eric clapton", "Guitar", "mbid1"),
matchPerformer("B.B. King", "b.b. king", "Guitar", "mbid2"),
matchPerformer("Nathan East", "nathan east", "Bass", "mbid3"),
))
})
It("should handle mismatched performer names and MBIDs for sub-roles", func() {
mf = toMediaFile(model.RawTags{
"PERFORMER:VOCALS": {"Singer A", "Singer B", "Singer C"},
"MUSICBRAINZ_PERFORMERID:VOCALS": {"mbid_vocals_a", "mbid_vocals_b"}, // Fewer MBIDs
"PERFORMER:DRUMS": {"Drummer X"},
"MUSICBRAINZ_PERFORMERID:DRUMS": {"mbid_drums_x", "mbid_drums_y"}, // More MBIDs
})
participants := mf.Participants
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(4))) // 3 vocalists + 1 drummer
p := participants[model.RolePerformer]
Expect(p).To(ContainElements(
matchPerformer("Singer A", "singer a", "Vocals", "mbid_vocals_a"),
matchPerformer("Singer B", "singer b", "Vocals", "mbid_vocals_b"),
matchPerformer("Singer C", "singer c", "Vocals", ""),
matchPerformer("Drummer X", "drummer x", "Drums", "mbid_drums_x"),
))
})
})
})
Describe("Other tags", func() {
@@ -644,6 +592,7 @@ var _ = Describe("Participants", func() {
Entry("REMIXER", model.RoleRemixer, "REMIXER"),
Entry("DJMIXER", model.RoleDJMixer, "DJMIXER"),
Entry("DIRECTOR", model.RoleDirector, "DIRECTOR"),
// TODO PERFORMER
)
})

View File

@@ -191,7 +191,6 @@ const (
TagReleaseCountry TagName = "releasecountry"
TagMedia TagName = "media"
TagCatalogNumber TagName = "catalognumber"
TagISRC TagName = "isrc"
TagBPM TagName = "bpm"
TagExplicitStatus TagName = "explicitstatus"

View File

@@ -162,17 +162,6 @@ func tagNames() []string {
return names
}
func numericTagNames() []string {
mappings := TagMappings()
names := make([]string, 0)
for k, cfg := range mappings {
if cfg.Type == TagTypeInteger || cfg.Type == TagTypeFloat {
names = append(names, string(k))
}
}
return names
}
func loadTagMappings() {
mappingsFile, err := resources.FS().Open("mappings.yaml")
if err != nil {
@@ -239,6 +228,5 @@ func init() {
// used in smart playlists
criteria.AddRoles(slices.Collect(maps.Keys(AllRoles)))
criteria.AddTagNames(tagNames())
criteria.AddNumericTags(numericTagNames())
})
}

View File

@@ -116,7 +116,6 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
"name": fullTextFilter(r.tableName),
"starred": booleanFilter,
"role": roleFilter,
"missing": booleanFilter,
})
r.setSortMappings(map[string]string{
"name": "order_artist_name",
@@ -203,7 +202,7 @@ func (r *artistRepository) getIndexKey(a model.Artist) string {
}
// TODO Cache the index (recalculate when there are changes to the DB)
func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (model.ArtistIndexes, error) {
func (r *artistRepository) GetIndex(roles ...model.Role) (model.ArtistIndexes, error) {
options := model.QueryOptions{Sort: "name"}
if len(roles) > 0 {
roleFilters := slice.Map(roles, func(r model.Role) Sqlizer {
@@ -211,13 +210,6 @@ func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (m
})
options.Filters = And(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 {
return nil, err
@@ -244,25 +236,6 @@ func (r *artistRepository) purgeEmpty() error {
return nil
}
// markMissing marks artists as missing if all their albums are missing.
func (r *artistRepository) markMissing() error {
q := Expr(`
with artists_with_non_missing_albums as (
select distinct aa.artist_id
from album_artists aa
join album a on aa.album_id = a.id
where a.missing = false
)
update artist
set missing = (artist.id not in (select artist_id from artists_with_non_missing_albums));
`)
_, err := r.executeSQL(q)
if err != nil {
return fmt.Errorf("marking missing artists: %w", err)
}
return nil
}
// RefreshPlayCounts updates the play count and last play date annotations for all artists, based
// on the media files associated with them.
func (r *artistRepository) RefreshPlayCounts() (int64, error) {

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
@@ -95,7 +94,7 @@ var _ = Describe("ArtistRepository", func() {
er := repo.Put(&artistBeatles)
Expect(er).To(BeNil())
idx, err := repo.GetIndex(false)
idx, err := repo.GetIndex()
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("F"))
@@ -113,7 +112,7 @@ var _ = Describe("ArtistRepository", func() {
// BFR Empty SortArtistName is not saved in the DB anymore
XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
idx, err := repo.GetIndex(false)
idx, err := repo.GetIndex()
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("B"))
@@ -135,7 +134,7 @@ var _ = Describe("ArtistRepository", func() {
er := repo.Put(&artistBeatles)
Expect(er).To(BeNil())
idx, err := repo.GetIndex(false)
idx, err := repo.GetIndex()
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("B"))
@@ -152,7 +151,7 @@ var _ = Describe("ArtistRepository", func() {
})
It("returns the index when SortArtistName is empty", func() {
idx, err := repo.GetIndex(false)
idx, err := repo.GetIndex()
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("B"))
@@ -234,91 +233,5 @@ var _ = Describe("ArtistRepository", func() {
Expect(m).ToNot(HaveKey("mbz_artist_id"))
})
})
Describe("Missing artist visibility", func() {
var raw *artistRepository
var missing model.Artist
insertMissing := func() {
missing = model.Artist{ID: "m1", Name: "Missing", OrderArtistName: "missing"}
Expect(repo.Put(&missing)).To(Succeed())
raw = repo.(*artistRepository)
_, err := raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missing.ID}))
Expect(err).ToNot(HaveOccurred())
}
removeMissing := func() {
if raw != nil {
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missing.ID}))
}
}
Context("regular user", func() {
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "u1"})
repo = NewArtistRepository(ctx, GetDBXBuilder())
insertMissing()
})
AfterEach(func() { removeMissing() })
It("does not return missing artist in GetAll", func() {
artists, err := repo.GetAll(model.QueryOptions{Filters: squirrel.Eq{"artist.missing": false}})
Expect(err).ToNot(HaveOccurred())
Expect(artists).To(HaveLen(2))
})
It("does not return missing artist in Search", func() {
res, err := repo.Search("missing", 0, 10, false)
Expect(err).ToNot(HaveOccurred())
Expect(res).To(BeEmpty())
})
It("does not return missing artist in GetIndex", func() {
idx, err := repo.GetIndex(false)
Expect(err).ToNot(HaveOccurred())
// Only 2 artists should be present
total := 0
for _, ix := range idx {
total += len(ix.Artists)
}
Expect(total).To(Equal(2))
})
})
Context("admin user", func() {
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "admin", IsAdmin: true})
repo = NewArtistRepository(ctx, GetDBXBuilder())
insertMissing()
})
AfterEach(func() { removeMissing() })
It("returns missing artist in GetAll", func() {
artists, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(artists).To(HaveLen(3))
})
It("returns missing artist in Search", func() {
res, err := repo.Search("missing", 0, 10, true)
Expect(err).ToNot(HaveOccurred())
Expect(res).To(HaveLen(1))
})
It("returns missing artist in GetIndex when included", func() {
idx, err := repo.GetIndex(true)
Expect(err).ToNot(HaveOccurred())
total := 0
for _, ix := range idx {
total += len(ix.Artists)
}
Expect(total).To(Equal(3))
})
})
})
})
})

View File

@@ -192,15 +192,6 @@ func (r *mediaFileRepository) Delete(id string) error {
return r.delete(Eq{"id": id})
}
func (r *mediaFileRepository) DeleteAllMissing() (int64, error) {
user := loggedUser(r.ctx)
if !user.IsAdmin {
return 0, rest.ErrPermissionDenied
}
del := Delete(r.tableName).Where(Eq{"missing": true})
return r.executeSQL(del)
}
func (r *mediaFileRepository) DeleteMissing(ids []string) error {
user := loggedUser(r.ctx)
if !user.IsAdmin {

View File

@@ -4,7 +4,6 @@ import (
"context"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
@@ -45,39 +44,14 @@ var _ = Describe("MediaRepository", func() {
It("delete tracks by id", func() {
newID := id.NewRandom()
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(BeNil())
Expect(mr.Delete(newID)).To(Succeed())
Expect(mr.Delete(newID)).To(BeNil())
_, err := mr.Get(newID)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("deletes all missing files", func() {
new1 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1}
new2 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1}
Expect(mr.Put(&new1)).To(Succeed())
Expect(mr.Put(&new2)).To(Succeed())
Expect(mr.MarkMissing(true, &new1, &new2)).To(Succeed())
adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true})
adminRepo := NewMediaFileRepository(adminCtx, GetDBXBuilder())
// Ensure the files are marked as missing and we have 2 of them
count, err := adminRepo.CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}})
Expect(count).To(BeNumerically("==", 2))
Expect(err).ToNot(HaveOccurred())
count, err = adminRepo.DeleteAllMissing()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(BeNumerically("==", 2))
_, err = mr.Get(new1.ID)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = mr.Get(new2.ID)
Expect(err).To(MatchError(model.ErrNotFound))
})
Context("Annotations", func() {
It("increments play count when the tracks does not have annotations", func() {
id := "incplay.firsttime"

View File

@@ -170,7 +170,6 @@ func (s *SQLStore) GC(ctx context.Context) error {
err := chain.RunSequentially(
trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty() }),
trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }),
trace(ctx, "mark missing artists", func() error { return s.Artist(ctx).(*artistRepository).markMissing() }),
trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty() }),
trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }),
trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }),

View File

@@ -65,11 +65,6 @@ func loggedUser(ctx context.Context) *model.User {
}
}
func isAdmin(ctx context.Context) bool {
user := loggedUser(ctx)
return user.IsAdmin
}
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
if r.tableName == "" {
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")

View File

@@ -41,9 +41,6 @@ func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding,
}
func (r *transcodingRepository) Put(t *model.Transcoding) error {
if !isAdmin(r.ctx) {
return rest.ErrPermissionDenied
}
_, err := r.put(t.ID, t)
return err
}
@@ -72,9 +69,6 @@ func (r *transcodingRepository) NewInstance() interface{} {
}
func (r *transcodingRepository) Save(entity interface{}) (string, error) {
if !isAdmin(r.ctx) {
return "", rest.ErrPermissionDenied
}
t := entity.(*model.Transcoding)
id, err := r.put(t.ID, t)
if errors.Is(err, model.ErrNotFound) {
@@ -84,9 +78,6 @@ func (r *transcodingRepository) Save(entity interface{}) (string, error) {
}
func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error {
if !isAdmin(r.ctx) {
return rest.ErrPermissionDenied
}
t := entity.(*model.Transcoding)
t.ID = id
_, err := r.put(id, t)
@@ -97,9 +88,6 @@ func (r *transcodingRepository) Update(id string, entity interface{}, cols ...st
}
func (r *transcodingRepository) Delete(id string) error {
if !isAdmin(r.ctx) {
return rest.ErrPermissionDenied
}
err := r.delete(Eq{"id": id})
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound

View File

@@ -1,96 +0,0 @@
package persistence
import (
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("TranscodingRepository", func() {
var repo model.TranscodingRepository
var adminRepo model.TranscodingRepository
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, regularUser)
repo = NewTranscodingRepository(ctx, GetDBXBuilder())
adminCtx := log.NewContext(GinkgoT().Context())
adminCtx = request.WithUser(adminCtx, adminUser)
adminRepo = NewTranscodingRepository(adminCtx, GetDBXBuilder())
})
AfterEach(func() {
// Clean up any transcoding created during the tests
tc, err := adminRepo.FindByFormat("test_format")
if err == nil {
err = adminRepo.(*transcodingRepository).Delete(tc.ID)
Expect(err).ToNot(HaveOccurred())
}
})
Describe("Admin User", func() {
It("creates a new transcoding", func() {
base, err := adminRepo.CountAll()
Expect(err).ToNot(HaveOccurred())
err = adminRepo.Put(&model.Transcoding{ID: "new", Name: "new", TargetFormat: "test_format", DefaultBitRate: 320, Command: "ffmpeg"})
Expect(err).ToNot(HaveOccurred())
count, err := adminRepo.CountAll()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(base + 1))
})
It("updates an existing transcoding", func() {
tr := &model.Transcoding{ID: "upd", Name: "old", TargetFormat: "test_format", DefaultBitRate: 100, Command: "ffmpeg"}
Expect(adminRepo.Put(tr)).To(Succeed())
tr.Name = "updated"
err := adminRepo.Put(tr)
Expect(err).ToNot(HaveOccurred())
res, err := adminRepo.FindByFormat("test_format")
Expect(err).ToNot(HaveOccurred())
Expect(res.Name).To(Equal("updated"))
})
It("deletes a transcoding", func() {
err := adminRepo.Put(&model.Transcoding{ID: "to-delete", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 256, Command: "ffmpeg"})
Expect(err).ToNot(HaveOccurred())
err = adminRepo.(*transcodingRepository).Delete("to-delete")
Expect(err).ToNot(HaveOccurred())
_, err = adminRepo.Get("to-delete")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("Regular User", func() {
It("fails to create", func() {
err := repo.Put(&model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"})
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
It("fails to update", func() {
tr := &model.Transcoding{ID: "updreg", Name: "old", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}
Expect(adminRepo.Put(tr)).To(Succeed())
tr.Name = "bad"
err := repo.Put(tr)
Expect(err).To(Equal(rest.ErrPermissionDenied))
//_ = adminRepo.(*transcodingRepository).Delete("updreg")
})
It("fails to delete", func() {
tr := &model.Transcoding{ID: "delreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}
Expect(adminRepo.Put(tr)).To(Succeed())
err := repo.(*transcodingRepository).Delete("delreg")
Expect(err).To(Equal(rest.ErrPermissionDenied))
//_ = adminRepo.(*transcodingRepository).Delete("delreg")
})
})
})

View File

@@ -17,10 +17,7 @@
"year": "Urtea",
"size": "Fitxategiaren tamaina",
"updatedAt": "Eguneratze-data:",
"bitRate": "Bit-tasa",
"bitDepth": "Bit-sakonera",
"sampleRate": "Lagin-tasa",
"channels": "Kanalak",
"bitRate": "Bit tasa",
"discSubtitle": "Diskoaren azpititulua",
"starred": "Gogokoa",
"comment": "Iruzkina",
@@ -28,13 +25,14 @@
"quality": "Kalitatea",
"bpm": "BPM",
"playDate": "Azkenekoz erreproduzitua:",
"channels": "Kanalak",
"createdAt": "Gehitu zen data:",
"grouping": "Multzokatzea",
"mood": "Aldartea",
"participants": "Partaide gehiago",
"tags": "Traola gehiago",
"mappedTags": "Esleitutako traolak",
"rawTags": "Traola gordinak"
"grouping": "",
"mood": "",
"participants": "",
"tags": "",
"mappedTags": "",
"rawTags": ""
},
"actions": {
"addToQueue": "Erreproduzitu ondoren",
@@ -54,26 +52,25 @@
"duration": "Iraupena",
"songCount": "abesti",
"playCount": "Erreprodukzioak",
"size": "Fitxategiaren tamaina",
"name": "Izena",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
"date": "Recording Date",
"originalDate": "Jatorrizkoa",
"releaseDate": "Argitaratze-data:",
"releases": "Argitaratzea |||| Argitaratzeak",
"released": "Argitaratua",
"updatedAt": "Aktualizatze-data:",
"comment": "Iruzkina",
"rating": "Balorazioa",
"createdAt": "Gehitu zen data:",
"recordLabel": "Disketxea",
"catalogNum": "Katalogo-zenbakia",
"releaseType": "Mota",
"grouping": "Multzokatzea",
"media": "Multimedia",
"mood": "Aldartea"
"size": "Fitxategiaren tamaina",
"originalDate": "Jatorrizkoa",
"releaseDate": "Argitaratze-data:",
"releases": "Argitaratzea |||| Argitaratzeak",
"released": "Argitaratua",
"recordLabel": "",
"catalogNum": "",
"releaseType": "",
"grouping": "",
"media": "",
"mood": ""
},
"actions": {
"playAll": "Erreproduzitu",
@@ -87,7 +84,7 @@
},
"lists": {
"all": "Guztiak",
"random": "Aleatorioki",
"random": "Aleatorioa",
"recentlyAdded": "Berriki gehitutakoak",
"recentlyPlayed": "Berriki entzundakoak",
"mostPlayed": "Gehien entzundakoak",
@@ -101,26 +98,26 @@
"name": "Izena",
"albumCount": "Album kopurua",
"songCount": "Abesti kopurua",
"size": "Tamaina",
"playCount": "Erreprodukzio kopurua",
"rating": "Balorazioa",
"genre": "Generoa",
"role": "Rola"
"size": "Tamaina",
"role": ""
},
"roles": {
"albumartist": "Albumeko egilea |||| Albumeko artistak",
"artist": "Artista |||| Artistak",
"composer": "Konpositorea |||| Konpositoreak",
"conductor": "Orkestra zuzendaria |||| Orkestra zuzendariak",
"lyricist": "Hitzen egilea |||| Hitzen egileak",
"arranger": "Moldatzailea |||| Moldatzaileak",
"producer": "Produktorea |||| Produktoreak",
"director": "Zuzendaria |||| Zuzendaria",
"engineer": "Teknikaria |||| Teknikariak",
"mixer": "Nahaslea |||| Nahasleak",
"remixer": "Remixerra |||| Remixerrak",
"djmixer": "DJ nahaslea |||| DJ nahasleak",
"performer": "Interpretatzailea |||| Interpretatzaileak"
"albumartist": "",
"artist": "",
"composer": "",
"conductor": "",
"lyricist": "",
"arranger": "",
"producer": "",
"director": "",
"engineer": "",
"mixer": "",
"remixer": "",
"djmixer": "",
"performer": ""
}
},
"user": {
@@ -241,8 +238,7 @@
"updatedAt": "Desagertze-data:"
},
"actions": {
"remove": "Kendu",
"remove_all": "Kendu guztia"
"remove": "Kendu"
},
"notifications": {
"removed": "Faltan zeuden fitxategiak kendu dira"
@@ -262,7 +258,7 @@
"sign_in": "Sartu",
"sign_in_error": "Autentifikazioak huts egin du, saiatu berriro",
"logout": "Amaitu saioa",
"insightsCollectionNote": "Navidromek erabilera-datu anonimoak biltzen ditu\nproiektua hobetzen laguntzeko. Egin klik [hemen]\ngehiago ikasteko, eta datuak ez biltzeko eskatzeko,\nhala nahi izanez gero."
"insightsCollectionNote": ""
},
"validation": {
"invalidChars": "Erabili hizkiak eta zenbakiak bakarrik",
@@ -402,33 +398,31 @@
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",
"delete_user_content": "Ziur zaide erabiltzaile hau eta bere datu guztiak (zerrendak eta hobespenak barne) ezabatu nahi dituzula?",
"remove_missing_title": "Kendu faltan dauden fitxategiak",
"remove_missing_content": "Ziur hautatutako fitxategiak datu-basetik kendu nahi dituzula? Betiko kenduko dira haiei buruzko erreferentziak, erreprodukzio-zenbaketak eta sailkapenak barne.",
"remove_all_missing_title": "Kendu faltan dauden fitxategi guztiak",
"remove_all_missing_content": "Ziur aurkitu ez diren fitxategi guztiak datu-basetik kendu nahi dituzula? Betiko kenduko dira haiei buruzko erreferentziak, erreprodukzio-zenbaketak eta sailkapenak barne.",
"notifications_blocked": "Nabigatzaileak jakinarazpenak blokeatzen ditu",
"notifications_not_available": "Nabigatzaile hau ez da jakinarazpenekin bateragarria edo Navidrome ez da HTTPS erabiltzen ari",
"lastfmLinkSuccess": "Last.fm konektatuta dago eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea gaituta dago",
"lastfmLinkFailure": "Ezin izan da Last.fm-rekin konektatu",
"lastfmUnlinkSuccess": "Last.fm deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea ezgaitu da",
"lastfmUnlinkFailure": "Ezin izan da Last.fm deskonektatu",
"listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da",
"listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da",
"listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu",
"openIn": {
"lastfm": "Ikusi Last.fm-n",
"musicbrainz": "Ikusi MusicBrainz-en"
},
"lastfmLink": "Irakurri gehiago…",
"listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da",
"listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da",
"listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu",
"downloadOriginalFormat": "Deskargatu jatorrizko formatua",
"shareOriginalFormat": "Partekatu jatorrizko formatua",
"shareDialogTitle": "Partekatu '%{name}' %{resource}",
"shareBatchDialogTitle": "Partekatu %{resource} bat |||| Partekatu %{smart_count} %{resource}",
"shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla",
"shareSuccess": "URLa arbelera kopiatu da: %{url}",
"shareFailure": "Errorea %{url} URLa arbelera kopiatzean",
"downloadDialogTitle": "Deskargatu '%{name}' %{resource}, (%{size})",
"downloadOriginalFormat": "Deskargatu jatorrizko formatua"
"shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla",
"remove_missing_title": "",
"remove_missing_content": ""
},
"menu": {
"library": "Liburutegia",
@@ -442,7 +436,6 @@
"language": "Hizkuntza",
"defaultView": "Bista, defektuz",
"desktop_notifications": "Mahaigaineko jakinarazpenak",
"lastfmNotConfigured": "Last.fm-ren API-gakoa ez dago konfiguratuta",
"lastfmScrobbling": "Bidali Last.fm-ra erabiltzailearen ohiturak",
"listenBrainzScrobbling": "Bidali ListenBrainz-era erabiltzailearen ohiturak",
"replaygain": "ReplayGain modua",
@@ -451,13 +444,14 @@
"none": "Bat ere ez",
"album": "Albuma",
"track": "Pista"
}
},
"lastfmNotConfigured": ""
}
},
"albumList": "Albumak",
"about": "Honi buruz",
"playlists": "Zerrendak",
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
"about": "Honi buruz"
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak"
},
"player": {
"playListsText": "Erreprodukzio-zerrenda",
@@ -489,10 +483,10 @@
"homepage": "Hasierako orria",
"source": "Iturburu kodea",
"featureRequests": "Eskatu ezaugarria",
"lastInsightsCollection": "Bildutako azken datuak",
"lastInsightsCollection": "",
"insights": {
"disabled": "Ezgaituta",
"waiting": "Zain"
"disabled": "",
"waiting": ""
}
}
},
@@ -502,10 +496,7 @@
"quickScan": "Arakatze azkarra",
"fullScan": "Arakatze sakona",
"serverUptime": "Zerbitzariak piztuta daraman denbora",
"serverDown": "LINEAZ KANPO",
"scanType": "Mota",
"status": "Errorea arakatzean",
"elapsedTime": "Igarotako denbora"
"serverDown": "LINEAZ KANPO"
},
"help": {
"title": "Navidromeren laster-teklak",

View File

@@ -18,7 +18,6 @@
"size": "Taille",
"updatedAt": "Mise à jour",
"bitRate": "Bitrate",
"sampleRate": "Fréquence d'échantillonnage",
"discSubtitle": "Sous-titre du disque",
"starred": "Favoris",
"comment": "Commentaire",
@@ -58,7 +57,6 @@
"genre": "Genre",
"compilation": "Compilation",
"year": "Année",
"date": "Date d'enregistrement",
"updatedAt": "Mis à jour le",
"comment": "Commentaire",
"rating": "Classement",

View File

@@ -18,8 +18,6 @@
"size": "Fájlméret",
"updatedAt": "Legutóbb frissítve",
"bitRate": "Bitráta",
"bitDepth": "Bitmélység",
"sampleRate": "Mintavételezési frekvencia",
"discSubtitle": "Lemezfelirat",
"starred": "Kedvenc",
"comment": "Megjegyzés",
@@ -58,7 +56,6 @@
"genre": "Stílus",
"compilation": "Válogatásalbum",
"year": "Év",
"date": "Felvétel dátuma",
"updatedAt": "Legutóbb frissítve",
"comment": "Megjegyzés",
"rating": "Értékelés",
@@ -192,7 +189,6 @@
"selectPlaylist": "Válassz egy lejátszási listát:",
"addNewPlaylist": "\"%{name}\" létrehozása",
"export": "Exportálás",
"saveQueue": "Műsorlista elmentése lejátszási listaként",
"makePublic": "Publikussá tétel",
"makePrivate": "Priváttá tétel"
},
@@ -233,15 +229,13 @@
},
"missing": {
"name": "Hiányzó fájl|||| Hiányzó fájlok",
"empty": "Nincsenek hiányzó fájlok",
"fields": {
"path": "Útvonal",
"size": "Méret",
"updatedAt": "Eltűnt ekkor:"
},
"actions": {
"remove": "Eltávolítás",
"remove_all": "Összes eltávolítása"
"remove": "Eltávolítás"
},
"notifications": {
"removed": "Hiányzó fájl(ok) eltávolítva"
@@ -401,8 +395,6 @@
"noPlaylistsAvailable": "Nem áll rendelkezésre",
"delete_user_title": "Felhasználó törlése '%{name}'",
"delete_user_content": "Biztos, hogy törölni akarod ezt a felhasználót az adataival (beállítások és lejátszási listák) együtt?",
"remove_all_missing_title": "Összes hiányzó fájl eltávolítása",
"remove_all_missing_content": "Biztos, hogy minden hiányzó fájlt törölni akarsz az adatbázisból? Ez minden hozzájuk fűződő referenciát törölni fog, beleértve a lejátszásaikat és értékeléseiket.",
"notifications_blocked": "A böngésződ beállításaiban letiltottad az értesítéseket erre az oldalra.",
"notifications_not_available": "Ez a böngésző nem támogatja az asztali értesítéseket, vagy a Navidrome-ot nem https-en keresztül használod.",
"lastfmLinkSuccess": "Sikeresen összekapcsolva Last.fm-el és halgatott számok küldése engedélyezve.",
@@ -414,7 +406,7 @@
"musicbrainz": "Megnyitás MusicBrainz-ben"
},
"lastfmLink": "Bővebben...",
"listenBrainzLinkSuccess": "Sikeresen összekapcsolva ListenBrainz-el. Halgatott számok küldése %{user} felhasználónak engedélyezve.",
"listenBrainzLinkSuccess": "Sikeresen összekapcsolva ListenBrainz-el és halgatott számok küldése %{user} felhasználónak engedélyezve.",
"listenBrainzLinkFailure": "Nem lehet kapcsolódni a Listenbrainz-hez: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz Last.fm leválasztva és a halgatott számok küldése kikapcsolva.",
"listenBrainzUnlinkFailure": "Nem sikerült leválasztani a ListenBrainz-et.",
@@ -459,7 +451,7 @@
"sharedPlaylists": "Megosztott lej. listák"
},
"player": {
"playListsText": "Műsorlista",
"playListsText": "Lejátszási lista",
"openText": "Megnyitás",
"closeText": "Bezárás",
"notContentText": "Nincs zene",
@@ -501,10 +493,7 @@
"quickScan": "Gyors beolvasás",
"fullScan": "Teljes beolvasás",
"serverUptime": "Szerver üzemidő",
"serverDown": "OFFLINE",
"scanType": "Típus",
"status": "Szkennelési hiba",
"elapsedTime": "Eltelt idő"
"serverDown": "OFFLINE"
},
"help": {
"title": "Navidrome Gyorsbillentyűk",

View File

@@ -1,5 +1,5 @@
{
"languageName": "Português (Brasil)",
"languageName": "Português",
"resources": {
"song": {
"name": "Música |||| Músicas",
@@ -33,8 +33,7 @@
"participants": "Outros Participantes",
"tags": "Outras Tags",
"mappedTags": "Tags mapeadas",
"rawTags": "Tags originais",
"missing": "Ausente"
"rawTags": "Tags originais"
},
"actions": {
"addToQueue": "Adicionar à fila",
@@ -73,8 +72,7 @@
"releaseType": "Tipo",
"grouping": "Agrupamento",
"media": "Mídia",
"mood": "Mood",
"missing": "Ausente"
"mood": "Mood"
},
"actions": {
"playAll": "Tocar",
@@ -106,8 +104,7 @@
"rating": "Classificação",
"genre": "Gênero",
"size": "Tamanho",
"role": "Role",
"missing": "Ausente"
"role": "Role"
},
"roles": {
"albumartist": "Artista do Álbum |||| Artistas do Álbum",
@@ -194,7 +191,6 @@
"selectPlaylist": "Selecione a playlist:",
"addNewPlaylist": "Criar \"%{name}\"",
"export": "Exportar",
"saveQueue": "Salvar fila em nova Playlist",
"makePublic": "Pública",
"makePrivate": "Pessoal"
},
@@ -242,8 +238,7 @@
"updatedAt": "Desaparecido em"
},
"actions": {
"remove": "Remover",
"remove_all": "Remover todos"
"remove": "Remover"
},
"notifications": {
"removed": "Arquivo(s) ausente(s) removido(s)"
@@ -427,9 +422,7 @@
"downloadDialogTitle": "Baixar %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Copie para o clipboard: Ctrl+C, Enter",
"remove_missing_title": "Remover arquivos ausentes",
"remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.",
"remove_all_missing_title": "Remover todos os arquivos ausentes",
"remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações."
"remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações."
},
"menu": {
"library": "Biblioteca",
@@ -503,10 +496,7 @@
"quickScan": "Scan rápido",
"fullScan": "Scan completo",
"serverUptime": "Uptime do servidor",
"serverDown": "DESCONECTADO",
"scanType": "Tipo",
"status": "Erro",
"elapsedTime": "Duração"
"serverDown": "DESCONECTADO"
},
"help": {
"title": "Teclas de atalho",
@@ -522,4 +512,4 @@
"current_song": "Vai para música atual"
}
}
}
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/auth"
@@ -38,9 +37,6 @@ type StatusInfo struct {
LastScan time.Time
Count uint32
FolderCount uint32
LastError string
ScanType string
ElapsedTime time.Duration
}
func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker,
@@ -98,7 +94,6 @@ type ProgressInfo struct {
ChangesDetected bool
Warning string
Error string
ForceUpdate bool
}
type scanner interface {
@@ -118,51 +113,20 @@ type controller struct {
changesDetected bool
}
// getScanInfo retrieves scan status from the database
func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed time.Duration, lastErr string) {
lastErr, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "")
scanType, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "")
startTimeStr, _ := s.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
if startTimeStr != "" {
startTime, err := time.Parse(time.RFC3339, startTimeStr)
if err == nil {
if running.Load() {
elapsed = time.Since(startTime)
} else {
// If scan is not running, try to get the last scan time for the library
lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library
if err == nil {
elapsed = lib.LastScanAt.Sub(startTime)
}
}
}
}
return scanType, elapsed, lastErr
}
func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library
if err != nil {
return nil, fmt.Errorf("getting library: %w", err)
}
scanType, elapsed, lastErr := s.getScanInfo(ctx)
if running.Load() {
status := &StatusInfo{
Scanning: true,
LastScan: lib.LastScanAt,
Count: s.count.Load(),
FolderCount: s.folderCount.Load(),
LastError: lastErr,
ScanType: scanType,
ElapsedTime: elapsed,
}
return status, nil
}
count, folderCount, err := s.getCounters(ctx)
if err != nil {
return nil, fmt.Errorf("getting library stats: %w", err)
@@ -172,9 +136,6 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
LastScan: lib.LastScanAt,
Count: uint32(count),
FolderCount: uint32(folderCount),
LastError: lastErr,
ScanType: scanType,
ElapsedTime: elapsed,
}, nil
}
@@ -232,14 +193,10 @@ 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
@@ -283,17 +240,12 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres
if p.FileCount > 0 {
s.folderCount.Add(1)
}
scanType, elapsed, lastErr := s.getScanInfo(ctx)
status := &events.ScanStatus{
Scanning: true,
Count: int64(s.count.Load()),
FolderCount: int64(s.folderCount.Load()),
Error: lastErr,
ScanType: scanType,
ElapsedTime: elapsed,
}
if s.limiter != nil && !p.ForceUpdate {
if s.limiter != nil {
s.limiter.Do(func() { s.sendMessage(ctx, status) })
} else {
s.sendMessage(ctx, status)

View File

@@ -1,57 +0,0 @@
package scanner_test
import (
"context"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Controller", func() {
var ctx context.Context
var ds *tests.MockDataStore
var ctrl scanner.Scanner
Describe("Status", func() {
BeforeEach(func() {
ctx = context.Background()
db.Init(ctx)
DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) })
DeferCleanup(configtest.SetupConfig())
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
ds.MockedProperty = &tests.MockedPropertyRepo{}
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance())
Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Name: "lib", Path: "/tmp"})).To(Succeed())
})
It("includes last scan error", func() {
Expect(ds.Property(ctx).Put(consts.LastScanErrorKey, "boom")).To(Succeed())
status, err := ctrl.Status(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(status.LastError).To(Equal("boom"))
})
It("includes scan type and error in status", func() {
// Set up test data in property repo
Expect(ds.Property(ctx).Put(consts.LastScanErrorKey, "test error")).To(Succeed())
Expect(ds.Property(ctx).Put(consts.LastScanTypeKey, "full")).To(Succeed())
// Get status and verify basic info
status, err := ctrl.Status(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(status.LastError).To(Equal("test error"))
Expect(status.ScanType).To(Equal("full"))
})
})
})

View File

@@ -6,8 +6,6 @@ import (
"sync/atomic"
ppl "github.com/google/go-pipeline/pkg/pipeline"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
@@ -184,35 +182,7 @@ func (p *phaseMissingTracks) finalize(err error) error {
if matched > 0 {
log.Info(p.ctx, "Scanner: Found moved files", "total", matched, err)
}
if err != nil {
return err
}
// Check if we should purge missing items
if conf.Server.Scanner.PurgeMissing == consts.PurgeMissingAlways || (conf.Server.Scanner.PurgeMissing == consts.PurgeMissingFull && p.state.fullScan) {
if err = p.purgeMissing(); err != nil {
log.Error(p.ctx, "Scanner: Error purging missing items", err)
}
}
return err
}
func (p *phaseMissingTracks) purgeMissing() error {
deletedCount, err := p.ds.MediaFile(p.ctx).DeleteAllMissing()
if err != nil {
return fmt.Errorf("error deleting missing files: %w", err)
}
if deletedCount > 0 {
log.Info(p.ctx, "Scanner: Purged missing items from the database", "mediaFiles", deletedCount)
// Set changesDetected to true so that garbage collection will run at the end of the scan process
p.state.changesDetected.Store(true)
} else {
log.Debug(p.ctx, "Scanner: No missing items to purge")
}
return nil
}
var _ phase[*missingTracks] = (*phaseMissingTracks)(nil)

View File

@@ -4,8 +4,6 @@ import (
"context"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@@ -224,66 +222,4 @@ var _ = Describe("phaseMissingTracks", func() {
Expect(state.changesDetected.Load()).To(BeFalse())
})
})
Describe("finalize", func() {
It("should return nil if no error", func() {
err := phase.finalize(nil)
Expect(err).To(BeNil())
Expect(state.changesDetected.Load()).To(BeFalse())
})
It("should return the error if provided", func() {
err := phase.finalize(context.DeadlineExceeded)
Expect(err).To(Equal(context.DeadlineExceeded))
Expect(state.changesDetected.Load()).To(BeFalse())
})
When("PurgeMissing is 'always'", func() {
BeforeEach(func() {
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways
mr.CountAllValue = 3
mr.DeleteAllMissingValue = 3
})
It("should purge missing files", func() {
Expect(state.changesDetected.Load()).To(BeFalse())
err := phase.finalize(nil)
Expect(err).To(BeNil())
Expect(state.changesDetected.Load()).To(BeTrue())
})
})
When("PurgeMissing is 'full'", func() {
BeforeEach(func() {
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull
mr.CountAllValue = 2
mr.DeleteAllMissingValue = 2
})
It("should not purge missing files if not a full scan", func() {
state.fullScan = false
err := phase.finalize(nil)
Expect(err).To(BeNil())
Expect(state.changesDetected.Load()).To(BeFalse())
})
It("should purge missing files if full scan", func() {
Expect(state.changesDetected.Load()).To(BeFalse())
state.fullScan = true
err := phase.finalize(nil)
Expect(err).To(BeNil())
Expect(state.changesDetected.Load()).To(BeTrue())
})
})
When("PurgeMissing is 'never'", func() {
BeforeEach(func() {
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever
mr.CountAllValue = 1
mr.DeleteAllMissingValue = 1
})
It("should not purge missing files", func() {
err := phase.finalize(nil)
Expect(err).To(BeNil())
Expect(state.changesDetected.Load()).To(BeFalse())
})
})
})
})

View File

@@ -57,21 +57,12 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
startTime := time.Now()
log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(libs))
// Store scan type and start time
scanType := "quick"
if state.fullScan {
scanType = "full"
}
_ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, scanType)
_ = s.ds.Property(ctx).Put(consts.LastScanStartTimeKey, startTime.Format(time.RFC3339))
// if there was a full scan in progress, force a full scan
if !state.fullScan {
for _, lib := range libs {
if lib.FullScanInProgress {
log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name)
state.fullScan = true
_ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full")
break
}
}
@@ -109,14 +100,11 @@ 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})
}
@@ -127,7 +115,6 @@ 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()

View File

@@ -10,7 +10,6 @@ import (
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
@@ -18,7 +17,6 @@ import (
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server/events"
@@ -49,15 +47,14 @@ var _ = Describe("Scanner", Ordered, func() {
}
BeforeAll(func() {
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
tmpDir := GinkgoT().TempDir()
conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL")
log.Warn("Using DB at " + conf.Server.DbPath)
//conf.Server.DbPath = ":memory:"
db.Db().SetMaxOpenConns(1)
})
BeforeEach(func() {
ctx = context.Background()
db.Init(ctx)
DeferCleanup(func() {
Expect(tests.ClearDB()).To(Succeed())
@@ -504,113 +501,6 @@ var _ = Describe("Scanner", Ordered, func() {
Expect(aa[0].MbzArtistID).To(Equal(beatlesMBID))
Expect(aa[0].SortArtistName).To(Equal("Beatles, The"))
})
Context("When PurgeMissing is configured", func() {
When("PurgeMissing is set to 'never'", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever
})
It("should mark files as missing but not delete them", func() {
By("Running initial scan")
Expect(runScanner(ctx, true)).To(Succeed())
By("Removing a file")
fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
By("Running another scan")
Expect(runScanner(ctx, true)).To(Succeed())
By("Checking files are marked as missing but not deleted")
count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{
Filters: squirrel.Eq{"missing": true},
})
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(1)))
mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(mf.Missing).To(BeTrue())
})
})
When("PurgeMissing is set to 'always'", func() {
BeforeEach(func() {
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways
})
It("should purge missing files on any scan", func() {
By("Running initial scan")
Expect(runScanner(ctx, false)).To(Succeed())
By("Removing a file")
fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
By("Running an incremental scan")
Expect(runScanner(ctx, false)).To(Succeed())
By("Checking missing files are deleted")
count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{
Filters: squirrel.Eq{"missing": true},
})
Expect(err).ToNot(HaveOccurred())
Expect(count).To(BeZero())
_, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
When("PurgeMissing is set to 'full'", func() {
BeforeEach(func() {
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull
})
It("should not purge missing files on incremental scans", func() {
By("Running initial scan")
Expect(runScanner(ctx, true)).To(Succeed())
By("Removing a file")
fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
By("Running an incremental scan")
Expect(runScanner(ctx, false)).To(Succeed())
By("Checking files are marked as missing but not deleted")
count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{
Filters: squirrel.Eq{"missing": true},
})
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(1)))
mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(mf.Missing).To(BeTrue())
})
It("should purge missing files only on full scans", func() {
By("Running initial scan")
Expect(runScanner(ctx, true)).To(Succeed())
By("Removing a file")
fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
By("Running a full scan")
Expect(runScanner(ctx, true)).To(Succeed())
By("Checking missing files are deleted")
count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{
Filters: squirrel.Eq{"missing": true},
})
Expect(err).ToNot(HaveOccurred())
Expect(count).To(BeZero())
_, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
})
})
})

View File

@@ -37,12 +37,9 @@ func (e *baseEvent) Data(evt Event) string {
type ScanStatus struct {
baseEvent
Scanning bool `json:"scanning"`
Count int64 `json:"count"`
FolderCount int64 `json:"folderCount"`
Error string `json:"error"`
ScanType string `json:"scanType"`
ElapsedTime time.Duration `json:"elapsedTime"`
Scanning bool `json:"scanning"`
Count int64 `json:"count"`
FolderCount int64 `json:"folderCount"`
}
type KeepAlive struct {

View File

@@ -63,29 +63,25 @@ func (r *missingRepository) EntityName() string {
}
func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
repo := ds.MediaFile(r.Context())
p := req.Params(r)
ids, _ := p.Strings("id")
err := ds.WithTx(func(tx model.DataStore) error {
if len(ids) == 0 {
_, err := tx.MediaFile(ctx).DeleteAllMissing()
return err
}
return tx.MediaFile(ctx).DeleteMissing(ids)
return repo.DeleteMissing(ids)
})
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Missing file not found", "id", ids[0])
log.Warn(r.Context(), "Missing file not found", "id", ids[0])
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
log.Error(r.Context(), "Error deleting missing tracks from DB", "ids", ids, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = ds.GC(ctx)
err = ds.GC(r.Context())
if err != nil {
log.Error(ctx, "Error running GC after deleting missing tracks", err)
log.Error(r.Context(), "Error running GC after deleting missing tracks", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

View File

@@ -38,7 +38,7 @@ func (api *Router) getArtist(r *http.Request, libId int, ifModifiedSince time.Ti
var indexes model.ArtistIndexes
if lib.LastScanAt.After(ifModifiedSince) {
indexes, err = api.ds.Artist(ctx).GetIndex(false, model.RoleAlbumArtist)
indexes, err = api.ds.Artist(ctx).GetIndex(model.RoleAlbumArtist)
if err != nil {
log.Error(ctx, "Error retrieving Indexes", err)
return nil, 0, err

View File

@@ -108,19 +108,12 @@ func SongsByRandom(genre string, fromYear, toYear int) Options {
return addDefaultFilters(options)
}
func SongWithLyrics(artist, title string) Options {
func SongWithArtistTitle(artist, title string) Options {
return addDefaultFilters(Options{
Sort: "updated_at",
Order: "desc",
Max: 1,
Filters: And{
Eq{"title": title},
NotEq{"lyrics": "[]"},
Or{
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}),
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}),
},
},
Sort: "updated_at",
Order: "desc",
Max: 1,
Filters: And{Eq{"artist": artist, "title": title}},
})
}

View File

@@ -224,7 +224,6 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op
child.BPM = int32(mf.BPM)
child.MediaType = responses.MediaTypeSong
child.MusicBrainzId = mf.MbzRecordingID
child.Isrc = mf.Tags.Values(model.TagISRC)
child.ReplayGain = responses.ReplayGain{
TrackGain: mf.RGTrackGain,
AlbumGain: mf.RGAlbumGain,

View File

@@ -23,9 +23,6 @@ func (api *Router) GetScanStatus(r *http.Request) (*responses.Subsonic, error) {
Count: int64(status.Count),
FolderCount: int64(status.FolderCount),
LastScan: &status.LastScan,
Error: status.LastError,
ScanType: status.ScanType,
ElapsedTime: int64(status.ElapsedTime),
}
return response, nil
}

View File

@@ -98,7 +98,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
response := newResponse()
lyricsResponse := responses.Lyrics{}
response.Lyrics = &lyricsResponse
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithLyrics(artist, title))
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithArtistTitle(artist, title))
if err != nil {
return nil, err

View File

@@ -15,7 +15,6 @@
"sortName": "sort name",
"mediaType": "album",
"musicBrainzId": "00000000-0000-0000-0000-000000000000",
"isrc": [],
"genres": [
{
"name": "Genre 1"

View File

@@ -99,9 +99,6 @@
"sortName": "sorted song",
"mediaType": "song",
"musicBrainzId": "4321",
"isrc": [
"ISRC-1"
],
"genres": [
{
"name": "rock"

View File

@@ -16,7 +16,6 @@
<artists id="1" name="artist1"></artists>
<artists id="2" name="artist2"></artists>
<song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist1 &amp; artist2" displayAlbumArtist="album artist1 &amp; album artist2" displayComposer="composer 1 &amp; 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>

View File

@@ -30,10 +30,6 @@
"sortName": "sorted title",
"mediaType": "song",
"musicBrainzId": "4321",
"isrc": [
"ISRC-1",
"ISRC-2"
],
"genres": [
{
"name": "rock"

View File

@@ -1,8 +1,6 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<directory id="1" name="N">
<child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted title" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist 1 &amp; artist 2" displayAlbumArtist="album artist 1 &amp; album artist 2" displayComposer="composer 1 &amp; 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>

View File

@@ -15,7 +15,6 @@
"sortName": "",
"mediaType": "",
"musicBrainzId": "",
"isrc": [],
"genres": [],
"replayGain": {},
"channelCount": 0,

View File

@@ -176,7 +176,6 @@ type OpenSubsonicChild struct {
SortName string `xml:"sortName,attr,omitempty" json:"sortName"`
MediaType MediaType `xml:"mediaType,attr,omitempty" json:"mediaType"`
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
Isrc Array[string] `xml:"isrc,omitempty" json:"isrc"`
Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"`
ReplayGain ReplayGain `xml:"replayGain,omitempty" json:"replayGain"`
ChannelCount int32 `xml:"channelCount,attr,omitempty" json:"channelCount"`
@@ -477,13 +476,10 @@ type Shares struct {
}
type ScanStatus struct {
Scanning bool `xml:"scanning,attr" json:"scanning"`
Count int64 `xml:"count,attr" json:"count"`
FolderCount int64 `xml:"folderCount,attr" json:"folderCount"`
LastScan *time.Time `xml:"lastScan,attr,omitempty" json:"lastScan,omitempty"`
Error string `xml:"error,attr,omitempty" json:"error,omitempty"`
ScanType string `xml:"scanType,attr,omitempty" json:"scanType,omitempty"`
ElapsedTime int64 `xml:"elapsedTime,attr,omitempty" json:"elapsedTime,omitempty"`
Scanning bool `xml:"scanning,attr" json:"scanning"`
Count int64 `xml:"count,attr" json:"count"`
FolderCount int64 `xml:"folderCount,attr" json:"folderCount"`
LastScan *time.Time `xml:"lastScan,attr,omitempty" json:"lastScan,omitempty"`
}
type Lyrics struct {

View File

@@ -224,8 +224,7 @@ var _ = Describe("Responses", func() {
child[0].OpenSubsonicChild = &OpenSubsonicChild{
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted title",
Isrc: []string{"ISRC-1", "ISRC-2"},
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
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",
@@ -313,7 +312,6 @@ 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,

View File

@@ -22,10 +22,6 @@ 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) {
@@ -165,35 +161,4 @@ 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)

View File

@@ -3,5 +3,4 @@ build/
prettier.config.js
.eslintrc
vite.config.js
public/3rdparty/workbox
coverage/
public/3rdparty/workbox

3300
ui/package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,8 @@
"start": "vite",
"build": "vite build",
"serve": "vite preview",
"test:watch": "vitest",
"test": "vitest --watch=false",
"test": "vitest",
"test:ci": "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.12.4",
"@material-ui/core": "^4.11.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.1.0",
"react-dnd-html5-backend": "^14.0.2",
"react-dom": "^17.0.2",
"react-drag-listview": "^0.1.9",
"react-drag-listview": "^0.1.8",
"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.3.0",
"redux-saga": "^1.1.3",
"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.15.21",
"@types/react": "^17.0.86",
"@types/node": "^22.13.9",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.5.0",
"@vitest/coverage-v8": "^3.1.4",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.8",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.5",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"happy-dom": "^17.4.7",
"jsdom": "^26.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"happy-dom": "^17.4.0",
"jsdom": "^26.0.0",
"prettier": "^3.5.3",
"ra-test": "^3.19.12",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite-plugin-pwa": "^0.21.2",
"vitest": "^3.1.4"
"typescript": "^5.8.2",
"vite": "^6.2.1",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^3.0.8"
},
"overrides": {
"vite": {

View File

@@ -1,12 +1,7 @@
import ReactGA from 'react-ga'
import { Provider } from 'react-redux'
import { createHashHistory } from 'history'
import {
Admin as RAAdmin,
Resource,
useSetLocale,
useRefresh,
} from 'react-admin'
import { Admin as RAAdmin, Resource } from 'react-admin'
import { HotKeys } from 'react-hotkeys'
import dataProvider from './dataProvider'
import authProvider from './authProvider'
@@ -27,7 +22,6 @@ import {
addToPlaylistDialogReducer,
expandInfoDialogReducer,
listenBrainzTokenDialogReducer,
saveQueueDialogReducer,
playerReducer,
albumViewReducer,
activityReducer,
@@ -37,7 +31,7 @@ import {
shareDialogReducer,
} from './reducers'
import createAdminStore from './store/createAdminStore'
import { i18nProvider, retrieveTranslation } from './i18n'
import { i18nProvider } from './i18n'
import config, { shareInfo } from './config'
import { keyMap } from './hotkeys'
import useChangeThemeColor from './useChangeThemeColor'
@@ -45,7 +39,6 @@ import SharePlayer from './share/SharePlayer'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { DndProvider } from 'react-dnd'
import missing from './missing/index.js'
import { useEffect } from 'react'
const history = createHashHistory()
@@ -69,7 +62,6 @@ const adminStore = createAdminStore({
downloadMenuDialog: downloadMenuDialogReducer,
expandInfoDialog: expandInfoDialogReducer,
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
saveQueueDialog: saveQueueDialogReducer,
shareDialog: shareDialogReducer,
activity: activityReducer,
settings: settingsReducer,
@@ -84,24 +76,6 @@ const App = () => (
)
const Admin = (props) => {
const setLocale = useSetLocale()
const refresh = useRefresh()
useEffect(() => {
if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) {
retrieveTranslation(config.defaultLanguage)
.then(() => setLocale(config.defaultLanguage))
.then(() => {
localStorage.setItem('locale', config.defaultLanguage)
refresh(true)
})
.catch((e) => {
// eslint-disable-next-line no-console
console.error(
'Cannot load language "' + config.defaultLanguage + '": ' + e,
)
})
}
}, [setLocale, refresh])
useChangeThemeColor()
/* eslint-disable react/jsx-key */
return (

View File

@@ -8,8 +8,6 @@ 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'
@@ -78,11 +76,3 @@ export const openListenBrainzTokenDialog = () => ({
export const closeListenBrainzTokenDialog = () => ({
type: LISTENBRAINZ_TOKEN_CLOSE,
})
export const openSaveQueueDialog = () => ({
type: SAVE_QUEUE_OPEN,
})
export const closeSaveQueueDialog = () => ({
type: SAVE_QUEUE_CLOSE,
})

View File

@@ -10,7 +10,6 @@ import {
ReferenceArrayInput,
ReferenceInput,
SearchInput,
usePermissions,
useRefresh,
useTranslate,
useVersion,
@@ -45,8 +44,6 @@ 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 />
@@ -156,7 +153,6 @@ const AlbumFilter = (props) => {
defaultValue={true}
/>
)}
{isAdmin && <NullableBooleanInput source="missing" />}
</Filter>
)
}

View File

@@ -9,7 +9,6 @@ import { makeStyles } from '@material-ui/core/styles'
import AlbumSongs from './AlbumSongs'
import AlbumDetails from './AlbumDetails'
import AlbumActions from './AlbumActions'
import { useResourceRefresh } from '../common'
const useStyles = makeStyles(
(theme) => ({
@@ -26,7 +25,6 @@ const AlbumShowLayout = (props) => {
const { loading, ...context } = useShowContext(props)
const { record } = context
const classes = useStyles()
useResourceRefresh('album', 'song')
return (
<>

View File

@@ -251,3 +251,175 @@ 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>
`;

View File

@@ -11,15 +11,12 @@ 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,
@@ -52,9 +49,6 @@ const useStyles = makeStyles({
},
},
},
missingRow: {
opacity: 0.3,
},
contextMenu: {
visibility: 'hidden',
},
@@ -65,8 +59,6 @@ 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({
@@ -89,7 +81,6 @@ const ArtistFilter = (props) => {
defaultValue={true}
/>
)}
{isAdmin && <NullableBooleanInput source="missing" />}
</Filter>
)
}
@@ -104,15 +95,7 @@ const ArtistDatagridRow = (props) => {
}),
[record],
)
const classes = useStyles()
const computedClasses = clsx(
props.className,
classes.row,
record?.missing && classes.missingRow,
)
return (
<DatagridRow ref={dragArtistRef} {...props} className={computedClasses} />
)
return <DatagridRow ref={dragArtistRef} {...props} />
}
const ArtistDatagridBody = (props) => (

View File

@@ -12,7 +12,7 @@ import subsonic from '../subsonic'
import AlbumGridView from '../album/AlbumGridView'
import MobileArtistDetails from './MobileArtistDetails'
import DesktopArtistDetails from './DesktopArtistDetails'
import { useAlbumsPerPage, useResourceRefresh } from '../common/index.js'
import { useAlbumsPerPage } from '../common/index.js'
const ArtistDetails = (props) => {
const record = useRecordContext(props)
@@ -55,7 +55,6 @@ const ArtistShowLayout = (props) => {
const record = useRecordContext()
const { width } = props
const [, perPageOptions] = useAlbumsPerPage(width)
useResourceRefresh('artist', 'album')
const maxPerPage = 90
let perPage = 0

View File

@@ -1,120 +1,32 @@
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 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 Placeholder = () => <LoveButton disabled={true} resource={'song'} />
const PlayerToolbar = ({ id, isRadio }) => {
const dispatch = useDispatch()
const Toolbar = ({ id }) => {
const { data, loading } = useGetOne('song', 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 />
{isDesktop ? (
<li className={`${listItemClass} item`}>
{saveQueueButton}
{loveButton}
</li>
) : (
<>
<li className={`${listItemClass} item`}>{saveQueueButton}</li>
<li className={`${listItemClass} item`}>{loveButton}</li>
</>
)}
<LoveButton
record={data}
resource={'song'}
disabled={loading || toggling}
/>
</>
)
}
const PlayerToolbar = ({ id, isRadio }) =>
id && !isRadio ? <Toolbar id={id} /> : <Placeholder />
export default PlayerToolbar

View File

@@ -1,166 +0,0 @@
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()
})
})
})

View File

@@ -44,9 +44,7 @@ const parseAndReplaceArtists = (
result.push(displayAlbumArtist.slice(lastIndex, index))
}
// Add the artist link
result.push(
<ALink artist={artist} className={className} key={artist.id} />,
)
result.push(<ALink artist={artist} className={className} />)
lastIndex = index + artist.name.length
}
})

View File

@@ -25,10 +25,7 @@ export const CollapsibleComment = ({ record }) => {
const classes = useStyles()
const [expanded, setExpanded] = useState(false)
const lines = useMemo(
() => record.comment?.split('\n') || [],
[record.comment],
)
const lines = record.comment.split('\n')
const formatted = useMemo(() => {
return lines.map((line, idx) => (
<span key={record.id + '-comment-' + idx}>

View File

@@ -23,8 +23,7 @@ const mapResource = (resource, params) => {
return [`playlist/${plsId}/tracks`, params]
}
case 'album':
case 'song':
case 'artist': {
case 'song': {
if (params.filter && !isAdmin()) {
params.filter.missing = false
}
@@ -36,9 +35,9 @@ const mapResource = (resource, params) => {
}
const callDeleteMany = (resource, params) => {
const ids = (params.ids || []).map((id) => `id=${id}`)
const query = ids.length > 0 ? `?${ids.join('&')}` : ''
return httpClient(`${REST_URL}/${resource}${query}`, {
const ids = params.ids.map((id) => `id=${id}`)
const idsParam = ids.join('&')
return httpClient(`${REST_URL}/${resource}?${idsParam}`, {
method: 'DELETE',
}).then((response) => ({ data: response.json.ids || [] }))
}

View File

@@ -2,12 +2,10 @@ import { AddToPlaylistDialog } from './AddToPlaylistDialog'
import DownloadMenuDialog from './DownloadMenuDialog'
import { HelpDialog } from './HelpDialog'
import { ShareDialog } from './ShareDialog'
import { SaveQueueDialog } from './SaveQueueDialog'
export const Dialogs = (props) => (
<>
<AddToPlaylistDialog />
<SaveQueueDialog />
<DownloadMenuDialog />
<HelpDialog />
<ShareDialog />

View File

@@ -1,117 +0,0 @@
import React, { useState, useCallback } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
useDataProvider,
useNotify,
useTranslate,
useRefresh,
} from 'react-admin'
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
CircularProgress,
} from '@material-ui/core'
import { closeSaveQueueDialog } from '../actions'
import { useHistory } from 'react-router-dom'
export const SaveQueueDialog = () => {
const dispatch = useDispatch()
const { open } = useSelector((state) => state.saveQueueDialog)
const queue = useSelector((state) => state.player.queue)
const [name, setName] = useState('')
const dataProvider = useDataProvider()
const notify = useNotify()
const translate = useTranslate()
const history = useHistory()
const [isSaving, setIsSaving] = useState(false)
const refresh = useRefresh()
const handleClose = useCallback(
(e) => {
setName('')
dispatch(closeSaveQueueDialog())
e.stopPropagation()
},
[dispatch],
)
const handleSave = useCallback(() => {
setIsSaving(true)
const ids = queue.map((item) => item.trackId)
dataProvider
.create('playlist', { data: { name } })
.then((res) => {
const playlistId = res.data.id
if (ids.length) {
return dataProvider
.create('playlistTrack', {
data: { ids },
filter: { playlist_id: playlistId },
})
.then(() => res)
}
return res
})
.then((res) => {
notify('ra.notification.created', 'info', { smart_count: 1 })
dispatch(closeSaveQueueDialog())
refresh()
history.push(`/playlist/${res.data.id}/show`)
})
.catch(() => notify('ra.page.error', { type: 'warning' }))
.finally(() => setIsSaving(false))
}, [dataProvider, dispatch, notify, queue, name, history, refresh])
const handleKeyPress = useCallback(
(e) => {
if (e.key === 'Enter' && name.trim() !== '') {
handleSave()
}
},
[handleSave, name],
)
return (
<Dialog
open={open}
onClose={isSaving ? undefined : handleClose}
aria-labelledby="save-queue-dialog"
fullWidth={true}
maxWidth={'sm'}
>
<DialogTitle id="save-queue-dialog">
{translate('resources.playlist.actions.saveQueue', { _: 'Save Queue' })}
</DialogTitle>
<DialogContent>
<TextField
value={name}
onChange={(e) => setName(e.target.value)}
onKeyPress={handleKeyPress}
autoFocus
fullWidth
variant={'outlined'}
label={translate('resources.playlist.fields.name')}
disabled={isSaving}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary" disabled={isSaving}>
{translate('ra.action.cancel')}
</Button>
<Button
onClick={handleSave}
color="primary"
disabled={name.trim() === '' || isSaving}
data-testid="save-queue-save"
startIcon={isSaving ? <CircularProgress size={20} /> : null}
>
{translate('ra.action.save')}
</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -1,91 +0,0 @@
import * as React from 'react'
import { TestContext } from 'ra-test'
import { DataProviderContext } from 'react-admin'
import {
cleanup,
fireEvent,
render,
waitFor,
screen,
} from '@testing-library/react'
import { SaveQueueDialog } from './SaveQueueDialog'
import { describe, afterEach, it, expect, vi, beforeAll } from 'vitest'
const queue = [{ trackId: 'song-1' }, { trackId: 'song-2' }]
const createTestUtils = (mockDataProvider) =>
render(
<DataProviderContext.Provider value={mockDataProvider}>
<TestContext
initialState={{
saveQueueDialog: { open: true },
player: { queue },
admin: { ui: { optimistic: false } },
}}
>
<SaveQueueDialog />
</TestContext>
</DataProviderContext.Provider>,
)
// Mock useHistory to update window.location.hash on push
vi.mock('react-router-dom', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
useHistory: () => ({
push: (url) => {
window.location.hash = `#${url}`
},
}),
}
})
beforeAll(() => {
// No need to patch pushState anymore
})
describe('SaveQueueDialog', () => {
afterEach(cleanup)
it('creates playlist and saves queue', async () => {
const mockDataProvider = {
create: vi
.fn()
.mockResolvedValueOnce({ data: { id: 'created-id' } })
.mockResolvedValueOnce({ data: { id: 'pt-id' } }),
}
createTestUtils(mockDataProvider)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'my playlist' },
})
fireEvent.click(screen.getByTestId('save-queue-save'))
await waitFor(() => {
expect(mockDataProvider.create).toHaveBeenNthCalledWith(1, 'playlist', {
data: { name: 'my playlist' },
})
})
await waitFor(() => {
expect(mockDataProvider.create).toHaveBeenNthCalledWith(
2,
'playlistTrack',
{
data: { ids: ['song-1', 'song-2'] },
filter: { playlist_id: 'created-id' },
},
)
})
await waitFor(() => {
expect(window.location.hash).toBe('#/playlist/created-id/show')
})
})
it('disables save button when name is empty', () => {
const mockDataProvider = { create: vi.fn() }
createTestUtils(mockDataProvider)
expect(screen.getByTestId('save-queue-save')).toBeDisabled()
})
})

View File

@@ -1,5 +1,4 @@
export * from './AboutDialog'
export * from './SelectPlaylistInput'
export * from './ListenBrainzTokenDialog'
export * from './SaveQueueDialog'
export * from './Dialogs'

View File

@@ -34,8 +34,7 @@
"participants": "Additional participants",
"tags": "Additional Tags",
"mappedTags": "Mapped tags",
"rawTags": "Raw tags",
"missing": "Missing"
"rawTags": "Raw tags"
},
"actions": {
"addToQueue": "Play Later",
@@ -74,8 +73,7 @@
"releaseType": "Type",
"grouping": "Grouping",
"media": "Media",
"mood": "Mood",
"missing": "Missing"
"mood": "Mood"
},
"actions": {
"playAll": "Play",
@@ -107,8 +105,7 @@
"playCount": "Plays",
"rating": "Rating",
"genre": "Genre",
"role": "Role",
"missing": "Missing"
"role": "Role"
},
"roles": {
"albumartist": "Album Artist |||| Album Artists",
@@ -195,7 +192,6 @@
"selectPlaylist": "Select a playlist:",
"addNewPlaylist": "Create \"%{name}\"",
"export": "Export",
"saveQueue": "Save Queue to Playlist",
"makePublic": "Make Public",
"makePrivate": "Make Private"
},
@@ -237,7 +233,7 @@
"actions": {}
},
"missing": {
"name": "Missing File |||| Missing Files",
"name": "Missing File|||| Missing Files",
"empty": "No Missing Files",
"fields": {
"path": "Path",
@@ -245,8 +241,7 @@
"updatedAt": "Disappeared on"
},
"actions": {
"remove": "Remove",
"remove_all": "Remove All"
"remove": "Remove"
},
"notifications": {
"removed": "Missing file(s) removed"
@@ -408,8 +403,6 @@
"delete_user_content": "Are you sure you want to delete this user and all their data (including playlists and preferences)?",
"remove_missing_title": "Remove missing files",
"remove_missing_content": "Are you sure you want to remove the selected missing files from the database? This will remove permanently any references to them, including their play counts and ratings.",
"remove_all_missing_title": "Remove all missing files",
"remove_all_missing_content": "Are you sure you want to remove all missing files from the database? This will permanently remove any references to them, including their play counts and ratings.",
"notifications_blocked": "You have blocked Notifications for this site in your browser's settings",
"notifications_not_available": "This browser does not support desktop notifications or you are not accessing Navidrome over https",
"lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled",
@@ -506,10 +499,7 @@
"quickScan": "Quick Scan",
"fullScan": "Full Scan",
"serverUptime": "Server Uptime",
"serverDown": "OFFLINE",
"scanType": "Type",
"status": "Scan Error",
"elapsedTime": "Elapsed Time"
"serverDown": "OFFLINE"
},
"help": {
"title": "Navidrome Hotkeys",

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { useSelector } from 'react-redux'
import { useDispatch, useSelector } from 'react-redux'
import { useNotify, useTranslate } from 'react-admin'
import {
Popover,
@@ -12,17 +12,15 @@ import {
CardActions,
Divider,
Box,
Typography,
} from '@material-ui/core'
import { FiActivity } from 'react-icons/fi'
import { BiError } from 'react-icons/bi'
import { VscSync } from 'react-icons/vsc'
import { GiMagnifyingGlass } from 'react-icons/gi'
import subsonic from '../subsonic'
import { useInitialScanStatus } from './useInitialScanStatus'
import { scanStatusUpdate } from '../actions'
import { useInterval } from '../common'
import { useScanElapsedTime } from './useScanElapsedTime'
import { formatDuration, formatShortDuration } from '../utils'
import { formatDuration } from '../utils'
import config from '../config'
const useStyles = makeStyles((theme) => ({
@@ -42,16 +40,7 @@ const useStyles = makeStyles((theme) => ({
zIndex: 2,
},
counterStatus: {
minWidth: '20em',
},
error: {
color: theme.palette.error.main,
},
card: {
maxWidth: 'none',
},
cardContent: {
padding: theme.spacing(2, 3),
minWidth: '15em',
},
}))
@@ -70,52 +59,41 @@ const Uptime = () => {
const ActivityPanel = () => {
const serverStart = useSelector((state) => state.activity.serverStart)
const up = serverStart.startTime
const scanStatus = useSelector((state) => state.activity.scanStatus)
const elapsed = useScanElapsedTime(
scanStatus.scanning,
scanStatus.elapsedTime,
)
const classes = useStyles({ up: up && !scanStatus.error })
const classes = useStyles({ up })
const translate = useTranslate()
const notify = useNotify()
const [anchorEl, setAnchorEl] = useState(null)
const open = Boolean(anchorEl)
useInitialScanStatus()
const dispatch = useDispatch()
const scanStatus = useSelector((state) => state.activity.scanStatus)
const handleMenuOpen = (event) => setAnchorEl(event.currentTarget)
const handleMenuClose = () => setAnchorEl(null)
const triggerScan = (full) => () => subsonic.startScan({ fullScan: full })
// Get updated status on component mount
useEffect(() => {
subsonic
.getScanStatus()
.then((resp) => resp.json['subsonic-response'])
.then((data) => {
if (data.status === 'ok') {
dispatch(scanStatusUpdate(data.scanStatus))
}
})
}, [dispatch])
useEffect(() => {
if (serverStart.version && serverStart.version !== config.version) {
notify('ra.notification.new_version', 'info', {}, false, 604800000 * 50)
}
}, [serverStart, notify])
const tooltipTitle = scanStatus.error
? `${translate('activity.status')}: ${scanStatus.error}`
: translate('activity.title')
const lastScanType = (() => {
switch (scanStatus.scanType) {
case 'full':
return translate('activity.fullScan')
case 'quick':
return translate('activity.quickScan')
default:
return ''
}
})()
return (
<div className={classes.wrapper}>
<Tooltip title={tooltipTitle}>
<Tooltip title={translate('activity.title')}>
<IconButton className={classes.button} onClick={handleMenuOpen}>
{!up || scanStatus.error ? (
<BiError size={'20'} />
) : (
<FiActivity size={'20'} />
)}
{up ? <FiActivity size={'20'} /> : <BiError size={'20'} />}
</IconButton>
</Tooltip>
{scanStatus.scanning && (
@@ -135,8 +113,8 @@ const ActivityPanel = () => {
open={open}
onClose={handleMenuClose}
>
<Card className={classes.card}>
<CardContent className={classes.cardContent}>
<Card>
<CardContent>
<Box display="flex" className={classes.counterStatus}>
<Box component="span" flex={2}>
{translate('activity.serverUptime')}:
@@ -147,7 +125,7 @@ const ActivityPanel = () => {
</Box>
</CardContent>
<Divider />
<CardContent className={classes.cardContent}>
<CardContent>
<Box display="flex" className={classes.counterStatus}>
<Box component="span" flex={2}>
{translate('activity.totalScanned')}:
@@ -156,38 +134,6 @@ const ActivityPanel = () => {
{scanStatus.folderCount || '-'}
</Box>
</Box>
<Box display="flex" className={classes.counterStatus} mt={2}>
<Box component="span" flex={2}>
{translate('activity.scanType')}:
</Box>
<Box component="span" flex={1}>
{lastScanType}
</Box>
</Box>
<Box display="flex" className={classes.counterStatus} mt={2}>
<Box component="span" flex={2}>
{translate('activity.elapsedTime')}:
</Box>
<Box component="span" flex={1}>
{formatShortDuration(elapsed)}
</Box>
</Box>
{scanStatus.error && (
<Box
display="flex"
flexDirection="column"
mt={2}
className={classes.error}
>
<Typography variant="subtitle2">
{translate('activity.status')}:
</Typography>
<Typography variant="body2">{scanStatus.error}</Typography>
</Box>
)}
</CardContent>
<Divider />
<CardActions>

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react'
import React, { useState, useCallback, useEffect } from 'react'
import PropTypes from 'prop-types'
import { Field, Form } from 'react-final-form'
import { useDispatch } from 'react-redux'
@@ -13,6 +13,8 @@ import {
createMuiTheme,
useLogin,
useNotify,
useRefresh,
useSetLocale,
useTranslate,
useVersion,
} from 'react-admin'
@@ -22,6 +24,7 @@ import Notification from './Notification'
import useCurrentTheme from '../themes/useCurrentTheme'
import config from '../config'
import { clearQueue } from '../actions'
import { retrieveTranslation } from '../i18n'
import { INSIGHTS_DOC_URL } from '../consts.js'
const useStyles = makeStyles(
@@ -397,8 +400,27 @@ Login.propTypes = {
// the right theme
const LoginWithTheme = (props) => {
const theme = useCurrentTheme()
const setLocale = useSetLocale()
const refresh = useRefresh()
const version = useVersion()
useEffect(() => {
if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) {
retrieveTranslation(config.defaultLanguage)
.then(() => {
setLocale(config.defaultLanguage).then(() => {
localStorage.setItem('locale', config.defaultLanguage)
})
refresh(true)
})
.catch((e) => {
throw new Error(
'Cannot load language "' + config.defaultLanguage + '": ' + e,
)
})
}
}, [refresh, setLocale])
return (
<ThemeProvider theme={createMuiTheme(theme)}>
<Login key={version} {...props} />

View File

@@ -1,18 +0,0 @@
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import subsonic from '../subsonic'
import { scanStatusUpdate } from '../actions'
export const useInitialScanStatus = () => {
const dispatch = useDispatch()
useEffect(() => {
subsonic
.getScanStatus()
.then((resp) => resp.json['subsonic-response'])
.then((data) => {
if (data.status === 'ok') {
dispatch(scanStatusUpdate(data.scanStatus))
}
})
}, [dispatch])
}

View File

@@ -1,26 +0,0 @@
import { useEffect, useState, useRef } from 'react'
import { useInterval } from '../common'
export const useScanElapsedTime = (scanning, elapsedTime) => {
const [elapsed, setElapsed] = useState(Number(elapsedTime) || 0)
const prevScanningRef = useRef(scanning)
useEffect(() => {
const prevScanning = prevScanningRef.current
const serverElapsed = Number(elapsedTime) || 0
if (scanning !== prevScanning) {
// Scan has just started or stopped - sync with server value
setElapsed(serverElapsed)
} else if (!scanning) {
// Not scanning -> always reflect server value (initial load or after finish)
setElapsed(serverElapsed)
}
prevScanningRef.current = scanning
}, [scanning, elapsedTime])
useInterval(() => setElapsed((prev) => prev + 1e9), scanning ? 1000 : null)
return elapsed
}

View File

@@ -1,135 +0,0 @@
import { renderHook, act } from '@testing-library/react-hooks'
import { vi } from 'vitest'
import { useScanElapsedTime } from './useScanElapsedTime'
describe('useScanElapsedTime', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('increments elapsed time while scanning', () => {
const { result } = renderHook(
({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed),
{
initialProps: { scanning: true, elapsed: 0 },
},
)
act(() => {
vi.advanceTimersByTime(3000)
})
expect(result.current).toBe(3e9)
})
it('stops incrementing when not scanning', () => {
const { result, rerender } = renderHook(
({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed),
{
initialProps: { scanning: false, elapsed: 2e9 },
},
)
act(() => {
vi.advanceTimersByTime(2000)
})
expect(result.current).toBe(2e9)
rerender({ scanning: true, elapsed: 2e9 })
act(() => {
vi.advanceTimersByTime(1000)
})
expect(result.current).toBe(3e9)
})
it('initializes with server value when scan starts', () => {
const { result, rerender } = renderHook(
({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed),
{
initialProps: { scanning: false, elapsed: 5e9 },
},
)
// Start scanning with a new elapsed time from server
rerender({ scanning: true, elapsed: 10e9 })
// Should use the server value when starting
expect(result.current).toBe(10e9)
act(() => {
vi.advanceTimersByTime(2000)
})
// Should continue from server value
expect(result.current).toBe(12e9)
})
it('updates elapsed time when not scanning and server value changes', () => {
const { result, rerender } = renderHook(
({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed),
{
initialProps: { scanning: false, elapsed: 0 },
},
)
// Server reports new elapsed time without changing scanning state
rerender({ scanning: false, elapsed: 8e9 })
expect(result.current).toBe(8e9)
})
it('ignores server updates during scanning', () => {
const { result, rerender } = renderHook(
({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed),
{
initialProps: { scanning: true, elapsed: 0 },
},
)
act(() => {
vi.advanceTimersByTime(3000)
})
expect(result.current).toBe(3e9)
// Server sends updated elapsed time during scan
rerender({ scanning: true, elapsed: 10e9 })
// Should ignore server update while scanning
expect(result.current).toBe(3e9)
act(() => {
vi.advanceTimersByTime(1000)
})
// Should continue from local timer
expect(result.current).toBe(4e9)
})
it('uses final server value when scan ends', () => {
const { result, rerender } = renderHook(
({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed),
{
initialProps: { scanning: true, elapsed: 0 },
},
)
act(() => {
vi.advanceTimersByTime(3000)
})
expect(result.current).toBe(3e9)
// Scan ends with final server value
rerender({ scanning: false, elapsed: 5e9 })
// Should use the final server value
expect(result.current).toBe(5e9)
})
})

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import DeleteIcon from '@material-ui/icons/Delete'
import { makeStyles, alpha } from '@material-ui/core/styles'
import { makeStyles } from '@material-ui/core/styles'
import { fade } from '@material-ui/core/styles/colorManipulator'
import clsx from 'clsx'
import {
Button,
@@ -16,7 +17,7 @@ const useStyles = makeStyles(
deleteButton: {
color: theme.palette.error.main,
'&:hover': {
backgroundColor: alpha(theme.palette.error.main, 0.12),
backgroundColor: fade(theme.palette.error.main, 0.12),
// Reset on mouse devices
'@media (hover: none)': {
backgroundColor: 'transparent',
@@ -28,14 +29,13 @@ const useStyles = makeStyles(
)
const DeleteMissingFilesButton = (props) => {
const { selectedIds, className, deleteAll = false } = props
const { selectedIds, className } = props
const [open, setOpen] = useState(false)
const unselectAll = useUnselectAll()
const refresh = useRefresh()
const notify = useNotify()
const ids = deleteAll ? [] : selectedIds
const [deleteMany, { loading }] = useDeleteMany('missing', ids, {
const [deleteMany, { loading }] = useDeleteMany('missing', selectedIds, {
onSuccess: () => {
notify('resources.missing.notifications.removed')
refresh()
@@ -57,11 +57,7 @@ const DeleteMissingFilesButton = (props) => {
<>
<Button
onClick={handleClick}
label={
deleteAll
? 'resources.missing.actions.remove_all'
: 'ra.action.remove'
}
label="ra.action.remove"
key="button"
className={clsx('ra-delete-button', classes.deleteButton, className)}
>
@@ -70,16 +66,8 @@ const DeleteMissingFilesButton = (props) => {
<Confirm
isOpen={open}
loading={loading}
title={
deleteAll
? 'message.remove_all_missing_title'
: 'message.remove_missing_title'
}
content={
deleteAll
? 'message.remove_all_missing_content'
: 'message.remove_missing_content'
}
title="message.remove_missing_title"
content="message.remove_missing_content"
onConfirm={handleConfirm}
onClose={handleDialogClose}
/>

View File

@@ -1,42 +0,0 @@
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx'
import * as RA from 'react-admin'
vi.mock('react-admin', async () => {
const actual = await vi.importActual('react-admin')
return {
...actual,
Button: ({ children, onClick, label }) => (
<button onClick={onClick}>{label || children}</button>
),
Confirm: ({ isOpen }) => (isOpen ? <div data-testid="confirm" /> : null),
useNotify: vi.fn(),
useDeleteMany: vi.fn(() => [vi.fn(), { loading: false }]),
useRefresh: vi.fn(),
useUnselectAll: vi.fn(),
}
})
describe('DeleteMissingFilesButton', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('uses remove_all label when deleteAll is true', () => {
const { getByRole } = render(<DeleteMissingFilesButton deleteAll />)
expect(getByRole('button').textContent).toBe(
'resources.missing.actions.remove_all',
)
})
it('calls useDeleteMany with empty ids when deleteAll is true', () => {
render(<DeleteMissingFilesButton deleteAll />)
expect(RA.useDeleteMany).toHaveBeenCalledWith(
'missing',
[],
expect.any(Object),
)
})
})

View File

@@ -1,4 +1,4 @@
import { List, SizeField, useResourceRefresh } from '../common/index'
import { List, SizeField } from '../common/index'
import {
Datagrid,
DateField,
@@ -8,7 +8,6 @@ import {
} from 'react-admin'
import jsonExport from 'jsonexport/dist'
import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx'
import MissingListActions from './MissingListActions.jsx'
const exporter = (files) => {
const filesToExport = files.map((file) => {
@@ -31,13 +30,11 @@ const MissingPagination = (props) => (
)
const MissingFilesList = (props) => {
useResourceRefresh('song')
return (
<List
{...props}
sort={{ field: 'updated_at', order: 'DESC' }}
exporter={exporter}
actions={<MissingListActions />}
bulkActionButtons={<BulkActionButtons />}
perPage={50}
pagination={<MissingPagination />}

View File

@@ -1,12 +0,0 @@
import React from 'react'
import { TopToolbar, ExportButton } from 'react-admin'
import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx'
const MissingListActions = (props) => (
<TopToolbar {...props}>
<ExportButton />
<DeleteMissingFilesButton deleteAll />
</TopToolbar>
)
export default MissingListActions

View File

@@ -10,7 +10,7 @@ import { makeStyles } from '@material-ui/core/styles'
import PlaylistDetails from './PlaylistDetails'
import PlaylistSongs from './PlaylistSongs'
import PlaylistActions from './PlaylistActions'
import { Title, canChangeTracks, useResourceRefresh } from '../common'
import { Title, canChangeTracks } from '../common'
const useStyles = makeStyles(
(theme) => ({
@@ -27,7 +27,6 @@ const PlaylistShowLayout = (props) => {
const { loading, ...context } = useShowContext(props)
const { record } = context
const classes = useStyles()
useResourceRefresh('song')
return (
<>

View File

@@ -6,24 +6,15 @@ import {
import config from '../config'
const initialState = {
scanStatus: {
scanning: false,
folderCount: 0,
count: 0,
error: '',
elapsedTime: 0,
},
scanStatus: { scanning: false, folderCount: 0, count: 0 },
serverStart: { version: config.version },
}
export const activityReducer = (previousState = initialState, payload) => {
const { type, data } = payload
switch (type) {
case EVENT_SCAN_STATUS: {
const elapsedTime = Number(data.elapsedTime) || 0
return { ...previousState, scanStatus: { ...data, elapsedTime } }
}
case EVENT_SCAN_STATUS:
return { ...previousState, scanStatus: data }
case EVENT_SERVER_START:
return {
...previousState,

View File

@@ -1,119 +0,0 @@
import { activityReducer } from './activityReducer'
import { EVENT_SCAN_STATUS, EVENT_SERVER_START } from '../actions'
import config from '../config'
describe('activityReducer', () => {
const initialState = {
scanStatus: {
scanning: false,
folderCount: 0,
count: 0,
error: '',
elapsedTime: 0,
},
serverStart: { version: config.version },
}
it('returns the initial state when no action is specified', () => {
expect(activityReducer(undefined, {})).toEqual(initialState)
})
it('handles EVENT_SCAN_STATUS action with elapsedTime field', () => {
const elapsedTime = 123456789 // nanoseconds
const action = {
type: EVENT_SCAN_STATUS,
data: {
scanning: true,
folderCount: 5,
count: 100,
error: '',
elapsedTime: elapsedTime,
},
}
const newState = activityReducer(initialState, action)
expect(newState.scanStatus).toEqual({
scanning: true,
folderCount: 5,
count: 100,
error: '',
elapsedTime: elapsedTime,
})
})
it('handles EVENT_SCAN_STATUS action with string elapsedTime', () => {
const action = {
type: EVENT_SCAN_STATUS,
data: {
scanning: true,
folderCount: 5,
count: 100,
error: '',
elapsedTime: '123456789',
},
}
const newState = activityReducer(initialState, action)
expect(newState.scanStatus.elapsedTime).toEqual(123456789)
})
it('handles EVENT_SCAN_STATUS with error field', () => {
const action = {
type: EVENT_SCAN_STATUS,
data: {
scanning: false,
folderCount: 0,
count: 0,
error: 'Test error message',
elapsedTime: 0,
},
}
const newState = activityReducer(initialState, action)
expect(newState.scanStatus.error).toEqual('Test error message')
})
it('handles EVENT_SERVER_START action', () => {
const action = {
type: EVENT_SERVER_START,
data: {
version: '1.0.0',
startTime: '2023-01-01T00:00:00Z',
},
}
const newState = activityReducer(initialState, action)
expect(newState.serverStart).toEqual({
version: '1.0.0',
startTime: Date.parse('2023-01-01T00:00:00Z'),
})
})
it('preserves the scanStatus when handling EVENT_SERVER_START', () => {
const currentState = {
scanStatus: {
scanning: true,
folderCount: 5,
count: 100,
error: 'Previous error',
elapsedTime: 12345,
},
serverStart: { version: config.version },
}
const action = {
type: EVENT_SERVER_START,
data: {
version: '1.0.0',
startTime: '2023-01-01T00:00:00Z',
},
}
const newState = activityReducer(currentState, action)
expect(newState.scanStatus).toEqual(currentState.scanStatus)
expect(newState.serverStart).toEqual({
version: '1.0.0',
startTime: Date.parse('2023-01-01T00:00:00Z'),
})
})
})

View File

@@ -13,8 +13,6 @@ import {
EXTENDED_INFO_CLOSE,
LISTENBRAINZ_TOKEN_OPEN,
LISTENBRAINZ_TOKEN_CLOSE,
SAVE_QUEUE_OPEN,
SAVE_QUEUE_CLOSE,
SHARE_MENU_OPEN,
SHARE_MENU_CLOSE,
} from '../actions'
@@ -171,18 +169,3 @@ export const listenBrainzTokenDialogReducer = (
return previousState
}
}
export const saveQueueDialogReducer = (
previousState = { open: false },
payload,
) => {
const { type } = payload
switch (type) {
case SAVE_QUEUE_OPEN:
return { ...previousState, open: true }
case SAVE_QUEUE_CLOSE:
return { ...previousState, open: false }
default:
return previousState
}
}

View File

@@ -8,8 +8,6 @@ import {
SearchInput,
TextField,
useTranslate,
NullableBooleanInput,
usePermissions,
} from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import FavoriteIcon from '@material-ui/icons/Favorite'
@@ -69,8 +67,6 @@ const useStyles = makeStyles({
const SongFilter = (props) => {
const classes = useStyles()
const translate = useTranslate()
const { permissions } = usePermissions()
const isAdmin = permissions === 'admin'
return (
<Filter {...props} variant={'outlined'}>
<SearchInput source="title" alwaysOn />
@@ -125,7 +121,6 @@ const SongFilter = (props) => {
defaultValue={true}
/>
)}
{isAdmin && <NullableBooleanInput source="missing" />}
</Filter>
)
}

View File

@@ -24,7 +24,6 @@ export const SongListActions = ({
const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
<ShuffleAllButton filters={filterValues} />
{filters &&
cloneElement(filters, {
resource,
@@ -33,6 +32,7 @@ export const SongListActions = ({
filterValues,
context: 'button',
})}
<ShuffleAllButton filters={filterValues} />
{isNotSmall && <ToggleFieldsMenu resource="song" />}
</TopToolbar>
)

View File

@@ -1,6 +1,7 @@
import React from 'react'
import DeleteIcon from '@material-ui/icons/Delete'
import { makeStyles, alpha } from '@material-ui/core/styles'
import { makeStyles } from '@material-ui/core/styles'
import { fade } from '@material-ui/core/styles/colorManipulator'
import clsx from 'clsx'
import {
useDeleteWithConfirmController,
@@ -15,7 +16,7 @@ const useStyles = makeStyles(
deleteButton: {
color: theme.palette.error.main,
'&:hover': {
backgroundColor: alpha(theme.palette.error.main, 0.12),
backgroundColor: fade(theme.palette.error.main, 0.12),
// Reset on mouse devices
'@media (hover: none)': {
backgroundColor: 'transparent',

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