Compare commits

..

1 commit

Author SHA1 Message Date
Deluan
bf72f00760 fix(persistence): keep song pages correct past the large-offset threshold
Past DevOffsetOptimize (50,000 by default) GetAll and the legacy LIKE
search exclude earlier rows with a "rowid NOT IN (subquery)". The
subquery selected only the rowid, so its ORDER BY could not use the
outer column names and SQLite matched them against every joined table.
Sorting songs by id, path, updatedAt or comment failed with "ambiguous
column name", so /api/song returned 500 for those sorts. Album legacy
search failed the same way on name.

The subquery and the outer query also ordered tied rows differently, so
sorts with many equal values (starred, rating, playCount, ...) repeated
some songs and skipped others once past the threshold, without errors.

The subquery now keeps the outer projection, so ORDER BY resolves the
same way in both queries. Paginated queries get a rowid tie-breaker, so
the order is total and every page agrees on where ties go. Every index
ends with the rowid, so index-served sorts stay index-served. createdAt
and updatedAt now sort by (date, id) to match their (date, id) indexes;
with the date alone, the rowid tie-breaker would need a temp B-tree.

Fixes #6285
2026-10-07 13:54:01 -04:00
62 changed files with 371 additions and 2532 deletions

View file

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

View file

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

View file

@ -5,7 +5,7 @@
[![Last Release](https://img.shields.io/github/v/release/navidrome/navidrome?logo=github&label=latest&style=flat-square)](https://github.com/navidrome/navidrome/releases) [![Last Release](https://img.shields.io/github/v/release/navidrome/navidrome?logo=github&label=latest&style=flat-square)](https://github.com/navidrome/navidrome/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/navidrome/navidrome/pipeline.yml?branch=master&logo=github&style=flat-square)](https://nightly.link/navidrome/navidrome/workflows/pipeline/master) [![Build](https://img.shields.io/github/actions/workflow/status/navidrome/navidrome/pipeline.yml?branch=master&logo=github&style=flat-square)](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
[![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest) [![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest)
[![Docker Pulls](https://img.shields.io/endpoint?url=https%3A%2F%2Fwww.navidrome.org%2Fbadges%2Fcontainer-downloads.json&logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/navidrome/navidrome) [![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/deluan/navidrome)
[![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF) [![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF)
[![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/) [![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](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>
[![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome)
## Features ## Features

View file

@ -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)
} }

View file

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

View file

@ -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) {

View file

@ -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))
})
}) })
}) })

View file

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

View file

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

View file

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

View file

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

View file

@ -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",

View file

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

View file

@ -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")

View 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() {

View file

@ -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)
} }

View file

@ -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() {

View file

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

View file

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

View file

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

View file

@ -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"
}, },

View file

@ -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",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"))

View file

@ -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())
}) })
}) })
}) })

View file

@ -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() {

View file

@ -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))
} }

View file

@ -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() {

View file

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

View file

@ -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
View file

@ -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",

View file

@ -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",

View file

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

View file

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

View file

@ -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} />
} }

View file

@ -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()
})
}) })

View file

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

View file

@ -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",

View file

@ -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)
}

View file

@ -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()
})
}) })

View file

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

View file

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

View file

@ -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)
}}
/>
)
}

View file

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

View file

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

View file

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

View file

@ -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,
},
}

View file

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

View file

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

View file

@ -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,
},
}

View file

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

View file

@ -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,
},
}