Compare commits

..

10 Commits

Author SHA1 Message Date
Deluan
b4b1830513 refactor: remove outdated comments regarding NATURALSORT collation behavior
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 11:35:36 -05:00
Deluan Quintão
eca4c5acf0 Merge branch 'master' into custom-collation-function 2026-02-17 11:27:15 -05:00
Deluan
e766a5d780 refactor: extend NATURALSORT collation to playlist and radio indexes
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 10:42:05 -05:00
Deluan
90d6cd5f47 refactor: update collation handling for natural sorting in SQL queries
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 10:37:08 -05:00
Deluan
24ab04581a fix format
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 09:45:34 -05:00
Deluan
8e647a0e41 chore(deps): bump golangci-lint to v2.10.0 and suppress new gosec false positives
Bump golangci-lint from v2.9.0 to v2.10.0, which includes a newer gosec
with additional taint-analysis rules (G117, G703, G704, G705) and a
stricter G101 check. Added inline //nolint:gosec comments to suppress
21 false positives across 19 files: struct fields flagged as secrets
(G117), w.Write calls flagged as XSS (G705), HTTP client calls flagged
as SSRF (G704), os.Stat/os.ReadFile/os.Remove flagged as path traversal
(G703), and a sort mapping flagged as hardcoded credentials (G101).

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 09:31:37 -05:00
Deluan Quintão
86c326bd4a fix doc
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-17 09:30:18 -05:00
Deluan
929e7193b4 refactor: use natural.Compare directly instead of wrapper 2026-02-17 09:00:23 -05:00
Deluan
9bcefea0ca refactor: use maruel/natural for NATURALSORT collation instead of custom impl
Replace the hand-rolled natural sort comparison with a thin wrapper
around github.com/maruel/natural.Compare, which is already a dependency.
The wrapper just lowercases both inputs for case-insensitive comparison.
2026-02-17 09:00:20 -05:00
Deluan
b0cb40b029 feat: add custom NATURALSORT collation for natural number ordering
Register a custom SQLite collation function (NATURALSORT) that compares
strings using natural sort ordering, where embedded numeric sequences are
compared as numbers rather than lexicographically. This fixes the issue
where albums like "Bravo Hits 1-132" sort as 1, 10, 100... instead of
1, 2, 3... 10, 11...

Closes navidrome/navidrome#4891
2026-02-17 09:00:15 -05:00
14 changed files with 286 additions and 132 deletions

View File

