Compare commits

...

58 Commits

Author SHA1 Message Date
Deluan
fa2cf36245 fix(subsonic): change role filter logic
fix #4140

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-29 14:54:09 -04:00
Caio Cotts
b19d5f0d3e Merge commit from fork 2025-05-28 19:00:20 -04:00
Deluan
175964b17a fix(ui): refine playlist details layout and disable play date display for mobile
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-28 18:39:20 -04:00
Deluan Quintão
90b095b409 fix(ui): update German, Greek, Esperanto, Spanish, Finnish, French, Indonesian, Dutch, Portuguese (BR), Russian, Swedish, Turkish, Ukrainian translations from POEditor (#3981)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-05-28 17:46:34 -04:00
Deluan
821f485022 fix(ui): improve playlist details layout with word break and stats styling
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-28 17:33:35 -04:00
Deluan Quintão
d4a053370a feat(server): add option Lastfm.ScrobbleFirstArtistOnly to send only the first artist (#4131)
fixes #3791

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-28 08:43:07 -04:00
ChekeredList71
66926ca466 fix(ui): update Hungarian translation (#4113)
added "missing" strings

Co-authored-by: peter <asd@>
2025-05-27 21:42:25 -04:00
Deluan
1f9cbe7345 feat(server): add M3U file to downloaded playlist
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-27 20:13:37 -04:00
Deluan
de698918ac Revert "fix(server): failed transcoded files should not be cached (#4124)"
This reverts commit 9dd5a8c334.
2025-05-27 19:53:10 -04:00
Deluan
71851b076c refactor: unify logic to export to M3U8
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-27 12:37:57 -04:00
Deluan Quintão
85a7268192 fix(ui): update titles for radios, shares and show pages (#4128) 2025-05-27 09:01:52 -04:00
Deluan Quintão
9dd5a8c334 fix(server): failed transcoded files should not be cached (#4124)
* Close stream on caching errors

* fix(test): replace errPartialReader with errFakeReader to fix lint error

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(test): update error assertion to check for substring in closed file error

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-26 20:30:26 -04:00
Deluan
030710afa9 fix(ui): enhance external link display with consistent minimum heights
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-26 18:21:55 -04:00
Kendall Garner
5050250902 fix(share): force share image to be square (to fix aspect ratio) (#4122)
* fix(ui): update artist link rendering and improve button styles

Signed-off-by: Deluan <deluan@navidrome.org>

* square share player

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-05-26 17:39:05 -04:00
Deluan
fb32cfd7db fix(ui): fix Reading mediafile(id:undefined): data not found error
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-26 12:32:37 -04:00
Deluan Quintão
d26e2e29a6 feat(ui): add smooth image transitions to album and artist artwork (#4120)
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-26 08:57:37 -04:00
Deluan
5c4fbdb7c1 feat(ui): add playlist cover art display
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-25 23:22:55 -04:00
Deluan
0cb02bce06 test: improve test reliability with longer sleep durations and generous tolerances
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-25 22:03:55 -04:00
Deluan
fe1ed582bc build(makefile): add golangci-lint installation step to setup
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-25 20:24:51 -04:00
Deluan Quintão
5e2db2c673 fix(server): fix numeric comparisons for float custom tags in smart playlists (#4116)
* Fix numeric comparisons for custom float tags

* feat(criteria): cast numeric tags for sorting and comparisons

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-25 17:52:27 -04:00
dependabot[bot]
fac9275c27 chore(deps): bump eslint-config-prettier from 9.1.0 to 10.1.5 in /ui (#4077)
Bumps [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) from 9.1.0 to 10.1.5.
- [Release notes](https://github.com/prettier/eslint-config-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-config-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-config-prettier/compare/v9.1.0...v10.1.5)

---
updated-dependencies:
- dependency-name: eslint-config-prettier
  dependency-version: 10.1.5
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-24 23:10:46 -04:00
dependabot[bot]
6b3afc03cc build(deps): bump golangci/golangci-lint-action in /.github/workflows (#4035)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 7 to 8.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v7...v8)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-24 23:05:47 -04:00
Deluan
35599230ff test: update test command to run without watch mode
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-24 22:58:04 -04:00
Deluan
13ea00e7f8 chore(deps): update JS dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-24 22:55:53 -04:00
Deluan
f7fb77054f build(makefile): fix golangci-lint installation path check
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-24 22:40:33 -04:00
Deluan
441c9f52cc chore(deps): update Go dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-24 22:36:05 -04:00
Deluan Quintão
b722f0dcfc fix(ui): improve scan status handling (again) (#4115) 2025-05-24 21:26:05 -04:00
Deluan
c98e4d02cb feat(ui): add missing filter for admin users in album, artist, and song lists
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-24 13:06:36 -04:00
Xabi
5ade9344ff fix(ui): update Basque translation (#4064)
* Update eu.json

Added roles, reordered some strings, small fixes

* fix(ui): update Basque translation

* Update eu.json

third time's the charm

* please bear with me

I'm not a developer. I'm trying my hardest.

* Update eu.json

Added newest strings
2025-05-24 12:53:51 -04:00
ChekeredList71
d903d3f1e0 fix(ui): update Hungarian translation (#4112)
added: bitDepth, sampleRate, album/date, saveQueue, missing/empty, actions/remove_all, remove_all_missing_title, remove_all_missing_content, scanType, status, elapsedTime

edited (better sentence structuring, making it make more sense, etc.): listenBrainzLinkSuccess, playListsText

Co-authored-by: ChekeredList71 <asd@asd.com>
2025-05-24 12:38:12 -04:00
Deluan
6bf6424864 fix(scanner): optimize missing flag update logic for artists
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-24 12:31:12 -04:00
Deluan
a9f93c97e1 fix(ui): improve elapsed time handling during scans
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-24 10:13:01 -04:00
Deluan
3350e6c115 fix(ui): elapsed time for scans
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-23 23:28:02 -04:00
Deluan Quintão
514aceb785 feat(ui) add Save Queue to Playlist (#4110)
* ui: add save queue to playlist

* fix(ui): improve toolbar layout

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): add loading state to save queue dialog

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ui): refresh playlist after saving queue

Signed-off-by: Deluan <deluan@navidrome.org>

* fix lint

Signed-off-by: Deluan <deluan@navidrome.org>

* remove duplication in PlayerToolbar and add tests

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(i18n): update save queue text for clarity in English and Portuguese

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-23 22:04:18 -04:00
Deluan
370f8ba293 fix(ui): update artist link rendering and improve button styles
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-23 17:42:19 -04:00
Deluan
1e4c759d93 test: fix flaky scanner tests by setting maximum open connections to 1
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-23 15:39:44 -04:00
Ewen
e06fbd26b7 fix(ui): update French translation (#4069)
Signed-off-by: Malesio <krytonspace@gmail.com>
2025-05-23 10:56:47 -04:00
Deluan
9062f4824e fix(ui): the Portuguese translation is actually Brazilian Portuguese
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-23 09:11:07 -04:00
Deluan
2503d2dbb8 fix: small formatting error in en.json
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-23 09:04:41 -04:00
Deluan
45188e710c fix(ui): update Portuguese translations
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-23 08:14:53 -04:00
Deluan
9dd050c377 fix: add useResourceRefresh hook to AlbumShow, ArtistShow, MissingFilesList, and PlaylistShow components
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-23 00:02:42 -04:00
Deluan Quintão
3ccc02f375 feat(ui): add remove all missing files functionality (#4108)
* Add remove all missing files feature

* test: update mediafile_repository tests for missing files deletion

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-22 22:28:10 -04:00
Copilot
992c78376c feat(scanner): add Scanner.PurgeMissing configuration option (#4107)
* Initial plan for issue

* Add Scanner.PurgeMissing configuration option

Co-authored-by: deluan <331353+deluan@users.noreply.github.com>

* Remove GC call from phaseMissingTracks.purgeMissing method

Co-authored-by: deluan <331353+deluan@users.noreply.github.com>

* Address PR comments for Scanner.PurgeMissing feature

Co-authored-by: deluan <331353+deluan@users.noreply.github.com>

* Address PR comments and add DeleteAllMissing method

Co-authored-by: deluan <331353+deluan@users.noreply.github.com>

* refactor(scanner): simplify purgeMissing logic and improve error handling

Signed-off-by: Deluan <deluan@navidrome.org>

* fix configuration test

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: deluan <331353+deluan@users.noreply.github.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-05-22 20:50:15 -04:00
Deluan Quintão
4a2412eef7 test: add PERFORMER tests (#4105)
* Add performer participant tests with MBIDs

* test: add handling for mismatched performer names and MBIDs in participant tests

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-22 16:41:08 -04:00
Deluan Quintão
98fdc42d09 test: fix ignored artwork tests (#4103)
* Fix artwork internal tests

* fix: rename artistReader functions to artistArtworkReader for clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: update artwork internal tests to handle corrupted cover scenarios

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-22 15:48:24 -04:00
Deluan
eb944bd261 chore: update Makefile to install golangci-lint if not present and adjust lint command
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-21 23:13:32 -04:00
Deluan
84384006a4 docs: update copilot instructions with important commands and linting guidelines
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-21 22:33:33 -04:00
Deluan Quintão
e5438552c6 fix(transcoding): restrict transcoding operations to admin users (#4096)
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-21 22:19:23 -04:00
Kendall Garner
6ac3acaaf8 fix(db): allow deleting users that have shares (#4098)
* fix(db): allow deleting users that have shares

* remove placeholders
2025-05-21 22:16:10 -04:00
Deluan
3953e3217d docs: add code guidelines for backend and frontend development
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-21 21:57:24 -04:00
Deluan Quintão
6731787053 fix(server): memory leak in cache warmer (#4095)
* Prevent cache warmer memory leak when cache disabled

* refactor(tests): replace disabledCache with mockFileCache in CacheWarmer tests

Signed-off-by: Deluan <deluan@navidrome.org>

* test(cache): enhance CacheWarmer tests for initialization, buffer management, and error handling

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-21 21:48:49 -04:00
Deluan
dd1d3907b4 Revert "refactor(server): simplify lastfm agent initialization logic"
This reverts commit 6f52c0201c.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-21 16:45:30 -04:00
Kendall Garner
924354eb4b fix(subsonic): find lyrics by artist or albumartist (#4093)
* find artist by multivalued exact match, instead of 'artist' field

* check if lyrics are not empty

* refactor(filters): rename function to better reflect its purpose

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-05-21 09:36:26 -04:00
Deluan Quintão
6880cffd16 feat(ui): add scan progress and error reporting to UI (#4094)
* feat(scanner): add LastScanError tracking to scanner status

- Introduced LastScanErrorKey constant for error tracking.
- Updated StatusInfo struct to include LastError field.
- Modified scanner logic to store and retrieve last scan error.
- Enhanced ScanStatus response to include error information.
- Updated UI components to display last scan error when applicable.
- Added tests to verify last scan error functionality.

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(scanner): enhance scan status with type and elapsed time tracking

- Added LastScanTypeKey and LastScanStartTimeKey constants for tracking scan type and start time.
- Updated StatusInfo struct to include ScanType and ElapsedTime fields.
- Implemented getScanInfo method to retrieve scan type, elapsed time, and last error.
- Modified scanner logic to store scan type and start time during scans.
- Enhanced ScanStatus response and UI components to display scan type and elapsed time.
- Added formatShortDuration utility for better elapsed time representation.
- Updated activity reducer to handle new scan status fields.

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(tests): consolidate controller status tests into a single file

- Removed the old controller_status_test.go file.
- Merged relevant tests into the new controller_test.go file for better organization and maintainability.
- Ensured all existing test cases for controller status are preserved and functional.

Signed-off-by: Deluan <deluan@navidrome.org>

* Fix formatting issues

* refactor(scanner): update getScanInfo method documentation

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-21 09:30:23 -04:00
Caio Cotts
fef1739c1a feat(server): add DefaultShareExpiration config option (#4082)
* add DefaultShareExpiration config option

* run prettier so that I can push

* undo reformatting

* sort imports
2025-05-20 22:17:30 -04:00
Deluan Quintão
453630d430 feat: hide missing artists from regular users and Subsonic API (#4092)
* Handle missing artists for non-admin users

* feat(artist): enhance ArtistList with missing row styling and class management

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-20 21:53:02 -04:00
Deluan
4733616d90 chore: removed unused file
Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-20 21:25:33 -04:00
Deluan Quintão
ba7fd13724 feat(subsonic): add ISRC support for OpenSubsonic Child (#4088)
* docs: add testing and logging guidelines to AGENTS.md

Signed-off-by: Deluan <deluan@navidrome.org>

* Introduce TagISRC and update ISRC handling

* fix: update .gitignore to exclude executable files and bin directory

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-05-20 12:37:27 -04:00
135 changed files with 5045 additions and 2841 deletions

53
.github/copilot-instructions.md vendored Normal file
View 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

View File

@@ -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
View File

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

View File

@@ -19,7 +19,7 @@ CROSS_TAGLIB_VERSION ?= 2.0.2-1
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First Install dependencies and prepare development environment
@echo Downloading Node dependencies...
@(cd ./ui && npm ci)
.PHONY: setup
@@ -46,11 +46,15 @@ testrace: ##@Development Run Go tests with race detector
.PHONY: test
testall: testrace ##@Development Run Go and JS tests
@(cd ./ui && npm run test:ci)
@(cd ./ui && npm run test)
.PHONY: testall
lint: ##@Development Lint Go code
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run -v --timeout 5m
install-golangci-lint: ##@Development Install golangci-lint if not present
@PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.1.6)
.PHONY: install-golangci-lint
lint: install-golangci-lint ##@Development Lint Go code
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
.PHONY: lint
lintall: lint ##@Development Lint Go and JS code

View File

@@ -72,6 +72,7 @@ type configOptions struct {
EnableUserEditing bool
EnableSharing bool
ShareURL string
DefaultShareExpiration time.Duration
DefaultDownloadableShare bool
DefaultTheme string
DefaultLanguage string
@@ -133,6 +134,7 @@ type scannerOptions struct {
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
FollowSymlinks bool // Whether to follow symlinks when scanning directories
PurgeMissing string // Values: "never", "always", "full"
}
type subsonicOptions struct {
@@ -152,10 +154,11 @@ type TagConf struct {
}
type lastfmOptions struct {
Enabled bool
ApiKey string
Secret string
Language string
Enabled bool
ApiKey string
Secret string
Language string
ScrobbleFirstArtistOnly bool
}
type spotifyOptions struct {
@@ -276,6 +279,7 @@ func Load(noConfigDump bool) {
validateScanSchedule,
validateBackupSchedule,
validatePlaylistsPath,
validatePurgeMissingOption,
)
if err != nil {
os.Exit(1)
@@ -381,6 +385,24 @@ func validatePlaylistsPath() error {
return nil
}
func validatePurgeMissingOption() error {
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
valid := false
for _, v := range allowedValues {
if v == Server.Scanner.PurgeMissing {
valid = true
break
}
}
if !valid {
err := fmt.Errorf("Invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
log.Error(err.Error())
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
return err
}
return nil
}
func validateScanSchedule() error {
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
Server.Scanner.Schedule = ""
@@ -420,7 +442,7 @@ func AddHook(hook func()) {
hooks = append(hooks, hook)
}
func init() {
func setViperDefaults() {
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
viper.SetDefault("cachefolder", "")
viper.SetDefault("datafolder", ".")
@@ -457,7 +479,6 @@ func init() {
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
viper.SetDefault("ffmpegpath", "")
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s")
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
viper.SetDefault("coverjpegquality", 75)
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
@@ -472,6 +493,7 @@ func init() {
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("enablesharing", false)
viper.SetDefault("shareurl", "")
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
viper.SetDefault("defaultdownloadableshare", false)
viper.SetDefault("gatrackingid", "")
viper.SetDefault("enableinsightscollector", true)
@@ -479,19 +501,15 @@ func init() {
viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second)
viper.SetDefault("passwordencryptionkey", "")
viper.SetDefault("reverseproxyuserheader", "Remote-User")
viper.SetDefault("reverseproxywhitelist", "")
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "")
viper.SetDefault("jukebox.enabled", false)
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
viper.SetDefault("jukebox.default", "")
viper.SetDefault("jukebox.adminonly", true)
viper.SetDefault("scanner.enabled", true)
viper.SetDefault("scanner.schedule", "0")
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
@@ -501,39 +519,32 @@ func init() {
viper.SetDefault("scanner.genreseparators", "")
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("scanner.followsymlinks", true)
viper.SetDefault("scanner.purgemissing", "never")
viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false)
viper.SetDefault("subsonic.legacyclients", "DSub")
viper.SetDefault("agents", "lastfm,spotify")
viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", "en")
viper.SetDefault("lastfm.apikey", "")
viper.SetDefault("lastfm.secret", "")
viper.SetDefault("lastfm.scrobblefirstartistonly", false)
viper.SetDefault("spotify.id", "")
viper.SetDefault("spotify.secret", "")
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
viper.SetDefault("backup.path", "")
viper.SetDefault("backup.schedule", "")
viper.SetDefault("backup.count", 0)
viper.SetDefault("pid.track", consts.DefaultTrackPID)
viper.SetDefault("pid.album", consts.DefaultAlbumPID)
viper.SetDefault("inspect.enabled", true)
viper.SetDefault("inspect.maxrequests", 1)
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
viper.SetDefault("devenableprofiler", false)
viper.SetDefault("devautocreateadminpassword", "")
@@ -554,6 +565,10 @@ func init() {
viper.SetDefault("devenableplayerinsights", true)
}
func init() {
setViperDefaults()
}
func InitConfig(cfgFile string) {
codecRegistry := viper.NewCodecRegistry()
_ = codecRegistry.RegisterCodec("ini", ini.Codec{})

View File

@@ -5,7 +5,7 @@ import (
"path/filepath"
"testing"
. "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/spf13/viper"
@@ -20,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"),

View File

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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -279,6 +279,13 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
return t.Track, nil
}
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile) string {
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[model.RoleArtist]) > 0 {
return track.Participants[model.RoleArtist][0].Name
}
return track.Artist
}
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
@@ -286,7 +293,7 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
}
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
artist: track.Artist,
artist: l.getArtistForScrobble(track),
track: track.Title,
album: track.Album,
trackNumber: track.TrackNumber,
@@ -312,7 +319,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
return nil
}
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
artist: s.Artist,
artist: l.getArtistForScrobble(&s.MediaFile),
track: s.Title,
album: s.Album,
trackNumber: s.TrackNumber,
@@ -344,10 +351,22 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
func init() {
conf.AddHook(func() {
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
return lastFMConstructor(ds)
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*lastfmAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := lastFMConstructor(ds)
if a != nil {
return a
}
return nil
})
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
return lastFMConstructor(ds)
// Same as above - this is a workaround for the fact that a (Scrobbler)(nil) is not the same as a (*lastfmAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := lastFMConstructor(ds)
if a != nil {
return a
}
return nil
})
})
}

View File

@@ -196,6 +196,12 @@ var _ = Describe("lastfmAgent", func() {
TrackNumber: 1,
Duration: 180,
MbzRecordingID: "mbz-123",
Participants: map[model.Role]model.ParticipantList{
model.RoleArtist: []model.Participant{
{Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
{Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
},
},
}
})
@@ -247,6 +253,23 @@ var _ = Describe("lastfmAgent", func() {
Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
})
When("ScrobbleFirstArtistOnly is true", func() {
BeforeEach(func() {
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
})
It("uses only the first artist", func() {
ts := time.Now()
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
Expect(err).ToNot(HaveOccurred())
sentParams := httpClient.SavedRequest.URL.Query()
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
})
})
It("skips songs with less than 31 seconds", func() {
track.Duration = 29
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}

View File

@@ -98,7 +98,7 @@ func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error
return model.ErrNotAuthorized
}
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
return a.zipMediaFiles(ctx, id, s.Format, s.MaxBitRate, out, s.Tracks)
return a.zipMediaFiles(ctx, id, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
}
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
@@ -109,15 +109,40 @@ func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bi
}
mfs := pls.MediaFiles()
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
return a.zipMediaFiles(ctx, id, format, bitrate, out, mfs)
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true)
}
func (a *archiver) zipMediaFiles(ctx context.Context, id string, format string, bitrate int, out io.Writer, mfs model.MediaFiles) error {
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error {
z := createZipWriter(out, format, bitrate)
zippedMfs := make(model.MediaFiles, len(mfs))
for idx, mf := range mfs {
file := a.playlistFilename(mf, format, idx)
_ = a.addFileToZip(ctx, z, mf, format, bitrate, file)
mf.Path = file
zippedMfs[idx] = mf
}
// Add M3U file if requested
if addM3U && len(zippedMfs) > 0 {
plsName := sanitizeName(name)
w, err := z.CreateHeader(&zip.FileHeader{
Name: plsName + ".m3u",
Modified: mfs[0].UpdatedAt,
Method: zip.Store,
})
if err != nil {
log.Error(ctx, "Error creating playlist zip entry", err)
return err
}
_, err = w.Write([]byte(zippedMfs.ToM3U8(plsName, false)))
if err != nil {
log.Error(ctx, "Error writing m3u in zip", err)
return err
}
}
err := z.Close()
if err != nil {
log.Error(ctx, "Error closing zip file", "id", id, err)

View File

@@ -145,9 +145,21 @@ var _ = Describe("Archiver", func() {
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).To(BeNil())
Expect(len(zr.File)).To(Equal(2))
Expect(len(zr.File)).To(Equal(3))
Expect(zr.File[0].Name).To(Equal("01 - AC_DC - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3"))
Expect(zr.File[2].Name).To(Equal("Test Playlist.m3u"))
// Verify M3U content
m3uFile, err := zr.File[2].Open()
Expect(err).To(BeNil())
defer m3uFile.Close()
m3uContent, err := io.ReadAll(m3uFile)
Expect(err).To(BeNil())
expectedM3U := "#EXTM3U\n#PLAYLIST:Test Playlist\n#EXTINF:0,AC/DC - track1\n01 - AC_DC - track1.mp3\n#EXTINF:0,Artist 2 - track2\n02 - Artist 2 - track2.mp3\n"
Expect(string(m3uContent)).To(Equal(expectedM3U))
})
})
})

View File

@@ -115,7 +115,7 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
} else {
switch artID.Kind {
case model.KindArtistArtwork:
artReader, err = 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:

View File

@@ -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
}

View File

@@ -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 {

View 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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View 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
View File

@@ -35,17 +35,17 @@ require (
github.com/jellydator/ttlcache/v3 v3.3.0
github.com/kardianos/service v1.2.2
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.1.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
View File

@@ -66,8 +66,8 @@ github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.9.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=

View File

@@ -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)

View File

@@ -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") {

View File

@@ -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"

View File

@@ -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}
}
}
}

View File

@@ -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()

View File

@@ -9,6 +9,7 @@ import (
"mime"
"path/filepath"
"slices"
"strings"
"time"
"github.com/gohugoio/hashstructure"
@@ -330,6 +331,23 @@ func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int
return currentPath, currentDisc
}
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
// https://docs.fileformat.com/audio/m3u/#extended-m3u
func (mfs MediaFiles) ToM3U8(title string, absolutePaths bool) string {
buf := strings.Builder{}
buf.WriteString("#EXTM3U\n")
buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", title))
for _, t := range mfs {
buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
if absolutePaths {
buf.WriteString(t.AbsolutePath() + "\n")
} else {
buf.WriteString(t.Path + "\n")
}
}
return buf.String()
}
type MediaFileCursor iter.Seq2[MediaFile, error]
type MediaFileRepository interface {
@@ -342,6 +360,7 @@ type MediaFileRepository interface {
GetCursor(options ...QueryOptions) (MediaFileCursor, error)
Delete(id string) error
DeleteMissing(ids []string) error
DeleteAllMissing() (int64, error)
FindByPaths(paths []string) (MediaFiles, error)
// The following methods are used exclusively by the scanner:

View File

@@ -402,6 +402,72 @@ var _ = Describe("MediaFiles", func() {
})
})
})
Describe("ToM3U8", func() {
It("returns header only for empty MediaFiles", func() {
mfs = MediaFiles{}
result := mfs.ToM3U8("My Playlist", false)
Expect(result).To(Equal("#EXTM3U\n#PLAYLIST:My Playlist\n"))
})
DescribeTable("duration formatting",
func(duration float32, expected string) {
mfs = MediaFiles{{Title: "Song", Artist: "Artist", Duration: duration, Path: "song.mp3"}}
result := mfs.ToM3U8("Test", false)
Expect(result).To(ContainSubstring(expected))
},
Entry("zero duration", float32(0.0), "#EXTINF:0,"),
Entry("whole number", float32(120.0), "#EXTINF:120,"),
Entry("rounds 0.5 down", float32(180.5), "#EXTINF:180,"),
Entry("rounds 0.6 up", float32(240.6), "#EXTINF:241,"),
)
Context("multiple tracks", func() {
BeforeEach(func() {
mfs = MediaFiles{
{Title: "Song One", Artist: "Artist A", Duration: 120, Path: "a/song1.mp3", LibraryPath: "/music"},
{Title: "Song Two", Artist: "Artist B", Duration: 241, Path: "b/song2.mp3", LibraryPath: "/music"},
{Title: "Song with \"quotes\" & ampersands", Artist: "Artist with Ümläuts", Duration: 90, Path: "special/file.mp3", LibraryPath: "/música"},
}
})
DescribeTable("generates correct output",
func(absolutePaths bool, expectedContent string) {
result := mfs.ToM3U8("Multi Track", absolutePaths)
Expect(result).To(Equal(expectedContent))
},
Entry("relative paths",
false,
"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n",
),
Entry("absolute paths",
true,
"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\n/music/a/song1.mp3\n#EXTINF:241,Artist B - Song Two\n/music/b/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\n/música/special/file.mp3\n",
),
Entry("special characters",
false,
"#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n",
),
)
})
Context("path variations", func() {
It("handles different path structures", func() {
mfs = MediaFiles{
{Title: "Root", Artist: "Artist", Duration: 60, Path: "song.mp3", LibraryPath: "/lib"},
{Title: "Nested", Artist: "Artist", Duration: 60, Path: "deep/nested/song.mp3", LibraryPath: "/lib"},
}
relativeResult := mfs.ToM3U8("Test", false)
Expect(relativeResult).To(ContainSubstring("song.mp3\n"))
Expect(relativeResult).To(ContainSubstring("deep/nested/song.mp3\n"))
absoluteResult := mfs.ToM3U8("Test", true)
Expect(absoluteResult).To(ContainSubstring("/lib/song.mp3\n"))
Expect(absoluteResult).To(ContainSubstring("/lib/deep/nested/song.mp3\n"))
})
})
})
})
var _ = Describe("MediaFile", func() {

View File

@@ -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
)
})

View File

@@ -1,10 +1,8 @@
package model
import (
"fmt"
"slices"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/model/criteria"
@@ -53,17 +51,9 @@ func (pls *Playlist) RemoveTracks(idxToRemove []int) {
pls.Tracks = newTracks
}
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
// https://docs.fileformat.com/audio/m3u/#extended-m3u
// ToM3U8 exports the playlist to the Extended M3U8 format
func (pls *Playlist) ToM3U8() string {
buf := strings.Builder{}
buf.WriteString("#EXTM3U\n")
buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", pls.Name))
for _, t := range pls.Tracks {
buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
buf.WriteString(t.AbsolutePath() + "\n")
}
return buf.String()
return pls.MediaFiles().ToM3U8(pls.Name, true)
}
func (pls *Playlist) AddTracks(mediaFileIds []string) {

View File

@@ -13,13 +13,17 @@ var _ = Describe("Playlist", func() {
pls = model.Playlist{Name: "Mellow sunset"}
pls.Tracks = model.PlaylistTracks{
{MediaFile: model.MediaFile{Artist: "Morcheeba feat. Kurt Wagner", Title: "What New York Couples Fight About",
Duration: 377.84, Path: "/music/library/Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}},
Duration: 377.84,
LibraryPath: "/music/library", Path: "Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}},
{MediaFile: model.MediaFile{Artist: "A Tribe Called Quest", Title: "Description of a Fool (Groove Armada's Acoustic mix)",
Duration: 374.49, Path: "/music/library/Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}},
Duration: 374.49,
LibraryPath: "/music/library", Path: "Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}},
{MediaFile: model.MediaFile{Artist: "Lou Reed", Title: "Walk on the Wild Side",
Duration: 253.1, Path: "/music/library/Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}},
Duration: 253.1,
LibraryPath: "/music/library", Path: "Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}},
{MediaFile: model.MediaFile{Artist: "Legião Urbana", Title: "On the Way Home",
Duration: 163.89, Path: "/music/library/Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}},
Duration: 163.89,
LibraryPath: "/music/library", Path: "Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}},
}
})
It("generates the correct M3U format", func() {

View File

@@ -2,7 +2,6 @@ package model
import (
"cmp"
"fmt"
"strings"
"time"
@@ -50,17 +49,9 @@ func (s Share) CoverArtID() ArtworkID {
type Shares []Share
// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in
// https://docs.fileformat.com/audio/m3u/#extended-m3u
// ToM3U8 exports the share to the Extended M3U8 format.
func (s Share) ToM3U8() string {
buf := strings.Builder{}
buf.WriteString("#EXTM3U\n")
buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", cmp.Or(s.Description, s.ID)))
for _, t := range s.Tracks {
buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title))
buf.WriteString(t.Path + "\n")
}
return buf.String()
return s.Tracks.ToM3U8(cmp.Or(s.Description, s.ID), false)
}
type ShareRepository interface {

View File

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

View File

@@ -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())
})
}

View File

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

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
@@ -94,7 +95,7 @@ var _ = Describe("ArtistRepository", func() {
er := repo.Put(&artistBeatles)
Expect(er).To(BeNil())
idx, err := repo.GetIndex()
idx, err := repo.GetIndex(false)
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("F"))
@@ -112,7 +113,7 @@ var _ = Describe("ArtistRepository", func() {
// BFR Empty SortArtistName is not saved in the DB anymore
XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
idx, err := repo.GetIndex()
idx, err := repo.GetIndex(false)
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("B"))
@@ -134,7 +135,7 @@ var _ = Describe("ArtistRepository", func() {
er := repo.Put(&artistBeatles)
Expect(er).To(BeNil())
idx, err := repo.GetIndex()
idx, err := repo.GetIndex(false)
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("B"))
@@ -151,7 +152,7 @@ var _ = Describe("ArtistRepository", func() {
})
It("returns the index when SortArtistName is empty", func() {
idx, err := repo.GetIndex()
idx, err := repo.GetIndex(false)
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(2))
Expect(idx[0].ID).To(Equal("B"))
@@ -233,5 +234,113 @@ var _ = Describe("ArtistRepository", func() {
Expect(m).ToNot(HaveKey("mbz_artist_id"))
})
})
Describe("Missing artist visibility", func() {
var raw *artistRepository
var missing model.Artist
insertMissing := func() {
missing = model.Artist{ID: "m1", Name: "Missing", OrderArtistName: "missing"}
Expect(repo.Put(&missing)).To(Succeed())
raw = repo.(*artistRepository)
_, err := raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missing.ID}))
Expect(err).ToNot(HaveOccurred())
}
removeMissing := func() {
if raw != nil {
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missing.ID}))
}
}
Context("regular user", func() {
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "u1"})
repo = NewArtistRepository(ctx, GetDBXBuilder())
insertMissing()
})
AfterEach(func() { removeMissing() })
It("does not return missing artist in GetAll", func() {
artists, err := repo.GetAll(model.QueryOptions{Filters: squirrel.Eq{"artist.missing": false}})
Expect(err).ToNot(HaveOccurred())
Expect(artists).To(HaveLen(2))
})
It("does not return missing artist in Search", func() {
res, err := repo.Search("missing", 0, 10, false)
Expect(err).ToNot(HaveOccurred())
Expect(res).To(BeEmpty())
})
It("does not return missing artist in GetIndex", func() {
idx, err := repo.GetIndex(false)
Expect(err).ToNot(HaveOccurred())
// Only 2 artists should be present
total := 0
for _, ix := range idx {
total += len(ix.Artists)
}
Expect(total).To(Equal(2))
})
})
Context("admin user", func() {
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "admin", IsAdmin: true})
repo = NewArtistRepository(ctx, GetDBXBuilder())
insertMissing()
})
AfterEach(func() { removeMissing() })
It("returns missing artist in GetAll", func() {
artists, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(artists).To(HaveLen(3))
})
It("returns missing artist in Search", func() {
res, err := repo.Search("missing", 0, 10, true)
Expect(err).ToNot(HaveOccurred())
Expect(res).To(HaveLen(1))
})
It("returns missing artist in GetIndex when included", func() {
idx, err := repo.GetIndex(true)
Expect(err).ToNot(HaveOccurred())
total := 0
for _, ix := range idx {
total += len(ix.Artists)
}
Expect(total).To(Equal(3))
})
})
})
})
Describe("roleFilter", func() {
It("filters out roles not present in the participants model", func() {
Expect(roleFilter("", "artist")).To(Equal(squirrel.NotEq{"stats ->> '$.artist'": nil}))
Expect(roleFilter("", "albumartist")).To(Equal(squirrel.NotEq{"stats ->> '$.albumartist'": nil}))
Expect(roleFilter("", "composer")).To(Equal(squirrel.NotEq{"stats ->> '$.composer'": nil}))
Expect(roleFilter("", "conductor")).To(Equal(squirrel.NotEq{"stats ->> '$.conductor'": nil}))
Expect(roleFilter("", "lyricist")).To(Equal(squirrel.NotEq{"stats ->> '$.lyricist'": nil}))
Expect(roleFilter("", "arranger")).To(Equal(squirrel.NotEq{"stats ->> '$.arranger'": nil}))
Expect(roleFilter("", "producer")).To(Equal(squirrel.NotEq{"stats ->> '$.producer'": nil}))
Expect(roleFilter("", "director")).To(Equal(squirrel.NotEq{"stats ->> '$.director'": nil}))
Expect(roleFilter("", "engineer")).To(Equal(squirrel.NotEq{"stats ->> '$.engineer'": nil}))
Expect(roleFilter("", "mixer")).To(Equal(squirrel.NotEq{"stats ->> '$.mixer'": nil}))
Expect(roleFilter("", "remixer")).To(Equal(squirrel.NotEq{"stats ->> '$.remixer'": nil}))
Expect(roleFilter("", "djmixer")).To(Equal(squirrel.NotEq{"stats ->> '$.djmixer'": nil}))
Expect(roleFilter("", "performer")).To(Equal(squirrel.NotEq{"stats ->> '$.performer'": nil}))
Expect(roleFilter("", "wizard")).To(Equal(squirrel.Eq{"1": 2}))
Expect(roleFilter("", "songanddanceman")).To(Equal(squirrel.Eq{"1": 2}))
Expect(roleFilter("", "artist') SELECT LIKE(CHAR(65,66,67,68,69,70,71),UPPER(HEX(RANDOMBLOB(500000000/2))))--")).To(Equal(squirrel.Eq{"1": 2}))
})
})
})

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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() }),

View File

@@ -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.")

View File

@@ -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

View 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")
})
})
})

View File

@@ -32,12 +32,15 @@
"participants": "Weitere Beteiligte",
"tags": "Weitere Tags",
"mappedTags": "Gemappte Tags",
"rawTags": "Tag Rohdaten"
"rawTags": "Tag Rohdaten",
"bitDepth": "Bittiefe",
"sampleRate": "Samplerate",
"missing": "Fehlend"
},
"actions": {
"addToQueue": "Später abspielen",
"playNow": "Jetzt abspielen",
"addToPlaylist": "Zur Playlist hinzufügen",
"addToPlaylist": "Zu einer Wiedergabeliste hinzufügen",
"shuffleAll": "Zufallswiedergabe",
"download": "Herunterladen",
"playNext": "Als nächstes abspielen",
@@ -70,14 +73,16 @@
"releaseType": "Typ",
"grouping": "Gruppierung",
"media": "Medium",
"mood": "Stimmung"
"mood": "Stimmung",
"date": "Aufnahmedatum",
"missing": "Fehlend"
},
"actions": {
"playAll": "Abspielen",
"playNext": "Als nächstes abspielen",
"addToQueue": "Später abspielen",
"shuffle": "Zufallswiedergabe",
"addToPlaylist": "Zur Playlist hinzufügen",
"addToPlaylist": "Zu einer Wiedergabeliste hinzufügen",
"download": "Herunterladen",
"info": "Mehr Informationen",
"share": "Freigabe erstellen"
@@ -102,7 +107,8 @@
"rating": "Bewertung",
"genre": "Genre",
"size": "Größe",
"role": "Rolle"
"role": "Rolle",
"missing": "Fehlend"
},
"roles": {
"albumartist": "Albuminterpret |||| Albuminterpreten",
@@ -172,7 +178,7 @@
}
},
"playlist": {
"name": "Playlist |||| Playlists",
"name": "Wiedergabeliste |||| Wiedergabelisten",
"fields": {
"name": "Name",
"duration": "Dauer",
@@ -186,11 +192,12 @@
"path": "Importieren aus"
},
"actions": {
"selectPlaylist": "Titel zur Playlist hinzufügen",
"selectPlaylist": "Wiedergabeliste auswählen:",
"addNewPlaylist": "\"%{name}\" erstellen",
"export": "Exportieren",
"makePublic": "Öffentlich machen",
"makePrivate": "Privat stellen"
"makePrivate": "Privat stellen",
"saveQueue": "Warteschlange in Wiedergabeliste speichern"
},
"message": {
"duplicate_song": "Duplikate hinzufügen",
@@ -235,11 +242,13 @@
"updatedAt": "Fehlt seit"
},
"actions": {
"remove": "Entfernen"
"remove": "Entfernen",
"remove_all": "alle entfernen"
},
"notifications": {
"removed": "Fehlende Datei(en) entfernt"
}
},
"empty": "keine fehlenden Dateien"
}
},
"ra": {
@@ -391,10 +400,10 @@
"note": "HINWEIS",
"transcodingDisabled": "Die Änderung der Transcodierungskonfiguration über die Web-UI ist aus Sicherheitsgründen deaktiviert. Wenn du die Transcodierungsoptionen ändern (bearbeiten oder hinzufügen) möchtest, starte den Server mit der Konfigurationsoption %{config} neu.",
"transcodingEnabled": "Navidrome läuft derzeit mit %{config}, wodurch es möglich ist, Systembefehle aus den Transkodierungseinstellungen über die Webschnittstelle auszuführen. Wir empfehlen, es aus Sicherheitsgründen zu deaktivieren und nur bei der Konfiguration von Transkodierungsoptionen zu aktivieren.",
"songsAddedToPlaylist": "Einen Titel zur Playlist hinzugefügt |||| %{smart_count} Titel zur Playlist hinzugefügt",
"noPlaylistsAvailable": "Keine Playlist verfügbar",
"songsAddedToPlaylist": "Einen Titel zur Wiedergabeliste hinzugefügt |||| %{smart_count} Titel zur Wiedergabeliste hinzugefügt",
"noPlaylistsAvailable": "Keine Wiedergabeliste verfügbar",
"delete_user_title": "Benutzer '%{name}' löschen",
"delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Playlisten und Einstellungen) wirklich löschen?",
"delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Wiedergabelisten und Einstellungen) wirklich löschen?",
"notifications_blocked": "Sie haben Benachrichtigungen für diese Seite in den Einstellungen Ihres Browsers blockiert",
"notifications_not_available": "Dieser Browser unterstützt keine Desktop-Benachrichtigungen",
"lastfmLinkSuccess": "Last.fm Verbindung hergestellt und scrobbling aktiviert",
@@ -419,7 +428,9 @@
"downloadDialogTitle": "Download %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "In Zwischenablage kopieren: Ctrl+C, Enter",
"remove_missing_title": "Fehlende Dateien entfernen",
"remove_missing_content": "Möchtest du die ausgewählten Fehlenden Dateien wirklich aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht."
"remove_missing_content": "Möchtest du die ausgewählten Fehlenden Dateien wirklich aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.",
"remove_all_missing_title": "Alle fehlenden Dateien entfernen",
"remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht."
},
"menu": {
"library": "Bibliothek",
@@ -447,8 +458,8 @@
},
"albumList": "Alben",
"about": "Über",
"playlists": "Playlisten",
"sharedPlaylists": "Geteilte Playlisten"
"playlists": "Wiedergabelisten",
"sharedPlaylists": "Geteilte Wiedergabelisten"
},
"player": {
"playListsText": "Wiedergabeliste abspielen",
@@ -493,7 +504,10 @@
"quickScan": "Schneller Scan",
"fullScan": "Kompletter Scan",
"serverUptime": "Server-Betriebszeit",
"serverDown": "OFFLINE"
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Scan Fehler",
"elapsedTime": "Laufzeit"
},
"help": {
"title": "Navidrome Hotkeys",

View File

@@ -33,7 +33,9 @@
"tags": "Πρόσθετες Ετικέτες",
"mappedTags": "Χαρτογραφημένες ετικέτες",
"rawTags": "Ακατέργαστες ετικέτες",
"bitDepth": "Λίγο βάθος"
"bitDepth": "Λίγο βάθος",
"sampleRate": "Ποσοστό δειγματοληψίας",
"missing": "Απών"
},
"actions": {
"addToQueue": "Αναπαραγωγη Μετα",
@@ -72,7 +74,8 @@
"grouping": "Ομαδοποίηση",
"media": "Μέσα",
"mood": "Διάθεση",
"date": "Ημερομηνία Ηχογράφησης"
"date": "Ημερομηνία Ηχογράφησης",
"missing": "Απών"
},
"actions": {
"playAll": "Αναπαραγωγή",
@@ -104,7 +107,8 @@
"rating": "Βαθμολογια",
"genre": "Είδος",
"size": "Μέγεθος",
"role": "Ρόλος"
"role": "Ρόλος",
"missing": "Απών"
},
"roles": {
"albumartist": "Καλλιτέχνης Άλμπουμ |||| Καλλιτέχνες άλμπουμ",
@@ -132,7 +136,7 @@
"name": "Όνομα",
"password": "Κωδικός Πρόσβασης",
"createdAt": "Δημιουργήθηκε στις",
"changePassword": "Αλλαγή Κωδικού Πρόσβασης;",
"changePassword": "Αλλαγή Κωδικού Πρόσβασης?",
"currentPassword": "Υπάρχων Κωδικός Πρόσβασης",
"newPassword": "Νέος Κωδικός Πρόσβασης",
"token": "Token",
@@ -192,11 +196,12 @@
"addNewPlaylist": "Δημιουργία \"%{name}\"",
"export": "Εξαγωγη",
"makePublic": "Να γίνει δημόσιο",
"makePrivate": "Να γίνει ιδιωτικό"
"makePrivate": "Να γίνει ιδιωτικό",
"saveQueue": "Αποθήκευση ουράς στη λίστα αναπαραγωγής"
},
"message": {
"duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών",
"song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε;"
"song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε?"
}
},
"radio": {
@@ -237,7 +242,8 @@
"updatedAt": "Εξαφανίστηκε"
},
"actions": {
"remove": "Αφαίρεση"
"remove": "Αφαίρεση",
"remove_all": "Αφαίρεση όλων"
},
"notifications": {
"removed": "Λείπει αρχείο(α) αφαιρέθηκε"
@@ -305,7 +311,7 @@
"skip": "Παράβλεψη",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Κοινοποίηση",
"download": "Λήψη "
"download": "Λήψη"
},
"boolean": {
"true": "Ναι",
@@ -344,10 +350,10 @@
},
"message": {
"about": "Σχετικά",
"are_you_sure": "Είστε σίγουροι;",
"bulk_delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε το %{name}; |||| Είστε σίγουροι πως θέλετε να διαγράψετε τα %{smart_count};",
"are_you_sure": "Είστε σίγουροι?",
"bulk_delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε το %{name}? |||| Είστε σίγουροι πως θέλετε να διαγράψετε τα %{smart_count}?",
"bulk_delete_title": "Διαγραφή του %{name} |||| Διαγραφή του %{smart_count} %{name}",
"delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το αντικείμενο;",
"delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το αντικείμενο?",
"delete_title": "Διαγραφή του %{name} #%{id}",
"details": "Λεπτομέρειες",
"error": "Παρουσιάστηκε ένα πρόβλημα από τη μεριά του πελάτη και το αίτημα σας δεν μπορεί να ολοκληρωθεί.",
@@ -356,12 +362,12 @@
"no": "Όχι",
"not_found": "Είτε έχετε εισάγει λανθασμένο URL, είτε ακολουθήσατε έναν υπερσύνδεσμο που δεν ισχύει.",
"yes": "Ναι",
"unsaved_changes": "Μερικές από τις αλλαγές σας δεν έχουν αποθηκευτεί. Είστε σίγουροι πως θέλετε να τις αγνοήσετε;"
"unsaved_changes": "Μερικές από τις αλλαγές σας δεν έχουν αποθηκευτεί. Είστε σίγουροι πως θέλετε να τις αγνοήσετε?"
},
"navigation": {
"no_results": "Δεν βρέθηκαν αποτελέσματα",
"no_more_results": "Η σελίδα %{page} είναι εκτός ορίων. Δοκιμάστε την προηγούμενη σελίδα.",
"page_out_of_boundaries": "Η σελίδα {page} είναι εκτός ορίων",
"page_out_of_boundaries": "Η σελίδα %{page} είναι εκτός ορίων",
"page_out_from_end": "Δεν είναι δυνατή η πλοήγηση πέραν της τελευταίας σελίδας",
"page_out_from_begin": "Δεν είναι δυνατή η πλοήγηση πριν τη σελίδα 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} από %{total}",
@@ -397,7 +403,7 @@
"songsAddedToPlaylist": "Προστέθηκε 1 τραγούδι στη λίστα αναπαραγωγής |||| Προστέθηκαν %{smart_count} τραγούδια στη λίστα αναπαραγωγής",
"noPlaylistsAvailable": "Κανένα διαθέσιμο",
"delete_user_title": "Διαγραφή του χρήστη '%{name}'",
"delete_user_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το χρήστη και όλα τα δεδομένα του (συμπεριλαμβανομένων των λιστών αναπαραγωγής και προτιμήσεων);",
"delete_user_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το χρήστη και όλα τα δεδομένα του (συμπεριλαμβανομένων των λιστών αναπαραγωγής και προτιμήσεων)?",
"notifications_blocked": "Έχετε μπλοκάρει τις Ειδοποιήσεις από τη σελίδα, μέσω των ρυθμίσεων του περιηγητή ιστού σας",
"notifications_not_available": "Αυτός ο περιηγητής ιστού δεν υποστηρίζει ειδοποιήσεις στην επιφάνεια εργασίας ή δεν έχετε πρόσβαση στο Navidrome μέσω https",
"lastfmLinkSuccess": "Το Last.fm έχει διασυνδεθεί επιτυχώς και η λειτουργία scrobbling ενεργοποιήθηκε",
@@ -422,7 +428,9 @@
"downloadDialogTitle": "Λήψη %{resource} '%{name}'(%{size})",
"shareCopyToClipboard": "Αντιγραφή στο πρόχειρο: Ctrl+C, Enter",
"remove_missing_title": "Αφαιρέστε τα αρχεία που λείπουν",
"remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων; Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους."
"remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους.",
"remove_all_missing_title": "Αφαίρεση όλων των αρχείων που λείπουν",
"remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους."
},
"menu": {
"library": "Βιβλιοθήκη",
@@ -496,7 +504,10 @@
"quickScan": "Γρήγορη Σάρωση",
"fullScan": "Πλήρης Σάρωση",
"serverUptime": "Λειτουργία Διακομιστή",
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ"
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ",
"scanType": "Τύπος",
"status": "Σφάλμα σάρωσης",
"elapsedTime": "Χρόνος που πέρασε"
},
"help": {
"title": "Συντομεύσεις του Navidrome",

View File

@@ -24,16 +24,18 @@
"rating": "Takso",
"quality": "Kvalito",
"bpm": "Pulsrapideco",
"playDate": "",
"channels": "",
"createdAt": "",
"playDate": "Laste Ludita",
"channels": "Kanaloj",
"createdAt": "Dato de aligo",
"grouping": "",
"mood": "",
"mood": "Humoro",
"participants": "",
"tags": "",
"mappedTags": "",
"rawTags": "",
"bitDepth": ""
"tags": "Aldonaj Etikedoj",
"mappedTags": "Mapigitaj etikedoj",
"rawTags": "Krudaj etikedoj",
"bitDepth": "",
"sampleRate": "",
"missing": ""
},
"actions": {
"addToQueue": "Ludi Poste",
@@ -42,7 +44,7 @@
"shuffleAll": "Miksu Ĉiujn",
"download": "Elŝuti",
"playNext": "Ludu Poste",
"info": ""
"info": "Akiri Informon"
}
},
"album": {
@@ -60,19 +62,20 @@
"updatedAt": "Ĝisdatigita je :",
"comment": "Komento",
"rating": "Takso",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": "",
"createdAt": "Dato aldonita",
"size": "Grando",
"originalDate": "Originala",
"releaseDate": "Publikiĝis",
"releases": "Publikiĝo |||| Publikiĝoj",
"released": "Publikiĝis",
"recordLabel": "",
"catalogNum": "",
"releaseType": "",
"releaseType": "Tipo",
"grouping": "",
"media": "",
"mood": "",
"date": ""
"mood": "Humoro",
"date": "",
"missing": ""
},
"actions": {
"playAll": "Ludi",
@@ -81,43 +84,44 @@
"shuffle": "Miksi",
"addToPlaylist": "Aldoni al la Ludlisto",
"download": "Elŝuti",
"info": "",
"share": ""
"info": "Akiri Informon",
"share": "Diskonigi"
},
"lists": {
"all": "Ĉiuj",
"random": "Hazarda",
"recentlyAdded": "Lastatempe Aldonita",
"recentlyPlayed": "Lastatempe Ludita",
"random": "Hazardaj",
"recentlyAdded": "Lastatempe Aldonitaj",
"recentlyPlayed": "Lastatempe Luditaj",
"mostPlayed": "Plej Luditaj",
"starred": "Stelplena",
"topRated": "Plej Alte Taksite"
"starred": "Stelplenaj",
"topRated": "Plej Alte Taksitaj"
}
},
"artist": {
"name": "Artisto |||| Artistoj",
"fields": {
"name": "Nomo",
"albumCount": "Nombro da albumoj",
"songCount": "Kanto kalkula",
"playCount": "Teatraĵoj",
"albumCount": "Kvanto da Albumoj",
"songCount": "Kanta Kalkulo",
"playCount": "Ludoj",
"rating": "Takso",
"genre": "",
"size": "",
"role": ""
"genre": "Ĝenro",
"size": "Grando",
"role": "",
"missing": ""
},
"roles": {
"albumartist": "",
"artist": "",
"composer": "",
"conductor": "",
"lyricist": "",
"arranger": "",
"albumartist": "Albuma Artisto |||| Albumaj Artistoj",
"artist": "Artisto |||| Artistoj",
"composer": "Komponisto |||| Komponistoj",
"conductor": "Dirigento |||| Dirigentoj",
"lyricist": "Kantoteksisto |||| Kantotekstistoj",
"arranger": "Aranĝisto |||| Aranĝistoj",
"producer": "",
"director": "",
"engineer": "",
"mixer": "",
"remixer": "",
"mixer": "Miksisto |||| Miksistoj",
"remixer": "Remiksisto |||| Remiksistoj",
"djmixer": "",
"performer": ""
}
@@ -135,8 +139,8 @@
"changePassword": "Ĉu Ŝanĝi Pasvorton?",
"currentPassword": "Nuna Pasvorto",
"newPassword": "Nova Pasvorto",
"token": "",
"lastAccessAt": ""
"token": "Ĵetono",
"lastAccessAt": "Lasta Atingo"
},
"helperTexts": {
"name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto"
@@ -147,8 +151,8 @@
"deleted": "Uzanto forigita"
},
"message": {
"listenBrainzToken": "",
"clickHereForToken": ""
"listenBrainzToken": "Enigi vian uzantan ĵetonon de ListenBrainz.",
"clickHereForToken": "Alkakli ĉi tie por akiri vian ĵetonon"
}
},
"player": {
@@ -161,7 +165,7 @@
"userName": "Uzantnomo",
"lastSeen": "Laste Vidita Je",
"reportRealPath": "Raporti vera pado",
"scrobbleEnabled": ""
"scrobbleEnabled": "Sendi Scrobbles al eksteraj servoj"
}
},
"transcoding": {
@@ -191,8 +195,9 @@
"selectPlaylist": "Elektu ludliston :",
"addNewPlaylist": "Krei \"%{name}\"",
"export": "Eksporti",
"makePublic": "",
"makePrivate": ""
"makePublic": "Publikigi",
"makePrivate": "Malpublikigi",
"saveQueue": ""
},
"message": {
"duplicate_song": "Aldoni duobligitajn kantojn",
@@ -200,33 +205,33 @@
}
},
"radio": {
"name": "",
"name": "Radio |||| Radioj",
"fields": {
"name": "",
"streamUrl": "",
"homePageUrl": "",
"updatedAt": "",
"createdAt": ""
"name": "Nomo",
"streamUrl": "Flua Ligilo",
"homePageUrl": "Hejmpaĝa Ligilo",
"updatedAt": "Ĝisdatiĝis je",
"createdAt": "Kreiĝis je"
},
"actions": {
"playNow": ""
"playNow": "Ludi Nun"
}
},
"share": {
"name": "",
"name": "Diskonigo |||| Diskonigoj",
"fields": {
"username": "",
"url": "",
"description": "",
"contents": "",
"expiresAt": "",
"lastVisitedAt": "",
"visitCount": "",
"format": "",
"maxBitRate": "",
"updatedAt": "",
"createdAt": "",
"downloadable": ""
"username": "Diskonigite De",
"url": "Ligilo",
"description": "Priskribo",
"contents": "Enhavo",
"expiresAt": "Senvalidiĝas",
"lastVisitedAt": "Laste Vizitita",
"visitCount": "Vizitoj",
"format": "Formato",
"maxBitRate": "Maks. Bitrapido",
"updatedAt": "Ĝisdatiĝis je",
"createdAt": "Fariĝis je",
"downloadable": "Ĉu Ebligi Elŝutojn?"
}
},
"missing": {
@@ -237,7 +242,8 @@
"updatedAt": ""
},
"actions": {
"remove": ""
"remove": "",
"remove_all": ""
},
"notifications": {
"removed": ""
@@ -258,7 +264,7 @@
"sign_in": "Ensaluti",
"sign_in_error": "Aŭtentikigo malsukcesis, bonvolu reprovi",
"logout": "Elsaluti",
"insightsCollectionNote": ""
"insightsCollectionNote": "Navidrome kolektas anoniman uzdatumon por helpi\nplibonigi la projekton. Alklaku [ĉi tie] por lerni pli kaj\nsupozi permeson se vi volas"
},
"validation": {
"invalidChars": "Bonvolu uzi nur literojn kaj ciferojn",
@@ -273,7 +279,7 @@
"oneOf": "Devas esti unu el: %{options}",
"regex": "Devas kongrui kun specifa formato (regexp): %{pattern}",
"unique": "Devas esti unika",
"url": ""
"url": "Devas esti valida ligilo"
},
"action": {
"add_filter": "Aldoni filtrilon",
@@ -303,9 +309,9 @@
"close_menu": "Fermu menuon",
"unselect": "Malelekti",
"skip": "Pasigi",
"bulk_actions_mobile": "",
"share": "",
"download": ""
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Diskonigi",
"download": "Elŝuti"
},
"boolean": {
"true": "Jes",
@@ -381,13 +387,13 @@
"i18n_error": "Ne eblas ŝargi la tradukojn por la specifa lingvo",
"canceled": "Ago nuligita",
"logged_out": "Via seanco finiĝis, bonvolu rekonekti.",
"new_version": ""
"new_version": "Nova versio haveblas! Bonvolu reŝargi la fenestron."
},
"toggleFieldsMenu": {
"columnsToDisplay": "",
"columnsToDisplay": "Kolumnoj Por Montri",
"layout": "Aranĝo",
"grid": "Krado",
"table": ""
"table": "Tabelo"
}
},
"message": {
@@ -400,29 +406,31 @@
"delete_user_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun uzanton kaj ĉiujn iliajn datumojn (inkluzive ludlistojn kaj preferojn) ?",
"notifications_blocked": "Vi blokis sciigojn por ĉi tiu retejo en la agordoj de via retumilo",
"notifications_not_available": "Ĉi tiu retumilo ne subtenas labortablajn sciigojn aŭ vi ne aliras Navidrome per https",
"lastfmLinkSuccess": "",
"lastfmLinkFailure": "",
"lastfmUnlinkSuccess": "",
"lastfmUnlinkFailure": "",
"lastfmLinkSuccess": "Last.fm sukcese ligiĝis kaj scrobbling ebliĝis",
"lastfmLinkFailure": "Last.fm ne povis ligiĝi",
"lastfmUnlinkSuccess": "Last.fm malligiĝis kaj scrobbling malebliĝis",
"lastfmUnlinkFailure": "Last.fm ne povis malligiĝi",
"openIn": {
"lastfm": "",
"musicbrainz": ""
"lastfm": "Malfermi en Last.fm",
"musicbrainz": "Malfermi en MusicBrainz"
},
"lastfmLink": "",
"listenBrainzLinkSuccess": "",
"listenBrainzLinkFailure": "",
"listenBrainzUnlinkSuccess": "",
"listenBrainzUnlinkFailure": "",
"downloadOriginalFormat": "",
"shareOriginalFormat": "",
"shareDialogTitle": "",
"shareBatchDialogTitle": "",
"shareSuccess": "",
"shareFailure": "",
"downloadDialogTitle": "",
"shareCopyToClipboard": "",
"lastfmLink": "Legi Pli...",
"listenBrainzLinkSuccess": "ListenBrainz sukcese ligiĝis kaj scrobbling ebliĝis kiel uzanto: %{user}",
"listenBrainzLinkFailure": "ListenBrainz ne povis ligiĝi: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz malligiĝis kaj scrobbling malebliĝis",
"listenBrainzUnlinkFailure": "ListenBrainz ne povis malligiĝi",
"downloadOriginalFormat": "Elŝuti en originala formato",
"shareOriginalFormat": "Diskonigi en originala formato",
"shareDialogTitle": "Diskonigi %{resource} '%{name}'",
"shareBatchDialogTitle": "Diskonigi 1 %{resource} |||| Diskonigi %{smart_count} %{resource}",
"shareSuccess": "Ligilo kopiiĝis al la tondujo: %{url}",
"shareFailure": "Eraro de kopio de ligilo %{url} al la tondujo",
"downloadDialogTitle": "Elŝuti %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Kopii al la tondujo: Ctrl+C, Enter",
"remove_missing_title": "",
"remove_missing_content": ""
"remove_missing_content": "Ĉu vi certas, ke vi volas forigi la elektitajn mankajn dosierojn de la datumbazo? Ĉi tio forigos eterne ĉiujn referencojn de ili, inkluzive iliajn ludkvantojn kaj taksojn.",
"remove_all_missing_title": "",
"remove_all_missing_content": ""
},
"menu": {
"library": "Biblioteko",
@@ -436,22 +444,22 @@
"language": "Lingvo",
"defaultView": "Defaŭlta Vido",
"desktop_notifications": "Labortablaj sciigoj",
"lastfmScrobbling": "",
"listenBrainzScrobbling": "",
"replaygain": "",
"preAmp": "",
"lastfmScrobbling": "Scrobble al Last.fm",
"listenBrainzScrobbling": "Scrobble al ListenBrainz",
"replaygain": "ReplayGain-Reĝimo",
"preAmp": "ReplayGain PreAmp (dB)",
"gain": {
"none": "",
"album": "",
"track": ""
"none": "Malebligita",
"album": "Uzi Albuman Songajnon",
"track": "Uzi Kantan Songajnon"
},
"lastfmNotConfigured": ""
}
},
"albumList": "Albumoj",
"about": "Pri",
"playlists": "",
"sharedPlaylists": ""
"playlists": "Ludlistoj",
"sharedPlaylists": "Diskonigitaj Ludistoj"
},
"player": {
"playListsText": "Atendovico",
@@ -485,7 +493,7 @@
"featureRequests": "Trajta peto",
"lastInsightsCollection": "",
"insights": {
"disabled": "",
"disabled": "Malebligita",
"waiting": ""
}
}
@@ -496,7 +504,10 @@
"quickScan": "Rapida Skanado",
"fullScan": "Plena Skanado",
"serverUptime": "Servila daŭro de funkciado",
"serverDown": "SENKONEKTA"
"serverDown": "SENKONEKTA",
"scanType": "",
"status": "",
"elapsedTime": ""
},
"help": {
"title": "Navidrome klavkomando",
@@ -509,7 +520,7 @@
"vol_up": "Pli volumo",
"vol_down": "Malpli volumo",
"toggle_love": "Baskuli la stelon de nuna kanto",
"current_song": ""
"current_song": "Iri al Nuna Kanto"
}
}
}

