Compare commits

...

17 Commits

Author SHA1 Message Date
dependabot[bot]
fc78482935
Merge 3c4fd33be74217fab5f42495d9a47c27fbb5f09a into dc07dc413daf5da43dfed4fffec9c8db320bf928 2025-11-24 22:41:06 -08:00
dependabot[bot]
dc07dc413d
chore(deps): bump golangci/golangci-lint-action in /.github/workflows (#4673)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 8 to 9.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v8...v9)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 23:36:19 -05:00
zacaj
3294bcacfc
feat: add Rated At field - #4653 (#4660)
* feat(model): add Rated At field - #4653

Signed-off-by: zacaj <zacaj@zacaj.com>

* fix(ui): ignore empty dates in rating/love tooltips - #4653

* refactor(ui): add isDateSet util function

Signed-off-by: zacaj <zacaj@zacaj.com>

* feat: add tests for isDateSet and rated_at sort mappings

Added comprehensive tests for isDateSet and urlValidate functions in
ui/src/utils/validations.test.js covering falsy values, Go zero date handling,
valid date strings, Date objects, and edge cases.

Added rated_at sort mapping to album, artist, and mediafile repositories,
following the same pattern as starred_at (sorting by rating first, then by
timestamp). This enables proper sorting by rating date in the UI.

---------

Signed-off-by: zacaj <zacaj@zacaj.com>
Co-authored-by: zacaj <zacaj@zacaj.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-11-24 23:18:05 -05:00
Deluan
228211f925 test: add smart playlist tag criteria tests for issue #4728
Add integration tests verifying the workaround for checking if a tag has any
value in smart playlists. The tests confirm that using 'contains' with an empty
string generates SQL that matches any non-empty tag value (value LIKE '%%'),
which is the recommended workaround for issue #4728.

Tests added:
- Verify contains with empty string matches tracks with tag values
- Verify notContains with empty string excludes tracks with tag values

Also updated test context to use GinkgoT().Context() instead of context.TODO().
2025-11-24 21:16:28 -05:00
dependabot[bot]
a6a682b385
chore(deps): bump actions/checkout from 5 to 6 in /.github/workflows (#4730)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 13:18:34 -05:00
Kendall Garner
c40f12e65b
fix(scanner): Use repeated arg instead of comma split (#4727) 2025-11-23 22:16:10 -05:00
Deluan
12d0898585 chore(docker): remove GODEBUG=asyncpreemptoff=1 flag, as it should not be needed on Go 1.15+
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-22 21:36:44 -05:00
Deluan
c21aee7360 fix(config): enables quoted ; as values in ini files
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-22 20:14:44 -05:00
Xavier Araque
ee51bd9281
feat(ui): add SquiddiesGlass Theme (#4632)
* feat: Add SquiddiesGlass Theme

* feat: fix commnets by gemini-code-assist in PR

* feat: fix Prettier format

* feat: fix play button, and text mobile

* feat: fix play button, and text mobile, prettier

* feat: fix chip, title artist

* fix: loading albbun, play button color

* prettier

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Xavier Araque <francisco.araque@toolfactory.net>
Co-authored-by: Deluan <deluan@navidrome.org>
2025-11-22 13:41:59 -05:00
Stephan Wahlen
2451e9e7ae
feat(ui): add AMusic (Apple Music inspired) theme (#4723)
* first show at AMuisc Theme

* prettier

* fix Duplicate key 'MuiButton'

* fix file name

* Update amusic.js

* Add styles for NDAlbumGridView in amusic.js

* Fix MuiToolbar background property in amusic.js

* Fix syntax error in amusic.js background property

* run prettier

* fix banded table styling and more

* more styling to player

- fix some appearances of green in queue
- match queue styling to rest of theme
- round albumart in player and prevent rotation

* fix queue panel background and border

to make it stand out more against the background

* fix stray comma

and lint+prettier

* queue hover still green

and player preview image not rounded properly

* Update amusic.css.js

* more mobile color fixes

* artist page

* prettier

* rounded art in albumgridview

* small tweaks to colors and radiuses

* artist and album heading

* external links colors

* unify font colors + albumgrid corner radius

* get rid of queue hover green

* unify colors in player

same red shades as primary

* mobile player floating panel background shade of green

* unify border colors

and attempt to get album cover corner radius working

* final touches

* Update amusic.css.js

* fix invisible button color fir muibutton

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix css syntax on player queue color overrides

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* remove unused MuiTableHead

* sort theme list in index.js alphabetically

* remove unused properties

* Revert "fix css syntax on player queue color overrides"

This reverts commit 503bba321d958aed5251667c58214822ceb70f59.

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-11-22 11:23:02 -05:00
Deluan
f6b2ab5726 feat(ui): add loading state to artist action buttons for improved user experience
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-21 22:23:38 -05:00
Deluan
67c4e24957 fix(scanner): defer artwork PreCache calls until after transaction commits
The CacheWarmer was failing with data not found errors because PreCache was being called inside the database transaction before the data was committed. The CacheWarmer runs in a separate goroutine with its own database context and could not access the uncommitted data due to transaction isolation.

Changed the persistChanges method in phase_1_folders.go to collect artwork IDs during the transaction and only call PreCache after the transaction successfully commits. This ensures the artwork data is visible to the CacheWarmer when it attempts to retrieve and cache the images.

The fix eliminates the data not found errors and allows the cache warmer to properly pre-cache album and artist artwork during library scanning.

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-21 15:27:25 -05:00
Deluan Quintão
255ed1f8e2
feat(deezer): Add artist bio, top tracks, related artists and language support (#4720)
* feat(deezer): add functions to fetch related artists, biographies, and top tracks for an artist

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

* feat(deezer): add language support for Deezer API client

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

* fix(deezer): Use GraphQL API for translated biographies

The previous implementation scraped the __DZR_APP_STATE__ from HTML,
which only contained English content. The actual biography displayed
on Deezer's website comes from their GraphQL API at pipe.deezer.com,
which properly respects the Accept-Language header and returns
translated content.

This change:
- Switches from HTML scraping to the GraphQL API
- Uses Accept-Language header instead of URL path for language
- Updates tests to match the new implementation
- Removes unused HTML fixture file

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

* refactor(deezer): move JWT token handling to a separate file for better organization

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

* feat(deezer): enhance JWT token handling with expiration validation

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

* refactor(deezer): change log level for unknown agent warnings from Warn to Debug

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

* fix(deezer): reduce JWT token expiration buffer from 10 minutes to 1 minute

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-21 15:09:24 -05:00
Deluan
152f57e642 chore(deps): update golangci-lint version to v2.6.2
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-20 10:38:54 -05:00
Deluan
5c16622501 chore(makefile): update golangci-lint version to v2.6.2
See comment 0c71842b12 (commitcomment-170969373)

Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-20 10:38:40 -05:00
Deluan
36fa869329 feat(scanner): improve error messages for cleanup operations in annotations, bookmarks, and tags
Signed-off-by: Deluan <deluan@navidrome.org>
2025-11-20 09:27:42 -05:00
dependabot[bot]
3c4fd33be7
chore(deps-dev): bump happy-dom from 20.0.8 to 20.0.10 in /ui
Bumps [happy-dom](https://github.com/capricorn86/happy-dom) from 20.0.8 to 20.0.10.
- [Release notes](https://github.com/capricorn86/happy-dom/releases)
- [Commits](https://github.com/capricorn86/happy-dom/compare/v20.0.8...v20.0.10)

---
updated-dependencies:
- dependency-name: happy-dom
  dependency-version: 20.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-03 17:27:47 +00:00
50 changed files with 2200 additions and 68 deletions

View File

@ -25,7 +25,7 @@ jobs:
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
@ -63,7 +63,7 @@ jobs:
name: Lint Go code
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Download TagLib
uses: ./.github/actions/download-taglib
@ -71,7 +71,7 @@ jobs:
version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
uses: golangci/golangci-lint-action@v9
with:
version: latest
problem-matchers: true
@ -93,7 +93,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download TagLib
uses: ./.github/actions/download-taglib
@ -114,7 +114,7 @@ jobs:
env:
NODE_OPTIONS: "--max_old_space_size=4096"
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
@ -145,7 +145,7 @@ jobs:
name: Lint i18n files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- run: |
set -e
for file in resources/i18n/*.json; do
@ -191,7 +191,7 @@ jobs:
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
@ -264,7 +264,7 @@ jobs:
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Download digests
uses: actions/download-artifact@v6
@ -318,7 +318,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/download-artifact@v6
with:
@ -352,7 +352,7 @@ jobs:
outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true

View File

@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Get updated translations
id: poeditor
env:

View File

@ -137,7 +137,6 @@ ENV ND_MUSICFOLDER=/music
ENV ND_DATAFOLDER=/data
ENV ND_CONFIGFILE=/data/navidrome.toml
ENV ND_PORT=4533
ENV GODEBUG="asyncpreemptoff=1"
RUN touch /.nddockerenv
EXPOSE ${ND_PORT}

View File

@ -16,7 +16,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.1.1-1
GOLANGCI_LINT_VERSION ?= v2.5.0
GOLANGCI_LINT_VERSION ?= v2.6.2
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")

View File

@ -4,7 +4,6 @@ import (
"context"
"encoding/gob"
"os"
"strings"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/db"
@ -19,13 +18,13 @@ import (
var (
fullScan bool
subprocess bool
targets string
targets []string
)
func init() {
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
scanCmd.Flags().StringVarP(&targets, "targets", "t", "", "comma-separated list of libraryID:folderPath pairs (e.g., \"1:Music/Rock,1:Music/Jazz,2:Classical\")")
scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
rootCmd.AddCommand(scanCmd)
}
@ -74,9 +73,9 @@ func runScanner(ctx context.Context) {
// Parse targets if provided
var scanTargets []model.ScanTarget
if targets != "" {
if len(targets) > 0 {
var err error
scanTargets, err = model.ParseTargets(strings.Split(targets, ","))
scanTargets, err = model.ParseTargets(targets)
if err != nil {
log.Fatal(ctx, "Failed to parse targets", err)
}

View File

@ -176,7 +176,8 @@ type spotifyOptions struct {
}
type deezerOptions struct {
Enabled bool
Enabled bool
Language string
}
type listenBrainzOptions struct {
@ -566,6 +567,7 @@ func setViperDefaults() {
viper.SetDefault("spotify.id", "")
viper.SetDefault("spotify.secret", "")
viper.SetDefault("deezer.enabled", true)
viper.SetDefault("deezer.language", "en")
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
@ -615,7 +617,12 @@ func init() {
func InitConfig(cfgFile string) {
codecRegistry := viper.NewCodecRegistry()
_ = codecRegistry.RegisterCodec("ini", ini.Codec{})
_ = codecRegistry.RegisterCodec("ini", ini.Codec{
LoadOptions: ini.LoadOptions{
UnescapeValueDoubleQuotes: true,
UnescapeValueCommentSymbols: true,
},
})
viper.SetOptions(viper.WithCodecRegistry(codecRegistry))
cfgFile = getConfigFile(cfgFile)

View File

@ -39,6 +39,7 @@ var _ = Describe("Configuration", func() {
Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format))
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
Expect(conf.Server.Tags["artist"].Split).To(Equal([]string{";"}))
// The config file used should be the one we created
Expect(conf.Server.ConfigFile).To(Equal(filename))

View File

@ -1,6 +1,7 @@
[default]
MusicFolder = /ini/music
UIWelcomeMessage = Welcome ini
UIWelcomeMessage = 'Welcome ini' ; Just a comment to test the LoadOptions
[Tags]
Custom.Aliases = ini,test
Custom.Aliases = ini,test
artist.Split = ";" # Should be able to read ; as a separator

View File

@ -2,6 +2,9 @@
"musicFolder": "/json/music",
"uiWelcomeMessage": "Welcome json",
"Tags": {
"artist": {
"split": ";"
},
"custom": {
"aliases": [
"json",

View File

@ -1,5 +1,7 @@
musicFolder = "/toml/music"
uiWelcomeMessage = "Welcome toml"
Tags.artist.Split = ';'
[Tags.custom]
aliases = ["toml", "test"]

View File

@ -1,6 +1,8 @@
musicFolder: "/yaml/music"
uiWelcomeMessage: "Welcome yaml"
Tags:
artist:
split: [";"]
custom:
aliases:
- yaml

View File

@ -87,7 +87,7 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
} else if isPlugin {
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
} else {
log.Warn("Unknown agent ignored", "name", name)
log.Debug("Unknown agent ignored", "name", name)
}
}
return validAgents

View File

@ -1,6 +1,7 @@
package deezer
import (
bytes "bytes"
"context"
"encoding/json"
"errors"
@ -9,11 +10,14 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"github.com/microcosm-cc/bluemonday"
"github.com/navidrome/navidrome/log"
)
const apiBaseURL = "https://api.deezer.com"
const authBaseURL = "https://auth.deezer.com"
var (
ErrNotFound = errors.New("deezer: not found")
@ -25,10 +29,15 @@ type httpDoer interface {
type client struct {
httpDoer httpDoer
language string
jwt jwtToken
}
func newClient(hc httpDoer) *client {
return &client{hc}
func newClient(hc httpDoer, language string) *client {
return &client{
httpDoer: hc,
language: language,
}
}
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
@ -53,7 +62,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A
return results.Data, nil
}
func (c *client) makeRequest(req *http.Request, response interface{}) error {
func (c *client) makeRequest(req *http.Request, response any) error {
log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL)
resp, err := c.httpDoer.Do(req)
if err != nil {
@ -81,3 +90,129 @@ func (c *client) parseError(data []byte) error {
}
return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message)
}
func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) {
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/related", apiBaseURL, artistID), nil)
if err != nil {
return nil, err
}
var results RelatedArtists
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
return results.Data, nil
}
func (c *client) getTopTracks(ctx context.Context, artistID int, limit int) ([]Track, error) {
params := url.Values{}
params.Add("limit", strconv.Itoa(limit))
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/top", apiBaseURL, artistID), nil)
if err != nil {
return nil, err
}
req.URL.RawQuery = params.Encode()
var results TopTracks
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
return results.Data, nil
}
const pipeAPIURL = "https://pipe.deezer.com/api"
var strictPolicy = bluemonday.StrictPolicy()
func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error) {
jwt, err := c.getJWT(ctx)
if err != nil {
return "", fmt.Errorf("deezer: failed to get JWT: %w", err)
}
query := map[string]any{
"operationName": "ArtistBio",
"variables": map[string]any{
"artistId": strconv.Itoa(artistID),
},
"query": `query ArtistBio($artistId: String!) {
artist(artistId: $artistId) {
bio {
full
}
}
}`,
}
body, err := json.Marshal(query)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "POST", pipeAPIURL, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", c.language)
req.Header.Set("Authorization", "Bearer "+jwt)
log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", c.language)
resp, err := c.httpDoer.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("deezer: failed to fetch biography: %s", resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
type graphQLResponse struct {
Data struct {
Artist struct {
Bio struct {
Full string `json:"full"`
} `json:"bio"`
} `json:"artist"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
}
}
var result graphQLResponse
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("deezer: failed to parse GraphQL response: %w", err)
}
if len(result.Errors) > 0 {
var errs []error
for m := range result.Errors {
errs = append(errs, errors.New(result.Errors[m].Message))
}
err := errors.Join(errs...)
return "", fmt.Errorf("deezer: GraphQL error: %w", err)
}
if result.Data.Artist.Bio.Full == "" {
return "", errors.New("deezer: biography not found")
}
return cleanBio(result.Data.Artist.Bio.Full), nil
}
func cleanBio(bio string) string {
bio = strings.ReplaceAll(bio, "</p>", "\n")
return strictPolicy.Sanitize(bio)
}

View File

@ -0,0 +1,101 @@
package deezer
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/navidrome/navidrome/log"
)
type jwtToken struct {
token string
expiresAt time.Time
mu sync.RWMutex
}
func (j *jwtToken) get() (string, bool) {
j.mu.RLock()
defer j.mu.RUnlock()
if time.Now().Before(j.expiresAt) {
return j.token, true
}
return "", false
}
func (j *jwtToken) set(token string, expiresIn time.Duration) {
j.mu.Lock()
defer j.mu.Unlock()
j.token = token
j.expiresAt = time.Now().Add(expiresIn)
}
func (c *client) getJWT(ctx context.Context) (string, error) {
// Check if we have a valid cached token
if token, valid := c.jwt.get(); valid {
return token, nil
}
// Fetch a new anonymous token
req, err := http.NewRequestWithContext(ctx, "GET", authBaseURL+"/login/anonymous?jo=p&rto=c", nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpDoer.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("deezer: failed to get JWT token: %s", resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
type authResponse struct {
JWT string `json:"jwt"`
}
var result authResponse
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("deezer: failed to parse auth response: %w", err)
}
if result.JWT == "" {
return "", errors.New("deezer: no JWT token in response")
}
// Parse JWT to get actual expiration time
token, err := jwt.ParseString(result.JWT, jwt.WithVerify(false), jwt.WithValidate(false))
if err != nil {
return "", fmt.Errorf("deezer: failed to parse JWT token: %w", err)
}
// Calculate TTL with a 1-minute buffer for clock skew and network delays
expiresAt := token.Expiration()
if expiresAt.IsZero() {
return "", errors.New("deezer: JWT token has no expiration time")
}
ttl := time.Until(expiresAt) - 1*time.Minute
if ttl <= 0 {
return "", errors.New("deezer: JWT token already expired or expires too soon")
}
c.jwt.set(result.JWT, ttl)
log.Trace(ctx, "Fetched new Deezer JWT token", "expiresAt", expiresAt, "ttl", ttl)
return result.JWT, nil
}

View File

@ -0,0 +1,293 @@
package deezer
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/lestrrat-go/jwx/v2/jwt"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("JWT Authentication", func() {
var httpClient *fakeHttpClient
var client *client
var ctx context.Context
BeforeEach(func() {
httpClient = &fakeHttpClient{}
client = newClient(httpClient, "en")
ctx = context.Background()
})
Describe("getJWT", func() {
Context("with a valid JWT response", func() {
It("successfully fetches and caches a JWT token", func() {
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
token, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token).To(Equal(testJWT))
})
It("returns the cached token on subsequent calls", func() {
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
// First call should fetch from API
token1, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token1).To(Equal(testJWT))
Expect(httpClient.lastRequest.URL.Path).To(Equal("/login/anonymous"))
// Second call should return cached token without hitting API
httpClient.lastRequest = nil // Clear last request to verify no new request is made
token2, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token2).To(Equal(testJWT))
Expect(httpClient.lastRequest).To(BeNil()) // No new request made
})
It("parses the JWT expiration time correctly", func() {
expectedExpiration := time.Now().Add(5 * time.Minute)
testToken, err := jwt.NewBuilder().
Expiration(expectedExpiration).
Build()
Expect(err).To(BeNil())
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
Expect(err).To(BeNil())
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
})
token, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token).ToNot(BeEmpty())
// Verify the token is cached until close to expiration
// The cache should expire 1 minute before the JWT expires
expectedCacheExpiry := expectedExpiration.Add(-1 * time.Minute)
Expect(client.jwt.expiresAt).To(BeTemporally("~", expectedCacheExpiry, 2*time.Second))
})
})
Context("with JWT tokens that expire soon", func() {
It("rejects tokens that expire in less than 1 minute", func() {
// Create a token that expires in 30 seconds (less than 1-minute buffer)
testJWT := createTestJWT(30 * time.Second)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
})
It("rejects already expired tokens", func() {
// Create a token that expired 1 minute ago
testJWT := createTestJWT(-1 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
})
It("accepts tokens that expire in more than 1 minute", func() {
// Create a token that expires in 2 minutes (just over the 1-minute buffer)
testJWT := createTestJWT(2 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
token, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token).ToNot(BeEmpty())
})
})
Context("with invalid responses", func() {
It("handles HTTP error responses", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 500,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get JWT token"))
})
It("handles malformed JSON responses", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{invalid json}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to parse auth response"))
})
It("handles responses with empty JWT field", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":""}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(Equal("deezer: no JWT token in response"))
})
It("handles invalid JWT tokens", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":"not-a-valid-jwt"}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to parse JWT token"))
})
It("rejects JWT tokens without expiration", func() {
// Create a JWT without expiration claim
testToken, err := jwt.NewBuilder().
Claim("custom", "value").
Build()
Expect(err).To(BeNil())
// Verify token has no expiration
Expect(testToken.Expiration().IsZero()).To(BeTrue())
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
Expect(err).To(BeNil())
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
})
_, err = client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(Equal("deezer: JWT token has no expiration time"))
})
})
Context("token caching behavior", func() {
It("fetches a new token when the cached token expires", func() {
// First token expires in 5 minutes
firstJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, firstJWT))),
})
token1, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token1).To(Equal(firstJWT))
// Manually expire the cached token
client.jwt.expiresAt = time.Now().Add(-1 * time.Second)
// Second token with different expiration (10 minutes)
secondJWT := createTestJWT(10 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, secondJWT))),
})
token2, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token2).To(Equal(secondJWT))
Expect(token2).ToNot(Equal(token1))
})
})
})
Describe("jwtToken cache", func() {
var cache *jwtToken
BeforeEach(func() {
cache = &jwtToken{}
})
It("returns false for expired tokens", func() {
cache.set("test-token", -1*time.Second) // Already expired
token, valid := cache.get()
Expect(valid).To(BeFalse())
Expect(token).To(BeEmpty())
})
It("returns true for valid tokens", func() {
cache.set("test-token", 4*time.Minute)
token, valid := cache.get()
Expect(valid).To(BeTrue())
Expect(token).To(Equal("test-token"))
})
It("is thread-safe for concurrent access", func() {
wg := sync.WaitGroup{}
// Writer goroutine
wg.Go(func() {
for i := 0; i < 100; i++ {
cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
time.Sleep(1 * time.Millisecond)
}
})
// Reader goroutine
wg.Go(func() {
for i := 0; i < 100; i++ {
cache.get()
time.Sleep(1 * time.Millisecond)
}
})
// Wait for both goroutines to complete
wg.Wait()
// Verify final state is valid
token, valid := cache.get()
Expect(valid).To(BeTrue())
Expect(token).To(HavePrefix("token-"))
})
})
})
// createTestJWT creates a valid JWT token for testing purposes
func createTestJWT(expiresIn time.Duration) string {
token, err := jwt.NewBuilder().
Expiration(time.Now().Add(expiresIn)).
Build()
if err != nil {
panic(fmt.Sprintf("failed to create test JWT: %v", err))
}
signed, err := jwt.Sign(token, jwt.WithInsecureNoSignature())
if err != nil {
panic(fmt.Sprintf("failed to sign test JWT: %v", err))
}
return string(signed)
}

View File

@ -2,10 +2,11 @@ package deezer
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -17,7 +18,7 @@ var _ = Describe("client", func() {
BeforeEach(func() {
httpClient = &fakeHttpClient{}
client = newClient(httpClient)
client = newClient(httpClient, "en")
})
Describe("ArtistImages", func() {
@ -26,7 +27,7 @@ var _ = Describe("client", func() {
Expect(err).To(BeNil())
httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200})
artists, err := client.searchArtists(context.TODO(), "Michael Jackson", 20)
artists, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(BeNil())
Expect(artists).To(HaveLen(17))
Expect(artists[0].Name).To(Equal("Michael Jackson"))
@ -39,10 +40,136 @@ var _ = Describe("client", func() {
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
})
_, err := client.searchArtists(context.TODO(), "Michael Jackson", 20)
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(MatchError(ErrNotFound))
})
})
Describe("ArtistBio", func() {
BeforeEach(func() {
// Mock the JWT token endpoint with a valid JWT that expires in 5 minutes
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
})
})
It("returns artist bio from a successful request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
bio, err := client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(BeNil())
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
Expect(bio).ToNot(ContainSubstring("<p>"))
Expect(bio).ToNot(ContainSubstring("</p>"))
})
It("uses the configured language", func() {
client = newClient(httpClient, "fr")
// Mock JWT token for the new client instance with a valid JWT
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
})
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
_, err = client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(BeNil())
Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
})
It("includes the JWT token in the request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
_, err = client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(BeNil())
// Verify that the Authorization header has the Bearer token format
authHeader := httpClient.lastRequest.Header.Get("Authorization")
Expect(authHeader).To(HavePrefix("Bearer "))
Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars
})
It("handles GraphQL errors", func() {
errorResponse := `{
"data": {
"artist": {
"bio": {
"full": ""
}
}
},
"errors": [
{
"message": "Artist not found"
},
{
"message": "Invalid artist ID"
}
]
}`
httpClient.mock("https://pipe.deezer.com/api", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 999)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("GraphQL error"))
Expect(err.Error()).To(ContainSubstring("Artist not found"))
Expect(err.Error()).To(ContainSubstring("Invalid artist ID"))
})
It("handles empty biography", func() {
emptyBioResponse := `{
"data": {
"artist": {
"bio": {
"full": ""
}
}
}
}`
httpClient.mock("https://pipe.deezer.com/api", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(MatchError("deezer: biography not found"))
})
It("handles JWT token fetch failure", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 500,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
})
It("handles JWT token that expires too soon", func() {
// Create a JWT that expires in 30 seconds (less than the 1-minute buffer)
expiredJWT := createTestJWT(30 * time.Second)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
})
})
})
type fakeHttpClient struct {

View File

@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/slice"
)
const deezerAgentName = "deezer"
@ -32,7 +33,7 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface {
Timeout: consts.DefaultHttpClientTimeOut,
}
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
agent.client = newClient(cachedHttpClient)
agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language)
return agent
}
@ -88,6 +89,56 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
return &artists[0], err
}
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
artist, err := s.searchArtist(ctx, name)
if err != nil {
return nil, err
}
related, err := s.client.getRelatedArtists(ctx, artist.ID)
if err != nil {
return nil, err
}
res := slice.Map(related, func(r Artist) agents.Artist {
return agents.Artist{
Name: r.Name,
}
})
if len(res) > limit {
res = res[:limit]
}
return res, nil
}
func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) {
artist, err := s.searchArtist(ctx, artistName)
if err != nil {
return nil, err
}
tracks, err := s.client.getTopTracks(ctx, artist.ID, count)
if err != nil {
return nil, err
}
res := slice.Map(tracks, func(r Track) agents.Song {
return agents.Song{
Name: r.Title,
}
})
return res, nil
}
func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) {
artist, err := s.searchArtist(ctx, name)
if err != nil {
return "", err
}
return s.client.getArtistBio(ctx, artist.ID)
}
func init() {
conf.AddHook(func() {
if conf.Server.Deezer.Enabled {

View File

@ -29,3 +29,38 @@ type Error struct {
Code int `json:"code"`
} `json:"error"`
}
type RelatedArtists struct {
Data []Artist `json:"data"`
Total int `json:"total"`
}
type TopTracks struct {
Data []Track `json:"data"`
Total int `json:"total"`
Next string `json:"next"`
}
type Track struct {
ID int `json:"id"`
Title string `json:"title"`
Link string `json:"link"`
Duration int `json:"duration"`
Rank int `json:"rank"`
Preview string `json:"preview"`
Artist Artist `json:"artist"`
Album Album `json:"album"`
Contributors []Artist `json:"contributors"`
}
type Album struct {
ID int `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverSmall string `json:"cover_small"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXl string `json:"cover_xl"`
Tracklist string `json:"tracklist"`
Type string `json:"type"`
}

View File

@ -35,4 +35,35 @@ var _ = Describe("Responses", func() {
Expect(errorResp.Error.Message).To(Equal("Missing parameters: q"))
})
})
Describe("Related Artists", func() {
It("parses the related artists response correctly", func() {
var resp RelatedArtists
body, err := os.ReadFile("tests/fixtures/deezer.artist.related.json")
Expect(err).To(BeNil())
err = json.Unmarshal(body, &resp)
Expect(err).To(BeNil())
Expect(resp.Data).To(HaveLen(20))
justice := resp.Data[0]
Expect(justice.Name).To(Equal("Justice"))
Expect(justice.ID).To(Equal(6404))
})
})
Describe("Top Tracks", func() {
It("parses the top tracks response correctly", func() {
var resp TopTracks
body, err := os.ReadFile("tests/fixtures/deezer.artist.top.json")
Expect(err).To(BeNil())
err = json.Unmarshal(body, &resp)
Expect(err).To(BeNil())
Expect(resp.Data).To(HaveLen(5))
track := resp.Data[0]
Expect(track.Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
Expect(track.ID).To(Equal(67238732))
Expect(track.Album.Title).To(Equal("Random Access Memories"))
})
})
})

View File

@ -0,0 +1,7 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE annotation ADD COLUMN rated_at datetime;
-- +goose StatementEnd
-- +goose Down

2
go.mod
View File

@ -1,6 +1,6 @@
module github.com/navidrome/navidrome
go 1.25.4
go 1.25
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d

View File

@ -6,6 +6,7 @@ type Annotations struct {
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
Rating int `structs:"rating" json:"rating,omitempty" `
RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
Starred bool `structs:"starred" json:"starred,omitempty" `
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
}

View File

@ -44,6 +44,7 @@ var fieldMap = map[string]*mappedField{
"loved": {field: "COALESCE(annotation.starred, false)"},
"dateloved": {field: "annotation.starred_at"},
"lastplayed": {field: "annotation.play_date"},
"daterated": {field: "annotation.rated_at"},
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
"rating": {field: "COALESCE(annotation.rating, 0)"},
"mbz_album_id": {field: "media_file.mbz_album_id"},

View File

@ -106,6 +106,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
"random": "random",
"recently_added": recentlyAddedSort(),
"starred_at": "starred, starred_at",
"rated_at": "rating, rated_at",
})
return r
}

View File

@ -141,6 +141,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
r.setSortMappings(map[string]string{
"name": "order_artist_name",
"starred_at": "starred, starred_at",
"rated_at": "rating, rated_at",
"song_count": "stats->>'total'->>'m'",
"album_count": "stats->>'total'->>'a'",
"size": "stats->>'total'->>'s'",

View File

@ -84,6 +84,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
"created_at": "media_file.created_at",
"recently_added": mediaFileRecentlyAddedSort(),
"starred_at": "starred, starred_at",
"rated_at": "rating, rated_at",
})
return r
}

View File

@ -388,6 +388,7 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
"coalesce(play_count, 0) as play_count",
"play_date",
"coalesce(rating, 0) as rating",
"rated_at",
"f.*",
"playlist_tracks.*",
"library.path as library_path",

View File

@ -1,7 +1,6 @@
package persistence
import (
"context"
"time"
"github.com/navidrome/navidrome/conf"
@ -11,13 +10,14 @@ import (
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
var _ = Describe("PlaylistRepository", func() {
var repo model.PlaylistRepository
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlaylistRepository(ctx, GetDBXBuilder())
})
@ -252,4 +252,118 @@ var _ = Describe("PlaylistRepository", func() {
Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
})
})
Describe("Smart Playlists with Tag Criteria", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string
var songWithGrouping, songWithoutGrouping model.MediaFile
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
// Register 'grouping' as a valid tag for smart playlists
criteria.AddTagNames([]string{"grouping"})
// Create a song with the grouping tag
songWithGrouping = model.MediaFile{
ID: "test-grouping-1",
Title: "Song With Grouping",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "/test/grouping/song1.mp3",
Tags: model.Tags{
"grouping": []string{"My Crate"},
},
Participants: model.Participants{},
LibraryID: 1,
Lyrics: "[]",
}
Expect(mfRepo.Put(&songWithGrouping)).To(Succeed())
// Create a song without the grouping tag
songWithoutGrouping = model.MediaFile{
ID: "test-grouping-2",
Title: "Song Without Grouping",
Artist: "Test Artist",
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
Path: "/test/grouping/song2.mp3",
Tags: model.Tags{},
Participants: model.Participants{},
LibraryID: 1,
Lyrics: "[]",
}
Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed())
})
AfterEach(func() {
if testPlaylistID != "" {
_ = repo.Delete(testPlaylistID)
testPlaylistID = ""
}
// Clean up test media files
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute()
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute()
})
It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() {
By("creating a smart playlist that checks if grouping tag has any value")
// This is the workaround for issue #4728: using 'contains' with empty string
// generates SQL: value LIKE '%%' which matches any non-empty string
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"grouping": ""},
},
}
newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying only the track with grouping tag is matched")
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID))
})
It("excludes tracks with a tag value using 'notContains' with empty string", func() {
By("creating a smart playlist that checks if grouping tag is NOT set")
rules := &criteria.Criteria{
Expression: criteria.All{
criteria.NotContains{"grouping": ""},
},
}
newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules}
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("refreshing the smart playlist")
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
pls, err := repo.GetWithTracks(newPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
By("verifying the track with grouping is NOT in the playlist")
for _, track := range pls.Tracks {
Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID))
}
By("verifying the track without grouping IS in the playlist")
var foundWithoutGrouping bool
for _, track := range pls.Tracks {
if track.MediaFileID == songWithoutGrouping.ID {
foundWithoutGrouping = true
break
}
}
Expect(foundWithoutGrouping).To(BeTrue())
})
})
})

View File

@ -97,6 +97,7 @@ func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
"coalesce(rating, 0) as rating",
"starred_at",
"play_date",
"rated_at",
"f.*",
"playlist_tracks.*",
).

View File

@ -28,6 +28,7 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
"coalesce(rating, 0) as rating",
"starred_at",
"play_date",
"rated_at",
)
if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" {
query = query.Columns(
@ -77,7 +78,8 @@ func (r sqlRepository) SetStar(starred bool, ids ...string) error {
}
func (r sqlRepository) SetRating(rating int, itemID string) error {
return r.annUpsert(map[string]interface{}{"rating": rating}, itemID)
ratedAt := time.Now()
return r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
}
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
@ -119,7 +121,7 @@ func (r sqlRepository) cleanAnnotations() error {
del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
c, err := r.executeSQL(del)
if err != nil {
return fmt.Errorf("error cleaning up annotations: %w", err)
return fmt.Errorf("error cleaning up %s annotations: %w", r.tableName, err)
}
if c > 0 {
log.Debug(r.ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c)

View File

@ -148,10 +148,10 @@ func (r sqlRepository) cleanBookmarks() error {
del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
c, err := r.executeSQL(del)
if err != nil {
return fmt.Errorf("error cleaning up bookmarks: %w", err)
return fmt.Errorf("error cleaning up %s bookmarks: %w", r.tableName, err)
}
if c > 0 {
log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c)
log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c, "itemType", r.tableName)
}
return nil
}

View File

@ -88,10 +88,10 @@ func (r *tagRepository) purgeUnused() error {
`)
c, err := r.executeSQL(del)
if err != nil {
return fmt.Errorf("error purging unused tags: %w", err)
return fmt.Errorf("error purging %s unused tags: %w", r.tableName, err)
}
if c > 0 {
log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c)
log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c, "table", r.tableName)
}
return err
}

View File

@ -8,12 +8,10 @@ import (
"io"
"os"
"os/exec"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
)
// scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid
@ -47,9 +45,10 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod
// Add targets if provided
if len(targets) > 0 {
targetsStr := strings.Join(slice.Map(targets, func(t model.ScanTarget) string { return t.String() }), ",")
args = append(args, "--targets", targetsStr)
log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targetsStr)
for _, target := range targets {
args = append(args, "-t", target.String())
}
log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targets)
} else {
log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe)
}

View File

@ -324,6 +324,9 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
defer p.measure(entry)()
p.state.changesDetected.Store(true)
// Collect artwork IDs to pre-cache after the transaction commits
var artworkIDs []model.ArtworkID
err := p.ds.WithTx(func(tx model.DataStore) error {
// Instantiate all repositories just once per folder
folderRepo := tx.Folder(p.ctx)
@ -362,7 +365,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
return err
}
if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists {
entry.job.cw.PreCache(entry.artists[i].CoverArtID())
artworkIDs = append(artworkIDs, entry.artists[i].CoverArtID())
}
}
@ -374,7 +377,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
return err
}
if entry.albums[i].Name != consts.UnknownAlbum {
entry.job.cw.PreCache(entry.albums[i].CoverArtID())
artworkIDs = append(artworkIDs, entry.albums[i].CoverArtID())
}
}
@ -411,6 +414,14 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
if err != nil {
log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err)
}
// Pre-cache artwork after the transaction commits successfully
if err == nil {
for _, artID := range artworkIDs {
entry.job.cw.PreCache(artID)
}
}
return entry, err
}

9
tests/fixtures/deezer.artist.bio.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"data": {
"artist": {
"bio": {
"full": "<p>Schoolmates Thomas and Guy-Manuel began their career in 1992 with the indie rock trio Darlin' (named after The Beach Boys song) but were scathingly dismissed by Melody Maker magazine as \"daft punk.\" Turning to house-inspired electronica, they used the put down as a name for their DJ-ing partnership and became a hugely successful and influential dance act.</p>"
}
}
}
}

File diff suppressed because one or more lines are too long

1
tests/fixtures/deezer.artist.top.json vendored Normal file

File diff suppressed because one or more lines are too long

8
ui/package-lock.json generated
View File

@ -59,7 +59,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.24",
"happy-dom": "^20.0.8",
"happy-dom": "^20.0.10",
"jsdom": "^26.1.0",
"prettier": "^3.6.2",
"ra-test": "^3.19.12",
@ -6187,9 +6187,9 @@
"dev": true
},
"node_modules/happy-dom": {
"version": "20.0.8",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.8.tgz",
"integrity": "sha512-TlYaNQNtzsZ97rNMBAm8U+e2cUQXNithgfCizkDgc11lgmN4j9CKMhO3FPGKWQYPwwkFcPpoXYF/CqEPLgzfOg==",
"version": "20.0.10",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.10.tgz",
"integrity": "sha512-6umCCHcjQrhP5oXhrHQQvLB0bwb1UzHAHdsXy+FjtKoYjUhmNZsQL8NivwM1vDvNEChJabVrUYxUnp/ZdYmy2g==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@ -68,7 +68,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.24",
"happy-dom": "^20.0.8",
"happy-dom": "^20.0.10",
"jsdom": "^26.1.0",
"prettier": "^3.6.2",
"ra-test": "^3.19.12",

View File

@ -1,7 +1,7 @@
import React from 'react'
import PropTypes from 'prop-types'
import { useDispatch } from 'react-redux'
import { useMediaQuery } from '@material-ui/core'
import { useMediaQuery, CircularProgress } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import {
Button,
@ -45,6 +45,12 @@ const useStyles = makeStyles((theme) => ({
},
}))
const LoadingButton = ({ loading, icon, ...rest }) => (
<Button {...rest}>
{loading ? <CircularProgress size={20} color="inherit" /> : icon}
</Button>
)
const ArtistActions = ({ className, record, ...rest }) => {
const dispatch = useDispatch()
const translate = useTranslate()
@ -52,34 +58,45 @@ const ArtistActions = ({ className, record, ...rest }) => {
const notify = useNotify()
const classes = useStyles()
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const [loadingAction, setLoadingAction] = React.useState(null)
const isLoading = !!loadingAction
const handlePlay = React.useCallback(async () => {
setLoadingAction('play')
try {
await playTopSongs(dispatch, notify, record.name)
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error fetching top songs for artist:', e)
notify('ra.page.error', 'warning')
} finally {
setLoadingAction(null)
}
}, [dispatch, notify, record])
const handleShuffle = React.useCallback(async () => {
setLoadingAction('shuffle')
try {
await playShuffle(dataProvider, dispatch, record.id)
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error fetching songs for shuffle:', e)
notify('ra.page.error', 'warning')
} finally {
setLoadingAction(null)
}
}, [dataProvider, dispatch, record, notify])
const handleRadio = React.useCallback(async () => {
setLoadingAction('radio')
try {
await playSimilar(dispatch, notify, record.id)
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error starting radio for artist:', e)
notify('ra.page.error', 'warning')
} finally {
setLoadingAction(null)
}
}, [dispatch, notify, record])
@ -88,30 +105,33 @@ const ArtistActions = ({ className, record, ...rest }) => {
className={`${className} ${classes.toolbar}`}
{...sanitizeListRestProps(rest)}
>
<Button
<LoadingButton
onClick={handlePlay}
label={translate('resources.artist.actions.topSongs')}
className={classes.button}
size={isMobile ? 'small' : 'medium'}
>
<PlayArrowIcon />
</Button>
<Button
disabled={isLoading}
loading={loadingAction === 'play'}
icon={<PlayArrowIcon />}
/>
<LoadingButton
onClick={handleShuffle}
label={translate('resources.artist.actions.shuffle')}
className={classes.button}
size={isMobile ? 'small' : 'medium'}
>
<ShuffleIcon />
</Button>
<Button
disabled={isLoading}
loading={loadingAction === 'shuffle'}
icon={<ShuffleIcon />}
/>
<LoadingButton
onClick={handleRadio}
label={translate('resources.artist.actions.radio')}
className={classes.button}
size={isMobile ? 'small' : 'medium'}
>
<IoIosRadio className={classes.radioIcon} />
</Button>
disabled={isLoading}
loading={loadingAction === 'radio'}
icon={<IoIosRadio className={classes.radioIcon} />}
/>
</TopToolbar>
)
}

View File

@ -1,10 +1,11 @@
import React from 'react'
import { isDateSet } from '../utils/validations'
import { DateField as RADateField } from 'react-admin'
export const DateField = (props) => {
const { record, source } = props
const value = record?.[source]
if (value === '0001-01-01T00:00:00Z' || value === null) return null
if (!isDateSet(value)) return null
return <RADateField {...props} />
}

View File

@ -7,6 +7,7 @@ import { makeStyles } from '@material-ui/core/styles'
import { useToggleLove } from './useToggleLove'
import { useRecordContext } from 'react-admin'
import config from '../config'
import { isDateSet } from '../utils/validations'
const useStyles = makeStyles({
love: {
@ -46,8 +47,13 @@ export const LoveButton = ({
<Button
onClick={handleToggleLove}
size={'small'}
disabled={disabled || loading || record?.missing}
disabled={disabled || loading || record.missing}
className={classes.love}
title={
isDateSet(record.starredAt)
? new Date(record.starredAt).toLocaleString()
: undefined
}
{...rest}
>
{record.starred ? (

View File

@ -2,6 +2,7 @@ import React, { useCallback } from 'react'
import PropTypes from 'prop-types'
import Rating from '@material-ui/lab/Rating'
import { makeStyles } from '@material-ui/core/styles'
import { isDateSet } from '../utils/validations'
import StarBorderIcon from '@material-ui/icons/StarBorder'
import clsx from 'clsx'
import { useRating } from './useRating'
@ -45,7 +46,14 @@ export const RatingField = ({
)
return (
<span onClick={(e) => stopPropagation(e)}>
<span
onClick={(e) => stopPropagation(e)}
title={
isDateSet(record.ratedAt)
? new Date(record.ratedAt).toLocaleString()
: undefined
}
>
<Rating
name={record.mediaFileId || record.id}
className={clsx(

View File

@ -0,0 +1,175 @@
const stylesheet = `
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle {
background: #c231ab
}
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track,
.react-jinke-music-player-mobile-progress .rc-slider-track {
background: linear-gradient(to left, #c231ab, #380eff)
}
.react-jinke-music-player-mobile {
background-color: #171717 !important;
}
.react-jinke-music-player-mobile-progress .rc-slider-handle {
background: #c231ab;
height: 20px;
width: 20px;
margin-top: -9px;
}
.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
background-color: #c231ab;
}
.react-jinke-music-player-pause-icon {
background-color: #c231ab;
border-radius: 50%;
outline: auto;
color: white;
}
.react-jinke-music-player-main .music-player-panel .panel-content .player-content {
z-index: 99999;
}
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-btn svg {
border-radius: 50%;
outline: auto;
color: white;
}
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-btn svg:hover {
background-color: #c231ab;
border-radius: 50%;
outline: auto;
color: white;
}
.react-jinke-music-player-main svg:hover {
color: #c231ab;
}
.react-jinke-music-player .music-player-controller {
color: #c231ab;
border: 1px solid #e14ac2;
}
.react-jinke-music-player .music-player-controller.music-player-playing:before {
border: 1px solid rgba(194, 49, 171, 0.3);
}
.react-jinke-music-player .music-player .destroy-btn {
background-color: #c2c1c2;
top: -7px;
border-radius: 50%;
display: flex;
}
.react-jinke-music-player .music-player .destroy-btn svg {
font-size: 20px;
}
@media screen and (max-width: 767px) {
.react-jinke-music-player .music-player .destroy-btn {
right: -12px;
}
}
.react-jinke-music-player-mobile-header-right {
right: 0;
top: 0;
}
@media screen and (max-width: 767px) {
.react-jinke-music-player-main svg {
font-size: 32px;
}
}
@keyframes gradientFlow {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.RaBulkActionsToolbar .MuiButton-label {
color: white;
}
a[aria-current="page"] {
color: #c231ab !important;
font-weight: bold;
}
a[aria-current="page"] .MuiListItemIcon-root {
color: #c231ab !important;
}
.panel-content {
position: relative;
overflow: hidden;
background: linear-gradient(90deg, #311f2f, #0a0912, #2f0c28);
background-size: 300% 300%;
animation: gradientFlow 10s ease-in-out infinite;
}
/* Equalizer bars */
.panel-content::before {
content: "";
position: absolute;
inset: 0;
background: repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.05) 0px,
rgba(255, 255, 255, 0.05) 2px,
transparent 1px,
transparent 3px
);
animation: equalizer 1.8s infinite ease-in-out;
filter: blur(1px);
opacity: 0.5;
}
@keyframes backgroundFlow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* Vertical movement, equalizer type */
@keyframes equalizer {
0%, 100% {
transform: scaleY(1);
opacity: 0.2;
}
25% {
transform: scaleY(1.4);
opacity: 0.9;
}
50% {
transform: scaleY(0.7);
opacity: 0.2;
}
75% {
transform: scaleY(1.2);
opacity: 0.8;
}
}
@keyframes pulse {
0% { opacity: 0.5; }
100% { opacity: 1; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`
export default stylesheet

View File

@ -0,0 +1,608 @@
import stylesheet from './SquiddiesGlass.css.js'
/**
* Color constants used throughout the Squiddies Glass theme.
* Provides a consistent color palette with pink, gray, purple, and basic colors.
* @type {Object}
*/
const colors = {
pink: {
100: '#fbe3f4',
200: '#f5b9e3',
300: '#ec7cd6',
400: '#e14ac2',
500: '#c231ab', // base
600: '#a31a92',
700: '#8b0f7e',
800: '#7a006d',
900: '#670066',
},
gray: {
50: '#c2c1c2',
100: '#b3b3b3', // light gray
200: '#282828', // medium dark
300: '#1d1d1d', // darker
400: '#181818', // even darker
500: '#171717', // darkest
},
purple: {
400: '#524590',
500: '#4d3249',
600: '#6d1c5e',
},
black: '#000',
white: '#fff',
dark: '#121212',
}
/**
* Shared style object for music list action buttons.
* Defines common styling for buttons in music lists, including hover effects and responsive scaling.
* @type {Object}
*/
const musicListActions = {
padding: '1rem 0',
alignItems: 'center',
'@global': {
button: {
border: '1px solid transparent',
backgroundColor: 'inherit',
color: colors.gray[100],
'&:hover': {
border: `1px solid ${colors.gray[100]}`,
backgroundColor: 'inherit !important',
},
},
'button:first-child:not(:only-child)': {
'@media screen and (max-width: 720px)': {
transform: 'scale(1.3)',
margin: '1em',
'&:hover': {
transform: 'scale(1.2) !important',
},
},
transform: 'scale(1.3)',
margin: '1em',
minWidth: 0,
padding: 5,
transition: 'transform .3s ease',
background: colors.pink[500],
color: `${colors.black} !important`,
borderRadius: 500,
border: 0,
'&:hover': {
transform: 'scale(1.2)',
backgroundColor: `${colors.pink[500]} !important`,
border: 0,
},
},
'button:only-child': {
marginTop: '0.3em',
},
'button:first-child>span:first-child': {
padding: 0,
color: `${colors.black} !important`,
},
'button:first-child>span:first-child>span': {
display: 'none',
},
'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
{
color: colors.gray[100],
},
},
}
/**
* Squiddies Glass theme configuration object.
* Defines the complete theme structure including typography, palette, component overrides, and player settings.
* @type {Object}
*/
export default {
/**
* The name of the theme.
* @type {string}
*/
themeName: 'Squiddies Glass',
/**
* Typography settings for the theme.
* Specifies font family and heading sizes.
* @type {Object}
*/
typography: {
fontFamily: "system-ui, 'Helvetica Neue', Helvetica, Arial, sans-serif",
h6: {
fontSize: '1rem', // AppBar title
},
},
/**
* Color palette configuration.
* Defines primary, secondary, and background colors for the theme.
* @type {Object}
*/
palette: {
primary: {
light: colors.pink[300],
main: colors.pink[500],
},
secondary: {
main: colors.white,
contrastText: colors.white,
},
background: {
default: colors.dark,
paper: colors.dark,
},
type: 'dark',
},
/**
* Component overrides for Material-UI and custom Navidrome components.
* Customizes the appearance and behavior of various UI components.
* @type {Object}
*/
overrides: {
// Material-UI Components
MuiAppBar: {
positionFixed: {
backgroundColor: `${colors.black} !important`,
boxShadow: 'none',
},
},
MuiButton: {
root: {
background: colors.pink[500],
color: colors.white,
border: '1px solid transparent',
borderRadius: 500,
'&:hover': {
background: `${colors.pink[900]} !important`,
},
},
textSecondary: {
border: `1px solid ${colors.gray[100]}`,
background: colors.black,
'&:hover': {
border: `1px solid ${colors.white} !important`,
background: `${colors.black} !important`,
},
},
label: {
color: colors.white,
paddingRight: '1rem',
paddingLeft: '0.7rem',
},
},
MuiCardMedia: {
root: {
position: 'relative',
overflow: 'hidden',
boxShadow: `0 2px 32px rgba(0,0,0,0.5), 0px 1px 5px rgba(0,0,0,0.1)`,
},
},
MuiDivider: {
root: {
margin: '.75rem 0',
},
},
MuiDrawer: {
root: {
background: colors.gray[500],
paddingTop: '10px',
},
},
MuiFormGroup: {
root: {
color: colors.pink[500],
},
},
MuiMenuItem: {
root: {
fontSize: '0.875rem',
},
},
MuiTableCell: {
root: {
borderBottom: `1px solid ${colors.gray[300]}`,
padding: '10px !important',
color: `${colors.gray[100]} !important`,
'& img': {
filter:
'brightness(0) saturate(100%) invert(36%) sepia(93%) saturate(7463%) hue-rotate(289deg) brightness(95%) contrast(102%);',
},
'& img + span': {
color: colors.pink[500],
},
},
head: {
borderBottom: `1px solid ${colors.gray[200]}`,
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: 1.2,
},
},
MuiTableRow: {
root: {
padding: '10px 0',
transition: 'background-color .3s ease',
'&:hover': {
backgroundColor: `${colors.gray[300]} !important`,
},
'@global': {
'td:nth-child(4)': {
color: `${colors.white} !important`,
},
},
},
},
// React Admin Components
RaBulkActionsToolbar: {
topToolbar: {
gap: '8px',
},
},
RaFilter: {
form: {
'& .MuiOutlinedInput-input:-webkit-autofill': {
'-webkit-box-shadow': `0 0 0 100px ${colors.gray[50]} inset`,
'-webkit-text-fill-color': colors.white,
},
},
},
RaFilterButton: {
root: {
marginRight: '1rem',
},
},
RaLayout: {
content: {
padding: '0 !important',
background: `linear-gradient(${colors.dark}, ${colors.gray[500]})`,
borderTopRightRadius: '8px',
borderTopLeftRadius: '8px',
},
contentWithSidebar: {
gap: '2px',
},
},
RaList: {
content: {
backgroundColor: 'inherit',
},
bulkActionsDisplayed: {
marginTop: '-20px',
},
},
RaListToolbar: {
toolbar: {
padding: '0 .55rem !important',
},
},
RaPaginationActions: {
currentPageButton: {
border: `1px solid ${colors.gray[100]}`,
},
button: {
backgroundColor: 'inherit',
minWidth: 48,
margin: '0 4px',
border: `1px solid ${colors.gray[200]}`,
'@global': {
'> .MuiButton-label': {
padding: 0,
},
},
},
actions: {
'@global': {
'.next-page': {
marginLeft: 8,
marginRight: 8,
},
'.previous-page': {
marginRight: 8,
},
},
},
},
RaSearchInput: {
input: {
paddingLeft: '.9rem',
border: 0,
'& .MuiInputBase-root': {
backgroundColor: `${colors.white} !important`,
borderRadius: '20px !important',
color: colors.black,
border: '0px',
'& fieldset': {
borderColor: colors.white,
},
'&:hover fieldset': {
borderColor: colors.white,
},
'&.Mui-focused fieldset': {
borderColor: colors.white,
},
'& svg': {
color: `${colors.black} !important`,
},
'& .MuiOutlinedInput-input:-webkit-autofill': {
borderRadius: '20px 0px 0px 20px',
'-webkit-box-shadow': `0 0 0 100px ${colors.gray[50]} inset`,
'-webkit-text-fill-color': colors.black,
},
},
},
},
RaSidebar: {
root: {
height: 'initial',
borderTopRightRadius: '8px',
borderTopLeftRadius: '8px',
},
},
// Navidrome Custom Components
NDAlbumDetails: {
root: {
boxShadow: 'none',
background: `linear-gradient(45deg, ${colors.purple[500]}, ${colors.purple[400]}, ${colors.purple[600]})`,
backgroundSize: '200% 200%',
animation: 'gradientFlow 8s ease-in-out infinite',
position: 'relative',
'&:before': {
content: '""',
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
background: `linear-gradient(to bottom, transparent, ${colors.dark})`,
},
},
cardContents: {
alignItems: 'flex-start',
},
coverParent: {
zIndex: '99999',
position: 'relative',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
'&::before': {
content: '""',
position: 'absolute',
inset: '0',
width: '100%',
height: '100%',
borderRadius: '50%',
animation: 'pulse 1.5s ease-in-out infinite alternate',
zIndex: -1,
},
'&::after': {
content: '""',
position: 'absolute',
inset: '0',
zIndex: '-1',
borderRadius: '50%',
background:
'repeating-conic-gradient(from 0deg, rgba(255,255,255,0.08) 0deg, rgba(255,255,255,0.08) 0.5deg, rgba(0,0,0,1) 1deg)',
filter: 'contrast(999) sepia(1)',
boxShadow:
'inset 0 0 25px rgba(255,255,255,0.05), inset 0 0 95px rgba(0,0,0,0.9)',
animation: 'spin 6s linear infinite',
},
},
details: {
zIndex: '99999',
},
recordName: {
fontSize: 'calc(1rem + 1.5vw)',
fontWeight: 900,
},
recordArtist: {
fontSize: '1.5rem',
fontWeight: 700,
textShadow: '0 2px 16px rgba(0, 0, 0, 0.3)',
},
recordMeta: {
fontSize: '.875rem',
color: `rgba(${colors.white}, 0.8)`,
},
content: {
paddingBottom: '0px !important',
paddingTop: '0px',
},
},
RaSingleFieldList: {
root: {
'& a:first-of-type > .MuiChip-root': {
marginLeft: '0px',
},
'& a > .MuiChip-root': {
backgroundColor: colors.pink[500],
fontSize: '0.6rem',
height: '20px',
'& .MuiChip-label': {
color: colors.white,
paddingLeft: '5px',
paddingRight: '5px',
},
},
},
},
MuiGridListTile: {
tile: {
'&:hover': {
boxShadow: '0 2px 32px rgba(0,0,0,0.5), 0px 1px 5px rgba(0,0,0,0.1)',
},
},
},
NDAlbumGridView: {
tileBar: {
background:
'linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0.4) 50%, rgba(0, 0, 0, 0) 100%)',
marginBottom: '2px',
},
albumName: {
marginTop: '0.5rem',
fontWeight: 700,
textTransform: 'none',
color: colors.white,
},
albumSubtitle: {
color: colors.gray[100],
},
albumContainer: {
backgroundColor: colors.gray[400],
borderRadius: '.5rem',
padding: '.75rem',
transition: 'background-color .3s ease',
'&:hover': {
backgroundColor: colors.gray[200],
},
},
albumPlayButton: {
color: colors.black,
backgroundColor: colors.pink[500],
borderRadius: '50%',
boxShadow: '0 8px 8px rgb(0 0 0 / 30%)',
padding: '0.35rem',
transition: 'padding .3s ease',
'&:hover': {
background: `${colors.pink[500]} !important`,
padding: '0.45rem',
},
},
},
NDAlbumShow: {
albumActions: musicListActions,
},
NDArtistShow: {
actions: {
padding: '2rem 0',
alignItems: 'center',
overflow: 'visible',
minHeight: '120px',
'@global': {
button: {
border: '1px solid transparent',
backgroundColor: 'inherit',
color: colors.gray[100],
margin: '0 0.5rem',
'&:hover': {
border: `1px solid ${colors.gray[100]}`,
backgroundColor: 'inherit !important',
},
},
// Hide shuffle button label (first button)
'button:first-child>span:first-child>span': {
display: 'none',
},
// Style shuffle button (first button)
'button:first-child': {
'@media screen and (max-width: 720px)': {
transform: 'scale(1.5)',
margin: '1rem',
'&:hover': {
transform: 'scale(1.6) !important',
},
},
transform: 'scale(2)',
margin: '1.5rem',
minWidth: 0,
padding: 5,
transition: 'transform .3s ease',
background: colors.pink[500],
color: colors.white,
borderRadius: 500,
border: 0,
'&:hover': {
transform: 'scale(2.1)',
backgroundColor: `${colors.pink[500]} !important`,
border: 0,
},
},
'button:first-child>span:first-child': {
padding: 0,
color: `${colors.black} !important`,
},
'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
{
color: colors.gray[100],
},
},
},
actionsContainer: {
overflow: 'visible',
},
},
NDAudioPlayer: {
audioTitle: {
color: colors.white,
fontSize: '1.5rem',
'& span:nth-child(3)': {
fontSize: '0.8rem',
},
},
songTitle: {
fontWeight: 900,
},
songInfo: {
fontSize: '0.9rem',
color: colors.gray[100],
},
},
NDCollapsibleComment: {
commentBlock: {
fontSize: '.875rem',
color: `rgba(${colors.white}, 0.8)`,
},
},
NDLogin: {
main: {
boxShadow: `inset 0 0 0 2000px rgba(${colors.black}, .75)`,
},
systemNameLink: {
color: colors.white,
},
card: {
border: `1px solid ${colors.gray[200]}`,
},
avatar: {
marginBottom: 0,
},
},
NDPlaylistDetails: {
container: {
background: `linear-gradient(${colors.gray[300]}, transparent)`,
borderRadius: 0,
paddingTop: '2.5rem !important',
boxShadow: 'none',
},
title: {
fontSize: 'calc(1.5rem + 1.5vw)',
fontWeight: 700,
color: colors.white,
},
details: {
fontSize: '.875rem',
color: `rgba(${colors.white}, 0.8)`,
},
},
NDPlaylistShow: {
playlistActions: musicListActions,
},
},
/**
* Player configuration settings.
* Specifies the player theme and associated stylesheet.
* @type {Object}
*/
player: {
theme: 'dark',
stylesheet,
},
}

View File

@ -0,0 +1,89 @@
const stylesheet = `
.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
color: #D60017
}
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle,
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track {
background-color: #ff4e6b
}
.react-jinke-music-player-main ::-webkit-scrollbar-thumb,
.react-jinke-music-player-mobile-progress .rc-slider-handle,
.react-jinke-music-player-mobile-progress .rc-slider-track {
background-color: #ff4e6b
}
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
box-shadow: 0 0 2px #ff4e6b
}
.audio-lists-panel-content .audio-item.playing,
.react-jinke-music-player-main .audio-item.playing svg,
.react-jinke-music-player-main .group player-delete {
color: #ff4e6b
}
.audio-lists-panel-content .audio-item:hover,
.audio-lists-panel-content .audio-item:hover svg
.audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg{
color: #D60017
}
.react-jinke-music-player-main .audio-item.playing .player-singer {
color: #ff4e6b !important
}
.react-jinke-music-player-main .lyric-btn,
.react-jinke-music-player-main .lyric-btn-active svg{
color: #ff4e6b !important
}
.react-jinke-music-player-main .lyric-btn-active {
color: #D60017 !important
}
.react-jinke-music-player-main .loading svg {
color: #ff4e6b !important
}
.react-jinke-music-player .music-player-controller .music-player-controller-setting{
background: #ff4e6b4d
}
.react-jinke-music-player-main .music-player-lyric{
color: #ff4e6b !important;
text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000
}
.react-jinke-music-player-main .music-player-panel,
.react-jinke-music-player-mobile,
.ril__outer{
background-color: #1f1f1f;
border: 1px solid #fff1;
}
.ril__toolbar{
background-color: #1d1d1d
}
.ril__toolbarItem{
font-size: 100%;
color: #eee
}
.audio-lists-panel{
background-color: #1f1f1f;
border: 1px solid #fff1;
border-radius: 6px 6px 0 0;
}
.react-jinke-music-player-main .music-player-panel .panel-content .img-rotate,
.react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover img.cover,
.react-jinke-music-player-mobile-cover {
border-radius: 6px !important;
animation-duration: 0s !important
}
.react-jinke-music-player-main .music-player-panel .panel-content .img-content{
width: 60px;
height: 60px
}
.react-jinke-music-player-main .songTitle{
color: #eee
}
.react-jinke-music-player .music-player-controller{
color: #ff4e6b
}
.audio-lists-panel-mobile .audio-item:not(.audio-lists-panel-sortable-highlight-bg){
background: unset
}
.lastfm-icon,
.musicbrainz-icon{
color: #eee
}
`
export default stylesheet

197
ui/src/themes/amusic.js Normal file
View File

@ -0,0 +1,197 @@
import stylesheet from './amusic.css.js'
export default {
themeName: 'AMusic',
typography: {
fontFamily:
'-apple-system, BlinkMacSystemFont, Apple Color Emoji, SF Pro, SF Pro Icons, Helvetica Neue, Helvetica, Arial, sans-serif',
h6: {
fontSize: '1rem', // AppBar title
},
h5: {
fontSize: '2em',
fontWeight: '600',
},
},
palette: {
primary: {
main: '#ff4e6b',
},
secondary: {
main: '#D60017',
contrastText: '#eee',
},
background: {
default: '#1a1a1a',
paper: '#1a1a1a',
},
type: 'dark',
},
overrides: {
MuiFormGroup: {
root: {
color: 'white',
},
},
MuiAppBar: {
positionFixed: {
backgroundColor: '#1d1d1d !important',
boxShadow: 'none',
borderBottom: '1px solid #fff1',
},
colorSecondary: {
color: '#eee',
},
},
MuiDrawer: {
root: {
background: '#1d1d1d',
borderRight: '1px solid #fff1',
},
},
MuiToolbar: {
root: {
background: 'transparent !important',
},
},
MuiCardMedia: {
img: {
borderRadius: '10px',
boxShadow: '5px 5px 20px #111',
},
},
MuiButton: {
root: {
background: '#D60017',
color: '#fff',
borderRadius: '6px',
paddingRight: '0.5rem',
paddingLeft: '0.5rem',
marginLeft: '0.5rem',
marginBottom: '0.5rem',
textTransform: 'capitalize',
fontWeight: 600,
},
textPrimary: {
color: '#eee',
},
textSecondary: {
color: '#eee',
backgroundColor: '#ff4e6b',
},
textSizeSmall: {
fontSize: '0.8rem',
paddingRight: '0.5rem',
paddingLeft: '0.5rem',
},
label: {
paddingRight: '1rem',
paddingLeft: '0.7rem',
},
},
MuiListItemIcon: {
root: {
color: '#ff4e6b',
},
},
MuiChip: {
root: {
borderRadius: '6px',
},
},
MuiIconButton: {
root: {
color: '#ff4e6b',
},
},
MuiTableBody: {
root: {
'&>tr:nth-child(odd)': {
background: 'rgba(255, 255, 255, 0.025)',
},
},
},
MuiTableRow: {
root: {
background: 'transparent',
},
},
MuiTableCell: {
root: {
borderBottom: '0 none !important',
padding: '10px !important',
color: '#b3b3b3 !important',
},
head: {
color: '#b3b3b3 !important',
},
},
MuiMenuItem: {
root: {
fontSize: '0.875rem',
borderRadius: '10px',
color: '#eee',
},
},
NDAlbumGridView: {
albumName: {
color: '#eee',
},
albumSubtitle: {
color: '#ccc',
},
albumPlayButton: {
color: '#ff4e6b !important',
},
albumArtistName: {
color: '#ff4e6b !important',
},
cover: {
borderRadius: '10px !important',
},
},
NDLogin: {
systemNameLink: {
color: '#D60017',
},
welcome: {
color: '#eee',
},
card: {
minWidth: 300,
backgroundColor: '#1d1d1d',
},
},
MuiPaper: {
elevation1: {
boxShadow: 'none',
},
root: {
color: '#eee',
},
},
NDMobileArtistDetails: {
bgContainer: {
background: '#1a1a1a',
},
artistName: {
fontWeight: '600',
fontSize: '2em',
},
},
NDDesktopArtistDetails: {
artistName: {
fontWeight: '600',
fontSize: '2em',
},
artistDetail: {
padding: 'unset',
paddingBottom: '1rem',
},
},
},
player: {
theme: 'dark',
stylesheet,
},
}

View File

@ -10,6 +10,8 @@ import NordTheme from './nord'
import GruvboxDarkTheme from './gruvboxDark'
import CatppuccinMacchiatoTheme from './catppuccinMacchiato'
import NuclearTheme from './nuclear'
import AmusicTheme from './amusic'
import SquiddiesGlassTheme from './SquiddiesGlass'
export default {
// Classic default themes
@ -17,6 +19,7 @@ export default {
DarkTheme,
// New themes should be added here, in alphabetic order
AmusicTheme,
CatppuccinMacchiatoTheme,
ElectricPurpleTheme,
ExtraDarkTheme,
@ -27,4 +30,5 @@ export default {
NordTheme,
NuclearTheme,
SpotifyTheme,
SquiddiesGlassTheme,
}

View File

@ -10,3 +10,16 @@ export const urlValidate = (value) => {
return 'ra.validation.url'
}
}
export function isDateSet(date) {
if (!date) {
return false
}
if (typeof date === 'string') {
return date !== '0001-01-01T00:00:00Z'
}
if (date instanceof Date) {
return date.toISOString() !== '0001-01-01T00:00:00Z'
}
return !!date
}

View File

@ -0,0 +1,73 @@
import { isDateSet, urlValidate } from './validations'
describe('urlValidate', () => {
it('returns undefined for valid URLs', () => {
expect(urlValidate('https://example.com')).toBeUndefined()
expect(urlValidate('http://localhost:3000')).toBeUndefined()
expect(urlValidate('ftp://files.example.com')).toBeUndefined()
})
it('returns undefined for empty values', () => {
expect(urlValidate('')).toBeUndefined()
expect(urlValidate(null)).toBeUndefined()
expect(urlValidate(undefined)).toBeUndefined()
})
it('returns error for invalid URLs', () => {
expect(urlValidate('not-a-url')).toEqual('ra.validation.url')
expect(urlValidate('example.com')).toEqual('ra.validation.url')
expect(urlValidate('://missing-protocol')).toEqual('ra.validation.url')
})
})
describe('isDateSet', () => {
describe('with falsy values', () => {
it('returns false for null', () => {
expect(isDateSet(null)).toBe(false)
})
it('returns false for undefined', () => {
expect(isDateSet(undefined)).toBe(false)
})
it('returns false for empty string', () => {
expect(isDateSet('')).toBe(false)
})
})
describe('with Go zero date string', () => {
it('returns false for Go zero date', () => {
expect(isDateSet('0001-01-01T00:00:00Z')).toBe(false)
})
})
describe('with valid date strings', () => {
it('returns true for ISO date strings', () => {
expect(isDateSet('2024-01-15T10:30:00Z')).toBe(true)
expect(isDateSet('2023-12-25T00:00:00Z')).toBe(true)
})
it('returns true for other date formats', () => {
expect(isDateSet('2024-01-15')).toBe(true)
})
})
describe('with Date objects', () => {
it('returns true for valid Date objects', () => {
expect(isDateSet(new Date())).toBe(true)
expect(isDateSet(new Date('2024-01-15T10:30:00Z'))).toBe(true)
})
// Note: Date objects representing Go zero date would return true because
// toISOString() adds milliseconds (0001-01-01T00:00:00.000Z).
// In practice, dates from the API come as strings, not Date objects,
// so this edge case doesn't occur.
})
describe('with other truthy values', () => {
it('returns true for non-date truthy values', () => {
expect(isDateSet(123)).toBe(true)
expect(isDateSet({})).toBe(true)
})
})
})