mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-08 03:08:50 +01:00
Compare commits
1 commit
master
...
fix/6285-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf72f00760 |
62 changed files with 371 additions and 2532 deletions
11
.github/workflows/pipeline.yml
vendored
11
.github/workflows/pipeline.yml
vendored
|
|
@ -519,18 +519,13 @@ jobs:
|
||||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
|
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
|
||||||
|
|
||||||
push-manifest-dockerhub:
|
push-manifest-dockerhub:
|
||||||
name: Push to Docker Hub (${{ matrix.repository }})
|
name: Push to Docker Hub
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
needs: [build, check-push-enabled]
|
needs: [build, check-push-enabled]
|
||||||
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
|
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
# Always push to DOCKER_HUB_REPO. The upstream repository also pushes to the navidrome/navidrome org repository.
|
|
||||||
repository: ${{ fromJSON(github.repository == 'navidrome/navidrome' && format('["{0}","navidrome/navidrome"]', vars.DOCKER_HUB_REPO) || format('["{0}"]', vars.DOCKER_HUB_REPO)) }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
|
@ -546,7 +541,7 @@ jobs:
|
||||||
id: docker
|
id: docker
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
hub_repository: ${{ matrix.repository }}
|
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
|
||||||
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
|
|
@ -563,7 +558,7 @@ jobs:
|
||||||
|
|
||||||
- name: Inspect image in Docker Hub
|
- name: Inspect image in Docker Hub
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ${{ matrix.repository }}:${{ steps.docker.outputs.version }}
|
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
|
||||||
|
|
||||||
cleanup-digests:
|
cleanup-digests:
|
||||||
name: Cleanup digest artifacts
|
name: Cleanup digest artifacts
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -18,7 +18,7 @@ endif
|
||||||
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386
|
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386
|
||||||
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
||||||
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||||
DOCKER_TAG ?= navidrome/navidrome:develop
|
DOCKER_TAG ?= deluan/navidrome:develop
|
||||||
|
|
||||||
GOLANGCI_LINT_VERSION ?= v2.14.0
|
GOLANGCI_LINT_VERSION ?= v2.14.0
|
||||||
VACUUM_VERSION ?= v0.30.6
|
VACUUM_VERSION ?= v0.30.6
|
||||||
|
|
@ -223,7 +223,7 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che
|
||||||
--output "./binaries" --target binary .
|
--output "./binaries" --target binary .
|
||||||
.PHONY: docker-build
|
.PHONY: docker-build
|
||||||
|
|
||||||
docker-image: ##@Cross_Compilation Build Docker image, tagged as `navidrome/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
|
docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
|
||||||
@echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true
|
@echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true
|
||||||
@echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true
|
@echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true
|
||||||
@echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true
|
@echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true
|
||||||
|
|
|
||||||
17
README.md
17
README.md
|
|
@ -5,7 +5,7 @@
|
||||||
[](https://github.com/navidrome/navidrome/releases)
|
[](https://github.com/navidrome/navidrome/releases)
|
||||||
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
||||||
[](https://github.com/navidrome/navidrome/releases/latest)
|
[](https://github.com/navidrome/navidrome/releases/latest)
|
||||||
[](https://hub.docker.com/r/navidrome/navidrome)
|
[](https://hub.docker.com/r/deluan/navidrome)
|
||||||
[](https://discord.gg/xh7j7yF)
|
[](https://discord.gg/xh7j7yF)
|
||||||
[](https://www.reddit.com/r/navidrome/)
|
[](https://www.reddit.com/r/navidrome/)
|
||||||
[](CODE_OF_CONDUCT.md)
|
[](CODE_OF_CONDUCT.md)
|
||||||
|
|
@ -35,18 +35,11 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
|
||||||
|
|
||||||
## Cloud Hosting
|
## Cloud Hosting
|
||||||
|
|
||||||
Several cloud hosting providers partner with us to offer [officially supported, cloud-hosted solutions](https://www.navidrome.org/docs/installation/managed). If you sign up with any of these providers, a share of the revenue funds the development of Navidrome at no additional cost for you.
|
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
|
||||||
|
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
|
||||||
<a href="https://www.pikapods.com/pods?run=navidrome"><img src="resources/hosting/pikapods.svg" alt="Run on PikaPods" width="200" height="40"></a>
|
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
|
||||||
<br>
|
|
||||||
<a href="https://zenith.hosting/host/navidrome?ref=navidrome"><img src="resources/hosting/zenith.svg" alt="Deploy with Zenith" width="200" height="40"></a>
|
|
||||||
<br>
|
|
||||||
<a href="https://store.elfhosted.com/product/navidrome/"><img src="resources/hosting/elfhosted.svg" alt="Deploy on ElfHosted" width="200" height="40"></a>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[](https://www.pikapods.com/pods?run=navidrome)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,10 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
backupCount int
|
backupCount int
|
||||||
backupDir string
|
backupDir string
|
||||||
force bool
|
force bool
|
||||||
integrityCheck bool
|
restorePath string
|
||||||
restorePath string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -32,7 +31,6 @@ func init() {
|
||||||
|
|
||||||
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)")
|
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)")
|
||||||
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
|
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
|
||||||
restoreCommand.Flags().BoolVarP(&integrityCheck, "integrity-check", "i", false, "check the backup file with a full integrity check before restoring. Finds more kinds of damage, but takes longer")
|
|
||||||
_ = restoreCommand.MarkFlagRequired("backup-file")
|
_ = restoreCommand.MarkFlagRequired("backup-file")
|
||||||
backupRoot.AddCommand(restoreCommand)
|
backupRoot.AddCommand(restoreCommand)
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +136,7 @@ func runRestore(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err := db.Restore(ctx, restorePath, integrityCheck)
|
err := db.Restore(ctx, restorePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error restoring database", "backupFile", restorePath, err)
|
log.Fatal("Error restoring database", "backupFile", restorePath, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,17 +76,6 @@ func (lfs *localFS) ResolveSymlink(name string) (string, error) {
|
||||||
return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name)))
|
return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadLink and Lstat implement fs.ReadLinkFS, so callers can detect symlinks without following them.
|
|
||||||
var _ fs.ReadLinkFS = (*localFS)(nil)
|
|
||||||
|
|
||||||
func (lfs *localFS) ReadLink(name string) (string, error) {
|
|
||||||
return fs.ReadLink(lfs.FS, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lfs *localFS) Lstat(name string) (fs.FileInfo, error) {
|
|
||||||
return fs.Lstat(lfs.FS, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) {
|
func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) {
|
||||||
res, err := lfs.extractor.Parse(path...)
|
res, err := lfs.extractor.Parse(path...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
135
db/backup.go
135
db/backup.go
|
|
@ -33,41 +33,59 @@ func backupPath(t time.Time) string {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func openBackupFile(path string) (*sql.DB, error) {
|
func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
|
||||||
Db() // Registers Driver, which the CLI commands haven't done at this point.
|
// heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/
|
||||||
|
existingConn, err := Db().Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting existing connection: %w", err)
|
||||||
|
}
|
||||||
|
defer existingConn.Close()
|
||||||
|
|
||||||
|
// The driver opens with SQLITE_OPEN_CREATE, so without this check a typo in the
|
||||||
|
// path would create an empty database and "restore" it over the live one.
|
||||||
|
if !isBackup {
|
||||||
|
// The driver splits the DSN at '?', so such a path would open a different file.
|
||||||
|
if strings.ContainsRune(path, '?') {
|
||||||
|
return fmt.Errorf("backup path cannot contain '?': %s", path)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return fmt.Errorf("backup file not available: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
backupDb, err := sql.Open(Driver, path)
|
backupDb, err := sql.Open(Driver, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("opening backup database in '%s': %w", path, err)
|
return fmt.Errorf("opening backup database in '%s': %w", path, err)
|
||||||
}
|
}
|
||||||
return backupDb, nil
|
defer backupDb.Close()
|
||||||
}
|
|
||||||
|
|
||||||
func copyDatabase(ctx context.Context, dest, source *sql.DB) error {
|
backupConn, err := backupDb.Conn(ctx)
|
||||||
// heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/
|
|
||||||
destConn, err := dest.Conn(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("getting destination connection: %w", err)
|
return fmt.Errorf("getting backup connection: %w", err)
|
||||||
}
|
}
|
||||||
defer destConn.Close()
|
defer backupConn.Close()
|
||||||
|
|
||||||
sourceConn, err := source.Conn(ctx)
|
err = existingConn.Raw(func(existing any) error {
|
||||||
if err != nil {
|
return backupConn.Raw(func(backup any) error {
|
||||||
return fmt.Errorf("getting source connection: %w", err)
|
var sourceOk, destOk bool
|
||||||
}
|
var sourceConn, destConn *sqlite3.SQLiteConn
|
||||||
defer sourceConn.Close()
|
|
||||||
|
|
||||||
return destConn.Raw(func(rawDest any) error {
|
if isBackup {
|
||||||
return sourceConn.Raw(func(rawSource any) error {
|
sourceConn, sourceOk = existing.(*sqlite3.SQLiteConn)
|
||||||
sqliteDest, destOk := rawDest.(*sqlite3.SQLiteConn)
|
destConn, destOk = backup.(*sqlite3.SQLiteConn)
|
||||||
if !destOk {
|
} else {
|
||||||
return fmt.Errorf("error trying to convert destination to sqlite connection")
|
sourceConn, sourceOk = backup.(*sqlite3.SQLiteConn)
|
||||||
|
destConn, destOk = existing.(*sqlite3.SQLiteConn)
|
||||||
}
|
}
|
||||||
sqliteSource, sourceOk := rawSource.(*sqlite3.SQLiteConn)
|
|
||||||
if !sourceOk {
|
if !sourceOk {
|
||||||
return fmt.Errorf("error trying to convert source to sqlite connection")
|
return fmt.Errorf("error trying to convert source to sqlite connection")
|
||||||
}
|
}
|
||||||
|
if !destOk {
|
||||||
|
return fmt.Errorf("error trying to convert destination to sqlite connection")
|
||||||
|
}
|
||||||
|
|
||||||
backupOp, err := sqliteDest.Backup("main", sqliteSource, "main")
|
backupOp, err := destConn.Backup("main", sourceConn, "main")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error starting sqlite backup: %w", err)
|
return fmt.Errorf("error starting sqlite backup: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -91,89 +109,24 @@ func copyDatabase(ctx context.Context, dest, source *sql.DB) error {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// checkRestoreSource runs sanity checks on a file about to be restored over the live
|
return err
|
||||||
// database. They catch the common ways a backup goes bad, not every kind of damage.
|
|
||||||
func checkRestoreSource(ctx context.Context, backupDb *sql.DB, path string, integrityCheck bool) error {
|
|
||||||
// SQLite opens an empty file as an empty database, and restoring it would wipe the live one.
|
|
||||||
// user and media_file exist since the first migration, so backups of any age have them.
|
|
||||||
var tables int
|
|
||||||
err := backupDb.QueryRowContext(ctx,
|
|
||||||
"SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN ('goose_db_version', 'user', 'media_file')").Scan(&tables)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading backup file '%s': %w", path, err)
|
|
||||||
}
|
|
||||||
if tables != 3 {
|
|
||||||
return fmt.Errorf("backup file '%s' is not a Navidrome database", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The table can be there with nothing in it, or with only the row goose adds when it creates it.
|
|
||||||
var applied int
|
|
||||||
err = backupDb.QueryRowContext(ctx,
|
|
||||||
"SELECT count(*) FROM goose_db_version WHERE is_applied = 1 AND version_id > 0").Scan(&applied)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading backup file '%s': %w", path, err)
|
|
||||||
}
|
|
||||||
if applied == 0 {
|
|
||||||
return fmt.Errorf("backup file '%s' has no applied migrations", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The queries above only read the first pages. Both checks walk the whole file, and
|
|
||||||
// integrity_check also compares indexes to their tables, which is much slower.
|
|
||||||
check := "PRAGMA quick_check(1)"
|
|
||||||
if integrityCheck {
|
|
||||||
check = "PRAGMA integrity_check(1)"
|
|
||||||
}
|
|
||||||
var result string
|
|
||||||
if err := backupDb.QueryRowContext(ctx, check).Scan(&result); err != nil {
|
|
||||||
return fmt.Errorf("checking backup file '%s': %w", path, err)
|
|
||||||
}
|
|
||||||
if result != "ok" {
|
|
||||||
return fmt.Errorf("backup file '%s' is damaged: %s", path, result)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Backup(ctx context.Context) (string, error) {
|
func Backup(ctx context.Context) (string, error) {
|
||||||
destPath := backupPath(time.Now())
|
destPath := backupPath(time.Now())
|
||||||
log.Debug(ctx, "Creating backup", "path", destPath)
|
log.Debug(ctx, "Creating backup", "path", destPath)
|
||||||
backupDb, err := openBackupFile(destPath)
|
err := backupOrRestore(ctx, true, destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer backupDb.Close()
|
|
||||||
|
|
||||||
if err := copyDatabase(ctx, backupDb, Db()); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return destPath, nil
|
return destPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Restore(ctx context.Context, path string, integrityCheck bool) error {
|
func Restore(ctx context.Context, path string) error {
|
||||||
log.Debug(ctx, "Restoring backup", "path", path)
|
log.Debug(ctx, "Restoring backup", "path", path)
|
||||||
|
return backupOrRestore(ctx, false, path)
|
||||||
// The driver splits the DSN at '?', so such a path would open a different file.
|
|
||||||
if strings.ContainsRune(path, '?') {
|
|
||||||
return fmt.Errorf("backup path cannot contain '?': %s", path)
|
|
||||||
}
|
|
||||||
// The driver opens with SQLITE_OPEN_CREATE, so without this check a typo in the
|
|
||||||
// path would create an empty database and "restore" it over the live one.
|
|
||||||
if _, err := os.Stat(path); err != nil {
|
|
||||||
return fmt.Errorf("backup file not available: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
backupDb, err := openBackupFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer backupDb.Close()
|
|
||||||
|
|
||||||
if err := checkRestoreSource(ctx, backupDb, path, integrityCheck); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return copyDatabase(ctx, Db(), backupDb)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Prune(ctx context.Context) (int, error) {
|
func Prune(ctx context.Context) (int, error) {
|
||||||
|
|
|
||||||
|
|
@ -21,22 +21,6 @@ func shortTime(year int, month time.Month, day, hour, minute int) time.Time {
|
||||||
return time.Date(year, month, day, hour, minute, 0, 0, time.UTC)
|
return time.Date(year, month, day, hour, minute, 0, 0, time.UTC)
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
gooseTableDDL = "CREATE TABLE goose_db_version(version_id, is_applied)"
|
|
||||||
navidromeTablesDDL = "CREATE TABLE user(id); CREATE TABLE media_file(id)"
|
|
||||||
)
|
|
||||||
|
|
||||||
func sqliteFile(statements string) func(path string) {
|
|
||||||
return func(path string) {
|
|
||||||
GinkgoHelper()
|
|
||||||
other, err := sql.Open(Driver, path)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
defer other.Close()
|
|
||||||
_, err = other.Exec(statements)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = Describe("database backups", func() {
|
var _ = Describe("database backups", func() {
|
||||||
When("there are a few backup files", func() {
|
When("there are a few backup files", func() {
|
||||||
var ctx context.Context
|
var ctx context.Context
|
||||||
|
|
@ -174,7 +158,7 @@ var _ = Describe("database backups", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(IsSchemaEmpty(ctx, Db())).To(BeTrue())
|
Expect(IsSchemaEmpty(ctx, Db())).To(BeTrue())
|
||||||
|
|
||||||
err = Restore(ctx, path, false)
|
err = Restore(ctx, path)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(IsSchemaEmpty(ctx, Db())).To(BeFalse())
|
Expect(IsSchemaEmpty(ctx, Db())).To(BeFalse())
|
||||||
})
|
})
|
||||||
|
|
@ -192,7 +176,6 @@ var _ = Describe("database backups", func() {
|
||||||
var err error
|
var err error
|
||||||
tempFolder, err = os.MkdirTemp("", "navidrome_restore")
|
tempFolder, err = os.MkdirTemp("", "navidrome_restore")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
conf.Server.Backup.Path = conf.NewDir(tempFolder)
|
|
||||||
DeferCleanup(func() {
|
DeferCleanup(func() {
|
||||||
Close(ctx)
|
Close(ctx)
|
||||||
_ = os.RemoveAll(tempFolder)
|
_ = os.RemoveAll(tempFolder)
|
||||||
|
|
@ -226,7 +209,7 @@ var _ = Describe("database backups", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
By("restoring the backup")
|
By("restoring the backup")
|
||||||
Expect(Restore(ctx, path, false)).To(Succeed())
|
Expect(Restore(ctx, path)).To(Succeed())
|
||||||
|
|
||||||
By("verifying the restored data is readable through a fresh connection")
|
By("verifying the restored data is readable through a fresh connection")
|
||||||
singleton.DeleteInstance[*sql.DB]()
|
singleton.DeleteInstance[*sql.DB]()
|
||||||
|
|
@ -235,91 +218,23 @@ var _ = Describe("database backups", func() {
|
||||||
Expect(userName).To(Equal("drilladmin"))
|
Expect(userName).To(Equal("drilladmin"))
|
||||||
})
|
})
|
||||||
|
|
||||||
expectRestoreRefused := func(path, expectedErr string) {
|
It("fails to restore from a backup file that does not exist, leaving the database intact", func() {
|
||||||
GinkgoHelper()
|
|
||||||
By("seeding user data in the current database")
|
By("seeding user data in the current database")
|
||||||
_, err := Db().ExecContext(ctx, `INSERT OR REPLACE INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at)
|
_, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at)
|
||||||
VALUES ('u-restore-2', 'keepme', 'keepme', 'keepme@example.com', 'x', 1, datetime('now'), datetime('now'))`)
|
VALUES ('u-restore-2', 'keepme', 'keepme', 'keepme@example.com', 'x', 1, datetime('now'), datetime('now'))`)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
By("attempting the restore")
|
By("attempting a restore from a nonexistent file")
|
||||||
Expect(Restore(ctx, path, false)).To(MatchError(ContainSubstring(expectedErr)))
|
missingPath := filepath.Join(tempFolder, "does_not_exist.db")
|
||||||
|
err = Restore(ctx, missingPath)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
|
||||||
By("verifying the database was not wiped")
|
By("verifying the database was not wiped")
|
||||||
var userName string
|
var userName string
|
||||||
Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-2'").Scan(&userName)).To(Succeed())
|
Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-2'").Scan(&userName)).To(Succeed())
|
||||||
Expect(userName).To(Equal("keepme"))
|
Expect(userName).To(Equal("keepme"))
|
||||||
}
|
|
||||||
|
|
||||||
It("fails to restore from a backup file that does not exist, leaving the database intact", func() {
|
|
||||||
missingPath := filepath.Join(tempFolder, "does_not_exist.db")
|
|
||||||
expectRestoreRefused(missingPath, "backup file not available")
|
|
||||||
_, statErr := os.Stat(missingPath)
|
_, statErr := os.Stat(missingPath)
|
||||||
Expect(statErr).To(MatchError(os.ErrNotExist))
|
Expect(statErr).To(MatchError(os.ErrNotExist))
|
||||||
})
|
})
|
||||||
|
|
||||||
DescribeTable("fails to restore from a file that is not a Navidrome database, leaving the database intact",
|
|
||||||
func(createFile func(path string), expectedErr string) {
|
|
||||||
invalidPath := filepath.Join(GinkgoT().TempDir(), "invalid_backup.db")
|
|
||||||
createFile(invalidPath)
|
|
||||||
expectRestoreRefused(invalidPath, expectedErr)
|
|
||||||
},
|
|
||||||
Entry("empty file", func(path string) {
|
|
||||||
Expect(os.WriteFile(path, nil, 0600)).To(Succeed())
|
|
||||||
}, "is not a Navidrome database"),
|
|
||||||
Entry("file that is not a SQLite database", func(path string) {
|
|
||||||
Expect(os.WriteFile(path, []byte("this is not a database, just some text"), 0600)).To(Succeed())
|
|
||||||
}, "reading backup file"),
|
|
||||||
Entry("SQLite database without the Navidrome schema",
|
|
||||||
sqliteFile("CREATE TABLE something_else(x)"), "is not a Navidrome database"),
|
|
||||||
Entry("another project's goose database",
|
|
||||||
sqliteFile(gooseTableDDL+"; INSERT INTO goose_db_version VALUES (1, 1)"), "is not a Navidrome database"),
|
|
||||||
Entry("goose database with only one of the Navidrome tables",
|
|
||||||
sqliteFile(gooseTableDDL+"; INSERT INTO goose_db_version VALUES (1, 1); CREATE TABLE media_file(id)"), "is not a Navidrome database"),
|
|
||||||
Entry("SQLite database with an empty goose_db_version table",
|
|
||||||
sqliteFile(gooseTableDDL+"; "+navidromeTablesDDL), "has no applied migrations"),
|
|
||||||
Entry("SQLite database with only goose's initial version row",
|
|
||||||
sqliteFile(gooseTableDDL+"; "+navidromeTablesDDL+"; INSERT INTO goose_db_version VALUES (0, 1)"), "has no applied migrations"),
|
|
||||||
Entry("SQLite database whose migrations were all rolled back",
|
|
||||||
sqliteFile(gooseTableDDL+"; "+navidromeTablesDDL+"; INSERT INTO goose_db_version VALUES (1, 0)"), "has no applied migrations"),
|
|
||||||
Entry("backup with garbage over its last pages", func(path string) {
|
|
||||||
// Filler rows go to the end of the file, so the damage below stays clear of
|
|
||||||
// the schema and of goose_db_version, which still read fine.
|
|
||||||
_, err := Db().ExecContext(ctx, "CREATE TABLE _restore_filler AS SELECT zeroblob(1000000) AS x")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
backupFile, err := Backup(ctx)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
data, err := os.ReadFile(backupFile)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
for i := len(data) * 4 / 5; i < len(data); i++ {
|
|
||||||
data[i] = 0xFF
|
|
||||||
}
|
|
||||||
Expect(os.WriteFile(path, data, 0600)).To(Succeed())
|
|
||||||
}, "is damaged"),
|
|
||||||
)
|
|
||||||
|
|
||||||
It("restores a good backup with the integrity check", func() {
|
|
||||||
path, err := Backup(ctx)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(Restore(ctx, path, true)).To(Succeed())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("refuses an index that doesn't match its table only with the integrity check", func() {
|
|
||||||
path, err := Backup(ctx)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
writeMismatchedIndex(ctx, path, 1)
|
|
||||||
restored := func() int {
|
|
||||||
var count int
|
|
||||||
Expect(Db().QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE name = 't'").Scan(&count)).To(Succeed())
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
Expect(Restore(ctx, path, true)).To(MatchError(ContainSubstring("is damaged")))
|
|
||||||
Expect(restored()).To(BeZero())
|
|
||||||
|
|
||||||
Expect(Restore(ctx, path, false)).To(Succeed())
|
|
||||||
Expect(restored()).To(Equal(1))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,18 @@ func newDB(ctx context.Context, upTo int64) *sql.DB {
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeMismatchedIndex adds an index declared over a different column than the one it
|
// openMismatchedIndexDB builds a database whose index is declared over a different
|
||||||
// was populated from, so integrity_check reports one issue per row and quick_check none.
|
// column than the one it was populated from, so integrity_check reports one issue per row.
|
||||||
func writeMismatchedIndex(ctx context.Context, path string, rows int) {
|
func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
d, err := sql.Open(db.Dialect, path)
|
path := filepath.Join(GinkgoT().TempDir(), "mismatched.db")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
open := func() *sql.DB {
|
||||||
d.SetMaxOpenConns(1)
|
d, err := sql.Open(db.Dialect, path)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
d.SetMaxOpenConns(1)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
d := open()
|
||||||
for _, stmt := range []string{
|
for _, stmt := range []string{
|
||||||
`create table t(a, b)`,
|
`create table t(a, b)`,
|
||||||
fmt.Sprintf(`with recursive s(x) as (select 1 union all select x+1 from s where x < %d)
|
fmt.Sprintf(`with recursive s(x) as (select 1 union all select x+1 from s where x < %d)
|
||||||
|
|
@ -57,16 +62,8 @@ func writeMismatchedIndex(ctx context.Context, path string, rows int) {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
}
|
}
|
||||||
Expect(d.Close()).To(Succeed()) // reopen so SQLite reparses the doctored schema
|
Expect(d.Close()).To(Succeed()) // reopen so SQLite reparses the doctored schema
|
||||||
}
|
|
||||||
|
|
||||||
func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB {
|
d = open()
|
||||||
GinkgoHelper()
|
|
||||||
path := filepath.Join(GinkgoT().TempDir(), "mismatched.db")
|
|
||||||
writeMismatchedIndex(ctx, path, rows)
|
|
||||||
|
|
||||||
d, err := sql.Open(db.Dialect, path)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
d.SetMaxOpenConns(1)
|
|
||||||
DeferCleanup(func() { _ = d.Close() })
|
DeferCleanup(func() { _ = d.Close() })
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"maps"
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
|
@ -44,7 +42,6 @@ func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFi
|
||||||
}
|
}
|
||||||
mf.Lyrics = md.mapLyrics()
|
mf.Lyrics = md.mapLyrics()
|
||||||
mf.ExplicitStatus = md.mapExplicitStatusTag()
|
mf.ExplicitStatus = md.mapExplicitStatusTag()
|
||||||
md.mapMovementNumbers(mf.Tags)
|
|
||||||
|
|
||||||
// Dates
|
// Dates
|
||||||
date, origDate, relDate := md.mapDates()
|
date, origDate, relDate := md.mapDates()
|
||||||
|
|
@ -138,27 +135,6 @@ func (md Metadata) mapGain(rg, r128 model.TagName) *float64 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mapMovementNumbers splits ID3 MVIN "n/N" values, like tuple() does for track and disc numbers.
|
|
||||||
// Totals are only filled when every value has one, so the parallel movement tags stay aligned.
|
|
||||||
func (md Metadata) mapMovementNumbers(tags model.Tags) {
|
|
||||||
values := md.Strings(model.TagMovementNumber)
|
|
||||||
if !slices.ContainsFunc(values, func(v string) bool { return strings.Contains(v, "/") }) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
numbers := make([]string, len(values))
|
|
||||||
totals := make([]string, len(values))
|
|
||||||
allHaveTotals := true
|
|
||||||
for i, v := range values {
|
|
||||||
var found bool
|
|
||||||
numbers[i], totals[i], found = strings.Cut(v, "/")
|
|
||||||
allHaveTotals = allHaveTotals && found
|
|
||||||
}
|
|
||||||
tags[model.TagMovementNumber] = numbers
|
|
||||||
if allHaveTotals && len(md.Strings(model.TagMovementTotal)) == 0 {
|
|
||||||
tags[model.TagMovementTotal] = totals
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (md Metadata) mapLyrics() string {
|
func (md Metadata) mapLyrics() string {
|
||||||
rawLyrics := md.Pairs(model.TagLyrics)
|
rawLyrics := md.Pairs(model.TagLyrics)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,27 +156,6 @@ var _ = Describe("ToMediaFile", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Movements", func() {
|
|
||||||
It("splits an ID3 MVIN n/N value into movement number and total", func() {
|
|
||||||
mf = toMediaFile(model.RawTags{"movementname": {"Allegro"}, "movementnumber": {"1/4"}})
|
|
||||||
Expect(mf.Tags).To(HaveKeyWithValue(model.TagMovementNumber, []string{"1"}))
|
|
||||||
Expect(mf.Tags).To(HaveKeyWithValue(model.TagMovementTotal, []string{"4"}))
|
|
||||||
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "Allegro", Number: 1, Count: 4}}))
|
|
||||||
})
|
|
||||||
It("maps MP4 movement number and count", func() {
|
|
||||||
mf = toMediaFile(model.RawTags{"movementname": {"Andante"}, "movementnumber": {"2"}, "movementcount": {"4"}})
|
|
||||||
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "Andante", Number: 2, Count: 4}}))
|
|
||||||
})
|
|
||||||
It("prefers an explicit movement total over the n/N total", func() {
|
|
||||||
mf = toMediaFile(model.RawTags{"movementname": {"Allegro"}, "movement": {"2/4"}, "movementtotal": {"5"}})
|
|
||||||
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "Allegro", Number: 2, Count: 5}}))
|
|
||||||
})
|
|
||||||
It("does not add a total when only some values have one", func() {
|
|
||||||
mf = toMediaFile(model.RawTags{"movementname": {"I", "II"}, "movement": {"1/4", "2"}})
|
|
||||||
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "I", Number: 1}, {Name: "II", Number: 2}}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("BitDepth", func() {
|
Describe("BitDepth", func() {
|
||||||
It("maps the bit depth when present", func() {
|
It("maps the bit depth when present", func() {
|
||||||
props.AudioProperties = metadata.AudioProperties{BitDepth: 24}
|
props.AudioProperties = metadata.AudioProperties{BitDepth: 24}
|
||||||
|
|
|
||||||
|
|
@ -75,13 +75,6 @@ var _ = Describe("Metadata", func() {
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
It("should map the TagLib-normalized initialkey tag to key", func() {
|
|
||||||
props.Tags = model.RawTags{"initialkey": {"Ebm"}}
|
|
||||||
md = metadata.New(filePath, props)
|
|
||||||
|
|
||||||
Expect(md.String(model.TagName("key"))).To(Equal("Ebm"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("should clean the tags map correctly", func() {
|
It("should clean the tags map correctly", func() {
|
||||||
const unknownTag = "UNKNOWN_TAG"
|
const unknownTag = "UNKNOWN_TAG"
|
||||||
props.Tags = model.RawTags{
|
props.Tags = model.RawTags{
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,8 @@ func NewMediaFileRepository(db dbx.Builder) model.MediaFileRepository {
|
||||||
"album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number",
|
"album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number",
|
||||||
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("media_file.title"),
|
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("media_file.title"),
|
||||||
"random": "random",
|
"random": "random",
|
||||||
"created_at": "media_file.created_at",
|
"created_at": "media_file.created_at, media_file.id",
|
||||||
|
"updated_at": "media_file.updated_at, media_file.id",
|
||||||
"recently_added": mediaFileRecentlyAddedSort(),
|
"recently_added": mediaFileRecentlyAddedSort(),
|
||||||
"starred_at": "starred, starred_at",
|
"starred_at": "starred, starred_at",
|
||||||
"rated_at": "rating, rated_at",
|
"rated_at": "rating, rated_at",
|
||||||
|
|
|
||||||
|
|
@ -352,15 +352,6 @@ func GetDBXBuilder() *dbx.DB {
|
||||||
return dbx.NewFromDB(db.Db(), db.Dialect)
|
return dbx.NewFromDB(db.Db(), db.Dialect)
|
||||||
}
|
}
|
||||||
|
|
||||||
// storedRows reads playlist_tracks without joining media_file, so it also shows rows pointing to no track.
|
|
||||||
func storedRows(playlistID string) []string {
|
|
||||||
GinkgoHelper()
|
|
||||||
var rows []string
|
|
||||||
Expect(GetDBXBuilder().NewQuery("SELECT id || ':' || media_file_id FROM playlist_tracks WHERE playlist_id = {:id} ORDER BY id").
|
|
||||||
Bind(dbx.Params{"id": playlistID}).Column(&rows)).To(Succeed())
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectCursor takes the cursor's underlying func type so the named cursor types
|
// collectCursor takes the cursor's underlying func type so the named cursor types
|
||||||
// (model.AlbumCursor, ...) infer T.
|
// (model.AlbumCursor, ...) infer T.
|
||||||
func collectCursor[T any](cursor func(func(T, error) bool), err error) []T {
|
func collectCursor[T any](cursor func(func(T, error) bool), err error) []T {
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,7 @@ func (r *playlistRepository) updatePlaylist(ctx context.Context, playlistId stri
|
||||||
}
|
}
|
||||||
|
|
||||||
// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns
|
// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns
|
||||||
// the library and existence check: every caller, including a full replace through Put, goes through it.
|
// the library check: every caller, including a full replace through Put, goes through it.
|
||||||
func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, startingPos int, mediaFileIds []string) (int, error) {
|
func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, startingPos int, mediaFileIds []string) (int, error) {
|
||||||
mediaFileIds, err := r.keepAccessible(ctx, mediaFileIds)
|
mediaFileIds, err := r.keepAccessible(ctx, mediaFileIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -305,9 +305,12 @@ func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, s
|
||||||
return len(mediaFileIds), r.refreshCounters(ctx, &model.Playlist{ID: playlistId})
|
return len(mediaFileIds), r.refreshCounters(ctx, &model.Playlist{ID: playlistId})
|
||||||
}
|
}
|
||||||
|
|
||||||
// keepAccessible drops ids the caller cannot read or that match no track, preserving order and duplicates.
|
// keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked
|
||||||
// Chunked because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER.
|
// because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER.
|
||||||
func (r *playlistRepository) keepAccessible(ctx context.Context, mediaFileIds []string) ([]string, error) {
|
func (r *playlistRepository) keepAccessible(ctx context.Context, mediaFileIds []string) ([]string, error) {
|
||||||
|
if visible, err := r.visibleLibraryIDs(ctx); err == nil && r.userSeesAllLibraries(ctx, visible) {
|
||||||
|
return mediaFileIds, nil
|
||||||
|
}
|
||||||
accessible := make(map[string]struct{}, len(mediaFileIds))
|
accessible := make(map[string]struct{}, len(mediaFileIds))
|
||||||
for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) {
|
for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) {
|
||||||
sq := r.applyLibraryFilter(ctx, Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file")
|
sq := r.applyLibraryFilter(ctx, Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file")
|
||||||
|
|
|
||||||
|
|
@ -469,26 +469,6 @@ var _ = Describe("PlaylistRepository", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("removeOrphans", func() {
|
|
||||||
It("deletes rows pointing to no track and renumbers the rest", func() {
|
|
||||||
pls := model.Playlist{Name: "Orphans", OwnerID: "userid"}
|
|
||||||
Expect(repo.Put(ctx, &pls)).To(Succeed())
|
|
||||||
DeferCleanup(func() { Expect(repo.Delete(ctx, pls.ID)).To(Succeed()) })
|
|
||||||
// Rows as an older version stored them, with an unknown id between real tracks
|
|
||||||
_, err := GetDBXBuilder().NewQuery(`INSERT INTO playlist_tracks (playlist_id, media_file_id, id)
|
|
||||||
VALUES ({:id}, '1001', 1), ({:id}, 'no-such-track', 2), ({:id}, '1002', 3)`).
|
|
||||||
Bind(dbx.Params{"id": pls.ID}).Execute()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
Expect(repo.(*playlistRepository).removeOrphans(ctx)).To(Succeed())
|
|
||||||
|
|
||||||
Expect(storedRows(pls.ID)).To(Equal([]string{"1:1001", "2:1002"}))
|
|
||||||
got, err := repo.Get(ctx, pls.ID)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(got.SongCount).To(Equal(2))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Exists is ctx-sensitive through userFilter, so callers that only want "does it still exist"
|
// Exists is ctx-sensitive through userFilter, so callers that only want "does it still exist"
|
||||||
// -- the public image route serving a share -- must elevate, or a private playlist looks gone.
|
// -- the public image route serving a share -- must elevate, or a private playlist looks gone.
|
||||||
Describe("Exists visibility", func() {
|
Describe("Exists visibility", func() {
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ func (r *playlistTrackRepository) Insert(ctx context.Context, mediaFileIds []str
|
||||||
if err != nil || inserted == n {
|
if err != nil || inserted == n {
|
||||||
return inserted, err
|
return inserted, err
|
||||||
}
|
}
|
||||||
// The shift above reserved a slot per requested id, so ids dropped by keepAccessible
|
// The shift above reserved a slot per requested id, so ids dropped by the library filter
|
||||||
// leave a hole. Close it.
|
// leave a hole. Close it.
|
||||||
return inserted, r.playlistRepo.renumber(ctx, r.playlistId)
|
return inserted, r.playlistRepo.renumber(ctx, r.playlistId)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,14 +83,12 @@ var _ = Describe("PlaylistTrackRepository", func() {
|
||||||
|
|
||||||
Describe("Insert", func() {
|
Describe("Insert", func() {
|
||||||
var tracks model.PlaylistTrackRepository
|
var tracks model.PlaylistTrackRepository
|
||||||
var plsID string
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
plsRepo := NewPlaylistRepository(GetDBXBuilder())
|
plsRepo := NewPlaylistRepository(GetDBXBuilder())
|
||||||
pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"}
|
pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"}
|
||||||
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
|
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
|
||||||
DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) })
|
DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) })
|
||||||
plsID = pls.ID
|
|
||||||
|
|
||||||
tracks = plsRepo.Tracks(ctx, pls.ID, false)
|
tracks = plsRepo.Tracks(ctx, pls.ID, false)
|
||||||
Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2))
|
Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2))
|
||||||
|
|
@ -125,40 +123,6 @@ var _ = Describe("PlaylistTrackRepository", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"}))
|
Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"}))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("drops ids that match no track, keeping positions contiguous", func() {
|
|
||||||
Expect(tracks.Insert(ctx, []string{"no-such-track", songComeTogether.ID}, 1)).To(Equal(1))
|
|
||||||
|
|
||||||
Expect(storedRows(plsID)).To(Equal([]string{
|
|
||||||
"1:" + songComeTogether.ID, "2:" + songDayInALife.ID, "3:" + songRadioactivity.ID,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Add", func() {
|
|
||||||
var tracks model.PlaylistTrackRepository
|
|
||||||
var plsID string
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
plsRepo := NewPlaylistRepository(GetDBXBuilder())
|
|
||||||
pls := model.Playlist{Name: "Add", OwnerID: "userid", OwnerName: "userid"}
|
|
||||||
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
|
|
||||||
DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) })
|
|
||||||
plsID = pls.ID
|
|
||||||
tracks = plsRepo.Tracks(ctx, pls.ID, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
It("drops ids that match no track and counts only the tracks added", func() {
|
|
||||||
Expect(tracks.Add(ctx, []string{songDayInALife.ID, "no-such-track", songComeTogether.ID, "!!not-an-id!!"})).To(Equal(2))
|
|
||||||
|
|
||||||
Expect(storedRows(plsID)).To(Equal([]string{"1:" + songDayInALife.ID, "2:" + songComeTogether.ID}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("adds nothing when no id matches a track", func() {
|
|
||||||
Expect(tracks.Add(ctx, []string{"no-such-track", "!!not-an-id!!"})).To(BeZero())
|
|
||||||
|
|
||||||
Expect(storedRows(plsID)).To(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Reorder", func() {
|
Describe("Reorder", func() {
|
||||||
|
|
|
||||||
|
|
@ -409,9 +409,7 @@ func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.S
|
||||||
// queryWithStableResults is a helper function to execute a query and return an iterator that will yield its results
|
// queryWithStableResults is a helper function to execute a query and return an iterator that will yield its results
|
||||||
// from a cursor, guaranteeing that the results will be stable, even if the underlying data changes.
|
// from a cursor, guaranteeing that the results will be stable, even if the underlying data changes.
|
||||||
func queryWithStableResults[T any](ctx context.Context, r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) {
|
func queryWithStableResults[T any](ctx context.Context, r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) {
|
||||||
if len(options) > 0 && options[0].Offset > 0 {
|
sq = r.paginate(sq, options...)
|
||||||
sq = r.optimizePagination(sq, options[0])
|
|
||||||
}
|
|
||||||
query, args, err := r.toSQL(sq)
|
query, args, err := r.toSQL(sq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -439,9 +437,7 @@ func queryWithStableResults[T any](ctx context.Context, r sqlRepository, sq Sele
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r sqlRepository) queryAll(ctx context.Context, sq SelectBuilder, response any, options ...model.QueryOptions) error {
|
func (r sqlRepository) queryAll(ctx context.Context, sq SelectBuilder, response any, options ...model.QueryOptions) error {
|
||||||
if len(options) > 0 && options[0].Offset > 0 {
|
sq = r.paginate(sq, options...)
|
||||||
sq = r.optimizePagination(sq, options[0])
|
|
||||||
}
|
|
||||||
query, args, err := r.toSQL(sq)
|
query, args, err := r.toSQL(sq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -472,15 +468,28 @@ func (r sqlRepository) queryAllSlice(ctx context.Context, sq SelectBuilder, resp
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// paginate adds a rowid tie-breaker, so the order is total and rows with equal sort values land on the
|
||||||
|
// same page every time. Indexes already end with the rowid, so index-served sorts stay index-served.
|
||||||
|
func (r sqlRepository) paginate(sq SelectBuilder, options ...model.QueryOptions) SelectBuilder {
|
||||||
|
if len(options) == 0 || (options[0].Max == 0 && options[0].Offset == 0) {
|
||||||
|
return sq
|
||||||
|
}
|
||||||
|
order := "asc"
|
||||||
|
if strings.EqualFold(strings.TrimSpace(options[0].Order), "desc") {
|
||||||
|
order = "desc"
|
||||||
|
}
|
||||||
|
return r.optimizePagination(sq.OrderBy(r.tableName+".rowid "+order), options[0])
|
||||||
|
}
|
||||||
|
|
||||||
// optimizePagination uses a less inefficient pagination, by not using OFFSET.
|
// optimizePagination uses a less inefficient pagination, by not using OFFSET.
|
||||||
// See https://gist.github.com/ssokolow/262503
|
// See https://gist.github.com/ssokolow/262503
|
||||||
func (r sqlRepository) optimizePagination(sq SelectBuilder, options model.QueryOptions) SelectBuilder {
|
func (r sqlRepository) optimizePagination(sq SelectBuilder, options model.QueryOptions) SelectBuilder {
|
||||||
if options.Offset > conf.Server.DevOffsetOptimize {
|
if options.Offset > conf.Server.DevOffsetOptimize {
|
||||||
sq = sq.RemoveOffset()
|
sq = sq.RemoveOffset()
|
||||||
rowidSq := sq.RemoveColumns().Columns(r.tableName + ".rowid")
|
// Keep the outer projection, so ORDER BY names resolve to the same columns in both queries.
|
||||||
rowidSq = rowidSq.Limit(uint64(options.Offset))
|
rowidSq := sq.Column(r.tableName + ".rowid as _paginated_rowid").Limit(uint64(options.Offset))
|
||||||
rowidSql, args, _ := rowidSq.ToSql()
|
rowidSql, args, _ := rowidSq.ToSql()
|
||||||
sq = sq.Where(r.tableName+".rowid not in ("+rowidSql+")", args...)
|
sq = sq.Where(r.tableName+".rowid not in (SELECT _paginated_rowid FROM ("+rowidSql+"))", args...)
|
||||||
}
|
}
|
||||||
return sq
|
return sq
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,12 @@ package persistence
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/utils/hasher"
|
"github.com/navidrome/navidrome/utils/hasher"
|
||||||
|
|
@ -421,3 +425,133 @@ var _ = Describe("sqlRepository", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var _ = Describe("paginated queries", func() {
|
||||||
|
var ctx context.Context
|
||||||
|
var mr model.MediaFileRepository
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
mr = NewMediaFileRepository(GetDBXBuilder())
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("SQL", func() {
|
||||||
|
var r sqlRepository
|
||||||
|
BeforeEach(func() {
|
||||||
|
r = sqlRepository{tableName: "media_file", sortMappings: map[string]string{"name": "title"}}
|
||||||
|
conf.Server.DevOffsetOptimize = 10
|
||||||
|
})
|
||||||
|
|
||||||
|
DescribeTable("adds a rowid tie-breaker in the requested direction",
|
||||||
|
func(options model.QueryOptions, expectedOrder string) {
|
||||||
|
sql, _, err := r.paginate(squirrel.Select("*").From("media_file"), options).ToSql()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(sql).To(HaveSuffix(expectedOrder))
|
||||||
|
},
|
||||||
|
Entry("no sort", model.QueryOptions{Max: 5}, "ORDER BY media_file.rowid asc"),
|
||||||
|
Entry("desc", model.QueryOptions{Sort: "name", Order: "desc", Max: 5}, "ORDER BY media_file.rowid desc"),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("leaves unpaginated queries alone", func() {
|
||||||
|
sql, _, _ := r.paginate(squirrel.Select("*").From("media_file"), model.QueryOptions{Sort: "name"}).ToSql()
|
||||||
|
Expect(sql).To(Equal("SELECT * FROM media_file"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("uses the rowid subquery only past the threshold, keeping the outer projection", func() {
|
||||||
|
sq := squirrel.Select("media_file.*", "library.path as library_path").From("media_file").OrderBy("id asc")
|
||||||
|
atThreshold, _, _ := r.paginate(sq.Limit(5).Offset(10), model.QueryOptions{Max: 5, Offset: 10}).ToSql()
|
||||||
|
Expect(atThreshold).ToNot(ContainSubstring("not in"))
|
||||||
|
|
||||||
|
past, _, _ := r.paginate(sq.Limit(5).Offset(11), model.QueryOptions{Max: 5, Offset: 11}).ToSql()
|
||||||
|
Expect(past).To(ContainSubstring("media_file.rowid not in (SELECT _paginated_rowid FROM (" +
|
||||||
|
"SELECT media_file.*, library.path as library_path, media_file.rowid as _paginated_rowid FROM media_file"))
|
||||||
|
Expect(past).ToNot(ContainSubstring("OFFSET"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// walk pages through the repository, crossing the optimizer threshold
|
||||||
|
walk := func(ctx context.Context, sort, order string, pageSize int) []string {
|
||||||
|
var ids []string
|
||||||
|
for offset := 0; ; offset += pageSize {
|
||||||
|
page, err := mr.GetAll(ctx, model.QueryOptions{Sort: sort, Order: order, Offset: offset, Max: pageSize, Seed: "seed"})
|
||||||
|
Expect(err).ToNot(HaveOccurred(), "sort=%s order=%s offset=%d", sort, order, offset)
|
||||||
|
for _, m := range page {
|
||||||
|
ids = append(ids, m.ID)
|
||||||
|
}
|
||||||
|
if len(page) < pageSize {
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DescribeTable("returns every song exactly once, in the same order as a single page",
|
||||||
|
func(sort string) {
|
||||||
|
conf.Server.DevOffsetOptimize = 2
|
||||||
|
for _, order := range []string{"asc", "desc"} {
|
||||||
|
expected := walk(ctx, sort, order, 1000)
|
||||||
|
Expect(len(expected)).To(BeNumerically(">=", len(testSongs)))
|
||||||
|
Expect(slices.Compact(slices.Sorted(slices.Values(expected)))).To(HaveLen(len(expected)))
|
||||||
|
// Page size 2 hits offset == threshold (plain OFFSET), size 3 hits offset > threshold first.
|
||||||
|
for _, pageSize := range []int{2, 3} {
|
||||||
|
Expect(walk(ctx, sort, order, pageSize)).To(Equal(expected), "sort=%s order=%s page=%d", sort, order, pageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Entry("id", "id"),
|
||||||
|
Entry("path", "path"),
|
||||||
|
Entry("updatedAt", "updatedAt"),
|
||||||
|
Entry("comment", "comment"),
|
||||||
|
Entry("title", "title"),
|
||||||
|
Entry("createdAt", "createdAt"),
|
||||||
|
Entry("starred (ties)", "starred"),
|
||||||
|
Entry("playCount (ties)", "playCount"),
|
||||||
|
Entry("rating (ties)", "rating"),
|
||||||
|
Entry("album", "album"),
|
||||||
|
Entry("seeded random", "random"),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("keeps the library filter on every page", func() {
|
||||||
|
_, otherLib, restrictedUser := restrictedFixture("paginate")
|
||||||
|
hidden := model.MediaFile{ID: "paginate-hidden", Title: "Hidden", LibraryID: otherLib.ID, Path: p("other/hidden.mp3")}
|
||||||
|
Expect(mr.Put(ctx, &hidden)).To(Succeed())
|
||||||
|
DeferCleanup(func() { _ = mr.Delete(ctx, hidden.ID) })
|
||||||
|
conf.Server.DevOffsetOptimize = 2
|
||||||
|
|
||||||
|
Expect(walk(ctx, "id", "asc", 1000)).To(ContainElement(hidden.ID))
|
||||||
|
restrictedCtx := request.WithUser(ctx, restrictedUser)
|
||||||
|
expected := walk(restrictedCtx, "id", "asc", 1000)
|
||||||
|
Expect(expected).ToNot(BeEmpty())
|
||||||
|
Expect(expected).ToNot(ContainElement(hidden.ID))
|
||||||
|
Expect(walk(restrictedCtx, "id", "asc", 3)).To(Equal(expected))
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("legacy search", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
conf.Server.Search.Backend = "legacy"
|
||||||
|
conf.Server.DevOffsetOptimize = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
It("pages albums whose name is shared with the joined library", func() {
|
||||||
|
alr := NewAlbumRepository(GetDBXBuilder())
|
||||||
|
all, err := alr.Search(ctx, "abbey", model.QueryOptions{Max: 10})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(all).To(HaveLen(2))
|
||||||
|
second, err := alr.Search(ctx, "abbey", model.QueryOptions{Max: 1, Offset: 1})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(second).To(HaveLen(1))
|
||||||
|
Expect(second[0].ID).To(Equal(all[1].ID))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("pages artists", func() {
|
||||||
|
arr := NewArtistRepository(GetDBXBuilder())
|
||||||
|
all, err := arr.Search(ctx, "the", model.QueryOptions{Max: 10})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(len(all)).To(BeNumerically(">=", 2))
|
||||||
|
second, err := arr.Search(ctx, "the", model.QueryOptions{Max: 1, Offset: 1})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(second).To(HaveLen(1))
|
||||||
|
Expect(second[0].ID).To(Equal(all[1].ID))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 35 KiB |
|
|
@ -1,25 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:ns1="http://www.bohemiancoding.com/sketch/ns" width="200" height="40" viewBox="0 0 200 40" role="img" aria-labelledby="badge-title"><!-- Artwork source: https://www.pikapods.com/static/run-button.svg; preserved and centered without distortion. --><title id="badge-title">Run on PikaPods</title><rect width="200" height="40" rx="6" fill="#43A047" /><svg viewBox="0 0 147 32" style="enable-background:new 0 0 147 32;" xml:space="preserve" x="8.12500000" y="0" width="183.75000000" height="40" preserveAspectRatio="xMidYMid meet">
|
|
||||||
<style type="text/css">
|
|
||||||
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#43A047;}
|
|
||||||
.st1{fill:#FFFFFF;}
|
|
||||||
.st2{opacity:0.6;fill:#FFFFFF;}
|
|
||||||
</style>
|
|
||||||
<title>Run on PikaPods Button</title>
|
|
||||||
<path id="Background" ns1:type="MSShapeGroup" class="st0" d="M4,0h139c2.2,0,4,1.8,4,4v24c0,2.2-1.8,4-4,4H4c-2.2,0-4-1.8-4-4V4 C0,1.8,1.8,0,4,0z" />
|
|
||||||
<path class="st1" d="M44.3,23.3c-0.3,0.6-0.8,0.8-1.4,0.8c-10,0-20,0-30,0c-2.6,0-4.8-1.7-5.4-4.3c-0.7-3,1.4-6,4.4-6.5 c0.3,0,0.5-0.1,0.8-0.1c0.7,0,1.2-0.5,1.2-1.1c0-0.6-0.5-1.1-1.2-1.1c-1.4,0-2.7,0-4.1,0c-0.7,0-1.2-0.4-1.2-1.1 C7.4,9.5,7.9,9,8.6,9c1.4,0,2.8,0,4.3,0c1.8,0,3.2,1.4,3.2,3.2c0,1.8-1.4,3.3-3.2,3.3c-1.8,0-3.1,1.2-3.3,2.9 c-0.2,1.5,0.8,3.4,2.9,3.5c1.2,0.1,2.3,0,3.5,0c0.5-4.5,2.5-8,6.3-10.6c2.2-1.5,4.6-2.2,7.1-2.4c-0.1,0.5-0.3,1-0.3,1.5 c-0.4,2.9,1.8,5.6,4.7,6c0.3,0,0.5,0.1,0.8,0c0.6-0.1,1-0.6,1-1.1c0-0.6-0.5-1-1.1-1c-1.3-0.1-2.3-0.7-2.9-1.8 c-0.6-1.2-0.4-2.6,0.6-3.7C33,8,34.3,7.6,35.5,8c1.2,0.4,2.1,1.6,2.2,2.9c0,0.1,0.1,0.3,0.2,0.4c3.2,2.2,5.2,5.2,6,9 c0.1,0.5,0.2,1.1,0.3,1.6C44.3,22.4,44.3,22.9,44.3,23.3z M38.8,17.7c0-0.6-0.5-1.1-1.1-1.1c-0.6,0-1.1,0.5-1.1,1.1 c0,0.6,0.5,1.1,1.1,1.1C38.3,18.7,38.8,18.2,38.8,17.7z" />
|
|
||||||
<g>
|
|
||||||
<path class="st1" d="M49.7,20.2v-8.6h3.7c0.9,0,1.6,0.1,2,0.2s0.7,0.4,1,0.8s0.4,0.8,0.4,1.4c0,0.6-0.2,1.2-0.6,1.6 s-0.9,0.7-1.7,0.8c0.4,0.2,0.7,0.5,0.9,0.7s0.6,0.7,1,1.4l1,1.7h-2.1L54,18.4c-0.4-0.7-0.8-1.1-0.9-1.3s-0.3-0.3-0.5-0.4 s-0.5-0.1-0.9-0.1h-0.4v3.6H49.7z M51.4,15.3h1.3c0.8,0,1.4,0,1.6-0.1s0.4-0.2,0.5-0.4s0.2-0.4,0.2-0.6c0-0.3-0.1-0.5-0.2-0.7 s-0.4-0.3-0.7-0.3c-0.1,0-0.6,0-1.3,0h-1.4V15.3z" />
|
|
||||||
<path class="st1" d="M62.4,20.2v-0.9c-0.2,0.3-0.5,0.6-0.9,0.8s-0.8,0.3-1.2,0.3c-0.4,0-0.8-0.1-1.1-0.3s-0.6-0.4-0.7-0.8 s-0.2-0.8-0.2-1.4V14h1.6v2.9c0,0.9,0,1.4,0.1,1.6s0.2,0.4,0.3,0.5s0.4,0.2,0.6,0.2c0.3,0,0.5-0.1,0.8-0.2s0.4-0.3,0.5-0.6 c0.1-0.2,0.1-0.8,0.1-1.7V14h1.6v6.2H62.4z" />
|
|
||||||
<path class="st1" d="M71.3,20.2h-1.6V17c0-0.7,0-1.1-0.1-1.3s-0.2-0.4-0.3-0.5s-0.3-0.2-0.6-0.2c-0.3,0-0.5,0.1-0.8,0.2 s-0.4,0.4-0.5,0.6s-0.1,0.7-0.1,1.4v2.8h-1.6V14h1.5v0.9c0.5-0.7,1.2-1.1,2.1-1.1c0.4,0,0.7,0.1,1,0.2s0.5,0.3,0.7,0.5 s0.3,0.4,0.3,0.7s0.1,0.6,0.1,1.1V20.2z" />
|
|
||||||
<path class="st2" d="M75.8,17.1c0-1.2,0.3-2,1-2.6c0.5-0.5,1.2-0.7,2-0.7c0.9,0,1.6,0.3,2.1,0.8s0.8,1.3,0.8,2.3 c0,0.8-0.1,1.4-0.4,1.9s-0.6,0.8-1,1.1s-1,0.4-1.5,0.4c-0.9,0-1.6-0.3-2.1-0.8S75.8,18.2,75.8,17.1z M76.9,17.1 c0,0.8,0.2,1.4,0.5,1.8s0.8,0.6,1.3,0.6c0.5,0,1-0.2,1.3-0.6s0.5-1,0.5-1.8c0-0.8-0.2-1.4-0.5-1.7s-0.8-0.6-1.3-0.6 c-0.5,0-1,0.2-1.3,0.6S76.9,16.3,76.9,17.1z" />
|
|
||||||
<path class="st2" d="M82.9,20.2V14h0.9v0.9c0.5-0.7,1.1-1,2-1c0.4,0,0.7,0.1,1,0.2s0.5,0.3,0.7,0.5s0.3,0.5,0.3,0.8 c0,0.2,0.1,0.5,0.1,1v3.8h-1.1v-3.8c0-0.4,0-0.8-0.1-1s-0.2-0.4-0.4-0.5s-0.5-0.2-0.7-0.2c-0.4,0-0.8,0.1-1.2,0.4S84,16,84,16.8 v3.4H82.9z" />
|
|
||||||
<path class="st2" d="M93,20.2v-8.6h3.2c0.6,0,1,0,1.3,0.1c0.4,0.1,0.8,0.2,1.1,0.4s0.5,0.5,0.7,0.8s0.3,0.7,0.3,1.2 c0,0.7-0.2,1.3-0.7,1.9s-1.3,0.8-2.5,0.8h-2.2v3.5H93z M94.2,15.7h2.2c0.7,0,1.3-0.1,1.6-0.4s0.5-0.7,0.5-1.2 c0-0.4-0.1-0.7-0.3-0.9s-0.4-0.4-0.7-0.5c-0.2-0.1-0.5-0.1-1.1-0.1h-2.2V15.7z" />
|
|
||||||
<path class="st2" d="M100.9,12.8v-1.2h1.1v1.2H100.9z M100.9,20.2V14h1.1v6.2H100.9z" />
|
|
||||||
<path class="st2" d="M103.6,20.2v-8.6h1.1v4.9l2.5-2.5h1.4l-2.4,2.3l2.6,3.9h-1.3l-2.1-3.2l-0.7,0.7v2.5H103.6z" />
|
|
||||||
<path class="st2" d="M113.6,19.5c-0.4,0.3-0.8,0.6-1.1,0.7s-0.7,0.2-1.2,0.2c-0.7,0-1.2-0.2-1.6-0.5s-0.6-0.8-0.6-1.3 c0-0.3,0.1-0.6,0.2-0.8s0.3-0.5,0.5-0.6s0.5-0.3,0.8-0.3c0.2-0.1,0.5-0.1,0.9-0.2c0.9-0.1,1.5-0.2,1.9-0.4c0-0.1,0-0.2,0-0.3 c0-0.4-0.1-0.7-0.3-0.9c-0.3-0.2-0.7-0.4-1.2-0.4c-0.5,0-0.9,0.1-1.1,0.3s-0.4,0.5-0.5,0.9l-1-0.1c0.1-0.4,0.2-0.8,0.5-1.1 s0.5-0.5,0.9-0.6s0.9-0.2,1.4-0.2c0.5,0,1,0.1,1.3,0.2s0.6,0.3,0.7,0.5s0.3,0.4,0.3,0.7c0,0.2,0.1,0.5,0.1,1v1.4c0,1,0,1.6,0.1,1.9 s0.1,0.5,0.3,0.7h-1.1C113.7,20,113.7,19.8,113.6,19.5z M113.6,17.1c-0.4,0.2-1,0.3-1.7,0.4c-0.4,0.1-0.7,0.1-0.9,0.2 s-0.3,0.2-0.4,0.3s-0.1,0.3-0.1,0.5c0,0.3,0.1,0.5,0.3,0.7s0.5,0.3,0.9,0.3c0.4,0,0.8-0.1,1.1-0.3s0.5-0.4,0.7-0.7 c0.1-0.2,0.2-0.6,0.2-1.1V17.1z" />
|
|
||||||
<path class="st2" d="M116.4,20.2v-8.6h3.2c0.6,0,1,0,1.3,0.1c0.4,0.1,0.8,0.2,1.1,0.4s0.5,0.5,0.7,0.8s0.3,0.7,0.3,1.2 c0,0.7-0.2,1.3-0.7,1.9s-1.3,0.8-2.5,0.8h-2.2v3.5H116.4z M117.5,15.7h2.2c0.7,0,1.3-0.1,1.6-0.4s0.5-0.7,0.5-1.2 c0-0.4-0.1-0.7-0.3-0.9s-0.4-0.4-0.7-0.5c-0.2-0.1-0.5-0.1-1.1-0.1h-2.2V15.7z" />
|
|
||||||
<path class="st2" d="M123.9,17.1c0-1.2,0.3-2,1-2.6c0.5-0.5,1.2-0.7,2-0.7c0.9,0,1.6,0.3,2.1,0.8s0.8,1.3,0.8,2.3 c0,0.8-0.1,1.4-0.4,1.9s-0.6,0.8-1,1.1s-1,0.4-1.5,0.4c-0.9,0-1.6-0.3-2.1-0.8S123.9,18.2,123.9,17.1z M124.9,17.1 c0,0.8,0.2,1.4,0.5,1.8s0.8,0.6,1.3,0.6c0.5,0,1-0.2,1.3-0.6s0.5-1,0.5-1.8c0-0.8-0.2-1.4-0.5-1.7s-0.8-0.6-1.3-0.6 c-0.5,0-1,0.2-1.3,0.6S124.9,16.3,124.9,17.1z" />
|
|
||||||
<path class="st2" d="M135,20.2v-0.8c-0.4,0.6-1,0.9-1.7,0.9c-0.5,0-1-0.1-1.4-0.4s-0.7-0.7-1-1.1s-0.3-1.1-0.3-1.7 c0-0.6,0.1-1.2,0.3-1.7s0.5-0.9,0.9-1.2s0.9-0.4,1.4-0.4c0.4,0,0.7,0.1,1,0.2s0.5,0.4,0.7,0.6v-3.1h1v8.6H135z M131.6,17.1 c0,0.8,0.2,1.4,0.5,1.8s0.7,0.6,1.2,0.6c0.5,0,0.9-0.2,1.2-0.6s0.5-1,0.5-1.7c0-0.9-0.2-1.5-0.5-1.9s-0.7-0.6-1.2-0.6 c-0.5,0-0.9,0.2-1.2,0.6S131.6,16.3,131.6,17.1z" />
|
|
||||||
<path class="st2" d="M137.2,18.4l1-0.2c0.1,0.4,0.2,0.7,0.5,1s0.6,0.3,1.1,0.3c0.5,0,0.8-0.1,1.1-0.3s0.4-0.4,0.4-0.7 c0-0.2-0.1-0.4-0.3-0.6c-0.1-0.1-0.5-0.2-1.1-0.4c-0.8-0.2-1.3-0.4-1.6-0.5s-0.5-0.3-0.7-0.6s-0.2-0.5-0.2-0.8 c0-0.3,0.1-0.5,0.2-0.8s0.3-0.4,0.5-0.6c0.2-0.1,0.4-0.2,0.7-0.3s0.6-0.1,0.9-0.1c0.5,0,0.9,0.1,1.3,0.2s0.6,0.3,0.8,0.6 s0.3,0.6,0.4,1l-1,0.1c0-0.3-0.2-0.6-0.4-0.8s-0.5-0.3-1-0.3c-0.5,0-0.8,0.1-1,0.2s-0.3,0.3-0.3,0.6c0,0.1,0,0.3,0.1,0.4 c0.1,0.1,0.2,0.2,0.4,0.3c0.1,0,0.4,0.1,0.9,0.3c0.7,0.2,1.3,0.4,1.6,0.5s0.5,0.3,0.7,0.6s0.3,0.5,0.3,0.9c0,0.4-0.1,0.7-0.3,1 s-0.5,0.6-0.9,0.7s-0.8,0.3-1.3,0.3c-0.8,0-1.4-0.2-1.8-0.5S137.3,19,137.2,18.4z" />
|
|
||||||
</g>
|
|
||||||
</svg></svg>
|
|
||||||
|
Before Width: | Height: | Size: 6.3 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 26 KiB |
|
|
@ -93,8 +93,7 @@
|
||||||
"addToPlaylist": "Gehitu zerrendara",
|
"addToPlaylist": "Gehitu zerrendara",
|
||||||
"download": "Deskargatu",
|
"download": "Deskargatu",
|
||||||
"info": "Erakutsi informazioa",
|
"info": "Erakutsi informazioa",
|
||||||
"share": "Partekatu",
|
"share": "Partekatu"
|
||||||
"refresh": "Freskatu metadatuak"
|
|
||||||
},
|
},
|
||||||
"lists": {
|
"lists": {
|
||||||
"all": "Guztiak",
|
"all": "Guztiak",
|
||||||
|
|
@ -156,13 +155,11 @@
|
||||||
"newPassword": "Pasahitz berria",
|
"newPassword": "Pasahitz berria",
|
||||||
"token": "Tokena",
|
"token": "Tokena",
|
||||||
"lastAccessAt": "Azken sarbidea",
|
"lastAccessAt": "Azken sarbidea",
|
||||||
"libraries": "Liburutegiak",
|
"libraries": "Liburutegiak"
|
||||||
"scrobbleFilter": "Erabiltzailearen ohituren iragazkia"
|
|
||||||
},
|
},
|
||||||
"helperTexts": {
|
"helperTexts": {
|
||||||
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira",
|
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira",
|
||||||
"libraries": "Hautatu erabiltzaile honentzat liburutegi jakinak, edo utzi hutsik defektuzko liburutegiak erabiltzeko",
|
"libraries": "Hautatu erabiltzaile honentzat liburutegi jakinak, edo utzi hutsik defektuzko liburutegiak erabiltzeko"
|
||||||
"scrobbleFilter": "Erreprodukzio-zerrenda adimenduaren arauekin bat datozen abestiak ez dira Last.fm, ListenBrainz edo erabiltzailearen ohiturak aztertzen dituzten pluginetara bidaltzen. Erreprodukzio-zerrenda adimenduen JSON sintaxi eta portaera bera erabiltzen du. Adibidea: {\"all\":[{\"lt\":{\"rating\":4}}]}. Utzi hutsik erabiltzailearen ohitura guztiak aztertzeko. Ez dio eragiten lokalean erreproduzitutako zenbaketari."
|
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"created": "Erabiltzailea sortu da",
|
"created": "Erabiltzailea sortu da",
|
||||||
|
|
@ -170,8 +167,7 @@
|
||||||
"deleted": "Erabiltzailea ezabatu da"
|
"deleted": "Erabiltzailea ezabatu da"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"librariesRequired": "Gutxienez liburutegi bat hautatu behar da administratzaile ez diren erabiltzaileentzat",
|
"librariesRequired": "Gutxienez liburutegi bat hautatu behar da administratzaile ez diren erabiltzaileentzat"
|
||||||
"invalidScrobbleFilter": "Erreprodukzio-zerrenda adimendunaren baliozko arauak izan behar dute. Muga, konpentsazioa eta freskatze-atzerapena ez dira onartzen."
|
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
|
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
|
||||||
|
|
@ -200,9 +196,6 @@
|
||||||
"targetFormat": "Helburuko formatua",
|
"targetFormat": "Helburuko formatua",
|
||||||
"defaultBitRate": "Bit tasa, defektuz",
|
"defaultBitRate": "Bit tasa, defektuz",
|
||||||
"command": "Komandoa"
|
"command": "Komandoa"
|
||||||
},
|
|
||||||
"choices": {
|
|
||||||
"noDefaultBitRate": "Bat ere ez"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"playlist": {
|
"playlist": {
|
||||||
|
|
@ -217,8 +210,7 @@
|
||||||
"songCount": "abesti",
|
"songCount": "abesti",
|
||||||
"comment": "Iruzkina",
|
"comment": "Iruzkina",
|
||||||
"sync": "Automatikoki inportatuak",
|
"sync": "Automatikoki inportatuak",
|
||||||
"path": "Inportatze-data:",
|
"path": "Inportatze-data:"
|
||||||
"starred": "Gogokoa"
|
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"selectPlaylist": "Hautatu zerrenda:",
|
"selectPlaylist": "Hautatu zerrenda:",
|
||||||
|
|
@ -400,7 +392,6 @@
|
||||||
"invalidJson": "Konfigurazioa baliozko JSON-a izan behar da"
|
"invalidJson": "Konfigurazioa baliozko JSON-a izan behar da"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"idHelp": "Pluginaren IDa, fitxategi-izenetik eratorria. Erabili konfigurazioaren aukeretan pluginari erreferentzia egitean, agenteak esaterako.",
|
|
||||||
"configHelp": "Konfiguratu plugina gako-balio bikoteak erabiliz. Utzi hutsik pluginak konfiguraziorik behar ez badu.",
|
"configHelp": "Konfiguratu plugina gako-balio bikoteak erabiliz. Utzi hutsik pluginak konfiguraziorik behar ez badu.",
|
||||||
"configValidationError": "Huts egin du konfigurazioaren balidazioak:",
|
"configValidationError": "Huts egin du konfigurazioaren balidazioak:",
|
||||||
"schemaRenderError": "Ezin izan da konfigurazioaren formularioa bihurtu. Litekeena da pluginaren eskema baliozkoa ez izatea.",
|
"schemaRenderError": "Ezin izan da konfigurazioaren formularioa bihurtu. Litekeena da pluginaren eskema baliozkoa ez izatea.",
|
||||||
|
|
@ -574,7 +565,6 @@
|
||||||
"coverRemoved": "Diskoaren azala kendu da",
|
"coverRemoved": "Diskoaren azala kendu da",
|
||||||
"coverUploadError": "Errorea diskoaren azala igotzean",
|
"coverUploadError": "Errorea diskoaren azala igotzean",
|
||||||
"coverRemoveError": "Errorea diskoaren azala kentzean",
|
"coverRemoveError": "Errorea diskoaren azala kentzean",
|
||||||
"metadataRefreshStarted": "Metadata atzeko planoan freskatzen ari da",
|
|
||||||
"note": "OHARRA",
|
"note": "OHARRA",
|
||||||
"transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.",
|
"transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.",
|
||||||
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
|
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
|
||||||
|
|
@ -645,7 +635,6 @@
|
||||||
},
|
},
|
||||||
"albumList": "Albumak",
|
"albumList": "Albumak",
|
||||||
"playlists": "Zerrendak",
|
"playlists": "Zerrendak",
|
||||||
"onlyFavourites": "Erakutsi gogokoak bakarrik",
|
|
||||||
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
|
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
|
||||||
"about": "Honi buruz"
|
"about": "Honi buruz"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -660,11 +660,6 @@
|
||||||
"options": {
|
"options": {
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
"language": "Língua",
|
"language": "Língua",
|
||||||
"dateFormat": "Formato de data",
|
|
||||||
"dateFormats": {
|
|
||||||
"language": "Igual à língua",
|
|
||||||
"browser": "Igual ao navegador"
|
|
||||||
},
|
|
||||||
"defaultView": "Tela inicial",
|
"defaultView": "Tela inicial",
|
||||||
"desktop_notifications": "Notificações",
|
"desktop_notifications": "Notificações",
|
||||||
"lastfmScrobbling": "Enviar scrobbles para Last.fm",
|
"lastfmScrobbling": "Enviar scrobbles para Last.fm",
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ additional:
|
||||||
aliases: [ grp1, grouping, ©grp, wm/contentgroupdescription ]
|
aliases: [ grp1, grouping, ©grp, wm/contentgroupdescription ]
|
||||||
album: true
|
album: true
|
||||||
key:
|
key:
|
||||||
aliases: [ tkey, key, initialkey, ----:com.apple.itunes:initialkey, wm/initialkey ]
|
aliases: [ tkey, key, ----:com.apple.itunes:initialkey, wm/initialkey ]
|
||||||
isrc:
|
isrc:
|
||||||
aliases: [ tsrc, isrc, ----:com.apple.itunes:isrc, wm/isrc ]
|
aliases: [ tsrc, isrc, ----:com.apple.itunes:isrc, wm/isrc ]
|
||||||
language:
|
language:
|
||||||
|
|
@ -231,9 +231,9 @@ additional:
|
||||||
movementname:
|
movementname:
|
||||||
aliases: [ mvnm, movementname, ©mvn ]
|
aliases: [ mvnm, movementname, ©mvn ]
|
||||||
movementtotal:
|
movementtotal:
|
||||||
aliases: [ movementtotal, movementcount, mvc ]
|
aliases: [ movementtotal, mvc ]
|
||||||
movement:
|
movement:
|
||||||
aliases: [ mvin, movement, movementnumber, mvi ]
|
aliases: [ mvin, movement, mvi ]
|
||||||
recordlabel:
|
recordlabel:
|
||||||
aliases: [ tpub, label, publisher, ----:com.apple.itunes:label, wm/publisher, organization ]
|
aliases: [ tpub, label, publisher, ----:com.apple.itunes:label, wm/publisher, organization ]
|
||||||
album: true
|
album: true
|
||||||
|
|
|
||||||
|
|
@ -43,13 +43,6 @@ func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<-
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// A full walk never descends into symlinked folders when following is disabled, so a
|
|
||||||
// target reached through one (e.g. a watcher event for a new link) is skipped too.
|
|
||||||
if !conf.Server.Scanner.FollowSymlinks && isSymlinkedPath(job.fs, folderPath) {
|
|
||||||
log.Debug(ctx, "Scanner: Skipping symlinked target folder, following is disabled", "path", folderPath)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create checker and push patterns from root to this folder
|
// Create checker and push patterns from root to this folder
|
||||||
checker := newIgnoreChecker(job.fs)
|
checker := newIgnoreChecker(job.fs)
|
||||||
err = checker.PushAllParents(ctx, folderPath)
|
err = checker.PushAllParents(ctx, folderPath)
|
||||||
|
|
@ -232,18 +225,6 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool,
|
||||||
return fileInfo.IsDir(), nil
|
return fileInfo.IsDir(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isSymlinkedPath returns true if folderPath, or any of its parent folders, is a symbolic link.
|
|
||||||
// It needs fsys to implement fs.ReadLinkFS, otherwise links are followed and never detected.
|
|
||||||
func isSymlinkedPath(fsys fs.FS, folderPath string) bool {
|
|
||||||
for p := path.Clean(folderPath); p != "." && p != "/"; p = path.Dir(p) {
|
|
||||||
info, err := fs.Lstat(fsys, p)
|
|
||||||
if err == nil && info.Mode()&fs.ModeSymlink != 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxSymlinkHops = 40
|
const maxSymlinkHops = 40
|
||||||
|
|
||||||
// resolveEntryName returns the name to classify the entry by, and whether to
|
// resolveEntryName returns the name to classify the entry by, and whether to
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"maps"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
|
@ -262,44 +260,6 @@ var _ = Describe("walk_dir_tree", func() {
|
||||||
// Folders not in targets should remain in lastUpdates
|
// Folders not in targets should remain in lastUpdates
|
||||||
Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3")))
|
Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3")))
|
||||||
})
|
})
|
||||||
|
|
||||||
// #6292: a watcher event for a new folder symlink makes the link itself a scan target
|
|
||||||
Context("symlinked target folders (production local storage FS)", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
libRoot := GinkgoT().TempDir()
|
|
||||||
Expect(os.MkdirAll(filepath.Join(libRoot, "Mozart", "Album1"), 0755)).To(Succeed())
|
|
||||||
Expect(os.WriteFile(filepath.Join(libRoot, "Mozart", "Album1", "track.mp3"), []byte("AUDIO"), 0600)).To(Succeed())
|
|
||||||
Expect(os.Symlink("Mozart", filepath.Join(libRoot, "Wolfgang Amadeus Mozart"))).To(Succeed())
|
|
||||||
job = &scanJob{fs: newLocalMusicFS(libRoot), lib: model.Library{Path: libRoot}}
|
|
||||||
})
|
|
||||||
|
|
||||||
walkTargets := func(targets ...string) map[string]*folderEntry {
|
|
||||||
results, err := walkDirTree(ctx, job, targets...)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
folders := map[string]*folderEntry{}
|
|
||||||
for folder := range results {
|
|
||||||
folders[folder.path] = folder
|
|
||||||
}
|
|
||||||
return folders
|
|
||||||
}
|
|
||||||
|
|
||||||
DescribeTable("with FollowSymlinks disabled",
|
|
||||||
func(target string, expected ...string) {
|
|
||||||
conf.Server.Scanner.FollowSymlinks = false
|
|
||||||
Expect(slices.Collect(maps.Keys(walkTargets(target)))).To(ConsistOf(expected))
|
|
||||||
},
|
|
||||||
Entry("skips a target that is a symlink", "Wolfgang Amadeus Mozart"),
|
|
||||||
Entry("skips a target under a symlinked folder", "Wolfgang Amadeus Mozart/Album1"),
|
|
||||||
Entry("walks a regular target", "Mozart", "Mozart", "Mozart/Album1"),
|
|
||||||
)
|
|
||||||
|
|
||||||
It("walks a symlinked target when FollowSymlinks is enabled", func() {
|
|
||||||
conf.Server.Scanner.FollowSymlinks = true
|
|
||||||
folders := walkTargets("Wolfgang Amadeus Mozart")
|
|
||||||
Expect(folders).To(HaveKey("Wolfgang Amadeus Mozart/Album1"))
|
|
||||||
Expect(folders["Wolfgang Amadeus Mozart/Album1"].audioFiles).To(HaveKey("track.mp3"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -473,8 +433,8 @@ var _ = Describe("walk_dir_tree", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Regression for #5752: the production localFS must resolve file symlinks.
|
// Regression for #5752: the production localFS must resolve file symlinks.
|
||||||
// fs.ReadLink-based resolution can't follow targets outside the library
|
// It wraps os.DirFS behind the fs.FS interface, so fs.ReadLink-based
|
||||||
// root, so full OS-level resolution is required.
|
// resolution is not available and full OS-level resolution is required.
|
||||||
Context("production local storage FS", func() {
|
Context("production local storage FS", func() {
|
||||||
var libRoot string
|
var libRoot string
|
||||||
var musicFS storage.MusicFS
|
var musicFS storage.MusicFS
|
||||||
|
|
@ -500,7 +460,12 @@ var _ = Describe("walk_dir_tree", func() {
|
||||||
Expect(os.Symlink(filepath.Join(pool, "mid.wav"), filepath.Join(libRoot, "evil.wav"))).To(Succeed())
|
Expect(os.Symlink(filepath.Join(pool, "mid.wav"), filepath.Join(libRoot, "evil.wav"))).To(Succeed())
|
||||||
Expect(os.Symlink(filepath.Join(pool, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).To(Succeed())
|
Expect(os.Symlink(filepath.Join(pool, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).To(Succeed())
|
||||||
|
|
||||||
musicFS = newLocalMusicFS(libRoot)
|
u, err := storage.LocalPathToURL(libRoot)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
s, err := storage.For(u.String())
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
musicFS, err = s.FS()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
||||||
walkRoot := func() *folderEntry {
|
walkRoot := func() *folderEntry {
|
||||||
|
|
@ -735,17 +700,6 @@ func getDirEntry(baseDir, name string) os.DirEntry {
|
||||||
panic(fmt.Sprintf("Could not find %s in %s", name, baseDir))
|
panic(fmt.Sprintf("Could not find %s in %s", name, baseDir))
|
||||||
}
|
}
|
||||||
|
|
||||||
// newLocalMusicFS returns the production local storage MusicFS rooted at libRoot
|
|
||||||
func newLocalMusicFS(libRoot string) storage.MusicFS {
|
|
||||||
u, err := storage.LocalPathToURL(libRoot)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
s, err := storage.For(u.String())
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
musicFS, err := s.FS()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
return musicFS
|
|
||||||
}
|
|
||||||
|
|
||||||
// mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks
|
// mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks
|
||||||
type mockMusicFS struct {
|
type mockMusicFS struct {
|
||||||
storage.MusicFS
|
storage.MusicFS
|
||||||
|
|
|
||||||
|
|
@ -158,8 +158,8 @@ and `MusicArtist` queries accept it too, matching artists credited on an album o
|
||||||
`Years`; `StudioIds` (record labels, as listed by `GET Studios`); `SearchTerm`;
|
`Years`; `StudioIds` (record labels, as listed by `GET Studios`); `SearchTerm`;
|
||||||
`Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone
|
`Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone
|
||||||
`isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as
|
`isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as
|
||||||
in Jellyfin; `Likes` and `Dislikes` are not supported, and `IsFolder`, `IsNotFolder` and
|
in Jellyfin; `Likes`, `Dislikes`, `IsFolder`, `IsNotFolder` and `IsResumable` have no Navidrome
|
||||||
`IsResumable` have no Navidrome equivalent, so all five are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties;
|
equivalent and are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties;
|
||||||
unrecognized keys are skipped, and `Random` always sorts alone);
|
unrecognized keys are skipped, and `Random` always sorts alone);
|
||||||
`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId`
|
`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId`
|
||||||
returns direct children only (no tracks — no track is a library's direct child).
|
returns direct children only (no tracks — no track is a library's direct child).
|
||||||
|
|
@ -176,7 +176,7 @@ returns direct children only (no tracks — no track is a library's direct child
|
||||||
| Artists / genres / labels | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres`, `GET Studios`, `GET Items/Filters` |
|
| Artists / genres / labels | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres`, `GET Studios`, `GET Items/Filters` |
|
||||||
| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Albums/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` |
|
| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Albums/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` |
|
||||||
| Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) |
|
| Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) |
|
||||||
| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE UserItems/{itemId}/Rating`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET`/`POST UserItems/{itemId}/UserData`, `GET`/`POST Users/{userId}/Items/{itemId}/UserData` |
|
| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` |
|
||||||
| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) |
|
| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) |
|
||||||
| Lyrics | `GET Audio/{itemId}/Lyrics` |
|
| Lyrics | `GET Audio/{itemId}/Lyrics` |
|
||||||
| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Playing/Ping` (no-op), `POST Sessions/Capabilities[/Full]` |
|
| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Playing/Ping` (no-op), `POST Sessions/Capabilities[/Full]` |
|
||||||
|
|
@ -402,20 +402,12 @@ make test PKG=./server/jellyfin/...
|
||||||
in the playlist, so ids change when entries are inserted or removed; re-read the list after an
|
in the playlist, so ids change when entries are inserted or removed; re-read the list after an
|
||||||
edit. Real Jellyfin uses the song id. Clients that echo `PlaylistItemId` back (Finamp) work;
|
edit. Real Jellyfin uses the song id. Clients that echo `PlaylistItemId` back (Finamp) work;
|
||||||
clients that send the song id as `EntryIds` or to `Move` (Jellify) get `404`.
|
clients that send the song id as `EntryIds` or to `Move` (Jellify) get `404`.
|
||||||
- **Ratings are stored as 0-5 stars.** Jellyfin's 0-10 `Rating` is rounded to the nearest star
|
- **Rating uses the `rating` param.** `POST Users/{userId}/Items/{itemId}/Rating` reads a `rating`
|
||||||
(any nonzero value keeps at least one), so it reads back as an even number. `likes=true` stores
|
(0-10) and stores it as 0-5 stars. Jellyfin's `likes` boolean is not read, so a client that sends
|
||||||
5 stars and `likes=false` 1 star, like Jellyfin's 10 and 1. A rating of 0 clears the rating.
|
only `likes` clears the rating.
|
||||||
- **`POST .../Rating` needs a value.** Jellyfin clears the rating when `likes` is missing; Navidrome
|
- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed), `Search/Hints` and the non-legacy
|
||||||
answers `400` and keeps it, as it does for a malformed or out-of-range value. Use
|
`UserItems/{itemId}/Rating` are not implemented. Play counts only change through playback
|
||||||
`DELETE .../Rating` to clear. A numeric `rating` param (0-10) is also accepted when `likes` is
|
reporting.
|
||||||
absent.
|
|
||||||
- **`POST .../UserData` writes only `Rating`, `Likes` and `IsFavorite`.** `Played`, `PlayCount`,
|
|
||||||
`LastPlayedDate` and `PlaybackPositionTicks` are ignored. A body that is not a single JSON
|
|
||||||
object, or a `Rating` outside 0-10, is a `400` and nothing is written.
|
|
||||||
- **Annotation writes can't target another user.** Rating and UserData writes whose `userId` names
|
|
||||||
a different user get `403`, even for admins; Jellyfin lets admins edit other users' data.
|
|
||||||
- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed) and `Search/Hints` are not
|
|
||||||
implemented. Play counts only change through playback reporting.
|
|
||||||
- **Some `Fields` are never emitted.** `ProviderIds`, `People`, `Etag` and `DateLastMediaAdded` are
|
- **Some `Fields` are never emitted.** `ProviderIds`, `People`, `Etag` and `DateLastMediaAdded` are
|
||||||
accepted but never returned.
|
accepted but never returned.
|
||||||
- **Blurhashes can be missing.** `ImageBlurHashes` carries the real blurhash that the artwork
|
- **Blurhashes can be missing.** `ImageBlurHashes` carries the real blurhash that the artwork
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,47 @@
|
||||||
package jellyfin
|
package jellyfin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||||
|
"github.com/navidrome/navidrome/utils/req"
|
||||||
)
|
)
|
||||||
|
|
||||||
type annotatedItem struct {
|
// resolveAnnotated finds which annotated repo owns id, returning the resource name used in
|
||||||
repo model.AnnotatedRepository
|
// refreshResource events. Albums and songs 404 when the user can't access their library; artists
|
||||||
resource string // name used in refreshResource events
|
// span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time
|
||||||
ann model.Annotations
|
// scoping. PlaylistRepository.Get enforces playlist visibility. When repo is nil the response has
|
||||||
}
|
// already been written, so callers must return without writing the annotation.
|
||||||
|
func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, resource string) {
|
||||||
// resolveAnnotated finds id's repo and the caller's annotations. Only albums and songs are library-gated
|
|
||||||
// (artists span libraries; playlists gate in Get). When !ok, the response has already been written.
|
|
||||||
func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (item annotatedItem, ok bool) {
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
entity, err := model.GetEntityByID(ctx, api.ds, id)
|
entity, err := model.GetEntityByID(ctx, api.ds, id)
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
api.internalError(w, r, err)
|
api.internalError(w, r, err)
|
||||||
return annotatedItem{}, false
|
return nil, ""
|
||||||
}
|
}
|
||||||
u, _ := request.UserFrom(ctx)
|
u, _ := request.UserFrom(ctx)
|
||||||
switch e := entity.(type) {
|
switch e := entity.(type) {
|
||||||
case *model.Album:
|
case *model.Album:
|
||||||
if u.HasLibraryAccess(e.LibraryID) {
|
if u.HasLibraryAccess(e.LibraryID) {
|
||||||
return annotatedItem{api.ds.Album(), "album", e.Annotations}, true
|
return api.ds.Album(), "album"
|
||||||
}
|
}
|
||||||
case *model.Artist:
|
case *model.Artist:
|
||||||
return annotatedItem{api.ds.Artist(), "artist", e.Annotations}, true
|
return api.ds.Artist(), "artist"
|
||||||
case *model.MediaFile:
|
case *model.MediaFile:
|
||||||
if u.HasLibraryAccess(e.LibraryID) {
|
if u.HasLibraryAccess(e.LibraryID) {
|
||||||
return annotatedItem{api.ds.MediaFile(), "song", e.Annotations}, true
|
return api.ds.MediaFile(), "song"
|
||||||
}
|
}
|
||||||
case *model.Playlist:
|
case *model.Playlist:
|
||||||
return annotatedItem{api.ds.Playlist(), "playlist", e.Annotations}, true
|
return api.ds.Playlist(), "playlist"
|
||||||
}
|
}
|
||||||
// Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent.
|
// Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent.
|
||||||
http.Error(w, "Not Found", http.StatusNotFound)
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
return annotatedItem{}, false
|
return nil, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify
|
// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify
|
||||||
|
|
@ -70,135 +65,65 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) {
|
||||||
api.ok(w, r, data)
|
api.ok(w, r, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// userDataChange holds already-validated writes; a nil field leaves that annotation untouched.
|
func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) {
|
||||||
type userDataChange struct {
|
|
||||||
rating *int // Navidrome stars; 0 clears
|
|
||||||
favorite *bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyUserData writes change for the itemId path param and answers with the item's resulting
|
|
||||||
// UserData, as Jellyfin does.
|
|
||||||
func (api *Router) applyUserData(w http.ResponseWriter, r *http.Request, change userDataChange) {
|
|
||||||
id, ok := itemIDParam(w, r, "itemId")
|
id, ok := itemIDParam(w, r, "itemId")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
item, ok := api.resolveAnnotated(w, r, id)
|
repo, resource := api.resolveAnnotated(w, r, id)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := repo.SetStar(r.Context(), starred, id); err != nil {
|
||||||
|
api.internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
|
||||||
|
encodedID := dto.EncodeID(id)
|
||||||
|
api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) { api.setFavorite(w, r, true) }
|
||||||
|
func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
api.setFavorite(w, r, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) {
|
||||||
|
id, ok := itemIDParam(w, r, "itemId")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := r.Context()
|
repo, resource := api.resolveAnnotated(w, r, id)
|
||||||
if change.rating != nil {
|
if repo == nil {
|
||||||
if err := item.repo.SetRating(ctx, *change.rating, id); err != nil {
|
|
||||||
api.internalError(w, r, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item.ann.Rating = *change.rating
|
|
||||||
}
|
|
||||||
if change.favorite != nil {
|
|
||||||
if err := item.repo.SetStar(ctx, *change.favorite, id); err != nil {
|
|
||||||
api.internalError(w, r, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item.ann.Starred = *change.favorite
|
|
||||||
}
|
|
||||||
if change.rating != nil || change.favorite != nil {
|
|
||||||
api.broker.SendMessage(ctx, (&events.RefreshResource{}).With(item.resource, id))
|
|
||||||
}
|
|
||||||
api.ok(w, r, dto.UserData(item.ann, id))
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyCallerUserData is applyUserData for routes that may name a userId: only the caller's own
|
|
||||||
// annotations can be written, so naming another user is refused. Undecodable ids are ignored.
|
|
||||||
func (api *Router) applyCallerUserData(w http.ResponseWriter, r *http.Request, change userDataChange) {
|
|
||||||
raw := chi.URLParam(r, "userId")
|
|
||||||
if raw == "" {
|
|
||||||
raw = r.URL.Query().Get("userid")
|
|
||||||
}
|
|
||||||
if target, ok := dto.DecodeID(raw); ok {
|
|
||||||
if u, _ := request.UserFrom(r.Context()); target != u.ID {
|
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
api.applyUserData(w, r, change)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) {
|
|
||||||
api.applyUserData(w, r, userDataChange{favorite: new(true)})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
|
|
||||||
api.applyUserData(w, r, userDataChange{favorite: new(false)})
|
|
||||||
}
|
|
||||||
|
|
||||||
// setRating reads Jellyfin's likes flag, or the numeric rating extension older clients send. Unlike
|
|
||||||
// Jellyfin, a missing or malformed value is a 400 rather than a clear: clearing is DELETE's job.
|
|
||||||
func (api *Router) setRating(w http.ResponseWriter, r *http.Request) {
|
|
||||||
q := r.URL.Query()
|
|
||||||
var stars int
|
|
||||||
switch {
|
|
||||||
case q.Has("likes"):
|
|
||||||
likes, err := strconv.ParseBool(q.Get("likes"))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "likes must be true or false", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stars = dto.StarsFromLikes(likes)
|
|
||||||
case q.Has("rating"):
|
|
||||||
rating, err := strconv.ParseFloat(q.Get("rating"), 64)
|
|
||||||
s, ok := dto.StarsFromRating(rating)
|
|
||||||
if err != nil || !ok {
|
|
||||||
http.Error(w, "rating must be a number from 0 to 10", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stars = s
|
|
||||||
default:
|
|
||||||
http.Error(w, "likes is required", http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
api.applyCallerUserData(w, r, userDataChange{rating: &stars})
|
if err := repo.SetRating(r.Context(), rating, id); err != nil {
|
||||||
|
api.internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
|
||||||
|
encodedID := dto.EncodeID(id)
|
||||||
|
d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID}
|
||||||
|
if rating > 0 {
|
||||||
|
jfRating := float64(rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10, mirrors dto.UserData
|
||||||
|
d.Rating = &jfRating
|
||||||
|
}
|
||||||
|
api.ok(w, r, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setRating maps Jellyfin's 0-10 rating (a nullable double, so fractional values are valid) to
|
||||||
|
// Navidrome's 0-5 stars. A nonzero rating floors at one star: rounding to 0 would clear it, since
|
||||||
|
// SetRating(0) is the delete path.
|
||||||
|
func (api *Router) setRating(w http.ResponseWriter, r *http.Request) {
|
||||||
|
jfRating := req.Params(r).Float64Or("rating", 0)
|
||||||
|
jfRating = min(max(jfRating, 0), 10) // clamp: a client sending e.g. Rating=100 must not write an out-of-domain rating
|
||||||
|
rating := int(math.Round(jfRating / 2))
|
||||||
|
if jfRating > 0 {
|
||||||
|
rating = max(rating, 1)
|
||||||
|
}
|
||||||
|
api.setItemRating(w, r, rating)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) {
|
func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) {
|
||||||
api.applyCallerUserData(w, r, userDataChange{rating: new(0)})
|
api.setItemRating(w, r, 0)
|
||||||
}
|
|
||||||
|
|
||||||
// updateUserItemData writes Rating, Likes and IsFavorite; omitted or null fields are left untouched.
|
|
||||||
// Play state is ignored because it only changes through playback reporting.
|
|
||||||
func (api *Router) updateUserItemData(w http.ResponseWriter, r *http.Request) {
|
|
||||||
body, ok := decodeUserDataBody(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
change := userDataChange{favorite: body.IsFavorite}
|
|
||||||
// Jellyfin applies Likes before Rating, so Rating wins when a body carries both.
|
|
||||||
switch {
|
|
||||||
case body.Rating != nil:
|
|
||||||
stars, ok := dto.StarsFromRating(*body.Rating)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Rating must be from 0 to 10", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
change.rating = &stars
|
|
||||||
case body.Likes != nil:
|
|
||||||
change.rating = new(dto.StarsFromLikes(*body.Likes))
|
|
||||||
}
|
|
||||||
api.applyCallerUserData(w, r, change)
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxUserDataBody = 64 << 10
|
|
||||||
|
|
||||||
// decodeUserDataBody accepts exactly one JSON object; Decode alone would ignore anything after it.
|
|
||||||
func decodeUserDataBody(w http.ResponseWriter, r *http.Request) (*dto.UpdateUserItemDataDto, bool) {
|
|
||||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxUserDataBody))
|
|
||||||
var body *dto.UpdateUserItemDataDto
|
|
||||||
err := dec.Decode(&body)
|
|
||||||
if err == nil && body != nil {
|
|
||||||
if _, err = dec.Token(); errors.Is(err, io.EOF) {
|
|
||||||
return body, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
|
||||||
return nil, false
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
|
@ -23,7 +21,7 @@ var _ = Describe("Annotations", func() {
|
||||||
var broker *fakeEventBroker
|
var broker *fakeEventBroker
|
||||||
// alice has access to library 1 only.
|
// alice has access to library 1 only.
|
||||||
ctxUser := func() context.Context {
|
ctxUser := func() context.Context {
|
||||||
return request.WithUser(GinkgoT().Context(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
|
return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
|
@ -32,16 +30,6 @@ var _ = Describe("Annotations", func() {
|
||||||
api = &Router{ds: ds, broker: broker}
|
api = &Router{ds: ds, broker: broker}
|
||||||
})
|
})
|
||||||
|
|
||||||
songWith := func(ann model.Annotations) *tests.MockMediaFileRepo {
|
|
||||||
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
||||||
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: ann}})
|
|
||||||
return mfRepo
|
|
||||||
}
|
|
||||||
songReq := func(method, target, body string, params ...string) *http.Request {
|
|
||||||
r := httptest.NewRequest(method, target, strings.NewReader(body)).WithContext(ctxUser())
|
|
||||||
return withChiURLParam(r, append([]string{"itemId", dto.EncodeID(testID("s1"))}, params...)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
Describe("markFavorite / unmarkFavorite", func() {
|
Describe("markFavorite / unmarkFavorite", func() {
|
||||||
It("stars a song and returns IsFavorite=true", func() {
|
It("stars a song and returns IsFavorite=true", func() {
|
||||||
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||||
|
|
@ -280,275 +268,41 @@ var _ = Describe("Annotations", func() {
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("clamps a Rating above 10 to Navidrome's max (5)", func() {
|
||||||
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||||
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser())
|
||||||
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
||||||
|
invoke(api.setRating, w, r)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("clamps a negative Rating to Navidrome's min (0)", func() {
|
||||||
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||||
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser())
|
||||||
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
||||||
|
invoke(api.setRating, w, r)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0))
|
||||||
|
})
|
||||||
|
|
||||||
It("emits a refreshResource event when rating a song", func() {
|
It("emits a refreshResource event when rating a song", func() {
|
||||||
songWith(model.Annotations{})
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||||
invoke(api.setRating, httptest.NewRecorder(), songReq("POST", "/Users/u1/Items/s1/Rating?Rating=8", ""))
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser())
|
||||||
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
||||||
|
invoke(api.setRating, w, r)
|
||||||
Expect(broker.Events).To(HaveLen(1))
|
Expect(broker.Events).To(HaveLen(1))
|
||||||
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`))
|
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("Jellyfin likes semantics", func() {
|
|
||||||
It("stores likes=true as 5 stars and reports Rating 10 / Likes true", func() {
|
|
||||||
mfRepo := songWith(model.Annotations{})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=true", ""))
|
|
||||||
Expect(w.Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
|
|
||||||
d := parseUserData(w)
|
|
||||||
Expect(*d.Rating).To(Equal(10.0))
|
|
||||||
Expect(*d.Likes).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("stores likes=false as 1 star (Jellyfin's dislike is Rating 1), not a clear", func() {
|
|
||||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?Likes=False", ""))
|
|
||||||
Expect(w.Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1))
|
|
||||||
d := parseUserData(w)
|
|
||||||
Expect(*d.Rating).To(Equal(2.0))
|
|
||||||
Expect(*d.Likes).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("prefers likes over the numeric rating extension when both are sent", func() {
|
|
||||||
mfRepo := songWith(model.Annotations{})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=false&rating=10", ""))
|
|
||||||
Expect(w.Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("still clears on an explicit rating=0 from numeric clients", func() {
|
|
||||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?rating=0", ""))
|
|
||||||
Expect(w.Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0))
|
|
||||||
Expect(parseUserData(w).Rating).To(BeNil())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps the favorite and play state and reports them in the response", func() {
|
|
||||||
playDate := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
||||||
mfRepo := songWith(model.Annotations{Starred: true, PlayCount: 3, PlayDate: &playDate})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=true", ""))
|
|
||||||
Expect(w.Code).To(Equal(http.StatusOK))
|
|
||||||
song := mfRepo.Data[testID("s1")]
|
|
||||||
Expect(song.Starred).To(BeTrue())
|
|
||||||
Expect(song.PlayCount).To(Equal(int64(3)))
|
|
||||||
Expect(song.PlayDate).To(Equal(&playDate))
|
|
||||||
d := parseUserData(w)
|
|
||||||
Expect(d.IsFavorite).To(BeTrue())
|
|
||||||
Expect(d.PlayCount).To(Equal(3))
|
|
||||||
Expect(d.Played).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("rejects a missing or malformed value without touching the stored rating",
|
|
||||||
func(query string) {
|
|
||||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating"+query, ""))
|
|
||||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
||||||
Expect(broker.Events).To(BeEmpty())
|
|
||||||
},
|
|
||||||
Entry("no params (Jellyfin would clear)", ""),
|
|
||||||
Entry("unrelated params only", "?userId=x&foo=1"),
|
|
||||||
Entry("empty likes", "?likes="),
|
|
||||||
Entry("non-boolean likes", "?likes=maybe"),
|
|
||||||
Entry("non-boolean likes even with a valid rating", "?likes=maybe&rating=8"),
|
|
||||||
Entry("empty rating", "?rating="),
|
|
||||||
Entry("non-numeric rating", "?rating=abc"),
|
|
||||||
Entry("NaN rating", "?rating=NaN"),
|
|
||||||
Entry("infinite rating", "?rating=Inf"),
|
|
||||||
Entry("rating above 10", "?Rating=100"),
|
|
||||||
Entry("negative rating", "?Rating=-5"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("authorization", func() {
|
|
||||||
DescribeTable("refuses a userId naming another user and leaves the rating",
|
|
||||||
func(method, target string, params ...string) {
|
|
||||||
handler := api.setRating
|
|
||||||
if method == "DELETE" {
|
|
||||||
handler = api.removeRating
|
|
||||||
}
|
|
||||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
invoke(handler, w, songReq(method, target, "", params...))
|
|
||||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
||||||
Expect(broker.Events).To(BeEmpty())
|
|
||||||
},
|
|
||||||
Entry("path userId", "POST", "/Users/u2/Items/s1/Rating?likes=true", "userId", dto.EncodeID(testID("u2"))),
|
|
||||||
Entry("query userId on the /UserItems form", "POST", "/UserItems/s1/Rating?likes=true&userId="+dto.EncodeID(testID("u2"))),
|
|
||||||
Entry("clearing", "DELETE", "/Users/u2/Items/s1/Rating", "userId", dto.EncodeID(testID("u2"))),
|
|
||||||
)
|
|
||||||
|
|
||||||
It("accepts the caller's own userId", func() {
|
|
||||||
mfRepo := songWith(model.Annotations{})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
invoke(api.setRating, w, songReq("POST", "/Users/u1/Items/s1/Rating?likes=true", "", "userId", dto.EncodeID(testID("u1"))))
|
|
||||||
Expect(w.Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("updateUserItemData", func() {
|
|
||||||
post := func(body string, params ...string) *httptest.ResponseRecorder {
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
invoke(api.updateUserItemData, w, songReq("POST", "/UserItems/s1/UserData", body, params...))
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
It("maps a 0-10 Rating to stars and answers with the full UserData", func() {
|
|
||||||
mfRepo := songWith(model.Annotations{Starred: true, PlayCount: 2})
|
|
||||||
w := post(`{"Rating":8}`)
|
|
||||||
Expect(w.Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
||||||
d := parseUserData(w)
|
|
||||||
Expect(*d.Rating).To(Equal(8.0))
|
|
||||||
Expect(*d.Likes).To(BeTrue())
|
|
||||||
Expect(d.IsFavorite).To(BeTrue())
|
|
||||||
Expect(d.PlayCount).To(Equal(2))
|
|
||||||
Expect(d.ItemId).To(Equal(dto.EncodeID(testID("s1"))))
|
|
||||||
Expect(broker.Events).To(HaveLen(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("stores the personal rating",
|
|
||||||
func(body string, before, expected int) {
|
|
||||||
mfRepo := songWith(model.Annotations{Rating: before})
|
|
||||||
Expect(post(body).Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("Likes true", `{"Likes":true}`, 0, 5),
|
|
||||||
Entry("Likes false (Rating 1)", `{"Likes":false}`, 4, 1),
|
|
||||||
Entry("Rating wins over Likes, as Jellyfin applies Likes first", `{"Likes":true,"Rating":2}`, 0, 1),
|
|
||||||
Entry("fractional Rating rounds to the nearest star", `{"Rating":7.5}`, 0, 4),
|
|
||||||
Entry("small nonzero Rating floors at one star", `{"Rating":0.4}`, 0, 1),
|
|
||||||
Entry("Rating 0 clears", `{"Rating":0}`, 4, 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
DescribeTable("leaves the rating untouched when Rating and Likes are omitted or null",
|
|
||||||
func(body string, wantEvents int) {
|
|
||||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
||||||
w := post(body)
|
|
||||||
Expect(w.Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
||||||
Expect(*parseUserData(w).Rating).To(Equal(8.0))
|
|
||||||
Expect(broker.Events).To(HaveLen(wantEvents))
|
|
||||||
},
|
|
||||||
Entry("empty object writes nothing", `{}`, 0),
|
|
||||||
Entry("explicit nulls write nothing", `{"Rating":null,"Likes":null,"IsFavorite":null}`, 0),
|
|
||||||
Entry("favorite only", `{"IsFavorite":true}`, 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
It("sets and unsets the favorite without touching the rating", func() {
|
|
||||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
||||||
Expect(post(`{"IsFavorite":true}`).Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Starred).To(BeTrue())
|
|
||||||
Expect(post(`{"IsFavorite":false}`).Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("ignores play-state fields, which only change through playback reporting", func() {
|
|
||||||
mfRepo := songWith(model.Annotations{PlayCount: 2})
|
|
||||||
w := post(`{"Played":true,"PlayCount":99,"LastPlayedDate":"2026-01-01T00:00:00Z","PlaybackPositionTicks":5}`)
|
|
||||||
Expect(w.Code).To(Equal(http.StatusOK))
|
|
||||||
song := mfRepo.Data[testID("s1")]
|
|
||||||
Expect(song.PlayCount).To(Equal(int64(2)))
|
|
||||||
Expect(song.PlayDate).To(BeNil())
|
|
||||||
Expect(broker.Events).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves state unchanged when a client echoes back the UserData it read", func() {
|
|
||||||
mfRepo := songWith(model.Annotations{Rating: 3, Starred: true, PlayCount: 1})
|
|
||||||
echo, err := json.Marshal(dto.UserData(mfRepo.Data[testID("s1")].Annotations, testID("s1")))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(post(string(echo)).Code).To(Equal(http.StatusOK))
|
|
||||||
song := mfRepo.Data[testID("s1")]
|
|
||||||
Expect(song.Rating).To(Equal(3))
|
|
||||||
Expect(song.Starred).To(BeTrue())
|
|
||||||
Expect(song.PlayCount).To(Equal(int64(1)))
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("rejects an invalid body without writing anything",
|
|
||||||
func(body string) {
|
|
||||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
||||||
Expect(post(body).Code).To(Equal(http.StatusBadRequest))
|
|
||||||
song := mfRepo.Data[testID("s1")]
|
|
||||||
Expect(song.Rating).To(Equal(4))
|
|
||||||
Expect(song.Starred).To(BeFalse())
|
|
||||||
Expect(broker.Events).To(BeEmpty())
|
|
||||||
},
|
|
||||||
Entry("empty body", ""),
|
|
||||||
Entry("malformed JSON", `{"Rating":`),
|
|
||||||
Entry("Rating above 10, even with a valid IsFavorite", `{"IsFavorite":true,"Rating":10.5}`),
|
|
||||||
Entry("negative Rating", `{"IsFavorite":true,"Rating":-1}`),
|
|
||||||
Entry("Rating as a string", `{"Rating":"8"}`),
|
|
||||||
Entry("Likes as a string", `{"Likes":"yes"}`),
|
|
||||||
Entry("IsFavorite as a number", `{"IsFavorite":1}`),
|
|
||||||
Entry("JSON null", `null`),
|
|
||||||
Entry("JSON array", `[]`),
|
|
||||||
Entry("trailing garbage after the object", `{"Rating":8} x`),
|
|
||||||
Entry("a second JSON value after the object", `{"Rating":8}{"Rating":2}`),
|
|
||||||
Entry("a second object after whitespace", "{\"Rating\":8}\n{}"),
|
|
||||||
)
|
|
||||||
|
|
||||||
It("accepts trailing whitespace after the object", func() {
|
|
||||||
mfRepo := songWith(model.Annotations{})
|
|
||||||
Expect(post("{\"Rating\":8}\r\n\t ").Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a body larger than the limit", func() {
|
|
||||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
||||||
big := `{"Rating":8,"Pad":"` + strings.Repeat("x", 70<<10) + `"}`
|
|
||||||
Expect(post(big).Code).To(Equal(http.StatusBadRequest))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("authorization", func() {
|
|
||||||
It("refuses a userId naming another user without writing", func() {
|
|
||||||
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
||||||
w := post(`{"Rating":10,"IsFavorite":true}`, "userId", dto.EncodeID(testID("u2")))
|
|
||||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("accepts the caller's own userId", func() {
|
|
||||||
songWith(model.Annotations{})
|
|
||||||
Expect(post(`{"Rating":10}`, "userId", dto.EncodeID(testID("u1"))).Code).To(Equal(http.StatusOK))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns 404 and writes nothing for an album in a library the user can't access", func() {
|
|
||||||
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
||||||
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r := httptest.NewRequest("POST", "/UserItems/a1/UserData", strings.NewReader(`{"Rating":10,"IsFavorite":true}`)).WithContext(ctxUser())
|
|
||||||
invoke(api.updateUserItemData, w, withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))))
|
|
||||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
||||||
Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(0))
|
|
||||||
Expect(albumRepo.Data[testID("a1")].Starred).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns 404 for an unknown item", func() {
|
|
||||||
w := post(`{"Rating":10}`)
|
|
||||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
func parseUserData(w *httptest.ResponseRecorder) dto.UserItemDataDto {
|
|
||||||
var d dto.UserItemDataDto
|
|
||||||
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeEventBroker struct {
|
type fakeEventBroker struct {
|
||||||
http.Handler
|
http.Handler
|
||||||
Events []events.Event
|
Events []events.Event
|
||||||
|
|
|
||||||
|
|
@ -146,8 +146,6 @@ func (api *Router) routes() http.Handler {
|
||||||
r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite)
|
r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite)
|
||||||
r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite)
|
r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite)
|
||||||
r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite)
|
r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite)
|
||||||
r.Post("/useritems/{itemId}/rating", api.setRating)
|
|
||||||
r.Delete("/useritems/{itemId}/rating", api.removeRating)
|
|
||||||
r.Post("/users/{userId}/items/{itemId}/rating", api.setRating)
|
r.Post("/users/{userId}/items/{itemId}/rating", api.setRating)
|
||||||
r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating)
|
r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating)
|
||||||
|
|
||||||
|
|
@ -155,8 +153,6 @@ func (api *Router) routes() http.Handler {
|
||||||
// /Users/{userId}/Items is the legacy spelling.
|
// /Users/{userId}/Items is the legacy spelling.
|
||||||
r.Get("/useritems/{itemId}/userdata", api.getUserItemData)
|
r.Get("/useritems/{itemId}/userdata", api.getUserItemData)
|
||||||
r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData)
|
r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData)
|
||||||
r.Post("/useritems/{itemId}/userdata", api.updateUserItemData)
|
|
||||||
r.Post("/users/{userId}/items/{itemId}/userdata", api.updateUserItemData)
|
|
||||||
|
|
||||||
r.Get("/artists/{itemId}/similar", api.getSimilarArtists)
|
r.Get("/artists/{itemId}/similar", api.getSimilarArtists)
|
||||||
r.Get("/items/{itemId}/similar", api.getSimilarItems)
|
r.Get("/items/{itemId}/similar", api.getSimilarItems)
|
||||||
|
|
|
||||||
|
|
@ -40,14 +40,6 @@ type UserItemDataDto struct {
|
||||||
Key string `json:"Key"`
|
Key string `json:"Key"`
|
||||||
ItemId string `json:"ItemId,omitempty"`
|
ItemId string `json:"ItemId,omitempty"`
|
||||||
LastPlayedDate *string `json:"LastPlayedDate,omitempty"`
|
LastPlayedDate *string `json:"LastPlayedDate,omitempty"`
|
||||||
Likes *bool `json:"Likes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateUserItemDataDto is the POST .../UserData body; other Jellyfin fields are accepted and ignored.
|
|
||||||
type UpdateUserItemDataDto struct {
|
|
||||||
Rating *float64 `json:"Rating"`
|
|
||||||
Likes *bool `json:"Likes"`
|
|
||||||
IsFavorite *bool `json:"IsFavorite"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BaseItemDto struct {
|
type BaseItemDto struct {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package dto
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
|
@ -122,30 +121,6 @@ func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// minLikeRating is Jellyfin's UserItemData.MinLikeValue: a rating at or above it reads as Likes.
|
|
||||||
const minLikeRating = 6.5
|
|
||||||
|
|
||||||
// StarsFromRating maps Jellyfin's 0-10 rating to 0-5 stars, rejecting out-of-range values and NaN.
|
|
||||||
// A nonzero rating floors at one star, since 0 stars means "not rated".
|
|
||||||
func StarsFromRating(rating float64) (int, bool) {
|
|
||||||
if !(rating >= 0 && rating <= 10) {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
stars := int(math.Round(rating / 2))
|
|
||||||
if rating > 0 {
|
|
||||||
stars = max(stars, 1)
|
|
||||||
}
|
|
||||||
return stars, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// StarsFromLikes mirrors Jellyfin, which stores likes as Rating 10 and dislikes as Rating 1.
|
|
||||||
func StarsFromLikes(likes bool) int {
|
|
||||||
if likes {
|
|
||||||
return 5
|
|
||||||
}
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func UserData(a model.Annotations, itemID string) *UserItemDataDto {
|
func UserData(a model.Annotations, itemID string) *UserItemDataDto {
|
||||||
// Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto.
|
// Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto.
|
||||||
encodedID := EncodeID(itemID)
|
encodedID := EncodeID(itemID)
|
||||||
|
|
@ -159,7 +134,6 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto {
|
||||||
if a.Rating > 0 {
|
if a.Rating > 0 {
|
||||||
r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10
|
r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10
|
||||||
d.Rating = &r
|
d.Rating = &r
|
||||||
d.Likes = new(r >= minLikeRating)
|
|
||||||
}
|
}
|
||||||
if s := JellyfinDate(a.PlayDate); s != "" {
|
if s := JellyfinDate(a.PlayDate); s != "" {
|
||||||
d.LastPlayedDate = &s
|
d.LastPlayedDate = &s
|
||||||
|
|
|
||||||
|
|
@ -478,30 +478,6 @@ var _ = Describe("mappers", func() {
|
||||||
Expect(item.ImageBlurHashes).To(BeNil())
|
Expect(item.ImageBlurHashes).To(BeNil())
|
||||||
})
|
})
|
||||||
|
|
||||||
DescribeTable("UserData derives Likes from the rating like Jellyfin (rating >= 6.5)",
|
|
||||||
func(stars int, expected *bool) {
|
|
||||||
d := UserData(model.Annotations{Rating: stars}, testID("s1"))
|
|
||||||
Expect(d.Likes).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("unrated has no Likes", 0, nil),
|
|
||||||
Entry("1 star (Jellyfin 2) dislikes", 1, new(false)),
|
|
||||||
Entry("3 stars (Jellyfin 6) dislikes", 3, new(false)),
|
|
||||||
Entry("4 stars (Jellyfin 8) likes", 4, new(true)),
|
|
||||||
Entry("5 stars (Jellyfin 10) likes", 5, new(true)),
|
|
||||||
)
|
|
||||||
|
|
||||||
It("emits Rating as a whole number and omits Likes when unrated", func() {
|
|
||||||
rated, err := json.Marshal(UserData(model.Annotations{Rating: 4}, testID("s1")))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(string(rated)).To(ContainSubstring(`"Rating":8,`))
|
|
||||||
Expect(string(rated)).To(ContainSubstring(`"Likes":true`))
|
|
||||||
|
|
||||||
unrated, err := json.Marshal(UserData(model.Annotations{}, testID("s1")))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(string(unrated)).ToNot(ContainSubstring(`"Rating"`))
|
|
||||||
Expect(string(unrated)).ToNot(ContainSubstring(`"Likes"`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("emits DateCreated and SortName on playlists, like albums and artists", func() {
|
It("emits DateCreated and SortName on playlists, like albums and artists", func() {
|
||||||
p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)}
|
p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)}
|
||||||
Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z"))
|
Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z"))
|
||||||
|
|
|
||||||
|
|
@ -130,78 +130,13 @@ var _ = Describe("Annotations", func() {
|
||||||
Expect(*itemUserData(id).Rating).To(Equal(float64(8)))
|
Expect(*itemUserData(id).Rating).To(Equal(float64(8)))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("rejects an out-of-range rating and keeps the stored one", func() {
|
It("clamps an out-of-range rating to the valid domain", func() {
|
||||||
id := albumID("Help!")
|
id := albumID("Help!")
|
||||||
Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=8", "").Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", "").Code).To(Equal(http.StatusBadRequest))
|
|
||||||
Expect(*itemUserData(id).Rating).To(Equal(float64(8)))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("stores Jellyfin likes on the current /UserItems route instead of clearing", func() {
|
|
||||||
id := songID("So What")
|
|
||||||
var liked dto.UserItemDataDto
|
|
||||||
parseInto(post("/UserItems/"+enc(id)+"/Rating?likes=true", ""), &liked)
|
|
||||||
Expect(*liked.Rating).To(Equal(float64(10)))
|
|
||||||
Expect(*liked.Likes).To(BeTrue())
|
|
||||||
|
|
||||||
var disliked dto.UserItemDataDto
|
|
||||||
parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?likes=false", ""), &disliked)
|
|
||||||
Expect(*disliked.Rating).To(Equal(float64(2)))
|
|
||||||
Expect(*disliked.Likes).To(BeFalse())
|
|
||||||
Expect(*itemUserData(id).Likes).To(BeFalse())
|
|
||||||
|
|
||||||
Expect(del("/UserItems/" + enc(id) + "/Rating").Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(itemUserData(id).Rating).To(BeNil())
|
|
||||||
Expect(itemUserData(id).Likes).To(BeNil())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not clear a rating when the request has no value", func() {
|
|
||||||
id := songID("So What")
|
|
||||||
Expect(post("/UserItems/"+enc(id)+"/Rating?likes=true", "").Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(post("/UserItems/"+enc(id)+"/Rating", "").Code).To(Equal(http.StatusBadRequest))
|
|
||||||
Expect(post("/UserItems/"+enc(id)+"/Rating?likes=sure", "").Code).To(Equal(http.StatusBadRequest))
|
|
||||||
Expect(*itemUserData(id).Rating).To(Equal(float64(10)))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("POST /UserItems/{id}/UserData", func() {
|
|
||||||
It("sets Rating, Likes and IsFavorite on both route spellings", func() {
|
|
||||||
id := songID("Come Together")
|
|
||||||
var data dto.UserItemDataDto
|
var data dto.UserItemDataDto
|
||||||
parseInto(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":6}`), &data)
|
parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", ""), &data)
|
||||||
Expect(*data.Rating).To(Equal(float64(6)))
|
// 100 clamps to 10 (Jellyfin) -> 5 (Navidrome) -> 10 back out.
|
||||||
Expect(*data.Likes).To(BeFalse())
|
Expect(data.Rating).ToNot(BeNil())
|
||||||
|
Expect(*data.Rating).To(Equal(float64(10)))
|
||||||
parseInto(post("/Users/"+enc(adminUser.ID)+"/Items/"+enc(id)+"/UserData", `{"Likes":true,"IsFavorite":true}`), &data)
|
|
||||||
stored := itemUserData(id)
|
|
||||||
Expect(*stored.Rating).To(Equal(float64(10)))
|
|
||||||
Expect(*stored.Likes).To(BeTrue())
|
|
||||||
Expect(stored.IsFavorite).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves omitted fields and play state untouched", func() {
|
|
||||||
id := songID("Come Together")
|
|
||||||
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":8,"IsFavorite":true}`).Code).To(Equal(http.StatusOK))
|
|
||||||
before := itemUserData(id)
|
|
||||||
|
|
||||||
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Played":true,"PlayCount":42,"LastPlayedDate":"2026-01-01T00:00:00Z"}`).Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"IsFavorite":false}`).Code).To(Equal(http.StatusOK))
|
|
||||||
|
|
||||||
after := itemUserData(id)
|
|
||||||
Expect(*after.Rating).To(Equal(float64(8)))
|
|
||||||
Expect(after.IsFavorite).To(BeFalse())
|
|
||||||
Expect(after.PlayCount).To(Equal(before.PlayCount))
|
|
||||||
Expect(after.Played).To(Equal(before.Played))
|
|
||||||
Expect(after.LastPlayedDate).To(Equal(before.LastPlayedDate))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects invalid bodies without writing", func() {
|
|
||||||
id := songID("Come Together")
|
|
||||||
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":11,"IsFavorite":true}`).Code).To(Equal(http.StatusBadRequest))
|
|
||||||
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":8} {"Rating":2}`).Code).To(Equal(http.StatusBadRequest))
|
|
||||||
stored := itemUserData(id)
|
|
||||||
Expect(stored.Rating).To(BeNil())
|
|
||||||
Expect(stored.IsFavorite).To(BeFalse())
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
@ -29,36 +28,6 @@ var _ = Describe("Multi-user access control", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("personal ratings", func() {
|
|
||||||
userData := func(u model.User, id string) *dto.UserItemDataDto {
|
|
||||||
var data dto.UserItemDataDto
|
|
||||||
parseInto(getAs(u, "/UserItems/"+enc(id)+"/UserData"), &data)
|
|
||||||
return &data
|
|
||||||
}
|
|
||||||
|
|
||||||
It("keeps each user's rating separate", func() {
|
|
||||||
id := songID("So What")
|
|
||||||
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(postAs(regularUser, "/UserItems/"+enc(id)+"/Rating?likes=false", "").Code).To(Equal(http.StatusOK))
|
|
||||||
|
|
||||||
Expect(*userData(adminUser, id).Rating).To(Equal(float64(10)))
|
|
||||||
Expect(*userData(regularUser, id).Rating).To(Equal(float64(2)))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("refuses writes that name another user, even for an admin, and changes nothing", func() {
|
|
||||||
id := songID("So What")
|
|
||||||
Expect(postAs(regularUser, "/UserItems/"+enc(id)+"/UserData", `{"Rating":4}`).Code).To(Equal(http.StatusOK))
|
|
||||||
|
|
||||||
Expect(postAs(regularUser, "/Users/"+enc(adminUser.ID)+"/Items/"+enc(id)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusForbidden))
|
|
||||||
Expect(post("/Users/"+enc(regularUser.ID)+"/Items/"+enc(id)+"/Rating?likes=true", "").Code).To(Equal(http.StatusForbidden))
|
|
||||||
Expect(del("/Users/" + enc(regularUser.ID) + "/Items/" + enc(id) + "/Rating").Code).To(Equal(http.StatusForbidden))
|
|
||||||
Expect(post("/UserItems/"+enc(id)+"/UserData?userId="+enc(regularUser.ID), `{"Rating":10}`).Code).To(Equal(http.StatusForbidden))
|
|
||||||
|
|
||||||
Expect(userData(adminUser, id).Rating).To(BeNil())
|
|
||||||
Expect(*userData(regularUser, id).Rating).To(Equal(float64(4)))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("private playlists", func() {
|
Describe("private playlists", func() {
|
||||||
It("does not expose another user's private playlist", func() {
|
It("does not expose another user's private playlist", func() {
|
||||||
adminPl := createPlaylist("Admin Private", nil)
|
adminPl := createPlaylist("Admin Private", nil)
|
||||||
|
|
@ -84,7 +53,6 @@ var _ = Describe("Multi-user access control", func() {
|
||||||
adminPl := createPlaylist("Admin Private", nil)
|
adminPl := createPlaylist("Admin Private", nil)
|
||||||
Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound))
|
Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound))
|
||||||
Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound))
|
Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound))
|
||||||
Expect(postAs(regularUser, "/UserItems/"+enc(adminPl)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusNotFound))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
It("lets each user manage their own playlist", func() {
|
It("lets each user manage their own playlist", func() {
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,11 @@ import (
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
// withChiURLParam simulates chi's routing having captured path parameters (key, value pairs), since
|
// withChiURLParam simulates chi's routing having captured a path parameter, since these
|
||||||
// these tests call handlers directly instead of going through the full router.
|
// tests call handlers directly instead of going through the full router.
|
||||||
func withChiURLParam(r *http.Request, kv ...string) *http.Request {
|
func withChiURLParam(r *http.Request, key, value string) *http.Request {
|
||||||
rctx := chi.NewRouteContext()
|
rctx := chi.NewRouteContext()
|
||||||
for i := 0; i+1 < len(kv); i += 2 {
|
rctx.URLParams.Add(key, value)
|
||||||
rctx.URLParams.Add(kv[i], kv[i+1])
|
|
||||||
}
|
|
||||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -137,15 +137,6 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() {
|
||||||
resp = doReq("setRating", "id", songID)
|
resp = doReq("setRating", "id", songID)
|
||||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("rejects ratings outside the 0-5 range", func() {
|
|
||||||
for _, rating := range []string{"-1", "6"} {
|
|
||||||
resp := doReq("setRating", "id", songID, "rating", rating)
|
|
||||||
|
|
||||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
|
||||||
Expect(resp.Error).ToNot(BeNil())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Scrobble", func() {
|
Describe("Scrobble", func() {
|
||||||
|
|
|
||||||
|
|
@ -17,25 +17,16 @@ import (
|
||||||
"github.com/navidrome/navidrome/utils/req"
|
"github.com/navidrome/navidrome/utils/req"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxRating is the highest value accepted by the Subsonic spec for setRating. 0 clears the rating.
|
|
||||||
const maxRating = 5
|
|
||||||
|
|
||||||
func (api *Router) SetRating(r *http.Request) (*responses.Subsonic, error) {
|
func (api *Router) SetRating(r *http.Request) (*responses.Subsonic, error) {
|
||||||
p := req.Params(r)
|
p := req.Params(r)
|
||||||
id, err := p.String("id")
|
id, err := p.String("id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rating64, err := p.Int64("rating")
|
rating, err := p.Int("rating")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Out-of-range values would otherwise land in average_rating, which every user sees.
|
|
||||||
// Check before narrowing to int, so a value like 4294967296 cannot wrap into range on 32-bit builds.
|
|
||||||
if rating64 < 0 || rating64 > maxRating {
|
|
||||||
return nil, newError(responses.ErrorGeneric, "Invalid rating: %d. Must be between 0 and %d", rating64, maxRating)
|
|
||||||
}
|
|
||||||
rating := int(rating64)
|
|
||||||
|
|
||||||
log.Debug(r, "Setting rating", "rating", rating, "id", id)
|
log.Debug(r, "Setting rating", "rating", rating, "id", id)
|
||||||
err = api.setRating(r.Context(), id, rating)
|
err = api.setRating(r.Context(), id, rating)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package subsonic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -234,53 +233,6 @@ var _ = Describe("MediaAnnotationController", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("SetRating range validation", func() {
|
|
||||||
var plRepo *tests.MockPlaylistRepo
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
plRepo = tests.CreateMockPlaylistRepo()
|
|
||||||
plRepo.SetData(model.Playlists{{ID: "pl-1", Name: "My Playlist", OwnerID: "u1"}})
|
|
||||||
ds.(*tests.MockDataStore).MockedPlaylist = plRepo
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("rejects ratings outside the 0-5 range allowed by the Subsonic spec",
|
|
||||||
func(rating string) {
|
|
||||||
_, err := router.SetRating(newGetRequest("id=pl-1", "rating=4"))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
r := newGetRequest("id=pl-1", "rating="+rating)
|
|
||||||
|
|
||||||
_, err = router.SetRating(r)
|
|
||||||
|
|
||||||
Expect(err).To(MatchError(errSubsonic))
|
|
||||||
var subErr subError
|
|
||||||
Expect(errors.As(err, &subErr)).To(BeTrue())
|
|
||||||
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
|
|
||||||
Expect(plRepo.Ratings).To(HaveKeyWithValue("pl-1", 4))
|
|
||||||
},
|
|
||||||
Entry("above maximum", "6"),
|
|
||||||
Entry("well above maximum", "1000"),
|
|
||||||
Entry("negative", "-3"),
|
|
||||||
// These wrap to 0 and 5 when narrowed to a 32-bit int.
|
|
||||||
Entry("2^32 (wraps to 0 on 32-bit)", "4294967296"),
|
|
||||||
Entry("2^32+5 (wraps to 5 on 32-bit)", "4294967301"),
|
|
||||||
)
|
|
||||||
|
|
||||||
DescribeTable("accepts ratings inside the 0-5 range",
|
|
||||||
func(rating int) {
|
|
||||||
r := newGetRequest("id=pl-1", fmt.Sprintf("rating=%d", rating))
|
|
||||||
|
|
||||||
_, err := router.SetRating(r)
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(plRepo.Ratings).To(HaveKeyWithValue("pl-1", rating))
|
|
||||||
},
|
|
||||||
Entry("zero clears the rating", 0),
|
|
||||||
Entry("middle of the range", 3),
|
|
||||||
Entry("maximum", 5),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Star with an unresolvable id", func() {
|
Describe("Star with an unresolvable id", func() {
|
||||||
It("skips the id without broadcasting an empty (wildcard) refresh", func() {
|
It("skips the id without broadcasting an empty (wildcard) refresh", func() {
|
||||||
r := newGetRequest("id=does-not-exist")
|
r := newGetRequest("id=does-not-exist")
|
||||||
|
|
|
||||||
8
ui/package-lock.json
generated
8
ui/package-lock.json
generated
|
|
@ -23,7 +23,7 @@
|
||||||
"inflection": "^3.0.2",
|
"inflection": "^3.0.2",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
"navidrome-music-player": "4.25.5",
|
"navidrome-music-player": "4.25.4",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"ra-data-json-server": "^3.19.12",
|
"ra-data-json-server": "^3.19.12",
|
||||||
"ra-i18n-polyglot": "^3.19.12",
|
"ra-i18n-polyglot": "^3.19.12",
|
||||||
|
|
@ -8613,9 +8613,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/navidrome-music-player": {
|
"node_modules/navidrome-music-player": {
|
||||||
"version": "4.25.5",
|
"version": "4.25.4",
|
||||||
"resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.5.tgz",
|
"resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.4.tgz",
|
||||||
"integrity": "sha512-fXuiwEI9MgtY92R74GidRweJjIVQszHYOw1azO+giQDRGOOw941qBVuMOy3AgkGbtyWye+SJtwn4ipr0MwhqjQ==",
|
"integrity": "sha512-5N7N94aJMIAfKZ0EKFfsHD95q2HBTAnAWyju0JflKnF+2u9Gr3qwnp3ZaaIC2K3z2GA69z0hqK0aOQ40FYsFrw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-icons/all-files": "^4.1.0",
|
"@react-icons/all-files": "^4.1.0",
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
"inflection": "^3.0.2",
|
"inflection": "^3.0.2",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
"navidrome-music-player": "4.25.5",
|
"navidrome-music-player": "4.25.4",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"ra-data-json-server": "^3.19.12",
|
"ra-data-json-server": "^3.19.12",
|
||||||
"ra-i18n-polyglot": "^3.19.12",
|
"ra-i18n-polyglot": "^3.19.12",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
ArrayField,
|
ArrayField,
|
||||||
BooleanField,
|
BooleanField,
|
||||||
ChipField,
|
ChipField,
|
||||||
|
DateField,
|
||||||
FunctionField,
|
FunctionField,
|
||||||
SingleFieldList,
|
SingleFieldList,
|
||||||
TextField,
|
TextField,
|
||||||
|
|
@ -16,7 +17,6 @@ import {
|
||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import {
|
import {
|
||||||
DateField,
|
|
||||||
ArtistLinkField,
|
ArtistLinkField,
|
||||||
MultiLineTextField,
|
MultiLineTextField,
|
||||||
ParticipantsInfo,
|
ParticipantsInfo,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
Datagrid,
|
Datagrid,
|
||||||
DatagridBody,
|
DatagridBody,
|
||||||
DatagridRow,
|
DatagridRow,
|
||||||
|
DateField,
|
||||||
NumberField,
|
NumberField,
|
||||||
TextField,
|
TextField,
|
||||||
FunctionField,
|
FunctionField,
|
||||||
|
|
@ -12,7 +13,6 @@ import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import { useDrag } from 'react-dnd'
|
import { useDrag } from 'react-dnd'
|
||||||
import {
|
import {
|
||||||
DateField,
|
|
||||||
ArtistLinkField,
|
ArtistLinkField,
|
||||||
ArtworkAvatar,
|
ArtworkAvatar,
|
||||||
DurationField,
|
DurationField,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { isDateSet } from '../utils/validations'
|
import { isDateSet } from '../utils/validations'
|
||||||
import { DateField as RADateField, useRecordContext } from 'react-admin'
|
import { DateField as RADateField } from 'react-admin'
|
||||||
import { useDateLocale } from '../i18n/useDateLocale'
|
import { useDateLocale } from '../i18n/useDateLocale'
|
||||||
|
|
||||||
export const DateField = (props) => {
|
export const DateField = (props) => {
|
||||||
const record = useRecordContext(props)
|
const { record, source } = props
|
||||||
const locale = useDateLocale()
|
const locale = useDateLocale()
|
||||||
const value = record?.[props.source]
|
const value = record?.[source]
|
||||||
if (!isDateSet(value)) return null
|
if (!isDateSet(value)) return null
|
||||||
return <RADateField locales={locale} {...props} />
|
return <RADateField locales={locale} {...props} />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { RecordContextProvider } from 'react-admin'
|
|
||||||
import { DateField } from './DateField'
|
import { DateField } from './DateField'
|
||||||
|
|
||||||
vi.mock('react-admin', async (importOriginal) => ({
|
vi.mock('react-admin', async (importOriginal) => ({
|
||||||
|
|
@ -30,13 +29,4 @@ describe('<DateField>', () => {
|
||||||
)
|
)
|
||||||
expect(container).toBeEmptyDOMElement()
|
expect(container).toBeEmptyDOMElement()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reads the record from the record context', () => {
|
|
||||||
render(
|
|
||||||
<RecordContextProvider value={record}>
|
|
||||||
<DateField source="updatedAt" />
|
|
||||||
</RecordContextProvider>,
|
|
||||||
)
|
|
||||||
expect(screen.getByText('17.9.2026')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import TableContainer from '@material-ui/core/TableContainer'
|
||||||
import TableRow from '@material-ui/core/TableRow'
|
import TableRow from '@material-ui/core/TableRow'
|
||||||
import {
|
import {
|
||||||
BooleanField,
|
BooleanField,
|
||||||
|
DateField,
|
||||||
TextField,
|
TextField,
|
||||||
NumberField,
|
NumberField,
|
||||||
FunctionField,
|
FunctionField,
|
||||||
|
|
@ -14,7 +15,6 @@ import {
|
||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
import { humanize, underscore } from 'inflection'
|
import { humanize, underscore } from 'inflection'
|
||||||
import {
|
import {
|
||||||
DateField,
|
|
||||||
ArtistLinkField,
|
ArtistLinkField,
|
||||||
BitrateField,
|
BitrateField,
|
||||||
ParticipantsInfo,
|
ParticipantsInfo,
|
||||||
|
|
|
||||||
|
|
@ -677,11 +677,6 @@
|
||||||
"options": {
|
"options": {
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"dateFormat": "Date Format",
|
|
||||||
"dateFormats": {
|
|
||||||
"language": "Match language",
|
|
||||||
"browser": "Match browser"
|
|
||||||
},
|
|
||||||
"defaultView": "Default View",
|
"defaultView": "Default View",
|
||||||
"desktop_notifications": "Desktop Notifications",
|
"desktop_notifications": "Desktop Notifications",
|
||||||
"lastfmNotConfigured": "Last.fm API-Key is not configured",
|
"lastfmNotConfigured": "Last.fm API-Key is not configured",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import { useLocale } from 'react-admin'
|
import { useLocale } from 'react-admin'
|
||||||
|
|
||||||
export const getStoredDateFormat = () =>
|
|
||||||
localStorage.getItem('dateFormat') || 'language'
|
|
||||||
|
|
||||||
// Our language codes are mostly region-less ("en"), and Intl reads a bare "en"
|
// Our language codes are mostly region-less ("en"), and Intl reads a bare "en"
|
||||||
// as en-US. Borrow the region from the browser when it speaks the same language.
|
// as en-US. Borrow the region from the browser when it speaks the same language.
|
||||||
const resolveDateLocale = (locale, browserLocales = []) => {
|
const resolveDateLocale = (locale, browserLocales = []) => {
|
||||||
|
|
@ -13,10 +10,5 @@ const resolveDateLocale = (locale, browserLocales = []) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// With the "browser" date format, return undefined so Intl uses the browser's
|
export const useDateLocale = () =>
|
||||||
// own locale, regardless of the selected language.
|
resolveDateLocale(useLocale(), navigator.languages)
|
||||||
export const useDateLocale = () => {
|
|
||||||
const locale = useLocale()
|
|
||||||
if (getStoredDateFormat() === 'browser') return undefined
|
|
||||||
return resolveDateLocale(locale, navigator.languages)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ vi.mock('react-admin', () => ({
|
||||||
describe('useDateLocale', () => {
|
describe('useDateLocale', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
localStorage.removeItem('dateFormat')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderWith = async (locale, browserLocales) => {
|
const renderWith = async (locale, browserLocales) => {
|
||||||
|
|
@ -43,10 +42,4 @@ describe('useDateLocale', () => {
|
||||||
const result = await renderWith(undefined, ['en-GB'])
|
const result = await renderWith(undefined, ['en-GB'])
|
||||||
expect(result.current).toBeUndefined()
|
expect(result.current).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns undefined to use the browser locale when set to browser', async () => {
|
|
||||||
localStorage.setItem('dateFormat', 'browser')
|
|
||||||
const result = await renderWith('en', ['fr-FR'])
|
|
||||||
expect(result.current).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import {
|
import {
|
||||||
DateField,
|
|
||||||
List,
|
List,
|
||||||
Pagination,
|
Pagination,
|
||||||
SizeField,
|
SizeField,
|
||||||
|
|
@ -8,6 +7,7 @@ import {
|
||||||
} from '../common/index'
|
} from '../common/index'
|
||||||
import {
|
import {
|
||||||
Datagrid,
|
Datagrid,
|
||||||
|
DateField,
|
||||||
TextField,
|
TextField,
|
||||||
downloadCSV,
|
downloadCSV,
|
||||||
Filter,
|
Filter,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { SimpleForm, Title, useTranslate } from 'react-admin'
|
||||||
import { Card } from '@material-ui/core'
|
import { Card } from '@material-ui/core'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import { SelectLanguage } from './SelectLanguage'
|
import { SelectLanguage } from './SelectLanguage'
|
||||||
import { SelectDateFormat } from './SelectDateFormat'
|
|
||||||
import { SelectTheme } from './SelectTheme'
|
import { SelectTheme } from './SelectTheme'
|
||||||
import { SelectDefaultView } from './SelectDefaultView'
|
import { SelectDefaultView } from './SelectDefaultView'
|
||||||
import { NotificationsToggle } from './NotificationsToggle'
|
import { NotificationsToggle } from './NotificationsToggle'
|
||||||
|
|
@ -25,7 +24,6 @@ const Personal = () => {
|
||||||
<SimpleForm toolbar={null} variant={'outlined'}>
|
<SimpleForm toolbar={null} variant={'outlined'}>
|
||||||
<SelectTheme />
|
<SelectTheme />
|
||||||
<SelectLanguage />
|
<SelectLanguage />
|
||||||
<SelectDateFormat />
|
|
||||||
<SelectDefaultView />
|
<SelectDefaultView />
|
||||||
{config.enableReplayGain && <ReplayGainToggle />}
|
{config.enableReplayGain && <ReplayGainToggle />}
|
||||||
<NotificationsToggle />
|
<NotificationsToggle />
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { SelectInput, useTranslate } from 'react-admin'
|
|
||||||
import { getStoredDateFormat } from '../i18n/useDateLocale'
|
|
||||||
|
|
||||||
export const SelectDateFormat = (props) => {
|
|
||||||
const translate = useTranslate()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SelectInput
|
|
||||||
{...props}
|
|
||||||
source="dateFormat"
|
|
||||||
label={translate('menu.personal.options.dateFormat')}
|
|
||||||
defaultValue={getStoredDateFormat()}
|
|
||||||
choices={[
|
|
||||||
{ id: 'language', name: 'menu.personal.options.dateFormats.language' },
|
|
||||||
{ id: 'browser', name: 'menu.personal.options.dateFormats.browser' },
|
|
||||||
]}
|
|
||||||
onChange={(event) => {
|
|
||||||
localStorage.setItem('dateFormat', event.target.value)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
Datagrid,
|
Datagrid,
|
||||||
|
DateField,
|
||||||
EditButton,
|
EditButton,
|
||||||
Filter,
|
Filter,
|
||||||
NullableBooleanInput,
|
NullableBooleanInput,
|
||||||
|
|
@ -19,7 +20,6 @@ import Switch from '@material-ui/core/Switch'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import { useMediaQuery } from '@material-ui/core'
|
import { useMediaQuery } from '@material-ui/core'
|
||||||
import {
|
import {
|
||||||
DateField,
|
|
||||||
ArtworkAvatar,
|
ArtworkAvatar,
|
||||||
DurationField,
|
DurationField,
|
||||||
List,
|
List,
|
||||||
|
|
@ -38,18 +38,6 @@ const useStyles = makeStyles((theme) => ({
|
||||||
button: {
|
button: {
|
||||||
color: theme.palette.type === 'dark' ? 'white' : undefined,
|
color: theme.palette.type === 'dark' ? 'white' : undefined,
|
||||||
},
|
},
|
||||||
// RA's layout root is min-width: fit-content; capping the card keeps the page width
|
|
||||||
content: {
|
|
||||||
[theme.breakpoints.down('xs')]: {
|
|
||||||
overflowX: 'auto',
|
|
||||||
maxWidth: '100vw',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
[theme.breakpoints.down('xs')]: {
|
|
||||||
overflowWrap: 'anywhere',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const PlaylistFilter = (props) => {
|
const PlaylistFilter = (props) => {
|
||||||
|
|
@ -146,7 +134,6 @@ export const PlaylistLove = ({ record, className }) => (
|
||||||
PlaylistLove.defaultProps = { source: 'starred', sortable: false }
|
PlaylistLove.defaultProps = { source: 'starred', sortable: false }
|
||||||
|
|
||||||
const PlaylistList = (props) => {
|
const PlaylistList = (props) => {
|
||||||
const classes = useStyles()
|
|
||||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
|
||||||
useResourceRefresh('playlist')
|
useResourceRefresh('playlist')
|
||||||
|
|
@ -178,7 +165,6 @@ const PlaylistList = (props) => {
|
||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
{...props}
|
{...props}
|
||||||
classes={{ content: classes.content }}
|
|
||||||
exporter={false}
|
exporter={false}
|
||||||
sort={{ field: 'name', order: 'ASC' }}
|
sort={{ field: 'name', order: 'ASC' }}
|
||||||
filters={<PlaylistFilter />}
|
filters={<PlaylistFilter />}
|
||||||
|
|
@ -187,7 +173,7 @@ const PlaylistList = (props) => {
|
||||||
>
|
>
|
||||||
<Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}>
|
<Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}>
|
||||||
<ArtworkAvatar source="id" variant="square" />
|
<ArtworkAvatar source="id" variant="square" />
|
||||||
<TextField source="name" className={classes.name} />
|
<TextField source="name" />
|
||||||
{columns}
|
{columns}
|
||||||
<Writable>
|
<Writable>
|
||||||
<EditButton />
|
<EditButton />
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import React, { cloneElement } from 'react'
|
||||||
import {
|
import {
|
||||||
CreateButton,
|
CreateButton,
|
||||||
Datagrid,
|
Datagrid,
|
||||||
|
DateField,
|
||||||
EditButton,
|
EditButton,
|
||||||
Filter,
|
Filter,
|
||||||
sanitizeListRestProps,
|
sanitizeListRestProps,
|
||||||
|
|
@ -14,7 +15,6 @@ import {
|
||||||
useTranslate,
|
useTranslate,
|
||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
import {
|
import {
|
||||||
DateField,
|
|
||||||
List,
|
List,
|
||||||
defaultRowsPerPageOptions,
|
defaultRowsPerPageOptions,
|
||||||
getStoredPerPage,
|
getStoredPerPage,
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
const stylesheet = `
|
|
||||||
.react-jinke-music-player-main .music-player-panel,
|
|
||||||
.react-jinke-music-player-mobile {
|
|
||||||
background-color: #0b0b0d;
|
|
||||||
border-top: 1px solid #ff4d1a;
|
|
||||||
}
|
|
||||||
.react-jinke-music-player-main .music-player-panel svg,
|
|
||||||
.react-jinke-music-player-main .songTitle {
|
|
||||||
color: #f2f2f0
|
|
||||||
}
|
|
||||||
.react-jinke-music-player-main svg:active,
|
|
||||||
.react-jinke-music-player-main svg:hover,
|
|
||||||
.react-jinke-music-player .music-player-controller {
|
|
||||||
color: #ff4d1a
|
|
||||||
}
|
|
||||||
.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,
|
|
||||||
.react-jinke-music-player-mobile-progress .rc-slider-handle,
|
|
||||||
.react-jinke-music-player-mobile-progress .rc-slider-track {
|
|
||||||
background-color: #ff4d1a
|
|
||||||
}
|
|
||||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
|
|
||||||
box-shadow: 0 0 2px #ff4d1a
|
|
||||||
}
|
|
||||||
.audio-lists-panel {
|
|
||||||
background-color: #151518
|
|
||||||
}
|
|
||||||
.audio-lists-panel-content .audio-item.playing,
|
|
||||||
.react-jinke-music-player-main .audio-item.playing svg,
|
|
||||||
.react-jinke-music-player-main .audio-item.playing .player-singer,
|
|
||||||
.react-jinke-music-player-main .lyric-btn-active svg,
|
|
||||||
.react-jinke-music-player-main .loading svg {
|
|
||||||
color: #ff4d1a !important
|
|
||||||
}
|
|
||||||
`
|
|
||||||
export default stylesheet
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
import stylesheet from './cave.css.js'
|
|
||||||
|
|
||||||
// Near-black background with a single orange accent, based on the colors of
|
|
||||||
// https://acave.pt (A Cave do Entulho)
|
|
||||||
const background = '#0b0b0d'
|
|
||||||
const surface = '#151518'
|
|
||||||
const text = '#f2f2f0'
|
|
||||||
const textMuted = '#b7b7b7'
|
|
||||||
const accent = '#ff4d1a'
|
|
||||||
const accentDark = '#e84518'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
themeName: 'Cave',
|
|
||||||
palette: {
|
|
||||||
primary: {
|
|
||||||
main: accent,
|
|
||||||
contrastText: background,
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
main: accent,
|
|
||||||
contrastText: background,
|
|
||||||
},
|
|
||||||
background: {
|
|
||||||
default: background,
|
|
||||||
paper: surface,
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
primary: text,
|
|
||||||
secondary: textMuted,
|
|
||||||
},
|
|
||||||
type: 'dark',
|
|
||||||
},
|
|
||||||
overrides: {
|
|
||||||
MuiAppBar: {
|
|
||||||
positionFixed: {
|
|
||||||
backgroundColor: `${background} !important`,
|
|
||||||
boxShadow: 'none',
|
|
||||||
borderBottom: `1px solid ${accent}`,
|
|
||||||
},
|
|
||||||
colorSecondary: {
|
|
||||||
color: text,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiDrawer: {
|
|
||||||
root: {
|
|
||||||
background: background,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiToolbar: {
|
|
||||||
root: {
|
|
||||||
background: 'transparent !important',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiButton: {
|
|
||||||
textPrimary: {
|
|
||||||
color: accent,
|
|
||||||
},
|
|
||||||
containedPrimary: {
|
|
||||||
color: background,
|
|
||||||
backgroundColor: accent,
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: accentDark,
|
|
||||||
'@media (hover: none)': {
|
|
||||||
backgroundColor: accent,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiListItemIcon: {
|
|
||||||
root: {
|
|
||||||
color: textMuted,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiTableBody: {
|
|
||||||
root: {
|
|
||||||
'&>tr:nth-child(odd)': {
|
|
||||||
background: 'rgba(255, 255, 255, 0.025)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiTableCell: {
|
|
||||||
root: {
|
|
||||||
borderBottom: '0 none !important',
|
|
||||||
},
|
|
||||||
head: {
|
|
||||||
color: `${textMuted} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiPaper: {
|
|
||||||
elevation1: {
|
|
||||||
boxShadow: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDAlbumGridView: {
|
|
||||||
albumName: {
|
|
||||||
color: text,
|
|
||||||
},
|
|
||||||
albumArtistName: {
|
|
||||||
color: textMuted,
|
|
||||||
},
|
|
||||||
albumPlayButton: {
|
|
||||||
color: accent,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDLogin: {
|
|
||||||
systemNameLink: {
|
|
||||||
color: accent,
|
|
||||||
},
|
|
||||||
welcome: {
|
|
||||||
color: text,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
minWidth: 300,
|
|
||||||
backgroundColor: `${background}e6`,
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
filter: 'hue-rotate(170deg) saturate(1.2)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDMobileArtistDetails: {
|
|
||||||
bgContainer: {
|
|
||||||
background: background,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaPaginationActions: {
|
|
||||||
currentPageButton: {
|
|
||||||
border: `2px solid ${accent}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
player: {
|
|
||||||
theme: 'dark',
|
|
||||||
stylesheet,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -19,15 +19,12 @@ import RosePineTheme from './rosePine'
|
||||||
import RosePineDawnTheme from './rosePineDawn'
|
import RosePineDawnTheme from './rosePineDawn'
|
||||||
import RosePineMoonTheme from './rosePineMoon'
|
import RosePineMoonTheme from './rosePineMoon'
|
||||||
import AmusicTheme from './amusic'
|
import AmusicTheme from './amusic'
|
||||||
import CaveTheme from './cave'
|
|
||||||
import SquiddiesGlassTheme from './SquiddiesGlass'
|
import SquiddiesGlassTheme from './SquiddiesGlass'
|
||||||
import NautilineTheme from './nautiline'
|
import NautilineTheme from './nautiline'
|
||||||
import MoonbaseAlphaTheme from './moonbaseAlpha'
|
import MoonbaseAlphaTheme from './moonbaseAlpha'
|
||||||
import MoonbaseBravoTheme from './moonbaseBravo'
|
import MoonbaseBravoTheme from './moonbaseBravo'
|
||||||
import TokyoNightLightTheme from './tokyoNightLight'
|
import TokyoNightLightTheme from './tokyoNightLight'
|
||||||
import TokyoNightTheme from './tokyoNight'
|
import TokyoNightTheme from './tokyoNight'
|
||||||
import SolarizedDarkTheme from './solarizedDark'
|
|
||||||
import SolarizedLightTheme from './solarizedLight'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
// Classic default themes
|
// Classic default themes
|
||||||
|
|
@ -40,7 +37,6 @@ export default {
|
||||||
CatppuccinLatteTheme,
|
CatppuccinLatteTheme,
|
||||||
CatppuccinMacchiatoTheme,
|
CatppuccinMacchiatoTheme,
|
||||||
CatppuccinMochaTheme,
|
CatppuccinMochaTheme,
|
||||||
CaveTheme,
|
|
||||||
DraculaTheme,
|
DraculaTheme,
|
||||||
ElectricPurpleTheme,
|
ElectricPurpleTheme,
|
||||||
ExtraDarkTheme,
|
ExtraDarkTheme,
|
||||||
|
|
@ -57,8 +53,6 @@ export default {
|
||||||
RosePineDawnTheme,
|
RosePineDawnTheme,
|
||||||
RosePineMoonTheme,
|
RosePineMoonTheme,
|
||||||
RosePineTheme,
|
RosePineTheme,
|
||||||
SolarizedDarkTheme,
|
|
||||||
SolarizedLightTheme,
|
|
||||||
SpotifyTheme,
|
SpotifyTheme,
|
||||||
SquiddiesGlassTheme,
|
SquiddiesGlassTheme,
|
||||||
TokyoNightLightTheme,
|
TokyoNightLightTheme,
|
||||||
|
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
const stylesheet = `
|
|
||||||
|
|
||||||
.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
|
|
||||||
color: #268bd2
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: #268bd2
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #268bd2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
|
|
||||||
box-shadow: 0 0 2px #268bd2
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main .audio-item.playing svg {
|
|
||||||
color: #268bd2
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main .audio-item.playing .player-singer {
|
|
||||||
color: #268bd2 !important
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main .loading svg {
|
|
||||||
color: #268bd2 !important
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle {
|
|
||||||
border: hidden;
|
|
||||||
box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rc-slider-rail, .rc-slider-track {
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main .rc-slider-rail,
|
|
||||||
.react-jinke-music-player-mobile-progress .rc-slider-rail {
|
|
||||||
background-color: rgba(147, 161, 161, .2) !important
|
|
||||||
}
|
|
||||||
|
|
||||||
.rc-slider {
|
|
||||||
padding: 3px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sound-operation > div:nth-child(4) {
|
|
||||||
transform: translateX(-50%) translateY(5%) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sound-operation {
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main .music-player-panel {
|
|
||||||
background-color: #073642;
|
|
||||||
color: #93a1a1;
|
|
||||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-lists-panel {
|
|
||||||
background-color: #073642;
|
|
||||||
bottom: 6.25rem;
|
|
||||||
box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-lists-panel-content .audio-item.playing {
|
|
||||||
background-color: rgba(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-lists-panel-content .audio-item:nth-child(2n+1) {
|
|
||||||
background-color: rgba(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-lists-panel-content .audio-item:active,
|
|
||||||
.audio-lists-panel-content .audio-item:hover {
|
|
||||||
background-color: #0d4251;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-lists-panel-header {
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn {
|
|
||||||
background-color: rgba(0, 0, 0, 0);
|
|
||||||
box-shadow: 0 0 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-lists-panel-content .audio-item {
|
|
||||||
line-height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main .music-player-panel .panel-content .img-content {
|
|
||||||
box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main .music-player-lyric {
|
|
||||||
color: #93a1a1;
|
|
||||||
-webkit-text-stroke: 0.5px #002b36;
|
|
||||||
font-weight: bolder;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg {
|
|
||||||
color: #268bd2 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
|
|
||||||
color: #268bd2
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg {
|
|
||||||
color: #268bd2
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-lists-panel-content .audio-item .player-icons {
|
|
||||||
scale: 75%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile */
|
|
||||||
|
|
||||||
.react-jinke-music-player-mobile-cover {
|
|
||||||
border: none;
|
|
||||||
box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player .music-player-controller {
|
|
||||||
border: none;
|
|
||||||
box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
|
|
||||||
color: #268bd2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player .music-player-controller .music-player-controller-setting {
|
|
||||||
color: rgba(38, 139, 210, .3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track {
|
|
||||||
background-color: #268bd2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-mobile-progress .rc-slider-handle {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export default stylesheet
|
|
||||||
|
|
@ -1,380 +0,0 @@
|
||||||
import stylesheet from './solarizedDark.css.js'
|
|
||||||
|
|
||||||
// Solarized palette by Ethan Schoonover: https://ethanschoonover.com/solarized/
|
|
||||||
const background = '#002b36' // base03
|
|
||||||
const surface = '#073642' // base02
|
|
||||||
const currentLine = '#0d4251' // base02, slightly lighter for hover and headers
|
|
||||||
const foreground = '#93a1a1' // base1
|
|
||||||
const comment = '#839496' // base0
|
|
||||||
const blue = '#268bd2'
|
|
||||||
const link = '#57b5ae' // cyan, lightened to keep text links above 4.5:1
|
|
||||||
const accent = '#2aa198' // cyan, since violet is too dark on base03
|
|
||||||
const red = '#dc322f'
|
|
||||||
|
|
||||||
// For Album, Playlist play button
|
|
||||||
const musicListActions = {
|
|
||||||
alignItems: 'center',
|
|
||||||
'@global': {
|
|
||||||
'button:first-child:not(:only-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',
|
|
||||||
backgroundColor: `${blue} !important`,
|
|
||||||
color: background,
|
|
||||||
borderRadius: 500,
|
|
||||||
border: 0,
|
|
||||||
'&:hover': {
|
|
||||||
transform: 'scale(2.1)',
|
|
||||||
backgroundColor: `${blue} !important`,
|
|
||||||
border: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'button:only-child': {
|
|
||||||
margin: '1.5rem',
|
|
||||||
},
|
|
||||||
'button:first-child>span:first-child': {
|
|
||||||
padding: 0,
|
|
||||||
},
|
|
||||||
'button:first-child>span:first-child>span': {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
|
|
||||||
{
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
themeName: 'Solarized Dark',
|
|
||||||
palette: {
|
|
||||||
primary: {
|
|
||||||
main: blue,
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
main: accent,
|
|
||||||
contrastText: background,
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
main: red,
|
|
||||||
},
|
|
||||||
type: 'dark',
|
|
||||||
background: {
|
|
||||||
default: background,
|
|
||||||
paper: surface,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overrides: {
|
|
||||||
MuiPaper: {
|
|
||||||
root: {
|
|
||||||
color: foreground,
|
|
||||||
backgroundColor: surface,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiAppBar: {
|
|
||||||
positionFixed: {
|
|
||||||
backgroundColor: `${surface} !important`,
|
|
||||||
boxShadow:
|
|
||||||
'rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px',
|
|
||||||
},
|
|
||||||
colorSecondary: {
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiDrawer: {
|
|
||||||
root: {
|
|
||||||
background: background,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiButton: {
|
|
||||||
textPrimary: {
|
|
||||||
color: blue,
|
|
||||||
},
|
|
||||||
textSecondary: {
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiIconButton: {
|
|
||||||
root: {
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiChip: {
|
|
||||||
root: {
|
|
||||||
backgroundColor: currentLine,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiFormGroup: {
|
|
||||||
root: {
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiFormLabel: {
|
|
||||||
root: {
|
|
||||||
color: comment,
|
|
||||||
'&$focused': {
|
|
||||||
color: blue,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiFormHelperText: {
|
|
||||||
error: {
|
|
||||||
color: red,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiToolbar: {
|
|
||||||
root: {
|
|
||||||
backgroundColor: `${surface} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiOutlinedInput: {
|
|
||||||
root: {
|
|
||||||
'& $notchedOutline': {
|
|
||||||
borderColor: currentLine,
|
|
||||||
},
|
|
||||||
'&:hover $notchedOutline': {
|
|
||||||
borderColor: comment,
|
|
||||||
},
|
|
||||||
'&$focused $notchedOutline': {
|
|
||||||
borderColor: blue,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiFilledInput: {
|
|
||||||
root: {
|
|
||||||
backgroundColor: currentLine,
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: comment,
|
|
||||||
},
|
|
||||||
'&$focused': {
|
|
||||||
backgroundColor: currentLine,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiTableRow: {
|
|
||||||
root: {
|
|
||||||
transition: 'background-color .3s ease',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: `${currentLine} !important`,
|
|
||||||
},
|
|
||||||
// Cells have an opaque background, so the hover must be applied to them too
|
|
||||||
'&:hover > .MuiTableCell-root': {
|
|
||||||
background: `${currentLine} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiTableHead: {
|
|
||||||
root: {
|
|
||||||
color: foreground,
|
|
||||||
background: surface,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiTableCell: {
|
|
||||||
root: {
|
|
||||||
color: foreground,
|
|
||||||
background: `${surface} !important`,
|
|
||||||
borderBottom: `1px solid ${currentLine}`,
|
|
||||||
},
|
|
||||||
head: {
|
|
||||||
color: `${foreground} !important`,
|
|
||||||
background: `${currentLine} !important`,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
color: `${foreground} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDAlbumGridView: {
|
|
||||||
albumName: {
|
|
||||||
marginTop: '0.5rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
albumSubtitle: {
|
|
||||||
color: comment,
|
|
||||||
},
|
|
||||||
albumContainer: {
|
|
||||||
backgroundColor: surface,
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '.75rem',
|
|
||||||
transition: 'background-color .3s ease',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: currentLine,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
albumPlayButton: {
|
|
||||||
backgroundColor: blue,
|
|
||||||
borderRadius: '50%',
|
|
||||||
boxShadow: '0 8px 8px rgb(0 0 0 / 30%)',
|
|
||||||
padding: '0.35rem',
|
|
||||||
transition: 'padding .3s ease',
|
|
||||||
'&:hover': {
|
|
||||||
background: `${blue} !important`,
|
|
||||||
padding: '0.45rem',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDPlaylistDetails: {
|
|
||||||
container: {
|
|
||||||
background: `linear-gradient(${currentLine}, transparent)`,
|
|
||||||
borderRadius: 0,
|
|
||||||
paddingTop: '2.5rem !important',
|
|
||||||
boxShadow: 'none',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontWeight: 700,
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
details: {
|
|
||||||
fontSize: '.875rem',
|
|
||||||
color: comment,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDAlbumDetails: {
|
|
||||||
root: {
|
|
||||||
background: `linear-gradient(${currentLine}, transparent)`,
|
|
||||||
borderRadius: 0,
|
|
||||||
boxShadow: 'none',
|
|
||||||
},
|
|
||||||
cardContents: {
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingTop: '1.5rem',
|
|
||||||
},
|
|
||||||
recordName: {
|
|
||||||
fontWeight: 700,
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
recordArtist: {
|
|
||||||
fontSize: '.875rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: accent,
|
|
||||||
},
|
|
||||||
recordMeta: {
|
|
||||||
fontSize: '.875rem',
|
|
||||||
color: comment,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDCollapsibleComment: {
|
|
||||||
commentBlock: {
|
|
||||||
fontSize: '.875rem',
|
|
||||||
color: comment,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDAlbumShow: {
|
|
||||||
albumActions: musicListActions,
|
|
||||||
},
|
|
||||||
NDPlaylistShow: {
|
|
||||||
playlistActions: musicListActions,
|
|
||||||
},
|
|
||||||
NDAudioPlayer: {
|
|
||||||
audioTitle: {
|
|
||||||
color: foreground,
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
},
|
|
||||||
songTitle: {
|
|
||||||
fontWeight: 400,
|
|
||||||
},
|
|
||||||
songInfo: {
|
|
||||||
fontSize: '0.675rem',
|
|
||||||
color: comment,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDLogin: {
|
|
||||||
systemNameLink: {
|
|
||||||
color: link,
|
|
||||||
},
|
|
||||||
welcome: {
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
minWidth: 300,
|
|
||||||
background: surface,
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
boxShadow: '3px 3px 5px #00212b',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDMobileArtistDetails: {
|
|
||||||
bgContainer: {
|
|
||||||
background: `linear-gradient(to bottom, rgba(0 43 54 / 72%), ${background})!important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaLayout: {
|
|
||||||
content: {
|
|
||||||
padding: '0 !important',
|
|
||||||
background: background,
|
|
||||||
},
|
|
||||||
root: {
|
|
||||||
backgroundColor: background,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaList: {
|
|
||||||
content: {
|
|
||||||
backgroundColor: background,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaListToolbar: {
|
|
||||||
toolbar: {
|
|
||||||
backgroundColor: background,
|
|
||||||
padding: '0 .55rem !important',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaSidebar: {
|
|
||||||
fixed: {
|
|
||||||
backgroundColor: background,
|
|
||||||
},
|
|
||||||
drawerPaper: {
|
|
||||||
backgroundColor: `${background} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaMenuItemLink: {
|
|
||||||
root: {
|
|
||||||
color: foreground,
|
|
||||||
'&[aria-current="page"]': {
|
|
||||||
color: `${blue} !important`,
|
|
||||||
},
|
|
||||||
'&[aria-current="page"] .MuiListItemIcon-root': {
|
|
||||||
color: `${blue} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
active: {
|
|
||||||
color: `${blue} !important`,
|
|
||||||
'& .MuiListItemIcon-root': {
|
|
||||||
color: `${blue} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaLink: {
|
|
||||||
link: {
|
|
||||||
color: link,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaButton: {
|
|
||||||
button: {
|
|
||||||
margin: '0 5px 0 5px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaPaginationActions: {
|
|
||||||
currentPageButton: {
|
|
||||||
border: `2px solid ${blue}`,
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
backgroundColor: currentLine,
|
|
||||||
minWidth: 48,
|
|
||||||
margin: '0 4px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
player: {
|
|
||||||
theme: 'dark',
|
|
||||||
stylesheet,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
const stylesheet = `
|
|
||||||
.react-jinke-music-player-main.light-theme .loading svg {
|
|
||||||
color: #268bd2;
|
|
||||||
font-size: 24px
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-mobile-play-model-tip {
|
|
||||||
background-color: #268bd2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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: #268bd2
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
|
|
||||||
box-shadow: 0 0 2px #268bd2
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .audio-item.playing svg {
|
|
||||||
color: #268bd2
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .audio-item.playing .player-singer {
|
|
||||||
color: #268bd2 !important
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
|
|
||||||
color: #268bd2
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg {
|
|
||||||
color: #268bd2
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme ::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #268bd2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme svg {
|
|
||||||
color: #586e75
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover {
|
|
||||||
color: #268bd2
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .rc-slider-rail {
|
|
||||||
background-color: rgba(88, 110, 117, .12) !important
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .music-player-controller {
|
|
||||||
background-color: #fdf6e3;
|
|
||||||
border-color: #fdf6e3
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .music-player-panel {
|
|
||||||
background-color: #fdf6e3;
|
|
||||||
box-shadow: 0 1px 2px 0 rgba(0, 43, 54, .05);
|
|
||||||
color: #586e75
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .music-player-panel .img-content {
|
|
||||||
box-shadow: 0 0 10px #e4ddc6
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .music-player-panel .progress-load-bar {
|
|
||||||
background-color: rgba(88, 110, 117, .08) !important
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .rc-switch {
|
|
||||||
color: #fdf6e3
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .rc-switch:after {
|
|
||||||
background-color: #fdf6e3
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .rc-switch-checked {
|
|
||||||
background-color: #268bd2 !important;
|
|
||||||
border: 1px solid #268bd2
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .rc-switch-inner {
|
|
||||||
color: #fdf6e3
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .audio-lists-btn {
|
|
||||||
background-color: #eee8d5 !important
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .audio-lists-btn:active, .react-jinke-music-player-main.light-theme .audio-lists-btn:hover {
|
|
||||||
background-color: #e4ddc6;
|
|
||||||
color: #586e75
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover, .react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover > svg {
|
|
||||||
color: #268bd2
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .audio-lists-panel {
|
|
||||||
background-color: #fdf6e3;
|
|
||||||
box-shadow: 0 0 2px #e4ddc6;
|
|
||||||
color: #586e75
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item {
|
|
||||||
background-color: #fdf6e3
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item:nth-child(odd) {
|
|
||||||
background-color: #f5efdc !important
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing {
|
|
||||||
background-color: #e4ddc6 !important
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing, .react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing svg {
|
|
||||||
color: #268bd2 !important
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export default stylesheet
|
|
||||||
|
|
@ -1,380 +0,0 @@
|
||||||
import stylesheet from './solarizedLight.css.js'
|
|
||||||
|
|
||||||
// Solarized palette by Ethan Schoonover: https://ethanschoonover.com/solarized/
|
|
||||||
const background = '#eee8d5' // base2
|
|
||||||
const surface = '#fdf6e3' // base3
|
|
||||||
const currentLine = '#e4ddc6' // base2, slightly darker for hover and headers
|
|
||||||
const foreground = '#586e75' // base01
|
|
||||||
const comment = '#657b83' // base00
|
|
||||||
const blue = '#268bd2'
|
|
||||||
const link = '#1c6599' // blue, darkened to keep text links above 4.5:1
|
|
||||||
const accent = '#5d61a9' // violet, darkened to keep text above 4.5:1
|
|
||||||
const red = '#dc322f'
|
|
||||||
|
|
||||||
// For Album, Playlist play button
|
|
||||||
const musicListActions = {
|
|
||||||
alignItems: 'center',
|
|
||||||
'@global': {
|
|
||||||
'button:first-child:not(:only-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',
|
|
||||||
backgroundColor: `${blue} !important`,
|
|
||||||
color: background,
|
|
||||||
borderRadius: 500,
|
|
||||||
border: 0,
|
|
||||||
'&:hover': {
|
|
||||||
transform: 'scale(2.1)',
|
|
||||||
backgroundColor: `${blue} !important`,
|
|
||||||
border: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'button:only-child': {
|
|
||||||
margin: '1.5rem',
|
|
||||||
},
|
|
||||||
'button:first-child>span:first-child': {
|
|
||||||
padding: 0,
|
|
||||||
},
|
|
||||||
'button:first-child>span:first-child>span': {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
|
|
||||||
{
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
themeName: 'Solarized Light',
|
|
||||||
palette: {
|
|
||||||
primary: {
|
|
||||||
main: blue,
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
main: accent,
|
|
||||||
contrastText: surface,
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
main: red,
|
|
||||||
},
|
|
||||||
type: 'light',
|
|
||||||
background: {
|
|
||||||
default: background,
|
|
||||||
paper: surface,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overrides: {
|
|
||||||
MuiPaper: {
|
|
||||||
root: {
|
|
||||||
color: foreground,
|
|
||||||
backgroundColor: surface,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiAppBar: {
|
|
||||||
positionFixed: {
|
|
||||||
backgroundColor: `${surface} !important`,
|
|
||||||
boxShadow:
|
|
||||||
'rgba(0, 43, 54, 0.12) 0px 4px 6px, rgba(0, 43, 54, 0.06) 0px 5px 7px',
|
|
||||||
},
|
|
||||||
colorSecondary: {
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiDrawer: {
|
|
||||||
root: {
|
|
||||||
background: background,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiButton: {
|
|
||||||
textPrimary: {
|
|
||||||
color: blue,
|
|
||||||
},
|
|
||||||
textSecondary: {
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiIconButton: {
|
|
||||||
root: {
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiChip: {
|
|
||||||
root: {
|
|
||||||
backgroundColor: currentLine,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiFormGroup: {
|
|
||||||
root: {
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiFormLabel: {
|
|
||||||
root: {
|
|
||||||
color: comment,
|
|
||||||
'&$focused': {
|
|
||||||
color: blue,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiFormHelperText: {
|
|
||||||
error: {
|
|
||||||
color: red,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiToolbar: {
|
|
||||||
root: {
|
|
||||||
backgroundColor: `${surface} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiOutlinedInput: {
|
|
||||||
root: {
|
|
||||||
'& $notchedOutline': {
|
|
||||||
borderColor: currentLine,
|
|
||||||
},
|
|
||||||
'&:hover $notchedOutline': {
|
|
||||||
borderColor: comment,
|
|
||||||
},
|
|
||||||
'&$focused $notchedOutline': {
|
|
||||||
borderColor: blue,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiFilledInput: {
|
|
||||||
root: {
|
|
||||||
backgroundColor: currentLine,
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: comment,
|
|
||||||
},
|
|
||||||
'&$focused': {
|
|
||||||
backgroundColor: currentLine,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiTableRow: {
|
|
||||||
root: {
|
|
||||||
transition: 'background-color .3s ease',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: `${currentLine} !important`,
|
|
||||||
},
|
|
||||||
// Cells have an opaque background, so the hover must be applied to them too
|
|
||||||
'&:hover > .MuiTableCell-root': {
|
|
||||||
background: `${currentLine} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiTableHead: {
|
|
||||||
root: {
|
|
||||||
color: foreground,
|
|
||||||
background: surface,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiTableCell: {
|
|
||||||
root: {
|
|
||||||
color: foreground,
|
|
||||||
background: `${surface} !important`,
|
|
||||||
borderBottom: `1px solid ${currentLine}`,
|
|
||||||
},
|
|
||||||
head: {
|
|
||||||
color: `${foreground} !important`,
|
|
||||||
background: `${currentLine} !important`,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
color: `${foreground} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDAlbumGridView: {
|
|
||||||
albumName: {
|
|
||||||
marginTop: '0.5rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
albumSubtitle: {
|
|
||||||
color: comment,
|
|
||||||
},
|
|
||||||
albumContainer: {
|
|
||||||
backgroundColor: surface,
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '.75rem',
|
|
||||||
transition: 'background-color .3s ease',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: currentLine,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
albumPlayButton: {
|
|
||||||
backgroundColor: blue,
|
|
||||||
borderRadius: '50%',
|
|
||||||
boxShadow: '0 8px 8px rgb(0 0 0 / 20%)',
|
|
||||||
padding: '0.35rem',
|
|
||||||
transition: 'padding .3s ease',
|
|
||||||
'&:hover': {
|
|
||||||
background: `${blue} !important`,
|
|
||||||
padding: '0.45rem',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDPlaylistDetails: {
|
|
||||||
container: {
|
|
||||||
background: `linear-gradient(${currentLine}, transparent)`,
|
|
||||||
borderRadius: 0,
|
|
||||||
paddingTop: '2.5rem !important',
|
|
||||||
boxShadow: 'none',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontWeight: 700,
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
details: {
|
|
||||||
fontSize: '.875rem',
|
|
||||||
color: comment,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDAlbumDetails: {
|
|
||||||
root: {
|
|
||||||
background: `linear-gradient(${currentLine}, transparent)`,
|
|
||||||
borderRadius: 0,
|
|
||||||
boxShadow: 'none',
|
|
||||||
},
|
|
||||||
cardContents: {
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingTop: '1.5rem',
|
|
||||||
},
|
|
||||||
recordName: {
|
|
||||||
fontWeight: 700,
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
recordArtist: {
|
|
||||||
fontSize: '.875rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: accent,
|
|
||||||
},
|
|
||||||
recordMeta: {
|
|
||||||
fontSize: '.875rem',
|
|
||||||
color: comment,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDCollapsibleComment: {
|
|
||||||
commentBlock: {
|
|
||||||
fontSize: '.875rem',
|
|
||||||
color: comment,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDAlbumShow: {
|
|
||||||
albumActions: musicListActions,
|
|
||||||
},
|
|
||||||
NDPlaylistShow: {
|
|
||||||
playlistActions: musicListActions,
|
|
||||||
},
|
|
||||||
NDAudioPlayer: {
|
|
||||||
audioTitle: {
|
|
||||||
color: foreground,
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
},
|
|
||||||
songTitle: {
|
|
||||||
fontWeight: 400,
|
|
||||||
},
|
|
||||||
songInfo: {
|
|
||||||
fontSize: '0.675rem',
|
|
||||||
color: comment,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDLogin: {
|
|
||||||
systemNameLink: {
|
|
||||||
color: link,
|
|
||||||
},
|
|
||||||
welcome: {
|
|
||||||
color: foreground,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
minWidth: 300,
|
|
||||||
background: surface,
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
boxShadow: '3px 3px 5px #93a1a1',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
NDMobileArtistDetails: {
|
|
||||||
bgContainer: {
|
|
||||||
background: `linear-gradient(to bottom, rgba(238 232 213 / 72%), ${background})!important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaLayout: {
|
|
||||||
content: {
|
|
||||||
padding: '0 !important',
|
|
||||||
background: background,
|
|
||||||
},
|
|
||||||
root: {
|
|
||||||
backgroundColor: background,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaList: {
|
|
||||||
content: {
|
|
||||||
backgroundColor: background,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaListToolbar: {
|
|
||||||
toolbar: {
|
|
||||||
backgroundColor: background,
|
|
||||||
padding: '0 .55rem !important',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaSidebar: {
|
|
||||||
fixed: {
|
|
||||||
backgroundColor: background,
|
|
||||||
},
|
|
||||||
drawerPaper: {
|
|
||||||
backgroundColor: `${background} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaMenuItemLink: {
|
|
||||||
root: {
|
|
||||||
color: foreground,
|
|
||||||
'&[aria-current="page"]': {
|
|
||||||
color: `${blue} !important`,
|
|
||||||
},
|
|
||||||
'&[aria-current="page"] .MuiListItemIcon-root': {
|
|
||||||
color: `${blue} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
active: {
|
|
||||||
color: `${blue} !important`,
|
|
||||||
'& .MuiListItemIcon-root': {
|
|
||||||
color: `${blue} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaLink: {
|
|
||||||
link: {
|
|
||||||
color: link,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaButton: {
|
|
||||||
button: {
|
|
||||||
margin: '0 5px 0 5px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
RaPaginationActions: {
|
|
||||||
currentPageButton: {
|
|
||||||
border: `2px solid ${blue}`,
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
backgroundColor: currentLine,
|
|
||||||
minWidth: 48,
|
|
||||||
margin: '0 4px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
player: {
|
|
||||||
theme: 'light',
|
|
||||||
stylesheet,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue