Compare commits

...

179 Commits

Author SHA1 Message Date
Kendall Garner
13af8ed43a fix(server): preserve m3u file order on import (#3314)
* fix(playlist): preserve m3u file order on import - 3307

Signed-off-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>

* test(server): cover playlist order

* refactor(server): micro-optimizations

* refactor(server): micro-optimizations

* fix(server): playlists imported from reader (POST /playlist) are not synced

* refactor(server): only allocate the capacity required to hold a playlist chunk

---------

Signed-off-by: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2024-09-27 16:05:12 -04:00
Deluan
825cbcbf53 fix(readme): reddit badge is working again. 2024-09-27 15:52:27 -04:00
Deluan
5be73d404f fix(server): allow changing local login background url 2024-09-27 15:18:20 -04:00
Andy
1fa245d141 fix(ui) update Swedish translation (#3316) 2024-09-27 14:53:11 -04:00
Kendall Garner
782cd26b3d fix(ui): save play mode for player (#3315)
* fix(ui): save play mode for player - 3019

* redux

* redux
2024-09-27 13:13:22 -04:00
Deluan
10a1b5faf8 test(scanner): remove redundant fixture file 2024-09-27 09:53:08 -04:00
dependabot[bot]
84dc10529d chore(deps): bump github.com/prometheus/client_golang from 1.20.3 to 1.20.4 (#3301)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.3 to 1.20.4.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.20.3...v1.20.4)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-26 18:52:34 -04:00
dependabot[bot]
24d911744e build(deps): bump github.com/pressly/goose/v3 from 3.22.0 to 3.22.1 (#3302)
Bumps [github.com/pressly/goose/v3](https://github.com/pressly/goose) from 3.22.0 to 3.22.1.
- [Release notes](https://github.com/pressly/goose/releases)
- [Changelog](https://github.com/pressly/goose/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pressly/goose/compare/v3.22.0...v3.22.1)

---
updated-dependencies:
- dependency-name: github.com/pressly/goose/v3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-26 18:45:04 -04:00
dependabot[bot]
6031d97c9d chore(deps): bump rollup from 2.79.1 to 2.79.2 in /ui (#3319)
Bumps [rollup](https://github.com/rollup/rollup) from 2.79.1 to 2.79.2.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v2.79.1...v2.79.2)

---
updated-dependencies:
- dependency-name: rollup
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-26 18:44:23 -04:00
Deluan
80acfc103f fix(server): throttle events sent to UI when scanning. Relates to #1511
See also: https://github.com/navidrome/navidrome/issues/1186#issuecomment-1554818537
2024-09-26 18:19:20 -04:00
Deluan Quintão
76614b8f16 fix(scanner): update lib.LastScanAt on each rescan (#3313) 2024-09-26 06:16:27 -04:00
Deluan
d31952f469 fix(ui): avoid invalid requests after logoff 2024-09-25 15:14:47 -04:00
Xabi
32d2d7c15b fix(ui): update Basque translation (#3306)
Small, unimportant changes
2024-09-22 12:26:09 -04:00
Deluan Quintão
669c8f4c49 refactor(server): replace RangeByChunks with Go 1.23 iterators (#3292)
* refactor(server): replace RangeByChunks with Go 1.23 iterators

* chore: fix comments re: SQLITE_MAX_VARIABLE_NUMBER

* test: improve playqueue test

* refactor(server): don't create a new iterator when it is not required
2024-09-22 11:47:10 -04:00
Deluan Quintão
3910e77a7a build(ci): change GitHub release notes (#3300) 2024-09-21 17:00:13 -04:00
Kendall Garner
196557a41a fix(ui): show effective dB of track when playing (#3293)
* show effective db of track when playing

* tests
2024-09-21 16:46:14 -04:00
Caio Cotts
11d96f1da4 fix(ui): sort mappings (#3296)
* fix(ui): update sort mapping for title in mediafile repository

* fix(ui): create sort mapping for username in share repository

* fix(ui): create sort mapping for owner_name in playlist repository

* fix(ui): create sort mapping for username in player repository

* fix(ui): remove sort mapping for track number in mediafile repository

* chore: add todo to change user_name
2024-09-20 21:36:59 -04:00
Deluan
e628aafa4b build(go): set toolchain to latest version 2024-09-20 18:04:36 -04:00
Deluan
ecf934feab fix(subsonic): random albums not reshuffling.
See: https://github.com/navidrome/navidrome/issues/3277#issuecomment-2364269787
2024-09-20 16:59:46 -04:00
Deluan
5b89bf747f fix(server): play queue should not return empty entries for deleted tracks 2024-09-20 11:22:37 -04:00
Ivan Pešić
7a6845fa5a feat(ui): add Serbian translation (#3287) 2024-09-20 08:51:40 -04:00
Deluan
b6433057e9 fix(ui): make random albums order stick when coming back to the grid 2024-09-19 20:16:50 -04:00
Deluan
d0784b6a21 chore(ci): change "update translations" PR title 2024-09-19 17:28:01 -04:00
gruneforth
b0e7941abe fix(ui): fix Nuclear Theme (#3291)
* Add Nuclear Theme

* Fix login screen color & Softened "link" coloring

---------

Co-authored-by: grune <grune@grunk.me>
2024-09-19 17:13:44 -04:00
Deluan Quintão
a02cfbe2a7 fix(ui): update German translation (#3290)
Co-authored-by: deluan <331353+deluan@users.noreply.github.com>
2024-09-19 14:08:44 -04:00
naiar
04603a1ea2 fix(subsonic): honour PreferSortTag when building indexes for getArtist and getIndexes (#3286)
* fix(scanner): use sort_artist_name when the config PreferSortTags is true - #3285

* revert unwanted modifications

* refactor(server): use cmp.Or to simplify nested ifs

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-09-19 13:44:29 -04:00
Deluan
50870d3e61 fix(ui): sort by favourited 2024-09-19 13:05:26 -04:00
DDinghoya
27780683aa feat(ui): update Korean translation (#3288) 2024-09-19 12:13:50 -04:00
Deluan
5baf0b80aa fix(ui): sort playlist by song duration (#3284) 2024-09-19 08:45:49 -04:00
Deluan
46be041e7b fix(scanner): improve M3U playlist import times (#2706) 2024-09-18 20:12:12 -04:00
Kendall Garner
ee2e04b832 fix(ui): random seed for album list on page reload (#3279)
* random seed for album list on page reload

* Nit: inline variable

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-09-18 12:35:13 -04:00
Kendall Garner
1ba390a72a random -> SEEDRAND (#3274) 2024-09-17 17:03:12 -04:00
Deluan Quintão
8134edb5d1 Fix genre and id filters (#3273) 2024-09-17 16:59:55 -04:00
dependabot[bot]
910a46120b Bump dompurify from 2.4.5 to 2.5.6 in /ui (#3270)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 2.4.5 to 2.5.6.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/2.4.5...2.5.6)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 20:04:37 -04:00
dependabot[bot]
8c86d0945c Bump github.com/mileusna/useragent from 1.3.4 to 1.3.5 (#3269)
Bumps [github.com/mileusna/useragent](https://github.com/mileusna/useragent) from 1.3.4 to 1.3.5.
- [Release notes](https://github.com/mileusna/useragent/releases)
- [Commits](https://github.com/mileusna/useragent/compare/v1.3.4...v1.3.5)

---
updated-dependencies:
- dependency-name: github.com/mileusna/useragent
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-16 19:13:29 -04:00
Caio Cotts
42047fde1a Remove shareURL value from config.js 2024-09-15 17:26:58 -04:00
Caio Cotts
2887cd65fc Fix wrong placement of When in test 2024-09-15 17:26:58 -04:00
Caio Cotts
8ac133027d Make the UI use the new ShareURL option 2024-09-15 17:26:58 -04:00
Caio Cotts
f0240280eb Add ShareURL configuration option 2024-09-15 17:26:58 -04:00
Reilly MacKenzie-Cree
d683688b0e Recursively refresh playlist tracks within smart playlist rules (#3018)
* Recursively refresh playlists within smart playlist rules

Signed-off-by: reillymc <reilly@mackenzie-cree.net>

* Clean up recursive smart playlist functions

Signed-off-by: reillymc <reilly@mackenzie-cree.net>

* Add smart playlist refresh timeout config and tests for nested track refetching

Signed-off-by: reillymc <reilly@mackenzie-cree.net>

* Change SmartPlaylistRefreshTimeout to SmartPlaylistRefreshDelay, increase default value

* Revert `smartPlaylistRefreshDelay` default to 5 seconds

---------

Signed-off-by: reillymc <reilly@mackenzie-cree.net>
Co-authored-by: Deluan <deluan@navidrome.org>
2024-09-15 13:27:54 -04:00
ChekeredList71
180035c1e3 Hungarian patch and typo fix for English (#3263)
* English typo fix

* hungarian-patch

You can find the changes here in detail: https://pastebin.com/GLtmwELv
2024-09-15 11:00:25 -04:00
Deluan
a132755d67 Move update-translations.sh script to workflow directory 2024-09-14 21:37:25 -04:00
Deluan
3107170afd Improve SQL sanitization 2024-09-14 18:53:34 -04:00
dependabot[bot]
d3bb4bb9a1 Bump send and express in /ui (#3260)
Bumps [send](https://github.com/pillarjs/send) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `send` from 0.18.0 to 0.19.0
- [Release notes](https://github.com/pillarjs/send/releases)
- [Changelog](https://github.com/pillarjs/send/blob/master/HISTORY.md)
- [Commits](https://github.com/pillarjs/send/compare/0.18.0...0.19.0)

Updates `express` from 4.20.0 to 4.21.0
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/4.21.0/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.20.0...4.21.0)

---
updated-dependencies:
- dependency-name: send
  dependency-type: indirect
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-14 12:36:32 -04:00
dependabot[bot]
41f380451c Bump path-to-regexp and express in /ui (#3255)
Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `path-to-regexp` from 1.8.0 to 1.9.0
- [Release notes](https://github.com/pillarjs/path-to-regexp/releases)
- [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md)
- [Commits](https://github.com/pillarjs/path-to-regexp/compare/v1.8.0...v1.9.0)

Updates `express` from 4.18.1 to 4.20.0
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.18.1...4.20.0)

---
updated-dependencies:
- dependency-name: path-to-regexp
  dependency-type: indirect
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-14 12:30:29 -04:00
Deluan
e65eb225c8 Small refactoring
- Remove duplication
- Remove warning about builtin keyword `new`
2024-09-13 20:18:00 -04:00
Deluan
e8d0f2ec2c Allow searching songs by filepath, for songs without Title 2024-09-13 18:04:21 -04:00
Deluan
47872c9e8a Fix pipeline 2024-09-13 17:43:50 -04:00
Deluan
9ae2ec1a07 Ignore #snapshot folders when scanning. Fixes #3257 2024-09-13 17:30:08 -04:00
Deluan
a1866c7ff3 Fix log message 2024-09-13 09:13:51 -04:00
Kendall Garner
9f1794b97e Only refresh smart playlist when fetching first track (#3244)
* Only refresh smart playlist when fetching first track

* res -> w
2024-09-10 20:18:37 -04:00
dependabot[bot]
e1762882e3 Bump github.com/prometheus/client_golang from 1.20.2 to 1.20.3 (#3245)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.2 to 1.20.3.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.20.3/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.20.2...v1.20.3)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-09 18:03:16 -04:00
dependabot[bot]
870b217eb9 Bump github.com/pressly/goose/v3 from 3.21.1 to 3.22.0 (#3247)
Bumps [github.com/pressly/goose/v3](https://github.com/pressly/goose) from 3.21.1 to 3.22.0.
- [Release notes](https://github.com/pressly/goose/releases)
- [Changelog](https://github.com/pressly/goose/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pressly/goose/compare/v3.21.1...v3.22.0)

---
updated-dependencies:
- dependency-name: github.com/pressly/goose/v3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-09 17:59:30 -04:00
dependabot[bot]
53af567b45 Bump golang.org/x/image from 0.19.0 to 0.20.0 (#3248)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.19.0 to 0.20.0.
- [Commits](https://github.com/golang/image/compare/v0.19.0...v0.20.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-09 17:59:01 -04:00
dependabot[bot]
605aaf87d8 Bump github.com/mattn/go-sqlite3 from 1.14.22 to 1.14.23 (#3249)
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.22 to 1.14.23.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.22...v1.14.23)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-sqlite3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-09 17:58:50 -04:00
dependabot[bot]
9950538089 Bump github.com/mattn/go-zglob from 0.0.5 to 0.0.6 (#3231)
Bumps [github.com/mattn/go-zglob](https://github.com/mattn/go-zglob) from 0.0.5 to 0.0.6.
- [Commits](https://github.com/mattn/go-zglob/compare/v0.0.5...v0.0.6)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-zglob
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-02 13:22:49 -04:00
Vlad Shulcz
4a55a148cf refactor(core): Refactor selectTranscodingOptions function (#3227)
* refactor(core): Refactor selectTranscodingOptions function - #3226

Signed-off-by: shulcz <vshulcz@gmail.com>

* chore: Fix selectTranscodingOptions function - #3226

Signed-off-by: shulcz <vshulcz@gmail.com>

* Small refactoring to make code more concise

* Fix log message

---------

Signed-off-by: shulcz <vshulcz@gmail.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2024-09-02 12:20:23 -04:00
Deluan
c1b75bca51 Improve change detection for POEditor files 2024-09-02 11:02:24 -04:00
Reilly MacKenzie-Cree
5baab4af77 Update dev container to use Go 1.23 and customizations object (#3228)
Signed-off-by: reillymc <reilly@mackenzie-cree.net>
2024-09-01 22:22:32 -04:00
Xabi
4c87a39242 Add Basque localisation (#3221)
* Add Basque localisation

Initial Basque localisation

* Update eu.json

fixes extra dash

* Update eu.json

fixes

* Update eu.json

653098th time's the charm
2024-09-01 16:03:15 -04:00
Deluan
fc5d18feb7 Change error code type to avoid integer overflow conversion warning 2024-09-01 14:49:48 -04:00
Deluan
4612b0a518 Bump Go dependencies 2024-08-31 19:20:38 -04:00
Deluan Quintão
68ddbf4856 Add i18n lint job 2024-08-31 14:54:04 -04:00
dependabot[bot]
a6d72d8623 Bump webpack from 5.76.1 to 5.94.0 in /ui (#3218)
Bumps [webpack](https://github.com/webpack/webpack) from 5.76.1 to 5.94.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.76.1...v5.94.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-29 16:11:25 -04:00
Deluan
1a41525a7e Upgrade go.mod to 1.23, allow override CI_RELEASER_VERSION for make single and make all 2024-08-29 15:14:20 -04:00
Deluan
8ca1aefad6 Change DefaultPlaylistPublicVisibility to false 2024-08-28 19:23:19 -04:00
John White
67d11dd144 feat: imported playlists are public by default (#3143)
* feat: imported playlists are public by default

* chore: make linter happy

---------

Co-authored-by: John White <john@activecode.dev>
2024-08-28 19:20:05 -04:00
Deluan Quintão
9f65f8f5a8 Update translations (#3164)
Co-authored-by: deluan <331353+deluan@users.noreply.github.com>
2024-08-28 19:14:27 -04:00
Deluan Quintão
bc06a59919 Upgrade TagLib 2.0.2, GoReleaser 2.2.0 (#3217)
* Upgrade ci-goreleaser

* Fix tests

* Fix taglib lib path in macOS
2024-08-28 19:13:08 -04:00
Sunny
6709ab3c5e fix(common): Hide Share/Get Info items in disc context menu - #3204 (#3209)
Signed-off-by: Sunny <sunny@sny.sh>
2024-08-26 21:40:05 -04:00
dependabot[bot]
195f2b3f38 Bump @testing-library/jest-dom from 6.4.8 to 6.5.0 in /ui (#3216)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.4.8 to 6.5.0.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.4.8...v6.5.0)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 21:30:56 -04:00
dependabot[bot]
6ea688e720 Bump github.com/prometheus/client_golang from 1.20.0 to 1.20.2 (#3213)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.0 to 1.20.2.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.20.0...v1.20.2)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 21:30:44 -04:00
dependabot[bot]
496c95fd47 Bump github.com/go-chi/httprate from 0.12.1 to 0.14.0 (#3211)
Bumps [github.com/go-chi/httprate](https://github.com/go-chi/httprate) from 0.12.1 to 0.14.0.
- [Release notes](https://github.com/go-chi/httprate/releases)
- [Commits](https://github.com/go-chi/httprate/compare/v0.12.1...v0.14.0)

---
updated-dependencies:
- dependency-name: github.com/go-chi/httprate
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 21:30:31 -04:00
dependabot[bot]
108bf31148 Bump github.com/pelletier/go-toml/v2 from 2.2.2 to 2.2.3 (#3212)
Bumps [github.com/pelletier/go-toml/v2](https://github.com/pelletier/go-toml) from 2.2.2 to 2.2.3.
- [Release notes](https://github.com/pelletier/go-toml/releases)
- [Changelog](https://github.com/pelletier/go-toml/blob/v2/.goreleaser.yaml)
- [Commits](https://github.com/pelletier/go-toml/compare/v2.2.2...v2.2.3)

---
updated-dependencies:
- dependency-name: github.com/pelletier/go-toml/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 21:30:14 -04:00
dependabot[bot]
7c81143ca9 Bump github.com/onsi/ginkgo/v2 from 2.20.0 to 2.20.1 (#3215)
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.20.0 to 2.20.1.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.20.0...v2.20.1)

---
updated-dependencies:
- dependency-name: github.com/onsi/ginkgo/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 21:29:39 -04:00
dependabot[bot]
533c394f09 Bump github.com/jellydator/ttlcache/v3 from 3.2.0 to 3.2.1 (#3214)
Bumps [github.com/jellydator/ttlcache/v3](https://github.com/jellydator/ttlcache) from 3.2.0 to 3.2.1.
- [Release notes](https://github.com/jellydator/ttlcache/releases)
- [Commits](https://github.com/jellydator/ttlcache/compare/v3.2.0...v3.2.1)

---
updated-dependencies:
- dependency-name: github.com/jellydator/ttlcache/v3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 21:29:28 -04:00
Deluan
c95fa11a2f Remove potential integer overflow conversion uint64 -> int64 2024-08-22 19:28:22 -04:00
Deluan
5d81849603 Fix lint errors 2024-08-21 12:15:25 -04:00
dependabot[bot]
1a8bef0743 Bump react-icons from 5.2.1 to 5.3.0 in /ui (#3200)
Bumps [react-icons](https://github.com/react-icons/react-icons) from 5.2.1 to 5.3.0.
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v5.2.1...v5.3.0)

---
updated-dependencies:
- dependency-name: react-icons
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 18:21:10 -04:00
dependabot[bot]
85bf7b5684 Bump @testing-library/jest-dom from 6.4.6 to 6.4.8 in /ui (#3172)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.4.6 to 6.4.8.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.4.6...v6.4.8)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 18:13:38 -04:00
dependabot[bot]
bdbff1ea38 Bump prettier from 3.3.2 to 3.3.3 in /ui (#3171)
Bumps [prettier](https://github.com/prettier/prettier) from 3.3.2 to 3.3.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.3.2...3.3.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 18:12:45 -04:00
dependabot[bot]
5d58048780 Bump github.com/prometheus/client_golang from 1.19.1 to 1.20.0 (#3199)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.19.1 to 1.20.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.19.1...v1.20.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-19 18:12:29 -04:00
Rob Emery
723f01d98c Fixing Build/lint error: "non-constant format string in call to fmt.Errorf (govet)" (#3198)
* Fixing " non-constant format string in call to fmt.Errorf (govet)"

* Its a string, not an int; read better.
2024-08-19 17:58:35 -04:00
Deluan Quintão
c4bd0e67fa Upgrade Go to 1.23 (#3190)
* Upgrade to Golang 1.23rc1

* Fix imports

* Go 1.23 final version

* Fix lint compatibility with ci-goreleaser
2024-08-19 17:47:54 -04:00
Deluan
0c33523f45 Bump dependencies 2024-08-10 12:22:36 -04:00
Deluan
14d085f651 Deprecate buildall 2024-08-07 16:19:44 -04:00
Deluan
4d4c71212f Build UI bundle on demand 2024-08-07 15:36:29 -04:00
Deluan
e1ba152a38 Reduce noise in logs when pre-caching artwork 2024-08-07 13:08:54 -04:00
Deluan
eaa7f7c7e9 Fix Player filter 2024-08-05 18:21:21 -04:00
Kendall Garner
290333ec59 Use same key for replaygain's preAmp (#3184)
Resolves #2933. To prevent this from happening again, make the localstorage keys consts for set/get
2024-08-03 21:18:41 -04:00
Kendall Garner
fa85e2a781 Use userId in player, other fixes (#3182)
* [bugfix] player: use userId, other fixes

This PR primarily resolves #1928 by switching the foreign key of `player` from `user.user_name` to `user.id`.
There are also a few other fixes/changes:

- For some bizarre reason, `ip_address` is never returned from `read`/`get`. Change the field to `ip`, which works. Somehow
- Update `players_test.go` mock to also check for user agent, replicating the actual code
- Update `player_repository.go` `isPermitted` to check user id. I don't know how this worked before...
- tests!
- a few places referred to `typ`, when it is really `userAgent`. Change the field names

* baseRequest -> selectPlayer

* remove comment

* update migration, make all of persistence foreign key enabled

* maybe don't forget to save the file first
2024-08-03 13:37:21 -04:00
dependabot[bot]
5360283bb0 Bump github.com/onsi/gomega from 1.33.1 to 1.34.0 (#3176)
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.33.1 to 1.34.0.
- [Release notes](https://github.com/onsi/gomega/releases)
- [Changelog](https://github.com/onsi/gomega/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/gomega/compare/v1.33.1...v1.34.0)

---
updated-dependencies:
- dependency-name: github.com/onsi/gomega
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-31 21:35:00 -04:00
dependabot[bot]
e59d81bf78 Bump github.com/microcosm-cc/bluemonday from 1.0.26 to 1.0.27 (#3141)
Bumps [github.com/microcosm-cc/bluemonday](https://github.com/microcosm-cc/bluemonday) from 1.0.26 to 1.0.27.
- [Release notes](https://github.com/microcosm-cc/bluemonday/releases)
- [Commits](https://github.com/microcosm-cc/bluemonday/compare/v1.0.26...v1.0.27)

---
updated-dependencies:
- dependency-name: github.com/microcosm-cc/bluemonday
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-28 15:33:40 -04:00
Deluan
7b2ddfd65a Fix "Cannot read properties of undefined". Closes #3070 2024-07-25 17:22:04 -04:00
Deluan
76c3f5131a Use SHA256 in Gravatar URLs 2024-07-23 17:49:46 -04:00
Soderes
f577704d7a Add Hungarian language (#3157) 2024-07-22 18:10:41 -04:00
dependabot[bot]
f46ff73c53 Bump github.com/go-chi/httprate from 0.9.0 to 0.10.0 (#3160)
Bumps [github.com/go-chi/httprate](https://github.com/go-chi/httprate) from 0.9.0 to 0.10.0.
- [Commits](https://github.com/go-chi/httprate/compare/v0.9.0...v0.10.0)

---
updated-dependencies:
- dependency-name: github.com/go-chi/httprate
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-22 15:31:46 -04:00
Deluan
d046c180bf Fix race condition 2024-07-22 14:27:02 -04:00
Caio Cotts
9b4abd9e5a Add Auto-Import toggle switch to playlists list view. 2024-07-18 00:07:59 +02:00
Caio Cotts
0de5f594fe Remove unnecessary Fragment component. 2024-07-18 00:07:59 +02:00
Deluan
33717f26d4 Fix album sorting in Artist page 2024-07-04 17:21:31 -04:00
dependabot[bot]
6722395879 Bump github.com/unrolled/secure from 1.14.0 to 1.15.0 (#3127)
Bumps [github.com/unrolled/secure](https://github.com/unrolled/secure) from 1.14.0 to 1.15.0.
- [Release notes](https://github.com/unrolled/secure/releases)
- [Commits](https://github.com/unrolled/secure/compare/v1.14.0...v1.15.0)

---
updated-dependencies:
- dependency-name: github.com/unrolled/secure
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 15:53:01 -04:00
dependabot[bot]
2667ad3921 Bump github.com/go-chi/chi/v5 from 5.0.14 to 5.1.0 (#3126)
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.0.14 to 5.1.0.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.0.14...v5.1.0)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 15:52:29 -04:00
Kendall Garner
3e1fa20413 fix background color for nord theme (#3124) 2024-06-29 18:50:33 -04:00
gruneforth
1802015737 Add Nuclear Theme (#3098) 2024-06-29 17:04:30 -04:00
Deluan
47378c6882 Remove unnecessary annotation table primary key 2024-06-29 11:45:41 -04:00
dependabot[bot]
81459cc421 Bump github.com/spf13/cobra from 1.8.0 to 1.8.1 (#3095)
Bumps [github.com/spf13/cobra](https://github.com/spf13/cobra) from 1.8.0 to 1.8.1.
- [Release notes](https://github.com/spf13/cobra/releases)
- [Commits](https://github.com/spf13/cobra/compare/v1.8.0...v1.8.1)

---
updated-dependencies:
- dependency-name: github.com/spf13/cobra
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:47:24 -04:00
dependabot[bot]
4cda3a58dc Bump braces from 3.0.2 to 3.0.3 in /ui (#3085)
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:47:00 -04:00
dependabot[bot]
56557bb0f3 Bump @testing-library/jest-dom from 6.4.5 to 6.4.6 in /ui (#3096)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.4.5 to 6.4.6.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.4.5...v6.4.6)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:46:44 -04:00
dependabot[bot]
c60f443179 Bump prettier from 3.3.1 to 3.3.2 in /ui (#3097)
Bumps [prettier](https://github.com/prettier/prettier) from 3.3.1 to 3.3.2.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.3.1...3.3.2)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:46:34 -04:00
dependabot[bot]
fa3998d6e1 Bump github.com/pressly/goose/v3 from 3.20.0 to 3.21.1 (#3114)
Bumps [github.com/pressly/goose/v3](https://github.com/pressly/goose) from 3.20.0 to 3.21.1.
- [Release notes](https://github.com/pressly/goose/releases)
- [Changelog](https://github.com/pressly/goose/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pressly/goose/compare/v3.20.0...v3.21.1)

---
updated-dependencies:
- dependency-name: github.com/pressly/goose/v3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:45:44 -04:00
dependabot[bot]
8542ac96c0 Bump github.com/go-chi/chi/v5 from 5.0.12 to 5.0.14 (#3115)
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.0.12 to 5.0.14.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.0.12...v5.0.14)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:45:25 -04:00
dependabot[bot]
4557add7ef Bump github.com/lestrrat-go/jwx/v2 from 2.0.21 to 2.1.0 (#3113)
Bumps [github.com/lestrrat-go/jwx/v2](https://github.com/lestrrat-go/jwx) from 2.0.21 to 2.1.0.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/develop/v2/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v2.0.21...v2.1.0)

---
updated-dependencies:
- dependency-name: github.com/lestrrat-go/jwx/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:45:13 -04:00
dependabot[bot]
004fae43f5 Bump golang.org/x/image from 0.17.0 to 0.18.0 (#3119)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.17.0 to 0.18.0.
- [Commits](https://github.com/golang/image/compare/v0.17.0...v0.18.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 17:44:58 -04:00
Deluan
7111535963 Don't panic on PostScan errors. Fix #3118 2024-06-25 17:14:17 -04:00
Deluan
3bc9e75b28 Evict expired items from SimpleCache 2024-06-24 17:32:34 -04:00
Deluan
3993c4d17f Upgrade to ttlcache/v3 2024-06-21 18:09:34 -04:00
Deluan
29b7b740ce Also use SimpleCache in cache.HTTPClient 2024-06-21 17:40:18 -04:00
Deluan
29bc17acd7 Wrap ttlcache in our own SimpleCache implementation 2024-06-21 17:21:09 -04:00
Deluan
4044642abf Add http headers to trace log 2024-06-16 22:31:47 -04:00
Kendall Garner
88eac6d7f3 fix album/media file random sort (#3089) 2024-06-12 21:06:59 -04:00
dependabot[bot]
f267f55713 Bump github.com/prometheus/client_golang from 1.19.0 to 1.19.1
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.19.0 to 1.19.1.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.19.0...v1.19.1)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 22:56:03 -04:00
dependabot[bot]
58990c4830 Bump @testing-library/jest-dom from 6.4.2 to 6.4.5 in /ui
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.4.2 to 6.4.5.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.4.2...v6.4.5)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 22:50:38 -04:00
dependabot[bot]
7a20233a35 Bump ejs from 3.1.9 to 3.1.10 in /ui
Bumps [ejs](https://github.com/mde/ejs) from 3.1.9 to 3.1.10.
- [Release notes](https://github.com/mde/ejs/releases)
- [Commits](https://github.com/mde/ejs/compare/v3.1.9...v3.1.10)

---
updated-dependencies:
- dependency-name: ejs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 22:48:44 -04:00
dependabot[bot]
45679e11c2 Bump clsx from 2.1.0 to 2.1.1 in /ui
Bumps [clsx](https://github.com/lukeed/clsx) from 2.1.0 to 2.1.1.
- [Release notes](https://github.com/lukeed/clsx/releases)
- [Commits](https://github.com/lukeed/clsx/compare/v2.1.0...v2.1.1)

---
updated-dependencies:
- dependency-name: clsx
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 22:42:52 -04:00
dependabot[bot]
05f34b0cce Bump golang.org/x/image from 0.16.0 to 0.17.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/image/compare/v0.16.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 22:35:02 -04:00
dependabot[bot]
586e725d6c Bump react-icons from 5.1.0 to 5.2.1 in /ui
Bumps [react-icons](https://github.com/react-icons/react-icons) from 5.1.0 to 5.2.1.
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v5.1.0...v5.2.1)

---
updated-dependencies:
- dependency-name: react-icons
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 22:27:47 -04:00
dependabot[bot]
a7c4c72dc6 Bump uuid from 9.0.1 to 10.0.0 in /ui
Bumps [uuid](https://github.com/uuidjs/uuid) from 9.0.1 to 10.0.0.
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v9.0.1...v10.0.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 22:17:49 -04:00
Deluan
232c45bd06 Increase artist image url sizes.
See https://support.symfonium.app/t/artist-picture-less-compressed/4447
2024-06-10 16:33:41 -04:00
Caio Cotts
1b77830eb4 Do not use lastFM api key and secret to determine if LastFM.Enabled should be set. 2024-06-10 16:26:39 -04:00
dependabot[bot]
e535f7eb78 Bump prettier from 3.3.0 to 3.3.1 in /ui
Bumps [prettier](https://github.com/prettier/prettier) from 3.3.0 to 3.3.1.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.3.0...3.3.1)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 15:31:43 -04:00
Deluan
d8b2f3d2cf Don't expose fullText data in the Native API 2024-06-09 11:19:22 -04:00
kartikynwa
56303cde23 Add R128_{TRACK,ALBUM}_GAIN support to the scanner (#3072)
* Add R128 gain tags support to the scanner

* Add R128 test to metadata_internal_test.go

* Pass explicit tag names to getGainValue function
2024-06-08 13:45:06 -04:00
Deluan
e434ca9372 Change resized image cache key 2024-06-08 13:37:30 -04:00
Deluan
3252fab171 Increase artist image url sizes.
See https://support.symfonium.app/t/artist-picture-less-compressed/4447
2024-06-08 13:32:57 -04:00
Deluan
6d526870b7 Fix race condition in external metadata retrieval 2024-06-06 21:01:35 -04:00
Deluan
34678611c0 Small refactoring 2024-06-06 20:15:34 -04:00
Deluan
0f7d6b5bc4 More micro-optimizations 2024-06-06 07:11:43 -04:00
Deluan
939f3eee97 Initialize Index Groups regex just once 2024-06-05 23:00:36 -04:00
Deluan
b4ef1b1e38 Replace gg.If with cmp.Or 2024-06-05 22:48:00 -04:00
Deluan
11bef060a3 Small refactoring 2024-06-05 22:40:22 -04:00
Deluan
abe5690018 Refactor string utilities into its own package str 2024-06-05 22:09:27 -04:00
Deluan
46fc38bf61 Fix tests expectations 2024-06-05 19:54:25 -04:00
dependabot[bot]
6d8d519807 Bump prettier from 3.2.5 to 3.3.0 in /ui (#3069)
Bumps [prettier](https://github.com/prettier/prettier) from 3.2.5 to 3.3.0.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.2.5...3.3.0)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-03 14:02:49 -04:00
dependabot[bot]
da9cf22b6b Bump github.com/spf13/viper from 1.18.2 to 1.19.0 (#3068)
Bumps [github.com/spf13/viper](https://github.com/spf13/viper) from 1.18.2 to 1.19.0.
- [Release notes](https://github.com/spf13/viper/releases)
- [Commits](https://github.com/spf13/viper/compare/v1.18.2...v1.19.0)

---
updated-dependencies:
- dependency-name: github.com/spf13/viper
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-03 13:25:33 -04:00
Deluan
8c3919d6a0 Simplify dbx wrapper 2024-06-01 15:01:28 -04:00
dependabot[bot]
4df69bd334 Bump github.com/onsi/ginkgo/v2 from 2.17.3 to 2.19.0 (#3054)
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.17.3 to 2.19.0.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.17.3...v2.19.0)

---
updated-dependencies:
- dependency-name: github.com/onsi/ginkgo/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-27 14:10:56 -04:00
Deluan
ee73a9d297 Small optimization in MediaFiles.ToAlbum() 2024-05-26 14:28:23 -04:00
Caio Cotts
0488fb92cb Fix image stuttering (#3035)
* Fix image stuttering.

* Fix docker publishing for PRs

* Write tests for new square parameter.

* Simplify code for createImage.

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-05-24 20:19:26 -04:00
Deluan
61903facdf Revert isDBInitialized 2024-05-22 16:20:57 -04:00
Drew Weymouth
b6fce0e686 Fix XML marshaling of OpenSubsonic structured lyrics (#3041) 2024-05-22 12:15:14 -04:00
Deluan
f88d3f82da Replace panics with log.Fatals 2024-05-21 17:50:02 -04:00
Deluan
55bff343cd Optimize SQLite3 access. Mainly separate read access from write access.
Based on tips from https://archive.is/Xfjh6#selection-257.0-278.0
2024-05-21 17:19:41 -04:00
dependabot[bot]
68f03d0167 Bump github.com/matoous/go-nanoid/v2 from 2.0.0 to 2.1.0 (#3038)
Bumps [github.com/matoous/go-nanoid/v2](https://github.com/matoous/go-nanoid) from 2.0.0 to 2.1.0.
- [Release notes](https://github.com/matoous/go-nanoid/releases)
- [Commits](https://github.com/matoous/go-nanoid/compare/v2.0.0...v2.1.0)

---
updated-dependencies:
- dependency-name: github.com/matoous/go-nanoid/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-21 10:45:04 -04:00
Deluan
643c763cb4 Show number of results from a query in the logs 2024-05-20 16:21:41 -04:00
Deluan Quintão
67865512c8 Update Ukrainian translations (#3029)
Co-authored-by: deluan <331353+deluan@users.noreply.github.com>
2024-05-19 22:17:13 -04:00
Deluan
b2ecc1d16f Fix G404 gosec lint error 2024-05-19 21:55:19 -04:00
Deluan
bcaa180fc7 Fix 32 bits builds 2024-05-19 13:03:13 -04:00
Deluan
aeed5a7099 Update caniuse-lite 2024-05-19 12:45:19 -04:00
Deluan
3977ef6e0f Make first WebUI random page stick 2024-05-19 12:35:30 -04:00
Deluan
653b4d97f9 Add missing Test function 2024-05-18 15:05:40 -04:00
Guilherme Souza
98218d045e Deterministic pagination in random albums sort (#1841)
* Deterministic pagination in random albums sort

* Reseed on first random page

* Add unit tests

* Use rand in Subsonic API

* Use different seeds per user on SEEDEDRAND() SQLite3 function

* Small refactor

* Fix id mismatch

* Add seeded random to media_file (subsonic endpoint `getRandomSongs`)

* Refactor

* Remove unneeded import

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-05-18 14:10:53 -04:00
Deluan
a9feeac793 Revert "Always run docker steps (#3034)"
This reverts commit 5d41165b5b.
2024-05-18 11:54:16 -04:00
Deluan
1c0551f4f7 Revert "Fix docker publishing for PRs"
This reverts commit 15c9a0ded3.
2024-05-18 11:54:15 -04:00
Deluan
15c9a0ded3 Fix docker publishing for PRs 2024-05-17 22:45:54 -04:00
Deluan Quintão
5d41165b5b Always run docker steps (#3034) 2024-05-17 22:22:47 -04:00
Deluan
0a763b91d5 Fix lint error 2024-05-17 21:46:59 -04:00
Deluan
4d28d534cc Refactor random.WeightedChooser, unsing generics 2024-05-17 15:45:34 -04:00
Deluan
a7a4fb522c Simplify resources.FS 2024-05-16 22:53:51 -04:00
Deluan
7f52ff72dc Simplify image format detection code 2024-05-16 13:49:40 -04:00
Deluan
8ed07333ed Improve resizeImage code readability 2024-05-16 13:49:40 -04:00
Rob Emery
52235c291d Fix memory leak in CachedGenreRepository (#3031)
that the scanner was run, the ttlcache was also created each time.
This caused (under testing with 166 genres in the database) the
memory consumed by navidrome to 101.18MB over approx 3 days; 96%
of which is in instances of this cache. Swapping to a singleton
has reduced this to down to ~ 2.6MB

Co-authored-by: Rob Emery <git@mintsoft.net>
2024-05-16 12:16:56 -04:00
Fynn Petersen-Frey
de0a08915c fix bug in jukebox: property unavailable (#3024)
* fix bug in jukebox: property unavailable

* fix lint error
2024-05-15 09:48:09 -04:00
Deluan
45c4583f1b Fix race condition 2024-05-13 09:28:19 -04:00
Deluan
478c709a64 Associate main entities with library 2024-05-12 21:37:42 -04:00
Deluan
477bcaee58 Store MusicFolder as a library in DB 2024-05-12 21:37:42 -04:00
Deluan
081ef85db6 Rename MediaFolder to Library 2024-05-12 21:37:42 -04:00
Deluan
6f2643e55e Refactor to use more Go 1.22 features 2024-05-12 20:04:53 -04:00
Deluan
9ee63b39cb Update Go to 1.22.3 2024-05-12 20:04:53 -04:00
211 changed files with 7111 additions and 10948 deletions

View File

@@ -4,7 +4,7 @@
"dockerfile": "Dockerfile",
"args": {
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
"VARIANT": "1.22",
"VARIANT": "1.23",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v20"
@@ -18,33 +18,37 @@
"--volume=${localWorkspaceFolder}:/workspaces/${localWorkspaceFolderBasename}:Z"
],
// Set *default* container specific settings.json values on container create.
"settings": {
"terminal.integrated.shell.linux": "/bin/bash",
"go.useGoProxyToCheckForToolUpdates": false,
"go.useLanguageServer": true,
"go.gopath": "/go",
"go.goroot": "/usr/local/go",
"go.toolsGopath": "/go/bin",
"go.formatTool": "goimports",
"go.lintOnSave": "package",
"go.lintTool": "golangci-lint",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
"customizations": {
"vscode": {
"settings": {
"terminal.integrated.shell.linux": "/bin/bash",
"go.useGoProxyToCheckForToolUpdates": false,
"go.useLanguageServer": true,
"go.gopath": "/go",
"go.goroot": "/usr/local/go",
"go.toolsGopath": "/go/bin",
"go.formatTool": "goimports",
"go.lintOnSave": "package",
"go.lintTool": "golangci-lint",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"golang.Go",
"esbenp.prettier-vscode",
"tamasfe.even-better-toml"
]
}
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"golang.Go",
"esbenp.prettier-vscode",
"tamasfe.even-better-toml"
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
4533,

View File

@@ -13,7 +13,7 @@ jobs:
go-lint:
name: Lint Go code
runs-on: ubuntu-latest
container: deluan/ci-goreleaser:1.22.3-1
container: deluan/ci-goreleaser:1.23.0-1
steps:
- uses: actions/checkout@v4
@@ -24,7 +24,6 @@ jobs:
uses: golangci/golangci-lint-action@v6
with:
version: latest
github-token: ${{ secrets.GITHUB_TOKEN }}
problem-matchers: true
args: --timeout 2m
@@ -44,7 +43,7 @@ jobs:
go:
name: Test Go code
runs-on: ubuntu-latest
container: deluan/ci-goreleaser:1.22.3-1
container: deluan/ci-goreleaser:1.23.0-1
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v4
@@ -100,11 +99,28 @@ jobs:
path: ui/build
retention-days: 7
i18n-lint:
name: Lint i18n files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
set -e
for file in resources/i18n/*.json; do
echo "Validating $file"
if ! jq empty "$file" 2>error.log; then
error_message=$(cat error.log)
line_number=$(echo "$error_message" | grep -oP 'line \K[0-9]+')
echo "::error file=$file,line=$line_number::$error_message"
exit 1
fi
done
binaries:
name: Build binaries
needs: [js, go, go-lint]
needs: [js, go, go-lint, i18n-lint]
runs-on: ubuntu-latest
container: deluan/ci-goreleaser:1.22.3-1
container: deluan/ci-goreleaser:1.23.0-1
steps:
- name: Checkout Code
uses: actions/checkout@v4

44
.github/workflows/update-translations.sh vendored Executable file
View File

@@ -0,0 +1,44 @@
#!/bin/sh
set -e
I18N_DIR=resources/i18n
# Function to process JSON: remove empty attributes and sort
process_json() {
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
}
check_lang_diff() {
filename=${I18N_DIR}/"$1".json
url=$(curl -s -X POST https://poeditor.com/api/ \
-d api_token="${POEDITOR_APIKEY}" \
-d action="export" \
-d id="${POEDITOR_PROJECTID}" \
-d language="$1" \
-d type="key_value_json" | jq -r .item)
if [ -z "$url" ]; then
echo "Failed to export $1"
return 1
fi
curl -sSL "$url" > poeditor.json
process_json "$filename" > "$filename".tmp
process_json poeditor.json > poeditor.tmp
diff=$(diff -u "$filename".tmp poeditor.tmp) || true
if [ -n "$diff" ]; then
echo "$diff"
mv poeditor.json "$filename"
fi
rm -f poeditor.json poeditor.tmp "$filename".tmp
}
for file in ${I18N_DIR}/*.json; do
name=$(basename "$file")
code=$(echo "$name" | cut -f1 -d.)
lang=$(jq -r .languageName < "$file")
echo "Downloading $lang ($code)"
check_lang_diff "$code"
done

View File

@@ -14,7 +14,7 @@ jobs:
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
run: |
./update-translations.sh
.github/workflows/update-translations.sh
- name: Show changes, if any
run: |
git status --porcelain
@@ -24,5 +24,5 @@ jobs:
with:
token: ${{ secrets.PAT }}
commit-message: Update translations
title: Update translations from POEditor
title: "fix(ui): update translations from POEditor"
branch: update-translations

View File

@@ -4,11 +4,11 @@ linters:
- asciicheck
- bidichk
- bodyclose
- copyloopvar
- dogsled
- durationcheck
- errcheck
- errorlint
- exportloopref
- gocyclo
- goprintffuncname
- gosec
@@ -26,8 +26,12 @@ linters:
- whitespace
linters-settings:
govet:
enable:
- nilness
gosec:
excludes:
- G501
- G401
- G505
- G115 # Can't check context, where the warning is clearly a false positive. See discussion in https://github.com/securego/gosec/pull/1149

View File

@@ -1,5 +1,6 @@
# GoReleaser config
project_name: navidrome
version: 2
builds:
- id: navidrome_linux_amd64
@@ -121,13 +122,51 @@ checksum:
name_template: "{{ .ProjectName }}_checksums.txt"
snapshot:
name_template: "{{ .Tag }}-SNAPSHOT"
version_template: "{{ .Tag }}-SNAPSHOT"
release:
draft: true
mode: append
footer: |
**Full Changelog**: https://github.com/navidrome/navidrome/compare/{{ .PreviousTag }}...{{ .Tag }}
## Helping out
This release is only possible thanks to the support of some **awesome people**!
Want to be one of them?
You can [sponsor](https://github.com/sponsors/deluan), pay me a [Ko-fi](https://ko-fi.com/deluan) or [contribute with code](https://www.navidrome.org/docs/developers/).
## Where to go next?
* Read installation instructions on our [website](https://www.navidrome.org/docs/installation/).
* Reach out on [Discord](https://discord.gg/xh7j7yF), [Reddit](https://www.reddit.com/r/navidrome/) and [Twitter](https://twitter.com/navidrome)!
changelog:
# sort: asc
sort: asc
use: github
filters:
exclude:
- "^docs:"
- "^test:"
- Merge pull request
- Merge remote-tracking branch
- Merge branch
- go mod tidy
groups:
- title: "New Features"
regexp: '^.*?feat(\(.+\))??!?:.+$'
order: 100
- title: "Security updates"
regexp: '^.*?sec(\(.+\))??!?:.+$'
order: 150
- title: "Bug fixes"
regexp: '^.*?(fix|refactor)(\(.+\))??!?:.+$'
order: 200
- title: "Documentation updates"
regexp: ^.*?docs?(\(.+\))??!?:.+$
order: 400
- title: "Build process updates"
regexp: ^.*?(build|ci)(\(.+\))??!?:.+$
order: 400
- title: Other work
order: 9999

View File

@@ -48,14 +48,15 @@ This improves the readability of the messages
It can be one of the following:
1. **feat**: Addition of a new feature
2. **fix**: Bug fix
3. **docs**: Documentation Changes
4. **style**: Changes to styling
5. **refactor**: Refactoring of code
6. **perf**: Code that affects performance
7. **test**: Updating or improving the current tests
8. **build**: Changes to Build process
9. **revert**: Reverting to a previous commit
10. **chore** : updating grunt tasks etc
3. **sec**: Fixing security issues
4. **docs**: Documentation Changes
5. **style**: Changes to styling
6. **refactor**: Refactoring of code
7. **perf**: Code that affects performance
8. **test**: Updating or improving the current tests
9. **build**: Changes to Build process
10. **revert**: Reverting to a previous commit
11. **chore** : updating grunt tasks etc
If there is a breaking change in your Pull Request, please add `BREAKING CHANGE` in the optional body section

View File

@@ -9,7 +9,9 @@ GIT_SHA=source_archive
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
endif
CI_RELEASER_VERSION=1.22.3-1 ## https://github.com/navidrome/ci-goreleaser
CI_RELEASER_VERSION ?= 1.23.0-1 ## https://github.com/navidrome/ci-goreleaser
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
@echo Downloading Node dependencies...
@@ -20,7 +22,7 @@ dev: check_env ##@Development Start Navidrome in development mode, with hot-re
npx foreman -j Procfile.dev -p 4533 start
.PHONY: dev
server: check_go_env ##@Development Start the backend in development mode
server: check_go_env buildjs ##@Development Start the backend in development mode
@go run github.com/cespare/reflex@latest -d none -c reflex.conf
.PHONY: server
@@ -78,28 +80,30 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
@(cd .git/hooks && ln -sf ../../git/* .)
.PHONY: setup-git
buildall: buildjs build ##@Build Build the project, both frontend and backend
.PHONY: buildall
build: warning-noui-build check_go_env ##@Build Build only backend
build: check_go_env buildjs ##@Build Build the project
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
.PHONY: build
debug-build: warning-noui-build check_go_env ##@Build Build only backend (with remote debug on)
buildall: deprecated build
.PHONY: buildall
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
.PHONY: debug-build
buildjs: check_node_env ##@Build Build only frontend
@(cd ./ui && npm run build)
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
.PHONY: buildjs
all: warning-noui-build ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend
ui/build/index.html: $(UI_SRC_FILES)
@(cd ./ui && npm run build)
all: buildjs ##@Cross_Compilation Build binaries for all supported platforms.
@echo "Building binaries for all platforms using builder ${CI_RELEASER_VERSION}"
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
goreleaser release --clean --skip=publish --snapshot
.PHONY: all
single: warning-noui-build ##@Cross_Compilation Build binaries for a single supported platforms. It does not build the frontend
single: buildjs ##@Cross_Compilation Build binaries for a single supported platforms.
@if [ -z "${GOOS}" -o -z "${GOARCH}" ]; then \
echo "Usage: GOOS=<os> GOARCH=<arch> make single"; \
echo "Options:"; \
@@ -117,10 +121,6 @@ docker: buildjs ##@Build Build Docker linux/amd64 image (tagged as `deluan/navid
docker build . --platform linux/amd64 -t deluan/navidrome:develop -f .github/workflows/pipeline.dockerfile
.PHONY: docker
warning-noui-build:
@echo "WARNING: This command does not build the frontend, it uses the latest built with 'make buildjs'"
.PHONY: warning-noui-build
get-music: ##@Development Download some free music from Navidrome's demo instance
mkdir -p music
( cd music; \
@@ -175,6 +175,10 @@ check_node_env:
pre-push: lintall testall
.PHONY: pre-push
deprecated:
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
.PHONY: deprecated
.DEFAULT_GOAL := help
HELP_FUN = \

View File

@@ -7,7 +7,7 @@
[![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest)
[![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/deluan/navidrome)
[![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF)
[![Subreddit](https://img.shields.io/badge/%2Fr%2Fnavidrome-%2B3000-red?logo=reddit)](https://www.reddit.com/r/navidrome/)
[![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md)
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your

View File

@@ -119,7 +119,7 @@ func startServer(ctx context.Context) func() error {
a.MountRouter("Profiling", "/debug", middleware.Profiler())
}
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
a.MountRouter("Background images", consts.DefaultUILoginBackgroundURL, backgrounds.NewHandler())
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
}
return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey)
}

View File

@@ -29,16 +29,16 @@ import (
// Injectors from wire_injectors.go:
func CreateServer(musicFolder string) *server.Server {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
dbDB := db.Db()
dataStore := persistence.New(dbDB)
broker := events.GetBroker()
serverServer := server.New(dataStore, broker)
return serverServer
}
func CreateNativeAPIRouter() *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
dbDB := db.Db()
dataStore := persistence.New(dbDB)
share := core.NewShare(dataStore)
playlists := core.NewPlaylists(dataStore)
router := nativeapi.New(dataStore, share, playlists)
@@ -46,8 +46,8 @@ func CreateNativeAPIRouter() *nativeapi.Router {
}
func CreateSubsonicAPIRouter() *subsonic.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
dbDB := db.Db()
dataStore := persistence.New(dbDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
agentsAgents := agents.New(dataStore)
@@ -69,8 +69,8 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
}
func CreatePublicRouter() *public.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
dbDB := db.Db()
dataStore := persistence.New(dbDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
agentsAgents := agents.New(dataStore)
@@ -85,22 +85,22 @@ func CreatePublicRouter() *public.Router {
}
func CreateLastFMRouter() *lastfm.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
dbDB := db.Db()
dataStore := persistence.New(dbDB)
router := lastfm.NewRouter(dataStore)
return router
}
func CreateListenBrainzRouter() *listenbrainz.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
dbDB := db.Db()
dataStore := persistence.New(dbDB)
router := listenbrainz.NewRouter(dataStore)
return router
}
func GetScanner() scanner.Scanner {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
dbDB := db.Db()
dataStore := persistence.New(dbDB)
playlists := core.NewPlaylists(dataStore)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
@@ -114,8 +114,8 @@ func GetScanner() scanner.Scanner {
}
func GetPlaybackServer() playback.PlaybackServer {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
dbDB := db.Db()
dataStore := persistence.New(dbDB)
playbackServer := playback.GetInstance(dataStore)
return playbackServer
}

View File

@@ -17,73 +17,76 @@ import (
)
type configOptions struct {
ConfigFile string
Address string
Port int
UnixSocketPerm string
MusicFolder string
DataFolder string
CacheFolder string
DbPath string
LogLevel string
ScanInterval time.Duration
ScanSchedule string
SessionTimeout time.Duration
BaseURL string
BasePath string
BaseHost string
BaseScheme string
TLSCert string
TLSKey string
UILoginBackgroundURL string
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
EnableDownloads bool
EnableExternalServices bool
EnableMediaFileCoverArt bool
TranscodingCacheSize string
ImageCacheSize string
AlbumPlayCountMode string
EnableArtworkPrecache bool
AutoImportPlaylists bool
PlaylistsPath string
AutoTranscodeDownload bool
DefaultDownsamplingFormat string
SearchFullString bool
RecentlyAddedByModTime bool
PreferSortTags bool
IgnoredArticles string
IndexGroups string
SubsonicArtistParticipations bool
FFmpegPath string
MPVPath string
MPVCmdTemplate string
CoverArtPriority string
CoverJpegQuality int
ArtistArtPriority string
EnableGravatar bool
EnableFavourites bool
EnableStarRating bool
EnableUserEditing bool
EnableSharing bool
DefaultDownloadableShare bool
DefaultTheme string
DefaultLanguage string
DefaultUIVolume int
EnableReplayGain bool
EnableCoverAnimation bool
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
AuthWindowLength time.Duration
PasswordEncryptionKey string
ReverseProxyUserHeader string
ReverseProxyWhitelist string
HTTPSecurityHeaders secureOptions
Prometheus prometheusOptions
Scanner scannerOptions
Jukebox jukeboxOptions
ConfigFile string
Address string
Port int
UnixSocketPerm string
MusicFolder string
DataFolder string
CacheFolder string
DbPath string
LogLevel string
ScanInterval time.Duration
ScanSchedule string
SessionTimeout time.Duration
BaseURL string
BasePath string
BaseHost string
BaseScheme string
TLSCert string
TLSKey string
UILoginBackgroundURL string
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
EnableDownloads bool
EnableExternalServices bool
EnableMediaFileCoverArt bool
TranscodingCacheSize string
ImageCacheSize string
AlbumPlayCountMode string
EnableArtworkPrecache bool
AutoImportPlaylists bool
DefaultPlaylistPublicVisibility bool
PlaylistsPath string
SmartPlaylistRefreshDelay time.Duration
AutoTranscodeDownload bool
DefaultDownsamplingFormat string
SearchFullString bool
RecentlyAddedByModTime bool
PreferSortTags bool
IgnoredArticles string
IndexGroups string
SubsonicArtistParticipations bool
FFmpegPath string
MPVPath string
MPVCmdTemplate string
CoverArtPriority string
CoverJpegQuality int
ArtistArtPriority string
EnableGravatar bool
EnableFavourites bool
EnableStarRating bool
EnableUserEditing bool
EnableSharing bool
ShareURL string
DefaultDownloadableShare bool
DefaultTheme string
DefaultLanguage string
DefaultUIVolume int
EnableReplayGain bool
EnableCoverAnimation bool
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
AuthWindowLength time.Duration
PasswordEncryptionKey string
ReverseProxyUserHeader string
ReverseProxyWhitelist string
HTTPSecurityHeaders secureOptions
Prometheus prometheusOptions
Scanner scannerOptions
Jukebox jukeboxOptions
Agents string
LastFM lastfmOptions
@@ -299,7 +302,9 @@ func init() {
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
viper.SetDefault("enableartworkprecache", true)
viper.SetDefault("autoimportplaylists", true)
viper.SetDefault("defaultplaylistpublicvisibility", false)
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
viper.SetDefault("enabledownloads", true)
viper.SetDefault("enableexternalservices", true)
viper.SetDefault("enablemediafilecoverart", true)
@@ -366,6 +371,7 @@ func init() {
viper.SetDefault("devautologinusername", "")
viper.SetDefault("devactivitypanel", true)
viper.SetDefault("enablesharing", false)
viper.SetDefault("shareurl", "")
viper.SetDefault("defaultdownloadableshare", false)
viper.SetDefault("devenablebufferedscrobble", true)
viper.SetDefault("devsidebarplaylists", true)

View File

@@ -11,7 +11,7 @@ import (
const (
AppName = "navidrome"
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on"
DefaultDbPath = "navidrome.db?cache=shared&_cache_size=1000000000&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&_foreign_keys=on&_txlock=immediate"
InitialSetupFlagKey = "InitialSetup"
UIAuthorizationHeader = "X-ND-Authorization"

View File

@@ -60,7 +60,7 @@ var _ = Describe("Agents", func() {
Describe("GetArtistMBID", func() {
It("returns on first match", func() {
Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid"))
Expect(mock.Args).To(ConsistOf("123", "test"))
Expect(mock.Args).To(HaveExactElements("123", "test"))
})
It("returns empty if artist is Various Artists", func() {
mbid, err := ag.GetArtistMBID(ctx, consts.VariousArtistsID, consts.VariousArtists)
@@ -78,7 +78,7 @@ var _ = Describe("Agents", func() {
mock.Err = errors.New("error")
_, err := ag.GetArtistMBID(ctx, "123", "test")
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("123", "test"))
Expect(mock.Args).To(HaveExactElements("123", "test"))
})
It("interrupts if the context is canceled", func() {
cancel()
@@ -91,7 +91,7 @@ var _ = Describe("Agents", func() {
Describe("GetArtistURL", func() {
It("returns on first match", func() {
Expect(ag.GetArtistURL(ctx, "123", "test", "mb123")).To(Equal("url"))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
})
It("returns empty if artist is Various Artists", func() {
url, err := ag.GetArtistURL(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
@@ -109,7 +109,7 @@ var _ = Describe("Agents", func() {
mock.Err = errors.New("error")
_, err := ag.GetArtistURL(ctx, "123", "test", "mb123")
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
})
It("interrupts if the context is canceled", func() {
cancel()
@@ -122,7 +122,7 @@ var _ = Describe("Agents", func() {
Describe("GetArtistBiography", func() {
It("returns on first match", func() {
Expect(ag.GetArtistBiography(ctx, "123", "test", "mb123")).To(Equal("bio"))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
})
It("returns empty if artist is Various Artists", func() {
bio, err := ag.GetArtistBiography(ctx, consts.VariousArtistsID, consts.VariousArtists, "")
@@ -140,7 +140,7 @@ var _ = Describe("Agents", func() {
mock.Err = errors.New("error")
_, err := ag.GetArtistBiography(ctx, "123", "test", "mb123")
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
})
It("interrupts if the context is canceled", func() {
cancel()
@@ -156,13 +156,13 @@ var _ = Describe("Agents", func() {
URL: "imageUrl",
Size: 100,
}}))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetArtistImages(ctx, "123", "test", "mb123")
Expect(err).To(MatchError("not found"))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123"))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123"))
})
It("interrupts if the context is canceled", func() {
cancel()
@@ -178,13 +178,13 @@ var _ = Describe("Agents", func() {
Name: "Joe Dohn",
MBID: "mbid321",
}}))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 1))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 1))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 1))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 1))
})
It("interrupts if the context is canceled", func() {
cancel()
@@ -200,13 +200,13 @@ var _ = Describe("Agents", func() {
Name: "A Song",
MBID: "mbid444",
}}))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 2))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 2))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
})
It("interrupts if the context is canceled", func() {
cancel()
@@ -236,13 +236,13 @@ var _ = Describe("Agents", func() {
},
},
}))
Expect(mock.Args).To(ConsistOf("album", "artist", "mbid"))
Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid"))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetAlbumInfo(ctx, "album", "artist", "mbid")
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(ConsistOf("album", "artist", "mbid"))
Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid"))
})
It("interrupts if the context is canceled", func() {
cancel()

View File

@@ -20,8 +20,8 @@ import (
var ErrUnavailable = errors.New("artwork unavailable")
type Artwork interface {
Get(ctx context.Context, artID model.ArtworkID, size int) (io.ReadCloser, time.Time, error)
GetOrPlaceholder(ctx context.Context, id string, size int) (io.ReadCloser, time.Time, error)
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error)
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error)
}
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, em core.ExternalMetadata) Artwork {
@@ -41,10 +41,10 @@ type artworkReader interface {
Reader(ctx context.Context) (io.ReadCloser, string, error)
}
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) {
func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
artID, err := a.getArtworkId(ctx, id)
if err == nil {
reader, lastUpdate, err = a.Get(ctx, artID, size)
reader, lastUpdate, err = a.Get(ctx, artID, size, square)
}
if errors.Is(err, ErrUnavailable) {
if artID.Kind == model.KindArtistArtwork {
@@ -57,8 +57,8 @@ func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int) (re
return reader, lastUpdate, err
}
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) {
artReader, err := a.getArtworkReader(ctx, artID, size)
func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) {
artReader, err := a.getArtworkReader(ctx, artID, size, square)
if err != nil {
return nil, time.Time{}, err
}
@@ -107,11 +107,11 @@ func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID,
return artID, nil
}
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int) (artworkReader, error) {
func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) {
var artReader artworkReader
var err error
if size > 0 {
artReader, err = resizedFromOriginal(ctx, a, artID, size)
if size > 0 || square {
artReader, err = resizedFromOriginal(ctx, a, artID, size, square)
} else {
switch artID.Kind {
case model.KindArtistArtwork:

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"
@@ -211,33 +215,83 @@ var _ = Describe("Artwork", func() {
alMultipleCovers,
})
})
It("returns a PNG if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15)
Expect(err).ToNot(HaveOccurred())
When("Square is false", func() {
It("returns a PNG if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
Expect(err).ToNot(HaveOccurred())
br, format, err := asImageReader(r)
Expect(format).To(Equal("image/png"))
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("png"))
Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15))
})
It("returns a JPEG if original image is not a PNG", func() {
conf.Server.CoverArtPriority = "cover.jpg"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
Expect(err).ToNot(HaveOccurred())
img, _, err := image.Decode(br)
Expect(err).ToNot(HaveOccurred())
Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15))
img, format, err := image.Decode(r)
Expect(format).To(Equal("jpeg"))
Expect(err).ToNot(HaveOccurred())
Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200))
})
})
It("returns a JPEG if original image is not a PNG", func() {
conf.Server.CoverArtPriority = "cover.jpg"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200)
Expect(err).ToNot(HaveOccurred())
When("When square is true", func() {
var alCover model.Album
br, format, err := asImageReader(r)
Expect(format).To(Equal("image/jpeg"))
Expect(err).ToNot(HaveOccurred())
DescribeTable("resize",
func(format string, landscape bool, size int) {
coverFileName := "cover." + format
dirName := createImage(format, landscape, size)
alCover = model.Album{
ID: "444",
Name: "Only external",
ImageFiles: filepath.Join(dirName, coverFileName),
}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alCover,
})
img, _, err := image.Decode(br)
Expect(err).ToNot(HaveOccurred())
Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200))
conf.Server.CoverArtPriority = coverFileName
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("png"))
Expect(img.Bounds().Size().X).To(Equal(size))
Expect(img.Bounds().Size().Y).To(Equal(size))
},
Entry("portrait png image", "png", false, 200),
Entry("landscape png image", "png", true, 200),
Entry("portrait jpg image", "jpg", false, 200),
Entry("landscape jpg image", "jpg", true, 200),
)
})
})
})
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,7 +31,7 @@ var _ = Describe("Artwork", func() {
Context("GetOrPlaceholder", func() {
Context("Empty ID", func() {
It("returns placeholder if album is not in the DB", func() {
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0)
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0, false)
Expect(err).ToNot(HaveOccurred())
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
@@ -49,7 +49,7 @@ var _ = Describe("Artwork", func() {
Context("Get", func() {
Context("Empty ID", func() {
It("returns an ErrUnavailable error", func() {
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0)
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0, false)
Expect(err).To(MatchError(artwork.ErrUnavailable))
})
})

View File

@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"io"
"maps"
"slices"
"sync"
"time"
@@ -14,7 +16,6 @@ import (
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/pl"
"golang.org/x/exp/maps"
)
type CacheWarmer interface {
@@ -94,7 +95,7 @@ func (a *cacheWarmer) run(ctx context.Context) {
continue
}
batch := maps.Keys(a.buffer)
batch := slices.Collect(maps.Keys(a.buffer))
a.buffer = make(map[model.ArtworkID]struct{})
a.mutex.Unlock()
@@ -121,7 +122,7 @@ func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID)
input := pl.FromSlice(ctx, batch)
errs := pl.Sink(ctx, 2, input, a.doCacheImage)
for err := range errs {
log.Warn(ctx, "Error warming cache", err)
log.Debug(ctx, "Error warming cache", err)
}
}
@@ -129,9 +130,9 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize)
r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, false)
if err != nil {
return fmt.Errorf("error caching id='%s': %w", id, err)
return fmt.Errorf("caching id='%s': %w", id, err)
}
defer r.Close()
_, err = io.Copy(io.Discard, r)

View File

@@ -17,7 +17,7 @@ import (
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/str"
)
type artistReader struct {
@@ -56,7 +56,7 @@ func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkI
}
}
a.files = strings.Join(files, consts.Zwsp)
a.artistFolder = utils.LongestCommonPrefix(paths)
a.artistFolder = str.LongestCommonPrefix(paths)
if !strings.HasSuffix(a.artistFolder, string(filepath.Separator)) {
a.artistFolder, _ = filepath.Split(a.artistFolder)
}

View File

@@ -1,7 +1,6 @@
package artwork
import (
"bufio"
"bytes"
"context"
"fmt"
@@ -9,7 +8,6 @@ import (
"image/jpeg"
"image/png"
"io"
"net/http"
"time"
"github.com/disintegration/imaging"
@@ -23,16 +21,18 @@ type resizedArtworkReader struct {
cacheKey string
lastUpdate time.Time
size int
square bool
a *artwork
}
func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int) (*resizedArtworkReader, error) {
func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int, square bool) (*resizedArtworkReader, error) {
r := &resizedArtworkReader{a: a}
r.artID = artID
r.size = size
r.square = square
// Get lastUpdated and cacheKey from original artwork
original, err := a.getArtworkReader(ctx, artID, 0)
original, err := a.getArtworkReader(ctx, artID, 0, false)
if err != nil {
return nil, err
}
@@ -42,12 +42,11 @@ func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID,
}
func (a *resizedArtworkReader) Key() string {
return fmt.Sprintf(
"%s.%d.%d",
a.cacheKey,
a.size,
conf.Server.CoverJpegQuality,
)
baseKey := fmt.Sprintf("%s.%d", a.cacheKey, a.size)
if a.square {
return baseKey + ".square"
}
return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverJpegQuality)
}
func (a *resizedArtworkReader) LastUpdated() time.Time {
@@ -56,7 +55,7 @@ func (a *resizedArtworkReader) LastUpdated() time.Time {
func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
// Get artwork in original size, possibly from cache
orig, _, err := a.a.Get(ctx, a.artID, 0)
orig, _, err := a.a.Get(ctx, a.artID, 0, false)
if err != nil {
return nil, "", err
}
@@ -66,7 +65,7 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
r := io.TeeReader(orig, buf)
defer orig.Close()
resized, origSize, err := resizeImage(r, a.size)
resized, origSize, err := resizeImage(r, a.size, a.square)
if resized == nil {
log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size)
} else {
@@ -83,54 +82,39 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil
}
func asImageReader(r io.Reader) (io.Reader, string, error) {
br := bufio.NewReader(r)
buf, err := br.Peek(512)
if err == io.EOF && len(buf) > 0 {
// Check if there are enough bytes to detect type
typ := http.DetectContentType(buf)
if typ != "" {
return br, typ, nil
}
}
if err != nil {
return nil, "", err
}
return br, http.DetectContentType(buf), nil
}
func resizeImage(reader io.Reader, size int) (io.Reader, int, error) {
r, format, err := asImageReader(reader)
func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error) {
original, format, err := image.Decode(reader)
if err != nil {
return nil, 0, err
}
img, _, err := image.Decode(r)
if err != nil {
return nil, 0, err
}
// Don't upscale the image
bounds := img.Bounds()
bounds := original.Bounds()
originalSize := max(bounds.Max.X, bounds.Max.Y)
if originalSize <= size {
if originalSize <= size && !square {
return nil, originalSize, nil
}
var m *image.NRGBA
// Preserve the aspect ratio of the image.
if bounds.Max.X > bounds.Max.Y {
m = imaging.Resize(img, size, 0, imaging.Lanczos)
var resized image.Image
if originalSize >= size {
resized = imaging.Fit(original, size, size, imaging.Lanczos)
} else {
m = imaging.Resize(img, 0, size, imaging.Lanczos)
if bounds.Max.Y < bounds.Max.X {
resized = imaging.Resize(original, size, 0, imaging.Lanczos)
} else {
resized = imaging.Resize(original, 0, size, imaging.Lanczos)
}
}
if square {
bg := image.NewRGBA(image.Rect(0, 0, size, size))
resized = imaging.OverlayCenter(bg, resized, 1)
}
buf := new(bytes.Buffer)
buf.Reset()
if format == "image/png" {
err = png.Encode(buf, m)
if format == "png" || square {
err = png.Encode(buf, resized)
} else {
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
err = jpeg.Encode(buf, resized, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
}
return buf, originalSize, err
}

View File

@@ -37,7 +37,7 @@ func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs
}
log.Trace(ctx, "Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err)
}
return nil, "", fmt.Errorf("could not get a cover art for %s: %w", artID, ErrUnavailable)
return nil, "", fmt.Errorf("could not get `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable)
}
type sourceFunc func() (r io.ReadCloser, path string, err error)
@@ -124,7 +124,7 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc
func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc {
return func() (io.ReadCloser, string, error) {
r, _, err := a.Get(ctx, id, 0)
r, _, err := a.Get(ctx, id, 0, false)
if err != nil {
return nil, "", err
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/navidrome/navidrome/utils"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/random"
"github.com/navidrome/navidrome/utils/str"
"golang.org/x/sync/errgroup"
)
@@ -42,8 +43,8 @@ type ExternalMetadata interface {
type externalMetadata struct {
ds model.DataStore
ag *agents.Agents
artistQueue chan<- *auxArtist
albumQueue chan<- *auxAlbum
artistQueue refreshQueue[auxArtist]
albumQueue refreshQueue[auxAlbum]
}
type auxAlbum struct {
@@ -58,8 +59,8 @@ type auxArtist struct {
func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMetadata {
e := &externalMetadata{ds: ds, ag: agents}
e.artistQueue = startRefreshQueue(context.TODO(), e.populateArtistInfo)
e.albumQueue = startRefreshQueue(context.TODO(), e.populateAlbumInfo)
e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo)
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
return e
}
@@ -74,7 +75,7 @@ func (e *externalMetadata) getAlbum(ctx context.Context, id string) (*auxAlbum,
switch v := entity.(type) {
case *model.Album:
album.Album = *v
album.Name = clearName(v.Name)
album.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getAlbum(ctx, v.AlbumID)
default:
@@ -99,9 +100,10 @@ func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*mod
}
}
// If info is expired, trigger a populateAlbumInfo in the background
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
enqueueRefresh(e.albumQueue, album)
e.albumQueue.enqueue(*album)
}
return &album.Album, nil
@@ -164,7 +166,7 @@ func (e *externalMetadata) getArtist(ctx context.Context, id string) (*auxArtist
switch v := entity.(type) {
case *model.Artist:
artist.Artist = *v
artist.Name = clearName(v.Name)
artist.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getArtist(ctx, v.ArtistID)
case *model.Album:
@@ -175,17 +177,6 @@ func (e *externalMetadata) getArtist(ctx context.Context, id string) (*auxArtist
return &artist, nil
}
// Replace some Unicode chars with their equivalent ASCII
func clearName(name string) string {
name = strings.ReplaceAll(name, "", "-")
name = strings.ReplaceAll(name, "", "-")
name = strings.ReplaceAll(name, "“", `"`)
name = strings.ReplaceAll(name, "”", `"`)
name = strings.ReplaceAll(name, "", `'`)
name = strings.ReplaceAll(name, "", `'`)
return name
}
func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
artist, err := e.refreshArtistInfo(ctx, id)
if err != nil {
@@ -215,7 +206,7 @@ func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (*a
// If info is expired, trigger a populateArtistInfo in the background
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artist.Name)
enqueueRefresh(e.artistQueue, artist)
e.artistQueue.enqueue(*artist)
}
return artist, nil
}
@@ -267,8 +258,8 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
return nil, ctx.Err()
}
weightedSongs := random.NewWeightedRandomChooser()
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser, count, artistWeight int) error {
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
return ctx.Err()
@@ -302,12 +293,12 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
var similarSongs model.MediaFiles
for len(similarSongs) < count && weightedSongs.Size() > 0 {
s, err := weightedSongs.GetAndRemove()
s, err := weightedSongs.Pick()
if err != nil {
log.Warn(ctx, "Error getting weighted song", err)
continue
}
similarSongs = append(similarSongs, s.(model.MediaFile))
similarSongs = append(similarSongs, s)
}
return similarSongs, nil
@@ -414,7 +405,7 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
squirrel.Eq{"artist_id": artistID},
squirrel.Eq{"album_artist_id": artistID},
},
squirrel.Like{"order_title": utils.SanitizeFieldForSorting(title)},
squirrel.Like{"order_title": str.SanitizeFieldForSorting(title)},
},
Sort: "starred desc, rating desc, year asc, compilation asc ",
Max: 1,
@@ -434,11 +425,11 @@ func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistUR
}
func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
bio, err := agent.GetArtistBiography(ctx, artist.ID, clearName(artist.Name), artist.MbzArtistID)
bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(artist.Name), artist.MbzArtistID)
if err != nil {
return
}
bio = utils.SanitizeText(bio)
bio = str.SanitizeText(bio)
bio = strings.ReplaceAll(bio, "\n", " ")
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
}
@@ -514,7 +505,7 @@ func (e *externalMetadata) findArtistByName(ctx context.Context, artistName stri
}
artist := &auxArtist{
Artist: artists[0],
Name: clearName(artists[0].Name),
Name: str.Clear(artists[0].Name),
}
return artist, nil
}
@@ -561,15 +552,17 @@ func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, c
return nil
}
func startRefreshQueue[T any](ctx context.Context, processFn func(context.Context, T) error) chan<- T {
type refreshQueue[T any] chan<- T
func newRefreshQueue[T any](ctx context.Context, processFn func(context.Context, *T) error) refreshQueue[T] {
queue := make(chan T, refreshQueueLength)
go func() {
for {
time.Sleep(refreshDelay)
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
select {
case a := <-queue:
_ = processFn(ctx, a)
case item := <-queue:
_ = processFn(ctx, &item)
cancel()
case <-ctx.Done():
cancel()
@@ -580,9 +573,9 @@ func startRefreshQueue[T any](ctx context.Context, processFn func(context.Contex
return queue
}
func enqueueRefresh[T any](queue chan<- T, item T) {
func (q *refreshQueue[T]) enqueue(item T) {
select {
case queue <- item:
default: // It is ok to miss a refresh
case *q <- item:
default: // It is ok to miss a refresh request
}
}

View File

@@ -1,6 +1,7 @@
package core
import (
"cmp"
"context"
"fmt"
"io"
@@ -127,56 +128,64 @@ func (s *Stream) EstimatedContentLength() int {
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
}
// TODO This function deserves some love (refactoring)
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) {
format = "raw"
if reqFormat == "raw" {
return format, 0
// selectTranscodingOptions selects the appropriate transcoding options based on the requested format and bitrate.
// If the requested format is "raw" or matches the media file's suffix and the requested bitrate is 0, it returns the
// original format and bitrate.
// Otherwise, it determines the format and bitrate using determineFormatAndBitRate and findTranscoding functions.
//
// NOTE: It is easier to follow the tests in core/media_streamer_internal_test.go to understand the different scenarios.
func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (string, int) {
if reqFormat == "raw" || reqFormat == mf.Suffix && reqBitRate == 0 {
return "raw", mf.BitRate
}
if reqFormat == mf.Suffix && reqBitRate == 0 {
bitRate = mf.BitRate
return format, bitRate
format, bitRate := determineFormatAndBitRate(ctx, mf.BitRate, reqFormat, reqBitRate)
if format == "" && bitRate == 0 {
return "raw", 0
}
trc, hasDefault := request.TranscodingFrom(ctx)
var cFormat string
var cBitRate int
return findTranscoding(ctx, ds, mf, format, bitRate)
}
// determineFormatAndBitRate determines the format and bitrate for transcoding based on the requested format and bitrate.
// If the requested format is not empty, it returns the requested format and bitrate.
// Otherwise, it checks for default transcoding settings from the context or server configuration.
func determineFormatAndBitRate(ctx context.Context, srcBitRate int, reqFormat string, reqBitRate int) (string, int) {
if reqFormat != "" {
cFormat = reqFormat
} else {
if hasDefault {
cFormat = trc.TargetFormat
cBitRate = trc.DefaultBitRate
if p, ok := request.PlayerFrom(ctx); ok {
cBitRate = p.MaxBitRate
}
} else if reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "" {
// If no format is specified and no transcoding associated to the player, but a bitrate is specified,
// and there is no transcoding set for the player, we use the default downsampling format.
// But only if the requested bitRate is lower than the original bitRate.
log.Debug("Default Downsampling", "Using default downsampling format", conf.Server.DefaultDownsamplingFormat)
cFormat = conf.Server.DefaultDownsamplingFormat
return reqFormat, reqBitRate
}
format, bitRate := "", 0
if trc, hasDefault := request.TranscodingFrom(ctx); hasDefault {
format = trc.TargetFormat
bitRate = trc.DefaultBitRate
if p, ok := request.PlayerFrom(ctx); ok && p.MaxBitRate > 0 && p.MaxBitRate < bitRate {
bitRate = p.MaxBitRate
}
} else if reqBitRate > 0 && reqBitRate < srcBitRate && conf.Server.DefaultDownsamplingFormat != "" {
// If no format is specified and no transcoding associated to the player, but a bitrate is specified,
// and there is no transcoding set for the player, we use the default downsampling format.
// But only if the requested bitRate is lower than the original bitRate.
log.Debug(ctx, "Using default downsampling format", "format", conf.Server.DefaultDownsamplingFormat)
format = conf.Server.DefaultDownsamplingFormat
}
if reqBitRate > 0 {
cBitRate = reqBitRate
return format, cmp.Or(reqBitRate, bitRate)
}
// findTranscoding finds the appropriate transcoding settings for the given format and bitrate.
// If the format matches the media file's suffix and the bitrate is greater than or equal to the original bitrate,
// it returns the original format and bitrate.
// Otherwise, it returns the target format and bitrate from the
// transcoding settings.
func findTranscoding(ctx context.Context, ds model.DataStore, mf *model.MediaFile, format string, bitRate int) (string, int) {
t, err := ds.Transcoding(ctx).FindByFormat(format)
if err != nil || t == nil || format == mf.Suffix && bitRate >= mf.BitRate {
return "raw", 0
}
if cBitRate == 0 && cFormat == "" {
return format, bitRate
}
t, err := ds.Transcoding(ctx).FindByFormat(cFormat)
if err == nil {
format = t.TargetFormat
if cBitRate != 0 {
bitRate = cBitRate
} else {
bitRate = t.DefaultBitRate
}
}
if format == mf.Suffix && bitRate >= mf.BitRate {
format = "raw"
bitRate = 0
}
return format, bitRate
return t.TargetFormat, cmp.Or(bitRate, t.DefaultBitRate)
}
var (

View File

@@ -150,7 +150,8 @@ func (t *MpvTrack) Position() int {
if retryCount > 5 {
return 0
}
break
time.Sleep(time.Duration(retryCount) * time.Millisecond)
continue
}
if err != nil {
@@ -166,7 +167,6 @@ func (t *MpvTrack) Position() int {
return int(pos)
}
}
return 0
}
func (t *MpvTrack) SetPosition(offset int) error {

View File

@@ -104,7 +104,7 @@ func (pd *Queue) Shuffle() {
var err error
pd.Index, err = pd.getMediaFileIndexByID(backupID)
if err != nil {
log.Error("Could not find ID while shuffling: " + backupID)
log.Error("Could not find ID while shuffling: %s", backupID)
}
}
@@ -114,7 +114,7 @@ func (pd *Queue) getMediaFileIndexByID(id string) (int, error) {
return idx, nil
}
}
return -1, fmt.Errorf("ID not found in playlist: " + id)
return -1, fmt.Errorf("ID not found in playlist: %s", id)
}
// Sets the index to a new, valid value inside the Items. Values lower than zero are going to be zero,

View File

@@ -13,7 +13,7 @@ import (
type Players interface {
Get(ctx context.Context, playerId string) (*model.Player, error)
Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error)
Register(ctx context.Context, id, client, userAgent, ip string) (*model.Player, *model.Transcoding, error)
}
func NewPlayers(ds model.DataStore) Players {
@@ -28,7 +28,7 @@ func (p *players) Register(ctx context.Context, id, client, userAgent, ip string
var plr *model.Player
var trc *model.Transcoding
var err error
userName, _ := request.UsernameFrom(ctx)
user, _ := request.UserFrom(ctx)
if id != "" {
plr, err = p.ds.Player(ctx).Get(id)
if err == nil && plr.Client != client {
@@ -36,22 +36,22 @@ func (p *players) Register(ctx context.Context, id, client, userAgent, ip string
}
}
if err != nil || id == "" {
plr, err = p.ds.Player(ctx).FindMatch(userName, client, userAgent)
plr, err = p.ds.Player(ctx).FindMatch(user.ID, client, userAgent)
if err == nil {
log.Debug(ctx, "Found matching player", "id", plr.ID, "client", client, "username", userName, "type", userAgent)
log.Debug(ctx, "Found matching player", "id", plr.ID, "client", client, "username", userName(ctx), "type", userAgent)
} else {
plr = &model.Player{
ID: uuid.NewString(),
UserName: userName,
UserId: user.ID,
Client: client,
ScrobbleEnabled: true,
}
log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", userName, "type", userAgent)
log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", userName(ctx), "type", userAgent)
}
}
plr.Name = fmt.Sprintf("%s [%s]", client, userAgent)
plr.UserAgent = userAgent
plr.IPAddress = ip
plr.IP = ip
plr.LastSeen = time.Now()
err = p.ds.Player(ctx).Put(plr)
if err != nil {

View File

@@ -34,7 +34,7 @@ var _ = Describe("Players", func() {
Expect(p.ID).ToNot(BeEmpty())
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(p.Client).To(Equal("client"))
Expect(p.UserName).To(Equal("johndoe"))
Expect(p.UserId).To(Equal("userid"))
Expect(p.UserAgent).To(Equal("chrome"))
Expect(repo.lastSaved).To(Equal(p))
Expect(trc).To(BeNil())
@@ -73,7 +73,7 @@ var _ = Describe("Players", func() {
})
It("finds player by client and user names when ID is not found", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", UserAgent: "chrome", LastSeen: time.Time{}}
repo.add(plr)
p, _, err := players.Register(ctx, "999", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
@@ -83,7 +83,7 @@ var _ = Describe("Players", func() {
})
It("finds player by client and user names when not ID is provided", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}}
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", UserAgent: "chrome", LastSeen: time.Time{}}
repo.add(plr)
p, _, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
@@ -102,6 +102,22 @@ var _ = Describe("Players", func() {
Expect(repo.lastSaved).To(Equal(p))
Expect(trc.ID).To(Equal("1"))
})
Context("bad username casing", func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "Johndoe"})
ctx = request.WithUsername(ctx, "Johndoe")
It("finds player by client and user names when not ID is provided", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", UserAgent: "chrome", LastSeen: time.Time{}}
repo.add(plr)
p, _, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).To(Equal("123"))
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(repo.lastSaved).To(Equal(p))
})
})
})
})
@@ -125,9 +141,9 @@ func (m *mockPlayerRepository) Get(id string) (*model.Player, error) {
return nil, model.ErrNotFound
}
func (m *mockPlayerRepository) FindMatch(userName, client, typ string) (*model.Player, error) {
func (m *mockPlayerRepository) FindMatch(userId, client, userAgent string) (*model.Player, error) {
for _, p := range m.data {
if p.Client == client && p.UserName == userName {
if p.Client == client && p.UserId == userId && p.UserAgent == userAgent {
return &p, nil
}
}

View File

@@ -1,8 +1,6 @@
package core
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
@@ -15,10 +13,12 @@ import (
"time"
"github.com/RaveNoX/go-jsoncommentstrip"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/slice"
)
type Playlists interface {
@@ -54,9 +54,9 @@ func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Pla
pls := &model.Playlist{
OwnerID: owner.ID,
Public: false,
Sync: true,
Sync: false,
}
pls, err := s.parseM3U(ctx, pls, "", reader)
err := s.parseM3U(ctx, pls, "", reader)
if err != nil {
log.Error(ctx, "Error parsing playlist", err)
return nil, err
@@ -84,10 +84,11 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, base
extension := strings.ToLower(filepath.Ext(playlistFile))
switch extension {
case ".nsp":
return s.parseNSP(ctx, pls, file)
err = s.parseNSP(ctx, pls, file)
default:
return s.parseM3U(ctx, pls, baseDir, file)
err = s.parseM3U(ctx, pls, baseDir, file)
}
return pls, err
}
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
@@ -111,14 +112,14 @@ func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*mod
return pls, nil
}
func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) (*model.Playlist, error) {
func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) error {
nsp := &nspFile{}
reader := jsoncommentstrip.NewReader(file)
dec := json.NewDecoder(reader)
err := dec.Decode(nsp)
if err != nil {
log.Error(ctx, "Error parsing SmartPlaylist", "playlist", pls.Name, err)
return nil, err
return err
}
pls.Rules = &nsp.Criteria
if nsp.Name != "" {
@@ -127,39 +128,50 @@ func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.R
if nsp.Comment != "" {
pls.Comment = nsp.Comment
}
return pls, nil
return nil
}
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, reader io.Reader) (*model.Playlist, error) {
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, reader io.Reader) error {
mediaFileRepository := s.ds.MediaFile(ctx)
scanner := bufio.NewScanner(reader)
scanner.Split(scanLines)
var mfs model.MediaFiles
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "#PLAYLIST:") {
if split := strings.Split(line, ":"); len(split) >= 2 {
pls.Name = split[1]
for lines := range slice.CollectChunks(slice.LinesFrom(reader), 400) {
filteredLines := make([]string, 0, len(lines))
for _, line := range lines {
line := strings.TrimSpace(line)
if strings.HasPrefix(line, "#PLAYLIST:") {
pls.Name = line[len("#PLAYLIST:"):]
continue
}
continue
// Skip empty lines and extended info
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "file://") {
line = strings.TrimPrefix(line, "file://")
line, _ = url.QueryUnescape(line)
}
if baseDir != "" && !filepath.IsAbs(line) {
line = filepath.Join(baseDir, line)
}
filteredLines = append(filteredLines, line)
}
// Skip empty lines and extended info
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "file://") {
line = strings.TrimPrefix(line, "file://")
line, _ = url.QueryUnescape(line)
}
if baseDir != "" && !filepath.IsAbs(line) {
line = filepath.Join(baseDir, line)
}
mf, err := mediaFileRepository.FindByPath(line)
found, err := mediaFileRepository.FindByPaths(filteredLines)
if err != nil {
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", line, err)
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
continue
}
mfs = append(mfs, *mf)
existing := make(map[string]int, len(found))
for idx := range found {
existing[found[idx].Path] = idx
}
for _, path := range filteredLines {
idx, ok := existing[path]
if ok {
mfs = append(mfs, found[idx])
} else {
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path)
}
}
}
if pls.Name == "" {
pls.Name = time.Now().Format(time.RFC3339)
@@ -167,7 +179,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir s
pls.Tracks = nil
pls.AddMediaFiles(mfs)
return pls, scanner.Err()
return nil
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
@@ -193,34 +205,11 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
} else {
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
newPls.OwnerID = owner.ID
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
}
return s.ds.Playlist(ctx).Put(newPls)
}
// From https://stackoverflow.com/a/41433698
func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
if data[i] == '\n' {
// We have a line terminated by single newline.
return i + 1, data[0:i], nil
}
advance = i + 1
if len(data) > i+1 && data[i+1] == '\n' {
advance += 1
}
return advance, data[0:i], nil
}
// If we're at EOF, we have a final, non-terminated line. Return it.
if atEOF {
return len(data), data, nil
}
// Request more data.
return 0, nil, nil
}
func (s *playlists) Update(ctx context.Context, playlistID string,
name *string, comment *string, public *bool,
idsToAdd []string, idxToRemove []int) error {

View File

@@ -3,19 +3,20 @@ package core
import (
"context"
"os"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Playlists", func() {
var ds model.DataStore
var ds *tests.MockDataStore
var ps Playlists
var mp mockedPlaylist
ctx := context.Background()
@@ -23,8 +24,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() {
mp = mockedPlaylist{}
ds = &tests.MockDataStore{
MockedMediaFile: &mockedMediaFile{},
MockedPlaylist: &mp,
MockedPlaylist: &mp,
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
@@ -32,12 +32,13 @@ var _ = Describe("Playlists", func() {
Describe("ImportFile", func() {
BeforeEach(func() {
ps = NewPlaylists(ds)
ds.MockedMediaFile = &mockedMediaFileRepo{}
})
Describe("M3U", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
Expect(err).To(BeNil())
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
@@ -48,13 +49,13 @@ var _ = Describe("Playlists", func() {
It("parses playlists using LF ending", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "lf-ended.m3u")
Expect(err).To(BeNil())
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
It("parses playlists using CR ending (old Mac format)", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "cr-ended.m3u")
Expect(err).To(BeNil())
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
})
@@ -62,7 +63,7 @@ var _ = Describe("Playlists", func() {
Describe("NSP", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/recently_played.nsp")
Expect(err).To(BeNil())
Expect(err).ToNot(HaveOccurred())
Expect(mp.last).To(Equal(pls))
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("Recently Played"))
@@ -76,18 +77,28 @@ var _ = Describe("Playlists", func() {
})
Describe("ImportM3U", func() {
var repo *mockedMediaFileFromListRepo
BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo
ps = NewPlaylists(ds)
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
It("parses well-formed playlists", func() {
f, _ := os.Open("tests/fixtures/playlists/pls-post-with-name.m3u")
repo.data = []string{
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
"/tests/fixtures/01 Invisible (RED) Edit Version.mp3",
}
f, _ := os.Open("tests/fixtures/playlists/pls-with-name.m3u")
defer f.Close()
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("playlist 1"))
Expect(err).To(BeNil())
Expect(pls.Sync).To(BeFalse())
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
@@ -97,25 +108,74 @@ var _ = Describe("Playlists", func() {
})
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
f, _ := os.Open("tests/fixtures/playlists/pls-post.m3u")
repo.data = []string{
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
"/tests/fixtures/01 Invisible (RED) Edit Version.mp3",
}
f, _ := os.Open("tests/fixtures/playlists/pls-without-name.m3u")
defer f.Close()
pls, err := ps.ImportM3U(ctx, f)
Expect(err).To(BeNil())
Expect(err).ToNot(HaveOccurred())
_, err = time.Parse(time.RFC3339, pls.Name)
Expect(err).To(BeNil())
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(3))
})
It("returns only tracks that exist in the database and in the same other as the m3u", func() {
repo.data = []string{
"test1.mp3",
"test2.mp3",
"test3.mp3",
}
m3u := strings.Join([]string{
"test3.mp3",
"test1.mp3",
"test4.mp3",
"test2.mp3",
}, "\n")
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks[0].Path).To(Equal("test3.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("test1.mp3"))
Expect(pls.Tracks[2].Path).To(Equal("test2.mp3"))
})
})
})
type mockedMediaFile struct {
// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
type mockedMediaFileRepo struct {
model.MediaFileRepository
}
func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) {
return &model.MediaFile{
ID: "123",
Path: s,
}, nil
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
for idx, path := range paths {
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: path,
})
}
return mfs, nil
}
// mockedMediaFileFromListRepo's FindByPaths method returns a list of MediaFiles based on the data field
type mockedMediaFileFromListRepo struct {
model.MediaFileRepository
data []string
}
func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
var mfs model.MediaFiles
for idx, path := range r.data {
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: path,
})
}
return mfs, nil
}
type mockedPlaylist struct {

View File

@@ -5,18 +5,16 @@ import (
"sort"
"time"
"github.com/jellydator/ttlcache/v2"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/singleton"
)
const maxNowPlayingExpire = 60 * time.Minute
type NowPlayingInfo struct {
MediaFile model.MediaFile
Start time.Time
@@ -39,7 +37,7 @@ type PlayTracker interface {
type playTracker struct {
ds model.DataStore
broker events.Broker
playMap *ttlcache.Cache
playMap cache.SimpleCache[string, NowPlayingInfo]
scrobblers map[string]Scrobbler
}
@@ -52,9 +50,7 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker {
// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by
// the GetPlayTracker function above
func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
m := ttlcache.NewCache()
m.SkipTTLExtensionOnHit(true)
_ = m.SetTTL(maxNowPlayingExpire)
m := cache.NewSimpleCache[string, NowPlayingInfo]()
p := &playTracker{ds: ds, playMap: m, broker: broker}
p.scrobblers = make(map[string]Scrobbler)
for name, constructor := range constructors {
@@ -84,7 +80,7 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
}
ttl := time.Duration(int(mf.Duration)+5) * time.Second
_ = p.playMap.SetWithTTL(playerId, info, ttl)
_ = p.playMap.AddWithTTL(playerId, info, ttl)
player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled {
p.dispatchNowPlaying(ctx, user.ID, mf)
@@ -111,15 +107,7 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *
}
func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) {
var res []NowPlayingInfo
for _, playerId := range p.playMap.GetKeys() {
value, err := p.playMap.Get(playerId)
if err != nil {
continue
}
info := value.(NowPlayingInfo)
res = append(res, info)
}
res := p.playMap.Values()
sort.Slice(res, func(i, j int) bool {
return res[i].Start.After(res[j].Start)
})
@@ -130,7 +118,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
username, _ := request.UsernameFrom(ctx)
player, _ := request.PlayerFrom(ctx)
if !player.ScrobbleEnabled {
log.Debug(ctx, "External scrobbling disabled for this player", "player", player.Name, "ip", player.IPAddress, "user", username)
log.Debug(ctx, "External scrobbling disabled for this player", "player", player.Name, "ip", player.IP, "user", username)
}
event := &events.RefreshResource{}
success := 0
@@ -162,15 +150,15 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, timestamp time.Time) error {
return p.ds.WithTx(func(tx model.DataStore) error {
err := p.ds.MediaFile(ctx).IncPlayCount(track.ID, timestamp)
err := tx.MediaFile(ctx).IncPlayCount(track.ID, timestamp)
if err != nil {
return err
}
err = p.ds.Album(ctx).IncPlayCount(track.AlbumID, timestamp)
err = tx.Album(ctx).IncPlayCount(track.AlbumID, timestamp)
if err != nil {
return err
}
err = p.ds.Artist(ctx).IncPlayCount(track.ArtistID, timestamp)
err = tx.Artist(ctx).IncPlayCount(track.ArtistID, timestamp)
return err
})
}

View File

@@ -4,11 +4,13 @@ import (
"database/sql"
"embed"
"fmt"
"runtime"
_ "github.com/mattn/go-sqlite3"
"github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
_ "github.com/navidrome/navidrome/db/migrations"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/hasher"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/pressly/goose/v3"
)
@@ -23,29 +25,77 @@ var embedMigrations embed.FS
const migrationsFolder = "migrations"
func Db() *sql.DB {
return singleton.GetInstance(func() *sql.DB {
type DB interface {
ReadDB() *sql.DB
WriteDB() *sql.DB
Close()
}
type db struct {
readDB *sql.DB
writeDB *sql.DB
}
func (d *db) ReadDB() *sql.DB {
return d.readDB
}
func (d *db) WriteDB() *sql.DB {
return d.writeDB
}
func (d *db) Close() {
if err := d.readDB.Close(); err != nil {
log.Error("Error closing read DB", err)
}
if err := d.writeDB.Close(); err != nil {
log.Error("Error closing write DB", err)
}
}
func Db() DB {
return singleton.GetInstance(func() *db {
sql.Register(Driver+"_custom", &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
},
})
Path = conf.Server.DbPath
if Path == ":memory:" {
Path = "file::memory:?cache=shared&_foreign_keys=on"
conf.Server.DbPath = Path
}
log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver)
instance, err := sql.Open(Driver, Path)
// Create a read database connection
rdb, err := sql.Open(Driver+"_custom", Path)
if err != nil {
panic(err)
log.Fatal("Error opening read database", err)
}
rdb.SetMaxOpenConns(max(4, runtime.NumCPU()))
// Create a write database connection
wdb, err := sql.Open(Driver+"_custom", Path)
if err != nil {
log.Fatal("Error opening write database", err)
}
wdb.SetMaxOpenConns(1)
return &db{
readDB: rdb,
writeDB: wdb,
}
return instance
})
}
func Close() error {
func Close() {
log.Info("Closing Database")
return Db().Close()
Db().Close()
}
func Init() func() {
db := Db()
db := Db().WriteDB()
// Disable foreign_keys to allow re-creating tables in migrations
_, err := db.Exec("PRAGMA foreign_keys=off")
@@ -75,11 +125,7 @@ func Init() func() {
log.Fatal("Failed to apply new migrations", err)
}
return func() {
if err := Close(); err != nil {
log.Error("Error closing DB", err)
}
}
return Close
}
type statusLogger struct{ numPending int }

View File

@@ -5,7 +5,7 @@ import (
"database/sql"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/str"
"github.com/pressly/goose/v3"
)
@@ -50,7 +50,7 @@ select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id,
if err != nil {
return err
}
all := utils.SanitizeStrings(artistId, albumArtistId, songArtistIds.String)
all := str.SanitizeStrings(artistId, albumArtistId, songArtistIds.String)
_, err = stmt.Exec(all, id)
if err != nil {
log.Error("Error setting album's artist_ids", "album", name, "albumId", id, err)

View File

@@ -5,7 +5,7 @@ import (
"database/sql"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/str"
"github.com/pressly/goose/v3"
)
@@ -33,8 +33,8 @@ func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error {
return err
}
newComment := utils.SanitizeText(comment.String)
newLyrics := utils.SanitizeText(lyrics.String)
newComment := str.SanitizeText(comment.String)
newLyrics := str.SanitizeText(lyrics.String)
_, err = stmt.Exec(newComment, newLyrics, id)
if err != nil {
log.Error("Error unescaping media_file's lyrics and comments", "title", title, "id", id, err)

View File

@@ -0,0 +1,71 @@
package migrations
import (
"context"
"database/sql"
"fmt"
"github.com/navidrome/navidrome/conf"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddLibraryTable, downAddLibraryTable)
}
func upAddLibraryTable(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table library (
id integer primary key autoincrement,
name text not null unique,
path text not null unique,
remote_path text null default '',
last_scan_at datetime not null default '0000-00-00 00:00:00',
updated_at datetime not null default current_timestamp,
created_at datetime not null default current_timestamp
);`)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
insert into library(id, name, path, last_scan_at) values(1, 'Music Library', '%s', current_timestamp);
delete from property where id like 'LastScan-%%';
`, conf.Server.MusicFolder))
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
alter table media_file add column library_id integer not null default 1
references library(id) on delete cascade;
alter table album add column library_id integer not null default 1
references library(id) on delete cascade;
create table if not exists library_artist
(
library_id integer not null default 1
references library(id)
on delete cascade,
artist_id varchar not null default null
references artist(id)
on delete cascade,
constraint library_artist_ux
unique (library_id, artist_id)
);
insert into library_artist(library_id, artist_id) select 1, id from artist;
`)
return err
}
func downAddLibraryTable(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table media_file drop column library_id;
alter table album drop column library_id;
drop table library_artist;
drop table library;
`)
return err
}

View File

@@ -0,0 +1,66 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upRemoveAnnotationId, downRemoveAnnotationId)
}
func upRemoveAnnotationId(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table annotation_dg_tmp
(
user_id varchar(255) default '' not null,
item_id varchar(255) default '' not null,
item_type varchar(255) default '' not null,
play_count integer default 0,
play_date datetime,
rating integer default 0,
starred bool default FALSE not null,
starred_at datetime,
unique (user_id, item_id, item_type)
);
insert into annotation_dg_tmp(user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at)
select user_id,
item_id,
item_type,
play_count,
play_date,
rating,
starred,
starred_at
from annotation;
drop table annotation;
alter table annotation_dg_tmp
rename to annotation;
create index annotation_play_count
on annotation (play_count);
create index annotation_play_date
on annotation (play_date);
create index annotation_rating
on annotation (rating);
create index annotation_starred
on annotation (starred);
create index annotation_starred_at
on annotation (starred_at);
`)
return err
}
func downRemoveAnnotationId(ctx context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,62 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upPlayerUseUserIdOverUsername, downPlayerUseUserIdOverUsername)
}
func upPlayerUseUserIdOverUsername(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
CREATE TABLE player_dg_tmp
(
id varchar(255) not null
primary key,
name varchar not null,
user_agent varchar,
user_id varchar not null
references user (id)
on update cascade on delete cascade,
client varchar not null,
ip varchar,
last_seen timestamp,
max_bit_rate int default 0,
transcoding_id varchar,
report_real_path bool default FALSE not null,
scrobble_enabled bool default true
);
INSERT INTO player_dg_tmp(
id, name, user_agent, user_id, client, ip, last_seen, max_bit_rate,
transcoding_id, report_real_path, scrobble_enabled
)
SELECT
id, name, user_agent,
IFNULL(
(select id from user where user_name = player.user_name), 'UNKNOWN_USERNAME'
),
client, ip_address, last_seen, max_bit_rate, transcoding_id, report_real_path, scrobble_enabled
FROM player;
DELETE FROM player_dg_tmp WHERE user_id = 'UNKNOWN_USERNAME';
DROP TABLE player;
ALTER TABLE player_dg_tmp RENAME TO player;
CREATE INDEX IF NOT EXISTS player_match
on player (client, user_agent, user_id);
CREATE INDEX IF NOT EXISTS player_name
on player (name);
`)
return err
}
func downPlayerUseUserIdOverUsername(ctx context.Context, tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

87
go.mod
View File

@@ -1,6 +1,8 @@
module github.com/navidrome/navidrome
go 1.21
go 1.23
toolchain go1.23.1
require (
github.com/Masterminds/squirrel v1.5.4
@@ -12,82 +14,85 @@ require (
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/disintegration/imaging v1.6.2
github.com/djherbis/atime v1.1.0
github.com/djherbis/fscache v0.10.2-0.20220222230828-2909c950912d
github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4
github.com/djherbis/stream v1.4.0
github.com/djherbis/times v1.6.0
github.com/dustin/go-humanize v1.0.1
github.com/fatih/structs v1.1.0
github.com/go-chi/chi/v5 v5.0.12
github.com/go-chi/chi/v5 v5.1.0
github.com/go-chi/cors v1.2.1
github.com/go-chi/httprate v0.9.0
github.com/go-chi/httprate v0.14.1
github.com/go-chi/jwtauth/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/google/wire v0.6.0
github.com/hashicorp/go-multierror v1.1.1
github.com/jellydator/ttlcache/v2 v2.11.1
github.com/jellydator/ttlcache/v3 v3.3.0
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.0.21
github.com/matoous/go-nanoid/v2 v2.0.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/mattn/go-zglob v0.0.4
github.com/microcosm-cc/bluemonday v1.0.26
github.com/mileusna/useragent v1.3.4
github.com/onsi/ginkgo/v2 v2.17.3
github.com/onsi/gomega v1.33.1
github.com/pelletier/go-toml/v2 v2.2.2
github.com/lestrrat-go/jwx/v2 v2.1.1
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mattn/go-sqlite3 v1.14.23
github.com/mattn/go-zglob v0.0.6
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.20.2
github.com/onsi/gomega v1.34.2
github.com/pelletier/go-toml/v2 v2.2.3
github.com/pocketbase/dbx v1.10.1
github.com/pressly/goose/v3 v3.20.0
github.com/prometheus/client_golang v1.19.0
github.com/pressly/goose/v3 v3.22.1
github.com/prometheus/client_golang v1.20.4
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.2
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
github.com/unrolled/secure v1.14.0
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842
golang.org/x/image v0.16.0
golang.org/x/sync v0.7.0
golang.org/x/text v0.15.0
github.com/unrolled/secure v1.15.0
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948
golang.org/x/image v0.20.0
golang.org/x/sync v0.8.0
golang.org/x/text v0.18.0
golang.org/x/time v0.6.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.17.9 // 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/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.5 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sethvargo/go-retry v0.2.4 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
@@ -95,11 +100,11 @@ require (
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/tools v0.21.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/tools v0.24.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
)

212
go.sum
View File

@@ -10,16 +10,16 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 h1:mGvOb3zxl4vCLv+dbf7JA6CAaM2UH/AGP1KX4DsJmTI=
@@ -32,8 +32,8 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g=
github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE=
github.com/djherbis/fscache v0.10.2-0.20220222230828-2909c950912d h1:eAikRiT337jlFa/NSKGb7K0uoP8/cana3EXzIDyFI6E=
github.com/djherbis/fscache v0.10.2-0.20220222230828-2909c950912d/go.mod h1:+uJNKpxCg52qVRGr+srICjiY8QvV0riatTzCGMUuSEY=
github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 h1:wdZllsLrDJtYfHiAKogB4PNHSDeO+v+5S3eqSWHGDlc=
github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4/go.mod h1:dHWjlanKIxaHVH1xJOTb4kzP800XdcXlgJ6JYlR2DPU=
github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE=
github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
@@ -46,29 +46,29 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.9.0 h1:21A+4WDMDA5FyWcg7mNrhj63aNT8CGh+Z1alOE/piU8=
github.com/go-chi/httprate v0.9.0/go.mod h1:6GOYBSwnpra4CQfAKXu8sQZg+nZ0M1g9QnyFvxrAB8A=
github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs=
github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0=
github.com/go-chi/jwtauth/v5 v5.3.1 h1:1ePWrjVctvp1tyBq5b/2ER8Th/+RbYc7x4qNsc5rh5A=
github.com/go-chi/jwtauth/v5 v5.3.1/go.mod h1:6Fl2RRmWXs3tJYE1IQGX81FsPoGqDwq9c15j52R5q80=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
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.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
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/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg=
github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA=
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -76,10 +76,11 @@ github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
@@ -89,18 +90,19 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jellydator/ttlcache/v2 v2.11.1 h1:AZGME43Eh2Vv3giG6GeqeLeFXxwxn1/qHItqWZl6U64=
github.com/jellydator/ttlcache/v2 v2.11.1/go.mod h1:RtE5Snf0/57e+2cLWFYWCCsLas2Hy3c5Z4n14XmSvTI=
github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc=
github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
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=
@@ -109,57 +111,58 @@ github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.5 h1:bsTfiH8xaKOJPrg1R+E3iE/AWZr/x0Phj9PBTG/OLUk=
github.com/lestrrat-go/httprc v1.0.5/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
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.0.21 h1:jAPKupy4uHgrHFEdjVjNkUgoBKtVDgrQPB/h55FHrR0=
github.com/lestrrat-go/jwx/v2 v2.0.21/go.mod h1:09mLW8zto6bWL9GbwnqAli+ArLf+5M33QLQPDggkUWM=
github.com/lestrrat-go/jwx/v2 v2.1.1 h1:Y2ltVl8J6izLYFs54BVcpXLv5msSW4o8eXwnzZLI32E=
github.com/lestrrat-go/jwx/v2 v2.1.1/go.mod h1:4LvZg7oxu6Q5VJwn7Mk/UwooNRnTHUpXBj2C4j3HNx0=
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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U=
github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0=
github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g=
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.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-zglob v0.0.4 h1:LQi2iOm0/fGgu80AioIJ/1j9w9Oh+9DZ39J4VAGzHQM=
github.com/mattn/go-zglob v0.0.4/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-zglob v0.0.6 h1:mP8RnmCgho4oaUYDIDn6GNxYk+qJGUs8fJLn+twYj2A=
github.com/mattn/go-zglob v0.0.6/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY=
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.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/mileusna/useragent v1.3.4 h1:MiuRRuvGjEie1+yZHO88UBYg8YBC/ddF6T7F56i3PCk=
github.com/mileusna/useragent v1.3.4/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/ginkgo/v2 v2.17.3 h1:oJcvKpIb7/8uLpDDtnQuf18xVnwKp8DTD7DQ6gTd/MU=
github.com/onsi/ginkgo/v2 v2.17.3/go.mod h1:nP2DPOQoNsQmsVyv5rDA8JkXQoCs6goXIvr/PRJ1eCc=
github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk=
github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/onsi/ginkgo/v2 v2.20.2 h1:7NVCeyIWROIAheY21RLS+3j2bb52W0W82tkberYytp4=
github.com/onsi/ginkgo/v2 v2.20.2/go.mod h1:K9gyxPIlb+aIvnZ8bd9Ak+YP18w3APlR+5coaZoE2ag=
github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8=
github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.10.1 h1:cw+vsyfCJD8YObOVeqb93YErnlxwYMkNZ4rwN0G0AaA=
github.com/pocketbase/dbx v1.10.1/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pressly/goose/v3 v3.20.0 h1:uPJdOxF/Ipj7ABVNOAMJXSxwFXZGwMGHNqjC8e61VA0=
github.com/pressly/goose/v3 v3.20.0/go.mod h1:BRfF2GcG4FTG12QfdBVy3q1yveaf4ckL9vWwEcIO3lA=
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/pressly/goose/v3 v3.22.1 h1:2zICEfr1O3yTP9BRZMGPj7qFxQ+ik6yeo+z1LMuioLc=
github.com/pressly/goose/v3 v3.22.1/go.mod h1:xtMpbstWyCpyH+0cxLTMCENWBG+0CSxvTsXhW95d5eo=
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
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.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -174,8 +177,8 @@ github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6g
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec=
github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
@@ -189,16 +192,14 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
@@ -206,70 +207,54 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/unrolled/secure v1.14.0 h1:u9vJTU/pR4Bny0ntLUMxdfLtmIRGvQf2sEFuA0TG9AE=
github.com/unrolled/secure v1.14.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/unrolled/secure v1.15.0 h1:q7x+pdp8jAHnbzxu6UheP8fRlG/rwYTb8TPuQ3rn9Og=
github.com/unrolled/secure v1.15.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA=
golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw=
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw=
golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM=
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=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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=
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 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -280,8 +265,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -296,29 +281,24 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20210112230658-8b4aab62c064/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
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.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw=
golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
@@ -331,14 +311,12 @@ 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/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4=
modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/sqlite v1.33.0 h1:WWkA/T2G17okiLGgKAj4/RMIvgyMT19yQ038160IeYk=
modernc.org/sqlite v1.33.0/go.mod h1:9uQ9hF/pCZoYZK73D/ud5Z7cIRIILSZI8NdIemVMTX8=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -15,7 +15,7 @@ import (
"github.com/sirupsen/logrus"
)
type Level uint8
type Level uint32
type LevelFunc = func(ctx interface{}, msg interface{}, keyValuePairs ...interface{})
@@ -109,6 +109,7 @@ func levelFromString(l string) Level {
// SetLogLevels sets the log levels for specific paths in the codebase.
func SetLogLevels(levels map[string]string) {
logLevels = nil
for k, v := range levels {
logLevels = append(logLevels, levelPath{path: k, level: levelFromString(v)})
}
@@ -158,7 +159,7 @@ func CurrentLevel() Level {
// IsGreaterOrEqualTo returns true if the caller's current log level is equal or greater than the provided level.
func IsGreaterOrEqualTo(level Level) bool {
return shouldLog(level)
return shouldLog(level, 2)
}
func Fatal(args ...interface{}) {
@@ -187,14 +188,14 @@ func Trace(args ...interface{}) {
}
func log(level Level, args ...interface{}) {
if !shouldLog(level) {
if !shouldLog(level, 3) {
return
}
logger, msg := parseArgs(args)
logger.Log(logrus.Level(level), msg)
}
func shouldLog(requiredLevel Level) bool {
func shouldLog(requiredLevel Level, skip int) bool {
if currentLevel >= requiredLevel {
return true
}
@@ -202,7 +203,7 @@ func shouldLog(requiredLevel Level) bool {
return false
}
_, file, _, ok := runtime.Caller(3)
_, file, _, ok := runtime.Caller(skip)
if !ok {
return false
}

View File

@@ -150,6 +150,10 @@ var _ = Describe("Logger", func() {
})
Describe("IsGreaterOrEqualTo", func() {
BeforeEach(func() {
SetLogLevels(nil)
})
It("returns false if log level is below provided level", func() {
SetLevel(LevelError)
Expect(IsGreaterOrEqualTo(LevelWarn)).To(BeFalse())

View File

@@ -12,6 +12,7 @@ type Album struct {
Annotations `structs:"-"`
ID string `structs:"id" json:"id"`
LibraryID int `structs:"library_id" json:"libraryId"`
Name string `structs:"name" json:"name"`
EmbedArtPath string `structs:"embed_art_path" json:"embedArtPath"`
ArtistID string `structs:"artist_id" json:"artistId"`
@@ -35,7 +36,7 @@ type Album struct {
Genre string `structs:"genre" json:"genre"`
Genres Genres `structs:"-" json:"genres"`
Discs Discs `structs:"discs" json:"discs,omitempty"`
FullText string `structs:"full_text" json:"fullText"`
FullText string `structs:"full_text" json:"-"`
SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"`

View File

@@ -3,11 +3,11 @@ package model
import "time"
type Annotations struct {
PlayCount int64 `structs:"-" json:"playCount"`
PlayDate *time.Time `structs:"-" json:"playDate" `
Rating int `structs:"-" json:"rating" `
Starred bool `structs:"-" json:"starred" `
StarredAt *time.Time `structs:"-" json:"starredAt"`
PlayCount int64 `structs:"play_count" json:"playCount"`
PlayDate *time.Time `structs:"play_date" json:"playDate" `
Rating int `structs:"rating" json:"rating" `
Starred bool `structs:"starred" json:"starred" `
StarredAt *time.Time `structs:"starred_at" json:"starredAt"`
}
type AnnotatedRepository interface {

View File

@@ -10,7 +10,7 @@ type Artist struct {
AlbumCount int `structs:"album_count" json:"albumCount"`
SongCount int `structs:"song_count" json:"songCount"`
Genres Genres `structs:"-" json:"genres"`
FullText string `structs:"full_text" json:"fullText"`
FullText string `structs:"full_text" json:"-"`
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"`
Size int64 `structs:"size" json:"size"`

View File

@@ -50,6 +50,21 @@ func (c Criteria) ToSql() (sql string, args []interface{}, err error) {
return c.Expression.ToSql()
}
func (c Criteria) ChildPlaylistIds() (ids []string) {
if c.Expression == nil {
return ids
}
switch rules := c.Expression.(type) {
case Any:
ids = rules.ChildPlaylistIds()
case All:
ids = rules.ChildPlaylistIds()
}
return ids
}
func (c Criteria) MarshalJSON() ([]byte, error) {
aux := struct {
All []Expression `json:"all,omitempty"`

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"github.com/google/uuid"
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
@@ -65,7 +66,7 @@ var _ = Describe("Criteria", func() {
sql, args, err := goObj.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal("(media_file.title LIKE ? AND media_file.title NOT LIKE ? AND (media_file.artist <> ? OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) AND COALESCE(genre.name, '') <> ?))"))
gomega.Expect(args).To(gomega.ConsistOf("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "test"))
gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "test"))
})
It("marshals to JSON", func() {
@@ -89,4 +90,94 @@ var _ = Describe("Criteria", func() {
gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc"))
})
It("extracts all child smart playlist IDs from All expression criteria", func() {
topLevelInPlaylistID := uuid.NewString()
topLevelNotInPlaylistID := uuid.NewString()
nestedAnyInPlaylistID := uuid.NewString()
nestedAnyNotInPlaylistID := uuid.NewString()
nestedAllInPlaylistID := uuid.NewString()
nestedAllNotInPlaylistID := uuid.NewString()
goObj := Criteria{
Expression: All{
InPlaylist{"id": topLevelInPlaylistID},
NotInPlaylist{"id": topLevelNotInPlaylistID},
Any{
InPlaylist{"id": nestedAnyInPlaylistID},
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
},
All{
InPlaylist{"id": nestedAllInPlaylistID},
NotInPlaylist{"id": nestedAllNotInPlaylistID},
},
},
}
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
})
It("extracts all child smart playlist IDs from Any expression criteria", func() {
topLevelInPlaylistID := uuid.NewString()
topLevelNotInPlaylistID := uuid.NewString()
nestedAnyInPlaylistID := uuid.NewString()
nestedAnyNotInPlaylistID := uuid.NewString()
nestedAllInPlaylistID := uuid.NewString()
nestedAllNotInPlaylistID := uuid.NewString()
goObj := Criteria{
Expression: Any{
InPlaylist{"id": topLevelInPlaylistID},
NotInPlaylist{"id": topLevelNotInPlaylistID},
Any{
InPlaylist{"id": nestedAnyInPlaylistID},
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
},
All{
InPlaylist{"id": nestedAllInPlaylistID},
NotInPlaylist{"id": nestedAllNotInPlaylistID},
},
},
}
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
})
It("extracts child smart playlist IDs from deeply nested expression", func() {
nestedAnyInPlaylistID := uuid.NewString()
nestedAnyNotInPlaylistID := uuid.NewString()
nestedAllInPlaylistID := uuid.NewString()
nestedAllNotInPlaylistID := uuid.NewString()
goObj := Criteria{
Expression: Any{
Any{
All{
Any{
InPlaylist{"id": nestedAnyInPlaylistID},
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
Any{
All{
InPlaylist{"id": nestedAllInPlaylistID},
NotInPlaylist{"id": nestedAllNotInPlaylistID},
},
},
},
},
},
},
}
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.ConsistOf(nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
})
})

View File

@@ -23,6 +23,10 @@ func (all All) MarshalJSON() ([]byte, error) {
return marshalConjunction("all", all)
}
func (all All) ChildPlaylistIds() (ids []string) {
return extractPlaylistIds(all)
}
type (
Any squirrel.Or
Or = Any
@@ -36,6 +40,10 @@ func (any Any) MarshalJSON() ([]byte, error) {
return marshalConjunction("any", any)
}
func (any Any) ChildPlaylistIds() (ids []string) {
return extractPlaylistIds(any)
}
type Is squirrel.Eq
type Eq = Is
@@ -275,3 +283,29 @@ func inList(m map[string]interface{}, negate bool) (sql string, args []interface
return "media_file.id IN (" + subQText + ")", subQArgs, nil
}
}
func extractPlaylistIds(inputRule interface{}) (ids []string) {
var id string
var ok bool
switch rule := inputRule.(type) {
case Any:
for _, rules := range rule {
ids = append(ids, extractPlaylistIds(rules)...)
}
case All:
for _, rules := range rule {
ids = append(ids, extractPlaylistIds(rules)...)
}
case InPlaylist:
if id, ok = rule["id"].(string); ok {
ids = append(ids, id)
}
case NotInPlaylist:
if id, ok = rule["id"].(string); ok {
ids = append(ids, id)
}
}
return
}

View File

@@ -18,7 +18,7 @@ var _ = Describe("Operators", func() {
sql, args, err := op.ToSql()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(sql).To(gomega.Equal(expectedSql))
gomega.Expect(args).To(gomega.ConsistOf(expectedArgs...))
gomega.Expect(args).To(gomega.HaveExactElements(expectedArgs...))
},
Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),

View File

@@ -13,6 +13,7 @@ type QueryOptions struct {
Max int
Offset int
Filters squirrel.Sqlizer
Seed string // for random sorting
}
type ResourceRepository interface {
@@ -20,10 +21,10 @@ type ResourceRepository interface {
}
type DataStore interface {
Library(ctx context.Context) LibraryRepository
Album(ctx context.Context) AlbumRepository
Artist(ctx context.Context) ArtistRepository
MediaFile(ctx context.Context) MediaFileRepository
MediaFolder(ctx context.Context) MediaFolderRepository
Genre(ctx context.Context) GenreRepository
Playlist(ctx context.Context) PlaylistRepository
PlayQueue(ctx context.Context) PlayQueueRepository

32
model/library.go Normal file
View File

@@ -0,0 +1,32 @@
package model
import (
"io/fs"
"os"
"time"
)
type Library struct {
ID int
Name string
Path string
RemotePath string
LastScanAt time.Time
UpdatedAt time.Time
CreatedAt time.Time
}
func (f Library) FS() fs.FS {
return os.DirFS(f.Path)
}
type Libraries []Library
type LibraryRepository interface {
Get(id int) (*Library, error)
Put(*Library) error
StoreMusicFolder() error
AddArtist(id int, artistID string) error
UpdateLastScan(id int, t time.Time) error
GetAll(...QueryOptions) (Libraries, error)
}

View File

@@ -8,7 +8,7 @@ import (
"strings"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/str"
)
type Line struct {
@@ -36,7 +36,7 @@ var (
)
func ToLyrics(language, text string) (*Lyrics, error) {
text = utils.SanitizeText(text)
text = str.SanitizeText(text)
lines := strings.Split(text, "\n")
@@ -67,7 +67,7 @@ func ToLyrics(language, text string) (*Lyrics, error) {
if idTag != nil {
switch idTag[1] {
case "ar":
artist = utils.SanitizeText(strings.TrimSpace(idTag[2]))
artist = str.SanitizeText(strings.TrimSpace(idTag[2]))
case "offset":
{
off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64)
@@ -78,7 +78,7 @@ func ToLyrics(language, text string) (*Lyrics, error) {
}
}
case "ti":
title = utils.SanitizeText(strings.TrimSpace(idTag[2]))
title = str.SanitizeText(strings.TrimSpace(idTag[2]))
}
continue

View File

@@ -12,8 +12,8 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
type MediaFile struct {
@@ -21,6 +21,7 @@ type MediaFile struct {
Bookmarkable `structs:"-"`
ID string `structs:"id" json:"id"`
LibraryID int `structs:"library_id" json:"libraryId"`
Path string `structs:"path" json:"path"`
Title string `structs:"title" json:"title"`
Album string `structs:"album" json:"album"`
@@ -47,7 +48,7 @@ type MediaFile struct {
Channels int `structs:"channels" json:"channels"`
Genre string `structs:"genre" json:"genre"`
Genres Genres `structs:"-" json:"genres"`
FullText string `structs:"full_text" json:"fullText"`
FullText string `structs:"full_text" json:"-"`
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"`
SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"`
@@ -186,7 +187,7 @@ func (mfs MediaFiles) ToAlbum() Album {
a.Genre = slice.MostFrequent(a.Genres).Name
slices.SortFunc(a.Genres, func(a, b Genre) int { return cmp.Compare(a.ID, b.ID) })
a.Genres = slices.Compact(a.Genres)
a.FullText = " " + utils.SanitizeStrings(fullText...)
a.FullText = " " + str.SanitizeStrings(fullText...)
a = fixAlbumArtist(a, albumArtistIds)
songArtistIds = append(songArtistIds, a.AlbumArtistID, a.ArtistID)
slices.Sort(songArtistIds)
@@ -197,15 +198,12 @@ func (mfs MediaFiles) ToAlbum() Album {
}
func allOrNothing(items []string) (string, int) {
sort.Strings(items)
items = slices.Compact(items)
if len(items) == 1 {
return items[0], 1
}
if len(items) > 1 {
sort.Strings(items)
if len(items) != 1 {
return "", len(slices.Compact(items))
}
return "", 0
return items[0], 1
}
func minMax(items []int) (int, int) {
@@ -267,6 +265,7 @@ type MediaFileRepository interface {
// Queries by path to support the scanner, no Annotations or Bookmarks required in the response
FindAllByPath(path string) (MediaFiles, error)
FindByPath(path string) (*MediaFile, error)
FindByPaths(paths []string) (MediaFiles, error)
FindPathsRecursively(basePath string) ([]string, error)
DeleteByPath(path string) (int64, error)

View File

@@ -1,23 +0,0 @@
package model
import (
"io/fs"
"os"
)
type MediaFolder struct {
ID int32
Name string
Path string
}
func (f MediaFolder) FS() fs.FS {
return os.DirFS(f.Path)
}
type MediaFolders []MediaFolder
type MediaFolderRepository interface {
Get(id int32) (*MediaFolder, error)
GetAll() (MediaFolders, error)
}

View File

@@ -5,12 +5,14 @@ import (
)
type Player struct {
Username string `structs:"-" json:"userName"`
ID string `structs:"id" json:"id"`
Name string `structs:"name" json:"name"`
UserAgent string `structs:"user_agent" json:"userAgent"`
UserName string `structs:"user_name" json:"userName"`
UserId string `structs:"user_id" json:"userId"`
Client string `structs:"client" json:"client"`
IPAddress string `structs:"ip_address" json:"ipAddress"`
IP string `structs:"ip" json:"ip"`
LastSeen time.Time `structs:"last_seen" json:"lastSeen"`
TranscodingId string `structs:"transcoding_id" json:"transcodingId"`
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate"`
@@ -22,7 +24,7 @@ type Players []Player
type PlayerRepository interface {
Get(id string) (*Player, error)
FindMatch(userName, client, typ string) (*Player, error)
FindMatch(userId, client, userAgent string) (*Player, error)
Put(p *Player) error
// TODO: Add CountAll method. Useful at least for metrics.
}

View File

@@ -1,10 +1,5 @@
package model
const (
// TODO Move other prop keys to here
PropLastScan = "LastScan"
)
type PropertyRepository interface {
Put(id string, value string) error
Get(id string) (string, error)

View File

@@ -42,7 +42,7 @@ func (s Share) CoverArtID() ArtworkID {
case "artist":
return Artist{ID: ids[0]}.CoverArtID()
}
rnd := random.Int64(len(s.Tracks))
rnd := random.Int64N(len(s.Tracks))
return s.Tracks[rnd].CoverArtID()
}

View File

@@ -16,7 +16,6 @@ import (
type albumRepository struct {
sqlRepository
sqlRestful
}
type dbAlbum struct {
@@ -59,7 +58,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
r.ctx = ctx
r.db = db
r.tableName = "album"
r.filterMappings = map[string]filterFunc{
r.registerModel(&model.Album{}, map[string]filterFunc{
"id": idFilter(r.tableName),
"name": fullTextFilter,
"compilation": booleanFilter,
@@ -68,24 +67,27 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
"recently_played": recentlyPlayedFilter,
"starred": booleanFilter,
"has_rating": hasRatingFilter,
}
"genre_id": eqFilter,
})
if conf.Server.PreferSortTags {
r.sortMappings = map[string]string{
"name": "COALESCE(NULLIF(sort_album_name,''),order_album_name)",
"artist": "compilation asc, COALESCE(NULLIF(sort_album_artist_name,''),order_album_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
"albumArtist": "compilation asc, COALESCE(NULLIF(sort_album_artist_name,''),order_album_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
"album_artist": "compilation asc, COALESCE(NULLIF(sort_album_artist_name,''),order_album_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc",
"random": "RANDOM()",
"random": "random",
"recently_added": recentlyAddedSort(),
"starred_at": "starred, starred_at",
}
} else {
r.sortMappings = map[string]string{
"name": "order_album_name asc, order_album_artist_name asc",
"artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
"albumArtist": "compilation asc, order_album_artist_name asc, order_album_name asc",
"album_artist": "compilation asc, order_album_artist_name asc, order_album_name asc",
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name, order_album_name asc",
"random": "RANDOM()",
"random": "random",
"recently_added": recentlyAddedSort(),
"starred_at": "starred, starred_at",
}
}
@@ -180,6 +182,7 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e
}
func (r *albumRepository) GetAllWithoutGenres(options ...model.QueryOptions) (model.Albums, error) {
r.resetSeededRandom(options)
sq := r.selectAlbum(options...)
var dba dbAlbums
err := r.queryAll(sq, &dba)
@@ -212,7 +215,7 @@ func (r *albumRepository) Search(q string, offset int, size int) (model.Albums,
}
func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(options...))
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *albumRepository) Read(id string) (interface{}, error) {
@@ -220,7 +223,7 @@ func (r *albumRepository) Read(id string) (interface{}, error) {
}
func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return r.GetAll(r.parseRestOptions(options...))
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
func (r *albumRepository) EntityName() string {

View File

@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -20,7 +21,7 @@ var _ = Describe("AlbumRepository", func() {
BeforeEach(func() {
ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe"})
repo = NewAlbumRepository(ctx, getDBXBuilder())
repo = NewAlbumRepository(ctx, NewDBXBuilder(db.Db()))
})
Describe("Get", func() {
@@ -100,7 +101,7 @@ var _ = Describe("AlbumRepository", func() {
conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeAbsolute
id := uuid.NewString()
Expect(repo.Put(&model.Album{ID: id, Name: "name", SongCount: songCount})).To(Succeed())
Expect(repo.Put(&model.Album{LibraryID: 1, ID: id, Name: "name", SongCount: songCount})).To(Succeed())
for i := 0; i < playCount; i++ {
Expect(repo.IncPlayCount(id, time.Now())).To(Succeed())
}
@@ -123,7 +124,7 @@ var _ = Describe("AlbumRepository", func() {
conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeNormalized
id := uuid.NewString()
Expect(repo.Put(&model.Album{ID: id, Name: "name", SongCount: songCount})).To(Succeed())
Expect(repo.Put(&model.Album{LibraryID: 1, ID: id, Name: "name", SongCount: songCount})).To(Succeed())
for i := 0; i < playCount; i++ {
Expect(repo.IncPlayCount(id, time.Now())).To(Succeed())
}

View File

@@ -1,6 +1,7 @@
package persistence
import (
"cmp"
"context"
"fmt"
"net/url"
@@ -14,12 +15,12 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/str"
"github.com/pocketbase/dbx"
)
type artistRepository struct {
sqlRepository
sqlRestful
indexGroups utils.IndexGroups
}
@@ -59,19 +60,22 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
r.ctx = ctx
r.db = db
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
r.tableName = "artist"
r.filterMappings = map[string]filterFunc{
"id": idFilter(r.tableName),
"name": fullTextFilter,
"starred": booleanFilter,
}
r.tableName = "artist" // To be used by the idFilter below
r.registerModel(&model.Artist{}, map[string]filterFunc{
"id": idFilter(r.tableName),
"name": fullTextFilter,
"starred": booleanFilter,
"genre_id": eqFilter,
})
if conf.Server.PreferSortTags {
r.sortMappings = map[string]string{
"name": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name)",
"name": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name)",
"starred_at": "starred, starred_at",
}
} else {
r.sortMappings = map[string]string{
"name": "order_artist_name",
"name": "order_artist_name",
"starred_at": "starred, starred_at",
}
}
return r
@@ -140,7 +144,11 @@ func (r *artistRepository) toModels(dba []dbArtist) model.Artists {
}
func (r *artistRepository) getIndexKey(a *model.Artist) string {
name := strings.ToLower(utils.NoArticle(a.Name))
source := a.Name
if conf.Server.PreferSortTags {
source = cmp.Or(a.SortArtistName, a.OrderArtistName, source)
}
name := strings.ToLower(str.RemoveArticle(source))
for k, v := range r.indexGroups {
key := strings.ToLower(k)
if strings.HasPrefix(name, key) {
@@ -152,7 +160,11 @@ func (r *artistRepository) getIndexKey(a *model.Artist) string {
// TODO Cache the index (recalculate when there are changes to the DB)
func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) {
all, err := r.GetAll(model.QueryOptions{Sort: "order_artist_name"})
sortColumn := "order_artist_name"
if conf.Server.PreferSortTags {
sortColumn = "sort_artist_name, order_artist_name"
}
all, err := r.GetAll(model.QueryOptions{Sort: sortColumn})
if err != nil {
return nil, err
}
@@ -199,7 +211,7 @@ func (r *artistRepository) Search(q string, offset int, size int) (model.Artists
}
func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(options...))
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *artistRepository) Read(id string) (interface{}, error) {
@@ -207,7 +219,7 @@ func (r *artistRepository) Read(id string) (interface{}, error) {
}
func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return r.GetAll(r.parseRestOptions(options...))
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
func (r *artistRepository) EntityName() string {

View File

@@ -4,9 +4,12 @@ import (
"context"
"github.com/fatih/structs"
"github.com/navidrome/navidrome/conf"
"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/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
. "github.com/onsi/gomega/gstruct"
@@ -18,7 +21,7 @@ var _ = Describe("ArtistRepository", func() {
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid"})
repo = NewArtistRepository(ctx, getDBXBuilder())
repo = NewArtistRepository(ctx, NewDBXBuilder(db.Db()))
})
Describe("Count", func() {
@@ -42,8 +45,148 @@ var _ = Describe("ArtistRepository", func() {
})
})
Describe("GetIndexKey", func() {
r := artistRepository{indexGroups: utils.ParseIndexGroups(conf.Server.IndexGroups)}
It("returns the index key when PreferSortTags is true and SortArtistName is not empty", func() {
conf.Server.PreferSortTags = true
a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"}
idx := GetIndexKey(&r, &a) // defines export_test.go
Expect(idx).To(Equal("F"))
a = model.Artist{SortArtistName: "foo", OrderArtistName: "Bar", Name: "Qux"}
idx = GetIndexKey(&r, &a)
Expect(idx).To(Equal("F"))
})
It("returns the index key when PreferSortTags is true, SortArtistName is empty and OrderArtistName is not empty", func() {
conf.Server.PreferSortTags = true
a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"}
idx := GetIndexKey(&r, &a)
Expect(idx).To(Equal("B"))
a = model.Artist{SortArtistName: "", OrderArtistName: "bar", Name: "Qux"}
idx = GetIndexKey(&r, &a)
Expect(idx).To(Equal("B"))
})
It("returns the index key when PreferSortTags is true, both SortArtistName, OrderArtistName are empty", func() {
conf.Server.PreferSortTags = true
a := model.Artist{SortArtistName: "", OrderArtistName: "", Name: "Qux"}
idx := GetIndexKey(&r, &a)
Expect(idx).To(Equal("Q"))
a = model.Artist{SortArtistName: "", OrderArtistName: "", Name: "qux"}
idx = GetIndexKey(&r, &a)
Expect(idx).To(Equal("Q"))
})
It("returns the index key when PreferSortTags is false and SortArtistName is not empty", func() {
conf.Server.PreferSortTags = false
a := model.Artist{SortArtistName: "Foo", OrderArtistName: "Bar", Name: "Qux"}
idx := GetIndexKey(&r, &a)
Expect(idx).To(Equal("Q"))
})
It("returns the index key when PreferSortTags is true, SortArtistName is empty and OrderArtistName is not empty", func() {
conf.Server.PreferSortTags = false
a := model.Artist{SortArtistName: "", OrderArtistName: "Bar", Name: "Qux"}
idx := GetIndexKey(&r, &a)
Expect(idx).To(Equal("Q"))
})
It("returns the index key when PreferSortTags is true, both sort_artist_name, order_artist_name are empty", func() {
conf.Server.PreferSortTags = false
a := model.Artist{SortArtistName: "", OrderArtistName: "", Name: "Qux"}
idx := GetIndexKey(&r, &a)
Expect(idx).To(Equal("Q"))
a = model.Artist{SortArtistName: "", OrderArtistName: "", Name: "qux"}
idx = GetIndexKey(&r, &a)
Expect(idx).To(Equal("Q"))
})
})
Describe("GetIndex", func() {
It("returns the index", func() {
It("returns the index when PreferSortTags is true and SortArtistName is not empty", func() {
conf.Server.PreferSortTags = true
artistBeatles.SortArtistName = "Foo"
er := repo.Put(&artistBeatles)
Expect(er).To(BeNil())
idx, err := repo.GetIndex()
Expect(err).To(BeNil())
Expect(idx).To(Equal(model.ArtistIndexes{
{
ID: "F",
Artists: model.Artists{
artistBeatles,
},
},
{
ID: "K",
Artists: model.Artists{
artistKraftwerk,
},
},
}))
artistBeatles.SortArtistName = ""
er = repo.Put(&artistBeatles)
Expect(er).To(BeNil())
})
It("returns the index when PreferSortTags is true and SortArtistName is empty", func() {
conf.Server.PreferSortTags = true
idx, err := repo.GetIndex()
Expect(err).To(BeNil())
Expect(idx).To(Equal(model.ArtistIndexes{
{
ID: "B",
Artists: model.Artists{
artistBeatles,
},
},
{
ID: "K",
Artists: model.Artists{
artistKraftwerk,
},
},
}))
})
It("returns the index when PreferSortTags is false and SortArtistName is not empty", func() {
conf.Server.PreferSortTags = false
artistBeatles.SortArtistName = "Foo"
er := repo.Put(&artistBeatles)
Expect(er).To(BeNil())
idx, err := repo.GetIndex()
Expect(err).To(BeNil())
Expect(idx).To(Equal(model.ArtistIndexes{
{
ID: "B",
Artists: model.Artists{
artistBeatles,
},
},
{
ID: "K",
Artists: model.Artists{
artistKraftwerk,
},
},
}))
artistBeatles.SortArtistName = ""
er = repo.Put(&artistBeatles)
Expect(er).To(BeNil())
})
It("returns the index when PreferSortTags is false and SortArtistName is empty", func() {
conf.Server.PreferSortTags = false
idx, err := repo.GetIndex()
Expect(err).To(BeNil())
Expect(idx).To(Equal(model.ArtistIndexes{

View File

@@ -0,0 +1,22 @@
package persistence
import (
"github.com/navidrome/navidrome/db"
"github.com/pocketbase/dbx"
)
type dbxBuilder struct {
dbx.Builder
wdb dbx.Builder
}
func NewDBXBuilder(d db.DB) *dbxBuilder {
b := &dbxBuilder{}
b.Builder = dbx.NewFromDB(d.ReadDB(), db.Driver)
b.wdb = dbx.NewFromDB(d.WriteDB(), db.Driver)
return b
}
func (d *dbxBuilder) Transactional(f func(*dbx.Tx) error) (err error) {
return d.wdb.(*dbx.DB).Transactional(f)
}

View File

@@ -0,0 +1,5 @@
package persistence
// Definitions for testing private methods
var GetIndexKey = (*artistRepository).getIndexKey

View File

@@ -14,17 +14,15 @@ import (
type genreRepository struct {
sqlRepository
sqlRestful
}
func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository {
r := &genreRepository{}
r.ctx = ctx
r.db = db
r.tableName = "genre"
r.filterMappings = map[string]filterFunc{
"name": containsFilter,
}
r.registerModel(&model.Genre{}, map[string]filterFunc{
"name": containsFilter("name"),
})
return r
}
@@ -60,7 +58,7 @@ func (r *genreRepository) Put(m *model.Genre) error {
}
func (r *genreRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.count(Select(), r.parseRestOptions(options...))
return r.count(Select(), r.parseRestOptions(r.ctx, options...))
}
func (r *genreRepository) Read(id string) (interface{}, error) {
@@ -71,7 +69,7 @@ func (r *genreRepository) Read(id string) (interface{}, error) {
}
func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
sel := r.newSelect(r.parseRestOptions(options...)).Columns("*")
sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
res := model.Genres{}
err := r.queryAll(sel, &res)
return res, err

View File

@@ -10,14 +10,13 @@ import (
"github.com/navidrome/navidrome/persistence"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
var _ = Describe("GenreRepository", func() {
var repo model.GenreRepository
BeforeEach(func() {
repo = persistence.NewGenreRepository(log.NewContext(context.TODO()), dbx.NewFromDB(db.Db(), db.Driver))
repo = persistence.NewGenreRepository(log.NewContext(context.TODO()), persistence.NewDBXBuilder(db.Db()))
})
Describe("GetAll()", func() {

View File

@@ -0,0 +1,83 @@
package persistence
import (
"context"
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/pocketbase/dbx"
)
type libraryRepository struct {
sqlRepository
}
func NewLibraryRepository(ctx context.Context, db dbx.Builder) model.LibraryRepository {
r := &libraryRepository{}
r.ctx = ctx
r.db = db
r.registerModel(&model.Library{}, nil)
return r
}
func (r *libraryRepository) Get(id int) (*model.Library, error) {
sq := r.newSelect().Columns("*").Where(Eq{"id": id})
var res model.Library
err := r.queryOne(sq, &res)
return &res, err
}
func (r *libraryRepository) Put(l *model.Library) error {
cols := map[string]any{
"name": l.Name,
"path": l.Path,
"remote_path": l.RemotePath,
"updated_at": time.Now(),
}
if l.ID != 0 {
cols["id"] = l.ID
}
sq := Insert(r.tableName).SetMap(cols).
Suffix(`on conflict(id) do update set name = excluded.name, path = excluded.path,
remote_path = excluded.remote_path, updated_at = excluded.updated_at`)
_, err := r.executeSQL(sq)
return err
}
const hardCodedMusicFolderID = 1
// TODO Remove this method when we have a proper UI to add libraries
func (r *libraryRepository) StoreMusicFolder() error {
sq := Update(r.tableName).Set("path", conf.Server.MusicFolder).Set("updated_at", time.Now()).
Where(Eq{"id": hardCodedMusicFolderID})
_, err := r.executeSQL(sq)
return err
}
func (r *libraryRepository) AddArtist(id int, artistID string) error {
sq := Insert("library_artist").Columns("library_id", "artist_id").Values(id, artistID).
Suffix(`on conflict(library_id, artist_id) do nothing`)
_, err := r.executeSQL(sq)
if err != nil {
return err
}
return nil
}
func (r *libraryRepository) UpdateLastScan(id int, t time.Time) error {
sq := Update(r.tableName).Set("last_scan_at", t).Where(Eq{"id": id})
_, err := r.executeSQL(sq)
return err
}
func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries, error) {
sq := r.newSelect(ops...).Columns("*")
res := model.Libraries{}
err := r.queryAll(sq, &res)
return res, err
}
var _ model.LibraryRepository = (*libraryRepository)(nil)

View File

@@ -18,7 +18,6 @@ import (
type mediaFileRepository struct {
sqlRepository
sqlRestful
}
func NewMediaFileRepository(ctx context.Context, db dbx.Builder) *mediaFileRepository {
@@ -26,26 +25,29 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) *mediaFileRepos
r.ctx = ctx
r.db = db
r.tableName = "media_file"
r.filterMappings = map[string]filterFunc{
"id": idFilter(r.tableName),
"title": fullTextFilter,
"starred": booleanFilter,
}
r.registerModel(&model.MediaFile{}, map[string]filterFunc{
"id": idFilter(r.tableName),
"title": fullTextFilter,
"starred": booleanFilter,
"genre_id": eqFilter,
})
if conf.Server.PreferSortTags {
r.sortMappings = map[string]string{
"title": "COALESCE(NULLIF(sort_title,''),title)",
"artist": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc",
"album": "COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc, COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_title,''),title) asc",
"random": "RANDOM()",
"createdAt": "media_file.created_at",
"title": "COALESCE(NULLIF(sort_title,''),order_title)",
"artist": "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc",
"album": "COALESCE(NULLIF(sort_album_name,''),order_album_name) asc, release_date asc, disc_number asc, track_number asc, COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc, COALESCE(NULLIF(sort_title,''),title) asc",
"random": "random",
"created_at": "media_file.created_at",
"starred_at": "starred, starred_at",
}
} else {
r.sortMappings = map[string]string{
"title": "order_title",
"artist": "order_artist_name asc, order_album_name asc, release_date asc, disc_number asc, track_number asc",
"album": "order_album_name asc, release_date asc, disc_number asc, track_number asc, order_artist_name asc, title asc",
"random": "RANDOM()",
"createdAt": "media_file.created_at",
"title": "order_title",
"artist": "order_artist_name asc, order_album_name asc, release_date asc, disc_number asc, track_number asc",
"album": "order_album_name asc, release_date asc, disc_number asc, track_number asc, order_artist_name asc, title asc",
"random": "random",
"created_at": "media_file.created_at",
"starred_at": "starred, starred_at",
}
}
return r
@@ -102,6 +104,7 @@ func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) {
}
func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
r.resetSeededRandom(options)
sq := r.selectMediaFile(options...)
res := model.MediaFiles{}
err := r.queryAll(sq, &res, options...)
@@ -124,6 +127,15 @@ func (r *mediaFileRepository) FindByPath(path string) (*model.MediaFile, error)
return &res[0], nil
}
func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths})
var res model.MediaFiles
if err := r.queryAll(sel, &res); err != nil {
return nil, err
}
return res, nil
}
func cleanPath(path string) string {
path = filepath.Clean(path)
if !strings.HasSuffix(path, string(os.PathSeparator)) {
@@ -208,7 +220,7 @@ func (r *mediaFileRepository) Search(q string, offset int, size int) (model.Medi
}
func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(options...))
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *mediaFileRepository) Read(id string) (interface{}, error) {
@@ -216,7 +228,7 @@ func (r *mediaFileRepository) Read(id string) (interface{}, error) {
}
func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return r.GetAll(r.parseRestOptions(options...))
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
func (r *mediaFileRepository) EntityName() string {

View File

@@ -6,6 +6,7 @@ import (
"github.com/Masterminds/squirrel"
"github.com/google/uuid"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -19,7 +20,7 @@ var _ = Describe("MediaRepository", func() {
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid"})
mr = NewMediaFileRepository(ctx, getDBXBuilder())
mr = NewMediaFileRepository(ctx, NewDBXBuilder(db.Db()))
})
It("gets mediafile from the DB", func() {
@@ -41,8 +42,8 @@ var _ = Describe("MediaRepository", func() {
})
It("finds tracks by path when using wildcards chars", func() {
Expect(mr.Put(&model.MediaFile{ID: "7001", Path: P("/Find:By'Path/_/123.mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{ID: "7002", Path: P("/Find:By'Path/1/123.mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7001", Path: P("/Find:By'Path/_/123.mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7002", Path: P("/Find:By'Path/1/123.mp3")})).To(BeNil())
found, err := mr.FindAllByPath(P("/Find:By'Path/_/"))
Expect(err).To(BeNil())
@@ -51,8 +52,8 @@ var _ = Describe("MediaRepository", func() {
})
It("finds tracks by path when using UTF8 chars", func() {
Expect(mr.Put(&model.MediaFile{ID: "7010", Path: P("/Пётр Ильич Чайковский/123.mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{ID: "7011", Path: P("/Пётр Ильич Чайковский/222.mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7010", Path: P("/Пётр Ильич Чайковский/123.mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7011", Path: P("/Пётр Ильич Чайковский/222.mp3")})).To(BeNil())
found, err := mr.FindAllByPath(P("/Пётр Ильич Чайковский/"))
Expect(err).To(BeNil())
@@ -60,8 +61,8 @@ var _ = Describe("MediaRepository", func() {
})
It("finds tracks by path case sensitively", func() {
Expect(mr.Put(&model.MediaFile{ID: "7003", Path: P("/Casesensitive/file1.mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{ID: "7004", Path: P("/casesensitive/file2.mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7003", Path: P("/Casesensitive/file1.mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: "7004", Path: P("/casesensitive/file2.mp3")})).To(BeNil())
found, err := mr.FindAllByPath(P("/Casesensitive"))
Expect(err).To(BeNil())
@@ -76,7 +77,7 @@ var _ = Describe("MediaRepository", func() {
It("delete tracks by id", func() {
id := uuid.NewString()
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
Expect(mr.Delete(id)).To(BeNil())
@@ -86,15 +87,15 @@ var _ = Describe("MediaRepository", func() {
It("delete tracks by path", func() {
id1 := "6001"
Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/abc/123/" + id1 + ".mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id1, Path: P("/abc/123/" + id1 + ".mp3")})).To(BeNil())
id2 := "6002"
Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/abc/123/" + id2 + ".mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id2, Path: P("/abc/123/" + id2 + ".mp3")})).To(BeNil())
id3 := "6003"
Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/ab_/" + id3 + ".mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id3, Path: P("/ab_/" + id3 + ".mp3")})).To(BeNil())
id4 := "6004"
Expect(mr.Put(&model.MediaFile{ID: id4, Path: P("/abc/" + id4 + ".mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id4, Path: P("/abc/" + id4 + ".mp3")})).To(BeNil())
id5 := "6005"
Expect(mr.Put(&model.MediaFile{ID: id5, Path: P("/Ab_/" + id5 + ".mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id5, Path: P("/Ab_/" + id5 + ".mp3")})).To(BeNil())
Expect(mr.DeleteByPath(P("/ab_"))).To(Equal(int64(1)))
@@ -108,11 +109,11 @@ var _ = Describe("MediaRepository", func() {
It("delete tracks by path containing UTF8 chars", func() {
id1 := "6011"
Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/Legião Urbana/" + id1 + ".mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id1, Path: P("/Legião Urbana/" + id1 + ".mp3")})).To(BeNil())
id2 := "6012"
Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/Legião Urbana/" + id2 + ".mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id2, Path: P("/Legião Urbana/" + id2 + ".mp3")})).To(BeNil())
id3 := "6003"
Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/Legião Urbana/" + id3 + ".mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id3, Path: P("/Legião Urbana/" + id3 + ".mp3")})).To(BeNil())
Expect(mr.FindAllByPath(P("/Legião Urbana"))).To(HaveLen(3))
Expect(mr.DeleteByPath(P("/Legião Urbana"))).To(Equal(int64(3)))
@@ -121,11 +122,11 @@ var _ = Describe("MediaRepository", func() {
It("only deletes tracks that match exact path", func() {
id1 := "6021"
Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/music/overlap/Ella Fitzgerald/" + id1 + ".mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id1, Path: P("/music/overlap/Ella Fitzgerald/" + id1 + ".mp3")})).To(BeNil())
id2 := "6022"
Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/music/overlap/Ella Fitzgerald/" + id2 + ".mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id2, Path: P("/music/overlap/Ella Fitzgerald/" + id2 + ".mp3")})).To(BeNil())
id3 := "6023"
Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/music/overlap/Ella Fitzgerald & Louis Armstrong - They Can't Take That Away From Me.mp3")})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id3, Path: P("/music/overlap/Ella Fitzgerald & Louis Armstrong - They Can't Take That Away From Me.mp3")})).To(BeNil())
Expect(mr.FindAllByPath(P("/music/overlap/Ella Fitzgerald"))).To(HaveLen(2))
Expect(mr.DeleteByPath(P("/music/overlap/Ella Fitzgerald"))).To(Equal(int64(2)))
@@ -146,7 +147,7 @@ var _ = Describe("MediaRepository", func() {
Context("Annotations", func() {
It("increments play count when the tracks does not have annotations", func() {
id := "incplay.firsttime"
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
playDate := time.Now()
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
@@ -159,7 +160,7 @@ var _ = Describe("MediaRepository", func() {
It("preserves play date if and only if provided date is older", func() {
id := "incplay.playdate"
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
playDate := time.Now()
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())
mf, err := mr.Get(id)
@@ -184,7 +185,7 @@ var _ = Describe("MediaRepository", func() {
It("increments play count on newly starred items", func() {
id := "star.incplay"
Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil())
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
Expect(mr.SetStar(true, id)).To(BeNil())
playDate := time.Now()
Expect(mr.IncPlayCount(id, playDate)).To(BeNil())

View File

@@ -1,37 +0,0 @@
package persistence
import (
"context"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/pocketbase/dbx"
)
type mediaFolderRepository struct {
ctx context.Context
}
func NewMediaFolderRepository(ctx context.Context, _ dbx.Builder) model.MediaFolderRepository {
return &mediaFolderRepository{ctx}
}
func (r *mediaFolderRepository) Get(id int32) (*model.MediaFolder, error) {
mediaFolder := hardCoded()
return &mediaFolder, nil
}
func (*mediaFolderRepository) GetAll() (model.MediaFolders, error) {
mediaFolder := hardCoded()
result := make(model.MediaFolders, 1)
result[0] = mediaFolder
return result, nil
}
func hardCoded() model.MediaFolder {
mediaFolder := model.MediaFolder{ID: 0, Path: conf.Server.MusicFolder}
mediaFolder.Name = "Music Library"
return mediaFolder
}
var _ model.MediaFolderRepository = (*mediaFolderRepository)(nil)

View File

@@ -2,7 +2,6 @@ package persistence
import (
"context"
"database/sql"
"reflect"
"github.com/navidrome/navidrome/db"
@@ -15,8 +14,8 @@ type SQLStore struct {
db dbx.Builder
}
func New(conn *sql.DB) model.DataStore {
return &SQLStore{db: dbx.NewFromDB(conn, db.Driver)}
func New(d db.DB) model.DataStore {
return &SQLStore{db: NewDBXBuilder(d)}
}
func (s *SQLStore) Album(ctx context.Context) model.AlbumRepository {
@@ -31,8 +30,8 @@ func (s *SQLStore) MediaFile(ctx context.Context) model.MediaFileRepository {
return NewMediaFileRepository(ctx, s.getDBXBuilder())
}
func (s *SQLStore) MediaFolder(ctx context.Context) model.MediaFolderRepository {
return NewMediaFolderRepository(ctx, s.getDBXBuilder())
func (s *SQLStore) Library(ctx context.Context) model.LibraryRepository {
return NewLibraryRepository(ctx, s.getDBXBuilder())
}
func (s *SQLStore) Genre(ctx context.Context) model.GenreRepository {
@@ -106,14 +105,18 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe
return nil
}
type transactional interface {
Transactional(f func(*dbx.Tx) error) (err error)
}
func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error {
conn, ok := s.db.(*dbx.DB)
if !ok {
conn = dbx.NewFromDB(db.Db(), db.Driver)
// If we are already in a transaction, just pass it down
if conn, ok := s.db.(*dbx.Tx); ok {
return block(&SQLStore{db: conn})
}
return conn.Transactional(func(tx *dbx.Tx) error {
newDb := &SQLStore{db: tx}
return block(newDb)
return s.db.(transactional).Transactional(func(tx *dbx.Tx) error {
return block(&SQLStore{db: tx})
})
}
@@ -172,7 +175,7 @@ func (s *SQLStore) GC(ctx context.Context, rootFolder string) error {
func (s *SQLStore) getDBXBuilder() dbx.Builder {
if s.db == nil {
return dbx.NewFromDB(db.Db(), db.Driver)
return NewDBXBuilder(db.Db())
}
return s.db
}

View File

@@ -14,7 +14,6 @@ import (
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
func TestPersistence(t *testing.T) {
@@ -22,17 +21,13 @@ func TestPersistence(t *testing.T) {
//os.Remove("./test-123.db")
//conf.Server.DbPath = "./test-123.db"
conf.Server.DbPath = "file::memory:?cache=shared"
conf.Server.DbPath = "file::memory:?cache=shared&_foreign_keys=on"
defer db.Init()()
log.SetLevel(log.LevelError)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Persistence Suite")
}
func getDBXBuilder() *dbx.DB {
return dbx.NewFromDB(db.Db(), db.Driver)
}
var (
genreElectronic = model.Genre{ID: "gn-1", Name: "Electronic"}
genreRock = model.Genre{ID: "gn-2", Name: "Rock"}
@@ -40,8 +35,8 @@ var (
)
var (
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: " kraftwerk"}
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: " beatles the"}
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk", AlbumCount: 1, FullText: " kraftwerk"}
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles", AlbumCount: 2, FullText: " beatles the"}
testArtists = model.Artists{
artistKraftwerk,
artistBeatles,
@@ -49,9 +44,9 @@ var (
)
var (
albumSgtPeppers = model.Album{ID: "101", Name: "Sgt Peppers", Artist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, EmbedArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: " beatles peppers sgt the", Discs: model.Discs{}}
albumAbbeyRoad = model.Album{ID: "102", Name: "Abbey Road", Artist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, EmbedArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: " abbey beatles road the", Discs: model.Discs{}}
albumRadioactivity = model.Album{ID: "103", Name: "Radioactivity", Artist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", Genre: "Electronic", Genres: model.Genres{genreElectronic, genreRock}, EmbedArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: " kraftwerk radioactivity", Discs: model.Discs{}}
albumSgtPeppers = model.Album{LibraryID: 1, ID: "101", Name: "Sgt Peppers", Artist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, EmbedArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: " beatles peppers sgt the", Discs: model.Discs{}}
albumAbbeyRoad = model.Album{LibraryID: 1, ID: "102", Name: "Abbey Road", Artist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, EmbedArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: " abbey beatles road the", Discs: model.Discs{}}
albumRadioactivity = model.Album{LibraryID: 1, ID: "103", Name: "Radioactivity", Artist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", Genre: "Electronic", Genres: model.Genres{genreElectronic, genreRock}, EmbedArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: " kraftwerk radioactivity", Discs: model.Discs{}}
testAlbums = model.Albums{
albumSgtPeppers,
albumAbbeyRoad,
@@ -60,10 +55,10 @@ var (
)
var (
songDayInALife = model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Genre: "Rock", Genres: model.Genres{genreRock}, Path: P("/beatles/1/sgt/a day.mp3"), FullText: " a beatles day in life peppers sgt the"}
songComeTogether = model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Genre: "Rock", Genres: model.Genres{genreRock}, Path: P("/beatles/1/come together.mp3"), FullText: " abbey beatles come road the together"}
songRadioactivity = model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Genre: "Electronic", Genres: model.Genres{genreElectronic}, Path: P("/kraft/radio/radio.mp3"), FullText: " kraftwerk radioactivity"}
songAntenna = model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk",
songDayInALife = model.MediaFile{LibraryID: 1, ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Genre: "Rock", Genres: model.Genres{genreRock}, Path: P("/beatles/1/sgt/a day.mp3"), FullText: " a beatles day in life peppers sgt the"}
songComeTogether = model.MediaFile{LibraryID: 1, ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Genre: "Rock", Genres: model.Genres{genreRock}, Path: P("/beatles/1/come together.mp3"), FullText: " abbey beatles come road the together"}
songRadioactivity = model.MediaFile{LibraryID: 1, ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Genre: "Electronic", Genres: model.Genres{genreElectronic}, Path: P("/kraft/radio/radio.mp3"), FullText: " kraftwerk radioactivity"}
songAntenna = model.MediaFile{LibraryID: 1, ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk",
AlbumID: "103", Genre: "Electronic", Genres: model.Genres{genreElectronic, genreRock},
Path: P("/kraft/radio/antenna.mp3"), FullText: " antenna kraftwerk",
RgAlbumGain: 1.0, RgAlbumPeak: 2.0, RgTrackGain: 3.0, RgTrackPeak: 4.0,
@@ -88,6 +83,12 @@ var (
testPlaylists []*model.Playlist
)
var (
adminUser = model.User{ID: "userid", UserName: "userid", Name: "admin", Email: "admin@email.com", IsAdmin: true}
regularUser = model.User{ID: "2222", UserName: "regular-user", Name: "Regular User", Email: "regular@example.com"}
testUsers = model.Users{adminUser, regularUser}
)
func P(path string) string {
return filepath.FromSlash(path)
}
@@ -95,15 +96,16 @@ func P(path string) string {
// Initialize test DB
// TODO Load this data setup from file(s)
var _ = BeforeSuite(func() {
conn := getDBXBuilder()
conn := NewDBXBuilder(db.Db())
ctx := log.NewContext(context.TODO())
user := model.User{ID: "userid", UserName: "userid", IsAdmin: true}
ctx = request.WithUser(ctx, user)
ctx = request.WithUser(ctx, adminUser)
ur := NewUserRepository(ctx, conn)
err := ur.Put(&user)
if err != nil {
panic(err)
for i := range testUsers {
err := ur.Put(&testUsers[i])
if err != nil {
panic(err)
}
}
gr := NewGenreRepository(ctx, conn)

View File

@@ -4,7 +4,6 @@ import (
"context"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -16,17 +15,13 @@ var _ = Describe("SQLStore", func() {
BeforeEach(func() {
ds = New(db.Db())
ctx = context.Background()
log.SetLevel(log.LevelFatal)
})
AfterEach(func() {
log.SetLevel(log.LevelError)
})
Describe("WithTx", func() {
Context("When block returns nil", func() {
It("commits changes to the DB", func() {
err := ds.WithTx(func(tx model.DataStore) error {
pl := tx.Player(ctx)
err := pl.Put(&model.Player{ID: "666", UserName: "userid"})
err := pl.Put(&model.Player{ID: "666", UserId: "userid"})
Expect(err).ToNot(HaveOccurred())
pr := tx.Property(ctx)
@@ -35,7 +30,7 @@ var _ = Describe("SQLStore", func() {
return nil
})
Expect(err).ToNot(HaveOccurred())
Expect(ds.Player(ctx).Get("666")).To(Equal(&model.Player{ID: "666", UserName: "userid"}))
Expect(ds.Player(ctx).Get("666")).To(Equal(&model.Player{ID: "666", UserId: "userid", Username: "userid"}))
Expect(ds.Property(ctx).Get("777")).To(Equal("value"))
})
})

View File

@@ -12,16 +12,17 @@ import (
type playerRepository struct {
sqlRepository
sqlRestful
}
func NewPlayerRepository(ctx context.Context, db dbx.Builder) model.PlayerRepository {
r := &playerRepository{}
r.ctx = ctx
r.db = db
r.tableName = "player"
r.filterMappings = map[string]filterFunc{
"name": containsFilter,
r.registerModel(&model.Player{}, map[string]filterFunc{
"name": containsFilter("player.name"),
})
r.sortMappings = map[string]string{
"user_name": "username", //TODO rename all user_name and userName to username
}
return r
}
@@ -31,18 +32,25 @@ func (r *playerRepository) Put(p *model.Player) error {
return err
}
func (r *playerRepository) selectPlayer(options ...model.QueryOptions) SelectBuilder {
return r.newSelect(options...).
Columns("player.*").
Join("user ON player.user_id = user.id").
Columns("user.user_name username")
}
func (r *playerRepository) Get(id string) (*model.Player, error) {
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
sel := r.selectPlayer().Where(Eq{"player.id": id})
var res model.Player
err := r.queryOne(sel, &res)
return &res, err
}
func (r *playerRepository) FindMatch(userName, client, userAgent string) (*model.Player, error) {
sel := r.newSelect().Columns("*").Where(And{
func (r *playerRepository) FindMatch(userId, client, userAgent string) (*model.Player, error) {
sel := r.selectPlayer().Where(And{
Eq{"client": client},
Eq{"user_agent": userAgent},
Eq{"user_name": userName},
Eq{"user_id": userId},
})
var res model.Player
err := r.queryOne(sel, &res)
@@ -50,7 +58,7 @@ func (r *playerRepository) FindMatch(userName, client, userAgent string) (*model
}
func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder {
s := r.newSelect(options...)
s := r.selectPlayer(options...)
return s.Where(r.addRestriction())
}
@@ -63,22 +71,22 @@ func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer {
if u.IsAdmin {
return s
}
return append(s, Eq{"user_name": u.UserName})
return append(s, Eq{"user_id": u.ID})
}
func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.count(r.newRestSelect(), r.parseRestOptions(options...))
return r.count(r.newRestSelect(), r.parseRestOptions(r.ctx, options...))
}
func (r *playerRepository) Read(id string) (interface{}, error) {
sel := r.newRestSelect().Columns("*").Where(Eq{"id": id})
sel := r.newRestSelect().Where(Eq{"player.id": id})
var res model.Player
err := r.queryOne(sel, &res)
return &res, err
}
func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
sel := r.newRestSelect(r.parseRestOptions(options...)).Columns("*")
sel := r.newRestSelect(r.parseRestOptions(r.ctx, options...))
res := model.Players{}
err := r.queryAll(sel, &res)
return res, err
@@ -94,7 +102,7 @@ func (r *playerRepository) NewInstance() interface{} {
func (r *playerRepository) isPermitted(p *model.Player) bool {
u := loggedUser(r.ctx)
return u.IsAdmin || p.UserName == u.UserName
return u.IsAdmin || p.UserId == u.ID
}
func (r *playerRepository) Save(entity interface{}) (string, error) {
@@ -123,7 +131,7 @@ func (r *playerRepository) Update(id string, entity interface{}, cols ...string)
}
func (r *playerRepository) Delete(id string) error {
filter := r.addRestriction(And{Eq{"id": id}})
filter := r.addRestriction(And{Eq{"player.id": id}})
err := r.delete(filter)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound

View File

@@ -0,0 +1,247 @@
package persistence
import (
"context"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/db"
"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("PlayerRepository", func() {
var adminRepo *playerRepository
var database *dbxBuilder
var (
adminPlayer1 = model.Player{ID: "1", Name: "NavidromeUI [Firefox/Linux]", UserAgent: "Firefox/Linux", UserId: adminUser.ID, Username: adminUser.UserName, Client: "NavidromeUI", IP: "127.0.0.1", ReportRealPath: true, ScrobbleEnabled: true}
adminPlayer2 = model.Player{ID: "2", Name: "GenericClient [Chrome/Windows]", IP: "192.168.0.5", UserAgent: "Chrome/Windows", UserId: adminUser.ID, Username: adminUser.UserName, Client: "GenericClient", MaxBitRate: 128}
regularPlayer = model.Player{ID: "3", Name: "NavidromeUI [Safari/macOS]", UserAgent: "Safari/macOS", UserId: regularUser.ID, Username: regularUser.UserName, Client: "NavidromeUI", ReportRealPath: true, ScrobbleEnabled: false}
players = model.Players{adminPlayer1, adminPlayer2, regularPlayer}
)
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
database = NewDBXBuilder(db.Db())
adminRepo = NewPlayerRepository(ctx, database).(*playerRepository)
for idx := range players {
err := adminRepo.Put(&players[idx])
Expect(err).To(BeNil())
}
})
AfterEach(func() {
items, err := adminRepo.ReadAll()
Expect(err).To(BeNil())
players, ok := items.(model.Players)
Expect(ok).To(BeTrue())
for i := range players {
err = adminRepo.Delete(players[i].ID)
Expect(err).To(BeNil())
}
})
Describe("EntityName", func() {
It("returns the right name", func() {
Expect(adminRepo.EntityName()).To(Equal("player"))
})
})
Describe("FindMatch", func() {
It("finds existing match", func() {
player, err := adminRepo.FindMatch(adminUser.ID, "NavidromeUI", "Firefox/Linux")
Expect(err).To(BeNil())
Expect(*player).To(Equal(adminPlayer1))
})
It("doesn't find bad match", func() {
_, err := adminRepo.FindMatch(regularUser.ID, "NavidromeUI", "Firefox/Linux")
Expect(err).To(Equal(model.ErrNotFound))
})
})
Describe("Get", func() {
It("Gets an existing item from user", func() {
player, err := adminRepo.Get(adminPlayer1.ID)
Expect(err).To(BeNil())
Expect(*player).To(Equal(adminPlayer1))
})
It("Gets an existing item from another user", func() {
player, err := adminRepo.Get(regularPlayer.ID)
Expect(err).To(BeNil())
Expect(*player).To(Equal(regularPlayer))
})
It("does not get nonexistent item", func() {
_, err := adminRepo.Get("i don't exist")
Expect(err).To(Equal(model.ErrNotFound))
})
})
DescribeTableSubtree("per context", func(admin bool, players model.Players, userPlayer model.Player, otherPlayer model.Player) {
var repo *playerRepository
BeforeEach(func() {
if admin {
repo = adminRepo
} else {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, regularUser)
repo = NewPlayerRepository(ctx, database).(*playerRepository)
}
})
baseCount := int64(len(players))
Describe("Count", func() {
It("should return all", func() {
count, err := repo.Count()
Expect(err).To(BeNil())
Expect(count).To(Equal(baseCount))
})
})
Describe("Delete", func() {
DescribeTable("item type", func(player model.Player) {
err := repo.Delete(player.ID)
Expect(err).To(BeNil())
isReal := player.UserId != ""
canDelete := admin || player.UserId == userPlayer.UserId
count, err := repo.Count()
Expect(err).To(BeNil())
if isReal && canDelete {
Expect(count).To(Equal(baseCount - 1))
} else {
Expect(count).To(Equal(baseCount))
}
item, err := repo.Get(player.ID)
if !isReal || canDelete {
Expect(err).To(Equal(model.ErrNotFound))
} else {
Expect(*item).To(Equal(player))
}
},
Entry("same user", userPlayer),
Entry("other item", otherPlayer),
Entry("fake item", model.Player{}),
)
})
Describe("Read", func() {
It("can read from current user", func() {
player, err := repo.Read(userPlayer.ID)
Expect(err).To(BeNil())
Expect(player).To(Equal(&userPlayer))
})
It("can read from other user or fail if not admin", func() {
player, err := repo.Read(otherPlayer.ID)
if admin {
Expect(err).To(BeNil())
Expect(player).To(Equal(&otherPlayer))
} else {
Expect(err).To(Equal(model.ErrNotFound))
}
})
It("does not get nonexistent item", func() {
_, err := repo.Read("i don't exist")
Expect(err).To(Equal(model.ErrNotFound))
})
})
Describe("ReadAll", func() {
It("should get all items", func() {
data, err := repo.ReadAll()
Expect(err).To(BeNil())
Expect(data).To(Equal(players))
})
})
Describe("Save", func() {
DescribeTable("item type", func(player model.Player) {
clone := player
clone.ID = ""
clone.IP = "192.168.1.1"
id, err := repo.Save(&clone)
if clone.UserId == "" {
Expect(err).To(HaveOccurred())
} else if !admin && player.Username == adminPlayer1.Username {
Expect(err).To(Equal(rest.ErrPermissionDenied))
clone.UserId = ""
} else {
Expect(err).To(BeNil())
Expect(id).ToNot(BeEmpty())
}
count, err := repo.Count()
Expect(err).To(BeNil())
clone.ID = id
newItem, err := repo.Get(id)
if clone.UserId == "" {
Expect(count).To(Equal(baseCount))
Expect(err).To(Equal(model.ErrNotFound))
} else {
Expect(count).To(Equal(baseCount + 1))
Expect(err).To(BeNil())
Expect(*newItem).To(Equal(clone))
}
},
Entry("same user", userPlayer),
Entry("other item", otherPlayer),
Entry("fake item", model.Player{}),
)
})
Describe("Update", func() {
DescribeTable("item type", func(player model.Player) {
clone := player
clone.IP = "192.168.1.1"
clone.MaxBitRate = 10000
err := repo.Update(clone.ID, &clone, "ip")
if clone.UserId == "" {
Expect(err).To(HaveOccurred())
} else if !admin && player.Username == adminPlayer1.Username {
Expect(err).To(Equal(rest.ErrPermissionDenied))
clone.IP = player.IP
} else {
Expect(err).To(BeNil())
}
clone.MaxBitRate = player.MaxBitRate
newItem, err := repo.Get(clone.ID)
if player.UserId == "" {
Expect(err).To(Equal(model.ErrNotFound))
} else if !admin && player.UserId == adminUser.ID {
Expect(*newItem).To(Equal(player))
} else {
Expect(*newItem).To(Equal(clone))
}
},
Entry("same user", userPlayer),
Entry("other item", otherPlayer),
Entry("fake item", model.Player{}),
)
})
},
Entry("admin context", true, players, adminPlayer1, regularPlayer),
Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1),
)
})

View File

@@ -5,20 +5,21 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"slices"
"time"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx"
)
type playlistRepository struct {
sqlRepository
sqlRestful
}
type dbPlaylist struct {
@@ -37,7 +38,10 @@ func (p dbPlaylist) PostMapArgs(args map[string]any) error {
var err error
if p.Playlist.IsSmartPlaylist() {
args["rules"], err = json.Marshal(p.Playlist.Rules)
return err
if err != nil {
return fmt.Errorf("invalid criteria expression: %w", err)
}
return nil
}
delete(args, "rules")
return nil
@@ -47,10 +51,12 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
r := &playlistRepository{}
r.ctx = ctx
r.db = db
r.tableName = "playlist"
r.filterMappings = map[string]filterFunc{
r.registerModel(&model.Playlist{}, map[string]filterFunc{
"q": playlistFilter,
"smart": smartPlaylistFilter,
})
r.sortMappings = map[string]string{
"owner_name": "owner_name",
}
return r
}
@@ -194,8 +200,8 @@ func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) Selec
}
func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
// Only refresh if it is a smart playlist and was not refreshed in the last 5 seconds
if !pls.IsSmartPlaylist() || (pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < 5*time.Second) {
// Only refresh if it is a smart playlist and was not refreshed within the interval provided by the refresh delay config
if !pls.IsSmartPlaylist() || (pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay) {
return false
}
@@ -219,6 +225,18 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
// Re-populate playlist based on Smart Playlist criteria
rules := *pls.Rules
// If the playlist depends on other playlists, recursively refresh them first
childPlaylistIds := rules.ChildPlaylistIds()
for _, id := range childPlaylistIds {
childPls, err := r.Get(id)
if err != nil {
log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", id, err)
return false
}
r.refreshSmartPlaylist(childPls)
}
sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
From("media_file").LeftJoin("annotation on (" +
"annotation.item_id = media_file.id" +
@@ -289,14 +307,12 @@ func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []st
}
func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) error {
// Break the track list in chunks to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
chunks := slice.BreakUp(mediaFileIds, 200)
// Break the track list in chunks to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit
// Add new tracks, chunk by chunk
pos := startingPos
for i := range chunks {
for chunk := range slices.Chunk(mediaFileIds, 200) {
ins := Insert("playlist_tracks").Columns("playlist_id", "media_file_id", "id")
for _, t := range chunks[i] {
for _, t := range chunk {
ins = ins.Values(playlistId, t, pos)
pos++
}
@@ -368,7 +384,7 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
}
func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(options...))
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *playlistRepository) Read(id string) (interface{}, error) {
@@ -376,7 +392,7 @@ func (r *playlistRepository) Read(id string) (interface{}, error) {
}
func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return r.GetAll(r.parseRestOptions(options...))
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
func (r *playlistRepository) EntityName() string {

View File

@@ -2,7 +2,10 @@ package persistence
import (
"context"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
@@ -17,7 +20,7 @@ var _ = Describe("PlaylistRepository", func() {
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlaylistRepository(ctx, getDBXBuilder())
repo = NewPlaylistRepository(ctx, NewDBXBuilder(db.Db()))
})
Describe("Count", func() {
@@ -119,13 +122,86 @@ var _ = Describe("PlaylistRepository", func() {
},
}
})
It("Put/Get", func() {
newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
Context("valid rules", func() {
Specify("Put/Get", func() {
newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
savedPls, err := repo.Get(newPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(savedPls.Rules).To(Equal(rules))
savedPls, err := repo.Get(newPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(savedPls.Rules).To(Equal(rules))
})
})
Context("invalid rules", func() {
It("fails to Put it in the DB", func() {
rules = &criteria.Criteria{
// This is invalid because "contains" cannot have multiple fields
Expression: criteria.All{
criteria.Contains{"genre": "Hardcore", "filetype": "mp3"},
},
}
newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression")))
})
})
Context("child smart playlists", func() {
When("refresh day has expired", func() {
It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&nestedPls)).To(Succeed())
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"id": nestedPls.ID},
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
nestedPlsRead, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
_, err = repo.GetWithTracks(parentPls.ID, true)
Expect(err).ToNot(HaveOccurred())
// Check that the nested playlist was refreshed by parent get by verifying evaluatedAt is updated since first nestedPls get
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally(">", *nestedPlsRead.EvaluatedAt))
})
})
When("refresh day has not expired", func() {
It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&nestedPls)).To(Succeed())
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"id": nestedPls.ID},
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
nestedPlsRead, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
_, err = repo.GetWithTracks(parentPls.ID, true)
Expect(err).ToNot(HaveOccurred())
// Check that the nested playlist was not refreshed by parent get by verifying evaluatedAt is not updated since first nestedPls get
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt))
})
})
})
})
})

View File

@@ -13,7 +13,6 @@ import (
type playlistTrackRepository struct {
sqlRepository
sqlRestful
playlistId string
playlist *model.Playlist
playlistRepo *playlistRepository
@@ -26,11 +25,13 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
p.ctx = r.ctx
p.db = r.db
p.tableName = "playlist_tracks"
p.registerModel(&model.PlaylistTrack{}, nil)
p.sortMappings = map[string]string{
"id": "playlist_tracks.id",
"artist": "order_artist_name asc",
"album": "order_album_name asc, order_album_artist_name asc",
"title": "order_title",
"id": "playlist_tracks.id",
"artist": "order_artist_name asc",
"album": "order_album_name asc, order_album_artist_name asc",
"title": "order_title",
"duration": "duration", // To make sure the field will be whitelisted
}
if conf.Server.PreferSortTags {
p.sortMappings["artist"] = "COALESCE(NULLIF(sort_artist_name,''),order_artist_name) asc"
@@ -51,7 +52,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
}
func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.count(Select().Where(Eq{"playlist_id": r.playlistId}), r.parseRestOptions(options...))
return r.count(Select().Where(Eq{"playlist_id": r.playlistId}), r.parseRestOptions(r.ctx, options...))
}
func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
@@ -112,7 +113,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]
}
func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return r.GetAll(r.parseRestOptions(options...))
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
func (r *playlistTrackRepository) EntityName() string {

View File

@@ -101,25 +101,22 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue {
return q
}
// loadTracks loads the tracks from the database. It receives a list of track IDs and returns a list of MediaFiles
// in the same order as the input list.
func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFiles {
if len(tracks) == 0 {
return nil
}
// Collect all ids
ids := make([]string, len(tracks))
for i, t := range tracks {
ids[i] = t.ID
}
// Break the list in chunks, up to 50 items, to avoid hitting SQLITE_MAX_FUNCTION_ARG limit
chunks := slice.BreakUp(ids, 50)
// Query each chunk of media_file ids and store results in a map
mfRepo := NewMediaFileRepository(r.ctx, r.db)
trackMap := map[string]model.MediaFile{}
for i := range chunks {
idsFilter := Eq{"media_file.id": chunks[i]}
// Create an iterator to collect all track IDs
ids := slice.SeqFunc(tracks, func(t model.MediaFile) string { return t.ID })
// Break the list in chunks, up to 500 items, to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit
for chunk := range slice.CollectChunks(ids, 500) {
idsFilter := Eq{"media_file.id": chunk}
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter})
if err != nil {
u := loggedUser(r.ctx)
@@ -131,9 +128,12 @@ func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFil
}
// Create a new list of tracks with the same order as the original
newTracks := make(model.MediaFiles, len(tracks))
for i, t := range tracks {
newTracks[i] = trackMap[t.ID]
// Exclude tracks that are not in the DB anymore
newTracks := make(model.MediaFiles, 0, len(tracks))
for _, t := range tracks {
if track, ok := trackMap[t.ID]; ok {
newTracks = append(newTracks, track)
}
}
return newTracks
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/Masterminds/squirrel"
"github.com/google/uuid"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -15,11 +16,12 @@ import (
var _ = Describe("PlayQueueRepository", func() {
var repo model.PlayQueueRepository
var ctx context.Context
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlayQueueRepository(ctx, getDBXBuilder())
repo = NewPlayQueueRepository(ctx, NewDBXBuilder(db.Db()))
})
Describe("PlayQueues", func() {
@@ -50,6 +52,37 @@ var _ = Describe("PlayQueueRepository", func() {
AssertPlayQueue(another, actual)
Expect(countPlayQueues(repo, "userid")).To(Equal(1))
})
It("does not return tracks if they don't exist in the DB", func() {
// Add a new song to the DB
newSong := songRadioactivity
newSong.ID = "temp-track"
mfRepo := NewMediaFileRepository(ctx, NewDBXBuilder(db.Db()))
Expect(mfRepo.Put(&newSong)).To(Succeed())
// Create a playqueue with the new song
pq := aPlayQueue("userid", newSong.ID, 0, newSong, songAntenna)
Expect(repo.Store(pq)).To(Succeed())
// Retrieve the playqueue
actual, err := repo.Retrieve("userid")
Expect(err).ToNot(HaveOccurred())
// The playqueue should contain both tracks
AssertPlayQueue(pq, actual)
// Delete the new song
Expect(mfRepo.Delete("temp-track")).To(Succeed())
// Retrieve the playqueue
actual, err = repo.Retrieve("userid")
Expect(err).ToNot(HaveOccurred())
// The playqueue should not contain the deleted track
Expect(actual.Items).To(HaveLen(1))
Expect(actual.Items[0].ID).To(Equal(songAntenna.ID))
})
})
})

View File

@@ -3,6 +3,7 @@ package persistence
import (
"context"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
@@ -13,7 +14,7 @@ var _ = Describe("Property Repository", func() {
var pr model.PropertyRepository
BeforeEach(func() {
pr = NewPropertyRepository(log.NewContext(context.TODO()), getDBXBuilder())
pr = NewPropertyRepository(log.NewContext(context.TODO()), NewDBXBuilder(db.Db()))
})
It("saves and restore a new property", func() {

View File

@@ -15,17 +15,15 @@ import (
type radioRepository struct {
sqlRepository
sqlRestful
}
func NewRadioRepository(ctx context.Context, db dbx.Builder) model.RadioRepository {
r := &radioRepository{}
r.ctx = ctx
r.db = db
r.tableName = "radio"
r.filterMappings = map[string]filterFunc{
"name": containsFilter,
}
r.registerModel(&model.Radio{}, map[string]filterFunc{
"name": containsFilter("name"),
})
r.sortMappings = map[string]string{
"name": "(name collate nocase), name",
}
@@ -96,7 +94,7 @@ func (r *radioRepository) Put(radio *model.Radio) error {
}
func (r *radioRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(options...))
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *radioRepository) EntityName() string {
@@ -112,7 +110,7 @@ func (r *radioRepository) Read(id string) (interface{}, error) {
}
func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return r.GetAll(r.parseRestOptions(options...))
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
func (r *radioRepository) Save(entity interface{}) (string, error) {

View File

@@ -4,6 +4,7 @@ import (
"context"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -22,7 +23,7 @@ var _ = Describe("RadioRepository", func() {
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewRadioRepository(ctx, getDBXBuilder())
repo = NewRadioRepository(ctx, NewDBXBuilder(db.Db()))
_ = repo.Put(&radioWithHomePage)
})
@@ -119,7 +120,7 @@ var _ = Describe("RadioRepository", func() {
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false})
repo = NewRadioRepository(ctx, getDBXBuilder())
repo = NewRadioRepository(ctx, NewDBXBuilder(db.Db()))
})
Describe("Count", func() {

View File

@@ -17,14 +17,16 @@ import (
type shareRepository struct {
sqlRepository
sqlRestful
}
func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareRepository {
r := &shareRepository{}
r.ctx = ctx
r.db = db
r.tableName = "share"
r.registerModel(&model.Share{}, map[string]filterFunc{})
r.sortMappings = map[string]string{
"username": "username",
}
return r
}
@@ -166,7 +168,7 @@ func (r *shareRepository) CountAll(options ...model.QueryOptions) (int64, error)
}
func (r *shareRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(options...))
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *shareRepository) EntityName() string {
@@ -185,7 +187,7 @@ func (r *shareRepository) Read(id string) (interface{}, error) {
}
func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
sq := r.selectShare(r.parseRestOptions(options...))
sq := r.selectShare(r.parseRestOptions(r.ctx, options...))
res := model.Shares{}
err := r.queryAll(sq, &res)
return res, err

View File

@@ -6,7 +6,6 @@ import (
"time"
. "github.com/Masterminds/squirrel"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
@@ -52,7 +51,6 @@ func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...strin
c, err := r.executeSQL(upd)
if c == 0 || errors.Is(err, sql.ErrNoRows) {
for _, itemID := range itemIDs {
values["ann_id"] = uuid.NewString()
values["user_id"] = userId(r.ctx)
values["item_type"] = r.tableName
values["item_id"] = itemID
@@ -83,7 +81,6 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
if c == 0 || errors.Is(err, sql.ErrNoRows) {
values := map[string]interface{}{}
values["ann_id"] = uuid.NewString()
values["user_id"] = userId(r.ctx)
values["item_type"] = r.tableName
values["item_id"] = itemID

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"reflect"
"strings"
"time"
@@ -14,14 +15,29 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/hasher"
"github.com/pocketbase/dbx"
)
// sqlRepository is the base repository for all SQL repositories. It provides common functions to interact with the DB.
// When creating a new repository using this base, you must:
//
// - Embed this struct.
// - Set ctx and db fields. ctx should be the context passed to the constructor method, usually obtained from the request
// - Call registerModel with the model instance and any possible filters.
// - If the model has a different table name than the default (lowercase of the model name), it should be set manually
// using the tableName field.
// - Sort mappings should be set in the sortMappings field. If the sort field is not in the map, it will be used as is.
//
// All fields in filters and sortMappings must be in snake_case. Only sorts and filters based on real field names or
// defined in the mappings will be allowed.
type sqlRepository struct {
ctx context.Context
tableName string
db dbx.Builder
sortMappings map[string]string
ctx context.Context
tableName string
db dbx.Builder
sortMappings map[string]string
filterMappings map[string]filterFunc
isFieldWhiteListed fieldWhiteListedFunc
}
const invalidUserId = "-1"
@@ -42,6 +58,16 @@ func loggedUser(ctx context.Context) *model.User {
}
}
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
if r.tableName == "" {
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
r.tableName = toSnakeCase(r.tableName)
}
r.tableName = strings.ToLower(r.tableName)
r.isFieldWhiteListed = registerModelWhiteList(instance)
r.filterMappings = filters
}
func (r sqlRepository) getTableName() string {
return r.tableName
}
@@ -137,6 +163,24 @@ func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOpti
return sq
}
func (r sqlRepository) seedKey() string {
return r.tableName + userId(r.ctx)
}
func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) {
if len(options) == 0 || options[0].Sort != "random" {
return
}
options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', %s.id)", r.seedKey(), r.tableName)
if options[0].Seed != "" {
hasher.SetSeed(r.seedKey(), options[0].Seed)
return
}
if options[0].Offset == 0 {
hasher.Reseed(r.seedKey())
}
}
func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
query, args, err := r.toSQL(sq)
if err != nil {
@@ -201,7 +245,7 @@ func (r sqlRepository) queryAll(sq SelectBuilder, response interface{}, options
r.logSQL(query, args, nil, -1, start)
return model.ErrNotFound
}
r.logSQL(query, args, err, -1, start)
r.logSQL(query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start)
return err
}
@@ -217,7 +261,7 @@ func (r sqlRepository) queryAllSlice(sq SelectBuilder, response interface{}) err
r.logSQL(query, args, nil, -1, start)
return model.ErrNotFound
}
r.logSQL(query, args, err, -1, start)
r.logSQL(query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start)
return err
}
@@ -253,7 +297,10 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
}
func (r sqlRepository) put(id string, m interface{}, colsToUpdate ...string) (newId string, err error) {
values, _ := toSQLArgs(m)
values, err := toSQLArgs(m)
if err != nil {
return "", fmt.Errorf("error preparing values to write to DB: %w", err)
}
// If there's an ID, try to update first
if id != "" {
updateValues := map[string]interface{}{}

View File

@@ -1,18 +1,30 @@
package persistence
import (
"context"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/hasher"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("sqlRepository", func() {
r := sqlRepository{}
var r sqlRepository
BeforeEach(func() {
r.ctx = request.WithUser(context.Background(), model.User{ID: "user-id"})
r.tableName = "table"
})
Describe("applyOptions", func() {
var sq squirrel.SelectBuilder
BeforeEach(func() {
sq = squirrel.Select("*").From("test")
r.sortMappings = map[string]string{
"name": "title",
}
})
It("does not add any clauses when options is empty", func() {
sq = r.applyOptions(sq, model.QueryOptions{})
@@ -27,17 +39,11 @@ var _ = Describe("sqlRepository", func() {
Offset: 2,
})
sql, _, _ := sq.ToSql()
Expect(sql).To(Equal("SELECT * FROM test ORDER BY name desc LIMIT 1 OFFSET 2"))
Expect(sql).To(Equal("SELECT * FROM test ORDER BY title desc LIMIT 1 OFFSET 2"))
})
})
Describe("toSQL", func() {
var r sqlRepository
BeforeEach(func() {
r = sqlRepository{}
})
It("returns error for invalid SQL", func() {
sq := squirrel.Select("*").From("test").Where(1)
_, _, err := r.toSQL(sq)
@@ -70,40 +76,61 @@ var _ = Describe("sqlRepository", func() {
})
})
Describe("sortMapping", func() {
Describe("sanitizeSort", func() {
BeforeEach(func() {
r.registerModel(&struct {
Field string `structs:"field"`
}{}, nil)
r.sortMappings = map[string]string{
"sort1": "mappedSort1",
"sortTwo": "mappedSort2",
"sort_three": "mappedSort3",
"sort1": "mappedSort1",
}
})
It("returns the mapped value when sort key exists", func() {
Expect(r.sortMapping("sort1")).To(Equal("mappedSort1"))
})
When("sanitizing sort", func() {
It("returns empty if the sort key is not found in the model nor in the mappings", func() {
sort, _ := r.sanitizeSort("unknown", "")
Expect(sort).To(BeEmpty())
})
Context("when sort key does not exist", func() {
It("returns the original sort key, snake cased", func() {
Expect(r.sortMapping("NotFoundSort")).To(Equal("not_found_sort"))
It("returns the mapped value when sort key exists", func() {
sort, _ := r.sanitizeSort("sort1", "")
Expect(sort).To(Equal("mappedSort1"))
})
It("is case insensitive", func() {
sort, _ := r.sanitizeSort("Sort1", "")
Expect(sort).To(Equal("mappedSort1"))
})
It("returns the field if it is a valid field", func() {
sort, _ := r.sanitizeSort("field", "")
Expect(sort).To(Equal("field"))
})
It("is case insensitive for fields", func() {
sort, _ := r.sanitizeSort("FIELD", "")
Expect(sort).To(Equal("field"))
})
})
When("sanitizing order", func() {
It("returns 'asc' if order is empty", func() {
_, order := r.sanitizeSort("", "")
Expect(order).To(Equal(""))
})
Context("when sort key is camel cased", func() {
It("returns the mapped value when camel case sort key exists", func() {
Expect(r.sortMapping("sortTwo")).To(Equal("mappedSort2"))
It("returns 'asc' if order is 'asc'", func() {
_, order := r.sanitizeSort("", "ASC")
Expect(order).To(Equal("asc"))
})
It("returns the mapped value when passing a snake case key", func() {
Expect(r.sortMapping("sort_two")).To(Equal("mappedSort2"))
})
})
Context("when sort key is snake cased", func() {
It("returns the mapped value when snake case sort key exists", func() {
Expect(r.sortMapping("sort_three")).To(Equal("mappedSort3"))
It("returns 'desc' if order is 'desc'", func() {
_, order := r.sanitizeSort("", "desc")
Expect(order).To(Equal("desc"))
})
It("returns the mapped value when passing a camel case key", func() {
Expect(r.sortMapping("sortThree")).To(Equal("mappedSort3"))
It("returns 'asc' if order is unknown", func() {
_, order := r.sanitizeSort("", "something")
Expect(order).To(Equal("asc"))
})
})
})
@@ -152,4 +179,36 @@ var _ = Describe("sqlRepository", func() {
})
})
})
Describe("resetSeededRandom", func() {
var id string
BeforeEach(func() {
id = r.seedKey()
hasher.SetSeed(id, "")
})
It("does not reset seed if sort is not random", func() {
var options []model.QueryOptions
r.resetSeededRandom(options)
Expect(hasher.CurrentSeed(id)).To(BeEmpty())
})
It("resets seed if sort is random", func() {
options := []model.QueryOptions{{Sort: "random"}}
r.resetSeededRandom(options)
Expect(hasher.CurrentSeed(id)).NotTo(BeEmpty())
})
It("resets seed if sort is random and seed is provided", func() {
options := []model.QueryOptions{{Sort: "random", Seed: "seed"}}
r.resetSeededRandom(options)
Expect(hasher.CurrentSeed(id)).To(Equal("seed"))
})
It("keeps seed when paginating", func() {
options := []model.QueryOptions{{Sort: "random", Seed: "seed", Offset: 0}}
r.resetSeededRandom(options)
Expect(hasher.CurrentSeed(id)).To(Equal("seed"))
options = []model.QueryOptions{{Sort: "random", Offset: 1}}
r.resetSeededRandom(options)
Expect(hasher.CurrentSeed(id)).To(Equal("seed"))
})
})
})

View File

@@ -3,6 +3,7 @@ package persistence
import (
"context"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -16,7 +17,7 @@ var _ = Describe("sqlBookmarks", func() {
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid"})
mr = NewMediaFileRepository(ctx, getDBXBuilder())
mr = NewMediaFileRepository(ctx, NewDBXBuilder(db.Db()))
})
Describe("Bookmarks", func() {

View File

@@ -1,9 +1,10 @@
package persistence
import (
"slices"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
)
func (r sqlRepository) withGenres(sql SelectBuilder) SelectBuilder {
@@ -22,19 +23,17 @@ func (r *sqlRepository) updateGenres(id string, genres model.Genres) error {
if len(genres) == 0 {
return nil
}
var genreIds []string
for _, g := range genres {
genreIds = append(genreIds, g.ID)
}
err = slice.RangeByChunks(genreIds, 100, func(ids []string) error {
for chunk := range slices.Chunk(genres, 100) {
ins := Insert(tableName+"_genres").Columns("genre_id", tableName+"_id")
for _, gid := range ids {
ins = ins.Values(gid, id)
for _, genre := range chunk {
ins = ins.Values(genre.ID, id)
}
_, err = r.executeSQL(ins)
return err
})
return err
if _, err = r.executeSQL(ins); err != nil {
return err
}
}
return nil
}
type baseRepository interface {
@@ -71,24 +70,24 @@ func appendGenre[T modelWithGenres](item *T, genre model.Genre) {
func loadGenres[T modelWithGenres](r baseRepository, ids []string, items map[string]*T) error {
tableName := r.getTableName()
return slice.RangeByChunks(ids, 900, func(ids []string) error {
for chunk := range slices.Chunk(ids, 900) {
sql := Select("genre.*", tableName+"_id as item_id").From("genre").
Join(tableName+"_genres ig on genre.id = ig.genre_id").
OrderBy(tableName+"_id", "ig.rowid").Where(Eq{tableName + "_id": ids})
OrderBy(tableName+"_id", "ig.rowid").Where(Eq{tableName + "_id": chunk})
var genres []struct {
model.Genre
ItemID string
}
err := r.queryAll(sql, &genres)
if err != nil {
if err := r.queryAll(sql, &genres); err != nil {
return err
}
for _, g := range genres {
appendGenre(items[g.ItemID], g.Genre)
}
return nil
})
}
return nil
}
func loadAllGenres[T modelWithGenres](r baseRepository, items []T) error {

View File

@@ -1,74 +1,113 @@
package persistence
import (
"context"
"fmt"
"reflect"
"strings"
"sync"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/fatih/structs"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type filterFunc = func(field string, value interface{}) Sqlizer
type filterFunc = func(field string, value any) Sqlizer
type sqlRestful struct {
filterMappings map[string]filterFunc
}
func (r sqlRestful) parseRestFilters(options rest.QueryOptions) Sqlizer {
func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.QueryOptions) Sqlizer {
if len(options.Filters) == 0 {
return nil
}
filters := And{}
for f, v := range options.Filters {
// Ignore filters with empty values
if v == "" {
continue
}
// Look for a custom filter function
f = strings.ToLower(f)
if ff, ok := r.filterMappings[f]; ok {
filters = append(filters, ff(f, v))
} else if strings.HasSuffix(strings.ToLower(f), "id") {
filters = append(filters, eqFilter(f, v))
} else {
filters = append(filters, startsWithFilter(f, v))
continue
}
// Ignore invalid filters (not based on a field or filter function)
if r.isFieldWhiteListed != nil && !r.isFieldWhiteListed(f) {
log.Warn(ctx, "Ignoring filter not whitelisted", "filter", f)
continue
}
// For fields ending in "id", use an exact match
if strings.HasSuffix(f, "id") {
filters = append(filters, eqFilter(f, v))
continue
}
// Default to a "starts with" filter
filters = append(filters, startsWithFilter(f, v))
}
return filters
}
func (r sqlRestful) parseRestOptions(options ...rest.QueryOptions) model.QueryOptions {
func (r *sqlRepository) parseRestOptions(ctx context.Context, options ...rest.QueryOptions) model.QueryOptions {
qo := model.QueryOptions{}
if len(options) > 0 {
qo.Sort = options[0].Sort
qo.Order = strings.ToLower(options[0].Order)
qo.Sort, qo.Order = r.sanitizeSort(options[0].Sort, options[0].Order)
qo.Max = options[0].Max
qo.Offset = options[0].Offset
qo.Filters = r.parseRestFilters(options[0])
if seed, ok := options[0].Filters["seed"].(string); ok {
qo.Seed = seed
delete(options[0].Filters, "seed")
}
qo.Filters = r.parseRestFilters(ctx, options[0])
}
return qo
}
func eqFilter(field string, value interface{}) Sqlizer {
func (r sqlRepository) sanitizeSort(sort, order string) (string, string) {
if sort != "" {
sort = toSnakeCase(sort)
if mapped, ok := r.sortMappings[sort]; ok {
sort = mapped
} else {
if !r.isFieldWhiteListed(sort) {
log.Warn(r.ctx, "Ignoring sort not whitelisted", "sort", sort)
sort = ""
}
}
}
if order != "" {
order = strings.ToLower(order)
if order != "desc" {
order = "asc"
}
}
return sort, order
}
func eqFilter(field string, value any) Sqlizer {
return Eq{field: value}
}
func startsWithFilter(field string, value interface{}) Sqlizer {
func startsWithFilter(field string, value any) Sqlizer {
return Like{field: fmt.Sprintf("%s%%", value)}
}
func containsFilter(field string, value interface{}) Sqlizer {
return Like{field: fmt.Sprintf("%%%s%%", value)}
func containsFilter(field string) func(string, any) Sqlizer {
return func(_ string, value any) Sqlizer {
return Like{field: fmt.Sprintf("%%%s%%", value)}
}
}
func booleanFilter(field string, value interface{}) Sqlizer {
func booleanFilter(field string, value any) Sqlizer {
v := strings.ToLower(value.(string))
return Eq{field: strings.ToLower(v) == "true"}
}
func fullTextFilter(field string, value interface{}) Sqlizer {
func fullTextFilter(_ string, value any) Sqlizer {
return fullTextExpr(value.(string))
}
func substringFilter(field string, value interface{}) Sqlizer {
func substringFilter(field string, value any) Sqlizer {
parts := strings.Split(value.(string), " ")
filters := And{}
for _, part := range parts {
@@ -77,8 +116,57 @@ func substringFilter(field string, value interface{}) Sqlizer {
return filters
}
func idFilter(tableName string) func(string, interface{}) Sqlizer {
return func(field string, value interface{}) Sqlizer {
func idFilter(tableName string) func(string, any) Sqlizer {
return func(field string, value any) Sqlizer {
return Eq{tableName + ".id": value}
}
}
func invalidFilter(ctx context.Context) func(string, any) Sqlizer {
return func(field string, value any) Sqlizer {
log.Warn(ctx, "Invalid filter", "fieldName", field, "value", value)
return Eq{"1": "0"}
}
}
var (
whiteList = map[string]map[string]struct{}{}
mutex sync.RWMutex
)
func registerModelWhiteList(instance any) fieldWhiteListedFunc {
name := reflect.TypeOf(instance).String()
registerFieldWhiteList(name, instance)
return getFieldWhiteListedFunc(name)
}
func registerFieldWhiteList(name string, instance any) {
mutex.Lock()
defer mutex.Unlock()
if whiteList[name] != nil {
return
}
m := structs.Map(instance)
whiteList[name] = map[string]struct{}{}
for k := range m {
whiteList[name][toSnakeCase(k)] = struct{}{}
}
ma := structs.Map(model.Annotations{})
for k := range ma {
whiteList[name][toSnakeCase(k)] = struct{}{}
}
}
type fieldWhiteListedFunc func(field string) bool
func getFieldWhiteListedFunc(tableName string) fieldWhiteListedFunc {
return func(field string) bool {
mutex.RLock()
defer mutex.RUnlock()
if _, ok := whiteList[tableName]; !ok {
return false
}
_, ok := whiteList[tableName][field]
return ok
}
}

View File

@@ -1,6 +1,8 @@
package persistence
import (
"context"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
. "github.com/onsi/ginkgo/v2"
@@ -9,31 +11,31 @@ import (
var _ = Describe("sqlRestful", func() {
Describe("parseRestFilters", func() {
var r sqlRestful
var r sqlRepository
var options rest.QueryOptions
BeforeEach(func() {
r = sqlRestful{}
r = sqlRepository{}
})
It("returns nil if filters is empty", func() {
options.Filters = nil
Expect(r.parseRestFilters(options)).To(BeNil())
Expect(r.parseRestFilters(context.Background(), options)).To(BeNil())
})
It("returns a '=' condition for 'id' filter", func() {
options.Filters = map[string]interface{}{"id": "123"}
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
})
It("returns a 'in' condition for multiples 'id' filters", func() {
options.Filters = map[string]interface{}{"id": []string{"123", "456"}}
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
})
It("returns a 'like' condition for other filters", func() {
options.Filters = map[string]interface{}{"name": "joe"}
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
})
It("uses the custom filter", func() {
@@ -43,7 +45,7 @@ var _ = Describe("sqlRestful", func() {
},
}
options.Filters = map[string]interface{}{"test": 100}
Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
})
})
})

View File

@@ -6,11 +6,11 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/str"
)
func getFullText(text ...string) string {
fullText := utils.SanitizeStrings(text...)
fullText := str.SanitizeStrings(text...)
return " " + fullText
}
@@ -39,7 +39,7 @@ func (r sqlRepository) doSearch(q string, offset, size int, results interface{},
}
func fullTextExpr(value string) Sqlizer {
q := utils.SanitizeStrings(value)
q := str.SanitizeStrings(value)
if q == "" {
return nil
}

View File

@@ -12,14 +12,13 @@ import (
type transcodingRepository struct {
sqlRepository
sqlRestful
}
func NewTranscodingRepository(ctx context.Context, db dbx.Builder) model.TranscodingRepository {
r := &transcodingRepository{}
r.ctx = ctx
r.db = db
r.tableName = "transcoding"
r.registerModel(&model.Transcoding{}, nil)
return r
}
@@ -47,7 +46,7 @@ func (r *transcodingRepository) Put(t *model.Transcoding) error {
}
func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.count(Select(), r.parseRestOptions(options...))
return r.count(Select(), r.parseRestOptions(r.ctx, options...))
}
func (r *transcodingRepository) Read(id string) (interface{}, error) {
@@ -55,7 +54,7 @@ func (r *transcodingRepository) Read(id string) (interface{}, error) {
}
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
sel := r.newSelect(r.parseRestOptions(options...)).Columns("*")
sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
res := model.Transcodings{}
err := r.queryAll(sel, &res)
return res, err

View File

@@ -22,7 +22,6 @@ import (
type userRepository struct {
sqlRepository
sqlRestful
}
var (
@@ -34,7 +33,9 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
r := &userRepository{}
r.ctx = ctx
r.db = db
r.tableName = "user"
r.registerModel(&model.User{}, map[string]filterFunc{
"password": invalidFilter(ctx),
})
once.Do(func() {
_ = r.initPasswordEncryptionKey()
})
@@ -91,7 +92,7 @@ func (r *userRepository) FindFirstAdmin() (*model.User, error) {
}
func (r *userRepository) FindByUsername(username string) (*model.User, error) {
sel := r.newSelect().Columns("*").Where(Like{"user_name": username})
sel := r.newSelect().Columns("*").Where(Expr("user_name = ? COLLATE NOCASE", username))
var usr model.User
err := r.queryOne(sel, &usr)
return &usr, err
@@ -123,10 +124,10 @@ func (r *userRepository) Count(options ...rest.QueryOptions) (int64, error) {
if !usr.IsAdmin {
return 0, rest.ErrPermissionDenied
}
return r.CountAll(r.parseRestOptions(options...))
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *userRepository) Read(id string) (interface{}, error) {
func (r *userRepository) Read(id string) (any, error) {
usr := loggedUser(r.ctx)
if !usr.IsAdmin && usr.ID != id {
return nil, rest.ErrPermissionDenied
@@ -138,23 +139,23 @@ func (r *userRepository) Read(id string) (interface{}, error) {
return usr, err
}
func (r *userRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *userRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
usr := loggedUser(r.ctx)
if !usr.IsAdmin {
return nil, rest.ErrPermissionDenied
}
return r.GetAll(r.parseRestOptions(options...))
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
func (r *userRepository) EntityName() string {
return "user"
}
func (r *userRepository) NewInstance() interface{} {
func (r *userRepository) NewInstance() any {
return &model.User{}
}
func (r *userRepository) Save(entity interface{}) (string, error) {
func (r *userRepository) Save(entity any) (string, error) {
usr := loggedUser(r.ctx)
if !usr.IsAdmin {
return "", rest.ErrPermissionDenied
@@ -170,7 +171,7 @@ func (r *userRepository) Save(entity interface{}) (string, error) {
return u.ID, err
}
func (r *userRepository) Update(id string, entity interface{}, cols ...string) error {
func (r *userRepository) Update(id string, entity any, _ ...string) error {
u := entity.(*model.User)
u.ID = id
usr := loggedUser(r.ctx)

View File

@@ -7,6 +7,7 @@ import (
"github.com/deluan/rest"
"github.com/google/uuid"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
@@ -18,7 +19,7 @@ var _ = Describe("UserRepository", func() {
var repo model.UserRepository
BeforeEach(func() {
repo = NewUserRepository(log.NewContext(context.TODO()), getDBXBuilder())
repo = NewUserRepository(log.NewContext(context.TODO()), NewDBXBuilder(db.Db()))
})
Describe("Put/Get/FindByUsername", func() {

View File

@@ -11,19 +11,12 @@ import (
"github.com/navidrome/navidrome/utils/merge"
)
var (
//go:embed *
embedFS embed.FS
fsOnce sync.Once
fsys fs.FS
)
//go:embed *
var embedFS embed.FS
func FS() fs.FS {
fsOnce.Do(func() {
fsys = merge.FS{
Base: embedFS,
Overlay: os.DirFS(path.Join(conf.Server.DataFolder, "resources")),
}
})
return fsys
}
var FS = sync.OnceValue(func() fs.FS {
return merge.FS{
Base: embedFS,
Overlay: os.DirFS(path.Join(conf.Server.DataFolder, "resources")),
}
})

View File

@@ -440,7 +440,7 @@
"totalScanned": "Insgesamt gescannte Ordner",
"quickScan": "Schneller Scan",
"fullScan": "Kompletter Scan",
"serverUptime": "Server Uptime",
"serverUptime": "Server-Betriebszeit",
"serverDown": "OFFLINE"
},
"help": {

466
resources/i18n/eu.json Normal file
View File

@@ -0,0 +1,466 @@
{
"languageName": "Euskara",
"resources": {
"song": {
"name": "Abestia |||| Abestiak",
"fields": {
"albumArtist": "Albumaren artista",
"duration": "Iraupena",
"trackNumber": "#",
"playCount": "Erreprodukzioak",
"title": "Titulua",
"artist": "Artista",
"album": "Albuma",
"path": "Fitxategiaren bidea",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
"size": "Fitxategiaren tamaina",
"updatedAt": "Eguneratze-data:",
"bitRate": "Bit tasa",
"channels": "Kanalak",
"discSubtitle": "Diskoaren azpititulua",
"starred": "Gogokoa",
"comment": "Iruzkina",
"rating": "Balorazioa",
"quality": "Kalitatea",
"bpm": "BPM",
"playDate": "Azkenekoz erreproduzitua:",
"createdAt": "Gehitu zen data:"
},
"actions": {
"addToQueue": "Erreproduzitu ondoren",
"playNow": "Erreproduzitu orain",
"addToPlaylist": "Gehitu erreprodukzio-zerrendara",
"shuffleAll": "Erreprodukzio aleatorioa",
"download": "Deskargatu",
"playNext": "Hurrengoa",
"info": "Lortu informazioa"
}
},
"album": {
"name": "Albuma |||| Albumak",
"fields": {
"albumArtist": "Albumaren artista",
"artist": "Artista",
"duration": "Iraupena",
"songCount": "abesti",
"playCount": "Erreprodukzioak",
"size": "Fitxategiaren tamaina",
"name": "Izena",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
"originalDate": "Jatorrizkoa",
"releaseDate": "Argitaratze-data:",
"releases": "Argitaratzea |||| Argitaratzeak",
"released": "Argitaratua",
"updatedAt": "Aktualizatze-data:",
"comment": "Iruzkina",
"rating": "Balorazioa",
"createdAt": "Gehitu zen data:"
},
"actions": {
"playAll": "Erreproduzitu",
"playNext": "Erreproduzitu segidan",
"addToQueue": "Erreproduzitu amaieran",
"share": "Partekatu",
"shuffle": "Aletorioa",
"addToPlaylist": "Gehitu zerrendara",
"download": "Deskargatu",
"info": "Lortu informazioa"
},
"lists": {
"all": "Guztiak",
"random": "Aleatorioa",
"recentlyAdded": "Berriki gehitutakoak",
"recentlyPlayed": "Berriki entzundakoak",
"mostPlayed": "Gehien entzundakoak",
"starred": "Gogokoak",
"topRated": "Hobekien baloratutakoak"
}
},
"artist": {
"name": "Artista |||| Artistak",
"fields": {
"name": "Izena",
"albumCount": "Album kopurua",
"songCount": "Abesti kopurua",
"size": "Tamaina",
"playCount": "Erreprodukzio kopurua",
"rating": "Balorazioa",
"genre": "Generoa"
}
},
"user": {
"name": "Erabiltzailea |||| Erabiltzaileak",
"fields": {
"userName": "Erabiltzailearen izena",
"isAdmin": "Administratzailea da",
"lastLoginAt": "Azken sartze-data:",
"updatedAt": "Eguneratze-data:",
"name": "Izena",
"password": "Pasahitza",
"createdAt": "Sortze-data:",
"changePassword": "Pasahitza aldatu?",
"currentPassword": "Uneko pasahitza",
"newPassword": "Pasahitz berria",
"token": "Tokena"
},
"helperTexts": {
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira"
},
"notifications": {
"created": "Erabiltzailea sortu da",
"updated": "Erabiltzailea eguneratu da",
"deleted": "Erabiltzailea ezabatu da"
},
"message": {
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
"clickHereForToken": "Egin klik hemen tokena lortzeko"
}
},
"player": {
"name": "Erreproduktorea |||| Erreproduktoreak",
"fields": {
"name": "Izena",
"transcodingId": "Transkodifikazioa",
"maxBitRate": "Gehienezko bit tasa",
"client": "Bezeroa",
"userName": "Erabiltzailea",
"lastSeen": "Azken konexioa",
"reportRealPath": "Erakutsi bide absolutua",
"scrobbleEnabled": "Bidali erabiltzailearen ohiturak hirugarrenen zerbitzuetara"
}
},
"transcoding": {
"name": "Transkodeketa |||| Transkodeketak",
"fields": {
"name": "Izena",
"targetFormat": "Helburuko formatua",
"defaultBitRate": "Bit tasa, defektuz",
"command": "Komandoa"
}
},
"playlist": {
"name": "Zerrenda |||| Zerrendak",
"fields": {
"name": "Izena",
"duration": "Iraupena",
"ownerName": "Jabea",
"public": "Publikoa",
"updatedAt": "Eguneratze-data:",
"createdAt": "Sortze-data:",
"songCount": "abesti",
"comment": "Iruzkina",
"sync": "Automatikoki inportatuak",
"path": "Inportatze-data:"
},
"actions": {
"selectPlaylist": "Hautatu zerrenda:",
"addNewPlaylist": "Sortu \"%{name}\"",
"export": "Esportatu",
"makePublic": "Egin publikoa",
"makePrivate": "Egin pribatua"
},
"message": {
"duplicate_song": "Hautatutako abesti batzuk lehendik ere daude zerrendan",
"song_exist": "Bikoiztutakoak gehitzen ari dira erreprodukzio-zerrendara. Ziur gehitu nahi dituzula?"
}
},
"radio": {
"name": "Irratia |||| Irratiak",
"fields": {
"name": "Izena",
"streamUrl": "Jarioaren URLa",
"homePageUrl": "Web orriaren URLa",
"updatedAt": "Eguneratze-data:",
"createdAt": "Sortze-data:"
},
"actions": {
"playNow": "Erreproduzitu orain"
}
},
"share": {
"name": "Partekatu",
"fields": {
"username": "Partekatzailea:",
"url": "URLa",
"description": "Deskribapena",
"downloadable": "Deskargatzea ahalbidetu?",
"contents": "Edukia",
"expiresAt": "Iraungitze-data:",
"lastVisitedAt": "Azkenekoz bisitatu zen:",
"visitCount": "Bisita kopurua",
"format": "Formatua",
"maxBitRate": "Gehienezko bit tasa",
"updatedAt": "Eguneratze-data:",
"createdAt": "Sortze-data:"
},
"notifications": {
},
"actions": {
}
}
},
"ra": {
"auth": {
"welcome1": "Eskerrik asko Navidrome instalatzeagatik!",
"welcome2": "Lehenik eta behin, sortu administratzaile kontua",
"confirmPassword": "Baieztatu pasahitza",
"buttonCreateAdmin": "Sortu administratzailea",
"auth_check_error": "Hasi saioa aurrera egiteko",
"user_menu": "Profila",
"username": "Erabiltzailea",
"password": "Pasahitza",
"sign_in": "Sartu",
"sign_in_error": "Autentifikazioak huts egin du, saiatu berriro",
"logout": "Itxi saioa"
},
"validation": {
"invalidChars": "Erabili hizkiak eta zenbakiak bakarrik",
"passwordDoesNotMatch": "Pasahitzak ez datoz bat",
"required": "Beharrezkoa",
"minLength": "Gutxienez %{min} karaktere izan behar ditu",
"maxLength": "Gehienez %{max} karaktere izan ditzake",
"minValue": "Gutxienez %{min} izan behar da",
"maxValue": "Gehienez %{max} izan daiteke",
"number": "Zenbakia izan behar da",
"email": "Baliozko ePosta helbidea izan behar da",
"oneOf": "Hauetako bat izan behar da: %{options}",
"regex": "Formatu zehatzarekin bat etorri behar da (regexp): %{pattern}",
"unique": "Bakarra izan behar da",
"url": "Baliozko URLa izan behar da"
},
"action": {
"add_filter": "Gehitu iragazkia",
"add": "Gehitu",
"back": "Itzuli",
"bulk_actions": "elementu 1 hautatuta |||| %{smart_count} elementu hautatuta",
"cancel": "Utzi",
"clear_input_value": "Garbitu balioa",
"clone": "Bikoiztu",
"confirm": "Baieztatu",
"create": "Sortu",
"delete": "Ezabatu",
"edit": "Editatu",
"export": "Esportatu",
"list": "Zerrenda",
"refresh": "Freskatu",
"remove_filter": "Ezabatu iragazkia",
"remove": "Ezabatu",
"save": "Gorde",
"search": "Bilatu",
"show": "Erakutsi",
"sort": "Ordenatu",
"undo": "Desegin",
"expand": "Hedatu",
"close": "Itxi",
"open_menu": "Ireki menua",
"close_menu": "Itxi menua",
"unselect": "Utzi hautatzeari",
"skip": "Utzi alde batera",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Partekatu",
"download": "Deskargatu"
},
"boolean": {
"true": "Bai",
"false": "Ez"
},
"page": {
"create": "Sortu %{name}",
"dashboard": "Mahaigaina",
"edit": "%{name} #%{id}",
"error": "Zerbaitek huts egin du",
"list": "%{name}",
"loading": "Kargatzen",
"not_found": "Ez da aurkitu",
"show": "%{name} #%{id}",
"empty": "Oraindik ez dago %{name}(r)ik.",
"invite": "Sortu nahi al duzu?"
},
"input": {
"file": {
"upload_several": "Jaregin edo hautatu igo nahi dituzun fitxategiak.",
"upload_single": "AJaregin edo hautatu igo nahi duzun fitxategia."
},
"image": {
"upload_several": "Jaregin edo hautatu igo nahi dituzun irudiak.",
"upload_single": "Jaregin edo hautatu igo nahi duzun irudia."
},
"references": {
"all_missing": "Ezin dira erreferentziazko datuak aurkitu.",
"many_missing": "Erreferentzietako bat gutxieenez ez dago eskuragai.",
"single_missing": "Ez dirudi erreferentzia eskuragai dagoenik."
},
"password": {
"toggle_visible": "Ezkutatu pasahitza",
"toggle_hidden": "Erakutsi pasahitza"
}
},
"message": {
"about": "Honi buruz",
"are_you_sure": "Ziur zaude?",
"bulk_delete_content": "Ziur %{name} ezabatu nahi duzula? |||| Ziur %{smart_count} hauek ezabatu nahi dituzula?",
"bulk_delete_title": "Ezabatu %{name} |||| Ezabatu %{smart_count} %{name}",
"delete_content": "Ziur elementu hau ezabatu nahi duzula?",
"delete_title": "Ezabatu %{name} #%{id}",
"details": "Xehetasunak",
"error": "Bezeroan errorea gertatu da eta eskaera ezin izan da gauzatu",
"invalid_form": "Formularioa ez da baliozkoa. Egiaztatu errorerik ez dagoela",
"loading": "Orria kargatzen ari da, itxaron",
"no": "Ez",
"not_found": "URLa ez da zuzena edo jarraitutako esteka akastuna da.",
"yes": "Bai",
"unsaved_changes": "Ez dira aldaketa batzuk gorde. Ziur muzin egin nahi diezula?"
},
"navigation": {
"no_results": "Ez da emaitzarik aurkitu",
"no_more_results": "%{page} orrialde-zenbakia mugetatik kanpo dago. Saiatu aurreko orrialdearekin.",
"page_out_of_boundaries": "%{page} orrialde-zenbakia mugetatik kanpo dago",
"page_out_from_end": "Ezin zara azken orrialdea baino haratago joan",
"page_out_from_begin": "Ezin zara lehenengo orrialdea baino aurrerago joan",
"page_range_info": "%{offsetBegin}-%{offsetEnd}, %{total} guztira",
"page_rows_per_page": "Errenkadak orrialdeko:",
"next": "Hurrengoa",
"prev": "Aurrekoa",
"skip_nav": "Joan edukira"
},
"notification": {
"updated": "Elementu bat eguneratu da |||| %{smart_count} elementu eguneratu dira",
"created": "Elementua sortu da",
"deleted": "Elementu bat ezabatu da |||| %{smart_count} elementu ezabatu dira.",
"bad_item": "Elementu okerra",
"item_doesnt_exist": "Elementua ez dago",
"http_error": "Errorea zerbitzariarekin komunikatzerakoan",
"data_provider_error": "Errorea datuen hornitzailean. Berrikusi kontsola xehetasun gehiagorako.",
"i18n_error": "Ezin izan dira zehaztutako hizkuntzaren itzulpenak kargatu",
"canceled": "Ekintza bertan behera utzi da",
"logged_out": "Saioa amaitu da, konektatu berriro.",
"new_version": "Bertsio berria eskuragai! Freskatu leihoa."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Erakusteko zutabeak",
"layout": "Antolaketa",
"grid": "Sareta",
"table": "Taula"
}
},
"message": {
"note": "OHARRA",
"transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.",
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
"songsAddedToPlaylist": "Abesti bat zerrendara gehitu da |||| %{smart_count} abesti zerrendara gehitu dira",
"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?",
"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",
"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}",
"shareSuccess": "URLa arbelera kopiatu da: %{url}",
"shareFailure": "Errorea %{url} URLa arbelera kopiatzean",
"downloadDialogTitle": "Deskargatu '%{name}' %{resource}, (%{size})",
"shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla"
},
"menu": {
"library": "Liburutegia",
"settings": "Ezarpenak",
"version": "Bertsioa",
"theme": "Itxura",
"personal": {
"name": "Pertsonala",
"options": {
"theme": "Itxura",
"language": "Hizkuntza",
"defaultView": "Bista, defektuz",
"desktop_notifications": "Mahaigaineko jakinarazpenak",
"lastfmScrobbling": "Bidali Last.fm-ra erabiltzailearen ohiturak",
"listenBrainzScrobbling": "Bidali ListenBrainz-era erabiltzailearen ohiturak",
"replaygain": "ReplayGain modua",
"preAmp": "ReplayGain PreAmp (dB)",
"gain": {
"none": "Bat ere ez",
"album": "Albuma",
"track": "Pista"
}
}
},
"albumList": "Albumak",
"playlists": "Zerrendak",
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
"about": "Honi buruz"
},
"player": {
"playListsText": "Erreprodukzio-zerrenda",
"openText": "Ireki",
"closeText": "Itxi",
"notContentText": "Ez dago musikarik",
"clickToPlayText": "Egin klik erreproduzitzeko",
"clickToPauseText": "Egin klik eteteko",
"nextTrackText": "Hurrengo pista",
"previousTrackText": "Aurreko pista",
"reloadText": "Freskatu",
"volumeText": "Bolumena",
"toggleLyricText": "Erakutsi letrak",
"toggleMiniModeText": "Ikonotu",
"destroyText": "Suntsitu",
"downloadText": "Deskargatu",
"removeAudioListsText": "Ezabatu audio-zerrendak",
"clickToDeleteText": "Egin klik %{name} ezabatzeko",
"emptyLyricText": "Ez dago letrarik",
"playModeText": {
"order": "Ordenean",
"orderLoop": "Errepikatu",
"singleLoop": "Errepikatu bakarra",
"shufflePlay": "Aleatorioa"
}
},
"about": {
"links": {
"homepage": "Hasierako orria",
"source": "Iturburu kodea",
"featureRequests": "Eskatu ezaugarria"
}
},
"activity": {
"title": "Ekintzak",
"totalScanned": "Arakatutako karpeta guztiak",
"quickScan": "Arakatze azkarra",
"fullScan": "Arakatze sakona",
"serverUptime": "Zerbitzariak piztuta daraman denbora",
"serverDown": "LINEAZ KANPO"
},
"help": {
"title": "Navidromeren laster-teklak",
"hotkeys": {
"show_help": "Erakutsi laguntza",
"toggle_menu": "Alboko barra bai / ez",
"toggle_play": "Erreproduzitu / Eten",
"prev_song": "Aurreko abestia",
"next_song": "Hurrengo abestia",
"vol_up": "Igo bolumena",
"vol_down": "Jaitsi bolumena",
"toggle_love": "Abestia gogoko bai / ez",
"current_song": "Uneko abestia"
}
}
}

460
resources/i18n/hu.json Normal file
View File

@@ -0,0 +1,460 @@
{
"languageName": "Magyar",
"resources": {
"song": {
"name": "Szám |||| Számok",
"fields": {
"albumArtist": "Album előadó",
"duration": "Hossz",
"trackNumber": "#",
"playCount": "Lejátszások",
"title": "Cím",
"artist": "Előadó",
"album": "Album",
"path": "Elérési út",
"genre": "Műfaj",
"compilation": "Válogatásalbum",
"year": "Év",
"size": "Fájlméret",
"updatedAt": "Legutóbb frissítve",
"bitRate": "Bitráta",
"discSubtitle": "Lemezfelirat",
"starred": "Kedvenc",
"comment": "Megjegyzés",
"rating": "Értékelés",
"quality": "Minőség",
"bpm": "BPM",
"playDate": "Utoljára lejátszva",
"channels": "Csatornák",
"createdAt": "Hozzáadva"
},
"actions": {
"addToQueue": "Lejátszás útolsóként",
"playNow": "Lejátszás",
"addToPlaylist": "Lejátszási listához adás",
"shuffleAll": "Keverés",
"download": "Letöltés",
"playNext": "Lejátszás következőként",
"info": "Részletek"
}
},
"album": {
"name": "Album |||| Albumok",
"fields": {
"albumArtist": "Album előadó",
"artist": "Előadó",
"duration": "Hossz",
"songCount": "Számok",
"playCount": "Lejátszások",
"name": "Név",
"genre": "Stílus",
"compilation": "Válogatásalbum",
"year": "Év",
"updatedAt": "Legutóbb frissítve",
"comment": "Megjegyzés",
"rating": "Értékelés",
"createdAt": "Létrehozva",
"size": "Méret",
"originalDate": "Eredeti",
"releaseDate": "Kiadva",
"releases": "Kiadó |||| Kiadók",
"released": "Kiadta"
},
"actions": {
"playAll": "Lejátszás",
"playNext": "Lejátszás következőként",
"addToQueue": "Lejátszás útolsóként",
"shuffle": "Keverés",
"addToPlaylist": "Lejátszási listához adás",
"download": "Letöltés",
"info": "Részletek",
"share": "Megosztás"
},
"lists": {
"all": "Mind",
"random": "Véletlenszerű",
"recentlyAdded": "Nemrég hozzáadott",
"recentlyPlayed": "Nemrég lejátszott",
"mostPlayed": "Legtöbbször lejátszott",
"starred": "Kedvencek",
"topRated": "Legjobbra értékelt"
}
},
"artist": {
"name": "Előadó |||| Előadók",
"fields": {
"name": "Név",
"albumCount": "Albumok száma",
"songCount": "Számok száma",
"playCount": "Lejátszások",
"rating": "Értékelés",
"genre": "Stílus",
"size": "Méret"
}
},
"user": {
"name": "Felhasználó |||| Felhasználók",
"fields": {
"userName": "Felhasználónév",
"isAdmin": "Admin",
"lastLoginAt": "Utolsó belépés",
"updatedAt": "Legutóbb frissítve",
"name": "Név",
"password": "Jelszó",
"createdAt": "Létrehozva",
"changePassword": "Jelszó módosítása?",
"currentPassword": "Jelenlegi jelszó",
"newPassword": "Új jelszó",
"token": "Token"
},
"helperTexts": {
"name": "A névváltoztatások csak a következő bejelentkezéskor jelennek meg"
},
"notifications": {
"created": "Felhasználó létrehozva",
"updated": "Felhasználó frissítve",
"deleted": "Felhasználó törölve"
},
"message": {
"listenBrainzToken": "Add meg a ListenBrainz felhasználó tokened.",
"clickHereForToken": "Kattints ide, hogy megszerezd a tokened"
}
},
"player": {
"name": "Lejátszó |||| Lejátszók",
"fields": {
"name": "Név",
"transcodingId": "Átkódolás",
"maxBitRate": "Max. bitráta",
"client": "Kliens",
"userName": "Felhasználó név",
"lastSeen": "Utoljára bejelentkezett",
"reportRealPath": "Valódi fájlútvonal küldése",
"scrobbleEnabled": "Statisztika küldése külső szolgáltatásoknak"
}
},
"transcoding": {
"name": "Átkódolás |||| Átkódolások",
"fields": {
"name": "Név",
"targetFormat": "Cél formátum",
"defaultBitRate": "Alapértelmezett bitráta",
"command": "Parancs"
}
},
"playlist": {
"name": "Lejátszási lista |||| Lejátszási listák",
"fields": {
"name": "Név",
"duration": "Hossz",
"ownerName": "Tulajdonos",
"public": "Publikus",
"updatedAt": "Frissítve",
"createdAt": "Létrehozva",
"songCount": "Számok",
"comment": "Megjegyzés",
"sync": "Auto-importálás",
"path": "Importálás"
},
"actions": {
"selectPlaylist": "Válassz egy lejátszási listát:",
"addNewPlaylist": "\"%{name}\" létrehozása",
"export": "Exportálás",
"makePublic": "Publikussá tétel",
"makePrivate": "Priváttá tétel"
},
"message": {
"duplicate_song": "Duplikált számok hozzáadása",
"song_exist": "Egyes számok már hozzá vannak adva a listához. Még egyszer hozzá akarod adni?"
}
},
"radio": {
"name": "Radió |||| Radiók",
"fields": {
"name": "Név",
"streamUrl": "Stream URL",
"homePageUrl": "Honlap URL",
"updatedAt": "Frissítve",
"createdAt": "Létrehozva"
},
"actions": {
"playNow": "Lejátszás"
}
},
"share": {
"name": "Megosztás |||| Megosztások",
"fields": {
"username": "Megosztotta",
"url": "URL",
"description": "Leírás",
"contents": "Tartalom",
"expiresAt": "Lejárat",
"lastVisitedAt": "Utoljára látogatva",
"visitCount": "Látogatók",
"format": "Formátum",
"maxBitRate": "Max. bitráta",
"updatedAt": "Frissítve",
"createdAt": "Létrehozva",
"downloadable": "Engedélyezed a letöltéseket?"
}
}
},
"ra": {
"auth": {
"welcome1": "Köszönjük, hogy a Navidrome-ot telepítetted!",
"welcome2": "A kezdéshez hozz létre egy admin felhasználót!",
"confirmPassword": "Jelszó megerősítése",
"buttonCreateAdmin": "Admin hozzáadása",
"auth_check_error": "Jelentkezz be a folytatáshoz!",
"user_menu": "Profil",
"username": "Felhasználó név",
"password": "Jelszó",
"sign_in": "Bejelentkezés",
"sign_in_error": "A hitelesítés sikertelen. Kérjük, próbáld újra!",
"logout": "Kijelentkezés"
},
"validation": {
"invalidChars": "Kérlek, csak betűket és számokat használj!",
"passwordDoesNotMatch": "A jelszó nem egyezik.",
"required": "Szükséges",
"minLength": "Legalább %{min} karakternek kell lennie",
"maxLength": "Legfeljebb %{max} karakternek kell lennie",
"minValue": "Legalább %{min}",
"maxValue": "Legfeljebb %{max} vagy kevesebb",
"number": "Számnak kell lennie",
"email": "Érvényes email címnek kell lennie",
"oneOf": "Az egyiknek kell lennie: %{options}",
"regex": "Meg kell felelnie egy adott formátumnak (regexp): %{pattern}",
"unique": "Egyedinek kell lennie",
"url": "Érvényes URL-nek kell lennie"
},
"action": {
"add_filter": "Szűrő hozzáadása",
"add": "Hozzáadás",
"back": "Vissza",
"bulk_actions": "1 kiválasztott elem |||| %{smart_count} kiválasztott elem",
"cancel": "Mégse",
"clear_input_value": "Üres érték",
"clone": "Klónozás",
"confirm": "Megerősítés",
"create": "Létrehozás",
"delete": "Törlés",
"edit": "Szerkesztés",
"export": "Exportálás",
"list": "Lista",
"refresh": "Frissítés",
"remove_filter": "Szűrő eltávolítása",
"remove": "Eltávolítás",
"save": "Mentés",
"search": "Keresés",
"show": "Megjelenítés",
"sort": "Rendezés",
"undo": "Vísszavonás",
"expand": "Kiterjesztés",
"close": "Bezárás",
"open_menu": "Menü megnyitása",
"close_menu": "Menü bezárása",
"unselect": "Kijelölés törlése",
"skip": "Átugrás",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Megosztás",
"download": "Letöltés"
},
"boolean": {
"true": "Igen",
"false": "Nem"
},
"page": {
"create": "%{name} létrehozása",
"dashboard": "Műszerfal",
"edit": "%{name} #%{id}",
"error": "Valami probléma történt",
"list": "%{name}",
"loading": "Betöltés",
"not_found": "Nem található",
"show": "%{name} #%{id}",
"empty": "Nincs %{name} még.",
"invite": "Szeretnél egyet hozzáadni?"
},
"input": {
"file": {
"upload_several": "Húzz ide néhány feltöltendő fájlt vagy válassz egyet.",
"upload_single": "Húzz ide egy feltöltendő fájlt vagy válassz egyet."
},
"image": {
"upload_several": "Húzz ide néhány feltöltendő képet vagy válassz egyet.",
"upload_single": "Húzz ide egy feltöltendő képet vagy válassz egyet."
},
"references": {
"all_missing": "Hivatkozási adatok nem találhatóak.",
"many_missing": "Legalább az egyik kapcsolódó hivatkozás már nem elérhető.",
"single_missing": "A kapcsolódó hivatkozás már nem elérhető."
},
"password": {
"toggle_visible": "Jelszó elrejtése",
"toggle_hidden": "Jelszó megjelenítése"
}
},
"message": {
"about": "Rólunk",
"are_you_sure": "Biztos vagy benne?",
"bulk_delete_content": "Biztos, hogy törölni akarod %{name}? |||| Biztos, hogy törölni akarod ezeket az %{smart_count} elemeket?",
"bulk_delete_title": "%{name} törlése |||| %{smart_count} %{name} elem törlése",
"delete_content": "Biztos, hogy törlöd ezt az elemet?",
"delete_title": "%{name} #%{id} törlése",
"details": "Részletek",
"error": "Kliens hiba lépett fel, és a kérést nem lehetett teljesíteni.",
"invalid_form": "Az űrlap érvénytelen. Kérlek, ellenőrizzd a hibákat.",
"loading": "Az oldal betöltődik. Egy pillanat.",
"no": "Nem",
"not_found": "Rossz hivatkozást írtál be, vagy egy rossz linket adtál meg.",
"yes": "Igen",
"unsaved_changes": "Néhány módosítás nem lett elmentve. Biztos, hogy figyelmen kívül akarod hagyni?"
},
"navigation": {
"no_results": "Nincs találat.",
"no_more_results": "Az oldalszám %{page} kívül esik a határokon. Próbáld meg az előző oldalt.",
"page_out_of_boundaries": "Az oldalszám %{page} kívül esik a határokon.",
"page_out_from_end": "Nem lehet az utolsó oldal után menni",
"page_out_from_begin": "Nem lehet az első oldal elé menni",
"page_range_info": "%{offsetBegin}-%{offsetEnd} of %{total}",
"page_rows_per_page": "Elemek oldalanként:",
"next": "Következő",
"prev": "Előző",
"skip_nav": "Ugrás a tartalomra"
},
"notification": {
"updated": "Elem frissítve |||| %{smart_count} elemek frissíteve",
"created": "Elem létrehozva",
"deleted": "Elem törölve |||| %{smart_count} elemek frissítve",
"bad_item": "Hibás elem",
"item_doesnt_exist": "Elem nem létezik",
"http_error": "Szerver kommunikációs hiba",
"data_provider_error": "Adatszolgáltatói hiba. Ellenőrizzd a konzolt a részletekért.",
"i18n_error": "Nem lehet betölteni a fordítást a kért nyelven",
"canceled": "A művelet visszavonva",
"logged_out": "A munkamenet lejárt. Kérlek, csatlakozz újra.",
"new_version": "Új verzió elérhető! Kérlek, frissítsd ezt az ablakot!"
},
"toggleFieldsMenu": {
"columnsToDisplay": "Megjelenítendő oszlopok",
"layout": "Elrendezés",
"grid": "Rács",
"table": "Tábla"
}
},
"message": {
"note": "MEGJEGYZÉS",
"transcodingDisabled": "Az átkódolási konfiguráció módosítása a webes felületen keresztül biztonsági okokból nem lehetséges. Ha módosítani szeretnéd az átkódolási beállításokat, indítsd újra a kiszolgálót a %{config} konfigurációs opcióval.",
"transcodingEnabled": "A Navidrome jelenleg a következőkkel fut %{config}, ez lehetővé teszi a rendszerparancsok futtatását az átkódolási beállításokból a webes felület segítségével. Javasoljuk, hogy biztonsági okokból tiltsd ezt le, és csak az átkódolási beállítások konfigurálásának idejére kapcsold be.",
"songsAddedToPlaylist": "1 szám hozzáadva a lejátszási listához |||| %{smart_count} szám hozzáadva a lejátszási listához",
"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?",
"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.",
"lastfmLinkFailure": "Nem lehet kapcsolódni a Last.fm-hez.",
"lastfmUnlinkSuccess": "Last.fm leválasztva és a halgatott számok küldése kikapcsolva.",
"lastfmUnlinkFailure": "Nem sikerült leválasztani a Last.fm-et.",
"openIn": {
"lastfm": "Megnyitás Last.fm-ben",
"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.",
"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.",
"downloadOriginalFormat": "Letöltés eredeti formátumban",
"shareOriginalFormat": "Megosztás eredeti formátumban",
"shareDialogTitle": "Megosztás %{resource} '%{name}'",
"shareBatchDialogTitle": "1 %{resource} megosztása |||| %{smart_count} %{resource} megosztása",
"shareSuccess": "Hivatkozás másolva a vágólapra: %{url}",
"shareFailure": "Hiba történt a hivatkozás %{url} vágólapra másolása közben.",
"downloadDialogTitle": "Letöltés %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Másolás vágólapra: Ctrl+C, Enter"
},
"menu": {
"library": "Könyvtár",
"settings": "Beállítások",
"version": "Verzió",
"theme": "Téma",
"personal": {
"name": "Személyes",
"options": {
"theme": "Téma",
"language": "Nyelv",
"defaultView": "Alapértelmezett nézet",
"desktop_notifications": "Asztali értesítések",
"lastfmScrobbling": "Halgatott számok küldése a Last.fm-nek",
"listenBrainzScrobbling": "Halgatott számok küldése a ListenBrainz-nek",
"replaygain": "ReplayGain mód",
"preAmp": "ReplayGain előerősítő (dB)",
"gain": {
"none": "Kikapcsolva",
"album": "Album",
"track": "Sáv"
}
}
},
"albumList": "Albumok",
"about": "Rólunk",
"playlists": "Lejátszási listák",
"sharedPlaylists": "Megosztott lej. listák"
},
"player": {
"playListsText": "Lejátszási lista",
"openText": "Megnyitás",
"closeText": "Bezárás",
"notContentText": "Nincs zene",
"clickToPlayText": "Lejátszás",
"clickToPauseText": "Szünet",
"nextTrackText": "Következő szám",
"previousTrackText": "Előző szám",
"reloadText": "Újratöltés",
"volumeText": "Hangerő",
"toggleLyricText": "Zeneszöveg",
"toggleMiniModeText": "Minimalizálás",
"destroyText": "Bezárás",
"downloadText": "Letöltés",
"removeAudioListsText": "Audio listák törlése",
"clickToDeleteText": "Kattints a törléshez %{name}",
"emptyLyricText": "Nincs szöveg",
"playModeText": {
"order": "Sorrendben",
"orderLoop": "Ismétlés",
"singleLoop": "Egy szám ismétlése",
"shufflePlay": "Véletlenszerű"
}
},
"about": {
"links": {
"homepage": "Honlap",
"source": "Forráskód",
"featureRequests": "Funkciókérések"
}
},
"activity": {
"title": "Aktivitás",
"totalScanned": "Beolvasott mappák összesen",
"quickScan": "Gyors beolvasás",
"fullScan": "Teljes beolvasás",
"serverUptime": "Szerver üzemidő",
"serverDown": "OFFLINE"
},
"help": {
"title": "Navidrome Gyorsbillentyűk",
"hotkeys": {
"show_help": "Mutasd ezt a súgót",
"toggle_menu": "Menu oldalsáv be",
"toggle_play": "Lejátszás / Szünet",
"prev_song": "Előző Szám",
"next_song": "Következő Szám",
"vol_up": "Hangerő fel",
"vol_down": "Hangerő le",
"toggle_love": "Ad hozzá ezt a számot a kedvencekhez",
"current_song": "Aktuális számhoz ugrás"
}
}
}

View File

@@ -2,139 +2,139 @@
"languageName": "한국어",
"resources": {
"song": {
"name": "",
"name": "노래 |||| 노래들",
"fields": {
"albumArtist": "앨범 아티스트",
"duration": "길이",
"duration": "시간",
"trackNumber": "#",
"playCount": "재생 수",
"playCount": "재생 수",
"title": "제목",
"artist": "아티스트",
"album": "앨범",
"path": "파일 경로",
"genre": "장르",
"compilation": "Compilation",
"compilation": "컴필레이션",
"year": "년",
"size": "파일 크기",
"updatedAt": "업데이트 날짜",
"updatedAt": "업데이트",
"bitRate": "비트레이트",
"discSubtitle": "디스크 서브타이틀",
"starred": "좋아요",
"comment": "코멘트",
"starred": "즐겨찾기",
"comment": "댓글",
"rating": "평가",
"quality": "품질",
"bpm": "BPM",
"playDate": "마지막 재생",
"channels": "채널",
"createdAt": "추가 날짜"
"createdAt": "추가 날짜"
},
"actions": {
"addToQueue": "마지막에 재생",
"playNow": "바로 재생",
"addToPlaylist": "플레이리스트에 추가",
"shuffleAll": "모든 셔플",
"addToQueue": "나중에 재생",
"playNow": "지금 재생",
"addToPlaylist": "재생목록에 추가",
"shuffleAll": "모든 노래 셔플",
"download": "다운로드",
"playNext": "다음 재생",
"info": "상세 정보"
"playNext": "다음 재생",
"info": "정보"
}
},
"album": {
"name": "앨범",
"name": "앨범 |||| 앨범들",
"fields": {
"albumArtist": "앨범 아티스트",
"artist": "아티스트",
"duration": "길이",
"songCount": "",
"playCount": "재생 수",
"duration": "시간",
"songCount": "노래",
"playCount": "재생 수",
"name": "이름",
"genre": "장르",
"compilation": "Compilation",
"compilation": "컴필레이션",
"year": "년",
"updatedAt": "업데이트 날짜",
"comment": "코멘트",
"updatedAt": "업데이트",
"comment": "댓글",
"rating": "평가",
"createdAt": "추가 날짜",
"createdAt": "추가 날짜",
"size": "크기",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"originalDate": "오리지널",
"releaseDate": "발매일",
"releases": "발매 음반 |||| 발매 음반들",
"released": "발매됨"
},
"actions": {
"playAll": "재생",
"playNext": "다음에 재생",
"addToQueue": "마지막에 재생",
"addToQueue": "나중에 재생",
"shuffle": "셔플",
"addToPlaylist": "플레이리스트에 추가",
"addToPlaylist": "재생목록 추가",
"download": "다운로드",
"info": "상세 정보",
"info": "정보",
"share": "공유"
},
"lists": {
"all": "전체",
"all": "모두",
"random": "랜덤",
"recentlyAdded": "최근 추가",
"recentlyPlayed": "최근 재생",
"mostPlayed": "가장 많이 재생",
"starred": "좋아요",
"recentlyAdded": "최근 추가",
"recentlyPlayed": "최근 재생",
"mostPlayed": "가장 많이 재생",
"starred": "즐겨찾기",
"topRated": "높은 평가"
}
},
"artist": {
"name": "아티스트",
"name": "아티스트 |||| 아티스트들",
"fields": {
"name": "이름",
"albumCount": "앨범 수",
"songCount": " 수",
"playCount": "재생 수",
"songCount": "노래 수",
"playCount": "재생 수",
"rating": "평가",
"genre": "장르",
"size": "크기"
}
},
"user": {
"name": "사용자",
"name": "사용자 |||| 사용자들",
"fields": {
"userName": "사용자",
"userName": "사용자이름",
"isAdmin": "관리자",
"lastLoginAt": "최종 로그인",
"updatedAt": "업데이트 날짜",
"lastLoginAt": "마지막 로그인",
"updatedAt": "업데이트",
"name": "이름",
"password": "비밀번호",
"createdAt": "생성 날짜",
"changePassword": "비밀번호를 변경하시겠습니까?",
"createdAt": "생성",
"changePassword": "비밀번호를 변경할까요?",
"currentPassword": "현재 비밀번호",
"newPassword": "새로운 비밀번호",
"newPassword": "새 비밀번호",
"token": "토큰"
},
"helperTexts": {
"name": "이름 변경은 다음 로그인 이후에 반영됩니다"
"name": "이름 변경 사항은 다음 로그인 이후에 반영"
},
"notifications": {
"created": "사용자 생성되었습니다",
"updated": "사용자 업데이트되었습니다",
"deleted": "사용자 삭제되었습니다"
"created": "사용자 생성",
"updated": "사용자 업데이트",
"deleted": "사용자 삭제"
},
"message": {
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요",
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.",
"clickHereForToken": "여기를 클릭하여 토큰을 얻으세요"
}
},
"player": {
"name": "플레이어",
"name": "플레이어 |||| 플레이어들",
"fields": {
"name": "이름",
"transcodingId": "트랜스코딩",
"maxBitRate": "최대 비트레이트",
"client": "클라이언트",
"userName": "사용자",
"lastSeen": "마지막 사용",
"reportRealPath": "실제 파일 경로 반환",
"scrobbleEnabled": "다른 서비스에 scrobble"
"userName": "사용자이름",
"lastSeen": "마지막으로 봤음",
"reportRealPath": "실제 경로 보고서",
"scrobbleEnabled": "외부 서비스에 스크로블 보내기"
}
},
"transcoding": {
"name": "트랜스코딩",
"name": "트랜스코딩 |||| 트랜스코딩들",
"fields": {
"name": "이름",
"targetFormat": "대상 포맷",
@@ -143,111 +143,111 @@
}
},
"playlist": {
"name": "플레이리스트",
"name": "재생목록 |||| 재생목록들",
"fields": {
"name": "이름",
"duration": "시간",
"duration": "지속",
"ownerName": "소유자",
"public": "공개",
"updatedAt": "업데이트 날짜",
"createdAt": "생성 날짜",
"songCount": "",
"comment": "코멘트",
"sync": "자동 임포트",
"path": "임포트 원본"
"updatedAt": "업데이트",
"createdAt": "생성",
"songCount": "노래",
"comment": "댓글",
"sync": "자동 가져오기",
"path": "다음에서 가져오기"
},
"actions": {
"selectPlaylist": "플레이리스트 선택",
"addNewPlaylist": "'%{name}' 생성",
"selectPlaylist": "재생목록 선택:",
"addNewPlaylist": "\"%{name}\" 만들기",
"export": "내보내기",
"makePublic": "공개하기",
"makePrivate": "비공개로 전환하기"
"makePublic": "공개",
"makePrivate": "비공개"
},
"message": {
"duplicate_song": "중복된 추가",
"song_exist": "이미 플레이리스트에 존재하는 입니다. 추가하시겠습니까?"
"duplicate_song": "중복된 노래 추가",
"song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?"
}
},
"radio": {
"name": "라디오",
"name": "라디오 |||| 라디오들",
"fields": {
"name": "이름",
"streamUrl": "스트리밍 URL",
"homePageUrl": "홈페이지 URL",
"updatedAt": "업데이트 날짜",
"createdAt": "생성 날짜"
"updatedAt": "업데이트",
"createdAt": "생성"
},
"actions": {
"playNow": "바로 재생"
"playNow": "지금 재생"
}
},
"share": {
"name": "공유",
"name": "공유 |||| 공유되는 것들",
"fields": {
"username": "공유",
"username": "공유",
"url": "URL",
"description": "설명",
"contents": "컨텐츠",
"expiresAt": "만료 날짜",
"lastVisitedAt": "최근 방문",
"expiresAt": "만료",
"lastVisitedAt": "마지막 방문",
"visitCount": "방문 수",
"format": "포맷",
"maxBitRate": "최대 비트레이트",
"updatedAt": "업데이트 날짜",
"createdAt": "생성 날짜",
"downloadable": ""
"updatedAt": "업데이트",
"createdAt": "생성",
"downloadable": "다운로드를 허용할까요?"
}
}
},
"ra": {
"auth": {
"welcome1": "Navidrome을 설치해 주셔서 감사합니다!",
"welcome2": "관리자 사용자를 생성하고 시작해 보세요",
"welcome2": "관리자를 만들고 시작해 보세요",
"confirmPassword": "비밀번호 확인",
"buttonCreateAdmin": "관리자 생성",
"auth_check_error": "인증에 실패했습니다. 다시 로그인하세요",
"user_menu": "프로",
"username": "사용자",
"buttonCreateAdmin": "관리자 만들기",
"auth_check_error": "계속하려면 로그인하세요",
"user_menu": "프로파일",
"username": "사용자이름",
"password": "비밀번호",
"sign_in": "로그인",
"sign_in_error": "인증에 실패했습니다. 입력값을 확인하세요",
"sign_in": "가입",
"sign_in_error": "인증에 실패했습니다. 다시 시도하세요",
"logout": "로그아웃"
},
"validation": {
"invalidChars": "문자와 숫자만 사용하세요",
"passwordDoesNotMatch": "비밀번호가 일치하지 않습니다",
"required": "필수 항목입니다",
"minLength": "%{min}자 이상이어야 합니다",
"maxLength": "%{max}자 이하이어야 합니다",
"minValue": "%{min} 이상이어야 합니다",
"maxValue": "%{max} 이하이어야 합니다",
"number": "숫자여야 합니다",
"email": "유효한 이메일 주소여야 합니다",
"oneOf": "다음 중 하나여야 합니다: %{options}",
"regex": "다음과 같은 형식이어야 합니다: %{pattern}",
"unique": "고유해야 합니다",
"url": "유효한 URL을 입력하세요"
"passwordDoesNotMatch": "비밀번호가 일치하지 않",
"required": "필수 항목",
"minLength": "%{min}자 이하여야 함",
"maxLength": "%{max}자 이하여야 함",
"minValue": "%{min} 이상이어야 ",
"maxValue": "%{max} 이하여야 함",
"number": "숫자여야 ",
"email": "유효한 이메일이어야 함",
"oneOf": "다음 중 하나여야 : %{options}",
"regex": "특정 형식(정규식)과 일치해야 함: %{pattern}",
"unique": "고유해야 ",
"url": "유효한 URL이어야 함"
},
"action": {
"add_filter": "필터 추가",
"add": "추가",
"back": "뒤로",
"bulk_actions": "%{smart_count}개 선택",
"back": "뒤로 가기",
"bulk_actions": "1 개 항목이 선택되었음 |||| %{smart_count} 항목이 선택되었음",
"cancel": "취소",
"clear_input_value": "우기",
"clear_input_value": "값 지우기",
"clone": "복제",
"confirm": "확인",
"create": "생성",
"create": "만들기",
"delete": "삭제",
"edit": "편집",
"export": "내보내기",
"list": "목록",
"refresh": "새로고침",
"remove_filter": "필터 제",
"remove": "제",
"refresh": "새로 고침",
"remove_filter": "필터 제",
"remove": "제",
"save": "저장",
"search": "검색",
"show": "상세 정보",
"show": "표시",
"sort": "정렬",
"undo": "실행 취소",
"expand": "확장",
@@ -255,7 +255,7 @@
"open_menu": "메뉴 열기",
"close_menu": "메뉴 닫기",
"unselect": "선택 해제",
"skip": "스킵",
"skip": "건너뛰기",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "공유",
"download": "다운로드"
@@ -265,115 +265,115 @@
"false": "아니요"
},
"page": {
"create": "%{name} 생성",
"create": "%{name} 만들기",
"dashboard": "대시보드",
"edit": "%{name} #%{id}",
"error": "문제가 발생했습니다",
"error": "문제가 발생하였음",
"list": "%{name}",
"loading": "로딩 중입니다. 잠시 기다려주세요",
"not_found": "찾을 수 없습니다",
"loading": "로딩 중",
"not_found": "찾을 수 없",
"show": "%{name} #%{id}",
"empty": "%{name}이(가) 없습니다",
"invite": "생성하시겠습니까?"
"empty": "아직 %{name}이(가) 없습니다.",
"invite": "추가할까요?"
},
"input": {
"file": {
"upload_several": "파일을 끌어 놓거나 클릭하여 업로드하세요",
"upload_single": "파일을 끌어 놓거나 클릭하여 업로드하세요"
"upload_several": "업로드할 파일을 몇 개 놓거나 클릭하여 하나를 선택하세요.",
"upload_single": "업로드할 파일을 몇 개 놓거나 클릭하여 선택하세요."
},
"image": {
"upload_several": "이미지를 끌어 놓거나 클릭하여 업로드하세요",
"upload_single": "이미지를 끌어 놓거나 클릭하여 업로드하세요"
"upload_several": "업로드할 사진을 몇 개 놓거나 클릭하여 하나를 선택하세요.",
"upload_single": "업로드할 사진을 몇 개 놓거나 클릭하여 선택하세요."
},
"references": {
"all_missing": "사용 가능한 데이터가 없습니다",
"many_missing": "선택한 데이터 중 일부가 사용 가능하지 않습니다",
"single_missing": "선택한 데이터가 사용 가능하지 않습니다"
"all_missing": "참조 데이터를 찾을 수 없습니다.",
"many_missing": "연관된 참조 중 적어도 하나는 더 이상 사용할 수 없는 것 같습니다.",
"single_missing": "연관된 참조는 더 이상 사용할 수 없는 것 같습니다."
},
"password": {
"toggle_visible": "숨기기",
"toggle_hidden": "보이기"
"toggle_visible": "비밀번호 숨기기",
"toggle_hidden": "비밀번호 표시"
}
},
"message": {
"about": "정보",
"are_you_sure": "정말로 이 작업을 수행하시겠습니까?",
"bulk_delete_content": "%{name}을(를) 삭제하시겠습니까? |||| %{smart_count}개의 항목을 삭제하시겠습니까?",
"bulk_delete_title": "%{name} 삭제 |||| %{name} %{smart_count} 삭제",
"delete_content": "삭제하시겠습니까?",
"are_you_sure": "확실한가요?",
"bulk_delete_content": "%{name}을(를) 삭제할까요? |||| %{smart_count} 개의 항목을 삭제할까요?",
"bulk_delete_title": "%{name} 삭제 |||| %{smart_count} %{name} 삭제",
"delete_content": "이 항목을 삭제할까요?",
"delete_title": "%{name} #%{id} 삭제",
"details": "세 정보",
"error": "클라이언트 오류로 처리를 완료할 수 없습니다",
"invalid_form": "입력값에 오류가 있습니다. 오류 메시지를 확인하세요",
"loading": "로딩 중입니다. 잠시만 기다려주세요",
"details": "세 정보",
"error": "클라이언트 오류가 발생하여 요청을 완료할 수 없습니다.",
"invalid_form": "양식이 유효하지 않습니다. 오류를 확인하세요",
"loading": "페이지가 로드 중입니다. 잠시만 기다려 주세요",
"no": "아니요",
"not_found": "잘못된 URL을 입력거나 잘못된 링크를 따라갔습니다",
"not_found": "잘못된 URL을 입력거나 잘못된 링크를 클릭했습니다.",
"yes": "예",
"unsaved_changes": "변경 사항이 저장되지 않았습니다. 이 페이지를 떠나시겠습니까?"
"unsaved_changes": "일부 변경 사항이 저장되지 않았습니다. 무시할까요?"
},
"navigation": {
"no_results": "결과가 없습니다",
"no_more_results": "페이지 %{page}는 최대 페이지 수를 초과했습니다. 이전 페이지로 돌아가세요",
"page_out_of_boundaries": "페이지 %{page}는 최대 페이지 수를 초과했습니다",
"page_out_from_end": "마지막 페이지 이후로 이동할 수 없습니다",
"page_out_from_begin": "첫 페이지 이전으로 이동할 수 없습니다",
"no_results": "결과를 찾을 수 없음",
"no_more_results": "페이지 번호 %{page}이(가) 경계를 벗어났습니다. 이전 페이지를 시도해 보세요.",
"page_out_of_boundaries": "페이지 번호 %{page}이(가) 경계를 벗어남",
"page_out_from_end": "마지막 페이지 뒤로 갈 수 없",
"page_out_from_begin": "첫 페이지 으로 수 없",
"page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
"page_rows_per_page": "페이지당 항목:",
"page_rows_per_page": "페이지당 항목:",
"next": "다음",
"prev": "이전",
"skip_nav": "메뉴 건너뛰기"
"skip_nav": "콘텐츠 건너뛰기"
},
"notification": {
"updated": "업데이트되었습니다 |||| %{smart_count}개 업데이트되었습니다",
"created": "생성되었습니다",
"deleted": "삭제되었습니다 |||| %{smart_count}개 삭제되었습니다",
"bad_item": "잘못된 항목입니다",
"item_doesnt_exist": "항목이 존재하지 않습니다",
"http_error": "통신 오류가 발생했습니다",
"data_provider_error": "dataProvider 오류입니다. 자세한 내용은 콘솔을 확인하세요",
"i18n_error": "번역 파일을 로드할 수 없습니다",
"canceled": "취소되었습니다",
"logged_out": "인증에 실패했습니다. 다시 로그인하세요",
"new_version": "새로운 버전이 사용 가능합니다! 페이지를 새로 고쳐주세요."
"updated": "요소 업데이트 |||| %{smart_count} 개 요소 업데이트됨",
"created": "요소 생성됨",
"deleted": "요소 삭제됨 |||| %{smart_count} 요소 삭제됨",
"bad_item": "잘못된 요소",
"item_doesnt_exist": "요소가 존재하지 않",
"http_error": "서버 통신 오류",
"data_provider_error": "dataProvider 오류입니다. 자세한 내용은 콘솔을 확인하세요.",
"i18n_error": "지정된 언어에 대한 번역을 로드할 수 없",
"canceled": "작업이 취소됨",
"logged_out": "세션이 종료되었습니다. 다시 연결하세요.",
"new_version": "새로운 버전이 출시되었습니다! 이 창을 새로 고침하세요."
},
"toggleFieldsMenu": {
"columnsToDisplay": "표시 열",
"columnsToDisplay": "표시 열",
"layout": "레이아웃",
"grid": "그리드",
"table": "테이블"
"grid": "격자",
"table": ""
}
},
"message": {
"note": "주의",
"transcodingDisabled": "보안상의 이유로 웹 인터페이스에서 트랜스코드 설정이 비활성화되어 있습니다.\n이를 설정하려면 환경 변수 %{config}를 설정하고 서버를 재시작하십시오.",
"transcodingEnabled": "Navidrome은 현재 %{config} 설정으로 실행되며, 웹 인터페이스 트랜스코 설정에 따라 명령을 실행할 수 있습니다.\n보안상의 이유로 이 설정은 트랜스코드 설정을 변경할 때만 활성화하는 것을 권장합니다.",
"songsAddedToPlaylist": "플레이리스트에 1곡 추가되었습니다 |||| 플레이리스트에 %{smart_count}곡 추가되었습니다",
"noPlaylistsAvailable": "사용 가능하지 않음",
"delete_user_title": "'%{name}' 삭제",
"delete_user_content": "이 사용자와 그의 모든 데이터(플레이리스트 및 설정 등)를 삭제하시겠습니까?",
"notifications_blocked": "브라우저의 설정으로 이 사이트의 알림 차단되어 있습니다",
"notifications_not_available": "이 브라우저는 데스크톱 알림을 지원하지 않습니다",
"lastfmLinkSuccess": "Last.fm과 연결되어 scrobble이 활성화되었습니다",
"lastfmLinkFailure": "Last.fm 연결할 수 없습니다",
"lastfmUnlinkSuccess": "설정이 해제되어 Last.fm으로의 scrobble이 비활성화되었습니다",
"lastfmUnlinkFailure": "Last.fm 연결 해제를 실패했습니다",
"note": "참고",
"transcodingDisabled": "웹 인터페이스를 통한 트랜스코딩 구성 변경은 보안상의 이유로 비활성화되어 있습니다. 트랜스코딩 옵션을 변경(편집 또는 추가)하려면, %{config} 구성 옵션으로 서버를 다시 시작하세요.",
"transcodingEnabled": "Navidrome은 현재 %{config}로 실행 중이므로 웹 인터페이스를 사용하여 트랜스코 설정에서 시스템 명령을 실행할 수 있습니다. 보안상의 이유로 비활성화하고 트랜스코딩 옵션을 구성할 때만 활성화하는 것이 좋습니다.",
"songsAddedToPlaylist": "1 개의 노래를 재생목록에 추가하였음 |||| %{smart_count} 개의 노래를 재생 목록에 추가하였음",
"noPlaylistsAvailable": "사용 가능한 노래 없음",
"delete_user_title": "사용자 '%{name}' 삭제",
"delete_user_content": "이 사용자와 (재생목록 및 기본 설정 포함된) 모든 데이터를 삭제할까요?",
"notifications_blocked": "탐색기 설정에서 이 사이트의 알림 차단하였음",
"notifications_not_available": "이 탐색기는 데스크톱 알림을 지원하지 않거나 https를 통해 Navidrome에 접속하지 않음",
"lastfmLinkSuccess": "Last.fm이 성공적으로 연결되었고 스크로블링이 활성화되었",
"lastfmLinkFailure": "Last.fm 연결할 수 없",
"lastfmUnlinkSuccess": "Last.fm이 연결 해제되었고 스크로블링이 비활성화되었",
"lastfmUnlinkFailure": "Last.fm 연결 해제할 수 없음",
"openIn": {
"lastfm": "Last.fm에서 열기",
"musicbrainz": "MusicBrainz에서 열기"
},
"lastfmLink": "계속 읽기",
"listenBrainzLinkSuccess": "%{user}에 대한 scrobbling 설정이 성공적으로 완료되었습니다",
"listenBrainzLinkFailure": "ListenBrainz 연결에 실패했습니다: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz와의 연결과 scrobbling이 비활성화되었습니다",
"listenBrainzUnlinkFailure": "ListenBrainz와의 연결 해제를 실패했습니다",
"downloadOriginalFormat": "원본 형식으로 다운로드",
"shareOriginalFormat": "원본 형식으로 공유",
"lastfmLink": " 읽기...",
"listenBrainzLinkSuccess": "ListenBrainz가 성공적으로 연결되었고 스크로블링이 사용자로 활성화되었음: %{user}",
"listenBrainzLinkFailure": "ListenBrainz 연결할 수 없음: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz 연결 해제되었고 스크로블링이 비활성화되었",
"listenBrainzUnlinkFailure": "ListenBrainz 연결 해제할 수 없음",
"downloadOriginalFormat": "오리지널 형식으로 다운로드",
"shareOriginalFormat": "오리지널 형식으로 공유",
"shareDialogTitle": "%{resource} '%{name}' 공유",
"shareBatchDialogTitle": "1 %{resource} 공유 |||| %{smart_count} %{resource} 공유",
"shareSuccess": "복사되었습니다: %{url}",
"shareFailure": "복사하지 못했습니다 %{url}",
"downloadDialogTitle": "다운로드 %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": ""
"shareSuccess": "URL이 클립보드에 복사됨: %{url}",
"shareFailure": "%{url}을 클립보드에 복사하는 중 오류 발생",
"downloadDialogTitle": "%{resource} '%{name}' (%{size}) 다운로드",
"shareCopyToClipboard": "클립보드에 복사: Ctrl+C, Enter"
},
"menu": {
"library": "라이브러리",
@@ -385,46 +385,46 @@
"options": {
"theme": "테마",
"language": "언어",
"defaultView": "기본 ",
"defaultView": "기본 보기",
"desktop_notifications": "데스크톱 알림",
"lastfmScrobbling": "Last.fm으로 scrobble하기",
"listenBrainzScrobbling": "ListenBrainz로 scrobble하기",
"replaygain": "ReplayGain 모드",
"preAmp": "프리앰프",
"lastfmScrobbling": "Last.fm으로 스크로블",
"listenBrainzScrobbling": "ListenBrainz로 스크로블",
"replaygain": "리플레이게인 모드",
"preAmp": "리플레이게인 프리앰프 (dB)",
"gain": {
"none": "비활성화",
"album": "앨범 Gain 사용",
"track": "트랙 Gain 사용"
"album": "앨범 게인 사용",
"track": "트랙 게인 사용"
}
}
},
"albumList": "앨범",
"about": "상세 정보",
"playlists": "플레이리스트",
"sharedPlaylists": "공유된 플레이리스트"
"about": "정보",
"playlists": "재생목록",
"sharedPlaylists": "공유된 재생목록"
},
"player": {
"playListsText": "재생 목록",
"playListsText": "대기열 재생",
"openText": "열기",
"closeText": "닫기",
"notContentText": "음악습니다",
"clickToPlayText": "클릭하여 재생",
"clickToPauseText": "일시 정지",
"nextTrackText": "다음 ",
"previousTrackText": "이전 ",
"reloadText": "새로 고침",
"volumeText": "음량",
"notContentText": "음악 없",
"clickToPlayText": "재생하려면 클릭",
"clickToPauseText": "일시 중지하려면 클릭",
"nextTrackText": "다음 트랙",
"previousTrackText": "이전 트랙",
"reloadText": "다시 로드하기",
"volumeText": "볼륨",
"toggleLyricText": "가사 전환",
"toggleMiniModeText": "최소화",
"destroyText": "제",
"destroyText": "제",
"downloadText": "다운로드",
"removeAudioListsText": "목록 비우기",
"clickToDeleteText": "클릭하여 %{name} 삭제",
"emptyLyricText": "가사습니다",
"removeAudioListsText": "오디오 목록 삭제",
"clickToDeleteText": "%{name}을(를) 삭제하려면 클릭",
"emptyLyricText": "가사 없",
"playModeText": {
"order": "순서대로",
"orderLoop": "반복",
"singleLoop": "한 곡 반복",
"singleLoop": "노래 하나 반복",
"shufflePlay": "셔플"
}
},
@@ -437,24 +437,24 @@
},
"activity": {
"title": "활동",
"totalScanned": "스캔된 폴더",
"totalScanned": "스캔된 전체 폴더",
"quickScan": "빠른 스캔",
"fullScan": "전체 스캔",
"serverUptime": "서버 가동 시간",
"serverDown": "서버 오프라인"
"serverDown": "오프라인"
},
"help": {
"title": "Navidrome 단축키",
"hotkeys": {
"show_help": "도움말 표시",
"toggle_menu": "사이드바 표시/숨기기",
"toggle_play": "재생/정지",
"prev_song": "이전 ",
"next_song": "다음 ",
"vol_up": "음량 높이기",
"vol_down": "음량 낮추기",
"toggle_love": "별표 토글",
"current_song": "현재 곡으로 이동"
"show_help": "도움말 표시",
"toggle_menu": "메뉴 사이드바 전환",
"toggle_play": "재생 / 일시 중지",
"prev_song": "이전 노래",
"next_song": "다음 노래",
"vol_up": "볼륨 높이기",
"vol_down": "볼륨 낮추기",
"toggle_love": "이 트랙을 즐겨찾기에 추가",
"current_song": "현재 노래로 이동"
}
}
}
}

466
resources/i18n/sr.json Normal file
View File

@@ -0,0 +1,466 @@
{
"languageName": "српски",
"resources": {
"song": {
"name": "Песма |||| Песме",
"fields": {
"albumArtist": "Уметник албума",
"duration": "Трајање",
"trackNumber": "#",
"playCount": "Пуштано",
"title": "Наслов",
"artist": "Уметник",
"album": "Албум",
"path": "Путања фајла",
"genre": "Жанр",
"compilation": "Компилација",
"year": "Година",
"size": "Величина фајла",
"updatedAt": "Ажурирано",
"bitRate": "Битски проток",
"channels": "Канала",
"discSubtitle": "Поднаслов диска",
"starred": "Омиљено",
"comment": "Коментар",
"rating": "Рејтинг",
"quality": "Квалитет",
"bpm": "BPM",
"playDate": "Последње пуштано",
"createdAt": "Датум додавања"
},
"actions": {
"addToQueue": "Пусти касније",
"playNow": "Пусти одмах",
"addToPlaylist": "Додај у плејлисту",
"shuffleAll": "Измешај све",
"download": "Преузми",
"playNext": "Пусти наредно",
"info": "Прикажи инфо"
}
},
"album": {
"name": "Албум |||| Албуми",
"fields": {
"albumArtist": "Уметник албума",
"artist": "Уметник",
"duration": "Трајање",
"songCount": "Песме",
"playCount": "Пуштано",
"size": "Величина",
"name": "Назив",
"genre": "Жанр",
"compilation": "Компилација",
"year": "Година",
"originalDate": "Оригинално",
"releaseDate": "Објављено",
"releases": "Издање|||| Издања",
"released": "Објављено",
"updatedAt": "Ажурирано",
"comment": "Коментар",
"rating": "Рејтинг",
"createdAt": "Датум додавања"
},
"actions": {
"playAll": "Пусти",
"playNext": "Пусти наредно",
"addToQueue": "Пусти касније",
"share": "Дели",
"shuffle": "Измешај",
"addToPlaylist": "Додај у плејлисту",
"download": "Преузми",
"info": "Прикажи инфо"
},
"lists": {
"all": "Све",
"random": "Насумично",
"recentlyAdded": "Додато недавно",
"recentlyPlayed": "Пуштано недавно",
"mostPlayed": "Најчешће пуштано",
"starred": "Омиљено",
"topRated": "Најбоље рангирано"
}
},
"artist": {
"name": "Уметник |||| Уметници",
"fields": {
"name": "Име",
"albumCount": "Број албума",
"songCount": "Број песама",
"size": "Величина",
"playCount": "Пуштано",
"rating": "Рејтинг",
"genre": "Жанр"
}
},
"user": {
"name": "Корисник |||| Корисници",
"fields": {
"userName": "Корисничко име",
"isAdmin": "Да ли је Админ",
"lastLoginAt": "Последња пријава",
"updatedAt": "Ажурирано",
"name": "Име",
"password": "Лозинка",
"createdAt": "Креирана",
"changePassword": "Измени лозинку?",
"currentPassword": "Текућа лозинка",
"newPassword": "Нова лозинка",
"token": "Жетон"
},
"helperTexts": {
"name": "Измене вашег имена ће постати видљиве након следеће пријаве"
},
"notifications": {
"created": "Корисник креиран",
"updated": "Корисник ажуриран",
"deleted": "Корисник обрисан"
},
"message": {
"listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон.",
"clickHereForToken": "Кликните овде да преузмете свој жетон"
}
},
"player": {
"name": "Плејер |||| Плејери",
"fields": {
"name": "Назив",
"transcodingId": "Транскодирање",
"maxBitRate": "Макс. битски проток",
"client": "Клијент",
"userName": "Корисничко име",
"lastSeen": "последњи пут виђен",
"reportRealPath": "Пријављуј реалну путању",
"scrobbleEnabled": "Шаљи скроблове на спољне сервисе"
}
},
"transcoding": {
"name": "Транскодирање |||| Транскодирања",
"fields": {
"name": "Назив",
"targetFormat": "Циљни формат",
"defaultBitRate": "Подразумевани битски проток",
"command": "Команда"
}
},
"playlist": {
"name": "Плејлиста |||| Плејлисте",
"fields": {
"name": "Назив",
"duration": "Трајање",
"ownerName": "Власник",
"public": "Јавна",
"updatedAt": "Ажурирана",
"createdAt": "Креирана",
"songCount": "Песме",
"comment": "Коментар",
"sync": "Ауто-увоз",
"path": "Увоз из"
},
"actions": {
"selectPlaylist": "Изабери плејлисту",
"addNewPlaylist": "Креирај \"%{name}\"",
"export": "Извоз",
"makePublic": "Учини јавном",
"makePrivate": "Учини приватном"
},
"message": {
"duplicate_song": "Додај дуплиране песме",
"song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?"
}
},
"radio": {
"name": "Радио |||| Радији",
"fields": {
"name": "Назив",
"streamUrl": "URL тока",
"homePageUrl": "URL почетне странице",
"updatedAt": "Ажурирано",
"createdAt": "Креирано"
},
"actions": {
"playNow": "Пусти одмах"
}
},
"share": {
"name": "Дељење |||| Дељења",
"fields": {
"username": "Поделио",
"url": "URL",
"description": "Опис",
"downloadable": "Допушта се преузимање?",
"contents": "Садржај",
"expiresAt": "Истиче",
"lastVisitedAt": "Последњи пут посећено",
"visitCount": "Број посета",
"format": "Формат",
"maxBitRate": "Макс. битски проток",
"updatedAt": "Ажурирано",
"createdAt": "Креирано"
},
"notifications": {
},
"actions": {
}
}
},
"ra": {
"auth": {
"welcome1": "Хвала што сте инсталирали Navidrome!",
"welcome2": "За почетак, креирајте админ корисника",
"confirmPassword": "Потврдите лозинку",
"buttonCreateAdmin": "Креирај админа",
"auth_check_error": "Ако желите да наставите, молимо вас да се пријавите",
"user_menu": "Профил",
"username": "Корисничко име",
"password": "Лозинка",
"sign_in": "Пријави се",
"sign_in_error": "Потврда идентитета није успела, покушајте поново",
"logout": "Одјави се"
},
"validation": {
"invalidChars": "Молимо вас да користите само слова и цифре",
"passwordDoesNotMatch": "Лозинка се не подудара",
"required": "Неопходно",
"minLength": "Мора да буде барем %{min} карактера",
"maxLength": "Мора да буде %{max} карактера или мање",
"minValue": "Мора да буде барем %{min}",
"maxValue": "Мора да буде %{max} или мање",
"number": "Мора да буде број",
"email": "Мора да буде исправна и-мејл адреса",
"oneOf": "Мора да буде једно од: %{options}",
"regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}",
"unique": "Мора да буде јединствено",
"url": "Мора да буде исправна URL адреса"
},
"action": {
"add_filter": "Додај филтер",
"add": "Додај",
"back": "Иди назад",
"bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Откажи",
"clear_input_value": "Обриши вредност",
"clone": "Клонирај",
"confirm": "Потврди",
"create": "Креирај",
"delete": "Обриши",
"edit": "Уреди",
"export": "Извези",
"list": "Листа",
"refresh": "Освежи",
"remove_filter": "Уклони овај филтер",
"remove": "Уклони",
"save": "Сачувај",
"search": "Тражи",
"show": "Прикажи",
"sort": "Сортирај",
"undo": "Поништи",
"expand": "Развиј",
"close": "Затвори",
"open_menu": "Отвори мени",
"close_menu": "Затвори мени",
"unselect": "Уклони избор",
"skip": "Прескочи",
"share": "Подели",
"download": "Преузми"
},
"boolean": {
"true": "Да",
"false": "Не"
},
"page": {
"create": "Креирај %{name}",
"dashboard": "Контролна табла",
"edit": "%{name} #%{id}",
"error": "Нешто је пошло наопако",
"list": "%{name}",
"loading": "Учитава се",
"not_found": "Није пронађено",
"show": "%{name} #%{id}",
"empty": "Још увек нема %{name}.",
"invite": "Желите ли да се дода?"
},
"input": {
"file": {
"upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.",
"upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете."
},
"image": {
"upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.",
"upload_single": "Упустите слику да се отпреми, или кликните да је изаберете."
},
"references": {
"all_missing": "Не могу да се нађу подаци референци.",
"many_missing": "Изгледа да барем једна од придружених референци више није доступна.",
"single_missing": "Изгледа да придружена референца више није доступна."
},
"password": {
"toggle_visible": "Сакриј лозинку",
"toggle_hidden": "Прикажи лозинку"
}
},
"message": {
"about": "О програму",
"are_you_sure": "Да ли сте сигурни?",
"bulk_delete_content": "Да ли заиста желите да обришете %{name}? |||| Да ли заиста желите да обришете %{smart_count} ставке?",
"bulk_delete_title": "Брисање %{name} |||| Брисање %{smart_count} %{name}",
"delete_content": "Да ли заиста желите да обришете ову ставку?",
"delete_title": "Брисање %{name} #%{id}",
"details": "Детаљи",
"error": "Дошло је до клијентске грешке и ваш захтев није могао да се изврши.",
"invalid_form": "Формулар није исправан. Молимо вас да исправите грешке",
"loading": "Страница се учитава, сачекајте мало",
"no": "Не",
"not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.",
"yes": "Да",
"unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?"
},
"navigation": {
"no_results": "Није пронађен ниједан резултат",
"no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.",
"page_out_of_boundaries": "Број странице %{page} је ван опсега",
"page_out_from_end": "Не може да се иде након последње странице",
"page_out_from_begin": "Не може да се иде испред странице 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}",
"page_rows_per_page": "Ставки по страници:",
"next": "Наредна",
"prev": "Претход",
"skip_nav": "Прескочи на садржај"
},
"notification": {
"updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано",
"created": "Елемент је креиран",
"deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано",
"bad_item": "Неисправни елемент",
"item_doesnt_exist": "Елемент не постоји",
"http_error": "Грешка у комуникацији са сервером",
"data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.",
"i18n_error": "Не могу да се учитају преводи за наведени језик",
"canceled": "Акција је отказана",
"logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.",
"new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Колоне за приказ",
"layout": "Распоред",
"grid": "Мрежа",
"table": "Табела"
}
},
"message": {
"note": "НАПОМЕНА",
"transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.",
"transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања.",
"songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама",
"noPlaylistsAvailable": "Није доступна ниједна",
"delete_user_title": "Брисање корисника %{name}",
"delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?",
"notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења",
"notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола",
"lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање",
"lastfmLinkFailure": "Last.fm није могао да се повеже",
"lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено",
"lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm",
"listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}",
"listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено",
"listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz",
"openIn": {
"lastfm": "Отвори у Last.fm",
"musicbrainz": "Отвори у MusicBrainz"
},
"lastfmLink": "Прочитај још...",
"shareOriginalFormat": "Подели у оригиналном формату",
"shareDialogTitle": "Подели %{resource} %{name}",
"shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}",
"shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер",
"shareSuccess": "URL је копиран у клипборд: %{url}",
"shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд",
"downloadDialogTitle": "Преузимање %{resource} %{name} (%{size})",
"downloadOriginalFormat": "Преузми у оригиналном формату"
},
"menu": {
"library": "Библиотека",
"settings": "Подешавања",
"version": "Верзија",
"theme": "Тема",
"personal": {
"name": "Лична",
"options": {
"theme": "Тема",
"language": "Језик",
"defaultView": "Подразумевани поглед",
"desktop_notifications": "Десктоп обавештења",
"lastfmScrobbling": "Скроблуј на Last.fm",
"listenBrainzScrobbling": "Скроблуј на ListenBrainz",
"replaygain": "ReplayGain режим",
"preAmp": "ReplayGain претпојачање (dB)",
"gain": {
"none": скљученоDisabled",
"album": "Користи Album појачање",
"track": "Користи Track појачање"
}
}
},
"albumList": "Албуми",
"playlists": "Плејлисте",
"sharedPlaylists": "Дељене плејлисте",
"about": "О"
},
"player": {
"playListsText": "Ред за пуштање",
"openText": "Отвори",
"closeText": "Затвори",
"notContentText": "Нема музике",
"clickToPlayText": "Кликни за пуштање",
"clickToPauseText": "Кликни за паузирање",
"nextTrackText": "Наредна нумера",
"previousTrackText": "Претходна нумера",
"reloadText": "Поново учитај",
"volumeText": "Јачина",
"toggleLyricText": "Укљ./Искљ. стихове",
"toggleMiniModeText": "Умањи",
"destroyText": "Уништи",
"downloadText": "Преузми",
"removeAudioListsText": "Обриши аудио листе",
"clickToDeleteText": "Кликните да обришете %{name}",
"emptyLyricText": "Нема стихова",
"playModeText": {
"order": "По редоследу",
"orderLoop": "Понови",
"singleLoop": "Понови једну",
"shufflePlay": "Промешано"
}
},
"about": {
"links": {
"homepage": "Почетна страница",
"source": "Изворни кôд",
"featureRequests": "Захтеви за функцијама"
}
},
"activity": {
"title": "Активност",
"totalScanned": "Укупан број скенираних фолдера",
"quickScan": "Брзо скенирање",
"fullScan": "Комплетно скенирање",
"serverUptime": "Сервер се извршава",
"serverDown": "ВАН МРЕЖЕ"
},
"help": {
"title": "Navidrome пречице",
"hotkeys": {
"show_help": "Прикажи ову помоћ",
"toggle_menu": "Укљ./Искљ. бочну траку менија",
"toggle_play": "Пусти / Паузирај",
"prev_song": "Претходна песма",
"next_song": "Наредна песма",
"current_song": "Иди на текућу песму",
"vol_up": "Појачај",
"vol_down": "Утишај",
"toggle_love": "Додај ову нумеру у омиљене"
}
}
}

View File

@@ -1,460 +1,462 @@
{
"languageName": "Svenska",
"resources": {
"song": {
"name": "Låt |||| Låtar",
"fields": {
"albumArtist": "Album artist",
"duration": "Längd",
"trackNumber": "#",
"playCount": "Spelad",
"title": "Titel",
"artist": "Artist",
"album": "Album",
"path": "Sökväg",
"genre": "Genre",
"compilation": "Samling",
"year": "År",
"size": "Filstorlek",
"updatedAt": "Uppdaterad",
"bitRate": "Bitrate",
"discSubtitle": "Underrubrik",
"starred": "Favorit",
"comment": "Kommentar",
"rating": "Betyg",
"quality": "Kvalite",
"bpm": "BPM",
"playDate": "",
"channels": "",
"createdAt": ""
},
"actions": {
"addToQueue": "Lägg till kön",
"playNow": "Spela",
"addToPlaylist": "Lägg till i spellista",
"shuffleAll": "Blanda",
"download": "Hämta",
"playNext": "Spela nästa",
"info": ""
}
},
"album": {
"name": "Album |||| Album",
"fields": {
"albumArtist": "Album artist",
"artist": "Artist",
"duration": "Längd",
"songCount": "Låtar",
"playCount": "Spelad",
"name": "Namn",
"genre": "Genre",
"compilation": "Samling",
"year": "År",
"updatedAt": "Uppdaterad",
"comment": "Kommentar",
"rating": "Betyg",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
},
"actions": {
"playAll": "Spela",
"playNext": "Spela härnäst",
"addToQueue": "Lägg till i kön",
"shuffle": "Blanda",
"addToPlaylist": "Lägg till i spellista",
"download": "Hämta",
"info": "",
"share": ""
},
"lists": {
"all": "Alla låtar",
"random": "Blanda",
"recentlyAdded": "Senast tillagda",
"recentlyPlayed": "Senast spelat",
"mostPlayed": "Mest spelat",
"starred": "Favoriter",
"topRated": "Betygsatta"
}
},
"artist": {
"name": "Artist |||| Artister",
"fields": {
"name": "Namn",
"albumCount": "Antal album",
"songCount": "Antal låtar",
"playCount": "Spelade",
"rating": "Betygsatt",
"genre": "Genre",
"size": ""
}
},
"user": {
"name": "Användare |||| Användare",
"fields": {
"userName": "Användarnamn",
"isAdmin": "Administratör",
"lastLoginAt": "Senaste inloggning",
"updatedAt": "Uppdaterad",
"name": "Namn",
"password": "Lösenord",
"createdAt": "Skapad",
"changePassword": "Byta lösenord?",
"currentPassword": "Nuvarande lösenord",
"newPassword": "Nytt lösenord",
"token": ""
},
"helperTexts": {
"name": "Namnändringar återspeglas vid nästkommande inloggning"
},
"notifications": {
"created": "Användare har skapats",
"updated": "Användare har uppdaterats",
"deleted": "Användare har tagits bort"
},
"message": {
"listenBrainzToken": "",
"clickHereForToken": ""
}
},
"player": {
"name": "Avkodare |||| Avkodare",
"fields": {
"name": "Namn",
"transcodingId": "Omkodning",
"maxBitRate": "Max. bitrate",
"client": "Klient",
"userName": "Användarnamn",
"lastSeen": "Senast sedd",
"reportRealPath": "Visa hela sökvägen",
"scrobbleEnabled": ""
}
},
"transcoding": {
"name": "Omkodning |||| Omkodningar",
"fields": {
"name": "Namn",
"targetFormat": "Målformat",
"defaultBitRate": "Standard bitrate",
"command": "Kommando"
}
},
"playlist": {
"name": "Spellista |||| Spellistor",
"fields": {
"name": "Namn",
"duration": "Längd",
"ownerName": "Ägare",
"public": "Offentlig",
"updatedAt": "Uppdaterad",
"createdAt": "Skapad",
"songCount": "Låt",
"comment": "Kommentar",
"sync": "Synkronisera",
"path": "Importerat från"
},
"actions": {
"selectPlaylist": "Välj en spellista:",
"addNewPlaylist": "Lägg till \"%{name}\"",
"export": "Exportera",
"makePublic": "",
"makePrivate": ""
},
"message": {
"duplicate_song": "Lägg till dubletter",
"song_exist": "Det finns dubletter som är på väg att läggas till i spellistan. Vill du lägga till dem ändå?"
}
},
"radio": {
"name": "",
"fields": {
"name": "",
"streamUrl": "",
"homePageUrl": "",
"updatedAt": "",
"createdAt": ""
},
"actions": {
"playNow": ""
}
},
"share": {
"name": "",
"fields": {
"username": "",
"url": "",
"description": "",
"contents": "",
"expiresAt": "",
"lastVisitedAt": "",
"visitCount": "",
"format": "",
"maxBitRate": "",
"updatedAt": "",
"createdAt": "",
"downloadable": ""
}
}
"languageName": "Svenska",
"resources": {
"song": {
"name": "Låt |||| Låtar",
"fields": {
"albumArtist": "Albumartist",
"duration": "Längd",
"trackNumber": "#",
"playCount": "Spelningar",
"title": "Titel",
"artist": "Artist",
"album": "Album",
"path": "Sökväg",
"genre": "Genre",
"compilation": "Samling",
"year": "År",
"size": "Filstorlek",
"updatedAt": "Uppdaterad",
"bitRate": "Bitrate",
"channels": "Channels",
"discSubtitle": "Underrubrik",
"starred": "Favorit",
"comment": "Kommentar",
"rating": "Betyg",
"quality": "Kvalitet",
"bpm": "BPM",
"playDate": "Senast spelad",
"createdAt": "Skapad"
},
"actions": {
"addToQueue": "Lägg till i kön",
"playNow": "Spela nu",
"addToPlaylist": "Lägg till i spellista",
"shuffleAll": "Shuffle",
"download": "Ladda ner",
"playNext": "Spela nästa",
"info": "Mer information"
}
},
"ra": {
"auth": {
"welcome1": "Tack för att du installerade Navidrome!",
"welcome2": "Lägg till en administratör för att börja",
"confirmPassword": "Bekräfta lösenord",
"buttonCreateAdmin": "Lägg till administratör",
"auth_check_error": "Var god logga in för att fortsätta",
"user_menu": "Profil",
"username": "Användarnamn",
"password": "Lösenord",
"sign_in": "Logga in",
"sign_in_error": "Felaktig inloggning, försök igen",
"logout": "Logga ut"
},
"validation": {
"invalidChars": "Ogiltiga tecken, du kan endast använda bokstäver och siffror",
"passwordDoesNotMatch": "Lösenorden matchar ej",
"required": "Krävs",
"minLength": "Mindst %{min} tecken",
"maxLength": "Max %{max} tecken",
"minValue": "Minst %{min}",
"maxValue": "Max %{max}",
"number": "Måste vara ett nummer",
"email": "Ange giltig e-post adress",
"oneOf": "Måste vara en av: %{options}",
"regex": "Måste matcha ett förvalt format (regexp): %{pattern}",
"unique": "Måste vara unik",
"url": ""
},
"action": {
"add_filter": "Lägg till filter",
"add": "Lägg till",
"back": "Tillbaka",
"bulk_actions": "%{smart_count} valda",
"cancel": "Avbryt",
"clear_input_value": "Rensa",
"clone": "Klona",
"confirm": "Bekräfta",
"create": "Lägg till",
"delete": "Ta bort",
"edit": "Redigera",
"export": "Exportera",
"list": "Lista",
"refresh": "Uppdatera",
"remove_filter": "Ta bort filter",
"remove": "Ta bort",
"save": "Spara",
"search": "Sök",
"show": "Visa",
"sort": "Sortera",
"undo": "Ångra",
"expand": "Expandera",
"close": "Lås",
"open_menu": "Öppna meny",
"close_menu": "Stäng meny",
"unselect": "Avmarkera",
"skip": "Skippa",
"bulk_actions_mobile": "",
"share": "",
"download": ""
},
"boolean": {
"true": "Ja",
"false": "Nej"
},
"page": {
"create": "Lägg till %{name}",
"dashboard": "Dashboard",
"edit": "%{name} #%{id}",
"error": "Ett fel uppstod",
"list": "%{name} lista",
"loading": "Bearbetar",
"not_found": "Hittade inget",
"show": "%{name} #%{id}",
"empty": "Ingen %{name} ännu",
"invite": "Vill du lägga till en?"
},
"input": {
"file": {
"upload_several": "Dra och släpp filer som ska laddas upp eller klicka för att markera filer.",
"upload_single": "Dra och släpp en fil som ska laddas upp eller klicka för att markera en fil."
},
"image": {
"upload_several": "Dra och släpp filer som ska laddas upp eller klicka för att markera filer.",
"upload_single": "Dra och släpp en bild som ska laddas upp eller klicka för att markera en fil."
},
"references": {
"all_missing": "Det går inte att hitta några referensdata.",
"many_missing": "Minst en av de associerade referenserna verkar inte längre vara tillgänglig.",
"single_missing": "Associerade referenser verkar inte längre vara tillgängliga."
},
"password": {
"toggle_visible": "Dölj lösenord",
"toggle_hidden": "Visa lösenord"
}
},
"message": {
"about": "Om",
"are_you_sure": "Är du säker?",
"bulk_delete_content": "Vill du ta bort %{name}? |||| Är du säker på att du vill ta bort %{smart_count} ?",
"bulk_delete_title": "Ta bort %{name} |||| Tar bort %{smart_count} %{name}",
"delete_content": "Är du säker du vill ta bort den här posten?",
"delete_title": "Ta bort %{name} #%{id}",
"details": "Detaljer",
"error": "Ett klientfel uppstod och begäran kunde inte slutföras.",
"invalid_form": "Formuläret är ogiltigt. Kontrollera eventuella fel",
"loading": "Sidan läses in, vad god vänta",
"no": "Nej",
"not_found": "Antingen skrev du fel URL eller så följde du en ogiltig länk.",
"yes": "Ja",
"unsaved_changes": "Du har osparade ändringar. Ignorera dem?"
},
"navigation": {
"no_results": "Inga resultat hittades",
"no_more_results": "Sidnumret %{page} existerar inte. Gå tillbaka till föregående sida.",
"page_out_of_boundaries": "Sidnumret %{page} existerar inte",
"page_out_from_end": "Finns inga fler sidor",
"page_out_from_begin": "Det finns ingen sida före sidan 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}",
"page_rows_per_page": "Antal per sida:",
"next": "Nästa",
"prev": "Föregående",
"skip_nav": "Skippa till innehåll"
},
"notification": {
"updated": "Spellistan har uppdaterats |||| %{smart_count} objekt som har uppdaterats",
"created": "Spellistan har skapats",
"deleted": "Spellistan har tagits bort |||| %{smart_count} objekt som tagits bort",
"bad_item": "Felaktigt element",
"item_doesnt_exist": "Spellistan hittades ej",
"http_error": "Kommunikationsfel med servern",
"data_provider_error": "dataProvider fel. Kontrollera din konsol för ytterligare detaljer.",
"i18n_error": "Det gick inte att läsa in översättningen av det begärda språket",
"canceled": "Åtgärden avbröts",
"logged_out": "Sessionen har löpt ut, anslut igen",
"new_version": ""
},
"toggleFieldsMenu": {
"columnsToDisplay": "",
"layout": "",
"grid": "",
"table": ""
}
"album": {
"name": "Album |||| Album",
"fields": {
"albumArtist": "Albumartist",
"artist": "Artist",
"duration": "Längd",
"songCount": "Antal låtar",
"playCount": "Spelningar",
"size": "Storlek",
"name": "Namn",
"genre": "Genre",
"compilation": "Samling",
"year": "År",
"originalDate": "Originaldatum",
"releaseDate": "Utgivningsdatum",
"releases": "Utgåva |||| Utgåvor",
"released": "Utgiven",
"updatedAt": "Uppdaterad",
"comment": "Kommentar",
"rating": "Betyg",
"createdAt": "Skapad"
},
"actions": {
"playAll": "Spela",
"playNext": "Spela härnäst",
"addToQueue": "Lägg till i kön",
"share": "Dela",
"shuffle": "Shuffle",
"addToPlaylist": "Lägg till i spellista",
"download": "Ladda ner",
"info": "Mer information"
},
"lists": {
"all": "Alla",
"random": "Blanda",
"recentlyAdded": "Senast tillagda",
"recentlyPlayed": "Senast spelade",
"mostPlayed": "Mest spelade",
"starred": "Favoriter",
"topRated": "Bästa betyg"
}
},
"message": {
"note": "NOTERA",
"transcodingDisabled": "Inställning för kodning via webbplattformen är ej aktiverat pga säkerhetsskäl. Starta om servern med alternativet %{config} markerat.",
"transcodingEnabled": "Navidrome körs för närvarande med %{config}, vilket gör att systemkommandon kan köras från webbplattformen. Du rekommenderas av säkerhetsskäl att du stänger av den och bara slår på den när du ställer in omkodning.",
"songsAddedToPlaylist": "Lade till 1 låt i spellistan |||| Lade till %{smart_count} låtar i spellistan",
"noPlaylistsAvailable": "Ingen tillgänglig",
"delete_user_title": "Ta bort användare '%{name}'",
"delete_user_content": "Vill du ta bort den här användaren och tillhörande data (inklusive spellistor och inställningar)?",
"notifications_blocked": "",
"notifications_not_available": "",
"lastfmLinkSuccess": "",
"lastfmLinkFailure": "",
"lastfmUnlinkSuccess": "",
"lastfmUnlinkFailure": "",
"openIn": {
"lastfm": "",
"musicbrainz": ""
},
"lastfmLink": "",
"listenBrainzLinkSuccess": "",
"listenBrainzLinkFailure": "",
"listenBrainzUnlinkSuccess": "",
"listenBrainzUnlinkFailure": "",
"downloadOriginalFormat": "",
"shareOriginalFormat": "",
"shareDialogTitle": "",
"shareBatchDialogTitle": "",
"shareSuccess": "",
"shareFailure": "",
"downloadDialogTitle": "",
"shareCopyToClipboard": ""
"artist": {
"name": "Artist |||| Artister",
"fields": {
"name": "Namn",
"albumCount": "Antal album",
"songCount": "Antal låtar",
"size": "Storlek",
"playCount": "Spelningar",
"rating": "Betyg",
"genre": "Genre"
}
},
"menu": {
"library": "Bibliotek",
"settings": "Inställningar",
"version": "Version",
"theme": "Tema",
"personal": {
"name": "Profil",
"options": {
"theme": "Tema",
"language": "Språk",
"defaultView": "Standardvy",
"desktop_notifications": "Skrivbords notiser",
"lastfmScrobbling": "",
"listenBrainzScrobbling": "",
"replaygain": "",
"preAmp": "",
"gain": {
"none": "",
"album": "",
"track": ""
}
}
},
"albumList": "Album",
"about": "Om",
"playlists": "",
"sharedPlaylists": ""
"user": {
"name": "Användare |||| Användare",
"fields": {
"userName": "Användarnamn",
"isAdmin": "Är admin",
"lastLoginAt": "Senaste inloggning",
"updatedAt": "Uppdaterad",
"name": "Namn",
"password": "Lösenord",
"createdAt": "Skapad",
"changePassword": "Byt lösenord?",
"currentPassword": "Nuvarande lösenord",
"newPassword": "Nytt lösenord",
"token": "Token"
},
"helperTexts": {
"name": "Ändringar av ditt namn syns först vid nästa inloggning."
},
"notifications": {
"created": "Användare skapad",
"updated": "Användare uppdaterad",
"deleted": "Användare borttagen"
},
"message": {
"listenBrainzToken": "Ange din ListenBrainz användar-token.",
"clickHereForToken": "Klicka här för att hämta din token"
}
},
"player": {
"playListsText": "Spellista",
"openText": "Öppna",
"closeText": "Stäng",
"notContentText": "Ingen musik",
"clickToPlayText": "Tryck för att spela",
"clickToPauseText": "Tryck för att pausa",
"nextTrackText": "Nästa låt",
"previousTrackText": "Föregående låt",
"reloadText": "Ladda om",
"volumeText": "Volym",
"toggleLyricText": "Låttext",
"toggleMiniModeText": "Minimera",
"destroyText": "Ta bort",
"downloadText": "Hämta",
"removeAudioListsText": "Töm spellista",
"clickToDeleteText": "Tryck för att ta bort %{name}",
"emptyLyricText": "Ingen låttext",
"playModeText": {
"order": "Normal",
"orderLoop": "Upprepa",
"singleLoop": "Upprepa en gång",
"shufflePlay": "Blanda"
}
"name": "Spelare |||| Spelare",
"fields": {
"name": "Namn",
"transcodingId": "Omkodning",
"maxBitRate": "Max. bitrate",
"client": "Klient",
"userName": "Användarnamn",
"lastSeen": "Senast sedd",
"reportRealPath": "Visa hela sökvägen",
"scrobbleEnabled": "Scrobbla till extern tjänst"
}
},
"about": {
"links": {
"homepage": "Hemsida",
"source": "Källkod",
"featureRequests": "Github issues"
}
"transcoding": {
"name": "Omkodning |||| Omkodningar",
"fields": {
"name": "Namn",
"targetFormat": "Målformat",
"defaultBitRate": "Standardbitrate",
"command": "Kommando"
}
},
"activity": {
"title": "Aktivitet",
"totalScanned": "Genomsökta mappar",
"quickScan": "Snabb genomsökning",
"fullScan": "Fullständig genomsökning\n",
"serverUptime": "Server uptime",
"serverDown": "OFFLINE"
"playlist": {
"name": "Spellista |||| Spellistor",
"fields": {
"name": "Namn",
"duration": "Längd",
"ownerName": "Ägare",
"public": "Offentlig",
"updatedAt": "Uppdaterad",
"createdAt": "Skapad",
"songCount": "Låtar",
"comment": "Kommentar",
"sync": "Auto-import",
"path": "Importera från"
},
"actions": {
"selectPlaylist": "Välj en spellista:",
"addNewPlaylist": "Skapa \"%{name}\"",
"export": "Exportera",
"makePublic": "Gör offentlig",
"makePrivate": "Gör privat"
},
"message": {
"duplicate_song": "Lägg till dubletter",
"song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?"
}
},
"help": {
"title": "Navidrome Hotkeys",
"hotkeys": {
"show_help": "Visa denna hjälp",
"toggle_menu": "Växla meny",
"toggle_play": "Spela / Pausa",
"prev_song": "Föregående låt",
"next_song": "Nästa låt",
"vol_up": "Volym Upp",
"vol_down": "Volym Ner",
"toggle_love": "Lägg till låt i Favoriter",
"current_song": ""
}
"radio": {
"name": "Radio |||| Radior",
"fields": {
"name": "Namn",
"streamUrl": "Stream-URL",
"homePageUrl": "Hemside-URL",
"updatedAt": "Uppdaterad",
"createdAt": "Skapad"
},
"actions": {
"playNow": "Spela nu"
}
},
"share": {
"name": "Dela |||| Delningar",
"fields": {
"username": "Delad av",
"url": "URL",
"description": "Beskrivning",
"downloadable": "Tillåt nedladdning?",
"contents": "Innehåll",
"expiresAt": "Giltig till",
"lastVisitedAt": "Senast besökt",
"visitCount": "Besök",
"format": "Format",
"maxBitRate": "Max. bitrate",
"updatedAt": "Uppdaterad",
"createdAt": "Skapad"
},
"notifications": {},
"actions": {}
}
}
},
"ra": {
"auth": {
"welcome1": "Tack för att du installerade Navidrome!",
"welcome2": "Skapa först ett admin-konto",
"confirmPassword": "Bekräfta lösenord",
"buttonCreateAdmin": "Skapa admin-konto",
"auth_check_error": "Logga in för att fortsätta",
"user_menu": "Profil",
"username": "Användarnamn",
"password": "Lösenord",
"sign_in": "Logga in",
"sign_in_error": "Felaktig inloggning, försök igen",
"logout": "Logga ut"
},
"validation": {
"invalidChars": "Använd enbart bokstäver och siffror",
"passwordDoesNotMatch": "Lösenordet matchar inte",
"required": "Krävs",
"minLength": "Måste ha minst %{min} tecken",
"maxLength": "Får maximalt ha %{max} tecken",
"minValue": "Måste vara minst %{min}",
"maxValue": "Får maximalt vara %{max}",
"number": "Måste vara ett nummer",
"email": "Måste vara en giltig e-postadress",
"oneOf": "Måste vara en av: %{options}",
"regex": "Måste matcha ett specifikt format (regexp): %{pattern}",
"unique": "Måste vara unik",
"url": "Måste vara en giltig URL"
},
"action": {
"add_filter": "Lägg till filter",
"add": "Lägg till",
"back": "Tillbaka",
"bulk_actions": "1 objekt vald |||| %{smart_count} objekt valda",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Avbryt",
"clear_input_value": "Rensa",
"clone": "Klona",
"confirm": "Bekräfta",
"create": "Skapa",
"delete": "Ta bort",
"edit": "Redigera",
"export": "Exportera",
"list": "Lista",
"refresh": "Uppdatera",
"remove_filter": "Ta bort filter",
"remove": "Radera",
"save": "Spara",
"search": "Sök",
"show": "Visa",
"sort": "Sortera",
"undo": "Ångra",
"expand": "Expandera",
"close": "Stäng",
"open_menu": "Öppna meny",
"close_menu": "Stäng meny",
"unselect": "Avmarkera",
"skip": "Hoppa över",
"share": "Dela",
"download": "Ladda ner"
},
"boolean": {
"true": "Ja",
"false": "Nej"
},
"page": {
"create": "Skapa %{name}",
"dashboard": "Dashboard",
"edit": "%{name} #%{id}",
"error": "Ett fel uppstod",
"list": "%{name}",
"loading": "Laddar",
"not_found": "Hittade inget",
"show": "%{name} #%{id}",
"empty": "Ingen %{name} ännu.",
"invite": "Vill du lägga till en?"
},
"input": {
"file": {
"upload_several": "Dra och släpp filer som ska laddas upp eller klicka för att välja dem.",
"upload_single": "Dra och släpp en fil som ska laddas upp eller klicka för att välja en fil."
},
"image": {
"upload_several": "Dra och släpp bilder som ska laddas upp eller klicka för att välja dem.",
"upload_single": "Dra och släpp en bild som ska laddas upp eller klicka för att välja en bild."
},
"references": {
"all_missing": "Hittade ingen referensdata.",
"many_missing": "Minst en av de associerade referenserna verkar inte längre vara tillgänglig.",
"single_missing": "Associerade referenser verkar inte längre vara tillgängliga."
},
"password": {
"toggle_visible": "Dölj password",
"toggle_hidden": "Visa password"
}
},
"message": {
"about": "Om",
"are_you_sure": "Är du säker",
"bulk_delete_content": "Vill du verkligen ta bort %{name}? |||| Vill du verkligen ta bort dessa %{smart_count} objekt?",
"bulk_delete_title": "Ta bort %{name} |||| Ta bort %{smart_count} %{name}",
"delete_content": "Vill du verkligen ta bort detta innehåll?",
"delete_title": "Ta bort %{name} #%{id}",
"details": "Detaljer",
"error": "Ett klientfel uppstod och begäran kunde inte slutföras.",
"invalid_form": "Formuläret är ogiltigt. Kontrollera eventuella fel",
"loading": "Sidan läses in, var god vänta",
"no": "Nej",
"not_found": "Antingen skrev du fel URL eller så följde du en ogiltig länk.",
"yes": "Ja",
"unsaved_changes": "Du har osparade ändringar. Ignorera dem?"
},
"navigation": {
"no_results": "Inga resultat hittades",
"no_more_results": "Sidnumret %{page} finns inte. Gå tillbaka till föregående sida.",
"page_out_of_boundaries": "Sidnumret %{page} finns inte",
"page_out_from_end": "Det finns inga fler sidor",
"page_out_from_begin": "Det finns ingen sida före sida 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}",
"page_rows_per_page": "Antal per sida:",
"next": "Nästa",
"prev": "Föregående",
"skip_nav": "Hoppa till innehåll"
},
"notification": {
"updated": "Element uppdaterat |||| %{smart_count} element uppdaterade",
"created": "Element skapat",
"deleted": "Element borttaget |||| %{smart_count} element borttagna",
"bad_item": "Felaktigt element",
"item_doesnt_exist": "Element finns inte",
"http_error": "Kommunikationsfel med servern",
"data_provider_error": "Fel i dataProvider. Kontrollera din konsol för mer information",
"i18n_error": "Kunde inte läsa in översättningen av det valda språket",
"canceled": "Åtgärden avbröts",
"logged_out": "Sessionen har avslutats, anslut på nytt",
"new_version": "Det finns en ny version! Uppdatera detta fönster."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Kolumner att visa",
"layout": "Layout",
"grid": "Rutnät",
"table": "Tabell"
}
},
"message": {
"note": "OBSERVERA",
"transcodingDisabled": "Inställning för kodning via webbgränssnittet är av säkerhetsskäl ej aktiverat. Starta om servern med alternativet %{config} markerat om du vill göra ändringar.",
"transcodingEnabled": "Navidrome körs för närvarande med %{config}, vilket gör att systemkommandon kan köras från webbplattformen. Du rekommenderas av säkerhetsskäl att du stänger av den och bara slår på den när du ställer in omkodning.",
"songsAddedToPlaylist": "La till en låt i spellistan |||| La till %{smart_count} låtar i spellistan",
"noPlaylistsAvailable": "Ingen tillgänglig",
"delete_user_title": "Ta bort användare '%{name}'",
"delete_user_content": "Är du säker på att du vill ta bort denna användare och alla deras spellistor och inställningar?",
"notifications_blocked": "Du har blockerat meddelanden från denna sajt in din webbläsares inställningar",
"notifications_not_available": "Denna webbläsare stödjer inte skrivbordsmeddelanden eller du använder inte Navidrome via https",
"lastfmLinkSuccess": "Last.fm är länkat och scrobbling är aktivt",
"lastfmLinkFailure": "Last.fm kunde inte länkas",
"lastfmUnlinkSuccess": "Last.fm är inte längre länkat och scrobbling är deaktiverat",
"lastfmUnlinkFailure": "Last.fm kunde inte avlänkas",
"listenBrainzLinkSuccess": "ListenBrainz är länkat och scrobbling är aktivt som användare: %{user}",
"listenBrainzLinkFailure": "ListenBrainz kunde inte länkas: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz är inte längre länkat och scrobbling är deaktiverat",
"listenBrainzUnlinkFailure": "ListenBrainz kunde inte avlänkas",
"openIn": {
"lastfm": "Öppna i Last.fm",
"musicbrainz": "Öppna i MusicBrainz"
},
"lastfmLink": "Läs mer...",
"shareOriginalFormat": "Dela i originalformat",
"shareDialogTitle": "Dela %{resource} '%{name}'",
"shareBatchDialogTitle": "Dela en %{resource} |||| Dela %{smart_count} %{resource}",
"shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter",
"shareSuccess": "URL kopierades till urklipp: %{url}",
"shareFailure": "Fel vid kopiering av URL %{url} till urklipp",
"downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})",
"downloadOriginalFormat": "Ladda ner i originalformat"
},
"menu": {
"library": "Bibliotek",
"settings": "Inställningar",
"version": "Version",
"theme": "Tema",
"personal": {
"name": "Personligt",
"options": {
"theme": "Tema",
"language": "Språk",
"defaultView": "Standardvy",
"desktop_notifications": "Skrivbordsmeddelanden",
"lastfmScrobbling": "Scrobbla till Last.fm",
"listenBrainzScrobbling": "Scrobbla till ListenBrainz",
"replaygain": "ReplayGain-läge",
"preAmp": "ReplayGain PreAmp (dB)",
"gain": {
"none": "Inaktiverad",
"album": "Använd gain för album",
"track": "Använd gain für låtar"
}
}
},
"albumList": "Album",
"playlists": "Spellistor",
"sharedPlaylists": "Delade spellistor",
"about": "Om"
},
"player": {
"playListsText": "Spela kön",
"openText": "Öppna",
"closeText": "Stäng",
"notContentText": "Ingen musik",
"clickToPlayText": "Klicka för att spela",
"clickToPauseText": "Klicka för att pausa",
"nextTrackText": "Nästa låt",
"previousTrackText": "Föregående låt",
"reloadText": "Ladda om",
"volumeText": "Volym",
"toggleLyricText": "Låttext av/på",
"toggleMiniModeText": "Minimera",
"destroyText": "Radera",
"downloadText": "Ladda ner",
"removeAudioListsText": "Ta bort audiolistor",
"clickToDeleteText": "Klicka för att ta bort %{name}",
"emptyLyricText": "Ingen låttext",
"playModeText": {
"order": "I ordningsföljd",
"orderLoop": "Upprepa",
"singleLoop": "Upprepa en",
"shufflePlay": "Shuffle"
}
},
"about": {
"links": {
"homepage": "Hemsida",
"source": "Källkod",
"featureRequests": "Funktionalitetförfrågan"
}
},
"activity": {
"title": "Aktivitet",
"totalScanned": "Genomsökta mappar",
"quickScan": "Snabbscan",
"fullScan": "Komplett scan",
"serverUptime": "Serverdrifttid",
"serverDown": "OFFLINE"
},
"help": {
"title": "Navidrome kortkommandon",
"hotkeys": {
"show_help": "Visa denna hjälp",
"toggle_menu": "Växla sidomeny",
"toggle_play": "Spela / pausa",
"prev_song": "Föregående låt",
"next_song": "Nästa låt",
"current_song": "Hoppa till nuvarande låt",
"vol_up": "Volym upp",
"vol_down": "Volym ner",
"toggle_love": "Lägg till låt i favoriter"
}
}
}

View File

@@ -2,7 +2,7 @@
"languageName": "Українська",
"resources": {
"song": {
"name": "Пісня Пісні",
"name": "Пісня |||| Пісні",
"fields": {
"albumArtist": "Виконавець альбому",
"duration": "Тривалість",
@@ -39,7 +39,7 @@
}
},
"album": {
"name": "Альбом Альбоми",
"name": "Альбом |||| Альбоми",
"fields": {
"albumArtist": "Автор Альбому",
"artist": "Виконавець",
@@ -55,10 +55,10 @@
"rating": "Рейтинг",
"createdAt": "Додано",
"size": "Розмір",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"originalDate": "Оригінал",
"releaseDate": "Дата випуску",
"releases": "Випуск |||| Випуски",
"released": "Випущений"
},
"actions": {
"playAll": "Прослухати",
@@ -81,7 +81,7 @@
}
},
"artist": {
"name": "Виконавець Виконавці",
"name": "Виконавець |||| Виконавці",
"fields": {
"name": "Назва",
"albumCount": "Кількість альбомів",
@@ -93,7 +93,7 @@
}
},
"user": {
"name": "Користувач Користувачі",
"name": "Користувач |||| Користувачі",
"fields": {
"userName": "Ім’я користувача",
"isAdmin": "Є адміністратором",
@@ -121,7 +121,7 @@
}
},
"player": {
"name": "Програвач Програвачі",
"name": "Програвач |||| Програвачі",
"fields": {
"name": "Назва",
"transcodingId": "ID транскодування",
@@ -134,7 +134,7 @@
}
},
"transcoding": {
"name": "Транскодування Транскодування",
"name": "Транскодувальник |||| Транскодувальники",
"fields": {
"name": "Назва",
"targetFormat": "Цільовий формат",
@@ -169,7 +169,7 @@
}
},
"radio": {
"name": "Радіо |||| Радіо",
"name": "Радіостанція |||| Радіостанції",
"fields": {
"name": "Назва",
"streamUrl": "Посилання на стрім",
@@ -232,7 +232,7 @@
"add_filter": "Додати фільтр",
"add": "Додати",
"back": "Повернутися назад",
"bulk_actions": "%{smart_count} обрано",
"bulk_actions": "1 обрано |||| %{smart_count} обрано",
"cancel": "Відмінити",
"clear_input_value": "Очистити",
"clone": "Клонувати",
@@ -381,7 +381,7 @@
"version": "Версія",
"theme": "Тема",
"personal": {
"name": "Особистий",
"name": "Особисті налаштування",
"options": {
"theme": "Тема",
"language": "Мова",

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