mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Merge branch 'master' into feat/support-playlist-paths
This commit is contained in:
commit
5f3bd021e8
6
.github/workflows/pipeline.yml
vendored
6
.github/workflows/pipeline.yml
vendored
@ -32,7 +32,7 @@ jobs:
|
||||
- name: Show git version info
|
||||
run: |
|
||||
echo "git describe (dirty): $(git describe --dirty --always --tags)"
|
||||
echo "git describe --tags: $(git describe --tags `git rev-list --tags --max-count=1`)"
|
||||
echo "git describe --tags --abbrev=0: $(git describe --tags --abbrev=0)"
|
||||
echo "git tag: $(git tag --sort=-committerdate | head -n 1)"
|
||||
echo "github_ref: $GITHUB_REF"
|
||||
echo "github_head_sha: ${{ github.event.pull_request.head.sha }}"
|
||||
@ -40,7 +40,7 @@ jobs:
|
||||
- name: Determine git current SHA and latest tag
|
||||
id: git-version
|
||||
run: |
|
||||
GIT_TAG=$(git tag --sort=-committerdate | head -n 1)
|
||||
GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true)
|
||||
if [ -n "$GIT_TAG" ]; then
|
||||
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
|
||||
GIT_TAG=${GIT_TAG}-SNAPSHOT
|
||||
@ -491,7 +491,7 @@ jobs:
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
version: '~> v2'
|
||||
version: '2.16.0'
|
||||
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
Makefile
2
Makefile
@ -9,7 +9,7 @@ export ND_ENABLEINSIGHTSCOLLECTOR=false
|
||||
|
||||
ifneq ("$(wildcard .git/HEAD)","")
|
||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT
|
||||
GIT_TAG=$(shell git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)-SNAPSHOT
|
||||
else
|
||||
GIT_SHA=source_archive
|
||||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
|
||||
|
||||
@ -794,7 +794,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
|
||||
viper.SetDefault("enableartworkupload", true)
|
||||
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
|
||||
viper.SetDefault("enablesharing", false)
|
||||
viper.SetDefault("enablesharing", true)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||
viper.SetDefault("defaultdownloadableshare", false)
|
||||
|
||||
@ -21,10 +21,15 @@ var _ = Describe("Lyrics", func() {
|
||||
var mf model.MediaFile
|
||||
var ctx context.Context
|
||||
|
||||
const badLyrics = "This is a set of lyrics\nThat is not good"
|
||||
unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(badLyrics))
|
||||
unsynced, _ := unsyncedList.Main()
|
||||
embeddedLyrics := model.LyricList{unsynced}
|
||||
embeddedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
Lang: "xxx",
|
||||
Line: []model.Line{
|
||||
{Value: "This is a set of lyrics"},
|
||||
{Value: "That is not good"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
syncedLyrics := model.LyricList{
|
||||
model.Lyrics{
|
||||
@ -390,7 +395,7 @@ var _ = Describe("Lyrics", func() {
|
||||
})
|
||||
|
||||
It("resolves lyrics from the matched media files", func() {
|
||||
embeddedList, err := model.ParseLyrics(".lrc", "eng", []byte("Embedded lyrics line"))
|
||||
embeddedList, err := model.ParseLyrics(ctx, ".lrc", "eng", []byte("Embedded lyrics line"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
embedded, _ := embeddedList.Main()
|
||||
embeddedJSON, err := json.Marshal(model.LyricList{embedded})
|
||||
|
||||
@ -28,6 +28,7 @@ func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, er
|
||||
func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) {
|
||||
ext := path.Ext(mf.Path)
|
||||
sidecarRelPath := mf.Path[0:len(mf.Path)-len(ext)] + suffix
|
||||
ctx = log.NewContext(ctx, "file", sidecarRelPath)
|
||||
|
||||
store, err := storage.For(mf.LibraryPath)
|
||||
if err != nil {
|
||||
@ -40,7 +41,7 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
|
||||
|
||||
f, err := fsys.Open(sidecarRelPath)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
log.Trace(ctx, "no lyrics found at path", "path", sidecarRelPath)
|
||||
log.Trace(ctx, "no lyrics found at path")
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
@ -52,18 +53,18 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list, err := model.ParseLyrics(suffix, "xxx", contents)
|
||||
list, err := model.ParseLyrics(ctx, suffix, "xxx", contents)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error parsing external lyric file", "path", sidecarRelPath, err)
|
||||
log.Error(ctx, "error parsing external lyric file", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(list) == 0 {
|
||||
log.Trace(ctx, "empty lyrics from external file", "path", sidecarRelPath)
|
||||
log.Trace(ctx, "empty lyrics from external file")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
log.Trace(ctx, "retrieved lyrics from external file", "path", sidecarRelPath)
|
||||
log.Trace(ctx, "retrieved lyrics from external file")
|
||||
return list, nil
|
||||
}
|
||||
|
||||
|
||||
@ -11,7 +11,11 @@ import (
|
||||
)
|
||||
|
||||
var _ = Describe("sources", func() {
|
||||
ctx := context.Background()
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
})
|
||||
|
||||
Describe("fromEmbedded", func() {
|
||||
It("should return nothing for a media file with no lyrics", func() {
|
||||
@ -26,8 +30,8 @@ var _ = Describe("sources", func() {
|
||||
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
|
||||
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
|
||||
|
||||
syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(syncedLyrics))
|
||||
unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(unsyncedLyrics))
|
||||
syncedList, _ := model.ParseLyrics(ctx, ".lrc", "eng", []byte(syncedLyrics))
|
||||
unsyncedList, _ := model.ParseLyrics(ctx, ".lrc", "xxx", []byte(unsyncedLyrics))
|
||||
synced, _ := syncedList.Main()
|
||||
unsynced, _ := unsyncedList.Main()
|
||||
|
||||
|
||||
@ -46,7 +46,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error {
|
||||
continue
|
||||
}
|
||||
|
||||
parsed, err := model.ParseLyrics(".lrc", "xxx", []byte(lyrics.String))
|
||||
parsed, err := model.ParseLyrics(ctx, ".lrc", "xxx", []byte(lyrics.String))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upBackfillArtistSearchNormalized, downBackfillArtistSearchNormalized)
|
||||
}
|
||||
|
||||
// The FTS5 migration back-filled artist.search_normalized with a SQL approximation that
|
||||
// cannot transliterate atomic letters (Ø, æ, ß, ...), and the scanner never rewrote the
|
||||
// column, leaving artists like "GØGGS" unfindable by ASCII searches. Recompute it in Go;
|
||||
// the artist_fts update trigger re-indexes every row that changes.
|
||||
func upBackfillArtistSearchNormalized(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(ctx, tx, "Rebuilding artist search index data. This may take a moment on large libraries.")
|
||||
|
||||
rows, err := tx.QueryContext(ctx, "SELECT id, name, search_normalized FROM artist")
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying artists: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
updates := map[string]string{}
|
||||
for rows.Next() {
|
||||
var id, name, current string
|
||||
if err := rows.Scan(&id, &name, ¤t); err != nil {
|
||||
return fmt.Errorf("scanning artist: %w", err)
|
||||
}
|
||||
if expected := str.NormalizeForFTS(name); expected != current {
|
||||
updates[id] = expected
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("iterating artists: %w", err)
|
||||
}
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, "UPDATE artist SET search_normalized = ? WHERE id = ?")
|
||||
if err != nil {
|
||||
return fmt.Errorf("preparing update: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
for id, normalized := range updates {
|
||||
if _, err := stmt.ExecContext(ctx, normalized, id); err != nil {
|
||||
return fmt.Errorf("updating artist %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downBackfillArtistSearchNormalized(context.Context, *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Composite indexes matching the media_file sort mappings for album, artist and
|
||||
-- albumArtist. Without them, SQLite cannot satisfy the multi-column ORDER BY and
|
||||
-- falls back to a full scan + temp B-tree sort of the whole table (including all
|
||||
-- its large columns) even for a small LIMIT.
|
||||
create index if not exists media_file_album_sort
|
||||
on media_file(order_album_name, album_id, disc_number, track_number, order_artist_name, title);
|
||||
create index if not exists media_file_artist_sort
|
||||
on media_file(order_artist_name, order_album_name, release_date, disc_number, track_number);
|
||||
create index if not exists media_file_album_artist_sort
|
||||
on media_file(order_album_artist_name, order_album_name, release_date, disc_number, track_number);
|
||||
|
||||
-- These two are strict prefixes of the composites above, so they are redundant now.
|
||||
drop index if exists media_file_order_album_name;
|
||||
drop index if exists media_file_order_artist_name;
|
||||
|
||||
-- No query filters or sorts on these columns: birth_time is only read in Go code;
|
||||
-- artist/album_artist conditions go through the media_file_artists table.
|
||||
drop index if exists media_file_birth_time;
|
||||
drop index if exists media_file_artist;
|
||||
drop index if exists media_file_album_artist;
|
||||
|
||||
-- These expression indexes are only usable when PreferSortTags is enabled, a
|
||||
-- config used by ~0.1% of installations (per insights), yet they are maintained
|
||||
-- on every write of every install. Dropping them means those installs fall back
|
||||
-- to a full sort; everyone else saves the space and the scanner write overhead.
|
||||
drop index if exists media_file_sort_title;
|
||||
drop index if exists media_file_sort_artist_name;
|
||||
drop index if exists media_file_sort_album_name;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
drop index if exists media_file_album_sort;
|
||||
drop index if exists media_file_artist_sort;
|
||||
drop index if exists media_file_album_artist_sort;
|
||||
|
||||
create index if not exists media_file_order_album_name
|
||||
on media_file(order_album_name);
|
||||
create index if not exists media_file_order_artist_name
|
||||
on media_file(order_artist_name);
|
||||
create index if not exists media_file_birth_time
|
||||
on media_file(birth_time);
|
||||
create index if not exists media_file_artist
|
||||
on media_file(artist);
|
||||
create index if not exists media_file_album_artist
|
||||
on media_file(album_artist);
|
||||
create index if not exists media_file_sort_title
|
||||
on media_file (coalesce(nullif(sort_title,''),order_title) collate NOCASE);
|
||||
create index if not exists media_file_sort_artist_name
|
||||
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
|
||||
create index if not exists media_file_sort_album_name
|
||||
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
|
||||
-- +goose StatementEnd
|
||||
@ -175,10 +175,14 @@ func NewContext(ctx context.Context, keyValuePairs ...any) context.Context {
|
||||
return ctx
|
||||
}
|
||||
|
||||
func SetDefaultLogger(l *logrus.Logger) {
|
||||
// SetDefaultLogger swaps the process-wide logger and returns the previous one,
|
||||
// so tests can restore the original (with its hooks and formatter) on cleanup.
|
||||
func SetDefaultLogger(l *logrus.Logger) *logrus.Logger {
|
||||
loggerMu.Lock()
|
||||
defer loggerMu.Unlock()
|
||||
prev := defaultLogger
|
||||
defaultLogger = l
|
||||
return prev
|
||||
}
|
||||
|
||||
func CurrentLevel() Level {
|
||||
|
||||
@ -24,7 +24,7 @@ func benchmarkParse(b *testing.B, suffix, fixture string) {
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(contents)))
|
||||
for b.Loop() {
|
||||
if _, err := ParseLyrics(suffix, "eng", contents); err != nil {
|
||||
if _, err := ParseLyrics(b.Context(), suffix, "eng", contents); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package model
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
@ -28,7 +29,10 @@ var lyricFormats = []struct {
|
||||
// ParseLyrics is the single entry point for parsing lyrics. A known suffix routes
|
||||
// to that format's parser; an empty or "auto" suffix content-sniffs. Either way,
|
||||
// a structured parser that does not match falls back to the LRC/plain-text floor.
|
||||
func ParseLyrics(suffix, lang string, contents []byte) (LyricList, error) {
|
||||
//
|
||||
// Parse failures are logged through ctx; callers that know the source should
|
||||
// attach it for attribution, e.g. log.NewContext(ctx, "file", path).
|
||||
func ParseLyrics(ctx context.Context, suffix, lang string, contents []byte) (LyricList, error) {
|
||||
contents = stripBOM(contents)
|
||||
suffix = strings.ToLower(suffix)
|
||||
sniff := suffix == "" || suffix == "auto"
|
||||
@ -41,17 +45,24 @@ func ParseLyrics(suffix, lang string, contents []byte) (LyricList, error) {
|
||||
candidates = append(candidates, f.parse)
|
||||
}
|
||||
}
|
||||
return parseFirstMatch(lang, contents, candidates...)
|
||||
return parseFirstMatch(ctx, sniff, lang, contents, candidates...)
|
||||
}
|
||||
|
||||
func parseFirstMatch(lang string, contents []byte, candidates ...lyricParser) (LyricList, error) {
|
||||
func parseFirstMatch(ctx context.Context, sniff bool, lang string, contents []byte, candidates ...lyricParser) (LyricList, error) {
|
||||
for _, parse := range candidates {
|
||||
list, err := parse(lang, contents)
|
||||
if err == nil && len(list) > 0 {
|
||||
return list, nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn("Error parsing lyrics, falling back to plain text", "error", err)
|
||||
// While sniffing, a probe rejecting content it does not own is expected
|
||||
// control flow, so keep it at trace. A failure under an explicit suffix
|
||||
// means the declared format is malformed and deserves a warning.
|
||||
if sniff {
|
||||
log.Trace(ctx, "Lyrics probe did not match, trying next format", err)
|
||||
} else {
|
||||
log.Warn(ctx, "Error parsing lyrics, falling back to plain text", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return plainLRC(lang, contents)
|
||||
|
||||
@ -3,14 +3,17 @@ package model
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
)
|
||||
|
||||
var _ = Describe("ParseLyrics", func() {
|
||||
DescribeTable("known suffix routes to the matching parser",
|
||||
func(suffix, contents string, wantSynced bool, wantFirst string) {
|
||||
list, err := ParseLyrics(suffix, "eng", []byte(contents))
|
||||
list, err := ParseLyrics(GinkgoT().Context(), suffix, "eng", []byte(contents))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Synced).To(Equal(wantSynced))
|
||||
@ -25,7 +28,7 @@ var _ = Describe("ParseLyrics", func() {
|
||||
|
||||
It("empty suffix content-sniffs (TTML)", func() {
|
||||
ttml := `<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="00:00.000" end="00:01.000">auto ttml</p></div></body></tt>`
|
||||
list, err := ParseLyrics("", "eng", []byte(ttml))
|
||||
list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(ttml))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line[0].Value).To(Equal("auto ttml"))
|
||||
@ -33,19 +36,72 @@ var _ = Describe("ParseLyrics", func() {
|
||||
|
||||
It("empty suffix content-sniffs (YAML)", func() {
|
||||
yaml := "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: auto yaml\n start_ms: 1000\n"
|
||||
list, err := ParseLyrics("auto", "eng", []byte(yaml))
|
||||
list, err := ParseLyrics(GinkgoT().Context(), "auto", "eng", []byte(yaml))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line[0].Value).To(Equal("auto yaml"))
|
||||
})
|
||||
|
||||
It("falls back to plain text when a known suffix fails to parse structurally", func() {
|
||||
list, err := ParseLyrics(".srt", "eng", []byte("not actually an srt file"))
|
||||
list, err := ParseLyrics(GinkgoT().Context(), ".srt", "eng", []byte("not actually an srt file"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Synced).To(BeFalse())
|
||||
Expect(list[0].Line[0].Value).To(Equal("not actually an srt file"))
|
||||
})
|
||||
|
||||
Describe("logging on parser probe failures", func() {
|
||||
var hook *test.Hook
|
||||
|
||||
BeforeEach(func() {
|
||||
prevLevel := log.CurrentLevel()
|
||||
l, h := test.NewNullLogger()
|
||||
hook = h
|
||||
// Swap the logger before raising the level: SetLevel also forces the
|
||||
// current default logger to logrus.TraceLevel, and the null logger would
|
||||
// otherwise stay at Info and drop Trace entries before the hook sees them.
|
||||
prevLogger := log.SetDefaultLogger(l)
|
||||
log.SetLevel(log.LevelTrace)
|
||||
DeferCleanup(func() {
|
||||
log.SetDefaultLogger(prevLogger)
|
||||
log.SetLevel(prevLevel)
|
||||
})
|
||||
})
|
||||
|
||||
// This is the source of the full-scan log spam: embedded lyrics are parsed
|
||||
// with an empty suffix (sniff mode), so every plain-text lyric fails the
|
||||
// YAML/SRT/TTML probes on its way to the plain-text fallback. A probe miss
|
||||
// during sniffing is expected control flow, not a warning.
|
||||
It("logs sniff probe misses at trace only, with file attribution", func() {
|
||||
ctx := log.NewContext(GinkgoT().Context(), "file", "/music/song.mp3")
|
||||
list, err := ParseLyrics(ctx, "", "eng", []byte("Just a plain\nlyric line\n"))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Line[0].Value).To(Equal("Just a plain"))
|
||||
entries := hook.AllEntries()
|
||||
Expect(entries).ToNot(BeEmpty(), "probe misses should be observable at trace")
|
||||
for _, e := range entries {
|
||||
Expect(e.Level).To(Equal(logrus.TraceLevel),
|
||||
"sniff-mode probe misses must not be logged above Trace")
|
||||
Expect(e.Data).To(HaveKeyWithValue("file", "/music/song.mp3"))
|
||||
}
|
||||
})
|
||||
|
||||
// A specific suffix means the user declared the format, so a structural
|
||||
// failure is worth surfacing loudly — and it must name the file.
|
||||
It("warns and names the file when a requested suffix fails to parse", func() {
|
||||
ctx := log.NewContext(GinkgoT().Context(), "file", "/music/song.yaml")
|
||||
list, err := ParseLyrics(ctx, ".yaml", "eng", []byte("not: [valid, yaml\n"))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1)) // still falls back to plain text
|
||||
entry := hook.LastEntry()
|
||||
Expect(entry).ToNot(BeNil())
|
||||
Expect(entry.Level).To(Equal(logrus.WarnLevel))
|
||||
Expect(entry.Data).To(HaveKeyWithValue("file", "/music/song.yaml"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ParseLyrics content-sniffing", func() {
|
||||
@ -67,7 +123,7 @@ var _ = Describe("ParseLyrics content-sniffing", func() {
|
||||
</body>
|
||||
</tt>`
|
||||
|
||||
list, err := ParseLyrics("", "ENG", []byte(content))
|
||||
list, err := ParseLyrics(GinkgoT().Context(), "", "ENG", []byte(content))
|
||||
|
||||
// ParseLyrics's job is to detect TTML and apply the tag language as the
|
||||
// default; the parser's cue/agent details are covered in lyrics_ttml_test.go.
|
||||
@ -104,7 +160,7 @@ var _ = Describe("ParseLyrics content-sniffing", func() {
|
||||
</body>
|
||||
</tt>`
|
||||
|
||||
list, err := ParseLyrics("", "eng", []byte(content))
|
||||
list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(3))
|
||||
@ -129,7 +185,7 @@ We're from subtitles
|
||||
00:00:22,801 --> 00:00:26,000
|
||||
Another subtitle line`
|
||||
|
||||
list, err := ParseLyrics("", "POR", []byte(content))
|
||||
list, err := ParseLyrics(GinkgoT().Context(), "", "POR", []byte(content))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(Equal(LyricList{
|
||||
@ -155,7 +211,7 @@ Another subtitle line`
|
||||
It("should parse embedded SRT blocks separated by whitespace-only blank lines", func() {
|
||||
content := "1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n \n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle"
|
||||
|
||||
list, err := ParseLyrics("", "eng", []byte(content))
|
||||
list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
@ -168,7 +224,7 @@ Another subtitle line`
|
||||
It("should keep embedded enhanced LRC cues", func() {
|
||||
content := "[00:01.00]<00:01.00>Lead <00:01.50>words"
|
||||
|
||||
list, err := ParseLyrics("", "eng", []byte(content))
|
||||
list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
@ -185,7 +241,7 @@ Another subtitle line`
|
||||
</body>
|
||||
</tt>`
|
||||
|
||||
list, err := ParseLyrics("", "eng", []byte(content))
|
||||
list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
@ -202,7 +258,7 @@ Another subtitle line`
|
||||
It("detects a Lyricsfile YAML payload via content-sniffing", func() {
|
||||
yaml := "version: \"1.0\"\nmetadata:\n title: Song\n language: eng\nlines:\n - text: sniffed yaml line\n start_ms: 1000\n"
|
||||
|
||||
list, err := ParseLyrics("", "eng", []byte(yaml))
|
||||
list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(yaml))
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
|
||||
@ -2,6 +2,7 @@ package metadata
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"math"
|
||||
@ -139,13 +140,14 @@ func (md Metadata) mapLyrics() string {
|
||||
|
||||
lyricList := make(model.LyricList, 0, len(rawLyrics))
|
||||
|
||||
ctx := log.NewContext(context.Background(), "file", md.filePath)
|
||||
for _, raw := range rawLyrics {
|
||||
lang := raw.Key()
|
||||
text := raw.Value()
|
||||
|
||||
lyrics, err := model.ParseLyrics("", lang, []byte(text))
|
||||
lyrics, err := model.ParseLyrics(ctx, "", lang, []byte(text))
|
||||
if err != nil {
|
||||
log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err)
|
||||
log.Warn(ctx, "Unexpected failure occurred when parsing lyrics", err)
|
||||
continue
|
||||
}
|
||||
for _, lyric := range lyrics {
|
||||
|
||||
@ -17,6 +17,7 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
@ -69,7 +70,7 @@ func (a *dbAlbum) PostMapArgs(args map[string]any) error {
|
||||
fullText = append(fullText, a.Album.Tags[model.TagCatalogNumber]...)
|
||||
args["full_text"] = formatFullText(fullText...)
|
||||
args["search_participants"] = strings.Join(participantNames, " ")
|
||||
args["search_normalized"] = normalizeForFTS(a.Name, a.AlbumArtist)
|
||||
args["search_normalized"] = str.NormalizeForFTS(a.Name, a.AlbumArtist)
|
||||
|
||||
args["tags"] = marshalTags(a.Album.Tags)
|
||||
args["participants"] = marshalParticipants(a.Album.Participants)
|
||||
|
||||
@ -19,6 +19,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
@ -102,8 +103,10 @@ func (a *dbArtist) PostMapArgs(m map[string]any) error {
|
||||
}
|
||||
similarArtists, _ := json.Marshal(sa)
|
||||
m["similar_artists"] = string(similarArtists)
|
||||
// When adding a derived column here, also add it to the scanner's artist Put column list
|
||||
// in phase_1_folders.go, or rescans will never update it (how search_normalized went stale).
|
||||
m["full_text"] = formatFullText(a.Name, a.SortArtistName)
|
||||
m["search_normalized"] = normalizeForFTS(a.Name)
|
||||
m["search_normalized"] = str.NormalizeForFTS(a.Name)
|
||||
|
||||
// Do not override the sort_artist_name and mbz_artist_id fields if they are empty
|
||||
// TODO: Better way to handle this?
|
||||
|
||||
@ -50,9 +50,6 @@ var _ = Describe("Collation", func() {
|
||||
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("media_file.path", "media_file", "path collate nocase"),
|
||||
Entry("playlist.name", "playlist", "name collate nocase"),
|
||||
Entry("radio.name", "radio", "name collate nocase"),
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
@ -62,7 +63,7 @@ func (m *dbMediaFile) PostMapArgs(args map[string]any) error {
|
||||
fullText = append(fullText, participantNames...)
|
||||
args["full_text"] = formatFullText(fullText...)
|
||||
args["search_participants"] = strings.Join(participantNames, " ")
|
||||
args["search_normalized"] = normalizeForFTS(m.FullTitle(), m.Album, m.Artist, m.AlbumArtist)
|
||||
args["search_normalized"] = str.NormalizeForFTS(m.FullTitle(), m.Album, m.Artist, m.AlbumArtist)
|
||||
args["tags"] = marshalTags(m.MediaFile.Tags)
|
||||
args["participants"] = marshalParticipants(m.MediaFile.Participants)
|
||||
return nil
|
||||
@ -90,6 +91,16 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
|
||||
"recently_added": mediaFileRecentlyAddedSort(),
|
||||
"starred_at": "starred, starred_at",
|
||||
"rated_at": "rating, rated_at",
|
||||
"year": "year",
|
||||
"genre": "genre",
|
||||
"duration": "duration",
|
||||
"channels": "channels",
|
||||
"bpm": "bpm",
|
||||
"path": "path",
|
||||
"comment": "comment",
|
||||
"play_count": "play_count",
|
||||
"play_date": "play_date",
|
||||
"rating": "rating",
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
168
persistence/sort_index_coverage_test.go
Normal file
168
persistence/sort_index_coverage_test.go
Normal file
@ -0,0 +1,168 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"maps"
|
||||
"regexp"
|
||||
"slices"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// These tests guard against sort options silently losing index support: adding or
|
||||
// changing a sort mapping, or dropping/renaming an index in a migration, must not
|
||||
// reintroduce full-table temp B-tree sorts on the large tables. Those are
|
||||
// catastrophic on big libraries but invisible on dev-sized ones, which is how the
|
||||
// unindexed album/artist song sorts went unnoticed for years.
|
||||
//
|
||||
// Every sort mapping is checked automatically: the real ORDER BY is built via
|
||||
// buildSortOrder (both directions) and verified with EXPLAIN QUERY PLAN against
|
||||
// the migrated test schema. The planner's choice is deterministic even on an
|
||||
// empty table. A sort passes when the plan has no full "USE TEMP B-TREE FOR
|
||||
// ORDER BY" step; an incremental sort of tie groups ("... FOR LAST TERM OF ORDER
|
||||
// BY") is fine, as it only sorts rows with equal leading columns.
|
||||
//
|
||||
// A new sort mapping therefore fails this test until a matching index is created.
|
||||
// The only escape hatch is exceptions, for sorts that genuinely cannot be
|
||||
// served by a table index (random, annotation-join columns, JSON expressions):
|
||||
// declaring one requires writing down the reason, making the trade-off visible in
|
||||
// review. The checks run with the default config: PreferSortTags=true rewrites
|
||||
// mappings to coalesce expressions with no matching indexes (used by ~0.1% of
|
||||
// installations, per insights), and is out of scope here.
|
||||
var _ = Describe("Sort index coverage", func() {
|
||||
conn := db.Db()
|
||||
|
||||
type repoCase struct {
|
||||
table string
|
||||
newRepo func(ctx context.Context) *sqlRepository
|
||||
// sort mapping -> reason it cannot be served by an index
|
||||
exceptions map[string]string
|
||||
}
|
||||
|
||||
cases := []repoCase{
|
||||
{
|
||||
table: "media_file",
|
||||
newRepo: func(ctx context.Context) *sqlRepository {
|
||||
return &NewMediaFileRepository(ctx, GetDBXBuilder()).(*mediaFileRepository).sqlRepository
|
||||
},
|
||||
exceptions: map[string]string{
|
||||
"random": "not a column sort",
|
||||
"starred_at": "sorts on annotation join columns",
|
||||
"rated_at": "sorts on annotation join columns",
|
||||
"play_count": "sorts on annotation join columns",
|
||||
"play_date": "sorts on annotation join columns",
|
||||
"rating": "sorts on annotation join columns",
|
||||
"comment": "UI-sortable but rarely used; not worth an index",
|
||||
},
|
||||
},
|
||||
{
|
||||
table: "album",
|
||||
newRepo: func(ctx context.Context) *sqlRepository {
|
||||
return &NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository).sqlRepository
|
||||
},
|
||||
exceptions: map[string]string{
|
||||
"random": "not a column sort",
|
||||
"starred_at": "sorts on annotation join columns",
|
||||
"rated_at": "sorts on annotation join columns",
|
||||
"max_year": "coalesce expression over original_date/max_year, no expression index",
|
||||
},
|
||||
},
|
||||
{
|
||||
table: "artist",
|
||||
newRepo: func(ctx context.Context) *sqlRepository {
|
||||
return &NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository).sqlRepository
|
||||
},
|
||||
exceptions: map[string]string{ //nolint:gosec // G101 false positive, same as the artist sortMappings
|
||||
"starred_at": "sorts on annotation join columns",
|
||||
"rated_at": "sorts on annotation join columns",
|
||||
"song_count": "JSON expression over stats column",
|
||||
"album_count": "JSON expression over stats column",
|
||||
"size": "JSON expression over stats column",
|
||||
"maincredit_song_count": "aggregate over JSON stats",
|
||||
"maincredit_album_count": "aggregate over JSON stats",
|
||||
"maincredit_size": "aggregate over JSON stats",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
newCtx := func() context.Context {
|
||||
ctx := log.NewContext(GinkgoT().Context())
|
||||
return request.WithUser(ctx, model.User{ID: "userid"})
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
It(fmt.Sprintf("uses an index for every sort mapping on %s", c.table), func() {
|
||||
r := c.newRepo(newCtx())
|
||||
for _, sort := range slices.Sorted(maps.Keys(r.sortMappings)) {
|
||||
if _, ok := c.exceptions[sort]; ok {
|
||||
continue
|
||||
}
|
||||
for _, dir := range []string{"asc", "desc"} {
|
||||
orderBy := r.buildSortOrder(sort, dir)
|
||||
Expect(checkSortUsesIndex(conn, c.table, orderBy)).To(Succeed(),
|
||||
"sort %q (%s) on table %q needs an index. Create one matching its ORDER BY, or, if it cannot be served by an index, add it to exceptions with the reason",
|
||||
sort, dir, c.table)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
It(fmt.Sprintf("has no stale exceptions entries for %s", c.table), func() {
|
||||
r := c.newRepo(newCtx())
|
||||
for _, sort := range slices.Sorted(maps.Keys(c.exceptions)) {
|
||||
Expect(r.sortMappings).To(HaveKey(sort),
|
||||
"exceptions entry %q on table %q does not match any sort mapping - remove it", sort, c.table)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
It("uses an index for recently_added when RecentlyAddedByModTime is enabled", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.RecentlyAddedByModTime = true
|
||||
for _, c := range cases[:2] { // media_file and album
|
||||
r := c.newRepo(newCtx())
|
||||
for _, dir := range []string{"asc", "desc"} {
|
||||
orderBy := r.buildSortOrder("recently_added", dir)
|
||||
Expect(checkSortUsesIndex(conn, c.table, orderBy)).To(Succeed(),
|
||||
"sort recently_added (%s) on table %q", dir, c.table)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Matches the full-sort step only: incremental tie-group sorts are reported as
|
||||
// "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" (or "LAST N TERMS") and are allowed.
|
||||
var fullTempBTreeSort = regexp.MustCompile(`USE TEMP B-TREE FOR ORDER BY`)
|
||||
|
||||
func checkSortUsesIndex(conn *sql.DB, table, orderBy string) error {
|
||||
rows, err := conn.Query(fmt.Sprintf("explain query plan select * from %s order by %s limit 15", table, orderBy))
|
||||
if err != nil {
|
||||
return fmt.Errorf("explain query plan failed for order by %q: %w", orderBy, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var details []string
|
||||
for rows.Next() {
|
||||
var id, parent, notUsed int
|
||||
var detail string
|
||||
if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil {
|
||||
return err
|
||||
}
|
||||
details = append(details, detail)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if slices.ContainsFunc(details, fullTempBTreeSort.MatchString) {
|
||||
return fmt.Errorf("no index satisfies ORDER BY %s - plan: %v", orderBy, details)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"github.com/deluan/sanitize"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
// containsCJK returns true if the string contains any CJK (Chinese/Japanese/Korean) characters.
|
||||
@ -35,48 +36,12 @@ func containsCJK(s string) bool {
|
||||
// as unbalanced string delimiters.
|
||||
var fts5SpecialChars = regexp.MustCompile(`[^\p{L}\p{N}\s*"\x00]`)
|
||||
|
||||
// fts5PunctStrip strips everything except letters and numbers (no whitespace, wildcards, or quotes).
|
||||
// Used for normalizing words at index time to create concatenated forms (e.g., "R.E.M." → "REM").
|
||||
var fts5PunctStrip = regexp.MustCompile(`[^\p{L}\p{N}]`)
|
||||
|
||||
// fts5Operators matches FTS5 boolean operators as whole words (case-insensitive).
|
||||
var fts5Operators = regexp.MustCompile(`(?i)\b(AND|OR|NOT|NEAR)\b`)
|
||||
|
||||
// fts5LeadingStar matches a * at the start of a token. FTS5 only supports * at the end (prefix queries).
|
||||
var fts5LeadingStar = regexp.MustCompile(`(^|[\s])\*+`)
|
||||
|
||||
// normalizeForFTS takes multiple strings and returns a space-separated, deduplicated list of
|
||||
// alternative searchable forms for each word: punctuation-stripped (R.E.M. → REM, AC/DC → ACDC)
|
||||
// and ASCII-transliterated (Bjørk → Bjork, œuvre → oeuvre). The transliterated form is needed
|
||||
// because FTS5's `unicode61 remove_diacritics 2` only handles NFKD-decomposable diacritics —
|
||||
// atomic letters like ø/æ/œ/ß survive tokenization, so the query side and index side disagree
|
||||
// without an explicit transliterated entry here.
|
||||
func normalizeForFTS(values ...string) string {
|
||||
seen := make(map[string]struct{})
|
||||
var result []string
|
||||
add := func(orig, variant string) {
|
||||
if variant == "" || variant == orig {
|
||||
return
|
||||
}
|
||||
lower := strings.ToLower(variant)
|
||||
if _, ok := seen[lower]; ok {
|
||||
return
|
||||
}
|
||||
seen[lower] = struct{}{}
|
||||
result = append(result, variant)
|
||||
}
|
||||
for _, v := range values {
|
||||
for word := range strings.FieldsSeq(v) {
|
||||
transliterated := sanitize.Accents(word)
|
||||
// Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne.
|
||||
add(word, fts5PunctStrip.ReplaceAllString(transliterated, ""))
|
||||
// Accent-only transliteration for words without name-punctuation (Bjørk → Bjork).
|
||||
add(word, transliterated)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, " ")
|
||||
}
|
||||
|
||||
// isSingleUnicodeLetter returns true if token is exactly one Unicode letter.
|
||||
func isSingleUnicodeLetter(token string) bool {
|
||||
r, size := utf8.DecodeRuneInString(token)
|
||||
@ -100,7 +65,7 @@ func processPunctuatedWords(input string, phrases []string) (string, []string) {
|
||||
result = append(result, w)
|
||||
continue
|
||||
}
|
||||
concat := fts5PunctStrip.ReplaceAllString(w, "")
|
||||
concat := str.FTSPunctStrip.ReplaceAllString(w, "")
|
||||
if concat == "" || concat == w {
|
||||
result = append(result, w)
|
||||
continue
|
||||
@ -140,13 +105,15 @@ func isDottedAbbreviation(w string, subTokens []string) bool {
|
||||
}
|
||||
|
||||
// buildFTS5Query preprocesses user input into a safe FTS5 MATCH expression.
|
||||
// Plain tokens are emitted as (token OR token*) so bm25 ranks exact-token hits above prefix-only matches.
|
||||
// It preserves quoted phrases and * prefix wildcards, neutralizes FTS5 operators
|
||||
// (by lowercasing them, since FTS5 operators are case-sensitive) and strips
|
||||
// special characters to prevent query injection.
|
||||
func buildFTS5Query(userInput string) string {
|
||||
// The second return reports whether tokenization degraded the query (see ftsQueryDegraded).
|
||||
func buildFTS5Query(userInput string) (string, bool) {
|
||||
q := strings.TrimSpace(userInput)
|
||||
if q == "" || q == `""` {
|
||||
return ""
|
||||
return "", false
|
||||
}
|
||||
|
||||
var phrases []string
|
||||
@ -186,25 +153,38 @@ func buildFTS5Query(userInput string) string {
|
||||
result = fts5LeadingStar.ReplaceAllString(result, "$1")
|
||||
tokens := strings.Fields(result)
|
||||
|
||||
// Append * to plain tokens for prefix matching (e.g., "love" → "love*").
|
||||
// Skip tokens that are already wildcarded or are quoted phrase placeholders.
|
||||
// Two forms per token: a plain prefix form (love*) used only to evaluate query
|
||||
// degradation, and the final (love OR love*) form. The OR adds no matches
|
||||
// (exact ⊂ prefix) but gives bm25 a high-IDF exact-term hit, ranking rows that
|
||||
// contain the literal word above prefix-only matches. Placeholders and
|
||||
// user-supplied wildcards pass through untouched in both forms.
|
||||
prefixTokens := make([]string, len(tokens))
|
||||
wrappedTokens := make([]string, len(tokens))
|
||||
for i, t := range tokens {
|
||||
if strings.HasPrefix(t, "\x00") || strings.HasSuffix(t, "*") {
|
||||
prefixTokens[i], wrappedTokens[i] = t, t
|
||||
continue
|
||||
}
|
||||
tokens[i] = t + "*"
|
||||
prefixTokens[i] = t + "*"
|
||||
wrappedTokens[i] = "(" + t + " OR " + t + "*)"
|
||||
}
|
||||
|
||||
// Use explicit AND between tokens — FTS5's implicit AND (space-separated)
|
||||
// doesn't work correctly with parenthesized OR groups from processPunctuatedWords.
|
||||
result = strings.Join(tokens, " AND ")
|
||||
// doesn't work correctly with parenthesized OR groups. The prefix form is
|
||||
// space-joined instead: it only feeds ftsQueryDegraded, which would count a
|
||||
// literal "AND" as a long token and never flag all-short-token queries.
|
||||
prefixQuery := strings.Join(prefixTokens, " ")
|
||||
result = strings.Join(wrappedTokens, " AND ")
|
||||
|
||||
for i, phrase := range phrases {
|
||||
placeholder := fmt.Sprintf("\x00PHRASE%d\x00", i)
|
||||
prefixQuery = strings.ReplaceAll(prefixQuery, placeholder, phrase)
|
||||
result = strings.ReplaceAll(result, placeholder, phrase)
|
||||
}
|
||||
|
||||
return result
|
||||
// Degradation is evaluated on the prefix form: ftsQueryDegraded treats
|
||||
// leading-( tokens as punctuated-word groups and would never flag wrapped ones.
|
||||
return result, ftsQueryDegraded(userInput, prefixQuery)
|
||||
}
|
||||
|
||||
// ftsColumn pairs an FTS5 column name with its BM25 relevance weight.
|
||||
@ -244,7 +224,10 @@ var ftsColumnDefs = map[string][]ftsColumn{
|
||||
"artist": {
|
||||
{"name", 10.0},
|
||||
{"sort_artist_name", 1.0},
|
||||
{"search_normalized", 1.0},
|
||||
// Same weight as name: for artists this column is purely the name in
|
||||
// alternate spelling (unlike media_file/album, where it mixes
|
||||
// title/album/artist variants and full weight would distort ranking).
|
||||
{"search_normalized", 10.0},
|
||||
},
|
||||
}
|
||||
|
||||
@ -329,7 +312,7 @@ func ftsQueryDegraded(original, ftsQuery string) bool {
|
||||
// Strip quotes from original for comparison — we want the raw content
|
||||
stripped := strings.ReplaceAll(original, `"`, "")
|
||||
// Extract the alphanumeric content from the original query
|
||||
alphaNum := fts5PunctStrip.ReplaceAllString(stripped, "")
|
||||
alphaNum := str.FTSPunctStrip.ReplaceAllString(stripped, "")
|
||||
// If the original is entirely alphanumeric, nothing was stripped — not degraded
|
||||
if len(alphaNum) == len(stripped) {
|
||||
return false
|
||||
@ -353,7 +336,7 @@ func ftsQueryDegraded(original, ftsQuery string) bool {
|
||||
if strings.HasPrefix(t, `"`) {
|
||||
// Extract content between quotes
|
||||
inner := strings.Trim(t, `"`)
|
||||
innerAlpha := fts5PunctStrip.ReplaceAllString(inner, " ")
|
||||
innerAlpha := str.FTSPunctStrip.ReplaceAllString(inner, " ")
|
||||
for it := range strings.FieldsSeq(innerAlpha) {
|
||||
if len(it) > 2 {
|
||||
return false
|
||||
@ -373,8 +356,8 @@ func ftsQueryDegraded(original, ftsQuery string) bool {
|
||||
// tokenization stripped significant content from the query (e.g., "1+" → "1*").
|
||||
// Returns nil when the query produces no searchable tokens at all.
|
||||
func newFTSSearch(tableName, query string) searchStrategy {
|
||||
q := buildFTS5Query(query)
|
||||
if q == "" || ftsQueryDegraded(query, q) {
|
||||
q, degraded := buildFTS5Query(query)
|
||||
if q == "" || degraded {
|
||||
// Fallback: try LIKE search with the raw query
|
||||
cleaned := strings.TrimSpace(strings.ReplaceAll(query, `"`, ""))
|
||||
if cleaned != "" {
|
||||
|
||||
@ -12,44 +12,45 @@ import (
|
||||
|
||||
var _ = DescribeTable("buildFTS5Query",
|
||||
func(input, expected string) {
|
||||
Expect(buildFTS5Query(input)).To(Equal(expected))
|
||||
q, _ := buildFTS5Query(input)
|
||||
Expect(q).To(Equal(expected))
|
||||
},
|
||||
Entry("returns empty string for empty input", "", ""),
|
||||
Entry("returns empty string for whitespace-only input", " ", ""),
|
||||
Entry("appends * to a single word for prefix matching", "beatles", "beatles*"),
|
||||
Entry("appends * to each word for prefix matching", "abbey road", "abbey* AND road*"),
|
||||
Entry("preserves quoted phrases without appending *", `"the beatles"`, `"the beatles"`),
|
||||
Entry("does not double-append * to existing prefix wildcard", "beat*", "beat*"),
|
||||
Entry("strips FTS5 operators and appends * to lowercased words", "AND OR NOT NEAR", "and* AND or* AND not* AND near*"),
|
||||
Entry("strips special FTS5 syntax characters and appends *", "test^col:val", "test* AND col* AND val*"),
|
||||
Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" AND abbey*`),
|
||||
Entry("handles prefix with multiple words", "beat* abbey", "beat* AND abbey*"),
|
||||
Entry("collapses multiple spaces", "abbey road", "abbey* AND road*"),
|
||||
Entry("strips leading * from tokens and appends trailing *", "*livia", "livia*"),
|
||||
Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "livia* AND oliv*"),
|
||||
Entry("wraps a single word as exact OR prefix", "beatles", "(beatles OR beatles*)"),
|
||||
Entry("wraps each word as exact OR prefix", "abbey road", "(abbey OR abbey*) AND (road OR road*)"),
|
||||
Entry("preserves quoted phrases without wrapping", `"the beatles"`, `"the beatles"`),
|
||||
Entry("does not wrap user-supplied prefix wildcard", "beat*", "beat*"),
|
||||
Entry("strips FTS5 operators and wraps lowercased words", "AND OR NOT NEAR", "(and OR and*) AND (or OR or*) AND (not OR not*) AND (near OR near*)"),
|
||||
Entry("strips special FTS5 syntax characters and wraps", "test^col:val", "(test OR test*) AND (col OR col*) AND (val OR val*)"),
|
||||
Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" AND (abbey OR abbey*)`),
|
||||
Entry("handles prefix with multiple words", "beat* abbey", "beat* AND (abbey OR abbey*)"),
|
||||
Entry("collapses multiple spaces", "abbey road", "(abbey OR abbey*) AND (road OR road*)"),
|
||||
Entry("strips leading * from tokens and wraps", "*livia", "(livia OR livia*)"),
|
||||
Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "(livia OR livia*) AND oliv*"),
|
||||
Entry("strips standalone *", "*", ""),
|
||||
Entry("strips apostrophe from input", "Guns N' Roses", "Guns* AND N* AND Roses*"),
|
||||
Entry("strips apostrophe from input", "Guns N' Roses", "(Guns OR Guns*) AND (N OR N*) AND (Roses OR Roses*)"),
|
||||
Entry("converts slashed word to phrase+concat OR", "AC/DC", `("AC DC" OR ACDC*)`),
|
||||
Entry("converts hyphenated word to phrase+concat OR", "a-ha", `("a ha" OR aha*)`),
|
||||
Entry("converts partial hyphenated word to phrase+concat OR", "a-h", `("a h" OR ah*)`),
|
||||
Entry("converts hyphenated name to phrase+concat OR", "Jay-Z", `("Jay Z" OR JayZ*)`),
|
||||
Entry("converts contraction to phrase+concat OR", "it's", `("it s" OR its*)`),
|
||||
Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* AND of* AND ("a ha" OR aha*)`),
|
||||
Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND got*`),
|
||||
Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* AND roll* AND vol* AND 2*"),
|
||||
Entry("transliterates NFKD-decomposable diacritics", "Björk début", "Bjork* AND debut*"),
|
||||
Entry("transliterates ø to o", "Øystein", "Oystein*"),
|
||||
Entry("transliterates œ ligature to oe", "œuvre", "oeuvre*"),
|
||||
Entry("transliterates æ ligature to ae", "Brennæ", "Brennae*"),
|
||||
Entry("transliterates mixed unicode words", "Mø Sigur Rós", "Mo* AND Sigur* AND Ros*"),
|
||||
Entry("transliterates ß to ss", "Straße", "Strasse*"),
|
||||
Entry("handles punctuated word mixed with plain words", "best of a-ha", `(best OR best*) AND (of OR of*) AND ("a ha" OR aha*)`),
|
||||
Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND (got OR got*)`),
|
||||
Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "(rock OR rock*) AND (roll OR roll*) AND (vol OR vol*) AND (2 OR 2*)"),
|
||||
Entry("transliterates NFKD-decomposable diacritics", "Björk début", "(Bjork OR Bjork*) AND (debut OR debut*)"),
|
||||
Entry("transliterates ø to o", "Øystein", "(Oystein OR Oystein*)"),
|
||||
Entry("transliterates œ ligature to oe", "œuvre", "(oeuvre OR oeuvre*)"),
|
||||
Entry("transliterates æ ligature to ae", "Brennæ", "(Brennae OR Brennae*)"),
|
||||
Entry("transliterates mixed unicode words", "Mø Sigur Rós", "(Mo OR Mo*) AND (Sigur OR Sigur*) AND (Ros OR Ros*)"),
|
||||
Entry("transliterates ß to ss", "Straße", "(Strasse OR Strasse*)"),
|
||||
Entry("preserves quoted unicode phrase verbatim", `"Björk"`, `"Björk"`),
|
||||
Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`),
|
||||
Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`),
|
||||
Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* AND of* AND "R E M"`),
|
||||
Entry("collapses abbreviation mixed with words", "best of R.E.M.", `(best OR best*) AND (of OR of*) AND "R E M"`),
|
||||
Entry("collapses two-letter abbreviation", "U.K.", `"U K"`),
|
||||
Entry("does not collapse single letter surrounded by words", "I am fine", "I* AND am* AND fine*"),
|
||||
Entry("does not collapse single standalone letter", "A test", "A* AND test*"),
|
||||
Entry("does not collapse single letter surrounded by words", "I am fine", "(I OR I*) AND (am OR am*) AND (fine OR fine*)"),
|
||||
Entry("does not collapse single standalone letter", "A test", "(A OR A*) AND (test OR test*)"),
|
||||
Entry("preserves quoted phrase with punctuation verbatim", `"ac/dc"`, `"ac/dc"`),
|
||||
Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`),
|
||||
Entry("returns empty string for punctuation-only input", "!!!!!!!", ""),
|
||||
@ -57,6 +58,20 @@ var _ = DescribeTable("buildFTS5Query",
|
||||
Entry("returns empty string for empty quoted phrase", `""`, ""),
|
||||
)
|
||||
|
||||
var _ = DescribeTable("buildFTS5Query degraded flag",
|
||||
func(input string, expected bool) {
|
||||
_, degraded := buildFTS5Query(input)
|
||||
Expect(degraded).To(Equal(expected))
|
||||
},
|
||||
Entry("plain words are not degraded", "beatles", false),
|
||||
Entry("special chars stripped leaving short token is degraded", "1+", true),
|
||||
Entry("multiple short tokens are degraded", "1+ 2+", true),
|
||||
Entry("short tokens mixed with a long word are not degraded", "1+ beatles", false),
|
||||
Entry("quoted short-token phrase is degraded", `"1+"`, true),
|
||||
Entry("punctuated-name group is not degraded", "AC/DC", false),
|
||||
Entry("empty input is not degraded", "", false),
|
||||
)
|
||||
|
||||
var _ = DescribeTable("ftsQueryDegraded",
|
||||
func(original, ftsQuery string, expected bool) {
|
||||
Expect(ftsQueryDegraded(original, ftsQuery)).To(Equal(expected))
|
||||
@ -74,28 +89,6 @@ var _ = DescribeTable("ftsQueryDegraded",
|
||||
Entry("not degraded for OR groups from processPunctuatedWords", "AC/DC", `("AC DC" OR ACDC*)`, false),
|
||||
)
|
||||
|
||||
var _ = DescribeTable("normalizeForFTS",
|
||||
func(expected string, values ...string) {
|
||||
Expect(normalizeForFTS(values...)).To(Equal(expected))
|
||||
},
|
||||
Entry("strips dots and concatenates", "REM", "R.E.M."),
|
||||
Entry("strips slash", "ACDC", "AC/DC"),
|
||||
Entry("strips hyphen", "Aha", "A-ha"),
|
||||
Entry("skips unchanged ASCII words", "", "The Beatles"),
|
||||
Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"),
|
||||
Entry("deduplicates", "REM", "R.E.M.", "R.E.M."),
|
||||
Entry("strips apostrophe from word", "N", "Guns N' Roses"),
|
||||
Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"),
|
||||
Entry("transliterates ø to o", "Bjork", "Bjørk"),
|
||||
Entry("transliterates Ø to O", "Oystein", "Øystein"),
|
||||
Entry("transliterates œ ligature to oe", "oeuvre", "œuvre"),
|
||||
Entry("transliterates Latin diacritics", "cafe", "café"),
|
||||
Entry("transliterates only the non-ASCII words", "Mo Ros", "Mø Rós"),
|
||||
Entry("combines punctuation strip and transliteration", "StEtienne St-Etienne", "St-Étienne"),
|
||||
Entry("deduplicates against punctuation form", "Cafe", "Café", "Cafe"),
|
||||
Entry("transliterates ß to ss", "Strasse", "Straße"),
|
||||
)
|
||||
|
||||
var _ = DescribeTable("containsCJK",
|
||||
func(input string, expected bool) {
|
||||
Expect(containsCJK(input)).To(Equal(expected))
|
||||
@ -165,7 +158,7 @@ var _ = Describe("ftsColumnDefs helpers", func() {
|
||||
|
||||
It("returns weight CSV for artist", func() {
|
||||
Expect(ftsBM25Weights).To(HaveKeyWithValue("artist",
|
||||
"10.0, 1.0, 1.0",
|
||||
"10.0, 1.0, 10.0",
|
||||
))
|
||||
})
|
||||
|
||||
@ -259,18 +252,18 @@ var _ = Describe("newFTSSearch", func() {
|
||||
Expect(fts.rankExpr).To(Equal("unknown_table_fts.rank"))
|
||||
})
|
||||
|
||||
It("wraps query with column filter for known tables", func() {
|
||||
It("wraps query with column filter", func() {
|
||||
strategy := newFTSSearch("artist", "Beatles")
|
||||
fts, ok := strategy.(*ftsSearch)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(fts.matchExpr).To(Equal("{name sort_artist_name search_normalized} : (Beatles*)"))
|
||||
Expect(fts.matchExpr).To(Equal("{name sort_artist_name search_normalized} : ((Beatles OR Beatles*))"))
|
||||
})
|
||||
|
||||
It("passes query without column filter for unknown tables", func() {
|
||||
strategy := newFTSSearch("unknown_table", "test")
|
||||
fts, ok := strategy.(*ftsSearch)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(fts.matchExpr).To(Equal("test*"))
|
||||
Expect(fts.matchExpr).To(Equal("(test OR test*)"))
|
||||
})
|
||||
|
||||
It("preserves phrase queries inside column filter", func() {
|
||||
@ -447,4 +440,38 @@ var _ = Describe("FTS5 Integration Search", func() {
|
||||
Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0")
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Exact-match ranking", func() {
|
||||
BeforeEach(func() {
|
||||
// Registered before the inserts so a mid-loop failure cannot leak corpus rows.
|
||||
DeferCleanup(func() {
|
||||
// library_artist rows are removed by the artist_id ON DELETE CASCADE FK.
|
||||
_, err := GetDBXBuilder().NewQuery("DELETE FROM artist WHERE id LIKE 'fts-rank-%'").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
// Corpus has no competing exact-word names ("Mo X"): exact-vs-exact order depends
|
||||
// on corpus statistics; the guaranteed property is exact > prefix.
|
||||
for _, a := range []model.Artist{
|
||||
{ID: "fts-rank-1", Name: "MØ", OrderArtistName: "mø"},
|
||||
{ID: "fts-rank-2", Name: "Modest Mouse", OrderArtistName: "modest mouse"},
|
||||
{ID: "fts-rank-3", Name: "Morrissey", OrderArtistName: "morrissey"},
|
||||
} {
|
||||
Expect(createArtistWithLibrary(arr, &a, 1)).To(Succeed())
|
||||
}
|
||||
})
|
||||
|
||||
It("ranks the exact transliterated match first for 'MO'", func() {
|
||||
results, err := arr.Search("MO", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(3))
|
||||
Expect(results[0].Name).To(Equal("MØ"), "exact match via search_normalized must outrank prefix matches")
|
||||
})
|
||||
|
||||
It("ranks the exact match first for the accented query 'MØ'", func() {
|
||||
results, err := arr.Search("MØ", model.QueryOptions{Max: 10})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).ToNot(BeEmpty())
|
||||
Expect(results[0].Name).To(Equal("MØ"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -44,15 +44,19 @@ func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (mode
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The lyric text comes from the plugin, not the media file's own tags, so
|
||||
// attribute logs to both the plugin and the track it was fetched for.
|
||||
ctx = log.NewContext(ctx, "plugin", l.name, "file", mf.Path)
|
||||
|
||||
var result model.LyricList
|
||||
for _, lt := range resp.Lyrics {
|
||||
lang := lt.Lang
|
||||
if lang == "" {
|
||||
lang = "xxx"
|
||||
}
|
||||
parsed, err := model.ParseLyrics("", lang, []byte(lt.Text))
|
||||
parsed, err := model.ParseLyrics(ctx, "", lang, []byte(lt.Text))
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error parsing plugin lyrics", "plugin", l.name, err)
|
||||
log.Warn(ctx, "Error parsing plugin lyrics", err)
|
||||
continue
|
||||
}
|
||||
for _, lyric := range parsed {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package metadata_old
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
@ -205,7 +206,7 @@ func (t Tags) Lyrics() string {
|
||||
basicLyrics := t.getAllTagValues("lyrics", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics")
|
||||
|
||||
for _, value := range basicLyrics {
|
||||
parsed, err := model.ParseLyrics(".lrc", "xxx", []byte(value))
|
||||
parsed, err := model.ParseLyrics(context.Background(), ".lrc", "xxx", []byte(value))
|
||||
if err != nil {
|
||||
log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err)
|
||||
continue
|
||||
@ -224,7 +225,7 @@ func (t Tags) Lyrics() string {
|
||||
}
|
||||
|
||||
for _, text := range value {
|
||||
parsed, err := model.ParseLyrics(".lrc", language, []byte(text))
|
||||
parsed, err := model.ParseLyrics(context.Background(), ".lrc", language, []byte(text))
|
||||
if err != nil {
|
||||
log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err)
|
||||
continue
|
||||
|
||||
@ -360,7 +360,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
|
||||
// Save all new/modified artists to DB. Their information will be incomplete, but they will be refreshed later
|
||||
for i := range entry.artists {
|
||||
err = artistRepo.Put(&entry.artists[i], "name",
|
||||
"mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "updated_at")
|
||||
"mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "search_normalized", "updated_at")
|
||||
if err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error persisting artist to DB", "folder", entry.path, "artist", entry.artists[i].Name, err)
|
||||
return err
|
||||
|
||||
@ -189,6 +189,34 @@ var _ = Describe("Scanner", Ordered, func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("Artist with atomic non-ASCII letters, 'GØGGS'", func() {
|
||||
BeforeEach(func() {
|
||||
goggs := template(_t{"albumartist": "GØGGS", "album": "Pre Strike Sweep", "year": 2018})
|
||||
createFS(fstest.MapFS{
|
||||
"GØGGS/Pre Strike Sweep/01 - Falling For You.mp3": goggs(track(1, "Falling For You")),
|
||||
})
|
||||
})
|
||||
|
||||
searchNormalized := func() string {
|
||||
var sn string
|
||||
Expect(db.Db().QueryRowContext(ctx,
|
||||
"SELECT search_normalized FROM artist WHERE name = 'GØGGS'").Scan(&sn)).To(Succeed())
|
||||
return sn
|
||||
}
|
||||
|
||||
It("repopulates a stale search_normalized on a full rescan", func() {
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
Expect(searchNormalized()).To(Equal("GOGGS"))
|
||||
|
||||
// Simulate the stale value left by the FTS5 migration's SQL back-fill
|
||||
_, err := db.Db().ExecContext(ctx, "UPDATE artist SET search_normalized = '' WHERE name = 'GØGGS'")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(runScanner(ctx, true)).To(Succeed())
|
||||
Expect(searchNormalized()).To(Equal("GOGGS"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Ignored entries", func() {
|
||||
BeforeEach(func() {
|
||||
revolver := template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966})
|
||||
|
||||
@ -25,6 +25,7 @@ var _ = Describe("Config API", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableSharing = false
|
||||
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
@ -27,6 +28,7 @@ var _ = Describe("Library API", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableSharing = false
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil)
|
||||
|
||||
@ -32,6 +32,7 @@ var _ = Describe("Song Endpoints", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableSharing = false
|
||||
conf.Server.SessionTimeout = time.Minute
|
||||
|
||||
// Setup mock repositories
|
||||
|
||||
@ -76,6 +76,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableSharing = false
|
||||
conf.Server.SessionTimeout = time.Minute
|
||||
|
||||
plsSvc = &mockPlaylistsService{}
|
||||
|
||||
@ -29,6 +29,7 @@ var _ = Describe("Plugin API", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableSharing = false
|
||||
conf.Server.Plugins.Enabled = true
|
||||
ds = &tests.MockDataStore{}
|
||||
mockManager = &tests.MockPluginManager{}
|
||||
|
||||
@ -100,8 +100,8 @@ var _ = Describe("GetLyricsBySongId", func() {
|
||||
|
||||
It("should return mixed lyrics", func() {
|
||||
r := newGetRequest("id=1")
|
||||
syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(syncedLyrics))
|
||||
unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(unsyncedLyrics))
|
||||
syncedList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "eng", []byte(syncedLyrics))
|
||||
unsyncedList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "xxx", []byte(unsyncedLyrics))
|
||||
synced, _ := syncedList.Main()
|
||||
unsynced, _ := unsyncedList.Main()
|
||||
lyricsJson, err := json.Marshal(model.LyricList{
|
||||
@ -158,7 +158,7 @@ var _ = Describe("GetLyricsBySongId", func() {
|
||||
|
||||
It("should parse lrc metadata", func() {
|
||||
r := newGetRequest("id=1")
|
||||
syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(metadata+"\n"+syncedLyrics))
|
||||
syncedList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "eng", []byte(metadata+"\n"+syncedLyrics))
|
||||
synced, _ := syncedList.Main()
|
||||
lyricsJson, err := json.Marshal(model.LyricList{
|
||||
synced,
|
||||
|
||||
@ -78,7 +78,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
||||
|
||||
When("client disconnects (context is cancelled)", func() {
|
||||
It("should not call the service if cancelled before the call", func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx, cancel := context.WithCancel(GinkgoT().Context())
|
||||
r := newGetRequest("id=34", "size=128", "square=true")
|
||||
r = r.WithContext(ctx)
|
||||
cancel()
|
||||
@ -93,7 +93,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
||||
})
|
||||
|
||||
It("should not return data if cancelled during the call", func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx, cancel := context.WithCancel(GinkgoT().Context())
|
||||
defer cancel()
|
||||
r := newGetRequest("id=34", "size=128", "square=true")
|
||||
r = r.WithContext(ctx)
|
||||
@ -113,7 +113,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
||||
Describe("GetLyrics", func() {
|
||||
It("should return data for given artist & title", func() {
|
||||
r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up")
|
||||
lyricsList, _ := model.ParseLyrics(".lrc", "eng", []byte("[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I"))
|
||||
lyricsList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "eng", []byte("[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I"))
|
||||
lyrics, _ := lyricsList.Main()
|
||||
lyricsJson, err := json.Marshal(model.LyricList{
|
||||
lyrics,
|
||||
|
||||
@ -143,9 +143,11 @@ const SongList = (props) => {
|
||||
return {
|
||||
album: isDesktop && <AlbumLinkField source="album" sortByOrder={'ASC'} />,
|
||||
artist: <ArtistLinkField source="artist" />,
|
||||
composer: <ArtistLinkField source="composer" />,
|
||||
composer: <ArtistLinkField source="composer" sortable={false} />,
|
||||
albumArtist: <ArtistLinkField source="albumArtist" />,
|
||||
trackNumber: isDesktop && <NumberField source="trackNumber" />,
|
||||
trackNumber: isDesktop && (
|
||||
<NumberField source="trackNumber" sortable={false} />
|
||||
),
|
||||
playCount: isDesktop && (
|
||||
<NumberField source="playCount" sortByOrder={'DESC'} />
|
||||
),
|
||||
|
||||
@ -13,6 +13,9 @@ import CatppuccinLatteTheme from './catppuccinLatte'
|
||||
import DraculaTheme from './dracula'
|
||||
import NuclearTheme from './nuclear'
|
||||
import NutballTheme from './nutball'
|
||||
import RosePineTheme from './rosePine'
|
||||
import RosePineDawnTheme from './rosePineDawn'
|
||||
import RosePineMoonTheme from './rosePineMoon'
|
||||
import AmusicTheme from './amusic'
|
||||
import SquiddiesGlassTheme from './SquiddiesGlass'
|
||||
import NautilineTheme from './nautiline'
|
||||
@ -43,6 +46,9 @@ export default {
|
||||
NordTheme,
|
||||
NuclearTheme,
|
||||
NutballTheme,
|
||||
RosePineDawnTheme,
|
||||
RosePineMoonTheme,
|
||||
RosePineTheme,
|
||||
SpotifyTheme,
|
||||
SquiddiesGlassTheme,
|
||||
TokyoNightLightTheme,
|
||||
|
||||
148
ui/src/themes/rosePine.css.js
Normal file
148
ui/src/themes/rosePine.css.js
Normal file
@ -0,0 +1,148 @@
|
||||
const stylesheet = `
|
||||
|
||||
.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
|
||||
color: #c4a7e7
|
||||
}
|
||||
|
||||
.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: #ebbcba
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
|
||||
background-color: #ebbcba;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
|
||||
box-shadow: 0 0 2px #ebbcba
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .audio-item.playing svg {
|
||||
color: #ebbcba
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .audio-item.playing .player-singer {
|
||||
color: #ebbcba !important
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .loading svg {
|
||||
color: #ebbcba !important
|
||||
}
|
||||
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle {
|
||||
border: none;
|
||||
box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 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;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel {
|
||||
background-color: #1f1d2e;
|
||||
color: #e0def4;
|
||||
box-shadow: 0 0 8px rgba(25, 23, 36, 0.35);
|
||||
}
|
||||
|
||||
.audio-lists-panel {
|
||||
background-color: #1f1d2e;
|
||||
bottom: 6.25rem;
|
||||
box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item.playing {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item:nth-child(2n+1) {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn {
|
||||
background-color:rgba(0,0,0,0);
|
||||
box-shadow:0 0 0 0;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item {
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .img-content {
|
||||
box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-lyric {
|
||||
color: #908caa;
|
||||
-webkit-text-stroke: 0.5px #191724;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg {
|
||||
color: #908caa !important;
|
||||
}
|
||||
|
||||
.audio-lists-panel-header {
|
||||
border-bottom:1px solid #26233a;
|
||||
box-shadow:none;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
|
||||
color: #ebbcba
|
||||
}
|
||||
|
||||
.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: #ebbcba
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item .player-icons {
|
||||
scale: 75%;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item:active,
|
||||
.audio-lists-panel-content .audio-item:hover {
|
||||
background-color: #26233a;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
|
||||
.react-jinke-music-player-mobile-cover {
|
||||
border: none;
|
||||
box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.react-jinke-music-player .music-player-controller {
|
||||
border: none;
|
||||
background-color: #1f1d2e;
|
||||
border-color: #1f1d2e;
|
||||
box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px;
|
||||
color: #ebbcba;
|
||||
}
|
||||
|
||||
.react-jinke-music-player .music-player-controller .music-player-controller-setting {
|
||||
color: rgba(196,167,231,.3);
|
||||
}
|
||||
|
||||
.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track {
|
||||
background-color: #ebbcba;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-mobile-progress .rc-slider-handle {
|
||||
border: none;
|
||||
}
|
||||
`
|
||||
|
||||
export default stylesheet
|
||||
108
ui/src/themes/rosePine.js
Normal file
108
ui/src/themes/rosePine.js
Normal file
@ -0,0 +1,108 @@
|
||||
import stylesheet from './rosePine.css.js'
|
||||
|
||||
export default {
|
||||
themeName: 'Rosé Pine',
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#ebbcba',
|
||||
},
|
||||
secondary: {
|
||||
main: '#1f1d2e',
|
||||
contrastText: '#e0def4',
|
||||
},
|
||||
type: 'dark',
|
||||
background: {
|
||||
default: '#191724',
|
||||
paper: '#1f1d2e',
|
||||
},
|
||||
},
|
||||
overrides: {
|
||||
MuiPaper: {
|
||||
root: {
|
||||
color: '#e0def4',
|
||||
backgroundColor: '#1f1d2e',
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
textPrimary: {
|
||||
color: '#31748f',
|
||||
},
|
||||
textSecondary: {
|
||||
color: '#e0def4',
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
colorSecondary: {
|
||||
color: '#6e6a86',
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
clickable: {
|
||||
background: '#26233a',
|
||||
},
|
||||
},
|
||||
MuiCheckbox: {
|
||||
colorSecondary: {
|
||||
color: '#6e6a86',
|
||||
'&$checked': {
|
||||
color: '#ebbcba',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiFormGroup: {
|
||||
root: {
|
||||
color: '#e0def4',
|
||||
},
|
||||
},
|
||||
MuiFormHelperText: {
|
||||
root: {
|
||||
'&$error': {
|
||||
color: '#eb6f92',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTableHead: {
|
||||
root: {
|
||||
color: '#e0def4',
|
||||
background: '#1f1d2e',
|
||||
},
|
||||
},
|
||||
MuiTableCell: {
|
||||
root: {
|
||||
color: '#e0def4',
|
||||
background: '#1f1d2e !important',
|
||||
},
|
||||
head: {
|
||||
color: '#e0def4',
|
||||
background: '#1f1d2e !important',
|
||||
},
|
||||
},
|
||||
NDLogin: {
|
||||
systemNameLink: {
|
||||
color: '#ebbcba',
|
||||
},
|
||||
icon: {},
|
||||
welcome: {
|
||||
color: '#e0def4',
|
||||
},
|
||||
card: {
|
||||
minWidth: 300,
|
||||
background: '#191724',
|
||||
},
|
||||
avatar: {},
|
||||
button: {
|
||||
boxShadow: '3px 3px 5px rgba(25, 23, 36, 0.35)',
|
||||
},
|
||||
},
|
||||
NDMobileArtistDetails: {
|
||||
bgContainer: {
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(25, 23, 36, 0.72), rgb(25, 23, 36))!important',
|
||||
},
|
||||
},
|
||||
},
|
||||
player: {
|
||||
theme: 'dark',
|
||||
stylesheet,
|
||||
},
|
||||
}
|
||||
198
ui/src/themes/rosePineDawn.css.js
Normal file
198
ui/src/themes/rosePineDawn.css.js
Normal file
@ -0,0 +1,198 @@
|
||||
const stylesheet = `
|
||||
.react-jinke-music-player-main.light-theme svg,
|
||||
.react-jinke-music-player .music-player-controller,
|
||||
.react-jinke-music-player .audio-circle-process-bar circle[class='stroke'] {
|
||||
color: #797593;
|
||||
stroke: #797593;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main svg:active,
|
||||
.react-jinke-music-player-main svg:hover {
|
||||
color: #907aa9;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme svg:active,
|
||||
.react-jinke-music-player-main.light-theme svg:hover {
|
||||
color: #907aa9;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-mobile-play-model-tip,
|
||||
.react-jinke-music-player-main.light-theme .play-mode-title {
|
||||
background-color: #d7827e;
|
||||
color: #faf4ed;
|
||||
}
|
||||
|
||||
.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: #d7827e;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
|
||||
background-color: #d7827e;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
|
||||
box-shadow: 0 0 2px #d7827e;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .audio-item.playing svg {
|
||||
color: #d7827e;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .audio-item.playing .player-singer {
|
||||
color: #d7827e !important;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .loading svg {
|
||||
color: #d7827e !important;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle {
|
||||
border: none;
|
||||
box-shadow:
|
||||
rgba(70, 66, 97, 0.12) 0px 4px 6px,
|
||||
rgba(70, 66, 97, 0.08) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.rc-slider-rail,
|
||||
.rc-slider-track {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.rc-slider {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .rc-switch-checked {
|
||||
background-color: #d7827e !important;
|
||||
border: 1px solid #d7827e;
|
||||
}
|
||||
|
||||
.sound-operation > div:nth-child(4) {
|
||||
transform: translateX(-50%) translateY(5%) !important;
|
||||
}
|
||||
|
||||
.sound-operation {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel {
|
||||
background-color: #fffaf3;
|
||||
color: #464261;
|
||||
box-shadow: 0 0 8px rgba(70, 66, 97, 0.12);
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .music-player-panel {
|
||||
color: #464261;
|
||||
}
|
||||
|
||||
.audio-lists-panel {
|
||||
background-color: #fffaf3;
|
||||
bottom: 6.25rem;
|
||||
box-shadow:
|
||||
rgba(70, 66, 97, 0.12) 0px 4px 6px,
|
||||
rgba(70, 66, 97, 0.08) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item.playing {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item:nth-child(2n+1) {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.audio-lists-panel-header {
|
||||
border-bottom: 1px solid #f2e9e1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
box-shadow: 0 0 0 0;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main.light-theme .audio-lists-panel-header {
|
||||
background-color: #fffaf3;
|
||||
color: #464261;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item {
|
||||
line-height: 32px;
|
||||
color: #464261;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .img-content {
|
||||
box-shadow:
|
||||
rgba(70, 66, 97, 0.12) 0px 4px 6px,
|
||||
rgba(70, 66, 97, 0.08) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-lyric {
|
||||
color: #797593;
|
||||
-webkit-text-stroke: 0.35px #faf4ed;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .lyric-btn-active,
|
||||
.react-jinke-music-player-main .lyric-btn-active svg {
|
||||
color: #797593 !important;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item.playing,
|
||||
.audio-lists-panel-content .audio-item.playing svg {
|
||||
color: #d7827e;
|
||||
}
|
||||
|
||||
.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: #d7827e;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item .player-icons {
|
||||
scale: 75%;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item:active,
|
||||
.audio-lists-panel-content .audio-item:hover {
|
||||
background-color: #f2e9e1;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
.react-jinke-music-player-mobile-cover {
|
||||
border: none;
|
||||
box-shadow:
|
||||
rgba(70, 66, 97, 0.12) 0px 4px 6px,
|
||||
rgba(70, 66, 97, 0.08) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.react-jinke-music-player .music-player-controller {
|
||||
border: none;
|
||||
background-color: #fffaf3;
|
||||
border-color: #fffaf3;
|
||||
box-shadow:
|
||||
rgba(70, 66, 97, 0.12) 0px 4px 6px,
|
||||
rgba(70, 66, 97, 0.08) 0px 5px 7px;
|
||||
color: #d7827e;
|
||||
}
|
||||
|
||||
.react-jinke-music-player .music-player-controller.music-player-playing:before {
|
||||
border: 1px solid rgba(70, 66, 97, 0.18);
|
||||
}
|
||||
|
||||
.react-jinke-music-player .music-player-controller .music-player-controller-setting {
|
||||
background: rgba(215, 130, 126, 0.2);
|
||||
color: #faf4ed;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-mobile-progress .rc-slider-handle,
|
||||
.react-jinke-music-player-mobile-progress .rc-slider-track {
|
||||
background-color: #d7827e;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-mobile-progress .rc-slider-handle {
|
||||
border: none;
|
||||
}
|
||||
`
|
||||
|
||||
export default stylesheet
|
||||
108
ui/src/themes/rosePineDawn.js
Normal file
108
ui/src/themes/rosePineDawn.js
Normal file
@ -0,0 +1,108 @@
|
||||
import stylesheet from './rosePineDawn.css.js'
|
||||
|
||||
export default {
|
||||
themeName: 'Rosé Pine Dawn',
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#d7827e',
|
||||
},
|
||||
secondary: {
|
||||
main: '#fffaf3',
|
||||
contrastText: '#464261',
|
||||
},
|
||||
type: 'light',
|
||||
background: {
|
||||
default: '#faf4ed',
|
||||
paper: '#fffaf3',
|
||||
},
|
||||
},
|
||||
overrides: {
|
||||
MuiPaper: {
|
||||
root: {
|
||||
color: '#464261',
|
||||
backgroundColor: '#fffaf3',
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
textPrimary: {
|
||||
color: '#286983',
|
||||
},
|
||||
textSecondary: {
|
||||
color: '#464261',
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
colorSecondary: {
|
||||
color: '#9893a5',
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
clickable: {
|
||||
background: '#f2e9e1',
|
||||
},
|
||||
},
|
||||
MuiCheckbox: {
|
||||
colorSecondary: {
|
||||
color: '#9893a5',
|
||||
'&$checked': {
|
||||
color: '#d7827e',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiFormGroup: {
|
||||
root: {
|
||||
color: '#464261',
|
||||
},
|
||||
},
|
||||
MuiFormHelperText: {
|
||||
root: {
|
||||
'&$error': {
|
||||
color: '#b4637a',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTableHead: {
|
||||
root: {
|
||||
color: '#464261',
|
||||
background: '#fffaf3',
|
||||
},
|
||||
},
|
||||
MuiTableCell: {
|
||||
root: {
|
||||
color: '#464261',
|
||||
background: '#fffaf3 !important',
|
||||
},
|
||||
head: {
|
||||
color: '#464261',
|
||||
background: '#fffaf3 !important',
|
||||
},
|
||||
},
|
||||
NDLogin: {
|
||||
systemNameLink: {
|
||||
color: '#d7827e',
|
||||
},
|
||||
icon: {},
|
||||
welcome: {
|
||||
color: '#464261',
|
||||
},
|
||||
card: {
|
||||
minWidth: 300,
|
||||
background: '#faf4ed',
|
||||
},
|
||||
avatar: {},
|
||||
button: {
|
||||
boxShadow: '3px 3px 5px rgba(87, 82, 121, 0.12)',
|
||||
},
|
||||
},
|
||||
NDMobileArtistDetails: {
|
||||
bgContainer: {
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(250, 244, 237, 0.72), rgb(250, 244, 237))!important',
|
||||
},
|
||||
},
|
||||
},
|
||||
player: {
|
||||
theme: 'light',
|
||||
stylesheet,
|
||||
},
|
||||
}
|
||||
148
ui/src/themes/rosePineMoon.css.js
Normal file
148
ui/src/themes/rosePineMoon.css.js
Normal file
@ -0,0 +1,148 @@
|
||||
const stylesheet = `
|
||||
|
||||
.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
|
||||
color: #c4a7e7
|
||||
}
|
||||
|
||||
.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: #ea9a97
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
|
||||
background-color: #ea9a97;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
|
||||
box-shadow: 0 0 2px #ea9a97
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .audio-item.playing svg {
|
||||
color: #ea9a97
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .audio-item.playing .player-singer {
|
||||
color: #ea9a97 !important
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .loading svg {
|
||||
color: #ea9a97 !important
|
||||
}
|
||||
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle {
|
||||
border: none;
|
||||
box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 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;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel {
|
||||
background-color: #2a273f;
|
||||
color: #e0def4;
|
||||
box-shadow: 0 0 8px rgba(35, 33, 54, 0.35);
|
||||
}
|
||||
|
||||
.audio-lists-panel {
|
||||
background-color: #2a273f;
|
||||
bottom: 6.25rem;
|
||||
box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item.playing {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item:nth-child(2n+1) {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn {
|
||||
background-color:rgba(0,0,0,0);
|
||||
box-shadow:0 0 0 0;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item {
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .img-content {
|
||||
box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .music-player-lyric {
|
||||
color: #908caa;
|
||||
-webkit-text-stroke: 0.5px #232136;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg {
|
||||
color: #908caa !important;
|
||||
}
|
||||
|
||||
.audio-lists-panel-header {
|
||||
border-bottom:1px solid #393552;
|
||||
box-shadow:none;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
|
||||
color: #ea9a97
|
||||
}
|
||||
|
||||
.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: #ea9a97
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item .player-icons {
|
||||
scale: 75%;
|
||||
}
|
||||
|
||||
.audio-lists-panel-content .audio-item:active,
|
||||
.audio-lists-panel-content .audio-item:hover {
|
||||
background-color: #393552;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
|
||||
.react-jinke-music-player-mobile-cover {
|
||||
border: none;
|
||||
box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px;
|
||||
}
|
||||
|
||||
.react-jinke-music-player .music-player-controller {
|
||||
border: none;
|
||||
background-color: #2a273f;
|
||||
border-color: #2a273f;
|
||||
box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px;
|
||||
color: #ea9a97;
|
||||
}
|
||||
|
||||
.react-jinke-music-player .music-player-controller .music-player-controller-setting {
|
||||
color: rgba(196,167,231,.3);
|
||||
}
|
||||
|
||||
.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track {
|
||||
background-color: #ea9a97;
|
||||
}
|
||||
|
||||
.react-jinke-music-player-mobile-progress .rc-slider-handle {
|
||||
border: none;
|
||||
}
|
||||
`
|
||||
|
||||
export default stylesheet
|
||||
108
ui/src/themes/rosePineMoon.js
Normal file
108
ui/src/themes/rosePineMoon.js
Normal file
@ -0,0 +1,108 @@
|
||||
import stylesheet from './rosePineMoon.css.js'
|
||||
|
||||
export default {
|
||||
themeName: 'Rosé Pine Moon',
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#ea9a97',
|
||||
},
|
||||
secondary: {
|
||||
main: '#2a273f',
|
||||
contrastText: '#e0def4',
|
||||
},
|
||||
type: 'dark',
|
||||
background: {
|
||||
default: '#232136',
|
||||
paper: '#2a273f',
|
||||
},
|
||||
},
|
||||
overrides: {
|
||||
MuiPaper: {
|
||||
root: {
|
||||
color: '#e0def4',
|
||||
backgroundColor: '#2a273f',
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
textPrimary: {
|
||||
color: '#3e8fb0',
|
||||
},
|
||||
textSecondary: {
|
||||
color: '#e0def4',
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
colorSecondary: {
|
||||
color: '#6e6a86',
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
clickable: {
|
||||
background: '#393552',
|
||||
},
|
||||
},
|
||||
MuiCheckbox: {
|
||||
colorSecondary: {
|
||||
color: '#6e6a86',
|
||||
'&$checked': {
|
||||
color: '#ea9a97',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiFormGroup: {
|
||||
root: {
|
||||
color: '#e0def4',
|
||||
},
|
||||
},
|
||||
MuiFormHelperText: {
|
||||
root: {
|
||||
'&$error': {
|
||||
color: '#eb6f92',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTableHead: {
|
||||
root: {
|
||||
color: '#e0def4',
|
||||
background: '#2a273f',
|
||||
},
|
||||
},
|
||||
MuiTableCell: {
|
||||
root: {
|
||||
color: '#e0def4',
|
||||
background: '#2a273f !important',
|
||||
},
|
||||
head: {
|
||||
color: '#e0def4',
|
||||
background: '#2a273f !important',
|
||||
},
|
||||
},
|
||||
NDLogin: {
|
||||
systemNameLink: {
|
||||
color: '#ea9a97',
|
||||
},
|
||||
icon: {},
|
||||
welcome: {
|
||||
color: '#e0def4',
|
||||
},
|
||||
card: {
|
||||
minWidth: 300,
|
||||
background: '#232136',
|
||||
},
|
||||
avatar: {},
|
||||
button: {
|
||||
boxShadow: '3px 3px 5px rgba(35, 33, 54, 0.35)',
|
||||
},
|
||||
},
|
||||
NDMobileArtistDetails: {
|
||||
bgContainer: {
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(35, 33, 54, 0.72), rgb(35, 33, 54))!important',
|
||||
},
|
||||
},
|
||||
},
|
||||
player: {
|
||||
theme: 'dark',
|
||||
stylesheet,
|
||||
},
|
||||
}
|
||||
45
utils/str/normalize_fts.go
Normal file
45
utils/str/normalize_fts.go
Normal file
@ -0,0 +1,45 @@
|
||||
package str
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/deluan/sanitize"
|
||||
)
|
||||
|
||||
// FTSPunctStrip matches any character that is not a letter or number. Index-time
|
||||
// normalization (NormalizeForFTS) and query-time processing in persistence share it
|
||||
// so both sides produce matching tokens.
|
||||
var FTSPunctStrip = regexp.MustCompile(`[^\p{L}\p{N}]`)
|
||||
|
||||
// NormalizeForFTS takes multiple strings and returns a space-separated, deduplicated list of
|
||||
// alternative searchable forms for each word: punctuation-stripped (R.E.M. → REM, AC/DC → ACDC)
|
||||
// and ASCII-transliterated (Bjørk → Bjork, œuvre → oeuvre). The transliterated form is needed
|
||||
// because FTS5's `unicode61 remove_diacritics 2` only handles NFKD-decomposable diacritics —
|
||||
// atomic letters like ø/æ/œ/ß survive tokenization, so the query side and index side disagree
|
||||
// without an explicit transliterated entry here.
|
||||
func NormalizeForFTS(values ...string) string {
|
||||
seen := make(map[string]struct{})
|
||||
var result []string
|
||||
add := func(orig, variant string) {
|
||||
if variant == "" || variant == orig {
|
||||
return
|
||||
}
|
||||
lower := strings.ToLower(variant)
|
||||
if _, ok := seen[lower]; ok {
|
||||
return
|
||||
}
|
||||
seen[lower] = struct{}{}
|
||||
result = append(result, variant)
|
||||
}
|
||||
for _, v := range values {
|
||||
for word := range strings.FieldsSeq(v) {
|
||||
transliterated := sanitize.Accents(word)
|
||||
// Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne.
|
||||
add(word, FTSPunctStrip.ReplaceAllString(transliterated, ""))
|
||||
// Accent-only transliteration for words without name-punctuation (Bjørk → Bjork).
|
||||
add(word, transliterated)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, " ")
|
||||
}
|
||||
29
utils/str/normalize_fts_test.go
Normal file
29
utils/str/normalize_fts_test.go
Normal file
@ -0,0 +1,29 @@
|
||||
package str_test
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = DescribeTable("NormalizeForFTS",
|
||||
func(expected string, values ...string) {
|
||||
Expect(str.NormalizeForFTS(values...)).To(Equal(expected))
|
||||
},
|
||||
Entry("strips dots and concatenates", "REM", "R.E.M."),
|
||||
Entry("strips slash", "ACDC", "AC/DC"),
|
||||
Entry("strips hyphen", "Aha", "A-ha"),
|
||||
Entry("skips unchanged ASCII words", "", "The Beatles"),
|
||||
Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"),
|
||||
Entry("deduplicates", "REM", "R.E.M.", "R.E.M."),
|
||||
Entry("strips apostrophe from word", "N", "Guns N' Roses"),
|
||||
Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"),
|
||||
Entry("transliterates ø to o", "Bjork", "Bjørk"),
|
||||
Entry("transliterates Ø to O", "Oystein", "Øystein"),
|
||||
Entry("transliterates œ ligature to oe", "oeuvre", "œuvre"),
|
||||
Entry("transliterates Latin diacritics", "cafe", "café"),
|
||||
Entry("transliterates only the non-ASCII words", "Mo Ros", "Mø Rós"),
|
||||
Entry("combines punctuation strip and transliteration", "StEtienne St-Etienne", "St-Étienne"),
|
||||
Entry("deduplicates against punctuation form", "Cafe", "Café", "Cafe"),
|
||||
Entry("transliterates ß to ss", "Strasse", "Straße"),
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user