View File

@@ -28,16 +28,19 @@
"channels": "Canales",
"createdAt": "Creado el",
"grouping": "Agrupación",
"mood": "",
"mood": "Estado de ánimo",
"participants": "Participantes",
"tags": "Etiquetas",
"mappedTags": "Etiquetas asignadas",
"rawTags": "Etiquetas sin procesar"
"rawTags": "Etiquetas sin procesar",
"bitDepth": "Profundidad de bits",
"sampleRate": "Frecuencia de muestreo",
"missing": "Faltante"
},
"actions": {
"addToQueue": "Reproducir después",
"playNow": "Reproducir ahora",
"addToPlaylist": "Agregar a la lista de reproducción",
"addToPlaylist": "Agregar a la playlist",
"shuffleAll": "Todas aleatorias",
"download": "Descarga",
"playNext": "Siguiente",
@@ -69,8 +72,10 @@
"catalogNum": "Número de catálogo",
"releaseType": "Tipo de lanzamiento",
"grouping": "Agrupación",
"media": "",
"mood": ""
"media": "Medios",
"mood": "Estado de ánimo",
"date": "Fecha de grabación",
"missing": "Faltante"
},
"actions": {
"playAll": "Reproducir",
@@ -102,7 +107,8 @@
"rating": "Calificación",
"genre": "Género",
"size": "Tamaño",
"role": "Rol"
"role": "Rol",
"missing": "Faltante"
},
"roles": {
"albumartist": "Artista del álbum",
@@ -190,11 +196,12 @@
"addNewPlaylist": "Creada \"%{name}\"",
"export": "Exportar",
"makePublic": "Hazla pública",
"makePrivate": "Hazla privada"
"makePrivate": "Hazla privada",
"saveQueue": "Guardar la fila de reproducción en una playlist"
},
"message": {
"duplicate_song": "Algunas de las canciones seleccionadas están presentes en la lista de reproducción",
"song_exist": "Se están agregando duplicados a la lista de reproducción. ¿Quieres agregar los duplicados o omitirlos?"
"duplicate_song": "Algunas de las canciones seleccionadas están presentes en la playlist",
"song_exist": "Se están agregando duplicados a la playlist. ¿Quieres agregar los duplicados o omitirlos?"
}
},
"radio": {
@@ -235,11 +242,13 @@
"updatedAt": "Actualizado el"
},
"actions": {
"remove": "Eliminar"
"remove": "Eliminar",
"remove_all": "Eliminar todo"
},
"notifications": {
"removed": "Eliminado"
}
},
"empty": "No hay archivos perdidos"
}
},
"ra": {
@@ -419,7 +428,9 @@
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro",
"remove_missing_title": "Eliminar elemento faltante",
"remove_missing_content": ""
"remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
"remove_all_missing_title": "Eliminar todos los archivos perdidos",
"remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones."
},
"menu": {
"library": "Biblioteca",
@@ -451,7 +462,7 @@
"sharedPlaylists": "Playlists Compartidas"
},
"player": {
"playListsText": "Lista de reproducción",
"playListsText": "Fila de reproducción",
"openText": "Abrir",
"closeText": "Cerrar",
"notContentText": "Sin música",
@@ -493,7 +504,10 @@
"quickScan": "Escaneo rápido",
"fullScan": "Escaneo completo",
"serverUptime": "Uptime del servidor",
"serverDown": "OFFLINE"
"serverDown": "OFFLINE",
"scanType": "Tipo",
"status": "Error de escaneo",
"elapsedTime": "Tiempo transcurrido"
},
"help": {
"title": "Atajos de teclado de Navidrome",
@@ -509,4 +523,4 @@
"current_song": "Canción actual"
}
}
}
}

View File

@@ -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",

View File

@@ -32,7 +32,10 @@
"participants": "Lisäosallistujat",
"tags": "Lisätunnisteet",
"mappedTags": "Mäpättyt tunnisteet",
"rawTags": "Raakatunnisteet"
"rawTags": "Raakatunnisteet",
"bitDepth": "Bittisyvyys",
"sampleRate": "Näytteenottotaajuus",
"missing": ""
},
"actions": {
"addToQueue": "Lisää jonoon",
@@ -70,7 +73,9 @@
"releaseType": "Tyyppi",
"grouping": "Ryhmittely",
"media": "Media",
"mood": "Tunnelma"
"mood": "Tunnelma",
"date": "Tallennuspäivä",
"missing": ""
},
"actions": {
"playAll": "Soita",
@@ -102,7 +107,8 @@
"rating": "Arvostelu",
"genre": "Tyylilaji",
"size": "Koko",
"role": "Rooli"
"role": "Rooli",
"missing": ""
},
"roles": {
"albumartist": "Albumitaiteilija |||| Albumitaiteilijat",
@@ -190,7 +196,8 @@
"addNewPlaylist": "Luo \"%{name}\"",
"export": "Vie",
"makePublic": "Tee julkinen",
"makePrivate": "Tee yksityinen"
"makePrivate": "Tee yksityinen",
"saveQueue": ""
},
"message": {
"duplicate_song": "Lisää olemassa oleva kappale",
@@ -235,11 +242,13 @@
"updatedAt": "Katosi"
},
"actions": {
"remove": "Poista"
"remove": "Poista",
"remove_all": ""
},
"notifications": {
"removed": "Puuttuvat tiedostot poistettu"
}
},
"empty": "Ei puuttuvia tiedostoja"
}
},
"ra": {
@@ -419,7 +428,9 @@
"downloadDialogTitle": "Lataa %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Kopio leikepöydälle: Ctrl+C, Enter",
"remove_missing_title": "Poista puuttuvat tiedostot",
"remove_missing_content": "Oletko varma, että haluat poistaa valitut puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien niiden soittojen määrät ja arvostelut."
"remove_missing_content": "Oletko varma, että haluat poistaa valitut puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien niiden soittojen määrät ja arvostelut.",
"remove_all_missing_title": "",
"remove_all_missing_content": ""
},
"menu": {
"library": "Kirjasto",
@@ -493,7 +504,10 @@
"quickScan": "Nopea tarkistus",
"fullScan": "Täysi tarkistus",
"serverUptime": "Palvelun käyttöaika",
"serverDown": "SAMMUTETTU"
"serverDown": "SAMMUTETTU",
"scanType": "",
"status": "",
"elapsedTime": ""
},
"help": {
"title": "Navidrome pikapainikkeet",

View File

@@ -33,7 +33,9 @@
"tags": "Étiquettes supplémentaires",
"mappedTags": "Étiquettes correspondantes",
"rawTags": "Étiquettes brutes",
"bitDepth": "Profondeur de bit"
"bitDepth": "Profondeur de bits",
"sampleRate": "Fréquence d'échantillonnage",
"missing": "Manquant"
},
"actions": {
"addToQueue": "Ajouter à la file",
@@ -71,7 +73,9 @@
"releaseType": "Type",
"grouping": "Regroupement",
"media": "Média",
"mood": "Humeur"
"mood": "Humeur",
"date": "Date d'enregistrement",
"missing": "Manquant"
},
"actions": {
"playAll": "Lire",
@@ -103,7 +107,8 @@
"rating": "Classement",
"genre": "Genre",
"size": "Taille",
"role": "Rôle"
"role": "Rôle",
"missing": "Manquant"
},
"roles": {
"albumartist": "Artiste de l'album |||| Artistes de l'album",
@@ -191,7 +196,8 @@
"addNewPlaylist": "Créer \"%{name}\"",
"export": "Exporter",
"makePublic": "Rendre publique",
"makePrivate": "Rendre privée"
"makePrivate": "Rendre privée",
"saveQueue": "Sauvegarder la file de lecture dans la playlist"
},
"message": {
"duplicate_song": "Pistes déjà présentes dans la playlist",
@@ -236,7 +242,8 @@
"updatedAt": "A disparu le"
},
"actions": {
"remove": "Supprimer"
"remove": "Supprimer",
"remove_all": "Tout supprimer"
},
"notifications": {
"removed": "Fichier(s) manquant(s) supprimé(s)"
@@ -421,7 +428,9 @@
"downloadDialogTitle": "Télécharger %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Copier vers le presse-papier : Ctrl+C, Enter",
"remove_missing_title": "Supprimer les fichiers manquants",
"remove_missing_content": "Êtes-vous sûr(e) de vouloir supprimer les fichiers manquants sélectionnés de la base de données ? Ceci supprimera définitivement toute référence à ceux-ci, y compris leurs nombres d'écoutes et leurs notations"
"remove_missing_content": "Êtes-vous sûr(e) de vouloir supprimer les fichiers manquants sélectionnés de la base de données ? Ceci supprimera définitivement toute référence à ceux-ci, y compris leurs nombres d'écoutes et leurs notations",
"remove_all_missing_title": "Supprimer tous les fichiers manquants",
"remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence."
},
"menu": {
"library": "Bibliothèque",
@@ -495,7 +504,10 @@
"quickScan": "Scan rapide",
"fullScan": "Scan complet",
"serverUptime": "Disponibilité du serveur",
"serverDown": "HORS LIGNE"
"serverDown": "HORS LIGNE",
"scanType": "Type",
"status": "Erreur de scan",
"elapsedTime": "Temps écoulé"
},
"help": {
"title": "Raccourcis Navidrome",

View File

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

View File

@@ -32,7 +32,10 @@
"participants": "Partisipan tambahan",
"tags": "Tag tambahan",
"mappedTags": "Tag yang dipetakan",
"rawTags": "Tag raw"
"rawTags": "Tag raw",
"bitDepth": "Bit depth",
"sampleRate": "Sample rate",
"missing": "Hilang"
},
"actions": {
"addToQueue": "Tambah ke antrean",
@@ -70,7 +73,9 @@
"releaseType": "Tipe",
"grouping": "Pengelompokkan",
"media": "Media",
"mood": "Mood"
"mood": "Mood",
"date": "Tanggal Perekaman",
"missing": "Hilang"
},
"actions": {
"playAll": "Putar",
@@ -102,7 +107,8 @@
"rating": "Peringkat",
"genre": "Genre",
"size": "Ukuran",
"role": "Peran"
"role": "Peran",
"missing": "Hilang"
},
"roles": {
"albumartist": "Artis Album |||| Artis Album",
@@ -163,7 +169,7 @@
}
},
"transcoding": {
"name": "Transkode |||| Transkode",
"name": "Transkoding |||| Transkoding",
"fields": {
"name": "Nama",
"targetFormat": "Target Format",
@@ -190,7 +196,8 @@
"addNewPlaylist": "Buat \"%{name}\"",
"export": "Ekspor",
"makePublic": "Jadikan Publik",
"makePrivate": "Jadikan Pribadi"
"makePrivate": "Jadikan Pribadi",
"saveQueue": "Simpan Antrean ke Playlist"
},
"message": {
"duplicate_song": "Tambahkan lagu duplikat",
@@ -235,11 +242,13 @@
"updatedAt": "Tidak muncul di"
},
"actions": {
"remove": "Hapus"
"remove": "Hapus",
"remove_all": "Hapus Semua"
},
"notifications": {
"removed": "File yang hilang dihapus"
}
},
"empty": "Tidak ada File yang Hilang"
}
},
"ra": {
@@ -277,7 +286,7 @@
"add": "Tambah",
"back": "Kembali",
"bulk_actions": "1 item dipilih |||| %{smart_count} item dipilih",
"cancel": "Batalkan",
"cancel": "Batal",
"clear_input_value": "Hapus",
"clone": "Klon",
"confirm": "Konfirmasi",
@@ -292,7 +301,7 @@
"save": "Simpan",
"search": "Cari",
"show": "Tampilkan",
"sort": "Sortir",
"sort": "Urutkan",
"undo": "Batalkan",
"expand": "Luaskan",
"close": "Tutup",
@@ -312,7 +321,7 @@
"create": "Buat %{name}",
"dashboard": "Dasbor",
"edit": "%{name} #%{id}",
"error": "Ada yang tidak beres",
"error": "Terjadi kesalahan",
"list": "%{name}",
"loading": "Memuat",
"not_found": "Tidak ditemukan",
@@ -356,7 +365,7 @@
"unsaved_changes": "Beberapa perubahan tidak disimpan. Apakah Kamu yakin ingin mengabaikannya?"
},
"navigation": {
"no_results": "Tidak ada hasil yang ditemukan",
"no_results": "Hasil tidak ditemukan",
"no_more_results": "Nomor halaman %{page} melampaui batas. Coba halaman sebelumnya.",
"page_out_of_boundaries": "Nomor halaman %{page} melampaui batas",
"page_out_from_end": "Tidak dapat menelusuri sebelum halaman terakhir",
@@ -371,8 +380,8 @@
"updated": "Elemen diperbarui |||| %{smart_count} elemen diperbarui",
"created": "Elemen dibuat",
"deleted": "Elemen dihapus |||| %{smart_count} elemen dihapus",
"bad_item": "Elemen salah",
"item_doesnt_exist": "Tidak ada elemen",
"bad_item": "Kesalahan elemen",
"item_doesnt_exist": "Elemen tidak ditemukan",
"http_error": "Kesalahan komunikasi peladen",
"data_provider_error": "dataProvider galat. Periksa konsol untuk detailnya.",
"i18n_error": "Tidak dapat memuat terjemahan untuk bahasa yang diatur",
@@ -419,7 +428,9 @@
"downloadDialogTitle": "Unduh %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Salin ke papan klip: Ctrl+C, Enter",
"remove_missing_title": "Hapus file yang hilang",
"remove_missing_content": "Apakah Anda yakin ingin menghapus file-file yang hilang dari basis data? Tindakan ini akan menghapus secara permanen semua referensi ke file-file tersebut, termasuk jumlah pemutaran dan peringkatnya."
"remove_missing_content": "Apakah Anda yakin ingin menghapus file-file yang hilang dari basis data? Tindakan ini akan menghapus secara permanen semua referensi ke file-file tersebut, termasuk jumlah pemutaran dan peringkatnya.",
"remove_all_missing_title": "Hapus semua file yang hilang",
"remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka."
},
"menu": {
"library": "Pustaka",
@@ -451,7 +462,7 @@
"sharedPlaylists": "Playlist yang Dibagikan"
},
"player": {
"playListsText": "Mainkan Antrean",
"playListsText": "Putar Antrean",
"openText": "Buka",
"closeText": "Tutup",
"notContentText": "Tidak ada musik",
@@ -471,7 +482,7 @@
"playModeText": {
"order": "Berurutan",
"orderLoop": "Ulang",
"singleLoop": "Ulangi Satu",
"singleLoop": "Ulangi Sekali",
"shufflePlay": "Acak"
}
},
@@ -493,7 +504,10 @@
"quickScan": "Pemindaian Cepat",
"fullScan": "Pemindaian Penuh",
"serverUptime": "Waktu Aktif Peladen",
"serverDown": "LURING"
"serverDown": "LURING",
"scanType": "Tipe",
"status": "Kesalahan Memindai",
"elapsedTime": "Waktu Berakhir"
},
"help": {
"title": "Tombol Pintasan Navidrome",

View File

@@ -26,7 +26,16 @@
"bpm": "BPM",
"playDate": "Laatst afgespeeld",
"channels": "Kanalen",
"createdAt": "Datum toegevoegd"
"createdAt": "Datum toegevoegd",
"grouping": "Groep",
"mood": "Sfeer",
"participants": "Extra deelnemers",
"tags": "Extra tags",
"mappedTags": "Gemapte tags",
"rawTags": "Onbewerkte tags",
"bitDepth": "Bit diepte",
"sampleRate": "Sample waarde",
"missing": "Ontbrekend"
},
"actions": {
"addToQueue": "Voeg toe aan wachtrij",
@@ -58,7 +67,15 @@
"originalDate": "Origineel",
"releaseDate": "Uitgegeven",
"releases": "Uitgave |||| Uitgaven",
"released": "Uitgegeven"
"released": "Uitgegeven",
"recordLabel": "Label",
"catalogNum": "Catalogus nummer",
"releaseType": "Type",
"grouping": "Groep",
"media": "Media",
"mood": "Sfeer",
"date": "Opnamedatum",
"missing": "Ontbrekend"
},
"actions": {
"playAll": "Afspelen",
@@ -89,7 +106,24 @@
"playCount": "Afgespeeld",
"rating": "Beoordeling",
"genre": "Genre",
"size": "Grootte"
"size": "Grootte",
"role": "Rol",
"missing": "Ontbrekend"
},
"roles": {
"albumartist": "Album artiest |||| Album artiesten",
"artist": "Artiest |||| Artiesten",
"composer": "Componist |||| Componisten",
"conductor": "Dirigent |||| Dirigenten",
"lyricist": "Tekstschrijver |||| Tekstschrijvers",
"arranger": "Arrangeur |||| Arrangeurs",
"producer": "Producent |||| Producenten",
"director": "Regisseur |||| Regisseurs",
"engineer": "Opnametechnicus |||| Opnametechnici",
"mixer": "Mixer |||| Mixers",
"remixer": "Remixer |||| Remixers",
"djmixer": "DJ Mixer |||| DJ Mixers",
"performer": "Performer |||| Performers"
}
},
"user": {
@@ -162,7 +196,8 @@
"addNewPlaylist": "Creëer \"%{name}\"",
"export": "Exporteer",
"makePublic": "Openbaar maken",
"makePrivate": "Privé maken"
"makePrivate": "Privé maken",
"saveQueue": "Bewaar wachtrij als playlist"
},
"message": {
"duplicate_song": "Dubbele nummers toevoegen",
@@ -198,6 +233,22 @@
"createdAt": "Gecreëerd op",
"downloadable": "Downloads toestaan?"
}
},
"missing": {
"name": "Ontbrekend bestand |||| Ontbrekende bestanden",
"fields": {
"path": "Pad",
"size": "Grootte",
"updatedAt": "Verdwenen op"
},
"actions": {
"remove": "Verwijder",
"remove_all": "Alles verwijderen"
},
"notifications": {
"removed": "Ontbrekende bestanden verwijderd"
},
"empty": "Geen ontbrekende bestanden"
}
},
"ra": {
@@ -212,7 +263,8 @@
"password": "Wachtwoord",
"sign_in": "Inloggen",
"sign_in_error": "Authenticatie mislukt, probeer opnieuw a.u.b.",
"logout": "Uitloggen"
"logout": "Uitloggen",
"insightsCollectionNote": "Navidrome verzamelt anonieme gebruiksdata om het project te verbeteren. Klik [hier] voor meer info en de mogelijkheid om te weigeren"
},
"validation": {
"invalidChars": "Gebruik alleen letters en cijfers",
@@ -374,7 +426,11 @@
"shareSuccess": "URL gekopieeerd naar klembord: %{url}",
"shareFailure": "Fout bij kopieren URL %{url} naar klembord",
"downloadDialogTitle": "Download %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Kopieeer naar klembord: Ctrl+C, Enter"
"shareCopyToClipboard": "Kopieeer naar klembord: Ctrl+C, Enter",
"remove_missing_title": "Verwijder ontbrekende bestanden",
"remove_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
"remove_all_missing_title": "Verwijder alle ontbrekende bestanden",
"remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen."
},
"menu": {
"library": "Bibliotheek",
@@ -396,16 +452,17 @@
"none": "Uitgeschakeld",
"album": "Gebruik Album Gain",
"track": "Gebruik Track Gain"
}
},
"lastfmNotConfigured": "Last.fm API-sleutel is niet geconfigureerd"
}
},
"albumList": "Albums",
"about": "Over",
"playlists": "Playlists",
"sharedPlaylists": "Gedeelde playlists"
"playlists": "Afspeellijsten",
"sharedPlaylists": "Gedeelde afspeellijsten"
},
"player": {
"playListsText": "Afspeellijst afspelen",
"playListsText": "Wachtrij",
"openText": "Openen",
"closeText": "Sluiten",
"notContentText": "Geen muziek",
@@ -433,7 +490,12 @@
"links": {
"homepage": "Thuispagina",
"source": "Broncode",
"featureRequests": "Functie verzoeken"
"featureRequests": "Functie verzoeken",
"lastInsightsCollection": "Laatste inzichten",
"insights": {
"disabled": "Uitgeschakeld",
"waiting": "Wachten"
}
}
},
"activity": {
@@ -442,7 +504,10 @@
"quickScan": "Snelle scan",
"fullScan": "Volledige scan",
"serverUptime": "Server uptime",
"serverDown": "Offline"
"serverDown": "Offline",
"scanType": "Type",
"status": "Scan fout",
"elapsedTime": "Verlopen tijd"
},
"help": {
"title": "Navidrome sneltoetsen",

View File

@@ -1,5 +1,5 @@
{
"languageName": "Português",
"languageName": "Português (Brasil)",
"resources": {
"song": {
"name": "Música |||| Músicas",
@@ -18,7 +18,6 @@
"size": "Tamanho",
"updatedAt": "Últ. Atualização",
"bitRate": "Bitrate",
"bitDepth": "Profundidade de bits",
"discSubtitle": "Sub-título do disco",
"starred": "Favorita",
"comment": "Comentário",
@@ -33,7 +32,10 @@
"participants": "Outros Participantes",
"tags": "Outras Tags",
"mappedTags": "Tags mapeadas",
"rawTags": "Tags originais"
"rawTags": "Tags originais",
"bitDepth": "Profundidade de bits",
"sampleRate": "Taxa de amostragem",
"missing": "Ausente"
},
"actions": {
"addToQueue": "Adicionar à fila",
@@ -57,7 +59,6 @@
"genre": "Gênero",
"compilation": "Coletânea",
"year": "Ano",
"date": "Data de Lançamento",
"updatedAt": "Últ. Atualização",
"comment": "Comentário",
"rating": "Classificação",
@@ -72,7 +73,9 @@
"releaseType": "Tipo",
"grouping": "Agrupamento",
"media": "Mídia",
"mood": "Mood"
"mood": "Mood",
"date": "Data de Lançamento",
"missing": "Ausente"
},
"actions": {
"playAll": "Tocar",
@@ -104,7 +107,8 @@
"rating": "Classificação",
"genre": "Gênero",
"size": "Tamanho",
"role": "Role"
"role": "Role",
"missing": "Ausente"
},
"roles": {
"albumartist": "Artista do Álbum |||| Artistas do Álbum",
@@ -192,7 +196,8 @@
"addNewPlaylist": "Criar \"%{name}\"",
"export": "Exportar",
"makePublic": "Pública",
"makePrivate": "Pessoal"
"makePrivate": "Pessoal",
"saveQueue": "Salvar fila em nova Playlist"
},
"message": {
"duplicate_song": "Adicionar músicas duplicadas",
@@ -231,18 +236,19 @@
},
"missing": {
"name": "Arquivo ausente |||| Arquivos ausentes",
"empty": "Nenhum arquivo ausente",
"fields": {
"path": "Caminho",
"size": "Tamanho",
"updatedAt": "Desaparecido em"
},
"actions": {
"remove": "Remover"
"remove": "Remover",
"remove_all": "Remover todos"
},
"notifications": {
"removed": "Arquivo(s) ausente(s) removido(s)"
}
},
"empty": "Nenhum arquivo ausente"
}
},
"ra": {
@@ -422,7 +428,9 @@
"downloadDialogTitle": "Baixar %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Copie para o clipboard: Ctrl+C, Enter",
"remove_missing_title": "Remover arquivos ausentes",
"remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações."
"remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.",
"remove_all_missing_title": "Remover todos os arquivos ausentes",
"remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações."
},
"menu": {
"library": "Biblioteca",
@@ -496,7 +504,10 @@
"quickScan": "Scan rápido",
"fullScan": "Scan completo",
"serverUptime": "Uptime do servidor",
"serverDown": "DESCONECTADO"
"serverDown": "DESCONECTADO",
"scanType": "Tipo",
"status": "Erro",
"elapsedTime": "Duração"
},
"help": {
"title": "Teclas de atalho",

View File

@@ -33,8 +33,9 @@
"tags": "Дополнительные теги",
"mappedTags": "Сопоставленные теги",
"rawTags": "Исходные теги",
"bitDepth": "Битовая глубина",
"sampleRate": "Частота дискретизации (Гц)"
"bitDepth": "Битовая глубина (Bit)",
"sampleRate": "Частота дискретизации (Hz)",
"missing": "Поле отсутствует"
},
"actions": {
"addToQueue": "В очередь",
@@ -73,7 +74,8 @@
"grouping": "Группирование",
"media": "Медиа",
"mood": "Настроение",
"date": "Дата записи"
"date": "Дата записи",
"missing": "Поле отсутствует"
},
"actions": {
"playAll": "Играть",
@@ -105,7 +107,8 @@
"rating": "Рейтинг",
"genre": "Жанр",
"size": "Размер",
"role": "Роль"
"role": "Роль",
"missing": "Поле отсутствует"
},
"roles": {
"albumartist": "Исполнитель альбома |||| Исполнители альбома",
@@ -157,7 +160,7 @@
"fields": {
"name": "Имя",
"transcodingId": "Транскодирование",
"maxBitRate": "Макс. Битрейт",
"maxBitRate": "Макс. битрейт",
"client": "Клиент",
"userName": "Пользователь",
"lastSeen": "Был на сайте",
@@ -175,7 +178,7 @@
}
},
"playlist": {
"name": "Плейлистов |||| Плейлисты",
"name": "Плейлист |||| Плейлисты",
"fields": {
"name": "Название",
"duration": "Длительность",
@@ -193,7 +196,8 @@
"addNewPlaylist": "Создать \"%{name}\"",
"export": "Экспорт",
"makePublic": "Опубликовать",
"makePrivate": "Сделать личным"
"makePrivate": "Сделать личным",
"saveQueue": "Сохранить очередь в плейлист"
},
"message": {
"duplicate_song": "Повторяющиеся треки",
@@ -224,7 +228,7 @@
"lastVisitedAt": "Последнее посещение",
"visitCount": "Посещения",
"format": "Формат",
"maxBitRate": "Макс. Битрейт",
"maxBitRate": "Макс. битрейт",
"updatedAt": "Обновлено в",
"createdAt": "Создано",
"downloadable": "Разрешить загрузку?"
@@ -238,7 +242,8 @@
"updatedAt": "Исчез"
},
"actions": {
"remove": "Удалить"
"remove": "Удалить",
"remove_all": "Убрать все"
},
"notifications": {
"removed": "Отсутствующие файлы удалены"
@@ -274,7 +279,7 @@
"oneOf": "Должно быть одним из: %{options}",
"regex": "Должно быть в формате (regexp): %{pattern}",
"unique": "Должно быть уникальным",
"url": "Должен быть действительным URL адрес"
"url": "Должен быть действительный URL"
},
"action": {
"add_filter": "Фильтр",
@@ -291,7 +296,7 @@
"export": "Экспорт",
"list": "Список",
"refresh": "Обновить",
"remove_filter": "Убрать фильтр",
"remove_filter": "Убрать этот фильтр",
"remove": "Удалить",
"save": "Сохранить",
"search": "Поиск",
@@ -382,7 +387,7 @@
"i18n_error": "Не удалось загрузить перевод для указанного языка",
"canceled": "Операция отменена",
"logged_out": "Ваша сессия завершена, попробуйте переподключиться/войти снова",
"new_version": "Доступна новая версия! Пожалуйста, обновите это окно"
"new_version": "Доступна новая версия! Пожалуйста, обновите это окно."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Отображение столбцов",
@@ -423,7 +428,9 @@
"downloadDialogTitle": "Скачать %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Копировать в буфер обмена: Ctrl+C, Enter",
"remove_missing_title": "Удалить отсутствующие файлы",
"remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах."
"remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах.",
"remove_all_missing_title": "Удалите все отсутствующие файлы",
"remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество игр и рейтинг."
},
"menu": {
"library": "Библиотека",
@@ -482,7 +489,7 @@
"about": {
"links": {
"homepage": "Главная",
"source": "Код",
"source": "Исходный код",
"featureRequests": "Предложения",
"lastInsightsCollection": "Последний сбор данных",
"insights": {
@@ -497,7 +504,10 @@
"quickScan": "Быстрое сканирование",
"fullScan": "Полное сканирование",
"serverUptime": "Время работы сервера",
"serverDown": "Оффлайн"
"serverDown": "Оффлайн",
"scanType": "Тип",
"status": "Ошибка сканирования",
"elapsedTime": "Прошедшее время"
},
"help": {
"title": "Горячие клавиши Navidrome",
@@ -510,7 +520,7 @@
"vol_up": "Увеличить громкость",
"vol_down": "Уменьшить громкость",
"toggle_love": "Добавить / удалить песню из избранного",
"current_song": "Перейти к текущей песне"
"current_song": "Перейти к текущему треку"
}
}
}

View File

@@ -26,7 +26,16 @@
"bpm": "BPM",
"playDate": "Senast spelad",
"channels": "Channels",
"createdAt": "Skapad"
"createdAt": "Skapad",
"grouping": "Gruppering",
"mood": "Stämning",
"participants": "Ytterligare medverkande",
"tags": "Ytterligare taggar",
"mappedTags": "Mappade taggar",
"rawTags": "Omodifierade taggar",
"bitDepth": "Bitdjup",
"sampleRate": "Samplingsfrekvens",
"missing": "Saknade"
},
"actions": {
"addToQueue": "Lägg till i kön",
@@ -58,7 +67,15 @@
"originalDate": "Originaldatum",
"releaseDate": "Utgivningsdatum",
"releases": "Utgåva |||| Utgåvor",
"released": "Utgiven"
"released": "Utgiven",
"recordLabel": "Skivbolag",
"catalogNum": "Katalognummer",
"releaseType": "Typ",
"grouping": "Gruppering",
"media": "Media",
"mood": "Stämning",
"date": "Inspelningsdatum",
"missing": "Saknade"
},
"actions": {
"playAll": "Spela",
@@ -89,7 +106,24 @@
"playCount": "Spelningar",
"rating": "Betyg",
"genre": "Genre",
"size": "Storlek"
"size": "Storlek",
"role": "Roll",
"missing": "Saknade"
},
"roles": {
"albumartist": "Albumartist |||| Albumartister",
"artist": "Artist |||| Artister",
"composer": "Kompositör |||| Kompositörer",
"conductor": "Dirigent |||| Dirigenter",
"lyricist": "Textförfattare |||| Textförfattare",
"arranger": "Arrangör |||| Arrangörer",
"producer": "Producent |||| Producenter",
"director": "Inspelningsledare |||| Inspelningsledare",
"engineer": "Ljudtekniker |||| Ljudtekniker",
"mixer": "Mixare |||| Mixare",
"remixer": "Remixare |||| Remixare",
"djmixer": "DJ-mixare |||| DJ-mixare",
"performer": "Utövande artist |||| Utövande artister"
}
},
"user": {
@@ -162,7 +196,8 @@
"addNewPlaylist": "Skapa \"%{name}\"",
"export": "Exportera",
"makePublic": "Gör offentlig",
"makePrivate": "Gör privat"
"makePrivate": "Gör privat",
"saveQueue": "Spara kö till spellista"
},
"message": {
"duplicate_song": "Lägg till dubletter",
@@ -198,6 +233,22 @@
"createdAt": "Skapad",
"downloadable": "Tillåt nedladdning?"
}
},
"missing": {
"name": "Saknad fil |||| Saknade filer",
"fields": {
"path": "Sökväg",
"size": "Storlek",
"updatedAt": "Försvann"
},
"actions": {
"remove": "Radera",
"remove_all": "Radera alla"
},
"notifications": {
"removed": "Saknade fil(er) borttagna"
},
"empty": "Inga saknade filer"
}
},
"ra": {
@@ -375,7 +426,11 @@
"shareSuccess": "URL kopierades till urklipp: %{url}",
"shareFailure": "Fel vid kopiering av URL %{url} till urklipp",
"downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter"
"shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter",
"remove_missing_title": "Ta bort saknade filer",
"remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.",
"remove_all_missing_title": "Ta bort alla saknade filer",
"remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg."
},
"menu": {
"library": "Bibliotek",
@@ -449,7 +504,10 @@
"quickScan": "Snabbscan",
"fullScan": "Komplett scan",
"serverUptime": "Serverdrifttid",
"serverDown": "OFFLINE"
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Fel vid scanning",
"elapsedTime": "Spelad tid"
},
"help": {
"title": "Navidrome kortkommandon",

View File

@@ -34,7 +34,8 @@
"mappedTags": "Eşlenen etiketler",
"rawTags": "Ham etiketler",
"bitDepth": "Bit derinliği",
"sampleRate": "Örnekleme Oranı"
"sampleRate": "Örnekleme Oranı",
"missing": ""
},
"actions": {
"addToQueue": "Oynatma Sırasına Ekle",
@@ -73,7 +74,8 @@
"grouping": "Gruplama",
"media": "Medya",
"mood": "Mod",
"date": "Kayıt Tarihi"
"date": "Kayıt Tarihi",
"missing": ""
},
"actions": {
"playAll": "Oynat",
@@ -105,7 +107,8 @@
"rating": "Derecelendirme",
"genre": "Tür",
"size": "Boyut",
"role": "Rol"
"role": "Rol",
"missing": ""
},
"roles": {
"albumartist": "Albüm Sanatçısı |||| Albüm Sanatçısı",
@@ -193,7 +196,8 @@
"addNewPlaylist": "Oluştur \"%{name}\"",
"export": "Aktar",
"makePublic": "Herkese Açık Yap",
"makePrivate": "Özel Yap"
"makePrivate": "Özel Yap",
"saveQueue": ""
},
"message": {
"duplicate_song": "Yinelenen şarkıları ekle",
@@ -238,7 +242,8 @@
"updatedAt": "Kaybolma"
},
"actions": {
"remove": "Kaldır"
"remove": "Kaldır",
"remove_all": "Tümünü Kaldır"
},
"notifications": {
"removed": "Eksik dosya(lar) kaldırıldı"
@@ -423,7 +428,9 @@
"downloadDialogTitle": "%{resource}: '%{name}' (%{size}) dosyasını indirin",
"shareCopyToClipboard": "Panoya kopyala: Ctrl+C, Enter",
"remove_missing_title": "Eksik dosyaları kaldır",
"remove_missing_content": "Seçili eksik dosyaları veritabanından kaldırmak istediğinizden emin misiniz? Bu, oynatma sayıları ve derecelendirmeleri dahil olmak üzere bunlara ilişkin tüm referansları kalıcı olarak kaldıracaktır."
"remove_missing_content": "Seçili eksik dosyaları veritabanından kaldırmak istediğinizden emin misiniz? Bu, oynatma sayıları ve derecelendirmeleri dahil olmak üzere bunlara ilişkin tüm referansları kalıcı olarak kaldıracaktır.",
"remove_all_missing_title": "Tüm eksik dosyaları kaldırın",
"remove_all_missing_content": "Veritabanından tüm eksik dosyaları kaldırmak istediğinizden emin misiniz? Bu, oynatma sayısı ve derecelendirmelerde dahil olmak üzere bunlara ilişkili tüm değerleri kalıcı olarak kaldıracaktır."
},
"menu": {
"library": "Kütüphane",
@@ -497,7 +504,10 @@
"quickScan": "Hızlı Tarama",
"fullScan": "Tam Tarama",
"serverUptime": "Sunucu Çalışma Süresi",
"serverDown": "ÇEVRİMDIŞI"
"serverDown": "ÇEVRİMDIŞI",
"scanType": "Tür",
"status": "Tarama Hatası",
"elapsedTime": "Geçen Süre"
},
"help": {
"title": "Navidrome Kısayolları",

View File

@@ -32,7 +32,10 @@
"participants": "Додаткові вчасники",
"tags": "Додаткові теги",
"mappedTags": "Зіставлені теги",
"rawTags": "Вихідні теги"
"rawTags": "Вихідні теги",
"bitDepth": "Глибина розрядності",
"sampleRate": "Частота дискретизації",
"missing": "Поле відсутнє"
},
"actions": {
"addToQueue": "Прослухати пізніше",
@@ -70,7 +73,9 @@
"releaseType": "Тип",
"grouping": "Групування",
"media": "Медіа",
"mood": "Настрій"
"mood": "Настрій",
"date": "Дата запису",
"missing": "Поле відсутнє"
},
"actions": {
"playAll": "Прослухати",
@@ -102,7 +107,8 @@
"rating": "Рейтинг",
"genre": "Жанр",
"size": "Розмір",
"role": "Роль"
"role": "Роль",
"missing": "Поле відсутнє"
},
"roles": {
"albumartist": "Виконавець альбому |||| Виконавці альбому",
@@ -190,7 +196,8 @@
"addNewPlaylist": "Створити \"%{name}\"",
"export": "Експортувати",
"makePublic": "Зробити публічним",
"makePrivate": "Зробити приватним"
"makePrivate": "Зробити приватним",
"saveQueue": "Зберегти чергу до плейлиста"
},
"message": {
"duplicate_song": "Додати повторювані пісні",
@@ -235,11 +242,13 @@
"updatedAt": "Зник"
},
"actions": {
"remove": "Видалити"
"remove": "Видалити",
"remove_all": "Вилучити всі"
},
"notifications": {
"removed": "Видалено зниклі файл(и)"
}
},
"empty": "Немає відсутніх файлів"
}
},
"ra": {
@@ -419,7 +428,9 @@
"downloadDialogTitle": "Завантаження %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Скопіювати в буфер: Ctrl+C, Enter",
"remove_missing_title": "Видалити зниклі файли",
"remove_missing_content": "Ви впевнені, що хочете видалити вибрані відсутні файли з бази даних? Це назавжди видалить усі посилання на них, включаючи кількість прослуховувань та рейтинги."
"remove_missing_content": "Ви впевнені, що хочете видалити вибрані відсутні файли з бази даних? Це назавжди видалить усі посилання на них, включаючи кількість прослуховувань та рейтинги.",
"remove_all_missing_title": "Видалити всі відсутні файли",
"remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами."
},
"menu": {
"library": "Бібліотека",
@@ -493,7 +504,10 @@
"quickScan": "Швидке сканування",
"fullScan": "Повне сканування",
"serverUptime": "Час роботи",
"serverDown": "Оффлайн"
"serverDown": "Оффлайн",
"scanType": "Тип",
"status": "Помилка сканування",
"elapsedTime": "Пройдений час"
},
"help": {
"title": "Гарячі клавіші Navidrome",

View File

@@ -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)

