mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-31 19:08:06 -05:00
Compare commits
44 Commits
codex
...
fix/defaul
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cb851f2a8 | ||
|
|
5e2db2c673 | ||
|
|
fac9275c27 | ||
|
|
6b3afc03cc | ||
|
|
35599230ff | ||
|
|
13ea00e7f8 | ||
|
|
f7fb77054f | ||
|
|
441c9f52cc | ||
|
|
b722f0dcfc | ||
|
|
c98e4d02cb | ||
|
|
5ade9344ff | ||
|
|
d903d3f1e0 | ||
|
|
6bf6424864 | ||
|
|
a9f93c97e1 | ||
|
|
3350e6c115 | ||
|
|
514aceb785 | ||
|
|
370f8ba293 | ||
|
|
1e4c759d93 | ||
|
|
e06fbd26b7 | ||
|
|
9062f4824e | ||
|
|
2503d2dbb8 | ||
|
|
45188e710c | ||
|
|
9dd050c377 | ||
|
|
3ccc02f375 | ||
|
|
992c78376c | ||
|
|
4a2412eef7 | ||
|
|
98fdc42d09 | ||
|
|
eb944bd261 | ||
|
|
84384006a4 | ||
|
|
e5438552c6 | ||
|
|
6ac3acaaf8 | ||
|
|
3953e3217d | ||
|
|
6731787053 | ||
|
|
dd1d3907b4 | ||
|
|
924354eb4b | ||
|
|
6880cffd16 | ||
|
|
fef1739c1a | ||
|
|
453630d430 | ||
|
|
4733616d90 | ||
|
|
ba7fd13724 | ||
|
|
5c18951e31 | ||
|
|
2b744c878e | ||
|
|
4548e75d49 | ||
|
|
841af03393 |
53
.github/copilot-instructions.md
vendored
Normal file
53
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
# Navidrome Code Guidelines
|
||||
|
||||
This is a music streaming server written in Go with a React frontend. The application manages music libraries, provides streaming capabilities, and offers various features like artist information, artwork handling, and external service integrations.
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Backend (Go)
|
||||
- Follow standard Go conventions and idioms
|
||||
- Use context propagation for cancellation signals
|
||||
- Write unit tests for new functionality using Ginkgo/Gomega
|
||||
- Use mutex appropriately for concurrent operations
|
||||
- Implement interfaces for dependencies to facilitate testing
|
||||
|
||||
### Frontend (React)
|
||||
- Use functional components with hooks
|
||||
- Follow React best practices for state management
|
||||
- Implement PropTypes for component properties
|
||||
- Prefer using React-Admin and Material-UI components
|
||||
- Icons should be imported from `react-icons` only
|
||||
- Follow existing patterns for API interaction
|
||||
|
||||
## Repository Structure
|
||||
- `core/`: Server-side business logic (artwork handling, playback, etc.)
|
||||
- `ui/`: React frontend components
|
||||
- `model/`: Data models and repository interfaces
|
||||
- `server/`: API endpoints and server implementation
|
||||
- `utils/`: Shared utility functions
|
||||
- `persistence/`: Database access layer
|
||||
- `scanner/`: Music library scanning functionality
|
||||
|
||||
## Key Guidelines
|
||||
1. Maintain cache management patterns for performance
|
||||
2. Follow the existing concurrency patterns (mutex, atomic)
|
||||
3. Use the testing framework appropriately (Ginkgo/Gomega for Go)
|
||||
4. Keep UI components focused and reusable
|
||||
5. Document configuration options in code
|
||||
6. Consider performance implications when working with music libraries
|
||||
7. Follow existing error handling patterns
|
||||
8. Ensure compatibility with external services (LastFM, Spotify)
|
||||
|
||||
## Development Workflow
|
||||
- Test changes thoroughly, especially around concurrent operations
|
||||
- Validate both backend and frontend interactions
|
||||
- Consider how changes will affect user experience and performance
|
||||
- Test with different music library sizes and configurations
|
||||
- Before committing, ALWAYS run `make format lint test`, and make sure there are no issues
|
||||
|
||||
## Important commands
|
||||
- `make build`: Build the application
|
||||
- `make test`: Run Go tests
|
||||
- To run tests for a specific package, use `make test PKG=./pkgname/...`
|
||||
- `make lintall`: Run linters
|
||||
- `make format`: Format code
|
||||
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
version: latest
|
||||
problem-matchers: true
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -24,4 +24,6 @@ docker-compose.yml
|
||||
!contrib/docker-compose.yml
|
||||
binaries
|
||||
navidrome-master
|
||||
*.exe
|
||||
AGENTS.md
|
||||
*.exe
|
||||
bin/
|
||||
10
Makefile
10
Makefile
@@ -46,11 +46,15 @@ testrace: ##@Development Run Go tests with race detector
|
||||
.PHONY: test
|
||||
|
||||
testall: testrace ##@Development Run Go and JS tests
|
||||
@(cd ./ui && npm run test:ci)
|
||||
@(cd ./ui && npm run test)
|
||||
.PHONY: testall
|
||||
|
||||
lint: ##@Development Lint Go code
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run -v --timeout 5m
|
||||
install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||
@PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.1.6)
|
||||
.PHONY: install-golangci-lint
|
||||
|
||||
lint: install-golangci-lint ##@Development Lint Go code
|
||||
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
|
||||
.PHONY: lint
|
||||
|
||||
lintall: lint ##@Development Lint Go and JS code
|
||||
|
||||
@@ -72,6 +72,7 @@ type configOptions struct {
|
||||
EnableUserEditing bool
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultShareExpiration time.Duration
|
||||
DefaultDownloadableShare bool
|
||||
DefaultTheme string
|
||||
DefaultLanguage string
|
||||
@@ -133,6 +134,7 @@ type scannerOptions struct {
|
||||
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
|
||||
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
|
||||
FollowSymlinks bool // Whether to follow symlinks when scanning directories
|
||||
PurgeMissing string // Values: "never", "always", "full"
|
||||
}
|
||||
|
||||
type subsonicOptions struct {
|
||||
@@ -276,6 +278,7 @@ func Load(noConfigDump bool) {
|
||||
validateScanSchedule,
|
||||
validateBackupSchedule,
|
||||
validatePlaylistsPath,
|
||||
validatePurgeMissingOption,
|
||||
)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
@@ -381,6 +384,24 @@ func validatePlaylistsPath() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePurgeMissingOption() error {
|
||||
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
|
||||
valid := false
|
||||
for _, v := range allowedValues {
|
||||
if v == Server.Scanner.PurgeMissing {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
err := fmt.Errorf("Invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
|
||||
log.Error(err.Error())
|
||||
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateScanSchedule() error {
|
||||
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
|
||||
Server.Scanner.Schedule = ""
|
||||
@@ -420,7 +441,7 @@ func AddHook(hook func()) {
|
||||
hooks = append(hooks, hook)
|
||||
}
|
||||
|
||||
func init() {
|
||||
func setViperDefaults() {
|
||||
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
|
||||
viper.SetDefault("cachefolder", "")
|
||||
viper.SetDefault("datafolder", ".")
|
||||
@@ -457,7 +478,6 @@ func init() {
|
||||
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
||||
viper.SetDefault("ffmpegpath", "")
|
||||
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s")
|
||||
|
||||
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||
viper.SetDefault("coverjpegquality", 75)
|
||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||
@@ -472,6 +492,7 @@ func init() {
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("enablesharing", false)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||
viper.SetDefault("defaultdownloadableshare", false)
|
||||
viper.SetDefault("gatrackingid", "")
|
||||
viper.SetDefault("enableinsightscollector", true)
|
||||
@@ -479,19 +500,15 @@ func init() {
|
||||
viper.SetDefault("authrequestlimit", 5)
|
||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||
viper.SetDefault("passwordencryptionkey", "")
|
||||
|
||||
viper.SetDefault("reverseproxyuserheader", "Remote-User")
|
||||
viper.SetDefault("reverseproxywhitelist", "")
|
||||
|
||||
viper.SetDefault("prometheus.enabled", false)
|
||||
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
|
||||
viper.SetDefault("prometheus.password", "")
|
||||
|
||||
viper.SetDefault("jukebox.enabled", false)
|
||||
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
|
||||
viper.SetDefault("jukebox.default", "")
|
||||
viper.SetDefault("jukebox.adminonly", true)
|
||||
|
||||
viper.SetDefault("scanner.enabled", true)
|
||||
viper.SetDefault("scanner.schedule", "0")
|
||||
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
|
||||
@@ -501,12 +518,11 @@ func init() {
|
||||
viper.SetDefault("scanner.genreseparators", "")
|
||||
viper.SetDefault("scanner.groupalbumreleases", false)
|
||||
viper.SetDefault("scanner.followsymlinks", true)
|
||||
|
||||
viper.SetDefault("scanner.purgemissing", "never")
|
||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||
viper.SetDefault("subsonic.artistparticipations", false)
|
||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub")
|
||||
|
||||
viper.SetDefault("agents", "lastfm,spotify")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", "en")
|
||||
@@ -516,24 +532,17 @@ func init() {
|
||||
viper.SetDefault("spotify.secret", "")
|
||||
viper.SetDefault("listenbrainz.enabled", true)
|
||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
||||
|
||||
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
|
||||
|
||||
viper.SetDefault("backup.path", "")
|
||||
viper.SetDefault("backup.schedule", "")
|
||||
viper.SetDefault("backup.count", 0)
|
||||
|
||||
viper.SetDefault("pid.track", consts.DefaultTrackPID)
|
||||
viper.SetDefault("pid.album", consts.DefaultAlbumPID)
|
||||
|
||||
viper.SetDefault("inspect.enabled", true)
|
||||
viper.SetDefault("inspect.maxrequests", 1)
|
||||
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
|
||||
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
viper.SetDefault("devenableprofiler", false)
|
||||
viper.SetDefault("devautocreateadminpassword", "")
|
||||
@@ -554,6 +563,10 @@ func init() {
|
||||
viper.SetDefault("devenableplayerinsights", true)
|
||||
}
|
||||
|
||||
func init() {
|
||||
setViperDefaults()
|
||||
}
|
||||
|
||||
func InitConfig(cfgFile string) {
|
||||
codecRegistry := viper.NewCodecRegistry()
|
||||
_ = codecRegistry.RegisterCodec("ini", ini.Codec{})
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
. "github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/spf13/viper"
|
||||
@@ -20,9 +20,10 @@ var _ = Describe("Configuration", func() {
|
||||
BeforeEach(func() {
|
||||
// Reset viper configuration
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
ResetConf()
|
||||
conf.ResetConf()
|
||||
})
|
||||
|
||||
DescribeTable("should load configuration from",
|
||||
@@ -30,17 +31,17 @@ var _ = Describe("Configuration", func() {
|
||||
filename := filepath.Join("testdata", "cfg."+format)
|
||||
|
||||
// Initialize config with the test file
|
||||
InitConfig(filename)
|
||||
conf.InitConfig(filename)
|
||||
// Load the configuration (with noConfigDump=true)
|
||||
Load(true)
|
||||
conf.Load(true)
|
||||
|
||||
// Execute the format-specific assertions
|
||||
Expect(Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
|
||||
Expect(Server.UIWelcomeMessage).To(Equal("Welcome " + format))
|
||||
Expect(Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
|
||||
Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
|
||||
Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format))
|
||||
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
|
||||
|
||||
// The config file used should be the one we created
|
||||
Expect(Server.ConfigFile).To(Equal(filename))
|
||||
Expect(conf.Server.ConfigFile).To(Equal(filename))
|
||||
},
|
||||
Entry("TOML format", "toml"),
|
||||
Entry("YAML format", "yaml"),
|
||||
|
||||
@@ -3,3 +3,5 @@ package conf
|
||||
func ResetConf() {
|
||||
Server = &configOptions{}
|
||||
}
|
||||
|
||||
var SetViperDefaults = setViperDefaults
|
||||
|
||||
@@ -14,6 +14,9 @@ const (
|
||||
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
|
||||
InitialSetupFlagKey = "InitialSetup"
|
||||
FullScanAfterMigrationFlagKey = "FullScanAfterMigration"
|
||||
LastScanErrorKey = "LastScanError"
|
||||
LastScanTypeKey = "LastScanType"
|
||||
LastScanStartTimeKey = "LastScanStartTime"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
|
||||
@@ -112,6 +115,12 @@ const (
|
||||
InsightsInitialDelay = 30 * time.Minute
|
||||
)
|
||||
|
||||
const (
|
||||
PurgeMissingNever = "never"
|
||||
PurgeMissingAlways = "always"
|
||||
PurgeMissingFull = "full"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultDownsamplingFormat = "opus"
|
||||
DefaultTranscodings = []struct {
|
||||
|
||||
@@ -45,7 +45,7 @@ func createAgents(ds model.DataStore) *Agents {
|
||||
continue
|
||||
}
|
||||
enabled = append(enabled, name)
|
||||
res = append(res, agent)
|
||||
res = append(res, init(ds))
|
||||
}
|
||||
log.Debug("List of agents enabled", "names", enabled)
|
||||
|
||||
|
||||
@@ -344,10 +344,22 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
func init() {
|
||||
conf.AddHook(func() {
|
||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||
return lastFMConstructor(ds)
|
||||
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*lastfmAgent)(nil)
|
||||
// See https://go.dev/doc/faq#nil_error
|
||||
a := lastFMConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
})
|
||||
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||
return lastFMConstructor(ds)
|
||||
// Same as above - this is a workaround for the fact that a (Scrobbler)(nil) is not the same as a (*lastfmAgent)(nil)
|
||||
// See https://go.dev/doc/faq#nil_error
|
||||
a := lastFMConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
|
||||
} else {
|
||||
switch artID.Kind {
|
||||
case model.KindArtistArtwork:
|
||||
artReader, err = newArtistReader(ctx, a, artID, a.provider)
|
||||
artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider)
|
||||
case model.KindAlbumArtwork:
|
||||
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
|
||||
case model.KindMediaFileArtwork:
|
||||
|
||||
@@ -4,7 +4,11 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
@@ -15,11 +19,11 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// TODO Fix tests
|
||||
var _ = XDescribe("Artwork", func() {
|
||||
var _ = Describe("Artwork", func() {
|
||||
var aw *artwork
|
||||
var ds model.DataStore
|
||||
var ffmpeg *tests.MockFFmpeg
|
||||
var folderRepo *fakeFolderRepo
|
||||
ctx := log.NewContext(context.TODO())
|
||||
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
|
||||
var arMultipleCovers model.Artist
|
||||
@@ -30,20 +34,21 @@ var _ = XDescribe("Artwork", func() {
|
||||
conf.Server.ImageCacheSize = "0" // Disable cache
|
||||
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
|
||||
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3"}
|
||||
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3"}
|
||||
//alOnlyExternal = model.Album{ID: "444", Name: "Only external", ImageFiles: "tests/fixtures/artist/an-album/front.png"}
|
||||
//alExternalNotFound = model.Album{ID: "555", Name: "External not found", ImageFiles: "tests/fixtures/NON_EXISTENT.png"}
|
||||
folderRepo = &fakeFolderRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
MockedFolder: folderRepo,
|
||||
}
|
||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
|
||||
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
|
||||
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}}
|
||||
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
|
||||
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
|
||||
alMultipleCovers = model.Album{
|
||||
ID: "666",
|
||||
Name: "All options",
|
||||
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
|
||||
//Paths: []string{"tests/fixtures/artist/an-album"},
|
||||
//ImageFiles: "tests/fixtures/artist/an-album/cover.jpg" + consts.Zwsp +
|
||||
// "tests/fixtures/artist/an-album/front.png" + consts.Zwsp +
|
||||
// "tests/fixtures/artist/an-album/artist.png",
|
||||
ID: "666",
|
||||
Name: "All options",
|
||||
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
|
||||
FolderIDs: []string{"f1"},
|
||||
AlbumArtistID: "777",
|
||||
}
|
||||
mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"}
|
||||
@@ -65,6 +70,7 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
Context("Embed images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = nil
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alEmbedNotFound,
|
||||
@@ -87,12 +93,17 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
Context("External images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyExternal,
|
||||
alExternalNotFound,
|
||||
})
|
||||
})
|
||||
It("returns external cover", func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"front.png"},
|
||||
}}
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
@@ -100,6 +111,7 @@ var _ = XDescribe("Artwork", func() {
|
||||
Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png"))
|
||||
})
|
||||
It("returns ErrUnavailable if external file is not available", func() {
|
||||
folderRepo.result = []model.Folder{}
|
||||
aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, _, err = aw.Reader(ctx)
|
||||
@@ -108,6 +120,10 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg", "front.png", "artist.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
@@ -130,6 +146,10 @@ var _ = XDescribe("Artwork", func() {
|
||||
Describe("artistArtworkReader", func() {
|
||||
Context("Multiple covers", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"artist.png"},
|
||||
}}
|
||||
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
|
||||
arMultipleCovers,
|
||||
})
|
||||
@@ -143,7 +163,7 @@ var _ = XDescribe("Artwork", func() {
|
||||
DescribeTable("ArtistArtPriority",
|
||||
func(priority string, expected string) {
|
||||
conf.Server.ArtistArtPriority = priority
|
||||
aw, err := newArtistReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -157,12 +177,16 @@ var _ = XDescribe("Artwork", func() {
|
||||
Describe("mediafileArtworkReader", func() {
|
||||
Context("ID not found", func() {
|
||||
It("returns ErrNotFound if mediafile is not in the DB", func() {
|
||||
_, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
|
||||
_, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND"))
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
Context("Embed images", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"front.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alOnlyEmbed,
|
||||
alOnlyExternal,
|
||||
@@ -185,11 +209,17 @@ var _ = XDescribe("Artwork", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
r, path, err := aw.Reader(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(io.ReadAll(r)).To(Equal([]byte("content from ffmpeg")))
|
||||
data, _ := io.ReadAll(r)
|
||||
Expect(data).ToNot(BeEmpty())
|
||||
Expect(path).To(Equal("tests/fixtures/test.ogg"))
|
||||
})
|
||||
It("returns album cover if cannot read embed artwork", func() {
|
||||
// Force fromTag to fail
|
||||
mfCorruptedCover.Path = "tests/fixtures/DOES_NOT_EXIST.ogg"
|
||||
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfCorruptedCover)).To(Succeed())
|
||||
// Simulate ffmpeg error
|
||||
ffmpeg.Error = errors.New("not available")
|
||||
|
||||
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, path, err := aw.Reader(ctx)
|
||||
@@ -207,6 +237,10 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
Describe("resizedArtworkReader", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
Path: "tests/fixtures/artist/an-album",
|
||||
ImageFiles: []string{"cover.jpg", "front.png"},
|
||||
}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alMultipleCovers,
|
||||
})
|
||||
@@ -241,12 +275,13 @@ var _ = XDescribe("Artwork", func() {
|
||||
DescribeTable("resize",
|
||||
func(format string, landscape bool, size int) {
|
||||
coverFileName := "cover." + format
|
||||
//dirName := createImage(format, landscape, size)
|
||||
dirName := createImage(format, landscape, size)
|
||||
alCover = model.Album{
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
//ImageFiles: filepath.Join(dirName, coverFileName),
|
||||
ID: "444",
|
||||
Name: "Only external",
|
||||
FolderIDs: []string{"tmp"},
|
||||
}
|
||||
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
|
||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
alCover,
|
||||
})
|
||||
@@ -270,24 +305,24 @@ var _ = XDescribe("Artwork", func() {
|
||||
})
|
||||
})
|
||||
|
||||
//func createImage(format string, landscape bool, size int) string {
|
||||
// var img image.Image
|
||||
//
|
||||
// if landscape {
|
||||
// img = image.NewRGBA(image.Rect(0, 0, size, size/2))
|
||||
// } else {
|
||||
// img = image.NewRGBA(image.Rect(0, 0, size/2, size))
|
||||
// }
|
||||
//
|
||||
// tmpDir := GinkgoT().TempDir()
|
||||
// f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
|
||||
// defer f.Close()
|
||||
// switch format {
|
||||
// case "png":
|
||||
// _ = png.Encode(f, img)
|
||||
// case "jpg":
|
||||
// _ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
|
||||
// }
|
||||
//
|
||||
// return tmpDir
|
||||
//}
|
||||
func createImage(format string, landscape bool, size int) string {
|
||||
var img image.Image
|
||||
|
||||
if landscape {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size, size/2))
|
||||
} else {
|
||||
img = image.NewRGBA(image.Rect(0, 0, size/2, size))
|
||||
}
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
|
||||
defer f.Close()
|
||||
switch format {
|
||||
case "png":
|
||||
_ = png.Encode(f, img)
|
||||
case "jpg":
|
||||
_ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
|
||||
}
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
// If the file cache is disabled, return a NOOP implementation
|
||||
if cache.Disabled(context.Background()) {
|
||||
log.Debug("Image cache disabled. Cache warmer will not run")
|
||||
return &noopCacheWarmer{}
|
||||
}
|
||||
|
||||
a := &cacheWarmer{
|
||||
artwork: artwork,
|
||||
cache: cache,
|
||||
@@ -53,6 +59,9 @@ type cacheWarmer struct {
|
||||
}
|
||||
|
||||
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
|
||||
if a.cache.Disabled(context.Background()) {
|
||||
return
|
||||
}
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
a.buffer[artID] = struct{}{}
|
||||
@@ -74,6 +83,17 @@ func (a *cacheWarmer) run(ctx context.Context) {
|
||||
break
|
||||
}
|
||||
|
||||
if a.cache.Disabled(ctx) {
|
||||
a.mutex.Lock()
|
||||
pending := len(a.buffer)
|
||||
a.buffer = make(map[model.ArtworkID]struct{})
|
||||
a.mutex.Unlock()
|
||||
if pending > 0 {
|
||||
log.Trace(ctx, "Cache disabled, discarding precache buffer", "bufferLen", pending)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If cache not available, keep waiting
|
||||
if !a.cache.Available(ctx) {
|
||||
if len(a.buffer) > 0 {
|
||||
|
||||
216
core/artwork/cache_warmer_test.go
Normal file
216
core/artwork/cache_warmer_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("CacheWarmer", func() {
|
||||
var (
|
||||
fc *mockFileCache
|
||||
aw *mockArtwork
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
fc = &mockFileCache{}
|
||||
aw = &mockArtwork{}
|
||||
})
|
||||
|
||||
Context("initialization", func() {
|
||||
It("returns noop when cache is disabled", func() {
|
||||
fc.SetDisabled(true)
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns noop when ImageCacheSize is 0", func() {
|
||||
conf.Server.ImageCacheSize = "0"
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns noop when EnableArtworkPrecache is false", func() {
|
||||
conf.Server.EnableArtworkPrecache = false
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*noopCacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns real implementation when properly configured", func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
cw := NewCacheWarmer(aw, fc)
|
||||
_, ok := cw.(*cacheWarmer)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("buffer management", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("drops buffered items when cache becomes disabled", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-test"))
|
||||
fc.SetDisabled(true)
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("adds multiple items to buffer", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-2"))
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
Expect(len(cw.buffer)).To(Equal(2))
|
||||
})
|
||||
|
||||
It("deduplicates items in buffer", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
Expect(len(cw.buffer)).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
Context("error handling", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("continues processing after artwork retrieval error", func() {
|
||||
aw.err = errors.New("artwork error")
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-error"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("continues processing after cache error", func() {
|
||||
fc.err = errors.New("cache error")
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-error"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Context("background processing", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ImageCacheSize = "100MB"
|
||||
conf.Server.EnableArtworkPrecache = true
|
||||
fc.SetDisabled(false)
|
||||
})
|
||||
|
||||
It("processes items in batches", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
for i := 0; i < 5; i++ {
|
||||
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
|
||||
}
|
||||
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
|
||||
It("wakes up on new items", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
|
||||
// Add first batch
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
|
||||
// Add second batch
|
||||
cw.PreCache(model.MustParseArtworkID("al-2"))
|
||||
Eventually(func() int {
|
||||
cw.mutex.Lock()
|
||||
defer cw.mutex.Unlock()
|
||||
return len(cw.buffer)
|
||||
}).Should(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockArtwork struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
if m.err != nil {
|
||||
return nil, time.Time{}, m.err
|
||||
}
|
||||
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
|
||||
}
|
||||
|
||||
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
|
||||
return m.Get(ctx, model.ArtworkID{}, size, square)
|
||||
}
|
||||
|
||||
type mockFileCache struct {
|
||||
disabled atomic.Bool
|
||||
ready atomic.Bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Get(ctx context.Context, item cache.Item) (*cache.CachedStream, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return &cache.CachedStream{Reader: io.NopCloser(strings.NewReader("cached"))}, nil
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Available(ctx context.Context) bool {
|
||||
return f.ready.Load() && !f.disabled.Load()
|
||||
}
|
||||
|
||||
func (f *mockFileCache) Disabled(ctx context.Context) bool {
|
||||
return f.disabled.Load()
|
||||
}
|
||||
|
||||
func (f *mockFileCache) SetDisabled(v bool) {
|
||||
f.disabled.Store(v)
|
||||
f.ready.Store(true)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ type artistReader struct {
|
||||
imgFiles []string
|
||||
}
|
||||
|
||||
func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||
ar, err := artwork.ds.Artist(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("artistReader", func() {
|
||||
var _ = Describe("artistArtworkReader", func() {
|
||||
var _ = Describe("loadArtistFolder", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
@@ -93,7 +94,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
}
|
||||
s.ID = id
|
||||
if V(s.ExpiresAt).IsZero() {
|
||||
s.ExpiresAt = P(time.Now().Add(365 * 24 * time.Hour))
|
||||
s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration))
|
||||
}
|
||||
|
||||
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]
|
||||
|
||||
36
db/migrations/20250522013904_share_user_id_on_delete.sql
Normal file
36
db/migrations/20250522013904_share_user_id_on_delete.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE share_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
expires_at datetime,
|
||||
last_visited_at datetime,
|
||||
resource_ids varchar not null,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
user_id varchar(255) not null
|
||||
constraint share_user_id_fk
|
||||
references user
|
||||
on update cascade on delete cascade,
|
||||
downloadable bool not null default false,
|
||||
description varchar not null default '',
|
||||
resource_type varchar not null default '',
|
||||
contents varchar not null default '',
|
||||
format varchar not null default '',
|
||||
max_bit_rate integer not null default 0,
|
||||
visit_count integer not null default 0
|
||||
);
|
||||
|
||||
|
||||
INSERT INTO share_tmp(
|
||||
id, expires_at, last_visited_at, resource_ids, created_at, updated_at, user_id, downloadable, description, resource_type, contents, format, max_bit_rate, visit_count
|
||||
) SELECT id, expires_at, last_visited_at, resource_ids, created_at, updated_at, user_id, downloadable, description, resource_type, contents, format, max_bit_rate, visit_count
|
||||
FROM share;
|
||||
|
||||
DROP TABLE share;
|
||||
|
||||
ALTER TABLE share_tmp RENAME To share;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
33
go.mod
33
go.mod
@@ -35,17 +35,17 @@ require (
|
||||
github.com/jellydator/ttlcache/v3 v3.3.0
|
||||
github.com/kardianos/service v1.2.2
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.27
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.23.4
|
||||
github.com/onsi/gomega v1.37.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/pressly/goose/v3 v3.24.2
|
||||
github.com/prometheus/client_golang v1.21.1
|
||||
github.com/pressly/goose/v3 v3.24.3
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/rjeczalik/notify v0.9.3
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
|
||||
@@ -56,12 +56,12 @@ require (
|
||||
github.com/unrolled/secure v1.17.0
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
|
||||
golang.org/x/image v0.26.0
|
||||
golang.org/x/net v0.38.0
|
||||
golang.org/x/sync v0.13.0
|
||||
golang.org/x/sys v0.32.0
|
||||
golang.org/x/text v0.24.0
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6
|
||||
golang.org/x/image v0.27.0
|
||||
golang.org/x/net v0.40.0
|
||||
golang.org/x/sync v0.14.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/text v0.25.0
|
||||
golang.org/x/time v0.11.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -80,18 +80,17 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
|
||||
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
@@ -102,23 +101,23 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/cast v1.8.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // indirect
|
||||
golang.org/x/tools v0.33.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
|
||||
80
go.sum
80
go.sum
@@ -66,8 +66,8 @@ github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
|
||||
@@ -85,8 +85,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4=
|
||||
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -130,24 +130,24 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
|
||||
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4 h1:uBCMmJX8oRZStmKuMMOFb0Yh9xmEMgNJLgjuKKt4/qc=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.4/go.mod h1:nWRbDFR1ALG2Z6GJbBXzfQaYyvn751KuuyySN2yR6is=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
|
||||
github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
@@ -174,16 +174,16 @@ github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU
|
||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
|
||||
github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
|
||||
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
||||
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
||||
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
|
||||
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
|
||||
@@ -213,8 +213,8 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
|
||||
github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
@@ -256,13 +256,13 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
|
||||
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -283,8 +283,8 @@ golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -292,8 +292,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -312,8 +312,8 @@ golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -334,8 +334,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -346,8 +346,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
@@ -363,11 +363,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
|
||||
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA=
|
||||
modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=
|
||||
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
|
||||
@@ -32,6 +32,8 @@ type Artist struct {
|
||||
SimilarArtists Artists `structs:"similar_artists" json:"-"`
|
||||
ExternalInfoUpdatedAt *time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt,omitempty"`
|
||||
|
||||
Missing bool `structs:"missing" json:"missing"`
|
||||
|
||||
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
|
||||
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
|
||||
}
|
||||
@@ -76,7 +78,7 @@ type ArtistRepository interface {
|
||||
UpdateExternalInfo(a *Artist) error
|
||||
Get(id string) (*Artist, error)
|
||||
GetAll(options ...QueryOptions) (Artists, error)
|
||||
GetIndex(roles ...Role) (ArtistIndexes, error)
|
||||
GetIndex(includeMissing bool, roles ...Role) (ArtistIndexes, error)
|
||||
|
||||
// The following methods are used exclusively by the scanner:
|
||||
RefreshPlayCounts() (int64, error)
|
||||
|
||||
@@ -4,6 +4,7 @@ package criteria
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
@@ -40,6 +41,9 @@ func (c Criteria) OrderBy() string {
|
||||
} else {
|
||||
mapped = f.field
|
||||
}
|
||||
if f.numeric {
|
||||
mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
|
||||
}
|
||||
}
|
||||
if c.Order != "" {
|
||||
if strings.EqualFold(c.Order, "asc") || strings.EqualFold(c.Order, "desc") {
|
||||
|
||||
@@ -109,6 +109,15 @@ var _ = Describe("Criteria", func() {
|
||||
)
|
||||
})
|
||||
|
||||
It("casts numeric tags when sorting", func() {
|
||||
AddTagNames([]string{"rate"})
|
||||
AddNumericTags([]string{"rate"})
|
||||
goObj.Sort = "rate"
|
||||
gomega.Expect(goObj.OrderBy()).To(
|
||||
gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"),
|
||||
)
|
||||
})
|
||||
|
||||
It("sorts by random", func() {
|
||||
newObj := goObj
|
||||
newObj.Sort = "random"
|
||||
|
||||
@@ -54,11 +54,12 @@ var fieldMap = map[string]*mappedField{
|
||||
}
|
||||
|
||||
type mappedField struct {
|
||||
field string
|
||||
order string
|
||||
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
|
||||
isTag bool // true if the field is a tag imported from the file metadata
|
||||
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
|
||||
field string
|
||||
order string
|
||||
isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.)
|
||||
isTag bool // true if the field is a tag imported from the file metadata
|
||||
alias string // name from `mappings.yml` that may differ from the name used in the smart playlist
|
||||
numeric bool // true if the field/tag should be treated as numeric
|
||||
}
|
||||
|
||||
func mapFields(expr map[string]any) map[string]any {
|
||||
@@ -145,6 +146,12 @@ type tagCond struct {
|
||||
|
||||
func (e tagCond) ToSql() (string, []any, error) {
|
||||
cond, args, err := e.cond.ToSql()
|
||||
|
||||
// Check if this tag is marked as numeric in the fieldMap
|
||||
if fm, ok := fieldMap[e.tag]; ok && fm.numeric {
|
||||
cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
|
||||
}
|
||||
|
||||
cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)",
|
||||
e.tag, cond)
|
||||
if e.not {
|
||||
@@ -205,3 +212,16 @@ func AddTagNames(tagNames []string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddNumericTags marks the given tag names as numeric so they can be cast
|
||||
// when used in comparisons or sorting.
|
||||
func AddNumericTags(tagNames []string) {
|
||||
for _, name := range tagNames {
|
||||
name := strings.ToLower(name)
|
||||
if fm, ok := fieldMap[name]; ok {
|
||||
fm.numeric = true
|
||||
} else {
|
||||
fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
var _ = BeforeSuite(func() {
|
||||
AddRoles([]string{"artist", "composer"})
|
||||
AddTagNames([]string{"genre"})
|
||||
AddNumericTags([]string{"rate"})
|
||||
})
|
||||
|
||||
var _ = Describe("Operators", func() {
|
||||
@@ -68,6 +69,15 @@ var _ = Describe("Operators", func() {
|
||||
Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"),
|
||||
)
|
||||
|
||||
// TODO Validate operators that are not valid for each field type.
|
||||
XDescribeTable("ToSQL - Invalid Operators",
|
||||
func(op Expression, expectedError string) {
|
||||
_, _, err := op.ToSql()
|
||||
gomega.Expect(err).To(gomega.MatchError(expectedError))
|
||||
},
|
||||
Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"),
|
||||
)
|
||||
|
||||
Describe("Custom Tags", func() {
|
||||
It("generates valid SQL", func() {
|
||||
AddTagNames([]string{"mood"})
|
||||
@@ -77,6 +87,14 @@ var _ = Describe("Operators", func() {
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.mood') where key='value' and value LIKE ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements("%Soft"))
|
||||
})
|
||||
It("casts numeric comparisons", func() {
|
||||
AddNumericTags([]string{"rate"})
|
||||
op := Lt{"rate": 6}
|
||||
sql, args, err := op.ToSql()
|
||||
gomega.Expect(err).ToNot(gomega.HaveOccurred())
|
||||
gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)"))
|
||||
gomega.Expect(args).To(gomega.HaveExactElements(6))
|
||||
})
|
||||
It("skips unknown tag names", func() {
|
||||
op := EndsWith{"unknown": "value"}
|
||||
sql, args, _ := op.ToSql()
|
||||
|
||||
@@ -342,6 +342,7 @@ type MediaFileRepository interface {
|
||||
GetCursor(options ...QueryOptions) (MediaFileCursor, error)
|
||||
Delete(id string) error
|
||||
DeleteMissing(ids []string) error
|
||||
DeleteAllMissing() (int64, error)
|
||||
FindByPaths(paths []string) (MediaFiles, error)
|
||||
|
||||
// The following methods are used exclusively by the scanner:
|
||||
|
||||
@@ -564,6 +564,58 @@ var _ = Describe("Participants", func() {
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
When("MUSICBRAINZ_PERFORMERID tag is set", func() {
|
||||
matchPerformer := func(name, orderName, subRole, mbid string) types.GomegaMatcher {
|
||||
return MatchFields(IgnoreExtras, Fields{
|
||||
"Artist": MatchFields(IgnoreExtras, Fields{
|
||||
"Name": Equal(name),
|
||||
"OrderArtistName": Equal(orderName),
|
||||
"MbzArtistID": Equal(mbid),
|
||||
}),
|
||||
"SubRole": Equal(subRole),
|
||||
})
|
||||
}
|
||||
|
||||
It("should map MBIDs to the correct performer", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"PERFORMER:GUITAR": {"Eric Clapton", "B.B. King"},
|
||||
"PERFORMER:BASS": {"Nathan East"},
|
||||
"MUSICBRAINZ_PERFORMERID:GUITAR": {"mbid1", "mbid2"},
|
||||
"MUSICBRAINZ_PERFORMERID:BASS": {"mbid3"},
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(3)))
|
||||
|
||||
p := participants[model.RolePerformer]
|
||||
Expect(p).To(ContainElements(
|
||||
matchPerformer("Eric Clapton", "eric clapton", "Guitar", "mbid1"),
|
||||
matchPerformer("B.B. King", "b.b. king", "Guitar", "mbid2"),
|
||||
matchPerformer("Nathan East", "nathan east", "Bass", "mbid3"),
|
||||
))
|
||||
})
|
||||
|
||||
It("should handle mismatched performer names and MBIDs for sub-roles", func() {
|
||||
mf = toMediaFile(model.RawTags{
|
||||
"PERFORMER:VOCALS": {"Singer A", "Singer B", "Singer C"},
|
||||
"MUSICBRAINZ_PERFORMERID:VOCALS": {"mbid_vocals_a", "mbid_vocals_b"}, // Fewer MBIDs
|
||||
"PERFORMER:DRUMS": {"Drummer X"},
|
||||
"MUSICBRAINZ_PERFORMERID:DRUMS": {"mbid_drums_x", "mbid_drums_y"}, // More MBIDs
|
||||
})
|
||||
|
||||
participants := mf.Participants
|
||||
Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(4))) // 3 vocalists + 1 drummer
|
||||
|
||||
p := participants[model.RolePerformer]
|
||||
Expect(p).To(ContainElements(
|
||||
matchPerformer("Singer A", "singer a", "Vocals", "mbid_vocals_a"),
|
||||
matchPerformer("Singer B", "singer b", "Vocals", "mbid_vocals_b"),
|
||||
matchPerformer("Singer C", "singer c", "Vocals", ""),
|
||||
matchPerformer("Drummer X", "drummer x", "Drums", "mbid_drums_x"),
|
||||
))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Other tags", func() {
|
||||
@@ -592,7 +644,6 @@ var _ = Describe("Participants", func() {
|
||||
Entry("REMIXER", model.RoleRemixer, "REMIXER"),
|
||||
Entry("DJMIXER", model.RoleDJMixer, "DJMIXER"),
|
||||
Entry("DIRECTOR", model.RoleDirector, "DIRECTOR"),
|
||||
// TODO PERFORMER
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ const (
|
||||
TagReleaseCountry TagName = "releasecountry"
|
||||
TagMedia TagName = "media"
|
||||
TagCatalogNumber TagName = "catalognumber"
|
||||
TagISRC TagName = "isrc"
|
||||
TagBPM TagName = "bpm"
|
||||
TagExplicitStatus TagName = "explicitstatus"
|
||||
|
||||
|
||||
@@ -162,6 +162,17 @@ func tagNames() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
func numericTagNames() []string {
|
||||
mappings := TagMappings()
|
||||
names := make([]string, 0)
|
||||
for k, cfg := range mappings {
|
||||
if cfg.Type == TagTypeInteger || cfg.Type == TagTypeFloat {
|
||||
names = append(names, string(k))
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func loadTagMappings() {
|
||||
mappingsFile, err := resources.FS().Open("mappings.yaml")
|
||||
if err != nil {
|
||||
@@ -228,5 +239,6 @@ func init() {
|
||||
// used in smart playlists
|
||||
criteria.AddRoles(slices.Collect(maps.Keys(AllRoles)))
|
||||
criteria.AddTagNames(tagNames())
|
||||
criteria.AddNumericTags(numericTagNames())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
"name": fullTextFilter(r.tableName),
|
||||
"starred": booleanFilter,
|
||||
"role": roleFilter,
|
||||
"missing": booleanFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "order_artist_name",
|
||||
@@ -202,7 +203,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(roles ...model.Role) (model.ArtistIndexes, error) {
|
||||
func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (model.ArtistIndexes, error) {
|
||||
options := model.QueryOptions{Sort: "name"}
|
||||
if len(roles) > 0 {
|
||||
roleFilters := slice.Map(roles, func(r model.Role) Sqlizer {
|
||||
@@ -210,6 +211,13 @@ func (r *artistRepository) GetIndex(roles ...model.Role) (model.ArtistIndexes, e
|
||||
})
|
||||
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
|
||||
@@ -236,6 +244,25 @@ func (r *artistRepository) purgeEmpty() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// markMissing marks artists as missing if all their albums are missing.
|
||||
func (r *artistRepository) markMissing() error {
|
||||
q := Expr(`
|
||||
with artists_with_non_missing_albums as (
|
||||
select distinct aa.artist_id
|
||||
from album_artists aa
|
||||
join album a on aa.album_id = a.id
|
||||
where a.missing = false
|
||||
)
|
||||
update artist
|
||||
set missing = (artist.id not in (select artist_id from artists_with_non_missing_albums));
|
||||
`)
|
||||
_, err := r.executeSQL(q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marking missing artists: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshPlayCounts updates the play count and last play date annotations for all artists, based
|
||||
// on the media files associated with them.
|
||||
func (r *artistRepository) RefreshPlayCounts() (int64, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -94,7 +95,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("F"))
|
||||
@@ -112,7 +113,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
|
||||
// BFR Empty SortArtistName is not saved in the DB anymore
|
||||
XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
@@ -134,7 +135,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
er := repo.Put(&artistBeatles)
|
||||
Expect(er).To(BeNil())
|
||||
|
||||
idx, err := repo.GetIndex()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
@@ -151,7 +152,7 @@ var _ = Describe("ArtistRepository", func() {
|
||||
})
|
||||
|
||||
It("returns the index when SortArtistName is empty", func() {
|
||||
idx, err := repo.GetIndex()
|
||||
idx, err := repo.GetIndex(false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(idx).To(HaveLen(2))
|
||||
Expect(idx[0].ID).To(Equal("B"))
|
||||
@@ -233,5 +234,91 @@ 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -192,6 +192,15 @@ func (r *mediaFileRepository) Delete(id string) error {
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) DeleteAllMissing() (int64, error) {
|
||||
user := loggedUser(r.ctx)
|
||||
if !user.IsAdmin {
|
||||
return 0, rest.ErrPermissionDenied
|
||||
}
|
||||
del := Delete(r.tableName).Where(Eq{"missing": true})
|
||||
return r.executeSQL(del)
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) DeleteMissing(ids []string) error {
|
||||
user := loggedUser(r.ctx)
|
||||
if !user.IsAdmin {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
@@ -44,14 +45,39 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
It("delete tracks by id", func() {
|
||||
newID := id.NewRandom()
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(BeNil())
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed())
|
||||
|
||||
Expect(mr.Delete(newID)).To(BeNil())
|
||||
Expect(mr.Delete(newID)).To(Succeed())
|
||||
|
||||
_, err := mr.Get(newID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("deletes all missing files", func() {
|
||||
new1 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1}
|
||||
new2 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1}
|
||||
Expect(mr.Put(&new1)).To(Succeed())
|
||||
Expect(mr.Put(&new2)).To(Succeed())
|
||||
Expect(mr.MarkMissing(true, &new1, &new2)).To(Succeed())
|
||||
|
||||
adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true})
|
||||
adminRepo := NewMediaFileRepository(adminCtx, GetDBXBuilder())
|
||||
|
||||
// Ensure the files are marked as missing and we have 2 of them
|
||||
count, err := adminRepo.CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}})
|
||||
Expect(count).To(BeNumerically("==", 2))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
count, err = adminRepo.DeleteAllMissing()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeNumerically("==", 2))
|
||||
|
||||
_, err = mr.Get(new1.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = mr.Get(new2.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
Context("Annotations", func() {
|
||||
It("increments play count when the tracks does not have annotations", func() {
|
||||
id := "incplay.firsttime"
|
||||
|
||||
@@ -170,6 +170,7 @@ func (s *SQLStore) GC(ctx context.Context) error {
|
||||
err := chain.RunSequentially(
|
||||
trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty() }),
|
||||
trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }),
|
||||
trace(ctx, "mark missing artists", func() error { return s.Artist(ctx).(*artistRepository).markMissing() }),
|
||||
trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty() }),
|
||||
trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }),
|
||||
trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }),
|
||||
|
||||
@@ -65,6 +65,11 @@ func loggedUser(ctx context.Context) *model.User {
|
||||
}
|
||||
}
|
||||
|
||||
func isAdmin(ctx context.Context) bool {
|
||||
user := loggedUser(ctx)
|
||||
return user.IsAdmin
|
||||
}
|
||||
|
||||
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
|
||||
if r.tableName == "" {
|
||||
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
|
||||
|
||||
@@ -41,6 +41,9 @@ func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding,
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Put(t *model.Transcoding) error {
|
||||
if !isAdmin(r.ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
_, err := r.put(t.ID, t)
|
||||
return err
|
||||
}
|
||||
@@ -69,6 +72,9 @@ func (r *transcodingRepository) NewInstance() interface{} {
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Save(entity interface{}) (string, error) {
|
||||
if !isAdmin(r.ctx) {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
t := entity.(*model.Transcoding)
|
||||
id, err := r.put(t.ID, t)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
@@ -78,6 +84,9 @@ func (r *transcodingRepository) Save(entity interface{}) (string, error) {
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
if !isAdmin(r.ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
t := entity.(*model.Transcoding)
|
||||
t.ID = id
|
||||
_, err := r.put(id, t)
|
||||
@@ -88,6 +97,9 @@ func (r *transcodingRepository) Update(id string, entity interface{}, cols ...st
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Delete(id string) error {
|
||||
if !isAdmin(r.ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.delete(Eq{"id": id})
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
|
||||
96
persistence/transcoding_repository_test.go
Normal file
96
persistence/transcoding_repository_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("TranscodingRepository", func() {
|
||||
var repo model.TranscodingRepository
|
||||
var adminRepo model.TranscodingRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(GinkgoT().Context())
|
||||
ctx = request.WithUser(ctx, regularUser)
|
||||
repo = NewTranscodingRepository(ctx, GetDBXBuilder())
|
||||
|
||||
adminCtx := log.NewContext(GinkgoT().Context())
|
||||
adminCtx = request.WithUser(adminCtx, adminUser)
|
||||
adminRepo = NewTranscodingRepository(adminCtx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
// Clean up any transcoding created during the tests
|
||||
tc, err := adminRepo.FindByFormat("test_format")
|
||||
if err == nil {
|
||||
err = adminRepo.(*transcodingRepository).Delete(tc.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
})
|
||||
|
||||
Describe("Admin User", func() {
|
||||
It("creates a new transcoding", func() {
|
||||
base, err := adminRepo.CountAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = adminRepo.Put(&model.Transcoding{ID: "new", Name: "new", TargetFormat: "test_format", DefaultBitRate: 320, Command: "ffmpeg"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
count, err := adminRepo.CountAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(base + 1))
|
||||
})
|
||||
|
||||
It("updates an existing transcoding", func() {
|
||||
tr := &model.Transcoding{ID: "upd", Name: "old", TargetFormat: "test_format", DefaultBitRate: 100, Command: "ffmpeg"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
tr.Name = "updated"
|
||||
err := adminRepo.Put(tr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
res, err := adminRepo.FindByFormat("test_format")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Name).To(Equal("updated"))
|
||||
})
|
||||
|
||||
It("deletes a transcoding", func() {
|
||||
err := adminRepo.Put(&model.Transcoding{ID: "to-delete", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 256, Command: "ffmpeg"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = adminRepo.(*transcodingRepository).Delete("to-delete")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = adminRepo.Get("to-delete")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Regular User", func() {
|
||||
It("fails to create", func() {
|
||||
err := repo.Put(&model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"})
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("fails to update", func() {
|
||||
tr := &model.Transcoding{ID: "updreg", Name: "old", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
tr.Name = "bad"
|
||||
err := repo.Put(tr)
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
|
||||
//_ = adminRepo.(*transcodingRepository).Delete("updreg")
|
||||
})
|
||||
|
||||
It("fails to delete", func() {
|
||||
tr := &model.Transcoding{ID: "delreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}
|
||||
Expect(adminRepo.Put(tr)).To(Succeed())
|
||||
|
||||
err := repo.(*transcodingRepository).Delete("delreg")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
|
||||
//_ = adminRepo.(*transcodingRepository).Delete("delreg")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,10 @@
|
||||
"year": "Urtea",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"updatedAt": "Eguneratze-data:",
|
||||
"bitRate": "Bit tasa",
|
||||
"bitRate": "Bit-tasa",
|
||||
"bitDepth": "Bit-sakonera",
|
||||
"sampleRate": "Lagin-tasa",
|
||||
"channels": "Kanalak",
|
||||
"discSubtitle": "Diskoaren azpititulua",
|
||||
"starred": "Gogokoa",
|
||||
"comment": "Iruzkina",
|
||||
@@ -25,14 +28,13 @@
|
||||
"quality": "Kalitatea",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Azkenekoz erreproduzitua:",
|
||||
"channels": "Kanalak",
|
||||
"createdAt": "Gehitu zen data:",
|
||||
"grouping": "",
|
||||
"mood": "",
|
||||
"participants": "",
|
||||
"tags": "",
|
||||
"mappedTags": "",
|
||||
"rawTags": ""
|
||||
"grouping": "Multzokatzea",
|
||||
"mood": "Aldartea",
|
||||
"participants": "Partaide gehiago",
|
||||
"tags": "Traola gehiago",
|
||||
"mappedTags": "Esleitutako traolak",
|
||||
"rawTags": "Traola gordinak"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Erreproduzitu ondoren",
|
||||
@@ -52,25 +54,26 @@
|
||||
"duration": "Iraupena",
|
||||
"songCount": "abesti",
|
||||
"playCount": "Erreprodukzioak",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"name": "Izena",
|
||||
"genre": "Generoa",
|
||||
"compilation": "Konpilazioa",
|
||||
"year": "Urtea",
|
||||
"updatedAt": "Aktualizatze-data:",
|
||||
"comment": "Iruzkina",
|
||||
"rating": "Balorazioa",
|
||||
"createdAt": "Gehitu zen data:",
|
||||
"size": "Fitxategiaren tamaina",
|
||||
"date": "Recording Date",
|
||||
"originalDate": "Jatorrizkoa",
|
||||
"releaseDate": "Argitaratze-data:",
|
||||
"releases": "Argitaratzea |||| Argitaratzeak",
|
||||
"released": "Argitaratua",
|
||||
"recordLabel": "",
|
||||
"catalogNum": "",
|
||||
"releaseType": "",
|
||||
"grouping": "",
|
||||
"media": "",
|
||||
"mood": ""
|
||||
"updatedAt": "Aktualizatze-data:",
|
||||
"comment": "Iruzkina",
|
||||
"rating": "Balorazioa",
|
||||
"createdAt": "Gehitu zen data:",
|
||||
"recordLabel": "Disketxea",
|
||||
"catalogNum": "Katalogo-zenbakia",
|
||||
"releaseType": "Mota",
|
||||
"grouping": "Multzokatzea",
|
||||
"media": "Multimedia",
|
||||
"mood": "Aldartea"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Erreproduzitu",
|
||||
@@ -84,7 +87,7 @@
|
||||
},
|
||||
"lists": {
|
||||
"all": "Guztiak",
|
||||
"random": "Aleatorioa",
|
||||
"random": "Aleatorioki",
|
||||
"recentlyAdded": "Berriki gehitutakoak",
|
||||
"recentlyPlayed": "Berriki entzundakoak",
|
||||
"mostPlayed": "Gehien entzundakoak",
|
||||
@@ -98,26 +101,26 @@
|
||||
"name": "Izena",
|
||||
"albumCount": "Album kopurua",
|
||||
"songCount": "Abesti kopurua",
|
||||
"size": "Tamaina",
|
||||
"playCount": "Erreprodukzio kopurua",
|
||||
"rating": "Balorazioa",
|
||||
"genre": "Generoa",
|
||||
"size": "Tamaina",
|
||||
"role": ""
|
||||
"role": "Rola"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "",
|
||||
"artist": "",
|
||||
"composer": "",
|
||||
"conductor": "",
|
||||
"lyricist": "",
|
||||
"arranger": "",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"mixer": "",
|
||||
"remixer": "",
|
||||
"djmixer": "",
|
||||
"performer": ""
|
||||
"albumartist": "Albumeko egilea |||| Albumeko artistak",
|
||||
"artist": "Artista |||| Artistak",
|
||||
"composer": "Konpositorea |||| Konpositoreak",
|
||||
"conductor": "Orkestra zuzendaria |||| Orkestra zuzendariak",
|
||||
"lyricist": "Hitzen egilea |||| Hitzen egileak",
|
||||
"arranger": "Moldatzailea |||| Moldatzaileak",
|
||||
"producer": "Produktorea |||| Produktoreak",
|
||||
"director": "Zuzendaria |||| Zuzendaria",
|
||||
"engineer": "Teknikaria |||| Teknikariak",
|
||||
"mixer": "Nahaslea |||| Nahasleak",
|
||||
"remixer": "Remixerra |||| Remixerrak",
|
||||
"djmixer": "DJ nahaslea |||| DJ nahasleak",
|
||||
"performer": "Interpretatzailea |||| Interpretatzaileak"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -238,7 +241,8 @@
|
||||
"updatedAt": "Desagertze-data:"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Kendu"
|
||||
"remove": "Kendu",
|
||||
"remove_all": "Kendu guztia"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Faltan zeuden fitxategiak kendu dira"
|
||||
@@ -258,7 +262,7 @@
|
||||
"sign_in": "Sartu",
|
||||
"sign_in_error": "Autentifikazioak huts egin du, saiatu berriro",
|
||||
"logout": "Amaitu saioa",
|
||||
"insightsCollectionNote": ""
|
||||
"insightsCollectionNote": "Navidromek erabilera-datu anonimoak biltzen ditu\nproiektua hobetzen laguntzeko. Egin klik [hemen]\ngehiago ikasteko, eta datuak ez biltzeko eskatzeko,\nhala nahi izanez gero."
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Erabili hizkiak eta zenbakiak bakarrik",
|
||||
@@ -398,31 +402,33 @@
|
||||
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
|
||||
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",
|
||||
"delete_user_content": "Ziur zaide erabiltzaile hau eta bere datu guztiak (zerrendak eta hobespenak barne) ezabatu nahi dituzula?",
|
||||
"remove_missing_title": "Kendu faltan dauden fitxategiak",
|
||||
"remove_missing_content": "Ziur hautatutako fitxategiak datu-basetik kendu nahi dituzula? Betiko kenduko dira haiei buruzko erreferentziak, erreprodukzio-zenbaketak eta sailkapenak barne.",
|
||||
"remove_all_missing_title": "Kendu faltan dauden fitxategi guztiak",
|
||||
"remove_all_missing_content": "Ziur aurkitu ez diren fitxategi guztiak datu-basetik kendu nahi dituzula? Betiko kenduko dira haiei buruzko erreferentziak, erreprodukzio-zenbaketak eta sailkapenak barne.",
|
||||
"notifications_blocked": "Nabigatzaileak jakinarazpenak blokeatzen ditu",
|
||||
"notifications_not_available": "Nabigatzaile hau ez da jakinarazpenekin bateragarria edo Navidrome ez da HTTPS erabiltzen ari",
|
||||
"lastfmLinkSuccess": "Last.fm konektatuta dago eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea gaituta dago",
|
||||
"lastfmLinkFailure": "Ezin izan da Last.fm-rekin konektatu",
|
||||
"lastfmUnlinkSuccess": "Last.fm deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea ezgaitu da",
|
||||
"lastfmUnlinkFailure": "Ezin izan da Last.fm deskonektatu",
|
||||
"listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da",
|
||||
"listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da",
|
||||
"listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu",
|
||||
"openIn": {
|
||||
"lastfm": "Ikusi Last.fm-n",
|
||||
"musicbrainz": "Ikusi MusicBrainz-en"
|
||||
},
|
||||
"lastfmLink": "Irakurri gehiago…",
|
||||
"listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da",
|
||||
"listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da",
|
||||
"listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu",
|
||||
"downloadOriginalFormat": "Deskargatu jatorrizko formatua",
|
||||
"shareOriginalFormat": "Partekatu jatorrizko formatua",
|
||||
"shareDialogTitle": "Partekatu '%{name}' %{resource}",
|
||||
"shareBatchDialogTitle": "Partekatu %{resource} bat |||| Partekatu %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla",
|
||||
"shareSuccess": "URLa arbelera kopiatu da: %{url}",
|
||||
"shareFailure": "Errorea %{url} URLa arbelera kopiatzean",
|
||||
"downloadDialogTitle": "Deskargatu '%{name}' %{resource}, (%{size})",
|
||||
"shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla",
|
||||
"remove_missing_title": "",
|
||||
"remove_missing_content": ""
|
||||
"downloadOriginalFormat": "Deskargatu jatorrizko formatua"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Liburutegia",
|
||||
@@ -436,6 +442,7 @@
|
||||
"language": "Hizkuntza",
|
||||
"defaultView": "Bista, defektuz",
|
||||
"desktop_notifications": "Mahaigaineko jakinarazpenak",
|
||||
"lastfmNotConfigured": "Last.fm-ren API-gakoa ez dago konfiguratuta",
|
||||
"lastfmScrobbling": "Bidali Last.fm-ra erabiltzailearen ohiturak",
|
||||
"listenBrainzScrobbling": "Bidali ListenBrainz-era erabiltzailearen ohiturak",
|
||||
"replaygain": "ReplayGain modua",
|
||||
@@ -444,14 +451,13 @@
|
||||
"none": "Bat ere ez",
|
||||
"album": "Albuma",
|
||||
"track": "Pista"
|
||||
},
|
||||
"lastfmNotConfigured": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Albumak",
|
||||
"about": "Honi buruz",
|
||||
"playlists": "Zerrendak",
|
||||
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak"
|
||||
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
|
||||
"about": "Honi buruz"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Erreprodukzio-zerrenda",
|
||||
@@ -483,10 +489,10 @@
|
||||
"homepage": "Hasierako orria",
|
||||
"source": "Iturburu kodea",
|
||||
"featureRequests": "Eskatu ezaugarria",
|
||||
"lastInsightsCollection": "",
|
||||
"lastInsightsCollection": "Bildutako azken datuak",
|
||||
"insights": {
|
||||
"disabled": "",
|
||||
"waiting": ""
|
||||
"disabled": "Ezgaituta",
|
||||
"waiting": "Zain"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -496,7 +502,10 @@
|
||||
"quickScan": "Arakatze azkarra",
|
||||
"fullScan": "Arakatze sakona",
|
||||
"serverUptime": "Zerbitzariak piztuta daraman denbora",
|
||||
"serverDown": "LINEAZ KANPO"
|
||||
"serverDown": "LINEAZ KANPO",
|
||||
"scanType": "Mota",
|
||||
"status": "Errorea arakatzean",
|
||||
"elapsedTime": "Igarotako denbora"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidromeren laster-teklak",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"size": "Taille",
|
||||
"updatedAt": "Mise à jour",
|
||||
"bitRate": "Bitrate",
|
||||
"sampleRate": "Fréquence d'échantillonnage",
|
||||
"discSubtitle": "Sous-titre du disque",
|
||||
"starred": "Favoris",
|
||||
"comment": "Commentaire",
|
||||
@@ -57,6 +58,7 @@
|
||||
"genre": "Genre",
|
||||
"compilation": "Compilation",
|
||||
"year": "Année",
|
||||
"date": "Date d'enregistrement",
|
||||
"updatedAt": "Mis à jour le",
|
||||
"comment": "Commentaire",
|
||||
"rating": "Classement",
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"size": "Fájlméret",
|
||||
"updatedAt": "Legutóbb frissítve",
|
||||
"bitRate": "Bitráta",
|
||||
"bitDepth": "Bitmélység",
|
||||
"sampleRate": "Mintavételezési frekvencia",
|
||||
"discSubtitle": "Lemezfelirat",
|
||||
"starred": "Kedvenc",
|
||||
"comment": "Megjegyzés",
|
||||
@@ -56,6 +58,7 @@
|
||||
"genre": "Stílus",
|
||||
"compilation": "Válogatásalbum",
|
||||
"year": "Év",
|
||||
"date": "Felvétel dátuma",
|
||||
"updatedAt": "Legutóbb frissítve",
|
||||
"comment": "Megjegyzés",
|
||||
"rating": "Értékelés",
|
||||
@@ -189,6 +192,7 @@
|
||||
"selectPlaylist": "Válassz egy lejátszási listát:",
|
||||
"addNewPlaylist": "\"%{name}\" létrehozása",
|
||||
"export": "Exportálás",
|
||||
"saveQueue": "Műsorlista elmentése lejátszási listaként",
|
||||
"makePublic": "Publikussá tétel",
|
||||
"makePrivate": "Priváttá tétel"
|
||||
},
|
||||
@@ -229,13 +233,15 @@
|
||||
},
|
||||
"missing": {
|
||||
"name": "Hiányzó fájl|||| Hiányzó fájlok",
|
||||
"empty": "Nincsenek hiányzó fájlok",
|
||||
"fields": {
|
||||
"path": "Útvonal",
|
||||
"size": "Méret",
|
||||
"updatedAt": "Eltűnt ekkor:"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Eltávolítás"
|
||||
"remove": "Eltávolítás",
|
||||
"remove_all": "Összes eltávolítása"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Hiányzó fájl(ok) eltávolítva"
|
||||
@@ -395,6 +401,8 @@
|
||||
"noPlaylistsAvailable": "Nem áll rendelkezésre",
|
||||
"delete_user_title": "Felhasználó törlése '%{name}'",
|
||||
"delete_user_content": "Biztos, hogy törölni akarod ezt a felhasználót az adataival (beállítások és lejátszási listák) együtt?",
|
||||
"remove_all_missing_title": "Összes hiányzó fájl eltávolítása",
|
||||
"remove_all_missing_content": "Biztos, hogy minden hiányzó fájlt törölni akarsz az adatbázisból? Ez minden hozzájuk fűződő referenciát törölni fog, beleértve a lejátszásaikat és értékeléseiket.",
|
||||
"notifications_blocked": "A böngésződ beállításaiban letiltottad az értesítéseket erre az oldalra.",
|
||||
"notifications_not_available": "Ez a böngésző nem támogatja az asztali értesítéseket, vagy a Navidrome-ot nem https-en keresztül használod.",
|
||||
"lastfmLinkSuccess": "Sikeresen összekapcsolva Last.fm-el és halgatott számok küldése engedélyezve.",
|
||||
@@ -406,7 +414,7 @@
|
||||
"musicbrainz": "Megnyitás MusicBrainz-ben"
|
||||
},
|
||||
"lastfmLink": "Bővebben...",
|
||||
"listenBrainzLinkSuccess": "Sikeresen összekapcsolva ListenBrainz-el és halgatott számok küldése %{user} felhasználónak engedélyezve.",
|
||||
"listenBrainzLinkSuccess": "Sikeresen összekapcsolva ListenBrainz-el. Halgatott számok küldése %{user} felhasználónak engedélyezve.",
|
||||
"listenBrainzLinkFailure": "Nem lehet kapcsolódni a Listenbrainz-hez: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "ListenBrainz Last.fm leválasztva és a halgatott számok küldése kikapcsolva.",
|
||||
"listenBrainzUnlinkFailure": "Nem sikerült leválasztani a ListenBrainz-et.",
|
||||
@@ -451,7 +459,7 @@
|
||||
"sharedPlaylists": "Megosztott lej. listák"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Lejátszási lista",
|
||||
"playListsText": "Műsorlista",
|
||||
"openText": "Megnyitás",
|
||||
"closeText": "Bezárás",
|
||||
"notContentText": "Nincs zene",
|
||||
@@ -493,7 +501,10 @@
|
||||
"quickScan": "Gyors beolvasás",
|
||||
"fullScan": "Teljes beolvasás",
|
||||
"serverUptime": "Szerver üzemidő",
|
||||
"serverDown": "OFFLINE"
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Típus",
|
||||
"status": "Szkennelési hiba",
|
||||
"elapsedTime": "Eltelt idő"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Gyorsbillentyűk",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"languageName": "Português",
|
||||
"languageName": "Português (Brasil)",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Música |||| Músicas",
|
||||
@@ -33,7 +33,8 @@
|
||||
"participants": "Outros Participantes",
|
||||
"tags": "Outras Tags",
|
||||
"mappedTags": "Tags mapeadas",
|
||||
"rawTags": "Tags originais"
|
||||
"rawTags": "Tags originais",
|
||||
"missing": "Ausente"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Adicionar à fila",
|
||||
@@ -72,7 +73,8 @@
|
||||
"releaseType": "Tipo",
|
||||
"grouping": "Agrupamento",
|
||||
"media": "Mídia",
|
||||
"mood": "Mood"
|
||||
"mood": "Mood",
|
||||
"missing": "Ausente"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Tocar",
|
||||
@@ -104,7 +106,8 @@
|
||||
"rating": "Classificação",
|
||||
"genre": "Gênero",
|
||||
"size": "Tamanho",
|
||||
"role": "Role"
|
||||
"role": "Role",
|
||||
"missing": "Ausente"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Artista do Álbum |||| Artistas do Álbum",
|
||||
@@ -191,6 +194,7 @@
|
||||
"selectPlaylist": "Selecione a playlist:",
|
||||
"addNewPlaylist": "Criar \"%{name}\"",
|
||||
"export": "Exportar",
|
||||
"saveQueue": "Salvar fila em nova Playlist",
|
||||
"makePublic": "Pública",
|
||||
"makePrivate": "Pessoal"
|
||||
},
|
||||
@@ -238,7 +242,8 @@
|
||||
"updatedAt": "Desaparecido em"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Remover"
|
||||
"remove": "Remover",
|
||||
"remove_all": "Remover todos"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Arquivo(s) ausente(s) removido(s)"
|
||||
@@ -422,7 +427,9 @@
|
||||
"downloadDialogTitle": "Baixar %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Copie para o clipboard: Ctrl+C, Enter",
|
||||
"remove_missing_title": "Remover arquivos ausentes",
|
||||
"remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações."
|
||||
"remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.",
|
||||
"remove_all_missing_title": "Remover todos os arquivos ausentes",
|
||||
"remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
@@ -496,7 +503,10 @@
|
||||
"quickScan": "Scan rápido",
|
||||
"fullScan": "Scan completo",
|
||||
"serverUptime": "Uptime do servidor",
|
||||
"serverDown": "DESCONECTADO"
|
||||
"serverDown": "DESCONECTADO",
|
||||
"scanType": "Tipo",
|
||||
"status": "Erro",
|
||||
"elapsedTime": "Duração"
|
||||
},
|
||||
"help": {
|
||||
"title": "Teclas de atalho",
|
||||
@@ -512,4 +522,4 @@
|
||||
"current_song": "Vai para música atual"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
@@ -37,6 +38,9 @@ type StatusInfo struct {
|
||||
LastScan time.Time
|
||||
Count uint32
|
||||
FolderCount uint32
|
||||
LastError string
|
||||
ScanType string
|
||||
ElapsedTime time.Duration
|
||||
}
|
||||
|
||||
func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker,
|
||||
@@ -94,6 +98,7 @@ type ProgressInfo struct {
|
||||
ChangesDetected bool
|
||||
Warning string
|
||||
Error string
|
||||
ForceUpdate bool
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
@@ -113,20 +118,51 @@ type controller struct {
|
||||
changesDetected bool
|
||||
}
|
||||
|
||||
// getScanInfo retrieves scan status from the database
|
||||
func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed time.Duration, lastErr string) {
|
||||
lastErr, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "")
|
||||
scanType, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "")
|
||||
startTimeStr, _ := s.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
|
||||
|
||||
if startTimeStr != "" {
|
||||
startTime, err := time.Parse(time.RFC3339, startTimeStr)
|
||||
if err == nil {
|
||||
if running.Load() {
|
||||
elapsed = time.Since(startTime)
|
||||
} else {
|
||||
// If scan is not running, try to get the last scan time for the library
|
||||
lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library
|
||||
if err == nil {
|
||||
elapsed = lib.LastScanAt.Sub(startTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scanType, elapsed, lastErr
|
||||
}
|
||||
|
||||
func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
|
||||
lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting library: %w", err)
|
||||
}
|
||||
|
||||
scanType, elapsed, lastErr := s.getScanInfo(ctx)
|
||||
|
||||
if running.Load() {
|
||||
status := &StatusInfo{
|
||||
Scanning: true,
|
||||
LastScan: lib.LastScanAt,
|
||||
Count: s.count.Load(),
|
||||
FolderCount: s.folderCount.Load(),
|
||||
LastError: lastErr,
|
||||
ScanType: scanType,
|
||||
ElapsedTime: elapsed,
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
count, folderCount, err := s.getCounters(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting library stats: %w", err)
|
||||
@@ -136,6 +172,9 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
|
||||
LastScan: lib.LastScanAt,
|
||||
Count: uint32(count),
|
||||
FolderCount: uint32(folderCount),
|
||||
LastError: lastErr,
|
||||
ScanType: scanType,
|
||||
ElapsedTime: elapsed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -193,10 +232,14 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin
|
||||
if count, folderCount, err := s.getCounters(ctx); err != nil {
|
||||
return scanWarnings, err
|
||||
} else {
|
||||
scanType, elapsed, lastErr := s.getScanInfo(ctx)
|
||||
s.sendMessage(ctx, &events.ScanStatus{
|
||||
Scanning: false,
|
||||
Count: count,
|
||||
FolderCount: folderCount,
|
||||
Error: lastErr,
|
||||
ScanType: scanType,
|
||||
ElapsedTime: elapsed,
|
||||
})
|
||||
}
|
||||
return scanWarnings, scanError
|
||||
@@ -240,12 +283,17 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres
|
||||
if p.FileCount > 0 {
|
||||
s.folderCount.Add(1)
|
||||
}
|
||||
|
||||
scanType, elapsed, lastErr := s.getScanInfo(ctx)
|
||||
status := &events.ScanStatus{
|
||||
Scanning: true,
|
||||
Count: int64(s.count.Load()),
|
||||
FolderCount: int64(s.folderCount.Load()),
|
||||
Error: lastErr,
|
||||
ScanType: scanType,
|
||||
ElapsedTime: elapsed,
|
||||
}
|
||||
if s.limiter != nil {
|
||||
if s.limiter != nil && !p.ForceUpdate {
|
||||
s.limiter.Do(func() { s.sendMessage(ctx, status) })
|
||||
} else {
|
||||
s.sendMessage(ctx, status)
|
||||
|
||||
57
scanner/controller_test.go
Normal file
57
scanner/controller_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package scanner_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Controller", func() {
|
||||
var ctx context.Context
|
||||
var ds *tests.MockDataStore
|
||||
var ctrl scanner.Scanner
|
||||
|
||||
Describe("Status", func() {
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
db.Init(ctx)
|
||||
DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) })
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
ds.MockedProperty = &tests.MockedPropertyRepo{}
|
||||
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Name: "lib", Path: "/tmp"})).To(Succeed())
|
||||
})
|
||||
|
||||
It("includes last scan error", func() {
|
||||
Expect(ds.Property(ctx).Put(consts.LastScanErrorKey, "boom")).To(Succeed())
|
||||
status, err := ctrl.Status(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(status.LastError).To(Equal("boom"))
|
||||
})
|
||||
|
||||
It("includes scan type and error in status", func() {
|
||||
// Set up test data in property repo
|
||||
Expect(ds.Property(ctx).Put(consts.LastScanErrorKey, "test error")).To(Succeed())
|
||||
Expect(ds.Property(ctx).Put(consts.LastScanTypeKey, "full")).To(Succeed())
|
||||
|
||||
// Get status and verify basic info
|
||||
status, err := ctrl.Status(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(status.LastError).To(Equal("test error"))
|
||||
Expect(status.ScanType).To(Equal("full"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
ppl "github.com/google/go-pipeline/pkg/pipeline"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
@@ -182,7 +184,35 @@ func (p *phaseMissingTracks) finalize(err error) error {
|
||||
if matched > 0 {
|
||||
log.Info(p.ctx, "Scanner: Found moved files", "total", matched, err)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if we should purge missing items
|
||||
if conf.Server.Scanner.PurgeMissing == consts.PurgeMissingAlways || (conf.Server.Scanner.PurgeMissing == consts.PurgeMissingFull && p.state.fullScan) {
|
||||
if err = p.purgeMissing(); err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error purging missing items", err)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *phaseMissingTracks) purgeMissing() error {
|
||||
deletedCount, err := p.ds.MediaFile(p.ctx).DeleteAllMissing()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error deleting missing files: %w", err)
|
||||
}
|
||||
|
||||
if deletedCount > 0 {
|
||||
log.Info(p.ctx, "Scanner: Purged missing items from the database", "mediaFiles", deletedCount)
|
||||
// Set changesDetected to true so that garbage collection will run at the end of the scan process
|
||||
p.state.changesDetected.Store(true)
|
||||
} else {
|
||||
log.Debug(p.ctx, "Scanner: No missing items to purge")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ phase[*missingTracks] = (*phaseMissingTracks)(nil)
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -222,4 +224,66 @@ var _ = Describe("phaseMissingTracks", func() {
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("finalize", func() {
|
||||
It("should return nil if no error", func() {
|
||||
err := phase.finalize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should return the error if provided", func() {
|
||||
err := phase.finalize(context.DeadlineExceeded)
|
||||
Expect(err).To(Equal(context.DeadlineExceeded))
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
})
|
||||
|
||||
When("PurgeMissing is 'always'", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways
|
||||
mr.CountAllValue = 3
|
||||
mr.DeleteAllMissingValue = 3
|
||||
})
|
||||
It("should purge missing files", func() {
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
err := phase.finalize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(state.changesDetected.Load()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
When("PurgeMissing is 'full'", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull
|
||||
mr.CountAllValue = 2
|
||||
mr.DeleteAllMissingValue = 2
|
||||
})
|
||||
It("should not purge missing files if not a full scan", func() {
|
||||
state.fullScan = false
|
||||
err := phase.finalize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
})
|
||||
It("should purge missing files if full scan", func() {
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
state.fullScan = true
|
||||
err := phase.finalize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(state.changesDetected.Load()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
When("PurgeMissing is 'never'", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
||||
mr.CountAllValue = 1
|
||||
mr.DeleteAllMissingValue = 1
|
||||
})
|
||||
It("should not purge missing files", func() {
|
||||
err := phase.finalize(nil)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(state.changesDetected.Load()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,12 +57,21 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
|
||||
startTime := time.Now()
|
||||
log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(libs))
|
||||
|
||||
// Store scan type and start time
|
||||
scanType := "quick"
|
||||
if state.fullScan {
|
||||
scanType = "full"
|
||||
}
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, scanType)
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanStartTimeKey, startTime.Format(time.RFC3339))
|
||||
|
||||
// if there was a full scan in progress, force a full scan
|
||||
if !state.fullScan {
|
||||
for _, lib := range libs {
|
||||
if lib.FullScanInProgress {
|
||||
log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name)
|
||||
state.fullScan = true
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full")
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -100,11 +109,14 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
|
||||
)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err)
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, err.Error())
|
||||
state.sendError(err)
|
||||
s.metrics.WriteAfterScanMetrics(ctx, false)
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, "")
|
||||
|
||||
if state.changesDetected.Load() {
|
||||
state.sendProgress(&ProgressInfo{ChangesDetected: true})
|
||||
}
|
||||
@@ -115,6 +127,7 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
|
||||
|
||||
func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error {
|
||||
return func() error {
|
||||
state.sendProgress(&ProgressInfo{ForceUpdate: true})
|
||||
return s.ds.WithTx(func(tx model.DataStore) error {
|
||||
if state.changesDetected.Load() {
|
||||
start := time.Now()
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
@@ -47,14 +49,15 @@ var _ = Describe("Scanner", Ordered, func() {
|
||||
}
|
||||
|
||||
BeforeAll(func() {
|
||||
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL")
|
||||
log.Warn("Using DB at " + conf.Server.DbPath)
|
||||
//conf.Server.DbPath = ":memory:"
|
||||
db.Db().SetMaxOpenConns(1)
|
||||
})
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
db.Init(ctx)
|
||||
DeferCleanup(func() {
|
||||
Expect(tests.ClearDB()).To(Succeed())
|
||||
@@ -501,6 +504,113 @@ var _ = Describe("Scanner", Ordered, func() {
|
||||
Expect(aa[0].MbzArtistID).To(Equal(beatlesMBID))
|
||||
Expect(aa[0].SortArtistName).To(Equal("Beatles, The"))
|
||||
})
|
||||
|
||||
Context("When PurgeMissing is configured", func() {
|
||||
When("PurgeMissing is set to 'never'", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
||||
})
|
||||
|
||||
It("should mark files as missing but not delete them", func() {
|
||||
By("Running initial scan")
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
By("Removing a file")
|
||||
fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
|
||||
By("Running another scan")
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
By("Checking files are marked as missing but not deleted")
|
||||
count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"missing": true},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(1)))
|
||||
|
||||
mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mf.Missing).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
When("PurgeMissing is set to 'always'", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways
|
||||
})
|
||||
|
||||
It("should purge missing files on any scan", func() {
|
||||
By("Running initial scan")
|
||||
Expect(runScanner(ctx, false)).To(Succeed())
|
||||
|
||||
By("Removing a file")
|
||||
fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
|
||||
By("Running an incremental scan")
|
||||
Expect(runScanner(ctx, false)).To(Succeed())
|
||||
|
||||
By("Checking missing files are deleted")
|
||||
count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"missing": true},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeZero())
|
||||
|
||||
_, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
When("PurgeMissing is set to 'full'", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull
|
||||
})
|
||||
|
||||
It("should not purge missing files on incremental scans", func() {
|
||||
By("Running initial scan")
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
By("Removing a file")
|
||||
fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
|
||||
By("Running an incremental scan")
|
||||
Expect(runScanner(ctx, false)).To(Succeed())
|
||||
|
||||
By("Checking files are marked as missing but not deleted")
|
||||
count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"missing": true},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(1)))
|
||||
|
||||
mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mf.Missing).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should purge missing files only on full scans", func() {
|
||||
By("Running initial scan")
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
By("Removing a file")
|
||||
fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
|
||||
By("Running a full scan")
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
|
||||
By("Checking missing files are deleted")
|
||||
count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"missing": true},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeZero())
|
||||
|
||||
_, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -37,9 +37,12 @@ func (e *baseEvent) Data(evt Event) string {
|
||||
|
||||
type ScanStatus struct {
|
||||
baseEvent
|
||||
Scanning bool `json:"scanning"`
|
||||
Count int64 `json:"count"`
|
||||
FolderCount int64 `json:"folderCount"`
|
||||
Scanning bool `json:"scanning"`
|
||||
Count int64 `json:"count"`
|
||||
FolderCount int64 `json:"folderCount"`
|
||||
Error string `json:"error"`
|
||||
ScanType string `json:"scanType"`
|
||||
ElapsedTime time.Duration `json:"elapsedTime"`
|
||||
}
|
||||
|
||||
type KeepAlive struct {
|
||||
|
||||
@@ -63,25 +63,29 @@ func (r *missingRepository) EntityName() string {
|
||||
}
|
||||
|
||||
func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) {
|
||||
repo := ds.MediaFile(r.Context())
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
err := ds.WithTx(func(tx model.DataStore) error {
|
||||
return repo.DeleteMissing(ids)
|
||||
if len(ids) == 0 {
|
||||
_, err := tx.MediaFile(ctx).DeleteAllMissing()
|
||||
return err
|
||||
}
|
||||
return tx.MediaFile(ctx).DeleteMissing(ids)
|
||||
})
|
||||
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(r.Context(), "Missing file not found", "id", ids[0])
|
||||
log.Warn(ctx, "Missing file not found", "id", ids[0])
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error deleting missing tracks from DB", "ids", ids, err)
|
||||
log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = ds.GC(r.Context())
|
||||
err = ds.GC(ctx)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "Error running GC after deleting missing tracks", err)
|
||||
log.Error(ctx, "Error running GC after deleting missing tracks", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func (api *Router) getArtist(r *http.Request, libId int, ifModifiedSince time.Ti
|
||||
|
||||
var indexes model.ArtistIndexes
|
||||
if lib.LastScanAt.After(ifModifiedSince) {
|
||||
indexes, err = api.ds.Artist(ctx).GetIndex(model.RoleAlbumArtist)
|
||||
indexes, err = api.ds.Artist(ctx).GetIndex(false, model.RoleAlbumArtist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving Indexes", err)
|
||||
return nil, 0, err
|
||||
|
||||
@@ -108,12 +108,19 @@ func SongsByRandom(genre string, fromYear, toYear int) Options {
|
||||
return addDefaultFilters(options)
|
||||
}
|
||||
|
||||
func SongWithArtistTitle(artist, title string) Options {
|
||||
func SongWithLyrics(artist, title string) Options {
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "updated_at",
|
||||
Order: "desc",
|
||||
Max: 1,
|
||||
Filters: And{Eq{"artist": artist, "title": title}},
|
||||
Sort: "updated_at",
|
||||
Order: "desc",
|
||||
Max: 1,
|
||||
Filters: And{
|
||||
Eq{"title": title},
|
||||
NotEq{"lyrics": "[]"},
|
||||
Or{
|
||||
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}),
|
||||
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -224,6 +224,7 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op
|
||||
child.BPM = int32(mf.BPM)
|
||||
child.MediaType = responses.MediaTypeSong
|
||||
child.MusicBrainzId = mf.MbzRecordingID
|
||||
child.Isrc = mf.Tags.Values(model.TagISRC)
|
||||
child.ReplayGain = responses.ReplayGain{
|
||||
TrackGain: mf.RGTrackGain,
|
||||
AlbumGain: mf.RGAlbumGain,
|
||||
|
||||
@@ -23,6 +23,9 @@ func (api *Router) GetScanStatus(r *http.Request) (*responses.Subsonic, error) {
|
||||
Count: int64(status.Count),
|
||||
FolderCount: int64(status.FolderCount),
|
||||
LastScan: &status.LastScan,
|
||||
Error: status.LastError,
|
||||
ScanType: status.ScanType,
|
||||
ElapsedTime: int64(status.ElapsedTime),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
|
||||
response := newResponse()
|
||||
lyricsResponse := responses.Lyrics{}
|
||||
response.Lyrics = &lyricsResponse
|
||||
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithArtistTitle(artist, title))
|
||||
mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithLyrics(artist, title))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"sortName": "sort name",
|
||||
"mediaType": "album",
|
||||
"musicBrainzId": "00000000-0000-0000-0000-000000000000",
|
||||
"isrc": [],
|
||||
"genres": [
|
||||
{
|
||||
"name": "Genre 1"
|
||||
|
||||
@@ -99,6 +99,9 @@
|
||||
"sortName": "sorted song",
|
||||
"mediaType": "song",
|
||||
"musicBrainzId": "4321",
|
||||
"isrc": [
|
||||
"ISRC-1"
|
||||
],
|
||||
"genres": [
|
||||
{
|
||||
"name": "rock"
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<artists id="1" name="artist1"></artists>
|
||||
<artists id="2" name="artist2"></artists>
|
||||
<song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist1 & artist2" displayAlbumArtist="album artist1 & album artist2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<isrc>ISRC-1</isrc>
|
||||
<genres name="rock"></genres>
|
||||
<genres name="progressive"></genres>
|
||||
<replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain>
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
"sortName": "sorted title",
|
||||
"mediaType": "song",
|
||||
"musicBrainzId": "4321",
|
||||
"isrc": [
|
||||
"ISRC-1",
|
||||
"ISRC-2"
|
||||
],
|
||||
"genres": [
|
||||
{
|
||||
"name": "rock"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<directory id="1" name="N">
|
||||
<child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted title" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist 1 & artist 2" displayAlbumArtist="album artist 1 & album artist 2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<isrc>ISRC-1</isrc>
|
||||
<isrc>ISRC-2</isrc>
|
||||
<genres name="rock"></genres>
|
||||
<genres name="progressive"></genres>
|
||||
<replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"sortName": "",
|
||||
"mediaType": "",
|
||||
"musicBrainzId": "",
|
||||
"isrc": [],
|
||||
"genres": [],
|
||||
"replayGain": {},
|
||||
"channelCount": 0,
|
||||
|
||||
@@ -176,6 +176,7 @@ type OpenSubsonicChild struct {
|
||||
SortName string `xml:"sortName,attr,omitempty" json:"sortName"`
|
||||
MediaType MediaType `xml:"mediaType,attr,omitempty" json:"mediaType"`
|
||||
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
|
||||
Isrc Array[string] `xml:"isrc,omitempty" json:"isrc"`
|
||||
Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"`
|
||||
ReplayGain ReplayGain `xml:"replayGain,omitempty" json:"replayGain"`
|
||||
ChannelCount int32 `xml:"channelCount,attr,omitempty" json:"channelCount"`
|
||||
@@ -476,10 +477,13 @@ type Shares struct {
|
||||
}
|
||||
|
||||
type ScanStatus struct {
|
||||
Scanning bool `xml:"scanning,attr" json:"scanning"`
|
||||
Count int64 `xml:"count,attr" json:"count"`
|
||||
FolderCount int64 `xml:"folderCount,attr" json:"folderCount"`
|
||||
LastScan *time.Time `xml:"lastScan,attr,omitempty" json:"lastScan,omitempty"`
|
||||
Scanning bool `xml:"scanning,attr" json:"scanning"`
|
||||
Count int64 `xml:"count,attr" json:"count"`
|
||||
FolderCount int64 `xml:"folderCount,attr" json:"folderCount"`
|
||||
LastScan *time.Time `xml:"lastScan,attr,omitempty" json:"lastScan,omitempty"`
|
||||
Error string `xml:"error,attr,omitempty" json:"error,omitempty"`
|
||||
ScanType string `xml:"scanType,attr,omitempty" json:"scanType,omitempty"`
|
||||
ElapsedTime int64 `xml:"elapsedTime,attr,omitempty" json:"elapsedTime,omitempty"`
|
||||
}
|
||||
|
||||
type Lyrics struct {
|
||||
|
||||
@@ -224,7 +224,8 @@ var _ = Describe("Responses", func() {
|
||||
child[0].OpenSubsonicChild = &OpenSubsonicChild{
|
||||
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
|
||||
Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted title",
|
||||
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
|
||||
Isrc: []string{"ISRC-1", "ISRC-2"},
|
||||
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
|
||||
Moods: []string{"happy", "sad"},
|
||||
ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6},
|
||||
DisplayArtist: "artist 1 & artist 2",
|
||||
@@ -312,6 +313,7 @@ var _ = Describe("Responses", func() {
|
||||
songs[0].OpenSubsonicChild = &OpenSubsonicChild{
|
||||
Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}},
|
||||
Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song",
|
||||
Isrc: []string{"ISRC-1"},
|
||||
Moods: []string{"happy", "sad"},
|
||||
ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6},
|
||||
BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16,
|
||||
|
||||
@@ -22,6 +22,10 @@ type MockMediaFileRepo struct {
|
||||
model.MediaFileRepository
|
||||
Data map[string]*model.MediaFile
|
||||
Err bool
|
||||
// Add fields and methods for controlling CountAll and DeleteAllMissing in tests
|
||||
CountAllValue int64
|
||||
CountAllOptions model.QueryOptions
|
||||
DeleteAllMissingValue int64
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) SetError(err bool) {
|
||||
@@ -161,4 +165,35 @@ func (m *MockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCur
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) CountAll(opts ...model.QueryOptions) (int64, error) {
|
||||
if m.Err {
|
||||
return 0, errors.New("error")
|
||||
}
|
||||
if m.CountAllValue != 0 {
|
||||
if len(opts) > 0 {
|
||||
m.CountAllOptions = opts[0]
|
||||
}
|
||||
return m.CountAllValue, nil
|
||||
}
|
||||
return int64(len(m.Data)), nil
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) DeleteAllMissing() (int64, error) {
|
||||
if m.Err {
|
||||
return 0, errors.New("error")
|
||||
}
|
||||
if m.DeleteAllMissingValue != 0 {
|
||||
return m.DeleteAllMissingValue, nil
|
||||
}
|
||||
// Remove all missing files from Data
|
||||
var count int64
|
||||
for id, mf := range m.Data {
|
||||
if mf.Missing {
|
||||
delete(m.Data, id)
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil)
|
||||
|
||||
@@ -3,4 +3,5 @@ build/
|
||||
prettier.config.js
|
||||
.eslintrc
|
||||
vite.config.js
|
||||
public/3rdparty/workbox
|
||||
public/3rdparty/workbox
|
||||
coverage/
|
||||
3300
ui/package-lock.json
generated
3300
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,8 @@
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ci": "vitest --watch=false",
|
||||
"test:watch": "vitest",
|
||||
"test": "vitest --watch=false",
|
||||
"test:coverage": "vitest run --coverage --watch=false",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint . --ext ts,tsx,js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
@@ -16,7 +16,7 @@
|
||||
"postinstall": "bin/update-workbox.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@material-ui/core": "^4.11.4",
|
||||
"@material-ui/core": "^4.12.4",
|
||||
"@material-ui/icons": "^4.11.3",
|
||||
"@material-ui/lab": "^4.0.0-alpha.58",
|
||||
"@material-ui/styles": "^4.11.5",
|
||||
@@ -35,9 +35,9 @@
|
||||
"react": "^17.0.2",
|
||||
"react-admin": "^3.19.12",
|
||||
"react-dnd": "^14.0.5",
|
||||
"react-dnd-html5-backend": "^14.0.2",
|
||||
"react-dnd-html5-backend": "^14.1.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-drag-listview": "^0.1.8",
|
||||
"react-drag-listview": "^0.1.9",
|
||||
"react-ga": "^3.3.1",
|
||||
"react-hotkeys": "^2.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
@@ -46,7 +46,7 @@
|
||||
"react-redux": "^7.2.9",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"redux": "^4.2.1",
|
||||
"redux-saga": "^1.1.3",
|
||||
"redux-saga": "^1.3.0",
|
||||
"uuid": "^11.1.0",
|
||||
"workbox-cli": "^7.3.0"
|
||||
},
|
||||
@@ -55,27 +55,27 @@
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/react-hooks": "^7.0.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.13.9",
|
||||
"@types/react": "^17.0.83",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/react": "^17.0.86",
|
||||
"@types/react-dom": "^17.0.26",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.0.8",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"happy-dom": "^17.4.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"happy-dom": "^17.4.7",
|
||||
"jsdom": "^26.1.0",
|
||||
"prettier": "^3.5.3",
|
||||
"ra-test": "^3.19.12",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.1",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vitest": "^3.0.8"
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^0.21.2",
|
||||
"vitest": "^3.1.4"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import ReactGA from 'react-ga'
|
||||
import { Provider } from 'react-redux'
|
||||
import { createHashHistory } from 'history'
|
||||
import { Admin as RAAdmin, Resource } from 'react-admin'
|
||||
import {
|
||||
Admin as RAAdmin,
|
||||
Resource,
|
||||
useSetLocale,
|
||||
useRefresh,
|
||||
} from 'react-admin'
|
||||
import { HotKeys } from 'react-hotkeys'
|
||||
import dataProvider from './dataProvider'
|
||||
import authProvider from './authProvider'
|
||||
@@ -22,6 +27,7 @@ import {
|
||||
addToPlaylistDialogReducer,
|
||||
expandInfoDialogReducer,
|
||||
listenBrainzTokenDialogReducer,
|
||||
saveQueueDialogReducer,
|
||||
playerReducer,
|
||||
albumViewReducer,
|
||||
activityReducer,
|
||||
@@ -31,7 +37,7 @@ import {
|
||||
shareDialogReducer,
|
||||
} from './reducers'
|
||||
import createAdminStore from './store/createAdminStore'
|
||||
import { i18nProvider } from './i18n'
|
||||
import { i18nProvider, retrieveTranslation } from './i18n'
|
||||
import config, { shareInfo } from './config'
|
||||
import { keyMap } from './hotkeys'
|
||||
import useChangeThemeColor from './useChangeThemeColor'
|
||||
@@ -39,6 +45,7 @@ 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()
|
||||
|
||||
@@ -62,6 +69,7 @@ const adminStore = createAdminStore({
|
||||
downloadMenuDialog: downloadMenuDialogReducer,
|
||||
expandInfoDialog: expandInfoDialogReducer,
|
||||
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
|
||||
saveQueueDialog: saveQueueDialogReducer,
|
||||
shareDialog: shareDialogReducer,
|
||||
activity: activityReducer,
|
||||
settings: settingsReducer,
|
||||
@@ -76,6 +84,24 @@ 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 (
|
||||
|
||||
@@ -8,6 +8,8 @@ export const EXTENDED_INFO_OPEN = 'EXTENDED_INFO_OPEN'
|
||||
export const EXTENDED_INFO_CLOSE = 'EXTENDED_INFO_CLOSE'
|
||||
export const LISTENBRAINZ_TOKEN_OPEN = 'LISTENBRAINZ_TOKEN_OPEN'
|
||||
export const LISTENBRAINZ_TOKEN_CLOSE = 'LISTENBRAINZ_TOKEN_CLOSE'
|
||||
export const SAVE_QUEUE_OPEN = 'SAVE_QUEUE_OPEN'
|
||||
export const SAVE_QUEUE_CLOSE = 'SAVE_QUEUE_CLOSE'
|
||||
export const DOWNLOAD_MENU_ALBUM = 'album'
|
||||
export const DOWNLOAD_MENU_ARTIST = 'artist'
|
||||
export const DOWNLOAD_MENU_PLAY = 'playlist'
|
||||
@@ -76,3 +78,11 @@ export const openListenBrainzTokenDialog = () => ({
|
||||
export const closeListenBrainzTokenDialog = () => ({
|
||||
type: LISTENBRAINZ_TOKEN_CLOSE,
|
||||
})
|
||||
|
||||
export const openSaveQueueDialog = () => ({
|
||||
type: SAVE_QUEUE_OPEN,
|
||||
})
|
||||
|
||||
export const closeSaveQueueDialog = () => ({
|
||||
type: SAVE_QUEUE_CLOSE,
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ReferenceArrayInput,
|
||||
ReferenceInput,
|
||||
SearchInput,
|
||||
usePermissions,
|
||||
useRefresh,
|
||||
useTranslate,
|
||||
useVersion,
|
||||
@@ -44,6 +45,8 @@ const useStyles = makeStyles({
|
||||
const AlbumFilter = (props) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const { permissions } = usePermissions()
|
||||
const isAdmin = permissions === 'admin'
|
||||
return (
|
||||
<Filter {...props} variant={'outlined'}>
|
||||
<SearchInput id="search" source="name" alwaysOn />
|
||||
@@ -153,6 +156,7 @@ const AlbumFilter = (props) => {
|
||||
defaultValue={true}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && <NullableBooleanInput source="missing" />}
|
||||
</Filter>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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) => ({
|
||||
@@ -25,6 +26,7 @@ const AlbumShowLayout = (props) => {
|
||||
const { loading, ...context } = useShowContext(props)
|
||||
const { record } = context
|
||||
const classes = useStyles()
|
||||
useResourceRefresh('album', 'song')
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -251,175 +251,3 @@ exports[`Details component > Mobile view > renders correctly with year range (st
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > renders correctly in mobile view 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
♫ Mar 15, 2018
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
○ Jun 15, 2020
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > renders correctly with all date fields 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
resources.album.fields.originalDate Mar 15, 2018
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
resources.album.fields.releaseDate Jun 15, 2020
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-6"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > renders correctly with date 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
May 1, 2020
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-2"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > renders correctly with date and originalDate 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
resources.album.fields.originalDate Mar 15, 2018
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-4"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > renders correctly with just year range 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-1"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > renders correctly with originalDate 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
resources.album.fields.originalDate Mar 15, 2018
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-3"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Details component > renders correctly with releaseDate 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
resources.album.fields.releaseDate Jun 15, 2020
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
12 resources.song.name
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span>
|
||||
01:00:00
|
||||
</span>
|
||||
</span>
|
||||
·
|
||||
<span>
|
||||
<span
|
||||
class="makeStyles-root-5"
|
||||
>
|
||||
100 KB
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -11,12 +11,15 @@ import {
|
||||
SelectInput,
|
||||
TextField,
|
||||
useTranslate,
|
||||
NullableBooleanInput,
|
||||
usePermissions,
|
||||
} from 'react-admin'
|
||||
import { useMediaQuery, withWidth } from '@material-ui/core'
|
||||
import FavoriteIcon from '@material-ui/icons/Favorite'
|
||||
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useDrag } from 'react-dnd'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
ArtistContextMenu,
|
||||
List,
|
||||
@@ -49,6 +52,9 @@ const useStyles = makeStyles({
|
||||
},
|
||||
},
|
||||
},
|
||||
missingRow: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
contextMenu: {
|
||||
visibility: 'hidden',
|
||||
},
|
||||
@@ -59,6 +65,8 @@ const useStyles = makeStyles({
|
||||
|
||||
const ArtistFilter = (props) => {
|
||||
const translate = useTranslate()
|
||||
const { permissions } = usePermissions()
|
||||
const isAdmin = permissions === 'admin'
|
||||
const rolesObj = en?.resources?.artist?.roles
|
||||
const roles = Object.keys(rolesObj).reduce((acc, role) => {
|
||||
acc.push({
|
||||
@@ -81,6 +89,7 @@ const ArtistFilter = (props) => {
|
||||
defaultValue={true}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && <NullableBooleanInput source="missing" />}
|
||||
</Filter>
|
||||
)
|
||||
}
|
||||
@@ -95,7 +104,15 @@ const ArtistDatagridRow = (props) => {
|
||||
}),
|
||||
[record],
|
||||
)
|
||||
return <DatagridRow ref={dragArtistRef} {...props} />
|
||||
const classes = useStyles()
|
||||
const computedClasses = clsx(
|
||||
props.className,
|
||||
classes.row,
|
||||
record?.missing && classes.missingRow,
|
||||
)
|
||||
return (
|
||||
<DatagridRow ref={dragArtistRef} {...props} className={computedClasses} />
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistDatagridBody = (props) => (
|
||||
|
||||
@@ -12,7 +12,7 @@ import subsonic from '../subsonic'
|
||||
import AlbumGridView from '../album/AlbumGridView'
|
||||
import MobileArtistDetails from './MobileArtistDetails'
|
||||
import DesktopArtistDetails from './DesktopArtistDetails'
|
||||
import { useAlbumsPerPage } from '../common/index.js'
|
||||
import { useAlbumsPerPage, useResourceRefresh } from '../common/index.js'
|
||||
|
||||
const ArtistDetails = (props) => {
|
||||
const record = useRecordContext(props)
|
||||
@@ -55,6 +55,7 @@ const ArtistShowLayout = (props) => {
|
||||
const record = useRecordContext()
|
||||
const { width } = props
|
||||
const [, perPageOptions] = useAlbumsPerPage(width)
|
||||
useResourceRefresh('artist', 'album')
|
||||
|
||||
const maxPerPage = 90
|
||||
let perPage = 0
|
||||
|
||||
@@ -1,32 +1,120 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useGetOne } from 'react-admin'
|
||||
import { GlobalHotKeys } from 'react-hotkeys'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { RiSaveLine } from 'react-icons/ri'
|
||||
import { LoveButton, useToggleLove } from '../common'
|
||||
import { openSaveQueueDialog } from '../actions'
|
||||
import { keyMap } from '../hotkeys'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
const Placeholder = () => <LoveButton disabled={true} resource={'song'} />
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexGrow: 1,
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.5rem',
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
},
|
||||
mobileListItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
listStyle: 'none',
|
||||
padding: theme.spacing(0.5),
|
||||
margin: 0,
|
||||
height: 24,
|
||||
},
|
||||
button: {
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
mobileButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px',
|
||||
},
|
||||
mobileIcon: {
|
||||
fontSize: '18px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
}))
|
||||
|
||||
const Toolbar = ({ id }) => {
|
||||
const PlayerToolbar = ({ id, isRadio }) => {
|
||||
const dispatch = useDispatch()
|
||||
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 />
|
||||
<LoveButton
|
||||
record={data}
|
||||
resource={'song'}
|
||||
disabled={loading || toggling}
|
||||
/>
|
||||
{isDesktop ? (
|
||||
<li className={`${listItemClass} item`}>
|
||||
{saveQueueButton}
|
||||
{loveButton}
|
||||
</li>
|
||||
) : (
|
||||
<>
|
||||
<li className={`${listItemClass} item`}>{saveQueueButton}</li>
|
||||
<li className={`${listItemClass} item`}>{loveButton}</li>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PlayerToolbar = ({ id, isRadio }) =>
|
||||
id && !isRadio ? <Toolbar id={id} /> : <Placeholder />
|
||||
|
||||
export default PlayerToolbar
|
||||
|
||||
166
ui/src/audioplayer/PlayerToolbar.test.jsx
Normal file
166
ui/src/audioplayer/PlayerToolbar.test.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { useGetOne } from 'react-admin'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useToggleLove } from '../common'
|
||||
import { openSaveQueueDialog } from '../actions'
|
||||
import PlayerToolbar from './PlayerToolbar'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@material-ui/core', async () => {
|
||||
const actual = await import('@material-ui/core')
|
||||
return {
|
||||
...actual,
|
||||
useMediaQuery: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-admin', () => ({
|
||||
useGetOne: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useDispatch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../common', () => ({
|
||||
LoveButton: ({ className, disabled }) => (
|
||||
<button data-testid="love-button" className={className} disabled={disabled}>
|
||||
Love
|
||||
</button>
|
||||
),
|
||||
useToggleLove: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../actions', () => ({
|
||||
openSaveQueueDialog: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-hotkeys', () => ({
|
||||
GlobalHotKeys: () => <div data-testid="global-hotkeys" />,
|
||||
}))
|
||||
|
||||
describe('<PlayerToolbar />', () => {
|
||||
const mockToggleLove = vi.fn()
|
||||
const mockDispatch = vi.fn()
|
||||
const mockSongData = { id: 'song-1', name: 'Test Song', starred: false }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: false })
|
||||
useToggleLove.mockReturnValue([mockToggleLove, false])
|
||||
useDispatch.mockReturnValue(mockDispatch)
|
||||
openSaveQueueDialog.mockReturnValue({ type: 'OPEN_SAVE_QUEUE_DIALOG' })
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Desktop layout', () => {
|
||||
beforeEach(() => {
|
||||
useMediaQuery.mockReturnValue(true) // isDesktop = true
|
||||
})
|
||||
|
||||
it('renders desktop toolbar with both buttons', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
// Both buttons should be in a single list item
|
||||
const listItems = screen.getAllByRole('listitem')
|
||||
expect(listItems).toHaveLength(1)
|
||||
|
||||
// Verify both buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
|
||||
// Verify desktop classes are applied
|
||||
expect(listItems[0].className).toContain('toolbar')
|
||||
})
|
||||
|
||||
it('disables save queue button when isRadio is true', () => {
|
||||
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
expect(saveQueueButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables love button when conditions are met', () => {
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('opens save queue dialog when save button is clicked', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
fireEvent.click(saveQueueButton)
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'OPEN_SAVE_QUEUE_DIALOG',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile layout', () => {
|
||||
beforeEach(() => {
|
||||
useMediaQuery.mockReturnValue(false) // isDesktop = false
|
||||
})
|
||||
|
||||
it('renders mobile toolbar with buttons in separate list items', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
// Each button should be in its own list item
|
||||
const listItems = screen.getAllByRole('listitem')
|
||||
expect(listItems).toHaveLength(2)
|
||||
|
||||
// Verify both buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
|
||||
// Verify mobile classes are applied
|
||||
expect(listItems[0].className).toContain('mobileListItem')
|
||||
expect(listItems[1].className).toContain('mobileListItem')
|
||||
})
|
||||
|
||||
it('disables save queue button when isRadio is true', () => {
|
||||
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
expect(saveQueueButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables love button when conditions are met', () => {
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Common behavior', () => {
|
||||
it('renders global hotkeys in both layouts', () => {
|
||||
// Test desktop layout
|
||||
useMediaQuery.mockReturnValue(true)
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
|
||||
|
||||
// Cleanup and test mobile layout
|
||||
cleanup()
|
||||
useMediaQuery.mockReturnValue(false)
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables buttons when id is not provided', () => {
|
||||
render(<PlayerToolbar />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -44,7 +44,9 @@ const parseAndReplaceArtists = (
|
||||
result.push(displayAlbumArtist.slice(lastIndex, index))
|
||||
}
|
||||
// Add the artist link
|
||||
result.push(<ALink artist={artist} className={className} />)
|
||||
result.push(
|
||||
<ALink artist={artist} className={className} key={artist.id} />,
|
||||
)
|
||||
lastIndex = index + artist.name.length
|
||||
}
|
||||
})
|
||||
|
||||
@@ -25,7 +25,10 @@ export const CollapsibleComment = ({ record }) => {
|
||||
const classes = useStyles()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const lines = record.comment.split('\n')
|
||||
const lines = useMemo(
|
||||
() => record.comment?.split('\n') || [],
|
||||
[record.comment],
|
||||
)
|
||||
const formatted = useMemo(() => {
|
||||
return lines.map((line, idx) => (
|
||||
<span key={record.id + '-comment-' + idx}>
|
||||
|
||||
@@ -23,7 +23,8 @@ const mapResource = (resource, params) => {
|
||||
return [`playlist/${plsId}/tracks`, params]
|
||||
}
|
||||
case 'album':
|
||||
case 'song': {
|
||||
case 'song':
|
||||
case 'artist': {
|
||||
if (params.filter && !isAdmin()) {
|
||||
params.filter.missing = false
|
||||
}
|
||||
@@ -35,9 +36,9 @@ const mapResource = (resource, params) => {
|
||||
}
|
||||
|
||||
const callDeleteMany = (resource, params) => {
|
||||
const ids = params.ids.map((id) => `id=${id}`)
|
||||
const idsParam = ids.join('&')
|
||||
return httpClient(`${REST_URL}/${resource}?${idsParam}`, {
|
||||
const ids = (params.ids || []).map((id) => `id=${id}`)
|
||||
const query = ids.length > 0 ? `?${ids.join('&')}` : ''
|
||||
return httpClient(`${REST_URL}/${resource}${query}`, {
|
||||
method: 'DELETE',
|
||||
}).then((response) => ({ data: response.json.ids || [] }))
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ 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 />
|
||||
|
||||
117
ui/src/dialogs/SaveQueueDialog.jsx
Normal file
117
ui/src/dialogs/SaveQueueDialog.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
91
ui/src/dialogs/SaveQueueDialog.test.jsx
Normal file
91
ui/src/dialogs/SaveQueueDialog.test.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './AboutDialog'
|
||||
export * from './SelectPlaylistInput'
|
||||
export * from './ListenBrainzTokenDialog'
|
||||
export * from './SaveQueueDialog'
|
||||
export * from './Dialogs'
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"participants": "Additional participants",
|
||||
"tags": "Additional Tags",
|
||||
"mappedTags": "Mapped tags",
|
||||
"rawTags": "Raw tags"
|
||||
"rawTags": "Raw tags",
|
||||
"missing": "Missing"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Play Later",
|
||||
@@ -73,7 +74,8 @@
|
||||
"releaseType": "Type",
|
||||
"grouping": "Grouping",
|
||||
"media": "Media",
|
||||
"mood": "Mood"
|
||||
"mood": "Mood",
|
||||
"missing": "Missing"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Play",
|
||||
@@ -105,7 +107,8 @@
|
||||
"playCount": "Plays",
|
||||
"rating": "Rating",
|
||||
"genre": "Genre",
|
||||
"role": "Role"
|
||||
"role": "Role",
|
||||
"missing": "Missing"
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "Album Artist |||| Album Artists",
|
||||
@@ -192,6 +195,7 @@
|
||||
"selectPlaylist": "Select a playlist:",
|
||||
"addNewPlaylist": "Create \"%{name}\"",
|
||||
"export": "Export",
|
||||
"saveQueue": "Save Queue to Playlist",
|
||||
"makePublic": "Make Public",
|
||||
"makePrivate": "Make Private"
|
||||
},
|
||||
@@ -233,7 +237,7 @@
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Missing File|||| Missing Files",
|
||||
"name": "Missing File |||| Missing Files",
|
||||
"empty": "No Missing Files",
|
||||
"fields": {
|
||||
"path": "Path",
|
||||
@@ -241,7 +245,8 @@
|
||||
"updatedAt": "Disappeared on"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Remove"
|
||||
"remove": "Remove",
|
||||
"remove_all": "Remove All"
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Missing file(s) removed"
|
||||
@@ -403,6 +408,8 @@
|
||||
"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",
|
||||
@@ -499,7 +506,10 @@
|
||||
"quickScan": "Quick Scan",
|
||||
"fullScan": "Full Scan",
|
||||
"serverUptime": "Server Uptime",
|
||||
"serverDown": "OFFLINE"
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Type",
|
||||
"status": "Scan Error",
|
||||
"elapsedTime": "Elapsed Time"
|
||||
},
|
||||
"help": {
|
||||
"title": "Navidrome Hotkeys",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useNotify, useTranslate } from 'react-admin'
|
||||
import {
|
||||
Popover,
|
||||
@@ -12,15 +12,17 @@ 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 { scanStatusUpdate } from '../actions'
|
||||
import { useInitialScanStatus } from './useInitialScanStatus'
|
||||
import { useInterval } from '../common'
|
||||
import { formatDuration } from '../utils'
|
||||
import { useScanElapsedTime } from './useScanElapsedTime'
|
||||
import { formatDuration, formatShortDuration } from '../utils'
|
||||
import config from '../config'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
@@ -40,7 +42,16 @@ const useStyles = makeStyles((theme) => ({
|
||||
zIndex: 2,
|
||||
},
|
||||
counterStatus: {
|
||||
minWidth: '15em',
|
||||
minWidth: '20em',
|
||||
},
|
||||
error: {
|
||||
color: theme.palette.error.main,
|
||||
},
|
||||
card: {
|
||||
maxWidth: 'none',
|
||||
},
|
||||
cardContent: {
|
||||
padding: theme.spacing(2, 3),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -59,41 +70,52 @@ const Uptime = () => {
|
||||
const ActivityPanel = () => {
|
||||
const serverStart = useSelector((state) => state.activity.serverStart)
|
||||
const up = serverStart.startTime
|
||||
const classes = useStyles({ up })
|
||||
const scanStatus = useSelector((state) => state.activity.scanStatus)
|
||||
const elapsed = useScanElapsedTime(
|
||||
scanStatus.scanning,
|
||||
scanStatus.elapsedTime,
|
||||
)
|
||||
const classes = useStyles({ up: up && !scanStatus.error })
|
||||
const translate = useTranslate()
|
||||
const notify = useNotify()
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const open = Boolean(anchorEl)
|
||||
const dispatch = useDispatch()
|
||||
const scanStatus = useSelector((state) => state.activity.scanStatus)
|
||||
useInitialScanStatus()
|
||||
|
||||
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={translate('activity.title')}>
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<IconButton className={classes.button} onClick={handleMenuOpen}>
|
||||
{up ? <FiActivity size={'20'} /> : <BiError size={'20'} />}
|
||||
{!up || scanStatus.error ? (
|
||||
<BiError size={'20'} />
|
||||
) : (
|
||||
<FiActivity size={'20'} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{scanStatus.scanning && (
|
||||
@@ -113,8 +135,8 @@ const ActivityPanel = () => {
|
||||
open={open}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Card className={classes.card}>
|
||||
<CardContent className={classes.cardContent}>
|
||||
<Box display="flex" className={classes.counterStatus}>
|
||||
<Box component="span" flex={2}>
|
||||
{translate('activity.serverUptime')}:
|
||||
@@ -125,7 +147,7 @@ const ActivityPanel = () => {
|
||||
</Box>
|
||||
</CardContent>
|
||||
<Divider />
|
||||
<CardContent>
|
||||
<CardContent className={classes.cardContent}>
|
||||
<Box display="flex" className={classes.counterStatus}>
|
||||
<Box component="span" flex={2}>
|
||||
{translate('activity.totalScanned')}:
|
||||
@@ -134,6 +156,38 @@ 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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Field, Form } from 'react-final-form'
|
||||
import { useDispatch } from 'react-redux'
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
createMuiTheme,
|
||||
useLogin,
|
||||
useNotify,
|
||||
useRefresh,
|
||||
useSetLocale,
|
||||
useTranslate,
|
||||
useVersion,
|
||||
} from 'react-admin'
|
||||
@@ -24,7 +22,6 @@ 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(
|
||||
@@ -400,27 +397,8 @@ 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} />
|
||||
|
||||
18
ui/src/layout/useInitialScanStatus.jsx
Normal file
18
ui/src/layout/useInitialScanStatus.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
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])
|
||||
}
|
||||
26
ui/src/layout/useScanElapsedTime.jsx
Normal file
26
ui/src/layout/useScanElapsedTime.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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
|
||||
}
|
||||
135
ui/src/layout/useScanElapsedTime.test.jsx
Normal file
135
ui/src/layout/useScanElapsedTime.test.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import DeleteIcon from '@material-ui/icons/Delete'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { fade } from '@material-ui/core/styles/colorManipulator'
|
||||
import { makeStyles, alpha } from '@material-ui/core/styles'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Button,
|
||||
@@ -17,7 +16,7 @@ const useStyles = makeStyles(
|
||||
deleteButton: {
|
||||
color: theme.palette.error.main,
|
||||
'&:hover': {
|
||||
backgroundColor: fade(theme.palette.error.main, 0.12),
|
||||
backgroundColor: alpha(theme.palette.error.main, 0.12),
|
||||
// Reset on mouse devices
|
||||
'@media (hover: none)': {
|
||||
backgroundColor: 'transparent',
|
||||
@@ -29,13 +28,14 @@ const useStyles = makeStyles(
|
||||
)
|
||||
|
||||
const DeleteMissingFilesButton = (props) => {
|
||||
const { selectedIds, className } = props
|
||||
const { selectedIds, className, deleteAll = false } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const unselectAll = useUnselectAll()
|
||||
const refresh = useRefresh()
|
||||
const notify = useNotify()
|
||||
|
||||
const [deleteMany, { loading }] = useDeleteMany('missing', selectedIds, {
|
||||
const ids = deleteAll ? [] : selectedIds
|
||||
const [deleteMany, { loading }] = useDeleteMany('missing', ids, {
|
||||
onSuccess: () => {
|
||||
notify('resources.missing.notifications.removed')
|
||||
refresh()
|
||||
@@ -57,7 +57,11 @@ const DeleteMissingFilesButton = (props) => {
|
||||
<>
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
label="ra.action.remove"
|
||||
label={
|
||||
deleteAll
|
||||
? 'resources.missing.actions.remove_all'
|
||||
: 'ra.action.remove'
|
||||
}
|
||||
key="button"
|
||||
className={clsx('ra-delete-button', classes.deleteButton, className)}
|
||||
>
|
||||
@@ -66,8 +70,16 @@ const DeleteMissingFilesButton = (props) => {
|
||||
<Confirm
|
||||
isOpen={open}
|
||||
loading={loading}
|
||||
title="message.remove_missing_title"
|
||||
content="message.remove_missing_content"
|
||||
title={
|
||||
deleteAll
|
||||
? 'message.remove_all_missing_title'
|
||||
: 'message.remove_missing_title'
|
||||
}
|
||||
content={
|
||||
deleteAll
|
||||
? 'message.remove_all_missing_content'
|
||||
: 'message.remove_missing_content'
|
||||
}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={handleDialogClose}
|
||||
/>
|
||||
|
||||
42
ui/src/missing/DeleteMissingFilesButton.test.jsx
Normal file
42
ui/src/missing/DeleteMissingFilesButton.test.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
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),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { List, SizeField } from '../common/index'
|
||||
import { List, SizeField, useResourceRefresh } from '../common/index'
|
||||
import {
|
||||
Datagrid,
|
||||
DateField,
|
||||
@@ -8,6 +8,7 @@ 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) => {
|
||||
@@ -30,11 +31,13 @@ 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 />}
|
||||
|
||||
12
ui/src/missing/MissingListActions.jsx
Normal file
12
ui/src/missing/MissingListActions.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
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
|
||||
@@ -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 } from '../common'
|
||||
import { Title, canChangeTracks, useResourceRefresh } from '../common'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
@@ -27,6 +27,7 @@ const PlaylistShowLayout = (props) => {
|
||||
const { loading, ...context } = useShowContext(props)
|
||||
const { record } = context
|
||||
const classes = useStyles()
|
||||
useResourceRefresh('song')
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -6,15 +6,24 @@ import {
|
||||
import config from '../config'
|
||||
|
||||
const initialState = {
|
||||
scanStatus: { scanning: false, folderCount: 0, count: 0 },
|
||||
scanStatus: {
|
||||
scanning: false,
|
||||
folderCount: 0,
|
||||
count: 0,
|
||||
error: '',
|
||||
elapsedTime: 0,
|
||||
},
|
||||
serverStart: { version: config.version },
|
||||
}
|
||||
|
||||
export const activityReducer = (previousState = initialState, payload) => {
|
||||
const { type, data } = payload
|
||||
|
||||
switch (type) {
|
||||
case EVENT_SCAN_STATUS:
|
||||
return { ...previousState, scanStatus: data }
|
||||
case EVENT_SCAN_STATUS: {
|
||||
const elapsedTime = Number(data.elapsedTime) || 0
|
||||
return { ...previousState, scanStatus: { ...data, elapsedTime } }
|
||||
}
|
||||
case EVENT_SERVER_START:
|
||||
return {
|
||||
...previousState,
|
||||
|
||||
119
ui/src/reducers/activityReducer.test.js
Normal file
119
ui/src/reducers/activityReducer.test.js
Normal file
@@ -0,0 +1,119 @@
|
||||
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'),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
EXTENDED_INFO_CLOSE,
|
||||
LISTENBRAINZ_TOKEN_OPEN,
|
||||
LISTENBRAINZ_TOKEN_CLOSE,
|
||||
SAVE_QUEUE_OPEN,
|
||||
SAVE_QUEUE_CLOSE,
|
||||
SHARE_MENU_OPEN,
|
||||
SHARE_MENU_CLOSE,
|
||||
} from '../actions'
|
||||
@@ -169,3 +171,18 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
SearchInput,
|
||||
TextField,
|
||||
useTranslate,
|
||||
NullableBooleanInput,
|
||||
usePermissions,
|
||||
} from 'react-admin'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import FavoriteIcon from '@material-ui/icons/Favorite'
|
||||
@@ -67,6 +69,8 @@ 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 />
|
||||
@@ -121,6 +125,7 @@ const SongFilter = (props) => {
|
||||
defaultValue={true}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && <NullableBooleanInput source="missing" />}
|
||||
</Filter>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export const SongListActions = ({
|
||||
const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
|
||||
return (
|
||||
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
|
||||
<ShuffleAllButton filters={filterValues} />
|
||||
{filters &&
|
||||
cloneElement(filters, {
|
||||
resource,
|
||||
@@ -32,7 +33,6 @@ export const SongListActions = ({
|
||||
filterValues,
|
||||
context: 'button',
|
||||
})}
|
||||
<ShuffleAllButton filters={filterValues} />
|
||||
{isNotSmall && <ToggleFieldsMenu resource="song" />}
|
||||
</TopToolbar>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import DeleteIcon from '@material-ui/icons/Delete'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { fade } from '@material-ui/core/styles/colorManipulator'
|
||||
import { makeStyles, alpha } from '@material-ui/core/styles'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
useDeleteWithConfirmController,
|
||||
@@ -16,7 +15,7 @@ const useStyles = makeStyles(
|
||||
deleteButton: {
|
||||
color: theme.palette.error.main,
|
||||
'&:hover': {
|
||||
backgroundColor: fade(theme.palette.error.main, 0.12),
|
||||
backgroundColor: alpha(theme.palette.error.main, 0.12),
|
||||
// Reset on mouse devices
|
||||
'@media (hover: none)': {
|
||||
backgroundColor: 'transparent',
|
||||
|
||||
@@ -25,6 +25,26 @@ export const formatDuration = (d) => {
|
||||
return `${days > 0 ? days + ':' : ''}${f}`
|
||||
}
|
||||
|
||||
export const formatShortDuration = (ns) => {
|
||||
// Convert nanoseconds to seconds
|
||||
const seconds = ns / 1e9
|
||||
if (seconds < 1.0) {
|
||||
return '<1s'
|
||||
}
|
||||
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h${minutes}m`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m${secs}s`
|
||||
}
|
||||
return `${secs}s`
|
||||
}
|
||||
|
||||
export const formatFullDate = (date, locale) => {
|
||||
const dashes = date.split('-').length - 1
|
||||
let options = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user