mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-05 09:48:37 +01:00
Compare commits
22 commits
fix/6285-l
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c1e68a1e4 |
||
|
|
755da3ea59 |
||
|
|
c9c70c952b |
||
|
|
563c5c72a1 |
||
|
|
2a57570e90 |
||
|
|
ec2e0a9ea9 |
||
|
|
56cc32aede |
||
|
|
df77c462e8 |
||
|
|
a45adbbb2e |
||
|
|
be201a2d79 |
||
|
|
30663f7d4f |
||
|
|
8e306b1f61 |
||
|
|
4ae97ab59e |
||
|
|
803b2da8ee | ||
|
|
74e96a2bb7 | ||
|
|
497dfe2586 |
||
|
|
b70ba3a49e | ||
|
|
d5a2e1e813 | ||
|
|
b4d2c3e4af | ||
|
|
22ce85e5d5 | ||
|
|
aea60c6e99 | ||
|
|
b78c963931 |
57 changed files with 2517 additions and 212 deletions
11
.github/workflows/pipeline.yml
vendored
11
.github/workflows/pipeline.yml
vendored
|
|
@ -519,13 +519,18 @@ 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
|
name: Push to Docker Hub (${{ matrix.repository }})
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -541,7 +546,7 @@ jobs:
|
||||||
id: docker
|
id: docker
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
|
hub_repository: ${{ matrix.repository }}
|
||||||
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
|
|
@ -558,7 +563,7 @@ jobs:
|
||||||
|
|
||||||
- name: Inspect image in Docker Hub
|
- name: Inspect image in Docker Hub
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
|
docker buildx imagetools inspect ${{ matrix.repository }}:${{ steps.docker.outputs.version }}
|
||||||
|
|
||||||
cleanup-digests:
|
cleanup-digests:
|
||||||
name: Cleanup digest artifacts
|
name: Cleanup digest artifacts
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -18,7 +18,7 @@ endif
|
||||||
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386
|
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386
|
||||||
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
||||||
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||||
DOCKER_TAG ?= deluan/navidrome:develop
|
DOCKER_TAG ?= navidrome/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 `deluan/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
|
docker-image: ##@Cross_Compilation Build Docker image, tagged as `navidrome/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
|
||||||
@echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true
|
@echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true
|
||||||
@echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true
|
@echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true
|
||||||
@echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true
|
@echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true
|
||||||
|
|
|
||||||
17
README.md
17
README.md
|
|
@ -5,7 +5,7 @@
|
||||||
[](https://github.com/navidrome/navidrome/releases)
|
[](https://github.com/navidrome/navidrome/releases)
|
||||||
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
||||||
[](https://github.com/navidrome/navidrome/releases/latest)
|
[](https://github.com/navidrome/navidrome/releases/latest)
|
||||||
[](https://hub.docker.com/r/deluan/navidrome)
|
[](https://hub.docker.com/r/navidrome/navidrome)
|
||||||
[](https://discord.gg/xh7j7yF)
|
[](https://discord.gg/xh7j7yF)
|
||||||
[](https://www.reddit.com/r/navidrome/)
|
[](https://www.reddit.com/r/navidrome/)
|
||||||
[](CODE_OF_CONDUCT.md)
|
[](CODE_OF_CONDUCT.md)
|
||||||
|
|
@ -35,11 +35,18 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
|
||||||
|
|
||||||
## Cloud Hosting
|
## Cloud Hosting
|
||||||
|
|
||||||
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
|
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.
|
||||||
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
|
|
||||||
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
|
<a href="https://www.pikapods.com/pods?run=navidrome"><img src="resources/hosting/pikapods.svg" alt="Run on PikaPods" width="200" height="40"></a>
|
||||||
|
<br>
|
||||||
|
<a href="https://zenith.hosting/host/navidrome?ref=navidrome"><img src="resources/hosting/zenith.svg" alt="Deploy with Zenith" width="200" height="40"></a>
|
||||||
|
<br>
|
||||||
|
<a href="https://store.elfhosted.com/product/navidrome/"><img src="resources/hosting/elfhosted.svg" alt="Deploy on ElfHosted" width="200" height="40"></a>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[](https://www.pikapods.com/pods?run=navidrome)
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
backupCount int
|
backupCount int
|
||||||
backupDir string
|
backupDir string
|
||||||
force bool
|
force bool
|
||||||
restorePath string
|
integrityCheck bool
|
||||||
|
restorePath string
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -31,6 +32,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +138,7 @@ func runRestore(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err := db.Restore(ctx, restorePath)
|
err := db.Restore(ctx, restorePath, integrityCheck)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error restoring database", "backupFile", restorePath, err)
|
log.Fatal("Error restoring database", "backupFile", restorePath, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,17 @@ 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 {
|
||||||
|
|
|
||||||
137
db/backup.go
137
db/backup.go
|
|
@ -33,59 +33,41 @@ func backupPath(t time.Time) string {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
|
func openBackupFile(path string) (*sql.DB, error) {
|
||||||
// heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/
|
Db() // Registers Driver, which the CLI commands haven't done at this point.
|
||||||
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 fmt.Errorf("opening backup database in '%s': %w", path, err)
|
return nil, fmt.Errorf("opening backup database in '%s': %w", path, err)
|
||||||
}
|
}
|
||||||
defer backupDb.Close()
|
return backupDb, nil
|
||||||
|
}
|
||||||
|
|
||||||
backupConn, err := backupDb.Conn(ctx)
|
func copyDatabase(ctx context.Context, dest, source *sql.DB) error {
|
||||||
|
// 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 backup connection: %w", err)
|
return fmt.Errorf("getting destination connection: %w", err)
|
||||||
}
|
}
|
||||||
defer backupConn.Close()
|
defer destConn.Close()
|
||||||
|
|
||||||
err = existingConn.Raw(func(existing any) error {
|
sourceConn, err := source.Conn(ctx)
|
||||||
return backupConn.Raw(func(backup any) error {
|
if err != nil {
|
||||||
var sourceOk, destOk bool
|
return fmt.Errorf("getting source connection: %w", err)
|
||||||
var sourceConn, destConn *sqlite3.SQLiteConn
|
}
|
||||||
|
defer sourceConn.Close()
|
||||||
|
|
||||||
if isBackup {
|
return destConn.Raw(func(rawDest any) error {
|
||||||
sourceConn, sourceOk = existing.(*sqlite3.SQLiteConn)
|
return sourceConn.Raw(func(rawSource any) error {
|
||||||
destConn, destOk = backup.(*sqlite3.SQLiteConn)
|
sqliteDest, destOk := rawDest.(*sqlite3.SQLiteConn)
|
||||||
} else {
|
|
||||||
sourceConn, sourceOk = backup.(*sqlite3.SQLiteConn)
|
|
||||||
destConn, destOk = existing.(*sqlite3.SQLiteConn)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !sourceOk {
|
|
||||||
return fmt.Errorf("error trying to convert source to sqlite connection")
|
|
||||||
}
|
|
||||||
if !destOk {
|
if !destOk {
|
||||||
return fmt.Errorf("error trying to convert destination to sqlite connection")
|
return fmt.Errorf("error trying to convert destination to sqlite connection")
|
||||||
}
|
}
|
||||||
|
sqliteSource, sourceOk := rawSource.(*sqlite3.SQLiteConn)
|
||||||
|
if !sourceOk {
|
||||||
|
return fmt.Errorf("error trying to convert source to sqlite connection")
|
||||||
|
}
|
||||||
|
|
||||||
backupOp, err := destConn.Backup("main", sourceConn, "main")
|
backupOp, err := sqliteDest.Backup("main", sqliteSource, "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)
|
||||||
}
|
}
|
||||||
|
|
@ -109,24 +91,89 @@ func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
// checkRestoreSource runs sanity checks on a file about to be restored over the live
|
||||||
|
// 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)
|
||||||
err := backupOrRestore(ctx, true, destPath)
|
backupDb, err := openBackupFile(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) error {
|
func Restore(ctx context.Context, path string, integrityCheck bool) error {
|
||||||
log.Debug(ctx, "Restoring backup", "path", path)
|
log.Debug(ctx, "Restoring backup", "path", path)
|
||||||
return backupOrRestore(ctx, false, path)
|
|
||||||
|
// The driver splits the DSN at '?', so such a path would open a different file.
|
||||||
|
if strings.ContainsRune(path, '?') {
|
||||||
|
return fmt.Errorf("backup path cannot contain '?': %s", path)
|
||||||
|
}
|
||||||
|
// The driver opens with SQLITE_OPEN_CREATE, so without this check a typo in the
|
||||||
|
// path would create an empty database and "restore" it over the live one.
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return fmt.Errorf("backup file not available: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backupDb, err := openBackupFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer backupDb.Close()
|
||||||
|
|
||||||
|
if err := checkRestoreSource(ctx, backupDb, path, integrityCheck); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return copyDatabase(ctx, Db(), backupDb)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Prune(ctx context.Context) (int, error) {
|
func Prune(ctx context.Context) (int, error) {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,22 @@ 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
|
||||||
|
|
@ -158,7 +174,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)
|
err = Restore(ctx, path, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(IsSchemaEmpty(ctx, Db())).To(BeFalse())
|
Expect(IsSchemaEmpty(ctx, Db())).To(BeFalse())
|
||||||
})
|
})
|
||||||
|
|
@ -176,6 +192,7 @@ 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)
|
||||||
|
|
@ -209,7 +226,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)).To(Succeed())
|
Expect(Restore(ctx, path, false)).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]()
|
||||||
|
|
@ -218,23 +235,91 @@ var _ = Describe("database backups", func() {
|
||||||
Expect(userName).To(Equal("drilladmin"))
|
Expect(userName).To(Equal("drilladmin"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("fails to restore from a backup file that does not exist, leaving the database intact", func() {
|
expectRestoreRefused := func(path, expectedErr string) {
|
||||||
|
GinkgoHelper()
|
||||||
By("seeding user data in the current database")
|
By("seeding user data in the current database")
|
||||||
_, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at)
|
_, err := Db().ExecContext(ctx, `INSERT OR REPLACE 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 a restore from a nonexistent file")
|
By("attempting the restore")
|
||||||
missingPath := filepath.Join(tempFolder, "does_not_exist.db")
|
Expect(Restore(ctx, path, false)).To(MatchError(ContainSubstring(expectedErr)))
|
||||||
err = Restore(ctx, missingPath)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
|
|
||||||
By("verifying the database was not wiped")
|
By("verifying the database was not wiped")
|
||||||
var userName string
|
var userName string
|
||||||
Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-2'").Scan(&userName)).To(Succeed())
|
Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-2'").Scan(&userName)).To(Succeed())
|
||||||
Expect(userName).To(Equal("keepme"))
|
Expect(userName).To(Equal("keepme"))
|
||||||
|
}
|
||||||
|
|
||||||
|
It("fails to restore from a backup file that does not exist, leaving the database intact", func() {
|
||||||
|
missingPath := filepath.Join(tempFolder, "does_not_exist.db")
|
||||||
|
expectRestoreRefused(missingPath, "backup file not available")
|
||||||
_, statErr := os.Stat(missingPath)
|
_, statErr := os.Stat(missingPath)
|
||||||
Expect(statErr).To(MatchError(os.ErrNotExist))
|
Expect(statErr).To(MatchError(os.ErrNotExist))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
DescribeTable("fails to restore from a file that is not a Navidrome database, leaving the database intact",
|
||||||
|
func(createFile func(path string), expectedErr string) {
|
||||||
|
invalidPath := filepath.Join(GinkgoT().TempDir(), "invalid_backup.db")
|
||||||
|
createFile(invalidPath)
|
||||||
|
expectRestoreRefused(invalidPath, expectedErr)
|
||||||
|
},
|
||||||
|
Entry("empty file", func(path string) {
|
||||||
|
Expect(os.WriteFile(path, nil, 0600)).To(Succeed())
|
||||||
|
}, "is not a Navidrome database"),
|
||||||
|
Entry("file that is not a SQLite database", func(path string) {
|
||||||
|
Expect(os.WriteFile(path, []byte("this is not a database, just some text"), 0600)).To(Succeed())
|
||||||
|
}, "reading backup file"),
|
||||||
|
Entry("SQLite database without the Navidrome schema",
|
||||||
|
sqliteFile("CREATE TABLE something_else(x)"), "is not a Navidrome database"),
|
||||||
|
Entry("another project's goose database",
|
||||||
|
sqliteFile(gooseTableDDL+"; INSERT INTO goose_db_version VALUES (1, 1)"), "is not a Navidrome database"),
|
||||||
|
Entry("goose database with only one of the Navidrome tables",
|
||||||
|
sqliteFile(gooseTableDDL+"; INSERT INTO goose_db_version VALUES (1, 1); CREATE TABLE media_file(id)"), "is not a Navidrome database"),
|
||||||
|
Entry("SQLite database with an empty goose_db_version table",
|
||||||
|
sqliteFile(gooseTableDDL+"; "+navidromeTablesDDL), "has no applied migrations"),
|
||||||
|
Entry("SQLite database with only goose's initial version row",
|
||||||
|
sqliteFile(gooseTableDDL+"; "+navidromeTablesDDL+"; INSERT INTO goose_db_version VALUES (0, 1)"), "has no applied migrations"),
|
||||||
|
Entry("SQLite database whose migrations were all rolled back",
|
||||||
|
sqliteFile(gooseTableDDL+"; "+navidromeTablesDDL+"; INSERT INTO goose_db_version VALUES (1, 0)"), "has no applied migrations"),
|
||||||
|
Entry("backup with garbage over its last pages", func(path string) {
|
||||||
|
// Filler rows go to the end of the file, so the damage below stays clear of
|
||||||
|
// the schema and of goose_db_version, which still read fine.
|
||||||
|
_, err := Db().ExecContext(ctx, "CREATE TABLE _restore_filler AS SELECT zeroblob(1000000) AS x")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
backupFile, err := Backup(ctx)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
data, err := os.ReadFile(backupFile)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
for i := len(data) * 4 / 5; i < len(data); i++ {
|
||||||
|
data[i] = 0xFF
|
||||||
|
}
|
||||||
|
Expect(os.WriteFile(path, data, 0600)).To(Succeed())
|
||||||
|
}, "is damaged"),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("restores a good backup with the integrity check", func() {
|
||||||
|
path, err := Backup(ctx)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(Restore(ctx, path, true)).To(Succeed())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("refuses an index that doesn't match its table only with the integrity check", func() {
|
||||||
|
path, err := Backup(ctx)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
writeMismatchedIndex(ctx, path, 1)
|
||||||
|
restored := func() int {
|
||||||
|
var count int
|
||||||
|
Expect(Db().QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE name = 't'").Scan(&count)).To(Succeed())
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
Expect(Restore(ctx, path, true)).To(MatchError(ContainSubstring("is damaged")))
|
||||||
|
Expect(restored()).To(BeZero())
|
||||||
|
|
||||||
|
Expect(Restore(ctx, path, false)).To(Succeed())
|
||||||
|
Expect(restored()).To(Equal(1))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -38,18 +38,13 @@ func newDB(ctx context.Context, upTo int64) *sql.DB {
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
// openMismatchedIndexDB builds a database whose index is declared over a different
|
// writeMismatchedIndex adds an index declared over a different column than the one it
|
||||||
// column than the one it was populated from, so integrity_check reports one issue per row.
|
// was populated from, so integrity_check reports one issue per row and quick_check none.
|
||||||
func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB {
|
func writeMismatchedIndex(ctx context.Context, path string, rows int) {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
path := filepath.Join(GinkgoT().TempDir(), "mismatched.db")
|
d, err := sql.Open(db.Dialect, path)
|
||||||
open := func() *sql.DB {
|
Expect(err).ToNot(HaveOccurred())
|
||||||
d, err := sql.Open(db.Dialect, path)
|
d.SetMaxOpenConns(1)
|
||||||
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)
|
||||||
|
|
@ -62,8 +57,16 @@ func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB {
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
d = open()
|
func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB {
|
||||||
|
GinkgoHelper()
|
||||||
|
path := filepath.Join(GinkgoT().TempDir(), "mismatched.db")
|
||||||
|
writeMismatchedIndex(ctx, path, rows)
|
||||||
|
|
||||||
|
d, err := sql.Open(db.Dialect, path)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
d.SetMaxOpenConns(1)
|
||||||
DeferCleanup(func() { _ = d.Close() })
|
DeferCleanup(func() { _ = d.Close() })
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ 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"
|
||||||
|
|
@ -42,6 +44,7 @@ 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()
|
||||||
|
|
@ -135,6 +138,27 @@ func (md Metadata) mapGain(rg, r128 model.TagName) *float64 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mapMovementNumbers splits ID3 MVIN "n/N" values, like tuple() does for track and disc numbers.
|
||||||
|
// Totals are only filled when every value has one, so the parallel movement tags stay aligned.
|
||||||
|
func (md Metadata) mapMovementNumbers(tags model.Tags) {
|
||||||
|
values := md.Strings(model.TagMovementNumber)
|
||||||
|
if !slices.ContainsFunc(values, func(v string) bool { return strings.Contains(v, "/") }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
numbers := make([]string, len(values))
|
||||||
|
totals := make([]string, len(values))
|
||||||
|
allHaveTotals := true
|
||||||
|
for i, v := range values {
|
||||||
|
var found bool
|
||||||
|
numbers[i], totals[i], found = strings.Cut(v, "/")
|
||||||
|
allHaveTotals = allHaveTotals && found
|
||||||
|
}
|
||||||
|
tags[model.TagMovementNumber] = numbers
|
||||||
|
if allHaveTotals && len(md.Strings(model.TagMovementTotal)) == 0 {
|
||||||
|
tags[model.TagMovementTotal] = totals
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (md Metadata) mapLyrics() string {
|
func (md Metadata) mapLyrics() string {
|
||||||
rawLyrics := md.Pairs(model.TagLyrics)
|
rawLyrics := md.Pairs(model.TagLyrics)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,27 @@ var _ = Describe("ToMediaFile", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("Movements", func() {
|
||||||
|
It("splits an ID3 MVIN n/N value into movement number and total", func() {
|
||||||
|
mf = toMediaFile(model.RawTags{"movementname": {"Allegro"}, "movementnumber": {"1/4"}})
|
||||||
|
Expect(mf.Tags).To(HaveKeyWithValue(model.TagMovementNumber, []string{"1"}))
|
||||||
|
Expect(mf.Tags).To(HaveKeyWithValue(model.TagMovementTotal, []string{"4"}))
|
||||||
|
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "Allegro", Number: 1, Count: 4}}))
|
||||||
|
})
|
||||||
|
It("maps MP4 movement number and count", func() {
|
||||||
|
mf = toMediaFile(model.RawTags{"movementname": {"Andante"}, "movementnumber": {"2"}, "movementcount": {"4"}})
|
||||||
|
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "Andante", Number: 2, Count: 4}}))
|
||||||
|
})
|
||||||
|
It("prefers an explicit movement total over the n/N total", func() {
|
||||||
|
mf = toMediaFile(model.RawTags{"movementname": {"Allegro"}, "movement": {"2/4"}, "movementtotal": {"5"}})
|
||||||
|
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "Allegro", Number: 2, Count: 5}}))
|
||||||
|
})
|
||||||
|
It("does not add a total when only some values have one", func() {
|
||||||
|
mf = toMediaFile(model.RawTags{"movementname": {"I", "II"}, "movement": {"1/4", "2"}})
|
||||||
|
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "I", Number: 1}, {Name: "II", Number: 2}}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("BitDepth", func() {
|
Describe("BitDepth", func() {
|
||||||
It("maps the bit depth when present", func() {
|
It("maps the bit depth when present", func() {
|
||||||
props.AudioProperties = metadata.AudioProperties{BitDepth: 24}
|
props.AudioProperties = metadata.AudioProperties{BitDepth: 24}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,13 @@ 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{
|
||||||
|
|
|
||||||
|
|
@ -352,6 +352,15 @@ func GetDBXBuilder() *dbx.DB {
|
||||||
return dbx.NewFromDB(db.Db(), db.Dialect)
|
return dbx.NewFromDB(db.Db(), db.Dialect)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storedRows reads playlist_tracks without joining media_file, so it also shows rows pointing to no track.
|
||||||
|
func storedRows(playlistID string) []string {
|
||||||
|
GinkgoHelper()
|
||||||
|
var rows []string
|
||||||
|
Expect(GetDBXBuilder().NewQuery("SELECT id || ':' || media_file_id FROM playlist_tracks WHERE playlist_id = {:id} ORDER BY id").
|
||||||
|
Bind(dbx.Params{"id": playlistID}).Column(&rows)).To(Succeed())
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
// collectCursor takes the cursor's underlying func type so the named cursor types
|
// collectCursor takes the cursor's underlying func type so the named cursor types
|
||||||
// (model.AlbumCursor, ...) infer T.
|
// (model.AlbumCursor, ...) infer T.
|
||||||
func collectCursor[T any](cursor func(func(T, error) bool), err error) []T {
|
func collectCursor[T any](cursor func(func(T, error) bool), err error) []T {
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,7 @@ func (r *playlistRepository) updatePlaylist(ctx context.Context, playlistId stri
|
||||||
}
|
}
|
||||||
|
|
||||||
// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns
|
// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns
|
||||||
// the library check: every caller, including a full replace through Put, goes through it.
|
// the library and existence 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,12 +305,9 @@ 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, preserving order and duplicates. Chunked
|
// keepAccessible drops ids the caller cannot read or that match no track, preserving order and duplicates.
|
||||||
// because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER.
|
// Chunked because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER.
|
||||||
func (r *playlistRepository) keepAccessible(ctx context.Context, mediaFileIds []string) ([]string, error) {
|
func (r *playlistRepository) keepAccessible(ctx context.Context, mediaFileIds []string) ([]string, error) {
|
||||||
if visible, err := r.visibleLibraryIDs(ctx); err == nil && r.userSeesAllLibraries(ctx, visible) {
|
|
||||||
return mediaFileIds, nil
|
|
||||||
}
|
|
||||||
accessible := make(map[string]struct{}, len(mediaFileIds))
|
accessible := make(map[string]struct{}, len(mediaFileIds))
|
||||||
for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) {
|
for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) {
|
||||||
sq := r.applyLibraryFilter(ctx, Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file")
|
sq := r.applyLibraryFilter(ctx, Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file")
|
||||||
|
|
|
||||||
|
|
@ -469,6 +469,26 @@ var _ = Describe("PlaylistRepository", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("removeOrphans", func() {
|
||||||
|
It("deletes rows pointing to no track and renumbers the rest", func() {
|
||||||
|
pls := model.Playlist{Name: "Orphans", OwnerID: "userid"}
|
||||||
|
Expect(repo.Put(ctx, &pls)).To(Succeed())
|
||||||
|
DeferCleanup(func() { Expect(repo.Delete(ctx, pls.ID)).To(Succeed()) })
|
||||||
|
// Rows as an older version stored them, with an unknown id between real tracks
|
||||||
|
_, err := GetDBXBuilder().NewQuery(`INSERT INTO playlist_tracks (playlist_id, media_file_id, id)
|
||||||
|
VALUES ({:id}, '1001', 1), ({:id}, 'no-such-track', 2), ({:id}, '1002', 3)`).
|
||||||
|
Bind(dbx.Params{"id": pls.ID}).Execute()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
Expect(repo.(*playlistRepository).removeOrphans(ctx)).To(Succeed())
|
||||||
|
|
||||||
|
Expect(storedRows(pls.ID)).To(Equal([]string{"1:1001", "2:1002"}))
|
||||||
|
got, err := repo.Get(ctx, pls.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(got.SongCount).To(Equal(2))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Exists is ctx-sensitive through userFilter, so callers that only want "does it still exist"
|
// Exists is ctx-sensitive through userFilter, so callers that only want "does it still exist"
|
||||||
// -- the public image route serving a share -- must elevate, or a private playlist looks gone.
|
// -- the public image route serving a share -- must elevate, or a private playlist looks gone.
|
||||||
Describe("Exists visibility", func() {
|
Describe("Exists visibility", func() {
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ func (r *playlistTrackRepository) Insert(ctx context.Context, mediaFileIds []str
|
||||||
if err != nil || inserted == n {
|
if err != nil || inserted == n {
|
||||||
return inserted, err
|
return inserted, err
|
||||||
}
|
}
|
||||||
// The shift above reserved a slot per requested id, so ids dropped by the library filter
|
// The shift above reserved a slot per requested id, so ids dropped by keepAccessible
|
||||||
// leave a hole. Close it.
|
// leave a hole. Close it.
|
||||||
return inserted, r.playlistRepo.renumber(ctx, r.playlistId)
|
return inserted, r.playlistRepo.renumber(ctx, r.playlistId)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,12 +83,14 @@ 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))
|
||||||
|
|
@ -123,6 +125,40 @@ 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() {
|
||||||
|
|
|
||||||
77
resources/hosting/elfhosted.svg
Normal file
77
resources/hosting/elfhosted.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 35 KiB |
25
resources/hosting/pikapods.svg
Normal file
25
resources/hosting/pikapods.svg
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 6.3 KiB |
16
resources/hosting/zenith.svg
Normal file
16
resources/hosting/zenith.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -93,7 +93,8 @@
|
||||||
"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",
|
||||||
|
|
@ -155,11 +156,13 @@
|
||||||
"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",
|
||||||
|
|
@ -167,7 +170,8 @@
|
||||||
"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",
|
||||||
|
|
@ -196,6 +200,9 @@
|
||||||
"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": {
|
||||||
|
|
@ -210,7 +217,8 @@
|
||||||
"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:",
|
||||||
|
|
@ -392,6 +400,7 @@
|
||||||
"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.",
|
||||||
|
|
@ -565,6 +574,7 @@
|
||||||
"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.",
|
||||||
|
|
@ -635,6 +645,7 @@
|
||||||
},
|
},
|
||||||
"albumList": "Albumak",
|
"albumList": "Albumak",
|
||||||
"playlists": "Zerrendak",
|
"playlists": "Zerrendak",
|
||||||
|
"onlyFavourites": "Erakutsi gogokoak bakarrik",
|
||||||
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
|
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
|
||||||
"about": "Honi buruz"
|
"about": "Honi buruz"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -660,6 +660,11 @@
|
||||||
"options": {
|
"options": {
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
"language": "Língua",
|
"language": "Língua",
|
||||||
|
"dateFormat": "Formato de data",
|
||||||
|
"dateFormats": {
|
||||||
|
"language": "Igual à língua",
|
||||||
|
"browser": "Igual ao navegador"
|
||||||
|
},
|
||||||
"defaultView": "Tela inicial",
|
"defaultView": "Tela inicial",
|
||||||
"desktop_notifications": "Notificações",
|
"desktop_notifications": "Notificações",
|
||||||
"lastfmScrobbling": "Enviar scrobbles para Last.fm",
|
"lastfmScrobbling": "Enviar scrobbles para Last.fm",
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ additional:
|
||||||
aliases: [ grp1, grouping, ©grp, wm/contentgroupdescription ]
|
aliases: [ grp1, grouping, ©grp, wm/contentgroupdescription ]
|
||||||
album: true
|
album: true
|
||||||
key:
|
key:
|
||||||
aliases: [ tkey, key, ----:com.apple.itunes:initialkey, wm/initialkey ]
|
aliases: [ tkey, key, initialkey, ----: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, mvc ]
|
aliases: [ movementtotal, movementcount, mvc ]
|
||||||
movement:
|
movement:
|
||||||
aliases: [ mvin, movement, mvi ]
|
aliases: [ mvin, movement, movementnumber, mvi ]
|
||||||
recordlabel:
|
recordlabel:
|
||||||
aliases: [ tpub, label, publisher, ----:com.apple.itunes:label, wm/publisher, organization ]
|
aliases: [ tpub, label, publisher, ----:com.apple.itunes:label, wm/publisher, organization ]
|
||||||
album: true
|
album: true
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,13 @@ 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)
|
||||||
|
|
@ -225,6 +232,18 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool,
|
||||||
return fileInfo.IsDir(), nil
|
return fileInfo.IsDir(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isSymlinkedPath returns true if folderPath, or any of its parent folders, is a symbolic link.
|
||||||
|
// It needs fsys to implement fs.ReadLinkFS, otherwise links are followed and never detected.
|
||||||
|
func isSymlinkedPath(fsys fs.FS, folderPath string) bool {
|
||||||
|
for p := path.Clean(folderPath); p != "." && p != "/"; p = path.Dir(p) {
|
||||||
|
info, err := fs.Lstat(fsys, p)
|
||||||
|
if err == nil && info.Mode()&fs.ModeSymlink != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const maxSymlinkHops = 40
|
const maxSymlinkHops = 40
|
||||||
|
|
||||||
// resolveEntryName returns the name to classify the entry by, and whether to
|
// resolveEntryName returns the name to classify the entry by, and whether to
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ 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"
|
||||||
|
|
@ -260,6 +262,44 @@ 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"))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -433,8 +473,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.
|
||||||
// It wraps os.DirFS behind the fs.FS interface, so fs.ReadLink-based
|
// fs.ReadLink-based resolution can't follow targets outside the library
|
||||||
// resolution is not available and full OS-level resolution is required.
|
// root, so 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
|
||||||
|
|
@ -460,12 +500,7 @@ 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())
|
||||||
|
|
||||||
u, err := storage.LocalPathToURL(libRoot)
|
musicFS = newLocalMusicFS(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 {
|
||||||
|
|
@ -700,6 +735,17 @@ func getDirEntry(baseDir, name string) os.DirEntry {
|
||||||
panic(fmt.Sprintf("Could not find %s in %s", name, baseDir))
|
panic(fmt.Sprintf("Could not find %s in %s", name, baseDir))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newLocalMusicFS returns the production local storage MusicFS rooted at libRoot
|
||||||
|
func newLocalMusicFS(libRoot string) storage.MusicFS {
|
||||||
|
u, err := storage.LocalPathToURL(libRoot)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
s, err := storage.For(u.String())
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
musicFS, err := s.FS()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
return musicFS
|
||||||
|
}
|
||||||
|
|
||||||
// mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks
|
// mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks
|
||||||
type mockMusicFS struct {
|
type mockMusicFS struct {
|
||||||
storage.MusicFS
|
storage.MusicFS
|
||||||
|
|
|
||||||
|
|
@ -158,8 +158,8 @@ and `MusicArtist` queries accept it too, matching artists credited on an album o
|
||||||
`Years`; `StudioIds` (record labels, as listed by `GET Studios`); `SearchTerm`;
|
`Years`; `StudioIds` (record labels, as listed by `GET Studios`); `SearchTerm`;
|
||||||
`Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone
|
`Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone
|
||||||
`isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as
|
`isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as
|
||||||
in Jellyfin; `Likes`, `Dislikes`, `IsFolder`, `IsNotFolder` and `IsResumable` have no Navidrome
|
in Jellyfin; `Likes` and `Dislikes` are not supported, and `IsFolder`, `IsNotFolder` and
|
||||||
equivalent and are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties;
|
`IsResumable` have no Navidrome equivalent, so all five 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 Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET 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 UserItems/{itemId}/Rating`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET`/`POST UserItems/{itemId}/UserData`, `GET`/`POST 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,12 +402,20 @@ 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`.
|
||||||
- **Rating uses the `rating` param.** `POST Users/{userId}/Items/{itemId}/Rating` reads a `rating`
|
- **Ratings are stored as 0-5 stars.** Jellyfin's 0-10 `Rating` is rounded to the nearest star
|
||||||
(0-10) and stores it as 0-5 stars. Jellyfin's `likes` boolean is not read, so a client that sends
|
(any nonzero value keeps at least one), so it reads back as an even number. `likes=true` stores
|
||||||
only `likes` clears the rating.
|
5 stars and `likes=false` 1 star, like Jellyfin's 10 and 1. A rating of 0 clears the rating.
|
||||||
- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed), `Search/Hints` and the non-legacy
|
- **`POST .../Rating` needs a value.** Jellyfin clears the rating when `likes` is missing; Navidrome
|
||||||
`UserItems/{itemId}/Rating` are not implemented. Play counts only change through playback
|
answers `400` and keeps it, as it does for a malformed or out-of-range value. Use
|
||||||
reporting.
|
`DELETE .../Rating` to clear. A numeric `rating` param (0-10) is also accepted when `likes` is
|
||||||
|
absent.
|
||||||
|
- **`POST .../UserData` writes only `Rating`, `Likes` and `IsFavorite`.** `Played`, `PlayCount`,
|
||||||
|
`LastPlayedDate` and `PlaybackPositionTicks` are ignored. A body that is not a single JSON
|
||||||
|
object, or a `Rating` outside 0-10, is a `400` and nothing is written.
|
||||||
|
- **Annotation writes can't target another user.** Rating and UserData writes whose `userId` names
|
||||||
|
a different user get `403`, even for admins; Jellyfin lets admins edit other users' data.
|
||||||
|
- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed) and `Search/Hints` are not
|
||||||
|
implemented. Play counts only change through playback reporting.
|
||||||
- **Some `Fields` are never emitted.** `ProviderIds`, `People`, `Etag` and `DateLastMediaAdded` are
|
- **Some `Fields` are never emitted.** `ProviderIds`, `People`, `Etag` and `DateLastMediaAdded` are
|
||||||
accepted but never returned.
|
accepted but never returned.
|
||||||
- **Blurhashes can be missing.** `ImageBlurHashes` carries the real blurhash that the artwork
|
- **Blurhashes can be missing.** `ImageBlurHashes` carries the real blurhash that the artwork
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,52 @@
|
||||||
package jellyfin
|
package jellyfin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"math"
|
"io"
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// resolveAnnotated finds which annotated repo owns id, returning the resource name used in
|
type annotatedItem struct {
|
||||||
// refreshResource events. Albums and songs 404 when the user can't access their library; artists
|
repo model.AnnotatedRepository
|
||||||
// span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time
|
resource string // name used in refreshResource events
|
||||||
// scoping. PlaylistRepository.Get enforces playlist visibility. When repo is nil the response has
|
ann model.Annotations
|
||||||
// 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 nil, ""
|
return annotatedItem{}, false
|
||||||
}
|
}
|
||||||
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 api.ds.Album(), "album"
|
return annotatedItem{api.ds.Album(), "album", e.Annotations}, true
|
||||||
}
|
}
|
||||||
case *model.Artist:
|
case *model.Artist:
|
||||||
return api.ds.Artist(), "artist"
|
return annotatedItem{api.ds.Artist(), "artist", e.Annotations}, true
|
||||||
case *model.MediaFile:
|
case *model.MediaFile:
|
||||||
if u.HasLibraryAccess(e.LibraryID) {
|
if u.HasLibraryAccess(e.LibraryID) {
|
||||||
return api.ds.MediaFile(), "song"
|
return annotatedItem{api.ds.MediaFile(), "song", e.Annotations}, true
|
||||||
}
|
}
|
||||||
case *model.Playlist:
|
case *model.Playlist:
|
||||||
return api.ds.Playlist(), "playlist"
|
return annotatedItem{api.ds.Playlist(), "playlist", e.Annotations}, true
|
||||||
}
|
}
|
||||||
// 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 nil, ""
|
return annotatedItem{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -65,65 +70,135 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) {
|
||||||
api.ok(w, r, data)
|
api.ok(w, r, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) {
|
// userDataChange holds already-validated writes; a nil field leaves that annotation untouched.
|
||||||
|
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
|
||||||
}
|
}
|
||||||
repo, resource := api.resolveAnnotated(w, r, id)
|
item, ok := api.resolveAnnotated(w, r, id)
|
||||||
if repo == nil {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := repo.SetStar(r.Context(), starred, id); err != nil {
|
ctx := r.Context()
|
||||||
api.internalError(w, r, err)
|
if change.rating != nil {
|
||||||
return
|
if err := item.repo.SetRating(ctx, *change.rating, id); err != nil {
|
||||||
|
api.internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.ann.Rating = *change.rating
|
||||||
}
|
}
|
||||||
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
|
if change.favorite != nil {
|
||||||
encodedID := dto.EncodeID(id)
|
if err := item.repo.SetStar(ctx, *change.favorite, id); err != nil {
|
||||||
api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID})
|
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) markFavorite(w http.ResponseWriter, r *http.Request) { api.setFavorite(w, r, true) }
|
|
||||||
func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
|
func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
|
||||||
api.setFavorite(w, r, false)
|
api.applyUserData(w, r, userDataChange{favorite: new(false)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) {
|
// setRating reads Jellyfin's likes flag, or the numeric rating extension older clients send. Unlike
|
||||||
id, ok := itemIDParam(w, r, "itemId")
|
// Jellyfin, a missing or malformed value is a 400 rather than a clear: clearing is DELETE's job.
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
repo, resource := api.resolveAnnotated(w, r, id)
|
|
||||||
if repo == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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) {
|
func (api *Router) setRating(w http.ResponseWriter, r *http.Request) {
|
||||||
jfRating := req.Params(r).Float64Or("rating", 0)
|
q := r.URL.Query()
|
||||||
jfRating = min(max(jfRating, 0), 10) // clamp: a client sending e.g. Rating=100 must not write an out-of-domain rating
|
var stars int
|
||||||
rating := int(math.Round(jfRating / 2))
|
switch {
|
||||||
if jfRating > 0 {
|
case q.Has("likes"):
|
||||||
rating = max(rating, 1)
|
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
|
||||||
}
|
}
|
||||||
api.setItemRating(w, r, rating)
|
api.applyCallerUserData(w, r, userDataChange{rating: &stars})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) {
|
func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) {
|
||||||
api.setItemRating(w, r, 0)
|
api.applyCallerUserData(w, r, userDataChange{rating: new(0)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateUserItemData writes Rating, Likes and IsFavorite; omitted or null fields are left untouched.
|
||||||
|
// Play state is ignored because it only changes through playback reporting.
|
||||||
|
func (api *Router) updateUserItemData(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, ok := decodeUserDataBody(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
change := userDataChange{favorite: body.IsFavorite}
|
||||||
|
// Jellyfin applies Likes before Rating, so Rating wins when a body carries both.
|
||||||
|
switch {
|
||||||
|
case body.Rating != nil:
|
||||||
|
stars, ok := dto.StarsFromRating(*body.Rating)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Rating must be from 0 to 10", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
change.rating = &stars
|
||||||
|
case body.Likes != nil:
|
||||||
|
change.rating = new(dto.StarsFromLikes(*body.Likes))
|
||||||
|
}
|
||||||
|
api.applyCallerUserData(w, r, change)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxUserDataBody = 64 << 10
|
||||||
|
|
||||||
|
// decodeUserDataBody accepts exactly one JSON object; Decode alone would ignore anything after it.
|
||||||
|
func decodeUserDataBody(w http.ResponseWriter, r *http.Request) (*dto.UpdateUserItemDataDto, bool) {
|
||||||
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxUserDataBody))
|
||||||
|
var body *dto.UpdateUserItemDataDto
|
||||||
|
err := dec.Decode(&body)
|
||||||
|
if err == nil && body != nil {
|
||||||
|
if _, err = dec.Token(); errors.Is(err, io.EOF) {
|
||||||
|
return body, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ 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"
|
||||||
|
|
@ -21,7 +23,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(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
|
return request.WithUser(GinkgoT().Context(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
|
@ -30,6 +32,16 @@ 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)
|
||||||
|
|
@ -268,41 +280,275 @@ 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() {
|
||||||
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
songWith(model.Annotations{})
|
||||||
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
invoke(api.setRating, httptest.NewRecorder(), songReq("POST", "/Users/u1/Items/s1/Rating?Rating=8", ""))
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser())
|
|
||||||
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
||||||
invoke(api.setRating, w, r)
|
|
||||||
Expect(broker.Events).To(HaveLen(1))
|
Expect(broker.Events).To(HaveLen(1))
|
||||||
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`))
|
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Context("Jellyfin likes semantics", func() {
|
||||||
|
It("stores likes=true as 5 stars and reports Rating 10 / Likes true", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=true", ""))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
|
||||||
|
d := parseUserData(w)
|
||||||
|
Expect(*d.Rating).To(Equal(10.0))
|
||||||
|
Expect(*d.Likes).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("stores likes=false as 1 star (Jellyfin's dislike is Rating 1), not a clear", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?Likes=False", ""))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1))
|
||||||
|
d := parseUserData(w)
|
||||||
|
Expect(*d.Rating).To(Equal(2.0))
|
||||||
|
Expect(*d.Likes).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("prefers likes over the numeric rating extension when both are sent", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=false&rating=10", ""))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("still clears on an explicit rating=0 from numeric clients", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?rating=0", ""))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0))
|
||||||
|
Expect(parseUserData(w).Rating).To(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("keeps the favorite and play state and reports them in the response", func() {
|
||||||
|
playDate := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
mfRepo := songWith(model.Annotations{Starred: true, PlayCount: 3, PlayDate: &playDate})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=true", ""))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
song := mfRepo.Data[testID("s1")]
|
||||||
|
Expect(song.Starred).To(BeTrue())
|
||||||
|
Expect(song.PlayCount).To(Equal(int64(3)))
|
||||||
|
Expect(song.PlayDate).To(Equal(&playDate))
|
||||||
|
d := parseUserData(w)
|
||||||
|
Expect(d.IsFavorite).To(BeTrue())
|
||||||
|
Expect(d.PlayCount).To(Equal(3))
|
||||||
|
Expect(d.Played).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
DescribeTable("rejects a missing or malformed value without touching the stored rating",
|
||||||
|
func(query string) {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating"+query, ""))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
Expect(broker.Events).To(BeEmpty())
|
||||||
|
},
|
||||||
|
Entry("no params (Jellyfin would clear)", ""),
|
||||||
|
Entry("unrelated params only", "?userId=x&foo=1"),
|
||||||
|
Entry("empty likes", "?likes="),
|
||||||
|
Entry("non-boolean likes", "?likes=maybe"),
|
||||||
|
Entry("non-boolean likes even with a valid rating", "?likes=maybe&rating=8"),
|
||||||
|
Entry("empty rating", "?rating="),
|
||||||
|
Entry("non-numeric rating", "?rating=abc"),
|
||||||
|
Entry("NaN rating", "?rating=NaN"),
|
||||||
|
Entry("infinite rating", "?rating=Inf"),
|
||||||
|
Entry("rating above 10", "?Rating=100"),
|
||||||
|
Entry("negative rating", "?Rating=-5"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("authorization", func() {
|
||||||
|
DescribeTable("refuses a userId naming another user and leaves the rating",
|
||||||
|
func(method, target string, params ...string) {
|
||||||
|
handler := api.setRating
|
||||||
|
if method == "DELETE" {
|
||||||
|
handler = api.removeRating
|
||||||
|
}
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(handler, w, songReq(method, target, "", params...))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
Expect(broker.Events).To(BeEmpty())
|
||||||
|
},
|
||||||
|
Entry("path userId", "POST", "/Users/u2/Items/s1/Rating?likes=true", "userId", dto.EncodeID(testID("u2"))),
|
||||||
|
Entry("query userId on the /UserItems form", "POST", "/UserItems/s1/Rating?likes=true&userId="+dto.EncodeID(testID("u2"))),
|
||||||
|
Entry("clearing", "DELETE", "/Users/u2/Items/s1/Rating", "userId", dto.EncodeID(testID("u2"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("accepts the caller's own userId", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.setRating, w, songReq("POST", "/Users/u1/Items/s1/Rating?likes=true", "", "userId", dto.EncodeID(testID("u1"))))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("updateUserItemData", func() {
|
||||||
|
post := func(body string, params ...string) *httptest.ResponseRecorder {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.updateUserItemData, w, songReq("POST", "/UserItems/s1/UserData", body, params...))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
It("maps a 0-10 Rating to stars and answers with the full UserData", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{Starred: true, PlayCount: 2})
|
||||||
|
w := post(`{"Rating":8}`)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
d := parseUserData(w)
|
||||||
|
Expect(*d.Rating).To(Equal(8.0))
|
||||||
|
Expect(*d.Likes).To(BeTrue())
|
||||||
|
Expect(d.IsFavorite).To(BeTrue())
|
||||||
|
Expect(d.PlayCount).To(Equal(2))
|
||||||
|
Expect(d.ItemId).To(Equal(dto.EncodeID(testID("s1"))))
|
||||||
|
Expect(broker.Events).To(HaveLen(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
DescribeTable("stores the personal rating",
|
||||||
|
func(body string, before, expected int) {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: before})
|
||||||
|
Expect(post(body).Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(expected))
|
||||||
|
},
|
||||||
|
Entry("Likes true", `{"Likes":true}`, 0, 5),
|
||||||
|
Entry("Likes false (Rating 1)", `{"Likes":false}`, 4, 1),
|
||||||
|
Entry("Rating wins over Likes, as Jellyfin applies Likes first", `{"Likes":true,"Rating":2}`, 0, 1),
|
||||||
|
Entry("fractional Rating rounds to the nearest star", `{"Rating":7.5}`, 0, 4),
|
||||||
|
Entry("small nonzero Rating floors at one star", `{"Rating":0.4}`, 0, 1),
|
||||||
|
Entry("Rating 0 clears", `{"Rating":0}`, 4, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
DescribeTable("leaves the rating untouched when Rating and Likes are omitted or null",
|
||||||
|
func(body string, wantEvents int) {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
w := post(body)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
Expect(*parseUserData(w).Rating).To(Equal(8.0))
|
||||||
|
Expect(broker.Events).To(HaveLen(wantEvents))
|
||||||
|
},
|
||||||
|
Entry("empty object writes nothing", `{}`, 0),
|
||||||
|
Entry("explicit nulls write nothing", `{"Rating":null,"Likes":null,"IsFavorite":null}`, 0),
|
||||||
|
Entry("favorite only", `{"IsFavorite":true}`, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("sets and unsets the favorite without touching the rating", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
Expect(post(`{"IsFavorite":true}`).Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Starred).To(BeTrue())
|
||||||
|
Expect(post(`{"IsFavorite":false}`).Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("ignores play-state fields, which only change through playback reporting", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{PlayCount: 2})
|
||||||
|
w := post(`{"Played":true,"PlayCount":99,"LastPlayedDate":"2026-01-01T00:00:00Z","PlaybackPositionTicks":5}`)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
song := mfRepo.Data[testID("s1")]
|
||||||
|
Expect(song.PlayCount).To(Equal(int64(2)))
|
||||||
|
Expect(song.PlayDate).To(BeNil())
|
||||||
|
Expect(broker.Events).To(BeEmpty())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("leaves state unchanged when a client echoes back the UserData it read", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 3, Starred: true, PlayCount: 1})
|
||||||
|
echo, err := json.Marshal(dto.UserData(mfRepo.Data[testID("s1")].Annotations, testID("s1")))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(post(string(echo)).Code).To(Equal(http.StatusOK))
|
||||||
|
song := mfRepo.Data[testID("s1")]
|
||||||
|
Expect(song.Rating).To(Equal(3))
|
||||||
|
Expect(song.Starred).To(BeTrue())
|
||||||
|
Expect(song.PlayCount).To(Equal(int64(1)))
|
||||||
|
})
|
||||||
|
|
||||||
|
DescribeTable("rejects an invalid body without writing anything",
|
||||||
|
func(body string) {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
Expect(post(body).Code).To(Equal(http.StatusBadRequest))
|
||||||
|
song := mfRepo.Data[testID("s1")]
|
||||||
|
Expect(song.Rating).To(Equal(4))
|
||||||
|
Expect(song.Starred).To(BeFalse())
|
||||||
|
Expect(broker.Events).To(BeEmpty())
|
||||||
|
},
|
||||||
|
Entry("empty body", ""),
|
||||||
|
Entry("malformed JSON", `{"Rating":`),
|
||||||
|
Entry("Rating above 10, even with a valid IsFavorite", `{"IsFavorite":true,"Rating":10.5}`),
|
||||||
|
Entry("negative Rating", `{"IsFavorite":true,"Rating":-1}`),
|
||||||
|
Entry("Rating as a string", `{"Rating":"8"}`),
|
||||||
|
Entry("Likes as a string", `{"Likes":"yes"}`),
|
||||||
|
Entry("IsFavorite as a number", `{"IsFavorite":1}`),
|
||||||
|
Entry("JSON null", `null`),
|
||||||
|
Entry("JSON array", `[]`),
|
||||||
|
Entry("trailing garbage after the object", `{"Rating":8} x`),
|
||||||
|
Entry("a second JSON value after the object", `{"Rating":8}{"Rating":2}`),
|
||||||
|
Entry("a second object after whitespace", "{\"Rating\":8}\n{}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("accepts trailing whitespace after the object", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{})
|
||||||
|
Expect(post("{\"Rating\":8}\r\n\t ").Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects a body larger than the limit", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
big := `{"Rating":8,"Pad":"` + strings.Repeat("x", 70<<10) + `"}`
|
||||||
|
Expect(post(big).Code).To(Equal(http.StatusBadRequest))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("authorization", func() {
|
||||||
|
It("refuses a userId naming another user without writing", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
w := post(`{"Rating":10,"IsFavorite":true}`, "userId", dto.EncodeID(testID("u2")))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("accepts the caller's own userId", func() {
|
||||||
|
songWith(model.Annotations{})
|
||||||
|
Expect(post(`{"Rating":10}`, "userId", dto.EncodeID(testID("u1"))).Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 404 and writes nothing for an album in a library the user can't access", func() {
|
||||||
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
||||||
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("POST", "/UserItems/a1/UserData", strings.NewReader(`{"Rating":10,"IsFavorite":true}`)).WithContext(ctxUser())
|
||||||
|
invoke(api.updateUserItemData, w, withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||||
|
Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(0))
|
||||||
|
Expect(albumRepo.Data[testID("a1")].Starred).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 404 for an unknown item", func() {
|
||||||
|
w := post(`{"Rating":10}`)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
func parseUserData(w *httptest.ResponseRecorder) dto.UserItemDataDto {
|
||||||
|
var d dto.UserItemDataDto
|
||||||
|
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
type fakeEventBroker struct {
|
type fakeEventBroker struct {
|
||||||
http.Handler
|
http.Handler
|
||||||
Events []events.Event
|
Events []events.Event
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,8 @@ 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)
|
||||||
|
|
||||||
|
|
@ -153,6 +155,8 @@ func (api *Router) routes() http.Handler {
|
||||||
// /Users/{userId}/Items is the legacy spelling.
|
// /Users/{userId}/Items is the legacy spelling.
|
||||||
r.Get("/useritems/{itemId}/userdata", api.getUserItemData)
|
r.Get("/useritems/{itemId}/userdata", api.getUserItemData)
|
||||||
r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData)
|
r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData)
|
||||||
|
r.Post("/useritems/{itemId}/userdata", api.updateUserItemData)
|
||||||
|
r.Post("/users/{userId}/items/{itemId}/userdata", api.updateUserItemData)
|
||||||
|
|
||||||
r.Get("/artists/{itemId}/similar", api.getSimilarArtists)
|
r.Get("/artists/{itemId}/similar", api.getSimilarArtists)
|
||||||
r.Get("/items/{itemId}/similar", api.getSimilarItems)
|
r.Get("/items/{itemId}/similar", api.getSimilarItems)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,14 @@ type UserItemDataDto struct {
|
||||||
Key string `json:"Key"`
|
Key string `json:"Key"`
|
||||||
ItemId string `json:"ItemId,omitempty"`
|
ItemId string `json:"ItemId,omitempty"`
|
||||||
LastPlayedDate *string `json:"LastPlayedDate,omitempty"`
|
LastPlayedDate *string `json:"LastPlayedDate,omitempty"`
|
||||||
|
Likes *bool `json:"Likes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserItemDataDto is the POST .../UserData body; other Jellyfin fields are accepted and ignored.
|
||||||
|
type UpdateUserItemDataDto struct {
|
||||||
|
Rating *float64 `json:"Rating"`
|
||||||
|
Likes *bool `json:"Likes"`
|
||||||
|
IsFavorite *bool `json:"IsFavorite"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BaseItemDto struct {
|
type BaseItemDto struct {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package dto
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
|
@ -121,6 +122,30 @@ 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)
|
||||||
|
|
@ -134,6 +159,7 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto {
|
||||||
if a.Rating > 0 {
|
if a.Rating > 0 {
|
||||||
r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10
|
r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10
|
||||||
d.Rating = &r
|
d.Rating = &r
|
||||||
|
d.Likes = new(r >= minLikeRating)
|
||||||
}
|
}
|
||||||
if s := JellyfinDate(a.PlayDate); s != "" {
|
if s := JellyfinDate(a.PlayDate); s != "" {
|
||||||
d.LastPlayedDate = &s
|
d.LastPlayedDate = &s
|
||||||
|
|
|
||||||
|
|
@ -478,6 +478,30 @@ var _ = Describe("mappers", func() {
|
||||||
Expect(item.ImageBlurHashes).To(BeNil())
|
Expect(item.ImageBlurHashes).To(BeNil())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
DescribeTable("UserData derives Likes from the rating like Jellyfin (rating >= 6.5)",
|
||||||
|
func(stars int, expected *bool) {
|
||||||
|
d := UserData(model.Annotations{Rating: stars}, testID("s1"))
|
||||||
|
Expect(d.Likes).To(Equal(expected))
|
||||||
|
},
|
||||||
|
Entry("unrated has no Likes", 0, nil),
|
||||||
|
Entry("1 star (Jellyfin 2) dislikes", 1, new(false)),
|
||||||
|
Entry("3 stars (Jellyfin 6) dislikes", 3, new(false)),
|
||||||
|
Entry("4 stars (Jellyfin 8) likes", 4, new(true)),
|
||||||
|
Entry("5 stars (Jellyfin 10) likes", 5, new(true)),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("emits Rating as a whole number and omits Likes when unrated", func() {
|
||||||
|
rated, err := json.Marshal(UserData(model.Annotations{Rating: 4}, testID("s1")))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(string(rated)).To(ContainSubstring(`"Rating":8,`))
|
||||||
|
Expect(string(rated)).To(ContainSubstring(`"Likes":true`))
|
||||||
|
|
||||||
|
unrated, err := json.Marshal(UserData(model.Annotations{}, testID("s1")))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(string(unrated)).ToNot(ContainSubstring(`"Rating"`))
|
||||||
|
Expect(string(unrated)).ToNot(ContainSubstring(`"Likes"`))
|
||||||
|
})
|
||||||
|
|
||||||
It("emits DateCreated and SortName on playlists, like albums and artists", func() {
|
It("emits DateCreated and SortName on playlists, like albums and artists", func() {
|
||||||
p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)}
|
p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)}
|
||||||
Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z"))
|
Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z"))
|
||||||
|
|
|
||||||
|
|
@ -130,13 +130,78 @@ var _ = Describe("Annotations", func() {
|
||||||
Expect(*itemUserData(id).Rating).To(Equal(float64(8)))
|
Expect(*itemUserData(id).Rating).To(Equal(float64(8)))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("clamps an out-of-range rating to the valid domain", func() {
|
It("rejects an out-of-range rating and keeps the stored one", 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("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", ""), &data)
|
parseInto(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":6}`), &data)
|
||||||
// 100 clamps to 10 (Jellyfin) -> 5 (Navidrome) -> 10 back out.
|
Expect(*data.Rating).To(Equal(float64(6)))
|
||||||
Expect(data.Rating).ToNot(BeNil())
|
Expect(*data.Likes).To(BeFalse())
|
||||||
Expect(*data.Rating).To(Equal(float64(10)))
|
|
||||||
|
parseInto(post("/Users/"+enc(adminUser.ID)+"/Items/"+enc(id)+"/UserData", `{"Likes":true,"IsFavorite":true}`), &data)
|
||||||
|
stored := itemUserData(id)
|
||||||
|
Expect(*stored.Rating).To(Equal(float64(10)))
|
||||||
|
Expect(*stored.Likes).To(BeTrue())
|
||||||
|
Expect(stored.IsFavorite).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("leaves omitted fields and play state untouched", func() {
|
||||||
|
id := songID("Come Together")
|
||||||
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":8,"IsFavorite":true}`).Code).To(Equal(http.StatusOK))
|
||||||
|
before := itemUserData(id)
|
||||||
|
|
||||||
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Played":true,"PlayCount":42,"LastPlayedDate":"2026-01-01T00:00:00Z"}`).Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"IsFavorite":false}`).Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
after := itemUserData(id)
|
||||||
|
Expect(*after.Rating).To(Equal(float64(8)))
|
||||||
|
Expect(after.IsFavorite).To(BeFalse())
|
||||||
|
Expect(after.PlayCount).To(Equal(before.PlayCount))
|
||||||
|
Expect(after.Played).To(Equal(before.Played))
|
||||||
|
Expect(after.LastPlayedDate).To(Equal(before.LastPlayedDate))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects invalid bodies without writing", func() {
|
||||||
|
id := songID("Come Together")
|
||||||
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":11,"IsFavorite":true}`).Code).To(Equal(http.StatusBadRequest))
|
||||||
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":8} {"Rating":2}`).Code).To(Equal(http.StatusBadRequest))
|
||||||
|
stored := itemUserData(id)
|
||||||
|
Expect(stored.Rating).To(BeNil())
|
||||||
|
Expect(stored.IsFavorite).To(BeFalse())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
@ -28,6 +29,36 @@ 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)
|
||||||
|
|
@ -53,6 +84,7 @@ var _ = Describe("Multi-user access control", func() {
|
||||||
adminPl := createPlaylist("Admin Private", nil)
|
adminPl := createPlaylist("Admin Private", nil)
|
||||||
Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound))
|
Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound))
|
||||||
Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound))
|
Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound))
|
||||||
|
Expect(postAs(regularUser, "/UserItems/"+enc(adminPl)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("lets each user manage their own playlist", func() {
|
It("lets each user manage their own playlist", func() {
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,13 @@ import (
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
// withChiURLParam simulates chi's routing having captured a path parameter, since these
|
// withChiURLParam simulates chi's routing having captured path parameters (key, value pairs), since
|
||||||
// tests call handlers directly instead of going through the full router.
|
// these tests call handlers directly instead of going through the full router.
|
||||||
func withChiURLParam(r *http.Request, key, value string) *http.Request {
|
func withChiURLParam(r *http.Request, kv ...string) *http.Request {
|
||||||
rctx := chi.NewRouteContext()
|
rctx := chi.NewRouteContext()
|
||||||
rctx.URLParams.Add(key, value)
|
for i := 0; i+1 < len(kv); i += 2 {
|
||||||
|
rctx.URLParams.Add(kv[i], kv[i+1])
|
||||||
|
}
|
||||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,15 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() {
|
||||||
resp = doReq("setRating", "id", songID)
|
resp = doReq("setRating", "id", songID)
|
||||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("rejects ratings outside the 0-5 range", func() {
|
||||||
|
for _, rating := range []string{"-1", "6"} {
|
||||||
|
resp := doReq("setRating", "id", songID, "rating", rating)
|
||||||
|
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||||
|
Expect(resp.Error).ToNot(BeNil())
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Scrobble", func() {
|
Describe("Scrobble", func() {
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,25 @@ 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
|
||||||
}
|
}
|
||||||
rating, err := p.Int("rating")
|
rating64, err := p.Int64("rating")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Out-of-range values would otherwise land in average_rating, which every user sees.
|
||||||
|
// Check before narrowing to int, so a value like 4294967296 cannot wrap into range on 32-bit builds.
|
||||||
|
if rating64 < 0 || rating64 > maxRating {
|
||||||
|
return nil, newError(responses.ErrorGeneric, "Invalid rating: %d. Must be between 0 and %d", rating64, maxRating)
|
||||||
|
}
|
||||||
|
rating := int(rating64)
|
||||||
|
|
||||||
log.Debug(r, "Setting rating", "rating", rating, "id", id)
|
log.Debug(r, "Setting rating", "rating", rating, "id", id)
|
||||||
err = api.setRating(r.Context(), id, rating)
|
err = api.setRating(r.Context(), id, rating)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package subsonic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -233,6 +234,53 @@ 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,7 +8,6 @@ import {
|
||||||
ArrayField,
|
ArrayField,
|
||||||
BooleanField,
|
BooleanField,
|
||||||
ChipField,
|
ChipField,
|
||||||
DateField,
|
|
||||||
FunctionField,
|
FunctionField,
|
||||||
SingleFieldList,
|
SingleFieldList,
|
||||||
TextField,
|
TextField,
|
||||||
|
|
@ -17,6 +16,7 @@ import {
|
||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import {
|
import {
|
||||||
|
DateField,
|
||||||
ArtistLinkField,
|
ArtistLinkField,
|
||||||
MultiLineTextField,
|
MultiLineTextField,
|
||||||
ParticipantsInfo,
|
ParticipantsInfo,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import {
|
||||||
Datagrid,
|
Datagrid,
|
||||||
DatagridBody,
|
DatagridBody,
|
||||||
DatagridRow,
|
DatagridRow,
|
||||||
DateField,
|
|
||||||
NumberField,
|
NumberField,
|
||||||
TextField,
|
TextField,
|
||||||
FunctionField,
|
FunctionField,
|
||||||
|
|
@ -13,6 +12,7 @@ import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import { useDrag } from 'react-dnd'
|
import { useDrag } from 'react-dnd'
|
||||||
import {
|
import {
|
||||||
|
DateField,
|
||||||
ArtistLinkField,
|
ArtistLinkField,
|
||||||
ArtworkAvatar,
|
ArtworkAvatar,
|
||||||
DurationField,
|
DurationField,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { isDateSet } from '../utils/validations'
|
import { isDateSet } from '../utils/validations'
|
||||||
import { DateField as RADateField } from 'react-admin'
|
import { DateField as RADateField, useRecordContext } from 'react-admin'
|
||||||
import { useDateLocale } from '../i18n/useDateLocale'
|
import { useDateLocale } from '../i18n/useDateLocale'
|
||||||
|
|
||||||
export const DateField = (props) => {
|
export const DateField = (props) => {
|
||||||
const { record, source } = props
|
const record = useRecordContext(props)
|
||||||
const locale = useDateLocale()
|
const locale = useDateLocale()
|
||||||
const value = record?.[source]
|
const value = record?.[props.source]
|
||||||
if (!isDateSet(value)) return null
|
if (!isDateSet(value)) return null
|
||||||
return <RADateField locales={locale} {...props} />
|
return <RADateField locales={locale} {...props} />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
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) => ({
|
||||||
|
|
@ -29,4 +30,13 @@ describe('<DateField>', () => {
|
||||||
)
|
)
|
||||||
expect(container).toBeEmptyDOMElement()
|
expect(container).toBeEmptyDOMElement()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reads the record from the record context', () => {
|
||||||
|
render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<DateField source="updatedAt" />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('17.9.2026')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ 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,
|
||||||
|
|
@ -15,6 +14,7 @@ import {
|
||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
import { humanize, underscore } from 'inflection'
|
import { humanize, underscore } from 'inflection'
|
||||||
import {
|
import {
|
||||||
|
DateField,
|
||||||
ArtistLinkField,
|
ArtistLinkField,
|
||||||
BitrateField,
|
BitrateField,
|
||||||
ParticipantsInfo,
|
ParticipantsInfo,
|
||||||
|
|
|
||||||
|
|
@ -677,6 +677,11 @@
|
||||||
"options": {
|
"options": {
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
|
"dateFormat": "Date Format",
|
||||||
|
"dateFormats": {
|
||||||
|
"language": "Match language",
|
||||||
|
"browser": "Match browser"
|
||||||
|
},
|
||||||
"defaultView": "Default View",
|
"defaultView": "Default View",
|
||||||
"desktop_notifications": "Desktop Notifications",
|
"desktop_notifications": "Desktop Notifications",
|
||||||
"lastfmNotConfigured": "Last.fm API-Key is not configured",
|
"lastfmNotConfigured": "Last.fm API-Key is not configured",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
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 = []) => {
|
||||||
|
|
@ -10,5 +13,10 @@ const resolveDateLocale = (locale, browserLocales = []) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDateLocale = () =>
|
// With the "browser" date format, return undefined so Intl uses the browser's
|
||||||
resolveDateLocale(useLocale(), navigator.languages)
|
// own locale, regardless of the selected language.
|
||||||
|
export const useDateLocale = () => {
|
||||||
|
const locale = useLocale()
|
||||||
|
if (getStoredDateFormat() === 'browser') return undefined
|
||||||
|
return resolveDateLocale(locale, navigator.languages)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ 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) => {
|
||||||
|
|
@ -42,4 +43,10 @@ describe('useDateLocale', () => {
|
||||||
const result = await renderWith(undefined, ['en-GB'])
|
const result = await renderWith(undefined, ['en-GB'])
|
||||||
expect(result.current).toBeUndefined()
|
expect(result.current).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns undefined to use the browser locale when set to browser', async () => {
|
||||||
|
localStorage.setItem('dateFormat', 'browser')
|
||||||
|
const result = await renderWith('en', ['fr-FR'])
|
||||||
|
expect(result.current).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
DateField,
|
||||||
List,
|
List,
|
||||||
Pagination,
|
Pagination,
|
||||||
SizeField,
|
SizeField,
|
||||||
|
|
@ -7,7 +8,6 @@ import {
|
||||||
} from '../common/index'
|
} from '../common/index'
|
||||||
import {
|
import {
|
||||||
Datagrid,
|
Datagrid,
|
||||||
DateField,
|
|
||||||
TextField,
|
TextField,
|
||||||
downloadCSV,
|
downloadCSV,
|
||||||
Filter,
|
Filter,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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'
|
||||||
|
|
@ -24,6 +25,7 @@ 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 />
|
||||||
|
|
|
||||||
22
ui/src/personal/SelectDateFormat.jsx
Normal file
22
ui/src/personal/SelectDateFormat.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { SelectInput, useTranslate } from 'react-admin'
|
||||||
|
import { getStoredDateFormat } from '../i18n/useDateLocale'
|
||||||
|
|
||||||
|
export const SelectDateFormat = (props) => {
|
||||||
|
const translate = useTranslate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectInput
|
||||||
|
{...props}
|
||||||
|
source="dateFormat"
|
||||||
|
label={translate('menu.personal.options.dateFormat')}
|
||||||
|
defaultValue={getStoredDateFormat()}
|
||||||
|
choices={[
|
||||||
|
{ id: 'language', name: 'menu.personal.options.dateFormats.language' },
|
||||||
|
{ id: 'browser', name: 'menu.personal.options.dateFormats.browser' },
|
||||||
|
]}
|
||||||
|
onChange={(event) => {
|
||||||
|
localStorage.setItem('dateFormat', event.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
Datagrid,
|
Datagrid,
|
||||||
DateField,
|
|
||||||
EditButton,
|
EditButton,
|
||||||
Filter,
|
Filter,
|
||||||
NullableBooleanInput,
|
NullableBooleanInput,
|
||||||
|
|
@ -20,6 +19,7 @@ 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,6 +38,18 @@ 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) => {
|
||||||
|
|
@ -134,6 +146,7 @@ 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')
|
||||||
|
|
@ -165,6 +178,7 @@ 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 />}
|
||||||
|
|
@ -173,7 +187,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" />
|
<TextField source="name" className={classes.name} />
|
||||||
{columns}
|
{columns}
|
||||||
<Writable>
|
<Writable>
|
||||||
<EditButton />
|
<EditButton />
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import React, { cloneElement } from 'react'
|
||||||
import {
|
import {
|
||||||
CreateButton,
|
CreateButton,
|
||||||
Datagrid,
|
Datagrid,
|
||||||
DateField,
|
|
||||||
EditButton,
|
EditButton,
|
||||||
Filter,
|
Filter,
|
||||||
sanitizeListRestProps,
|
sanitizeListRestProps,
|
||||||
|
|
@ -15,6 +14,7 @@ import {
|
||||||
useTranslate,
|
useTranslate,
|
||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
import {
|
import {
|
||||||
|
DateField,
|
||||||
List,
|
List,
|
||||||
defaultRowsPerPageOptions,
|
defaultRowsPerPageOptions,
|
||||||
getStoredPerPage,
|
getStoredPerPage,
|
||||||
|
|
|
||||||
36
ui/src/themes/cave.css.js
Normal file
36
ui/src/themes/cave.css.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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
|
||||||
135
ui/src/themes/cave.js
Normal file
135
ui/src/themes/cave.js
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import stylesheet from './cave.css.js'
|
||||||
|
|
||||||
|
// Near-black background with a single orange accent, based on the colors of
|
||||||
|
// https://acave.pt (A Cave do Entulho)
|
||||||
|
const background = '#0b0b0d'
|
||||||
|
const surface = '#151518'
|
||||||
|
const text = '#f2f2f0'
|
||||||
|
const textMuted = '#b7b7b7'
|
||||||
|
const accent = '#ff4d1a'
|
||||||
|
const accentDark = '#e84518'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
themeName: 'Cave',
|
||||||
|
palette: {
|
||||||
|
primary: {
|
||||||
|
main: accent,
|
||||||
|
contrastText: background,
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: accent,
|
||||||
|
contrastText: background,
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: background,
|
||||||
|
paper: surface,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: text,
|
||||||
|
secondary: textMuted,
|
||||||
|
},
|
||||||
|
type: 'dark',
|
||||||
|
},
|
||||||
|
overrides: {
|
||||||
|
MuiAppBar: {
|
||||||
|
positionFixed: {
|
||||||
|
backgroundColor: `${background} !important`,
|
||||||
|
boxShadow: 'none',
|
||||||
|
borderBottom: `1px solid ${accent}`,
|
||||||
|
},
|
||||||
|
colorSecondary: {
|
||||||
|
color: text,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiDrawer: {
|
||||||
|
root: {
|
||||||
|
background: background,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiToolbar: {
|
||||||
|
root: {
|
||||||
|
background: 'transparent !important',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiButton: {
|
||||||
|
textPrimary: {
|
||||||
|
color: accent,
|
||||||
|
},
|
||||||
|
containedPrimary: {
|
||||||
|
color: background,
|
||||||
|
backgroundColor: accent,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: accentDark,
|
||||||
|
'@media (hover: none)': {
|
||||||
|
backgroundColor: accent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiListItemIcon: {
|
||||||
|
root: {
|
||||||
|
color: textMuted,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTableBody: {
|
||||||
|
root: {
|
||||||
|
'&>tr:nth-child(odd)': {
|
||||||
|
background: 'rgba(255, 255, 255, 0.025)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTableCell: {
|
||||||
|
root: {
|
||||||
|
borderBottom: '0 none !important',
|
||||||
|
},
|
||||||
|
head: {
|
||||||
|
color: `${textMuted} !important`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiPaper: {
|
||||||
|
elevation1: {
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NDAlbumGridView: {
|
||||||
|
albumName: {
|
||||||
|
color: text,
|
||||||
|
},
|
||||||
|
albumArtistName: {
|
||||||
|
color: textMuted,
|
||||||
|
},
|
||||||
|
albumPlayButton: {
|
||||||
|
color: accent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NDLogin: {
|
||||||
|
systemNameLink: {
|
||||||
|
color: accent,
|
||||||
|
},
|
||||||
|
welcome: {
|
||||||
|
color: text,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
minWidth: 300,
|
||||||
|
backgroundColor: `${background}e6`,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
filter: 'hue-rotate(170deg) saturate(1.2)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NDMobileArtistDetails: {
|
||||||
|
bgContainer: {
|
||||||
|
background: background,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RaPaginationActions: {
|
||||||
|
currentPageButton: {
|
||||||
|
border: `2px solid ${accent}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
theme: 'dark',
|
||||||
|
stylesheet,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -19,12 +19,15 @@ 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
|
||||||
|
|
@ -37,6 +40,7 @@ export default {
|
||||||
CatppuccinLatteTheme,
|
CatppuccinLatteTheme,
|
||||||
CatppuccinMacchiatoTheme,
|
CatppuccinMacchiatoTheme,
|
||||||
CatppuccinMochaTheme,
|
CatppuccinMochaTheme,
|
||||||
|
CaveTheme,
|
||||||
DraculaTheme,
|
DraculaTheme,
|
||||||
ElectricPurpleTheme,
|
ElectricPurpleTheme,
|
||||||
ExtraDarkTheme,
|
ExtraDarkTheme,
|
||||||
|
|
@ -53,6 +57,8 @@ export default {
|
||||||
RosePineDawnTheme,
|
RosePineDawnTheme,
|
||||||
RosePineMoonTheme,
|
RosePineMoonTheme,
|
||||||
RosePineTheme,
|
RosePineTheme,
|
||||||
|
SolarizedDarkTheme,
|
||||||
|
SolarizedLightTheme,
|
||||||
SpotifyTheme,
|
SpotifyTheme,
|
||||||
SquiddiesGlassTheme,
|
SquiddiesGlassTheme,
|
||||||
TokyoNightLightTheme,
|
TokyoNightLightTheme,
|
||||||
|
|
|
||||||
148
ui/src/themes/solarizedDark.css.js
Normal file
148
ui/src/themes/solarizedDark.css.js
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
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
|
||||||
380
ui/src/themes/solarizedDark.js
Normal file
380
ui/src/themes/solarizedDark.js
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
123
ui/src/themes/solarizedLight.css.js
Normal file
123
ui/src/themes/solarizedLight.css.js
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
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
|
||||||
380
ui/src/themes/solarizedLight.js
Normal file
380
ui/src/themes/solarizedLight.js
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
import stylesheet from './solarizedLight.css.js'
|
||||||
|
|
||||||
|
// Solarized palette by Ethan Schoonover: https://ethanschoonover.com/solarized/
|
||||||
|
const background = '#eee8d5' // base2
|
||||||
|
const surface = '#fdf6e3' // base3
|
||||||
|
const currentLine = '#e4ddc6' // base2, slightly darker for hover and headers
|
||||||
|
const foreground = '#586e75' // base01
|
||||||
|
const comment = '#657b83' // base00
|
||||||
|
const blue = '#268bd2'
|
||||||
|
const link = '#1c6599' // blue, darkened to keep text links above 4.5:1
|
||||||
|
const accent = '#5d61a9' // violet, darkened to keep text above 4.5:1
|
||||||
|
const red = '#dc322f'
|
||||||
|
|
||||||
|
// For Album, Playlist play button
|
||||||
|
const musicListActions = {
|
||||||
|
alignItems: 'center',
|
||||||
|
'@global': {
|
||||||
|
'button:first-child:not(:only-child)': {
|
||||||
|
'@media screen and (max-width: 720px)': {
|
||||||
|
transform: 'scale(1.5)',
|
||||||
|
margin: '1rem',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.6) !important',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transform: 'scale(2)',
|
||||||
|
margin: '1.5rem',
|
||||||
|
minWidth: 0,
|
||||||
|
padding: 5,
|
||||||
|
transition: 'transform .3s ease',
|
||||||
|
backgroundColor: `${blue} !important`,
|
||||||
|
color: background,
|
||||||
|
borderRadius: 500,
|
||||||
|
border: 0,
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(2.1)',
|
||||||
|
backgroundColor: `${blue} !important`,
|
||||||
|
border: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'button:only-child': {
|
||||||
|
margin: '1.5rem',
|
||||||
|
},
|
||||||
|
'button:first-child>span:first-child': {
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
'button:first-child>span:first-child>span': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
|
||||||
|
{
|
||||||
|
color: foreground,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
themeName: 'Solarized Light',
|
||||||
|
palette: {
|
||||||
|
primary: {
|
||||||
|
main: blue,
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: accent,
|
||||||
|
contrastText: surface,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
main: red,
|
||||||
|
},
|
||||||
|
type: 'light',
|
||||||
|
background: {
|
||||||
|
default: background,
|
||||||
|
paper: surface,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrides: {
|
||||||
|
MuiPaper: {
|
||||||
|
root: {
|
||||||
|
color: foreground,
|
||||||
|
backgroundColor: surface,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiAppBar: {
|
||||||
|
positionFixed: {
|
||||||
|
backgroundColor: `${surface} !important`,
|
||||||
|
boxShadow:
|
||||||
|
'rgba(0, 43, 54, 0.12) 0px 4px 6px, rgba(0, 43, 54, 0.06) 0px 5px 7px',
|
||||||
|
},
|
||||||
|
colorSecondary: {
|
||||||
|
color: foreground,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiDrawer: {
|
||||||
|
root: {
|
||||||
|
background: background,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiButton: {
|
||||||
|
textPrimary: {
|
||||||
|
color: blue,
|
||||||
|
},
|
||||||
|
textSecondary: {
|
||||||
|
color: foreground,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiIconButton: {
|
||||||
|
root: {
|
||||||
|
color: foreground,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiChip: {
|
||||||
|
root: {
|
||||||
|
backgroundColor: currentLine,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiFormGroup: {
|
||||||
|
root: {
|
||||||
|
color: foreground,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiFormLabel: {
|
||||||
|
root: {
|
||||||
|
color: comment,
|
||||||
|
'&$focused': {
|
||||||
|
color: blue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiFormHelperText: {
|
||||||
|
error: {
|
||||||
|
color: red,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiToolbar: {
|
||||||
|
root: {
|
||||||
|
backgroundColor: `${surface} !important`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiOutlinedInput: {
|
||||||
|
root: {
|
||||||
|
'& $notchedOutline': {
|
||||||
|
borderColor: currentLine,
|
||||||
|
},
|
||||||
|
'&:hover $notchedOutline': {
|
||||||
|
borderColor: comment,
|
||||||
|
},
|
||||||
|
'&$focused $notchedOutline': {
|
||||||
|
borderColor: blue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiFilledInput: {
|
||||||
|
root: {
|
||||||
|
backgroundColor: currentLine,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: comment,
|
||||||
|
},
|
||||||
|
'&$focused': {
|
||||||
|
backgroundColor: currentLine,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTableRow: {
|
||||||
|
root: {
|
||||||
|
transition: 'background-color .3s ease',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: `${currentLine} !important`,
|
||||||
|
},
|
||||||
|
// Cells have an opaque background, so the hover must be applied to them too
|
||||||
|
'&:hover > .MuiTableCell-root': {
|
||||||
|
background: `${currentLine} !important`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTableHead: {
|
||||||
|
root: {
|
||||||
|
color: foreground,
|
||||||
|
background: surface,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTableCell: {
|
||||||
|
root: {
|
||||||
|
color: foreground,
|
||||||
|
background: `${surface} !important`,
|
||||||
|
borderBottom: `1px solid ${currentLine}`,
|
||||||
|
},
|
||||||
|
head: {
|
||||||
|
color: `${foreground} !important`,
|
||||||
|
background: `${currentLine} !important`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
color: `${foreground} !important`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NDAlbumGridView: {
|
||||||
|
albumName: {
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: foreground,
|
||||||
|
},
|
||||||
|
albumSubtitle: {
|
||||||
|
color: comment,
|
||||||
|
},
|
||||||
|
albumContainer: {
|
||||||
|
backgroundColor: surface,
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '.75rem',
|
||||||
|
transition: 'background-color .3s ease',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: currentLine,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
albumPlayButton: {
|
||||||
|
backgroundColor: blue,
|
||||||
|
borderRadius: '50%',
|
||||||
|
boxShadow: '0 8px 8px rgb(0 0 0 / 20%)',
|
||||||
|
padding: '0.35rem',
|
||||||
|
transition: 'padding .3s ease',
|
||||||
|
'&:hover': {
|
||||||
|
background: `${blue} !important`,
|
||||||
|
padding: '0.45rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NDPlaylistDetails: {
|
||||||
|
container: {
|
||||||
|
background: `linear-gradient(${currentLine}, transparent)`,
|
||||||
|
borderRadius: 0,
|
||||||
|
paddingTop: '2.5rem !important',
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontWeight: 700,
|
||||||
|
color: foreground,
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
fontSize: '.875rem',
|
||||||
|
color: comment,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NDAlbumDetails: {
|
||||||
|
root: {
|
||||||
|
background: `linear-gradient(${currentLine}, transparent)`,
|
||||||
|
borderRadius: 0,
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
cardContents: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: '1.5rem',
|
||||||
|
},
|
||||||
|
recordName: {
|
||||||
|
fontWeight: 700,
|
||||||
|
color: foreground,
|
||||||
|
},
|
||||||
|
recordArtist: {
|
||||||
|
fontSize: '.875rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: accent,
|
||||||
|
},
|
||||||
|
recordMeta: {
|
||||||
|
fontSize: '.875rem',
|
||||||
|
color: comment,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NDCollapsibleComment: {
|
||||||
|
commentBlock: {
|
||||||
|
fontSize: '.875rem',
|
||||||
|
color: comment,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NDAlbumShow: {
|
||||||
|
albumActions: musicListActions,
|
||||||
|
},
|
||||||
|
NDPlaylistShow: {
|
||||||
|
playlistActions: musicListActions,
|
||||||
|
},
|
||||||
|
NDAudioPlayer: {
|
||||||
|
audioTitle: {
|
||||||
|
color: foreground,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
},
|
||||||
|
songTitle: {
|
||||||
|
fontWeight: 400,
|
||||||
|
},
|
||||||
|
songInfo: {
|
||||||
|
fontSize: '0.675rem',
|
||||||
|
color: comment,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NDLogin: {
|
||||||
|
systemNameLink: {
|
||||||
|
color: link,
|
||||||
|
},
|
||||||
|
welcome: {
|
||||||
|
color: foreground,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
minWidth: 300,
|
||||||
|
background: surface,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
boxShadow: '3px 3px 5px #93a1a1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NDMobileArtistDetails: {
|
||||||
|
bgContainer: {
|
||||||
|
background: `linear-gradient(to bottom, rgba(238 232 213 / 72%), ${background})!important`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RaLayout: {
|
||||||
|
content: {
|
||||||
|
padding: '0 !important',
|
||||||
|
background: background,
|
||||||
|
},
|
||||||
|
root: {
|
||||||
|
backgroundColor: background,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RaList: {
|
||||||
|
content: {
|
||||||
|
backgroundColor: background,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RaListToolbar: {
|
||||||
|
toolbar: {
|
||||||
|
backgroundColor: background,
|
||||||
|
padding: '0 .55rem !important',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RaSidebar: {
|
||||||
|
fixed: {
|
||||||
|
backgroundColor: background,
|
||||||
|
},
|
||||||
|
drawerPaper: {
|
||||||
|
backgroundColor: `${background} !important`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RaMenuItemLink: {
|
||||||
|
root: {
|
||||||
|
color: foreground,
|
||||||
|
'&[aria-current="page"]': {
|
||||||
|
color: `${blue} !important`,
|
||||||
|
},
|
||||||
|
'&[aria-current="page"] .MuiListItemIcon-root': {
|
||||||
|
color: `${blue} !important`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
color: `${blue} !important`,
|
||||||
|
'& .MuiListItemIcon-root': {
|
||||||
|
color: `${blue} !important`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RaLink: {
|
||||||
|
link: {
|
||||||
|
color: link,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RaButton: {
|
||||||
|
button: {
|
||||||
|
margin: '0 5px 0 5px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RaPaginationActions: {
|
||||||
|
currentPageButton: {
|
||||||
|
border: `2px solid ${blue}`,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: currentLine,
|
||||||
|
minWidth: 48,
|
||||||
|
margin: '0 4px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
player: {
|
||||||
|
theme: 'light',
|
||||||
|
stylesheet,
|
||||||
|
},
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue