mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-18 23:25:30 -05:00
Compare commits
10 Commits
update-tra
...
custom-col
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4b1830513 | ||
|
|
eca4c5acf0 | ||
|
|
e766a5d780 | ||
|
|
90d6cd5f47 | ||
|
|
24ab04581a | ||
|
|
8e647a0e41 | ||
|
|
86c326bd4a | ||
|
|
929e7193b4 | ||
|
|
9bcefea0ca | ||
|
|
b0cb40b029 |
9
db/db.go
9
db/db.go
@@ -6,7 +6,9 @@ import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/maruel/natural"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
_ "github.com/navidrome/navidrome/db/migrations"
|
||||
@@ -31,7 +33,12 @@ func Db() *sql.DB {
|
||||
return singleton.GetInstance(func() *sql.DB {
|
||||
sql.Register(Driver, &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
|
||||
if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.RegisterCollation("NATURALSORT", func(a, b string) int {
|
||||
return natural.Compare(strings.ToLower(a), strings.ToLower(b))
|
||||
})
|
||||
},
|
||||
})
|
||||
Path = conf.Server.DbPath
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
-- +goose Up
|
||||
|
||||
-- Change order_*/sort_* column collation from NOCASE to NATURALSORT.
|
||||
-- This way bare ORDER BY on these columns automatically uses natural sorting,
|
||||
-- without needing explicit COLLATE NATURALSORT in every query.
|
||||
PRAGMA writable_schema = ON;
|
||||
UPDATE sqlite_master
|
||||
SET sql = replace(sql, 'collate NOCASE', 'collate NATURALSORT')
|
||||
WHERE type = 'table' AND name IN ('artist', 'album', 'media_file', 'playlist', 'radio');
|
||||
PRAGMA writable_schema = OFF;
|
||||
|
||||
-- Recreate indexes on order_* and sort expression fields to use NATURALSORT collation.
|
||||
-- This enables natural number ordering (e.g., "Album 2" before "Album 10").
|
||||
|
||||
-- Artist indexes
|
||||
drop index if exists artist_order_artist_name;
|
||||
create index artist_order_artist_name
|
||||
on artist (order_artist_name collate NATURALSORT);
|
||||
|
||||
drop index if exists artist_sort_name;
|
||||
create index artist_sort_name
|
||||
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT);
|
||||
|
||||
-- Album indexes
|
||||
drop index if exists album_order_album_name;
|
||||
create index album_order_album_name
|
||||
on album (order_album_name collate NATURALSORT);
|
||||
|
||||
drop index if exists album_order_album_artist_name;
|
||||
create index album_order_album_artist_name
|
||||
on album (order_album_artist_name collate NATURALSORT);
|
||||
|
||||
drop index if exists album_alphabetical_by_artist;
|
||||
create index album_alphabetical_by_artist
|
||||
on album (compilation, order_album_artist_name collate NATURALSORT, order_album_name collate NATURALSORT);
|
||||
|
||||
drop index if exists album_sort_name;
|
||||
create index album_sort_name
|
||||
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT);
|
||||
|
||||
drop index if exists album_sort_album_artist_name;
|
||||
create index album_sort_album_artist_name
|
||||
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NATURALSORT);
|
||||
|
||||
-- Media file indexes
|
||||
drop index if exists media_file_order_title;
|
||||
create index media_file_order_title
|
||||
on media_file (order_title collate NATURALSORT);
|
||||
|
||||
drop index if exists media_file_order_album_name;
|
||||
create index media_file_order_album_name
|
||||
on media_file (order_album_name collate NATURALSORT);
|
||||
|
||||
drop index if exists media_file_order_artist_name;
|
||||
create index media_file_order_artist_name
|
||||
on media_file (order_artist_name collate NATURALSORT);
|
||||
|
||||
drop index if exists media_file_sort_title;
|
||||
create index media_file_sort_title
|
||||
on media_file (coalesce(nullif(sort_title,''),order_title) collate NATURALSORT);
|
||||
|
||||
drop index if exists media_file_sort_artist_name;
|
||||
create index media_file_sort_artist_name
|
||||
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT);
|
||||
|
||||
drop index if exists media_file_sort_album_name;
|
||||
create index media_file_sort_album_name
|
||||
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT);
|
||||
|
||||
-- Playlist and radio indexes: recreate to match new NATURALSORT column collation
|
||||
drop index if exists playlist_name;
|
||||
create index playlist_name
|
||||
on playlist (name collate NATURALSORT);
|
||||
|
||||
drop index if exists radio_name;
|
||||
create index radio_name
|
||||
on radio (name collate NATURALSORT);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
-- Restore NOCASE column collation
|
||||
PRAGMA writable_schema = ON;
|
||||
UPDATE sqlite_master
|
||||
SET sql = replace(sql, 'collate NATURALSORT', 'collate NOCASE')
|
||||
WHERE type = 'table' AND name IN ('artist', 'album', 'media_file', 'playlist', 'radio');
|
||||
PRAGMA writable_schema = OFF;
|
||||
|
||||
-- Restore NOCASE collation indexes
|
||||
|
||||
-- Artist indexes
|
||||
drop index if exists artist_order_artist_name;
|
||||
create index artist_order_artist_name
|
||||
on artist (order_artist_name);
|
||||
|
||||
drop index if exists artist_sort_name;
|
||||
create index artist_sort_name
|
||||
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
|
||||
|
||||
-- Album indexes
|
||||
drop index if exists album_order_album_name;
|
||||
create index album_order_album_name
|
||||
on album (order_album_name);
|
||||
|
||||
drop index if exists album_order_album_artist_name;
|
||||
create index album_order_album_artist_name
|
||||
on album (order_album_artist_name);
|
||||
|
||||
drop index if exists album_alphabetical_by_artist;
|
||||
create index album_alphabetical_by_artist
|
||||
on album (compilation, order_album_artist_name, order_album_name);
|
||||
|
||||
drop index if exists album_sort_name;
|
||||
create index album_sort_name
|
||||
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
|
||||
|
||||
drop index if exists album_sort_album_artist_name;
|
||||
create index album_sort_album_artist_name
|
||||
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NOCASE);
|
||||
|
||||
-- Media file indexes
|
||||
drop index if exists media_file_order_title;
|
||||
create index media_file_order_title
|
||||
on media_file (order_title);
|
||||
|
||||
drop index if exists media_file_order_album_name;
|
||||
create index media_file_order_album_name
|
||||
on media_file (order_album_name);
|
||||
|
||||
drop index if exists media_file_order_artist_name;
|
||||
create index media_file_order_artist_name
|
||||
on media_file (order_artist_name);
|
||||
|
||||
drop index if exists media_file_sort_title;
|
||||
create index media_file_sort_title
|
||||
on media_file (coalesce(nullif(sort_title,''),order_title) collate NOCASE);
|
||||
|
||||
drop index if exists media_file_sort_artist_name;
|
||||
create index media_file_sort_artist_name
|
||||
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
|
||||
|
||||
drop index if exists media_file_sort_album_name;
|
||||
create index media_file_sort_album_name
|
||||
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
|
||||
|
||||
-- Restore playlist and radio indexes
|
||||
drop index if exists playlist_name;
|
||||
create index playlist_name
|
||||
on playlist (name);
|
||||
|
||||
drop index if exists radio_name;
|
||||
create index radio_name
|
||||
on radio (name);
|
||||
@@ -17,45 +17,45 @@ import (
|
||||
var _ = Describe("Collation", func() {
|
||||
conn := db.Db()
|
||||
DescribeTable("Column collation",
|
||||
func(table, column string) {
|
||||
Expect(checkCollation(conn, table, column)).To(Succeed())
|
||||
func(table, column, expectedCollation string) {
|
||||
Expect(checkCollation(conn, table, column, expectedCollation)).To(Succeed())
|
||||
},
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name"),
|
||||
Entry("artist.sort_artist_name", "artist", "sort_artist_name"),
|
||||
Entry("album.order_album_name", "album", "order_album_name"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name"),
|
||||
Entry("album.sort_album_name", "album", "sort_album_name"),
|
||||
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name"),
|
||||
Entry("media_file.order_title", "media_file", "order_title"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name"),
|
||||
Entry("media_file.sort_title", "media_file", "sort_title"),
|
||||
Entry("media_file.sort_album_name", "media_file", "sort_album_name"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"),
|
||||
Entry("playlist.name", "playlist", "name"),
|
||||
Entry("radio.name", "radio", "name"),
|
||||
Entry("user.name", "user", "name"),
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name", "NATURALSORT"),
|
||||
Entry("artist.sort_artist_name", "artist", "sort_artist_name", "NATURALSORT"),
|
||||
Entry("album.order_album_name", "album", "order_album_name", "NATURALSORT"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name", "NATURALSORT"),
|
||||
Entry("album.sort_album_name", "album", "sort_album_name", "NATURALSORT"),
|
||||
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name", "NATURALSORT"),
|
||||
Entry("media_file.order_title", "media_file", "order_title", "NATURALSORT"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name", "NATURALSORT"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name", "NATURALSORT"),
|
||||
Entry("media_file.sort_title", "media_file", "sort_title", "NATURALSORT"),
|
||||
Entry("media_file.sort_album_name", "media_file", "sort_album_name", "NATURALSORT"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name", "NATURALSORT"),
|
||||
Entry("playlist.name", "playlist", "name", "NATURALSORT"),
|
||||
Entry("radio.name", "radio", "name", "NATURALSORT"),
|
||||
Entry("user.name", "user", "name", "NOCASE"),
|
||||
)
|
||||
|
||||
DescribeTable("Index collation",
|
||||
func(table, column string) {
|
||||
Expect(checkIndexUsage(conn, table, column)).To(Succeed())
|
||||
},
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name collate nocase"),
|
||||
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
||||
Entry("album.order_album_name", "album", "order_album_name collate nocase"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate nocase"),
|
||||
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
||||
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate nocase"),
|
||||
Entry("media_file.order_title", "media_file", "order_title collate nocase"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name collate nocase"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate nocase"),
|
||||
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate nocase"),
|
||||
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name collate NATURALSORT"),
|
||||
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT"),
|
||||
Entry("album.order_album_name", "album", "order_album_name collate NATURALSORT"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate NATURALSORT"),
|
||||
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT"),
|
||||
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NATURALSORT"),
|
||||
Entry("media_file.order_title", "media_file", "order_title collate NATURALSORT"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name collate NATURALSORT"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate NATURALSORT"),
|
||||
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate NATURALSORT"),
|
||||
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT"),
|
||||
Entry("media_file.path", "media_file", "path collate nocase"),
|
||||
Entry("playlist.name", "playlist", "name collate nocase"),
|
||||
Entry("radio.name", "radio", "name collate nocase"),
|
||||
Entry("playlist.name", "playlist", "name collate NATURALSORT"),
|
||||
Entry("radio.name", "radio", "name collate NATURALSORT"),
|
||||
Entry("user.user_name", "user", "user_name collate nocase"),
|
||||
)
|
||||
})
|
||||
@@ -91,7 +91,7 @@ order by %[2]s`, table, column))
|
||||
return errors.New("no rows returned")
|
||||
}
|
||||
|
||||
func checkCollation(conn *sql.DB, table string, column string) error {
|
||||
func checkCollation(conn *sql.DB, table, column, expectedCollation string) error {
|
||||
rows, err := conn.Query(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND tbl_name='%s'", table))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -113,12 +113,12 @@ func checkCollation(conn *sql.DB, table string, column string) error {
|
||||
if !re.MatchString(res) {
|
||||
return fmt.Errorf("column '%s' not found in table '%s'", column, table)
|
||||
}
|
||||
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+NOCASE`, column))
|
||||
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+%s`, column, expectedCollation))
|
||||
if re.MatchString(res) {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("table '%s' not found", table)
|
||||
}
|
||||
return fmt.Errorf("column '%s' in table '%s' does not have NOCASE collation", column, table)
|
||||
return fmt.Errorf("column '%s' in table '%s' does not have %s collation", column, table, expectedCollation)
|
||||
}
|
||||
|
||||
@@ -82,11 +82,11 @@ func (e existsCond) ToSql() (string, []any, error) {
|
||||
|
||||
var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`)
|
||||
|
||||
// Convert the order_* columns to an expression using sort_* columns. Example:
|
||||
// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
|
||||
// mapSortOrder converts order_* columns to an expression using sort_* columns with NATURALSORT collation. Example:
|
||||
// order_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate NATURALSORT)
|
||||
// It finds order column names anywhere in the substring
|
||||
func mapSortOrder(tableName, order string) string {
|
||||
order = strings.ToLower(order)
|
||||
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName)
|
||||
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate NATURALSORT)", tableName)
|
||||
return sortOrderRegex.ReplaceAllString(order, repl)
|
||||
}
|
||||
|
||||
@@ -94,13 +94,13 @@ var _ = Describe("Helpers", func() {
|
||||
sort := "ORDER_ALBUM_NAME asc"
|
||||
mapped := mapSortOrder("album", sort)
|
||||
Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` +
|
||||
` collate nocase) asc`))
|
||||
` collate NATURALSORT) asc`))
|
||||
})
|
||||
It("changes multiple order columns to sort expressions", func() {
|
||||
sort := "compilation, order_title asc, order_album_artist_name desc, year desc"
|
||||
mapped := mapSortOrder("album", sort)
|
||||
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` +
|
||||
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`))
|
||||
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate NATURALSORT) asc,` +
|
||||
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate NATURALSORT) desc, year desc`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -674,8 +674,7 @@
|
||||
"exportSuccess": "Η διαμόρφωση εξήχθη στο πρόχειρο σε μορφή TOML",
|
||||
"exportFailed": "Η αντιγραφή της διαμόρφωσης απέτυχε",
|
||||
"devFlagsHeader": "Σημαίες Ανάπτυξης (υπόκειται σε αλλαγές / αφαίρεση)",
|
||||
"devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις",
|
||||
"downloadToml": "Λήψη διαμόρφωσης (TOML)"
|
||||
"devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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} 分鐘前"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user