@@ -6,7 +6,9 @@ import (
"embed"
"fmt"
"runtime"
"strings"
"github.com/maruel/natural"
"github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
_ "github.com/navidrome/navidrome/db/migrations"
@@ -31,7 +33,12 @@ func Db() *sql.DB {
return singleton.GetInstance(func() *sql.DB {
sql.Register(Driver, &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil {
return err
}
return conn.RegisterCollation("NATURALSORT", func(a, b string) int {
return natural.Compare(strings.ToLower(a), strings.ToLower(b))
})
},
})
Path = conf.Server.DbPath

View File

@@ -0,0 +1,152 @@
-- +goose Up
-- Change order_*/sort_* column collation from NOCASE to NATURALSORT.
-- This way bare ORDER BY on these columns automatically uses natural sorting,
-- without needing explicit COLLATE NATURALSORT in every query.
PRAGMA writable_schema = ON;
UPDATE sqlite_master
SET sql = replace(sql, 'collate NOCASE', 'collate NATURALSORT')
WHERE type = 'table' AND name IN ('artist', 'album', 'media_file', 'playlist', 'radio');
PRAGMA writable_schema = OFF;
-- Recreate indexes on order_* and sort expression fields to use NATURALSORT collation.
-- This enables natural number ordering (e.g., "Album 2" before "Album 10").
-- Artist indexes
drop index if exists artist_order_artist_name;
create index artist_order_artist_name
on artist (order_artist_name collate NATURALSORT);
drop index if exists artist_sort_name;
create index artist_sort_name
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT);
-- Album indexes
drop index if exists album_order_album_name;
create index album_order_album_name
on album (order_album_name collate NATURALSORT);
drop index if exists album_order_album_artist_name;
create index album_order_album_artist_name
on album (order_album_artist_name collate NATURALSORT);
drop index if exists album_alphabetical_by_artist;
create index album_alphabetical_by_artist
on album (compilation, order_album_artist_name collate NATURALSORT, order_album_name collate NATURALSORT);
drop index if exists album_sort_name;
create index album_sort_name
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT);
drop index if exists album_sort_album_artist_name;
create index album_sort_album_artist_name
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NATURALSORT);
-- Media file indexes
drop index if exists media_file_order_title;
create index media_file_order_title
on media_file (order_title collate NATURALSORT);
drop index if exists media_file_order_album_name;
create index media_file_order_album_name
on media_file (order_album_name collate NATURALSORT);
drop index if exists media_file_order_artist_name;
create index media_file_order_artist_name
on media_file (order_artist_name collate NATURALSORT);
drop index if exists media_file_sort_title;
create index media_file_sort_title
on media_file (coalesce(nullif(sort_title,''),order_title) collate NATURALSORT);
drop index if exists media_file_sort_artist_name;
create index media_file_sort_artist_name
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT);
drop index if exists media_file_sort_album_name;
create index media_file_sort_album_name
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT);
-- Playlist and radio indexes: recreate to match new NATURALSORT column collation
drop index if exists playlist_name;
create index playlist_name
on playlist (name collate NATURALSORT);
drop index if exists radio_name;
create index radio_name
on radio (name collate NATURALSORT);
-- +goose Down
-- Restore NOCASE column collation
PRAGMA writable_schema = ON;
UPDATE sqlite_master
SET sql = replace(sql, 'collate NATURALSORT', 'collate NOCASE')
WHERE type = 'table' AND name IN ('artist', 'album', 'media_file', 'playlist', 'radio');
PRAGMA writable_schema = OFF;
-- Restore NOCASE collation indexes
-- Artist indexes
drop index if exists artist_order_artist_name;
create index artist_order_artist_name
on artist (order_artist_name);
drop index if exists artist_sort_name;
create index artist_sort_name
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
-- Album indexes
drop index if exists album_order_album_name;
create index album_order_album_name
on album (order_album_name);
drop index if exists album_order_album_artist_name;
create index album_order_album_artist_name
on album (order_album_artist_name);
drop index if exists album_alphabetical_by_artist;
create index album_alphabetical_by_artist
on album (compilation, order_album_artist_name, order_album_name);
drop index if exists album_sort_name;
create index album_sort_name
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
drop index if exists album_sort_album_artist_name;
create index album_sort_album_artist_name
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NOCASE);
-- Media file indexes
drop index if exists media_file_order_title;
create index media_file_order_title
on media_file (order_title);
drop index if exists media_file_order_album_name;
create index media_file_order_album_name
on media_file (order_album_name);
drop index if exists media_file_order_artist_name;
create index media_file_order_artist_name
on media_file (order_artist_name);
drop index if exists media_file_sort_title;
create index media_file_sort_title
on media_file (coalesce(nullif(sort_title,''),order_title) collate NOCASE);
drop index if exists media_file_sort_artist_name;
create index media_file_sort_artist_name
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
drop index if exists media_file_sort_album_name;
create index media_file_sort_album_name
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
-- Restore playlist and radio indexes
drop index if exists playlist_name;
create index playlist_name
on playlist (name);
drop index if exists radio_name;
create index radio_name
on radio (name);

View File