View 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"))
})
})
})

View File

@@ -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)

View File

@@ -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())
})
})
})
})

View File

@@ -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()

View File

@@ -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))
})
})
})
})
})

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -37,8 +37,9 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) {
return
}
size := p.IntOr("size", 0)
square := p.BoolOr("square", false)
imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, false)
imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, square)
switch {
case errors.Is(err, context.Canceled):
return

View File

@@ -38,7 +38,7 @@ func (api *Router) getArtist(r *http.Request, libId int, ifModifiedSince time.Ti
var indexes model.ArtistIndexes
if lib.LastScanAt.After(ifModifiedSince) {
indexes, err = api.ds.Artist(ctx).GetIndex(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

View File

@@ -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}),
},
},
})
}

View File

@@ -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,

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

@@ -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 &amp; artist2" displayAlbumArtist="album artist1 &amp; album artist2" displayComposer="composer 1 &amp; composer 2" explicitStatus="clean">
<isrc>ISRC-1</isrc>
<genres name="rock"></genres>
<genres name="progressive"></genres>
<replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain>

View File

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

View File

@@ -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 &amp; artist 2" displayAlbumArtist="album artist 1 &amp; album artist 2" displayComposer="composer 1 &amp; composer 2" explicitStatus="clean">
<isrc>ISRC-1</isrc>
<isrc>ISRC-2</isrc>
<genres name="rock"></genres>
<genres name="progressive"></genres>
<replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain>

