Compare commits

...

604 Commits

Author SHA1 Message Date
Deluan Quintão
851f54ea57 fix(ci): fix linux packages upload (#3569)
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-20 23:38:28 -05:00
Deluan Quintão
72a0f59be3 fix(ui): update translations from POEditor (#3568)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2024-12-20 18:48:22 -05:00
Deluan Quintão
c11fd9ce28 feat(ci): add updated language names to the POEditor PR title (#3566)
* refactor(ci): add updated languages to the POEditor PR title

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

* refactor(ci): add an author to the PR

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-20 18:46:00 -05:00
Deluan
3e47819f7a fix(server): reduce album placeholder image size by converting it to webp
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-19 18:42:31 -05:00
Deluan
906ac635c2 fix(insights): check if running in a container
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-19 18:05:33 -05:00
Deluan
6bc4c0317f fix(insights): better status
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-19 17:21:08 -05:00
Deluan
04f296cc73 fix(ui): show last.fm api-key missing in a FormHelperText
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-19 16:59:26 -05:00
Dany Marcoux
21dd04cb7d docs: set org.opencontainers.image.source label in Dockerfile (#3564)
As documented in the OCI Image Format,
org.opencontainers.image.source[1] identifies an image's source
repository. This is purely for documentation purposes. It does however
help tools such as Renovate[2] to find the changelogs when a new
Navidrome version is released. The changelogs would then be included in
the PR Renovate creates.

[1]: 5325ec4885/annotations.md (L24)
[2]: https://docs.renovatebot.com/modules/datasource/docker/#description

Signed-off-by: Dany Marcoux <git@dmarcoux.com>
2024-12-19 16:56:33 -05:00
Deluan Quintão
2d8507cfd7 fix(ui): don't hide Last.fm scrobble switch (#3561)
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-19 09:08:28 -05:00
Deluan Quintão
6c11649b06 fix(insights): fix issues and improve reports (#3558)
* fix(insights): show error whn reading library counts

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

* fix(insights): wait 30 mins before send first report

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

* fix(insights): send number of active players, grouped by client type

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

* fix(insights): disable reports when running in dev mode

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

* fix(insights): add Dockerfile to the docker build, to avoid `vcs.modified=true`

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

* fix(insights): add more linux fs types

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

* fix(insights): need admin permissions to retrieve library counts

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

* fix(insights): dev flag to disable player insights

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-18 20:37:35 -05:00
Caio Cotts
4f8cd5307c fix(ui): fix play queue for play button and context menus (#3559) 2024-12-18 17:57:42 -05:00
York
32afe9698c fix(ui): completed the translation of zh-Hant and zh-Hans (#3450)
* Completed the translation of zh-Hant and zh-Hans

* Update translation terms in zh-Hans and zh-Hant files

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-12-17 17:56:27 -05:00
Deluan Quintão
3e7c4b6f70 fix(ui): update Turkish, Galician and Polish translations from POEditor (#3426)
Co-authored-by: deluan <331353+deluan@users.noreply.github.com>
2024-12-17 17:48:02 -05:00
qx100
dcc84e29d9 fix(ui): Update Chinese (simplified) Translation (#3490) 2024-12-17 17:45:53 -05:00
Xabi
0d520dea2d fix(ui): update Basque (#3542)
* fix(ui): update eu.json

Added:
- lastAccessAt

Updated:
- lastLoginAt

* fix(ui): update eu.json

Updated:
- logout
2024-12-17 17:44:38 -05:00
Deluan
2bb918f8a1 chore(deps): bump Go dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-17 17:41:57 -05:00
Deluan Quintão
8e2052ff95 feat(Insights): add anonymous usage data collection (#3543)
* feat(insights): initial code (WIP)

* feat(insights): add more info

* feat(insights): add fs info

* feat(insights): export insights.Data

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

* feat(insights): more config info

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

* refactor(insights): move data struct to its own package

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

* refactor(insights): omit some attrs if empty

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

* feat(insights): send insights to server, add option to disable

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

* fix(insights): remove info about anonymous login

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

* chore(insights): fix lint

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

* fix(insights): disable collector if EnableExternalServices is false

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

* fix(insights): fix type casting for 32bit platforms

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

* fix(insights): remove EnableExternalServices from the collection (as it will always be false)

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

* chore(insights): fix lint

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

* refactor(insights): rename function for consistency

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

* feat(insights): log the data sent to the collector server

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

* feat(insights): add last collection timestamp to the "about" dialog.

Also add opt-out info to the SignUp form

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

* feat(insights): only sends the initial data collection after an admin user is created

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

* feat(insights): remove dangling comment

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

* feat(insights): Translate insights messages

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

* fix(insights): reporting empty library

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

* refactor: move URL to consts.js

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-17 17:10:55 -05:00
Deluan
bc3576e092 chore(deps): bump prettier
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-10 11:17:30 -05:00
Deluan
44bc70b269 chore(deps): bump JS dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-10 11:14:21 -05:00
Deluan
297f72ff1a chore(deps): bump Alpine version
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-09 16:54:11 -05:00
Deluan
181c29613f chore(deps): bump Go dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-09 16:50:52 -05:00
Kendall Garner
1a36f06147 fix(ui): service worker crashing on precacheAndRoute (#3528) 2024-12-08 17:43:34 -05:00
Deluan
9cbdb20a31 fix(server): don't try to save JWT if it fails to encrypt
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-05 22:19:39 -05:00
Deluan
7f030b0859 fix(server): encrypt jwt secret at rest
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-05 21:52:15 -05:00
Deluan
177a1f853f fix(server): more race conditions when updating artist/album from external sources
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-04 17:34:26 -05:00
Deluan
627417dae3 fix(server): add disc number to fake path.
Also revert "feat(server): enable "Report Real Path" by default"

Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-02 09:35:39 -05:00
Deluan
2b0bfbd75a fix(server): race condition when updating artist/album from external sources
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-01 20:16:40 -05:00
Deluan
8fb09e71b6 feat(server): get artist images from Last.fm
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-01 17:31:18 -05:00
Deluan
c94def801e feat(server): enable "Report Real Path" by default
Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-01 17:02:15 -05:00
Deluan Quintão
cbf5e3d51b fix(ui): PWA not updating properly in new Vite config (#3493)
* fix: pwa not updating. use the custom code we had before

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

* fix: docker build

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-30 10:33:16 -05:00
Deluan
1c0ebb9460 chore(deps): bump JS dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-25 17:41:23 -05:00
Deluan
94bc1a1d41 chore(deps): bump Go dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-25 17:31:53 -05:00
Kursat Aktas
9ae898d071 feat: add Navidrome Guru on Gurubase.io (#3491)
Signed-off-by: Kursat Aktas <kursat.ce@gmail.com>
2024-11-23 17:29:00 -05:00
Deluan
054946dc42 chore: update sanitize with updated diacritics
See https://github.com/navidrome/navidrome/issues/255#issuecomment-2488595427

Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-20 11:31:13 -05:00
Deluan
ccce1c0f6d fix: pre-cache square images, or else they are not useful for the Album Grid
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-19 18:41:50 -05:00
Deluan
81edef925c refactor: when resizing, don't buffer the original image "just in case"
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-19 18:41:50 -05:00
Deluan
2d4f483812 refactor: remove unnecessary intermediate buffer for ffmpeg image extraction
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-19 18:41:50 -05:00
Deluan
d229ff39e5 refactor: reduce GC pressure by pre-allocating slices
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-19 18:41:50 -05:00
Deluan
3982ba7258 revert: separation of write and read DBs
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-19 18:41:50 -05:00
Deluan
1bf94531fd refactor: better implementation of newRefreshQueue.
- use pointer references in channel
 - actually exits when context is canceled

Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-19 18:07:03 -05:00
Deluan
6c38dc234f refactor: change toSQL to use ReplaceAllStringFunc, to cause less static allocations
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-18 14:07:41 +02:00
Deluan
c1adf407a1 refactor: load translations with sync.OnceValues
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-18 14:07:31 +02:00
Deluan
c952dc343a chore(deps): bump JS dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-11 13:47:01 -05:00
Deluan
3671598121 chore(deps): bump Go dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-11 13:30:09 -05:00
Deluan Quintão
cd0cf7c12b feat: cache login background images (#3462)
* feat: use direct links to unsplash for background images

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

* feat: cache images from unsplash

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

* refactor: use cache.HTTPClient to reduce complexity

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

* refactor: remove magic numbers

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-08 20:23:30 -05:00
Deluan Quintão
6c6223f2f9 fix: forcing transcoding when client does not specify transcoding options (#3455)
* fix: wip

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

* fix: revert #3227

It is not respecting the server configured transcoding for the player

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-05 20:39:05 -05:00
Deluan
6ff7ab52f4 chore(deps): bump JS dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-04 12:27:55 -05:00
Deluan
faed2ea8d7 chore(deps): bump Go dependencies
Signed-off-by: Deluan <deluan@navidrome.org>
2024-11-04 12:26:39 -05:00
Deluan
cf69df877a chore(deps): bump js dependencies 2024-10-28 22:07:36 -04:00
Deluan
075a7e2640 chore(deps): bump go dependencies 2024-10-28 22:02:52 -04:00
Deluan Quintão
3fda7445b0 fix(server): try to find proper embedded front cover - only for vorbis comments for now (#3348)
* fix(artwork): get the first image from vorbis comments, not the last. fixes #3254

This uses a fork for now.

* fix(artwork): prioritize getting embedded types that are listed as "front" covers

* fix: cleanup
2024-10-27 21:59:22 -04:00
Deluan Quintão
fcb5e1b806 fix(server): fix case-insensitive sort order and add indexes to improve performance (#3425)
* refactor(server): better sort mappings

* refactor(server): simplify GetIndex

* fix: recreate tables and indexes using proper collation

Also add tests to ensure proper collation

* chore: remove unused method

* fix: sort expressions

* fix: lint errors

* fix: cleanup
2024-10-26 14:06:34 -04:00
Kendall Garner
154e13f7c9 build: add packages for deb and rpm to release (#3202)
* support packing deb/rpm/archlinux

* .-.

* initial test

* fix postinstall, remove execstop

* bash -> sh, create toml manually if it doesn't exist (thanks debian)

* don't forget that newline

* postrm

* comments, contrib -> packaging/linux

* contrib > packaging in .goreleaser

* actually add toml

* openrc/sysv templates

* add apk. nothing else yet

* wait, we have a ntive uninstall

* fix: merge errors, move packaging to release

* chore: remove old goreleaser conf

* ci: remove `release` dependency on `docker push`

* ci: fix release version

* ci: upload packages

* ci: try to fix json file list

* ci: replace the json file list with a txt artifact

* postremove -> preremove, skip install/remove error

* actually do preremove

* better preremove

* ci: fix

* ci: fix?

* ci: clean-up

* ci: try to change labels and filenames

* ci: fix?

* ci: fix?

* ci: add `make package` target

* ci: make labels more readable

hope it doesn't break the pipeline again

* build: remove alpine and archlinux packages, for now.

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-10-26 13:31:45 -04:00
Deluan Quintão
69e2a6d620 build(netgo): make sure the project is always compiled with netgo build tag (#3428)
* build(netgo): make sure the project is always compiled with `netgo` build tag

* docs(netgo): better comments
2024-10-26 13:28:23 -04:00
dependabot[bot]
15b2dc6b48 chore(deps-dev): bump eslint-plugin-jsx-a11y in /ui (#3416)
Bumps [eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y) from 6.10.0 to 6.10.1.
- [Release notes](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/releases)
- [Changelog](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/compare/v6.10.0...v6.10.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-jsx-a11y
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-23 23:24:43 -04:00
dependabot[bot]
1c48a55759 chore(deps-dev): bump eslint-plugin-react-refresh in /ui (#3419)
Bumps [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) from 0.4.12 to 0.4.13.
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.4.12...v0.4.13)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-refresh
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-23 23:16:45 -04:00
dependabot[bot]
a9b301dfc5 chore(deps-dev): bump @testing-library/jest-dom in /ui (#3418)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.6.1 to 6.6.2.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.6.1...v6.6.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-23 23:16:21 -04:00
dependabot[bot]
b86a69567d chore(deps-dev): bump @types/node from 22.7.6 to 22.7.7 in /ui (#3417)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.7.6 to 22.7.7.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-23 23:13:39 -04:00
dependabot[bot]
67474b776c chore(deps-dev): bump @vitejs/plugin-react from 4.3.2 to 4.3.3 in /ui (#3415)
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 4.3.2 to 4.3.3.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/v4.3.3/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-23 23:13:30 -04:00
Deluan
9d8c49750e ci: ignore refactor commits in release notes 2024-10-23 23:00:32 -04:00
Deluan Quintão
a557f37834 refactor: small improvements and clean up (#3423)
* refactor: replace custom map functions with slice.Map

* refactor: extract StringerValue function

* refactor: removed unnecessary if

* chore: removed invalid comment

* refactor: replace more map functions

* chore: fix FFmpeg typo
2024-10-22 22:54:31 -04:00
Kendall Garner
0a650de357 feat(subsonic): add MusicBrainz ID and Sort Name to getArtists 2024-10-22 22:00:31 -04:00
Rob Emery
9c3b456165 feat(build): MSI installer improvements (#3376)
* feat(build): add a make target to build a msi installer locally

* Testing wrapping the executable in cmd

* build(ci): build msis in parallel

* feat(server): add LogFile config option

* Revert "Testing wrapping the executable in cmd"

This reverts commit be29592254.

* Adding --log-file for service executable

* feat(ini): wip

* feat(ini): parse nested ini section

* fix(conf): fix fatal error messages

* Now navidrome supports INI, we can use the built-in msi ini system
and not require the VBScript to convert it into toml

* File needs to be called .ini to be parsed as an INI and correct filename
needs to be passed to the service

* fix(msi): build msi locally

* fix(msi): pipeline

* fix(msi): pipeline

* fix(msi): pipeline

* fix(msi): pipeline

* fix(msi): pipeline

* fix(msi): Makefile

* fix(msi): more clean up

* fix(log): convert LF to CRLF on Windows

* fix(msi): config filename should be case-insensitive

* fix(msi): make it a little more idiomatic

* Including the latest windows release of ffmpeg into the msi
as built by https://www.gyan.dev/ffmpeg/builds/ (linked
to on the official ffmpeg source)

* This should version independent

* Need bash expansion for the * to work

* This will run twice, once for x86 and once for x64, I'll make it cache
the executable for now as it'll be quicker

* Silencing wget

* Add ffmpeg path to the config so Navidrome knows where to find it

* refactor: download ffmpeg from our repository

* When going back from the "Are you ready to install?" it should go back to the
Settings dialogue that you just came from

* fix: comments

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-10-22 19:32:56 -04:00
Deluan
23bebe4e06 feat(subsonic): getOpenSubsonicExtensions is now public 2024-10-21 17:21:18 -04:00
Deluan
8808eaddda fix(server): allow extra spaces in transcoding commands 2024-10-20 19:35:16 -04:00
Deluan
bbb3182bc9 refactor(server): remove ffmpeg unused code 2024-10-20 19:35:16 -04:00
Caio Cotts
82633d7490 fix(playlists): make the m3u parser case-insensitive again #3410 2024-10-20 14:21:39 -04:00
Deluan
28668782c6 fix(server): FFmpegPath can contain spaces 2024-10-20 13:58:39 -04:00
Deluan
97c06aba1a perf(server): add index for sort tags.
Improves search performance when searching with PreferSortTags=true
2024-10-19 20:46:54 -04:00
Deluan
9c46e2b262 fix: use docker buildx, as required by Linux 2024-10-19 11:55:03 -04:00
Deluan Quintão
3713032f57 fix(ui): update translations from POEditor (#3349)
Co-authored-by: deluan <331353+deluan@users.noreply.github.com>
2024-10-17 20:29:54 -04:00
Ivan Pešić
a358d107aa fix(ui): update Serbian translation (#3361) 2024-10-17 20:24:50 -04:00
jan666
5f6a90e5aa build: fix build on FreeBSD (#3403)
- vite: use rollup/wasm-node
- use vitejs/plugin-react instead of plugin-react-swc
2024-10-17 19:26:53 -04:00
Deluan Quintão
0232afd98d fix(ui): service worker does not load new version of ui (#3402)
* fix(pwa): wip

* fix(pwa): wip
2024-10-16 21:39:14 -04:00
Deluan
270ae3549d chore(deps): bump peter-evans/create-pull-request from 6 to 7 2024-10-16 20:31:45 -04:00
Deluan Quintão
8b5af67647 feat(subsonic): support OS clearing play queue (#3399) 2024-10-16 10:59:22 -04:00
Deluan Quintão
00c6a0ed1f fix: use target platform to build final image (#3397)
* fix: use target platform to build final image

* fix: remove armv5 from supported images
2024-10-15 22:47:05 -04:00
dependabot[bot]
ff79ac4336 chore(deps-dev): bump eslint-plugin-react from 7.37.0 to 7.37.1 in /ui (#3362)
Bumps [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) from 7.37.0 to 7.37.1.
- [Release notes](https://github.com/jsx-eslint/eslint-plugin-react/releases)
- [Changelog](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.37.0...v7.37.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-react
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-15 19:59:19 -04:00
dependabot[bot]
8d37781a47 chore(deps-dev): bump eslint-plugin-react-hooks in /ui (#3381)
Bumps [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) from 4.6.2 to 5.0.0.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/eslint-plugin-react-hooks@5.0.0/packages/eslint-plugin-react-hooks)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-hooks
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-15 19:47:38 -04:00
dependabot[bot]
a6fb7fd705 chore(deps-dev): bump typescript from 5.6.2 to 5.6.3 in /ui (#3380)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.6.2 to 5.6.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.6.2...v5.6.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-15 19:45:16 -04:00
dependabot[bot]
fd81039f1b chore(deps-dev): bump @vitest/coverage-v8 from 2.1.1 to 2.1.3 in /ui (#3379)
Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 2.1.1 to 2.1.3.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v2.1.3/packages/coverage-v8)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-15 19:32:13 -04:00
dependabot[bot]
bc4aa55de3 chore(deps-dev): bump @types/node from 22.7.4 to 22.7.5 in /ui (#3378)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.7.4 to 22.7.5.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-15 19:31:54 -04:00
dependabot[bot]
6ec6ac1595 chore(deps-dev): bump vite from 5.4.8 to 5.4.9 in /ui (#3382)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.8 to 5.4.9.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.9/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.9/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-15 19:31:28 -04:00
Deluan
06e38a8024 chore(deps): go mod tidy 2024-10-15 19:29:13 -04:00
Deluan
6e5eea980d chore(deps): bump Go dependencies 2024-10-15 19:24:22 -04:00
Deluan Quintão
943b456d3f fix: do not try to push to ghcr.io without proper permissions (#3395) 2024-10-15 19:10:44 -04:00
Deluan Quintão
16d1314a68 fix: do not add nil filters (#3394) 2024-10-15 19:03:16 -04:00
Deluan Quintão
ae6499b941 fix: PRs should not try to push to docker (#3393) 2024-10-15 18:28:27 -04:00
Deluan Quintão
214287e00d build: new pipeline, new way to cross-compile and build docker images locally. (#3388)
* build: new pipeline, new way to cross-compile and build docker images locally. (#3383)

* build: use alternative repositories

* build: fix

* build: validate taglib downloads

* build: control concurrency

* build: validate xx version

* build: remove taglib download validation as the version can be changed as an argument.
2024-10-15 16:46:01 -04:00
Deluan
af1add4312 Revert "build: new pipeline, new way to cross-compile and build docker images locally. (#3383)"
This reverts commit b14c790641.
2024-10-14 18:52:02 -04:00
Deluan Quintão
b14c790641 build: new pipeline, new way to cross-compile and build docker images locally. (#3383)
* refactor(ci): faster pipeline, add support for darwin/arm64 (#26)

* feat: WIP

* feat: WIP - all except windows

* fix: Bump crazymax/osxcross to 14.5

* feat: bundle UI

* fix: works on all 10!

* fix: WIP

* fix: add git sha and tag

* fix: download taglib from cross-taglib

* feat: add more dependabot coverage

* feat: build JS bundle using Docker

* refactor: pipeline

* fix: wip

* fix: wip

* fix: wip

* fix: wip

* fix: wip

* fix: wip

* fix: wip

* fix: wip

* fix: wip

* fix: real

* fix: no container

* fix: no container

* fix: pkg-config

* fix: pkg-config

* fix: pkg-config

* fix: pkg-config

* fix: pkg-config

* fix: add lint

* fix: add lint

* fix: add lint

* fix: add lint

* fix: add lint

* fix: add lint

* fix: add js

* fix: gittags

* fix: gittags

* test: is_release

* test: is_release

* test: is_release

* test: push image

* test: push image

* test: push image

* test: push image

* test: push image

* test: push image

* test: push image

* test: push image

* test: push image

* fix: extract download taglib action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: extract prepare docker action

* fix: add msi

* fix: add msi

* fix: add msi

* fix: add msi

* fix: add msi

* test: full

* test: full

* test: disable some platforms to avoid hitting the rate limit

* test: disable some platforms to avoid hitting the rate limit

* fix: use ecr.aws for base images

* test: full release

* test: full release

* fix: clean-up

* refactor: pipeline clean-up (#32)

* fix: clean-up

* fix: clean-up

* fix: clean-up

* fix: fetch all tags

* fix: version

* fix: version

* fix: no need to setup QEMU

* fix: don't try to push images in unauthorized branches

* fix: check push enabled

* fix: change layout?
2024-10-14 18:41:19 -04:00
Deluan
d9fa19dab3 build(ci): bump goreleaser to 2.3.2 2024-10-06 13:20:31 -04:00
Egor
0281d06b01 feat(ui): Allow drag-and-drop song title from player to sidebar playlist (#2435)
* feat(ui): Allow drag-and-drop song title from player to sidebar playlist

Signed-off-by: egor.aristov <egor.aristov@vk.team>

* prettier

---------

Signed-off-by: egor.aristov <egor.aristov@vk.team>
Co-authored-by: egor.aristov <egor.aristov@vk.team>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-10-02 13:10:24 -04:00
Lokke
de04393b47 fix(ui): update German translation (#3345)
Update German translation with minor adjustments
2024-10-02 08:48:47 -04:00
Kendall Garner
55730514ea feat(server): provide native backup/restore mechanism (#3194)
* [enhancement]: Provide native backup/restore mechanism

- db.go: add Backup/Restore functions that utilize Sqlite's built-in online backup mechanism
- support automatic backup with schedule, limit number of files
- provide commands to manually backup/restore Navidrome

Notes:
`Step(-1)` results in a read-only lock being held for the entire duration of the backup.
This will block out any other write operation (and may hold additional locks.
An alternate implementation that doesn't block but instead retries is available at https://www.sqlite.org/backup.html#:~:text=of%20a%20Running-,database,-%2F*%0A**%20Perform%20an%20online (easily adaptable to go), but has the potential problem of continually getting restarted by background writes.

Additionally, the restore should still only be called when Navidrome is offline, as the restore process does not run migrations that are missing.

* remove empty line

* add more granular backup schedule

* do not remove files when bypass is set

* move prune

* refactor: small nitpicks

* change commands and flags

* tests, return path from backup

* refactor: small nitpicks

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-10-01 19:58:54 -04:00
Rob Emery
768160b05e feat: Windows MSI installer and service support (#3125)
* First version/rough layout of the required wix to build an MSI that embeds everything

* Don't need revision number

* produced exe from existing build process is navidrome not Navidrome

* Adding Kardianos wrapper around Cobra so the callbacks are handled
automatically (this is basically only for windows)

* Adding pointless check to shut up lint for now

* make format

* Revert disabling npm tidy

* Using Kardianos always will result in the application hanging so it
needs only be wrapped to handle the callbacks if it's being used in
the service context, otherwise use cobra directly

* Copying in service installation etc from https://github.com/navidrome/navidrome/pull/2295

* Under Linux this installs a user service (I don't think this is
correct, but lets get this working first). User units/services
cannot depends on system units, so previously this bombed out
with Exit Code 5.

* Under Windows we can install both the x86 and x64 builds, they
will install to different folders, but previously they would
overwrite the service as they were both called Navidrome. Now,
it will install 2 services. This will still be weird/broken as
they will attempt to listen on the same port, however uninstalling
the "wrong" arch will not cause the "right" one to be partially
uninstalled anymore

* Reverting changes to the context as they don't really seem necessary anyway

* Need to consistently name the service

* Fixing broken context

* The included files should be removed when the app is uninstalled

* Reverting back to the original context here, I don't think
it makes any difference to running under kardianos

* Let's see what we have immediately available

* OK, the build takes ages so let's just try and do the whole thing in one go, maybe we'll get lucky

* Need -r on directory copy, plus we'll probably need to install wixl

* No sudo cmd, so I assume this runs as root

* WORKSPACE!

* Moving the version to be a single variable, we'll probably be able to pull it from the github tag or whatever

* Might as well put the msi in the right folder, it's tidier

* Writing the version number into the msi, from the output of goreleaser

* Using jq to parse the goreleaser metadata, so need to install it

* MSI only supports numerical version numbers, so I'll make the "snapshot" version .1 minor patch greater

* -r or --raw (on newer versions) means we don't get the "" around the value

* Running as a user service I think makes limited sense for this

* Will now ask for configuration settings during install.

MSI/WiX only supports writing out INI files, Toml is almost
INI compatable, except that the INI needs to write out a section
first, so we need to have a script to strip that off.

We are forced to display a License.rtf file by the UI so I think
the build process should probably rename the default licence file
and that will suffice.

Uninstalling works cleanly, howvever upgrades seem to leave the old
version installed in "programs and features" currently.

Adding the UI has introduced a requirement for WiX 0.103

* Updating the build to include --ext ui for the new config ui

* Configuration dialog should not display for upgrades as the config file
is already written

* Making description consistent with the systemd service and making
the build process produce the required License.rtf

* Fixing " non-constant format string in call to fmt.Errorf (govet)"

* Its a string, not an int; read better.

* Wixl 103 is required for --ext ui, so we need 24.04

* OK this is still installing Wix 0.101, maybe it all needs to be 24.04?

* Switching the builds back to ubuntu-latest (22.04 at current)
as it runs on a custom container, it's actually debian anyway
Moving msi build into its own job so it can run on 24.04 so
we have access to wixl 0.103 for --ext ui support

* Forcing build

* Whitespace fix

* Adding sudo I guess

* Gotta checkout as well

* Adding debugging for when there's soemthing wrong with the paths

* Adding more ls to see if the output has worked

* The msi's are in subdirs

* Actually they're in the ./wix directory

* Still can't find these msi's?

* I think that was being treated literally previously

* No idea why this isn't working, give it a relative path instead?

* Making explicit on the dialogue that the configuration file will be
where the installation dir is

* The lint keeps failing and it's just getting in the way so I'll
turn this off for now and we'll edit out this commit from the merge

* Cutting more out of the build to get more stuff out of the way

* Need to increase the width to fit the text in

* Calling everything License.rtf, presumably one of them is correct

* I am pretty sure the License.rtf loading is broken under Wixl; so
let's just bypass the EULA from the UI which is a nicer experience
for the users anyway

* This needs to be after WelcomeDlg now the Eula isn't displayed

* You're supposed to be able to use <WixVariable> to override the
location that the bmp's are loaded from, I can't get this to work
under wixl so I'm guessing given that the ui extension is new, it
hasn't been implemented with that in mind. So we'll hack it by
overwriting the files installed with the package.

* We should make this less brittle so when wixl is updated it still works

* Re-enabling the lint and tests etc

* Improving the scaling quality and removing borders from images to
tidy them up a tad

* Pretty sure this isn't necessary as MY_PROPERTY will always be false

* Without publishing this event, we can't continue to the next dialogue
however I think we should be able to get away without the property

* Refactoring out the duplication so we only have one service
defined and we can run that either way

* Pushing the Interactive check into the root commmand? Feels like it
is probably getting closer to the right place at least

* go tidy

* OK this didn't work under windows, I'm guessing it's because
it's lacking all the metadata about the service it needs to
report back to Windows on.

* We need to run service execute now so that the windows
service will behave (hopefully)!

* Lint

* go tidy

* Renaming service to "navidrome" rather than "Navidrome" as this
is the filename that systemd writes and it's unusual to have
capital letters in service names under Linux.
Switching to use service execute for Linux to mirror Windows

* Need to provide the arguments to append

* Without passing the context around, the DB isn't closed gracefully
so we end up with with .db-shm and .db-wal files for recovery

* We should log fatal rather than outputting directly to stdout

* go tidy

* refactor: small nitpicks

* fix: terminate service gracefully

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-10-01 19:40:53 -04:00
Deluan
b7285b28cf fix(test): vitest was hanging due to vite-plugin-eslint plugin 2024-10-01 17:12:54 -04:00
Deluan
eab6aadc0f chore(deps): bump Go to 1.23.2 2024-10-01 14:54:22 -04:00
ChekeredList71
640a734896 fix(ui): update Hungarian translation (#3346)
Added new Hungarian translation for "lastAccessAt".
2024-10-01 14:13:15 -04:00
Deluan Quintão
a9334b7787 feat(ui): show user's lastAccess (#3342)
* feat(server): update user's lastAccess

* feat(ui): show user's lastAccess
2024-09-30 20:46:10 -04:00
dependabot[bot]
5e8085bf3c chore(deps-dev): bump eslint-plugin-react from 7.36.1 to 7.37.0 in /ui (#3334)
Bumps [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) from 7.36.1 to 7.37.0.
- [Release notes](https://github.com/jsx-eslint/eslint-plugin-react/releases)
- [Changelog](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.36.1...v7.37.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-react
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-30 14:31:58 -04:00
dependabot[bot]
b2eb533082 chore(deps-dev): bump @vitejs/plugin-react-swc in /ui (#3339)
Bumps [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) from 3.7.0 to 3.7.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react-swc/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react-swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react-swc/compare/v3.7.0...v3.7.1)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react-swc"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-30 14:31:41 -04:00
dependabot[bot]
936af2d895 chore(deps-dev): bump @types/node from 22.6.1 to 22.7.4 in /ui (#3335)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.6.1 to 22.7.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-30 14:31:25 -04:00
dependabot[bot]
b1c18a428b chore(deps-dev): bump vite from 5.4.7 to 5.4.8 in /ui (#3340)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.7 to 5.4.8.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.8/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.8/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-30 14:31:10 -04:00
Deluan Quintão
1fac9cc3ee chore: add poeditor logo to readme (#3329) 2024-09-30 13:25:03 -04:00
Deluan
92a1f19271 fix(scanner): make activity panel update rate configurable 2024-09-30 12:06:23 -04:00
crazygolem
06c9c1e64a feat(server): require explicitly enabling reverse proxy auth for unix sockets (#3062) 2024-09-29 13:28:44 -04:00
Deluan
ed3ab5385d chore(deps): bump rollup from 2.79.1 to 2.79.2 in /ui 2024-09-28 11:56:35 -04:00
Deluan Quintão
fcdd30ba8f build(ui): migrate from CRA/Jest to Vite/Vitest (#3311)
* feat: create vite project

* feat: it's alive!

* feat: `make dev` working!

* feat: replace custom serviceWorker with vite plugin

* test: replace Jest with Vitest

* fix: run prettier

* fix: skip eslint for now.

* chore: remove ui.old folder

* refactor: replace lodash.pick with simple destructuring

* fix: eslint errors (wip)

* fix: eslint errors (wip)

* fix: display-name eslint errors (wip)

* fix: no-console eslint errors (wip)

* fix: react-refresh/only-export-components eslint errors (wip)

* fix: react-refresh/only-export-components eslint errors (wip)

* fix: react-refresh/only-export-components eslint errors (wip)

* fix: react-refresh/only-export-components eslint errors (wip)

* fix: build

* fix: pwa manifest

* refactor: pwa manifest

* refactor: simplify PORT configuration

* refactor: rename simple JS files

* test: cover playlistUtils

* fix: react-image-lightbox

* feat(ui): add sourcemaps to help debug issues
2024-09-28 11:54:36 -04:00
dependabot[bot]
dd48a23f92 chore(deps): bump github.com/unrolled/secure from 1.15.0 to 1.16.0 (#3327)
Bumps [github.com/unrolled/secure](https://github.com/unrolled/secure) from 1.15.0 to 1.16.0.
- [Release notes](https://github.com/unrolled/secure/releases)
- [Commits](https://github.com/unrolled/secure/compare/v1.15.0...v1.16.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-28 11:32:21 -04:00
dependabot[bot]
6040a50297 chore(deps): bump alpine from 3.18 to 3.20 in /.github/workflows (#3326)
Bumps alpine from 3.18 to 3.20.

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-28 11:30:36 -04:00
Deluan
9e5849e4dc build(dependabot): add docker configuration 2024-09-28 10:52:23 -04:00
Kendall Garner
13af8ed43a fix(server): preserve m3u file order on import (#3314)
* fix(playlist): preserve m3u file order on import - 3307

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

* test(server): cover playlist order

* refactor(server): micro-optimizations

* refactor(server): micro-optimizations

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

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

---------

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

* redux

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

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

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

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

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

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

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

* chore: fix comments re: SQLITE_MAX_VARIABLE_NUMBER

* test: improve playqueue test

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

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

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

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

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

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

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

* Fix login screen color & Softened "link" coloring

---------

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

* revert unwanted modifications

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

---------

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

* Nit: inline variable

---------

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

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

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

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

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

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

* Clean up recursive smart playlist functions

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

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

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

* Change SmartPlaylistRefreshTimeout to SmartPlaylistRefreshDelay, increase default value

* Revert `smartPlaylistRefreshDelay` default to 5 seconds

---------

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

* hungarian-patch

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: Fix selectTranscodingOptions function - #3226

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

* Small refactoring to make code more concise

* Fix log message

---------

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

Initial Basque localisation

* Update eu.json

fixes extra dash

* Update eu.json

fixes

* Update eu.json

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

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

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

* chore: make linter happy

---------

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

* Fix tests

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Fix imports

* Go 1.23 final version

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

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

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

* baseRequest -> selectPlayer

* remove comment

* update migration, make all of persistence foreign key enabled

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Add R128 test to metadata_internal_test.go

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

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

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

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

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

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

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

* Fix docker publishing for PRs

* Write tests for new square parameter.

* Simplify code for createImage.

---------

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

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

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

* Reseed on first random page

* Add unit tests

* Use rand in Subsonic API

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

* Small refactor

* Fix id mismatch

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

* Refactor

* Remove unneeded import

---------

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

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

* fix lint error
2024-05-15 09:48:09 -04:00
Deluan
45c4583f1b Fix race condition 2024-05-13 09:28:19 -04:00
Deluan
478c709a64 Associate main entities with library 2024-05-12 21:37:42 -04:00
Deluan
477bcaee58 Store MusicFolder as a library in DB 2024-05-12 21:37:42 -04:00
Deluan
081ef85db6 Rename MediaFolder to Library 2024-05-12 21:37:42 -04:00
Deluan
6f2643e55e Refactor to use more Go 1.22 features 2024-05-12 20:04:53 -04:00
Deluan
9ee63b39cb Update Go to 1.22.3 2024-05-12 20:04:53 -04:00
Deluan
c556088820 Change dsf mime-type to audio/x-dsf.
Fix #3021
2024-05-12 11:33:50 -04:00
Deluan
78f554721a Revert "Add download link to PR" workflow 2024-05-11 20:40:12 -04:00
Deluan
2c8c87a980 Remove duplicated test 2024-05-11 20:15:02 -04:00
Deluan
3463d0c208 Simplify random.Int64 usage with generics 2024-05-11 20:10:46 -04:00
Deluan
0ae2944073 Refactor random functions 2024-05-11 20:04:21 -04:00
Deluan
30ae468dc1 Uses Unix milliseconds support from standard Go lib 2024-05-11 19:50:30 -04:00
Deluan
ec68d69d56 Refactor cache.HTTPClient 2024-05-11 19:37:12 -04:00
Deluan
955a9b43af Refactor merge.FS 2024-05-11 19:37:12 -04:00
Deluan
56809419c2 Fix "Add download link to PR" workflow 2024-05-11 18:50:46 -04:00
Deluan
3a2a5e961b Add samplingRate to OpenSubsonic responses 2024-05-11 17:57:45 -04:00
Deluan
f3bb022238 Add sampleRate to the DB 2024-05-11 17:57:45 -04:00
Deluan
472324e280 Read sampleRate from audio files 2024-05-11 17:57:45 -04:00
Deluan
ed83c22632 Do not panic if when updatePlaylist is called with a non-existent ID.
Fix #2876
2024-05-11 15:37:50 -04:00
edthu
2fdc1677f7 Add Catppuccin Macchiato Theme (#3014)
* Added Catppuccin Macchiato theme

* fixed index.js formatting
2024-05-11 13:08:51 -04:00
Deluan
80e68dfbcd Bump actions/github-script to v7 2024-05-10 16:00:21 -04:00
Deluan
a9c745839b Bump actions/stale and dessant/lock-threads versions 2024-05-10 15:51:16 -04:00
Deluan
bb96d455f8 Replace sync.WaitGroup with more appropriate errgroup.Group 2024-05-10 15:27:07 -04:00
Deluan
c0885b55db Fix M3U mimetype on Debian Bullseye 2024-05-09 22:26:15 -04:00
Deluan
00cbe4c357 Update Go to 1.22.3 2024-05-09 22:26:15 -04:00
Valeri Sokolov
2b49c7ff76 fix: languageName for Persian (#3011)
"انگلیسی" is "English"
2024-05-09 17:08:43 -04:00
Deluan
09d1fd0658 Simplify normalized AlbumPlayCountMode calc 2024-05-09 08:13:42 -04:00
Deluan
747069b229 Remove unused code 2024-05-09 07:47:32 -04:00
Deluan
885cd345ab Clean up runNavidrome function 2024-05-09 07:44:08 -04:00
Deluan Quintão
c4b05dac28 Make sorting lists by name/title case-insensitive (#2993)
* Make sort by order_* fields case-insensitive.

* Sort internet radios by name case-insensitive
2024-05-09 07:08:15 -04:00
Deluan Quintão
6408dda948 Terminate all MPV instances when stopping Navidrome (#3008)
* Terminate all mpv instances when stopping Navidrome

* Exit trackSwitcher goroutine when terminating

* Remove potential race condition when starting the Playback device

* Fix lint error

* Removed unused and unneeded vars/functions

* Use device short name in log

* Small refactor

* Small nitpick

* Make start functions more uniform
2024-05-09 06:57:24 -04:00
Deluan
677d9947f3 Make dependency injection more consistent 2024-05-08 22:21:38 -04:00
Deluan
a0290587b9 Fix migration package name mismatch 2024-05-08 19:54:48 -04:00
Deluan
eb93136b3f Change default transcodings to a proper typed struct 2024-05-08 17:39:25 -04:00
Deluan
62cc8a2d4b Fix ambiguous column when sorting media_files by created_at.
Fix #3006
2024-05-08 08:24:26 -04:00
Deluan
dd4374cec6 Limit access to Jukebox for admins only (configurable).
Closes #2849
2024-05-07 19:35:43 -04:00
Deluan
86567f5406 Bump Go dependencies 2024-05-07 19:26:02 -04:00
Matthias Schmidt
ff8dca5abe Guard against missing active track (#2996)
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-05-07 19:22:39 -04:00
Matthias Schmidt
b3d70e9264 Persist adjusted volume (#2997)
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-05-07 19:21:35 -04:00
Ludovic Fernandez
4d29184998 Improves golangci-lint configuration and workflow (#3004)
* chore: the default Go version is based on the go.mod

* chore: use linter configuration instead of exclude-rules

* chore: update workflow
2024-05-07 18:52:26 -04:00
Deluan
2470471b2b Pin golangci-lint-action version as a workaround to fix the pipeline.
See https://github.com/golangci/golangci-lint/issues/4695
2024-05-06 21:53:47 +02:00
Deluan
544ae90ec1 Fix CollapsibleComment in PlaylistDetails. Closes #2992 2024-05-02 13:48:10 -04:00
Deluan
aef49cb8d6 Add HTTPSecurityHeaders.CustomFrameOptionsValue option.
Requested in https://github.com/navidrome/navidrome/issues/248#issuecomment-1783768985
2024-05-02 12:35:16 -04:00
Deluan
7c5eec715d Fix typo 2024-05-01 23:09:11 -04:00
Kendall Garner
a4c2232041 Sort repeated lyrics that may be out of order (#2989)
With synchronized lyrics with repeated text, there is not a guarantee that the repeat is in order (e.g. `[00:00.00][00:10.00] a\n[00:05.00]b`).
This change will post-process lyrics with repeated timestamps in one line to ensure that it is always sorted.
2024-05-01 21:54:46 -04:00
Deluan
8f11b991d2 Bump Go dependencies 2024-05-01 20:40:34 -04:00
Deluan
d4a9a9e555 Fix PlaylistTracks's loadAllGenres. Fix #2988 2024-05-01 20:17:42 -04:00
Deluan
a8955f24e0 Fix AlbumPlayCountMode. Closes #2984 2024-05-01 20:05:36 -04:00
Deluan
2c06a4234e Fix int types in OpenSubsonic responses.
Refer to https://support.symfonium.app/t/symfonium-sync-crashes-when-tpos-is-not-an-int/4204
2024-05-01 13:57:11 -04:00
Deluan
7ab7b5df5e Fix signaler on Windows 2024-04-28 18:32:28 -04:00
Deluan
3d9fff36f7 Use signal.NotifyContext 2024-04-28 17:44:11 -04:00
Deluan
31fcab07d2 Refactor loadGenres, remove duplication 2024-04-28 17:04:12 -04:00
Deluan
de90152a71 Refactor DB Album mapping to model.Album 2024-04-28 13:51:57 -04:00
Deluan
27875ba2dd Load mime_types from external file 2024-04-28 12:18:24 -04:00
Deluan
28f7ef43c1 Remove AlbumPlayCountMode from command line options 2024-04-27 20:39:16 -04:00
Deluan
92a98cd558 Add tests for AlbumPlayCountMode, change the calc to match the request from #1032 2024-04-27 15:20:46 -04:00
Deluan
5d50558610 Add tests for AlbumPlayCountMode 2024-04-27 15:07:50 -04:00
vvdveen
8bff1ad512 Add AlbumPlayCountMode config option (#2803)
Closes #1032

* feat(album_repository.go): add kodi-style album playcount option - #1032

Signed-off-by: Victor van der Veen <vvdveen@gmail.com>

* fix format issue and remove reference to kodi (now normalized)

Signed-off-by: Victor van der Veen <vvdveen@gmail.com>

* reduced complexity but added rounding

Signed-off-by: Victor van der Veen <vvdveen@gmail.com>

* Use constants for AlbumPlayCountMode values

---------

Signed-off-by: Victor van der Veen <vvdveen@gmail.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2024-04-27 14:10:40 -04:00
crazygolem
1e96b858a9 Add support for Reverse Proxy auth in Subsonic endpoints (#2558)
* feat(subsonic): Add support for Reverse Proxy auth - #2557

Signed-off-by: Jeremiah Menétrey <superjun1@gmail.com>

* Small refactoring

---------

Signed-off-by: Jeremiah Menétrey <superjun1@gmail.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-04-27 13:47:42 -04:00
Deluan
aafd5a952c Bump github.com/spf13/viper from 1.15.0 to 1.18.2 2024-04-26 22:11:43 -04:00
Deluan Quintão
d9cd5efd67 Bump Go dependencies (#2976)
* Fix build

* Bump dependencies
2024-04-26 18:21:10 -04:00
Deluan
affa9c3478 Bump github.com/pressly/goose/v3 from 3.19.2 to 3.20.0 2024-04-26 18:07:06 -04:00
Anna Smith
651a8fdaf9 Fix typo in comment (#2974) 2024-04-26 17:59:39 -04:00
Deluan
f7fc17c0f7 Add OpenSubsonic channelCount 2024-04-26 17:51:04 -04:00
Deluan
f5df948eb1 Fix scrobble error spam in the logs.
Relates to #2831 and #2975
2024-04-26 16:59:14 -04:00
crazygolem
18143fa5a1 Use the RealIP middleware also behind a reverse proxy (#2858)
* Use the RealIP middleware only behind a reverse proxy

* Fix proxy ip source in tests

* Fix test for PR#2087

The PR did not update the test after changing the behavior, but the test still
passed because another condition was preventing the user from being created in
the test.

* Use RealIP even without a trusted reverse proxy

* Use own type for context key

* Fix casing to follow go's conventions

* Do not apply RealIP middleware twice

* Fix IP source in logs

The most interesting data point in the log message is the proxy's IP, but
having the client IP too can help identify integration issues.
2024-04-25 20:43:58 -04:00
Tim
8f9ed1b994 Handling long playlist comments (#2973)
Closes #1737

* wrapping playlist comment in a <Collapse> element

* Extract common collapsible logic into a component

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-04-25 20:28:25 -04:00
dependabot[bot]
cf66594b6d Bump github.com/onsi/gomega from 1.32.0 to 1.33.0 (#2968)
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.32.0 to 1.33.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.32.0...v1.33.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-22 17:09:51 -04:00
Deluan
ca005f6457 Include MPV in release Docker image. Refers to #2910 2024-04-21 21:02:36 -04:00
Deluan
6dcfe4d455 Fix typo 2024-04-20 13:16:50 -04:00
Deluan
7871d69adb Allow comments in the NSP file.
See comment https://github.com/navidrome/navidrome/issues/1417#issuecomment-2064731407
2024-04-20 12:50:45 -04:00
Deluan
78182f40d6 Block regular users from changing their own playlists ownership 2024-04-20 12:08:07 -04:00
Deluan
9aeaaa6610 Fix issue in https://github.com/navidrome/navidrome/issues/2767#issuecomment-2065636352 2024-04-19 12:38:02 -04:00
dependabot[bot]
068c1e9a23 Bump golang.org/x/net from 0.21.0 to 0.23.0 (#2962)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.21.0 to 0.23.0.
- [Commits](https://github.com/golang/net/compare/v0.21.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-19 09:15:08 -04:00
Jonathan
bcec15dc13 Externalize MPV command template (#2948)
* externalise MPVTemplate

* Remove unnecessary comment

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2024-04-15 21:31:54 -04:00
dependabot[bot]
cf6603e3ec Bump react-icons from 5.0.1 to 5.1.0 in /ui (#2957)
Bumps [react-icons](https://github.com/react-icons/react-icons) from 5.0.1 to 5.1.0.
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v5.0.1...v5.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-15 14:35:00 -04:00
dependabot[bot]
88d6757121 Bump github.com/pelletier/go-toml/v2 from 2.2.0 to 2.2.1 (#2956)
Bumps [github.com/pelletier/go-toml/v2](https://github.com/pelletier/go-toml) from 2.2.0 to 2.2.1.
- [Release notes](https://github.com/pelletier/go-toml/releases)
- [Changelog](https://github.com/pelletier/go-toml/blob/v2/.goreleaser.yaml)
- [Commits](https://github.com/pelletier/go-toml/compare/v2.2.0...v2.2.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-15 14:34:33 -04:00
Andrew Katsikas
c2f932c21c Fix jukebox mode under Windows (#2774)
* bug(core/playback/mpv): jukebox mode under windows - #2767

Use named pipe for socket path under windows during mpv playback, change function name, unexport function

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(core/playback/mpv): jukebox mode under windows - #2767

Fix typo

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(core/playback/mpv): jukebox mode under windows - navidrome#2767

Early return for Close on Windows

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(core/playback/mpv): jukebox mode under windows - navidrome#2767

Update import and run prettier

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(core/playback/mpv): jukebox mode under windows - navidrome#2767

Update function name

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(core/playback/mpv): jukebox mode under windows - navidrome#2767

Create track_close files for both platforms and move MpvTrack Close into new file

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(core/playback/mpv): jukebox mode under windows - navidrome#2767

Create SocketName function for both platforms, restore name of TempFileName

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(core/playback/mpv): jukebox mode under windows - navidrome#2767

Add missing params to SocketName on windows

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* Unexport SocketName, use socketName in NewTrack

---------

Signed-off-by: apkatsikas <apkatsikas@gmail.com>
2024-04-14 13:50:37 -04:00
Deluan
d968f7f530 Remove deprecation warning about notify 2024-04-13 15:27:54 -04:00
dependabot[bot]
5fc78f120c Bump prettier from 3.2.2 to 3.2.5 in /ui (#2844)
Bumps [prettier](https://github.com/prettier/prettier) from 3.2.2 to 3.2.5.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.2.2...3.2.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-13 15:10:03 -04:00
dependabot[bot]
52dfa97262 Bump @testing-library/jest-dom from 6.2.0 to 6.4.2 in /ui (#2845)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.2.0 to 6.4.2.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.2.0...v6.4.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-13 15:09:53 -04:00
dependabot[bot]
c1eef058a4 Bump follow-redirects from 1.15.4 to 1.15.6 in /ui (#2911)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.4 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.4...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-13 15:09:34 -04:00
Deluan
7f551a7932 Add make target to build docker image 2024-04-13 13:29:45 -04:00
oftenoccur
bcb71b85c0 Fix some typos in comments (#2949)
Signed-off-by: oftenoccur <ezc5@sina.com>
2024-04-11 14:58:14 -04:00
Deluan
8720bd154f Ignore formatting diffs when checking for POEditor changes 2024-04-11 14:55:53 -04:00
Cyrille
699be19bb9 Fix a few mistakes in the French translation (#2872)
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-04-10 19:37:08 -04:00
looklose
22cc9e0cd5 Fix function name in comment (#2947)
Signed-off-by: looklose <shishuaiqun@yeah.net>
2024-04-10 12:53:21 -04:00
dependabot[bot]
6e36abdd62 Bump github.com/go-chi/jwtauth/v5 from 5.3.0 to 5.3.1
Bumps [github.com/go-chi/jwtauth/v5](https://github.com/go-chi/jwtauth) from 5.3.0 to 5.3.1.
- [Release notes](https://github.com/go-chi/jwtauth/releases)
- [Commits](https://github.com/go-chi/jwtauth/compare/v5.3.0...v5.3.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-09 20:45:43 -04:00
dependabot[bot]
e98c7374a9 Bump github.com/pelletier/go-toml/v2 from 2.1.1 to 2.2.0
Bumps [github.com/pelletier/go-toml/v2](https://github.com/pelletier/go-toml) from 2.1.1 to 2.2.0.
- [Release notes](https://github.com/pelletier/go-toml/releases)
- [Changelog](https://github.com/pelletier/go-toml/blob/v2/.goreleaser.yaml)
- [Commits](https://github.com/pelletier/go-toml/compare/v2.1.1...v2.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-09 20:45:27 -04:00
Deluan Quintão
de7f553526 Update Go to 1.22.2 and TagLib to 2.0.1 (#2946) 2024-04-09 19:00:38 -04:00
dependabot[bot]
9cc0cc2e93 Bump github.com/pressly/goose/v3 from 3.18.0 to 3.19.2
Bumps [github.com/pressly/goose/v3](https://github.com/pressly/goose) from 3.18.0 to 3.19.2.
- [Release notes](https://github.com/pressly/goose/releases)
- [Changelog](https://github.com/pressly/goose/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pressly/goose/compare/v3.18.0...v3.19.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-08 19:52:34 -04:00
dependabot[bot]
24298605d4 Bump github.com/onsi/ginkgo/v2 from 2.15.0 to 2.17.1
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.15.0 to 2.17.1.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.15.0...v2.17.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-08 19:46:24 -04:00
Deluan
4865d04ec6 Fix DiscTitle OpenSubsonic compatibility. Closes #2929 2024-04-08 19:05:36 -04:00
dependabot[bot]
81770351de Bump github.com/onsi/gomega from 1.31.1 to 1.32.0
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.31.1 to 1.32.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.31.1...v1.32.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>
2024-04-08 19:03:15 -04:00
dependabot[bot]
b6bbba754a Bump golang.org/x/sync from 0.6.0 to 0.7.0
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.6.0 to 0.7.0.
- [Commits](https://github.com/golang/sync/compare/v0.6.0...v0.7.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-08 18:57:52 -04:00
deluan
4f6121fae1 Update translations 2024-04-03 07:31:54 -04:00
Kendall Garner
f12dfb485a Expose OpenSubsonic release date for album (#2906)
* [enhancement]: OS expose release date for album, make original optional

* not optional

* remove omitempty
2024-04-03 07:30:01 -04:00
Deluan
e81bf5125f Bump actions versions 2024-04-02 19:37:59 -04:00
dependabot[bot]
a47acb6674 Bump github.com/lestrrat-go/jwx/v2 from 2.0.20 to 2.0.21
Bumps [github.com/lestrrat-go/jwx/v2](https://github.com/lestrrat-go/jwx) from 2.0.20 to 2.0.21.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/develop/v2/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v2.0.20...v2.0.21)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-26 11:54:28 -04:00
dependabot[bot]
4a15677474 Bump google.golang.org/protobuf from 1.32.0 to 1.33.0
Bumps google.golang.org/protobuf from 1.32.0 to 1.33.0.

---
updated-dependencies:
- dependency-name: google.golang.org/protobuf
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-26 11:53:51 -04:00
Deluan
859cdda0bd Bump Go dependencies 2024-03-03 21:30:28 -05:00
Deluan
87ecd118bb Bump goose to 3.18.0.
To fix the ambiguous import issue, I used:
go get -u google.golang.org/genproto/googleapis/rpc
2024-03-03 21:27:33 -05:00
Deluan
5abe156777 Logs don't panic when receiving a nil *time.Time 2024-02-18 13:06:01 -05:00
Deluan
fa72aaa462 Move TempFileName to utils 2024-02-18 12:52:06 -05:00
Deluan
6eb13c9f79 Run Test job in ci-goreleaser container 2024-02-18 12:52:06 -05:00
Deluan
b67d1c0830 Show taglib and ffmpeg versions in the log 2024-02-18 12:52:06 -05:00
Deluan
effd588406 Stop using deprecated TagLib method length 2024-02-18 12:52:06 -05:00
Deluan
6f4c55dbde Use new ci-goreleaser (with TagLib 2) 2024-02-18 12:52:06 -05:00
Deluan
176329343a Send Subsonic formatted response on marshalling errors 2024-02-17 10:39:29 -05:00
Deluan
97c7e5daaf Use new slices package from Go standard lib 2024-02-16 22:00:44 -05:00
Deluan
166eb37787 Use Go builtin min/max func 2024-02-16 21:53:16 -05:00
Deluan
f7a4387d0e Bump github.com/jellydator/ttlcache/v2 to v2.11.1 2024-02-16 21:42:22 -05:00
Deluan
71e5b271fb Bump github.com/xrash/smetrics version 2024-02-16 20:52:23 -05:00
Deluan
d51148ea4c Bump github.com/go-chi/chi/v5 to v5.0.12 2024-02-16 20:51:30 -05:00
Deluan
7cb8cc115e Bump github.com/mattn/go-sqlite3 to v1.14.22 2024-02-16 20:50:45 -05:00
Deluan
69d91189c2 Upgrade ginkgo and gomega 2024-02-16 20:49:37 -05:00
Deluan
88063fc189 Upgrade ginkgo and gomega 2024-02-16 20:47:53 -05:00
Deluan
912e144b71 Bump github.com/google/uuid to 1.6.0 2024-02-16 20:46:41 -05:00
Deluan
87484fe7a9 Bump github.com/google/wire to 0.6.0 2024-02-16 20:45:11 -05:00
Deluan
58f64355c2 Bump golang.org/x/exp version 2024-02-16 20:43:12 -05:00
Deluan Quintão
7167e5ac87 Upgrade to Go 1.22 and Node v20 (#2861)
* Remove workaround for missing `context.WithoutCancel` in Go 1.20

* Upgrade to Go 1.22

* Upgrade GitHub Actions

* Upgrade Node to v20
2024-02-16 20:29:16 -05:00
Deluan
d8e1748928 Return 500 in case of Subsonic response marshalling errors 2024-02-16 19:59:24 -05:00
Deluan
9a051967f6 Handle "Infinity" values for ReplayGain. Fix #2862 2024-02-16 18:44:58 -05:00
Deluan
0b2cf30096 Don't swallow marshalling errors in the Subsonic API 2024-02-16 18:43:36 -05:00
Deluan
6d253225de Use order/sort album/artist when sorting tracks in playlists. Fixes #2819 2024-02-15 21:52:00 -05:00
Caio Cotts
bf2bcb1279 Fix null values in DB (#2840)
* Fix album image_files being null.

* Fix small nitpick.

* Use ExecContext instead of Exec.

* Change more columns to not null and set default values.

* Remove columns that don't need to be changed from migration.

* Fix typo.

* Remove unnecessary select statements.

* Remove duplicate code.

* Do not apply changes to radio table.

* Do not apply changes full_text columns and respective indexes.

* Fix musicbrainz columns.

* Rename migration.

* Make ExternalInfoUpdatedAt nullable

* Make Share's timestamps nullable

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2024-02-07 20:45:08 -05:00
Deluan Quintão
ac4ceab143 Update French translation (#2834)
Co-authored-by: deluan <deluan@users.noreply.github.com>
2024-02-05 20:10:21 -05:00
Deluan
6226741517 Create resources.FS only once 2024-02-03 12:05:19 -05:00
Deluan
79a4d8f6ad Simplify ShortDur code and tests 2024-02-02 21:07:27 -05:00
Deluan Quintão
61257f89d2 Update translations (#2832)
Co-authored-by: deluan <deluan@users.noreply.github.com>
2024-01-30 07:25:42 -05:00
Deluan
1f71e56741 Don't expose Last.fm API Key in the index.html 2024-01-29 21:42:27 -05:00
Kendall Garner
3a9b3452a2 Set rating value to 0 when value is null (#2824) 2024-01-29 06:26:15 -05:00
Deluan
5125558f52 Make Subsonic search query default to "" if not present.
See https://github.com/orgs/music-assistant/discussions/414#discussioncomment-8265985
2024-01-27 20:00:02 -05:00
Deluan
5f9b6b632d Add a "upgrading schema" log message to the DB initialization when there are pending migrations. 2024-01-27 19:44:49 -05:00
Deluan
fa7cc40d23 Add tests for toSQL 2024-01-27 12:16:38 -05:00
caiocotts
58218e6dc4 Fix fields not being sent on getPlaylist.view responses. 2024-01-26 12:41:55 -05:00
Deluan
67c82f524b "Fix" Reddit badge 2024-01-24 20:24:13 -05:00
Deluan
fb7fd21984 Don't add empty TIPL roles 2024-01-24 19:22:25 -05:00
Deluan
a6fc84a2e1 Parse the ID3v2.4 TIPL frame 2024-01-23 20:50:43 -05:00
Deluan
1e5e8be192 Import ID3 sort_* tags 2024-01-23 18:07:11 -05:00
Deluan
fd61b29a84 Small readability improvement in MergeFS tests 2024-01-21 16:20:47 -05:00
Deluan
2b33ef72e3 Remove offset and limit from count queries. Fixes #2443 2024-01-20 22:02:05 -05:00
Deluan
2fb913f5c9 Add log message to try to capture error in #2735 2024-01-20 20:18:59 -05:00
Deluan
6c05493cda Improve some Jukebox error messages 2024-01-20 20:10:32 -05:00
Deluan
3ca4f44118 Simplify default middlewares setup 2024-01-20 19:17:21 -05:00
Deluan
34c29a156f Simplify RealIP middleware setup 2024-01-20 18:58:12 -05:00
dependabot[bot]
b442736a0f Bump connected-react-router from 6.9.1 to 6.9.3 in /ui (#2741)
Bumps [connected-react-router](https://github.com/supasate/connected-react-router) from 6.9.1 to 6.9.3.
- [Release notes](https://github.com/supasate/connected-react-router/releases)
- [Commits](https://github.com/supasate/connected-react-router/compare/v6.9.1...v6.9.3)

---
updated-dependencies:
- dependency-name: connected-react-router
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:42:43 -05:00
dependabot[bot]
90fccf00d1 Bump workbox-cli from 6.5.4 to 7.0.0 in /ui (#2737)
Bumps [workbox-cli](https://github.com/googlechrome/workbox) from 6.5.4 to 7.0.0.
- [Release notes](https://github.com/googlechrome/workbox/releases)
- [Commits](https://github.com/googlechrome/workbox/compare/v6.5.4...v7.0.0)

---
updated-dependencies:
- dependency-name: workbox-cli
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:38:44 -05:00
dependabot[bot]
bcd4a52616 Bump golang.org/x/sync from 0.5.0 to 0.6.0 (#2779)
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.5.0 to 0.6.0.
- [Commits](https://github.com/golang/sync/compare/v0.5.0...v0.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:37:46 -05:00
dependabot[bot]
84cffa6b94 Bump github.com/prometheus/client_golang from 1.17.0 to 1.18.0 (#2759)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.17.0 to 1.18.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.17.0...v1.18.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:37:01 -05:00
dependabot[bot]
a51b1b25d2 Bump uuid from 8.3.2 to 9.0.1 in /ui (#2740)
Bumps [uuid](https://github.com/uuidjs/uuid) from 8.3.2 to 9.0.1.
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v8.3.2...v9.0.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:36:14 -05:00
dependabot[bot]
9f317c054b Bump @testing-library/user-event from 14.5.1 to 14.5.2 in /ui (#2757)
Bumps [@testing-library/user-event](https://github.com/testing-library/user-event) from 14.5.1 to 14.5.2.
- [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/v14.5.1...v14.5.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:35:20 -05:00
dependabot[bot]
5f8d01a207 Bump clsx from 2.0.0 to 2.1.0 in /ui (#2758)
Bumps [clsx](https://github.com/lukeed/clsx) from 2.0.0 to 2.1.0.
- [Release notes](https://github.com/lukeed/clsx/releases)
- [Commits](https://github.com/lukeed/clsx/compare/v2.0.0...v2.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:35:08 -05:00
dependabot[bot]
8a648d717a Bump github.com/go-chi/chi/v5 from 5.0.10 to 5.0.11 (#2742)
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.0.10 to 5.0.11.
- [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.10...v5.0.11)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:34:58 -05:00
dependabot[bot]
a0dc2ee051 Bump github.com/pelletier/go-toml/v2 from 2.0.6 to 2.1.1 (#2760)
Bumps [github.com/pelletier/go-toml/v2](https://github.com/pelletier/go-toml) from 2.0.6 to 2.1.1.
- [Release notes](https://github.com/pelletier/go-toml/releases)
- [Changelog](https://github.com/pelletier/go-toml/blob/v2/.goreleaser.yaml)
- [Commits](https://github.com/pelletier/go-toml/compare/v2.0.6...v2.1.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:32:53 -05:00
dependabot[bot]
ffb4de1e27 Bump github.com/unrolled/secure from 1.13.0 to 1.14.0 (#2761)
Bumps [github.com/unrolled/secure](https://github.com/unrolled/secure) from 1.13.0 to 1.14.0.
- [Release notes](https://github.com/unrolled/secure/releases)
- [Commits](https://github.com/unrolled/secure/compare/v1.13.0...v1.14.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:31:50 -05:00
dependabot[bot]
e1fc7983a5 Bump golang.org/x/image from 0.14.0 to 0.15.0 (#2778)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.14.0 to 0.15.0.
- [Commits](https://github.com/golang/image/compare/v0.14.0...v0.15.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:31:18 -05:00
dependabot[bot]
2a43f54eb1 Bump follow-redirects from 1.15.2 to 1.15.4 in /ui (#2786)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.2 to 1.15.4.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.2...v1.15.4)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:30:55 -05:00
dependabot[bot]
f654e92113 Bump github.com/lestrrat-go/jwx/v2 from 2.0.18 to 2.0.19 (#2792)
Bumps [github.com/lestrrat-go/jwx/v2](https://github.com/lestrrat-go/jwx) from 2.0.18 to 2.0.19.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/develop/v2/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v2.0.18...v2.0.19)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-20 18:29:46 -05:00
flyingOwl
dfa453cc4a Add (not)inplaylist operator to smart playlists (#1884)
Closes #1417 

A smart playlist can use the playlist id for filtering. This can be
used to create combined playlists or to filter multiple playlists.

To filter by a playlist id, a subquery is created that will match the
media ids with the playlists within the playlist_tracks table.

Signed-off-by: flyingOwl <ofenfisch@googlemail.com>
2024-01-20 18:22:17 -05:00
Johannes Engl
8f03454312 Make server unix socket file permission configurable via flag UnixSocketPerm (#2763)
* feat(any): Add flag unixsocketperm with default 0017 - #2625

Signed-off-by: johannesengl <hello@johannesengl.com>

* feat(server): Update unix socket file perm based on config - #2625

Signed-off-by: johannesengl <hello@johannesengl.com>

* Fix default value of socket.

* Refactor unix socket file creation.

* Remove misplaced comment

---------

Signed-off-by: johannesengl <hello@johannesengl.com>
Co-authored-by: Caio Cotts <caio@cotts.com.br>
Co-authored-by: Deluan <deluan@navidrome.org>
2024-01-20 14:50:30 -05:00
dependabot[bot]
8570773b90 Bump prettier from 3.1.1 to 3.2.2 in /ui
Bumps [prettier](https://github.com/prettier/prettier) from 3.1.1 to 3.2.2.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.1.1...3.2.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-18 09:23:10 -05:00
caiocotts
6cff91e17d Use the default import path for jest-dom. 2024-01-17 17:07:43 -05:00
dependabot[bot]
d0df81a8df Bump @testing-library/jest-dom from 5.16.5 to 6.2.0 in /ui
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 5.16.5 to 6.2.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.16.5...v6.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-17 17:07:43 -05:00
dependabot[bot]
75f3ef64e2 Bump react-icons from 4.4.0 to 5.0.1 in /ui
Bumps [react-icons](https://github.com/react-icons/react-icons) from 4.4.0 to 5.0.1.
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v4.4.0...v5.0.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-17 16:10:00 -05:00
dependabot[bot]
170ac93926 Bump github.com/onsi/ginkgo/v2 from 2.13.2 to 2.14.0
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.13.2 to 2.14.0.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.13.2...v2.14.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-17 15:58:57 -05:00
Deluan
6f7b48202e Make the GetInstance concurrent test more readable 2023-12-28 16:50:07 -05:00
Deluan
6e2be7f95f Don't force a full scan after upgrading the lyrics 2023-12-28 04:55:45 -05:00
Deluan
0d8f8e3afd Optimize Singleton (sometimes a simple lock is a better solution) 2023-12-27 22:12:34 -05:00
Deluan
e50382e3bf Fix ReplayGain values not being retrieved from DB 2023-12-27 21:14:54 -05:00
Kendall Garner
814161d78d Add OS Lyrics extension (#2656)
* draft commit

* time to fight pipeline

* round 2 changes

* remove unnecessary line

* fight taglib. again

* make taglib work again???

* add id3 tags

* taglib 1.12 vs 1.13

* use int instead for windows

* store as json now

* add migration, more tests

* support repeated line, multiline

* fix ms and support .m, .mm, .mmm

* address some concerns, make cpp a bit safer

* separate responses from model

* remove [:]

* Add trace log

* Try to unblock pipeline

* Fix merge errors

* Fix SIGSEGV error (proper handling of empty frames)

* Add fallback artist/title to structured lyrics

* Rename conflicting named vars

* Fix tests

* Do we still need ffmpeg in the pipeline?

* Revert "Do we still need ffmpeg in the pipeline?"

Yes we do.

This reverts commit 87df7f6df7.

* Does this passes now, with a newer ffmpeg version?

* Revert "Does this passes now, with a newer ffmpeg version?"

No, it does not :(

This reverts commit 372eb4b0ae.

* My OCD made me do it :P

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2023-12-27 20:20:29 -05:00
Deluan
130ab76c79 go mod tidy 2023-12-27 13:04:26 -05:00
Deluan
a186a795f6 Omit empty Genre attributes 2023-12-27 12:44:25 -05:00
Deluan
798b03eabd Add "inspect" command to CLI 2023-12-27 12:41:28 -05:00
Deluan
ea7ba22699 Discard duplicated tags 2023-12-26 19:35:14 -05:00
Andrew Katsikas
b4815ecee5 Add TAK support (#2745)
* bug(consts/mime_types): tak-support - 2514

Add tak to mime_types audioFormats

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* bug(scanner): tak-support - 2514

Add tak test fixture file and add fixes for tag_scanner and walk_dir_tree tests

Signed-off-by: apkatsikas <apkatsikas@gmail.com>

* Remove comment

---------

Signed-off-by: apkatsikas <apkatsikas@gmail.com>
2023-12-26 18:39:15 -05:00
Deluan
51e07d4cb5 Add log.IsGreaterOrEqualTo, that take into consideration path-scoped log levels 2023-12-25 16:35:16 -05:00
Deluan
03119e5ccf Add more trace log to TagLib Wrapper 2023-12-23 14:10:38 -05:00
Deluan Quintão
15e1394fa3 Implement originalReleaseDate in OpenSubsonic responses. (#2733)
See https://github.com/opensubsonic/open-subsonic-api/pull/80
2023-12-22 21:03:55 -05:00
Deluan
3f349b1b58 Add todo as a reminder to replace min/max in Go 1.22 2023-12-21 19:19:46 -05:00
Deluan
dfcc189cff Replace all utils.Param* with req.Params 2023-12-21 17:41:09 -05:00
Deluan
00597e01e9 Add req.Params to replace utils.Param* 2023-12-21 16:32:37 -05:00
Dany Marcoux
965fc9d9be Remove beep and the files where it was imported (#2731)
Beep isn't needed anymore since we rely on MPV instead.

The changes to `go.mod` and `go.sum` were done with:
```
go get github.com/faiface/beep@none
go mod tidy
```

Signed-off-by: Dany Marcoux <git@dmarcoux.com>
2023-12-21 08:00:31 -05:00
Deluan Quintão
781ff40464 Bump Go version to 1.21.5 (#2729) 2023-12-20 20:02:40 -05:00
Deluan
a6ed0442f2 Name mapDates return values 2023-12-20 16:29:39 -05:00
dependabot[bot]
515efe37f0 Bump @testing-library/user-event from 13.5.0 to 14.5.1 in /ui
Bumps [@testing-library/user-event](https://github.com/testing-library/user-event) from 13.5.0 to 14.5.1.
- [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.5.0...v14.5.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-19 13:18:13 -05:00
dependabot[bot]
6c28c111bb Bump @adobe/css-tools from 4.3.1 to 4.3.2 in /ui
Bumps [@adobe/css-tools](https://github.com/adobe/css-tools) from 4.3.1 to 4.3.2.
- [Changelog](https://github.com/adobe/css-tools/blob/main/History.md)
- [Commits](https://github.com/adobe/css-tools/commits)

---
updated-dependencies:
- dependency-name: "@adobe/css-tools"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-19 12:34:13 -05:00
dependabot[bot]
92a88ad4d9 Bump golang.org/x/crypto from 0.16.0 to 0.17.0 (#2722)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/crypto/compare/v0.16.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-18 17:45:37 -05:00
dependabot[bot]
4ccc0a92bf Bump jwt-decode from 3.1.2 to 4.0.0 in /ui (#2714)
* Bump jwt-decode from 3.1.2 to 4.0.0 in /ui

Bumps [jwt-decode](https://github.com/auth0/jwt-decode) from 3.1.2 to 4.0.0.
- [Release notes](https://github.com/auth0/jwt-decode/releases)
- [Changelog](https://github.com/auth0/jwt-decode/blob/main/CHANGELOG.md)
- [Commits](https://github.com/auth0/jwt-decode/compare/v3.1.2...v4.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>

* Make jwt-decode a named import.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Caio Cotts <caio@cotts.com.br>
2023-12-18 17:28:42 -05:00
dependabot[bot]
df3de047ca Bump clsx from 1.1.1 to 2.0.0 in /ui
Bumps [clsx](https://github.com/lukeed/clsx) from 1.1.1 to 2.0.0.
- [Release notes](https://github.com/lukeed/clsx/releases)
- [Commits](https://github.com/lukeed/clsx/compare/v1.1.1...v2.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-18 15:15:00 -05:00
Caio Cotts
86757663d6 Reformat code with Prettier's new rules. 2023-12-18 15:12:24 -05:00
dependabot[bot]
735d670a5b Bump prettier from 2.8.2 to 3.1.1 in /ui
Bumps [prettier](https://github.com/prettier/prettier) from 2.8.2 to 3.1.1.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/2.8.2...3.1.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-18 15:12:24 -05:00
dependabot[bot]
30179146c3 Bump deepmerge from 4.2.2 to 4.3.1 in /ui
Bumps [deepmerge](https://github.com/TehShrike/deepmerge) from 4.2.2 to 4.3.1.
- [Changelog](https://github.com/TehShrike/deepmerge/blob/master/changelog.md)
- [Commits](https://github.com/TehShrike/deepmerge/compare/v4.2.2...v4.3.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-18 14:22:11 -05:00
dependabot[bot]
03a9f22ed9 Bump @material-ui/icons from 4.11.2 to 4.11.3 in /ui
Bumps [@material-ui/icons](https://github.com/mui-org/material-ui/tree/HEAD/packages/material-ui-icons) from 4.11.2 to 4.11.3.
- [Release notes](https://github.com/mui-org/material-ui/releases)
- [Changelog](https://github.com/mui/material-ui/blob/v4.11.3/CHANGELOG.md)
- [Commits](https://github.com/mui-org/material-ui/commits/v4.11.3/packages/material-ui-icons)

---
updated-dependencies:
- dependency-name: "@material-ui/icons"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-18 14:17:25 -05:00
dependabot[bot]
39e92a1918 Bump github.com/mattn/go-sqlite3 from 1.14.18 to 1.14.19
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.18 to 1.14.19.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.18...v1.14.19)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-18 14:07:07 -05:00
Deluan
421ce91a9e Fix mpvipc dependency 2023-12-17 13:57:15 -05:00
Deluan
12aae5e951 Some cleanup in the jukebox code, specially log messages 2023-12-17 13:15:47 -05:00
Deluan
932152eb7e Change required fields in Subsonic Jukebox endpoint
See discussion here: https://gitlab.com/ultrasonic/ultrasonic/-/issues/1266#note_1621953651
2023-12-17 13:15:47 -05:00
Deluan
0e3175ea17 Better workaround for Go 1.20 missing context.WithoutCancel 2023-12-16 13:33:03 -05:00
Deluan
d3f6b4692d Temporary fix for scan context cancellation for Go 1.20 2023-12-15 07:59:34 -05:00
Deluan
70effa09e8 Don't cancel Scan on context cancellation 2023-12-14 22:52:48 -05:00
Deluan
7ccf685973 Fix PreferSortTags 2023-12-14 21:45:47 -05:00
Deluan
2aef227572 Add context to SQL queries, enabling cancellation 2023-12-14 17:13:09 -05:00
Deluan
d80e1a260b Fix possible authentication bypass 2023-12-13 19:32:05 -05:00
dependabot[bot]
fd4605d7dc Bump github.com/mattn/go-zglob from 0.0.3 to 0.0.4 (#2015)
Bumps [github.com/mattn/go-zglob](https://github.com/mattn/go-zglob) from 0.0.3 to 0.0.4.
- [Release notes](https://github.com/mattn/go-zglob/releases)
- [Commits](https://github.com/mattn/go-zglob/compare/v0.0.3...v0.0.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-13 17:27:42 -05:00
Deluan
a6493c4c36 Bump github.com/google/uuid to v1.5.0 2023-12-13 16:47:05 -05:00
Kendall Garner
54597bd575 Allow reverse proxy auth for unix socket (#2701) 2023-12-12 06:06:27 -05:00
Deluan Quintão
ab53313273 Add new PrefSortTags option (#2696) 2023-12-11 20:37:11 -05:00
Deluan
d3669f46a9 go mod tidy 2023-12-11 19:03:27 -05:00
Deluan
d89de9060a Bump Go dependencies 2023-12-11 17:25:14 -05:00
Deluan
ac3668a33e Removed unused diodes package 2023-12-11 17:22:10 -05:00
dependabot[bot]
6d924ad742 Bump github.com/go-chi/jwtauth/v5 from 5.2.0 to 5.3.0 (#2699)
Bumps [github.com/go-chi/jwtauth/v5](https://github.com/go-chi/jwtauth) from 5.2.0 to 5.3.0.
- [Release notes](https://github.com/go-chi/jwtauth/releases)
- [Commits](https://github.com/go-chi/jwtauth/compare/v5.2.0...v5.3.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-11 13:41:03 -05:00
Deluan
78d557c185 Remove LastFM shared key 2023-12-10 21:11:40 -05:00
Deluan
546aa26a0a Removed duplicated code 2023-12-09 14:11:07 -05:00
dependabot[bot]
fc677f7951 Bump github.com/lestrrat-go/jwx/v2 from 2.0.17 to 2.0.18 (#2684)
Bumps [github.com/lestrrat-go/jwx/v2](https://github.com/lestrrat-go/jwx) from 2.0.17 to 2.0.18.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/develop/v2/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v2.0.17...v2.0.18)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-09 14:04:56 -05:00
Deluan
aed0309161 Return AlbumID3 in search3 results 2023-12-09 14:01:22 -05:00
Deluan
465cc091b0 Convert internal disc number representation to int 2023-12-09 13:53:38 -05:00
Deluan
2c9035fdd0 Add discTitles to OpenSubsonic responses 2023-12-09 13:53:38 -05:00
Deluan
af7eead037 Add discs to album 2023-12-09 13:53:38 -05:00
Deluan Quintão
0ca0d5da22 Replace beego/orm with dbx (#2693)
* Start migration to dbx package

* Fix annotations and bookmarks bindings

* Fix tests

* Fix more tests

* Remove remaining references to beego/orm

* Add PostScanner/PostMapper interfaces

* Fix importing SmartPlaylists

* Renaming

* More renaming

* Fix artist DB mapping

* Fix playlist updates

* Remove bookmarks at the end of the test

* Remove remaining `orm` struct tags

* Fix user timestamps DB access

* Fix smart playlist evaluated_at DB access

* Fix search3
2023-12-09 13:52:17 -05:00
dependabot[bot]
7074455e0e Bump github.com/onsi/ginkgo/v2 from 2.13.1 to 2.13.2
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.13.1 to 2.13.2.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.13.1...v2.13.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-08 21:35:42 -05:00
caiocotts
2f2fbeb009 Fix ld warnings on taglib_wrapper. 2023-12-04 15:19:12 -05:00
Kendall Garner
742fd16a01 Parse more itunes keys, optimize taglib wrapper (#2680)
* parse more itunes keys

* Move special iTunes M4A logic to Go code

* Simplify ASF/WMA tags handling

* Simplify ASF/WMA tags handling even more, moving compilation logic to `metadata` normalizer

* Remove strdups from C++ code, `C.GoString` already duplicates the strings

* reduced set

* remove strdup

* Small nitpick

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2023-12-03 14:19:16 -05:00
Deluan Quintão
7766ee069c Return http form post extension (OpenSubsonic) (#2676) 2023-12-02 19:46:57 -05:00
Deluan
4cd7c7f39f Fix FileHaunter tests 2023-12-02 19:40:59 -05:00
Deluan
81daee3b9b Fix FileHaunter tests 2023-12-02 18:43:24 -05:00
Deluan
9b434d743f Ignore flaky FileHaunter tests 2023-12-02 18:32:48 -05:00
Deluan
4641dc0b2b Add ReplayGain to OpenSubsonic API Child response 2023-12-02 15:28:44 -05:00
Deluan
812dc2090f Add support for timeOffset in /stream endpoint 2023-12-02 13:10:36 -05:00
Deluan
a9cf54afef Return genres in bookmark endpoints (OpenSubsonic) 2023-12-02 11:36:16 -05:00
Deluan
595186b1b2 Coalesce null annotation values, to better rank them against annotations with value 0 2023-12-02 11:35:54 -05:00
Deluan
cdccdc56c9 Add more OpenSubsonic fields
- isCompilation
- sortName
2023-11-28 21:26:00 -05:00
Deluan
f580c5b8bc Add more OpenSubsonic fields
- mediaType
- musicBrainzId (Child)
2023-11-28 21:12:28 -05:00
deluan
f0e25c251d Update translations 2023-11-28 06:10:03 -05:00
Deluan
abde399e7b Upgrade to Goose 3.15.1 2023-11-27 14:46:44 -05:00
Deluan
1b4483d32b Remove tools.go 2023-11-27 14:06:00 -05:00
Deluan
f7fe8ba938 npx update-browserslist-db@latest 2023-11-27 13:56:16 -05:00
Deluan
f543e7accc Fix getOpenSubsonicExtensions endpoint
Match the current doc: https://opensubsonic.netlify.app/docs/endpoints/getopensubsonicextensions/

openSubsonicExtensions must be an array, not a struct
2023-11-27 13:27:10 -05:00
Deluan Quintão
60a5fbe1fe Optimize search3, by removing OFFSET when paginating (#2655)
* Optimize pagination, removing offset

* For search, don't add `where` clause for empty queries

* Revert "Replace `COUNT(DISTINCT primary_key)` with `COUNT(*)`"

Genres are required as part of the count queries, so filter by genres work

* Optimize search3 query, using order by id if it is a "" query.

Also fix the optimizePagination query logic

* Allow offset optimizer threshold to be configured
2023-11-27 13:06:23 -05:00
Deluan
28dc98dec4 Revert "Replace COUNT(DISTINCT primary_key) with COUNT(*)"
Genres are required as part of the count queries, so filter by genres work
2023-11-25 23:08:20 -05:00
Deluan
8c8e1ea701 Replace COUNT(DISTINCT primary_key) with COUNT(*) 2023-11-25 22:46:15 -05:00
Deluan
b964018cd7 Show SQL errors in queryAll 2023-11-25 13:54:38 -05:00
Deluan
9aa7b80d0d Generalize BreakUp/RangByChunks functions 2023-11-25 12:13:36 -05:00
Deluan
c3efc57259 Use TagLib 1.13.1 for snapshots/releases 2023-11-24 20:35:38 -05:00
Deluan
27a92b05e7 Fixed deprecated GoReleaser options 2023-11-24 18:08:34 -05:00
Deluan
21f1354cd1 Revert "Bump golang.org/x/exp, change slices.SortFunc function call"
This reverts commit 474f32f1
2023-11-24 17:57:22 -05:00
Deluan
069da5d91c Bump Go to 1.21.4 2023-11-24 17:51:36 -05:00
Deluan
69d2ced852 Bump Go dependencies 2023-11-24 16:45:52 -05:00
Deluan
17ac8d25cb Bump dependencies 2023-11-24 16:40:20 -05:00
Deluan
474f32f1b8 Bump golang.org/x/exp, change slices.SortFunc function call 2023-11-24 16:38:47 -05:00
Deluan
ecadcfb403 Make ParamInt generic (any int type) 2023-11-23 13:40:06 -05:00
Caio Cotts
f69c27d146 Return genres in search3 endpoint. 2023-11-21 21:34:03 -05:00
Caio Cotts
bb7186ce2f Fix marshaling for genres. 2023-11-21 21:34:03 -05:00
dependabot[bot]
5d1493e845 Bump @adobe/css-tools from 4.0.1 to 4.3.1 in /ui
Bumps [@adobe/css-tools](https://github.com/adobe/css-tools) from 4.0.1 to 4.3.1.
- [Changelog](https://github.com/adobe/css-tools/blob/main/History.md)
- [Commits](https://github.com/adobe/css-tools/commits)

---
updated-dependencies:
- dependency-name: "@adobe/css-tools"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-21 08:47:14 -05:00
Deluan
d0fe406800 Fix Go 1.20 build 2023-11-21 08:32:22 -05:00
Deluan
c8fbf6b60e Bump dependencies 2023-11-21 08:22:02 -05:00
deluan
e5bc3ca200 Update translations 2023-11-21 08:15:32 -05:00
tarokeitaro
6d88dd2c66 Add Indonesian Language 2023-11-21 08:06:51 -05:00
caiocotts
eebfbc5381 Revert walk_dir_tree.go back to using the os package. 2023-11-21 07:17:22 -05:00
Deluan
a5dfd2d4a1 Format subsonic response snapshots 2023-11-18 14:43:40 -05:00
Drew Weymouth
7773522803 Expose OpenSubsonic fields Genres, MusicBrainzId, Bpm, Comment (#2597)
* add Genres to subsonic responses

* add genres in GetAlbum response

* add musicBrainzId

* add Bpm and Comment OpenSubsonic fields

* remove omitempty on OpenSubsonic fields

* add custom JSON marshalers to ensure genres attribute is non-nil

* regenerate snapshots to capture now-mandatory fields
2023-11-18 14:40:00 -05:00
Deluan
53607fe114 Publish all new images to Docker Registry 2023-11-16 23:21:20 -05:00
Caio Cotts
fee0f40a52 Bump dependencies 2023-11-16 20:38:45 -05:00
Caio Cotts
9d2aaff8cb Bump golang.org/x/tools from 0.13.0 to 0.15.0 2023-11-16 20:19:29 -05:00
Caio Cotts
2ff4023cce Bump golang.org/x/image from 0.12.0 to 0.14.0 2023-11-16 20:16:25 -05:00
Kendall Garner
79870b1090 Do not empty old artist metadata (#2423) 2023-11-16 19:20:37 -05:00
Kendall Garner
7a858a2db3 Fix external link for artist page if LastFM is missinb but Musicbrainz is not (#2533)
* fix mbz link if lastfm does not exist

* use lastfmUrl field

* fix artist info undefined
2023-11-16 19:07:52 -05:00
dependabot[bot]
9cefaf66a4 Bump github.com/onsi/gomega from 1.29.0 to 1.30.0
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.29.0 to 1.30.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.29.0...v1.30.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>
2023-11-16 18:45:21 -05:00
Kendall Garner
3debd31b12 Add more replaygain tests, fix wma (#2356)
* add more replaygain tests, fix wma

* Convert individual specs to a table spec

* Fix pipeline, by commenting incompatible tests

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2023-11-14 20:25:18 -05:00
Deluan Quintão
24d9fb5b48 Update translations (#2409)
Co-authored-by: deluan <deluan@users.noreply.github.com>
2023-11-14 19:21:26 -05:00
certuna
40841ab917 Small date mapping fix (#2584)
* Update mapping.go

fallback in the case there's no Date tagged but Original Date or Release Date are tagged

* Add tests

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2023-11-11 17:13:07 -05:00
certuna
bae5fc946b Fix hardcoded IPv4 literals (#2602)
* Update server_test.go

no hardcoded IPv4 literals

* Update package.json

no hardcoded IPv4 literals
2023-11-11 16:46:53 -05:00
Deluan
e055826068 Fix devcontainer for Go 1.21 2023-11-09 18:23:44 -05:00
dependabot[bot]
54bde266b4 Bump github.com/mattn/go-sqlite3 from 1.14.16 to 1.14.18
Bumps [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) from 1.14.16 to 1.14.18.
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.16...v1.14.18)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-07 18:24:08 -05:00
dependabot[bot]
3a7376901b Bump golang.org/x/sync from 0.3.0 to 0.5.0
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.3.0 to 0.5.0.
- [Commits](https://github.com/golang/sync/compare/v0.3.0...v0.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-07 18:19:32 -05:00
dependabot[bot]
de3d870100 Bump github.com/spf13/cobra from 1.7.0 to 1.8.0
Bumps [github.com/spf13/cobra](https://github.com/spf13/cobra) from 1.7.0 to 1.8.0.
- [Release notes](https://github.com/spf13/cobra/releases)
- [Commits](https://github.com/spf13/cobra/compare/v1.7.0...v1.8.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-07 18:08:13 -05:00
certuna
03175e1a9d Use file creation date for Date Added/CreatedAt (#2553)
* Update mapping.go

CreatedAt = BirthTime

* Update metadata.go

Add BirthTime() function

* Update spread_fs.go

Replacing djherbis/atime package with djherbis/times, as times includes the functionality of atime

* Update go.mod

remove djherbis/atime, add djherbis/times

* Update mapping.go

time package not used anymore

* Update go.sum

removed djherbis/atime, added djherbis/times

* Update spread_fs.go

revert to previous, cannot get rid of /atime after all since it's a dependency of /fscache

* Update go.mod

djherbis/times 1.6.0 now released

* Update go.sum

new sums

* Update metadata.go

Inverted if statement, more readable

* Update go.mod

format fix

* Update go.sum

format fix

* Update go.sum

format fix

* Update go.sum

format fix

* Update metadata.go

variable name times -> fileProperties
check for errors

* Update metadata.go

reverse order of error check

* Update metadata.go

typo

* Update metadata.go

https://github.com/navidrome/navidrome/pull/2553#issuecomment-1787967615
2023-11-01 16:41:07 -04:00
Sam Watson
26472f46fe POST endpoint for importing m3u playlists - #2078 (#2273)
* wip: API endpoint for creating playlists from m3u files

* wip: get user id from context

* temporarily disable failing test

* custom logic for playlist route to accomodate m3u content type

* incorporate playlist parsing into existing logic in core

* re-enable test

* fix locally failing test

* Address requested changes.

* Improve ImportFile tests.

* Remove ownerID as a parameter of ImportM3U.

* Write tests for ImportM3U.

* Separate ImportM3U test into two.

* Test OwnerID and playlist Name.

---------

Co-authored-by: Sam Watson <SwatsonCodes@users.noreply.github.com>
Co-authored-by: caiocotts <caio@cotts.com.br>
2023-11-01 14:59:47 -04:00
dependabot[bot]
6bca7531aa Bump @babel/traverse from 7.19.3 to 7.23.2 in /ui
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.19.3 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-31 17:46:10 -04:00
dependabot[bot]
68d1d5c99f Bump github.com/lestrrat-go/jwx/v2 from 2.0.12 to 2.0.16
Bumps [github.com/lestrrat-go/jwx/v2](https://github.com/lestrrat-go/jwx) from 2.0.12 to 2.0.16.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/develop/v2/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v2.0.12...v2.0.16)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-31 17:27:45 -04:00
dependabot[bot]
db6c46091e Bump github.com/beego/beego/v2 from 2.0.7 to 2.1.3
Bumps [github.com/beego/beego/v2](https://github.com/beego/beego) from 2.0.7 to 2.1.3.
- [Release notes](https://github.com/beego/beego/releases)
- [Changelog](https://github.com/beego/beego/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/beego/beego/compare/v2.0.7...v2.1.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-31 17:24:55 -04:00
dependabot[bot]
4cd916bb78 Bump react-router-dom from 5.3.0 to 5.3.4 in /ui
Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 5.3.0 to 5.3.4.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/v5.3.4/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-31 16:55:59 -04:00
dependabot[bot]
c40e83efab Bump github.com/go-chi/chi/v5 from 5.0.8 to 5.0.10
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.0.8 to 5.0.10.
- [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.8...v5.0.10)

---
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>
2023-10-31 16:44:35 -04:00
Stephan Wahlen
9094f41f25 Improve TopSongs findMatchingTrack by de-prioritizing compilations (#2532)
in reference to https://github.com/navidrome/navidrome/issues/1701
2023-10-31 16:00:53 -04:00
dependabot[bot]
9ff95b6ced Bump github.com/onsi/gomega from 1.27.10 to 1.29.0
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.27.10 to 1.29.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.27.10...v1.29.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>
2023-10-31 15:48:16 -04:00
Kendall Garner
77ace8570c Coalesce genre to null for smart playlist (#2573) 2023-10-31 13:22:57 -04:00
Matthias Schmidt
59f0c487e7 Jukebox cleanup (#2554)
* Fixing typo FFmpegPath -> MPVPath

* Fixing panic by applying afontenot patch

* Using mpv audio-device flag and naming for config and playback
2023-10-17 18:12:48 -04:00
Deluan
2cd4358172 Make Jukebox available to Subsonic clients 2023-09-14 20:15:39 -04:00
dependabot[bot]
248bf232ff Bump github.com/lestrrat-go/jwx/v2 from 2.0.11 to 2.0.12 (#2480)
Bumps [github.com/lestrrat-go/jwx/v2](https://github.com/lestrrat-go/jwx) from 2.0.11 to 2.0.12.
- [Release notes](https://github.com/lestrrat-go/jwx/releases)
- [Changelog](https://github.com/lestrrat-go/jwx/blob/develop/v2/Changes)
- [Commits](https://github.com/lestrrat-go/jwx/compare/v2.0.11...v2.0.12)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-10 18:06:51 -04:00
dependabot[bot]
b5664ab905 Bump github.com/onsi/ginkgo/v2 from 2.11.0 to 2.12.0 (#2497)
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.11.0 to 2.12.0.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.11.0...v2.12.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-10 12:28:43 -04:00
Lukas H
ac7f94e620 Fix text being unreadable with Ligera theme (#2517)
Change MuiFormGroup color to make it readable.
2023-09-10 12:28:21 -04:00
dependabot[bot]
d45f9f172d Bump github.com/google/uuid from 1.3.0 to 1.3.1 (#2489)
Bumps [github.com/google/uuid](https://github.com/google/uuid) from 1.3.0 to 1.3.1.
- [Release notes](https://github.com/google/uuid/releases)
- [Changelog](https://github.com/google/uuid/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/uuid/compare/v1.3.0...v1.3.1)

---
updated-dependencies:
- dependency-name: github.com/google/uuid
  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>
2023-09-10 12:05:58 -04:00
dependabot[bot]
250107d668 Bump golang.org/x/image from 0.9.0 to 0.12.0 (#2507)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.9.0 to 0.12.0.
- [Commits](https://github.com/golang/image/compare/v0.9.0...v0.12.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-10 12:05:28 -04:00
BoniK
64b14db55a Add Korean Language (#2463) 2023-09-10 11:52:18 -04:00
dependabot[bot]
73d1851c0d Bump golang.org/x/tools from 0.9.1 to 0.13.0 (#2516)
Bumps [golang.org/x/tools](https://github.com/golang/tools) from 0.9.1 to 0.13.0.
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.9.1...v0.13.0)

---
updated-dependencies:
- dependency-name: golang.org/x/tools
  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>
2023-09-10 11:48:33 -04:00
Matthias Schmidt
1b16e1140f Jukebox mode (#2289)
* Adding cache directory to ignore-list

* Adding jukebox-related config options

* Adding DevEnableJukebox config option pls. dummy server

* Adding types and routers

* Now without panic

* First draft on parsing the action

* Some cleanups

* Adding playback server

* Verify audio device configuration

* Adding debug-build target to have full symbol support

* Adding beep sound library pls some example code. Not working yet

* Play a fixed mp3 on any interface access for testing purposes

* Put action code into separate file, adding stringer, more debug output, prepare structs, validation

* Put action parameter parser code where it belongs

* Have a single Action transporting all information

* User fmt.Errorf for error-generation

* Adding wide playback interface

* Use action map for parsing, stringer instead switch stmt.

* Use but only one switch case and direct dispatch, refactoring

* Add error handling and pushing to client

* send decent errormessage, no internal server error

* Adding playback devices slice and load it from config

* Combine config-verification and structure init

* Return user-specific device

* Separate playback server from device

* Use dataStore to retrieve mediafile by id

* WIP: Playlist and start/stop handling. Doing start/stop the hard way as of now

* WIP: set, start and stop work on one single song. More to come

* Dont need to wait for the end

* Merge jukebox_action.go into jukebox.go

* Remove getParameterAsInt64(). Use existing requiredParamInt() instead

* Dont need to call newFailure() explicitly

* Remove int64, use int instead.

* Add and set action now accept multiple ids

* Kickout copy of childFromMediaFile(). It is not needed here.

* Refactoring devices and playbackServer

* Turn (internal) playback.DeviceStatus into subsonic JukeboxStatus when rendering output. Indexes int64 -> int

* Now we have a position and playing status

* Switching gain to float32, xs:float is defined as 32 bit. Fixing nasty copy/pointer bug

* Now with volume control

* Start working the queue

* Remove user from device interface

* Rename function GetDevice -> GetDeviceForUser to make intention clearer

* Have a nice stringer for the queue

* User Prepared boolean for now to allow pause/unpause

* Skipping works, but without offsets

* Make ChildFromMediaFile public to be used in jukebox get() implementation

* Return position in seconds and implement offset-skip in seconds

* Default offset to 0

* Adding a simple setGain implementation

* Prepare for transcoding AAC

* WIP: transcode to WAV to use beeps wav decoder. Not done yet.

* WIP: out of sheer desparation: convert to MP3 (which works) rather than WAV to troubleshoot issue.

* Use FLAC as intermediate format to play Apple AAC

* A bit of cleanup

* Catching the end-of-stream event for further reactions

* Have a trackSwitching goroutine waiting on channel when track ends

* Move decoder code into own file. Restructure code a bit

* Now with going on to play the next song in the playlist

* Adding shuffle feature

* Implementing remove action

* Cleanup code

* Remove templates for ffmpeg mp3 generation. Not needed anymore.

* Adding some documentation

* Check whether offset into track is in range. Fixing potential remove track bug. Documentation

* Make golangci-lint happy: handling return values

* Adding test suite and example dummy for playback package

* Adding some basic queue tests

* Only use Jukebox.Enabled config option

* Adding stream closing handling

* Pass context.Context to all PlaybackDevice methods

* Remove unneeded function

* Correct spelling

* Reduce visibility of ChildFromMediaFile

* Decomplicate action-parsing

* Adding simple tempfile-based AAC->FLAC transcoding. No parallel reading and writing yet.

* Try to optimize pipe-writing, tempfile-handling and reading. Not done yet.

* Do a synchronous copy of the tempfile. Racecondition detected

* More debugging statements and fixing the play/pause bug. More work needed

* Start the trackSwitcher() with each device once. Return JSON position even if its 0. More debug-output

* Moving all track-handling code into own module

* Fix typo. Do not pass ctx around when not applicable

* WIP: More refactoring, debugging output

* Fix nil pointer

* Repairing MP3 playback by pinning indirect dependencies: hajimehoshi/go-mp3 and hajimehoshi/oto

* Do not forget to cleanup after a skip action

* Make resync with master easy

* Adding missing mocks

* Adding missing error-handling found by linter

* Updating github.com/hajimehoshi/oto

* Removing duplicate function

* Move BEEP-related code into own package

* Juggle beep-related code around as preparation for interface access

* More refactoring for interface separation

* Gather CloseDevice() behind Track interface.

* Adding skeleton, draft audio-interface using mpv.io

* Adding majority of interface commands using messages to mpv socket.

* Adding end-of-stream handling

* MPV: start/stop are working

* postition is given in float in mpv

* Unify Close() and CloseDevice(). Using temp filename for controlling socket

* Wait until control-socket shows up. Cleanup socket in Close()

* Use canceable command. Rename to Executor

* Skipping tracks works now

* Now with actually setting the position

* Fix regain

* Add missing error-handling found by linter

* Adding retry mode on time-pos property getter

* Remove unneeded code on queue

* Putting build-tag beep onto beep files

* Remove deprecated call to rand.Seed()

"As of Go 1.20 there is no reason to call Seed with a random value. Programs that call Seed with a known value to get a specific sequence of results should use New(NewSource(seed)) to obtain a local random generator."

* Using int32 to conform to Subsonic API spec

* Fix merge error

* Minor style changes

* Get username from context

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2023-09-10 11:25:22 -04:00
Deluan Quintão
f941347cf1 Upgrade to Go 1.21 (#2475)
* Upgrade to Go 1.21

* Remove 'replacements' from goreleaser config
2023-08-09 11:39:49 -04:00
dependabot[bot]
1b5cefdada Bump github.com/onsi/gomega from 1.27.8 to 1.27.9 (#2450)
Bumps [github.com/onsi/gomega](https://github.com/onsi/gomega) from 1.27.8 to 1.27.9.
- [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.27.8...v1.27.9)

---
updated-dependencies:
- dependency-name: github.com/onsi/gomega
  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>
2023-07-22 19:21:39 -04:00
dependabot[bot]
4cf25fc611 Bump github.com/microcosm-cc/bluemonday from 1.0.24 to 1.0.25 (#2449)
Bumps [github.com/microcosm-cc/bluemonday](https://github.com/microcosm-cc/bluemonday) from 1.0.24 to 1.0.25.
- [Release notes](https://github.com/microcosm-cc/bluemonday/releases)
- [Commits](https://github.com/microcosm-cc/bluemonday/compare/v1.0.24...v1.0.25)

---
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>
2023-07-22 19:11:15 -04:00
dependabot[bot]
14ba83ea1b Bump github.com/go-chi/chi/v5 from 5.0.8 to 5.0.10 (#2444)
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.0.8 to 5.0.10.
- [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.8...v5.0.10)

---
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>
2023-07-21 19:12:56 -04:00
dependabot[bot]
08f3fd1343 Bump github.com/pressly/goose/v3 from 3.13.1 to 3.13.4 (#2442)
Bumps [github.com/pressly/goose/v3](https://github.com/pressly/goose) from 3.13.1 to 3.13.4.
- [Release notes](https://github.com/pressly/goose/releases)
- [Changelog](https://github.com/pressly/goose/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pressly/goose/compare/v3.13.1...v3.13.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2023-07-21 19:11:59 -04:00
dependabot[bot]
3d66f58725 Bump tough-cookie from 4.1.2 to 4.1.3 in /ui (#2441)
Bumps [tough-cookie](https://github.com/salesforce/tough-cookie) from 4.1.2 to 4.1.3.
- [Release notes](https://github.com/salesforce/tough-cookie/releases)
- [Changelog](https://github.com/salesforce/tough-cookie/blob/master/CHANGELOG.md)
- [Commits](https://github.com/salesforce/tough-cookie/compare/v4.1.2...v4.1.3)

---
updated-dependencies:
- dependency-name: tough-cookie
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-21 19:04:32 -04:00
dependabot[bot]
5b1ba3df05 Bump word-wrap from 1.2.3 to 1.2.4 in /ui (#2446)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-21 19:02:10 -04:00
Deluan
a002830775 Fix EnableMediaFileCoverArt option default value 2023-07-10 18:07:58 -04:00
dependabot[bot]
7b600bed05 Bump golang.org/x/tools from 0.10.0 to 0.11.0 (#2432)
Bumps [golang.org/x/tools](https://github.com/golang/tools) from 0.10.0 to 0.11.0.
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.10.0...v0.11.0)

---
updated-dependencies:
- dependency-name: golang.org/x/tools
  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>
2023-07-05 16:38:32 -04:00
dependabot[bot]
7d0a1916d8 Bump golang.org/x/image from 0.8.0 to 0.9.0 (#2430)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.8.0 to 0.9.0.
- [Commits](https://github.com/golang/image/compare/v0.8.0...v0.9.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-05 16:34:58 -04:00
dependabot[bot]
c7fe311c7f Bump github.com/go-chi/jwtauth/v5 from 5.1.0 to 5.1.1 (#2427)
Bumps [github.com/go-chi/jwtauth/v5](https://github.com/go-chi/jwtauth) from 5.1.0 to 5.1.1.
- [Release notes](https://github.com/go-chi/jwtauth/releases)
- [Commits](https://github.com/go-chi/jwtauth/compare/v5.1.0...v5.1.1)

---
updated-dependencies:
- dependency-name: github.com/go-chi/jwtauth/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>
2023-07-05 16:31:38 -04:00
dependabot[bot]
4520a34648 Bump github.com/pressly/goose/v3 from 3.11.2 to 3.13.1 (#2428)
Bumps [github.com/pressly/goose/v3](https://github.com/pressly/goose) from 3.11.2 to 3.13.1.
- [Release notes](https://github.com/pressly/goose/releases)
- [Changelog](https://github.com/pressly/goose/blob/master/CHANGELOG.md)
- [Commits](https://github.com/pressly/goose/compare/v3.11.2...v3.13.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-05 16:31:22 -04:00
BenzLeung
3e14c3c4f8 Add support for lyrics tag unsynced lyrics (#2391)
* Add support for lyrics tag `unsynced_lyrics`

* Update metadata.go

* Update metadata.go

resolve lint issue

* format the code with `goimports`

format the code with `goimports`
2023-06-20 09:32:49 -04:00
dependabot[bot]
1e891d6b07 Bump github.com/prometheus/client_golang from 1.15.1 to 1.16.0 (#2408)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.15.1 to 1.16.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.15.1...v1.16.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-06-19 14:23:36 -04:00
dependabot[bot]
caf9b22d35 Bump golang.org/x/image from 0.7.0 to 0.8.0 (#2407)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.7.0 to 0.8.0.
- [Commits](https://github.com/golang/image/compare/v0.7.0...v0.8.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-06-19 14:23:01 -04:00
Deluan Quintão
4f8742bcd1 Update translations (#2329)
Co-authored-by: deluan <deluan@users.noreply.github.com>
2023-06-19 12:27:56 -04:00
Deluan
26aa0f4fff Fix typo 2023-06-19 11:19:43 -04:00
Deluan
4898f31f6d Add format target to Makefile 2023-06-19 11:15:51 -04:00
Philipp Wolfer
9da013f339 Submit duration to ListenBrainz (#2405) 2023-06-17 12:27:00 -04:00
Deluan
5af67c78af Upgrade Go dependencies 2023-06-16 22:34:02 -04:00
Philipp Wolfer
c8608956be Fix listenbrainz submission and clarify MusicBrainz recording ID field (#2279)
* Fix MB recording ID parameter name for ListenBrainz submission

This follows the ListenBrainz API documentation.

Fixes #1657

* Rename MediaFile.MbzTrackID to MbzRecordingID

This better reflects the actual data. That the MusicBrainz
recording ID is stored in file metadata as musicbrainz_trackid
is a historical artifact.

* Rename database column mbz_track_id to mbz_recording_id
2023-06-16 18:00:01 -04:00
Deluan
36eda871f6 Fix locale-dependent tests. Closes #2402 2023-06-16 16:38:03 -04:00
David Casado
7c92a73208 Ignore playlists starting with a dot - #2367 (#2390) 2023-06-16 15:55:17 -04:00
785 changed files with 34180 additions and 43106 deletions

View File

@@ -2,7 +2,7 @@
# [Choice] Go version: 1, 1.15, 1.14
ARG VARIANT="1"
FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT}
FROM mcr.microsoft.com/vscode/devcontainers/go:${VARIANT}
# [Option] Install Node.js
ARG INSTALL_NODE="true"
@@ -17,4 +17,4 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# RUN go get -x <your-dependency-or-tool>
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1

View File

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

View File

@@ -1,10 +1,18 @@
.DS_Store
ui/node_modules
ui/build
!ui/build/.gitkeep
Dockerfile
docker-compose*.yml
data
*.db
testDB
navidrome
navidrome.db
navidrome.toml
tmp
!tmp/taglib
dist
binaries
cache
music
!Dockerfile

View File

@@ -0,0 +1,23 @@
name: 'Download TagLib'
description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
inputs:
version:
description: 'Version of TagLib to download'
required: true
platform:
description: 'Platform to download TagLib for'
default: 'linux-amd64'
runs:
using: 'composite'
steps:
- name: Download TagLib
shell: bash
run: |
mkdir -p /tmp/taglib
cd /tmp
FILE=taglib-${{ inputs.platform }}.tar.gz
wget https://github.com/navidrome/cross-taglib/releases/download/v${{ inputs.version }}/${FILE}
tar -xzf ${FILE} -C taglib
PKG_CONFIG_PREFIX=/tmp/taglib
echo "PKG_CONFIG_PREFIX=${PKG_CONFIG_PREFIX}" >> $GITHUB_ENV
echo "PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:${PKG_CONFIG_PREFIX}/lib/pkgconfig" >> $GITHUB_ENV

View File

@@ -0,0 +1,84 @@
name: 'Prepare Docker Buildx environment'
description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
inputs:
github_token:
description: 'GitHub token'
required: true
default: ''
hub_repository:
description: 'Docker Hub repository to push images to'
required: false
default: ''
hub_username:
description: 'Docker Hub username'
required: false
default: ''
hub_password:
description: 'Docker Hub password'
required: false
default: ''
outputs:
tags:
description: 'Docker image tags'
value: ${{ steps.meta.outputs.tags }}
labels:
description: 'Docker image labels'
value: ${{ steps.meta.outputs.labels }}
annotations:
description: 'Docker image annotations'
value: ${{ steps.meta.outputs.annotations }}
version:
description: 'Docker image version'
value: ${{ steps.meta.outputs.version }}
hub_repository:
description: 'Docker Hub repository'
value: ${{ env.DOCKER_HUB_REPO }}
hub_enabled:
description: 'Is Docker Hub enabled'
value: ${{ env.DOCKER_HUB_ENABLED }}
runs:
using: 'composite'
steps:
- name: Check Docker Hub configuration
shell: bash
run: |
if [ -z "${{inputs.hub_repository}}" ]; then
echo "DOCKER_HUB_REPO=none" >> $GITHUB_ENV
echo "DOCKER_HUB_ENABLED=false" >> $GITHUB_ENV
else
echo "DOCKER_HUB_REPO=${{inputs.hub_repository}}" >> $GITHUB_ENV
echo "DOCKER_HUB_ENABLED=true" >> $GITHUB_ENV
fi
- name: Login to Docker Hub
if: inputs.hub_username != '' && inputs.hub_password != ''
uses: docker/login-action@v3
with:
username: ${{ inputs.hub_username }}
password: ${{ inputs.hub_password }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ inputs.github_token }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata for Docker image
id: meta
uses: docker/metadata-action@v5
with:
labels: |
maintainer=deluan@navidrome.org
images: |
name=${{env.DOCKER_HUB_REPO}},enable=${{env.DOCKER_HUB_ENABLED}}
name=ghcr.io/${{ github.repository }}
tags: |
type=ref,event=pr
type=semver,pattern={{version}}
type=raw,value=develop,enable={{is_default_branch}}

View File

@@ -10,3 +10,13 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/.github/workflows"
schedule:
interval: weekly
open-pull-requests-limit: 10

View File

@@ -1,38 +0,0 @@
#####################################################
### Copy platform specific binary
FROM bash as copy-binary
ARG TARGETPLATFORM
RUN echo "Target Platform = ${TARGETPLATFORM}"
COPY dist .
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
RUN if [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then cp navidrome_linux_arm_linux_arm_7/navidrome /navidrome; fi
RUN chmod +x /navidrome
#####################################################
### Build Final Image
FROM alpine as release
LABEL maintainer="deluan@navidrome.org"
# Install ffmpeg and output build config
RUN apk add --no-cache ffmpeg
RUN ffmpeg -buildconf
COPY --from=copy-binary /navidrome /app/
VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER /music
ENV ND_DATAFOLDER /data
ENV ND_PORT 4533
ENV GODEBUG "asyncpreemptoff=1"
EXPOSE ${ND_PORT}
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
WORKDIR /app
ENTRYPOINT ["/app/navidrome"]

View File

@@ -1,4 +1,4 @@
name: 'Pipeline: Test, Lint, Build'
name: "Pipeline: Test, Lint, Build"
on:
push:
branches:
@@ -8,81 +8,114 @@ on:
pull_request:
branches:
- master
concurrency:
group: ${{ startsWith(github.ref, 'refs/tags/v') && 'tag' || 'branch' }}-${{ github.ref }}
cancel-in-progress: true
env:
CROSS_TAGLIB_VERSION: "2.0.2-1"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
jobs:
git-version:
name: Get version info
runs-on: ubuntu-latest
outputs:
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Show git version info
run: |
echo "git describe (dirty): $(git describe --dirty --always --tags)"
echo "git describe --tags: $(git describe --tags `git rev-list --tags --max-count=1`)"
echo "git tag: $(git tag --sort=-committerdate | head -n 1)"
git tag -l
- name: Determine git current SHA and latest tag
id: git-version
run: |
GIT_TAG=$(git tag --sort=-committerdate | head -n 1)
if [ -n "$GIT_TAG" ]; then
GIT_TAG=${GIT_TAG}-SNAPSHOT
echo "GIT_TAG=$GIT_TAG" >> $GITHUB_OUTPUT
fi
GIT_SHA=$(git rev-parse --short HEAD)
PR_NUM=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
if [[ $PR_NUM != "null" ]]; then
GIT_SHA="pr-${PR_NUM}/${GIT_SHA}"
fi
echo "GIT_SHA=$GIT_SHA" >> $GITHUB_OUTPUT
echo "GIT_TAG=$GIT_TAG"
echo "GIT_SHA=$GIT_SHA"
go-lint:
name: Lint Go code
runs-on: ubuntu-latest
steps:
- name: Install taglib
run: sudo apt-get install libtag1-dev
- uses: actions/checkout@v4
- name: Set up Go 1.20
uses: actions/setup-go@v3
- name: Download TagLib
uses: ./.github/actions/download-taglib
with:
go-version: 1.20.x
- uses: actions/checkout@v3
version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v6
with:
version: latest
github-token: ${{ secrets.GITHUB_TOKEN }}
problem-matchers: true
args: --timeout 2m
- name: Install goimports
run: go install golang.org/x/tools/cmd/goimports
- run: goimports -w `find . -name '*.go' | grep -v '_gen.go$'`
- name: Run go goimports
run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$'`
- run: go mod tidy
- name: Verify no changes from goimports and go mod tidy
run: |
git status --porcelain
if [ -n "$(git status --porcelain)" ]; then
echo 'To fix this check, run "goimports -w $(find . -name '*.go' | grep -v '_gen.go$') && go mod tidy"'
echo 'To fix this check, run "make format" and commit the changes'
exit 1
fi
go:
name: Test with Go ${{ matrix.go_version }}
name: Test Go code
runs-on: ubuntu-latest
strategy:
matrix:
go_version: [1.20.x,1.19.x]
steps:
- name: Install taglib
run: sudo apt-get install libtag1-dev
- name: Check out code into the Go module directory
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up Go ${{ matrix.go_version }}
uses: actions/setup-go@v3
- name: Download TagLib
uses: ./.github/actions/download-taglib
with:
go-version: ${{ matrix.go_version }}
cache: true
version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: Download dependencies
if: steps.cache-go.outputs.cache-hit != 'true'
continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}}
run: go mod download
- name: Test
continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}}
run: go test -shuffle=on -race -cover ./... -v
run: |
pkg-config --define-prefix --cflags --libs taglib # for debugging
go test -shuffle=on -tags netgo -race -cover ./... -v
js:
name: Build JS bundle
name: Test JS code
runs-on: ubuntu-latest
env:
NODE_OPTIONS: '--max_old_space_size=4096'
NODE_OPTIONS: "--max_old_space_size=4096"
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: 20
cache: "npm"
cache-dependency-path: "**/package-lock.json"
- name: npm install dependencies
run: |
@@ -104,121 +137,289 @@ jobs:
cd ui
npm run build
- uses: actions/upload-artifact@v3
with:
name: js-bundle
path: ui/build
retention-days: 7
binaries:
name: Build binaries
needs: [js, go, go-lint]
i18n-lint:
name: Lint i18n files
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/checkout@v4
- run: |
set -e
for file in resources/i18n/*.json; do
echo "Validating $file"
if ! jq empty "$file" 2>error.log; then
error_message=$(cat error.log)
line_number=$(echo "$error_message" | grep -oP 'line \K[0-9]+')
echo "::error file=$file,line=$line_number::$error_message"
exit 1
fi
done
- uses: actions/download-artifact@v3
with:
name: js-bundle
path: ui/build
check-push-enabled:
name: Check Docker configuration
runs-on: ubuntu-latest
outputs:
is_enabled: ${{ steps.check.outputs.is_enabled }}
steps:
- name: Check if Docker push is configured
id: check
run: echo "is_enabled=${{ secrets.DOCKER_HUB_USERNAME != '' }}" >> $GITHUB_OUTPUT
- name: Config /github/workspace folder as trusted
uses: docker://deluan/ci-goreleaser:1.20.3-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: /bin/bash -c "git config --global --add safe.directory /github/workspace; git describe --dirty --always --tags"
- name: Run GoReleaser - SNAPSHOT
if: startsWith(github.ref, 'refs/tags/') != true
uses: docker://deluan/ci-goreleaser:1.20.3-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: goreleaser release --rm-dist --skip-publish --snapshot
- name: Run GoReleaser - RELEASE
if: startsWith(github.ref, 'refs/tags/')
uses: docker://deluan/ci-goreleaser:1.20.3-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: goreleaser release --rm-dist
- uses: actions/upload-artifact@v3
with:
name: binaries
path: |
dist
!dist/*.tar.gz
!dist/*.zip
retention-days: 7
docker:
name: Build and publish Docker images
needs: [binaries]
build:
name: Build
needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled]
strategy:
matrix:
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
runs-on: ubuntu-latest
env:
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
IS_ARMV5: ${{ matrix.platform == 'linux/arm/v5' && 'true' || 'false' }}
IS_DOCKER_PUSH_CONFIGURED: ${{ needs.check-push-enabled.outputs.is_enabled == 'true' }}
DOCKER_BUILD_SUMMARY: false
GIT_SHA: ${{ needs.git-version.outputs.git_sha }}
GIT_TAG: ${{ needs.git-version.outputs.git_tag }}
steps:
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
if: env.DOCKER_IMAGE != ''
- name: Sanitize platform name
id: set-platform
run: |
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
if: env.DOCKER_IMAGE != ''
- uses: actions/checkout@v4
- uses: actions/checkout@v3
if: env.DOCKER_IMAGE != ''
- uses: actions/download-artifact@v3
if: env.DOCKER_IMAGE != ''
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
id: docker
with:
name: binaries
path: dist
github_token: ${{ secrets.GITHUB_TOKEN }}
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to Docker Hub
if: env.DOCKER_IMAGE != ''
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.DOCKER_IMAGE != ''
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
if: env.DOCKER_IMAGE != ''
id: meta
uses: docker/metadata-action@v4
with:
labels: |
maintainer=deluan
images: |
name=${{secrets.DOCKER_IMAGE}},enable=${{env.GITHUB_REF_TYPE == 'tag' || github.ref == format('refs/heads/{0}', 'master')}}
name=ghcr.io/${{ github.repository }}
tags: |
type=ref,event=pr
type=semver,pattern={{version}}
type=raw,value=develop,enable={{is_default_branch}}
- name: Build and Push
if: env.DOCKER_IMAGE != ''
uses: docker/build-push-action@v4
- name: Build Binaries
uses: docker/build-push-action@v6
with:
context: .
file: .github/workflows/pipeline.dockerfile
platforms: linux/amd64,linux/386,linux/arm/v6,linux/arm/v7,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
file: Dockerfile
platforms: ${{ matrix.platform }}
outputs: |
type=local,dest=./output/${{ env.PLATFORM }}
target: binary
build-args: |
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
- name: Upload Binaries
uses: actions/upload-artifact@v4
with:
name: navidrome-${{ env.PLATFORM }}
path: ./output
retention-days: 7
- name: Build and push image by digest
id: push-image
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.docker.outputs.labels }}
build-args: |
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
outputs: |
type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }}
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
run: |
mkdir -p /tmp/digests
digest="${{ steps.push-image.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
push-manifest:
name: Push Docker manifest
runs-on: ubuntu-latest
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true'
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@v4
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
id: docker
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Create manifest list and push to ghcr.io
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Create manifest list and push to Docker Hub
working-directory: /tmp/digests
if: vars.DOCKER_HUB_REPO != ''
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ vars.DOCKER_HUB_REPO }}@sha256:%s ' *)
- name: Inspect image in ghcr.io
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
- name: Inspect image in Docker Hub
if: vars.DOCKER_HUB_REPO != ''
run: |
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
- name: Delete unnecessary digest artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
done
msi:
name: Build Windows installers
needs: [build, git-version]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: ./binaries
pattern: navidrome-windows*
merge-multiple: true
- name: Install Wix
run: sudo apt-get install -y wixl jq
- name: Build MSI
env:
GIT_TAG: ${{ needs.git-version.outputs.git_tag }}
run: |
rm -rf binaries/msi
sudo GIT_TAG=$GIT_TAG release/wix/build_msi.sh ${GITHUB_WORKSPACE} 386
sudo GIT_TAG=$GIT_TAG release/wix/build_msi.sh ${GITHUB_WORKSPACE} amd64
du -h binaries/msi/*.msi
- name: Upload MSI files
uses: actions/upload-artifact@v4
with:
name: navidrome-windows-installers
path: binaries/msi/*.msi
retention-days: 7
release:
name: Package/Release
needs: [build, msi]
runs-on: ubuntu-latest
outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/download-artifact@v4
with:
path: ./binaries
pattern: navidrome-*
merge-multiple: true
- run: ls -lR ./binaries
- name: Set RELEASE_FLAGS for snapshot releases
if: env.IS_RELEASE == 'false'
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: '~> v2'
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Remove build artifacts
run: |
ls -l ./dist
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
uses: actions/upload-artifact@v4
with:
name: packages
path: dist/navidrome_0*
- id: set-package-list
name: Export list of generated packages
run: |
cd dist
set +x
ITEMS=$(ls navidrome_0* | sed 's/^navidrome_0[^_]*_linux_//' | jq -R -s -c 'split("\n")[:-1]')
echo $ITEMS
echo "package_list=${ITEMS}" >> $GITHUB_OUTPUT
upload-packages:
name: Upload Linux PKG
runs-on: ubuntu-latest
needs: [release]
strategy:
matrix:
item: ${{ fromJson(needs.release.outputs.package_list) }}
steps:
- name: Download all-packages artifact
uses: actions/download-artifact@v4
with:
name: packages
path: ./dist
- name: Upload all-packages artifact
uses: actions/upload-artifact@v4
with:
name: navidrome_linux_${{ matrix.item }}
path: dist/navidrome_0*_linux_${{ matrix.item }}
# delete-artifacts:
# name: Delete unused artifacts
# runs-on: ubuntu-latest
# needs: [upload-packages]
# steps:
# - name: Delete all-packages artifact
# env:
# GH_TOKEN: ${{ github.token }}
# run: |
# for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("packages")) | .id'); do
# gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
# done

View File

@@ -12,8 +12,9 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v4.0.0
- uses: dessant/lock-threads@v5
with:
process-only: 'issues, prs'
issue-inactive-days: 120
pr-inactive-days: 120
log-output: true
@@ -27,7 +28,7 @@ jobs:
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
- uses: actions/stale@v7
- uses: actions/stale@v9
with:
operations-per-run: 999
days-before-issue-stale: 180

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

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

View File

@@ -8,21 +8,26 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Get updated translations
id: poeditor
env:
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
run: |
./update-translations.sh
.github/workflows/update-translations.sh 2> title.tmp
title=$(cat title.tmp)
echo "::set-output name=title::$title"
rm title.tmp
- name: Show changes, if any
run: |
git status --porcelain
git diff
- name: Create Pull Request
uses: peter-evans/create-pull-request@v4
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PAT }}
commit-message: Update translations
title: Update translations from POEditor
author: "navidrome-bot <navidrome-bot@navidrome.org>"
commit-message: "fix(ui): update translations from POEditor"
title: "fix(ui): update ${{ steps.poeditor.outputs.title }} translations from POEditor"
branch: update-translations

12
.gitignore vendored
View File

@@ -11,17 +11,17 @@ wiki
TODO.md
var
navidrome.toml
!release/linux/navidrome.toml
master.zip
testDB
navidrome.db
cache/*
*.swp
embedded_gen.go
dist
music
navidrome.db-shm
navidrome.db-wal
tags
*.db*
.gitinfo
docker-compose.yml
!contrib/docker-compose.yml
!contrib/docker-compose.yml
binaries
taglib
navidrome-master

View File

@@ -1,5 +1,6 @@
run:
go: "1.19"
build-tags:
- netgo
linters:
enable:
@@ -7,11 +8,11 @@ linters:
- asciicheck
- bidichk
- bodyclose
- copyloopvar
- dogsled
- durationcheck
- errcheck
- errorlint
- exportloopref
- gocyclo
- goprintffuncname
- gosec
@@ -28,8 +29,13 @@ linters:
- unused
- whitespace
issues:
exclude-rules:
- linters:
- gosec
text: "(G501|G401|G505):"
linters-settings:
govet:
enable:
- nilness
gosec:
excludes:
- G501
- G401
- G505
- G115 # Can't check context, where the warning is clearly a false positive. See discussion in https://github.com/securego/gosec/pull/1149

View File

@@ -1,139 +0,0 @@
# GoReleaser config
project_name: navidrome
builds:
- id: navidrome_linux_amd64
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static -lz'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_386
env:
- CGO_ENABLED=1
- PKG_CONFIG_PATH=/i386/lib/pkgconfig
goos:
- linux
goarch:
- "386"
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_arm
env:
- CGO_ENABLED=1
- CC=arm-linux-gnueabi-gcc
- CXX=arm-linux-gnueabi-g++
- PKG_CONFIG_PATH=/arm/lib/pkgconfig
goos:
- linux
goarch:
- arm
goarm:
- "5"
- "6"
- "7"
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_arm64
env:
- CGO_ENABLED=1
- CC=aarch64-linux-gnu-gcc
- CXX=aarch64-linux-gnu-g++
- PKG_CONFIG_PATH=/arm64/lib/pkgconfig
goos:
- linux
goarch:
- arm64
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_windows_386
env:
- CGO_ENABLED=1
- CC=i686-w64-mingw32-gcc
- CXX=i686-w64-mingw32-g++
- PKG_CONFIG_PATH=/mingw32/lib/pkgconfig
goos:
- windows
goarch:
- "386"
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_windows_amd64
env:
- CGO_ENABLED=1
- CC=x86_64-w64-mingw32-gcc
- CXX=x86_64-w64-mingw32-g++
- PKG_CONFIG_PATH=/mingw64/lib/pkgconfig
goos:
- windows
goarch:
- amd64
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_darwin_amd64
env:
- CGO_ENABLED=1
- CC=o64-clang
- CXX=o64-clang++
- PKG_CONFIG_PATH=/darwin/lib/pkgconfig
goos:
- darwin
goarch:
- amd64
flags:
- -tags=netgo
ldflags:
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
archives:
- format_overrides:
- goos: windows
format: zip
replacements:
darwin: macOS
linux: Linux
windows: Windows
386: i386
amd64: x86_64
checksum:
name_template: "{{ .ProjectName }}_checksums.txt"
snapshot:
name_template: "{{ .Tag }}-SNAPSHOT"
release:
draft: true
changelog:
# sort: asc
filters:
exclude:
- "^docs:"

2
.nvmrc
View File

@@ -1 +1 @@
v18
v20

View File

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

144
Dockerfile Normal file
View File

@@ -0,0 +1,144 @@
FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcross
########################################################################################################################
### Build xx (orignal image: tonistiigi/xx)
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.21 AS xx-build
# v1.5.0
ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a
RUN apk add -U --no-cache git
RUN git clone https://github.com/tonistiigi/xx && \
cd xx && \
git checkout ${XX_VERSION} && \
mkdir -p /out && \
cp src/xx-* /out/
RUN cd /out && \
ln -s xx-cc /out/xx-clang && \
ln -s xx-cc /out/xx-clang++ && \
ln -s xx-cc /out/xx-c++ && \
ln -s xx-apt /out/xx-apt-get
# xx mimics the original tonistiigi/xx image
FROM scratch AS xx
COPY --from=xx-build /out/ /usr/bin/
########################################################################################################################
### Get TagLib
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.21 AS taglib-build
ARG TARGETPLATFORM
ARG CROSS_TAGLIB_VERSION=2.0.2-1
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
RUN <<EOT
PLATFORM=$(echo ${TARGETPLATFORM} | tr '/' '-')
FILE=taglib-${PLATFORM}.tar.gz
DOWNLOAD_URL=${CROSS_TAGLIB_RELEASES_URL}${FILE}
wget ${DOWNLOAD_URL}
mkdir /taglib
tar -xzf ${FILE} -C /taglib
EOT
########################################################################################################################
### Build Navidrome UI
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
WORKDIR /app
# Install node dependencies
COPY ui/package.json ui/package-lock.json ./
COPY ui/bin/ ./bin/
RUN npm ci
# Build bundle
COPY ui/ ./
RUN npm run build -- --outDir=/build
FROM scratch AS ui-bundle
COPY --from=ui /build /build
########################################################################################################################
### Build Navidrome binary
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.23-bookworm AS base
RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / /
WORKDIR /workspace
FROM --platform=$BUILDPLATFORM base AS build
# Install build dependencies for the target platform
ARG TARGETPLATFORM
ARG GIT_SHA
ARG GIT_TAG
RUN xx-apt install -y binutils gcc g++ libc6-dev zlib1g-dev
RUN xx-verify --setup
RUN --mount=type=bind,source=. \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod \
go mod download
RUN --mount=type=bind,source=. \
--mount=from=ui,source=/build,target=./ui/build,ro \
--mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod \
--mount=from=taglib-build,target=/taglib,src=/taglib,ro <<EOT
# Setup CGO cross-compilation environment
xx-go --wrap
export CGO_ENABLED=1
export PKG_CONFIG_PATH=/taglib/lib/pkgconfig
cat $(go env GOENV)
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
# So let's use gcc for everything except Darwin.
if [ "$(xx-info os)" != "darwin" ]; then
export CC=$(xx-info)-gcc
export CXX=$(xx-info)-g++
export LD_EXTRA="-extldflags '-static -latomic'"
fi
if [ "$(xx-info os)" = "windows" ]; then
export EXT=".exe"
fi
go build -tags=netgo -ldflags="${LD_EXTRA} -w -s \
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
-o /out/navidrome${EXT} .
EOT
# Verify if the binary was built for the correct platform and it is statically linked
RUN xx-verify --static /out/navidrome*
FROM scratch AS binary
COPY --from=build /out /
########################################################################################################################
### Build Final Image
FROM public.ecr.aws/docker/library/alpine:3.21 AS final
LABEL maintainer="deluan@navidrome.org"
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
# Install ffmpeg and mpv
RUN apk add -U --no-cache ffmpeg mpv
# Copy navidrome binary
COPY --from=build /out/navidrome /app/
VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER=/music
ENV ND_DATAFOLDER=/data
ENV ND_PORT=4533
ENV GODEBUG="asyncpreemptoff=1"
RUN touch /.nddockerenv
EXPOSE ${ND_PORT}
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
WORKDIR /app
ENTRYPOINT ["/app/navidrome"]

128
Makefile
View File

@@ -3,13 +3,21 @@ NODE_VERSION=$(shell cat .nvmrc)
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`)
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT
else
GIT_SHA=source_archive
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
endif
CI_RELEASER_VERSION=1.20.3-1 ## https://github.com/navidrome/ci-goreleaser
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,darwin/amd64,darwin/arm64,windows/amd64,windows/386
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.0.2-1
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
@echo Downloading Node dependencies...
@@ -17,23 +25,23 @@ setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and
.PHONY: setup
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
npx foreman -j Procfile.dev -p 4533 start
ND_ENABLEINSIGHTSCOLLECTOR="false" npx foreman -j Procfile.dev -p 4533 start
.PHONY: dev
server: check_go_env ##@Development Start the backend in development mode
@go run github.com/cespare/reflex@latest -d none -c reflex.conf
server: check_go_env buildjs ##@Development Start the backend in development mode
@ND_ENABLEINSIGHTSCOLLECTOR="false" go run github.com/cespare/reflex@latest -d none -c reflex.conf
.PHONY: server
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
go run github.com/onsi/ginkgo/v2/ginkgo@latest watch -notify ./...
go run github.com/onsi/ginkgo/v2/ginkgo@latest watch -tags netgo -notify ./...
.PHONY: watch
test: ##@Development Run Go tests
go test -race -shuffle=on ./...
go test -tags netgo -race -shuffle=on ./...
.PHONY: test
testall: test ##@Development Run Go and JS tests
@(cd ./ui && npm test -- --watchAll=false)
@(cd ./ui && npm run test:ci)
.PHONY: testall
lint: ##@Development Lint Go code
@@ -45,8 +53,14 @@ lintall: lint ##@Development Lint Go and JS code
@(cd ./ui && npm run lint)
.PHONY: lintall
format: ##@Development Format code
@(cd ./ui && npm run prettier)
@go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v _gen.go$$`
@go mod tidy
.PHONY: format
wire: check_go_env ##@Development Update Dependency Injection
go run github.com/google/wire/cmd/wire@latest ./...
go run github.com/google/wire/cmd/wire@latest gen -tags=netgo ./...
.PHONY: wire
snapshots: ##@Development Update (GoLang) Snapshot tests
@@ -55,12 +69,12 @@ snapshots: ##@Development Update (GoLang) Snapshot tests
migration-sql: ##@Development Create an empty SQL migration file
@if [ -z "${name}" ]; then echo "Usage: make migration-sql name=name_of_migration_file"; exit 1; fi
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migration create ${name} sql
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migrations create ${name} sql
.PHONY: migration
migration-go: ##@Development Create an empty Go migration file
@if [ -z "${name}" ]; then echo "Usage: make migration-go name=name_of_migration_file"; exit 1; fi
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migration create ${name}
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migrations create ${name}
.PHONY: migration
setup-dev: setup
@@ -72,37 +86,68 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
@(cd .git/hooks && ln -sf ../../git/* .)
.PHONY: setup-git
buildall: buildjs build ##@Build Build the project, both frontend and backend
.PHONY: buildall
build: warning-noui-build check_go_env ##@Build Build only backend
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
build: check_go_env buildjs ##@Build Build the project
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
.PHONY: build
buildjs: check_node_env ##@Build Build only frontend
@(cd ./ui && npm run build)
buildall: deprecated build
.PHONY: buildall
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
.PHONY: debug-build
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
.PHONY: buildjs
all: warning-noui-build ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
goreleaser release --rm-dist --skip-publish --snapshot
.PHONY: all
docker-buildjs: ##@Build Build only frontend using Docker
docker build --output "./ui" --target ui-bundle .
.PHONY: docker-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:"; \
grep -- "- id: navidrome_" .goreleaser.yml | sed 's/- id: navidrome_//g'; \
exit 1; \
fi
@echo "Building binaries for ${GOOS}/${GOARCH}"
docker run -t -v $(PWD):/workspace -e GOOS -e GOARCH -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
goreleaser build --rm-dist --snapshot --single-target --id navidrome_${GOOS}_${GOARCH}
.PHONY: single
ui/build/index.html: $(UI_SRC_FILES)
@(cd ./ui && npm run build)
warning-noui-build:
@echo "WARNING: This command does not build the frontend, it uses the latest built with 'make buildjs'"
.PHONY: warning-noui-build
docker-platforms: ##@Cross_Compilation List supported platforms
@echo "Supported platforms:"
@echo "$(SUPPORTED_PLATFORMS)" | tr ',' '\n' | sort | sed 's/^/ /'
@echo "\nUsage: make PLATFORMS=\"linux/amd64\" docker-build"
@echo " make IMAGE_PLATFORMS=\"linux/amd64\" docker-image"
.PHONY: docker-platforms
docker-build: ##@Cross_Compilation Cross-compile for any supported platform (check `make docker-platforms`)
docker buildx build \
--platform $(PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
--output "./binaries" --target binary .
.PHONY: docker-build
docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
@echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true
@echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true
@echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true
docker buildx build \
--platform $(IMAGE_PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
--tag $(DOCKER_TAG) .
.PHONY: docker-image
docker-msi: ##@Cross_Compilation Build MSI installer for Windows
make docker-build PLATFORMS=windows/386,windows/amd64
DOCKER_CLI_HINTS=false docker build -q -t navidrome-msi-builder -f release/wix/msitools.dockerfile .
@rm -rf binaries/msi
docker run -it --rm -v $(PWD):/workspace -v $(PWD)/binaries:/workspace/binaries -e GIT_TAG=${GIT_TAG} \
navidrome-msi-builder sh -c "release/wix/build_msi.sh /workspace 386 && release/wix/build_msi.sh /workspace amd64"
@du -h binaries/msi/*.msi
.PHONY: docker-msi
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
goreleaser release -f release/goreleaser.yml --clean --skip=publish --snapshot
.PHONY: package
get-music: ##@Development Download some free music from Navidrome's demo instance
mkdir -p music
@@ -119,6 +164,11 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
##########################################
#### Miscellaneous
clean:
@rm -rf ./binaries ./dist ./ui/build/*
@touch ./ui/build/.gitkeep
.PHONY: clean
release:
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
go mod tidy
@@ -158,6 +208,10 @@ check_node_env:
pre-push: lintall testall
.PHONY: pre-push
deprecated:
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
.PHONY: deprecated
.DEFAULT_GOAL := help
HELP_FUN = \

View File

@@ -9,6 +9,7 @@
[![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF)
[![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md)
[![Gurubase](https://img.shields.io/badge/Gurubase-Ask%20Navidrome%20Guru-006BFF?style=flat-square)](https://gurubase.io/g/navidrome)
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
music collection from any browser or mobile device. It's like your personal Spotify!
@@ -56,6 +57,15 @@ A share of the revenue helps fund the development of Navidrome at no additional
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
- Translated to **various languages**
## Translations
Navidrome uses [POEditor](https://poeditor.com/) for translations, and we are always looking
for [more contributors](https://www.navidrome.org/docs/developers/translations/)
<a href="https://poeditor.com/">
<img height="32" src="https://github.com/user-attachments/assets/c19b1d2b-01e1-4682-a007-12356c42147c">
</a>
## Documentation
All documentation can be found in the project's website: https://www.navidrome.org/docs.
Here are some useful direct links:

186
cmd/backup.go Normal file
View File

@@ -0,0 +1,186 @@
package cmd
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/spf13/cobra"
)
var (
backupCount int
backupDir string
force bool
restorePath string
)
func init() {
rootCmd.AddCommand(backupRoot)
backupCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory to manually make backup")
backupRoot.AddCommand(backupCmd)
pruneCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory holding Navidrome backups")
pruneCmd.Flags().IntVarP(&backupCount, "keep-count", "k", -1, "specify the number of backups to keep. 0 remove ALL backups, and negative values mean to use the default from configuration")
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
backupRoot.AddCommand(pruneCmd)
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
_ = restoreCommand.MarkFlagRequired("backup-path")
backupRoot.AddCommand(restoreCommand)
}
var (
backupRoot = &cobra.Command{
Use: "backup",
Aliases: []string{"bkp"},
Short: "Create, restore and prune database backups",
Long: "Create, restore and prune database backups",
}
backupCmd = &cobra.Command{
Use: "create",
Short: "Create a backup database",
Long: "Manually backup Navidrome database. This will ignore BackupCount",
Run: func(cmd *cobra.Command, _ []string) {
runBackup(cmd.Context())
},
}
pruneCmd = &cobra.Command{
Use: "prune",
Short: "Prune database backups",
Long: "Manually prune database backups according to backup rules",
Run: func(cmd *cobra.Command, _ []string) {
runPrune(cmd.Context())
},
}
restoreCommand = &cobra.Command{
Use: "restore",
Short: "Restore Navidrome database",
Long: "Restore Navidrome database from a backup. This must be done offline",
Run: func(cmd *cobra.Command, _ []string) {
runRestore(cmd.Context())
},
}
)
func runBackup(ctx context.Context) {
if backupDir != "" {
conf.Server.Backup.Path = backupDir
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
start := time.Now()
path, err := db.Backup(ctx)
if err != nil {
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
log.Info("Backup complete", "elapsed", elapsed, "path", path)
}
func runPrune(ctx context.Context) {
if backupDir != "" {
conf.Server.Backup.Path = backupDir
}
if backupCount != -1 {
conf.Server.Backup.Count = backupCount
}
if conf.Server.Backup.Count == 0 && !force {
fmt.Println("Warning: pruning ALL backups")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Restore cancelled")
return
}
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
start := time.Now()
count, err := db.Prune(ctx)
if err != nil {
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
log.Info("Prune complete", "elapsed", elapsed, "successfully pruned", count)
}
func runRestore(ctx context.Context) {
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
if !force {
fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Restore cancelled")
return
}
}
start := time.Now()
err := db.Restore(ctx, restorePath)
if err != nil {
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
}
elapsed := time.Since(start)
log.Info("Restore complete", "elapsed", elapsed)
}

99
cmd/inspect.go Normal file
View File

@@ -0,0 +1,99 @@
package cmd
import (
"encoding/json"
"fmt"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/scanner/metadata"
"github.com/navidrome/navidrome/tests"
"github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
var (
extractor string
format string
)
func init() {
inspectCmd.Flags().StringVarP(&extractor, "extractor", "x", "", "extractor to use (ffmpeg or taglib, default: auto)")
inspectCmd.Flags().StringVarP(&format, "format", "f", "pretty", "output format (pretty, toml, yaml, json, jsonindent)")
rootCmd.AddCommand(inspectCmd)
}
var inspectCmd = &cobra.Command{
Use: "inspect [files to inspect]",
Short: "Inspect tags",
Long: "Show file tags as seen by Navidrome",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runInspector(args)
},
}
var marshalers = map[string]func(interface{}) ([]byte, error){
"pretty": prettyMarshal,
"toml": toml.Marshal,
"yaml": yaml.Marshal,
"json": json.Marshal,
"jsonindent": func(v interface{}) ([]byte, error) {
return json.MarshalIndent(v, "", " ")
},
}
func prettyMarshal(v interface{}) ([]byte, error) {
out := v.([]inspectorOutput)
var res strings.Builder
for i := range out {
res.WriteString(fmt.Sprintf("====================\nFile: %s\n\n", out[i].File))
t, _ := toml.Marshal(out[i].RawTags)
res.WriteString(fmt.Sprintf("Raw tags:\n%s\n\n", t))
t, _ = toml.Marshal(out[i].MappedTags)
res.WriteString(fmt.Sprintf("Mapped tags:\n%s\n\n", t))
}
return []byte(res.String()), nil
}
type inspectorOutput struct {
File string
RawTags metadata.ParsedTags
MappedTags model.MediaFile
}
func runInspector(args []string) {
if extractor != "" {
conf.Server.Scanner.Extractor = extractor
}
log.Info("Using extractor", "extractor", conf.Server.Scanner.Extractor)
md, err := metadata.Extract(args...)
if err != nil {
log.Fatal("Error extracting tags", err)
}
mapper := scanner.NewMediaFileMapper(conf.Server.MusicFolder, &tests.MockedGenreRepo{})
marshal := marshalers[format]
if marshal == nil {
log.Fatal("Invalid format", "format", format)
}
var out []inspectorOutput
for k, v := range md {
if !model.IsAudioFile(k) {
continue
}
if len(v.Tags) == 0 {
continue
}
out = append(out, inspectorOutput{
File: k,
RawTags: v.Tags,
MappedTags: mapper.ToMediaFile(v),
})
}
data, _ := marshal(out)
fmt.Println(string(data))
}

View File

@@ -2,30 +2,27 @@ package cmd
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/scheduler"
"github.com/navidrome/navidrome/server/backgrounds"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/sync/errgroup"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/sync/errgroup"
)
var interrupted = errors.New("service was interrupted")
var (
cfgFile string
noBanner bool
@@ -39,17 +36,20 @@ Complete documentation is available at https://www.navidrome.org/docs`,
preRun()
},
Run: func(cmd *cobra.Command, args []string) {
runNavidrome()
runNavidrome(cmd.Context())
},
PostRun: func(cmd *cobra.Command, args []string) {
postRun()
},
Version: consts.Version,
}
)
// Execute runs the root cobra command, which will start the Navidrome server by calling the runNavidrome function.
func Execute() {
rootCmd.SetVersionTemplate(`{{println .Version}}`)
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
log.Fatal(err)
}
}
@@ -60,26 +60,44 @@ func preRun() {
conf.Load()
}
func runNavidrome() {
db.Init()
defer func() {
if err := db.Close(); err != nil {
log.Error("Error closing DB", err)
}
log.Info("Navidrome stopped, bye.")
}()
func postRun() {
log.Info("Navidrome stopped, bye.")
}
g, ctx := errgroup.WithContext(context.Background())
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
// it will cancel the context and exit gracefully.
func runNavidrome(ctx context.Context) {
defer db.Init()()
ctx, cancel := mainContext(ctx)
defer cancel()
g, ctx := errgroup.WithContext(ctx)
g.Go(startServer(ctx))
g.Go(startSignaler(ctx))
g.Go(startSignaller(ctx))
g.Go(startScheduler(ctx))
g.Go(startPlaybackServer(ctx))
g.Go(schedulePeriodicScan(ctx))
g.Go(schedulePeriodicBackup(ctx))
g.Go(startInsightsCollector(ctx))
if err := g.Wait(); err != nil && !errors.Is(err, interrupted) {
if err := g.Wait(); err != nil {
log.Error("Fatal error in Navidrome. Aborting", err)
}
}
// mainContext returns a context that is cancelled when the process receives a signal to exit.
func mainContext(ctx context.Context) (context.Context, context.CancelFunc) {
return signal.NotifyContext(ctx,
os.Interrupt,
syscall.SIGHUP,
syscall.SIGTERM,
syscall.SIGABRT,
)
}
// startServer starts the Navidrome web server, adding all the necessary routers.
func startServer(ctx context.Context) func() error {
return func() error {
a := CreateServer(conf.Server.MusicFolder)
@@ -94,19 +112,20 @@ func startServer(ctx context.Context) func() error {
}
if conf.Server.Prometheus.Enabled {
// blocking call because takes <1ms but useful if fails
core.WriteInitialMetrics()
metrics.WriteInitialMetrics()
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, promhttp.Handler())
}
if conf.Server.DevEnableProfiler {
a.MountRouter("Profiling", "/debug", middleware.Profiler())
}
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
a.MountRouter("Background images", consts.DefaultUILoginBackgroundURL, backgrounds.NewHandler())
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
}
return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey)
}
}
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
func schedulePeriodicScan(ctx context.Context) func() error {
return func() error {
schedule := conf.Server.ScanSchedule
@@ -136,16 +155,80 @@ func schedulePeriodicScan(ctx context.Context) func() error {
}
}
func startScheduler(ctx context.Context) func() error {
log.Info(ctx, "Starting scheduler")
schedulerInstance := scheduler.GetInstance()
func schedulePeriodicBackup(ctx context.Context) func() error {
return func() error {
schedule := conf.Server.Backup.Schedule
if schedule == "" {
log.Warn("Periodic backup is DISABLED")
return nil
}
schedulerInstance := scheduler.GetInstance()
log.Info("Scheduling periodic backup", "schedule", schedule)
err := schedulerInstance.Add(schedule, func() {
start := time.Now()
path, err := db.Backup(ctx)
elapsed := time.Since(start)
if err != nil {
log.Error(ctx, "Error backing up database", "elapsed", elapsed, err)
return
}
log.Info(ctx, "Backup complete", "elapsed", elapsed, "path", path)
count, err := db.Prune(ctx)
if err != nil {
log.Error(ctx, "Error pruning database", "error", err)
} else if count > 0 {
log.Info(ctx, "Successfully pruned old files", "count", count)
} else {
log.Info(ctx, "No backups pruned")
}
})
return err
}
}
// startScheduler starts the Navidrome scheduler, which is used to run periodic tasks.
func startScheduler(ctx context.Context) func() error {
return func() error {
log.Info(ctx, "Starting scheduler")
schedulerInstance := scheduler.GetInstance()
schedulerInstance.Run(ctx)
return nil
}
}
// startInsightsCollector starts the Navidrome Insight Collector, if configured.
func startInsightsCollector(ctx context.Context) func() error {
return func() error {
if !conf.Server.EnableInsightsCollector {
log.Info(ctx, "Insight Collector is DISABLED")
return nil
}
log.Info(ctx, "Starting Insight Collector")
time.Sleep(conf.Server.DevInsightsInitialDelay)
ic := CreateInsights()
ic.Run(ctx)
return nil
}
}
// startPlaybackServer starts the Navidrome playback server, if configured.
// It is responsible for the Jukebox functionality
func startPlaybackServer(ctx context.Context) func() error {
return func() error {
if !conf.Server.Jukebox.Enabled {
log.Debug("Jukebox is DISABLED")
return nil
}
log.Info(ctx, "Starting Jukebox service")
playbackInstance := GetPlaybackServer()
return playbackInstance.Run(ctx)
}
}
// TODO: Implement some struct tags to map flags to viper
func init() {
cobra.OnInitialize(func() {
@@ -158,16 +241,19 @@ func init() {
rootCmd.PersistentFlags().String("datafolder", viper.GetString("datafolder"), "folder to store application data (DB), needs write access")
rootCmd.PersistentFlags().String("cachefolder", viper.GetString("cachefolder"), "folder to store cache data (transcoding, images...), needs write access")
rootCmd.PersistentFlags().StringP("loglevel", "l", viper.GetString("loglevel"), "log level, possible values: error, info, debug, trace")
rootCmd.PersistentFlags().String("logfile", viper.GetString("logfile"), "log file path, if not set logs will be printed to stderr")
_ = viper.BindPFlag("musicfolder", rootCmd.PersistentFlags().Lookup("musicfolder"))
_ = viper.BindPFlag("datafolder", rootCmd.PersistentFlags().Lookup("datafolder"))
_ = viper.BindPFlag("cachefolder", rootCmd.PersistentFlags().Lookup("cachefolder"))
_ = viper.BindPFlag("loglevel", rootCmd.PersistentFlags().Lookup("loglevel"))
_ = viper.BindPFlag("logfile", rootCmd.PersistentFlags().Lookup("logfile"))
rootCmd.Flags().StringP("address", "a", viper.GetString("address"), "IP address to bind to")
rootCmd.Flags().IntP("port", "p", viper.GetInt("port"), "HTTP port Navidrome will listen to")
rootCmd.Flags().String("baseurl", viper.GetString("baseurl"), "base URL to configure Navidrome behind a proxy (ex: /music or http://my.server.com)")
rootCmd.Flags().String("tlscert", viper.GetString("tlscert"), "optional path to a TLS cert file (enables HTTPS listening)")
rootCmd.Flags().String("unixsocketperm", viper.GetString("unixsocketperm"), "optional file permission for the unix socket")
rootCmd.Flags().String("tlskey", viper.GetString("tlskey"), "optional path to a TLS key file (enables HTTPS listening)")
rootCmd.Flags().Duration("sessiontimeout", viper.GetDuration("sessiontimeout"), "how long Navidrome will wait before closing web ui idle sessions")
@@ -176,6 +262,7 @@ func init() {
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
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`")
@@ -184,6 +271,7 @@ func init() {
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
_ = viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))
_ = viper.BindPFlag("tlscert", rootCmd.Flags().Lookup("tlscert"))
_ = viper.BindPFlag("unixsocketperm", rootCmd.Flags().Lookup("unixsocketperm"))
_ = viper.BindPFlag("tlskey", rootCmd.Flags().Lookup("tlskey"))
_ = viper.BindPFlag("baseurl", rootCmd.Flags().Lookup("baseurl"))

View File

@@ -1,27 +0,0 @@
//go:build windows || plan9
package cmd
import (
"context"
"os"
"os/signal"
"github.com/navidrome/navidrome/log"
)
func startSignaler(ctx context.Context) func() error {
log.Info(ctx, "Starting signaler")
return func() error {
var sigChan = make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt)
select {
case sig := <-sigChan:
log.Info(ctx, "Received termination signal", "signal", sig)
return interrupted
case <-ctx.Done():
return nil
}
}
}

14
cmd/signaller_nounix.go Normal file
View File

@@ -0,0 +1,14 @@
//go:build windows || plan9
package cmd
import (
"context"
)
// Windows and Plan9 don't support SIGUSR1, so we don't need to start a signaler
func startSignaller(ctx context.Context) func() error {
return func() error {
return nil
}
}

View File

@@ -14,28 +14,17 @@ import (
const triggerScanSignal = syscall.SIGUSR1
func startSignaler(ctx context.Context) func() error {
func startSignaller(ctx context.Context) func() error {
log.Info(ctx, "Starting signaler")
scanner := GetScanner()
return func() error {
var sigChan = make(chan os.Signal, 1)
signal.Notify(
sigChan,
os.Interrupt,
triggerScanSignal,
syscall.SIGHUP,
syscall.SIGTERM,
syscall.SIGABRT,
)
signal.Notify(sigChan, triggerScanSignal)
for {
select {
case sig := <-sigChan:
if sig != triggerScanSignal {
log.Info(ctx, "Received termination signal", "signal", sig)
return interrupted
}
log.Info(ctx, "Received signal, triggering a new scan", "signal", sig)
start := time.Now()
err := scanner.RescanAll(ctx, false)

267
cmd/svc.go Normal file
View File

@@ -0,0 +1,267 @@
package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/kardianos/service"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/spf13/cobra"
)
var (
svcStatusLabels = map[service.Status]string{
service.StatusUnknown: "Unknown",
service.StatusStopped: "Stopped",
service.StatusRunning: "Running",
}
installUser string
workingDirectory string
)
func init() {
svcCmd.AddCommand(buildInstallCmd())
svcCmd.AddCommand(buildUninstallCmd())
svcCmd.AddCommand(buildStartCmd())
svcCmd.AddCommand(buildStopCmd())
svcCmd.AddCommand(buildStatusCmd())
svcCmd.AddCommand(buildExecuteCmd())
rootCmd.AddCommand(svcCmd)
}
var svcCmd = &cobra.Command{
Use: "service",
Aliases: []string{"svc"},
Short: "Manage Navidrome as a service",
Long: fmt.Sprintf("Manage Navidrome as a service, using the OS service manager (%s)", service.Platform()),
Run: runServiceCmd,
}
type svcControl struct {
ctx context.Context
cancel context.CancelFunc
done chan struct{}
}
func (p *svcControl) Start(service.Service) error {
p.done = make(chan struct{})
p.ctx, p.cancel = context.WithCancel(context.Background())
go func() {
runNavidrome(p.ctx)
close(p.done)
}()
return nil
}
func (p *svcControl) Stop(service.Service) error {
log.Info("Stopping service")
p.cancel()
select {
case <-p.done:
log.Info("Service stopped gracefully")
case <-time.After(10 * time.Second):
log.Error("Service did not stop in time. Killing it.")
}
return nil
}
var svcInstance = sync.OnceValue(func() service.Service {
options := make(service.KeyValue)
options["Restart"] = "on-failure"
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
options["UserService"] = false
options["LogDirectory"] = conf.Server.DataFolder
options["SystemdScript"] = systemdScript
if conf.Server.LogFile != "" {
options["LogOutput"] = false
} else {
options["LogOutput"] = true
options["LogDirectory"] = conf.Server.DataFolder
}
svcConfig := &service.Config{
UserName: installUser,
Name: "navidrome",
DisplayName: "Navidrome",
Description: "Your Personal Streaming Service",
Dependencies: []string{
"After=remote-fs.target network.target",
},
WorkingDirectory: executablePath(),
Option: options,
}
arguments := []string{"service", "execute"}
if conf.Server.ConfigFile != "" {
arguments = append(arguments, "-c", conf.Server.ConfigFile)
}
svcConfig.Arguments = arguments
prg := &svcControl{}
svc, err := service.New(prg, svcConfig)
if err != nil {
log.Fatal(err)
}
return svc
})
func runServiceCmd(cmd *cobra.Command, _ []string) {
_ = cmd.Help()
}
func executablePath() string {
if workingDirectory != "" {
return workingDirectory
}
ex, err := os.Executable()
if err != nil {
log.Fatal(err)
}
return filepath.Dir(ex)
}
func buildInstallCmd() *cobra.Command {
runInstallCmd := func(_ *cobra.Command, _ []string) {
var err error
println("Installing service with:")
println(" working directory: " + executablePath())
println(" music folder: " + conf.Server.MusicFolder)
println(" data folder: " + conf.Server.DataFolder)
if conf.Server.LogFile != "" {
println(" log file: " + conf.Server.LogFile)
} else {
println(" logs folder: " + conf.Server.DataFolder)
}
if cfgFile != "" {
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
if err != nil {
log.Fatal(err)
}
println(" config file: " + conf.Server.ConfigFile)
}
err = svcInstance().Install()
if err != nil {
log.Fatal(err)
}
println("Service installed. Use 'navidrome svc start' to start it.")
}
cmd := &cobra.Command{
Use: "install",
Short: "Install Navidrome service.",
Run: runInstallCmd,
}
cmd.Flags().StringVarP(&installUser, "user", "u", "", "user to run service")
cmd.Flags().StringVarP(&workingDirectory, "working-directory", "w", "", "working directory of service")
return cmd
}
func buildUninstallCmd() *cobra.Command {
return &cobra.Command{
Use: "uninstall",
Short: "Uninstall Navidrome service. Does not delete the music or data folders",
Run: func(cmd *cobra.Command, args []string) {
err := svcInstance().Uninstall()
if err != nil {
log.Fatal(err)
}
println("Service uninstalled. Music and data folders are still intact.")
},
}
}
func buildStartCmd() *cobra.Command {
return &cobra.Command{
Use: "start",
Short: "Start Navidrome service",
Run: func(cmd *cobra.Command, args []string) {
err := svcInstance().Start()
if err != nil {
log.Fatal(err)
}
println("Service started. Use 'navidrome svc status' to check its status.")
},
}
}
func buildStopCmd() *cobra.Command {
return &cobra.Command{
Use: "stop",
Short: "Stop Navidrome service",
Run: func(cmd *cobra.Command, args []string) {
err := svcInstance().Stop()
if err != nil {
log.Fatal(err)
}
println("Service stopped. Use 'navidrome svc status' to check its status.")
},
}
}
func buildStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show Navidrome service status",
Run: func(cmd *cobra.Command, args []string) {
status, err := svcInstance().Status()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Navidrome is %s.\n", svcStatusLabels[status])
},
}
}
func buildExecuteCmd() *cobra.Command {
return &cobra.Command{
Use: "execute",
Short: "Run navidrome as a service in the foreground (it is very unlikely you want to run this, you are better off running just navidrome)",
Run: func(cmd *cobra.Command, args []string) {
err := svcInstance().Run()
if err != nil {
log.Fatal(err)
}
},
}
}
const systemdScript = `[Unit]
Description={{.Description}}
ConditionFileIsExecutable={{.Path|cmdEscape}}
{{range $i, $dep := .Dependencies}}
{{$dep}} {{end}}
[Service]
StartLimitInterval=5
StartLimitBurst=10
ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
{{if .WorkingDirectory}}WorkingDirectory={{.WorkingDirectory|cmdEscape}}{{end}}
{{if .UserName}}User={{.UserName}}{{end}}
{{if .Restart}}Restart={{.Restart}}{{end}}
{{if .SuccessExitStatus}}SuccessExitStatus={{.SuccessExitStatus}}{{end}}
TimeoutStopSec=20
RestartSec=120
EnvironmentFile=-/etc/sysconfig/{{.Name}}
DevicePolicy=closed
NoNewPrivileges=yes
PrivateTmp=yes
ProtectControlGroups=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
{{if .WorkingDirectory}}ReadWritePaths={{.WorkingDirectory|cmdEscape}}{{end}}
ProtectSystem=full
[Install]
WantedBy=multi-user.target
`

View File

@@ -1,6 +1,6 @@
// Code generated by Wire. DO NOT EDIT.
//go:generate go run github.com/google/wire/cmd/wire
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
//go:build !wireinject
// +build !wireinject
@@ -14,6 +14,8 @@ import (
"github.com/navidrome/navidrome/core/agents/listenbrainz"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/persistence"
@@ -23,7 +25,6 @@ import (
"github.com/navidrome/navidrome/server/nativeapi"
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/subsonic"
"sync"
)
// Injectors from wire_injectors.go:
@@ -32,7 +33,8 @@ func CreateServer(musicFolder string) *server.Server {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
broker := events.GetBroker()
serverServer := server.New(dataStore, broker)
insights := metrics.GetInstance(dataStore)
serverServer := server.New(dataStore, broker, insights)
return serverServer
}
@@ -40,7 +42,9 @@ func CreateNativeAPIRouter() *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore)
router := nativeapi.New(dataStore, share)
playlists := core.NewPlaylists(dataStore)
insights := metrics.GetInstance(dataStore)
router := nativeapi.New(dataStore, share, playlists, insights)
return router
}
@@ -57,11 +61,13 @@ func CreateSubsonicAPIRouter() *subsonic.Router {
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
players := core.NewPlayers(dataStore)
scanner := GetScanner()
broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
scannerScanner := scanner.GetInstance(dataStore, playlists, cacheWarmer, broker)
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scanner, broker, playlists, playTracker, share)
playbackServer := playback.GetInstance(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scannerScanner, broker, playlists, playTracker, share, playbackServer)
return router
}
@@ -95,7 +101,14 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
return router
}
func createScanner() scanner.Scanner {
func CreateInsights() metrics.Insights {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
insights := metrics.GetInstance(dataStore)
return insights
}
func GetScanner() scanner.Scanner {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
playlists := core.NewPlaylists(dataStore)
@@ -106,23 +119,17 @@ func createScanner() scanner.Scanner {
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
scannerScanner := scanner.New(dataStore, playlists, cacheWarmer, broker)
scannerScanner := scanner.GetInstance(dataStore, playlists, cacheWarmer, broker)
return scannerScanner
}
func GetPlaybackServer() playback.PlaybackServer {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
playbackServer := playback.GetInstance(dataStore)
return playbackServer
}
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, db.Db)
// Scanner must be a Singleton
var (
onceScanner sync.Once
scannerInstance scanner.Scanner
)
func GetScanner() scanner.Scanner {
onceScanner.Do(func() {
scannerInstance = createScanner()
})
return scannerInstance
}
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, db.Db)

View File

@@ -3,13 +3,13 @@
package cmd
import (
"sync"
"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/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
@@ -23,6 +23,7 @@ import (
var allProviders = wire.NewSet(
core.Set,
artwork.Set,
server.New,
subsonic.New,
nativeapi.New,
public.New,
@@ -30,12 +31,12 @@ var allProviders = wire.NewSet(
lastfm.NewRouter,
listenbrainz.NewRouter,
events.GetBroker,
scanner.GetInstance,
db.Db,
)
func CreateServer(musicFolder string) *server.Server {
panic(wire.Build(
server.New,
allProviders,
))
}
@@ -49,7 +50,6 @@ func CreateNativeAPIRouter() *nativeapi.Router {
func CreateSubsonicAPIRouter() *subsonic.Router {
panic(wire.Build(
allProviders,
GetScanner,
))
}
@@ -71,22 +71,20 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
))
}
// Scanner must be a Singleton
var (
onceScanner sync.Once
scannerInstance scanner.Scanner
)
func GetScanner() scanner.Scanner {
onceScanner.Do(func() {
scannerInstance = createScanner()
})
return scannerInstance
}
func createScanner() scanner.Scanner {
func CreateInsights() metrics.Insights {
panic(wire.Build(
allProviders,
))
}
func GetScanner() scanner.Scanner {
panic(wire.Build(
allProviders,
))
}
func GetPlaybackServer() playback.PlaybackServer {
panic(wire.Build(
allProviders,
scanner.New,
))
}

View File

@@ -0,0 +1,4 @@
package buildtags
// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all
// required build tags are disabled.

11
conf/buildtags/netgo.go Normal file
View File

@@ -0,0 +1,11 @@
//go:build netgo
package buildtags
// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project.
// If the tag is not included, the compilation will fail because this variable won't be defined, and the `main.go`
// file requires it.
// Why this tag is required? See https://github.com/navidrome/navidrome/issues/700
var NETGO = true

View File

@@ -12,72 +12,84 @@ import (
"github.com/kr/pretty"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/number"
"github.com/robfig/cron/v3"
"github.com/spf13/viper"
)
type configOptions struct {
ConfigFile string
Address string
Port int
MusicFolder string
DataFolder string
CacheFolder string
DbPath string
LogLevel string
ScanInterval time.Duration
ScanSchedule string
SessionTimeout time.Duration
BaseURL string
BasePath string
BaseHost string
BaseScheme string
TLSCert string
TLSKey string
UILoginBackgroundURL string
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
EnableDownloads bool
EnableExternalServices bool
EnableMediaFileCoverArt bool
TranscodingCacheSize string
ImageCacheSize string
EnableArtworkPrecache bool
AutoImportPlaylists bool
PlaylistsPath string
AutoTranscodeDownload bool
DefaultDownsamplingFormat string
SearchFullString bool
RecentlyAddedByModTime bool
IgnoredArticles string
IndexGroups string
SubsonicArtistParticipations bool
FFmpegPath string
CoverArtPriority string
CoverJpegQuality int
ArtistArtPriority string
EnableGravatar bool
EnableFavourites bool
EnableStarRating bool
EnableUserEditing bool
EnableSharing bool
DefaultDownloadableShare bool
DefaultTheme string
DefaultLanguage string
DefaultUIVolume int
EnableReplayGain bool
EnableCoverAnimation bool
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
AuthWindowLength time.Duration
PasswordEncryptionKey string
ReverseProxyUserHeader string
ReverseProxyWhitelist string
Prometheus prometheusOptions
Scanner scannerOptions
ConfigFile string
Address string
Port int
UnixSocketPerm string
MusicFolder string
DataFolder string
CacheFolder string
DbPath string
LogLevel string
LogFile string
ScanInterval time.Duration
ScanSchedule string
SessionTimeout time.Duration
BaseURL string
BasePath string
BaseHost string
BaseScheme string
TLSCert string
TLSKey string
UILoginBackgroundURL string
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
EnableDownloads bool
EnableExternalServices bool
EnableInsightsCollector bool
EnableMediaFileCoverArt bool
TranscodingCacheSize string
ImageCacheSize string
AlbumPlayCountMode string
EnableArtworkPrecache bool
AutoImportPlaylists bool
DefaultPlaylistPublicVisibility bool
PlaylistsPath string
SmartPlaylistRefreshDelay time.Duration
AutoTranscodeDownload bool
DefaultDownsamplingFormat string
SearchFullString bool
RecentlyAddedByModTime bool
PreferSortTags bool
IgnoredArticles string
IndexGroups string
SubsonicArtistParticipations bool
FFmpegPath string
MPVPath string
MPVCmdTemplate string
CoverArtPriority string
CoverJpegQuality int
ArtistArtPriority string
EnableGravatar bool
EnableFavourites bool
EnableStarRating bool
EnableUserEditing bool
EnableSharing bool
ShareURL string
DefaultDownloadableShare bool
DefaultTheme string
DefaultLanguage string
DefaultUIVolume int
EnableReplayGain bool
EnableCoverAnimation bool
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
AuthWindowLength time.Duration
PasswordEncryptionKey string
ReverseProxyUserHeader string
ReverseProxyWhitelist string
HTTPSecurityHeaders secureOptions
Prometheus prometheusOptions
Scanner scannerOptions
Jukebox jukeboxOptions
Backup backupOptions
Agents string
LastFM lastfmOptions
@@ -91,14 +103,18 @@ type configOptions struct {
DevAutoCreateAdminPassword string
DevAutoLoginUsername string
DevActivityPanel bool
DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool
DevEnableBufferedScrobble bool
DevShowArtistPage bool
DevOffsetOptimize int
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
DevArtworkThrottleBacklogTimeout time.Duration
DevArtistInfoTimeToLive time.Duration
DevAlbumInfoTimeToLive time.Duration
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
}
type scannerOptions struct {
@@ -124,11 +140,30 @@ type listenBrainzOptions struct {
BaseURL string
}
type secureOptions struct {
CustomFrameOptionsValue string
}
type prometheusOptions struct {
Enabled bool
MetricsPath string
}
type AudioDeviceDefinition []string
type jukeboxOptions struct {
Enabled bool
Devices []AudioDeviceDefinition
Default string
AdminOnly bool
}
type backupOptions struct {
Count int
Path string
Schedule string
}
var (
Server = &configOptions{}
hooks []func()
@@ -145,14 +180,17 @@ func LoadFromFile(confFile string) {
}
func Load() {
parseIniFileConfiguration()
err := viper.Unmarshal(&Server)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", "path", Server.DataFolder, err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
os.Exit(1)
}
@@ -161,7 +199,7 @@ func Load() {
}
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", "path", Server.CacheFolder, err)
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
os.Exit(1)
}
@@ -170,6 +208,24 @@ func Load() {
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
}
if Server.Backup.Path != "" {
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err)
os.Exit(1)
}
}
out := os.Stderr
if Server.LogFile != "" {
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
os.Exit(1)
}
log.SetOutput(out)
}
log.SetLevelString(Server.LogLevel)
log.SetLogLevels(Server.DevLogLevels)
log.SetLogSourceLine(Server.DevLogSourceLine)
@@ -179,10 +235,14 @@ func Load() {
os.Exit(1)
}
if err := validateBackupSchedule(); err != nil {
os.Exit(1)
}
if Server.BaseURL != "" {
u, err := url.Parse(Server.BaseURL)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Invalid BaseURL %s: %s\n", Server.BaseURL, err.Error())
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
os.Exit(1)
}
Server.BasePath = u.Path
@@ -193,12 +253,12 @@ func Load() {
}
// Print current configuration if log level is Debug
if log.CurrentLevel() >= log.LevelDebug {
if log.IsGreaterOrEqualTo(log.LevelDebug) {
prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
if Server.EnableLogRedacting {
prettyConf = log.Redact(prettyConf)
}
_, _ = fmt.Fprintln(os.Stderr, prettyConf)
_, _ = fmt.Fprintln(out, prettyConf)
}
if !Server.EnableExternalServices {
@@ -211,8 +271,34 @@ func Load() {
}
}
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
// section into the root level.
func parseIniFileConfiguration() {
cfgFile := viper.ConfigFileUsed()
if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" {
var iniConfig map[string]interface{}
err := viper.Unmarshal(&iniConfig)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
cfg, ok := iniConfig["default"].(map[string]any)
if !ok {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
os.Exit(1)
}
err = viper.MergeConfigMap(cfg)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
os.Exit(1)
}
}
}
func disableExternalServices() {
log.Info("All external integrations are DISABLED!")
Server.EnableInsightsCollector = false
Server.LastFM.Enabled = false
Server.Spotify.ID = ""
Server.ListenBrainz.Enabled = false
@@ -240,15 +326,35 @@ func validateScanSchedule() error {
Server.ScanSchedule = ""
return nil
}
if _, err := time.ParseDuration(Server.ScanSchedule); err == nil {
Server.ScanSchedule = "@every " + Server.ScanSchedule
var err error
Server.ScanSchedule, err = validateSchedule(Server.ScanSchedule, "ScanSchedule")
return err
}
func validateBackupSchedule() error {
if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
Server.Backup.Schedule = ""
return nil
}
var err error
Server.Backup.Schedule, err = validateSchedule(Server.Backup.Schedule, "BackupSchedule")
return err
}
func validateSchedule(schedule, field string) (string, error) {
if _, err := time.ParseDuration(schedule); err == nil {
schedule = "@every " + schedule
}
c := cron.New()
_, err := c.AddFunc(Server.ScanSchedule, func() {})
id, err := c.AddFunc(schedule, func() {})
if err != nil {
log.Error("Invalid ScanSchedule. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", "schedule", Server.ScanSchedule, err)
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", field, err)
} else {
c.Remove(id)
}
return err
return schedule, err
}
// AddHook is used to register initialization code that should run as soon as the config is loaded
@@ -261,8 +367,10 @@ func init() {
viper.SetDefault("cachefolder", "")
viper.SetDefault("datafolder", ".")
viper.SetDefault("loglevel", "info")
viper.SetDefault("logfile", "")
viper.SetDefault("address", "0.0.0.0")
viper.SetDefault("port", 4533)
viper.SetDefault("unixsocketperm", "0660")
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
viper.SetDefault("scaninterval", -1)
viper.SetDefault("scanschedule", "@every 1m")
@@ -275,20 +383,26 @@ func init() {
viper.SetDefault("enabletranscodingconfig", false)
viper.SetDefault("transcodingcachesize", "100MB")
viper.SetDefault("imagecachesize", "100MB")
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
viper.SetDefault("enableartworkprecache", true)
viper.SetDefault("autoimportplaylists", true)
viper.SetDefault("defaultplaylistpublicvisibility", false)
viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath)
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
viper.SetDefault("enabledownloads", true)
viper.SetDefault("enableexternalservices", true)
viper.SetDefault("enableMediaFileCoverArt", true)
viper.SetDefault("enablemediafilecoverart", true)
viper.SetDefault("autotranscodedownload", false)
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
viper.SetDefault("searchfullstring", false)
viper.SetDefault("recentlyaddedbymodtime", false)
viper.SetDefault("prefersorttags", false)
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
viper.SetDefault("subsonicartistparticipations", false)
viper.SetDefault("ffmpegpath", "")
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s")
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
viper.SetDefault("coverjpegquality", 75)
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
@@ -302,6 +416,7 @@ func init() {
viper.SetDefault("enablereplaygain", true)
viper.SetDefault("enablecoveranimation", true)
viper.SetDefault("gatrackingid", "")
viper.SetDefault("enableinsightscollector", true)
viper.SetDefault("enablelogredacting", true)
viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second)
@@ -313,6 +428,11 @@ func init() {
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", "/metrics")
viper.SetDefault("jukebox.enabled", false)
viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{})
viper.SetDefault("jukebox.default", "")
viper.SetDefault("jukebox.adminonly", true)
viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor)
viper.SetDefault("scanner.genreseparators", ";/,")
viper.SetDefault("scanner.groupalbumreleases", false)
@@ -320,29 +440,40 @@ func init() {
viper.SetDefault("agents", "lastfm,spotify")
viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", "en")
viper.SetDefault("lastfm.apikey", consts.LastFMAPIKey)
viper.SetDefault("lastfm.secret", consts.LastFMAPISecret)
viper.SetDefault("lastfm.apikey", "")
viper.SetDefault("lastfm.secret", "")
viper.SetDefault("spotify.id", "")
viper.SetDefault("spotify.secret", "")
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
viper.SetDefault("backup.path", "")
viper.SetDefault("backup.schedule", "")
viper.SetDefault("backup.count", 0)
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)
viper.SetDefault("devenableprofiler", false)
viper.SetDefault("devautocreateadminpassword", "")
viper.SetDefault("devautologinusername", "")
viper.SetDefault("devactivitypanel", true)
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
viper.SetDefault("enablesharing", false)
viper.SetDefault("shareurl", "")
viper.SetDefault("defaultdownloadableshare", false)
viper.SetDefault("devenablebufferedscrobble", true)
viper.SetDefault("devsidebarplaylists", true)
viper.SetDefault("devshowartistpage", true)
viper.SetDefault("devartworkmaxrequests", number.Max(2, runtime.NumCPU()/3))
viper.SetDefault("devoffsetoptimize", 50000)
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3))
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay)
viper.SetDefault("devenableplayerinsights", true)
}
func InitConfig(cfgFile string) {

48
conf/mime/mime_types.go Normal file
View File

@@ -0,0 +1,48 @@
package mime
import (
"mime"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/resources"
"gopkg.in/yaml.v3"
)
type mimeConf struct {
Types map[string]string `yaml:"types"`
Lossless []string `yaml:"lossless"`
}
var LosslessFormats []string
func initMimeTypes() {
// In some circumstances, Windows sets JS mime-type to `text/plain`!
_ = mime.AddExtensionType(".js", "text/javascript")
_ = mime.AddExtensionType(".css", "text/css")
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
f, err := resources.FS().Open("mime_types.yaml")
if err != nil {
log.Fatal("Fatal error opening mime_types.yaml", err)
}
defer f.Close()
var mimeConf mimeConf
err = yaml.NewDecoder(f).Decode(&mimeConf)
if err != nil {
log.Fatal("Fatal error parsing mime_types.yaml", err)
}
for ext, typ := range mimeConf.Types {
_ = mime.AddExtensionType(ext, typ)
}
for _, ext := range mimeConf.Lossless {
LosslessFormats = append(LosslessFormats, strings.TrimPrefix(ext, "."))
}
}
func init() {
conf.AddHook(initMimeTypes)
}

View File

@@ -3,6 +3,7 @@ package consts
import (
"crypto/md5"
"fmt"
"os"
"path/filepath"
"strings"
"time"
@@ -57,7 +58,7 @@ const (
SkipScanFile = ".ndignore"
PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "placeholder.png"
PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png"
UICoverArtSize = 300
DefaultUIVolume = 100
@@ -81,32 +82,43 @@ const (
DefaultCacheCleanUpInterval = 10 * time.Minute
)
// Shared secrets (only add here "secrets" that can be public)
const (
LastFMAPIKey = "9b94a5515ea66b2da3ec03c12300327e" // nolint:gosec
LastFMAPISecret = "74cb6557cec7171d921af5d7d887c587" // nolint:gosec
AlbumPlayCountModeAbsolute = "absolute"
AlbumPlayCountModeNormalized = "normalized"
)
const (
InsightsIDKey = "InsightsID"
InsightsEndpoint = "https://insights.navidrome.org/collect"
InsightsUpdateInterval = 24 * time.Hour
InsightsInitialDelay = 30 * time.Minute
)
var (
DefaultDownsamplingFormat = "opus"
DefaultTranscodings = []map[string]interface{}{
DefaultTranscodings = []struct {
Name string
TargetFormat string
DefaultBitRate int
Command string
}{
{
"name": "mp3 audio",
"targetFormat": "mp3",
"defaultBitRate": 192,
"command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
Name: "mp3 audio",
TargetFormat: "mp3",
DefaultBitRate: 192,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
},
{
"name": "opus audio",
"targetFormat": "opus",
"defaultBitRate": 128,
"command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
Name: "opus audio",
TargetFormat: "opus",
DefaultBitRate: 128,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
},
{
"name": "aac audio",
"targetFormat": "aac",
"defaultBitRate": 256,
"command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
Name: "aac audio",
TargetFormat: "aac",
DefaultBitRate: 256,
Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
},
}
@@ -123,3 +135,11 @@ var (
ServerStart = time.Now()
)
var InContainer = func() bool {
// Check if the /.nddockerenv file exists
if _, err := os.Stat("/.nddockerenv"); err == nil {
return true
}
return false
}()

View File

@@ -1,64 +0,0 @@
package consts
import (
"mime"
"sort"
"strings"
)
type format struct {
typ string
lossless bool
}
var audioFormats = map[string]format{
".mp3": {typ: "audio/mpeg"},
".ogg": {typ: "audio/ogg"},
".oga": {typ: "audio/ogg"},
".opus": {typ: "audio/ogg"},
".aac": {typ: "audio/mp4"},
".alac": {typ: "audio/mp4", lossless: true},
".m4a": {typ: "audio/mp4"},
".m4b": {typ: "audio/mp4"},
".flac": {typ: "audio/flac", lossless: true},
".wav": {typ: "audio/x-wav", lossless: true},
".wma": {typ: "audio/x-ms-wma"},
".ape": {typ: "audio/x-monkeys-audio", lossless: true},
".mpc": {typ: "audio/x-musepack"},
".shn": {typ: "audio/x-shn", lossless: true},
".aif": {typ: "audio/x-aiff"},
".aiff": {typ: "audio/x-aiff"},
".m3u": {typ: "audio/x-mpegurl"},
".pls": {typ: "audio/x-scpls"},
".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",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".png": "image/png",
".bmp": "image/bmp",
}
var LosslessFormats []string
func init() {
for ext, fmt := range audioFormats {
_ = mime.AddExtensionType(ext, fmt.typ)
if fmt.lossless {
LosslessFormats = append(LosslessFormats, strings.TrimPrefix(ext, "."))
}
}
sort.Strings(LosslessFormats)
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,7 +11,7 @@
#
# navidrome_enable (bool): Set to YES to enable navidrome
# Default: NO
# navidrome_config (str): navidrome configration file
# navidrome_config (str): navidrome configuration file
# Default: /usr/local/etc/navidrome/config.toml
# navidrome_datafolder (str): navidrome Folder to store application data
# Default: www

View File

@@ -11,15 +11,13 @@ WantedBy=multi-user.target
User=navidrome
Group=navidrome
Type=simple
ExecStart=/usr/bin/navidrome
ExecStart=/usr/bin/navidrome --configfile "/etc/navidrome/navidrome.toml"
StateDirectory=navidrome
WorkingDirectory=/var/lib/navidrome
TimeoutStopSec=20
KillMode=process
Restart=on-failure
EnvironmentFile=-/etc/sysconfig/navidrome
# See https://www.freedesktop.org/software/systemd/man/systemd.exec.html
CapabilityBoundingSet=
DevicePolicy=closed

View File

@@ -134,7 +134,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
}
similar, err := agent.GetSimilarArtists(ctx, id, name, mbid, limit)
if len(similar) > 0 && err == nil {
if log.CurrentLevel() >= log.LevelTrace {
if log.IsGreaterOrEqualTo(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))

View File

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

View File

@@ -3,18 +3,21 @@ package lastfm
import (
"context"
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/andybalholm/cascadia"
"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"
"github.com/navidrome/navidrome/utils/cache"
"golang.org/x/net/html"
)
const (
@@ -47,7 +50,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.client = newClient(l.apiKey, l.secret, l.lang, chc)
return l
}
@@ -178,6 +181,44 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi
return res, nil
}
var artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
a, err := l.callArtistGetInfo(ctx, name, mbid)
if err != nil {
return nil, fmt.Errorf("get artist info: %w", err)
}
req, err := http.NewRequest(http.MethodGet, a.URL, nil)
if err != nil {
return nil, fmt.Errorf("create artist image request: %w", err)
}
resp, err := l.client.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("get artist url: %w", err)
}
defer resp.Body.Close()
node, err := html.Parse(resp.Body)
if err != nil {
return nil, fmt.Errorf("parse html: %w", err)
}
var res []agents.ExternalImage
n := cascadia.Query(node, artistOpenGraphQuery)
if n == nil {
return res, nil
}
for _, attr := range n.Attr {
if attr.Key == "content" {
res = []agents.ExternalImage{
{URL: attr.Val},
}
break
}
}
return res, nil
}
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string) (*Album, error) {
a, err := l.client.albumGetInfo(ctx, name, artist, mbid)
var lfErr *lastFMError
@@ -257,7 +298,7 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
track: track.Title,
album: track.Album,
trackNumber: track.TrackNumber,
mbid: track.MbzTrackID,
mbid: track.MbzRecordingID,
duration: int(track.Duration),
albumArtist: track.AlbumArtist,
})
@@ -283,7 +324,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
track: s.Title,
album: s.Album,
trackNumber: s.TrackNumber,
mbid: s.MbzTrackID,
mbid: s.MbzRecordingID,
duration: int(s.Duration),
albumArtist: s.AlbumArtist,
timestamp: s.TimeStamp,
@@ -311,12 +352,14 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
func init() {
conf.AddHook(func() {
if conf.Server.LastFM.Enabled {
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
return lastFMConstructor(ds)
})
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
return lastFMConstructor(ds)
})
if conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != "" {
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
return lastFMConstructor(ds)
})
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
return lastFMConstructor(ds)
})
}
}
})
}

View File

@@ -234,14 +234,14 @@ var _ = Describe("lastfmAgent", func() {
agent = lastFMConstructor(ds)
agent.client = client
track = &model.MediaFile{
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
AlbumArtist: "Track AlbumArtist",
TrackNumber: 1,
Duration: 180,
MbzTrackID: "mbz-123",
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
AlbumArtist: "Track AlbumArtist",
TrackNumber: 1,
Duration: 180,
MbzRecordingID: "mbz-123",
}
})
@@ -262,7 +262,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
Expect(sentParams.Get("mbid")).To(Equal(track.MbzTrackID))
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
})
It("returns ErrNotAuthorized if user is not linked", func() {
@@ -289,7 +289,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
Expect(sentParams.Get("mbid")).To(Equal(track.MbzTrackID))
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
})

View File

@@ -18,7 +18,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/req"
)
//go:embed token_received.html
@@ -65,7 +65,9 @@ func (s *Router) routes() http.Handler {
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{}
resp := map[string]interface{}{
"apiKey": s.apiKey,
}
u, _ := request.UserFrom(r.Context())
key, err := s.sessionKeys.Get(r.Context(), u.ID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
@@ -89,13 +91,14 @@ func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
}
func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
token := utils.ParamString(r, "token")
if token == "" {
p := req.Params(r)
token, err := p.String("token")
if err != nil {
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
return
}
uid := utils.ParamString(r, "uid")
if uid == "" {
uid, err := p.String("uid")
if err != nil {
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
return
}
@@ -103,7 +106,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
// Need to add user to context, as this is a non-authenticated endpoint, so it does not
// automatically contain any user info
ctx := request.WithUser(r.Context(), model.User{ID: uid})
err := s.fetchSessionKey(ctx, uid, token)
err = s.fetchSessionKey(ctx, uid, token)
if err != nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)

View File

@@ -8,13 +8,13 @@ import (
"fmt"
"net/http"
"net/url"
"slices"
"sort"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/log"
"golang.org/x/exp/slices"
)
const (

View File

@@ -11,7 +11,7 @@ import (
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/cache"
)
const (
@@ -35,7 +35,7 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.client = newClient(l.baseURL, chc)
return l
}
@@ -55,8 +55,9 @@ func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
SubmissionClientVersion: consts.Version,
TrackNumber: track.TrackNumber,
ArtistMbzIDs: []string{track.MbzArtistID},
TrackMbzID: track.MbzTrackID,
RecordingMbzID: track.MbzRecordingID,
ReleaseMbID: track.MbzAlbumID,
DurationMs: int(track.Duration * 1000),
},
},
}

View File

@@ -32,14 +32,15 @@ var _ = Describe("listenBrainzAgent", func() {
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",
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
TrackNumber: 1,
MbzRecordingID: "mbz-123",
MbzAlbumID: "mbz-456",
MbzArtistID: "mbz-789",
Duration: 142.2,
}
})
@@ -60,11 +61,12 @@ var _ = Describe("listenBrainzAgent", func() {
"SubmissionClient": Equal(consts.AppName),
"SubmissionClientVersion": Equal(consts.Version),
"TrackNumber": Equal(track.TrackNumber),
"TrackMbzID": Equal(track.MbzTrackID),
"RecordingMbzID": Equal(track.MbzRecordingID),
"ReleaseMbID": Equal(track.MbzAlbumID),
"ArtistMbzIDs": MatchAllElements(idArtistId, Elements{
"mbz-789": Equal(track.MbzArtistID),
}),
"DurationMs": Equal(142200),
}),
}),
}))

View File

@@ -76,9 +76,10 @@ type additionalInfo struct {
SubmissionClient string `json:"submission_client,omitempty"`
SubmissionClientVersion string `json:"submission_client_version,omitempty"`
TrackNumber int `json:"tracknumber,omitempty"`
TrackMbzID string `json:"track_mbid,omitempty"`
RecordingMbzID string `json:"recording_mbid,omitempty"`
ArtistMbzIDs []string `json:"artist_mbids,omitempty"`
ReleaseMbID string `json:"release_mbid,omitempty"`
DurationMs int `json:"duration_ms,omitempty"`
}
func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {

View File

@@ -74,10 +74,11 @@ var _ = Describe("client", func() {
TrackName: "Track Title",
ReleaseName: "Track Album",
AdditionalInfo: additionalInfo{
TrackNumber: 1,
TrackMbzID: "mbz-123",
ArtistMbzIDs: []string{"mbz-789"},
ReleaseMbID: "mbz-456",
TrackNumber: 1,
RecordingMbzID: "mbz-123",
ArtistMbzIDs: []string{"mbz-789"},
ReleaseMbID: "mbz-456",
DurationMs: 142200,
},
},
}

View File

@@ -13,7 +13,7 @@ import (
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/cache"
"github.com/xrash/smetrics"
)
@@ -35,7 +35,7 @@ func spotifyConstructor(ds model.DataStore) agents.Interface {
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut)
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.client = newClient(l.id, l.secret, chc)
return l
}

View File

@@ -150,7 +150,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
var r io.ReadCloser
if format != "raw" && format != "" {
r, err = a.ms.DoStream(ctx, &mf, format, bitrate)
r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0)
} else {
r, err = os.Open(mf.Path)
}
@@ -160,7 +160,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
}
defer func() {
if err := r.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug {
if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
log.Error(ctx, "Error closing stream", "id", mf.ID, "file", mf.Path, err)
}
}()

View File

@@ -44,7 +44,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
out := new(bytes.Buffer)
err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)
@@ -73,7 +73,7 @@ var _ = Describe("Archiver", func() {
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out)
@@ -104,7 +104,7 @@ var _ = Describe("Archiver", func() {
}
sh.On("Load", mock.Anything, "1").Return(share, nil)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipShare(context.Background(), "1", out)
@@ -136,7 +136,7 @@ var _ = Describe("Archiver", func() {
plRepo := &mockPlaylistRepository{}
plRepo.On("GetWithTracks", "1", true).Return(pls, nil)
ds.On("Playlist", mock.Anything).Return(plRepo)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
out := new(bytes.Buffer)
err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)
@@ -192,8 +192,8 @@ type mockMediaStreamer struct {
core.MediaStreamer
}
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, format string, bitrate int) (*core.Stream, error) {
args := m.Called(ctx, mf, format, bitrate)
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) {
args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset)
if args.Error(1) != nil {
return nil, args.Error(1)
}

View File

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

View File

@@ -4,7 +4,11 @@ import (
"context"
"errors"
"image"
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@@ -211,33 +215,83 @@ var _ = Describe("Artwork", func() {
alMultipleCovers,
})
})
It("returns a PNG if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15)
Expect(err).ToNot(HaveOccurred())
When("Square is false", func() {
It("returns a PNG if original image is a PNG", func() {
conf.Server.CoverArtPriority = "front.png"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false)
Expect(err).ToNot(HaveOccurred())
br, format, err := asImageReader(r)
Expect(format).To(Equal("image/png"))
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("png"))
Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15))
})
It("returns a JPEG if original image is not a PNG", func() {
conf.Server.CoverArtPriority = "cover.jpg"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false)
Expect(err).ToNot(HaveOccurred())
img, _, err := image.Decode(br)
Expect(err).ToNot(HaveOccurred())
Expect(img.Bounds().Size().X).To(Equal(15))
Expect(img.Bounds().Size().Y).To(Equal(15))
img, format, err := image.Decode(r)
Expect(format).To(Equal("jpeg"))
Expect(err).ToNot(HaveOccurred())
Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200))
})
})
It("returns a JPEG if original image is not a PNG", func() {
conf.Server.CoverArtPriority = "cover.jpg"
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200)
Expect(err).ToNot(HaveOccurred())
When("When square is true", func() {
var alCover model.Album
br, format, err := asImageReader(r)
Expect(format).To(Equal("image/jpeg"))
Expect(err).ToNot(HaveOccurred())
DescribeTable("resize",
func(format string, landscape bool, size int) {
coverFileName := "cover." + format
dirName := createImage(format, landscape, size)
alCover = model.Album{
ID: "444",
Name: "Only external",
ImageFiles: filepath.Join(dirName, coverFileName),
}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alCover,
})
img, _, err := image.Decode(br)
Expect(err).ToNot(HaveOccurred())
Expect(img.Bounds().Size().X).To(Equal(200))
Expect(img.Bounds().Size().Y).To(Equal(200))
conf.Server.CoverArtPriority = coverFileName
r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true)
Expect(err).ToNot(HaveOccurred())
img, format, err := image.Decode(r)
Expect(err).ToNot(HaveOccurred())
Expect(format).To(Equal("png"))
Expect(img.Bounds().Size().X).To(Equal(size))
Expect(img.Bounds().Size().Y).To(Equal(size))
},
Entry("portrait png image", "png", false, 200),
Entry("landscape png image", "png", true, 200),
Entry("portrait jpg image", "jpg", false, 200),
Entry("landscape jpg image", "jpg", true, 200),
)
})
})
})
func createImage(format string, landscape bool, size int) string {
var img image.Image
if landscape {
img = image.NewRGBA(image.Rect(0, 0, size, size/2))
} else {
img = image.NewRGBA(image.Rect(0, 0, size/2, size))
}
tmpDir := GinkgoT().TempDir()
f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
defer f.Close()
switch format {
case "png":
_ = png.Encode(f, img)
case "jpg":
_ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
}
return tmpDir
}

View File

@@ -31,7 +31,7 @@ var _ = Describe("Artwork", func() {
Context("GetOrPlaceholder", func() {
Context("Empty ID", func() {
It("returns placeholder if album is not in the DB", func() {
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0)
r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0, false)
Expect(err).ToNot(HaveOccurred())
ph, err := resources.FS().Open(consts.PlaceholderAlbumArt)
@@ -49,7 +49,7 @@ var _ = Describe("Artwork", func() {
Context("Get", func() {
Context("Empty ID", func() {
It("returns an ErrUnavailable error", func() {
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0)
_, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0, false)
Expect(err).To(MatchError(artwork.ErrUnavailable))
})
})

View File

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

View File

@@ -63,7 +63,7 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":
ff = append(ff, fromTag(a.album.EmbedArtPath), fromFFmpegTag(ctx, ffmpeg, a.album.EmbedArtPath))
ff = append(ff, fromTag(ctx, a.album.EmbedArtPath), fromFFmpegTag(ctx, ffmpeg, a.album.EmbedArtPath))
case pattern == "external":
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.em))
case a.album.ImageFiles != "":

View File

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

View File

@@ -55,7 +55,7 @@ func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, str
var ff []sourceFunc
if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork {
ff = []sourceFunc{
fromTag(a.mediafile.Path),
fromTag(ctx, a.mediafile.Path),
fromFFmpegTag(ctx, a.a.ffmpeg, a.mediafile.Path),
}
}

View File

@@ -1,7 +1,6 @@
package artwork
import (
"bufio"
"bytes"
"context"
"fmt"
@@ -9,14 +8,12 @@ import (
"image/jpeg"
"image/png"
"io"
"net/http"
"time"
"github.com/disintegration/imaging"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/number"
)
type resizedArtworkReader struct {
@@ -24,16 +21,18 @@ type resizedArtworkReader struct {
cacheKey string
lastUpdate time.Time
size int
square bool
a *artwork
}
func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int) (*resizedArtworkReader, error) {
func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int, square bool) (*resizedArtworkReader, error) {
r := &resizedArtworkReader{a: a}
r.artID = artID
r.size = size
r.square = square
// Get lastUpdated and cacheKey from original artwork
original, err := a.getArtworkReader(ctx, artID, 0)
original, err := a.getArtworkReader(ctx, artID, 0, false)
if err != nil {
return nil, err
}
@@ -43,12 +42,11 @@ func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID,
}
func (a *resizedArtworkReader) Key() string {
return fmt.Sprintf(
"%s.%d.%d",
a.cacheKey,
a.size,
conf.Server.CoverJpegQuality,
)
baseKey := fmt.Sprintf("%s.%d", a.cacheKey, a.size)
if a.square {
return baseKey + ".square"
}
return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverJpegQuality)
}
func (a *resizedArtworkReader) LastUpdated() time.Time {
@@ -57,17 +55,13 @@ func (a *resizedArtworkReader) LastUpdated() time.Time {
func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
// Get artwork in original size, possibly from cache
orig, _, err := a.a.Get(ctx, a.artID, 0)
orig, _, err := a.a.Get(ctx, a.artID, 0, false)
if err != nil {
return nil, "", err
}
// Keep a copy of the original data. In case we can't resize it, send it as is
buf := new(bytes.Buffer)
r := io.TeeReader(orig, buf)
defer orig.Close()
resized, origSize, err := resizeImage(r, a.size)
resized, origSize, err := resizeImage(orig, a.size, a.square)
if resized == nil {
log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size)
} else {
@@ -77,61 +71,46 @@ func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, strin
log.Warn(ctx, "Could not resize image. Will return image as is", "artID", a.artID, "size", a.size, err)
}
if err != nil || resized == nil {
// Force finish reading any remaining data
_, _ = io.Copy(io.Discard, r)
return io.NopCloser(buf), "", nil //nolint:nilerr
// if we couldn't resize the image, return the original
orig, _, err = a.a.Get(ctx, a.artID, 0, false)
return orig, "", err
}
return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil
}
func asImageReader(r io.Reader) (io.Reader, string, error) {
br := bufio.NewReader(r)
buf, err := br.Peek(512)
if err == io.EOF && len(buf) > 0 {
// Check if there are enough bytes to detect type
typ := http.DetectContentType(buf)
if typ != "" {
return br, typ, nil
}
}
if err != nil {
return nil, "", err
}
return br, http.DetectContentType(buf), nil
}
func resizeImage(reader io.Reader, size int) (io.Reader, int, error) {
r, format, err := asImageReader(reader)
func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error) {
original, format, err := image.Decode(reader)
if err != nil {
return nil, 0, err
}
img, _, err := image.Decode(r)
if err != nil {
return nil, 0, err
}
bounds := original.Bounds()
originalSize := max(bounds.Max.X, bounds.Max.Y)
// Don't upscale the image
bounds := img.Bounds()
originalSize := number.Max(bounds.Max.X, bounds.Max.Y)
if originalSize <= size {
if originalSize <= size && !square {
return nil, originalSize, nil
}
var m *image.NRGBA
// Preserve the aspect ratio of the image.
if bounds.Max.X > bounds.Max.Y {
m = imaging.Resize(img, size, 0, imaging.Lanczos)
var resized image.Image
if originalSize >= size {
resized = imaging.Fit(original, size, size, imaging.Lanczos)
} else {
m = imaging.Resize(img, 0, size, imaging.Lanczos)
if bounds.Max.Y < bounds.Max.X {
resized = imaging.Resize(original, size, 0, imaging.Lanczos)
} else {
resized = imaging.Resize(original, 0, size, imaging.Lanczos)
}
}
if square {
bg := image.NewRGBA(image.Rect(0, 0, size, size))
resized = imaging.OverlayCenter(bg, resized, 1)
}
buf := new(bytes.Buffer)
buf.Reset()
if format == "image/png" {
err = png.Encode(buf, m)
if format == "png" || square {
err = png.Encode(buf, resized)
} else {
err = jpeg.Encode(buf, m, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
err = jpeg.Encode(buf, resized, &jpeg.Options{Quality: conf.Server.CoverJpegQuality})
}
return buf, originalSize, err
}

View File

@@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"reflect"
"regexp"
"runtime"
"strings"
"time"
@@ -37,7 +38,7 @@ func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs
}
log.Trace(ctx, "Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err)
}
return nil, "", fmt.Errorf("could not get a cover art for %s: %w", artID, ErrUnavailable)
return nil, "", fmt.Errorf("could not get `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable)
}
type sourceFunc func() (r io.ReadCloser, path string, err error)
@@ -79,7 +80,14 @@ func fromExternalFile(ctx context.Context, files string, pattern string) sourceF
}
}
func fromTag(path string) sourceFunc {
// These regexes are used to match the picture type in the file, in the order they are listed.
var picTypeRegexes = []*regexp.Regexp{
regexp.MustCompile(`(?i).*cover.*front.*|.*front.*cover.*`),
regexp.MustCompile(`(?i).*front.*`),
regexp.MustCompile(`(?i).*cover.*`),
}
func fromTag(ctx context.Context, path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {
return nil, "", nil
@@ -95,10 +103,31 @@ func fromTag(path string) sourceFunc {
return nil, "", err
}
picture := m.Picture()
if picture == nil {
types := m.PictureTypes()
if len(types) == 0 {
return nil, "", fmt.Errorf("no embedded image found in %s", path)
}
var picture *tag.Picture
for _, regex := range picTypeRegexes {
for _, t := range types {
if regex.MatchString(t) {
log.Trace(ctx, "Found embedded image", "type", t, "path", path)
picture = m.Pictures(t)
break
}
}
if picture != nil {
break
}
}
if picture == nil {
log.Trace(ctx, "Could not find a front image. Getting the first one", "type", types[0], "path", path)
picture = m.Picture()
}
if picture == nil {
return nil, "", fmt.Errorf("could not load embedded image from %s", path)
}
return io.NopCloser(bytes.NewReader(picture.Data)), path, nil
}
}
@@ -112,19 +141,13 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc
if err != nil {
return nil, "", err
}
defer r.Close()
buf := new(bytes.Buffer)
_, err = io.Copy(buf, r)
if err != nil {
return nil, "", err
}
return io.NopCloser(buf), path, nil
return r, path, nil
}
}
func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc {
return func() (io.ReadCloser, string, error) {
r, _, err := a.Get(ctx, id, 0)
r, _, err := a.Get(ctx, id, 0, false)
if err != nil {
return nil, "", err
}

View File

@@ -1,34 +1,47 @@
package auth
import (
"cmp"
"context"
"crypto/sha256"
"sync"
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/google/uuid"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils"
)
var (
once sync.Once
Secret []byte
TokenAuth *jwtauth.JWTAuth
)
// Init creates a JWTAuth object from the secret stored in the DB.
// If the secret is not found, it will create a new one and store it in the DB.
func Init(ds model.DataStore) {
once.Do(func() {
ctx := context.TODO()
log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout)
secret, err := ds.Property(context.TODO()).DefaultGet(consts.JWTSecretKey, "not so secret")
if err != nil {
log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err)
secret, err := ds.Property(ctx).Get(consts.JWTSecretKey)
if err != nil || secret == "" {
log.Info(ctx, "Creating new JWT secret, used for encrypting UI sessions")
secret = createNewSecret(ctx, ds)
} else {
if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil {
log.Error(ctx, "Could not decrypt JWT secret, creating a new one", err)
secret = createNewSecret(ctx, ds)
}
}
Secret = []byte(secret)
TokenAuth = jwtauth.New("HS256", Secret, nil)
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
})
}
@@ -110,3 +123,25 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
ctx = request.WithUsername(ctx, u.UserName)
return request.WithUser(ctx, *u)
}
func createNewSecret(ctx context.Context, ds model.DataStore) string {
secret := uuid.NewString()
encSecret, err := utils.Encrypt(ctx, getEncKey(), secret)
if err != nil {
log.Error(ctx, "Could not encrypt JWT secret", err)
return secret
}
if err := ds.Property(ctx).Put(consts.JWTSecretKey, encSecret); err != nil {
log.Error(ctx, "Could not save JWT secret in DB", err)
}
return secret
}
func getEncKey() []byte {
key := cmp.Or(
conf.Server.PasswordEncryptionKey,
consts.DefaultEncryptionKey,
)
sum := sha256.Sum256([]byte(key))
return sum[:]
}

View File

@@ -4,12 +4,12 @@ import (
"testing"
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -32,8 +32,10 @@ var _ = BeforeSuite(func() {
var _ = Describe("Auth", func() {
BeforeEach(func() {
auth.Secret = []byte(testJWTSecret)
auth.TokenAuth = jwtauth.New("HS256", auth.Secret, nil)
ds := &tests.MockDataStore{
MockedProperty: &tests.MockedPropertyRepo{},
}
auth.Init(ds)
})
Describe("Validate", func() {

View File

@@ -9,7 +9,6 @@ import (
"time"
"github.com/Masterminds/squirrel"
"github.com/deluan/sanitize"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
_ "github.com/navidrome/navidrome/core/agents/lastfm"
@@ -18,7 +17,9 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/number"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/random"
"github.com/navidrome/navidrome/utils/str"
"golang.org/x/sync/errgroup"
)
@@ -42,8 +43,8 @@ type ExternalMetadata interface {
type externalMetadata struct {
ds model.DataStore
ag *agents.Agents
artistQueue chan<- *auxArtist
albumQueue chan<- *auxAlbum
artistQueue refreshQueue[auxArtist]
albumQueue refreshQueue[auxAlbum]
}
type auxAlbum struct {
@@ -58,29 +59,29 @@ type auxArtist struct {
func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMetadata {
e := &externalMetadata{ds: ds, ag: agents}
e.artistQueue = startRefreshQueue(context.TODO(), e.populateArtistInfo)
e.albumQueue = startRefreshQueue(context.TODO(), e.populateAlbumInfo)
e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo)
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
return e
}
func (e *externalMetadata) getAlbum(ctx context.Context, id string) (*auxAlbum, error) {
func (e *externalMetadata) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
var entity interface{}
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
return nil, err
return auxAlbum{}, err
}
var album auxAlbum
switch v := entity.(type) {
case *model.Album:
album.Album = *v
album.Name = clearName(v.Name)
album.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getAlbum(ctx, v.AlbumID)
default:
return nil, model.ErrNotFound
return auxAlbum{}, model.ErrNotFound
}
return &album, nil
return album, nil
}
func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) {
@@ -90,35 +91,37 @@ func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*mod
return nil, err
}
if album.ExternalInfoUpdatedAt.IsZero() {
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", album.ExternalInfoUpdatedAt, "id", id, "name", album.Name)
err = e.populateAlbumInfo(ctx, album)
updatedAt := V(album.ExternalInfoUpdatedAt)
if updatedAt.IsZero() {
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", album.Name)
album, err = e.populateAlbumInfo(ctx, album)
if err != nil {
return nil, err
}
}
if time.Since(album.ExternalInfoUpdatedAt) > conf.Server.DevAlbumInfoTimeToLive {
// If info is expired, trigger a populateAlbumInfo in the background
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
enqueueRefresh(e.albumQueue, album)
e.albumQueue.enqueue(&album)
}
return &album.Album, nil
}
func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album *auxAlbum) error {
func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
start := time.Now()
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
if errors.Is(err, agents.ErrNotFound) {
return nil
return album, nil
}
if err != nil {
log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", album.Name, "artist", album.AlbumArtist,
"elapsed", time.Since(start), err)
return err
return album, err
}
album.ExternalInfoUpdatedAt = time.Now()
album.ExternalInfoUpdatedAt = P(time.Now())
album.ExternalUrl = info.URL
if info.Description != "" {
@@ -149,40 +152,29 @@ func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album *auxAlbu
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start))
}
return nil
return album, nil
}
func (e *externalMetadata) getArtist(ctx context.Context, id string) (*auxArtist, error) {
func (e *externalMetadata) getArtist(ctx context.Context, id string) (auxArtist, error) {
var entity interface{}
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
return nil, err
return auxArtist{}, err
}
var artist auxArtist
switch v := entity.(type) {
case *model.Artist:
artist.Artist = *v
artist.Name = clearName(v.Name)
artist.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getArtist(ctx, v.ArtistID)
case *model.Album:
return e.getArtist(ctx, v.AlbumArtistID)
default:
return nil, model.ErrNotFound
return auxArtist{}, model.ErrNotFound
}
return &artist, nil
}
// Replace some Unicode chars with their equivalent ASCII
func clearName(name string) string {
name = strings.ReplaceAll(name, "", "-")
name = strings.ReplaceAll(name, "", "-")
name = strings.ReplaceAll(name, "“", `"`)
name = strings.ReplaceAll(name, "”", `"`)
name = strings.ReplaceAll(name, "", `'`)
name = strings.ReplaceAll(name, "", `'`)
return name
return artist, nil
}
func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
@@ -191,34 +183,35 @@ func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, simi
return nil, err
}
err = e.loadSimilar(ctx, artist, similarCount, includeNotPresent)
err = e.loadSimilar(ctx, &artist, similarCount, includeNotPresent)
return &artist.Artist, err
}
func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (*auxArtist, error) {
func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (auxArtist, error) {
artist, err := e.getArtist(ctx, id)
if err != nil {
return nil, err
return auxArtist{}, err
}
// 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.populateArtistInfo(ctx, artist)
updatedAt := V(artist.ExternalInfoUpdatedAt)
if updatedAt.IsZero() {
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artist.Name)
artist, err = e.populateArtistInfo(ctx, artist)
if err != nil {
return nil, err
return auxArtist{}, err
}
}
// If info is expired, trigger a populateArtistInfo in the background
if time.Since(artist.ExternalInfoUpdatedAt) > conf.Server.DevArtistInfoTimeToLive {
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name)
enqueueRefresh(e.artistQueue, artist)
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artist.Name)
e.artistQueue.enqueue(&artist)
}
return artist, nil
}
func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist *auxArtist) error {
func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
start := time.Now()
// Get MBID first, if it is not yet available
if artist.MbzArtistID == "" {
@@ -231,18 +224,18 @@ func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist *auxAr
// Call all registered agents and collect information
g := errgroup.Group{}
g.SetLimit(2)
g.Go(func() error { e.callGetImage(ctx, e.ag, artist); return nil })
g.Go(func() error { e.callGetBiography(ctx, e.ag, artist); return nil })
g.Go(func() error { e.callGetURL(ctx, e.ag, artist); return nil })
g.Go(func() error { e.callGetSimilar(ctx, e.ag, artist, maxSimilarArtists, true); return nil })
g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetSimilar(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
_ = g.Wait()
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "ArtistInfo update canceled", "elapsed", "id", artist.ID, "name", artist.Name, time.Since(start), ctx.Err())
return ctx.Err()
return artist, ctx.Err()
}
artist.ExternalInfoUpdatedAt = time.Now()
artist.ExternalInfoUpdatedAt = P(time.Now())
err := e.ds.Artist(ctx).Put(&artist.Artist)
if err != nil {
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artist.Name,
@@ -250,7 +243,7 @@ func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist *auxAr
} else {
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start))
}
return nil
return artist, nil
}
func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
@@ -259,20 +252,20 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
return nil, err
}
e.callGetSimilar(ctx, e.ag, artist, 15, false)
e.callGetSimilar(ctx, e.ag, &artist, 15, false)
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
return nil, ctx.Err()
}
weightedSongs := utils.NewWeightedRandomChooser()
addArtist := func(a model.Artist, weightedSongs *utils.WeightedChooser, count, artistWeight int) error {
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
return ctx.Err()
}
topCount := number.Max(count, 20)
topCount := max(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)
@@ -300,12 +293,12 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
var similarSongs model.MediaFiles
for len(similarSongs) < count && weightedSongs.Size() > 0 {
s, err := weightedSongs.GetAndRemove()
s, err := weightedSongs.Pick()
if err != nil {
log.Warn(ctx, "Error getting weighted song", err)
continue
}
similarSongs = append(similarSongs, s.(model.MediaFile))
similarSongs = append(similarSongs, s)
}
return similarSongs, nil
@@ -317,7 +310,7 @@ func (e *externalMetadata) ArtistImage(ctx context.Context, id string) (*url.URL
return nil, err
}
e.callGetImage(ctx, e.ag, artist)
e.callGetImage(ctx, e.ag, &artist)
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "ArtistImage call canceled", ctx.Err())
return nil, ctx.Err()
@@ -399,7 +392,7 @@ func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents
func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) {
if mbid != "" {
mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"mbz_track_id": mbid},
Filters: squirrel.Eq{"mbz_recording_id": mbid},
})
if err == nil && len(mfs) > 0 {
return &mfs[0], nil
@@ -412,9 +405,9 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
squirrel.Eq{"artist_id": artistID},
squirrel.Eq{"album_artist_id": artistID},
},
squirrel.Like{"order_title": strings.TrimSpace(sanitize.Accents(title))},
squirrel.Like{"order_title": str.SanitizeFieldForSorting(title)},
},
Sort: "starred desc, rating desc, year asc",
Sort: "starred desc, rating desc, year asc, compilation asc ",
Max: 1,
})
if err != nil || len(mfs) == 0 {
@@ -432,11 +425,11 @@ func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistUR
}
func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
bio, err := agent.GetArtistBiography(ctx, artist.ID, clearName(artist.Name), artist.MbzArtistID)
bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(artist.Name), artist.MbzArtistID)
if err != nil {
return
}
bio = utils.SanitizeText(bio)
bio = str.SanitizeText(bio)
bio = strings.ReplaceAll(bio, "\n", " ")
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
}
@@ -512,7 +505,7 @@ func (e *externalMetadata) findArtistByName(ctx context.Context, artistName stri
}
artist := &auxArtist{
Artist: artists[0],
Name: clearName(artists[0].Name),
Name: str.Clear(artists[0].Name),
}
return artist, nil
}
@@ -559,28 +552,33 @@ func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, c
return nil
}
func startRefreshQueue[T any](ctx context.Context, processFn func(context.Context, T) error) chan<- T {
queue := make(chan T, refreshQueueLength)
type refreshQueue[T any] chan<- *T
func newRefreshQueue[T any](ctx context.Context, processFn func(context.Context, T) (T, error)) refreshQueue[T] {
queue := make(chan *T, refreshQueueLength)
go func() {
for {
time.Sleep(refreshDelay)
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
select {
case a := <-queue:
_ = processFn(ctx, a)
cancel()
case <-ctx.Done():
cancel()
break
return
case <-time.After(refreshDelay):
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
select {
case item := <-queue:
_, _ = processFn(ctx, *item)
cancel()
case <-ctx.Done():
cancel()
}
}
}
}()
return queue
}
func enqueueRefresh[T any](queue chan<- T, item T) {
func (q *refreshQueue[T]) enqueue(item *T) {
select {
case queue <- item:
default: // It is ok to miss a refresh
case *q <- item:
default: // It is ok to miss a refresh request
}
}

View File

@@ -16,10 +16,12 @@ import (
)
type FFmpeg interface {
Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error)
Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error)
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
Probe(ctx context.Context, files []string) (string, error)
CmdPath() (string, error)
IsAvailable() bool
Version() string
}
func New() FFmpeg {
@@ -33,11 +35,11 @@ const (
type ffmpeg struct{}
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error) {
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
args := createFFmpegCommand(command, path, maxBitRate)
args := createFFmpegCommand(command, path, maxBitRate, offset)
return e.start(ctx, args)
}
@@ -45,7 +47,7 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser,
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
args := createFFmpegCommand(extractImageCmd, path, 0)
args := createFFmpegCommand(extractImageCmd, path, 0, 0)
return e.start(ctx, args)
}
@@ -64,6 +66,29 @@ func (e *ffmpeg) CmdPath() (string, error) {
return ffmpegCmd()
}
func (e *ffmpeg) IsAvailable() bool {
_, err := ffmpegCmd()
return err == nil
}
// Version executes ffmpeg -version and extracts the version from the output.
// Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers
func (e *ffmpeg) Version() string {
cmd, err := ffmpegCmd()
if err != nil {
return "N/A"
}
out, err := exec.Command(cmd, "-version").CombinedOutput() // #nosec
if err != nil {
return "N/A"
}
parts := strings.Split(string(out), " ")
if len(parts) < 3 {
return "N/A"
}
return parts[2]
}
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
j := &ffCmd{args: args}
@@ -86,7 +111,7 @@ type ffCmd struct {
func (j *ffCmd) start() error {
cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out
if log.CurrentLevel() >= log.LevelTrace {
if log.IsGreaterOrEqualTo(log.LevelTrace) {
cmd.Stderr = os.Stderr
} else {
cmd.Stderr = io.Discard
@@ -113,22 +138,27 @@ func (j *ffCmd) wait() {
}
// Path will always be an absolute path
func createFFmpegCommand(cmd, path string, maxBitRate int) []string {
split := strings.Split(fixCmd(cmd), " ")
for i, s := range split {
s = strings.ReplaceAll(s, "%s", path)
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
split[i] = s
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
var args []string
for _, s := range fixCmd(cmd) {
if strings.Contains(s, "%s") {
s = strings.ReplaceAll(s, "%s", path)
args = append(args, s)
if offset > 0 && !strings.Contains(cmd, "%t") {
args = append(args, "-ss", strconv.Itoa(offset))
}
} else {
s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset))
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
args = append(args, s)
}
}
return split
return args
}
func createProbeCommand(cmd string, inputs []string) []string {
split := strings.Split(fixCmd(cmd), " ")
var args []string
for _, s := range split {
for _, s := range fixCmd(cmd) {
if s == "%s" {
for _, inp := range inputs {
args = append(args, "-i", inp)
@@ -140,18 +170,15 @@ func createProbeCommand(cmd string, inputs []string) []string {
return args
}
func fixCmd(cmd string) string {
split := strings.Split(cmd, " ")
var result []string
func fixCmd(cmd string) []string {
split := strings.Fields(cmd)
cmdPath, _ := ffmpegCmd()
for _, s := range split {
for i, s := range split {
if s == "ffmpeg" || s == "ffmpeg.exe" {
result = append(result, cmdPath)
} else {
result = append(result, s)
split[i] = cmdPath
}
}
return strings.Join(result, " ")
return split
}
func ffmpegCmd() (string, error) {
@@ -174,6 +201,7 @@ func ffmpegCmd() (string, error) {
return ffmpegPath, ffmpegErr
}
// These variables are accessible here for tests. Do not use them directly in production code. Use ffmpegCmd() instead.
var (
ffOnce sync.Once
ffmpegPath string

View File

@@ -24,9 +24,26 @@ var _ = Describe("ffmpeg", func() {
})
Describe("createFFmpegCommand", func() {
It("creates a valid command line", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123)
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
It("handles extra spaces in the command string", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
Context("when command has time offset param", func() {
It("creates a valid command line with offset", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"}))
})
})
Context("when command does not have time offset param", func() {
It("adds time offset after the input file name", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"}))
})
})
})
Describe("createProbeCommand", func() {
@@ -35,4 +52,17 @@ var _ = Describe("ffmpeg", func() {
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"}))
})
})
When("ffmpegPath is set", func() {
It("returns the correct ffmpeg path", func() {
ffmpegPath = "/usr/bin/ffmpeg"
args := createProbeCommand(probeCmd, []string{"one.mp3"})
Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "ffmetadata"}))
})
It("returns the correct ffmpeg path with spaces", func() {
ffmpegPath = "/usr/bin/with spaces/ffmpeg.exe"
args := createProbeCommand(probeCmd, []string{"one.mp3"})
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
})
})
})

View File

@@ -19,8 +19,8 @@ import (
)
type MediaStreamer interface {
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error)
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int) (*Stream, error)
NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error)
DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error)
}
type TranscodingCache cache.FileCache
@@ -40,22 +40,23 @@ type streamJob struct {
mf *model.MediaFile
format string
bitRate int
offset int
}
func (j *streamJob) Key() string {
return fmt.Sprintf("%s.%s.%d.%s", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format)
return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset)
}
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) {
func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, err
}
return ms.DoStream(ctx, mf, reqFormat, reqBitRate)
return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset)
}
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int) (*Stream, error) {
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) {
var format string
var bitRate int
var cached bool
@@ -70,7 +71,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
if format == "raw" {
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", mf.Path,
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format)
f, err := os.Open(mf.Path)
@@ -88,6 +89,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
mf: mf,
format: format,
bitRate: bitRate,
offset: reqOffset,
}
r, err := ms.cache.Get(ctx, job)
if err != nil {
@@ -100,7 +102,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF
s.Seeker = r.Seeker
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", mf.Path,
"requestBitrate", reqBitRate, "requestFormat", reqFormat,
"requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset,
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
@@ -199,7 +201,7 @@ func NewTranscodingCache() TranscodingCache {
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
return nil, os.ErrInvalid
}
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.mf.Path, job.bitRate)
out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.mf.Path, job.bitRate, job.offset)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid

View File

@@ -122,10 +122,11 @@ var _ = Describe("MediaStreamer", func() {
Expect(bitRate).To(Equal(0))
})
})
Context("player has maxBitRate configured", func() {
BeforeEach(func() {
t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96}
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 80}
p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 192}
ctx = request.WithTranscoding(ctx, t)
ctx = request.WithPlayer(ctx, p)
})
@@ -140,7 +141,7 @@ var _ = Describe("MediaStreamer", func() {
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(80))
Expect(bitRate).To(Equal(192))
})
It("returns requested format", func() {
mf.Suffix = "flac"
@@ -152,9 +153,9 @@ var _ = Describe("MediaStreamer", func() {
It("returns requested bitrate", func() {
mf.Suffix = "flac"
mf.BitRate = 1000
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80)
format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160)
Expect(format).To(Equal("oga"))
Expect(bitRate).To(Equal(80))
Expect(bitRate).To(Equal(160))
})
})
})

View File

@@ -39,34 +39,34 @@ var _ = Describe("MediaStreamer", func() {
Context("NewStream", func() {
It("returns a seekable stream if format is 'raw'", func() {
s, err := streamer.NewStream(ctx, "123", "raw", 0)
s, err := streamer.NewStream(ctx, "123", "raw", 0, 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a seekable stream if maxBitRate is 0", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 0)
s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a seekable stream if maxBitRate is higher than file bitRate", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 320)
s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0)
Expect(err).ToNot(HaveOccurred())
Expect(s.Seekable()).To(BeTrue())
})
It("returns a NON seekable stream if transcode is required", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 64)
s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0)
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeFalse())
Expect(s.Duration()).To(Equal(float32(257.0)))
})
It("returns a seekable stream if the file is complete in the cache", func() {
s, err := streamer.NewStream(ctx, "123", "mp3", 32)
s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0)
Expect(err).To(BeNil())
_, _ = io.ReadAll(s)
_ = s.Close()
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
s, err = streamer.NewStream(ctx, "123", "mp3", 32)
s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0)
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeTrue())
})

262
core/metrics/insights.go Normal file
View File

@@ -0,0 +1,262 @@
package metrics
import (
"bytes"
"context"
"encoding/json"
"math"
"net/http"
"path/filepath"
"runtime"
"runtime/debug"
"sync"
"sync/atomic"
"time"
"github.com/Masterminds/squirrel"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/metrics/insights"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/singleton"
)
type Insights interface {
Run(ctx context.Context)
LastRun(ctx context.Context) (timestamp time.Time, success bool)
}
var (
insightsID string
)
type insightsCollector struct {
ds model.DataStore
lastRun atomic.Int64
lastStatus atomic.Bool
}
func GetInstance(ds model.DataStore) Insights {
return singleton.GetInstance(func() *insightsCollector {
id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey)
if err != nil {
log.Trace("Could not get Insights ID from DB. Creating one", err)
id = uuid.NewString()
err = ds.Property(context.TODO()).Put(consts.InsightsIDKey, id)
if err != nil {
log.Trace("Could not save Insights ID to DB", err)
}
}
insightsID = id
return &insightsCollector{ds: ds}
})
}
func (c *insightsCollector) Run(ctx context.Context) {
ctx = auth.WithAdminUser(ctx, c.ds)
for {
c.sendInsights(ctx)
select {
case <-time.After(consts.InsightsUpdateInterval):
continue
case <-ctx.Done():
return
}
}
}
func (c *insightsCollector) LastRun(context.Context) (timestamp time.Time, success bool) {
t := c.lastRun.Load()
return time.UnixMilli(t), c.lastStatus.Load()
}
func (c *insightsCollector) sendInsights(ctx context.Context) {
count, err := c.ds.User(ctx).CountAll(model.QueryOptions{})
if err != nil {
log.Trace(ctx, "Could not check user count", err)
return
}
if count == 0 {
log.Trace(ctx, "No users found, skipping Insights data collection")
return
}
hc := &http.Client{
Timeout: consts.DefaultHttpClientTimeOut,
}
data := c.collect(ctx)
if data == nil {
return
}
body := bytes.NewReader(data)
req, err := http.NewRequestWithContext(ctx, "POST", consts.InsightsEndpoint, body)
if err != nil {
log.Trace(ctx, "Could not create Insights request", err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := hc.Do(req)
if err != nil {
log.Trace(ctx, "Could not send Insights data", err)
return
}
log.Info(ctx, "Sent Insights data (for details see http://navidrome.org/docs/getting-started/insights", "data",
string(data), "server", consts.InsightsEndpoint, "status", resp.Status)
c.lastRun.Store(time.Now().UnixMilli())
c.lastStatus.Store(resp.StatusCode < 300)
resp.Body.Close()
}
func buildInfo() (map[string]string, string) {
bInfo := map[string]string{}
var version string
if info, ok := debug.ReadBuildInfo(); ok {
for _, setting := range info.Settings {
if setting.Value == "" {
continue
}
bInfo[setting.Key] = setting.Value
}
version = info.GoVersion
}
return bInfo, version
}
func getFSInfo(path string) *insights.FSInfo {
var info insights.FSInfo
// Normalize the path
absPath, err := filepath.Abs(path)
if err != nil {
return nil
}
absPath = filepath.Clean(absPath)
fsType, err := getFilesystemType(absPath)
if err != nil {
return nil
}
info.Type = fsType
return &info
}
var staticData = sync.OnceValue(func() insights.Data {
// Basic info
data := insights.Data{
InsightsID: insightsID,
Version: consts.Version,
}
// Build info
data.Build.Settings, data.Build.GoVersion = buildInfo()
data.OS.Containerized = consts.InContainer
// OS info
data.OS.Type = runtime.GOOS
data.OS.Arch = runtime.GOARCH
data.OS.NumCPU = runtime.NumCPU()
data.OS.Version, data.OS.Distro = getOSVersion()
// FS info
data.FS.Music = getFSInfo(conf.Server.MusicFolder)
data.FS.Data = getFSInfo(conf.Server.DataFolder)
if conf.Server.CacheFolder != "" {
data.FS.Cache = getFSInfo(conf.Server.CacheFolder)
}
if conf.Server.Backup.Path != "" {
data.FS.Backup = getFSInfo(conf.Server.Backup.Path)
}
// Config info
data.Config.LogLevel = conf.Server.LogLevel
data.Config.LogFileConfigured = conf.Server.LogFile != ""
data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != ""
data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL
data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache
data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation
data.Config.EnableDownloads = conf.Server.EnableDownloads
data.Config.EnableSharing = conf.Server.EnableSharing
data.Config.EnableStarRating = conf.Server.EnableStarRating
data.Config.EnableLastFM = conf.Server.LastFM.Enabled
data.Config.EnableListenBrainz = conf.Server.ListenBrainz.Enabled
data.Config.EnableMediaFileCoverArt = conf.Server.EnableMediaFileCoverArt
data.Config.EnableSpotify = conf.Server.Spotify.ID != ""
data.Config.EnableJukebox = conf.Server.Jukebox.Enabled
data.Config.EnablePrometheus = conf.Server.Prometheus.Enabled
data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize
data.Config.ImageCacheSize = conf.Server.ImageCacheSize
data.Config.ScanSchedule = conf.Server.ScanSchedule
data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds()))
data.Config.SearchFullString = conf.Server.SearchFullString
data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime
data.Config.PreferSortTags = conf.Server.PreferSortTags
data.Config.BackupSchedule = conf.Server.Backup.Schedule
data.Config.BackupCount = conf.Server.Backup.Count
data.Config.DevActivityPanel = conf.Server.DevActivityPanel
return data
})
func (c *insightsCollector) collect(ctx context.Context) []byte {
data := staticData()
data.Uptime = time.Since(consts.ServerStart).Milliseconds() / 1000
// Library info
var err error
data.Library.Tracks, err = c.ds.MediaFile(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading tracks count", err)
}
data.Library.Albums, err = c.ds.Album(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading albums count", err)
}
data.Library.Artists, err = c.ds.Artist(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading artists count", err)
}
data.Library.Playlists, err = c.ds.Playlist(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading playlists count", err)
}
data.Library.Shares, err = c.ds.Share(ctx).CountAll()
if err != nil {
log.Trace(ctx, "Error reading shares count", err)
}
data.Library.Radios, err = c.ds.Radio(ctx).Count()
if err != nil {
log.Trace(ctx, "Error reading radios count", err)
}
data.Library.ActiveUsers, err = c.ds.User(ctx).CountAll(model.QueryOptions{
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},
})
if err != nil {
log.Trace(ctx, "Error reading active users count", err)
}
if conf.Server.DevEnablePlayerInsights {
data.Library.ActivePlayers, err = c.ds.Player(ctx).CountByClient(model.QueryOptions{
Filters: squirrel.Gt{"last_seen": time.Now().Add(-7 * 24 * time.Hour)},
})
if err != nil {
log.Trace(ctx, "Error reading active players count", err)
}
}
// Memory info
var m runtime.MemStats
runtime.ReadMemStats(&m)
data.Mem.Alloc = m.Alloc
data.Mem.TotalAlloc = m.TotalAlloc
data.Mem.Sys = m.Sys
data.Mem.NumGC = m.NumGC
// Marshal to JSON
resp, err := json.Marshal(data)
if err != nil {
log.Trace(ctx, "Could not marshal Insights data", err)
return nil
}
return resp
}

View File

@@ -0,0 +1,73 @@
package insights
type Data struct {
InsightsID string `json:"id"`
Version string `json:"version"`
Uptime int64 `json:"uptime"`
Build struct {
// build settings used by the Go compiler
Settings map[string]string `json:"settings"`
GoVersion string `json:"goVersion"`
} `json:"build"`
OS struct {
Type string `json:"type"`
Distro string `json:"distro,omitempty"`
Version string `json:"version,omitempty"`
Containerized bool `json:"containerized"`
Arch string `json:"arch"`
NumCPU int `json:"numCPU"`
} `json:"os"`
Mem struct {
Alloc uint64 `json:"alloc"`
TotalAlloc uint64 `json:"totalAlloc"`
Sys uint64 `json:"sys"`
NumGC uint32 `json:"numGC"`
} `json:"mem"`
FS struct {
Music *FSInfo `json:"music,omitempty"`
Data *FSInfo `json:"data,omitempty"`
Cache *FSInfo `json:"cache,omitempty"`
Backup *FSInfo `json:"backup,omitempty"`
} `json:"fs"`
Library struct {
Tracks int64 `json:"tracks"`
Albums int64 `json:"albums"`
Artists int64 `json:"artists"`
Playlists int64 `json:"playlists"`
Shares int64 `json:"shares"`
Radios int64 `json:"radios"`
ActiveUsers int64 `json:"activeUsers"`
ActivePlayers map[string]int64 `json:"activePlayers,omitempty"`
} `json:"library"`
Config struct {
LogLevel string `json:"logLevel,omitempty"`
LogFileConfigured bool `json:"logFileConfigured,omitempty"`
TLSConfigured bool `json:"tlsConfigured,omitempty"`
ScanSchedule string `json:"scanSchedule,omitempty"`
TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"`
ImageCacheSize string `json:"imageCacheSize,omitempty"`
EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"`
EnableDownloads bool `json:"enableDownloads,omitempty"`
EnableSharing bool `json:"enableSharing,omitempty"`
EnableStarRating bool `json:"enableStarRating,omitempty"`
EnableLastFM bool `json:"enableLastFM,omitempty"`
EnableListenBrainz bool `json:"enableListenBrainz,omitempty"`
EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"`
EnableSpotify bool `json:"enableSpotify,omitempty"`
EnableJukebox bool `json:"enableJukebox,omitempty"`
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"`
SessionTimeout uint64 `json:"sessionTimeout,omitempty"`
SearchFullString bool `json:"searchFullString,omitempty"`
RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"`
PreferSortTags bool `json:"preferSortTags,omitempty"`
BackupSchedule string `json:"backupSchedule,omitempty"`
BackupCount int `json:"backupCount,omitempty"`
DevActivityPanel bool `json:"devActivityPanel,omitempty"`
DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"`
} `json:"config"`
}
type FSInfo struct {
Type string `json:"type,omitempty"`
}

View File

@@ -0,0 +1,37 @@
package metrics
import (
"os/exec"
"strings"
"syscall"
)
func getOSVersion() (string, string) {
cmd := exec.Command("sw_vers", "-productVersion")
output, err := cmd.Output()
if err != nil {
return "", ""
}
return strings.TrimSpace(string(output)), ""
}
func getFilesystemType(path string) (string, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return "", err
}
// Convert the filesystem type name from [16]int8 to string
fsType := make([]byte, 0, 16)
for _, c := range stat.Fstypename {
if c == 0 {
break
}
fsType = append(fsType, byte(c))
}
return string(fsType), nil
}

View File

@@ -0,0 +1,9 @@
//go:build !linux && !windows && !darwin
package metrics
import "errors"
func getOSVersion() (string, string) { return "", "" }
func getFilesystemType(_ string) (string, error) { return "", errors.New("not implemented") }

View File

@@ -0,0 +1,84 @@
package metrics
import (
"fmt"
"io"
"os"
"strings"
"syscall"
)
func getOSVersion() (string, string) {
file, err := os.Open("/etc/os-release")
if err != nil {
return "", ""
}
defer file.Close()
osRelease, err := io.ReadAll(file)
if err != nil {
return "", ""
}
lines := strings.Split(string(osRelease), "\n")
version := ""
distro := ""
for _, line := range lines {
if strings.HasPrefix(line, "VERSION_ID=") {
version = strings.ReplaceAll(strings.TrimPrefix(line, "VERSION_ID="), "\"", "")
}
if strings.HasPrefix(line, "ID=") {
distro = strings.ReplaceAll(strings.TrimPrefix(line, "ID="), "\"", "")
}
}
return version, distro
}
// MountInfo represents an entry from /proc/self/mountinfo
type MountInfo struct {
MountPoint string
FSType string
}
var fsTypeMap = map[int64]string{
0x5346414f: "afs",
0x61756673: "aufs",
0x9123683E: "btrfs",
0x28cd3d45: "cramfs",
0x64626720: "debugfs",
0x0000EF53: "ext2/ext3/ext4",
0x6a656a63: "fakeowner", // FS inside a container
0x65735546: "fuse",
0x4244: "hfs",
0x9660: "iso9660",
0x3153464a: "jfs",
0x00006969: "nfs",
0x794c7630: "overlayfs",
0x9fa0: "proc",
0x517b: "smb",
0xfe534d42: "smb2",
0x73717368: "squashfs",
0x62656572: "sysfs",
0x01021994: "tmpfs",
0x01021997: "v9fs",
0x4d44: "vfat",
0x58465342: "xfs",
0x2FC12FC1: "zfs",
}
func getFilesystemType(path string) (string, error) {
var fsStat syscall.Statfs_t
err := syscall.Statfs(path, &fsStat)
if err != nil {
return "", err
}
fsType := fsStat.Type
fsName, exists := fsTypeMap[int64(fsType)] //nolint:unconvert
if !exists {
fsName = fmt.Sprintf("unknown(0x%x)", fsType)
}
return fsName, nil
}

View File

@@ -0,0 +1,53 @@
package metrics
import (
"os/exec"
"regexp"
"golang.org/x/sys/windows"
)
// Ex: Microsoft Windows [Version 10.0.26100.1742]
var winVerRegex = regexp.MustCompile(`Microsoft Windows \[Version ([\d\.]+)\]`)
func getOSVersion() (version string, _ string) {
cmd := exec.Command("cmd", "/c", "ver")
output, err := cmd.Output()
if err != nil {
return "", ""
}
matches := winVerRegex.FindStringSubmatch(string(output))
if len(matches) != 2 {
return string(output), ""
}
return matches[1], ""
}
func getFilesystemType(path string) (string, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return "", err
}
var volumeName, filesystemName [windows.MAX_PATH + 1]uint16
var serialNumber uint32
var maxComponentLen, filesystemFlags uint32
err = windows.GetVolumeInformation(
pathPtr,
&volumeName[0],
windows.MAX_PATH,
&serialNumber,
&maxComponentLen,
&filesystemFlags,
&filesystemName[0],
windows.MAX_PATH)
if err != nil {
return "", err
}
return windows.UTF16ToString(filesystemName[:]), nil
}

View File

@@ -1,4 +1,4 @@
package core
package metrics
import (
"context"

299
core/playback/device.go Normal file
View File

@@ -0,0 +1,299 @@
package playback
import (
"context"
"errors"
"fmt"
"sync"
"github.com/navidrome/navidrome/core/playback/mpv"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type Track interface {
IsPlaying() bool
SetVolume(value float32) // Used to control the playback volume. A float value between 0.0 and 1.0.
Pause()
Unpause()
Position() int
SetPosition(offset int) error
Close()
String() string
}
type playbackDevice struct {
serviceCtx context.Context
ParentPlaybackServer PlaybackServer
Default bool
User string
Name string
DeviceName string
PlaybackQueue *Queue
Gain float32
PlaybackDone chan bool
ActiveTrack Track
startTrackSwitcher sync.Once
}
type DeviceStatus struct {
CurrentIndex int
Playing bool
Gain float32
Position int
}
const DefaultGain float32 = 1.0
func (pd *playbackDevice) getStatus() DeviceStatus {
pos := 0
if pd.ActiveTrack != nil {
pos = pd.ActiveTrack.Position()
}
return DeviceStatus{
CurrentIndex: pd.PlaybackQueue.Index,
Playing: pd.isPlaying(),
Gain: pd.Gain,
Position: pos,
}
}
// NewPlaybackDevice creates a new playback device which implements all the basic Jukebox mode commands defined here:
// http://www.subsonic.org/pages/api.jsp#jukeboxControl
// Starts the trackSwitcher goroutine for the device.
func NewPlaybackDevice(ctx context.Context, playbackServer PlaybackServer, name string, deviceName string) *playbackDevice {
return &playbackDevice{
serviceCtx: ctx,
ParentPlaybackServer: playbackServer,
User: "",
Name: name,
DeviceName: deviceName,
Gain: DefaultGain,
PlaybackQueue: NewQueue(),
PlaybackDone: make(chan bool),
}
}
func (pd *playbackDevice) String() string {
return fmt.Sprintf("Name: %s, Gain: %.4f, Loaded track: %s", pd.Name, pd.Gain, pd.ActiveTrack)
}
func (pd *playbackDevice) Get(ctx context.Context) (model.MediaFiles, DeviceStatus, error) {
log.Debug(ctx, "Processing Get action", "device", pd)
return pd.PlaybackQueue.Get(), pd.getStatus(), nil
}
func (pd *playbackDevice) Status(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, fmt.Sprintf("processing Status action on: %s, queue: %s", pd, pd.PlaybackQueue))
return pd.getStatus(), nil
}
// Set is similar to a clear followed by a add, but will not change the currently playing track.
func (pd *playbackDevice) Set(ctx context.Context, ids []string) (DeviceStatus, error) {
log.Debug(ctx, "Processing Set action", "ids", ids, "device", pd)
_, err := pd.Clear(ctx)
if err != nil {
log.Error(ctx, "error setting tracks", ids)
return pd.getStatus(), err
}
return pd.Add(ctx, ids)
}
func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, "Processing Start action", "device", pd)
pd.startTrackSwitcher.Do(func() {
log.Info(ctx, "Starting trackSwitcher goroutine")
// Start one trackSwitcher goroutine with each device
go func() {
pd.trackSwitcherGoroutine()
}()
})
if pd.ActiveTrack != nil {
if pd.isPlaying() {
log.Debug("trying to start an already playing track")
} else {
pd.ActiveTrack.Unpause()
}
} else {
if !pd.PlaybackQueue.IsEmpty() {
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
if err != nil {
return pd.getStatus(), err
}
pd.ActiveTrack.Unpause()
}
}
return pd.getStatus(), nil
}
func (pd *playbackDevice) Stop(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, "Processing Stop action", "device", pd)
if pd.ActiveTrack != nil {
pd.ActiveTrack.Pause()
}
return pd.getStatus(), nil
}
func (pd *playbackDevice) Skip(ctx context.Context, index int, offset int) (DeviceStatus, error) {
log.Debug(ctx, "Processing Skip action", "index", index, "offset", offset, "device", pd)
wasPlaying := pd.isPlaying()
if pd.ActiveTrack != nil && wasPlaying {
pd.ActiveTrack.Pause()
}
if index != pd.PlaybackQueue.Index && pd.ActiveTrack != nil {
pd.ActiveTrack.Close()
pd.ActiveTrack = nil
}
if pd.ActiveTrack == nil {
err := pd.switchActiveTrackByIndex(index)
if err != nil {
return pd.getStatus(), err
}
}
err := pd.ActiveTrack.SetPosition(offset)
if err != nil {
log.Error(ctx, "error setting position", err)
return pd.getStatus(), err
}
if wasPlaying {
_, err = pd.Start(ctx)
if err != nil {
log.Error(ctx, "error starting new track after skipping")
return pd.getStatus(), err
}
}
return pd.getStatus(), nil
}
func (pd *playbackDevice) Add(ctx context.Context, ids []string) (DeviceStatus, error) {
log.Debug(ctx, "Processing Add action", "ids", ids, "device", pd)
if len(ids) < 1 {
return pd.getStatus(), nil
}
items := model.MediaFiles{}
for _, id := range ids {
mf, err := pd.ParentPlaybackServer.GetMediaFile(id)
if err != nil {
return DeviceStatus{}, err
}
log.Debug(ctx, "Found mediafile: "+mf.Path)
items = append(items, *mf)
}
pd.PlaybackQueue.Add(items)
return pd.getStatus(), nil
}
func (pd *playbackDevice) Clear(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, "Processing Clear action", "device", pd)
if pd.ActiveTrack != nil {
pd.ActiveTrack.Pause()
pd.ActiveTrack.Close()
pd.ActiveTrack = nil
}
pd.PlaybackQueue.Clear()
return pd.getStatus(), nil
}
func (pd *playbackDevice) Remove(ctx context.Context, index int) (DeviceStatus, error) {
log.Debug(ctx, "Processing Remove action", "index", index, "device", pd)
// pausing if attempting to remove running track
if pd.isPlaying() && pd.PlaybackQueue.Index == index {
_, err := pd.Stop(ctx)
if err != nil {
log.Error(ctx, "error stopping running track")
return pd.getStatus(), err
}
}
if index > -1 && index < pd.PlaybackQueue.Size() {
pd.PlaybackQueue.Remove(index)
} else {
log.Error(ctx, "Index to remove out of range: "+fmt.Sprint(index))
}
return pd.getStatus(), nil
}
func (pd *playbackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) {
log.Debug(ctx, "Processing Shuffle action", "device", pd)
if pd.PlaybackQueue.Size() > 1 {
pd.PlaybackQueue.Shuffle()
}
return pd.getStatus(), nil
}
// SetGain is used to control the playback volume. A float value between 0.0 and 1.0.
func (pd *playbackDevice) SetGain(ctx context.Context, gain float32) (DeviceStatus, error) {
log.Debug(ctx, "Processing SetGain action", "newGain", gain, "device", pd)
if pd.ActiveTrack != nil {
pd.ActiveTrack.SetVolume(gain)
}
pd.Gain = gain
return pd.getStatus(), nil
}
func (pd *playbackDevice) isPlaying() bool {
return pd.ActiveTrack != nil && pd.ActiveTrack.IsPlaying()
}
func (pd *playbackDevice) trackSwitcherGoroutine() {
log.Debug("Started trackSwitcher goroutine", "device", pd)
for {
select {
case <-pd.PlaybackDone:
log.Debug("Track switching detected")
if pd.ActiveTrack != nil {
pd.ActiveTrack.Close()
pd.ActiveTrack = nil
}
if !pd.PlaybackQueue.IsAtLastElement() {
pd.PlaybackQueue.IncreaseIndex()
log.Debug("Switching to next song", "queue", pd.PlaybackQueue.String())
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
if err != nil {
log.Error("Error switching track", err)
}
if pd.ActiveTrack != nil {
pd.ActiveTrack.Unpause()
}
} else {
log.Debug("There is no song left in the playlist. Finish.")
}
case <-pd.serviceCtx.Done():
log.Debug("Stopping trackSwitcher goroutine", "device", pd.Name)
return
}
}
}
func (pd *playbackDevice) switchActiveTrackByIndex(index int) error {
pd.PlaybackQueue.SetIndex(index)
currentTrack := pd.PlaybackQueue.Current()
if currentTrack == nil {
return errors.New("could not get current track")
}
track, err := mpv.NewTrack(pd.serviceCtx, pd.PlaybackDone, pd.DeviceName, *currentTrack)
if err != nil {
return err
}
pd.ActiveTrack = track
pd.ActiveTrack.SetVolume(pd.Gain)
return nil
}

123
core/playback/mpv/mpv.go Normal file
View File

@@ -0,0 +1,123 @@
package mpv
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
func start(ctx context.Context, args []string) (Executor, error) {
log.Debug("Executing mpv command", "cmd", args)
j := Executor{args: args}
j.PipeReader, j.out = io.Pipe()
err := j.start(ctx)
if err != nil {
return Executor{}, err
}
go j.wait()
return j, nil
}
func (j *Executor) Cancel() error {
if j.cmd != nil {
return j.cmd.Cancel()
}
return fmt.Errorf("there is non command to cancel")
}
type Executor struct {
*io.PipeReader
out *io.PipeWriter
args []string
cmd *exec.Cmd
}
func (j *Executor) start(ctx context.Context) error {
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out
if log.IsGreaterOrEqualTo(log.LevelTrace) {
cmd.Stderr = os.Stderr
} else {
cmd.Stderr = io.Discard
}
j.cmd = cmd
if err := cmd.Start(); err != nil {
return fmt.Errorf("starting cmd: %w", err)
}
return nil
}
func (j *Executor) wait() {
if err := j.cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
} else {
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
}
return
}
_ = j.out.Close()
}
// Path will always be an absolute path
func createMPVCommand(deviceName string, filename string, socketName string) []string {
split := strings.Split(fixCmd(conf.Server.MPVCmdTemplate), " ")
for i, s := range split {
s = strings.ReplaceAll(s, "%d", deviceName)
s = strings.ReplaceAll(s, "%f", filename)
s = strings.ReplaceAll(s, "%s", socketName)
split[i] = s
}
return split
}
func fixCmd(cmd string) string {
split := strings.Split(cmd, " ")
var result []string
cmdPath, _ := mpvCommand()
for _, s := range split {
if s == "mpv" || s == "mpv.exe" {
result = append(result, cmdPath)
} else {
result = append(result, s)
}
}
return strings.Join(result, " ")
}
// This is a 1:1 copy of the stuff in ffmpeg.go, need to be unified.
func mpvCommand() (string, error) {
mpvOnce.Do(func() {
if conf.Server.MPVPath != "" {
mpvPath = conf.Server.MPVPath
mpvPath, mpvErr = exec.LookPath(mpvPath)
} else {
mpvPath, mpvErr = exec.LookPath("mpv")
if errors.Is(mpvErr, exec.ErrDot) {
log.Trace("mpv found in current folder '.'")
mpvPath, mpvErr = exec.LookPath("./mpv")
}
}
if mpvErr == nil {
log.Info("Found mpv", "path", mpvPath)
return
}
})
return mpvPath, mpvErr
}
var (
mpvOnce sync.Once
mpvPath string
mpvErr error
)

View File

@@ -0,0 +1,22 @@
//go:build !windows
package mpv
import (
"os"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils"
)
func socketName(prefix, suffix string) string {
return utils.TempFileName(prefix, suffix)
}
func removeSocket(socketName string) {
log.Debug("Removing socketfile", "socketfile", socketName)
err := os.Remove(socketName)
if err != nil {
log.Error("Error cleaning up socketfile", "socketfile", socketName, err)
}
}

View File

@@ -0,0 +1,19 @@
//go:build windows
package mpv
import (
"path/filepath"
"github.com/google/uuid"
)
func socketName(prefix, suffix string) string {
// Windows needs to use a named pipe for the socket
// see https://mpv.io/manual/master#using-mpv-from-other-programs-or-scripts
return filepath.Join(`\\.\pipe\mpvsocket`, prefix+uuid.NewString()+suffix)
}
func removeSocket(string) {
// Windows automatically handles cleaning up named pipe
}

220
core/playback/mpv/track.go Normal file
View File

@@ -0,0 +1,220 @@
package mpv
// Audio-playback using mpv media-server. See mpv.io
// https://github.com/dexterlb/mpvipc
// https://mpv.io/manual/master/#json-ipc
// https://mpv.io/manual/master/#properties
import (
"context"
"fmt"
"os"
"time"
"github.com/dexterlb/mpvipc"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type MpvTrack struct {
MediaFile model.MediaFile
PlaybackDone chan bool
Conn *mpvipc.Connection
IPCSocketName string
Exe *Executor
CloseCalled bool
}
func NewTrack(ctx context.Context, playbackDoneChannel chan bool, deviceName string, mf model.MediaFile) (*MpvTrack, error) {
log.Debug("Loading track", "trackPath", mf.Path, "mediaType", mf.ContentType())
if _, err := mpvCommand(); err != nil {
return nil, err
}
tmpSocketName := socketName("mpv-ctrl-", ".socket")
args := createMPVCommand(deviceName, mf.Path, tmpSocketName)
exe, err := start(ctx, args)
if err != nil {
log.Error("Error starting mpv process", err)
return nil, err
}
// wait for socket to show up
err = waitForSocket(tmpSocketName, 3*time.Second, 100*time.Millisecond)
if err != nil {
log.Error("Error or timeout waiting for control socket", "socketname", tmpSocketName, err)
return nil, err
}
conn := mpvipc.NewConnection(tmpSocketName)
err = conn.Open()
if err != nil {
log.Error("Error opening new connection", err)
return nil, err
}
theTrack := &MpvTrack{MediaFile: mf, PlaybackDone: playbackDoneChannel, Conn: conn, IPCSocketName: tmpSocketName, Exe: &exe, CloseCalled: false}
go func() {
conn.WaitUntilClosed()
log.Info("Hitting end-of-stream, signalling on channel")
if !theTrack.CloseCalled {
playbackDoneChannel <- true
}
}()
return theTrack, nil
}
func (t *MpvTrack) String() string {
return fmt.Sprintf("Name: %s, Socket: %s", t.MediaFile.Path, t.IPCSocketName)
}
// Used to control the playback volume. A float value between 0.0 and 1.0.
func (t *MpvTrack) SetVolume(value float32) {
// mpv's volume as described in the --volume parameter:
// Set the startup volume. 0 means silence, 100 means no volume reduction or amplification.
// Negative values can be passed for compatibility, but are treated as 0.
log.Debug("Setting volume", "volume", value, "track", t)
vol := int(value * 100)
err := t.Conn.Set("volume", vol)
if err != nil {
log.Error("Error setting volume", "volume", value, "track", t, err)
}
}
func (t *MpvTrack) Unpause() {
log.Debug("Unpausing track", "track", t)
err := t.Conn.Set("pause", false)
if err != nil {
log.Error("Error unpausing track", "track", t, err)
}
}
func (t *MpvTrack) Pause() {
log.Debug("Pausing track", "track", t)
err := t.Conn.Set("pause", true)
if err != nil {
log.Error("Error pausing track", "track", t, err)
}
}
func (t *MpvTrack) Close() {
log.Debug("Closing resources", "track", t)
t.CloseCalled = true
// trying to shutdown mpv process using socket
if t.isSocketFilePresent() {
log.Debug("sending shutdown command")
_, err := t.Conn.Call("quit")
if err != nil {
log.Warn("Error sending quit command to mpv-ipc socket", err)
if t.Exe != nil {
log.Debug("cancelling executor")
err = t.Exe.Cancel()
if err != nil {
log.Warn("Error canceling executor", err)
}
}
}
}
if t.isSocketFilePresent() {
removeSocket(t.IPCSocketName)
}
}
func (t *MpvTrack) isSocketFilePresent() bool {
if len(t.IPCSocketName) < 1 {
return false
}
fileInfo, err := os.Stat(t.IPCSocketName)
return err == nil && fileInfo != nil && !fileInfo.IsDir()
}
// Position returns the playback position in seconds.
// Every now and then the mpv IPC interface returns "mpv error: property unavailable"
// in this case we have to retry
func (t *MpvTrack) Position() int {
retryCount := 0
for {
position, err := t.Conn.Get("time-pos")
if err != nil && err.Error() == "mpv error: property unavailable" {
retryCount += 1
log.Debug("Got mpv error, retrying...", "retries", retryCount, err)
if retryCount > 5 {
return 0
}
time.Sleep(time.Duration(retryCount) * time.Millisecond)
continue
}
if err != nil {
log.Error("Error getting position in track", "track", t, err)
return 0
}
pos, ok := position.(float64)
if !ok {
log.Error("Could not cast position from mpv into float64", "position", position, "track", t)
return 0
} else {
return int(pos)
}
}
}
func (t *MpvTrack) SetPosition(offset int) error {
log.Debug("Setting position", "offset", offset, "track", t)
pos := t.Position()
if pos == offset {
log.Debug("No position difference, skipping operation", "track", t)
return nil
}
err := t.Conn.Set("time-pos", float64(offset))
if err != nil {
log.Error("Could not set the position in track", "track", t, "offset", offset, err)
return err
}
return nil
}
func (t *MpvTrack) IsPlaying() bool {
log.Debug("Checking if track is playing", "track", t)
pausing, err := t.Conn.Get("pause")
if err != nil {
log.Error("Problem getting paused status", "track", t, err)
return false
}
pause, ok := pausing.(bool)
if !ok {
log.Error("Could not cast pausing to boolean", "track", t, "value", pausing)
return false
}
return !pause
}
func waitForSocket(path string, timeout time.Duration, pause time.Duration) error {
start := time.Now()
end := start.Add(timeout)
var retries int = 0
for {
fileInfo, err := os.Stat(path)
if err == nil && fileInfo != nil && !fileInfo.IsDir() {
log.Debug("Socket found", "retries", retries, "waitTime", time.Since(start))
return nil
}
if time.Now().After(end) {
return fmt.Errorf("timeout reached: %s", timeout)
}
time.Sleep(pause)
retries += 1
}
}

View File

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

View File

@@ -0,0 +1,127 @@
// Package playback implements audio playback using PlaybackDevices. It is used to implement the Jukebox mode in turn.
// It makes use of the MPV library to do the playback. Major parts are:
// - decoder which includes decoding and transcoding of various audio file formats
// - device implementing the basic functions to work with audio devices like set, play, stop, skip, ...
// - queue a simple playlist
package playback
import (
"context"
"fmt"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/singleton"
)
type PlaybackServer interface {
Run(ctx context.Context) error
GetDeviceForUser(user string) (*playbackDevice, error)
GetMediaFile(id string) (*model.MediaFile, error)
}
type playbackServer struct {
ctx *context.Context
datastore model.DataStore
playbackDevices []playbackDevice
}
// GetInstance returns the playback-server singleton
func GetInstance(ds model.DataStore) PlaybackServer {
return singleton.GetInstance(func() *playbackServer {
return &playbackServer{datastore: ds}
})
}
// Run starts the playback server which serves request until canceled using the given context
func (ps *playbackServer) Run(ctx context.Context) error {
ps.ctx = &ctx
devices, err := ps.initDeviceStatus(ctx, conf.Server.Jukebox.Devices, conf.Server.Jukebox.Default)
if err != nil {
return err
}
ps.playbackDevices = devices
log.Info(ctx, fmt.Sprintf("%d audio devices found", len(devices)))
defaultDevice, _ := ps.getDefaultDevice()
log.Info(ctx, "Using audio device: "+defaultDevice.DeviceName)
<-ctx.Done()
// Should confirm all subprocess are terminated before returning
return nil
}
func (ps *playbackServer) initDeviceStatus(ctx context.Context, devices []conf.AudioDeviceDefinition, defaultDevice string) ([]playbackDevice, error) {
pbDevices := make([]playbackDevice, max(1, len(devices)))
defaultDeviceFound := false
if defaultDevice == "" {
// if there are no devices given and no default device, we create a synthetic device named "auto"
if len(devices) == 0 {
pbDevices[0] = *NewPlaybackDevice(ctx, ps, "auto", "auto")
}
// if there is but only one entry and no default given, just use that.
if len(devices) == 1 {
if len(devices[0]) != 2 {
return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(devices[0]))
}
pbDevices[0] = *NewPlaybackDevice(ctx, ps, devices[0][0], devices[0][1])
}
if len(devices) > 1 {
return []playbackDevice{}, fmt.Errorf("number of audio device found is %d, but no default device defined. Set Jukebox.Default", len(devices))
}
pbDevices[0].Default = true
return pbDevices, nil
}
for idx, audioDevice := range devices {
if len(audioDevice) != 2 {
return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(audioDevice))
}
pbDevices[idx] = *NewPlaybackDevice(ctx, ps, audioDevice[0], audioDevice[1])
if audioDevice[0] == defaultDevice {
pbDevices[idx].Default = true
defaultDeviceFound = true
}
}
if !defaultDeviceFound {
return []playbackDevice{}, fmt.Errorf("default device name not found: %s ", defaultDevice)
}
return pbDevices, nil
}
func (ps *playbackServer) getDefaultDevice() (*playbackDevice, error) {
for idx := range ps.playbackDevices {
if ps.playbackDevices[idx].Default {
return &ps.playbackDevices[idx], nil
}
}
return nil, fmt.Errorf("no default device found")
}
// GetMediaFile retrieves the MediaFile given by the id parameter
func (ps *playbackServer) GetMediaFile(id string) (*model.MediaFile, error) {
return ps.datastore.MediaFile(*ps.ctx).Get(id)
}
// GetDeviceForUser returns the audio playback device for the given user. As of now this is but only the default device.
func (ps *playbackServer) GetDeviceForUser(user string) (*playbackDevice, error) {
log.Debug("Processing GetDevice", "user", user)
// README: here we might plug-in the user-device mapping one fine day
device, err := ps.getDefaultDevice()
if err != nil {
return nil, err
}
device.User = user
return device, nil
}

136
core/playback/queue.go Normal file
View File

@@ -0,0 +1,136 @@
package playback
import (
"fmt"
"math/rand"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type Queue struct {
Index int
Items model.MediaFiles
}
func NewQueue() *Queue {
return &Queue{
Index: -1,
Items: model.MediaFiles{},
}
}
func (pd *Queue) String() string {
filenames := ""
for idx, item := range pd.Items {
filenames += fmt.Sprint(idx) + ":" + item.Path + " "
}
return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames)
}
// returns the current mediafile or nil
func (pd *Queue) Current() *model.MediaFile {
if pd.Index == -1 {
return nil
}
if pd.Index >= len(pd.Items) {
log.Error("internal error: current song index out of bounds", "idx", pd.Index, "length", len(pd.Items))
return nil
}
return &pd.Items[pd.Index]
}
// returns the whole queue
func (pd *Queue) Get() model.MediaFiles {
return pd.Items
}
func (pd *Queue) Size() int {
return len(pd.Items)
}
func (pd *Queue) IsEmpty() bool {
return len(pd.Items) < 1
}
// set is similar to a clear followed by a add, but will not change the currently playing track.
func (pd *Queue) Set(items model.MediaFiles) {
pd.Clear()
pd.Items = append(pd.Items, items...)
}
// adding mediafiles to the queue
func (pd *Queue) Add(items model.MediaFiles) {
pd.Items = append(pd.Items, items...)
if pd.Index == -1 && len(pd.Items) > 0 {
pd.Index = 0
}
}
// empties whole queue
func (pd *Queue) Clear() {
pd.Index = -1
pd.Items = nil
}
// idx Zero-based index of the song to skip to or remove.
func (pd *Queue) Remove(idx int) {
current := pd.Current()
backupID := ""
if current != nil {
backupID = current.ID
}
pd.Items = append(pd.Items[:idx], pd.Items[idx+1:]...)
var err error
pd.Index, err = pd.getMediaFileIndexByID(backupID)
if err != nil {
// we seem to have deleted the current id, setting to default:
pd.Index = -1
}
}
func (pd *Queue) Shuffle() {
current := pd.Current()
backupID := ""
if current != nil {
backupID = current.ID
}
rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] })
var err error
pd.Index, err = pd.getMediaFileIndexByID(backupID)
if err != nil {
log.Error("Could not find ID while shuffling: %s", backupID)
}
}
func (pd *Queue) getMediaFileIndexByID(id string) (int, error) {
for idx, item := range pd.Items {
if item.ID == id {
return idx, nil
}
}
return -1, fmt.Errorf("ID not found in playlist: %s", id)
}
// Sets the index to a new, valid value inside the Items. Values lower than zero are going to be zero,
// values above will be limited by number of items.
func (pd *Queue) SetIndex(idx int) {
pd.Index = max(0, min(idx, len(pd.Items)-1))
}
// Are we at the last track?
func (pd *Queue) IsAtLastElement() bool {
return (pd.Index + 1) >= len(pd.Items)
}
// Goto next index
func (pd *Queue) IncreaseIndex() {
if !pd.IsAtLastElement() {
pd.SetIndex(pd.Index + 1)
}
}

121
core/playback/queue_test.go Normal file
View File

@@ -0,0 +1,121 @@
package playback
import (
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Queues", func() {
var queue *Queue
BeforeEach(func() {
queue = NewQueue()
})
Describe("use empty queue", func() {
It("is empty", func() {
Expect(queue.Items).To(BeEmpty())
Expect(queue.Index).To(Equal(-1))
})
})
Describe("Operate on small queue", func() {
BeforeEach(func() {
mfs := model.MediaFiles{
{
ID: "1", Artist: "Queen", Compilation: false, Path: "/music1/hammer.mp3",
},
{
ID: "2", Artist: "Vinyard Rose", Compilation: false, Path: "/music1/cassidy.mp3",
},
}
queue.Add(mfs)
})
It("contains the preloaded data", func() {
Expect(queue.Get).ToNot(BeNil())
Expect(queue.Size()).To(Equal(2))
})
It("could read data by ID", func() {
idx, err := queue.getMediaFileIndexByID("1")
Expect(err).ToNot(HaveOccurred())
Expect(idx).ToNot(BeNil())
Expect(idx).To(Equal(0))
queue.SetIndex(idx)
mf := queue.Current()
Expect(mf).ToNot(BeNil())
Expect(mf.ID).To(Equal("1"))
Expect(mf.Artist).To(Equal("Queen"))
Expect(mf.Path).To(Equal("/music1/hammer.mp3"))
})
})
Describe("Read/Write operations", func() {
BeforeEach(func() {
mfs := model.MediaFiles{
{
ID: "1", Artist: "Queen", Compilation: false, Path: "/music1/hammer.mp3",
},
{
ID: "2", Artist: "Vinyard Rose", Compilation: false, Path: "/music1/cassidy.mp3",
},
{
ID: "3", Artist: "Pink Floyd", Compilation: false, Path: "/music1/time.mp3",
},
{
ID: "4", Artist: "Mike Oldfield", Compilation: false, Path: "/music1/moonlight-shadow.mp3",
},
{
ID: "5", Artist: "Red Hot Chili Peppers", Compilation: false, Path: "/music1/californication.mp3",
},
}
queue.Add(mfs)
})
It("contains the preloaded data", func() {
Expect(queue.Get).ToNot(BeNil())
Expect(queue.Size()).To(Equal(5))
})
It("could read data by ID", func() {
idx, err := queue.getMediaFileIndexByID("5")
Expect(err).ToNot(HaveOccurred())
Expect(idx).ToNot(BeNil())
Expect(idx).To(Equal(4))
queue.SetIndex(idx)
mf := queue.Current()
Expect(mf).ToNot(BeNil())
Expect(mf.ID).To(Equal("5"))
Expect(mf.Artist).To(Equal("Red Hot Chili Peppers"))
Expect(mf.Path).To(Equal("/music1/californication.mp3"))
})
It("could shuffle the data correctly", func() {
queue.Shuffle()
Expect(queue.Size()).To(Equal(5))
})
It("could remove entries correctly", func() {
queue.Remove(0)
Expect(queue.Size()).To(Equal(4))
queue.Remove(3)
Expect(queue.Size()).To(Equal(3))
})
It("clear the whole thing on request", func() {
Expect(queue.Size()).To(Equal(5))
queue.Clear()
Expect(queue.Size()).To(Equal(0))
})
})
})

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
package core
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
@@ -14,15 +12,19 @@ import (
"strings"
"time"
"github.com/RaveNoX/go-jsoncommentstrip"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/slice"
)
type Playlists interface {
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
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
}
type playlists struct {
@@ -47,6 +49,26 @@ func (s *playlists) ImportFile(ctx context.Context, dir string, fname string) (*
return pls, err
}
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
owner, _ := request.UserFrom(ctx)
pls := &model.Playlist{
OwnerID: owner.ID,
Public: false,
Sync: false,
}
err := s.parseM3U(ctx, pls, "", reader)
if err != nil {
log.Error(ctx, "Error parsing playlist", err)
return nil, err
}
err = s.ds.Playlist(ctx).Put(pls)
if err != nil {
log.Error(ctx, "Error saving playlist", err)
return nil, err
}
return pls, nil
}
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, baseDir string) (*model.Playlist, error) {
pls, err := s.newSyncedPlaylist(baseDir, playlistFile)
if err != nil {
@@ -62,10 +84,11 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, base
extension := strings.ToLower(filepath.Ext(playlistFile))
switch extension {
case ".nsp":
return s.parseNSP(ctx, pls, file)
err = s.parseNSP(ctx, pls, file)
default:
return s.parseM3U(ctx, pls, baseDir, file)
err = s.parseM3U(ctx, pls, baseDir, file)
}
return pls, err
}
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
@@ -89,13 +112,14 @@ func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*mod
return pls, nil
}
func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) (*model.Playlist, error) {
func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) error {
nsp := &nspFile{}
dec := json.NewDecoder(file)
reader := jsoncommentstrip.NewReader(file)
dec := json.NewDecoder(reader)
err := dec.Decode(nsp)
if err != nil {
log.Error(ctx, "Error parsing SmartPlaylist", "playlist", pls.Name, err)
return nil, err
return err
}
pls.Rules = &nsp.Criteria
if nsp.Name != "" {
@@ -104,38 +128,58 @@ func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.R
if nsp.Comment != "" {
pls.Comment = nsp.Comment
}
return pls, nil
return nil
}
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, file io.Reader) (*model.Playlist, error) {
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, reader io.Reader) error {
mediaFileRepository := s.ds.MediaFile(ctx)
scanner := bufio.NewScanner(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
for lines := range slice.CollectChunks(slice.LinesFrom(reader), 400) {
filteredLines := make([]string, 0, len(lines))
for _, line := range lines {
line := strings.TrimSpace(line)
if strings.HasPrefix(line, "#PLAYLIST:") {
pls.Name = line[len("#PLAYLIST:"):]
continue
}
// Skip empty lines and extended info
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "file://") {
line = strings.TrimPrefix(line, "file://")
line, _ = url.QueryUnescape(line)
}
if baseDir != "" && !filepath.IsAbs(line) {
line = filepath.Join(baseDir, line)
}
filteredLines = append(filteredLines, line)
}
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)
found, err := mediaFileRepository.FindByPaths(filteredLines)
if err != nil {
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path, err)
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
continue
}
mfs = append(mfs, *mf)
existing := make(map[string]int, len(found))
for idx := range found {
existing[strings.ToLower(found[idx].Path)] = idx
}
for _, path := range filteredLines {
idx, ok := existing[strings.ToLower(path)]
if ok {
mfs = append(mfs, found[idx])
} else {
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path)
}
}
}
if pls.Name == "" {
pls.Name = time.Now().Format(time.RFC3339)
}
pls.Tracks = nil
pls.AddMediaFiles(mfs)
return pls, scanner.Err()
return nil
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
@@ -157,38 +201,15 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
newPls.Comment = pls.Comment
newPls.OwnerID = pls.OwnerID
newPls.Public = pls.Public
newPls.EvaluatedAt = time.Time{}
newPls.EvaluatedAt = &time.Time{}
} else {
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
newPls.OwnerID = owner.ID
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
}
return s.ds.Playlist(ctx).Put(newPls)
}
// From https://stackoverflow.com/a/41433698
func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
if data[i] == '\n' {
// We have a line terminated by single newline.
return i + 1, data[0:i], nil
}
advance = i + 1
if len(data) > i+1 && data[i+1] == '\n' {
advance += 1
}
return advance, data[0:i], nil
}
// If we're at EOF, we have a final, non-terminated line. Return it.
if atEOF {
return len(data), data, nil
}
// Request more data.
return 0, nil, nil
}
func (s *playlists) Update(ctx context.Context, playlistID string,
name *string, comment *string, public *bool,
idsToAdd []string, idxToRemove []int) error {
@@ -199,13 +220,17 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
var pls *model.Playlist
var err error
repo := tx.Playlist(ctx)
tracks := repo.Tracks(playlistID, true)
if tracks == nil {
return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID)
}
if needsTrackRefresh {
pls, err = repo.GetWithTracks(playlistID, true)
pls.RemoveTracks(idxToRemove)
pls.AddTracks(idsToAdd)
} else {
if len(idsToAdd) > 0 {
_, err = repo.Tracks(playlistID, true).Add(idsToAdd)
_, err = tracks.Add(idsToAdd)
if err != nil {
return err
}
@@ -232,7 +257,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
}
// Special case: The playlist is now empty
if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
if err = repo.Tracks(playlistID, true).DeleteAll(); err != nil {
if err = tracks.DeleteAll(); err != nil {
return err
}
}

View File

@@ -2,66 +2,198 @@ package core
import (
"context"
"os"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Playlists", func() {
var ds model.DataStore
var ds *tests.MockDataStore
var ps Playlists
var mp mockedPlaylist
ctx := context.Background()
BeforeEach(func() {
mp = mockedPlaylist{}
ds = &tests.MockDataStore{
MockedMediaFile: &mockedMediaFile{},
MockedPlaylist: &mockedPlaylist{},
MockedPlaylist: &mp,
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
Describe("ImportFile", func() {
BeforeEach(func() {
ps = NewPlaylists(ds)
ds.MockedMediaFile = &mockedMediaFileRepo{}
})
Describe("M3U", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
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"))
Expect(mp.last).To(Equal(pls))
})
It("parses playlists using LF ending", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "lf-ended.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
It("parses playlists using CR ending (old Mac format)", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "cr-ended.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(2))
})
})
Describe("NSP", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/recently_played.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(mp.last).To(Equal(pls))
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("Recently Played"))
Expect(pls.Comment).To(Equal("Recently played tracks"))
Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
Expect(pls.Rules.Order).To(Equal("desc"))
Expect(pls.Rules.Limit).To(Equal(100))
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
})
})
})
Describe("ImportM3U", func() {
var repo *mockedMediaFileFromListRepo
BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo
ps = NewPlaylists(ds)
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u")
Expect(err).To(BeNil())
repo.data = []string{
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
"/tests/fixtures/01 Invisible (RED) Edit Version.mp3",
}
f, _ := os.Open("tests/fixtures/playlists/pls-with-name.m3u")
defer f.Close()
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("playlist 1"))
Expect(pls.Sync).To(BeFalse())
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3"))
Expect(mp.last).To(Equal(pls))
f.Close()
})
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("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
repo.data = []string{
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
"/tests/fixtures/01 Invisible (RED) Edit Version.mp3",
}
f, _ := os.Open("tests/fixtures/playlists/pls-without-name.m3u")
defer f.Close()
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
_, err = time.Parse(time.RFC3339, pls.Name)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(3))
})
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))
It("returns only tracks that exist in the database and in the same other as the m3u", func() {
repo.data = []string{
"test1.mp3",
"test2.mp3",
"test3.mp3",
}
m3u := strings.Join([]string{
"test3.mp3",
"test1.mp3",
"test4.mp3",
"test2.mp3",
}, "\n")
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(3))
Expect(pls.Tracks[0].Path).To(Equal("test3.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("test1.mp3"))
Expect(pls.Tracks[2].Path).To(Equal("test2.mp3"))
})
It("is case-insensitive when comparing paths", func() {
repo.data = []string{
"tEsT1.Mp3",
}
m3u := strings.Join([]string{
"TeSt1.mP3",
}, "\n")
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal("tEsT1.Mp3"))
})
})
})
type mockedMediaFile struct {
// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
type mockedMediaFileRepo struct {
model.MediaFileRepository
}
func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) {
return &model.MediaFile{
ID: "123",
Path: s,
}, nil
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
for idx, path := range paths {
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: path,
})
}
return mfs, nil
}
// mockedMediaFileFromListRepo's FindByPaths method returns a list of MediaFiles based on the data field
type mockedMediaFileFromListRepo struct {
model.MediaFileRepository
data []string
}
func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
var mfs model.MediaFiles
for idx, path := range r.data {
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: path,
})
}
return mfs, nil
}
type mockedPlaylist struct {
last *model.Playlist
model.PlaylistRepository
}
@@ -69,6 +201,7 @@ func (r *mockedPlaylist) FindByPath(string) (*model.Playlist, error) {
return nil, model.ErrNotFound
}
func (r *mockedPlaylist) Put(*model.Playlist) error {
func (r *mockedPlaylist) Put(pls *model.Playlist) error {
r.last = pls
return nil
}

View File

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

View File

@@ -40,16 +40,16 @@ var _ = Describe("PlayTracker", func() {
tracker = newPlayTracker(ds, events.GetBroker())
track = model.MediaFile{
ID: "123",
Title: "Track Title",
Album: "Track Album",
AlbumID: "al-1",
Artist: "Track Artist",
ArtistID: "ar-1",
AlbumArtist: "Track AlbumArtist",
TrackNumber: 1,
Duration: 180,
MbzTrackID: "mbz-123",
ID: "123",
Title: "Track Title",
Album: "Track Album",
AlbumID: "al-1",
Artist: "Track Artist",
ArtistID: "ar-1",
AlbumArtist: "Track AlbumArtist",
TrackNumber: 1,
Duration: 180,
MbzRecordingID: "mbz-123",
}
_ = ds.MediaFile(ctx).Put(&track)
artist = model.Artist{ID: "ar-1"}

View File

@@ -10,6 +10,7 @@ import (
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
)
@@ -34,10 +35,11 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error
if err != nil {
return nil, err
}
if !share.ExpiresAt.IsZero() && share.ExpiresAt.Before(time.Now()) {
expiresAt := V(share.ExpiresAt)
if !expiresAt.IsZero() && expiresAt.Before(time.Now()) {
return nil, model.ErrExpired
}
share.LastVisitedAt = time.Now()
share.LastVisitedAt = P(time.Now())
share.VisitCount++
err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count")
@@ -90,8 +92,8 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
return "", err
}
s.ID = id
if s.ExpiresAt.IsZero() {
s.ExpiresAt = time.Now().Add(365 * 24 * time.Hour)
if V(s.ExpiresAt).IsZero() {
s.ExpiresAt = P(time.Now().Add(365 * 24 * time.Hour))
}
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]
@@ -128,7 +130,7 @@ func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...stri
cols := []string{"description", "downloadable"}
// TODO Better handling of Share expiration
if !entity.(*model.Share).ExpiresAt.IsZero() {
if !V(entity.(*model.Share).ExpiresAt).IsZero() {
cols = append(cols, "expires_at")
}
return r.Persistable.Update(id, entity, cols...)

View File

@@ -4,6 +4,8 @@ import (
"github.com/google/wire"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
)
@@ -18,4 +20,6 @@ var Set = wire.NewSet(
agents.New,
ffmpeg.New,
scrobbler.GetPlayTracker,
playback.GetInstance,
metrics.GetInstance,
)

165
db/backup.go Normal file
View File

@@ -0,0 +1,165 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"time"
"github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
const (
backupPrefix = "navidrome_backup"
backupRegexString = backupPrefix + "_(.+)\\.db"
)
var backupRegex = regexp.MustCompile(backupRegexString)
const backupSuffixLayout = "2006.01.02_15.04.05"
func backupPath(t time.Time) string {
return filepath.Join(
conf.Server.Backup.Path,
fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)),
)
}
func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
// heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/
backupDb, err := sql.Open(Driver, path)
if err != nil {
return err
}
defer backupDb.Close()
existingConn, err := Db().Conn(ctx)
if err != nil {
return err
}
defer existingConn.Close()
backupConn, err := backupDb.Conn(ctx)
if err != nil {
return err
}
defer backupConn.Close()
err = existingConn.Raw(func(existing any) error {
return backupConn.Raw(func(backup any) error {
var sourceOk, destOk bool
var sourceConn, destConn *sqlite3.SQLiteConn
if isBackup {
sourceConn, sourceOk = existing.(*sqlite3.SQLiteConn)
destConn, destOk = backup.(*sqlite3.SQLiteConn)
} else {
sourceConn, sourceOk = backup.(*sqlite3.SQLiteConn)
destConn, destOk = existing.(*sqlite3.SQLiteConn)
}
if !sourceOk {
return fmt.Errorf("error trying to convert source to sqlite connection")
}
if !destOk {
return fmt.Errorf("error trying to convert destination to sqlite connection")
}
backupOp, err := destConn.Backup("main", sourceConn, "main")
if err != nil {
return fmt.Errorf("error starting sqlite backup: %w", err)
}
defer backupOp.Close()
// Caution: -1 means that sqlite will hold a read lock until the operation finishes
// This will lock out other writes that could happen at the same time
done, err := backupOp.Step(-1)
if !done {
return fmt.Errorf("backup not done with step -1")
}
if err != nil {
return fmt.Errorf("error during backup step: %w", err)
}
err = backupOp.Finish()
if err != nil {
return fmt.Errorf("error finishing backup: %w", err)
}
return nil
})
})
return err
}
func Backup(ctx context.Context) (string, error) {
destPath := backupPath(time.Now())
err := backupOrRestore(ctx, true, destPath)
if err != nil {
return "", err
}
return destPath, nil
}
func Restore(ctx context.Context, path string) error {
return backupOrRestore(ctx, false, path)
}
func Prune(ctx context.Context) (int, error) {
files, err := os.ReadDir(conf.Server.Backup.Path)
if err != nil {
return 0, fmt.Errorf("unable to read database backup entries: %w", err)
}
var backupTimes []time.Time
for _, file := range files {
if !file.IsDir() {
submatch := backupRegex.FindStringSubmatch(file.Name())
if len(submatch) == 2 {
timestamp, err := time.Parse(backupSuffixLayout, submatch[1])
if err == nil {
backupTimes = append(backupTimes, timestamp)
}
}
}
}
if len(backupTimes) <= conf.Server.Backup.Count {
return 0, nil
}
slices.SortFunc(backupTimes, func(a, b time.Time) int {
return b.Compare(a)
})
pruneCount := 0
var errs []error
for _, timeToPrune := range backupTimes[conf.Server.Backup.Count:] {
log.Debug(ctx, "Pruning backup", "time", timeToPrune)
path := backupPath(timeToPrune)
err = os.Remove(path)
if err != nil {
errs = append(errs, err)
} else {
pruneCount++
}
}
if len(errs) > 0 {
err = errors.Join(errs...)
log.Error(ctx, "Failed to delete one or more files", "errors", err)
}
return pruneCount, err
}

153
db/backup_test.go Normal file
View File

@@ -0,0 +1,153 @@
package db
import (
"context"
"database/sql"
"math/rand"
"os"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func shortTime(year int, month time.Month, day, hour, minute int) time.Time {
return time.Date(year, month, day, hour, minute, 0, 0, time.UTC)
}
var _ = Describe("database backups", func() {
When("there are a few backup files", func() {
var ctx context.Context
var timesShuffled []time.Time
timesDecreasingChronologically := []time.Time{
shortTime(2024, 11, 6, 5, 11),
shortTime(2024, 11, 6, 5, 8),
shortTime(2024, 11, 6, 4, 32),
shortTime(2024, 11, 6, 2, 4),
shortTime(2024, 11, 6, 1, 52),
shortTime(2024, 11, 5, 23, 0),
shortTime(2024, 11, 5, 6, 4),
shortTime(2024, 11, 4, 2, 4),
shortTime(2024, 11, 3, 8, 5),
shortTime(2024, 11, 2, 5, 24),
shortTime(2024, 11, 1, 5, 24),
shortTime(2024, 10, 31, 5, 9),
shortTime(2024, 10, 30, 5, 9),
shortTime(2024, 10, 23, 14, 3),
shortTime(2024, 10, 22, 3, 6),
shortTime(2024, 10, 11, 14, 3),
shortTime(2024, 9, 21, 19, 5),
shortTime(2024, 9, 3, 8, 5),
shortTime(2024, 7, 5, 1, 1),
shortTime(2023, 8, 2, 19, 5),
shortTime(2021, 8, 2, 19, 5),
shortTime(2020, 8, 2, 19, 5),
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tempFolder, err := os.MkdirTemp("", "navidrome_backup")
Expect(err).ToNot(HaveOccurred())
conf.Server.Backup.Path = tempFolder
DeferCleanup(func() {
_ = os.RemoveAll(tempFolder)
})
timesShuffled = make([]time.Time, len(timesDecreasingChronologically))
copy(timesShuffled, timesDecreasingChronologically)
rand.Shuffle(len(timesShuffled), func(i, j int) {
timesShuffled[i], timesShuffled[j] = timesShuffled[j], timesShuffled[i]
})
for _, time := range timesShuffled {
path := backupPath(time)
file, err := os.Create(path)
Expect(err).ToNot(HaveOccurred())
_ = file.Close()
}
ctx = context.Background()
})
DescribeTable("prune", func(count, expected int) {
conf.Server.Backup.Count = count
pruneCount, err := Prune(ctx)
Expect(err).ToNot(HaveOccurred())
for idx, time := range timesDecreasingChronologically {
_, err := os.Stat(backupPath(time))
shouldExist := idx < conf.Server.Backup.Count
if shouldExist {
Expect(err).ToNot(HaveOccurred())
} else {
Expect(err).To(MatchError(os.ErrNotExist))
}
}
Expect(len(timesDecreasingChronologically) - pruneCount).To(Equal(expected))
},
Entry("preserve latest 5 backups", 5, 5),
Entry("delete all files", 0, 0),
Entry("preserve all files when at length", len(timesDecreasingChronologically), len(timesDecreasingChronologically)),
Entry("preserve all files when less than count", 10000, len(timesDecreasingChronologically)))
})
Describe("backup and restore", Ordered, func() {
var ctx context.Context
BeforeAll(func() {
ctx = context.Background()
DeferCleanup(configtest.SetupConfig())
conf.Server.DbPath = "file::memory:?cache=shared&_foreign_keys=on"
DeferCleanup(Init())
})
BeforeEach(func() {
tempFolder, err := os.MkdirTemp("", "navidrome_backup")
Expect(err).ToNot(HaveOccurred())
conf.Server.Backup.Path = tempFolder
DeferCleanup(func() {
_ = os.RemoveAll(tempFolder)
})
})
It("successfully backups the database", func() {
path, err := Backup(ctx)
Expect(err).ToNot(HaveOccurred())
backup, err := sql.Open(Driver, path)
Expect(err).ToNot(HaveOccurred())
Expect(isSchemaEmpty(backup)).To(BeFalse())
})
It("successfully restores the database", func() {
path, err := Backup(ctx)
Expect(err).ToNot(HaveOccurred())
// https://stackoverflow.com/questions/525512/drop-all-tables-command
_, err = Db().ExecContext(ctx, `
PRAGMA writable_schema = 1;
DELETE FROM sqlite_master WHERE type in ('table', 'index', 'trigger');
PRAGMA writable_schema = 0;
`)
Expect(err).ToNot(HaveOccurred())
Expect(isSchemaEmpty(Db())).To(BeTrue())
err = Restore(ctx, path)
Expect(err).ToNot(HaveOccurred())
Expect(isSchemaEmpty(Db())).To(BeFalse())
})
})
})

View File

@@ -5,26 +5,34 @@ import (
"embed"
"fmt"
_ "github.com/mattn/go-sqlite3"
"github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
_ "github.com/navidrome/navidrome/db/migration"
_ "github.com/navidrome/navidrome/db/migrations"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/hasher"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/pressly/goose/v3"
)
var (
Driver = "sqlite3"
Path string
Dialect = "sqlite3"
Driver = Dialect + "_custom"
Path string
)
//go:embed migration/*.sql
//go:embed migrations/*.sql
var embedMigrations embed.FS
const migrationsFolder = "migration"
const migrationsFolder = "migrations"
func Db() *sql.DB {
return singleton.GetInstance(func() *sql.DB {
sql.Register(Driver, &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
},
})
Path = conf.Server.DbPath
if Path == ":memory:" {
Path = "file::memory:?cache=shared&_foreign_keys=on"
@@ -39,12 +47,15 @@ func Db() *sql.DB {
})
}
func Close() error {
func Close() {
log.Info("Closing Database")
return Db().Close()
err := Db().Close()
if err != nil {
log.Error("Error closing Database", err)
}
}
func Init() {
func Init() func() {
db := Db()
// Disable foreign_keys to allow re-creating tables in migrations
@@ -60,17 +71,46 @@ func Init() {
}
gooseLogger := &logAdapter{silent: isSchemaEmpty(db)}
goose.SetLogger(gooseLogger)
goose.SetBaseFS(embedMigrations)
err = goose.SetDialect(Driver)
err = goose.SetDialect(Dialect)
if err != nil {
log.Fatal("Invalid DB driver", "driver", Driver, err)
}
if !isSchemaEmpty(db) && hasPendingMigrations(db, migrationsFolder) {
log.Info("Upgrading DB Schema to latest version")
}
goose.SetLogger(gooseLogger)
err = goose.Up(db, migrationsFolder)
if err != nil {
log.Fatal("Failed to apply new migrations", err)
}
return Close
}
type statusLogger struct{ numPending int }
func (*statusLogger) Fatalf(format string, v ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) }
func (l *statusLogger) Printf(format string, v ...interface{}) {
if len(v) < 1 {
return
}
if v0, ok := v[0].(string); !ok {
return
} else if v0 == "Pending" {
l.numPending++
}
}
func hasPendingMigrations(db *sql.DB, folder string) bool {
l := &statusLogger{}
goose.SetLogger(l)
err := goose.Status(db, folder)
if err != nil {
log.Fatal("Failed to check for pending migrations", err)
}
return l.numPending > 0
}
func isSchemaEmpty(db *sql.DB) bool {

View File

@@ -21,7 +21,7 @@ var _ = Describe("isSchemaEmpty", func() {
var db *sql.DB
BeforeEach(func() {
path := "file::memory:"
db, _ = sql.Open(Driver, path)
db, _ = sql.Open(Dialect, path)
})
It("returns false if the goose metadata table is found", func() {

View File

@@ -1,20 +0,0 @@
package migrations
import (
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200326090707, Down20200326090707)
}
func Up20200326090707(tx *sql.Tx) error {
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
}
func Down20200326090707(tx *sql.Tx) error {
return nil
}

View File

@@ -1,20 +0,0 @@
package migrations
import (
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upTouchPlaylists, downTouchPlaylists)
}
func upTouchPlaylists(tx *sql.Tx) error {
_, err := tx.Exec(`update playlist set updated_at = datetime('now');`)
return err
}
func downTouchPlaylists(tx *sql.Tx) error {
return nil
}

View File

@@ -1,23 +0,0 @@
package migrations
import (
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(upAddDownloadToShare, downAddDownloadToShare)
}
func upAddDownloadToShare(tx *sql.Tx) error {
_, err := tx.Exec(`
alter table share
add downloadable bool not null default false;
`)
return err
}
func downAddDownloadToShare(tx *sql.Tx) error {
return nil
}

View File

@@ -1,6 +1,7 @@
package migrations
import (
"context"
"database/sql"
"github.com/navidrome/navidrome/log"
@@ -8,10 +9,10 @@ import (
)
func init() {
goose.AddMigration(Up20200130083147, Down20200130083147)
goose.AddMigrationContext(Up20200130083147, Down20200130083147)
}
func Up20200130083147(tx *sql.Tx) error {
func Up20200130083147(_ context.Context, tx *sql.Tx) error {
log.Info("Creating DB Schema")
_, err := tx.Exec(`
create table if not exists album
@@ -178,6 +179,6 @@ create table if not exists user
return err
}
func Down20200130083147(tx *sql.Tx) error {
func Down20200130083147(_ context.Context, tx *sql.Tx) error {
return nil
}

View File

@@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200131183653, Down20200131183653)
goose.AddMigrationContext(Up20200131183653, Down20200131183653)
}
func Up20200131183653(tx *sql.Tx) error {
func Up20200131183653(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table search_dg_tmp
(
@@ -36,7 +37,7 @@ update annotation set item_type = 'media_file' where item_type = 'mediaFile';
return err
}
func Down20200131183653(tx *sql.Tx) error {
func Down20200131183653(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
create table search_dg_tmp
(

View File

@@ -1,16 +1,17 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up20200208222418, Down20200208222418)
goose.AddMigrationContext(Up20200208222418, Down20200208222418)
}
func Up20200208222418(tx *sql.Tx) error {
func Up20200208222418(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
update annotation set play_count = 0 where play_count is null;
update annotation set rating = 0 where rating is null;
@@ -50,6 +51,6 @@ create index annotation_starred
return err
}
func Down20200208222418(tx *sql.Tx) error {
func Down20200208222418(_ context.Context, tx *sql.Tx) error {
return nil
}

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