@@ -17,45 +17,45 @@ import (
var _ = Describe("Collation", func() {
conn := db.Db()
DescribeTable("Column collation",
func(table, column string) {
Expect(checkCollation(conn, table, column)).To(Succeed())
func(table, column, expectedCollation string) {
Expect(checkCollation(conn, table, column, expectedCollation)).To(Succeed())
},
Entry("artist.order_artist_name", "artist", "order_artist_name"),
Entry("artist.sort_artist_name", "artist", "sort_artist_name"),
Entry("album.order_album_name", "album", "order_album_name"),
Entry("album.order_album_artist_name", "album", "order_album_artist_name"),
Entry("album.sort_album_name", "album", "sort_album_name"),
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name"),
Entry("media_file.order_title", "media_file", "order_title"),
Entry("media_file.order_album_name", "media_file", "order_album_name"),
Entry("media_file.order_artist_name", "media_file", "order_artist_name"),
Entry("media_file.sort_title", "media_file", "sort_title"),
Entry("media_file.sort_album_name", "media_file", "sort_album_name"),
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"),
Entry("playlist.name", "playlist", "name"),
Entry("radio.name", "radio", "name"),
Entry("user.name", "user", "name"),
Entry("artist.order_artist_name", "artist", "order_artist_name", "NATURALSORT"),
Entry("artist.sort_artist_name", "artist", "sort_artist_name", "NATURALSORT"),
Entry("album.order_album_name", "album", "order_album_name", "NATURALSORT"),
Entry("album.order_album_artist_name", "album", "order_album_artist_name", "NATURALSORT"),
Entry("album.sort_album_name", "album", "sort_album_name", "NATURALSORT"),
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name", "NATURALSORT"),
Entry("media_file.order_title", "media_file", "order_title", "NATURALSORT"),
Entry("media_file.order_album_name", "media_file", "order_album_name", "NATURALSORT"),
Entry("media_file.order_artist_name", "media_file", "order_artist_name", "NATURALSORT"),
Entry("media_file.sort_title", "media_file", "sort_title", "NATURALSORT"),
Entry("media_file.sort_album_name", "media_file", "sort_album_name", "NATURALSORT"),
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name", "NATURALSORT"),
Entry("playlist.name", "playlist", "name", "NATURALSORT"),
Entry("radio.name", "radio", "name", "NATURALSORT"),
Entry("user.name", "user", "name", "NOCASE"),
)
DescribeTable("Index collation",
func(table, column string) {
Expect(checkIndexUsage(conn, table, column)).To(Succeed())
},
Entry("artist.order_artist_name", "artist", "order_artist_name collate nocase"),
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
Entry("album.order_album_name", "album", "order_album_name collate nocase"),
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate nocase"),
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate nocase"),
Entry("media_file.order_title", "media_file", "order_title collate nocase"),
Entry("media_file.order_album_name", "media_file", "order_album_name collate nocase"),
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate nocase"),
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate nocase"),
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
Entry("artist.order_artist_name", "artist", "order_artist_name collate NATURALSORT"),
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT"),
Entry("album.order_album_name", "album", "order_album_name collate NATURALSORT"),
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate NATURALSORT"),
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT"),
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NATURALSORT"),
Entry("media_file.order_title", "media_file", "order_title collate NATURALSORT"),
Entry("media_file.order_album_name", "media_file", "order_album_name collate NATURALSORT"),
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate NATURALSORT"),
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate NATURALSORT"),
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT"),
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT"),
Entry("media_file.path", "media_file", "path collate nocase"),
Entry("playlist.name", "playlist", "name collate nocase"),
Entry("radio.name", "radio", "name collate nocase"),
Entry("playlist.name", "playlist", "name collate NATURALSORT"),
Entry("radio.name", "radio", "name collate NATURALSORT"),
Entry("user.user_name", "user", "user_name collate nocase"),
)
})
@@ -91,7 +91,7 @@ order by %[2]s`, table, column))
return errors.New("no rows returned")
}
func checkCollation(conn *sql.DB, table string, column string) error {
func checkCollation(conn *sql.DB, table, column, expectedCollation string) error {
rows, err := conn.Query(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND tbl_name='%s'", table))
if err != nil {
return err
@@ -113,12 +113,12 @@ func checkCollation(conn *sql.DB, table string, column string) error {
if !re.MatchString(res) {
return fmt.Errorf("column '%s' not found in table '%s'", column, table)
}
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+NOCASE`, column))
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+%s`, column, expectedCollation))
if re.MatchString(res) {
return nil
}
} else {
return fmt.Errorf("table '%s' not found", table)
}
return fmt.Errorf("column '%s' in table '%s' does not have NOCASE collation", column, table)
return fmt.Errorf("column '%s' in table '%s' does not have %s collation", column, table, expectedCollation)
}

View File

@@ -82,11 +82,11 @@ func (e existsCond) ToSql() (string, []any, error) {
var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`)
// Convert the order_* columns to an expression using sort_* columns. Example:
// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
// mapSortOrder converts order_* columns to an expression using sort_* columns with NATURALSORT collation. Example:
// order_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate NATURALSORT)
// It finds order column names anywhere in the substring
func mapSortOrder(tableName, order string) string {
order = strings.ToLower(order)
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName)
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate NATURALSORT)", tableName)
return sortOrderRegex.ReplaceAllString(order, repl)
}

View File

@@ -94,13 +94,13 @@ var _ = Describe("Helpers", func() {
sort := "ORDER_ALBUM_NAME asc"
mapped := mapSortOrder("album", sort)
Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` +
` collate nocase) asc`))
` collate NATURALSORT) asc`))
})
It("changes multiple order columns to sort expressions", func() {
sort := "compilation, order_title asc, order_album_artist_name desc, year desc"
mapped := mapSortOrder("album", sort)
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` +
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`))
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate NATURALSORT) asc,` +
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate NATURALSORT) desc, year desc`))
})
})
})

View File

@@ -71,8 +71,8 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun
//
// If PreferSortTags is enabled, it will map the order fields to the corresponding sort expression,
// which gives precedence to sort tags.
// Ex: order_title => (coalesce(nullif(sort_title,),order_title) collate nocase)
// To avoid performance issues, indexes should be created for these sort expressions
// Ex: order_title => (coalesce(nullif(sort_title,""), order_title) collate NATURALSORT)
// To avoid performance issues, indexes should be created for these sort expressions.
//
// NOTE: if an individual item has spaces, it should be wrapped in parentheses. For example,
// you should write "(lyrics != '[]')". This prevents the item being split unexpectedly.

View File

@@ -675,7 +675,7 @@
"exportFailed": "Kunne ikke kopiere konfigurationen",
"devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)",
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver",
"downloadToml": "Download konfigurationen (TOML)"
"downloadToml": ""
}
},
"activity": {

View File

@@ -674,8 +674,7 @@
"exportSuccess": "Konfiguration im TOML Format in die Zwischenablage kopiert",
"exportFailed": "Fehler beim Kopieren der Konfiguration",
"devFlagsHeader": "Entwicklungseinstellungen (können sich ändern)",
"devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden",
"downloadToml": "Konfiguration Herunterladen (TOML)"
"devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden"
}
},
"activity": {

View File

@@ -674,8 +674,7 @@
"exportSuccess": "Η διαμόρφωση εξήχθη στο πρόχειρο σε μορφή TOML",
"exportFailed": "Η αντιγραφή της διαμόρφωσης απέτυχε",
"devFlagsHeader": "Σημαίες Ανάπτυξης (υπόκειται σε αλλαγές / αφαίρεση)",
"devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις",
"downloadToml": "Λήψη διαμόρφωσης (TOML)"
"devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις"
}
},
"activity": {

View File

@@ -674,8 +674,7 @@
"exportSuccess": "Configuración exportada al portapapeles en formato TOML",
"exportFailed": "Error al copiar la configuración",
"devFlagsHeader": "Indicadores de desarrollo (sujetos a cambios o eliminación)",
"devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras",
"downloadToml": "Descargar la configuración (TOML)"
"devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras"
}
},
"activity": {

View File

@@ -674,8 +674,7 @@
"exportSuccess": "La configuration a été copiée vers le presse-papier au format TOML",
"exportFailed": "Une erreur est survenue en copiant la configuration",
"devFlagsHeader": "Options de développement (peuvent être amenés à changer / être supprimés)",
"devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur",
"downloadToml": "Télécharger la configuration (TOML)"
"devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur"
}
},
"activity": {

View File

@@ -674,8 +674,7 @@
"exportSuccess": "Inställningarna kopierade till urklippet i TOML-format",
"exportFailed": "Kopiering av inställningarna misslyckades",
"devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)",
"devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner",
"downloadToml": "Ladda ner konfiguration (TOML)"
"devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner"
}
},
"activity": {

View File

@@ -10,14 +10,19 @@
"playCount": "播放次數",
"title": "標題",
"artist": "藝人",
"composer": "作曲者",
"album": "專輯",
"path": "檔案路徑",
"libraryName": "媒體庫",
"genre": "曲風",
"compilation": "合輯",
"year": "發行年份",
"size": "檔案大小",
"updatedAt": "更新於",
"bitRate": "位元率",
"bitDepth": "位元深度",
"sampleRate": "取樣率",
"channels": "聲道",
"discSubtitle": "光碟副標題",
"starred": "收藏",
"comment": "註解",
@@ -25,7 +30,6 @@
"quality": "品質",
"bpm": "BPM",
"playDate": "上次播放",
"channels": "聲道",
"createdAt": "建立於",
"grouping": "分組",
"mood": "情緒",
@@ -33,21 +37,17 @@
"tags": "額外標籤",
"mappedTags": "分類後標籤",
"rawTags": "原始標籤",
"bitDepth": "位元深度",
"sampleRate": "取樣率",
"missing": "遺失",
"libraryName": "媒體庫",
"composer": "作曲者"
"missing": "遺失"
},
"actions": {
"addToQueue": "加入至播放佇列",
"playNow": "立即播放",
"addToPlaylist": "加入至播放清單",
"showInPlaylist": "在播放清單中顯示",
"shuffleAll": "全部隨機播放",
"download": "下載",
"playNext": "下一首播放",
"info": "取得資訊",
"showInPlaylist": "在播放清單中顯示",
"instantMix": "即時混音"
}
},
@@ -59,38 +59,38 @@
"duration": "長度",
"songCount": "歌曲數",
"playCount": "播放次數",
"size": "檔案大小",
"name": "名稱",
"libraryName": "媒體庫",
"genre": "曲風",
"compilation": "合輯",
"year": "發行年份",
"updatedAt": "更新於",
"comment": "註解",
"rating": "評分",
"createdAt": "建立於",
"size": "檔案大小",
"date": "錄製日期",
"originalDate": "原始日期",
"releaseDate": "發行日期",
"releases": "發行",
"released": "已發行",
"updatedAt": "更新於",
"comment": "註解",
"rating": "評分",
"createdAt": "建立於",
"recordLabel": "唱片公司",
"catalogNum": "目錄編號",
"releaseType": "發行類型",
"grouping": "分組",
"media": "媒體類型",
"mood": "情緒",
"date": "錄製日期",
"missing": "遺失",
"libraryName": "媒體庫"
"missing": "遺失"
},
"actions": {
"playAll": "播放全部",
"playNext": "下一首播放",
"addToQueue": "加入至播放佇列",
"share": "分享",
"shuffle": "隨機播放",
"addToPlaylist": "加入至播放清單",
"download": "下載",
"info": "取得資訊",
"share": "分享"
"info": "取得資訊"
},
"lists": {
"all": "所有",
@@ -108,10 +108,10 @@
"name": "名稱",
"albumCount": "專輯數",
"songCount": "歌曲數",
"size": "檔案大小",
"playCount": "播放次數",
"rating": "評分",
"genre": "曲風",
"size": "檔案大小",
"role": "參與角色",
"missing": "遺失"
},
@@ -132,9 +132,9 @@
"maincredit": "專輯藝人或藝人 |||| 專輯藝人或藝人"
},
"actions": {
"topSongs": "熱門歌曲",
"shuffle": "隨機播放",
"radio": "電台",
"topSongs": "熱門歌曲"
"radio": "電台"
}
},
"user": {
@@ -143,6 +143,7 @@
"userName": "使用者名稱",
"isAdmin": "管理員",
"lastLoginAt": "上次登入",
"lastAccessAt": "上次存取",
"updatedAt": "更新於",
"name": "名稱",
"password": "密碼",
@@ -151,7 +152,6 @@
"currentPassword": "目前密碼",
"newPassword": "新密碼",
"token": "權杖",
"lastAccessAt": "上次存取",
"libraries": "媒體庫"
},
"helperTexts": {
@@ -163,14 +163,14 @@
"updated": "使用者已更新",
"deleted": "使用者已刪除"
},
"validation": {
"librariesRequired": "非管理員使用者必須至少選擇一個媒體庫"
},
"message": {
"listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖",
"clickHereForToken": "點擊此處來獲得您的 ListenBrainz 權杖",
"selectAllLibraries": "選取全部媒體庫",
"adminAutoLibraries": "管理員預設可存取所有媒體庫"
},
"validation": {
"librariesRequired": "非管理員使用者必須至少選擇一個媒體庫"
}
},
"player": {
@@ -213,9 +213,9 @@
"selectPlaylist": "選取播放清單:",
"addNewPlaylist": "建立「%{name}」",
"export": "匯出",
"saveQueue": "將播放佇列儲存到播放清單",
"makePublic": "設為公開",
"makePrivate": "設為私人",
"saveQueue": "將播放佇列儲存到播放清單",
"searchOrCreate": "搜尋播放清單,或輸入名稱來新建…",
"pressEnterToCreate": "按 Enter 鍵建立新的播放清單",
"removeFromSelection": "移除選取項目"
@@ -246,6 +246,7 @@
"username": "分享者",
"url": "網址",
"description": "描述",
"downloadable": "允許下載?",
"contents": "內容",
"expiresAt": "過期時間",
"lastVisitedAt": "上次造訪時間",
@@ -253,17 +254,19 @@
"format": "格式",
"maxBitRate": "最大位元率",
"updatedAt": "更新於",
"createdAt": "建立於",
"downloadable": "允許下載?"
}
"createdAt": "建立於"
},
"notifications": {},
"actions": {}
},
"missing": {
"name": "遺失檔案 |||| 遺失檔案",
"empty": "無遺失檔案",
"fields": {
"path": "路徑",
"size": "檔案大小",
"updatedAt": "遺失於",
"libraryName": "媒體庫"
"libraryName": "媒體庫",
"updatedAt": "遺失於"
},
"actions": {
"remove": "刪除",
@@ -271,8 +274,7 @@
},
"notifications": {
"removed": "遺失檔案已刪除"
},
"empty": "無遺失檔案"
}
},
"library": {
"name": "媒體庫 |||| 媒體庫",
@@ -302,20 +304,20 @@
},
"actions": {
"scan": "掃描媒體庫",
"manageUsers": "管理使用者權限",
"viewDetails": "查看詳細資料",
"quickScan": "快速掃描",
"fullScan": "完整掃描"
"fullScan": "完整掃描",
"manageUsers": "管理使用者權限",
"viewDetails": "查看詳細資料"
},
"notifications": {
"created": "成功建立媒體庫",
"updated": "成功更新媒體庫",
"deleted": "成功刪除媒體庫",
"scanStarted": "開始掃描媒體庫",
"scanCompleted": "媒體庫掃描完成",
"quickScanStarted": "快速掃描已開始",
"fullScanStarted": "完整掃描已開始",
"scanError": "掃描啟動失敗,請檢查日誌"
"scanError": "掃描啟動失敗,請檢查日誌",
"scanCompleted": "媒體庫掃描完成"
},
"validation": {
"nameRequired": "請輸入媒體庫名稱",
@@ -387,6 +389,8 @@
},
"messages": {
"configHelp": "使用鍵值對設定插件。若插件無需設定則留空。",
"configValidationError": "設定驗證失敗:",
"schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。",
"clickPermissions": "點擊權限以查看詳細資訊",
"noConfig": "無設定",
"allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。",
@@ -396,9 +400,7 @@
"allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。",
"noLibraries": "未選擇媒體庫",
"librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。",
"requiredHosts": "必要的 Hosts",
"configValidationError": "設定驗證失敗:",
"schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。"
"requiredHosts": "必要的 Hosts"
},
"placeholders": {
"configKey": "鍵",
@@ -441,6 +443,7 @@
"add": "加入",
"back": "返回",
"bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "取消",
"clear_input_value": "清除",
"clone": "複製",
@@ -464,7 +467,6 @@
"close_menu": "關閉選單",
"unselect": "取消選取",
"skip": "略過",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "分享",
"download": "下載"
},
@@ -556,42 +558,48 @@
"transcodingDisabled": "出於安全原因,已禁用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 設定選項的情況下重新啟動伺服器。",
"transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。",
"songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單",
"noSimilarSongsFound": "找不到相似歌曲",
"startingInstantMix": "正在載入即時混音...",
"noTopSongsFound": "找不到熱門歌曲",
"noPlaylistsAvailable": "沒有可用的播放清單",
"delete_user_title": "刪除使用者「%{name}」",
"delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?",
"remove_missing_title": "刪除遺失檔案",
"remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。",
"remove_all_missing_title": "刪除所有遺失檔案",
"remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。",
"notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知",
"notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome",
"lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄",
"lastfmLinkFailure": "無法連接 Last.fm",
"lastfmUnlinkSuccess": "已取消與 Last.fm 的連接並停用音樂記錄",
"lastfmUnlinkFailure": "無法取消與 Last.fm 的連接",
"listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄",
"listenBrainzLinkFailure": "無法連接 ListenBrainz%{error}",
"listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄",
"listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接",
"openIn": {
"lastfm": "在 Last.fm 中開啟",
"musicbrainz": "在 MusicBrainz 中開啟"
},
"lastfmLink": "查看更多…",
"listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄",
"listenBrainzLinkFailure": "無法連接 ListenBrainz%{error}",
"listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄",
"listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接",
"downloadOriginalFormat": "下載原始格式",
"shareOriginalFormat": "分享原始格式",
"shareDialogTitle": "分享 %{resource} '%{name}'",
"shareBatchDialogTitle": "分享 1 個%{resource} |||| 分享 %{smart_count} 個%{resource}",
"shareCopyToClipboard": "複製到剪貼簿Ctrl+C, Enter",
"shareSuccess": "分享成功,連結已複製到剪貼簿:%{url}",
"shareFailure": "分享連結複製失敗:%{url}",
"downloadDialogTitle": "下載 %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "複製到剪貼簿Ctrl+C, Enter",
"remove_missing_title": "刪除遺失檔案",
"remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。",
"remove_all_missing_title": "刪除所有遺失檔案",
"remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。",
"noSimilarSongsFound": "找不到相似歌曲",
"noTopSongsFound": "找不到熱門歌曲",
"startingInstantMix": "正在載入即時混音..."
"downloadOriginalFormat": "下載原始格式"
},
"menu": {
"library": "媒體庫",
"librarySelector": {
"allLibraries": "所有媒體庫 (%{count})",
"multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
"selectLibraries": "選取媒體庫",
"none": "無"
},
"settings": "設定",
"version": "版本",
"theme": "主題",
@@ -602,6 +610,7 @@
"language": "語言",
"defaultView": "預設畫面",
"desktop_notifications": "桌面通知",
"lastfmNotConfigured": "Last.fm API 金鑰未設定",
"lastfmScrobbling": "啟用 Last.fm 音樂記錄",
"listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄",
"replaygain": "重播增益模式",
@@ -610,20 +619,13 @@
"none": "無",
"album": "專輯增益",
"track": "曲目增益"
},
"lastfmNotConfigured": "Last.fm API 金鑰未設定"
}
}
},
"albumList": "專輯",
"about": "關於",
"playlists": "播放清單",
"sharedPlaylists": "分享的播放清單",
"librarySelector": {
"allLibraries": "所有媒體庫 (%{count})",
"multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
"selectLibraries": "選取媒體庫",
"none": "無"
}
"about": "關於"
},
"player": {
"playListsText": "播放佇列",
@@ -674,8 +676,7 @@
"exportSuccess": "設定已以 TOML 格式匯出至剪貼簿",
"exportFailed": "設定複製失敗",
"devFlagsHeader": "開發旗標(可能會更改/刪除)",
"devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除",
"downloadToml": "下載設定檔 (TOML)"
"devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除"
}
},
"activity": {
@@ -683,12 +684,17 @@
"totalScanned": "已掃描的資料夾總數",
"quickScan": "快速掃描",
"fullScan": "完全掃描",
"selectiveScan": "選擇性掃描",
"serverUptime": "伺服器運作時間",
"serverDown": "伺服器已離線",
"scanType": "掃描類型",
"status": "掃描錯誤",
"elapsedTime": "經過時間",
"selectiveScan": "選擇性掃描"
"elapsedTime": "經過時間"
},
"nowPlaying": {
"title": "正在播放",
"empty": "無播放內容",
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
},
"help": {
"title": "Navidrome 快捷鍵",
@@ -698,15 +704,10 @@
"toggle_play": "播放/暫停",
"prev_song": "上一首歌",
"next_song": "下一首歌",
"current_song": "前往目前歌曲",
"vol_up": "提高音量",
"vol_down": "降低音量",
"toggle_love": "新增此歌曲至收藏",
"current_song": "前往目前歌曲"
"toggle_love": "新增此歌曲至收藏"
}
},
"nowPlaying": {
"title": "正在播放",
"empty": "無播放內容",
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
}
}
}

View File

@@ -24,9 +24,8 @@ type Broker interface {
const (
keepAliveFrequency = 15 * time.Second
// The timeout must be higher than the keepAliveFrequency, or the lack of activity will cause the channel to close.
writeTimeOut = keepAliveFrequency + 5*time.Second
bufferSize = 1
writeTimeOut = 5 * time.Second
bufferSize = 1
)
type (