Compare commits

...

228 Commits

Author SHA1 Message Date
Deluan
af5c2b5a42 Round song duration (instead of truncating it). Relates to #1926 2022-10-10 21:33:00 -04:00
Deluan
62e7492357 Add Linkify test 2022-10-07 17:44:16 -04:00
Deluan
53a4ea673b Linkify urls in playlist comments 2022-10-07 16:12:07 -04:00
Deluan
c530ccf138 Linkify urls in album comments. Fixes #1053, supersedes #1570 and #1169
Simple approach, may be extended/enhanced in the future.
2022-10-06 23:46:30 -04:00
Deluan
fa5dc5af10 Fix adding songs to plain playlists 2022-10-06 19:45:31 -04:00
Deluan
bbd3882a75 Some clean-up in criteria package 2022-10-04 15:24:29 -04:00
Deluan
12b4a48842 Fix get info dialog in artist page. Closes #1909 2022-10-04 12:30:04 -04:00
dependabot[bot]
37f7625c7d Bump github.com/prometheus/client_golang from 1.12.1 to 1.13.0 (#1902)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.12.1 to 1.13.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.12.1...v1.13.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>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-10-02 20:34:29 -04:00
dependabot[bot]
7612a55859 Bump github.com/mileusna/useragent from 1.2.0 to 1.2.1 (#1901)
Bumps [github.com/mileusna/useragent](https://github.com/mileusna/useragent) from 1.2.0 to 1.2.1.
- [Release notes](https://github.com/mileusna/useragent/releases)
- [Commits](https://github.com/mileusna/useragent/compare/v1.2.0...v1.2.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-10-02 20:34:01 -04:00
Deluan
3d5a1cef92 Don't allow adding songs to smart playlists 2022-10-02 20:14:15 -04:00
Aleksey Lobanov
552989a05b Add basic Prometheus metrics handler (#1830)
* feat: Add Prometheus configuration options

* feat: Add Prometheus metrics handler

* build: prometheus became direct dependency

* docs: change description for prometheus metrics path
2022-10-02 19:59:53 -04:00
Renere
6a6fa3e3b5 Nord Theme - Make links have a different colour (#1900) 2022-10-01 22:23:33 -04:00
Zane van Iperen
c7ef4bd803 Capture "musicbrainz_releasetrackid" tag (#1827)
* db/migration: typo fix

* model: add MbzReleaseTrackID field

* scanner: capture the musicbrainz_releasetrackid tag
2022-10-01 12:13:47 -04:00
Renere
22507c9789 Add Nord Theme. Closes #1158 and supersedes #1159 (#1899).
* Re-add tpbnick's Nord theme

* Run Prettier formatter on Nord theme

* Update themes index

* Fix button margins

* Modernise the look of switches

* Adjust margins and padding

* Fix sidebar's background colour not applying to all of sidebar when scrolling down

* Adjust App Bar box shadow

* Adjust roundedness

* Adjust shadows

* Adjust outlined inputs

* Add transitions to items in sidebar when hovered / losing hover

* Adjust border radiuses

* Adjust pagination buttons

* Add big play button from Spotify theme

* Remove playlist background gradient

* Adjust colour of MuiChip elelments

* Adjust table borders

* Remove duplicate MuiTableRow key

* Attempt to make switches in both the playlist section and settings section visable against background & the toggle. Not ideal.

* Style the player

* Format CSS to Prettier standards

* Fix mobile player style

* Make play button in album grid view blue

* Make main view background lighter
2022-10-01 12:01:21 -04:00
Deluan
87feac041b Add make target to download some music for development purposes. Closes #1703 2022-09-30 23:10:33 -04:00
Deluan
f82df70302 Add nilerr linter 2022-09-30 20:18:14 -04:00
Deluan
364e699ac1 Add asciicheck, bidichk, and durationcheck linters 2022-09-30 20:17:59 -04:00
Deluan
0798959be8 Add asasalint linter 2022-09-30 19:55:44 -04:00
William Lohan
4209e14208 Add theme Electric Purple (#1889)
* add theme file

add theme file electricPurple.js

* import theme file 

import theme file  electricPurple

* add electricPurple.css.js
2022-09-30 19:54:00 -04:00
Deluan
77dbafff0f Add errorlint linter 2022-09-30 19:33:39 -04:00
Deluan
db67c1277e Fix error comparisons 2022-09-30 18:54:25 -04:00
Deluan
7b0a8f47de Add exportloopref linter 2022-09-30 18:23:47 -04:00
William Lohan
16865f0fca remove deprecated linters (#1898) 2022-09-30 18:11:44 -04:00
Deluan
5965459bb9 Update browserlist db 2022-09-30 13:33:42 -04:00
Steve Richter
66818b25ec Allow ExternalLink icons to be styled (#1503)
* Allow ArtistExternalLink icons to be styled

* Allow AlbumExternalLink icons to be styled

* Standardize external links' classes to kebab-case

Co-authored-by: Deluan <deluan@navidrome.org>
2022-09-30 13:33:35 -04:00
Deluan
e7fab8bb7b Show AlbumArtist in Album table view. Fixes #1626 2022-09-29 16:47:44 -04:00
joaomqc
8befe10ee6 fix(UI): Warn if track is already present when adding to playlist - 1604 (#1897)
* fix(UI): Warn if track is already present when adding to playlist - 1604

Signed-off-by: joaomqc <joaomqc@hotmail.com>

* fix tests

Signed-off-by: joaomqc <joaomqc@hotmail.com>

Signed-off-by: joaomqc <joaomqc@hotmail.com>
Co-authored-by: João Coelho <1120458@isep.ipp.pt>
2022-09-29 13:19:14 -04:00
Deluan
218d14727a Bump redux and react-redux versions 2022-09-29 11:05:05 -04:00
Evan.Shu
50a4ce6ba2 Fix add playlist dialog (#1758) 2022-09-28 22:15:39 -04:00
henning mueller
8130c05ccc Mount devcontainer workspace SELinux compatible (#1816) 2022-09-28 22:10:06 -04:00
Deluan
15952a3c7f npm audit fix 2022-09-28 22:01:13 -04:00
Nemo Xiong
9a99a2bd49 Update Chinese (simplified) translations (#1633)
* add new translations

* translation: fix improper full width character usage in zh-Hans translation

Full width % messed up with format strings.

* translation: fix two machine translations in zh-Hans

* translation: fix one mistranslation in zh-Hans

* translation: fix format in zh-Hans

* translation: fix format and two translations in zh-Hans

* translation: fix format in zh-Hans
2022-09-28 21:47:48 -04:00
dependabot[bot]
c7b65509ae Bump @testing-library/jest-dom from 5.15.0 to 5.16.5 in /ui (#1836)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 5.15.0 to 5.16.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/v5.15.0...v5.16.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-28 21:43:26 -04:00
Deluan
6b09dc7198 Fix new test-library eslint errors 2022-09-28 21:30:20 -04:00
Deluan
86ab35069d Upgrade react-scripts to 5.0.1
This also upgrades WebPack to v5, which should fix the issue #1768
2022-09-28 21:03:22 -04:00
Deluan
413292da6b Reduce go mod download verbosity 2022-09-28 20:27:53 -04:00
Deluan
694968c607 Bump dependencies 2022-09-28 13:25:08 -04:00
Deluan
6dc70d6810 Don't reset language to default after logout 2022-09-28 13:06:32 -04:00
Deluan
bfeb8ef6b3 DefaultLanguage is now case-insensitive 2022-09-28 11:30:22 -04:00
Deluan Quintão
ba28e9a109 Update README. Fixes #1834 2022-09-27 21:32:23 -04:00
Andy Klimczak
2f7a3c5eda feat: Add listenbrainz base url configuration (#1774)
* feat: Add listenbrainz base url configuration

- ListenBrainz.BaseURL config value

* Don't need to store baseUrl

* Use `url.JoinPath` to concatenate url paths

* Replace url.JoinPath (Go 1.19 only) with custom function

Co-authored-by: Deluan <deluan@navidrome.org>
2022-09-27 21:06:28 -04:00
Deluan
cb3ba23fce New config DefaultLanguage. Closes #1561 2022-09-27 19:31:09 -04:00
Manuel
72cde6dfde fix:(middlewares.go) - Set Cookie SameSite mode to Strict - 1776 (#1777)
* None is deprecated and will fallback to Lax in the future.
* Using Strict is future proof and provides additional CSR protection

Signed-off-by: Manuel Kroeber <manuel.kroeber@gmail.com>

Signed-off-by: Manuel Kroeber <manuel.kroeber@gmail.com>
2022-09-27 17:58:47 -04:00
Kendall Garner
751e42c705 Fix creating server (#1894) 2022-09-27 16:53:40 -04:00
Deluan
ded9ab53e5 Use armhf for ARM builds 2022-09-27 16:47:47 -04:00
Deluan
416b5c7d13 Fix Linux 32 bits build 2022-09-26 23:54:03 -04:00
Deluan
afb31c3eae Fix invalid option in pipeline 2022-09-26 22:56:17 -04:00
Deluan
dd57278ba2 Upgrade to GoLang 1.19 and bump golangci-lint version 2022-09-26 22:44:54 -04:00
Deluan
2a3cd08f20 Fix GO-S2114 security issue
See https://deepsource.io/directory/analyzers/go/issues/GO-S2114
2022-09-26 22:33:42 -04:00
Deluan
a7a0e23956 Fix formatting 2022-09-26 21:28:10 -04:00
Deluan
4cf43ed735 Only compute version once 2022-09-14 21:09:39 -04:00
Deluan
ebad96b8a4 Fix warning about mixing value and pointer receivers 2022-08-21 14:42:17 -04:00
Deluan
e981ee27c0 Add test for WithTx 2022-07-30 13:07:38 -04:00
Deluan
965dbccd48 Upgrade to latest go-sqlite3 (it's v1.14, not v2!) 2022-07-30 12:46:20 -04:00
Deluan
695f82a1a0 Upgrade to Beego 2's orm 2022-07-30 12:43:48 -04:00
Deluan
16afd3a490 Remove //+build tags, as the code does not compile on older versions of Go anymore 2022-07-29 08:41:28 -04:00
Deluan
67f2a89d89 Fix tracks never "loved" to be selected in Smart Playlists. Refer to https://github.com/navidrome/navidrome/issues/1417#issuecomment-1163423575 2022-07-27 21:09:39 -04:00
dependabot[bot]
bf1f93ef1a Bump github.com/go-chi/httprate from 0.5.2 to 0.6.0 (#1828)
Bumps [github.com/go-chi/httprate](https://github.com/go-chi/httprate) from 0.5.2 to 0.6.0.
- [Release notes](https://github.com/go-chi/httprate/releases)
- [Commits](https://github.com/go-chi/httprate/compare/v0.5.2...v0.6.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>
2022-07-27 15:29:30 -04:00
Deluan
ebf7354df4 Add more info in search log message 2022-07-27 14:59:01 -04:00
Deluan
c0066ebd85 Add log warn when request is cancelled/interrupted 2022-07-27 14:27:18 -04:00
Deluan
cd5bce7b16 Speed up /search subsonic endpoints by parallelizing the queries 2022-07-27 13:56:04 -04:00
Deluan
d613b19306 Simplify Singleton usage by leveraging Go 1.18's generics 2022-07-27 12:15:05 -04:00
Deluan
a2d9aaeff8 Fix Quality translation in Spanish 2022-07-27 10:42:04 -04:00
Deluan
49392e06a7 Update caniuse-lite 2022-07-26 17:48:29 -04:00
Deluan
181cb8a2b7 Remove interfacer linter, as it does not work with Go 1.18 and will not be updated (it is deprecated) 2022-07-26 16:59:52 -04:00
Deluan
31882abf6f Upgrade Ginkgo to V2 2022-07-26 16:53:17 -04:00
Deluan
0d8eaa2878 Remove experimental version of context package 2022-07-26 16:41:10 -04:00
Deluan
f4bffb1676 Update @djherbis's packages 2022-07-26 15:16:56 -04:00
Deluan
f21847308c Remove hardcoded github.com/dhowden/tag branch. Fix #1764 2022-07-26 15:10:16 -04:00
Deluan
9c3b14c5c4 Return 501 for "not implemented". Fixes #1785 2022-07-26 13:18:08 -04:00
Deluan
8cd405d15e Add IP to Subsonic API's invalid login log messages. Closes #1814 2022-07-25 23:54:49 -04:00
Deluan
35bec14d4d Add missing test case for #1778 2022-07-25 23:34:09 -04:00
Deluan
321b3c5a64 Fix fscache key mapping. Closes #1778 2022-07-25 23:01:19 -04:00
Deluan
b7e50f7731 Fix docker build in pipeline 2022-07-25 10:54:19 -04:00
dependabot[bot]
2e9c81c3de Bump github.com/mileusna/useragent from 1.0.2 to 1.1.0 (#1819)
Bumps [github.com/mileusna/useragent](https://github.com/mileusna/useragent) from 1.0.2 to 1.1.0.
- [Release notes](https://github.com/mileusna/useragent/releases)
- [Commits](https://github.com/mileusna/useragent/compare/v1.0.2...v1.1.0)

---
updated-dependencies:
- dependency-name: github.com/mileusna/useragent
  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>
2022-07-25 10:43:45 -04:00
dependabot[bot]
49647423aa Bump github.com/sirupsen/logrus from 1.8.1 to 1.9.0 (#1821)
Bumps [github.com/sirupsen/logrus](https://github.com/sirupsen/logrus) from 1.8.1 to 1.9.0.
- [Release notes](https://github.com/sirupsen/logrus/releases)
- [Changelog](https://github.com/sirupsen/logrus/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sirupsen/logrus/compare/v1.8.1...v1.9.0)

---
updated-dependencies:
- dependency-name: github.com/sirupsen/logrus
  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>
2022-07-25 10:42:51 -04:00
dependabot[bot]
9f62533bb0 Bump github.com/go-chi/cors from 1.2.0 to 1.2.1 (#1822)
Bumps [github.com/go-chi/cors](https://github.com/go-chi/cors) from 1.2.0 to 1.2.1.
- [Release notes](https://github.com/go-chi/cors/releases)
- [Commits](https://github.com/go-chi/cors/compare/v1.2.0...v1.2.1)

---
updated-dependencies:
- dependency-name: github.com/go-chi/cors
  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>
2022-07-25 10:08:48 -04:00
dependabot[bot]
7d58f4469a Bump github.com/lestrrat-go/jwx from 1.2.17 to 1.2.25 (#1742)
Bumps [github.com/lestrrat-go/jwx](https://github.com/lestrrat-go/jwx) from 1.2.17 to 1.2.25.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/v1.2.25/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v1.2.17...v1.2.25)

---
updated-dependencies:
- dependency-name: github.com/lestrrat-go/jwx
  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>
2022-07-25 10:08:06 -04:00
dependabot[bot]
974816f0a2 Bump github.com/onsi/gomega from 1.18.1 to 1.20.0 (#1817)
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.18.1 to 1.20.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.18.1...v1.20.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>
2022-07-24 20:35:38 -04:00
Deluan
7665478a52 Upgrade golangci-lint and fix new lint error 2022-07-24 19:30:23 -04:00
Deluan
bde5be347b Build with GoLang 1.18.4 2022-07-24 19:02:09 -04:00
Deluan
aae79b4561 Upgrade to GoLang 1.18 2022-07-24 15:31:22 -04:00
Ian Kerins
ce0db8344b Fix signaler not exiting on cancel (#1638)
* fix: make signaler exit on cancel

`break` is incorrect here, as it just breaks out of the select.
`return` to exit the function instead.

Fixes #1636.

Signed-off-by: Ian Kerins <ianskerins@gmail.com>

* fix: exit non-zero on fatal error

Signed-off-by: Ian Kerins <ianskerins@gmail.com>
2022-03-30 10:04:17 -04:00
Matt Doyle
5987cd0c08 Fixes a coloring glitch with the Monokai theme "unauthorized" popup (#1670)
* Fixes the coloring on the Monokai theme auth popup

* Indentation fix
2022-03-26 22:41:29 -04:00
Matt Doyle
e7cf74d863 Adds a Monokai theme (#1669)
* Adds a new Monokai theme

* Deletes a commented-out line
2022-03-26 21:14:13 -04:00
Deluan
2ddd3acba6 Fix translatable label 2022-02-10 18:18:03 -05:00
Deluan
028723f721 Fix loading overridden translations from ${DataFolder}/resources/i18n 2022-02-10 14:56:39 -05:00
Deluan
50ff8bcce7 Add "random" sort option for Smart Playlists 2022-02-09 09:39:42 -05:00
Deluan
e966d94c0b Force correct mime-type for JS and CSS files 2022-02-08 15:17:35 -05:00
dependabot[bot]
86fe1e3b2c Bump github.com/ReneKroon/ttlcache/v2 from 2.9.0 to 2.11.0 (#1529)
Bumps [github.com/ReneKroon/ttlcache/v2](https://github.com/ReneKroon/ttlcache) from 2.9.0 to 2.11.0.
- [Release notes](https://github.com/ReneKroon/ttlcache/releases)
- [Changelog](https://github.com/ReneKroon/ttlcache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ReneKroon/ttlcache/compare/v2.9.0...v2.11.0)

---
updated-dependencies:
- dependency-name: github.com/ReneKroon/ttlcache/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>
2022-01-21 19:35:19 -05:00
dependabot[bot]
eed54d7e10 Bump github.com/lestrrat-go/jwx from 1.2.11 to 1.2.17 (#1574)
Bumps [github.com/lestrrat-go/jwx](https://github.com/lestrrat-go/jwx) from 1.2.11 to 1.2.17.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/main/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v1.2.11...v1.2.17)

---
updated-dependencies:
- dependency-name: github.com/lestrrat-go/jwx
  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>
2022-01-21 19:26:28 -05:00
dependabot[bot]
ab36344d76 Bump github.com/microcosm-cc/bluemonday from 1.0.16 to 1.0.17 (#1560)
Bumps [github.com/microcosm-cc/bluemonday](https://github.com/microcosm-cc/bluemonday) from 1.0.16 to 1.0.17.
- [Release notes](https://github.com/microcosm-cc/bluemonday/releases)
- [Commits](https://github.com/microcosm-cc/bluemonday/compare/v1.0.16...v1.0.17)

---
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>
2022-01-21 19:26:11 -05:00
dependabot[bot]
e5d03a3bdb Bump github.com/Masterminds/squirrel from 1.5.1 to 1.5.2 (#1501)
Bumps [github.com/Masterminds/squirrel](https://github.com/Masterminds/squirrel) from 1.5.1 to 1.5.2.
- [Release notes](https://github.com/Masterminds/squirrel/releases)
- [Commits](https://github.com/Masterminds/squirrel/compare/v1.5.1...v1.5.2)

---
updated-dependencies:
- dependency-name: github.com/Masterminds/squirrel
  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>
2022-01-21 19:25:38 -05:00
Deluan Quintão
30813cd34b Update translations (#1578)
* Add Catalan translation

* Move Bulgarian translation to correct path

* Update zh-Hans.json (POEditor.com)

* Update zh-Hant.json (POEditor.com)

* Update cs.json (POEditor.com)

* Update da.json (POEditor.com)

* Update nl.json (POEditor.com)

* Update eo.json (POEditor.com)

* Update fi.json (POEditor.com)

* Update fr.json (POEditor.com)

* Update de.json (POEditor.com)

* Update it.json (POEditor.com)

* Update ja.json (POEditor.com)

* Update fa.json (POEditor.com)

* Update pl.json (POEditor.com)

* Update pt.json (POEditor.com)

* Update ru.json (POEditor.com)

* Update sl.json (POEditor.com)

* Update es.json (POEditor.com)

* Update th.json (POEditor.com)

* Update tr.json (POEditor.com)

* Update uk.json (POEditor.com)

* Update bg.json (POEditor.com)

* Update ca.json (POEditor.com)
2022-01-21 19:21:19 -05:00
MrEddX
6164f37c9e Added Bulgarian Translation (#1577)
Initial Release
2022-01-21 19:09:48 -05:00
Deluan
9e79b5cbf2 Fix potential SQL injection in Smart Playlists 2022-01-18 21:36:29 -05:00
Steve Richter
8c707b4e0c Handle invalid theme in ui state (#1504) 2022-01-05 18:47:14 -05:00
Deluan
910091f1f1 Fix playCount casing 2021-12-14 09:33:34 -05:00
Deluan
2e1b985d30 Revert "Direct link to dev build"
This reverts commit a99b9b4d44.
2021-12-10 12:41:19 -05:00
Deluan Quintão
100b80528e Update README.md 2021-12-09 12:24:43 -05:00
Deluan
bde9d5f954 Fix TypeError: Cannot read property 'id' of undefined 2021-12-03 17:15:39 -05:00
Deluan
69615f1aa1 Trying to fix multiple EventStream connections, one more time 2021-12-02 10:49:32 -05:00
Deluan
a99b9b4d44 Direct link to dev build 2021-11-29 22:57:26 -05:00
Deluan
9892524ab8 Connect eventStream after login 2021-11-29 18:49:29 -05:00
Deluan
9fe978953c Try to avoid creating multiple eventStreams 2021-11-29 17:47:34 -05:00
Deluan Quintão
5425c1a4d7 Update translations (#1489)
* Update fi.json (POEditor.com)

* Update fr.json (POEditor.com)

* Update de.json (POEditor.com)

* Update ja.json (POEditor.com)

* Update sl.json (POEditor.com)

* Update es.json (POEditor.com)
2021-11-26 12:34:22 -05:00
Deluan
afe1e4cfcd Fix lint for public credentials 2021-11-25 15:55:53 -05:00
Deluan
20cdd38fc4 Better logging for agents configuration 2021-11-25 15:48:32 -05:00
dependabot[bot]
913a4cf546 Bump github.com/onsi/gomega from 1.16.0 to 1.17.0 (#1459)
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.16.0 to 1.17.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.16.0...v1.17.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>
2021-11-23 23:43:32 -05:00
dependabot[bot]
121ada5acd Bump @testing-library/jest-dom from 5.14.1 to 5.15.0 in /ui (#1456)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 5.14.1 to 5.15.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/v5.14.1...v5.15.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>
2021-11-23 23:13:22 -05:00
dependabot[bot]
e59a95c9eb Bump github.com/golangci/golangci-lint from 1.42.1 to 1.43.0 (#1460)
Bumps [github.com/golangci/golangci-lint](https://github.com/golangci/golangci-lint) from 1.42.1 to 1.43.0.
- [Release notes](https://github.com/golangci/golangci-lint/releases)
- [Changelog](https://github.com/golangci/golangci-lint/blob/master/CHANGELOG.md)
- [Commits](https://github.com/golangci/golangci-lint/compare/v1.42.1...v1.43.0)

---
updated-dependencies:
- dependency-name: github.com/golangci/golangci-lint
  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>
2021-11-23 23:12:27 -05:00
Mahoo Huang
d75f286ae8 Update zh-Hans.json (#1478) 2021-11-23 23:11:49 -05:00
Deluan
30d3f1eda0 Add userRating to Subsonic Album/Artist responses. Closes #1486 2021-11-23 21:50:57 -05:00
dependabot[bot]
6a1f9678b1 Bump github.com/ReneKroon/ttlcache/v2 from 2.8.1 to 2.9.0 (#1414)
Bumps [github.com/ReneKroon/ttlcache/v2](https://github.com/ReneKroon/ttlcache) from 2.8.1 to 2.9.0.
- [Release notes](https://github.com/ReneKroon/ttlcache/releases)
- [Changelog](https://github.com/ReneKroon/ttlcache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ReneKroon/ttlcache/compare/v2.8.1...v2.9.0)

---
updated-dependencies:
- dependency-name: github.com/ReneKroon/ttlcache/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>
2021-11-22 12:50:42 -05:00
dependabot[bot]
a0977ce48a Bump github.com/go-chi/chi/v5 from 5.0.4 to 5.0.7 (#1484)
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.0.4 to 5.0.7.
- [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.4...v5.0.7)

---
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>
2021-11-22 12:29:10 -05:00
dependabot[bot]
b3d8038686 Bump github.com/lestrrat-go/jwx from 1.2.7 to 1.2.11 (#1485)
Bumps [github.com/lestrrat-go/jwx](https://github.com/lestrrat-go/jwx) from 1.2.7 to 1.2.11.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/main/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v1.2.7...v1.2.11)

---
updated-dependencies:
- dependency-name: github.com/lestrrat-go/jwx
  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>
2021-11-22 12:28:23 -05:00
Deluan
0714f08274 Recover from SIGSEGVs in taglib's code 2021-11-20 12:33:06 -05:00
Deluan
cbeaadf8e2 Fix logging smart playlist's song count 2021-11-20 12:29:09 -05:00
Deluan
3e282df639 Set volume to 100% when web player is in mobile mode.
Fix #1429
2021-11-19 19:45:18 -05:00
Deluan
ce7940bbfc Allow overriding name and comment when importing NSP playlists 2021-11-19 19:14:38 -05:00
Deluan
92c31c961d Fix values from annotation table cannot be compared to 0
Solves this issue: https://github.com/navidrome/navidrome/issues/1417#issuecomment-974052454
2021-11-19 18:22:33 -05:00
Deluan
4bf4765442 Bot that adds a download link on pull requests 2021-11-19 13:07:55 -05:00
Brice Johnson
6d947f6f7e Allowing 3rd party UIs to access x-total-count http header (#1470)
* Adding 'x-content-duratin' and 'x-total-count' to CORS exposed headers

* Moving cors setup to middlewares.go

* adding x-nd-authorization to exposed headers
2021-11-19 10:07:54 -05:00
Deluan Quintão
8c7d95c135 Update Translations (#1471)
* Update zh-Hans.json (POEditor.com)

* Update zh-Hant.json (POEditor.com)

* Update cs.json (POEditor.com)

* Update da.json (POEditor.com)

* Update nl.json (POEditor.com)

* Update eo.json (POEditor.com)

* Update fr.json (POEditor.com)

* Update de.json (POEditor.com)

* Update it.json (POEditor.com)

* Update ja.json (POEditor.com)

* Update fa.json (POEditor.com)

* Update pl.json (POEditor.com)

* Update pt.json (POEditor.com)

* Update ru.json (POEditor.com)

* Update sl.json (POEditor.com)

* Update es.json (POEditor.com)

* Update th.json (POEditor.com)

* Update tr.json (POEditor.com)

* Update uk.json (POEditor.com)

* Update fi.json (POEditor.com)
2021-11-18 17:52:12 -05:00
Deluan
d4447e373f Fix sorting albums by year (should use name as secondary sort field).
Relates to https://github.com/navidrome/navidrome/issues/961#issuecomment-967624681
2021-11-17 21:47:14 -05:00
Steve Richter
3bd6f82c80 Rename ListenBrainz config flag and enable by default (#1443) 2021-11-17 21:11:53 -05:00
BIKI DAS
da26c5cfe0 Combined multiple appends into a single one (#1464) 2021-11-17 19:51:44 -05:00
Deluan
023d7bfa8a Remove link from songs to artist (when artist has no albums) 2021-11-17 18:47:54 -05:00
Deluan
48a627885c Simplify prototype definition for taglib_read 2021-11-13 12:18:42 -05:00
Deluan
91b470c93b Show artist link in Songs lists 2021-11-05 20:25:12 -04:00
Deluan
1c82bf5179 Remove non-album artist_ids from the DB 2021-11-05 20:24:50 -04:00
Deluan
0d9dcebf32 Fix playlist cannot be empty via Subsonic API 2021-11-05 10:23:45 -04:00
Deluan
5994c31f4c Fix migration to support null values 2021-11-04 21:23:41 -04:00
Deluan
804fb716db Show in the logs how long it took to startup 2021-11-04 13:49:05 -04:00
Deluan
d3a2f769b7 Better logging of GetSimilar call 2021-11-03 15:59:16 -04:00
Deluan
68a84ec832 Smarter cache of external info calls (last.fm / spotify) 2021-11-03 14:13:50 -04:00
Deluan
9e48d87f84 Add a new index for album, to optimize the getAlbumList?type=alphabeticalByArtist Subsonic query 2021-11-02 22:08:21 -04:00
Deluan
9712a5b1c6 Fix error codes for required parameters in getAlbumList 2021-11-02 21:38:08 -04:00
Deluan
9422373be0 Optimize AlbumRepository.GetAll and add a GetAllWithoutGenres method specifically for Subsonic API, where multiple-genres are not required 2021-11-02 21:19:49 -04:00
dependabot[bot]
bc8132ef1f Bump @testing-library/user-event from 13.2.1 to 13.5.0 in /ui
Bumps [@testing-library/user-event](https://github.com/testing-library/user-event) from 13.2.1 to 13.5.0.
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v13.2.1...v13.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-02 15:54:18 -04:00
dependabot[bot]
82bc8cd0aa Bump github.com/go-chi/httprate from 0.5.1 to 0.5.2
Bumps [github.com/go-chi/httprate](https://github.com/go-chi/httprate) from 0.5.1 to 0.5.2.
- [Release notes](https://github.com/go-chi/httprate/releases)
- [Commits](https://github.com/go-chi/httprate/compare/v0.5.1...v0.5.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-02 15:53:53 -04:00
dependabot[bot]
1e5ab59df8 Bump github.com/onsi/ginkgo from 1.16.4 to 1.16.5
Bumps [github.com/onsi/ginkgo](https://github.com/onsi/ginkgo) from 1.16.4 to 1.16.5.
- [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/v1.16.4...v1.16.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-02 15:52:39 -04:00
dependabot[bot]
28ad91a9d6 Bump github.com/Masterminds/squirrel from 1.5.0 to 1.5.1
Bumps [github.com/Masterminds/squirrel](https://github.com/Masterminds/squirrel) from 1.5.0 to 1.5.1.
- [Release notes](https://github.com/Masterminds/squirrel/releases)
- [Commits](https://github.com/Masterminds/squirrel/compare/v1.5.0...v1.5.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-02 15:52:15 -04:00
dependabot[bot]
e40e86590c Bump github.com/microcosm-cc/bluemonday from 1.0.15 to 1.0.16
Bumps [github.com/microcosm-cc/bluemonday](https://github.com/microcosm-cc/bluemonday) from 1.0.15 to 1.0.16.
- [Release notes](https://github.com/microcosm-cc/bluemonday/releases)
- [Commits](https://github.com/microcosm-cc/bluemonday/compare/v1.0.15...v1.0.16)

---
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>
2021-11-02 15:49:33 -04:00
Deluan
12818fb590 Make song/album/artist endpoints read-only 2021-11-02 14:44:50 -04:00
Deluan
aaeaa3c590 Fix playlist filter 2021-11-02 12:56:43 -04:00
Deluan
053909196c More info in scrobbling logs 2021-11-02 12:25:29 -04:00
Deluan
6a87fc88f7 Ignores invalid timestamps in requests (use current time)
Fix this issue: https://www.reddit.com/r/navidrome/comments/ql3imf/scrobbling_fails_when_using_substreamer/
2021-11-02 10:33:40 -04:00
Deluan
975986ab16 Add bulk action to make playlists private/public
Better responsiveness
2021-11-01 21:27:36 -04:00
Deluan
778f474d26 Use new rest lib (Update receives all columns that need to be updated) 2021-11-01 21:27:36 -04:00
Deluan
b2acec0a09 When externals services are disabled, only disable UILoginBackgroundURL if it is not set by the user 2021-11-01 09:11:32 -04:00
Deluan
e7202339af Ignore empty lines in M3U files 2021-10-31 20:43:30 -04:00
Deluan
8c2e4da396 Fix dateLoved in criteria. Now log invalid field names in criteria 2021-10-31 20:31:11 -04:00
Deluan
a4d3bf42a7 Remove some duplicated code 2021-10-31 15:08:06 -04:00
Deluan
765557d739 Remove "Show" button from PlaylistEdit view 2021-10-31 15:08:06 -04:00
Deluan
86afd16cc8 Allow changing playlist's owner. Relates to #698 2021-10-31 15:08:06 -04:00
Deluan
133fed344f Add owner_id to playlist 2021-10-31 15:08:06 -04:00
Deluan
84bbcdbfc2 Add artist image lightbox 2021-10-30 20:05:01 -04:00
Deluan
1823159b25 New config to disable all external integrations. Closes #102 2021-10-30 18:03:46 -04:00
Deluan
0b5ed9eb80 Update ListenBrainz Portuguese translations 2021-10-30 16:19:39 -04:00
Steve Richter
a56d5bc850 Listenbrainz scrobbling (#1424)
* Refactor session_keys to its own package

* Adjust play_tracker

- Don't send external NowPlaying/Scrobble for tracks with unknown artist
- Continue to the next agent on error

* Implement ListenBrainz Agent and Auth Router

* Implement frontend for ListenBrainz linking

* Update listenBrainzRequest

- Don't marshal Player to json
- Rename Track to Title

* Return ErrRetryLater on ListenBrainz server errors

* Add tests for listenBrainzAgent

* Add tests for ListenBrainz Client

* Adjust ListenBrainzTokenDialog to handle errors better

* Refactor listenbrainz.formatListen and listenBrainzRequest structs

* Refactor agent auth_routers

* Refactor session_keys to agents package

* Add test for listenBrainzResponse

* Add tests for ListenBrainz auth_router

* Update ListenBrainzTokenDialog and auth_router

* Adjust player scrobble toggle
2021-10-30 12:17:42 -04:00
Steve Richter
ccc871d1f7 Only reset player scrobbled state on track change or end (#1432)
* Only reset player scrobbled state on track change or end

* Only reset player start time on track change or end
2021-10-30 12:09:40 -04:00
Deluan
d3e142233b Fix TypeError: Cannot read properties of undefined (reading 'length') 2021-10-29 18:10:17 -04:00
Deluan
a42aeff88d Optimize queries by path, should speed up the scanner a bit 2021-10-29 13:11:51 -04:00
Deluan
7cdbc04c5e Update caniuse-lite 2021-10-29 11:49:10 -04:00
Deluan
f3fae7e233 Optimize basic media_file query, avoiding adding "group by" or joining with genres if not required 2021-10-29 09:50:22 -04:00
Deluan
074732b1dc Filter playlists by names and comments 2021-10-28 13:58:06 -04:00
Deluan
66a9cbb7d9 Remove temp folders after tests 2021-10-28 10:40:31 -04:00
Deluan
fa3471f527 Simplify resources code, enabling any resource to be overridden (not just translations) 2021-10-28 10:25:25 -04:00
Deluan
9072412812 Fix translations on Windows 2021-10-28 09:41:37 -04:00
Deluan
cca32360db Use refetch when changing the playlist (as opposed to a full refresh) 2021-10-27 20:53:58 -04:00
Deluan
85d48478e8 Add .mka file format. Only works with ffmpeg extractor 2021-10-27 15:00:32 -04:00
Deluan
2183eb6498 Should not allow changing sort order in Album songs view 2021-10-27 14:35:58 -04:00
Deluan
ea435d0f60 Fix error on empty playlists. Simplify code for some operations 2021-10-27 09:50:24 -04:00
Deluan
f645c4769c Fix double escaped lyrics and comments 2021-10-26 19:33:21 -04:00
Deluan
5e87280750 Load playlist track genres 2021-10-26 18:46:08 -04:00
Deluan
526b6597c8 Remove duplication for loading tracks 2021-10-26 18:34:21 -04:00
Deluan
5dce499d6d Fix/Optimized Playlist tracks deletion 2021-10-26 14:05:28 -04:00
Deluan
fbd87ba577 Fix console error "Cannot convert undefined or null to object PlaylistsSubMenu" 2021-10-26 14:05:05 -04:00
Deluan
63b5191ea7 Fix lint 2021-10-26 10:57:59 -04:00
Deluan
af00503b77 Optimize playlist updates 2021-10-26 10:45:14 -04:00
Steve Richter
85185e3b98 Misc small changes (#1433)
* Fix React key warning in HelpDialog

* Change "lyric" to "lyrics" in en.json
2021-10-26 08:57:20 -04:00
Deluan
83eaafcbfb Add dateLoved Criteria field 2021-10-25 16:44:59 -04:00
Deluan
93ce0b5683 Fix Genre field and Contains/NotContains/StartsWith/EndsWith in Criteria (Smart Playlists) 2021-10-25 16:17:03 -04:00
Deluan
47549ecfc1 Increase updatePlaylist chunk to 100 tracks 2021-10-25 13:00:46 -04:00
Deluan
ed1ca65ad5 Show hotkeys as chips, for easier reading 2021-10-25 11:14:43 -04:00
Deluan
8d6b5f9d02 Speed up Subsonic GetPlaylist (by optimizing loadTracks) 2021-10-25 11:14:20 -04:00
Deluan
76fdcd112b Tweak SimilarSongs algorithm to prioritize the requested main artist 2021-10-24 18:04:40 -04:00
Deluan
18e1c169f9 Don't read the whole smart playlist file in memory 2021-10-24 14:41:08 -04:00
whorfin
4bc4daa68f Improve git-vs-tarball detection (#1423)
* Extract version from directory name if .git dir is missing

* Avoid using shell

* Remove .gitinfo build from pipeline

* Fix git-detecting rule to be robust in presence of setup-git
2021-10-23 21:27:19 -04:00
Deluan
cc1659aa73 Better way to match top songs from external sources (Last.fm) 2021-10-23 20:26:30 -04:00
Deluan
31c598de07 Fix drag-n-drop from a playlist, also fix useDrag memoization 2021-10-23 20:25:28 -04:00
Deluan
2e2a647e67 Make SmartPlaylists read-only 2021-10-23 20:25:28 -04:00
Deluan
d169f54e7d Rename hasCoverArt field in criteria 2021-10-23 20:25:28 -04:00
Deluan
1494be9aaa Add playCount and playDate columns to album songs list 2021-10-23 20:25:28 -04:00
Deluan
c73f64ee3a Removed unused code 2021-10-23 20:25:28 -04:00
Deluan
806b13cf42 Update stats of Smart Playlist when it is created
Also fix loadTracks
2021-10-23 20:25:28 -04:00
Deluan
2c860edeb5 Don't import invalid .nsp files 2021-10-23 20:25:28 -04:00
Deluan
6a550dab77 Use new Criteria and remove SmartPlaylist struct 2021-10-23 20:25:28 -04:00
Deluan
3972616585 New Criteria API 2021-10-23 20:25:28 -04:00
Deluan
d0ce030386 Add PlayCount and PlayDate columns to PlaylistSongs 2021-10-23 20:25:28 -04:00
Deluan
947353610c Include never played songs in the "not in the last" operator 2021-10-23 20:25:28 -04:00
Deluan
2b57b98a4b Fix smart playlist refreshing only after the tracks were loaded 2021-10-23 20:25:28 -04:00
Deluan
1a96e9fe65 Import smart playlists (extension .nsp) 2021-10-23 20:25:28 -04:00
Deluan
21da1df4ea Cache smart playlist refreshes for 5 seconds 2021-10-23 20:25:28 -04:00
Deluan
d21932bd1b First version of SmartPlaylists being generated on demand 2021-10-23 20:25:28 -04:00
Deluan
c72add516a Add methods to Playlist model
Also, don't load genres for Playlists tracks (not necessary for now)
2021-10-23 20:25:28 -04:00
Deluan
d200933b68 Reduce number of queries for some playlists operations.
Also allow admins to update/delete playlists from other users in the Subsonic API. Closes #1366
2021-10-23 20:25:28 -04:00
Deluan
943082ef4e Fix time-based tests (again) 2021-10-23 20:25:28 -04:00
Deluan
c3fb4e1282 Fix rules serialization 2021-10-23 20:25:28 -04:00
Deluan
9c8f779f42 Fix time-based tests 2021-10-23 20:25:28 -04:00
Deluan
815623715e Load SmartPlaylists rules from DB 2021-10-23 20:25:28 -04:00
Deluan
7221b49b98 More tests 2021-10-23 20:25:28 -04:00
Deluan
cf8d08ec26 Initial drafts for Smart Playlists 2021-10-23 20:25:28 -04:00
Deluan
2a756eab88 Show external links on all resolutions but mobile 2021-10-21 10:30:53 -04:00
Deluan
104679ca6e Guard against record being undefined. Fix error Cannot read properties of undefined (reading 'id') 2021-10-19 20:22:56 -04:00
Dheeraj Lalwani
5621551dd0 Adds Lyrics Support to Subsonic API (#1379)
* Add function 'isSynced' that identifies if lyrics are synced or not and add tests for the same

* implement 'getLyrics' which returns lyrics if they exist

Signed-off-by: Dheeraj Lalwani <lalwanidheeraj1234@gmail.com>

* remove timestamps frorom the the lyrics if they are synced, fix filters & clean up code

Signed-off-by: Dheeraj Lalwani <lalwanidheeraj1234@gmail.com>

* add snapshot tests for the 'Lyrics' response & add some clean up

Signed-off-by: Dheeraj Lalwani <lalwanidheeraj1234@gmail.com>

* add tests for 'GetLyrics' function

Signed-off-by: Dheeraj Lalwani <lalwanidheeraj1234@gmail.com>

* update the snapshot test & the test for 'GetLyrics' function

Signed-off-by: Dheeraj Lalwani <lalwanidheeraj1234@gmail.com>
2021-10-19 16:33:06 -04:00
Deluan
3214783ce9 Remove double-retching playlist's tracks 2021-10-19 12:54:21 -04:00
Chirag Lulla
34b01c2cbf Display lyrics on UI if synced lyrics present in metadata (#1406)
Signed-off-by: Chirag Lulla <lullachirag239@gmail.com>
2021-10-19 10:21:20 -04:00
Deluan
0d2a8f5338 Enable new Artist Page by default 2021-10-17 11:07:59 -04:00
Deluan
b7fedddfd8 Guard against record being undefined. Fix error Cannot read properties of undefined (reading 'albumId') 2021-10-16 20:29:31 -04:00
Dnouv
1d742cf8c7 Artist page improvements (#1391)
* Seperate mobile desktop components

* Fix err

* Rename classes and fix some styles

* Add lastFM button and remove console log

* Add Mbiz Icon

* render bio as dangerouslySetInnerHTML and remove unused css classes

* Add Fav and Stars

* Remove unstandardised class selector

* Remove ext link from m view

* Fix naming and simplify rounded styling

* Refactor ArtistShow:

- Extracted DesktopArtistDetails to its own file
- Removed album count as it was incorrect, it is not considering compilations
- Show bio and image from Native API, if it is available, before calling `getArtistInfo`

Co-authored-by: Deluan <deluan@navidrome.org>
2021-10-15 21:02:11 -04:00
Deluan
7505b5c554 Bump GoLang to 1.17.2 2021-10-13 09:53:44 -04:00
Deluan
174ad9e9da Fix ffmpeg bitrate parsing for flac files 2021-10-12 22:02:24 -04:00
certuna
ba0ee6aba4 Rename manifest.json to manifest.webmanifest (#1399)
* Rename manifest.json to manifest.webmanifest

browser consoles keep complaining that the manifest doesn't have the `.webmanifest` extension.

* FIx manifest.webmanifest references

Co-authored-by: Deluan <deluan@navidrome.org>
2021-10-12 20:06:09 -04:00
Deluan Quintão
6b38acad49 Update README.md 2021-10-08 10:51:09 -04:00
Deluan
ee8943f338 Fix semantic classes for currently playing song
Fix #1364
2021-10-07 19:49:27 -04:00
Serguey Parkhomovsky
86a87b4bb1 Fix default volume (#1395)
With the update in #1378, the default volume is now erroneously set to 25% instead of 50%. Remove the Math.pow and set it to 50% instead.
2021-10-07 17:21:08 -04:00
328 changed files with 24117 additions and 25625 deletions

View File

@@ -4,16 +4,18 @@
"dockerfile": "Dockerfile",
"args": {
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
"VARIANT": "1.17",
"VARIANT": "1.19",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v16"
}
},
"workspaceMount": "",
"runArgs": [
"--cap-add=SYS_PTRACE",
"--security-opt",
"seccomp=unconfined"
"seccomp=unconfined",
"--volume=${localWorkspaceFolder}:/workspaces/${localWorkspaceFolderBasename}:Z"
],
// Set *default* container specific settings.json values on container create.
"settings": {

View File

@@ -0,0 +1,54 @@
name: Add download link to PR
on:
workflow_run:
workflows: ['Test workflow with upload']
types: [completed]
jobs:
pr_comment:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v3
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
script: |
const {owner, repo} = context.repo;
const run_id = ${{github.event.workflow_run.id}};
const pull_head_sha = '${{github.event.workflow_run.head_sha}}';
const pull_user_id = ${{github.event.sender.id}};
const issue_number = await (async () => {
const pulls = await github.pulls.list({owner, repo});
for await (const {data} of github.paginate.iterator(pulls)) {
for (const pull of data) {
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
return pull.number;
}
}
}
})();
if (issue_number) {
core.info(`Using pull request ${issue_number}`);
} else {
return core.error(`No matching pull request found`);
}
const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
let body = `Download the artifacts for this pull request:\n`;
for (const art of artifacts) {
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
const {data: comments} = await github.issues.listComments({repo, owner, issue_number});
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]');
if (existing_comment) {
core.info(`Updating comment ${existing_comment.id}`);
await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
} else {
core.info(`Creating a comment`);
await github.issues.createComment({repo, owner, issue_number, body});
}

View File

@@ -6,7 +6,7 @@ ARG TARGETPLATFORM
RUN echo "Target Platform = ${TARGETPLATFORM}"
COPY dist .
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then cp navidrome_linux_amd64_linux_amd64/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then cp navidrome_linux_amd64_linux_amd64_v1/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/386" ]; then cp navidrome_linux_386_linux_386/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then cp navidrome_linux_arm64_linux_arm64/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then cp navidrome_linux_arm_linux_arm_6/navidrome /navidrome; fi

View File

@@ -16,18 +16,18 @@ jobs:
- name: Install taglib
run: sudo apt-get install libtag1-dev
- name: Set up Go 1.17
uses: actions/setup-go@v2
- name: Set up Go 1.19
uses: actions/setup-go@v3
with:
go-version: 1.17
go-version: 1.19
id: go
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: golangci-lint
uses: golangci/golangci-lint-action@v2
uses: golangci/golangci-lint-action@v3
with:
version: v1.40
version: latest
github-token: ${{ secrets.GITHUB_TOKEN }}
args: --timeout 2m
@@ -48,20 +48,19 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go_version: [1.16.x, 1.17.x]
go_version: [1.18.x,1.19.x]
steps:
- name: Install taglib
run: sudo apt-get install libtag1-dev
- name: Set up Go ${{ matrix.go_version }}
uses: actions/setup-go@v2
uses: actions/setup-go@v3
with:
stable: '!contains(${{ matrix.go_version }}, "beta") && !contains(${{ matrix.go_version }}, "rc")'
go-version: ${{ matrix.go_version }}
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
uses: actions/checkout@v3
- uses: actions/cache@v2
id: cache-go
@@ -86,7 +85,7 @@ jobs:
env:
NODE_OPTIONS: '--max_old_space_size=4096'
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: actions/setup-node@v2
with:
node-version: 16
@@ -125,7 +124,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 0
@@ -142,7 +141,7 @@ jobs:
- name: Run GoReleaser - SNAPSHOT
if: startsWith(github.ref, 'refs/tags/') != true
uses: docker://deluan/ci-goreleaser:1.17.1-1
uses: docker://deluan/ci-goreleaser:1.19.1-3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -150,7 +149,7 @@ jobs:
- name: Run GoReleaser - RELEASE
if: startsWith(github.ref, 'refs/tags/')
uses: docker://deluan/ci-goreleaser:1.17.1-1
uses: docker://deluan/ci-goreleaser:1.19.1-3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -182,7 +181,7 @@ jobs:
uses: docker/setup-buildx-action@v1
if: env.DOCKER_IMAGE != ''
- uses: actions/checkout@v2
- uses: actions/checkout@v3
if: env.DOCKER_IMAGE != ''
- uses: actions/download-artifact@v2

View File

@@ -1,25 +1,31 @@
run:
go: "1.19"
linters:
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- deadcode
- depguard
- dogsled
- durationcheck
- errcheck
- errorlint
- exportloopref
- gocyclo
- goprintffuncname
- gosec
- gosimple
- govet
- ineffassign
- interfacer
- misspell
- nilerr
- rowserrcheck
- staticcheck
- structcheck
- typecheck
- unconvert
- unused
- varcheck
- whitespace
issues:

View File

@@ -18,6 +18,7 @@ builds:
- id: navidrome_linux_386
env:
- CGO_ENABLED=1
- PKG_CONFIG_PATH=/i386/lib/pkgconfig
goos:
- linux
goarch:
@@ -31,8 +32,9 @@ builds:
- id: navidrome_linux_arm
env:
- CGO_ENABLED=1
- CC=arm-linux-gnueabi-gcc
- CXX=arm-linux-gnueabi-g++
- CC=arm-linux-gnueabihf-gcc
- CXX=arm-linux-gnueabihf-g++
- PKG_CONFIG_PATH=/arm/lib/pkgconfig
goos:
- linux
goarch:
@@ -52,6 +54,7 @@ builds:
- CGO_ENABLED=1
- CC=aarch64-linux-gnu-gcc
- CXX=aarch64-linux-gnu-g++
- PKG_CONFIG_PATH=/arm64/lib/pkgconfig
goos:
- linux
goarch:

View File

@@ -1,7 +1,7 @@
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
NODE_VERSION=$(shell cat .nvmrc)
ifneq ("$(wildcard .git)","")
ifneq ("$(wildcard .git/HEAD)","")
GIT_SHA=$(shell git rev-parse --short HEAD)
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)
else
@@ -9,7 +9,7 @@ GIT_SHA=source_archive
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
endif
CI_RELEASER_VERSION=1.17.1-1 ## https://github.com/navidrome/ci-goreleaser
CI_RELEASER_VERSION=1.19.1-3 ## https://github.com/navidrome/ci-goreleaser
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
@echo Downloading Node dependencies...
@@ -25,7 +25,7 @@ server: check_go_env ##@Development Start the backend in development mode
.PHONY: server
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
go run github.com/onsi/ginkgo/ginkgo watch -notify ./...
go run github.com/onsi/ginkgo/v2/ginkgo watch -notify ./...
.PHONY: watch
test: ##@Development Run Go tests
@@ -49,7 +49,7 @@ wire: check_go_env ##@Development Update Dependency Injection
.PHONY: wire
snapshots: ##@Development Update (GoLang) Snapshot tests
UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/ginkgo ./server/subsonic/...
UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/v2/ginkgo ./server/subsonic/...
.PHONY: snapshots
migration: ##@Development Create an empty migration file
@@ -69,8 +69,7 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
buildall: buildjs build ##@Build Build the project, both frontend and backend
.PHONY: buildall
build: check_go_env ##@Build Build only backend
@echo "WARNING: This command does not build the frontend, it uses the latest one built by 'make buildjs'"
build: warning-noui-build check_go_env ##@Build Build only backend
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
@@ -78,14 +77,12 @@ buildjs: check_node_env ##@Build Build only frontend
@(cd ./ui && npm run build)
.PHONY: buildjs
all: ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend
@echo "WARNING: This command does not builds the frontend, it uses the latest one built by 'make buildjs'"
all: warning-noui-build ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
goreleaser release --rm-dist --skip-publish --snapshot
.PHONY: all
single: ##@Cross_Compilation Build binaries for a single supported platforms. It does not build the frontend
@echo "WARNING: This command does not build the frontend, it uses the latest one built by 'make buildjs'"
single: warning-noui-build ##@Cross_Compilation Build binaries for a single supported platforms. It does not build the frontend
@if [ -z "${GOOS}" -o -z "${GOARCH}" ]; then \
echo "Usage: GOOS=<os> GOARCH=<arch> make single"; \
echo "Options:"; \
@@ -97,6 +94,22 @@ single: ##@Cross_Compilation Build binaries for a single supported platforms. It
goreleaser build --rm-dist --snapshot --single-target --id navidrome_${GOOS}_${GOARCH}
.PHONY: single
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; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=ec2093ec4801402f1e17cc462195cdbb" > brock.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=NavidromeUI&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \
for file in *.zip; do unzip -n $${file}; done )
@echo "Done. Remember to set your MusicFolder to ./music"
.PHONY: get-music
##########################################
#### Miscellaneous
@@ -111,7 +124,7 @@ release:
download-deps:
@echo Downloading Go dependencies...
@go mod download -x
@go mod download
@go mod tidy # To revert any changes made by the `go mod download` command
.PHONY: download-deps

View File

@@ -1,4 +1,4 @@
<img src="resources/logo-192x192.png" alt="Navidrome logo" title="navidrome" align="right" height="60px" />
<a href="https://www.navidrome.org"><img src="resources/logo-192x192.png" alt="Navidrome logo" title="navidrome" align="right" height="60px" /></a>
# Navidrome Music Server
@@ -27,6 +27,10 @@ please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or j
See instructions in the [project's website](https://www.navidrome.org/docs/installation/)
If you plan to host Navidrome in the cloud, a great option is to get a virtual server at [BuyVM](https://my.frantech.ca/aff.php?aff=4605).
They have plans that start at $3.50/month! If you decide to sign up, please consider using our [affliliate link](https://my.frantech.ca/aff.php?aff=4605),
to help support the project <3
## Features
- Handles very **large music collections**

View File

@@ -10,8 +10,11 @@ import (
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/scheduler"
"github.com/oklog/run"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
@@ -31,7 +34,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
Run: func(cmd *cobra.Command, args []string) {
runNavidrome()
},
Version: consts.Version(),
Version: consts.Version,
}
)
@@ -45,7 +48,7 @@ func Execute() {
func preRun() {
if !noBanner {
println(consts.Banner())
println(resources.Banner())
}
conf.Load()
}
@@ -68,6 +71,7 @@ func runNavidrome() {
if err := g.Run(); err != nil {
log.Error("Fatal error in Navidrome. Aborting", err)
os.Exit(1)
}
}
@@ -79,6 +83,12 @@ func startServer() (func() error, func(err error)) {
if conf.Server.LastFM.Enabled {
a.MountRouter("LastFM Auth", consts.URLPathNativeAPI+"/lastfm", CreateLastFMRouter())
}
if conf.Server.ListenBrainz.Enabled {
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
}
if conf.Server.Prometheus.Enabled {
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, promhttp.Handler())
}
return a.Run(fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port))
}, func(err error) {
if err != nil {
@@ -107,7 +117,7 @@ func startSignaler() (func() error, func(err error)) {
}
log.Info(ctx, "Triggered scan complete", "elapsed", time.Since(start).Round(100*time.Millisecond))
case <-ctx.Done():
break
return nil
}
}
}, func(err error) {
@@ -186,12 +196,19 @@ func init() {
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`")
rootCmd.Flags().String("prometheus.metricspath", viper.GetString("prometheus.metricspath"), "http endpoint for prometheus metrics")
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
_ = viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))
_ = viper.BindPFlag("sessiontimeout", rootCmd.Flags().Lookup("sessiontimeout"))
_ = viper.BindPFlag("scaninterval", rootCmd.Flags().Lookup("scaninterval"))
_ = viper.BindPFlag("baseurl", rootCmd.Flags().Lookup("baseurl"))
_ = viper.BindPFlag("uiloginbackgroundurl", rootCmd.Flags().Lookup("uiloginbackgroundurl"))
_ = viper.BindPFlag("prometheus.enabled", rootCmd.Flags().Lookup("prometheus.enabled"))
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))

View File

@@ -1,10 +1,11 @@
package cmd
import (
"context"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/spf13/cobra"
"golang.org/x/net/context"
)
var fullRescan bool

View File

@@ -1,5 +1,4 @@
//go:build !windows && !plan9
// +build !windows,!plan9
package cmd

View File

@@ -1,7 +1,8 @@
// Code generated by Wire. DO NOT EDIT.
//go:generate go run github.com/google/wire/cmd/wire
//+build !wireinject
//go:build !wireinject
// +build !wireinject
package cmd
@@ -10,6 +11,7 @@ import (
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/agents/lastfm"
"github.com/navidrome/navidrome/core/agents/listenbrainz"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/transcoder"
"github.com/navidrome/navidrome/db"
@@ -54,8 +56,9 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
scanner := GetScanner()
broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore)
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
router := subsonic.New(dataStore, artwork, mediaStreamer, archiver, players, externalMetadata, scanner, broker, playTracker)
router := subsonic.New(dataStore, artwork, mediaStreamer, archiver, players, externalMetadata, scanner, broker, playlists, playTracker)
return router
}
@@ -66,20 +69,28 @@ func CreateLastFMRouter() *lastfm.Router {
return router
}
func CreateListenBrainzRouter() *listenbrainz.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
router := listenbrainz.NewRouter(dataStore)
return router
}
func createScanner() scanner.Scanner {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
playlists := core.NewPlaylists(dataStore)
artworkCache := core.GetImageCache()
artwork := core.NewArtwork(dataStore, artworkCache)
cacheWarmer := core.NewCacheWarmer(artwork, artworkCache)
broker := events.GetBroker()
scannerScanner := scanner.New(dataStore, cacheWarmer, broker)
scannerScanner := scanner.New(dataStore, playlists, cacheWarmer, broker)
return scannerScanner
}
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, subsonic.New, nativeapi.New, persistence.New, lastfm.NewRouter, events.GetBroker, db.Db)
var allProviders = wire.NewSet(core.Set, subsonic.New, nativeapi.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, db.Db)
// Scanner must be a Singleton
var (

View File

@@ -1,5 +1,4 @@
//go:build wireinject
// +build wireinject
package cmd
@@ -9,6 +8,7 @@ import (
"github.com/google/wire"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents/lastfm"
"github.com/navidrome/navidrome/core/agents/listenbrainz"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
@@ -24,6 +24,7 @@ var allProviders = wire.NewSet(
nativeapi.New,
persistence.New,
lastfm.NewRouter,
listenbrainz.NewRouter,
events.GetBroker,
db.Db,
)
@@ -54,6 +55,12 @@ func CreateLastFMRouter() *lastfm.Router {
))
}
func CreateListenBrainzRouter() *listenbrainz.Router {
panic(wire.Build(
allProviders,
))
}
// Scanner must be a Singleton
var (
onceScanner sync.Once

View File

@@ -29,6 +29,7 @@ type configOptions struct {
UILoginBackgroundURL string
EnableTranscodingConfig bool
EnableDownloads bool
EnableExternalServices bool
TranscodingCacheSize string
ImageCacheSize string
AutoImportPlaylists bool
@@ -47,6 +48,7 @@ type configOptions struct {
EnableStarRating bool
EnableUserEditing bool
DefaultTheme string
DefaultLanguage string
EnableCoverAnimation bool
GATrackingID string
EnableLogRedacting bool
@@ -55,12 +57,14 @@ type configOptions struct {
PasswordEncryptionKey string
ReverseProxyUserHeader string
ReverseProxyWhitelist string
Prometheus prometheusOptions
Scanner scannerOptions
Agents string
LastFM lastfmOptions
Spotify spotifyOptions
Agents string
LastFM lastfmOptions
Spotify spotifyOptions
ListenBrainz listenBrainzOptions
// DevFlags. These are used to enable/disable debugging and incomplete features
DevLogSourceLine bool
@@ -93,6 +97,16 @@ type spotifyOptions struct {
Secret string
}
type listenBrainzOptions struct {
Enabled bool
BaseURL string
}
type prometheusOptions struct {
Enabled bool
MetricsPath string
}
var (
Server = &configOptions{}
hooks []func()
@@ -137,12 +151,27 @@ func Load() {
fmt.Println(prettyConf)
}
if !Server.EnableExternalServices {
disableExternalServices()
}
// Call init hooks
for _, hook := range hooks {
hook()
}
}
func disableExternalServices() {
log.Info("All external integrations are DISABLED!")
Server.LastFM.Enabled = false
Server.Spotify.ID = ""
Server.ListenBrainz.Enabled = false
Server.Agents = ""
if Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL {
Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
}
}
func validateScanSchedule() error {
if Server.ScanInterval != -1 {
log.Warn("ScanInterval is DEPRECATED. Please use ScanSchedule. See docs at https://navidrome.org/docs/usage/configuration-options/")
@@ -194,6 +223,7 @@ func init() {
viper.SetDefault("autoimportplaylists", true)
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
viper.SetDefault("enabledownloads", true)
viper.SetDefault("enableexternalservices", true)
// Config options only valid for file/env configuration
viper.SetDefault("searchfullstring", false)
@@ -209,6 +239,7 @@ func init() {
viper.SetDefault("enablestarrating", true)
viper.SetDefault("enableuserediting", true)
viper.SetDefault("defaulttheme", "Dark")
viper.SetDefault("defaultlanguage", "")
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("gatrackingid", "")
viper.SetDefault("enablelogredacting", true)
@@ -219,6 +250,9 @@ func init() {
viper.SetDefault("reverseproxyuserheader", "Remote-User")
viper.SetDefault("reverseproxywhitelist", "")
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", "/metrics")
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
viper.SetDefault("scanner.genreseparators", ";/,")
@@ -229,6 +263,8 @@ func init() {
viper.SetDefault("lastfm.secret", consts.LastFMAPISecret)
viper.SetDefault("spotify.id", "")
viper.SetDefault("spotify.secret", "")
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
@@ -240,7 +276,7 @@ func init() {
viper.SetDefault("devenableshare", false)
viper.SetDefault("devenablebufferedscrobble", true)
viper.SetDefault("devsidebarplaylists", true)
viper.SetDefault("devshowartistpage", false)
viper.SetDefault("devshowartistpage", true)
}
func InitConfig(cfgFile string) {

View File

@@ -35,16 +35,20 @@ const (
// Login backgrounds from https://unsplash.com/collections/20072696/navidrome
DefaultUILoginBackgroundURL = "https://source.unsplash.com/collection/20072696/1600x900"
// In case external integrations are disabled
DefaultUILoginBackgroundURLOffline = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAIAAAAiOjnJAAAABGdBTUEAALGPC/xhBQAAAiJJREFUeF7t0IEAAAAAw6D5Ux/khVBhwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDDwMDDVlwABBWcSrQAAAABJRU5ErkJggg=="
RequestThrottleBacklogLimit = 100
RequestThrottleBacklogTimeout = time.Minute
ArtistInfoTimeToLive = 3 * 24 * time.Hour
ServerReadHeaderTimeout = 3 * time.Second
ArtistInfoTimeToLive = 24 * time.Hour
I18nFolder = "i18n"
SkipScanFile = ".ndignore"
PlaceholderAlbumArt = "navidrome-600x600.png"
PlaceholderAlbumArt = "placeholder.png"
PlaceholderAvatar = "logo-192x192.png"
DefaultHttpClientTimeOut = 10 * time.Second
@@ -66,7 +70,7 @@ const (
// Shared secrets (only add here "secrets" that can be public)
const (
LastFMAPIKey = "9b94a5515ea66b2da3ec03c12300327e"
LastFMAPIKey = "9b94a5515ea66b2da3ec03c12300327e" // nolint:gosec
LastFMAPISecret = "74cb6557cec7171d921af5d7d887c587" // nolint:gosec
)

View File

@@ -33,6 +33,7 @@ var audioFormats = map[string]format{
".dsf": {typ: "audio/dsd", lossless: true},
".wv": {typ: "audio/x-wavpack", lossless: true},
".wvp": {typ: "audio/x-wavpack", lossless: true},
".mka": {typ: "audio/x-matroska"},
}
var imageFormats = map[string]string{
".gif": "image/gif",
@@ -56,4 +57,8 @@ func init() {
for ext, typ := range imageFormats {
_ = mime.AddExtensionType(ext, typ)
}
// In some circumstances, Windows sets JS mime-type to `text/plain`!
_ = mime.AddExtensionType(".js", "text/javascript")
_ = mime.AddExtensionType(".css", "text/css")
}

View File

@@ -11,15 +11,16 @@ var (
gitSha string
)
// Formats:
// Version holds the version string, with tag and git sha info.
// Examples:
// dev
// v0.2.0 (5b84188)
// v0.3.2-SNAPSHOT (715f552)
// master (9ed35cb)
func Version() string {
var Version = func() string {
if gitSha == "" {
return "dev"
}
gitTag = strings.TrimPrefix(gitTag, "v")
return fmt.Sprintf("%s (%s)", gitTag, gitSha)
}
}()

View File

@@ -18,7 +18,10 @@ type Agents struct {
}
func New(ds model.DataStore) *Agents {
order := strings.Split(conf.Server.Agents, ",")
var order []string
if conf.Server.Agents != "" {
order = strings.Split(conf.Server.Agents, ",")
}
order = append(order, PlaceholderAgentName)
var res []Interface
for _, name := range order {
@@ -51,7 +54,7 @@ func (a *Agents) GetMBID(ctx context.Context, id string, name string) (string, e
mbid, err := agent.GetMBID(ctx, id, name)
if mbid != "" && err == nil {
log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start))
return mbid, err
return mbid, nil
}
}
return "", ErrNotFound
@@ -70,7 +73,7 @@ func (a *Agents) GetURL(ctx context.Context, id, name, mbid string) (string, err
url, err := agent.GetURL(ctx, id, name, mbid)
if url != "" && err == nil {
log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start))
return url, err
return url, nil
}
}
return "", ErrNotFound
@@ -89,7 +92,7 @@ func (a *Agents) GetBiography(ctx context.Context, id, name, mbid string) (strin
bio, err := agent.GetBiography(ctx, id, name, mbid)
if bio != "" && err == nil {
log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
return bio, err
return bio, nil
}
}
return "", ErrNotFound
@@ -107,7 +110,11 @@ func (a *Agents) GetSimilar(ctx context.Context, id, name, mbid string, limit in
}
similar, err := agent.GetSimilar(ctx, id, name, mbid, limit)
if len(similar) > 0 && err == nil {
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
if log.CurrentLevel() >= log.LevelTrace {
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
} else {
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similarReceived", len(similar), "elapsed", time.Since(start))
}
return similar, err
}
}
@@ -127,7 +134,7 @@ func (a *Agents) GetImages(ctx context.Context, id, name, mbid string) ([]Artist
images, err := agent.GetImages(ctx, id, name, mbid)
if len(images) > 0 && err == nil {
log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start))
return images, err
return images, nil
}
}
return nil, ErrNotFound
@@ -146,7 +153,7 @@ func (a *Agents) GetTopSongs(ctx context.Context, id, artistName, mbid string, c
songs, err := agent.GetTopSongs(ctx, id, artistName, mbid, count)
if len(songs) > 0 && err == nil {
log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start))
return songs, err
return songs, nil
}
}
return nil, ErrNotFound

View File

@@ -5,7 +5,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -8,7 +8,7 @@ import (
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/conf"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -2,6 +2,7 @@ package lastfm
import (
"context"
"errors"
"net/http"
"github.com/navidrome/navidrome/conf"
@@ -14,12 +15,13 @@ import (
)
const (
lastFMAgentName = "lastfm"
lastFMAgentName = "lastfm"
sessionKeyProperty = "LastFMSessionKey"
)
type lastfmAgent struct {
ds model.DataStore
sessionKeys *sessionKeys
sessionKeys *agents.SessionKeys
apiKey string
secret string
lang string
@@ -32,7 +34,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
lang: conf.Server.LastFM.Language,
apiKey: conf.Server.LastFM.ApiKey,
secret: conf.Server.LastFM.Secret,
sessionKeys: &sessionKeys{ds: ds},
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
@@ -117,7 +119,9 @@ func (l *lastfmAgent) GetTopSongs(ctx context.Context, id, artistName, mbid stri
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, mbid string) (*Artist, error) {
a, err := l.client.ArtistGetInfo(ctx, name, mbid)
lfErr, isLastFMError := err.(*lastFMError)
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && ((err == nil && a.Name == "[unknown]") || (isLastFMError && lfErr.Code == 6)) {
log.Warn(ctx, "LastFM/artist.getInfo could not find artist by mbid, trying again", "artist", name, "mbid", mbid)
return l.callArtistGetInfo(ctx, name, "")
@@ -132,7 +136,8 @@ func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, mbid s
func (l *lastfmAgent) callArtistGetSimilar(ctx context.Context, name string, mbid string, limit int) ([]Artist, error) {
s, err := l.client.ArtistGetSimilar(ctx, name, mbid, limit)
lfErr, isLastFMError := err.(*lastFMError)
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && ((err == nil && s.Attr.Artist == "[unknown]") || (isLastFMError && lfErr.Code == 6)) {
log.Warn(ctx, "LastFM/artist.getSimilar could not find artist by mbid, trying again", "artist", name, "mbid", mbid)
return l.callArtistGetSimilar(ctx, name, "", limit)
@@ -146,7 +151,8 @@ func (l *lastfmAgent) callArtistGetSimilar(ctx context.Context, name string, mbi
func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName, mbid string, count int) ([]Track, error) {
t, err := l.client.ArtistGetTopTracks(ctx, artistName, mbid, count)
lfErr, isLastFMError := err.(*lastFMError)
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if mbid != "" && ((err == nil && t.Attr.Artist == "[unknown]") || (isLastFMError && lfErr.Code == 6)) {
log.Warn(ctx, "LastFM/artist.getTopTracks could not find artist by mbid, trying again", "artist", artistName, "mbid", mbid)
return l.callArtistGetTopTracks(ctx, artistName, "", count)
@@ -159,7 +165,7 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName, mb
}
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
sk, err := l.sessionKeys.get(ctx, userId)
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return scrobbler.ErrNotAuthorized
}
@@ -181,7 +187,7 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
}
func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
sk, err := l.sessionKeys.get(ctx, userId)
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return scrobbler.ErrNotAuthorized
}
@@ -203,7 +209,8 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
if err == nil {
return nil
}
lfErr, isLastFMError := err.(*lastFMError)
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if !isLastFMError {
log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err)
return scrobbler.ErrRetryLater
@@ -215,7 +222,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
}
func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
sk, err := l.sessionKeys.get(ctx, userId)
sk, err := l.sessionKeys.Get(ctx, userId)
return err == nil && sk != ""
}

View File

@@ -15,7 +15,7 @@ import (
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
_ "embed"
"errors"
"net/http"
"time"
@@ -12,6 +13,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -25,7 +27,7 @@ var tokenReceivedPage []byte
type Router struct {
http.Handler
ds model.DataStore
sessionKeys *sessionKeys
sessionKeys *agents.SessionKeys
client *Client
apiKey string
secret string
@@ -36,7 +38,7 @@ func NewRouter(ds model.DataStore) *Router {
ds: ds,
apiKey: conf.Server.LastFM.ApiKey,
secret: conf.Server.LastFM.Secret,
sessionKeys: &sessionKeys{ds: ds},
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
r.Handler = r.routes()
hc := &http.Client{
@@ -63,10 +65,10 @@ func (s *Router) routes() http.Handler {
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{"status": true}
resp := map[string]interface{}{}
u, _ := request.UserFrom(r.Context())
key, err := s.sessionKeys.get(r.Context(), u.ID)
if err != nil && err != model.ErrNotFound {
key, err := s.sessionKeys.Get(r.Context(), u.ID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
resp["error"] = err
resp["status"] = false
_ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp)
@@ -78,7 +80,7 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
u, _ := request.UserFrom(r.Context())
err := s.sessionKeys.delete(r.Context(), u.ID)
err := s.sessionKeys.Delete(r.Context(), u.ID)
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
} else {
@@ -119,7 +121,7 @@ func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
"requestId", middleware.GetReqID(ctx), err)
return err
}
err = s.sessionKeys.put(ctx, uid, sessionKey)
err = s.sessionKeys.Put(ctx, uid, sessionKey)
if err != nil {
log.Error("Could not save LastFM session key", "userId", uid, "requestId", middleware.GetReqID(ctx), err)
}

View File

@@ -157,11 +157,11 @@ func (c *Client) Scrobble(ctx context.Context, sessionKey string, info ScrobbleI
}
if resp.Scrobbles.Scrobble.IgnoredMessage.Code != "0" {
log.Warn(ctx, "LastFM: Scrobble was ignored", "code", resp.Scrobbles.Scrobble.IgnoredMessage.Code,
"text", resp.Scrobbles.Scrobble.IgnoredMessage.Text)
"text", resp.Scrobbles.Scrobble.IgnoredMessage.Text, "info", info)
}
if resp.Scrobbles.Attr.Accepted != 1 {
log.Warn(ctx, "LastFM: Scrobble was not accepted", "code", resp.Scrobbles.Scrobble.IgnoredMessage.Code,
"text", resp.Scrobbles.Scrobble.IgnoredMessage.Text)
"text", resp.Scrobbles.Scrobble.IgnoredMessage.Text, "info", info)
}
return nil
}

View File

@@ -12,7 +12,7 @@ import (
"os"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -5,7 +5,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -4,7 +4,7 @@ import (
"encoding/json"
"os"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -1,28 +0,0 @@
package lastfm
import (
"context"
"github.com/navidrome/navidrome/model"
)
const (
sessionKeyProperty = "LastFMSessionKey"
)
// sessionKeys is a simple wrapper around the UserPropsRepository
type sessionKeys struct {
ds model.DataStore
}
func (sk *sessionKeys) put(ctx context.Context, userId, sessionKey string) error {
return sk.ds.UserProps(ctx).Put(userId, sessionKeyProperty, sessionKey)
}
func (sk *sessionKeys) get(ctx context.Context, userId string) (string, error) {
return sk.ds.UserProps(ctx).Get(userId, sessionKeyProperty)
}
func (sk *sessionKeys) delete(ctx context.Context, userId string) error {
return sk.ds.UserProps(ctx).Delete(userId, sessionKeyProperty)
}

View File

@@ -0,0 +1,117 @@
package listenbrainz
import (
"context"
"errors"
"net/http"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
)
const (
listenBrainzAgentName = "listenbrainz"
sessionKeyProperty = "ListenBrainzSessionKey"
)
type listenBrainzAgent struct {
ds model.DataStore
sessionKeys *agents.SessionKeys
baseURL string
client *Client
}
func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
l := &listenBrainzAgent{
ds: ds,
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
baseURL: conf.Server.ListenBrainz.BaseURL,
}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.client = NewClient(l.baseURL, chc)
return l
}
func (l *listenBrainzAgent) AgentName() string {
return listenBrainzAgentName
}
func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
li := listenInfo{
TrackMetadata: trackMetadata{
ArtistName: track.Artist,
TrackName: track.Title,
ReleaseName: track.Album,
AdditionalInfo: additionalInfo{
TrackNumber: track.TrackNumber,
ArtistMbzIDs: []string{track.MbzArtistID},
TrackMbzID: track.MbzTrackID,
ReleaseMbID: track.MbzAlbumID,
},
},
}
return li
}
func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return scrobbler.ErrNotAuthorized
}
li := l.formatListen(track)
err = l.client.UpdateNowPlaying(ctx, sk, li)
if err != nil {
log.Warn(ctx, "ListenBrainz UpdateNowPlaying returned error", "track", track.Title, err)
return scrobbler.ErrUnrecoverable
}
return nil
}
func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return scrobbler.ErrNotAuthorized
}
li := l.formatListen(&s.MediaFile)
li.ListenedAt = int(s.TimeStamp.Unix())
err = l.client.Scrobble(ctx, sk, li)
if err == nil {
return nil
}
var lbErr *listenBrainzError
isListenBrainzError := errors.As(err, &lbErr)
if !isListenBrainzError {
log.Warn(ctx, "ListenBrainz Scrobble returned HTTP error", "track", s.Title, err)
return scrobbler.ErrRetryLater
}
if lbErr.Code == 500 || lbErr.Code == 503 {
return scrobbler.ErrRetryLater
}
return scrobbler.ErrUnrecoverable
}
func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool {
sk, err := l.sessionKeys.Get(ctx, userId)
return err == nil && sk != ""
}
func init() {
conf.AddHook(func() {
if conf.Server.ListenBrainz.Enabled {
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
return listenBrainzConstructor(ds)
})
}
})
}

View File

@@ -0,0 +1,158 @@
package listenbrainz
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"time"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
. "github.com/onsi/gomega/gstruct"
)
var _ = Describe("listenBrainzAgent", func() {
var ds model.DataStore
var ctx context.Context
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
var track *model.MediaFile
BeforeEach(func() {
ds = &tests.MockDataStore{}
ctx = context.Background()
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
httpClient = &tests.FakeHttpClient{}
agent = listenBrainzConstructor(ds)
agent.client = NewClient("http://localhost:8080", httpClient)
track = &model.MediaFile{
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
TrackNumber: 1,
MbzTrackID: "mbz-123",
MbzAlbumID: "mbz-456",
MbzArtistID: "mbz-789",
}
})
Describe("formatListen", func() {
It("constructs the listenInfo properly", func() {
var idArtistId = func(element interface{}) string {
return element.(string)
}
lr := agent.formatListen(track)
Expect(lr).To(MatchAllFields(Fields{
"ListenedAt": Equal(0),
"TrackMetadata": MatchAllFields(Fields{
"ArtistName": Equal(track.Artist),
"TrackName": Equal(track.Title),
"ReleaseName": Equal(track.Album),
"AdditionalInfo": MatchAllFields(Fields{
"TrackNumber": Equal(track.TrackNumber),
"TrackMbzID": Equal(track.MbzTrackID),
"ReleaseMbID": Equal(track.MbzAlbumID),
"ArtistMbzIDs": MatchAllElements(idArtistId, Elements{
"mbz-789": Equal(track.MbzArtistID),
}),
}),
}),
}))
})
})
Describe("NowPlaying", func() {
It("updates NowPlaying successfully", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
err := agent.NowPlaying(ctx, "user-1", track)
Expect(err).ToNot(HaveOccurred())
})
It("returns ErrNotAuthorized if user is not linked", func() {
err := agent.NowPlaying(ctx, "user-2", track)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
})
Describe("Scrobble", func() {
var sc scrobbler.Scrobble
BeforeEach(func() {
sc = scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}
})
It("sends a Scrobble successfully", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).ToNot(HaveOccurred())
})
It("sets the Timestamp properly", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).ToNot(HaveOccurred())
decoder := json.NewDecoder(httpClient.SavedRequest.Body)
var lr listenBrainzRequestBody
err = decoder.Decode(&lr)
Expect(err).ToNot(HaveOccurred())
Expect(lr.Payload[0].ListenedAt).To(Equal(int(sc.TimeStamp.Unix())))
})
It("returns ErrNotAuthorized if user is not linked", func() {
err := agent.Scrobble(ctx, "user-2", sc)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
It("returns ErrRetryLater on error 503", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 503, "error": "Cannot submit listens to queue, please try again later."}`)),
StatusCode: 503,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrRetryLater on error 500", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 500, "error": "Something went wrong. Please try again."}`)),
StatusCode: 500,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrRetryLater on http errors", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad Gateway`)),
StatusCode: 500,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrUnrecoverable on other errors", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "error": "BadRequest: Invalid JSON document submitted."}`)),
StatusCode: 400,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
})
})
})

View File

@@ -0,0 +1,121 @@
package listenbrainz
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
)
type sessionKeysRepo interface {
Put(ctx context.Context, userId, sessionKey string) error
Get(ctx context.Context, userId string) (string, error)
Delete(ctx context.Context, userId string) error
}
type Router struct {
http.Handler
ds model.DataStore
sessionKeys sessionKeysRepo
client *Client
}
func NewRouter(ds model.DataStore) *Router {
r := &Router{
ds: ds,
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
r.Handler = r.routes()
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
r.client = NewClient(conf.Server.ListenBrainz.BaseURL, hc)
return r
}
func (s *Router) routes() http.Handler {
r := chi.NewRouter()
r.Group(func(r chi.Router) {
r.Use(server.Authenticator(s.ds))
r.Use(server.JWTRefresher)
r.Get("/link", s.getLinkStatus)
r.Put("/link", s.link)
r.Delete("/link", s.unlink)
})
return r
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{}
u, _ := request.UserFrom(r.Context())
key, err := s.sessionKeys.Get(r.Context(), u.ID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
resp["error"] = err
resp["status"] = false
_ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp)
return
}
resp["status"] = key != ""
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
}
func (s *Router) link(w http.ResponseWriter, r *http.Request) {
type tokenPayload struct {
Token string `json:"token"`
}
var payload tokenPayload
err := json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if payload.Token == "" {
_ = rest.RespondWithError(w, http.StatusBadRequest, "Token is required")
return
}
u, _ := request.UserFrom(r.Context())
resp, err := s.client.ValidateToken(r.Context(), payload.Token)
if err != nil {
log.Error(r.Context(), "Could not validate ListenBrainz token", "userId", u.ID, "requestId", middleware.GetReqID(r.Context()), err)
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}
if !resp.Valid {
_ = rest.RespondWithError(w, http.StatusBadRequest, "Invalid token")
return
}
err = s.sessionKeys.Put(r.Context(), u.ID, payload.Token)
if err != nil {
log.Error("Could not save ListenBrainz token", "userId", u.ID, "requestId", middleware.GetReqID(r.Context()), err)
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
return
}
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]interface{}{"status": resp.Valid, "user": resp.UserName})
}
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
u, _ := request.UserFrom(r.Context())
err := s.sessionKeys.Delete(r.Context(), u.ID)
if err != nil {
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
} else {
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]string{})
}
}

View File

@@ -0,0 +1,130 @@
package listenbrainz
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ListenBrainz Auth Router", func() {
var sk *fakeSessionKeys
var httpClient *tests.FakeHttpClient
var r Router
var req *http.Request
var resp *httptest.ResponseRecorder
BeforeEach(func() {
sk = &fakeSessionKeys{KeyName: sessionKeyProperty}
httpClient = &tests.FakeHttpClient{}
cl := NewClient("http://localhost/", httpClient)
r = Router{
sessionKeys: sk,
client: cl,
}
resp = httptest.NewRecorder()
})
Describe("getLinkStatus", func() {
It("returns false when there is no stored session key", func() {
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
r.getLinkStatus(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
var parsed map[string]interface{}
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(false))
})
It("returns true when there is a stored session key", func() {
sk.KeyValue = "sk-1"
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
r.getLinkStatus(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
var parsed map[string]interface{}
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(true))
})
})
Describe("link", func() {
It("returns bad request when no token is sent", func() {
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{}`))
r.link(resp, req)
Expect(resp.Code).To(Equal(http.StatusBadRequest))
})
It("returns bad request when the token is invalid", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token invalid.", "valid": false}`)),
StatusCode: 200,
}
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "invalid-tok-1"}`))
r.link(resp, req)
Expect(resp.Code).To(Equal(http.StatusBadRequest))
})
It("returns true and the username when the token is valid", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
StatusCode: 200,
}
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`))
r.link(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
var parsed map[string]interface{}
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(true))
Expect(parsed["user"]).To(Equal("ListenBrainzUser"))
})
It("saves the session key when the token is valid", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
StatusCode: 200,
}
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`))
r.link(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
Expect(sk.KeyValue).To(Equal("tok-1"))
})
})
Describe("unlink", func() {
It("removes the session key when unlinking", func() {
sk.KeyValue = "tok-1"
req = httptest.NewRequest("DELETE", "/listenbrainz/link", nil)
r.unlink(resp, req)
Expect(resp.Code).To(Equal(http.StatusOK))
Expect(sk.KeyValue).To(Equal(""))
})
})
})
type fakeSessionKeys struct {
KeyName string
KeyValue string
}
func (sk *fakeSessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
sk.KeyValue = sessionKey
return nil
}
func (sk *fakeSessionKeys) Get(ctx context.Context, userId string) (string, error) {
return sk.KeyValue, nil
}
func (sk *fakeSessionKeys) Delete(ctx context.Context, userId string) error {
sk.KeyValue = ""
return nil
}

View File

@@ -0,0 +1,172 @@
package listenbrainz
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"github.com/navidrome/navidrome/log"
)
type listenBrainzError struct {
Code int
Message string
}
func (e *listenBrainzError) Error() string {
return fmt.Sprintf("ListenBrainz error(%d): %s", e.Code, e.Message)
}
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
func NewClient(baseURL string, hc httpDoer) *Client {
return &Client{baseURL, hc}
}
type Client struct {
baseURL string
hc httpDoer
}
type listenBrainzResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Error string `json:"error"`
Status string `json:"status"`
Valid bool `json:"valid"`
UserName string `json:"user_name"`
}
type listenBrainzRequest struct {
ApiKey string
Body listenBrainzRequestBody
}
type listenBrainzRequestBody struct {
ListenType listenType `json:"listen_type,omitempty"`
Payload []listenInfo `json:"payload,omitempty"`
}
type listenType string
const (
Single listenType = "single"
PlayingNow listenType = "playing_now"
)
type listenInfo struct {
ListenedAt int `json:"listened_at,omitempty"`
TrackMetadata trackMetadata `json:"track_metadata,omitempty"`
}
type trackMetadata struct {
ArtistName string `json:"artist_name,omitempty"`
TrackName string `json:"track_name,omitempty"`
ReleaseName string `json:"release_name,omitempty"`
AdditionalInfo additionalInfo `json:"additional_info,omitempty"`
}
type additionalInfo struct {
TrackNumber int `json:"tracknumber,omitempty"`
TrackMbzID string `json:"track_mbid,omitempty"`
ArtistMbzIDs []string `json:"artist_mbids,omitempty"`
ReleaseMbID string `json:"release_mbid,omitempty"`
}
func (c *Client) ValidateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {
r := &listenBrainzRequest{
ApiKey: apiKey,
}
response, err := c.makeRequest(http.MethodGet, "validate-token", r)
if err != nil {
return nil, err
}
return response, nil
}
func (c *Client) UpdateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error {
r := &listenBrainzRequest{
ApiKey: apiKey,
Body: listenBrainzRequestBody{
ListenType: PlayingNow,
Payload: []listenInfo{li},
},
}
resp, err := c.makeRequest(http.MethodPost, "submit-listens", r)
if err != nil {
return err
}
if resp.Status != "ok" {
log.Warn(ctx, "ListenBrainz: NowPlaying was not accepted", "status", resp.Status)
}
return nil
}
func (c *Client) Scrobble(ctx context.Context, apiKey string, li listenInfo) error {
r := &listenBrainzRequest{
ApiKey: apiKey,
Body: listenBrainzRequestBody{
ListenType: Single,
Payload: []listenInfo{li},
},
}
resp, err := c.makeRequest(http.MethodPost, "submit-listens", r)
if err != nil {
return err
}
if resp.Status != "ok" {
log.Warn(ctx, "ListenBrainz: Scrobble was not accepted", "status", resp.Status)
}
return nil
}
func (c *Client) path(endpoint string) (string, error) {
u, err := url.Parse(c.baseURL)
if err != nil {
return "", err
}
u.Path = path.Join(u.Path, endpoint)
return u.String(), nil
}
func (c *Client) makeRequest(method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
b, _ := json.Marshal(r.Body)
uri, err := c.path(endpoint)
if err != nil {
return nil, err
}
req, _ := http.NewRequest(method, uri, bytes.NewBuffer(b))
if r.ApiKey != "" {
req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey))
}
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var response listenBrainzResponse
jsonErr := decoder.Decode(&response)
if resp.StatusCode != 200 && jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if jsonErr != nil {
return nil, jsonErr
}
if response.Code != 0 && response.Code != 200 {
return &response, &listenBrainzError{Code: response.Code, Message: response.Error}
}
return &response, nil
}

View File

@@ -0,0 +1,115 @@
package listenbrainz
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"os"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Client", func() {
var httpClient *tests.FakeHttpClient
var client *Client
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client = NewClient("BASE_URL/", httpClient)
})
Describe("listenBrainzResponse", func() {
It("parses a response properly", func() {
var response listenBrainzResponse
err := json.Unmarshal([]byte(`{"code": 200, "message": "Message", "user_name": "UserName", "valid": true, "status": "ok", "error": "Error"}`), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response.Code).To(Equal(200))
Expect(response.Message).To(Equal("Message"))
Expect(response.UserName).To(Equal("UserName"))
Expect(response.Valid).To(BeTrue())
Expect(response.Status).To(Equal("ok"))
Expect(response.Error).To(Equal("Error"))
})
})
Describe("ValidateToken", func() {
BeforeEach(func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
StatusCode: 200,
}
})
It("formats the request properly", func() {
_, err := client.ValidateToken(context.Background(), "LB-TOKEN")
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/validate-token"))
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
})
It("parses and returns the response", func() {
res, err := client.ValidateToken(context.Background(), "LB-TOKEN")
Expect(err).ToNot(HaveOccurred())
Expect(res.Valid).To(Equal(true))
Expect(res.UserName).To(Equal("ListenBrainzUser"))
})
})
Context("with listenInfo", func() {
var li listenInfo
BeforeEach(func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)),
StatusCode: 200,
}
li = listenInfo{
TrackMetadata: trackMetadata{
ArtistName: "Track Artist",
TrackName: "Track Title",
ReleaseName: "Track Album",
AdditionalInfo: additionalInfo{
TrackNumber: 1,
TrackMbzID: "mbz-123",
ArtistMbzIDs: []string{"mbz-789"},
ReleaseMbID: "mbz-456",
},
},
}
})
Describe("UpdateNowPlaying", func() {
It("formats the request properly", func() {
Expect(client.UpdateNowPlaying(context.Background(), "LB-TOKEN", li)).To(Succeed())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
f, _ := os.ReadFile("tests/fixtures/listenbrainz.nowplaying.request.json")
Expect(body).To(MatchJSON(f))
})
})
Describe("Scrobble", func() {
BeforeEach(func() {
li.ListenedAt = 1635000000
})
It("formats the request properly", func() {
Expect(client.Scrobble(context.Background(), "LB-TOKEN", li)).To(Succeed())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
f, _ := os.ReadFile("tests/fixtures/listenbrainz.scrobble.request.json")
Expect(body).To(MatchJSON(f))
})
})
})
})

View File

@@ -0,0 +1,17 @@
package listenbrainz
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestListenBrainz(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelCritical)
RegisterFailHandler(Fail)
RunSpecs(t, "ListenBrainz Test Suite")
}

View File

@@ -0,0 +1,25 @@
package agents
import (
"context"
"github.com/navidrome/navidrome/model"
)
// SessionKeys is a simple wrapper around the UserPropsRepository
type SessionKeys struct {
model.DataStore
KeyName string
}
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey)
}
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName)
}
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName)
}

View File

@@ -0,0 +1,37 @@
package agents
import (
"context"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("SessionKeys", func() {
ctx := context.Background()
user := model.User{ID: "u-1"}
ds := &tests.MockDataStore{MockedUserProps: &tests.MockedUserPropsRepo{}}
sk := SessionKeys{DataStore: ds, KeyName: "fakeSessionKey"}
It("uses the assigned key name", func() {
Expect(sk.KeyName).To(Equal("fakeSessionKey"))
})
It("stores a value in the DB", func() {
Expect(sk.Put(ctx, user.ID, "test-stored-value")).To(BeNil())
})
It("fetches the stored value", func() {
value, err := sk.Get(ctx, user.ID)
Expect(err).ToNot(HaveOccurred())
Expect(value).To(Equal("test-stored-value"))
})
It("deletes the stored value", func() {
Expect(sk.Delete(ctx, user.ID)).To(BeNil())
})
It("handles a not found value", func() {
_, err := sk.Get(ctx, "u-2")
Expect(err).To(MatchError(model.ErrNotFound))
})
})

View File

@@ -7,7 +7,7 @@ import (
"net/http"
"os"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -4,7 +4,7 @@ import (
"encoding/json"
"os"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -2,6 +2,7 @@ package spotify
import (
"context"
"errors"
"fmt"
"net/http"
"sort"
@@ -46,7 +47,7 @@ func (s *spotifyAgent) AgentName() string {
func (s *spotifyAgent) GetImages(ctx context.Context, id, name, mbid string) ([]agents.ArtistImage, error) {
a, err := s.searchArtist(ctx, name)
if err != nil {
if err == model.ErrNotFound {
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Artist not found in Spotify", "artist", name)
} else {
log.Error(ctx, "Error calling Spotify", "artist", name, err)

View File

@@ -5,7 +5,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -54,12 +54,12 @@ func (a *archiver) ZipArtist(ctx context.Context, id string, out io.Writer) erro
}
func (a *archiver) ZipPlaylist(ctx context.Context, id string, out io.Writer) error {
pls, err := a.ds.Playlist(ctx).Get(id)
pls, err := a.ds.Playlist(ctx).GetWithTracks(id)
if err != nil {
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
return err
}
return a.zipTracks(ctx, id, out, pls.Tracks, a.createPlaylistHeader)
return a.zipTracks(ctx, id, out, pls.MediaFiles(), a.createPlaylistHeader)
}
func (a *archiver) zipTracks(ctx context.Context, id string, out io.Writer, mfs model.MediaFiles, ch createHeader) error {

View File

@@ -57,7 +57,7 @@ func (ci *imageInfo) Key() string {
func (a *artwork) Get(ctx context.Context, id string, size int) (io.ReadCloser, error) {
path, lastUpdate, err := a.getImagePath(ctx, id)
if err != nil && err != model.ErrNotFound {
if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, err
}
@@ -106,7 +106,7 @@ func (a *artwork) getImagePath(ctx context.Context, id string) (path string, las
mf, err = a.ds.MediaFile(ctx).Get(id)
// If it is not, may be an albumId
if err == model.ErrNotFound {
if errors.Is(err, model.ErrNotFound) {
return a.getImagePath(ctx, "al-"+id)
}
if err != nil {
@@ -127,11 +127,11 @@ func (a *artwork) getArtwork(ctx context.Context, id string, path string, size i
defer func() {
if err != nil {
log.Warn(ctx, "Error extracting image", "path", path, "size", size, err)
reader, err = resources.FS.Open(consts.PlaceholderAlbumArt)
reader, err = resources.FS().Open(consts.PlaceholderAlbumArt)
if size != 0 && err == nil {
var r io.ReadCloser
r, err = resources.FS.Open(consts.PlaceholderAlbumArt)
r, err = resources.FS().Open(consts.PlaceholderAlbumArt)
reader, err = resizeImage(r, size, true)
}
}

View File

@@ -9,7 +9,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -39,6 +39,9 @@ var _ = Describe("Artwork", func() {
Eventually(func() bool { return cache.Ready(context.TODO()) }).Should(BeTrue())
artwork = NewArtwork(ds, cache)
})
AfterEach(func() {
_ = os.RemoveAll(conf.Server.DataFolder)
})
It("retrieves the external artwork art for an album", func() {
r, err := artwork.Get(ctx, "al-444", 0)

View File

@@ -10,7 +10,7 @@ import (
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -25,10 +25,11 @@ const (
oneDay = 24 * time.Hour
)
var _ = BeforeSuite(func() {
conf.Server.SessionTimeout = 2 * oneDay
})
var _ = Describe("Auth", func() {
BeforeSuite(func() {
conf.Server.SessionTimeout = 2 * oneDay
})
BeforeEach(func() {
auth.Secret = []byte(testJWTSecret)

View File

@@ -5,7 +5,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -8,10 +8,11 @@ import (
"time"
"github.com/Masterminds/squirrel"
"github.com/microcosm-cc/bluemonday"
"github.com/kennygrant/sanitize"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
_ "github.com/navidrome/navidrome/core/agents/lastfm"
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
_ "github.com/navidrome/navidrome/core/agents/spotify"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -82,8 +83,18 @@ func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, simi
return nil, err
}
// If we have fresh info, just return it and trigger a refresh in the background
if time.Since(artist.ExternalInfoUpdatedAt) < consts.ArtistInfoTimeToLive {
// If we don't have any info, retrieves it now
if artist.ExternalInfoUpdatedAt.IsZero() {
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id, "name", artist.Name)
err = e.refreshArtistInfo(ctx, artist)
if err != nil {
return nil, err
}
}
// If info is expired, trigger a refresh in the background
if time.Since(artist.ExternalInfoUpdatedAt) > consts.ArtistInfoTimeToLive {
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
@@ -92,16 +103,8 @@ func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, simi
log.Error("Error refreshing ArtistInfo", "id", id, "name", artist.Name, err)
}
}()
log.Debug("Found cached ArtistInfo, refreshing in the background", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name)
err := e.loadSimilar(ctx, artist, similarCount, includeNotPresent)
return &artist.Artist, err
}
log.Debug(ctx, "ArtistInfo not cached or expired", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id, "name", artist.Name)
err = e.refreshArtistInfo(ctx, artist)
if err != nil {
return nil, err
}
err = e.loadSimilar(ctx, artist, similarCount, includeNotPresent)
return &artist.Artist, err
}
@@ -162,28 +165,37 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
return nil, ctx.Err()
}
artists := model.Artists{artist.Artist}
artists = append(artists, artist.SimilarArtists...)
weightedSongs := utils.NewWeightedRandomChooser()
for _, a := range artists {
addArtist := func(a model.Artist, weightedSongs *utils.WeightedChooser, count, artistWeight int) error {
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
return nil, ctx.Err()
return ctx.Err()
}
topCount := utils.MaxInt(count, 20)
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, Artist: a}, topCount)
if err != nil {
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
continue
return nil
}
weight := topCount * 4
weight := topCount * (4 + artistWeight)
for _, mf := range topSongs {
weightedSongs.Put(mf, weight)
weight -= 4
}
return nil
}
err = addArtist(artist.Artist, weightedSongs, count, 10)
if err != nil {
return nil, err
}
for _, a := range artist.SimilarArtists {
err := addArtist(a, weightedSongs, count, 0)
if err != nil {
return nil, err
}
}
var similarSongs model.MediaFiles
@@ -242,6 +254,7 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
if err == nil && len(mfs) > 0 {
return &mfs[0], nil
}
return e.findMatchingTrack(ctx, "", artistID, title)
}
mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
@@ -249,9 +262,10 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
squirrel.Eq{"artist_id": artistID},
squirrel.Eq{"album_artist_id": artistID},
},
squirrel.Like{"title": title},
squirrel.Like{"order_title": strings.TrimSpace(sanitize.Accents(title))},
},
Sort: "starred desc, rating desc, year asc",
Max: 1,
})
if err != nil || len(mfs) == 0 {
return nil, model.ErrNotFound
@@ -272,8 +286,7 @@ func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.Ar
if bio == "" || err != nil {
return
}
policy := bluemonday.UGCPolicy()
bio = policy.Sanitize(bio)
bio = utils.SanitizeText(bio)
bio = strings.ReplaceAll(bio, "\n", " ")
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -32,6 +32,9 @@ var _ = Describe("MediaStreamer", func() {
Eventually(func() bool { return testCache.Ready(context.TODO()) }).Should(BeTrue())
streamer = NewMediaStreamer(ds, ffmpeg, testCache)
})
AfterEach(func() {
_ = os.RemoveAll(conf.Server.DataFolder)
})
Context("NewStream", func() {
It("returns a seekable stream if format is 'raw'", func() {

View File

@@ -8,7 +8,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

263
core/playlists.go Normal file
View File

@@ -0,0 +1,263 @@
package core
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
)
type Playlists interface {
ImportFile(ctx context.Context, dir string, fname string) (*model.Playlist, error)
Update(ctx context.Context, playlistId string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
}
type playlists struct {
ds model.DataStore
}
func NewPlaylists(ds model.DataStore) Playlists {
return &playlists{ds: ds}
}
func IsPlaylist(filePath string) bool {
extension := strings.ToLower(filepath.Ext(filePath))
return extension == ".m3u" || extension == ".m3u8" || extension == ".nsp"
}
func (s *playlists) ImportFile(ctx context.Context, dir string, fname string) (*model.Playlist, error) {
pls, err := s.parsePlaylist(ctx, fname, dir)
if err != nil {
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(dir, fname), err)
return nil, err
}
log.Debug("Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
err = s.updatePlaylist(ctx, pls)
if err != nil {
log.Error(ctx, "Error updating playlist", "path", filepath.Join(dir, fname), err)
}
return pls, err
}
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, baseDir string) (*model.Playlist, error) {
pls, err := s.newSyncedPlaylist(baseDir, playlistFile)
if err != nil {
return nil, err
}
file, err := os.Open(pls.Path)
if err != nil {
return nil, err
}
defer file.Close()
extension := strings.ToLower(filepath.Ext(playlistFile))
switch extension {
case ".nsp":
return s.parseNSP(ctx, pls, file)
default:
return s.parseM3U(ctx, pls, baseDir, file)
}
}
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
playlistPath := filepath.Join(baseDir, playlistFile)
info, err := os.Stat(playlistPath)
if err != nil {
return nil, err
}
var extension = filepath.Ext(playlistFile)
var name = playlistFile[0 : len(playlistFile)-len(extension)]
pls := &model.Playlist{
Name: name,
Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
Public: false,
Path: playlistPath,
Sync: true,
UpdatedAt: info.ModTime(),
}
return pls, nil
}
func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) (*model.Playlist, error) {
nsp := &nspFile{}
dec := json.NewDecoder(file)
err := dec.Decode(nsp)
if err != nil {
log.Error(ctx, "Error parsing SmartPlaylist", "playlist", pls.Name, err)
return nil, err
}
pls.Rules = &nsp.Criteria
if nsp.Name != "" {
pls.Name = nsp.Name
}
if nsp.Comment != "" {
pls.Comment = nsp.Comment
}
return pls, nil
}
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, file io.Reader) (*model.Playlist, error) {
mediaFileRepository := s.ds.MediaFile(ctx)
scanner := bufio.NewScanner(file)
scanner.Split(scanLines)
var mfs model.MediaFiles
for scanner.Scan() {
path := strings.TrimSpace(scanner.Text())
// Skip empty lines and extended info
if path == "" || strings.HasPrefix(path, "#") {
continue
}
if strings.HasPrefix(path, "file://") {
path = strings.TrimPrefix(path, "file://")
path, _ = url.QueryUnescape(path)
}
if !filepath.IsAbs(path) {
path = filepath.Join(baseDir, path)
}
mf, err := mediaFileRepository.FindByPath(path)
if err != nil {
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path, err)
continue
}
mfs = append(mfs, *mf)
}
pls.Tracks = nil
pls.AddMediaFiles(mfs)
return pls, scanner.Err()
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
owner, _ := request.UserFrom(ctx)
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return err
}
if err == nil && !pls.Sync {
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
return nil
}
if err == nil {
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
newPls.ID = pls.ID
newPls.Name = pls.Name
newPls.Comment = pls.Comment
newPls.OwnerID = pls.OwnerID
newPls.Public = pls.Public
newPls.EvaluatedAt = time.Time{}
} else {
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
newPls.OwnerID = owner.ID
}
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 {
needsInfoUpdate := name != nil || comment != nil || public != nil
needsTrackRefresh := len(idxToRemove) > 0
return s.ds.WithTx(func(tx model.DataStore) error {
var pls *model.Playlist
var err error
repo := tx.Playlist(ctx)
if needsTrackRefresh {
pls, err = repo.GetWithTracks(playlistId)
pls.RemoveTracks(idxToRemove)
pls.AddTracks(idsToAdd)
} else {
if len(idsToAdd) > 0 {
_, err = repo.Tracks(playlistId).Add(idsToAdd)
if err != nil {
return err
}
}
if needsInfoUpdate {
pls, err = repo.Get(playlistId)
}
}
if err != nil {
return err
}
if !needsTrackRefresh && !needsInfoUpdate {
return nil
}
if name != nil {
pls.Name = *name
}
if comment != nil {
pls.Comment = *comment
}
if public != nil {
pls.Public = *public
}
// Special case: The playlist is now empty
if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
if err = repo.Tracks(playlistId).DeleteAll(); err != nil {
return err
}
}
return repo.Put(pls)
})
}
type nspFile struct {
criteria.Criteria
Name string `json:"name"`
Comment string `json:"comment"`
}
func (i *nspFile) UnmarshalJSON(data []byte) error {
m := map[string]interface{}{}
err := json.Unmarshal(data, &m)
if err != nil {
return err
}
i.Name, _ = m["name"].(string)
i.Comment, _ = m["comment"].(string)
return json.Unmarshal(data, &i.Criteria)
}

89
core/playlists_test.go Normal file
View File

@@ -0,0 +1,89 @@
package core
import (
"context"
"path/filepath"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("IsPlaylist", func() {
It("returns true for a M3U file", func() {
Expect(IsPlaylist(filepath.Join("path", "to", "test.m3u"))).To(BeTrue())
})
It("returns true for a M3U8 file", func() {
Expect(IsPlaylist(filepath.Join("path", "to", "test.m3u8"))).To(BeTrue())
})
It("returns false for a non-playlist file", func() {
Expect(IsPlaylist("testm3u")).To(BeFalse())
})
})
var _ = Describe("Playlists", func() {
var ds model.DataStore
var ps Playlists
ctx := context.Background()
BeforeEach(func() {
ds = &tests.MockDataStore{
MockedMediaFile: &mockedMediaFile{},
MockedPlaylist: &mockedPlaylist{},
}
})
Describe("ImportFile", func() {
BeforeEach(func() {
ps = NewPlaylists(ds)
})
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
Expect(err).To(BeNil())
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"))
})
It("parses playlists using LF ending", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "lf-ended.m3u")
Expect(err).To(BeNil())
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(pls.Tracks).To(HaveLen(2))
})
})
})
type mockedMediaFile struct {
model.MediaFileRepository
}
func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) {
return &model.MediaFile{
ID: "123",
Path: s,
}, nil
}
type mockedPlaylist struct {
model.PlaylistRepository
}
func (r *mockedPlaylist) FindByPath(string) (*model.Playlist, error) {
return nil, model.ErrNotFound
}
func (r *mockedPlaylist) Put(*model.Playlist) error {
return nil
}

View File

@@ -2,6 +2,7 @@ package scrobbler
import (
"context"
"errors"
"time"
"github.com/navidrome/navidrome/log"
@@ -92,16 +93,14 @@ func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string)
MediaFile: entry.MediaFile,
TimeStamp: entry.PlayTime,
})
if errors.Is(err, ErrRetryLater) {
log.Warn(ctx, "Could not send scrobble. Will be retried", "userId", entry.UserID,
"track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err)
return false
}
if err != nil {
switch err {
case ErrRetryLater:
log.Warn(ctx, "Could not send scrobble. Will be retried", "userId", entry.UserID,
"track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err)
return false
default:
log.Error(ctx, "Error sending scrobble to service. Discarding", "scrobbler", b.service,
"userId", entry.UserID, "artist", entry.Artist, "track", entry.Title, err)
}
log.Error(ctx, "Error sending scrobble to service. Discarding", "scrobbler", b.service,
"userId", entry.UserID, "artist", entry.Artist, "track", entry.Title, err)
}
err = buffer.Dequeue(entry)
if err != nil {

View File

@@ -6,6 +6,7 @@ import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/ReneKroon/ttlcache/v2"
"github.com/navidrome/navidrome/log"
@@ -44,7 +45,7 @@ type playTracker struct {
}
func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker {
instance := singleton.Get(playTracker{}, func() interface{} {
return singleton.GetInstance(func() *playTracker {
m := ttlcache.NewCache()
m.SkipTTLExtensionOnHit(true)
_ = m.SetTTL(nowPlayingExpire)
@@ -59,7 +60,6 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker {
}
return p
})
return instance.(*playTracker)
}
func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error {
@@ -85,6 +85,10 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, tra
log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
return
}
if t.Artist == consts.UnknownArtist {
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
return
}
// TODO Parallelize
for name, s := range p.scrobblers {
if !s.IsAuthorized(ctx, userId) {
@@ -94,7 +98,7 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, tra
err := s.NowPlaying(ctx, userId, t)
if err != nil {
log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
return
continue
}
}
}
@@ -136,9 +140,9 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro
} else {
success++
event.With("song", mf.ID).With("album", mf.AlbumID).With("artist", mf.AlbumArtistID)
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username)
log.Info("Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username, "timestamp", s.Timestamp)
if player.ScrobbleEnabled {
_ = p.dispatchScrobble(ctx, mf, s.Timestamp)
p.dispatchScrobble(ctx, mf, s.Timestamp)
}
}
}
@@ -164,7 +168,11 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times
})
}
func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time) error {
func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time) {
if t.Artist == consts.UnknownArtist {
log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist)
return
}
u, _ := request.UserFrom(ctx)
scrobble := Scrobble{MediaFile: *t, TimeStamp: playTime}
for name, s := range p.scrobblers {
@@ -172,17 +180,16 @@ func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile,
continue
}
if conf.Server.DevEnableBufferedScrobble {
log.Debug(ctx, "Buffering scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
log.Debug(ctx, "Buffering Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
} else {
log.Debug(ctx, "Sending scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
log.Debug(ctx, "Sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
}
err := s.Scrobble(ctx, u.ID, scrobble)
if err != nil {
log.Error(ctx, "Error sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
return err
continue
}
}
return nil
}
var constructors map[string]Constructor

View File

@@ -6,12 +6,13 @@ import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -78,6 +79,14 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse())
})
It("does not send track to agent if artist is unknown", func() {
track.Artist = consts.UnknownArtist
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse())
})
@@ -146,7 +155,7 @@ var _ = Describe("PlayTracker", func() {
Expect(fake.ScrobbleCalled).To(BeFalse())
})
It("does not send track to agent player is not enabled to send scrobbles", func() {
It("does not send track to agent if player is not enabled to send scrobbles", func() {
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
@@ -155,6 +164,15 @@ var _ = Describe("PlayTracker", func() {
Expect(fake.ScrobbleCalled).To(BeFalse())
})
It("does not send track to agent if artist is unknown", func() {
track.Artist = consts.UnknownArtist
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled).To(BeFalse())
})
It("increments play counts even if it cannot scrobble", func() {
fake.Error = errors.New("error")

View File

@@ -5,7 +5,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -49,6 +49,6 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
return id, err
}
func (r *shareRepositoryWrapper) Update(entity interface{}, _ ...string) error {
return r.Persistable.Update(entity, "description")
func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
return r.Persistable.Update(id, entity, "description")
}

View File

@@ -6,7 +6,7 @@ import (
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -41,7 +41,7 @@ var _ = Describe("Share", func() {
Describe("Update", func() {
It("filters out read-only fields", func() {
entity := "entity"
err := repo.Update(entity)
err := repo.Update("id", entity)
Expect(err).ToNot(HaveOccurred())
Expect(mockedRepo.(*tests.MockShareRepo).Entity).To(Equal("entity"))
Expect(mockedRepo.(*tests.MockShareRepo).Cols).To(ConsistOf("description"))

View File

@@ -5,7 +5,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -20,4 +20,5 @@ var Set = wire.NewSet(
transcoder.New,
scrobbler.GetPlayTracker,
NewShare,
NewPlaylists,
)

View File

@@ -19,7 +19,7 @@ var (
)
func Db() *sql.DB {
instance := singleton.Get(&sql.DB{}, func() interface{} {
return singleton.GetInstance(func() *sql.DB {
Path = conf.Server.DbPath
if Path == ":memory:" {
Path = "file::memory:?cache=shared&_foreign_keys=on"
@@ -32,7 +32,6 @@ func Db() *sql.DB {
}
return instance
})
return instance.(*sql.DB)
}
func EnsureLatestVersion() {

View File

@@ -6,7 +6,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -0,0 +1,37 @@
package migrations
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(upAddSmartPlaylist, downAddSmartPlaylist)
}
func upAddSmartPlaylist(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table playlist
add column rules varchar null;
alter table playlist
add column evaluated_at datetime null;
create index if not exists playlist_evaluated_at
on playlist(evaluated_at);
create table playlist_fields (
field varchar(255) not null,
playlist_id varchar(255) not null
constraint playlist_fields_playlist_id_fk
references playlist
on update cascade on delete cascade
);
create unique index playlist_fields_idx
on playlist_fields (field, playlist_id);
`)
return err
}
func downAddSmartPlaylist(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,62 @@
package migrations
import (
"database/sql"
"strings"
"github.com/kennygrant/sanitize"
"github.com/navidrome/navidrome/log"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(upAddOrderTitleToMediaFile, downAddOrderTitleToMediaFile)
}
func upAddOrderTitleToMediaFile(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table main.media_file
add order_title varchar null collate NOCASE;
create index if not exists media_file_order_title
on media_file (order_title);
`)
if err != nil {
return err
}
return upAddOrderTitleToMediaFile_populateOrderTitle(tx)
}
//goland:noinspection GoSnakeCaseUsage
func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error {
rows, err := tx.Query(`select id, title from media_file`)
if err != nil {
return err
}
defer rows.Close()
stmt, err := tx.Prepare("update media_file set order_title = ? where id = ?")
if err != nil {
return err
}
var id, title string
for rows.Next() {
err = rows.Scan(&id, &title)
if err != nil {
return err
}
orderTitle := strings.TrimSpace(sanitize.Accents(title))
_, err = stmt.Exec(orderTitle, id)
if err != nil {
log.Error("Error setting media_file's order_title", "title", title, "id", id, err)
}
}
return rows.Err()
}
func downAddOrderTitleToMediaFile(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,47 @@
package migrations
import (
"database/sql"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(upUnescapeLyricsAndComments, downUnescapeLyricsAndComments)
}
func upUnescapeLyricsAndComments(tx *sql.Tx) error {
rows, err := tx.Query(`select id, comment, lyrics, title from media_file`)
if err != nil {
return err
}
defer rows.Close()
stmt, err := tx.Prepare("update media_file set comment = ?, lyrics = ? where id = ?")
if err != nil {
return err
}
var id, title string
var comment, lyrics sql.NullString
for rows.Next() {
err = rows.Scan(&id, &comment, &lyrics, &title)
if err != nil {
return err
}
newComment := utils.SanitizeText(comment.String)
newLyrics := utils.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)
}
}
return rows.Err()
}
func downUnescapeLyricsAndComments(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,60 @@
package migrations
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(upAddUseridToPlaylist, downAddUseridToPlaylist)
}
func upAddUseridToPlaylist(tx *sql.Tx) error {
_, err := tx.Exec(`
create table playlist_dg_tmp
(
id varchar(255) not null
primary key,
name varchar(255) default '' not null,
comment varchar(255) default '' not null,
duration real default 0 not null,
song_count integer default 0 not null,
public bool default FALSE not null,
created_at datetime,
updated_at datetime,
path string default '' not null,
sync bool default false not null,
size integer default 0 not null,
rules varchar,
evaluated_at datetime,
owner_id varchar(255) not null
constraint playlist_user_user_id_fk
references user
on update cascade on delete cascade
);
insert into playlist_dg_tmp(id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at, owner_id)
select id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at,
(select id from user where user_name = owner) as user_id from playlist;
drop table playlist;
alter table playlist_dg_tmp rename to playlist;
create index playlist_created_at
on playlist (created_at);
create index playlist_evaluated_at
on playlist (evaluated_at);
create index playlist_name
on playlist (name);
create index playlist_size
on playlist (size);
create index playlist_updated_at
on playlist (updated_at);
`)
return err
}
func downAddUseridToPlaylist(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,23 @@
package migrations
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(upAddAlphabeticalByArtistIndex, downAddAlphabeticalByArtistIndex)
}
func upAddAlphabeticalByArtistIndex(tx *sql.Tx) error {
_, err := tx.Exec(`
create index album_alphabetical_by_artist
ON album(compilation, order_album_artist_name, order_album_name)
`)
return err
}
func downAddAlphabeticalByArtistIndex(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,22 @@
package migrations
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(upRemoveInvalidArtistIds, downRemoveInvalidArtistIds)
}
func upRemoveInvalidArtistIds(tx *sql.Tx) error {
_, err := tx.Exec(`
update media_file set artist_id = '' where not exists(select 1 from artist where id = artist_id)
`)
return err
}
func downRemoveInvalidArtistIds(tx *sql.Tx) error {
return nil
}

View File

@@ -0,0 +1,28 @@
package migrations
import (
"database/sql"
"github.com/pressly/goose"
)
func init() {
goose.AddMigration(upAddMusicbrainzReleaseTrackId, downAddMusicbrainzReleaseTrackId)
}
func upAddMusicbrainzReleaseTrackId(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table media_file
add mbz_release_track_id varchar(255);
`)
if err != nil {
return err
}
notice(tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(tx)
}
func downAddMusicbrainzReleaseTrackId(tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@@ -8,7 +8,7 @@ import (
"github.com/navidrome/navidrome/consts"
)
// Use this in migrations that need to communicate something important (braking changes, forced reindexes, etc...)
// Use this in migrations that need to communicate something important (breaking changes, forced reindexes, etc...)
func notice(tx *sql.Tx, msg string) {
if isDBInitialized(tx) {
fmt.Printf(`

249
go.mod
View File

@@ -1,56 +1,243 @@
module github.com/navidrome/navidrome
go 1.16
go 1.18
require (
code.cloudfoundry.org/go-diodes v0.0.0-20190809170250-f77fb823c7ee
github.com/ClickHouse/clickhouse-go v1.4.5 // indirect
github.com/Masterminds/squirrel v1.5.0
github.com/ReneKroon/ttlcache/v2 v2.8.1
github.com/astaxie/beego v1.12.3
github.com/Masterminds/squirrel v1.5.3
github.com/ReneKroon/ttlcache/v2 v2.11.0
github.com/beego/beego/v2 v2.0.5
github.com/bradleyjkemp/cupaloy v2.3.0+incompatible
github.com/cespare/reflex v0.3.1
github.com/deluan/rest v0.0.0-20210503015435-e7091d44f0ba
github.com/denisenkom/go-mssqldb v0.10.0 // indirect
github.com/dhowden/tag v0.0.0-20200412032933-5d76b8eaae27
github.com/deluan/rest v0.0.0-20211101235434-380523c4bb47
github.com/dhowden/tag v0.0.0-20220618230019-adf36e896086
github.com/disintegration/imaging v1.6.2
github.com/djherbis/atime v1.1.0
github.com/djherbis/fscache v0.10.2-0.20201024185917-a0daa9e52747
github.com/djherbis/fscache v0.10.2-0.20220222230828-2909c950912d
github.com/djherbis/stream v1.4.0
github.com/dustin/go-humanize v1.0.0
github.com/fatih/structs v1.1.0
github.com/go-chi/chi/v5 v5.0.4
github.com/go-chi/cors v1.2.0
github.com/go-chi/httprate v0.5.1
github.com/go-chi/chi/v5 v5.0.7
github.com/go-chi/cors v1.2.1
github.com/go-chi/httprate v0.7.0
github.com/go-chi/jwtauth/v5 v5.0.2
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/golangci/golangci-lint v1.42.1
github.com/golangci/golangci-lint v1.49.0
github.com/google/uuid v1.3.0
github.com/google/wire v0.5.0
github.com/kennygrant/sanitize v0.0.0-20170120101633-6a0bfdde8629
github.com/kr/pretty v0.3.0
github.com/lestrrat-go/jwx v1.2.7
github.com/lestrrat-go/jwx v1.2.25
github.com/matoous/go-nanoid v1.5.0
github.com/mattn/go-sqlite3 v2.0.3+incompatible
github.com/mattn/go-sqlite3 v1.14.15
github.com/mattn/go-zglob v0.0.3
github.com/microcosm-cc/bluemonday v1.0.15
github.com/mileusna/useragent v1.0.2
github.com/microcosm-cc/bluemonday v1.0.20
github.com/mileusna/useragent v1.2.1
github.com/oklog/run v1.1.0
github.com/onsi/ginkgo v1.16.4
github.com/onsi/gomega v1.16.0
github.com/onsi/ginkgo/v2 v2.2.0
github.com/onsi/gomega v1.20.2
github.com/pressly/goose v2.7.0+incompatible
github.com/prometheus/client_golang v1.13.0
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.8.1
github.com/spf13/cobra v1.2.1
github.com/spf13/viper v1.9.0
github.com/stretchr/testify v1.7.0
github.com/unrolled/secure v1.0.9
github.com/sirupsen/logrus v1.9.0
github.com/spf13/cobra v1.5.0
github.com/spf13/viper v1.13.0
github.com/stretchr/testify v1.8.0
github.com/unrolled/secure v1.13.0
github.com/xrash/smetrics v0.0.0-20200730060457-89a2a8a1fb0b
github.com/ziutek/mymysql v1.5.4 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d
golang.org/x/tools v0.1.7
gopkg.in/djherbis/atime.v1 v1.0.0 // indirect
gopkg.in/djherbis/stream.v1 v1.3.1
golang.org/x/text v0.3.7
golang.org/x/tools v0.1.12
)
replace github.com/dhowden/tag => github.com/wader/tag v0.0.0-20200426234345-d072771f6a51
require (
4d63.com/gochecknoglobals v0.1.0 // indirect
github.com/Antonboom/errname v0.1.7 // indirect
github.com/Antonboom/nilnil v0.1.1 // indirect
github.com/BurntSushi/toml v1.2.0 // indirect
github.com/ClickHouse/clickhouse-go v1.4.5 // indirect
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect
github.com/GaijinEntertainment/go-exhaustruct/v2 v2.3.0 // indirect
github.com/Masterminds/semver v1.5.0 // indirect
github.com/OpenPeeDeeP/depguard v1.1.0 // indirect
github.com/alexkohler/prealloc v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/ashanbrown/forbidigo v1.3.0 // indirect
github.com/ashanbrown/makezero v1.1.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bkielbasa/cyclop v1.2.0 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v3 v3.3.0 // indirect
github.com/breml/bidichk v0.2.3 // indirect
github.com/breml/errchkjson v0.3.0 // indirect
github.com/butuzov/ireturn v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/charithe/durationcheck v0.0.9 // indirect
github.com/chavacava/garif v0.0.0-20220630083739-93517212f375 // indirect
github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58 // indirect
github.com/creack/pty v1.1.11 // indirect
github.com/curioswitch/go-reassign v0.1.2 // indirect
github.com/daixiang0/gci v0.6.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d // indirect
github.com/denis-tingaikin/go-header v0.4.3 // indirect
github.com/denisenkom/go-mssqldb v0.10.0 // indirect
github.com/esimonov/ifshort v1.0.4 // indirect
github.com/ettle/strcase v0.1.1 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/go-critic/go-critic v0.6.4 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect
github.com/go-toolsmith/astcast v1.0.0 // indirect
github.com/go-toolsmith/astcopy v1.0.1 // indirect
github.com/go-toolsmith/astequal v1.0.2 // indirect
github.com/go-toolsmith/astfmt v1.0.0 // indirect
github.com/go-toolsmith/astp v1.0.0 // indirect
github.com/go-toolsmith/strparse v1.0.0 // indirect
github.com/go-toolsmith/typep v1.0.2 // indirect
github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.9.7 // indirect
github.com/gofrs/flock v0.8.1 // indirect
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golangci/check v0.0.0-20180506172741-cfe4005ccda2 // indirect
github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a // indirect
github.com/golangci/go-misc v0.0.0-20220329215616-d24fe342adfe // indirect
github.com/golangci/gofmt v0.0.0-20190930125516-244bba706f1a // indirect
github.com/golangci/lint-1 v0.0.0-20191013205115-297bf364a8e0 // indirect
github.com/golangci/maligned v0.0.0-20180506175553-b1d89398deca // indirect
github.com/golangci/misspell v0.3.5 // indirect
github.com/golangci/revgrep v0.0.0-20220804021717-745bb2f7c2e6 // indirect
github.com/golangci/unconvert v0.0.0-20180507085042-28b1c447d1f4 // indirect
github.com/google/go-cmp v0.5.8 // indirect
github.com/google/pprof v0.0.0-20220829040838-70bd9ae97f40 // indirect
github.com/google/subcommands v1.0.1 // indirect
github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.4.2 // indirect
github.com/gostaticanalysis/forcetypeassert v0.1.0 // indirect
github.com/gostaticanalysis/nilerr v0.1.1 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/jgautheron/goconst v1.5.1 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
github.com/jirfag/go-printf-func-name v0.0.0-20200119135958-7558a9eaa5af // indirect
github.com/julz/importas v0.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kisielk/errcheck v1.6.2 // indirect
github.com/kisielk/gotool v1.0.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/kulti/thelper v0.6.3 // indirect
github.com/kunwardeep/paralleltest v1.0.6 // indirect
github.com/kyoh86/exportloopref v0.1.8 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/ldez/gomoddirectives v0.2.3 // indirect
github.com/ldez/tagliatelle v0.3.1 // indirect
github.com/leonklingele/grouper v1.1.0 // indirect
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
github.com/lestrrat-go/blackmagic v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/iter v1.0.1 // indirect
github.com/lestrrat-go/option v1.0.0 // indirect
github.com/lib/pq v1.10.6 // indirect
github.com/lufeee/execinquery v1.2.1 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/maratori/testpackage v1.1.0 // indirect
github.com/matoous/godox v0.0.0-20210227103229-6504466cf951 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mbilski/exhaustivestruct v1.2.0 // indirect
github.com/mgechev/revive v1.2.3 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moricho/tparallel v0.2.1 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354 // indirect
github.com/nishanths/exhaustive v0.8.1 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/ogier/pflag v0.0.1 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/onsi/ginkgo v1.16.5 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.0.5 // indirect
github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/polyfloyd/go-errorlint v1.0.2 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/quasilyte/go-ruleguard v0.3.17 // indirect
github.com/quasilyte/gogrep v0.0.0-20220120141003-628d8b3623b5 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/rogpeppe/go-internal v1.8.1 // indirect
github.com/ryancurrah/gomodguard v1.2.4 // indirect
github.com/ryanrolds/sqlclosecheck v0.3.0 // indirect
github.com/sanposhiho/wastedassign/v2 v2.0.6 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.13.0 // indirect
github.com/securego/gosec/v2 v2.13.1 // indirect
github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c // indirect
github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 // indirect
github.com/sivchari/containedctx v1.0.2 // indirect
github.com/sivchari/nosnakecase v1.7.0 // indirect
github.com/sivchari/tenv v1.7.0 // indirect
github.com/sonatard/noctx v0.0.1 // indirect
github.com/sourcegraph/go-diff v0.6.1 // indirect
github.com/spf13/afero v1.8.2 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.1.1 // indirect
github.com/stretchr/objx v0.4.0 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/sylvia7788/contextcheck v1.0.6 // indirect
github.com/tdakkota/asciicheck v0.1.1 // indirect
github.com/tetafro/godot v1.4.11 // indirect
github.com/timakin/bodyclose v0.0.0-20210704033933-f49887972144 // indirect
github.com/timonwong/logrlint v0.1.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.6.2 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.0 // indirect
github.com/ultraware/funlen v0.0.3 // indirect
github.com/ultraware/whitespace v0.0.5 // indirect
github.com/uudashr/gocognit v1.0.6 // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.2.0 // indirect
github.com/ziutek/mymysql v1.5.4 // indirect
gitlab.com/bosi/decorder v0.2.3 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.17.0 // indirect
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect
golang.org/x/exp/typeparams v0.0.0-20220613132600-b0d781184e0d // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b // indirect
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.3.3 // indirect
mvdan.cc/gofumpt v0.3.1 // indirect
mvdan.cc/interfacer v0.0.0-20180901003855-c20040233aed // indirect
mvdan.cc/lint v0.0.0-20170908181259-adc824a0674b // indirect
mvdan.cc/unparam v0.0.0-20220706161116-678bad134442 // indirect
)

1024
go.sum
View File

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ package log
import (
"time"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -6,7 +6,7 @@ import (
"net/http/httptest"
"testing"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"

View File

@@ -6,6 +6,7 @@ import (
"time"
"github.com/navidrome/navidrome/cmd"
_ "github.com/navidrome/navidrome/model/criteria"
)
func main() {

View File

@@ -52,6 +52,7 @@ type AlbumRepository interface {
Put(*Album) error
Get(id string) (*Album, error)
GetAll(...QueryOptions) (Albums, error)
GetAllWithoutGenres(...QueryOptions) (Albums, error)
Search(q string, offset int, size int) (Albums, error)
Refresh(ids ...string) error
AnnotatedRepository

102
model/criteria/criteria.go Normal file
View File

@@ -0,0 +1,102 @@
// Package criteria implements a Criteria API based on Masterminds/squirrel
package criteria
import (
"encoding/json"
"errors"
"strings"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
)
type Expression = squirrel.Sqlizer
type Criteria struct {
Expression
Sort string
Order string
Limit int
Offset int
}
func (c Criteria) OrderBy() string {
if c.Sort == "" {
c.Sort = "title"
}
f := fieldMap[strings.ToLower(c.Sort)]
var mapped string
if f == nil {
log.Error("Invalid field in 'sort' field. Using 'title'", "sort", c.Sort)
mapped = fieldMap["title"].field
} else {
if f.order == "" {
mapped = f.field
} else {
mapped = f.order
}
}
if c.Order != "" {
if strings.EqualFold(c.Order, "asc") || strings.EqualFold(c.Order, "desc") {
mapped = mapped + " " + c.Order
} else {
log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order)
}
}
return mapped
}
func (c Criteria) ToSql() (sql string, args []interface{}, err error) {
return c.Expression.ToSql()
}
func (c Criteria) MarshalJSON() ([]byte, error) {
aux := struct {
All []Expression `json:"all,omitempty"`
Any []Expression `json:"any,omitempty"`
Sort string `json:"sort,omitempty"`
Order string `json:"order,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
}{
Sort: c.Sort,
Order: c.Order,
Limit: c.Limit,
Offset: c.Offset,
}
switch rules := c.Expression.(type) {
case Any:
aux.Any = rules
case All:
aux.All = rules
default:
aux.All = All{rules}
}
return json.Marshal(aux)
}
func (c *Criteria) UnmarshalJSON(data []byte) error {
var aux struct {
All unmarshalConjunctionType `json:"all"`
Any unmarshalConjunctionType `json:"any"`
Sort string `json:"sort"`
Order string `json:"order"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
if len(aux.Any) > 0 {
c.Expression = Any(aux.Any)
} else if len(aux.All) > 0 {
c.Expression = All(aux.All)
} else {
return errors.New("invalid criteria json. missing rules (key 'all' or 'any')")
}
c.Sort = aux.Sort
c.Order = aux.Order
c.Limit = aux.Limit
c.Offset = aux.Offset
return nil
}

View File

@@ -0,0 +1,16 @@
package criteria
import (
"testing"
_ "github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/log"
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
func TestCriteria(t *testing.T) {
log.SetLevel(log.LevelCritical)
gomega.RegisterFailHandler(Fail)
RunSpecs(t, "Criteria Suite")
}

View File

@@ -0,0 +1,90 @@
package criteria
import (
"bytes"
"encoding/json"
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
var _ = Describe("Criteria", func() {
var goObj Criteria
var jsonObj string
BeforeEach(func() {
goObj = Criteria{
Expression: All{
Contains{"title": "love"},
NotContains{"title": "hate"},
Any{
IsNot{"artist": "u2"},
Is{"album": "best of"},
},
All{
StartsWith{"comment": "this"},
InTheRange{"year": []int{1980, 1990}},
},
},
Sort: "title",
Order: "asc",
Limit: 20,
Offset: 10,
}
var b bytes.Buffer
err := json.Compact(&b, []byte(`
{
"all": [
{ "contains": {"title": "love"} },
{ "notContains": {"title": "hate"} },
{ "any": [
{ "isNot": {"artist": "u2"} },
{ "is": {"album": "best of"} }
]
},
{ "all": [
{ "startsWith": {"comment": "this"} },
{ "inTheRange": {"year":[1980,1990]} }
]
}
],
"sort": "title",
"order": "asc",
"limit": 20,
"offset": 10
}
`))
if err != nil {
panic(err)
}
jsonObj = b.String()
})
It("generates valid SQL", 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 <= ?)))"))
gomega.Expect(args).To(gomega.ConsistOf("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990))
})
It("marshals to JSON", func() {
j, err := json.Marshal(goObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
})
It("is reversible to/from JSON", func() {
var newObj Criteria
err := json.Unmarshal([]byte(jsonObj), &newObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
j, err := json.Marshal(newObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(j)).To(gomega.Equal(jsonObj))
})
It("allows sort by random", func() {
newObj := goObj
newObj.Sort = "random"
gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc"))
})
})

62
model/criteria/fields.go Normal file
View File

@@ -0,0 +1,62 @@
package criteria
import (
"strings"
"github.com/navidrome/navidrome/log"
)
var fieldMap = map[string]*mappedField{
"title": {field: "media_file.title"},
"album": {field: "media_file.album"},
"artist": {field: "media_file.artist"},
"albumartist": {field: "media_file.album_artist"},
"hascoverart": {field: "media_file.has_cover_art"},
"tracknumber": {field: "media_file.track_number"},
"discnumber": {field: "media_file.disc_number"},
"year": {field: "media_file.year"},
"size": {field: "media_file.size"},
"compilation": {field: "media_file.compilation"},
"dateadded": {field: "media_file.created_at"},
"datemodified": {field: "media_file.updated_at"},
"discsubtitle": {field: "media_file.disc_subtitle"},
"comment": {field: "media_file.comment"},
"lyrics": {field: "media_file.lyrics"},
"sorttitle": {field: "media_file.sort_title"},
"sortalbum": {field: "media_file.sort_album_name"},
"sortartist": {field: "media_file.sort_artist_name"},
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
"albumtype": {field: "media_file.mbz_album_type"},
"albumcomment": {field: "media_file.mbz_album_comment"},
"catalognumber": {field: "media_file.catalog_num"},
"filepath": {field: "media_file.path"},
"filetype": {field: "media_file.suffix"},
"duration": {field: "media_file.duration"},
"bitrate": {field: "media_file.bit_rate"},
"bpm": {field: "media_file.bpm"},
"channels": {field: "media_file.channels"},
"genre": {field: "genre.name"},
"loved": {field: "COALESCE(annotation.starred, false)"},
"dateloved": {field: "annotation.starred_at"},
"lastplayed": {field: "annotation.play_date"},
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
"rating": {field: "COALESCE(annotation.rating, 0)"},
"random": {field: "", order: "random()"},
}
type mappedField struct {
field string
order string
}
func mapFields(expr map[string]interface{}) map[string]interface{} {
m := make(map[string]interface{})
for f, v := range expr {
if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" {
m[dbf.field] = v
} else {
log.Error("Invalid field in criteria", "field", f)
}
}
return m
}

View File

@@ -0,0 +1,16 @@
package criteria
import (
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
var _ = Describe("fields", func() {
Describe("mapFields", func() {
It("ignores random fields", func() {
m := map[string]interface{}{"random": "123"}
m = mapFields(m)
gomega.Expect(m).To(gomega.BeEmpty())
})
})
})

125
model/criteria/json.go Normal file
View File

@@ -0,0 +1,125 @@
package criteria
import (
"encoding/json"
"fmt"
"strings"
"time"
)
type unmarshalConjunctionType []Expression
func (uc *unmarshalConjunctionType) UnmarshalJSON(data []byte) error {
var raw []map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
var es unmarshalConjunctionType
for _, e := range raw {
for k, v := range e {
k = strings.ToLower(k)
expr := unmarshalExpression(k, v)
if expr == nil {
expr = unmarshalConjunction(k, v)
}
if expr == nil {
return fmt.Errorf(`invalid expression key %s`, k)
}
es = append(es, expr)
}
}
*uc = es
return nil
}
func unmarshalExpression(opName string, rawValue json.RawMessage) Expression {
m := make(map[string]interface{})
err := json.Unmarshal(rawValue, &m)
if err != nil {
return nil
}
switch opName {
case "is":
return Is(m)
case "isnot":
return IsNot(m)
case "gt":
return Gt(m)
case "lt":
return Lt(m)
case "contains":
return Contains(m)
case "notcontains":
return NotContains(m)
case "startswith":
return StartsWith(m)
case "endswith":
return EndsWith(m)
case "intherange":
return InTheRange(m)
case "before":
return Before(m)
case "after":
return After(m)
case "inthelast":
return InTheLast(m)
case "notinthelast":
return NotInTheLast(m)
}
return nil
}
func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression {
var items unmarshalConjunctionType
err := json.Unmarshal(rawValue, &items)
if err != nil {
return nil
}
switch conjName {
case "any":
return Any(items)
case "all":
return All(items)
}
return nil
}
func marshalExpression(name string, value map[string]interface{}) ([]byte, error) {
if len(value) != 1 {
return nil, fmt.Errorf(`invalid %s expression length %d for values %v`, name, len(value), value)
}
b := strings.Builder{}
b.WriteString(`{"` + name + `":{`)
for f, v := range value {
j, err := json.Marshal(v)
if err != nil {
return nil, err
}
b.WriteString(`"` + f + `":`)
b.Write(j)
break
}
b.WriteString("}}")
return []byte(b.String()), nil
}
func marshalConjunction(name string, conj []Expression) ([]byte, error) {
aux := struct {
All []Expression `json:"all,omitempty"`
Any []Expression `json:"any,omitempty"`
}{}
if name == "any" {
aux.Any = conj
} else {
aux.All = conj
}
return json.Marshal(aux)
}
type date time.Time
func (t date) MarshalJSON() ([]byte, error) {
stamp := fmt.Sprintf(`"%s"`, time.Time(t).Format("2006-01-02"))
return []byte(stamp), nil
}

229
model/criteria/operators.go Normal file
View File

@@ -0,0 +1,229 @@
package criteria
import (
"fmt"
"reflect"
"strconv"
"time"
"github.com/Masterminds/squirrel"
)
type (
All squirrel.And
And = All
)
func (all All) ToSql() (sql string, args []interface{}, err error) {
return squirrel.And(all).ToSql()
}
func (all All) MarshalJSON() ([]byte, error) {
return marshalConjunction("all", all)
}
type (
Any squirrel.Or
Or = Any
)
func (any Any) ToSql() (sql string, args []interface{}, err error) {
return squirrel.Or(any).ToSql()
}
func (any Any) MarshalJSON() ([]byte, error) {
return marshalConjunction("any", any)
}
type Is squirrel.Eq
type Eq = Is
func (is Is) ToSql() (sql string, args []interface{}, err error) {
return squirrel.Eq(mapFields(is)).ToSql()
}
func (is Is) MarshalJSON() ([]byte, error) {
return marshalExpression("is", is)
}
type IsNot squirrel.NotEq
func (in IsNot) ToSql() (sql string, args []interface{}, err error) {
return squirrel.NotEq(mapFields(in)).ToSql()
}
func (in IsNot) MarshalJSON() ([]byte, error) {
return marshalExpression("isNot", in)
}
type Gt squirrel.Gt
func (gt Gt) ToSql() (sql string, args []interface{}, err error) {
return squirrel.Gt(mapFields(gt)).ToSql()
}
func (gt Gt) MarshalJSON() ([]byte, error) {
return marshalExpression("gt", gt)
}
type Lt squirrel.Lt
func (lt Lt) ToSql() (sql string, args []interface{}, err error) {
return squirrel.Lt(mapFields(lt)).ToSql()
}
func (lt Lt) MarshalJSON() ([]byte, error) {
return marshalExpression("lt", lt)
}
type Before squirrel.Lt
func (bf Before) ToSql() (sql string, args []interface{}, err error) {
return squirrel.Lt(mapFields(bf)).ToSql()
}
func (bf Before) MarshalJSON() ([]byte, error) {
return marshalExpression("before", bf)
}
type After squirrel.Gt
func (af After) ToSql() (sql string, args []interface{}, err error) {
return squirrel.Gt(mapFields(af)).ToSql()
}
func (af After) MarshalJSON() ([]byte, error) {
return marshalExpression("after", af)
}
type Contains map[string]interface{}
func (ct Contains) ToSql() (sql string, args []interface{}, err error) {
lk := squirrel.Like{}
for f, v := range mapFields(ct) {
lk[f] = fmt.Sprintf("%%%s%%", v)
}
return lk.ToSql()
}
func (ct Contains) MarshalJSON() ([]byte, error) {
return marshalExpression("contains", ct)
}
type NotContains map[string]interface{}
func (nct NotContains) ToSql() (sql string, args []interface{}, err error) {
lk := squirrel.NotLike{}
for f, v := range mapFields(nct) {
lk[f] = fmt.Sprintf("%%%s%%", v)
}
return lk.ToSql()
}
func (nct NotContains) MarshalJSON() ([]byte, error) {
return marshalExpression("notContains", nct)
}
type StartsWith map[string]interface{}
func (sw StartsWith) ToSql() (sql string, args []interface{}, err error) {
lk := squirrel.Like{}
for f, v := range mapFields(sw) {
lk[f] = fmt.Sprintf("%s%%", v)
}
return lk.ToSql()
}
func (sw StartsWith) MarshalJSON() ([]byte, error) {
return marshalExpression("startsWith", sw)
}
type EndsWith map[string]interface{}
func (sw EndsWith) ToSql() (sql string, args []interface{}, err error) {
lk := squirrel.Like{}
for f, v := range mapFields(sw) {
lk[f] = fmt.Sprintf("%%%s", v)
}
return lk.ToSql()
}
func (sw EndsWith) MarshalJSON() ([]byte, error) {
return marshalExpression("endsWith", sw)
}
type InTheRange map[string]interface{}
func (itr InTheRange) ToSql() (sql string, args []interface{}, err error) {
var and squirrel.And
for f, v := range mapFields(itr) {
s := reflect.ValueOf(v)
if s.Kind() != reflect.Slice || s.Len() != 2 {
return "", nil, fmt.Errorf("invalid range for 'in' operator: %s", v)
}
and = append(and,
squirrel.GtOrEq{f: s.Index(0).Interface()},
squirrel.LtOrEq{f: s.Index(1).Interface()},
)
}
return and.ToSql()
}
func (itr InTheRange) MarshalJSON() ([]byte, error) {
return marshalExpression("inTheRange", itr)
}
type InTheLast map[string]interface{}
func (itl InTheLast) ToSql() (sql string, args []interface{}, err error) {
exp, err := inPeriod(itl, false)
if err != nil {
return "", nil, err
}
return exp.ToSql()
}
func (itl InTheLast) MarshalJSON() ([]byte, error) {
return marshalExpression("inTheLast", itl)
}
type NotInTheLast map[string]interface{}
func (nitl NotInTheLast) ToSql() (sql string, args []interface{}, err error) {
exp, err := inPeriod(nitl, true)
if err != nil {
return "", nil, err
}
return exp.ToSql()
}
func (nitl NotInTheLast) MarshalJSON() ([]byte, error) {
return marshalExpression("notInTheLast", nitl)
}
func inPeriod(m map[string]interface{}, negate bool) (Expression, error) {
var field string
var value interface{}
for f, v := range mapFields(m) {
field, value = f, v
break
}
str := fmt.Sprintf("%v", value)
v, err := strconv.ParseInt(str, 10, 64)
if err != nil {
return nil, err
}
firstDate := startOfPeriod(v, time.Now())
if negate {
return Or{
squirrel.Lt{field: firstDate},
squirrel.Eq{field: nil},
}, nil
}
return squirrel.Gt{field: firstDate}, nil
}
func startOfPeriod(numDays int64, from time.Time) string {
return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
}

View File

@@ -0,0 +1,70 @@
package criteria
import (
"encoding/json"
"fmt"
"time"
. "github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
var _ = Describe("Operators", func() {
rangeStart := date(time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local))
rangeEnd := date(time.Date(2021, 11, 01, 0, 0, 0, 0, time.Local))
DescribeTable("ToSQL",
func(op Expression, expectedSql string, expectedArgs ...any) {
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...))
},
Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),
Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10),
Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10),
Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),
Entry("notContains", NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"),
Entry("startsWith", StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"),
Entry("endsWith", EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"),
Entry("inTheRange [number]", InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
Entry("inTheRange [date]", InTheRange{"lastPlayed": []date{rangeStart, rangeEnd}}, "(annotation.play_date >= ? AND annotation.play_date <= ?)", rangeStart, rangeEnd),
Entry("before", Before{"lastPlayed": rangeStart}, "annotation.play_date < ?", rangeStart),
Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart),
// TODO These may be flaky
Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", startOfPeriod(30, time.Now())),
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", startOfPeriod(30, time.Now())),
)
DescribeTable("JSON Marshaling",
func(op Expression, jsonString string) {
obj := And{op}
newJs, err := json.Marshal(obj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(newJs)).To(gomega.Equal(fmt.Sprintf(`{"all":[%s]}`, jsonString)))
var unmarshalObj unmarshalConjunctionType
js := "[" + jsonString + "]"
err = json.Unmarshal([]byte(js), &unmarshalObj)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(unmarshalObj[0]).To(gomega.Equal(op))
},
Entry("is [string]", Is{"title": "Low Rider"}, `{"is":{"title":"Low Rider"}}`),
Entry("is [bool]", Is{"loved": false}, `{"is":{"loved":false}}`),
Entry("isNot", IsNot{"title": "Low Rider"}, `{"isNot":{"title":"Low Rider"}}`),
Entry("gt", Gt{"playCount": 10.0}, `{"gt":{"playCount":10}}`),
Entry("lt", Lt{"playCount": 10.0}, `{"lt":{"playCount":10}}`),
Entry("contains", Contains{"title": "Low Rider"}, `{"contains":{"title":"Low Rider"}}`),
Entry("notContains", NotContains{"title": "Low Rider"}, `{"notContains":{"title":"Low Rider"}}`),
Entry("startsWith", StartsWith{"title": "Low Rider"}, `{"startsWith":{"title":"Low Rider"}}`),
Entry("endsWith", EndsWith{"title": "Low Rider"}, `{"endsWith":{"title":"Low Rider"}}`),
Entry("inTheRange [number]", InTheRange{"year": []interface{}{1980.0, 1990.0}}, `{"inTheRange":{"year":[1980,1990]}}`),
Entry("inTheRange [date]", InTheRange{"lastPlayed": []interface{}{"2021-10-01", "2021-11-01"}}, `{"inTheRange":{"lastPlayed":["2021-10-01","2021-11-01"]}}`),
Entry("before", Before{"lastPlayed": "2021-10-01"}, `{"before":{"lastPlayed":"2021-10-01"}}`),
Entry("after", After{"lastPlayed": "2021-10-01"}, `{"after":{"lastPlayed":"2021-10-01"}}`),
Entry("inTheLast", InTheLast{"lastPlayed": 30.0}, `{"inTheLast":{"lastPlayed":30}}`),
Entry("notInTheLast", NotInTheLast{"lastPlayed": 30.0}, `{"notInTheLast":{"lastPlayed":30}}`),
)
})

View File

@@ -35,6 +35,7 @@ type MediaFile struct {
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"`
OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"`
OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"`
OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"`
OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"`
@@ -44,6 +45,7 @@ type MediaFile struct {
Bpm int `structs:"bpm" json:"bpm,omitempty"`
CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"`
MbzTrackID string `structs:"mbz_track_id" json:"mbzTrackId,omitempty" orm:"column(mbz_track_id)"`
MbzReleaseTrackID string `structs:"mbz_release_track_id" json:"mbzReleaseTrackId,omitempty" orm:"column(mbz_release_track_id)"`
MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty" orm:"column(mbz_album_id)"`
MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty" orm:"column(mbz_artist_id)"`
MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty" orm:"column(mbz_album_artist_id)"`
@@ -65,11 +67,13 @@ type MediaFileRepository interface {
Put(m *MediaFile) error
Get(id string) (*MediaFile, error)
GetAll(options ...QueryOptions) (MediaFiles, error)
Search(q string, offset int, size int) (MediaFiles, error)
Delete(id string) error
// 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)
FindPathsRecursively(basePath string) ([]string, error)
Search(q string, offset int, size int) (MediaFiles, error)
Delete(id string) error
DeleteByPath(path string) (int64, error)
AnnotatedRepository

View File

@@ -1,11 +1,20 @@
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 {

18
model/model_suite_test.go Normal file
View File

@@ -0,0 +1,18 @@
package model_test
import (
"testing"
_ "github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestModel(t *testing.T) {
tests.Init(t, true)
log.SetLevel(log.LevelCritical)
RegisterFailHandler(Fail)
RunSpecs(t, "Model Suite")
}

View File

@@ -1,32 +1,93 @@
package model
import (
"strconv"
"time"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/utils"
)
type Playlist struct {
ID string `structs:"id" json:"id" orm:"column(id)"`
Name string `structs:"name" json:"name"`
Comment string `structs:"comment" json:"comment"`
Duration float32 `structs:"duration" json:"duration"`
Size int64 `structs:"size" json:"size"`
SongCount int `structs:"song_count" json:"songCount"`
Owner string `structs:"owner" json:"owner"`
Public bool `structs:"public" json:"public"`
Tracks MediaFiles `structs:"-" json:"tracks,omitempty"`
Path string `structs:"path" json:"path"`
Sync bool `structs:"sync" json:"sync"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
ID string `structs:"id" json:"id" orm:"column(id)"`
Name string `structs:"name" json:"name"`
Comment string `structs:"comment" json:"comment"`
Duration float32 `structs:"duration" json:"duration"`
Size int64 `structs:"size" json:"size"`
SongCount int `structs:"song_count" json:"songCount"`
OwnerName string `structs:"-" json:"ownerName"`
OwnerID string `structs:"owner_id" json:"ownerId" orm:"column(owner_id)"`
Public bool `structs:"public" json:"public"`
Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"`
Path string `structs:"path" json:"path"`
Sync bool `structs:"sync" json:"sync"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
// SmartPlaylist attributes
Rules *criteria.Criteria `structs:"-" json:"rules"`
EvaluatedAt time.Time `structs:"evaluated_at" json:"evaluatedAt"`
}
func (pls Playlist) IsSmartPlaylist() bool {
return pls.Rules != nil && pls.Rules.Expression != nil
}
func (pls Playlist) MediaFiles() MediaFiles {
if len(pls.Tracks) == 0 {
return nil
}
return pls.Tracks.MediaFiles()
}
func (pls *Playlist) RemoveTracks(idxToRemove []int) {
var newTracks PlaylistTracks
for i, t := range pls.Tracks {
if utils.IntInSlice(i, idxToRemove) {
continue
}
newTracks = append(newTracks, t)
}
pls.Tracks = newTracks
}
func (pls *Playlist) AddTracks(mediaFileIds []string) {
pos := len(pls.Tracks)
for _, mfId := range mediaFileIds {
pos++
t := PlaylistTrack{
ID: strconv.Itoa(pos),
MediaFileID: mfId,
MediaFile: MediaFile{ID: mfId},
PlaylistID: pls.ID,
}
pls.Tracks = append(pls.Tracks, t)
}
}
func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
pos := len(pls.Tracks)
for _, mf := range mfs {
pos++
t := PlaylistTrack{
ID: strconv.Itoa(pos),
MediaFileID: mf.ID,
MediaFile: mf,
PlaylistID: pls.ID,
}
pls.Tracks = append(pls.Tracks, t)
}
}
type Playlists []Playlist
type PlaylistRepository interface {
ResourceRepository
CountAll(options ...QueryOptions) (int64, error)
Exists(id string) (bool, error)
Put(pls *Playlist) error
Get(id string) (*Playlist, error)
GetWithTracks(id string) (*Playlist, error)
GetAll(options ...QueryOptions) (Playlists, error)
FindByPath(path string) (*Playlist, error)
Delete(id string) error
@@ -42,13 +103,22 @@ type PlaylistTrack struct {
type PlaylistTracks []PlaylistTrack
func (plt PlaylistTracks) MediaFiles() MediaFiles {
mfs := make(MediaFiles, len(plt))
for i, t := range plt {
mfs[i] = t.MediaFile
}
return mfs
}
type PlaylistTrackRepository interface {
ResourceRepository
GetAll(options ...QueryOptions) (PlaylistTracks, error)
Add(mediaFileIds []string) (int, error)
AddAlbums(albumIds []string) (int, error)
AddArtists(artistIds []string) (int, error)
AddDiscs(discs []DiscID) (int, error)
Update(mediaFileIds []string) error
Delete(id string) error
Delete(id ...string) error
DeleteAll() error
Reorder(pos int, newPos int) error
}

View File

@@ -11,7 +11,7 @@ import (
"time"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/beego/beego/v2/client/orm"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
@@ -25,7 +25,7 @@ type albumRepository struct {
sqlRestful
}
func NewAlbumRepository(ctx context.Context, o orm.Ormer) model.AlbumRepository {
func NewAlbumRepository(ctx context.Context, o orm.QueryExecutor) model.AlbumRepository {
r := &albumRepository{}
r.ctx = ctx
r.ormer = o
@@ -93,7 +93,18 @@ func (r *albumRepository) Exists(id string) (bool, error) {
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
sql := r.newSelectWithAnnotation("album.id", options...).Columns("album.*")
return r.withGenres(sql).GroupBy("album.id")
if len(options) > 0 && options[0].Filters != nil {
s, _, _ := options[0].Filters.ToSql()
// If there's any reference of genre in the filter, joins with genre
if strings.Contains(s, "genre") {
sql = r.withGenres(sql)
// If there's no filter on genre_id, group the results by media_file.id
if !strings.Contains(s, "genre_id") {
sql = sql.GroupBy("album.id")
}
}
}
return sql
}
func (r *albumRepository) Get(id string) (*model.Album, error) {
@@ -118,13 +129,21 @@ func (r *albumRepository) Put(m *model.Album) error {
}
func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, error) {
res, err := r.GetAllWithoutGenres(options...)
if err != nil {
return nil, err
}
err = r.loadAlbumGenres(&res)
return res, err
}
func (r *albumRepository) GetAllWithoutGenres(options ...model.QueryOptions) (model.Albums, error) {
sq := r.selectAlbum(options...)
res := model.Albums{}
err := r.queryAll(sq, &res)
if err != nil {
return nil, err
}
err = r.loadAlbumGenres(&res)
return res, err
}
@@ -366,22 +385,5 @@ func (r *albumRepository) NewInstance() interface{} {
return &model.Album{}
}
func (r albumRepository) Delete(id string) error {
return r.delete(Eq{"album.id": id})
}
func (r albumRepository) Save(entity interface{}) (string, error) {
album := entity.(*model.Album)
id, err := r.put(album.ID, album)
return id, err
}
func (r albumRepository) Update(entity interface{}, cols ...string) error {
album := entity.(*model.Album)
_, err := r.put(album.ID, album)
return err
}
var _ model.AlbumRepository = (*albumRepository)(nil)
var _ model.ResourceRepository = (*albumRepository)(nil)
var _ rest.Persistable = (*albumRepository)(nil)

View File

@@ -5,13 +5,13 @@ import (
"os"
"path/filepath"
"github.com/astaxie/beego/orm"
"github.com/beego/beego/v2/client/orm"
"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/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -88,19 +88,25 @@ var _ = Describe("AlbumRepository", func() {
})
Describe("getCoverFromPath", func() {
testFolder, _ := os.MkdirTemp("", "album_persistence_tests")
if err := os.MkdirAll(testFolder, 0777); err != nil {
panic(err)
}
if _, err := os.Create(filepath.Join(testFolder, "Cover.jpeg")); err != nil {
panic(err)
}
if _, err := os.Create(filepath.Join(testFolder, "FRONT.PNG")); err != nil {
panic(err)
}
var testFolder, testPath, embeddedPath string
BeforeEach(func() {
testFolder, _ = os.MkdirTemp("", "album_persistence_tests")
if err := os.MkdirAll(testFolder, 0777); err != nil {
panic(err)
}
if _, err := os.Create(filepath.Join(testFolder, "Cover.jpeg")); err != nil {
panic(err)
}
if _, err := os.Create(filepath.Join(testFolder, "FRONT.PNG")); err != nil {
panic(err)
}
testPath = filepath.Join(testFolder, "somefile.test")
embeddedPath = filepath.Join(testFolder, "somefile.mp3")
})
AfterEach(func() {
_ = os.RemoveAll(testFolder)
})
testPath := filepath.Join(testFolder, "somefile.test")
embeddedPath := filepath.Join(testFolder, "somefile.mp3")
It("returns audio file for embedded cover", func() {
conf.Server.CoverArtPriority = "embedded, cover.*, front.*"
Expect(getCoverFromPath(testPath, embeddedPath)).To(Equal(""))

View File

@@ -8,7 +8,7 @@ import (
"strings"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/beego/beego/v2/client/orm"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
@@ -28,7 +28,7 @@ type dbArtist struct {
SimilarArtists string `structs:"similar_artists" json:"similarArtists"`
}
func NewArtistRepository(ctx context.Context, o orm.Ormer) model.ArtistRepository {
func NewArtistRepository(ctx context.Context, o orm.QueryExecutor) model.ArtistRepository {
r := &artistRepository{}
r.ctx = ctx
r.ormer = o
@@ -275,21 +275,5 @@ func (r *artistRepository) NewInstance() interface{} {
return &model.Artist{}
}
func (r artistRepository) Delete(id string) error {
return r.delete(Eq{"artist.id": id})
}
func (r artistRepository) Save(entity interface{}) (string, error) {
artist := entity.(*model.Artist)
err := r.Put(artist)
return artist.ID, err
}
func (r artistRepository) Update(entity interface{}, cols ...string) error {
artist := entity.(*model.Artist)
return r.Put(artist)
}
var _ model.ArtistRepository = (*artistRepository)(nil)
var _ model.ResourceRepository = (*artistRepository)(nil)
var _ rest.Persistable = (*artistRepository)(nil)

View File

@@ -3,11 +3,11 @@ package persistence
import (
"context"
"github.com/astaxie/beego/orm"
"github.com/beego/beego/v2/client/orm"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
. "github.com/onsi/gomega/gstruct"
)

View File

@@ -4,7 +4,7 @@ import (
"context"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/beego/beego/v2/client/orm"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -15,7 +15,7 @@ type genreRepository struct {
sqlRestful
}
func NewGenreRepository(ctx context.Context, o orm.Ormer) model.GenreRepository {
func NewGenreRepository(ctx context.Context, o orm.QueryExecutor) model.GenreRepository {
r := &genreRepository{}
r.ctx = ctx
r.ormer = o

View File

@@ -3,11 +3,11 @@ package persistence_test
import (
"context"
"github.com/astaxie/beego/orm"
"github.com/beego/beego/v2/client/orm"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -37,17 +37,25 @@ func toSnakeCase(str string) string {
}
func exists(subTable string, cond squirrel.Sqlizer) existsCond {
return existsCond{subTable: subTable, cond: cond}
return existsCond{subTable: subTable, cond: cond, not: false}
}
func notExists(subTable string, cond squirrel.Sqlizer) existsCond {
return existsCond{subTable: subTable, cond: cond, not: true}
}
type existsCond struct {
subTable string
cond squirrel.Sqlizer
not bool
}
func (e existsCond) ToSql() (string, []interface{}, error) {
sql, args, err := e.cond.ToSql()
sql = fmt.Sprintf("exists (select 1 from %s where %s)", e.subTable, sql)
if e.not {
sql = "not " + sql
}
return sql, args, err
}

View File

@@ -6,7 +6,7 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -59,6 +59,16 @@ var _ = Describe("Helpers", func() {
})
})
Describe("notExists", func() {
It("constructs the correct NOT EXISTS query", func() {
e := notExists("artist", squirrel.ConcatExpr("id = artist_id"))
sql, args, err := e.ToSql()
Expect(sql).To(Equal("not exists (select 1 from artist where id = artist_id)"))
Expect(args).To(BeEmpty())
Expect(err).To(BeNil())
})
})
Describe("getMostFrequentMbzID", func() {
It(`returns "" when no ids are passed`, func() {
Expect(getMostFrequentMbzID(context.TODO(), " ", "", "")).To(Equal(""))

View File

@@ -9,7 +9,7 @@ import (
"unicode/utf8"
. "github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/beego/beego/v2/client/orm"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -20,7 +20,7 @@ type mediaFileRepository struct {
sqlRestful
}
func NewMediaFileRepository(ctx context.Context, o orm.Ormer) *mediaFileRepository {
func NewMediaFileRepository(ctx context.Context, o orm.QueryExecutor) *mediaFileRepository {
r := &mediaFileRepository{}
r.ctx = ctx
r.ormer = o
@@ -61,7 +61,18 @@ func (r *mediaFileRepository) Put(m *model.MediaFile) error {
func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
sql := r.newSelectWithAnnotation("media_file.id", options...).Columns("media_file.*")
sql = r.withBookmark(sql, "media_file.id")
return r.withGenres(sql).GroupBy("media_file.id")
if len(options) > 0 && options[0].Filters != nil {
s, _, _ := options[0].Filters.ToSql()
// If there's any reference of genre in the filter, joins with genre
if strings.Contains(s, "genre") {
sql = r.withGenres(sql)
// If there's no filter on genre_id, group the results by media_file.id
if !strings.Contains(s, "genre_id") {
sql = sql.GroupBy("media_file.id")
}
}
}
return sql
}
func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) {
@@ -89,7 +100,7 @@ func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.Media
}
func (r *mediaFileRepository) FindByPath(path string) (*model.MediaFile, error) {
sel := r.selectMediaFile().Where(Eq{"path": path})
sel := r.newSelect().Columns("*").Where(Eq{"path": path})
var res model.MediaFiles
if err := r.queryAll(sel, &res); err != nil {
return nil, err
@@ -118,7 +129,7 @@ func (r *mediaFileRepository) FindAllByPath(path string) (model.MediaFiles, erro
// Query by path based on https://stackoverflow.com/a/13911906/653632
path = cleanPath(path)
pathLen := utf8.RuneCountInString(path)
sel0 := r.selectMediaFile().Columns(fmt.Sprintf("substr(path, %d) AS item", pathLen+2)).
sel0 := r.newSelect().Columns("media_file.*", fmt.Sprintf("substr(path, %d) AS item", pathLen+2)).
Where(pathStartsWith(path))
sel := r.newSelect().Columns("*", "item NOT GLOB '*"+string(os.PathSeparator)+"*' AS isLast").
Where(Eq{"isLast": 1}).FromSelect(sel0, "sel0")
@@ -166,6 +177,13 @@ func (r *mediaFileRepository) DeleteByPath(basePath string) (int64, error) {
return r.executeSQL(del)
}
func (r *mediaFileRepository) removeNonAlbumArtistIds() error {
upd := Update(r.tableName).Set("artist_id", "").Where(notExists("artist", ConcatExpr("id = artist_id")))
log.Debug(r.ctx, "Removing non-album artist_id")
_, err := r.executeSQL(upd)
return err
}
func (r *mediaFileRepository) Search(q string, offset int, size int) (model.MediaFiles, error) {
results := model.MediaFiles{}
err := r.doSearch(q, offset, size, &results, "title")
@@ -192,17 +210,5 @@ func (r *mediaFileRepository) NewInstance() interface{} {
return &model.MediaFile{}
}
func (r *mediaFileRepository) Save(entity interface{}) (string, error) {
mf := entity.(*model.MediaFile)
err := r.Put(mf)
return mf.ID, err
}
func (r *mediaFileRepository) Update(entity interface{}, cols ...string) error {
mf := entity.(*model.MediaFile)
return r.Put(mf)
}
var _ model.MediaFileRepository = (*mediaFileRepository)(nil)
var _ model.ResourceRepository = (*mediaFileRepository)(nil)
var _ rest.Persistable = (*mediaFileRepository)(nil)

View File

@@ -5,12 +5,12 @@ import (
"time"
"github.com/Masterminds/squirrel"
"github.com/astaxie/beego/orm"
"github.com/beego/beego/v2/client/orm"
"github.com/google/uuid"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

View File

@@ -3,7 +3,7 @@ package persistence
import (
"context"
"github.com/astaxie/beego/orm"
"github.com/beego/beego/v2/client/orm"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
)
@@ -12,7 +12,7 @@ type mediaFolderRepository struct {
ctx context.Context
}
func NewMediaFolderRepository(ctx context.Context, o orm.Ormer) model.MediaFolderRepository {
func NewMediaFolderRepository(ctx context.Context, o orm.QueryExecutor) model.MediaFolderRepository {
return &mediaFolderRepository{ctx}
}

View File

@@ -5,14 +5,14 @@ import (
"database/sql"
"reflect"
"github.com/astaxie/beego/orm"
"github.com/beego/beego/v2/client/orm"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type SQLStore struct {
orm orm.Ormer
orm orm.QueryExecutor
db *sql.DB
}
@@ -106,27 +106,10 @@ func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error {
if err != nil {
return err
}
err = o.Begin()
if err != nil {
return err
}
newDb := &SQLStore{orm: o}
err = block(newDb)
if err != nil {
err2 := o.Rollback()
if err2 != nil {
return err2
}
return err
}
err2 := o.Commit()
if err2 != nil {
return err2
}
return nil
return o.DoTx(func(ctx context.Context, txOrm orm.TxOrmer) error {
newDb := &SQLStore{orm: txOrm}
return block(newDb)
})
}
func (s *SQLStore) GC(ctx context.Context, rootFolder string) error {
@@ -135,6 +118,11 @@ func (s *SQLStore) GC(ctx context.Context, rootFolder string) error {
log.Error(ctx, "Error removing dangling tracks", err)
return err
}
err = s.MediaFile(ctx).(*mediaFileRepository).removeNonAlbumArtistIds()
if err != nil {
log.Error(ctx, "Error removing non-album artist_ids", err)
return err
}
err = s.Album(ctx).(*albumRepository).purgeEmpty()
if err != nil {
log.Error(ctx, "Error removing empty albums", err)
@@ -177,7 +165,7 @@ func (s *SQLStore) GC(ctx context.Context, rootFolder string) error {
return err
}
func (s *SQLStore) getOrmer() orm.Ormer {
func (s *SQLStore) getOrmer() orm.QueryExecutor {
if s.orm == nil {
o, err := orm.NewOrmWithDB(db.Driver, "default", s.db)
if err != nil {

View File

@@ -5,7 +5,7 @@ import (
"path/filepath"
"testing"
"github.com/astaxie/beego/orm"
"github.com/beego/beego/v2/client/orm"
_ "github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
@@ -13,7 +13,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -70,98 +70,108 @@ var (
)
var (
plsBest = model.Playlist{
Name: "Best",
Comment: "No Comments",
Owner: "userid",
Public: true,
SongCount: 2,
Tracks: model.MediaFiles{{ID: "1001"}, {ID: "1003"}},
}
plsCool = model.Playlist{Name: "Cool", Owner: "userid", Tracks: model.MediaFiles{{ID: "1004"}}}
testPlaylists = []*model.Playlist{&plsBest, &plsCool}
plsBest model.Playlist
plsCool model.Playlist
testPlaylists []*model.Playlist
)
func P(path string) string {
return filepath.FromSlash(path)
}
var _ = Describe("Initialize test DB", func() {
// Initialize test DB
// TODO Load this data setup from file(s)
var _ = BeforeSuite(func() {
o := orm.NewOrm()
ctx := log.NewContext(context.TODO())
user := model.User{ID: "userid", UserName: "userid"}
ctx = request.WithUser(ctx, user)
// TODO Load this data setup from file(s)
BeforeSuite(func() {
o := orm.NewOrm()
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid"})
ur := NewUserRepository(ctx, o)
err := ur.Put(&user)
if err != nil {
panic(err)
}
gr := NewGenreRepository(ctx, o)
for i := range testGenres {
g := testGenres[i]
err := gr.Put(&g)
if err != nil {
panic(err)
}
}
mr := NewMediaFileRepository(ctx, o)
for i := range testSongs {
s := testSongs[i]
err := mr.Put(&s)
if err != nil {
panic(err)
}
}
alr := NewAlbumRepository(ctx, o).(*albumRepository)
for i := range testAlbums {
a := testAlbums[i]
err := alr.Put(&a)
if err != nil {
panic(err)
}
}
arr := NewArtistRepository(ctx, o)
for i := range testArtists {
a := testArtists[i]
err := arr.Put(&a)
if err != nil {
panic(err)
}
}
pr := NewPlaylistRepository(ctx, o)
for i := range testPlaylists {
err := pr.Put(testPlaylists[i])
if err != nil {
panic(err)
}
}
// Prepare annotations
if err := arr.SetStar(true, artistBeatles.ID); err != nil {
gr := NewGenreRepository(ctx, o)
for i := range testGenres {
g := testGenres[i]
err := gr.Put(&g)
if err != nil {
panic(err)
}
ar, _ := arr.Get(artistBeatles.ID)
artistBeatles.Starred = true
artistBeatles.StarredAt = ar.StarredAt
testArtists[1] = artistBeatles
}
if err := alr.SetStar(true, albumRadioactivity.ID); err != nil {
mr := NewMediaFileRepository(ctx, o)
for i := range testSongs {
s := testSongs[i]
err := mr.Put(&s)
if err != nil {
panic(err)
}
al, _ := alr.Get(albumRadioactivity.ID)
albumRadioactivity.Starred = true
albumRadioactivity.StarredAt = al.StarredAt
testAlbums[2] = albumRadioactivity
}
if err := mr.SetStar(true, songComeTogether.ID); err != nil {
alr := NewAlbumRepository(ctx, o).(*albumRepository)
for i := range testAlbums {
a := testAlbums[i]
err := alr.Put(&a)
if err != nil {
panic(err)
}
mf, _ := mr.Get(songComeTogether.ID)
songComeTogether.Starred = true
songComeTogether.StarredAt = mf.StarredAt
testSongs[1] = songComeTogether
}
})
arr := NewArtistRepository(ctx, o)
for i := range testArtists {
a := testArtists[i]
err := arr.Put(&a)
if err != nil {
panic(err)
}
}
plsBest = model.Playlist{
Name: "Best",
Comment: "No Comments",
OwnerID: "userid",
OwnerName: "userid",
Public: true,
SongCount: 2,
}
plsBest.AddTracks([]string{"1001", "1003"})
plsCool = model.Playlist{Name: "Cool", OwnerID: "userid", OwnerName: "userid"}
plsCool.AddTracks([]string{"1004"})
testPlaylists = []*model.Playlist{&plsBest, &plsCool}
pr := NewPlaylistRepository(ctx, o)
for i := range testPlaylists {
err := pr.Put(testPlaylists[i])
if err != nil {
panic(err)
}
}
// Prepare annotations
if err := arr.SetStar(true, artistBeatles.ID); err != nil {
panic(err)
}
ar, _ := arr.Get(artistBeatles.ID)
artistBeatles.Starred = true
artistBeatles.StarredAt = ar.StarredAt
testArtists[1] = artistBeatles
if err := alr.SetStar(true, albumRadioactivity.ID); err != nil {
panic(err)
}
al, _ := alr.Get(albumRadioactivity.ID)
albumRadioactivity.Starred = true
albumRadioactivity.StarredAt = al.StarredAt
testAlbums[2] = albumRadioactivity
if err := mr.SetStar(true, songComeTogether.ID); err != nil {
panic(err)
}
mf, _ := mr.Get(songComeTogether.ID)
songComeTogether.Starred = true
songComeTogether.StarredAt = mf.StarredAt
testSongs[1] = songComeTogether
})

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