mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-03 06:41:01 +00:00
Compare commits
17 Commits
2c349790d2
...
fc78482935
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc78482935 | ||
|
|
dc07dc413d | ||
|
|
3294bcacfc | ||
|
|
228211f925 | ||
|
|
a6a682b385 | ||
|
|
c40f12e65b | ||
|
|
12d0898585 | ||
|
|
c21aee7360 | ||
|
|
ee51bd9281 | ||
|
|
2451e9e7ae | ||
|
|
f6b2ab5726 | ||
|
|
67c4e24957 | ||
|
|
255ed1f8e2 | ||
|
|
152f57e642 | ||
|
|
5c16622501 | ||
|
|
36fa869329 | ||
|
|
3c4fd33be7 |
20
.github/workflows/pipeline.yml
vendored
20
.github/workflows/pipeline.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/update-translations.yml
vendored
2
.github/workflows/update-translations.yml
vendored
@ -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:
|
||||
|
||||
@ -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}
|
||||
|
||||
2
Makefile
2
Makefile
@ -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/*")
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
5
conf/testdata/cfg.ini
vendored
5
conf/testdata/cfg.ini
vendored
@ -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
|
||||
3
conf/testdata/cfg.json
vendored
3
conf/testdata/cfg.json
vendored
@ -2,6 +2,9 @@
|
||||
"musicFolder": "/json/music",
|
||||
"uiWelcomeMessage": "Welcome json",
|
||||
"Tags": {
|
||||
"artist": {
|
||||
"split": ";"
|
||||
},
|
||||
"custom": {
|
||||
"aliases": [
|
||||
"json",
|
||||
|
||||
2
conf/testdata/cfg.toml
vendored
2
conf/testdata/cfg.toml
vendored
@ -1,5 +1,7 @@
|
||||
musicFolder = "/toml/music"
|
||||
uiWelcomeMessage = "Welcome toml"
|
||||
|
||||
Tags.artist.Split = ';'
|
||||
|
||||
[Tags.custom]
|
||||
aliases = ["toml", "test"]
|
||||
|
||||
2
conf/testdata/cfg.yaml
vendored
2
conf/testdata/cfg.yaml
vendored
@ -1,6 +1,8 @@
|
||||
musicFolder: "/yaml/music"
|
||||
uiWelcomeMessage: "Welcome yaml"
|
||||
Tags:
|
||||
artist:
|
||||
split: [";"]
|
||||
custom:
|
||||
aliases:
|
||||
- yaml
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
101
core/agents/deezer/client_auth.go
Normal file
101
core/agents/deezer/client_auth.go
Normal 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
|
||||
}
|
||||
293
core/agents/deezer/client_auth_test.go
Normal file
293
core/agents/deezer/client_auth_test.go
Normal 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)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE annotation ADD COLUMN rated_at datetime;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
|
||||
2
go.mod
2
go.mod
@ -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
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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'",
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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.*",
|
||||
).
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
9
tests/fixtures/deezer.artist.bio.json
vendored
Normal 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>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
tests/fixtures/deezer.artist.related.json
vendored
Normal file
1
tests/fixtures/deezer.artist.related.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
tests/fixtures/deezer.artist.top.json
vendored
Normal file
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
8
ui/package-lock.json
generated
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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(
|
||||
|
||||
175
ui/src/themes/SquiddiesGlass.css.js
Normal file
175
ui/src/themes/SquiddiesGlass.css.js
Normal 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
|
||||
608
ui/src/themes/SquiddiesGlass.js
Normal file
608
ui/src/themes/SquiddiesGlass.js
Normal 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,
|
||||
},
|
||||
}
|
||||
89
ui/src/themes/amusic.css.js
Normal file
89
ui/src/themes/amusic.css.js
Normal 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
197
ui/src/themes/amusic.js
Normal 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,
|
||||
},
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
73
ui/src/utils/validations.test.js
Normal file
73
ui/src/utils/validations.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user