mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-19 07:37:53 -05:00
Compare commits
26 Commits
v0.60.3
...
custom-col
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4b1830513 | ||
|
|
eca4c5acf0 | ||
|
|
e766a5d780 | ||
|
|
90d6cd5f47 | ||
|
|
24ab04581a | ||
|
|
8e647a0e41 | ||
|
|
86c326bd4a | ||
|
|
5fa8356b31 | ||
|
|
929e7193b4 | ||
|
|
9bcefea0ca | ||
|
|
b0cb40b029 | ||
|
|
cad9cdc53e | ||
|
|
b774133cd1 | ||
|
|
a20d56c137 | ||
|
|
b64d8ad334 | ||
|
|
f00af7f983 | ||
|
|
875ffc2b78 | ||
|
|
885334c819 | ||
|
|
ff86b9f2b9 | ||
|
|
13d3d510f5 | ||
|
|
656009e5f8 | ||
|
|
06b3a1f33e | ||
|
|
0f4e8376cb | ||
|
|
199cde4109 | ||
|
|
897de02a84 | ||
|
|
7ee56fe3bf |
138
.github/workflows/push-translations.sh
vendored
Executable file
138
.github/workflows/push-translations.sh
vendored
Executable file
@@ -0,0 +1,138 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
I18N_DIR=resources/i18n
|
||||
|
||||
# Normalize JSON for deterministic comparison:
|
||||
# remove empty/null attributes, sort keys alphabetically
|
||||
process_json() {
|
||||
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
|
||||
}
|
||||
|
||||
# Get list of all languages configured in the POEditor project
|
||||
get_language_list() {
|
||||
curl -s -X POST https://api.poeditor.com/v2/languages/list \
|
||||
-d api_token="${POEDITOR_APIKEY}" \
|
||||
-d id="${POEDITOR_PROJECTID}"
|
||||
}
|
||||
|
||||
# Extract language name from the language list JSON given a language code
|
||||
get_language_name() {
|
||||
lang_code="$1"
|
||||
lang_list="$2"
|
||||
echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name"
|
||||
}
|
||||
|
||||
# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr")
|
||||
get_lang_code() {
|
||||
filepath="$1"
|
||||
filename=$(basename "$filepath")
|
||||
echo "${filename%.*}"
|
||||
}
|
||||
|
||||
# Export the current translation for a language from POEditor (v2 API)
|
||||
export_language() {
|
||||
lang_code="$1"
|
||||
response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \
|
||||
-d api_token="${POEDITOR_APIKEY}" \
|
||||
-d id="${POEDITOR_PROJECTID}" \
|
||||
-d language="$lang_code" \
|
||||
-d type="key_value_json")
|
||||
|
||||
url=$(echo "$response" | jq -r '.result.url')
|
||||
if [ -z "$url" ] || [ "$url" = "null" ]; then
|
||||
echo "Failed to export $lang_code: $response" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "$url"
|
||||
}
|
||||
|
||||
# Flatten nested JSON to POEditor languages/update format.
|
||||
# POEditor uses term + context pairs, where:
|
||||
# term = the leaf key name
|
||||
# context = the parent path as "key1"."key2"."key3" (empty for root keys)
|
||||
flatten_to_poeditor() {
|
||||
jq -c '[paths(scalars) as $p |
|
||||
{
|
||||
"term": ($p | last | tostring),
|
||||
"context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end),
|
||||
"translation": {"content": getpath($p)}
|
||||
}
|
||||
]' "$1"
|
||||
}
|
||||
|
||||
# Update translations for a language in POEditor via languages/update API
|
||||
update_language() {
|
||||
lang_code="$1"
|
||||
file="$2"
|
||||
|
||||
flatten_to_poeditor "$file" > /tmp/poeditor_data.json
|
||||
response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \
|
||||
-d api_token="${POEDITOR_APIKEY}" \
|
||||
-d id="${POEDITOR_PROJECTID}" \
|
||||
-d language="$lang_code" \
|
||||
--data-urlencode data@/tmp/poeditor_data.json)
|
||||
rm -f /tmp/poeditor_data.json
|
||||
|
||||
status=$(echo "$response" | jq -r '.response.status')
|
||||
if [ "$status" != "success" ]; then
|
||||
echo "Failed to update $lang_code: $response" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
parsed=$(echo "$response" | jq -r '.result.translations.parsed')
|
||||
added=$(echo "$response" | jq -r '.result.translations.added')
|
||||
updated=$(echo "$response" | jq -r '.result.translations.updated')
|
||||
echo " Translations - parsed: $parsed, added: $added, updated: $updated"
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <file1> [file2] ..."
|
||||
echo "No files specified. Nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
lang_list=$(get_language_list)
|
||||
upload_count=0
|
||||
|
||||
for file in "$@"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "Warning: File not found: $file, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
lang_code=$(get_lang_code "$file")
|
||||
lang_name=$(get_language_name "$lang_code" "$lang_list")
|
||||
|
||||
if [ -z "$lang_name" ]; then
|
||||
echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Processing $lang_name ($lang_code)..."
|
||||
|
||||
# Export current state from POEditor
|
||||
url=$(export_language "$lang_code")
|
||||
curl -sSL "$url" -o poeditor_export.json
|
||||
|
||||
# Normalize both files for comparison
|
||||
process_json "$file" > local_normalized.json
|
||||
process_json poeditor_export.json > remote_normalized.json
|
||||
|
||||
# Compare normalized versions
|
||||
if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then
|
||||
echo " No differences, skipping"
|
||||
else
|
||||
echo " Differences found, updating POEditor..."
|
||||
update_language "$lang_code" "$file"
|
||||
upload_count=$((upload_count + 1))
|
||||
fi
|
||||
|
||||
rm -f poeditor_export.json local_normalized.json remote_normalized.json
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done. Updated $upload_count translation(s) in POEditor."
|
||||
32
.github/workflows/push-translations.yml
vendored
Normal file
32
.github/workflows/push-translations.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: POEditor export
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'resources/i18n/*.json'
|
||||
|
||||
jobs:
|
||||
push-translations:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'navidrome' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect changed translation files
|
||||
id: changed
|
||||
run: |
|
||||
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- 'resources/i18n/*.json' | tr '\n' ' ')
|
||||
echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT
|
||||
echo "Changed translation files: $CHANGED_FILES"
|
||||
|
||||
- name: Push translations to POEditor
|
||||
if: ${{ steps.changed.outputs.files != '' }}
|
||||
env:
|
||||
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
|
||||
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
|
||||
run: |
|
||||
.github/workflows/push-translations.sh ${{ steps.changed.outputs.files }}
|
||||
2
Makefile
2
Makefile
@@ -20,7 +20,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||
CROSS_TAGLIB_VERSION ?= 2.1.1-2
|
||||
GOLANGCI_LINT_VERSION ?= v2.8.0
|
||||
GOLANGCI_LINT_VERSION ?= v2.10.0
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ func (c *client) getJWT(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
JWT string `json:"jwt"`
|
||||
JWT string `json:"jwt"` //nolint:gosec
|
||||
}
|
||||
|
||||
var result authResponse
|
||||
|
||||
@@ -110,7 +110,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx)))
|
||||
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx))) //nolint:gosec
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ type listenBrainzResponse struct {
|
||||
}
|
||||
|
||||
type listenBrainzRequest struct {
|
||||
ApiKey string
|
||||
ApiKey string //nolint:gosec
|
||||
Body listenBrainzRequestBody
|
||||
}
|
||||
|
||||
|
||||
@@ -172,8 +172,8 @@ type TagConf struct {
|
||||
|
||||
type lastfmOptions struct {
|
||||
Enabled bool
|
||||
ApiKey string
|
||||
Secret string
|
||||
ApiKey string //nolint:gosec
|
||||
Secret string //nolint:gosec
|
||||
Language string
|
||||
ScrobbleFirstArtistOnly bool
|
||||
|
||||
@@ -183,7 +183,7 @@ type lastfmOptions struct {
|
||||
|
||||
type spotifyOptions struct {
|
||||
ID string
|
||||
Secret string
|
||||
Secret string //nolint:gosec
|
||||
}
|
||||
|
||||
type deezerOptions struct {
|
||||
@@ -208,7 +208,7 @@ type httpHeaderOptions struct {
|
||||
type prometheusOptions struct {
|
||||
Enabled bool
|
||||
MetricsPath string
|
||||
Password string
|
||||
Password string //nolint:gosec
|
||||
}
|
||||
|
||||
type AudioDeviceDefinition []string
|
||||
@@ -748,7 +748,7 @@ func getConfigFile(cfgFile string) string {
|
||||
}
|
||||
cfgFile = os.Getenv("ND_CONFIGFILE")
|
||||
if cfgFile != "" {
|
||||
if _, err := os.Stat(cfgFile); err == nil {
|
||||
if _, err := os.Stat(cfgFile); err == nil { //nolint:gosec
|
||||
return cfgFile
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, err
|
||||
hc := http.Client{Timeout: 5 * time.Second}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
|
||||
req.Header.Set("User-Agent", consts.HTTPUserAgent)
|
||||
resp, err := hc.Do(req)
|
||||
resp, err := hc.Do(req) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ func (c *insightsCollector) sendInsights(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := hc.Do(req)
|
||||
resp, err := hc.Do(req) //nolint:gosec
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Could not send Insights data", err)
|
||||
return
|
||||
@@ -220,7 +220,7 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
|
||||
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
|
||||
data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
|
||||
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
|
||||
data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID
|
||||
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
|
||||
|
||||
return data
|
||||
|
||||
@@ -44,7 +44,7 @@ func newLocalStorage(u url.URL) storage.Storage {
|
||||
|
||||
func (s *localStorage) FS() (storage.MusicFS, error) {
|
||||
path := s.u.Path
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if _, err := os.Stat(path); err != nil { //nolint:gosec
|
||||
return nil, fmt.Errorf("%w: %s", err, path)
|
||||
}
|
||||
return &localFS{FS: os.DirFS(path), extractor: s.extractor}, nil
|
||||
|
||||
9
db/db.go
9
db/db.go
@@ -6,7 +6,9 @@ import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/maruel/natural"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
_ "github.com/navidrome/navidrome/db/migrations"
|
||||
@@ -31,7 +33,12 @@ func Db() *sql.DB {
|
||||
return singleton.GetInstance(func() *sql.DB {
|
||||
sql.Register(Driver, &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
|
||||
if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.RegisterCollation("NATURALSORT", func(a, b string) int {
|
||||
return natural.Compare(strings.ToLower(a), strings.ToLower(b))
|
||||
})
|
||||
},
|
||||
})
|
||||
Path = conf.Server.DbPath
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
-- +goose Up
|
||||
|
||||
-- Change order_*/sort_* column collation from NOCASE to NATURALSORT.
|
||||
-- This way bare ORDER BY on these columns automatically uses natural sorting,
|
||||
-- without needing explicit COLLATE NATURALSORT in every query.
|
||||
PRAGMA writable_schema = ON;
|
||||
UPDATE sqlite_master
|
||||
SET sql = replace(sql, 'collate NOCASE', 'collate NATURALSORT')
|
||||
WHERE type = 'table' AND name IN ('artist', 'album', 'media_file', 'playlist', 'radio');
|
||||
PRAGMA writable_schema = OFF;
|
||||
|
||||
-- Recreate indexes on order_* and sort expression fields to use NATURALSORT collation.
|
||||
-- This enables natural number ordering (e.g., "Album 2" before "Album 10").
|
||||
|
||||
-- Artist indexes
|
||||
drop index if exists artist_order_artist_name;
|
||||
create index artist_order_artist_name
|
||||
on artist (order_artist_name collate NATURALSORT);
|
||||
|
||||
drop index if exists artist_sort_name;
|
||||
create index artist_sort_name
|
||||
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT);
|
||||
|
||||
-- Album indexes
|
||||
drop index if exists album_order_album_name;
|
||||
create index album_order_album_name
|
||||
on album (order_album_name collate NATURALSORT);
|
||||
|
||||
drop index if exists album_order_album_artist_name;
|
||||
create index album_order_album_artist_name
|
||||
on album (order_album_artist_name collate NATURALSORT);
|
||||
|
||||
drop index if exists album_alphabetical_by_artist;
|
||||
create index album_alphabetical_by_artist
|
||||
on album (compilation, order_album_artist_name collate NATURALSORT, order_album_name collate NATURALSORT);
|
||||
|
||||
drop index if exists album_sort_name;
|
||||
create index album_sort_name
|
||||
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT);
|
||||
|
||||
drop index if exists album_sort_album_artist_name;
|
||||
create index album_sort_album_artist_name
|
||||
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NATURALSORT);
|
||||
|
||||
-- Media file indexes
|
||||
drop index if exists media_file_order_title;
|
||||
create index media_file_order_title
|
||||
on media_file (order_title collate NATURALSORT);
|
||||
|
||||
drop index if exists media_file_order_album_name;
|
||||
create index media_file_order_album_name
|
||||
on media_file (order_album_name collate NATURALSORT);
|
||||
|
||||
drop index if exists media_file_order_artist_name;
|
||||
create index media_file_order_artist_name
|
||||
on media_file (order_artist_name collate NATURALSORT);
|
||||
|
||||
drop index if exists media_file_sort_title;
|
||||
create index media_file_sort_title
|
||||
on media_file (coalesce(nullif(sort_title,''),order_title) collate NATURALSORT);
|
||||
|
||||
drop index if exists media_file_sort_artist_name;
|
||||
create index media_file_sort_artist_name
|
||||
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT);
|
||||
|
||||
drop index if exists media_file_sort_album_name;
|
||||
create index media_file_sort_album_name
|
||||
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT);
|
||||
|
||||
-- Playlist and radio indexes: recreate to match new NATURALSORT column collation
|
||||
drop index if exists playlist_name;
|
||||
create index playlist_name
|
||||
on playlist (name collate NATURALSORT);
|
||||
|
||||
drop index if exists radio_name;
|
||||
create index radio_name
|
||||
on radio (name collate NATURALSORT);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
-- Restore NOCASE column collation
|
||||
PRAGMA writable_schema = ON;
|
||||
UPDATE sqlite_master
|
||||
SET sql = replace(sql, 'collate NATURALSORT', 'collate NOCASE')
|
||||
WHERE type = 'table' AND name IN ('artist', 'album', 'media_file', 'playlist', 'radio');
|
||||
PRAGMA writable_schema = OFF;
|
||||
|
||||
-- Restore NOCASE collation indexes
|
||||
|
||||
-- Artist indexes
|
||||
drop index if exists artist_order_artist_name;
|
||||
create index artist_order_artist_name
|
||||
on artist (order_artist_name);
|
||||
|
||||
drop index if exists artist_sort_name;
|
||||
create index artist_sort_name
|
||||
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
|
||||
|
||||
-- Album indexes
|
||||
drop index if exists album_order_album_name;
|
||||
create index album_order_album_name
|
||||
on album (order_album_name);
|
||||
|
||||
drop index if exists album_order_album_artist_name;
|
||||
create index album_order_album_artist_name
|
||||
on album (order_album_artist_name);
|
||||
|
||||
drop index if exists album_alphabetical_by_artist;
|
||||
create index album_alphabetical_by_artist
|
||||
on album (compilation, order_album_artist_name, order_album_name);
|
||||
|
||||
drop index if exists album_sort_name;
|
||||
create index album_sort_name
|
||||
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
|
||||
|
||||
drop index if exists album_sort_album_artist_name;
|
||||
create index album_sort_album_artist_name
|
||||
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NOCASE);
|
||||
|
||||
-- Media file indexes
|
||||
drop index if exists media_file_order_title;
|
||||
create index media_file_order_title
|
||||
on media_file (order_title);
|
||||
|
||||
drop index if exists media_file_order_album_name;
|
||||
create index media_file_order_album_name
|
||||
on media_file (order_album_name);
|
||||
|
||||
drop index if exists media_file_order_artist_name;
|
||||
create index media_file_order_artist_name
|
||||
on media_file (order_artist_name);
|
||||
|
||||
drop index if exists media_file_sort_title;
|
||||
create index media_file_sort_title
|
||||
on media_file (coalesce(nullif(sort_title,''),order_title) collate NOCASE);
|
||||
|
||||
drop index if exists media_file_sort_artist_name;
|
||||
create index media_file_sort_artist_name
|
||||
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
|
||||
|
||||
drop index if exists media_file_sort_album_name;
|
||||
create index media_file_sort_album_name
|
||||
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
|
||||
|
||||
-- Restore playlist and radio indexes
|
||||
drop index if exists playlist_name;
|
||||
create index playlist_name
|
||||
on playlist (name);
|
||||
|
||||
drop index if exists radio_name;
|
||||
create index radio_name
|
||||
on radio (name);
|
||||
6
go.mod
6
go.mod
@@ -7,7 +7,7 @@ replace (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
|
||||
// Fork to implement raw tags support
|
||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0
|
||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -46,13 +46,13 @@ require (
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/maruel/natural v1.3.0
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.28.1
|
||||
github.com/onsi/gomega v1.39.1
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pressly/goose/v3 v3.26.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rjeczalik/notify v0.9.3
|
||||
|
||||
12
go.sum
12
go.sum
@@ -36,8 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0 h1:R8fMzz++cqdQ3DVjzrmAKmZFr2PT8vT8pQEfRzxms00=
|
||||
github.com/deluan/go-taglib v0.0.0-20260209170351-c057626454d0/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e h1:pwx3kmHzl1N28coJV2C1zfm2ZF0qkQcGX+Z6BvXteB4=
|
||||
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
|
||||
@@ -179,8 +179,8 @@ github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
@@ -210,8 +210,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
||||
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
|
||||
@@ -38,7 +38,7 @@ type MediaFile struct {
|
||||
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead
|
||||
// AlbumArtist is the display name used for the album artist.
|
||||
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
|
||||
AlbumID string `structs:"album_id" json:"albumId"`
|
||||
AlbumID string `structs:"album_id" json:"albumId" hash:"ignore"`
|
||||
HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
|
||||
TrackNumber int `structs:"track_number" json:"trackNumber"`
|
||||
DiscNumber int `structs:"disc_number" json:"discNumber"`
|
||||
|
||||
@@ -22,7 +22,7 @@ type User struct {
|
||||
Password string `structs:"-" json:"-"`
|
||||
// This is used to set or change a password when calling Put. If it is empty, the password is not changed.
|
||||
// It is received from the UI with the name "password"
|
||||
NewPassword string `structs:"password,omitempty" json:"password,omitempty"`
|
||||
NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec
|
||||
// If changing the password, this is also required
|
||||
CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"`
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
"missing": booleanFilter,
|
||||
"library_id": artistLibraryIdFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
r.setSortMappings(map[string]string{ //nolint:gosec
|
||||
"name": "order_artist_name",
|
||||
"starred_at": "starred, starred_at",
|
||||
"rated_at": "rating, rated_at",
|
||||
|
||||
@@ -17,45 +17,45 @@ import (
|
||||
var _ = Describe("Collation", func() {
|
||||
conn := db.Db()
|
||||
DescribeTable("Column collation",
|
||||
func(table, column string) {
|
||||
Expect(checkCollation(conn, table, column)).To(Succeed())
|
||||
func(table, column, expectedCollation string) {
|
||||
Expect(checkCollation(conn, table, column, expectedCollation)).To(Succeed())
|
||||
},
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name"),
|
||||
Entry("artist.sort_artist_name", "artist", "sort_artist_name"),
|
||||
Entry("album.order_album_name", "album", "order_album_name"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name"),
|
||||
Entry("album.sort_album_name", "album", "sort_album_name"),
|
||||
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name"),
|
||||
Entry("media_file.order_title", "media_file", "order_title"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name"),
|
||||
Entry("media_file.sort_title", "media_file", "sort_title"),
|
||||
Entry("media_file.sort_album_name", "media_file", "sort_album_name"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"),
|
||||
Entry("playlist.name", "playlist", "name"),
|
||||
Entry("radio.name", "radio", "name"),
|
||||
Entry("user.name", "user", "name"),
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name", "NATURALSORT"),
|
||||
Entry("artist.sort_artist_name", "artist", "sort_artist_name", "NATURALSORT"),
|
||||
Entry("album.order_album_name", "album", "order_album_name", "NATURALSORT"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name", "NATURALSORT"),
|
||||
Entry("album.sort_album_name", "album", "sort_album_name", "NATURALSORT"),
|
||||
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name", "NATURALSORT"),
|
||||
Entry("media_file.order_title", "media_file", "order_title", "NATURALSORT"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name", "NATURALSORT"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name", "NATURALSORT"),
|
||||
Entry("media_file.sort_title", "media_file", "sort_title", "NATURALSORT"),
|
||||
Entry("media_file.sort_album_name", "media_file", "sort_album_name", "NATURALSORT"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name", "NATURALSORT"),
|
||||
Entry("playlist.name", "playlist", "name", "NATURALSORT"),
|
||||
Entry("radio.name", "radio", "name", "NATURALSORT"),
|
||||
Entry("user.name", "user", "name", "NOCASE"),
|
||||
)
|
||||
|
||||
DescribeTable("Index collation",
|
||||
func(table, column string) {
|
||||
Expect(checkIndexUsage(conn, table, column)).To(Succeed())
|
||||
},
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name collate nocase"),
|
||||
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
||||
Entry("album.order_album_name", "album", "order_album_name collate nocase"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate nocase"),
|
||||
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
||||
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate nocase"),
|
||||
Entry("media_file.order_title", "media_file", "order_title collate nocase"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name collate nocase"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate nocase"),
|
||||
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate nocase"),
|
||||
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name collate NATURALSORT"),
|
||||
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT"),
|
||||
Entry("album.order_album_name", "album", "order_album_name collate NATURALSORT"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate NATURALSORT"),
|
||||
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT"),
|
||||
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NATURALSORT"),
|
||||
Entry("media_file.order_title", "media_file", "order_title collate NATURALSORT"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name collate NATURALSORT"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate NATURALSORT"),
|
||||
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate NATURALSORT"),
|
||||
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT"),
|
||||
Entry("media_file.path", "media_file", "path collate nocase"),
|
||||
Entry("playlist.name", "playlist", "name collate nocase"),
|
||||
Entry("radio.name", "radio", "name collate nocase"),
|
||||
Entry("playlist.name", "playlist", "name collate NATURALSORT"),
|
||||
Entry("radio.name", "radio", "name collate NATURALSORT"),
|
||||
Entry("user.user_name", "user", "user_name collate nocase"),
|
||||
)
|
||||
})
|
||||
@@ -91,7 +91,7 @@ order by %[2]s`, table, column))
|
||||
return errors.New("no rows returned")
|
||||
}
|
||||
|
||||
func checkCollation(conn *sql.DB, table string, column string) error {
|
||||
func checkCollation(conn *sql.DB, table, column, expectedCollation string) error {
|
||||
rows, err := conn.Query(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND tbl_name='%s'", table))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -113,12 +113,12 @@ func checkCollation(conn *sql.DB, table string, column string) error {
|
||||
if !re.MatchString(res) {
|
||||
return fmt.Errorf("column '%s' not found in table '%s'", column, table)
|
||||
}
|
||||
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+NOCASE`, column))
|
||||
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+%s`, column, expectedCollation))
|
||||
if re.MatchString(res) {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("table '%s' not found", table)
|
||||
}
|
||||
return fmt.Errorf("column '%s' in table '%s' does not have NOCASE collation", column, table)
|
||||
return fmt.Errorf("column '%s' in table '%s' does not have %s collation", column, table, expectedCollation)
|
||||
}
|
||||
|
||||
@@ -82,11 +82,11 @@ func (e existsCond) ToSql() (string, []any, error) {
|
||||
|
||||
var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`)
|
||||
|
||||
// Convert the order_* columns to an expression using sort_* columns. Example:
|
||||
// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
|
||||
// mapSortOrder converts order_* columns to an expression using sort_* columns with NATURALSORT collation. Example:
|
||||
// order_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate NATURALSORT)
|
||||
// It finds order column names anywhere in the substring
|
||||
func mapSortOrder(tableName, order string) string {
|
||||
order = strings.ToLower(order)
|
||||
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName)
|
||||
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate NATURALSORT)", tableName)
|
||||
return sortOrderRegex.ReplaceAllString(order, repl)
|
||||
}
|
||||
|
||||
@@ -94,13 +94,13 @@ var _ = Describe("Helpers", func() {
|
||||
sort := "ORDER_ALBUM_NAME asc"
|
||||
mapped := mapSortOrder("album", sort)
|
||||
Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` +
|
||||
` collate nocase) asc`))
|
||||
` collate NATURALSORT) asc`))
|
||||
})
|
||||
It("changes multiple order columns to sort expressions", func() {
|
||||
sort := "compilation, order_title asc, order_album_artist_name desc, year desc"
|
||||
mapped := mapSortOrder("album", sort)
|
||||
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` +
|
||||
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`))
|
||||
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate NATURALSORT) asc,` +
|
||||
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate NATURALSORT) desc, year desc`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -148,7 +148,9 @@ func (r *mediaFileRepository) Exists(id string) (bool, error) {
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Put(m *model.MediaFile) error {
|
||||
m.CreatedAt = time.Now()
|
||||
if m.CreatedAt.IsZero() {
|
||||
m.CreatedAt = time.Now()
|
||||
}
|
||||
id, err := r.putByMatch(Eq{"path": m.Path, "library_id": m.LibraryID}, m.ID, &dbMediaFile{MediaFile: m})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -104,6 +104,68 @@ var _ = Describe("MediaRepository", func() {
|
||||
}
|
||||
})
|
||||
|
||||
Describe("Put CreatedAt behavior (#5050)", func() {
|
||||
It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() {
|
||||
before := time.Now().Add(-time.Second)
|
||||
newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "/test/created-at-zero.mp3"}
|
||||
Expect(mr.Put(&newFile)).To(Succeed())
|
||||
|
||||
retrieved, err := mr.Get(newFile.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(retrieved.CreatedAt).To(BeTemporally(">", before))
|
||||
|
||||
_ = mr.Delete(newFile.ID)
|
||||
})
|
||||
|
||||
It("preserves CreatedAt when inserting a new file with non-zero CreatedAt", func() {
|
||||
originalTime := time.Date(2020, 3, 15, 10, 30, 0, 0, time.UTC)
|
||||
newFile := model.MediaFile{
|
||||
ID: id.NewRandom(),
|
||||
LibraryID: 1,
|
||||
Path: "/test/created-at-preserved.mp3",
|
||||
CreatedAt: originalTime,
|
||||
}
|
||||
Expect(mr.Put(&newFile)).To(Succeed())
|
||||
|
||||
retrieved, err := mr.Get(newFile.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
|
||||
|
||||
_ = mr.Delete(newFile.ID)
|
||||
})
|
||||
|
||||
It("does not reset CreatedAt when updating an existing file", func() {
|
||||
originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
fileID := id.NewRandom()
|
||||
newFile := model.MediaFile{
|
||||
ID: fileID,
|
||||
LibraryID: 1,
|
||||
Path: "/test/created-at-update.mp3",
|
||||
Title: "Original Title",
|
||||
CreatedAt: originalTime,
|
||||
}
|
||||
Expect(mr.Put(&newFile)).To(Succeed())
|
||||
|
||||
// Update the file with a new title but zero CreatedAt
|
||||
updatedFile := model.MediaFile{
|
||||
ID: fileID,
|
||||
LibraryID: 1,
|
||||
Path: "/test/created-at-update.mp3",
|
||||
Title: "Updated Title",
|
||||
// CreatedAt is zero - should NOT overwrite the stored value
|
||||
}
|
||||
Expect(mr.Put(&updatedFile)).To(Succeed())
|
||||
|
||||
retrieved, err := mr.Get(fileID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(retrieved.Title).To(Equal("Updated Title"))
|
||||
// CreatedAt should still be the original time (not reset)
|
||||
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
|
||||
|
||||
_ = mr.Delete(fileID)
|
||||
})
|
||||
})
|
||||
|
||||
It("checks existence of mediafiles in the DB", func() {
|
||||
Expect(mr.Exists(songAntenna.ID)).To(BeTrue())
|
||||
Expect(mr.Exists("666")).To(BeFalse())
|
||||
|
||||
@@ -71,8 +71,8 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun
|
||||
//
|
||||
// If PreferSortTags is enabled, it will map the order fields to the corresponding sort expression,
|
||||
// which gives precedence to sort tags.
|
||||
// Ex: order_title => (coalesce(nullif(sort_title,”),order_title) collate nocase)
|
||||
// To avoid performance issues, indexes should be created for these sort expressions
|
||||
// Ex: order_title => (coalesce(nullif(sort_title,""), order_title) collate NATURALSORT)
|
||||
// To avoid performance issues, indexes should be created for these sort expressions.
|
||||
//
|
||||
// NOTE: if an individual item has spaces, it should be wrapped in parentheses. For example,
|
||||
// you should write "(lyrics != '[]')". This prevents the item being split unexpectedly.
|
||||
|
||||
@@ -333,76 +333,76 @@
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Plugin |||| Plugins",
|
||||
"actions": {
|
||||
"addConfig": "Tilføj konfiguration",
|
||||
"disable": "Deaktivér",
|
||||
"disabledDueToError": "Ret fejlen før aktivering",
|
||||
"disabledLibrariesRequired": "Vælg biblioteker før aktivering",
|
||||
"disabledUsersRequired": "Vælg brugere før aktivering",
|
||||
"enable": "Aktivér",
|
||||
"rescan": "Genskan"
|
||||
},
|
||||
"fields": {
|
||||
"allLibraries": "Tillad alle biblioteker",
|
||||
"allUsers": "Tillad alle brugere",
|
||||
"id": "ID",
|
||||
"name": "Navn",
|
||||
"description": "Beskrivelse",
|
||||
"version": "Version",
|
||||
"author": "Forfatter",
|
||||
"website": "Hjemmeside",
|
||||
"permissions": "Tilladelser",
|
||||
"enabled": "Aktiveret",
|
||||
"status": "Status",
|
||||
"path": "Sti",
|
||||
"lastError": "Fejl",
|
||||
"hasError": "Fejl",
|
||||
"updatedAt": "Opdateret",
|
||||
"createdAt": "Installeret",
|
||||
"configKey": "Nøgle",
|
||||
"configValue": "Værdi",
|
||||
"createdAt": "Installeret",
|
||||
"description": "Beskrivelse",
|
||||
"enabled": "Aktiveret",
|
||||
"hasError": "Fejl",
|
||||
"id": "ID",
|
||||
"lastError": "Fejl",
|
||||
"name": "Navn",
|
||||
"path": "Sti",
|
||||
"permissions": "Tilladelser",
|
||||
"selectedLibraries": "Valgte biblioteker",
|
||||
"allUsers": "Tillad alle brugere",
|
||||
"selectedUsers": "Valgte brugere",
|
||||
"status": "Status",
|
||||
"updatedAt": "Opdateret",
|
||||
"version": "Version",
|
||||
"website": "Hjemmeside"
|
||||
"allLibraries": "Tillad alle biblioteker",
|
||||
"selectedLibraries": "Valgte biblioteker"
|
||||
},
|
||||
"messages": {
|
||||
"allLibrariesHelp": "Når aktiveret, vil pluginet have adgang til alle biblioteker, inklusiv dem der oprettes i fremtiden.",
|
||||
"allUsersHelp": "Når aktiveret, vil pluginet have adgang til alle brugere, inklusiv dem der oprettes i fremtiden.",
|
||||
"clickPermissions": "Klik på en tilladelse for detaljer",
|
||||
"configHelp": "Konfigurér pluginet med nøgle-værdi-par. Lad stå tomt, hvis pluginet ikke kræver konfiguration.",
|
||||
"configValidationError": "Konfigurationsvalidering mislykkedes:",
|
||||
"librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.",
|
||||
"noConfig": "Ingen konfiguration angivet",
|
||||
"noLibraries": "Ingen biblioteker valgt",
|
||||
"noUsers": "Ingen brugere valgt",
|
||||
"permissionReason": "Årsag",
|
||||
"requiredHosts": "Påkrævede værter",
|
||||
"schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt.",
|
||||
"usersRequired": "Dette plugin kræver adgang til brugeroplysninger. Vælg hvilke brugere pluginet kan tilgå, eller aktivér 'Tillad alle brugere'."
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
"info": "Pluginoplysninger",
|
||||
"configuration": "Konfiguration",
|
||||
"manifest": "Manifest",
|
||||
"usersPermission": "Brugertilladelse",
|
||||
"libraryPermission": "Bibliotekstilladelse"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Aktiveret",
|
||||
"disabled": "Deaktiveret"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Aktivér",
|
||||
"disable": "Deaktivér",
|
||||
"disabledDueToError": "Ret fejlen før aktivering",
|
||||
"disabledUsersRequired": "Vælg brugere før aktivering",
|
||||
"disabledLibrariesRequired": "Vælg biblioteker før aktivering",
|
||||
"addConfig": "Tilføj konfiguration",
|
||||
"rescan": "Genskan"
|
||||
},
|
||||
"notifications": {
|
||||
"disabled": "Plugin deaktiveret",
|
||||
"enabled": "Plugin aktiveret",
|
||||
"error": "Fejl ved opdatering af plugin",
|
||||
"updated": "Plugin opdateret"
|
||||
"disabled": "Plugin deaktiveret",
|
||||
"updated": "Plugin opdateret",
|
||||
"error": "Fejl ved opdatering af plugin"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Konfigurationen skal være gyldig JSON"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Konfigurér pluginet med nøgle-værdi-par. Lad stå tomt, hvis pluginet ikke kræver konfiguration.",
|
||||
"clickPermissions": "Klik på en tilladelse for detaljer",
|
||||
"noConfig": "Ingen konfiguration angivet",
|
||||
"allUsersHelp": "Når aktiveret, vil pluginet have adgang til alle brugere, inklusiv dem der oprettes i fremtiden.",
|
||||
"noUsers": "Ingen brugere valgt",
|
||||
"permissionReason": "Årsag",
|
||||
"usersRequired": "Dette plugin kræver adgang til brugeroplysninger. Vælg hvilke brugere pluginet kan tilgå, eller aktivér 'Tillad alle brugere'.",
|
||||
"allLibrariesHelp": "Når aktiveret, vil pluginet have adgang til alle biblioteker, inklusiv dem der oprettes i fremtiden.",
|
||||
"noLibraries": "Ingen biblioteker valgt",
|
||||
"librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.",
|
||||
"requiredHosts": "Påkrævede hosts",
|
||||
"configValidationError": "Konfigurationsvalidering mislykkedes:",
|
||||
"schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "nøgle",
|
||||
"configValue": "værdi"
|
||||
},
|
||||
"sections": {
|
||||
"configuration": "Konfiguration",
|
||||
"info": "Pluginoplysninger",
|
||||
"libraryPermission": "Bibliotekstilladelse",
|
||||
"manifest": "Manifest",
|
||||
"status": "Status",
|
||||
"usersPermission": "Brugertilladelse"
|
||||
},
|
||||
"status": {
|
||||
"disabled": "Deaktiveret",
|
||||
"enabled": "Aktiveret"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Konfigurationen skal være gyldig JSON"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -674,7 +674,8 @@
|
||||
"exportSuccess": "Konfigurationen eksporteret til udklipsholder i TOML-format",
|
||||
"exportFailed": "Kunne ikke kopiere konfigurationen",
|
||||
"devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)",
|
||||
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver"
|
||||
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver",
|
||||
"downloadToml": ""
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"languageName": "Euskara",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Abestia |||| Abestiak",
|
||||
"name": "Abestia |||| Abesti",
|
||||
"fields": {
|
||||
"albumArtist": "Albumaren artista",
|
||||
"duration": "Iraupena",
|
||||
@@ -10,6 +10,7 @@
|
||||
"playCount": "Erreprodukzioak",
|
||||
"title": "Titulua",
|
||||
"artist": "Artista",
|
||||
"composer": "Konpositorea",
|
||||
"album": "Albuma",
|
||||
"path": "Fitxategiaren bidea",
|
||||
"libraryName": "Liburutegia",
|
||||
@@ -33,9 +34,9 @@
|
||||
"grouping": "Multzokatzea",
|
||||
"mood": "Aldartea",
|
||||
"participants": "Partaide gehiago",
|
||||
"tags": "Traola gehiago",
|
||||
"mappedTags": "Esleitutako traolak",
|
||||
"rawTags": "Traola gordinak",
|
||||
"tags": "Etiketa gehiago",
|
||||
"mappedTags": "Esleitutako etiketak",
|
||||
"rawTags": "Etiketa gordinak",
|
||||
"missing": "Ez da aurkitu"
|
||||
},
|
||||
"actions": {
|
||||
@@ -46,11 +47,12 @@
|
||||
"shuffleAll": "Erreprodukzio aleatorioa",
|
||||
"download": "Deskargatu",
|
||||
"playNext": "Hurrengoa",
|
||||
"info": "Erakutsi informazioa"
|
||||
"info": "Erakutsi informazioa",
|
||||
"instantMix": "Berehalako nahastea"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Albuma |||| Albumak",
|
||||
"name": "Albuma |||| Album",
|
||||
"fields": {
|
||||
"albumArtist": "Albumaren artista",
|
||||
"artist": "Artista",
|
||||
@@ -66,7 +68,7 @@
|
||||
"date": "Recording Date",
|
||||
"originalDate": "Jatorrizkoa",
|
||||
"releaseDate": "Argitaratze-data",
|
||||
"releases": "Argitaratzea |||| Argitaratzeak",
|
||||
"releases": "Argitaratzea |||| Argitaratze",
|
||||
"released": "Argitaratua",
|
||||
"updatedAt": "Aktualizatze-data:",
|
||||
"comment": "Iruzkina",
|
||||
@@ -101,7 +103,7 @@
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Artista |||| Artistak",
|
||||
"name": "Artista |||| Artista",
|
||||
"fields": {
|
||||
"name": "Izena",
|
||||
"albumCount": "Album kopurua",
|
||||
@@ -330,6 +332,80 @@
|
||||
"scanInProgress": "Araketa abian da…",
|
||||
"noLibrariesAssigned": "Ez da liburutegirik egokitu erabiltzaile honentzat"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Plugina |||| Plugin",
|
||||
"fields": {
|
||||
"id": "IDa",
|
||||
"name": "Izena",
|
||||
"description": "Deskribapena",
|
||||
"version": "Bertsioa",
|
||||
"author": "Autorea",
|
||||
"website": "Webgunea",
|
||||
"permissions": "Baimenak",
|
||||
"enabled": "Gaituta",
|
||||
"status": "Egoera",
|
||||
"path": "Bidea",
|
||||
"lastError": "Errorea",
|
||||
"hasError": "Errorea",
|
||||
"updatedAt": "Eguneratuta",
|
||||
"createdAt": "Instalatuta",
|
||||
"configKey": "Gakoa",
|
||||
"configValue": "Balioa",
|
||||
"allUsers": "Baimendu erabiltzaile guztiak",
|
||||
"selectedUsers": "Hautatutako erabiltzaileak",
|
||||
"allLibraries": "Baimendu liburutegi guztiak",
|
||||
"selectedLibraries": "Hautatutako liburutegiak"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Egoera",
|
||||
"info": "Pluginaren informazioa",
|
||||
"configuration": "Konfigurazioa",
|
||||
"manifest": "Manifestua",
|
||||
"usersPermission": "Erabiltzaileen baimenak",
|
||||
"libraryPermission": "Liburutegien baimenak"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Gaituta",
|
||||
"disabled": "Ezgaituta"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Gaitu",
|
||||
"disable": "Ezgaitu",
|
||||
"disabledDueToError": "Konpondu errorea gaitu baino lehen",
|
||||
"disabledUsersRequired": "Hautatu erabiltzaileak gaitu baino lehen",
|
||||
"disabledLibrariesRequired": "Hautatu liburutegiak gaitu baino lehen",
|
||||
"addConfig": "Gehitu konfigurazioa",
|
||||
"rescan": "Arakatu berriro"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Plugina gaituta",
|
||||
"disabled": "Plugina ezgaituta",
|
||||
"updated": "Plugina eguneratuta",
|
||||
"error": "Errorea plugina eguneratzean"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Konfigurazioa baliozko JSON-a izan behar da"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Konfiguratu plugina gako-balio bikoteak erabiliz. Utzi hutsik pluginak konfiguraziorik behar ez badu.",
|
||||
"configValidationError": "Huts egin du konfigurazioaren balidazioak:",
|
||||
"schemaRenderError": "Ezin izan da konfigurazioaren formularioa bihurtu. Litekeena da pluginaren eskema baliozkoa ez izatea.",
|
||||
"clickPermissions": "Sakatu baimen batean xehetasunetarako",
|
||||
"noConfig": "Ez da konfiguraziorik ezarri",
|
||||
"allUsersHelp": "Gaituta dagoenean, pluginak erabiltzaile guztiak atzitu ditzazke, baita etorkizunean sortuko direnak ere.",
|
||||
"noUsers": "Ez da erabiltzailerik hautatu",
|
||||
"permissionReason": "Arrazoia",
|
||||
"usersRequired": "Plugin honek erabiltzaileen informaziora sarbidea behar du. Hautatu zein erabiltzaile atzitu dezakeen pluginak, edo gaitu 'Baimendu erabiltzaile guztiak'.",
|
||||
"allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.",
|
||||
"noLibraries": "Ez da liburutegirik hautatu",
|
||||
"librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.",
|
||||
"requiredHosts": "Beharrezko ostatatzaileak"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "gakoa",
|
||||
"configValue": "balioa"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -483,6 +559,7 @@
|
||||
"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.",
|
||||
"songsAddedToPlaylist": "Abesti bat zerrendara gehitu da |||| %{smart_count} abesti zerrendara gehitu dira",
|
||||
"noSimilarSongsFound": "Ez da antzeko abestirik aurkitu",
|
||||
"startingInstantMix": "Berehalako nahastea kargatzen…",
|
||||
"noTopSongsFound": "Ez da aparteko abestirik aurkitu",
|
||||
"noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri",
|
||||
"delete_user_title": "Ezabatu '%{name}' erabiltzailea",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"playCount": "Lejátszások",
|
||||
"title": "Cím",
|
||||
"artist": "Előadó",
|
||||
"composer": "Zeneszerző",
|
||||
"album": "Album",
|
||||
"path": "Elérési út",
|
||||
"libraryName": "Könyvtár",
|
||||
@@ -46,7 +47,8 @@
|
||||
"shuffleAll": "Keverés",
|
||||
"download": "Letöltés",
|
||||
"playNext": "Lejátszás következőként",
|
||||
"info": "Részletek"
|
||||
"info": "Részletek",
|
||||
"instantMix": "Instant keverés"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@@ -325,6 +327,80 @@
|
||||
"scanInProgress": "Szkennelés folyamatban...",
|
||||
"noLibrariesAssigned": "Ehhez a felhasználóhoz nincsenek könyvtárak adva"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Kiegészítő |||| Kiegészítők",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Név",
|
||||
"description": "Leírás",
|
||||
"version": "Verzió",
|
||||
"author": "Fejlesztő",
|
||||
"website": "Weboldal",
|
||||
"permissions": "Engedélyek",
|
||||
"enabled": "Engedélyezve",
|
||||
"status": "Státusz",
|
||||
"path": "Útvonal",
|
||||
"lastError": "Hiba",
|
||||
"hasError": "Hiba",
|
||||
"updatedAt": "Frissítve",
|
||||
"createdAt": "Telepítve",
|
||||
"configKey": "Kulcs",
|
||||
"configValue": "Érték",
|
||||
"allUsers": "Összes felhasználó engedélyezése",
|
||||
"selectedUsers": "Kiválasztott felhasználók engedélyezése",
|
||||
"allLibraries": "Összes könyvtár engedélyezése",
|
||||
"selectedLibraries": "Kiválasztott könyvtárak engedélyezése"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Státusz",
|
||||
"info": "Kiegészítő információi",
|
||||
"configuration": "Konfiguráció",
|
||||
"manifest": "Manifest",
|
||||
"usersPermission": "Felhasználói engedélyek",
|
||||
"libraryPermission": "Könyvtári engedélyek"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Engedélyezve",
|
||||
"disabled": "Letiltva"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Engedélyezés",
|
||||
"disable": "Letiltás",
|
||||
"disabledDueToError": "Javítsd ki a kiegészítő hibáját",
|
||||
"disabledUsersRequired": "Válassz felhasználókat",
|
||||
"disabledLibrariesRequired": "Válassz könyvtárakat",
|
||||
"addConfig": "Konfiguráció hozzáadása",
|
||||
"rescan": "Újraszkennelés"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Kiegészítő engedélyezve",
|
||||
"disabled": "Kiegészítő letiltva",
|
||||
"updated": "Kiegészítő frissítve",
|
||||
"error": "Hiba történt a kiegészítő frissítése közben"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "A konfigurációs JSON érvénytelen"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Konfiguráld a kiegészítőt kulcs-érték párokkal. Hagyd a mezőt üresen, ha nincs szükség konfigurációra.",
|
||||
"configValidationError": "Helytelen konfiguráció:",
|
||||
"schemaRenderError": "Nem sikerült megjeleníteni a konfigurációs űrlapot. A bővítmény sémája érvénytelen lehet.",
|
||||
"clickPermissions": "Kattints egy engedélyre a részletekért",
|
||||
"noConfig": "Nincs konfiguráció beállítva",
|
||||
"allUsersHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott felhasználóhoz.",
|
||||
"noUsers": "Nincsenek kiválasztott felhasználók",
|
||||
"permissionReason": "Indok",
|
||||
"usersRequired": "Ez a kiegészítő hozzáférést kér felhasználói információkhoz. Válaszd ki, melyik felhasználókat érheti el, vagy az 'Összes felhasználó engedélyezése' opciót.",
|
||||
"allLibrariesHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott könyvtárhoz.",
|
||||
"noLibraries": "Nincs kiválasztott könyvtár",
|
||||
"librariesRequired": "Ez a kiegészítő hozzáférést kér könyvtárinformációkhoz. Válaszd ki, melyik könyvtárakat érheti el, vagy az 'Összes könyvtár engedélyezése' opciót.",
|
||||
"requiredHosts": "Szükséges hostok"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "kulcs",
|
||||
"configValue": "érték"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -402,7 +478,7 @@
|
||||
"loading": "Betöltés",
|
||||
"not_found": "Nem található",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Nincs %{name} még.",
|
||||
"empty": "Nincsenek %{name}.",
|
||||
"invite": "Szeretnél egyet hozzáadni?"
|
||||
},
|
||||
"input": {
|
||||
@@ -478,6 +554,7 @@
|
||||
"transcodingEnabled": "A Navidrome jelenleg a következőkkel fut %{config}, ez lehetővé teszi a rendszerparancsok futtatását az átkódolási beállításokból a webes felület segítségével. Javasoljuk, hogy biztonsági okokból tiltsd ezt le, és csak az átkódolási beállítások konfigurálásának idejére kapcsold be.",
|
||||
"songsAddedToPlaylist": "1 szám hozzáadva a lejátszási listához |||| %{smart_count} szám hozzáadva a lejátszási listához",
|
||||
"noSimilarSongsFound": "Nem találhatóak hasonló számok",
|
||||
"startingInstantMix": "Instant keverés töltődik...",
|
||||
"noTopSongsFound": "Nincsenek top számok",
|
||||
"noPlaylistsAvailable": "Nem áll rendelkezésre",
|
||||
"delete_user_title": "Felhasználó törlése '%{name}'",
|
||||
@@ -591,6 +668,7 @@
|
||||
"currentValue": "Jelenlegi érték",
|
||||
"configurationFile": "Konfigurációs fájl",
|
||||
"exportToml": "Konfiguráció exportálása (TOML)",
|
||||
"downloadToml": "Konfiguráció letöltése (TOML)",
|
||||
"exportSuccess": "Konfiguráció kiexportálva a vágólapra, TOML formában",
|
||||
"exportFailed": "Nem sikerült kimásolni a konfigurációt",
|
||||
"devFlagsHeader": "Fejlesztői beállítások (változások/eltávolítás jogát fenntartjuk)",
|
||||
|
||||
@@ -674,7 +674,8 @@
|
||||
"exportSuccess": "Configuração exportada para o clipboard em formato TOML",
|
||||
"exportFailed": "Falha ao copiar configuração",
|
||||
"devFlagsHeader": "Flags de Desenvolvimento (sujeitas a mudança/remoção)",
|
||||
"devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras"
|
||||
"devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras",
|
||||
"downloadToml": "Baixar configuração (TOML)"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -158,7 +158,7 @@ func writeTargetsToFile(targets []model.ScanTarget) (string, error) {
|
||||
|
||||
for _, target := range targets {
|
||||
if _, err := fmt.Fprintln(tmpFile, target.String()); err != nil {
|
||||
os.Remove(tmpFile.Name())
|
||||
os.Remove(tmpFile.Name()) //nolint:gosec
|
||||
return "", fmt.Errorf("failed to write to temp file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -267,6 +268,10 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error
|
||||
oldAlbumID := missing.AlbumID
|
||||
newAlbumID := target.AlbumID
|
||||
|
||||
// Preserve the original created_at from the missing file, so moved tracks
|
||||
// don't appear in "Recently Added"
|
||||
target.CreatedAt = missing.CreatedAt
|
||||
|
||||
// Update the target media file with the missing file's ID. This effectively "moves" the track
|
||||
// to the new location while keeping its annotations and references intact.
|
||||
target.ID = missing.ID
|
||||
@@ -298,6 +303,14 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error
|
||||
log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err)
|
||||
}
|
||||
|
||||
// Keep created_at field from previous instance of the album, so moved albums
|
||||
// don't appear in "Recently Added"
|
||||
if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil {
|
||||
if !errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(p.ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here
|
||||
p.processedAlbumAnnotations[newAlbumID] = true
|
||||
}
|
||||
|
||||
@@ -724,6 +724,120 @@ var _ = Describe("phaseMissingTracks", func() {
|
||||
}) // End of Context "with multiple libraries"
|
||||
})
|
||||
|
||||
Describe("CreatedAt preservation (#5050)", func() {
|
||||
var albumRepo *tests.MockAlbumRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
albumRepo.ReassignAnnotationCalls = make(map[string]string)
|
||||
albumRepo.CopyAttributesCalls = make(map[string]string)
|
||||
})
|
||||
|
||||
It("should preserve the missing track's created_at when moving within a library", func() {
|
||||
originalTime := time.Date(2020, 3, 15, 10, 0, 0, 0, time.UTC)
|
||||
missingTrack := model.MediaFile{
|
||||
ID: "1", PID: "A", Path: "old/song.mp3",
|
||||
AlbumID: "album-1",
|
||||
LibraryID: 1,
|
||||
CreatedAt: originalTime,
|
||||
Tags: model.Tags{"title": []string{"My Song"}},
|
||||
Size: 100,
|
||||
}
|
||||
matchedTrack := model.MediaFile{
|
||||
ID: "2", PID: "A", Path: "new/song.mp3",
|
||||
AlbumID: "album-1", // Same album
|
||||
LibraryID: 1,
|
||||
CreatedAt: time.Now(), // Much newer
|
||||
Tags: model.Tags{"title": []string{"My Song"}},
|
||||
Size: 100,
|
||||
}
|
||||
|
||||
_ = ds.MediaFile(ctx).Put(&missingTrack)
|
||||
_ = ds.MediaFile(ctx).Put(&matchedTrack)
|
||||
|
||||
in := &missingTracks{
|
||||
missing: []model.MediaFile{missingTrack},
|
||||
matched: []model.MediaFile{matchedTrack},
|
||||
}
|
||||
|
||||
_, err := phase.processMissingTracks(in)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
movedTrack, _ := ds.MediaFile(ctx).Get("1")
|
||||
Expect(movedTrack.Path).To(Equal("new/song.mp3"))
|
||||
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
|
||||
})
|
||||
|
||||
It("should preserve created_at during cross-library moves with album change", func() {
|
||||
originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
missingTrack := model.MediaFile{
|
||||
ID: "missing-ca", PID: "B", Path: "lib1/song.mp3",
|
||||
AlbumID: "old-album",
|
||||
LibraryID: 1,
|
||||
CreatedAt: originalTime,
|
||||
}
|
||||
matchedTrack := model.MediaFile{
|
||||
ID: "matched-ca", PID: "B", Path: "lib2/song.mp3",
|
||||
AlbumID: "new-album",
|
||||
LibraryID: 2,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Set up albums so CopyAttributes can find them
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "old-album", LibraryID: 1, CreatedAt: originalTime},
|
||||
{ID: "new-album", LibraryID: 2, CreatedAt: time.Now()},
|
||||
})
|
||||
|
||||
_ = ds.MediaFile(ctx).Put(&missingTrack)
|
||||
_ = ds.MediaFile(ctx).Put(&matchedTrack)
|
||||
|
||||
err := phase.moveMatched(matchedTrack, missingTrack)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Track's created_at should be preserved from the missing file
|
||||
movedTrack, _ := ds.MediaFile(ctx).Get("missing-ca")
|
||||
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
|
||||
|
||||
// Album's created_at should be copied from old to new
|
||||
Expect(albumRepo.CopyAttributesCalls).To(HaveKeyWithValue("old-album", "new-album"))
|
||||
|
||||
// Verify the new album's CreatedAt was actually updated
|
||||
newAlbum, err := albumRepo.Get("new-album")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(newAlbum.CreatedAt).To(Equal(originalTime))
|
||||
})
|
||||
|
||||
It("should not copy album created_at when album ID does not change", func() {
|
||||
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
missingTrack := model.MediaFile{
|
||||
ID: "missing-same", PID: "C", Path: "dir1/song.mp3",
|
||||
AlbumID: "same-album",
|
||||
LibraryID: 1,
|
||||
CreatedAt: originalTime,
|
||||
}
|
||||
matchedTrack := model.MediaFile{
|
||||
ID: "matched-same", PID: "C", Path: "dir2/song.mp3",
|
||||
AlbumID: "same-album", // Same album
|
||||
LibraryID: 1,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
_ = ds.MediaFile(ctx).Put(&missingTrack)
|
||||
_ = ds.MediaFile(ctx).Put(&matchedTrack)
|
||||
|
||||
err := phase.moveMatched(matchedTrack, missingTrack)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Track's created_at should still be preserved
|
||||
movedTrack, _ := ds.MediaFile(ctx).Get("missing-same")
|
||||
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
|
||||
|
||||
// CopyAttributes should NOT have been called (same album)
|
||||
Expect(albumRepo.CopyAttributesCalls).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Album Annotation Reassignment", func() {
|
||||
var (
|
||||
albumRepo *tests.MockAlbumRepo
|
||||
|
||||
@@ -80,7 +80,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e
|
||||
}
|
||||
c := http.Client{Timeout: imageRequestTimeout}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil)
|
||||
resp, err := c.Do(req) //nolint:bodyclose // No need to close resp.Body, it will be closed via the CachedStream wrapper
|
||||
resp, err := c.Do(req) //nolint:bodyclose,gosec // No need to close resp.Body, it will be closed via the CachedStream wrapper
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline)
|
||||
return strings.NewReader(string(defaultImage)), nil
|
||||
|
||||
113
server/e2e/doc.go
Normal file
113
server/e2e/doc.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Package e2e provides end-to-end integration tests for the Navidrome Subsonic API.
|
||||
//
|
||||
// These tests exercise the full HTTP request/response cycle through the Subsonic API router,
|
||||
// using a real SQLite database and real repository implementations while stubbing out external
|
||||
// services (artwork, streaming, scrobbling, etc.) with noop implementations.
|
||||
//
|
||||
// # Test Infrastructure
|
||||
//
|
||||
// The suite uses [Ginkgo] v2 as the test runner and [Gomega] for assertions. It is invoked
|
||||
// through the standard Go test entry point [TestSubsonicE2E], which initializes the test
|
||||
// environment, creates a temporary SQLite database, and runs the specs.
|
||||
//
|
||||
// # Setup and Teardown
|
||||
//
|
||||
// During [BeforeSuite], the test infrastructure:
|
||||
//
|
||||
// 1. Creates a temporary SQLite database with WAL journal mode.
|
||||
// 2. Initializes the schema via [db.Init].
|
||||
// 3. Creates two test users: an admin ("admin") and a regular user ("regular"),
|
||||
// both with the password "password".
|
||||
// 4. Creates a single library ("Music Library") backed by a fake in-memory filesystem
|
||||
// (scheme "fake:///music") using the [storagetest] package.
|
||||
// 5. Populates the filesystem with a set of test tracks spanning multiple artists,
|
||||
// albums, genres, and years.
|
||||
// 6. Runs the scanner to import all metadata into the database.
|
||||
// 7. Takes a snapshot of the database to serve as a golden baseline for test isolation.
|
||||
//
|
||||
// # Test Data
|
||||
//
|
||||
// The fake filesystem contains the following music library structure:
|
||||
//
|
||||
// Rock/The Beatles/Abbey Road/
|
||||
// 01 - Come Together.mp3 (1969, Rock)
|
||||
// 02 - Something.mp3 (1969, Rock)
|
||||
// Rock/The Beatles/Help!/
|
||||
// 01 - Help.mp3 (1965, Rock)
|
||||
// Rock/Led Zeppelin/IV/
|
||||
// 01 - Stairway To Heaven.mp3 (1971, Rock)
|
||||
// Jazz/Miles Davis/Kind of Blue/
|
||||
// 01 - So What.mp3 (1959, Jazz)
|
||||
// Pop/
|
||||
// 01 - Standalone Track.mp3 (2020, Pop)
|
||||
//
|
||||
// # Database Isolation
|
||||
//
|
||||
// Before each top-level Describe block, the [setupTestDB] function restores the database
|
||||
// to its golden snapshot state using SQLite's ATTACH DATABASE mechanism. This copies all
|
||||
// table data from the snapshot back into the main database, providing each test group with
|
||||
// a clean, consistent starting state without the overhead of re-scanning the filesystem.
|
||||
//
|
||||
// A fresh [subsonic.Router] is also created for each test group, wired with real data store
|
||||
// repositories and noop stubs for external services:
|
||||
//
|
||||
// - noopArtwork: returns [model.ErrNotFound] for all artwork requests.
|
||||
// - noopStreamer: returns [model.ErrNotFound] for all stream requests.
|
||||
// - noopArchiver: returns [model.ErrNotFound] for all archive requests.
|
||||
// - noopProvider: returns empty results for all external metadata lookups.
|
||||
// - noopPlayTracker: silently discards all scrobble events.
|
||||
//
|
||||
// # Request Helpers
|
||||
//
|
||||
// Tests build HTTP requests using the [buildReq] helper, which constructs a Subsonic API
|
||||
// request with authentication parameters (username, password, API version "1.16.1", client
|
||||
// name "test-client", and JSON format). Convenience wrappers include:
|
||||
//
|
||||
// - [doReq]: sends a request as the admin user and returns the parsed JSON response.
|
||||
// - [doReqWithUser]: sends a request as a specific user.
|
||||
// - [doRawReq] / [doRawReqWithUser]: returns the raw [httptest.ResponseRecorder] for
|
||||
// binary content or status code inspection.
|
||||
//
|
||||
// Responses are parsed via [parseJSONResponse], which unwraps the Subsonic JSON envelope
|
||||
// and returns the inner response map.
|
||||
//
|
||||
// # Test Organization
|
||||
//
|
||||
// Each test file covers a logical group of Subsonic API endpoints:
|
||||
//
|
||||
// - subsonic_system_test.go: ping, getLicense, getOpenSubsonicExtensions
|
||||
// - subsonic_browsing_test.go: getMusicFolders, getIndexes, getArtists, getMusicDirectory,
|
||||
// getArtist, getAlbum, getSong, getGenres
|
||||
// - subsonic_searching_test.go: search2, search3
|
||||
// - subsonic_album_lists_test.go: getAlbumList, getAlbumList2
|
||||
// - subsonic_playlists_test.go: createPlaylist, getPlaylist, getPlaylists,
|
||||
// updatePlaylist, deletePlaylist
|
||||
// - subsonic_media_annotation_test.go: star, unstar, getStarred, setRating, scrobble
|
||||
// - subsonic_media_retrieval_test.go: stream, download, getCoverArt, getAvatar,
|
||||
// getLyrics, getLyricsBySongId
|
||||
// - subsonic_bookmarks_test.go: createBookmark, getBookmarks, deleteBookmark,
|
||||
// savePlayQueue, getPlayQueue
|
||||
// - subsonic_radio_test.go: getInternetRadioStations, createInternetRadioStation,
|
||||
// updateInternetRadioStation, deleteInternetRadioStation
|
||||
// - subsonic_sharing_test.go: createShare, getShares, updateShare, deleteShare
|
||||
// - subsonic_users_test.go: getUser, getUsers
|
||||
// - subsonic_scan_test.go: getScanStatus, startScan
|
||||
// - subsonic_multiuser_test.go: multi-user isolation and permission enforcement
|
||||
// - subsonic_multilibrary_test.go: multi-library access control and data isolation
|
||||
//
|
||||
// Some test groups use Ginkgo's Ordered decorator to run tests sequentially within a block,
|
||||
// allowing later tests to depend on state created by earlier ones (e.g., creating a playlist
|
||||
// and then verifying it can be retrieved).
|
||||
//
|
||||
// # Running
|
||||
//
|
||||
// The e2e tests are included in the standard test suite and can be run with:
|
||||
//
|
||||
// make test PKG=./server/e2e # Run only e2e tests
|
||||
// make test # Run all tests including e2e
|
||||
// make test-race # Run with race detector
|
||||
//
|
||||
// [Ginkgo]: https://onsi.github.io/ginkgo/
|
||||
// [Gomega]: https://onsi.github.io/gomega/
|
||||
// [storagetest]: /core/storage/storagetest
|
||||
package e2e
|
||||
@@ -104,7 +104,7 @@ func writeEvent(ctx context.Context, w io.Writer, event message, timeout time.Du
|
||||
log.Debug(ctx, "Error setting write timeout", err)
|
||||
}
|
||||
|
||||
_, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.id, event.event, event.data)
|
||||
_, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.id, event.event, event.data) //nolint:gosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func inspect(ds model.DataStore) http.HandlerFunc {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if _, err := w.Write(response); err != nil {
|
||||
if _, err := w.Write(response); err != nil { //nolint:gosec
|
||||
log.Error(ctx, "Error sending response to client", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
_, err = w.Write(resp)
|
||||
_, err = w.Write(resp) //nolint:gosec
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
@@ -243,7 +243,7 @@ func (api *Router) addInsightsRoute(r chi.Router) {
|
||||
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
last, success := api.insights.LastRun(r.Context())
|
||||
if conf.Server.EnableInsightsCollector {
|
||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
|
||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`)) //nolint:gosec
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`))
|
||||
}
|
||||
|
||||
@@ -19,47 +19,33 @@ import (
|
||||
|
||||
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
|
||||
|
||||
func getPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
// Add a middleware to capture the playlistId
|
||||
wrapper := func(handler restHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
plsRepo := ds.Playlist(ctx)
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
p := req.Params(r)
|
||||
start := p.Int64Or("_start", 0)
|
||||
return plsRepo.Tracks(plsId, start == 0)
|
||||
}
|
||||
|
||||
handler(constructor).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func playlistTracksHandler(ds model.DataStore, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
accept := r.Header.Get("accept")
|
||||
if strings.ToLower(accept) == "audio/x-mpegurl" {
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
tracks := ds.Playlist(r.Context()).Tracks(plsId, refreshSmartPlaylist(r))
|
||||
if tracks == nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
handler(func(ctx context.Context) rest.Repository { return tracks }).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func getPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
handler := playlistTracksHandler(ds, rest.GetAll, func(r *http.Request) bool {
|
||||
return req.Params(r).Int64Or("_start", 0) == 0
|
||||
})
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.ToLower(r.Header.Get("accept")) == "audio/x-mpegurl" {
|
||||
handleExportPlaylist(ds)(w, r)
|
||||
return
|
||||
}
|
||||
wrapper(rest.GetAll)(w, r)
|
||||
handler(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func getPlaylistTrack(ds model.DataStore) http.HandlerFunc {
|
||||
// Add a middleware to capture the playlistId
|
||||
wrapper := func(handler restHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
plsRepo := ds.Playlist(ctx)
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
return plsRepo.Tracks(plsId, true)
|
||||
}
|
||||
|
||||
handler(constructor).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
return wrapper(rest.Get)
|
||||
return playlistTracksHandler(ds, rest.Get, func(*http.Request) bool { return true })
|
||||
}
|
||||
|
||||
func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
|
||||
@@ -73,7 +59,7 @@ func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, err = w.Write([]byte(pls.ToM3U8()))
|
||||
_, err = w.Write([]byte(pls.ToM3U8())) //nolint:gosec
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending m3u contents", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -104,7 +90,7 @@ func handleExportPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", pls.Name)
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
|
||||
_, err = w.Write([]byte(pls.ToM3U8()))
|
||||
_, err = w.Write([]byte(pls.ToM3U8())) //nolint:gosec
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending playlist", "name", pls.Name)
|
||||
return
|
||||
@@ -176,7 +162,7 @@ func addToPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
count += c
|
||||
|
||||
// Must return an object with an ID, to satisfy ReactAdmin `create` call
|
||||
_, err = fmt.Fprintf(w, `{"added":%d}`, count)
|
||||
_, err = fmt.Fprintf(w, `{"added":%d}`, count) //nolint:gosec
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
@@ -218,7 +204,7 @@ func reorderItem(ds model.DataStore) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id))
|
||||
_, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id)) //nolint:gosec
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
@@ -239,6 +225,6 @@ func getSongPlaylists(ds model.DataStore) http.HandlerFunc {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
_, _ = w.Write(data) //nolint:gosec
|
||||
}
|
||||
}
|
||||
|
||||
167
server/nativeapi/playlists_test.go
Normal file
167
server/nativeapi/playlists_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type mockPlaylistTrackRepo struct {
|
||||
model.PlaylistTrackRepository
|
||||
tracks model.PlaylistTracks
|
||||
}
|
||||
|
||||
func (m *mockPlaylistTrackRepo) Count(...rest.QueryOptions) (int64, error) {
|
||||
return int64(len(m.tracks)), nil
|
||||
}
|
||||
|
||||
func (m *mockPlaylistTrackRepo) ReadAll(...rest.QueryOptions) (any, error) {
|
||||
return m.tracks, nil
|
||||
}
|
||||
|
||||
func (m *mockPlaylistTrackRepo) EntityName() string {
|
||||
return "playlist_track"
|
||||
}
|
||||
|
||||
func (m *mockPlaylistTrackRepo) NewInstance() any {
|
||||
return &model.PlaylistTrack{}
|
||||
}
|
||||
|
||||
func (m *mockPlaylistTrackRepo) Read(id string) (any, error) {
|
||||
for _, t := range m.tracks {
|
||||
if t.ID == id {
|
||||
return &t, nil
|
||||
}
|
||||
}
|
||||
return nil, rest.ErrNotFound
|
||||
}
|
||||
|
||||
var _ = Describe("Playlist Tracks Endpoint", func() {
|
||||
var (
|
||||
router http.Handler
|
||||
ds *tests.MockDataStore
|
||||
plsRepo *tests.MockPlaylistRepo
|
||||
userRepo *tests.MockedUserRepo
|
||||
w *httptest.ResponseRecorder
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SessionTimeout = time.Minute
|
||||
|
||||
plsRepo = &tests.MockPlaylistRepo{}
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: plsRepo,
|
||||
MockedUser: userRepo,
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
}
|
||||
|
||||
auth.Init(ds)
|
||||
|
||||
testUser := model.User{
|
||||
ID: "user-1",
|
||||
UserName: "testuser",
|
||||
Name: "Test User",
|
||||
IsAdmin: false,
|
||||
NewPassword: "testpass",
|
||||
}
|
||||
err := userRepo.Put(&testUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
createAuthenticatedRequest := func(method, path string) *http.Request {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
testUser := model.User{ID: "user-1", UserName: "testuser"}
|
||||
token, err := auth.CreateToken(&testUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
|
||||
return req
|
||||
}
|
||||
|
||||
Describe("GET /playlist/{playlistId}/tracks", func() {
|
||||
It("returns 404 when playlist does not exist", func() {
|
||||
req := createAuthenticatedRequest("GET", "/playlist/non-existent/tracks")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("returns tracks when playlist exists", func() {
|
||||
plsRepo.TracksReturn = &mockPlaylistTrackRepo{
|
||||
tracks: model.PlaylistTracks{
|
||||
{ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"},
|
||||
{ID: "2", MediaFileID: "mf-2", PlaylistID: "pls-1"},
|
||||
},
|
||||
}
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.PlaylistTrack
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).To(HaveLen(2))
|
||||
Expect(response[0].ID).To(Equal("1"))
|
||||
Expect(response[1].ID).To(Equal("2"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GET /playlist/{playlistId}/tracks/{id}", func() {
|
||||
It("returns 404 when playlist does not exist", func() {
|
||||
req := createAuthenticatedRequest("GET", "/playlist/non-existent/tracks/1")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("returns the track when playlist exists", func() {
|
||||
plsRepo.TracksReturn = &mockPlaylistTrackRepo{
|
||||
tracks: model.PlaylistTracks{
|
||||
{ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"},
|
||||
},
|
||||
}
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks/1")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response model.PlaylistTrack
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.ID).To(Equal("1"))
|
||||
Expect(response.MediaFileID).To(Equal("mf-1"))
|
||||
})
|
||||
|
||||
It("returns 404 when track does not exist in playlist", func() {
|
||||
plsRepo.TracksReturn = &mockPlaylistTrackRepo{
|
||||
tracks: model.PlaylistTracks{},
|
||||
}
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks/999")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -87,7 +87,7 @@ func getQueue(ds model.DataStore) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(resp)
|
||||
_, _ = w.Write(resp) //nolint:gosec
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ func (pub *Router) handleM3U(w http.ResponseWriter, r *http.Request) {
|
||||
s = pub.mapShareToM3U(r, *s)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Content-Type", "audio/x-mpegurl")
|
||||
_, _ = w.Write([]byte(s.ToM3U8()))
|
||||
_, _ = w.Write([]byte(s.ToM3U8())) //nolint:gosec
|
||||
}
|
||||
|
||||
func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id string) {
|
||||
|
||||
@@ -244,7 +244,7 @@ func (s *Server) frontendAssetsHandler() http.Handler {
|
||||
// It provides detailed error messages for common issues like encrypted private keys.
|
||||
func validateTLSCertificates(certFile, keyFile string) error {
|
||||
// Read the key file to check for encryption
|
||||
keyData, err := os.ReadFile(keyFile)
|
||||
keyData, err := os.ReadFile(keyFile) //nolint:gosec
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading TLS key file: %w", err)
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := w.Write(response); err != nil {
|
||||
if _, err := w.Write(response); err != nil { //nolint:gosec
|
||||
log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type MockAlbumRepo struct {
|
||||
Err bool
|
||||
Options model.QueryOptions
|
||||
ReassignAnnotationCalls map[string]string // prevID -> newID
|
||||
CopyAttributesCalls map[string]string // fromID -> toID
|
||||
}
|
||||
|
||||
func (m *MockAlbumRepo) SetError(err bool) {
|
||||
@@ -142,6 +143,32 @@ func (m *MockAlbumRepo) ReassignAnnotation(prevID string, newID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyAttributes copies attributes from one album to another
|
||||
func (m *MockAlbumRepo) CopyAttributes(fromID, toID string, columns ...string) error {
|
||||
if m.Err {
|
||||
return errors.New("unexpected error")
|
||||
}
|
||||
from, ok := m.Data[fromID]
|
||||
if !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
to, ok := m.Data[toID]
|
||||
if !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
for _, col := range columns {
|
||||
switch col {
|
||||
case "created_at":
|
||||
to.CreatedAt = from.CreatedAt
|
||||
}
|
||||
}
|
||||
if m.CopyAttributesCalls == nil {
|
||||
m.CopyAttributesCalls = make(map[string]string)
|
||||
}
|
||||
m.CopyAttributesCalls[fromID] = toID
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRating sets the rating for an album
|
||||
func (m *MockAlbumRepo) SetRating(rating int, itemID string) error {
|
||||
if m.Err {
|
||||
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
type MockPlaylistRepo struct {
|
||||
model.PlaylistRepository
|
||||
|
||||
Entity *model.Playlist
|
||||
Error error
|
||||
Entity *model.Playlist
|
||||
Error error
|
||||
TracksReturn model.PlaylistTrackRepository
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) {
|
||||
@@ -22,6 +23,10 @@ func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) {
|
||||
return m.Entity, nil
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository {
|
||||
return m.TracksReturn
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) {
|
||||
if m.Error != nil {
|
||||
return 0, m.Error
|
||||
|
||||
@@ -9,6 +9,7 @@ import TableBody from '@material-ui/core/TableBody'
|
||||
import TableRow from '@material-ui/core/TableRow'
|
||||
import TableCell from '@material-ui/core/TableCell'
|
||||
import Paper from '@material-ui/core/Paper'
|
||||
import CloudDownloadIcon from '@material-ui/icons/CloudDownload'
|
||||
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||
import FileCopyIcon from '@material-ui/icons/FileCopy'
|
||||
import Button from '@material-ui/core/Button'
|
||||
@@ -245,6 +246,21 @@ const ConfigTabContent = ({ configData }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadToml = () => {
|
||||
const tomlContent = configToToml(configData, translate)
|
||||
const tomlFile = new File([tomlContent], 'navidrome.toml', {
|
||||
type: 'text/plain',
|
||||
})
|
||||
|
||||
const tomlFileLink = document.createElement('a')
|
||||
const tomlFileUrl = URL.createObjectURL(tomlFile)
|
||||
tomlFileLink.href = tomlFileUrl
|
||||
tomlFileLink.download = tomlFile.name
|
||||
tomlFileLink.click()
|
||||
|
||||
URL.revokeObjectURL(tomlFileUrl)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.configContainer}>
|
||||
<Button
|
||||
@@ -252,11 +268,23 @@ const ConfigTabContent = ({ configData }) => {
|
||||
startIcon={<FileCopyIcon />}
|
||||
onClick={handleCopyToml}
|
||||
className={classes.copyButton}
|
||||
disabled={!configData}
|
||||
disabled={
|
||||
!configData || !navigator.clipboard || !window.isSecureContext
|
||||
}
|
||||
size="small"
|
||||
>
|
||||
{translate('about.config.exportToml')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<CloudDownloadIcon />}
|
||||
onClick={handleDownloadToml}
|
||||
className={classes.copyButton}
|
||||
disabled={!configData}
|
||||
size="small"
|
||||
>
|
||||
{translate('about.config.downloadToml')}
|
||||
</Button>
|
||||
<TableContainer className={classes.tableContainer}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
|
||||
@@ -673,6 +673,7 @@
|
||||
"currentValue": "Current Value",
|
||||
"configurationFile": "Configuration File",
|
||||
"exportToml": "Export Configuration (TOML)",
|
||||
"downloadToml": "Download Configuration (TOML)",
|
||||
"exportSuccess": "Configuration exported to clipboard in TOML format",
|
||||
"exportFailed": "Failed to copy configuration",
|
||||
"devFlagsHeader": "Development Flags (subject to change/removal)",
|
||||
|
||||
@@ -127,10 +127,12 @@ const reducePlayNext = (state, { data }) => {
|
||||
const newQueue = []
|
||||
const current = state.current || {}
|
||||
let foundPos = false
|
||||
let currentIndex = 0
|
||||
state.queue.forEach((item) => {
|
||||
newQueue.push(item)
|
||||
if (item.uuid === current.uuid) {
|
||||
foundPos = true
|
||||
currentIndex = newQueue.length - 1
|
||||
Object.keys(data).forEach((id) => {
|
||||
newQueue.push(mapToAudioLists(data[id]))
|
||||
})
|
||||
@@ -145,6 +147,7 @@ const reducePlayNext = (state, { data }) => {
|
||||
return {
|
||||
...state,
|
||||
queue: newQueue,
|
||||
playIndex: foundPos ? currentIndex : undefined,
|
||||
clear: true,
|
||||
}
|
||||
}
|
||||
|
||||
181
ui/src/themes/dracula.css.js
Normal file
181
ui/src/themes/dracula.css.js
Normal file
@@ -0,0 +1,181 @@
|
||||
const stylesheet = `
|
||||
|
||||
/* Icon hover: pink */
|
||||
.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
|
||||
color: #ff79c6
|
||||
}
|
||||
|
||||
/* Progress bar: purple */
|
||||
.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: #bd93f9
|
||||
}
|
||||
|
||||
/* Volume bar: green */
|
||||
.sound-operation .rc-slider-handle, .sound-operation .rc-slider-track {
|
||||
background-color: #50fa7b !important
|
||||
}
|
||||
|
||||
.sound-operation .rc-slider-handle:active {
|
||||
box-shadow: 0 0 2px #50fa7b !important
|
||||
}
|
||||
|
||||
/* Scrollbar: comment */
|
||||
.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
|
||||
background-color: #6272a4;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
|
||||
box-shadow: 0 0 2px #bd93f9
|
||||
}
|
||||
|
||||
/* Now playing icon: cyan */
|
||||
.react-jinke-music-player-main .audio-item.playing svg {
|
||||
color: #8be9fd
|
||||
}
|
||||
|
||||
/* Now playing artist: cyan */
|
||||
.react-jinke-music-player-main .audio-item.playing .player-singer {
|
||||
color: #8be9fd !important
|
||||
}
|
||||
|
||||
/* Loading spinner: orange */
|
||||
.react-jinke-music-player-main .loading svg {
|
||||
color: #ffb86c !important
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle {
|
||||
border: hidden;
|
||||
box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.rc-slider-rail, .rc-slider-track {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.rc-slider {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.sound-operation > div:nth-child(4) {
|
||||
transform: translateX(-50%) translateY(5%) !important;
|
||||
}
|
||||
|
||||
.sound-operation {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Player panel background */
|
||||
.react-jinke-music-player-main .music-player-panel {
|
||||
background-color: #282a36;
|
||||
color: #f8f8f2;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* Song title in player: foreground */
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-title {
|
||||
color: #f8f8f2;
|
||||
}
|
||||
|
||||
/* Duration/time text: yellow */
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .duration, .react-jinke-music-player-main .music-player-panel .panel-content .player-content .current-time {
|
||||
color: #f1fa8c
|
||||
}
|
||||
|
||||
/* Audio list panel */
|
||||
.audio-lists-panel {
|
||||
background-color: #282a36;
|
||||
bottom: 6.25rem;
|
||||
box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item.playing {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item:nth-child(2n+1) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Playlist hover: current line */
|
||||
.audio-lists-panel-content .audio-item:active,
|
||||
.audio-lists-panel-content .audio-item:hover {
|
||||
background-color: #44475a;
|
||||
}
|
||||
|
||||
.audio-lists-panel-header {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Playlist header text: orange */
|
||||
.audio-lists-panel-header-title {
|
||||
color: #ffb86c;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item {
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .img-content {
|
||||
box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px;
|
||||
}
|
||||
|
||||
/* Lyrics: yellow */
|
||||
.react-jinke-music-player-main .music-player-lyric {
|
||||
color: #f1fa8c;
|
||||
-webkit-text-stroke: 0.5px #282a36;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/* Lyric button active: yellow */
|
||||
.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg {
|
||||
color: #f1fa8c !important;
|
||||
}
|
||||
|
||||
/* Playlist now playing: cyan */
|
||||
.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
|
||||
color: #8be9fd
|
||||
}
|
||||
|
||||
/* Playlist hover icons: pink */
|
||||
.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: #ff79c6
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item .player-icons {
|
||||
scale: 75%;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
|
||||
.react-jinke-music-player-mobile-cover {
|
||||
border: none;
|
||||
box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.react-jinke-music-player .music-player-controller {
|
||||
border: none;
|
||||
box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px;
|
||||
color: #bd93f9;
|
||||
}
|
||||
|
||||
.react-jinke-music-player .music-player-controller .music-player-controller-setting {
|
||||
color: rgba(189, 147, 249, 0.3);
|
||||
}
|
||||
|
||||
/* Mobile progress: green */
|
||||
.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track {
|
||||
background-color: #50fa7b;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-mobile-progress .rc-slider-handle {
|
||||
border: none;
|
||||
}
|
||||
`
|
||||
|
||||
export default stylesheet
|
||||
397
ui/src/themes/dracula.js
Normal file
397
ui/src/themes/dracula.js
Normal file
@@ -0,0 +1,397 @@
|
||||
import stylesheet from './dracula.css.js'
|
||||
|
||||
// Dracula color palette
|
||||
const background = '#282a36'
|
||||
const currentLine = '#44475a'
|
||||
const foreground = '#f8f8f2'
|
||||
const comment = '#6272a4'
|
||||
const cyan = '#8be9fd'
|
||||
const green = '#50fa7b'
|
||||
const pink = '#ff79c6'
|
||||
const purple = '#bd93f9'
|
||||
const orange = '#ffb86c'
|
||||
const red = '#ff5555'
|
||||
const yellow = '#f1fa8c'
|
||||
|
||||
// Darker shade for surfaces
|
||||
const surface = '#21222c'
|
||||
|
||||
// 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: `${green} !important`,
|
||||
color: background,
|
||||
borderRadius: 500,
|
||||
border: 0,
|
||||
'&:hover': {
|
||||
transform: 'scale(2.1)',
|
||||
backgroundColor: `${green} !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: 'Dracula',
|
||||
palette: {
|
||||
primary: {
|
||||
main: purple,
|
||||
},
|
||||
secondary: {
|
||||
main: currentLine,
|
||||
contrastText: foreground,
|
||||
},
|
||||
error: {
|
||||
main: red,
|
||||
},
|
||||
type: 'dark',
|
||||
background: {
|
||||
default: background,
|
||||
paper: surface,
|
||||
},
|
||||
},
|
||||
overrides: {
|
||||
MuiPaper: {
|
||||
root: {
|
||||
color: foreground,
|
||||
backgroundColor: surface,
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
positionFixed: {
|
||||
backgroundColor: `${currentLine} !important`,
|
||||
boxShadow:
|
||||
'rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px',
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
root: {
|
||||
background: background,
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
textPrimary: {
|
||||
color: purple,
|
||||
},
|
||||
textSecondary: {
|
||||
color: foreground,
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
root: {
|
||||
color: foreground,
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
root: {
|
||||
backgroundColor: currentLine,
|
||||
},
|
||||
},
|
||||
MuiFormGroup: {
|
||||
root: {
|
||||
color: foreground,
|
||||
},
|
||||
},
|
||||
MuiFormLabel: {
|
||||
root: {
|
||||
color: comment,
|
||||
'&$focused': {
|
||||
color: purple,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiToolbar: {
|
||||
root: {
|
||||
backgroundColor: `${surface} !important`,
|
||||
},
|
||||
},
|
||||
MuiOutlinedInput: {
|
||||
root: {
|
||||
'& $notchedOutline': {
|
||||
borderColor: currentLine,
|
||||
},
|
||||
'&:hover $notchedOutline': {
|
||||
borderColor: comment,
|
||||
},
|
||||
'&$focused $notchedOutline': {
|
||||
borderColor: purple,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiFilledInput: {
|
||||
root: {
|
||||
backgroundColor: currentLine,
|
||||
'&:hover': {
|
||||
backgroundColor: comment,
|
||||
},
|
||||
'&$focused': {
|
||||
backgroundColor: currentLine,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTableRow: {
|
||||
root: {
|
||||
transition: 'background-color .3s ease',
|
||||
'&:hover': {
|
||||
backgroundColor: `${currentLine} !important`,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTableHead: {
|
||||
root: {
|
||||
color: foreground,
|
||||
background: surface,
|
||||
},
|
||||
},
|
||||
MuiTableCell: {
|
||||
root: {
|
||||
color: foreground,
|
||||
background: `${surface} !important`,
|
||||
borderBottom: `1px solid ${currentLine}`,
|
||||
},
|
||||
head: {
|
||||
color: `${yellow} !important`,
|
||||
background: `${currentLine} !important`,
|
||||
},
|
||||
body: {
|
||||
color: `${foreground} !important`,
|
||||
},
|
||||
},
|
||||
MuiSwitch: {
|
||||
colorSecondary: {
|
||||
'&$checked': {
|
||||
color: green,
|
||||
},
|
||||
'&$checked + $track': {
|
||||
backgroundColor: green,
|
||||
},
|
||||
},
|
||||
},
|
||||
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: green,
|
||||
borderRadius: '50%',
|
||||
boxShadow: '0 8px 8px rgb(0 0 0 / 30%)',
|
||||
padding: '0.35rem',
|
||||
transition: 'padding .3s ease',
|
||||
'&:hover': {
|
||||
background: `${green} !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: pink,
|
||||
},
|
||||
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: purple,
|
||||
},
|
||||
welcome: {
|
||||
color: foreground,
|
||||
},
|
||||
card: {
|
||||
minWidth: 300,
|
||||
background: background,
|
||||
},
|
||||
button: {
|
||||
boxShadow: '3px 3px 5px #191a21',
|
||||
},
|
||||
},
|
||||
NDMobileArtistDetails: {
|
||||
bgContainer: {
|
||||
background: `linear-gradient(to bottom, rgba(40 42 54 / 72%), ${surface})!important`,
|
||||
},
|
||||
},
|
||||
RaLayout: {
|
||||
content: {
|
||||
padding: '0 !important',
|
||||
background: surface,
|
||||
},
|
||||
root: {
|
||||
backgroundColor: background,
|
||||
},
|
||||
},
|
||||
RaList: {
|
||||
content: {
|
||||
backgroundColor: surface,
|
||||
},
|
||||
},
|
||||
RaListToolbar: {
|
||||
toolbar: {
|
||||
backgroundColor: background,
|
||||
padding: '0 .55rem !important',
|
||||
},
|
||||
},
|
||||
RaSidebar: {
|
||||
fixed: {
|
||||
backgroundColor: background,
|
||||
},
|
||||
drawerPaper: {
|
||||
backgroundColor: `${background} !important`,
|
||||
},
|
||||
},
|
||||
MuiTableSortLabel: {
|
||||
root: {
|
||||
color: `${yellow} !important`,
|
||||
'&:hover': {
|
||||
color: `${orange} !important`,
|
||||
},
|
||||
'&$active': {
|
||||
color: `${orange} !important`,
|
||||
'&& $icon': {
|
||||
color: `${orange} !important`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
RaMenuItemLink: {
|
||||
root: {
|
||||
color: foreground,
|
||||
'&[aria-current="page"]': {
|
||||
color: `${pink} !important`,
|
||||
},
|
||||
'&[aria-current="page"] .MuiListItemIcon-root': {
|
||||
color: `${pink} !important`,
|
||||
},
|
||||
},
|
||||
active: {
|
||||
color: `${pink} !important`,
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: `${pink} !important`,
|
||||
},
|
||||
},
|
||||
},
|
||||
RaLink: {
|
||||
link: {
|
||||
color: cyan,
|
||||
},
|
||||
},
|
||||
RaButton: {
|
||||
button: {
|
||||
margin: '0 5px 0 5px',
|
||||
},
|
||||
},
|
||||
RaPaginationActions: {
|
||||
currentPageButton: {
|
||||
border: `2px solid ${purple}`,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: currentLine,
|
||||
minWidth: 48,
|
||||
margin: '0 4px',
|
||||
},
|
||||
},
|
||||
},
|
||||
player: {
|
||||
theme: 'dark',
|
||||
stylesheet,
|
||||
},
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import ElectricPurpleTheme from './electricPurple'
|
||||
import NordTheme from './nord'
|
||||
import GruvboxDarkTheme from './gruvboxDark'
|
||||
import CatppuccinMacchiatoTheme from './catppuccinMacchiato'
|
||||
import DraculaTheme from './dracula'
|
||||
import NuclearTheme from './nuclear'
|
||||
import AmusicTheme from './amusic'
|
||||
import SquiddiesGlassTheme from './SquiddiesGlass'
|
||||
@@ -22,6 +23,7 @@ export default {
|
||||
// New themes should be added here, in alphabetic order
|
||||
AmusicTheme,
|
||||
CatppuccinMacchiatoTheme,
|
||||
DraculaTheme,
|
||||
ElectricPurpleTheme,
|
||||
ExtraDarkTheme,
|
||||
GreenTheme,
|
||||
|
||||
Reference in New Issue
Block a user