View File

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

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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)

View File

@@ -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
View File

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,8 @@
"start": "vite",
"build": "vite build",
"serve": "vite preview",
"test": "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": {

View File

@@ -22,6 +22,7 @@ import {
addToPlaylistDialogReducer,
expandInfoDialogReducer,
listenBrainzTokenDialogReducer,
saveQueueDialogReducer,
playerReducer,
albumViewReducer,
activityReducer,
@@ -62,6 +63,7 @@ const adminStore = createAdminStore({
downloadMenuDialog: downloadMenuDialogReducer,
expandInfoDialog: expandInfoDialogReducer,
listenBrainzTokenDialog: listenBrainzTokenDialogReducer,
saveQueueDialog: saveQueueDialogReducer,
shareDialog: shareDialogReducer,
activity: activityReducer,
settings: settingsReducer,

View File

@@ -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,
})

View File

@@ -72,6 +72,10 @@ const useStyles = makeStyles(
width: '15em',
minWidth: '15em',
},
backgroundColor: 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
cover: {
objectFit: 'contain',
@@ -79,6 +83,11 @@ const useStyles = makeStyles(
display: 'block',
width: '100%',
height: '100%',
backgroundColor: 'transparent',
transition: 'opacity 0.3s ease-in-out',
},
coverLoading: {
opacity: 0.5,
},
loveButton: {
top: theme.spacing(-0.2),
@@ -213,6 +222,8 @@ const AlbumDetails = (props) => {
const [isLightboxOpen, setLightboxOpen] = useState(false)
const [expanded, setExpanded] = useState(false)
const [albumInfo, setAlbumInfo] = useState()
const [imageLoading, setImageLoading] = useState(false)
const [imageError, setImageError] = useState(false)
let notes =
albumInfo?.notes?.replace(new RegExp('<.*>', 'g'), '') || record.notes
@@ -236,23 +247,51 @@ const AlbumDetails = (props) => {
})
}, [record])
// Reset image state when album changes
useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const imageUrl = subsonic.getCoverArtUrl(record, 300)
const fullImageUrl = subsonic.getCoverArtUrl(record)
const handleOpenLightbox = useCallback(() => setLightboxOpen(true), [])
const handleImageLoad = useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
const handleOpenLightbox = useCallback(() => {
if (!imageError) {
setLightboxOpen(true)
}
}, [imageError])
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
return (
<Card className={classes.root}>
<div className={classes.cardContents}>
<div className={classes.coverParent}>
<CardMedia
key={record.id}
component={'img'}
src={imageUrl}
width="400"
height="400"
className={classes.cover}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox}
onLoad={handleImageLoad}
onError={handleImageError}
title={record.name}
style={{
cursor: imageError ? 'default' : 'pointer',
}}
/>
</div>
<div className={classes.details}>
@@ -337,7 +376,7 @@ const AlbumDetails = (props) => {
</Collapse>
</div>
)}
{isLightboxOpen && (
{isLightboxOpen && !imageError && (
<Lightbox
imagePadding={50}
animationDuration={200}

View File

@@ -94,6 +94,10 @@ const useCoverStyles = makeStyles({
width: '100%',
objectFit: 'contain',
height: (props) => props.height,
transition: 'opacity 0.3s ease-in-out',
},
coverLoading: {
opacity: 0.5,
},
})
@@ -113,6 +117,8 @@ const Cover = withContentRect('bounds')(({
// Force height to be the same as the width determined by the GridList
// noinspection JSSuspiciousNameCombination
const classes = useCoverStyles({ height: contentRect.bounds.width })
const [imageLoading, setImageLoading] = React.useState(true)
const [imageError, setImageError] = React.useState(false)
const [, dragAlbumRef] = useDrag(
() => ({
type: DraggableTypes.ALBUM,
@@ -121,13 +127,33 @@ const Cover = withContentRect('bounds')(({
}),
[record],
)
// Reset image state when record changes
React.useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const handleImageLoad = React.useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = React.useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
return (
<div ref={measureRef}>
<div ref={dragAlbumRef}>
<img
key={record.id} // Force re-render when record changes
src={subsonic.getCoverArtUrl(record, 300, true)}
alt={record.name}
className={classes.cover}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onLoad={handleImageLoad}
onError={handleImageError}
/>
</div>
</div>

View File

@@ -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>
)
}

View File

@@ -4,11 +4,13 @@ import {
ShowContextProvider,
useShowContext,
useShowController,
Title as RaTitle,
} from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
import AlbumSongs from './AlbumSongs'
import AlbumDetails from './AlbumDetails'
import AlbumActions from './AlbumActions'
import { useResourceRefresh, Title } from '../common'
const useStyles = makeStyles(
(theme) => ({
@@ -25,9 +27,11 @@ const AlbumShowLayout = (props) => {
const { loading, ...context } = useShowContext(props)
const { record } = context
const classes = useStyles()
useResourceRefresh('album', 'song')
return (
<>
{record && <RaTitle title={<Title subTitle={record.name} />} />}
{record && <AlbumDetails {...context} />}
{record && (
<ReferenceManyField

View File

@@ -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>
`;

View File

@@ -6,8 +6,16 @@ import { ImLastfm2 } from 'react-icons/im'
import MusicBrainz from '../icons/MusicBrainz'
import { intersperse } from '../utils'
import config from '../config'
import { makeStyles } from '@material-ui/core/styles'
const useStyles = makeStyles({
linkBar: {
minHeight: '1.875em',
},
})
const ArtistExternalLinks = ({ artistInfo, record }) => {
const classes = useStyles()
const translate = useTranslate()
let linkButtons = []
const lastFMlink = artistInfo?.biography?.match(
@@ -52,7 +60,7 @@ const ArtistExternalLinks = ({ artistInfo, record }) => {
<MusicBrainz className="musicbrainz-icon" />,
)
return <div>{intersperse(linkButtons, ' ')}</div>
return <div className={classes.linkBar}>{intersperse(linkButtons, ' ')}</div>
}
export default ArtistExternalLinks

View File

@@ -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) => (

View File

@@ -7,12 +7,13 @@ import {
useShowContext,
ReferenceManyField,
Pagination,
Title as RaTitle,
} from 'react-admin'
import subsonic from '../subsonic'
import AlbumGridView from '../album/AlbumGridView'
import MobileArtistDetails from './MobileArtistDetails'
import DesktopArtistDetails from './DesktopArtistDetails'
import { useAlbumsPerPage } from '../common/index.js'
import { useAlbumsPerPage, useResourceRefresh, Title } from '../common/index.js'
const ArtistDetails = (props) => {
const record = useRecordContext(props)
@@ -55,6 +56,7 @@ const ArtistShowLayout = (props) => {
const record = useRecordContext()
const { width } = props
const [, perPageOptions] = useAlbumsPerPage(width)
useResourceRefresh('artist', 'album')
const maxPerPage = 90
let perPage = 0
@@ -75,6 +77,7 @@ const ArtistShowLayout = (props) => {
return (
<>
{record && <RaTitle title={<Title subTitle={record.name} />} />}
{record && <ArtistDetails />}
{record && (
<ReferenceManyField

View File

@@ -29,6 +29,7 @@ const useStyles = makeStyles(
float: 'left',
wordBreak: 'break-word',
cursor: 'pointer',
minHeight: '4.5em',
},
content: {
flex: '1 0 auto',
@@ -38,11 +39,22 @@ const useStyles = makeStyles(
height: '12rem',
borderRadius: '6em',
cursor: 'pointer',
backgroundColor: 'transparent',
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
},
coverLoading: {
opacity: 0.5,
},
artistImage: {
maxHeight: '12rem',
minHeight: '12rem',
width: '12rem',
minWidth: '12rem',
backgroundColor: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'none',
},
artistDetail: {
@@ -73,8 +85,31 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
const classes = useStyles()
const title = record.name
const [isLightboxOpen, setLightboxOpen] = React.useState(false)
const [imageLoading, setImageLoading] = React.useState(false)
const [imageError, setImageError] = React.useState(false)
// Reset image state when artist changes
React.useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const handleImageLoad = React.useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = React.useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
const handleOpenLightbox = React.useCallback(() => {
if (!imageError) {
setLightboxOpen(true)
}
}, [imageError])
const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), [])
const handleCloseLightbox = React.useCallback(
() => setLightboxOpen(false),
[],
@@ -86,10 +121,17 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
<Card className={classes.artistImage}>
{artistInfo && (
<CardMedia
className={classes.cover}
image={subsonic.getCoverArtUrl(record, 300)}
key={record.id}
component="img"
src={subsonic.getCoverArtUrl(record, 300)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox}
onLoad={handleImageLoad}
onError={handleImageError}
title={title}
style={{
cursor: imageError ? 'default' : 'pointer',
}}
/>
)}
</Card>
@@ -140,7 +182,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
)}
</Typography>
</div>
{isLightboxOpen && (
{isLightboxOpen && !imageError && (
<Lightbox
imagePadding={50}
animationDuration={200}

View File

@@ -50,6 +50,12 @@ const useStyles = makeStyles(
width: 151,
boxShadow: '0px 0px 6px 0px #565656',
borderRadius: '5px',
backgroundColor: 'transparent',
transition: 'opacity 0.3s ease-in-out',
objectFit: 'cover',
},
coverLoading: {
opacity: 0.5,
},
artistImage: {
marginLeft: '1em',
@@ -81,8 +87,31 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
const classes = useStyles({ img, expanded })
const title = record.name
const [isLightboxOpen, setLightboxOpen] = React.useState(false)
const [imageLoading, setImageLoading] = React.useState(false)
const [imageError, setImageError] = React.useState(false)
// Reset image state when artist changes
React.useEffect(() => {
setImageLoading(true)
setImageError(false)
}, [record.id])
const handleImageLoad = React.useCallback(() => {
setImageLoading(false)
setImageError(false)
}, [])
const handleImageError = React.useCallback(() => {
setImageLoading(false)
setImageError(true)
}, [])
const handleOpenLightbox = React.useCallback(() => {
if (!imageError) {
setLightboxOpen(true)
}
}, [imageError])
const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), [])
const handleCloseLightbox = React.useCallback(
() => setLightboxOpen(false),
[],
@@ -95,10 +124,17 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
<Card className={classes.artistImage}>
{artistInfo && (
<CardMedia
className={classes.cover}
image={subsonic.getCoverArtUrl(record, 300)}
key={record.id}
component="img"
src={subsonic.getCoverArtUrl(record, 300)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox}
onLoad={handleImageLoad}
onError={handleImageError}
title={title}
style={{
cursor: imageError ? 'default' : 'pointer',
}}
/>
)}
</Card>
@@ -136,7 +172,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
</Typography>
</Collapse>
</div>
{isLightboxOpen && (
{isLightboxOpen && !imageError && (
<Lightbox
imagePadding={50}
animationDuration={200}

View File

@@ -1,32 +1,120 @@
import React, { useCallback } from 'react'
import { useDispatch } from 'react-redux'
import { useGetOne } from 'react-admin'
import { GlobalHotKeys } from 'react-hotkeys'
import IconButton from '@material-ui/core/IconButton'
import { useMediaQuery } from '@material-ui/core'
import { RiSaveLine } from 'react-icons/ri'
import { LoveButton, useToggleLove } from '../common'
import { openSaveQueueDialog } from '../actions'
import { keyMap } from '../hotkeys'
import { makeStyles } from '@material-ui/core/styles'
const Placeholder = () => <LoveButton disabled={true} resource={'song'} />
const useStyles = makeStyles((theme) => ({
toolbar: {
display: 'flex',
alignItems: 'center',
flexGrow: 1,
justifyContent: 'flex-end',
gap: '0.5rem',
listStyle: 'none',
padding: 0,
margin: 0,
},
mobileListItem: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
listStyle: 'none',
padding: theme.spacing(0.5),
margin: 0,
height: 24,
},
button: {
width: '2.5rem',
height: '2.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
},
mobileButton: {
width: 24,
height: 24,
padding: 0,
margin: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
},
mobileIcon: {
fontSize: '18px',
display: 'flex',
alignItems: 'center',
},
}))
const Toolbar = ({ id }) => {
const { data, loading } = useGetOne('song', id)
const PlayerToolbar = ({ id, isRadio }) => {
const dispatch = useDispatch()
const { data, loading } = useGetOne('song', id, { enabled: !!id })
const [toggleLove, toggling] = useToggleLove('song', data)
const isDesktop = useMediaQuery('(min-width:810px)')
const classes = useStyles()
const handlers = {
TOGGLE_LOVE: useCallback(() => toggleLove(), [toggleLove]),
}
const handleSaveQueue = useCallback(
(e) => {
dispatch(openSaveQueueDialog())
e.stopPropagation()
},
[dispatch],
)
const buttonClass = isDesktop ? classes.button : classes.mobileButton
const listItemClass = isDesktop ? classes.toolbar : classes.mobileListItem
const saveQueueButton = (
<IconButton
size={isDesktop ? 'small' : undefined}
onClick={handleSaveQueue}
disabled={isRadio}
data-testid="save-queue-button"
className={buttonClass}
>
<RiSaveLine className={!isDesktop ? classes.mobileIcon : undefined} />
</IconButton>
)
const loveButton = (
<LoveButton
record={data}
resource={'song'}
size={isDesktop ? undefined : 'inherit'}
disabled={loading || toggling || !id || isRadio}
className={buttonClass}
/>
)
return (
<>
<GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges />
<LoveButton
record={data}
resource={'song'}
disabled={loading || toggling}
/>
{isDesktop ? (
<li className={`${listItemClass} item`}>
{saveQueueButton}
{loveButton}
</li>
) : (
<>
<li className={`${listItemClass} item`}>{saveQueueButton}</li>
<li className={`${listItemClass} item`}>{loveButton}</li>
</>
)}
</>
)
}
const PlayerToolbar = ({ id, isRadio }) =>
id && !isRadio ? <Toolbar id={id} /> : <Placeholder />
export default PlayerToolbar

View 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()
})
})
})

View File

@@ -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
}
})

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