mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Merge branch 'master' into subsonic-folder
This commit is contained in:
commit
d3bd3107e8
@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
@ -74,7 +75,7 @@ func runScanner(ctx context.Context) {
|
|||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
defer db.Db().Close()
|
defer db.Db().Close()
|
||||||
ds := persistence.New(sqlDB)
|
ds := persistence.New(sqlDB)
|
||||||
pls := playlists.NewPlaylists(ds)
|
pls := playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
|
|
||||||
// Parse targets from command line or file
|
// Parse targets from command line or file
|
||||||
var scanTargets []model.ScanTarget
|
var scanTargets []model.ScanTarget
|
||||||
|
|||||||
@ -63,7 +63,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
|||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
imageUploadService := core.NewImageUploadService()
|
||||||
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||||
insights := metrics.GetInstance(dataStore)
|
insights := metrics.GetInstance(dataStore)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
@ -79,7 +80,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
|||||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||||
user := core.NewUser(dataStore, manager)
|
user := core.NewUser(dataStore, manager)
|
||||||
maintenance := core.NewMaintenance(dataStore)
|
maintenance := core.NewMaintenance(dataStore)
|
||||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager)
|
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,7 +101,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||||
players := core.NewPlayers(dataStore)
|
players := core.NewPlayers(dataStore)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
imageUploadService := core.NewImageUploadService()
|
||||||
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||||
playbackServer := playback.GetInstance(dataStore)
|
playbackServer := playback.GetInstance(dataStore)
|
||||||
@ -169,7 +171,8 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
|||||||
provider := external.NewProvider(dataStore, agentsAgents)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
imageUploadService := core.NewImageUploadService()
|
||||||
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||||
return modelScanner
|
return modelScanner
|
||||||
}
|
}
|
||||||
@ -186,7 +189,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
|||||||
provider := external.NewProvider(dataStore, agentsAgents)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore)
|
imageUploadService := core.NewImageUploadService()
|
||||||
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||||
return watcher
|
return watcher
|
||||||
|
|||||||
@ -71,6 +71,7 @@ type configOptions struct {
|
|||||||
CoverArtPriority string
|
CoverArtPriority string
|
||||||
CoverArtQuality int
|
CoverArtQuality int
|
||||||
ArtistArtPriority string
|
ArtistArtPriority string
|
||||||
|
ArtistImageFolder string
|
||||||
DiscArtPriority string
|
DiscArtPriority string
|
||||||
LyricsPriority string
|
LyricsPriority string
|
||||||
EnableGravatar bool
|
EnableGravatar bool
|
||||||
|
|||||||
@ -103,6 +103,12 @@ const (
|
|||||||
DefaultCacheCleanUpInterval = 10 * time.Minute
|
DefaultCacheCleanUpInterval = 10 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Entity types
|
||||||
|
const (
|
||||||
|
EntityArtist = "artist"
|
||||||
|
EntityPlaylist = "playlist"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AlbumPlayCountModeAbsolute = "absolute"
|
AlbumPlayCountModeAbsolute = "absolute"
|
||||||
AlbumPlayCountModeNormalized = "normalized"
|
AlbumPlayCountModeNormalized = "normalized"
|
||||||
|
|||||||
@ -28,7 +28,7 @@ var _ = Describe("Artwork", func() {
|
|||||||
var ffmpeg *tests.MockFFmpeg
|
var ffmpeg *tests.MockFFmpeg
|
||||||
var folderRepo *fakeFolderRepo
|
var folderRepo *fakeFolderRepo
|
||||||
ctx := log.NewContext(context.TODO())
|
ctx := log.NewContext(context.TODO())
|
||||||
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
|
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album
|
||||||
var arMultipleCovers model.Artist
|
var arMultipleCovers model.Artist
|
||||||
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
|
var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile
|
||||||
|
|
||||||
@ -44,8 +44,9 @@ var _ = Describe("Artwork", func() {
|
|||||||
}
|
}
|
||||||
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
|
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
|
||||||
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
|
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
|
||||||
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}}
|
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}}
|
||||||
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
|
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
|
||||||
|
alSingleDisc = model.Album{ID: "888", Name: "Single disc", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}
|
||||||
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
|
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
|
||||||
alMultipleCovers = model.Album{
|
alMultipleCovers = model.Album{
|
||||||
ID: "666",
|
ID: "666",
|
||||||
@ -193,6 +194,7 @@ var _ = Describe("Artwork", func() {
|
|||||||
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||||
alOnlyEmbed,
|
alOnlyEmbed,
|
||||||
alOnlyExternal,
|
alOnlyExternal,
|
||||||
|
alSingleDisc,
|
||||||
})
|
})
|
||||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||||
mfWithEmbed,
|
mfWithEmbed,
|
||||||
@ -236,6 +238,28 @@ var _ = Describe("Artwork", func() {
|
|||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(path).To(Equal("al-444_0"))
|
Expect(path).To(Equal("al-444_0"))
|
||||||
})
|
})
|
||||||
|
It("falls back to disc cover art when media file has a disc number on a multi-disc album", func() {
|
||||||
|
mfWithDisc := model.MediaFile{ID: "46", Path: "tests/fixtures/test.ogg", AlbumID: "444", DiscNumber: 2}
|
||||||
|
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfWithDisc)).To(Succeed())
|
||||||
|
|
||||||
|
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithDisc.ID))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
_, path, err := aw.Reader(ctx)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
// Should fall back to disc art, which itself falls back to album art
|
||||||
|
Expect(path).To(Equal("dc-444:2_0"))
|
||||||
|
})
|
||||||
|
It("falls back to album cover art for single-disc albums even with a disc number", func() {
|
||||||
|
mfOnSingleDisc := model.MediaFile{ID: "47", Path: "tests/fixtures/test.ogg", AlbumID: "888", DiscNumber: 1}
|
||||||
|
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfOnSingleDisc)).To(Succeed())
|
||||||
|
|
||||||
|
aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfOnSingleDisc.ID))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
_, path, err := aw.Reader(ctx)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
// Single-disc album should skip disc art and go straight to album art
|
||||||
|
Expect(path).To(Equal("al-888_0"))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
Describe("playlistArtworkReader", func() {
|
Describe("playlistArtworkReader", func() {
|
||||||
|
|||||||
@ -59,10 +59,11 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *albumArtworkReader) Key() string {
|
func (a *albumArtworkReader) Key() string {
|
||||||
var hash [16]byte
|
hashInput := conf.Server.CoverArtPriority
|
||||||
if conf.Server.EnableExternalServices {
|
if conf.Server.EnableExternalServices {
|
||||||
hash = md5.Sum([]byte(conf.Server.Agents + conf.Server.CoverArtPriority))
|
hashInput += conf.Server.Agents
|
||||||
}
|
}
|
||||||
|
hash := md5.Sum([]byte(hashInput))
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"%s.%x.%t",
|
"%s.%x.%t",
|
||||||
a.cacheKey.Key(),
|
a.cacheKey.Key(),
|
||||||
|
|||||||
@ -29,11 +29,12 @@ const (
|
|||||||
|
|
||||||
type artistReader struct {
|
type artistReader struct {
|
||||||
cacheKey
|
cacheKey
|
||||||
a *artwork
|
a *artwork
|
||||||
provider external.Provider
|
provider external.Provider
|
||||||
artist model.Artist
|
artist model.Artist
|
||||||
artistFolder string
|
artistFolder string
|
||||||
imgFiles []string
|
imgFiles []string
|
||||||
|
imgFolderImgPath string // cached path from ArtistImageFolder lookup
|
||||||
}
|
}
|
||||||
|
|
||||||
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||||
@ -71,9 +72,20 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A
|
|||||||
//a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt
|
//a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt
|
||||||
|
|
||||||
a.cacheKey.lastUpdate = *imagesUpdatedAt
|
a.cacheKey.lastUpdate = *imagesUpdatedAt
|
||||||
|
if ar.UpdatedAt != nil && ar.UpdatedAt.After(a.cacheKey.lastUpdate) {
|
||||||
|
a.cacheKey.lastUpdate = *ar.UpdatedAt
|
||||||
|
}
|
||||||
if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) {
|
if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) {
|
||||||
a.cacheKey.lastUpdate = artistFolderLastUpdate
|
a.cacheKey.lastUpdate = artistFolderLastUpdate
|
||||||
}
|
}
|
||||||
|
if conf.Server.ArtistImageFolder != "" && strings.Contains(strings.ToLower(conf.Server.ArtistArtPriority), "image-folder") {
|
||||||
|
a.imgFolderImgPath = findImageInArtistFolder(conf.Server.ArtistImageFolder, ar.MbzArtistID, ar.Name)
|
||||||
|
if a.imgFolderImgPath != "" {
|
||||||
|
if info, err := os.Stat(a.imgFolderImgPath); err == nil && info.ModTime().After(a.cacheKey.lastUpdate) {
|
||||||
|
a.cacheKey.lastUpdate = info.ModTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
a.cacheKey.artID = artID
|
a.cacheKey.artID = artID
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
@ -93,10 +105,15 @@ func (a *artistReader) LastUpdated() time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||||
var ff = a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)
|
ff := []sourceFunc{a.fromArtistUploadedImage()}
|
||||||
|
ff = append(ff, a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)...)
|
||||||
return selectImageReader(ctx, a.artID, ff...)
|
return selectImageReader(ctx, a.artID, ff...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *artistReader) fromArtistUploadedImage() sourceFunc {
|
||||||
|
return fromLocalFile(a.artist.UploadedImagePath())
|
||||||
|
}
|
||||||
|
|
||||||
func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc {
|
func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc {
|
||||||
var ff []sourceFunc
|
var ff []sourceFunc
|
||||||
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
||||||
@ -104,6 +121,8 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
|
|||||||
switch {
|
switch {
|
||||||
case pattern == "external":
|
case pattern == "external":
|
||||||
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider))
|
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider))
|
||||||
|
case pattern == "image-folder":
|
||||||
|
ff = append(ff, a.fromArtistImageFolder(ctx))
|
||||||
case strings.HasPrefix(pattern, "album/"):
|
case strings.HasPrefix(pattern, "album/"):
|
||||||
ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
|
ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
|
||||||
default:
|
default:
|
||||||
@ -196,3 +215,51 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu
|
|||||||
}
|
}
|
||||||
return folderPath, folders[0].ImagesUpdatedAt, nil
|
return folderPath, folders[0].ImagesUpdatedAt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *artistReader) fromArtistImageFolder(ctx context.Context) sourceFunc {
|
||||||
|
return func() (io.ReadCloser, string, error) {
|
||||||
|
folder := conf.Server.ArtistImageFolder
|
||||||
|
if folder == "" {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
// Use cached path from newArtistArtworkReader if available,
|
||||||
|
// avoiding a second directory scan.
|
||||||
|
path := a.imgFolderImgPath
|
||||||
|
if path == "" {
|
||||||
|
path = findImageInArtistFolder(folder, a.artist.MbzArtistID, a.artist.Name)
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return nil, "", fmt.Errorf("no image found for artist %q in %s", a.artist.Name, folder)
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return f, path, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findImageInArtistFolder scans a folder for an image file matching the artist's MBID or name
|
||||||
|
// (case-insensitive). Returns the full path, or empty string if not found.
|
||||||
|
func findImageInArtistFolder(folder, mbzArtistID, artistName string) string {
|
||||||
|
entries, err := os.ReadDir(folder)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, candidate := range []string{mbzArtistID, artistName} {
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||||
|
if strings.EqualFold(base, candidate) && model.IsImageFile(name) {
|
||||||
|
return filepath.Join(folder, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
@ -413,6 +415,257 @@ var _ = Describe("artistArtworkReader", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("fromArtistUploadedImage", func() {
|
||||||
|
var (
|
||||||
|
tempDir string
|
||||||
|
reader *artistReader
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
tempDir = GinkgoT().TempDir()
|
||||||
|
conf.Server.DataFolder = tempDir
|
||||||
|
|
||||||
|
// Create the artwork/artist directory
|
||||||
|
Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed())
|
||||||
|
|
||||||
|
reader = &artistReader{}
|
||||||
|
})
|
||||||
|
|
||||||
|
When("artist has an uploaded image", func() {
|
||||||
|
It("returns the uploaded image", func() {
|
||||||
|
imgPath := filepath.Join(tempDir, "artwork", "artist", "ar-1_test.jpg")
|
||||||
|
Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
reader.artist = model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"}
|
||||||
|
sf := reader.fromArtistUploadedImage()
|
||||||
|
r, path, err := sf()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(r).ToNot(BeNil())
|
||||||
|
Expect(path).To(Equal(imgPath))
|
||||||
|
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(string(data)).To(Equal("uploaded artist image"))
|
||||||
|
r.Close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("artist has no uploaded image", func() {
|
||||||
|
It("returns nil reader (falls through)", func() {
|
||||||
|
reader.artist = model.Artist{ID: "ar-1"}
|
||||||
|
sf := reader.fromArtistUploadedImage()
|
||||||
|
r, path, err := sf()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(r).To(BeNil())
|
||||||
|
Expect(path).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("fromArtistImageFolder", func() {
|
||||||
|
var (
|
||||||
|
ctx context.Context
|
||||||
|
tempDir string
|
||||||
|
ar *artistReader
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
ctx = context.Background()
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
tempDir = GinkgoT().TempDir()
|
||||||
|
ar = &artistReader{}
|
||||||
|
})
|
||||||
|
|
||||||
|
When("ArtistImageFolder is not configured", func() {
|
||||||
|
It("returns nil (skips)", func() {
|
||||||
|
conf.Server.ArtistImageFolder = ""
|
||||||
|
ar.artist = model.Artist{Name: "Test Artist"}
|
||||||
|
sf := ar.fromArtistImageFolder(ctx)
|
||||||
|
r, path, err := sf()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(r).To(BeNil())
|
||||||
|
Expect(path).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("image exists matching MBID", func() {
|
||||||
|
It("finds the image by MBID", func() {
|
||||||
|
conf.Server.ArtistImageFolder = tempDir
|
||||||
|
mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
|
||||||
|
imgPath := filepath.Join(tempDir, mbid+".jpg")
|
||||||
|
Expect(os.WriteFile(imgPath, []byte("mbid image"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
|
||||||
|
sf := ar.fromArtistImageFolder(ctx)
|
||||||
|
r, path, err := sf()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(r).ToNot(BeNil())
|
||||||
|
Expect(path).To(Equal(imgPath))
|
||||||
|
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(string(data)).To(Equal("mbid image"))
|
||||||
|
r.Close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("MBID match is case-insensitive", func() {
|
||||||
|
It("finds the image regardless of case", func() {
|
||||||
|
conf.Server.ArtistImageFolder = tempDir
|
||||||
|
mbid := "F27EC8DB-AF05-4F36-916E-3D57F91ECF5E"
|
||||||
|
imgPath := filepath.Join(tempDir, "f27ec8db-af05-4f36-916e-3d57f91ecf5e.png")
|
||||||
|
Expect(os.WriteFile(imgPath, []byte("mbid case image"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
|
||||||
|
sf := ar.fromArtistImageFolder(ctx)
|
||||||
|
r, path, err := sf()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(r).ToNot(BeNil())
|
||||||
|
Expect(path).To(Equal(imgPath))
|
||||||
|
r.Close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("no MBID file exists but artist name file does", func() {
|
||||||
|
It("falls back to artist name match", func() {
|
||||||
|
conf.Server.ArtistImageFolder = tempDir
|
||||||
|
imgPath := filepath.Join(tempDir, "Test Artist.jpg")
|
||||||
|
Expect(os.WriteFile(imgPath, []byte("name image"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: "nonexistent-mbid"}
|
||||||
|
sf := ar.fromArtistImageFolder(ctx)
|
||||||
|
r, path, err := sf()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(r).ToNot(BeNil())
|
||||||
|
Expect(path).To(Equal(imgPath))
|
||||||
|
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(string(data)).To(Equal("name image"))
|
||||||
|
r.Close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("artist name match is case-insensitive", func() {
|
||||||
|
It("matches regardless of case", func() {
|
||||||
|
conf.Server.ArtistImageFolder = tempDir
|
||||||
|
imgPath := filepath.Join(tempDir, "test artist.jpg")
|
||||||
|
Expect(os.WriteFile(imgPath, []byte("case insensitive"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
ar.artist = model.Artist{Name: "Test Artist"}
|
||||||
|
sf := ar.fromArtistImageFolder(ctx)
|
||||||
|
r, path, err := sf()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(r).ToNot(BeNil())
|
||||||
|
Expect(path).To(Equal(imgPath))
|
||||||
|
r.Close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("both MBID and name files exist", func() {
|
||||||
|
It("prefers MBID over name match", func() {
|
||||||
|
conf.Server.ArtistImageFolder = tempDir
|
||||||
|
mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
|
||||||
|
mbidPath := filepath.Join(tempDir, mbid+".jpg")
|
||||||
|
namePath := filepath.Join(tempDir, "Test Artist.jpg")
|
||||||
|
Expect(os.WriteFile(mbidPath, []byte("mbid image"), 0600)).To(Succeed())
|
||||||
|
Expect(os.WriteFile(namePath, []byte("name image"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid}
|
||||||
|
sf := ar.fromArtistImageFolder(ctx)
|
||||||
|
r, path, err := sf()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(r).ToNot(BeNil())
|
||||||
|
Expect(path).To(Equal(mbidPath))
|
||||||
|
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(string(data)).To(Equal("mbid image"))
|
||||||
|
r.Close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("no matching image found", func() {
|
||||||
|
It("returns an error", func() {
|
||||||
|
conf.Server.ArtistImageFolder = tempDir
|
||||||
|
// Create an unrelated file
|
||||||
|
Expect(os.WriteFile(filepath.Join(tempDir, "other.jpg"), []byte("other"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
ar.artist = model.Artist{Name: "Test Artist"}
|
||||||
|
sf := ar.fromArtistImageFolder(ctx)
|
||||||
|
r, _, err := sf()
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(r).To(BeNil())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("no image found"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("cached imgFolderImgPath is set", func() {
|
||||||
|
It("uses cached path instead of scanning", func() {
|
||||||
|
conf.Server.ArtistImageFolder = tempDir
|
||||||
|
imgPath := filepath.Join(tempDir, "cached.jpg")
|
||||||
|
Expect(os.WriteFile(imgPath, []byte("cached image"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
ar.artist = model.Artist{Name: "Test Artist"}
|
||||||
|
ar.imgFolderImgPath = imgPath
|
||||||
|
sf := ar.fromArtistImageFolder(ctx)
|
||||||
|
r, path, err := sf()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(r).ToNot(BeNil())
|
||||||
|
Expect(path).To(Equal(imgPath))
|
||||||
|
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(string(data)).To(Equal("cached image"))
|
||||||
|
r.Close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("findImageInArtistFolder", func() {
|
||||||
|
var tempDir string
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
tempDir = GinkgoT().TempDir()
|
||||||
|
})
|
||||||
|
|
||||||
|
When("matching file exists by MBID", func() {
|
||||||
|
It("returns the file path", func() {
|
||||||
|
mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e"
|
||||||
|
imgPath := filepath.Join(tempDir, mbid+".jpg")
|
||||||
|
Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
path := findImageInArtistFolder(tempDir, mbid, "Test")
|
||||||
|
Expect(path).To(Equal(imgPath))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("matching file exists by name", func() {
|
||||||
|
It("returns the file path", func() {
|
||||||
|
imgPath := filepath.Join(tempDir, "Test Artist.png")
|
||||||
|
Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
path := findImageInArtistFolder(tempDir, "", "Test Artist")
|
||||||
|
Expect(path).To(Equal(imgPath))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("no matching file exists", func() {
|
||||||
|
It("returns empty string", func() {
|
||||||
|
path := findImageInArtistFolder(tempDir, "", "Unknown Artist")
|
||||||
|
Expect(path).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
When("folder does not exist", func() {
|
||||||
|
It("returns empty string", func() {
|
||||||
|
path := findImageInArtistFolder("/nonexistent/path", "", "Test")
|
||||||
|
Expect(path).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
type fakeFolderRepo struct {
|
type fakeFolderRepo struct {
|
||||||
|
|||||||
@ -26,16 +26,22 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
_, _, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
a := &mediafileArtworkReader{
|
a := &mediafileArtworkReader{
|
||||||
a: artwork,
|
a: artwork,
|
||||||
mediafile: *mf,
|
mediafile: *mf,
|
||||||
album: *al,
|
album: *al,
|
||||||
}
|
}
|
||||||
a.cacheKey.artID = artID
|
a.cacheKey.artID = artID
|
||||||
if al.UpdatedAt.After(mf.UpdatedAt) {
|
a.cacheKey.lastUpdate = mf.UpdatedAt
|
||||||
|
if al.UpdatedAt.After(a.cacheKey.lastUpdate) {
|
||||||
a.cacheKey.lastUpdate = al.UpdatedAt
|
a.cacheKey.lastUpdate = al.UpdatedAt
|
||||||
} else {
|
}
|
||||||
a.cacheKey.lastUpdate = mf.UpdatedAt
|
if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) {
|
||||||
|
a.cacheKey.lastUpdate = *imagesUpdatedAt
|
||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
@ -60,6 +66,12 @@ func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, str
|
|||||||
fromFFmpegTag(ctx, a.a.ffmpeg, path),
|
fromFFmpegTag(ctx, a.a.ffmpeg, path),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID()))
|
// For multi-disc albums, fall back to disc artwork first; for single-disc albums,
|
||||||
|
// skip disc resolution (it would just fall through to album art anyway).
|
||||||
|
if len(a.album.Discs) > 1 {
|
||||||
|
ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.DiscCoverArtID()))
|
||||||
|
} else {
|
||||||
|
ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID()))
|
||||||
|
}
|
||||||
return selectImageReader(ctx, a.artID, ff...)
|
return selectImageReader(ctx, a.artID, ff...)
|
||||||
}
|
}
|
||||||
|
|||||||
71
core/image_upload.go
Normal file
71
core/image_upload.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ImageUploadService interface {
|
||||||
|
SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
|
||||||
|
RemoveImage(ctx context.Context, path string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageUploadService struct{}
|
||||||
|
|
||||||
|
func NewImageUploadService() ImageUploadService {
|
||||||
|
return &imageUploadService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *imageUploadService) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) {
|
||||||
|
filename := imageFilename(entityID, name, ext)
|
||||||
|
absPath := model.UploadedImagePath(entityType, filename)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("creating image directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old image if it exists
|
||||||
|
if oldPath != "" {
|
||||||
|
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Warn(ctx, "Failed to remove old image", "path", oldPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save new image
|
||||||
|
f, err := os.Create(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("creating image file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(f, reader); err != nil {
|
||||||
|
return "", fmt.Errorf("writing image file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *imageUploadService) RemoveImage(ctx context.Context, path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("removing image %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageFilename(id, name, ext string) string {
|
||||||
|
clean := utils.CleanFileName(name)
|
||||||
|
if clean == "" {
|
||||||
|
return id + ext
|
||||||
|
}
|
||||||
|
return id + "_" + clean + ext
|
||||||
|
}
|
||||||
99
core/image_upload_test.go
Normal file
99
core/image_upload_test.go
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("ImageUploadService", func() {
|
||||||
|
var svc core.ImageUploadService
|
||||||
|
var tmpDir string
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
tmpDir = GinkgoT().TempDir()
|
||||||
|
conf.Server.DataFolder = tmpDir
|
||||||
|
svc = core.NewImageUploadService()
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("SetImage", func() {
|
||||||
|
It("creates directory and saves image file", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
reader := strings.NewReader("fake image data")
|
||||||
|
filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(filename).To(Equal("ar-1_pink_floyd.jpg"))
|
||||||
|
|
||||||
|
absPath := filepath.Join(tmpDir, "artwork", "artist", "ar-1_pink_floyd.jpg")
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(string(data)).To(Equal("fake image data"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to ID-only filename when name cleans to empty", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
reader := strings.NewReader("data")
|
||||||
|
filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(filename).To(Equal("pl-1.png"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("removes old image when replacing", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
oldDir := filepath.Join(tmpDir, "artwork", "artist")
|
||||||
|
Expect(os.MkdirAll(oldDir, 0755)).To(Succeed())
|
||||||
|
oldFile := filepath.Join(oldDir, "ar-1_old.png")
|
||||||
|
Expect(os.WriteFile(oldFile, []byte("old"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
reader := strings.NewReader("new image")
|
||||||
|
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "New Name", oldFile, reader, ".jpg")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(oldFile).ToNot(BeAnExistingFile())
|
||||||
|
|
||||||
|
newPath := filepath.Join(oldDir, "ar-1_new_name.jpg")
|
||||||
|
Expect(newPath).To(BeAnExistingFile())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("ignores missing old file without error", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
reader := strings.NewReader("data")
|
||||||
|
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("RemoveImage", func() {
|
||||||
|
It("removes the file at the given path", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := filepath.Join(tmpDir, "artwork", "artist")
|
||||||
|
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
||||||
|
path := filepath.Join(dir, "ar-1_test.jpg")
|
||||||
|
Expect(os.WriteFile(path, []byte("img"), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
err := svc.RemoveImage(ctx, path)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(path).ToNot(BeAnExistingFile())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("succeeds when file does not exist", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
err := svc.RemoveImage(ctx, "/nonexistent/file.jpg")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("succeeds with empty path", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
err := svc.RemoveImage(ctx, "")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/criteria"
|
"github.com/navidrome/navidrome/model/criteria"
|
||||||
@ -42,7 +43,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
var folder *model.Folder
|
var folder *model.Folder
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
||||||
libPath, _ := os.Getwd()
|
libPath, _ := os.Getwd()
|
||||||
// Set up library with the actual library path that matches the folder
|
// Set up library with the actual library path that matches the folder
|
||||||
@ -117,7 +118,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
|
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}}
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
|
|
||||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||||
@ -135,7 +136,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
|
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
|
|
||||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||||
@ -154,7 +155,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
|
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
|
|
||||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||||
@ -173,7 +174,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
|
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
|
|
||||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||||
@ -190,7 +191,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
|
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
|
|
||||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||||
@ -207,7 +208,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
|
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
|
|
||||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||||
@ -224,7 +225,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
|
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
|
|
||||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||||
@ -242,7 +243,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
|
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
|
|
||||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||||
@ -256,7 +257,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
tmpDir := GinkgoT().TempDir()
|
tmpDir := GinkgoT().TempDir()
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
|
|
||||||
m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n"
|
m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n"
|
||||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||||
@ -283,7 +284,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
tmpDir := GinkgoT().TempDir()
|
tmpDir := GinkgoT().TempDir()
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
|
|
||||||
m3u := "test.mp3\n"
|
m3u := "test.mp3\n"
|
||||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||||
@ -358,7 +359,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
tmpDir := GinkgoT().TempDir()
|
tmpDir := GinkgoT().TempDir()
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
|
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
|
|
||||||
// Create the playlist file on disk with the filesystem's normalization form
|
// Create the playlist file on disk with the filesystem's normalization form
|
||||||
plsFile := tmpDir + "/" + filesystemName + ".m3u"
|
plsFile := tmpDir + "/" + filesystemName + ".m3u"
|
||||||
@ -418,7 +419,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
"def.mp3", // This is playlists/def.mp3 relative to plsDir
|
"def.mp3", // This is playlists/def.mp3 relative to plsDir
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("handles relative paths that reference files in other libraries", func() {
|
It("handles relative paths that reference files in other libraries", func() {
|
||||||
@ -574,7 +575,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
// Recreate playlists service to pick up new mock
|
// Recreate playlists service to pick up new mock
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
|
|
||||||
// Create playlist in music library that references both tracks
|
// Create playlist in music library that references both tracks
|
||||||
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
|
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
|
||||||
@ -617,7 +618,7 @@ var _ = Describe("Playlists - Import", func() {
|
|||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
repo = &mockedMediaFileFromListRepo{}
|
repo = &mockedMediaFileFromListRepo{}
|
||||||
ds.MockedMediaFile = repo
|
ds.MockedMediaFile = repo
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
|
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package playlists
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -12,6 +11,7 @@ import (
|
|||||||
"github.com/bmatcuk/doublestar/v4"
|
"github.com/bmatcuk/doublestar/v4"
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
@ -50,12 +50,20 @@ type Playlists interface {
|
|||||||
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
|
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
type playlists struct {
|
// ImageUploadService is a local interface satisfied by core.ImageUploadService.
|
||||||
ds model.DataStore
|
// Defined here to avoid an import cycle between core and core/playlists.
|
||||||
|
type ImageUploadService interface {
|
||||||
|
SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
|
||||||
|
RemoveImage(ctx context.Context, path string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPlaylists(ds model.DataStore) Playlists {
|
type playlists struct {
|
||||||
return &playlists{ds: ds}
|
ds model.DataStore
|
||||||
|
imgUpload ImageUploadService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlaylists(ds model.DataStore, imgUpload ImageUploadService) Playlists {
|
||||||
|
return &playlists{ds: ds, imgUpload: imgUpload}
|
||||||
}
|
}
|
||||||
|
|
||||||
func InPath(folder model.Folder) bool {
|
func InPath(folder model.Folder) bool {
|
||||||
@ -288,33 +296,13 @@ func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.R
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := pls.ImageFilename(ext)
|
|
||||||
oldPath := pls.UploadedImagePath()
|
oldPath := pls.UploadedImagePath()
|
||||||
pls.UploadedImage = filename
|
filename, err := s.imgUpload.SetImage(ctx, consts.EntityPlaylist, pls.ID, pls.Name, oldPath, reader, ext)
|
||||||
absPath := pls.UploadedImagePath()
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
|
|
||||||
return fmt.Errorf("creating playlist images directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove old image if it exists
|
|
||||||
if oldPath != "" {
|
|
||||||
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
|
|
||||||
log.Warn(ctx, "Failed to remove old playlist image", "path", oldPath, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save new image
|
|
||||||
f, err := os.Create(absPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating playlist image file: %w", err)
|
return err
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
if _, err := io.Copy(f, reader); err != nil {
|
|
||||||
return fmt.Errorf("writing playlist image file: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pls.UploadedImage = filename
|
||||||
return s.ds.Playlist(ctx).Put(pls)
|
return s.ds.Playlist(ctx).Put(pls)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -324,10 +312,8 @@ func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if path := pls.UploadedImagePath(); path != "" {
|
if err := s.imgUpload.RemoveImage(ctx, pls.UploadedImagePath()); err != nil {
|
||||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
return err
|
||||||
log.Warn(ctx, "Failed to remove playlist image", "path", path, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pls.UploadedImage = ""
|
pls.UploadedImage = ""
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/criteria"
|
"github.com/navidrome/navidrome/model/criteria"
|
||||||
@ -41,7 +42,7 @@ var _ = Describe("Playlists", func() {
|
|||||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||||
}
|
}
|
||||||
mockPlsRepo.TracksRepo = mockTracks
|
mockPlsRepo.TracksRepo = mockTracks
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("allows owner to delete their playlist", func() {
|
It("allows owner to delete their playlist", func() {
|
||||||
@ -80,7 +81,7 @@ var _ = Describe("Playlists", func() {
|
|||||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||||
}
|
}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("creates a new playlist with owner set from context", func() {
|
It("creates a new playlist with owner set from context", func() {
|
||||||
@ -138,7 +139,7 @@ var _ = Describe("Playlists", func() {
|
|||||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||||
}
|
}
|
||||||
mockPlsRepo.TracksRepo = mockTracks
|
mockPlsRepo.TracksRepo = mockTracks
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("allows owner to update their playlist", func() {
|
It("allows owner to update their playlist", func() {
|
||||||
@ -201,7 +202,7 @@ var _ = Describe("Playlists", func() {
|
|||||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||||
}
|
}
|
||||||
mockPlsRepo.TracksRepo = mockTracks
|
mockPlsRepo.TracksRepo = mockTracks
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("allows owner to add tracks", func() {
|
It("allows owner to add tracks", func() {
|
||||||
@ -249,7 +250,7 @@ var _ = Describe("Playlists", func() {
|
|||||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||||
}
|
}
|
||||||
mockPlsRepo.TracksRepo = mockTracks
|
mockPlsRepo.TracksRepo = mockTracks
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("allows owner to remove tracks", func() {
|
It("allows owner to remove tracks", func() {
|
||||||
@ -283,7 +284,7 @@ var _ = Describe("Playlists", func() {
|
|||||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||||
}
|
}
|
||||||
mockPlsRepo.TracksRepo = mockTracks
|
mockPlsRepo.TracksRepo = mockTracks
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("allows owner to reorder", func() {
|
It("allows owner to reorder", func() {
|
||||||
@ -312,7 +313,7 @@ var _ = Describe("Playlists", func() {
|
|||||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||||
}
|
}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("saves image file and updates UploadedImage", func() {
|
It("saves image file and updates UploadedImage", func() {
|
||||||
@ -382,7 +383,7 @@ var _ = Describe("Playlists", func() {
|
|||||||
"pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"},
|
"pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"},
|
||||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||||
}
|
}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("removes file and clears UploadedImage", func() {
|
It("removes file and clears UploadedImage", func() {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/criteria"
|
"github.com/navidrome/navidrome/model/criteria"
|
||||||
@ -36,7 +37,7 @@ var _ = Describe("REST Adapter", func() {
|
|||||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||||
}
|
}
|
||||||
ps = playlists.NewPlaylists(ds)
|
ps = playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Save", func() {
|
Describe("Save", func() {
|
||||||
|
|||||||
@ -23,6 +23,8 @@ var Set = wire.NewSet(
|
|||||||
NewLibrary,
|
NewLibrary,
|
||||||
NewUser,
|
NewUser,
|
||||||
NewMaintenance,
|
NewMaintenance,
|
||||||
|
NewImageUploadService,
|
||||||
|
wire.Bind(new(playlists.ImageUploadService), new(ImageUploadService)),
|
||||||
stream.NewTranscodeDecider,
|
stream.NewTranscodeDecider,
|
||||||
agents.GetAgents,
|
agents.GetAgents,
|
||||||
external.NewProvider,
|
external.NewProvider,
|
||||||
|
|||||||
22
db/migrations/20260315233131_add_artist_uploaded_image.go
Normal file
22
db/migrations/20260315233131_add_artist_uploaded_image.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
goose.AddMigrationContext(upAddArtistUploadedImage, downAddArtistUploadedImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func upAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN uploaded_image VARCHAR(255) DEFAULT ''`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func downAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
// This code is executed when the migration is rolled back.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
74
db/migrations/20260316000000_normalize_timestamps.sql
Normal file
74
db/migrations/20260316000000_normalize_timestamps.sql
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
-- Normalize T-format timestamps (RFC3339Nano with 'T' separator) to SQLite-compatible format.
|
||||||
|
-- SQLite uses string comparison for ORDER BY on TEXT columns, so 'T' (ASCII 84) > ' ' (ASCII 32)
|
||||||
|
-- causes T-format timestamps to sort after space-format ones, breaking "Recently Added" ordering.
|
||||||
|
|
||||||
|
UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||||
|
UPDATE album SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||||
|
UPDATE album SET imported_at = replace(replace(imported_at, 'T', ' '), 'Z', '+00:00') WHERE imported_at LIKE '%T%';
|
||||||
|
UPDATE album SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%';
|
||||||
|
|
||||||
|
UPDATE media_file SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||||
|
UPDATE media_file SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||||
|
UPDATE media_file SET birth_time = replace(replace(birth_time, 'T', ' '), 'Z', '+00:00') WHERE birth_time LIKE '%T%';
|
||||||
|
|
||||||
|
UPDATE artist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||||
|
UPDATE artist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||||
|
UPDATE artist SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%';
|
||||||
|
|
||||||
|
UPDATE annotation SET play_date = replace(replace(play_date, 'T', ' '), 'Z', '+00:00') WHERE play_date LIKE '%T%';
|
||||||
|
UPDATE annotation SET starred_at = replace(replace(starred_at, 'T', ' '), 'Z', '+00:00') WHERE starred_at LIKE '%T%';
|
||||||
|
UPDATE annotation SET rated_at = replace(replace(rated_at, 'T', ' '), 'Z', '+00:00') WHERE rated_at LIKE '%T%';
|
||||||
|
|
||||||
|
UPDATE playlist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||||
|
UPDATE playlist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||||
|
UPDATE playlist SET evaluated_at = replace(replace(evaluated_at, 'T', ' '), 'Z', '+00:00') WHERE evaluated_at LIKE '%T%';
|
||||||
|
|
||||||
|
UPDATE user SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||||
|
UPDATE user SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||||
|
UPDATE user SET last_login_at = replace(replace(last_login_at, 'T', ' '), 'Z', '+00:00') WHERE last_login_at LIKE '%T%';
|
||||||
|
UPDATE user SET last_access_at = replace(replace(last_access_at, 'T', ' '), 'Z', '+00:00') WHERE last_access_at LIKE '%T%';
|
||||||
|
|
||||||
|
UPDATE player SET last_seen = replace(replace(last_seen, 'T', ' '), 'Z', '+00:00') WHERE last_seen LIKE '%T%';
|
||||||
|
|
||||||
|
UPDATE playqueue SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||||
|
UPDATE playqueue SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||||
|
|
||||||
|
UPDATE bookmark SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||||
|
UPDATE bookmark SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||||
|
|
||||||
|
UPDATE share SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||||
|
UPDATE share SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||||
|
UPDATE share SET expires_at = replace(replace(expires_at, 'T', ' '), 'Z', '+00:00') WHERE expires_at LIKE '%T%';
|
||||||
|
UPDATE share SET last_visited_at = replace(replace(last_visited_at, 'T', ' '), 'Z', '+00:00') WHERE last_visited_at LIKE '%T%';
|
||||||
|
|
||||||
|
UPDATE radio SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||||
|
UPDATE radio SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||||
|
|
||||||
|
UPDATE folder SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||||
|
UPDATE folder SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||||
|
UPDATE folder SET images_updated_at = replace(replace(images_updated_at, 'T', ' '), 'Z', '+00:00') WHERE images_updated_at LIKE '%T%';
|
||||||
|
|
||||||
|
UPDATE library SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||||
|
UPDATE library SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||||
|
UPDATE library SET last_scan_at = replace(replace(last_scan_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_at LIKE '%T%';
|
||||||
|
UPDATE library SET last_scan_started_at = replace(replace(last_scan_started_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_started_at LIKE '%T%';
|
||||||
|
|
||||||
|
UPDATE scrobble_buffer SET play_time = replace(replace(play_time, 'T', ' '), 'Z', '+00:00') WHERE play_time LIKE '%T%';
|
||||||
|
UPDATE scrobble_buffer SET enqueue_time = replace(replace(enqueue_time, 'T', ' '), 'Z', '+00:00') WHERE enqueue_time LIKE '%T%';
|
||||||
|
|
||||||
|
UPDATE plugin SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%';
|
||||||
|
UPDATE plugin SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%';
|
||||||
|
|
||||||
|
-- Replace plain indexes with expression indexes for datetime()-based sorting
|
||||||
|
DROP INDEX IF EXISTS album_created_at;
|
||||||
|
CREATE INDEX album_created_at ON album(datetime(created_at));
|
||||||
|
DROP INDEX IF EXISTS album_updated_at;
|
||||||
|
CREATE INDEX album_updated_at ON album(datetime(updated_at));
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP INDEX IF EXISTS album_created_at;
|
||||||
|
CREATE INDEX album_created_at ON album(created_at);
|
||||||
|
DROP INDEX IF EXISTS album_updated_at;
|
||||||
|
CREATE INDEX album_updated_at ON album(updated_at);
|
||||||
@ -4,6 +4,8 @@ import (
|
|||||||
"maps"
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
@ -34,6 +36,8 @@ type Artist struct {
|
|||||||
|
|
||||||
Missing bool `structs:"missing" json:"missing"`
|
Missing bool `structs:"missing" json:"missing"`
|
||||||
|
|
||||||
|
UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"`
|
||||||
|
|
||||||
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
|
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
|
||||||
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
|
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
|
||||||
}
|
}
|
||||||
@ -58,6 +62,10 @@ func (a Artist) CoverArtID() ArtworkID {
|
|||||||
return artworkIDFromArtist(a)
|
return artworkIDFromArtist(a)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a Artist) UploadedImagePath() string {
|
||||||
|
return UploadedImagePath(consts.EntityArtist, a.UploadedImage)
|
||||||
|
}
|
||||||
|
|
||||||
// Roles returns the roles this artist has participated in., based on the Stats field
|
// Roles returns the roles this artist has participated in., based on the Stats field
|
||||||
func (a Artist) Roles() []Role {
|
func (a Artist) Roles() []Role {
|
||||||
return slices.Collect(maps.Keys(a.Stats))
|
return slices.Collect(maps.Keys(a.Stats))
|
||||||
|
|||||||
30
model/artist_test.go
Normal file
30
model/artist_test.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package model_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Artist", func() {
|
||||||
|
Describe("UploadedImagePath", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.DataFolder = "/data"
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns empty string when no image uploaded", func() {
|
||||||
|
a := model.Artist{ID: "ar-1"}
|
||||||
|
Expect(a.UploadedImagePath()).To(BeEmpty())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns full path when image is set", func() {
|
||||||
|
a := model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"}
|
||||||
|
Expect(a.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "artist", "ar-1_test.jpg")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
17
model/image.go
Normal file
17
model/image.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UploadedImagePath returns the absolute filesystem path for a manually uploaded
|
||||||
|
// entity cover image. Returns empty string if filename is empty.
|
||||||
|
func UploadedImagePath(entityType, filename string) string {
|
||||||
|
if filename == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, entityType, filename)
|
||||||
|
}
|
||||||
@ -119,7 +119,16 @@ func (mf MediaFile) CoverArtID() ArtworkID {
|
|||||||
if mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt {
|
if mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt {
|
||||||
return artworkIDFromMediaFile(mf)
|
return artworkIDFromMediaFile(mf)
|
||||||
}
|
}
|
||||||
// if it does not have a coverArt, fallback to the album cover
|
// Otherwise fallback to disc (if available) or album cover
|
||||||
|
return mf.DiscCoverArtID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscCoverArtID returns the disc artwork ID when the media file has a disc number,
|
||||||
|
// otherwise it returns the album artwork ID.
|
||||||
|
func (mf MediaFile) DiscCoverArtID() ArtworkID {
|
||||||
|
if mf.DiscNumber > 0 {
|
||||||
|
return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil)
|
||||||
|
}
|
||||||
return mf.AlbumCoverArtID()
|
return mf.AlbumCoverArtID()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -504,13 +504,26 @@ var _ = Describe("MediaFile", func() {
|
|||||||
Expect(id.Kind).To(Equal(KindMediaFileArtwork))
|
Expect(id.Kind).To(Equal(KindMediaFileArtwork))
|
||||||
Expect(id.ID).To(Equal(mf.ID))
|
Expect(id.ID).To(Equal(mf.ID))
|
||||||
})
|
})
|
||||||
It("returns its album id if HasCoverArt is false", func() {
|
It("returns disc art id if HasCoverArt is false and DiscNumber > 0", func() {
|
||||||
|
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false, DiscNumber: 2}
|
||||||
|
id := mf.CoverArtID()
|
||||||
|
Expect(id.Kind).To(Equal(KindDiscArtwork))
|
||||||
|
Expect(id.ID).To(Equal("1:2"))
|
||||||
|
})
|
||||||
|
It("returns its album id if HasCoverArt is false and DiscNumber is 0", func() {
|
||||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false}
|
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false}
|
||||||
id := mf.CoverArtID()
|
id := mf.CoverArtID()
|
||||||
Expect(id.Kind).To(Equal(KindAlbumArtwork))
|
Expect(id.Kind).To(Equal(KindAlbumArtwork))
|
||||||
Expect(id.ID).To(Equal(mf.AlbumID))
|
Expect(id.ID).To(Equal(mf.AlbumID))
|
||||||
})
|
})
|
||||||
It("returns its album id if EnableMediaFileCoverArt is disabled", func() {
|
It("returns disc art id if EnableMediaFileCoverArt is disabled and DiscNumber > 0", func() {
|
||||||
|
conf.Server.EnableMediaFileCoverArt = false
|
||||||
|
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, DiscNumber: 3}
|
||||||
|
id := mf.CoverArtID()
|
||||||
|
Expect(id.Kind).To(Equal(KindDiscArtwork))
|
||||||
|
Expect(id.ID).To(Equal("1:3"))
|
||||||
|
})
|
||||||
|
It("returns its album id if EnableMediaFileCoverArt is disabled and DiscNumber is 0", func() {
|
||||||
conf.Server.EnableMediaFileCoverArt = false
|
conf.Server.EnableMediaFileCoverArt = false
|
||||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
||||||
id := mf.CoverArtID()
|
id := mf.CoverArtID()
|
||||||
|
|||||||
@ -1,15 +1,12 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/model/criteria"
|
"github.com/navidrome/navidrome/model/criteria"
|
||||||
"github.com/navidrome/navidrome/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Playlist struct {
|
type Playlist struct {
|
||||||
@ -108,16 +105,6 @@ func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
|
|||||||
pls.refreshStats()
|
pls.refreshStats()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImageFilename returns a human-friendly filename for an uploaded playlist cover image.
|
|
||||||
// Format: <ID>_<clean_name><ext>, falling back to <ID><ext> if the name cleans to empty.
|
|
||||||
func (pls Playlist) ImageFilename(ext string) string {
|
|
||||||
clean := utils.CleanFileName(pls.Name)
|
|
||||||
if clean == "" {
|
|
||||||
return pls.ID + ext
|
|
||||||
}
|
|
||||||
return pls.ID + "_" + clean + ext
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pls Playlist) CoverArtID() ArtworkID {
|
func (pls Playlist) CoverArtID() ArtworkID {
|
||||||
return artworkIDFromPlaylist(pls)
|
return artworkIDFromPlaylist(pls)
|
||||||
}
|
}
|
||||||
@ -127,10 +114,7 @@ func (pls Playlist) CoverArtID() ArtworkID {
|
|||||||
// This does NOT cover sidecar images or external URLs — those are resolved
|
// This does NOT cover sidecar images or external URLs — those are resolved
|
||||||
// by the artwork reader's fallback chain.
|
// by the artwork reader's fallback chain.
|
||||||
func (pls Playlist) UploadedImagePath() string {
|
func (pls Playlist) UploadedImagePath() string {
|
||||||
if pls.UploadedImage == "" {
|
return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage)
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, "playlist", pls.UploadedImage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Playlists []Playlist
|
type Playlists []Playlist
|
||||||
|
|||||||
@ -7,28 +7,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("Playlist", func() {
|
var _ = Describe("Playlist", func() {
|
||||||
Describe("ImageFilename", func() {
|
|
||||||
It("returns ID_cleanname.ext for a normal name", func() {
|
|
||||||
pls := model.Playlist{ID: "abc123", Name: "My Cool Playlist"}
|
|
||||||
Expect(pls.ImageFilename(".jpg")).To(Equal("abc123_my_cool_playlist.jpg"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to ID.ext when name cleans to empty", func() {
|
|
||||||
pls := model.Playlist{ID: "abc123", Name: "!!!"}
|
|
||||||
Expect(pls.ImageFilename(".png")).To(Equal("abc123.png"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to ID.ext for empty name", func() {
|
|
||||||
pls := model.Playlist{ID: "abc123", Name: ""}
|
|
||||||
Expect(pls.ImageFilename(".jpg")).To(Equal("abc123.jpg"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles names with special characters", func() {
|
|
||||||
pls := model.Playlist{ID: "x1", Name: "Rock & Roll! (2024)"}
|
|
||||||
Expect(pls.ImageFilename(".webp")).To(Equal("x1_rock__roll_2024.webp"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("ToM3U8()", func() {
|
Describe("ToM3U8()", func() {
|
||||||
var pls model.Playlist
|
var pls model.Playlist
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
|||||||
@ -143,9 +143,9 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
|
|||||||
|
|
||||||
func recentlyAddedSort() string {
|
func recentlyAddedSort() string {
|
||||||
if conf.Server.RecentlyAddedByModTime {
|
if conf.Server.RecentlyAddedByModTime {
|
||||||
return "updated_at"
|
return "datetime(album.updated_at)"
|
||||||
}
|
}
|
||||||
return "created_at"
|
return "datetime(album.created_at)"
|
||||||
}
|
}
|
||||||
|
|
||||||
func recentlyPlayedFilter(string, any) Sqlizer {
|
func recentlyPlayedFilter(string, any) Sqlizer {
|
||||||
|
|||||||
@ -85,6 +85,53 @@ var _ = Describe("AlbumRepository", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("recently_added sort", func() {
|
||||||
|
It("sorts correctly regardless of timestamp format (T-format vs space-format)", func() {
|
||||||
|
// Both timestamps share the same date prefix "2024-01-15" so the T vs space
|
||||||
|
// character at position 10 determines sort order in raw string comparison.
|
||||||
|
// Without normalization, 'T' (ASCII 84) > ' ' (ASCII 32) makes the older
|
||||||
|
// T-format timestamp sort AFTER the newer space-format one.
|
||||||
|
|
||||||
|
// Older album: morning of Jan 15, stored in T-format
|
||||||
|
olderAlbum := &model.Album{LibraryID: 1, ID: "ts-older", Name: "Older Album"}
|
||||||
|
Expect(albumRepo.Put(olderAlbum)).To(Succeed())
|
||||||
|
_, err := albumRepo.executeSQL(squirrel.Update("album").
|
||||||
|
Set("created_at", "2024-01-15T08:00:00Z").
|
||||||
|
Where(squirrel.Eq{"id": "ts-older"}))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Newer album: evening of Jan 15, stored in space-format
|
||||||
|
newerAlbum := &model.Album{LibraryID: 1, ID: "ts-newer", Name: "Newer Album"}
|
||||||
|
Expect(albumRepo.Put(newerAlbum)).To(Succeed())
|
||||||
|
_, err = albumRepo.executeSQL(squirrel.Update("album").
|
||||||
|
Set("created_at", "2024-01-15 20:00:00+00:00").
|
||||||
|
Where(squirrel.Eq{"id": "ts-newer"}))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
albums, err := albumRepo.GetAll(model.QueryOptions{Sort: "recently_added", Order: "desc"})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Find positions of our test albums
|
||||||
|
olderIdx, newerIdx := -1, -1
|
||||||
|
for i, a := range albums {
|
||||||
|
switch a.ID {
|
||||||
|
case "ts-older":
|
||||||
|
olderIdx = i
|
||||||
|
case "ts-newer":
|
||||||
|
newerIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expect(olderIdx).To(BeNumerically(">=", 0), "older album not found in results")
|
||||||
|
Expect(newerIdx).To(BeNumerically(">=", 0), "newer album not found in results")
|
||||||
|
// Newer album (evening, space-format) should come before older album (morning, T-format) in desc order
|
||||||
|
Expect(newerIdx).To(BeNumerically("<", olderIdx),
|
||||||
|
"Newer album (20:00 space-format) should sort before older album (08:00 T-format) in desc order")
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"ts-older", "ts-newer"}}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Context("Filters", func() {
|
Context("Filters", func() {
|
||||||
var albumWithoutAnnotation model.Album
|
var albumWithoutAnnotation model.Album
|
||||||
|
|
||||||
|
|||||||
@ -31,13 +31,14 @@
|
|||||||
"mood": "Настроение",
|
"mood": "Настроение",
|
||||||
"participants": "Допълнителни участници",
|
"participants": "Допълнителни участници",
|
||||||
"tags": "Допълнителни етикети",
|
"tags": "Допълнителни етикети",
|
||||||
"mappedTags": "",
|
"mappedTags": "Картирани тагове",
|
||||||
"rawTags": "",
|
"rawTags": "Сурови тагове",
|
||||||
"bitDepth": "Битова дълбочина",
|
"bitDepth": "Битова дълбочина",
|
||||||
"sampleRate": "",
|
"sampleRate": "Честота на семплиране",
|
||||||
"missing": "Липсва",
|
"missing": "Липсва",
|
||||||
"libraryName": "",
|
"libraryName": "Библиотека",
|
||||||
"composer": ""
|
"composer": "Композитор",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Пусни по-късно",
|
"addToQueue": "Пусни по-късно",
|
||||||
@ -47,8 +48,8 @@
|
|||||||
"download": "Свали",
|
"download": "Свали",
|
||||||
"playNext": "Следваща",
|
"playNext": "Следваща",
|
||||||
"info": "Информация",
|
"info": "Информация",
|
||||||
"showInPlaylist": "",
|
"showInPlaylist": "Показване в плейлиста",
|
||||||
"instantMix": ""
|
"instantMix": "Незабавен микс"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"album": {
|
"album": {
|
||||||
@ -80,7 +81,7 @@
|
|||||||
"mood": "Настроение",
|
"mood": "Настроение",
|
||||||
"date": "Дата на запис",
|
"date": "Дата на запис",
|
||||||
"missing": "Липсва",
|
"missing": "Липсва",
|
||||||
"libraryName": ""
|
"libraryName": "Библиотека"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"playAll": "Пусни",
|
"playAll": "Пусни",
|
||||||
@ -129,12 +130,12 @@
|
|||||||
"remixer": "Ремиксер |||| Ремиксери",
|
"remixer": "Ремиксер |||| Ремиксери",
|
||||||
"djmixer": "DJ миксер |||| DJ миксери",
|
"djmixer": "DJ миксер |||| DJ миксери",
|
||||||
"performer": "Изпълнител |||| Изпълнители",
|
"performer": "Изпълнител |||| Изпълнители",
|
||||||
"maincredit": ""
|
"maincredit": "Изпълнител на албума или изпълнител |||| Изпълнители на албума или изпълнители"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"shuffle": "",
|
"shuffle": "Разбъркване",
|
||||||
"radio": "",
|
"radio": "Радио",
|
||||||
"topSongs": ""
|
"topSongs": "Топ песни"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
@ -152,11 +153,11 @@
|
|||||||
"newPassword": "Нова парола",
|
"newPassword": "Нова парола",
|
||||||
"token": "Токен",
|
"token": "Токен",
|
||||||
"lastAccessAt": "Последен достъп",
|
"lastAccessAt": "Последен достъп",
|
||||||
"libraries": ""
|
"libraries": "Библиотеки"
|
||||||
},
|
},
|
||||||
"helperTexts": {
|
"helperTexts": {
|
||||||
"name": "Промените в името ще бъдат отразени при следващото влизане",
|
"name": "Промените в името ще бъдат отразени при следващото влизане",
|
||||||
"libraries": ""
|
"libraries": "Изберете конкретни библиотеки за този потребител или оставете празно, за да използвате библиотеки по подразбиране"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"created": "Потребителят е създаден",
|
"created": "Потребителят е създаден",
|
||||||
@ -166,11 +167,11 @@
|
|||||||
"message": {
|
"message": {
|
||||||
"listenBrainzToken": "Въведете Вашия токен за ListenBrainz.",
|
"listenBrainzToken": "Въведете Вашия токен за ListenBrainz.",
|
||||||
"clickHereForToken": "Кликнете тук, за да получите Вашия токен",
|
"clickHereForToken": "Кликнете тук, за да получите Вашия токен",
|
||||||
"selectAllLibraries": "",
|
"selectAllLibraries": "Изберете всички библиотеки",
|
||||||
"adminAutoLibraries": ""
|
"adminAutoLibraries": "Администраторите автоматично получават достъп до всички библиотеки"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"librariesRequired": ""
|
"librariesRequired": "Трябва да бъде избрана поне една библиотека за потребители без администраторски права"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
@ -215,16 +216,16 @@
|
|||||||
"export": "Експорт",
|
"export": "Експорт",
|
||||||
"makePublic": "Направи публичен",
|
"makePublic": "Направи публичен",
|
||||||
"makePrivate": "Направи личен",
|
"makePrivate": "Направи личен",
|
||||||
"saveQueue": "",
|
"saveQueue": "Запазване на опашката в плейлист",
|
||||||
"searchOrCreate": "",
|
"searchOrCreate": "Търсете в плейлисти или пишете, за да създадете нови...",
|
||||||
"pressEnterToCreate": "",
|
"pressEnterToCreate": "Натиснете Enter, за да създадете нов плейлист",
|
||||||
"removeFromSelection": ""
|
"removeFromSelection": "Премахване от селекцията"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"duplicate_song": "Добави дублирани песни",
|
"duplicate_song": "Добави дублирани песни",
|
||||||
"song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?",
|
"song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?",
|
||||||
"noPlaylistsFound": "",
|
"noPlaylistsFound": "Няма намерени плейлисти",
|
||||||
"noPlaylists": ""
|
"noPlaylists": "Няма налични плейлисти"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"radio": {
|
"radio": {
|
||||||
@ -263,7 +264,7 @@
|
|||||||
"path": "Път",
|
"path": "Път",
|
||||||
"size": "Размер",
|
"size": "Размер",
|
||||||
"updatedAt": "Изчезнал на",
|
"updatedAt": "Изчезнал на",
|
||||||
"libraryName": ""
|
"libraryName": "Библиотека"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"remove": "Премахни",
|
"remove": "Премахни",
|
||||||
@ -275,134 +276,136 @@
|
|||||||
"empty": "Няма липсващи файлове"
|
"empty": "Няма липсващи файлове"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"name": "",
|
"name": "Библиотека |||| Библиотеки",
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "",
|
"name": "Име",
|
||||||
"path": "",
|
"path": "Път",
|
||||||
"remotePath": "",
|
"remotePath": "Отдалечен път",
|
||||||
"lastScanAt": "",
|
"lastScanAt": "Последно сканиране",
|
||||||
"songCount": "",
|
"songCount": "Песни",
|
||||||
"albumCount": "",
|
"albumCount": "Албуми",
|
||||||
"artistCount": "",
|
"artistCount": "Изпълнители",
|
||||||
"totalSongs": "",
|
"totalSongs": "Песни",
|
||||||
"totalAlbums": "",
|
"totalAlbums": "Албуми",
|
||||||
"totalArtists": "",
|
"totalArtists": "Изпълнители",
|
||||||
"totalFolders": "",
|
"totalFolders": "Папки",
|
||||||
"totalFiles": "",
|
"totalFiles": "Файлове",
|
||||||
"totalMissingFiles": "",
|
"totalMissingFiles": "Липсващи файлове",
|
||||||
"totalSize": "",
|
"totalSize": "Общ размер",
|
||||||
"totalDuration": "",
|
"totalDuration": "Продължителност",
|
||||||
"defaultNewUsers": "",
|
"defaultNewUsers": "По подразбиране за нови потребители",
|
||||||
"createdAt": "",
|
"createdAt": "Създаден",
|
||||||
"updatedAt": ""
|
"updatedAt": "Актуализиран"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"basic": "",
|
"basic": "Основна информация",
|
||||||
"statistics": ""
|
"statistics": "Статистика"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"scan": "",
|
"scan": "Сканирай библиотеката",
|
||||||
"manageUsers": "",
|
"manageUsers": "Управление на потребителския достъп",
|
||||||
"viewDetails": "",
|
"viewDetails": "Преглед на подробности",
|
||||||
"quickScan": "Quick Scan",
|
"quickScan": "Quick Scan",
|
||||||
"fullScan": ""
|
"fullScan": "Пълно сканиране"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"created": "",
|
"created": "Библиотеката е създадена успешно",
|
||||||
"updated": "",
|
"updated": "Библиотеката е актуализирана успешно",
|
||||||
"deleted": "",
|
"deleted": "Библиотеката е изтрита успешно",
|
||||||
"scanStarted": "",
|
"scanStarted": "Сканирането на библиотеката започна",
|
||||||
"scanCompleted": "",
|
"scanCompleted": "Сканирането на библиотеката е завършено",
|
||||||
"quickScanStarted": "",
|
"quickScanStarted": "Бързото сканиране започна",
|
||||||
"fullScanStarted": "",
|
"fullScanStarted": "Пълното сканиране започна",
|
||||||
"scanError": ""
|
"scanError": "Грешка при стартиране на сканирането. Проверете лог файловете"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"nameRequired": "",
|
"nameRequired": "Името на библиотеката е задължително",
|
||||||
"pathRequired": "",
|
"pathRequired": "Пътят към библиотеката е задължителен",
|
||||||
"pathNotDirectory": "",
|
"pathNotDirectory": "Пътят до библиотеката трябва да е директория",
|
||||||
"pathNotFound": "",
|
"pathNotFound": "Пътят към библиотеката не е намерен",
|
||||||
"pathNotAccessible": "",
|
"pathNotAccessible": "Пътят до библиотеката не е достъпен",
|
||||||
"pathInvalid": ""
|
"pathInvalid": "Невалиден път към библиотеката"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"deleteConfirm": "",
|
"deleteConfirm": "Сигурни ли сте, че желаете да изтриете тази библиотека? Това ще премахне всички свързани данни и потребителски достъп.",
|
||||||
"scanInProgress": "",
|
"scanInProgress": "Сканирането е в ход...",
|
||||||
"noLibrariesAssigned": ""
|
"noLibrariesAssigned": "Няма библиотеки, присвоени на този потребител"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugin": {
|
"plugin": {
|
||||||
"name": "",
|
"name": "Плъгин |||| Плъгини",
|
||||||
"fields": {
|
"fields": {
|
||||||
"id": "",
|
"id": "ID номер",
|
||||||
"name": "",
|
"name": "Име",
|
||||||
"description": "",
|
"description": "Описание",
|
||||||
"version": "",
|
"version": "Версия",
|
||||||
"author": "",
|
"author": "Автор",
|
||||||
"website": "",
|
"website": "Уебсайт",
|
||||||
"permissions": "",
|
"permissions": "Разрешения",
|
||||||
"enabled": "",
|
"enabled": "Активирано",
|
||||||
"status": "",
|
"status": "Статус",
|
||||||
"path": "",
|
"path": "Път",
|
||||||
"lastError": "",
|
"lastError": "Грешка",
|
||||||
"hasError": "",
|
"hasError": "Грешка",
|
||||||
"updatedAt": "",
|
"updatedAt": "Актуализирано",
|
||||||
"createdAt": "",
|
"createdAt": "Инсталирано",
|
||||||
"configKey": "",
|
"configKey": "Ключ",
|
||||||
"configValue": "",
|
"configValue": "Стойност",
|
||||||
"allUsers": "",
|
"allUsers": "Разрешаване на всички потребители",
|
||||||
"selectedUsers": "",
|
"selectedUsers": "Избрани потребители",
|
||||||
"allLibraries": "",
|
"allLibraries": "Разрешаване на всички библиотеки",
|
||||||
"selectedLibraries": ""
|
"selectedLibraries": "Избрани библиотеки",
|
||||||
|
"allowWriteAccess": ""
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "",
|
"status": "Статус",
|
||||||
"info": "",
|
"info": "Информация за плъгина",
|
||||||
"configuration": "",
|
"configuration": "Конфигурация",
|
||||||
"manifest": "",
|
"manifest": "Манифест",
|
||||||
"usersPermission": "",
|
"usersPermission": "Права за потребители",
|
||||||
"libraryPermission": ""
|
"libraryPermission": "Права за библиотека"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"enabled": "",
|
"enabled": "Активирано",
|
||||||
"disabled": ""
|
"disabled": "Деактивирано"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"enable": "",
|
"enable": "Активирай",
|
||||||
"disable": "",
|
"disable": "Деактивирай",
|
||||||
"disabledDueToError": "",
|
"disabledDueToError": "Поправете грешката преди активиране",
|
||||||
"disabledUsersRequired": "",
|
"disabledUsersRequired": "Изберете потребители преди активиране",
|
||||||
"disabledLibrariesRequired": "",
|
"disabledLibrariesRequired": "Изберете библиотеки преди активиране",
|
||||||
"addConfig": "",
|
"addConfig": "Добавяне на конфигурация",
|
||||||
"rescan": ""
|
"rescan": "Повторно сканиране"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"enabled": "",
|
"enabled": "Плъгинът е активиран",
|
||||||
"disabled": "",
|
"disabled": "Плъгинът е деактивиран",
|
||||||
"updated": "",
|
"updated": "Плъгинът е актуализиран",
|
||||||
"error": ""
|
"error": "Грешка при актуализиране на плъгина"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"invalidJson": ""
|
"invalidJson": "Конфигурацията трябва да е валиден JSON"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"configHelp": "",
|
"configHelp": "Конфигурирайте плъгина, използвайки двойки ключ-стойност. Оставете празно, ако плъгинът не изисква конфигурация.",
|
||||||
"clickPermissions": "",
|
"clickPermissions": "Кликнете върху разрешение за подробности",
|
||||||
"noConfig": "",
|
"noConfig": "Няма зададена конфигурация",
|
||||||
"allUsersHelp": "",
|
"allUsersHelp": "Когато е активиран, плъгинът ще има достъп до всички потребители, включително тези, създадени в бъдеще.",
|
||||||
"noUsers": "",
|
"noUsers": "Няма избрани потребители",
|
||||||
"permissionReason": "",
|
"permissionReason": "Причина",
|
||||||
"usersRequired": "",
|
"usersRequired": "Този плъгин изисква достъп до потребителска информация. Изберете до кои потребители плъгинът може да има достъп или активирайте „Разрешаване на всички потребители“.",
|
||||||
"allLibrariesHelp": "",
|
"allLibrariesHelp": "Когато е активиран, плъгинът ще има достъп до всички библиотеки, включително тези, създадени в бъдеще.",
|
||||||
"noLibraries": "",
|
"noLibraries": "Няма избрани библиотеки",
|
||||||
"librariesRequired": "",
|
"librariesRequired": "Този плъгин изисква достъп до информация за библиотеката. Изберете до кои библиотеки плъгинът може да има достъп или активирайте „Разрешаване на всички библиотеки“.",
|
||||||
"requiredHosts": "",
|
"requiredHosts": "Необходими хостове",
|
||||||
"configValidationError": "",
|
"configValidationError": "Валидирането на конфигурацията не бе успешно:",
|
||||||
"schemaRenderError": ""
|
"schemaRenderError": "Не може да се изобрази формята за конфигурация. Схемата на плъгина може да е невалидна.",
|
||||||
|
"allowWriteAccessHelp": ""
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "",
|
"configKey": "ключ",
|
||||||
"configValue": ""
|
"configValue": "стойност"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -586,9 +589,9 @@
|
|||||||
"remove_missing_content": "Сигурни ли сте, че желаете да премахнете избраните липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.",
|
"remove_missing_content": "Сигурни ли сте, че желаете да премахнете избраните липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.",
|
||||||
"remove_all_missing_title": "Премахни всички липсващи файлове",
|
"remove_all_missing_title": "Премахни всички липсващи файлове",
|
||||||
"remove_all_missing_content": "Сигурни ли сте, че желаете да премахнете всички липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.",
|
"remove_all_missing_content": "Сигурни ли сте, че желаете да премахнете всички липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.",
|
||||||
"noSimilarSongsFound": "",
|
"noSimilarSongsFound": "Не са намерени подобни песни",
|
||||||
"noTopSongsFound": "",
|
"noTopSongsFound": "Няма намерени топ песни",
|
||||||
"startingInstantMix": ""
|
"startingInstantMix": "Зареждане на незабавен микс..."
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"library": "Библиотека",
|
"library": "Библиотека",
|
||||||
@ -619,10 +622,10 @@
|
|||||||
"playlists": "Плейлисти",
|
"playlists": "Плейлисти",
|
||||||
"sharedPlaylists": "Споделени плейлисти",
|
"sharedPlaylists": "Споделени плейлисти",
|
||||||
"librarySelector": {
|
"librarySelector": {
|
||||||
"allLibraries": "",
|
"allLibraries": "Всички библиотеки (%{count})",
|
||||||
"multipleLibraries": "",
|
"multipleLibraries": "%{selected} от %{total} библиотеки",
|
||||||
"selectLibraries": "",
|
"selectLibraries": "Изберете библиотеки",
|
||||||
"none": ""
|
"none": "Няма"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
@ -655,7 +658,7 @@
|
|||||||
"homepage": "Начална страница",
|
"homepage": "Начална страница",
|
||||||
"source": "Програмен код",
|
"source": "Програмен код",
|
||||||
"featureRequests": "Заявете функционалност",
|
"featureRequests": "Заявете функционалност",
|
||||||
"lastInsightsCollection": "",
|
"lastInsightsCollection": "Последна колекция от анализи",
|
||||||
"insights": {
|
"insights": {
|
||||||
"disabled": "Деактивиран",
|
"disabled": "Деактивиран",
|
||||||
"waiting": "Изчакване"
|
"waiting": "Изчакване"
|
||||||
@ -669,12 +672,13 @@
|
|||||||
"configName": "Име на конфигурация",
|
"configName": "Име на конфигурация",
|
||||||
"environmentVariable": "Променлива на средата",
|
"environmentVariable": "Променлива на средата",
|
||||||
"currentValue": "Текуща стойност",
|
"currentValue": "Текуща стойност",
|
||||||
"configurationFile": "",
|
"configurationFile": "Конфигурационен файл",
|
||||||
"exportToml": "Експортиране на конфигурация (TOML)",
|
"exportToml": "Експортиране на конфигурация (TOML)",
|
||||||
"exportSuccess": "Конфигурация, експортирана в клипборда във формат TOML",
|
"exportSuccess": "Конфигурация, експортирана в клипборда във формат TOML",
|
||||||
"exportFailed": "Неуспешно копиране на конфигурация",
|
"exportFailed": "Неуспешно копиране на конфигурация",
|
||||||
"devFlagsHeader": "",
|
"devFlagsHeader": "Флагове за разработка (подлежащи на промяна/премахване)",
|
||||||
"devFlagsComment": ""
|
"devFlagsComment": "Това са експериментални настройки и е възможно да бъдат премахнати в бъдещи версии.",
|
||||||
|
"downloadToml": "Изтегляне на конфигурация (TOML)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@ -687,7 +691,7 @@
|
|||||||
"scanType": "Последно сканиране",
|
"scanType": "Последно сканиране",
|
||||||
"status": "Грешка при сканиране",
|
"status": "Грешка при сканиране",
|
||||||
"elapsedTime": "Изминало време",
|
"elapsedTime": "Изминало време",
|
||||||
"selectiveScan": ""
|
"selectiveScan": "Селективен"
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Бързи клавиши на Navidrome",
|
"title": "Бързи клавиши на Navidrome",
|
||||||
@ -704,8 +708,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nowPlaying": {
|
"nowPlaying": {
|
||||||
"title": "",
|
"title": "Сега свири",
|
||||||
"empty": "",
|
"empty": "Нищо не се възпроизвежда",
|
||||||
"minutesAgo": ""
|
"minutesAgo": "преди %{smart_count} минута |||| преди %{smart_count} минути"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,8 @@
|
|||||||
"sampleRate": "Freqüencia de mostreig",
|
"sampleRate": "Freqüencia de mostreig",
|
||||||
"missing": "Desaparegut",
|
"missing": "Desaparegut",
|
||||||
"libraryName": "Biblioteca",
|
"libraryName": "Biblioteca",
|
||||||
"composer": "Compositor"
|
"composer": "Compositor",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Reprodueix després",
|
"addToQueue": "Reprodueix després",
|
||||||
@ -353,7 +354,8 @@
|
|||||||
"allUsers": "Permet tots els usuaris",
|
"allUsers": "Permet tots els usuaris",
|
||||||
"selectedUsers": "Usuaris seleccionats",
|
"selectedUsers": "Usuaris seleccionats",
|
||||||
"allLibraries": "Permet totes les llibreries",
|
"allLibraries": "Permet totes les llibreries",
|
||||||
"selectedLibraries": "Biblioteques seleccionades"
|
"selectedLibraries": "Biblioteques seleccionades",
|
||||||
|
"allowWriteAccess": ""
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Estat",
|
"status": "Estat",
|
||||||
@ -398,7 +400,8 @@
|
|||||||
"librariesRequired": "Aquest controlador necessita accedir a la informació de la biblioteca. Selecciona a quines biblioteques pot accedir o activa «Permet totes les biblioteques».",
|
"librariesRequired": "Aquest controlador necessita accedir a la informació de la biblioteca. Selecciona a quines biblioteques pot accedir o activa «Permet totes les biblioteques».",
|
||||||
"requiredHosts": "Hosts requerits",
|
"requiredHosts": "Hosts requerits",
|
||||||
"configValidationError": "Ha fallat la validació de la configuració:",
|
"configValidationError": "Ha fallat la validació de la configuració:",
|
||||||
"schemaRenderError": "No s'ha pogut renderitzar el formulari de configuració. És possible que l'esquema del controlador sigui invàlid."
|
"schemaRenderError": "No s'ha pogut renderitzar el formulari de configuració. És possible que l'esquema del controlador sigui invàlid.",
|
||||||
|
"allowWriteAccessHelp": ""
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "clau",
|
"configKey": "clau",
|
||||||
@ -674,7 +677,8 @@
|
|||||||
"exportSuccess": "Configuració exportada al porta-retalls en format TOML",
|
"exportSuccess": "Configuració exportada al porta-retalls en format TOML",
|
||||||
"exportFailed": "La còpia de la configuració ha fallat",
|
"exportFailed": "La còpia de la configuració ha fallat",
|
||||||
"devFlagsHeader": "Indicadors de desenvolupament (subjecte a canvis o eliminació)",
|
"devFlagsHeader": "Indicadors de desenvolupament (subjecte a canvis o eliminació)",
|
||||||
"devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures"
|
"devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures",
|
||||||
|
"downloadToml": "Descarrega la configuració (TOML)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@ -708,4 +712,4 @@
|
|||||||
"empty": "No s'està reproduint res",
|
"empty": "No s'està reproduint res",
|
||||||
"minutesAgo": "Fa %{smart_count} minut |||| Fa %{smart_count} minuts"
|
"minutesAgo": "Fa %{smart_count} minut |||| Fa %{smart_count} minuts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,8 @@
|
|||||||
"sampleRate": "Samplingfrekvens",
|
"sampleRate": "Samplingfrekvens",
|
||||||
"missing": "Manglende",
|
"missing": "Manglende",
|
||||||
"libraryName": "Bibliotek",
|
"libraryName": "Bibliotek",
|
||||||
"composer": "Komponist"
|
"composer": "Komponist",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Afspil senere",
|
"addToQueue": "Afspil senere",
|
||||||
@ -353,7 +354,8 @@
|
|||||||
"allUsers": "Tillad alle brugere",
|
"allUsers": "Tillad alle brugere",
|
||||||
"selectedUsers": "Valgte brugere",
|
"selectedUsers": "Valgte brugere",
|
||||||
"allLibraries": "Tillad alle biblioteker",
|
"allLibraries": "Tillad alle biblioteker",
|
||||||
"selectedLibraries": "Valgte biblioteker"
|
"selectedLibraries": "Valgte biblioteker",
|
||||||
|
"allowWriteAccess": ""
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@ -398,7 +400,8 @@
|
|||||||
"librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.",
|
"librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.",
|
||||||
"requiredHosts": "Påkrævede hosts",
|
"requiredHosts": "Påkrævede hosts",
|
||||||
"configValidationError": "Konfigurationsvalidering mislykkedes:",
|
"configValidationError": "Konfigurationsvalidering mislykkedes:",
|
||||||
"schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt."
|
"schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt.",
|
||||||
|
"allowWriteAccessHelp": ""
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "nøgle",
|
"configKey": "nøgle",
|
||||||
@ -675,7 +678,7 @@
|
|||||||
"exportFailed": "Kunne ikke kopiere konfigurationen",
|
"exportFailed": "Kunne ikke kopiere konfigurationen",
|
||||||
"devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)",
|
"devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)",
|
||||||
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver",
|
"devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver",
|
||||||
"downloadToml": ""
|
"downloadToml": "Download konfigurationen (TOML)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@ -709,4 +712,4 @@
|
|||||||
"empty": "Intet afspilles nu",
|
"empty": "Intet afspilles nu",
|
||||||
"minutesAgo": "for %{smart_count} minut siden |||| for %{smart_count} minutter siden"
|
"minutesAgo": "for %{smart_count} minut siden |||| for %{smart_count} minutter siden"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,8 @@
|
|||||||
"sampleRate": "Samplerate",
|
"sampleRate": "Samplerate",
|
||||||
"missing": "Fehlend",
|
"missing": "Fehlend",
|
||||||
"libraryName": "Bibliothek",
|
"libraryName": "Bibliothek",
|
||||||
"composer": "Komponist"
|
"composer": "Komponist",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Später abspielen",
|
"addToQueue": "Später abspielen",
|
||||||
@ -353,7 +354,8 @@
|
|||||||
"allUsers": "Alle Benutzer",
|
"allUsers": "Alle Benutzer",
|
||||||
"selectedUsers": "Ausgewählte Benutzer",
|
"selectedUsers": "Ausgewählte Benutzer",
|
||||||
"allLibraries": "Alle Bibliotheken",
|
"allLibraries": "Alle Bibliotheken",
|
||||||
"selectedLibraries": "Ausgewählte Bibliotheken"
|
"selectedLibraries": "Ausgewählte Bibliotheken",
|
||||||
|
"allowWriteAccess": "Schreibzugriff erlauben"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@ -398,7 +400,8 @@
|
|||||||
"librariesRequired": "Dieses Plugin benötigt Zugriff auf Bibliotheken. Wähle aus, auf welche Bibliotheken das Plugin zugreifen darf oder wähle 'Alle Bibliotheken'.",
|
"librariesRequired": "Dieses Plugin benötigt Zugriff auf Bibliotheken. Wähle aus, auf welche Bibliotheken das Plugin zugreifen darf oder wähle 'Alle Bibliotheken'.",
|
||||||
"requiredHosts": "Benötigte Hosts",
|
"requiredHosts": "Benötigte Hosts",
|
||||||
"configValidationError": "Validierung der Konfiguration fehlgeschlagen:",
|
"configValidationError": "Validierung der Konfiguration fehlgeschlagen:",
|
||||||
"schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt."
|
"schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt.",
|
||||||
|
"allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff."
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "Schlüssel",
|
"configKey": "Schlüssel",
|
||||||
@ -588,7 +591,13 @@
|
|||||||
"remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.",
|
"remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.",
|
||||||
"noSimilarSongsFound": "Keine ähnlichen Titel gefunden",
|
"noSimilarSongsFound": "Keine ähnlichen Titel gefunden",
|
||||||
"noTopSongsFound": "Keine beliebten Titel gefunden",
|
"noTopSongsFound": "Keine beliebten Titel gefunden",
|
||||||
"startingInstantMix": "Lade Sofort-Mix..."
|
"startingInstantMix": "Lade Sofort-Mix...",
|
||||||
|
"uploadCover": "Cover hochladen",
|
||||||
|
"removeCover": "Cover entfernen",
|
||||||
|
"coverUploaded": "Cover aktualisiert",
|
||||||
|
"coverRemoved": "Cover entfernt",
|
||||||
|
"coverUploadError": "Fehler beim Hochladen des Covers",
|
||||||
|
"coverRemoveError": "Fehler beim Entfernen des Covers"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"library": "Bibliothek",
|
"library": "Bibliothek",
|
||||||
@ -674,7 +683,8 @@
|
|||||||
"exportSuccess": "Konfiguration im TOML Format in die Zwischenablage kopiert",
|
"exportSuccess": "Konfiguration im TOML Format in die Zwischenablage kopiert",
|
||||||
"exportFailed": "Fehler beim Kopieren der Konfiguration",
|
"exportFailed": "Fehler beim Kopieren der Konfiguration",
|
||||||
"devFlagsHeader": "Entwicklungseinstellungen (können sich ändern)",
|
"devFlagsHeader": "Entwicklungseinstellungen (können sich ändern)",
|
||||||
"devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden"
|
"devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden",
|
||||||
|
"downloadToml": "Konfiguration Herunterladen (TOML)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@ -708,4 +718,4 @@
|
|||||||
"empty": "Keine Wiedergabe",
|
"empty": "Keine Wiedergabe",
|
||||||
"minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten"
|
"minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,8 @@
|
|||||||
"sampleRate": "Ποσοστό δειγματοληψίας",
|
"sampleRate": "Ποσοστό δειγματοληψίας",
|
||||||
"missing": "Απών",
|
"missing": "Απών",
|
||||||
"libraryName": "Βιβλιοθήκη",
|
"libraryName": "Βιβλιοθήκη",
|
||||||
"composer": "Συνθέτης"
|
"composer": "Συνθέτης",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Αναπαραγωγη Μετα",
|
"addToQueue": "Αναπαραγωγη Μετα",
|
||||||
@ -353,7 +354,8 @@
|
|||||||
"allUsers": "Επιτρέψτε όλους τους χρήστες",
|
"allUsers": "Επιτρέψτε όλους τους χρήστες",
|
||||||
"selectedUsers": "Επιλογή χρηστών",
|
"selectedUsers": "Επιλογή χρηστών",
|
||||||
"allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες",
|
"allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες",
|
||||||
"selectedLibraries": "Επιλεγμένες βιβλιοθήκες"
|
"selectedLibraries": "Επιλεγμένες βιβλιοθήκες",
|
||||||
|
"allowWriteAccess": ""
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Κατάσταση",
|
"status": "Κατάσταση",
|
||||||
@ -398,7 +400,8 @@
|
|||||||
"librariesRequired": "Αυτό το πρόσθετο απαιτεί πρόσβαση στις πληροφορίες βιβλιοθήκης. Επιλέξτε σε ποιές βιβλιοθήκες μπορεί να έχει πρόσβαση το πρόσθετο, ή ενεργοποιήστε το 'Επιτρέψτε όλες τις βιβλιοθήκες'",
|
"librariesRequired": "Αυτό το πρόσθετο απαιτεί πρόσβαση στις πληροφορίες βιβλιοθήκης. Επιλέξτε σε ποιές βιβλιοθήκες μπορεί να έχει πρόσβαση το πρόσθετο, ή ενεργοποιήστε το 'Επιτρέψτε όλες τις βιβλιοθήκες'",
|
||||||
"requiredHosts": "Απαιτούμενοι hosts",
|
"requiredHosts": "Απαιτούμενοι hosts",
|
||||||
"configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:",
|
"configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:",
|
||||||
"schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο."
|
"schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.",
|
||||||
|
"allowWriteAccessHelp": ""
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "κλειδί",
|
"configKey": "κλειδί",
|
||||||
@ -674,7 +677,8 @@
|
|||||||
"exportSuccess": "Η διαμόρφωση εξήχθη στο πρόχειρο σε μορφή TOML",
|
"exportSuccess": "Η διαμόρφωση εξήχθη στο πρόχειρο σε μορφή TOML",
|
||||||
"exportFailed": "Η αντιγραφή της διαμόρφωσης απέτυχε",
|
"exportFailed": "Η αντιγραφή της διαμόρφωσης απέτυχε",
|
||||||
"devFlagsHeader": "Σημαίες Ανάπτυξης (υπόκειται σε αλλαγές / αφαίρεση)",
|
"devFlagsHeader": "Σημαίες Ανάπτυξης (υπόκειται σε αλλαγές / αφαίρεση)",
|
||||||
"devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις"
|
"devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις",
|
||||||
|
"downloadToml": "Λήψη διαμόρφωσης (TOML)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@ -708,4 +712,4 @@
|
|||||||
"empty": "Δεν παίζει τίποτα",
|
"empty": "Δεν παίζει τίποτα",
|
||||||
"minutesAgo": "%{smart_count} λεπτό πριν |||| %{smart_count} λεπτά πριν"
|
"minutesAgo": "%{smart_count} λεπτό πριν |||| %{smart_count} λεπτά πριν"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,8 @@
|
|||||||
"sampleRate": "Frecuencia de muestreo",
|
"sampleRate": "Frecuencia de muestreo",
|
||||||
"missing": "Faltante",
|
"missing": "Faltante",
|
||||||
"libraryName": "Biblioteca",
|
"libraryName": "Biblioteca",
|
||||||
"composer": "Compositor"
|
"composer": "Compositor",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Reproducir después",
|
"addToQueue": "Reproducir después",
|
||||||
@ -353,7 +354,8 @@
|
|||||||
"allUsers": "Permitir todos los usuarios",
|
"allUsers": "Permitir todos los usuarios",
|
||||||
"selectedUsers": "Usuarios seleccionados",
|
"selectedUsers": "Usuarios seleccionados",
|
||||||
"allLibraries": "Permitir todas las bibliotecas",
|
"allLibraries": "Permitir todas las bibliotecas",
|
||||||
"selectedLibraries": "Bibliotecas seleccionadas"
|
"selectedLibraries": "Bibliotecas seleccionadas",
|
||||||
|
"allowWriteAccess": ""
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
@ -398,7 +400,8 @@
|
|||||||
"librariesRequired": "Este plugin requiere acceso a la información de las bibliotecas. Selecciona a qué bibliotecas puede acceder el plugin, o activa 'Permitir todas las bibliotecas'.",
|
"librariesRequired": "Este plugin requiere acceso a la información de las bibliotecas. Selecciona a qué bibliotecas puede acceder el plugin, o activa 'Permitir todas las bibliotecas'.",
|
||||||
"requiredHosts": "Hosts requeridos",
|
"requiredHosts": "Hosts requeridos",
|
||||||
"configValidationError": "La validación de la configuración falló:",
|
"configValidationError": "La validación de la configuración falló:",
|
||||||
"schemaRenderError": "No se pudo renderizar el formulario de configuración. Es posible que el esquema del complemento no sea válido."
|
"schemaRenderError": "No se pudo renderizar el formulario de configuración. Es posible que el esquema del complemento no sea válido.",
|
||||||
|
"allowWriteAccessHelp": ""
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "clave",
|
"configKey": "clave",
|
||||||
@ -674,7 +677,8 @@
|
|||||||
"exportSuccess": "Configuración exportada al portapapeles en formato TOML",
|
"exportSuccess": "Configuración exportada al portapapeles en formato TOML",
|
||||||
"exportFailed": "Error al copiar la configuración",
|
"exportFailed": "Error al copiar la configuración",
|
||||||
"devFlagsHeader": "Indicadores de desarrollo (sujetos a cambios o eliminación)",
|
"devFlagsHeader": "Indicadores de desarrollo (sujetos a cambios o eliminación)",
|
||||||
"devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras"
|
"devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras",
|
||||||
|
"downloadToml": "Descargar la configuración (TOML)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@ -708,4 +712,4 @@
|
|||||||
"empty": "Nada en reproducción",
|
"empty": "Nada en reproducción",
|
||||||
"minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
|
"minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,8 @@
|
|||||||
"sampleRate": "Näytteenottotaajuus",
|
"sampleRate": "Näytteenottotaajuus",
|
||||||
"missing": "Puuttuva",
|
"missing": "Puuttuva",
|
||||||
"libraryName": "Kirjasto",
|
"libraryName": "Kirjasto",
|
||||||
"composer": "Säveltäjä"
|
"composer": "Säveltäjä",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Lisää jonoon",
|
"addToQueue": "Lisää jonoon",
|
||||||
@ -353,7 +354,8 @@
|
|||||||
"allUsers": "Salli kaikki käyttäjät",
|
"allUsers": "Salli kaikki käyttäjät",
|
||||||
"selectedUsers": "Valitut käyttäjät",
|
"selectedUsers": "Valitut käyttäjät",
|
||||||
"allLibraries": "Salli kaikki kirjastot",
|
"allLibraries": "Salli kaikki kirjastot",
|
||||||
"selectedLibraries": "Valitut kirjastot"
|
"selectedLibraries": "Valitut kirjastot",
|
||||||
|
"allowWriteAccess": "Salli kirjoitusoikeus"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Tila",
|
"status": "Tila",
|
||||||
@ -398,7 +400,8 @@
|
|||||||
"librariesRequired": "Tämä laajennus vaatii pääsyn kirjastotietoihin. Valitse, mihin kirjastoihin laajennus voi käyttää, tai ota käyttöön 'Salli kaikki kirjastot'.",
|
"librariesRequired": "Tämä laajennus vaatii pääsyn kirjastotietoihin. Valitse, mihin kirjastoihin laajennus voi käyttää, tai ota käyttöön 'Salli kaikki kirjastot'.",
|
||||||
"requiredHosts": "Vaaditut palvelimet",
|
"requiredHosts": "Vaaditut palvelimet",
|
||||||
"configValidationError": "Määrityksen validointi epäonnistui:",
|
"configValidationError": "Määrityksen validointi epäonnistui:",
|
||||||
"schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen."
|
"schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen.",
|
||||||
|
"allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus."
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "avain",
|
"configKey": "avain",
|
||||||
@ -588,7 +591,13 @@
|
|||||||
"remove_all_missing_content": "Haluatko varmasti poistaa kaikki puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien toistomäärät ja arvostelut.",
|
"remove_all_missing_content": "Haluatko varmasti poistaa kaikki puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien toistomäärät ja arvostelut.",
|
||||||
"noSimilarSongsFound": "Samankaltaisia kappaleita ei löytynyt",
|
"noSimilarSongsFound": "Samankaltaisia kappaleita ei löytynyt",
|
||||||
"noTopSongsFound": "Suosituimpia kappaleita ei löytynyt",
|
"noTopSongsFound": "Suosituimpia kappaleita ei löytynyt",
|
||||||
"startingInstantMix": "Ladataan Pikasekoitus..."
|
"startingInstantMix": "Ladataan Pikasekoitus...",
|
||||||
|
"uploadCover": "Lataa kansikuva",
|
||||||
|
"removeCover": "Poista kansikuva",
|
||||||
|
"coverUploaded": "Kansikuva päivitetty",
|
||||||
|
"coverRemoved": "Kansikuva poistettu",
|
||||||
|
"coverUploadError": "Virhe ladattaessa kansikuvaa",
|
||||||
|
"coverRemoveError": "Virhe poistettaessa kansikuvaa"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"library": "Kirjasto",
|
"library": "Kirjasto",
|
||||||
@ -674,7 +683,8 @@
|
|||||||
"exportSuccess": "Määritykset viety leikepöydälle TOML-muodossa",
|
"exportSuccess": "Määritykset viety leikepöydälle TOML-muodossa",
|
||||||
"exportFailed": "Määritysten kopiointi epäonnistui",
|
"exportFailed": "Määritysten kopiointi epäonnistui",
|
||||||
"devFlagsHeader": "Kehitysliput (voivat muuttua/poistua)",
|
"devFlagsHeader": "Kehitysliput (voivat muuttua/poistua)",
|
||||||
"devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa"
|
"devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa",
|
||||||
|
"downloadToml": "Lataa määritykset (TOML)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@ -708,4 +718,4 @@
|
|||||||
"empty": "Ei soita mitään",
|
"empty": "Ei soita mitään",
|
||||||
"minutesAgo": "%{smart_count} minuutti sitten |||| %{smart_count} minuuttia sitten"
|
"minutesAgo": "%{smart_count} minuutti sitten |||| %{smart_count} minuuttia sitten"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,8 @@
|
|||||||
"sampleRate": "Fréquence d'échantillonnage",
|
"sampleRate": "Fréquence d'échantillonnage",
|
||||||
"missing": "Manquant",
|
"missing": "Manquant",
|
||||||
"libraryName": "Bibliothèque",
|
"libraryName": "Bibliothèque",
|
||||||
"composer": "Compositeur·e"
|
"composer": "Compositeur·e",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Ajouter à la file",
|
"addToQueue": "Ajouter à la file",
|
||||||
@ -353,7 +354,8 @@
|
|||||||
"allUsers": "Autoriser tous les utilisateur·rices",
|
"allUsers": "Autoriser tous les utilisateur·rices",
|
||||||
"selectedUsers": "Utilisateur·rices sélectionné.e.s",
|
"selectedUsers": "Utilisateur·rices sélectionné.e.s",
|
||||||
"allLibraries": "Autoriser toutes les bibliothèques",
|
"allLibraries": "Autoriser toutes les bibliothèques",
|
||||||
"selectedLibraries": "Bibliothèques sélectionnées"
|
"selectedLibraries": "Bibliothèques sélectionnées",
|
||||||
|
"allowWriteAccess": ""
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Statut",
|
"status": "Statut",
|
||||||
@ -398,7 +400,8 @@
|
|||||||
"librariesRequired": "Cette extension nécessite l'accès aux information de la bibliothèque. Sélectionnez à quelles bibliothèque cette extension a accès, ou sélectionnez 'Autoriser toutes les bibliothèques'.",
|
"librariesRequired": "Cette extension nécessite l'accès aux information de la bibliothèque. Sélectionnez à quelles bibliothèque cette extension a accès, ou sélectionnez 'Autoriser toutes les bibliothèques'.",
|
||||||
"requiredHosts": "Hôtes requis",
|
"requiredHosts": "Hôtes requis",
|
||||||
"configValidationError": "Erreur lors de la validation de la configuration",
|
"configValidationError": "Erreur lors de la validation de la configuration",
|
||||||
"schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide."
|
"schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide.",
|
||||||
|
"allowWriteAccessHelp": ""
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "clef",
|
"configKey": "clef",
|
||||||
@ -674,7 +677,8 @@
|
|||||||
"exportSuccess": "La configuration a été copiée vers le presse-papier au format TOML",
|
"exportSuccess": "La configuration a été copiée vers le presse-papier au format TOML",
|
||||||
"exportFailed": "Une erreur est survenue en copiant la configuration",
|
"exportFailed": "Une erreur est survenue en copiant la configuration",
|
||||||
"devFlagsHeader": "Options de développement (peuvent être amenés à changer / être supprimés)",
|
"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"
|
"devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur",
|
||||||
|
"downloadToml": "Télécharger la configuration (TOML)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@ -708,4 +712,4 @@
|
|||||||
"empty": "Aucun titre en cours de lecture",
|
"empty": "Aucun titre en cours de lecture",
|
||||||
"minutesAgo": "Il y a %{smart_count} minute |||| Il y a %{smart_count} minutes"
|
"minutesAgo": "Il y a %{smart_count} minute |||| Il y a %{smart_count} minutes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,8 @@
|
|||||||
"sampleRate": "Taxa de mostra",
|
"sampleRate": "Taxa de mostra",
|
||||||
"missing": "Falta",
|
"missing": "Falta",
|
||||||
"libraryName": "Biblioteca",
|
"libraryName": "Biblioteca",
|
||||||
"composer": "Composición"
|
"composer": "Composición",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Ao final da cola",
|
"addToQueue": "Ao final da cola",
|
||||||
@ -353,7 +354,8 @@
|
|||||||
"allUsers": "Para todas as usuarias",
|
"allUsers": "Para todas as usuarias",
|
||||||
"selectedUsers": "Usuarias seleccionadas",
|
"selectedUsers": "Usuarias seleccionadas",
|
||||||
"allLibraries": "Permitir todas as bibliotecas",
|
"allLibraries": "Permitir todas as bibliotecas",
|
||||||
"selectedLibraries": "Selecciona bibliotecas"
|
"selectedLibraries": "Selecciona bibliotecas",
|
||||||
|
"allowWriteAccess": ""
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
@ -398,7 +400,8 @@
|
|||||||
"librariesRequired": "O complemento precisa acceso á información sobre a biblioteca. Selecciona as bibliotecas ás que pode acceder, ou activa 'Todas as bibliotecas'.",
|
"librariesRequired": "O complemento precisa acceso á información sobre a biblioteca. Selecciona as bibliotecas ás que pode acceder, ou activa 'Todas as bibliotecas'.",
|
||||||
"requiredHosts": "Servidores requeridos",
|
"requiredHosts": "Servidores requeridos",
|
||||||
"configValidationError": "Fallou a comprobación da configuración:",
|
"configValidationError": "Fallou a comprobación da configuración:",
|
||||||
"schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido."
|
"schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido.",
|
||||||
|
"allowWriteAccessHelp": ""
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "clave",
|
"configKey": "clave",
|
||||||
@ -674,7 +677,8 @@
|
|||||||
"exportSuccess": "Configuración exportada ao portapapeis no formato TOML",
|
"exportSuccess": "Configuración exportada ao portapapeis no formato TOML",
|
||||||
"exportFailed": "Fallou a copia da configuración",
|
"exportFailed": "Fallou a copia da configuración",
|
||||||
"devFlagsHeader": "Configuracións de Desenvolvemento (suxeitas a cambio/retirada)",
|
"devFlagsHeader": "Configuracións de Desenvolvemento (suxeitas a cambio/retirada)",
|
||||||
"devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións"
|
"devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións",
|
||||||
|
"downloadToml": "Descargar configuración (TOML)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@ -708,4 +712,4 @@
|
|||||||
"empty": "Sen reprodución",
|
"empty": "Sen reprodución",
|
||||||
"minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos"
|
"minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -218,15 +218,9 @@
|
|||||||
"saveQueue": "Salvar fila em nova Playlist",
|
"saveQueue": "Salvar fila em nova Playlist",
|
||||||
"searchOrCreate": "Buscar playlists ou criar nova...",
|
"searchOrCreate": "Buscar playlists ou criar nova...",
|
||||||
"pressEnterToCreate": "Pressione Enter para criar nova playlist",
|
"pressEnterToCreate": "Pressione Enter para criar nova playlist",
|
||||||
"removeFromSelection": "Remover da seleção",
|
"removeFromSelection": "Remover da seleção"
|
||||||
"uploadCover": "Enviar Capa",
|
|
||||||
"removeCover": "Remover Capa"
|
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"coverUploaded": "Capa atualizada",
|
|
||||||
"coverRemoved": "Capa removida",
|
|
||||||
"coverUploadError": "Erro ao enviar capa",
|
|
||||||
"coverRemoveError": "Erro ao remover capa",
|
|
||||||
"duplicate_song": "Adicionar músicas duplicadas",
|
"duplicate_song": "Adicionar músicas duplicadas",
|
||||||
"song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?",
|
"song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?",
|
||||||
"noPlaylistsFound": "Nenhuma playlist encontrada",
|
"noPlaylistsFound": "Nenhuma playlist encontrada",
|
||||||
@ -560,6 +554,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
|
"uploadCover": "Enviar Capa",
|
||||||
|
"removeCover": "Remover Capa",
|
||||||
|
"coverUploaded": "Capa atualizada",
|
||||||
|
"coverRemoved": "Capa removida",
|
||||||
|
"coverUploadError": "Erro ao enviar capa",
|
||||||
|
"coverRemoveError": "Erro ao remover capa",
|
||||||
"note": "ATENÇÃO",
|
"note": "ATENÇÃO",
|
||||||
"transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}",
|
"transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}",
|
||||||
"transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão",
|
"transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão",
|
||||||
|
|||||||
@ -37,7 +37,8 @@
|
|||||||
"sampleRate": "Частота дискретизации (Hz)",
|
"sampleRate": "Частота дискретизации (Hz)",
|
||||||
"missing": "Поле отсутствует",
|
"missing": "Поле отсутствует",
|
||||||
"libraryName": "Библиотека",
|
"libraryName": "Библиотека",
|
||||||
"composer": "Композитор"
|
"composer": "Композитор",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "В очередь",
|
"addToQueue": "В очередь",
|
||||||
@ -353,7 +354,8 @@
|
|||||||
"allUsers": "Разрешить всем пользователям",
|
"allUsers": "Разрешить всем пользователям",
|
||||||
"selectedUsers": "Выбранные пользователи",
|
"selectedUsers": "Выбранные пользователи",
|
||||||
"allLibraries": "Разрешить доступ ко всем библиотекам",
|
"allLibraries": "Разрешить доступ ко всем библиотекам",
|
||||||
"selectedLibraries": "Избранные библиотеки"
|
"selectedLibraries": "Избранные библиотеки",
|
||||||
|
"allowWriteAccess": ""
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Статус",
|
"status": "Статус",
|
||||||
@ -398,7 +400,8 @@
|
|||||||
"librariesRequired": "Этому плагину требуется доступ к библиотечной информации. Выберите, к каким библиотекам плагин может получить доступ, или включите \"Разрешить все библиотеки\".",
|
"librariesRequired": "Этому плагину требуется доступ к библиотечной информации. Выберите, к каким библиотекам плагин может получить доступ, или включите \"Разрешить все библиотеки\".",
|
||||||
"requiredHosts": "Необходимые хосты",
|
"requiredHosts": "Необходимые хосты",
|
||||||
"configValidationError": "Проверка конфигурации завершилась неудачей:",
|
"configValidationError": "Проверка конфигурации завершилась неудачей:",
|
||||||
"schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна."
|
"schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна.",
|
||||||
|
"allowWriteAccessHelp": ""
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "ключ",
|
"configKey": "ключ",
|
||||||
@ -674,7 +677,8 @@
|
|||||||
"exportSuccess": "Конфигурация экспортирована в буфер обмена в формате TOML",
|
"exportSuccess": "Конфигурация экспортирована в буфер обмена в формате TOML",
|
||||||
"exportFailed": "Не удалось скопировать конфигурацию",
|
"exportFailed": "Не удалось скопировать конфигурацию",
|
||||||
"devFlagsHeader": "Флаги разработки (могут быть изменены/удалены)",
|
"devFlagsHeader": "Флаги разработки (могут быть изменены/удалены)",
|
||||||
"devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях."
|
"devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях.",
|
||||||
|
"downloadToml": "Скачать конфигурацию (TOML)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@ -708,4 +712,4 @@
|
|||||||
"empty": "Ничего не играет",
|
"empty": "Ничего не играет",
|
||||||
"minutesAgo": "%{smart_count} минут назад |||| %{smart_count} минут назад"
|
"minutesAgo": "%{smart_count} минут назад |||| %{smart_count} минут назад"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,8 @@
|
|||||||
"sampleRate": "Frekvenca vzorčenja",
|
"sampleRate": "Frekvenca vzorčenja",
|
||||||
"missing": "Manjka",
|
"missing": "Manjka",
|
||||||
"libraryName": "Knjižnica",
|
"libraryName": "Knjižnica",
|
||||||
"composer": "Skladatelj"
|
"composer": "Skladatelj",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Predvajaj kasneje",
|
"addToQueue": "Predvajaj kasneje",
|
||||||
@ -48,7 +49,7 @@
|
|||||||
"playNext": "Naslednji",
|
"playNext": "Naslednji",
|
||||||
"info": "Več informacij",
|
"info": "Več informacij",
|
||||||
"showInPlaylist": "Prikaži na seznamu predvajanja",
|
"showInPlaylist": "Prikaži na seznamu predvajanja",
|
||||||
"instantMix": ""
|
"instantMix": "Instant Mix"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"album": {
|
"album": {
|
||||||
@ -353,7 +354,8 @@
|
|||||||
"allUsers": "Dovoli vsem uporabnikom",
|
"allUsers": "Dovoli vsem uporabnikom",
|
||||||
"selectedUsers": "Izbrani uporabniki",
|
"selectedUsers": "Izbrani uporabniki",
|
||||||
"allLibraries": "Dovoli vse knjižnice",
|
"allLibraries": "Dovoli vse knjižnice",
|
||||||
"selectedLibraries": "Izbrane knjižnice"
|
"selectedLibraries": "Izbrane knjižnice",
|
||||||
|
"allowWriteAccess": ""
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@ -397,8 +399,9 @@
|
|||||||
"noLibraries": "Ni izbranih knjižnic",
|
"noLibraries": "Ni izbranih knjižnic",
|
||||||
"librariesRequired": "Vtičnik zahteva dostop do knjižnih informacij. Izberi do katerih knjižnic lahko dostopa, ali vključi dostop do vseh knjižnic.",
|
"librariesRequired": "Vtičnik zahteva dostop do knjižnih informacij. Izberi do katerih knjižnic lahko dostopa, ali vključi dostop do vseh knjižnic.",
|
||||||
"requiredHosts": "Zahtevani gostitelji",
|
"requiredHosts": "Zahtevani gostitelji",
|
||||||
"configValidationError": "",
|
"configValidationError": "Validacija konfiguracije neuspešna:",
|
||||||
"schemaRenderError": ""
|
"schemaRenderError": "Konfiguracijskega obrazca ni mogoče upodobiti. Shema vtičnika je morda neveljavna.",
|
||||||
|
"allowWriteAccessHelp": ""
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "ključ",
|
"configKey": "ključ",
|
||||||
@ -588,7 +591,7 @@
|
|||||||
"remove_all_missing_content": "Ste prepričani, da želite odstraniti vse manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.",
|
"remove_all_missing_content": "Ste prepričani, da želite odstraniti vse manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.",
|
||||||
"noSimilarSongsFound": "Ni najdenih podobnih pesmi",
|
"noSimilarSongsFound": "Ni najdenih podobnih pesmi",
|
||||||
"noTopSongsFound": "Ni najdenih najboljših pesmi",
|
"noTopSongsFound": "Ni najdenih najboljših pesmi",
|
||||||
"startingInstantMix": ""
|
"startingInstantMix": "Nalaganje Instant Mix..."
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"library": "Knjižnica",
|
"library": "Knjižnica",
|
||||||
@ -674,7 +677,8 @@
|
|||||||
"exportSuccess": "Konfiguracija izvožena v odložišče v formatu TOML",
|
"exportSuccess": "Konfiguracija izvožena v odložišče v formatu TOML",
|
||||||
"exportFailed": "Kopiranje konfiguracije ni uspelo",
|
"exportFailed": "Kopiranje konfiguracije ni uspelo",
|
||||||
"devFlagsHeader": "Razvojne zastavice (lahko se spremenijo/odstranijo)",
|
"devFlagsHeader": "Razvojne zastavice (lahko se spremenijo/odstranijo)",
|
||||||
"devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah"
|
"devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah",
|
||||||
|
"downloadToml": "Naloži konfiguracijo (TOML)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@ -708,4 +712,4 @@
|
|||||||
"empty": "Nič se ne predvaja",
|
"empty": "Nič se ne predvaja",
|
||||||
"minutesAgo": "Pred %{smart_count} minuto |||| Pred %{smart_count} minutami"
|
"minutesAgo": "Pred %{smart_count} minuto |||| Pred %{smart_count} minutami"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,8 @@
|
|||||||
"sampleRate": "Samplingsfrekvens",
|
"sampleRate": "Samplingsfrekvens",
|
||||||
"missing": "Saknade",
|
"missing": "Saknade",
|
||||||
"libraryName": "Bibliotek",
|
"libraryName": "Bibliotek",
|
||||||
"composer": "Kompositör"
|
"composer": "Kompositör",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Lägg till i kön",
|
"addToQueue": "Lägg till i kön",
|
||||||
@ -353,7 +354,8 @@
|
|||||||
"allUsers": "Tillåt alla användare",
|
"allUsers": "Tillåt alla användare",
|
||||||
"selectedUsers": "Valda användare",
|
"selectedUsers": "Valda användare",
|
||||||
"allLibraries": "Tillåt alla bibliotek",
|
"allLibraries": "Tillåt alla bibliotek",
|
||||||
"selectedLibraries": "Valda bibliotek"
|
"selectedLibraries": "Valda bibliotek",
|
||||||
|
"allowWriteAccess": ""
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@ -398,7 +400,8 @@
|
|||||||
"librariesRequired": "Detta tillägg kräver tillgång till biblioteksinformation. Välj vilka bibliotek tillägget kan komma åt eller aktivera 'Tillåt alla bibliotek'.",
|
"librariesRequired": "Detta tillägg kräver tillgång till biblioteksinformation. Välj vilka bibliotek tillägget kan komma åt eller aktivera 'Tillåt alla bibliotek'.",
|
||||||
"requiredHosts": "Krävda värdar",
|
"requiredHosts": "Krävda värdar",
|
||||||
"configValidationError": "Validering av konfigurationen misslyckades:",
|
"configValidationError": "Validering av konfigurationen misslyckades:",
|
||||||
"schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt."
|
"schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.",
|
||||||
|
"allowWriteAccessHelp": ""
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "nyckel",
|
"configKey": "nyckel",
|
||||||
@ -674,7 +677,8 @@
|
|||||||
"exportSuccess": "Inställningarna kopierade till urklippet i TOML-format",
|
"exportSuccess": "Inställningarna kopierade till urklippet i TOML-format",
|
||||||
"exportFailed": "Kopiering av inställningarna misslyckades",
|
"exportFailed": "Kopiering av inställningarna misslyckades",
|
||||||
"devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)",
|
"devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)",
|
||||||
"devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner"
|
"devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner",
|
||||||
|
"downloadToml": "Ladda ner konfiguration (TOML)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@ -708,4 +712,4 @@
|
|||||||
"empty": "Inget spelas",
|
"empty": "Inget spelas",
|
||||||
"minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan"
|
"minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,8 @@
|
|||||||
"sampleRate": "แซมเปิ้ลเรต",
|
"sampleRate": "แซมเปิ้ลเรต",
|
||||||
"missing": "หายไป",
|
"missing": "หายไป",
|
||||||
"libraryName": "ห้องสมุด",
|
"libraryName": "ห้องสมุด",
|
||||||
"composer": "ผู้แต่ง"
|
"composer": "ผู้แต่ง",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "เพิ่มในคิว",
|
"addToQueue": "เพิ่มในคิว",
|
||||||
@ -48,7 +49,7 @@
|
|||||||
"playNext": "เล่นถัดไป",
|
"playNext": "เล่นถัดไป",
|
||||||
"info": "ดูรายละเอียด",
|
"info": "ดูรายละเอียด",
|
||||||
"showInPlaylist": "แสดงในเพลย์ลิสต์",
|
"showInPlaylist": "แสดงในเพลย์ลิสต์",
|
||||||
"instantMix": ""
|
"instantMix": "อินสแตนต์ มิก"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"album": {
|
"album": {
|
||||||
@ -353,7 +354,8 @@
|
|||||||
"allUsers": "อนุญาติผู้ใช้ทั้งหมด",
|
"allUsers": "อนุญาติผู้ใช้ทั้งหมด",
|
||||||
"selectedUsers": "ผู้ใช้ถูกเลือก",
|
"selectedUsers": "ผู้ใช้ถูกเลือก",
|
||||||
"allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด",
|
"allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด",
|
||||||
"selectedLibraries": "ห้องสมุดเพลงถูกเลือก"
|
"selectedLibraries": "ห้องสมุดเพลงถูกเลือก",
|
||||||
|
"allowWriteAccess": ""
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "สถานะ",
|
"status": "สถานะ",
|
||||||
@ -398,7 +400,8 @@
|
|||||||
"librariesRequired": "ปลั๊กอินนี้ต้องการเข้าถึงข้อมูลห้องสมุดเพลง เลือกห้องสมุดเพลงที่ต้องการให้ปลั๊กอินเข้าถึงหรือเปิดใช้งานกับห้องสมุดเพลงทั้งหมด",
|
"librariesRequired": "ปลั๊กอินนี้ต้องการเข้าถึงข้อมูลห้องสมุดเพลง เลือกห้องสมุดเพลงที่ต้องการให้ปลั๊กอินเข้าถึงหรือเปิดใช้งานกับห้องสมุดเพลงทั้งหมด",
|
||||||
"requiredHosts": "ต้องการ Host",
|
"requiredHosts": "ต้องการ Host",
|
||||||
"configValidationError": "การตั้งค่าเกิดความผิดพลาด",
|
"configValidationError": "การตั้งค่าเกิดความผิดพลาด",
|
||||||
"schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน"
|
"schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน",
|
||||||
|
"allowWriteAccessHelp": ""
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "คีย์",
|
"configKey": "คีย์",
|
||||||
@ -588,7 +591,7 @@
|
|||||||
"remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
|
"remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
|
||||||
"noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน",
|
"noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน",
|
||||||
"noTopSongsFound": "ไม่พบเพลงยอดนิยม",
|
"noTopSongsFound": "ไม่พบเพลงยอดนิยม",
|
||||||
"startingInstantMix": ""
|
"startingInstantMix": "กำลังโหลดอินสแตนท์ มิก..."
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"library": "ห้องสมุดเพลง",
|
"library": "ห้องสมุดเพลง",
|
||||||
@ -674,7 +677,8 @@
|
|||||||
"exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว",
|
"exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว",
|
||||||
"exportFailed": "คัดลอกการตั้งค่าล้มเหลว",
|
"exportFailed": "คัดลอกการตั้งค่าล้มเหลว",
|
||||||
"devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)",
|
"devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)",
|
||||||
"devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง"
|
"devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง",
|
||||||
|
"downloadToml": "ดาวน์โหลดการตั้งค่า (TOML)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@ -708,4 +712,4 @@
|
|||||||
"empty": "ไม่มีเพลงเล่น",
|
"empty": "ไม่มีเพลงเล่น",
|
||||||
"minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว"
|
"minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,19 +10,14 @@
|
|||||||
"playCount": "播放次數",
|
"playCount": "播放次數",
|
||||||
"title": "標題",
|
"title": "標題",
|
||||||
"artist": "藝人",
|
"artist": "藝人",
|
||||||
"composer": "作曲者",
|
|
||||||
"album": "專輯",
|
"album": "專輯",
|
||||||
"path": "檔案路徑",
|
"path": "檔案路徑",
|
||||||
"libraryName": "媒體庫",
|
|
||||||
"genre": "曲風",
|
"genre": "曲風",
|
||||||
"compilation": "合輯",
|
"compilation": "合輯",
|
||||||
"year": "發行年份",
|
"year": "發行年份",
|
||||||
"size": "檔案大小",
|
"size": "檔案大小",
|
||||||
"updatedAt": "更新於",
|
"updatedAt": "更新於",
|
||||||
"bitRate": "位元率",
|
"bitRate": "位元率",
|
||||||
"bitDepth": "位元深度",
|
|
||||||
"sampleRate": "取樣率",
|
|
||||||
"channels": "聲道",
|
|
||||||
"discSubtitle": "光碟副標題",
|
"discSubtitle": "光碟副標題",
|
||||||
"starred": "收藏",
|
"starred": "收藏",
|
||||||
"comment": "註解",
|
"comment": "註解",
|
||||||
@ -30,6 +25,7 @@
|
|||||||
"quality": "品質",
|
"quality": "品質",
|
||||||
"bpm": "BPM",
|
"bpm": "BPM",
|
||||||
"playDate": "上次播放",
|
"playDate": "上次播放",
|
||||||
|
"channels": "聲道",
|
||||||
"createdAt": "建立於",
|
"createdAt": "建立於",
|
||||||
"grouping": "分組",
|
"grouping": "分組",
|
||||||
"mood": "情緒",
|
"mood": "情緒",
|
||||||
@ -37,17 +33,22 @@
|
|||||||
"tags": "額外標籤",
|
"tags": "額外標籤",
|
||||||
"mappedTags": "分類後標籤",
|
"mappedTags": "分類後標籤",
|
||||||
"rawTags": "原始標籤",
|
"rawTags": "原始標籤",
|
||||||
"missing": "遺失"
|
"bitDepth": "位元深度",
|
||||||
|
"sampleRate": "取樣率",
|
||||||
|
"missing": "遺失",
|
||||||
|
"libraryName": "媒體庫",
|
||||||
|
"composer": "作曲者",
|
||||||
|
"disc": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "加入至播放佇列",
|
"addToQueue": "加入至播放佇列",
|
||||||
"playNow": "立即播放",
|
"playNow": "立即播放",
|
||||||
"addToPlaylist": "加入至播放清單",
|
"addToPlaylist": "加入至播放清單",
|
||||||
"showInPlaylist": "在播放清單中顯示",
|
|
||||||
"shuffleAll": "全部隨機播放",
|
"shuffleAll": "全部隨機播放",
|
||||||
"download": "下載",
|
"download": "下載",
|
||||||
"playNext": "下一首播放",
|
"playNext": "下一首播放",
|
||||||
"info": "取得資訊",
|
"info": "取得資訊",
|
||||||
|
"showInPlaylist": "在播放清單中顯示",
|
||||||
"instantMix": "即時混音"
|
"instantMix": "即時混音"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -59,38 +60,38 @@
|
|||||||
"duration": "長度",
|
"duration": "長度",
|
||||||
"songCount": "歌曲數",
|
"songCount": "歌曲數",
|
||||||
"playCount": "播放次數",
|
"playCount": "播放次數",
|
||||||
"size": "檔案大小",
|
|
||||||
"name": "名稱",
|
"name": "名稱",
|
||||||
"libraryName": "媒體庫",
|
|
||||||
"genre": "曲風",
|
"genre": "曲風",
|
||||||
"compilation": "合輯",
|
"compilation": "合輯",
|
||||||
"year": "發行年份",
|
"year": "發行年份",
|
||||||
"date": "錄製日期",
|
|
||||||
"originalDate": "原始日期",
|
|
||||||
"releaseDate": "發行日期",
|
|
||||||
"releases": "發行",
|
|
||||||
"released": "已發行",
|
|
||||||
"updatedAt": "更新於",
|
"updatedAt": "更新於",
|
||||||
"comment": "註解",
|
"comment": "註解",
|
||||||
"rating": "評分",
|
"rating": "評分",
|
||||||
"createdAt": "建立於",
|
"createdAt": "建立於",
|
||||||
|
"size": "檔案大小",
|
||||||
|
"originalDate": "原始日期",
|
||||||
|
"releaseDate": "發行日期",
|
||||||
|
"releases": "發行",
|
||||||
|
"released": "已發行",
|
||||||
"recordLabel": "唱片公司",
|
"recordLabel": "唱片公司",
|
||||||
"catalogNum": "目錄編號",
|
"catalogNum": "目錄編號",
|
||||||
"releaseType": "發行類型",
|
"releaseType": "發行類型",
|
||||||
"grouping": "分組",
|
"grouping": "分組",
|
||||||
"media": "媒體類型",
|
"media": "媒體類型",
|
||||||
"mood": "情緒",
|
"mood": "情緒",
|
||||||
"missing": "遺失"
|
"date": "錄製日期",
|
||||||
|
"missing": "遺失",
|
||||||
|
"libraryName": "媒體庫"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"playAll": "播放全部",
|
"playAll": "播放全部",
|
||||||
"playNext": "下一首播放",
|
"playNext": "下一首播放",
|
||||||
"addToQueue": "加入至播放佇列",
|
"addToQueue": "加入至播放佇列",
|
||||||
"share": "分享",
|
|
||||||
"shuffle": "隨機播放",
|
"shuffle": "隨機播放",
|
||||||
"addToPlaylist": "加入至播放清單",
|
"addToPlaylist": "加入至播放清單",
|
||||||
"download": "下載",
|
"download": "下載",
|
||||||
"info": "取得資訊"
|
"info": "取得資訊",
|
||||||
|
"share": "分享"
|
||||||
},
|
},
|
||||||
"lists": {
|
"lists": {
|
||||||
"all": "所有",
|
"all": "所有",
|
||||||
@ -108,10 +109,10 @@
|
|||||||
"name": "名稱",
|
"name": "名稱",
|
||||||
"albumCount": "專輯數",
|
"albumCount": "專輯數",
|
||||||
"songCount": "歌曲數",
|
"songCount": "歌曲數",
|
||||||
"size": "檔案大小",
|
|
||||||
"playCount": "播放次數",
|
"playCount": "播放次數",
|
||||||
"rating": "評分",
|
"rating": "評分",
|
||||||
"genre": "曲風",
|
"genre": "曲風",
|
||||||
|
"size": "檔案大小",
|
||||||
"role": "參與角色",
|
"role": "參與角色",
|
||||||
"missing": "遺失"
|
"missing": "遺失"
|
||||||
},
|
},
|
||||||
@ -132,9 +133,9 @@
|
|||||||
"maincredit": "專輯藝人或藝人 |||| 專輯藝人或藝人"
|
"maincredit": "專輯藝人或藝人 |||| 專輯藝人或藝人"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"topSongs": "熱門歌曲",
|
|
||||||
"shuffle": "隨機播放",
|
"shuffle": "隨機播放",
|
||||||
"radio": "電台"
|
"radio": "電台",
|
||||||
|
"topSongs": "熱門歌曲"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
@ -143,7 +144,6 @@
|
|||||||
"userName": "使用者名稱",
|
"userName": "使用者名稱",
|
||||||
"isAdmin": "管理員",
|
"isAdmin": "管理員",
|
||||||
"lastLoginAt": "上次登入",
|
"lastLoginAt": "上次登入",
|
||||||
"lastAccessAt": "上次存取",
|
|
||||||
"updatedAt": "更新於",
|
"updatedAt": "更新於",
|
||||||
"name": "名稱",
|
"name": "名稱",
|
||||||
"password": "密碼",
|
"password": "密碼",
|
||||||
@ -152,6 +152,7 @@
|
|||||||
"currentPassword": "目前密碼",
|
"currentPassword": "目前密碼",
|
||||||
"newPassword": "新密碼",
|
"newPassword": "新密碼",
|
||||||
"token": "權杖",
|
"token": "權杖",
|
||||||
|
"lastAccessAt": "上次存取",
|
||||||
"libraries": "媒體庫"
|
"libraries": "媒體庫"
|
||||||
},
|
},
|
||||||
"helperTexts": {
|
"helperTexts": {
|
||||||
@ -163,14 +164,14 @@
|
|||||||
"updated": "使用者已更新",
|
"updated": "使用者已更新",
|
||||||
"deleted": "使用者已刪除"
|
"deleted": "使用者已刪除"
|
||||||
},
|
},
|
||||||
"validation": {
|
|
||||||
"librariesRequired": "非管理員使用者必須至少選擇一個媒體庫"
|
|
||||||
},
|
|
||||||
"message": {
|
"message": {
|
||||||
"listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖",
|
"listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖",
|
||||||
"clickHereForToken": "點擊此處來獲得您的 ListenBrainz 權杖",
|
"clickHereForToken": "點擊此處來獲得您的 ListenBrainz 權杖",
|
||||||
"selectAllLibraries": "選取全部媒體庫",
|
"selectAllLibraries": "選取全部媒體庫",
|
||||||
"adminAutoLibraries": "管理員預設可存取所有媒體庫"
|
"adminAutoLibraries": "管理員預設可存取所有媒體庫"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"librariesRequired": "非管理員使用者必須至少選擇一個媒體庫"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
@ -213,9 +214,9 @@
|
|||||||
"selectPlaylist": "選取播放清單:",
|
"selectPlaylist": "選取播放清單:",
|
||||||
"addNewPlaylist": "建立「%{name}」",
|
"addNewPlaylist": "建立「%{name}」",
|
||||||
"export": "匯出",
|
"export": "匯出",
|
||||||
"saveQueue": "將播放佇列儲存到播放清單",
|
|
||||||
"makePublic": "設為公開",
|
"makePublic": "設為公開",
|
||||||
"makePrivate": "設為私人",
|
"makePrivate": "設為私人",
|
||||||
|
"saveQueue": "將播放佇列儲存到播放清單",
|
||||||
"searchOrCreate": "搜尋播放清單,或輸入名稱來新建…",
|
"searchOrCreate": "搜尋播放清單,或輸入名稱來新建…",
|
||||||
"pressEnterToCreate": "按 Enter 鍵建立新的播放清單",
|
"pressEnterToCreate": "按 Enter 鍵建立新的播放清單",
|
||||||
"removeFromSelection": "移除選取項目"
|
"removeFromSelection": "移除選取項目"
|
||||||
@ -246,7 +247,6 @@
|
|||||||
"username": "分享者",
|
"username": "分享者",
|
||||||
"url": "網址",
|
"url": "網址",
|
||||||
"description": "描述",
|
"description": "描述",
|
||||||
"downloadable": "允許下載?",
|
|
||||||
"contents": "內容",
|
"contents": "內容",
|
||||||
"expiresAt": "過期時間",
|
"expiresAt": "過期時間",
|
||||||
"lastVisitedAt": "上次造訪時間",
|
"lastVisitedAt": "上次造訪時間",
|
||||||
@ -254,19 +254,17 @@
|
|||||||
"format": "格式",
|
"format": "格式",
|
||||||
"maxBitRate": "最大位元率",
|
"maxBitRate": "最大位元率",
|
||||||
"updatedAt": "更新於",
|
"updatedAt": "更新於",
|
||||||
"createdAt": "建立於"
|
"createdAt": "建立於",
|
||||||
},
|
"downloadable": "允許下載?"
|
||||||
"notifications": {},
|
}
|
||||||
"actions": {}
|
|
||||||
},
|
},
|
||||||
"missing": {
|
"missing": {
|
||||||
"name": "遺失檔案 |||| 遺失檔案",
|
"name": "遺失檔案 |||| 遺失檔案",
|
||||||
"empty": "無遺失檔案",
|
|
||||||
"fields": {
|
"fields": {
|
||||||
"path": "路徑",
|
"path": "路徑",
|
||||||
"size": "檔案大小",
|
"size": "檔案大小",
|
||||||
"libraryName": "媒體庫",
|
"updatedAt": "遺失於",
|
||||||
"updatedAt": "遺失於"
|
"libraryName": "媒體庫"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"remove": "刪除",
|
"remove": "刪除",
|
||||||
@ -274,7 +272,8 @@
|
|||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"removed": "遺失檔案已刪除"
|
"removed": "遺失檔案已刪除"
|
||||||
}
|
},
|
||||||
|
"empty": "無遺失檔案"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"name": "媒體庫 |||| 媒體庫",
|
"name": "媒體庫 |||| 媒體庫",
|
||||||
@ -304,20 +303,20 @@
|
|||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"scan": "掃描媒體庫",
|
"scan": "掃描媒體庫",
|
||||||
"quickScan": "快速掃描",
|
|
||||||
"fullScan": "完整掃描",
|
|
||||||
"manageUsers": "管理使用者權限",
|
"manageUsers": "管理使用者權限",
|
||||||
"viewDetails": "查看詳細資料"
|
"viewDetails": "查看詳細資料",
|
||||||
|
"quickScan": "快速掃描",
|
||||||
|
"fullScan": "完整掃描"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"created": "成功建立媒體庫",
|
"created": "成功建立媒體庫",
|
||||||
"updated": "成功更新媒體庫",
|
"updated": "成功更新媒體庫",
|
||||||
"deleted": "成功刪除媒體庫",
|
"deleted": "成功刪除媒體庫",
|
||||||
"scanStarted": "開始掃描媒體庫",
|
"scanStarted": "開始掃描媒體庫",
|
||||||
|
"scanCompleted": "媒體庫掃描完成",
|
||||||
"quickScanStarted": "快速掃描已開始",
|
"quickScanStarted": "快速掃描已開始",
|
||||||
"fullScanStarted": "完整掃描已開始",
|
"fullScanStarted": "完整掃描已開始",
|
||||||
"scanError": "掃描啟動失敗,請檢查日誌",
|
"scanError": "掃描啟動失敗,請檢查日誌"
|
||||||
"scanCompleted": "媒體庫掃描完成"
|
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"nameRequired": "請輸入媒體庫名稱",
|
"nameRequired": "請輸入媒體庫名稱",
|
||||||
@ -355,7 +354,8 @@
|
|||||||
"allUsers": "允許所有使用者",
|
"allUsers": "允許所有使用者",
|
||||||
"selectedUsers": "選定的使用者",
|
"selectedUsers": "選定的使用者",
|
||||||
"allLibraries": "允許所有媒體庫",
|
"allLibraries": "允許所有媒體庫",
|
||||||
"selectedLibraries": "選定的媒體庫"
|
"selectedLibraries": "選定的媒體庫",
|
||||||
|
"allowWriteAccess": "允許寫入權限"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"status": "狀態",
|
"status": "狀態",
|
||||||
@ -389,8 +389,6 @@
|
|||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"configHelp": "使用鍵值對設定插件。若插件無需設定則留空。",
|
"configHelp": "使用鍵值對設定插件。若插件無需設定則留空。",
|
||||||
"configValidationError": "設定驗證失敗:",
|
|
||||||
"schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。",
|
|
||||||
"clickPermissions": "點擊權限以查看詳細資訊",
|
"clickPermissions": "點擊權限以查看詳細資訊",
|
||||||
"noConfig": "無設定",
|
"noConfig": "無設定",
|
||||||
"allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。",
|
"allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。",
|
||||||
@ -400,7 +398,10 @@
|
|||||||
"allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。",
|
"allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。",
|
||||||
"noLibraries": "未選擇媒體庫",
|
"noLibraries": "未選擇媒體庫",
|
||||||
"librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。",
|
"librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。",
|
||||||
"requiredHosts": "必要的 Hosts"
|
"requiredHosts": "必要的 Hosts",
|
||||||
|
"configValidationError": "設定驗證失敗:",
|
||||||
|
"schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。",
|
||||||
|
"allowWriteAccessHelp": "啟用後,插件可以修改媒體庫目錄中的檔案。 預設情況下,插件具有唯讀權限。"
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "鍵",
|
"configKey": "鍵",
|
||||||
@ -443,7 +444,6 @@
|
|||||||
"add": "加入",
|
"add": "加入",
|
||||||
"back": "返回",
|
"back": "返回",
|
||||||
"bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項",
|
"bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項",
|
||||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"clear_input_value": "清除",
|
"clear_input_value": "清除",
|
||||||
"clone": "複製",
|
"clone": "複製",
|
||||||
@ -467,6 +467,7 @@
|
|||||||
"close_menu": "關閉選單",
|
"close_menu": "關閉選單",
|
||||||
"unselect": "取消選取",
|
"unselect": "取消選取",
|
||||||
"skip": "略過",
|
"skip": "略過",
|
||||||
|
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||||
"share": "分享",
|
"share": "分享",
|
||||||
"download": "下載"
|
"download": "下載"
|
||||||
},
|
},
|
||||||
@ -558,48 +559,48 @@
|
|||||||
"transcodingDisabled": "出於安全原因,已禁用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 設定選項的情況下重新啟動伺服器。",
|
"transcodingDisabled": "出於安全原因,已禁用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 設定選項的情況下重新啟動伺服器。",
|
||||||
"transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。",
|
"transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。",
|
||||||
"songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單",
|
"songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單",
|
||||||
"noSimilarSongsFound": "找不到相似歌曲",
|
|
||||||
"startingInstantMix": "正在載入即時混音...",
|
|
||||||
"noTopSongsFound": "找不到熱門歌曲",
|
|
||||||
"noPlaylistsAvailable": "沒有可用的播放清單",
|
"noPlaylistsAvailable": "沒有可用的播放清單",
|
||||||
"delete_user_title": "刪除使用者「%{name}」",
|
"delete_user_title": "刪除使用者「%{name}」",
|
||||||
"delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?",
|
"delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?",
|
||||||
"remove_missing_title": "刪除遺失檔案",
|
|
||||||
"remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。",
|
|
||||||
"remove_all_missing_title": "刪除所有遺失檔案",
|
|
||||||
"remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。",
|
|
||||||
"notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知",
|
"notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知",
|
||||||
"notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome",
|
"notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome",
|
||||||
"lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄",
|
"lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄",
|
||||||
"lastfmLinkFailure": "無法連接 Last.fm",
|
"lastfmLinkFailure": "無法連接 Last.fm",
|
||||||
"lastfmUnlinkSuccess": "已取消與 Last.fm 的連接並停用音樂記錄",
|
"lastfmUnlinkSuccess": "已取消與 Last.fm 的連接並停用音樂記錄",
|
||||||
"lastfmUnlinkFailure": "無法取消與 Last.fm 的連接",
|
"lastfmUnlinkFailure": "無法取消與 Last.fm 的連接",
|
||||||
"listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄",
|
|
||||||
"listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}",
|
|
||||||
"listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄",
|
|
||||||
"listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接",
|
|
||||||
"openIn": {
|
"openIn": {
|
||||||
"lastfm": "在 Last.fm 中開啟",
|
"lastfm": "在 Last.fm 中開啟",
|
||||||
"musicbrainz": "在 MusicBrainz 中開啟"
|
"musicbrainz": "在 MusicBrainz 中開啟"
|
||||||
},
|
},
|
||||||
"lastfmLink": "查看更多…",
|
"lastfmLink": "查看更多…",
|
||||||
|
"listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄",
|
||||||
|
"listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}",
|
||||||
|
"listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄",
|
||||||
|
"listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接",
|
||||||
|
"downloadOriginalFormat": "下載原始格式",
|
||||||
"shareOriginalFormat": "分享原始格式",
|
"shareOriginalFormat": "分享原始格式",
|
||||||
"shareDialogTitle": "分享 %{resource} '%{name}'",
|
"shareDialogTitle": "分享 %{resource} '%{name}'",
|
||||||
"shareBatchDialogTitle": "分享 1 個%{resource} |||| 分享 %{smart_count} 個%{resource}",
|
"shareBatchDialogTitle": "分享 1 個%{resource} |||| 分享 %{smart_count} 個%{resource}",
|
||||||
"shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter",
|
|
||||||
"shareSuccess": "分享成功,連結已複製到剪貼簿:%{url}",
|
"shareSuccess": "分享成功,連結已複製到剪貼簿:%{url}",
|
||||||
"shareFailure": "分享連結複製失敗:%{url}",
|
"shareFailure": "分享連結複製失敗:%{url}",
|
||||||
"downloadDialogTitle": "下載 %{resource} '%{name}' (%{size})",
|
"downloadDialogTitle": "下載 %{resource} '%{name}' (%{size})",
|
||||||
"downloadOriginalFormat": "下載原始格式"
|
"shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter",
|
||||||
|
"remove_missing_title": "刪除遺失檔案",
|
||||||
|
"remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。",
|
||||||
|
"remove_all_missing_title": "刪除所有遺失檔案",
|
||||||
|
"remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。",
|
||||||
|
"noSimilarSongsFound": "找不到相似歌曲",
|
||||||
|
"noTopSongsFound": "找不到熱門歌曲",
|
||||||
|
"startingInstantMix": "正在載入即時混音...",
|
||||||
|
"uploadCover": "上傳封面",
|
||||||
|
"removeCover": "移除封面",
|
||||||
|
"coverUploaded": "已更新封面圖",
|
||||||
|
"coverRemoved": "已移除封面圖",
|
||||||
|
"coverUploadError": "上傳封面圖時發生錯誤",
|
||||||
|
"coverRemoveError": "移除封面圖時發生錯誤"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"library": "媒體庫",
|
"library": "媒體庫",
|
||||||
"librarySelector": {
|
|
||||||
"allLibraries": "所有媒體庫 (%{count})",
|
|
||||||
"multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
|
|
||||||
"selectLibraries": "選取媒體庫",
|
|
||||||
"none": "無"
|
|
||||||
},
|
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
"theme": "主題",
|
"theme": "主題",
|
||||||
@ -610,7 +611,6 @@
|
|||||||
"language": "語言",
|
"language": "語言",
|
||||||
"defaultView": "預設畫面",
|
"defaultView": "預設畫面",
|
||||||
"desktop_notifications": "桌面通知",
|
"desktop_notifications": "桌面通知",
|
||||||
"lastfmNotConfigured": "Last.fm API 金鑰未設定",
|
|
||||||
"lastfmScrobbling": "啟用 Last.fm 音樂記錄",
|
"lastfmScrobbling": "啟用 Last.fm 音樂記錄",
|
||||||
"listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄",
|
"listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄",
|
||||||
"replaygain": "重播增益模式",
|
"replaygain": "重播增益模式",
|
||||||
@ -619,13 +619,20 @@
|
|||||||
"none": "無",
|
"none": "無",
|
||||||
"album": "專輯增益",
|
"album": "專輯增益",
|
||||||
"track": "曲目增益"
|
"track": "曲目增益"
|
||||||
}
|
},
|
||||||
|
"lastfmNotConfigured": "Last.fm API 金鑰未設定"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"albumList": "專輯",
|
"albumList": "專輯",
|
||||||
|
"about": "關於",
|
||||||
"playlists": "播放清單",
|
"playlists": "播放清單",
|
||||||
"sharedPlaylists": "分享的播放清單",
|
"sharedPlaylists": "分享的播放清單",
|
||||||
"about": "關於"
|
"librarySelector": {
|
||||||
|
"allLibraries": "所有媒體庫 (%{count})",
|
||||||
|
"multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
|
||||||
|
"selectLibraries": "選取媒體庫",
|
||||||
|
"none": "無"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"playListsText": "播放佇列",
|
"playListsText": "播放佇列",
|
||||||
@ -676,7 +683,8 @@
|
|||||||
"exportSuccess": "設定已以 TOML 格式匯出至剪貼簿",
|
"exportSuccess": "設定已以 TOML 格式匯出至剪貼簿",
|
||||||
"exportFailed": "設定複製失敗",
|
"exportFailed": "設定複製失敗",
|
||||||
"devFlagsHeader": "開發旗標(可能會更改/刪除)",
|
"devFlagsHeader": "開發旗標(可能會更改/刪除)",
|
||||||
"devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除"
|
"devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除",
|
||||||
|
"downloadToml": "下載設定檔 (TOML)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
@ -684,17 +692,12 @@
|
|||||||
"totalScanned": "已掃描的資料夾總數",
|
"totalScanned": "已掃描的資料夾總數",
|
||||||
"quickScan": "快速掃描",
|
"quickScan": "快速掃描",
|
||||||
"fullScan": "完全掃描",
|
"fullScan": "完全掃描",
|
||||||
"selectiveScan": "選擇性掃描",
|
|
||||||
"serverUptime": "伺服器運作時間",
|
"serverUptime": "伺服器運作時間",
|
||||||
"serverDown": "伺服器已離線",
|
"serverDown": "伺服器已離線",
|
||||||
"scanType": "掃描類型",
|
"scanType": "掃描類型",
|
||||||
"status": "掃描錯誤",
|
"status": "掃描錯誤",
|
||||||
"elapsedTime": "經過時間"
|
"elapsedTime": "經過時間",
|
||||||
},
|
"selectiveScan": "選擇性掃描"
|
||||||
"nowPlaying": {
|
|
||||||
"title": "正在播放",
|
|
||||||
"empty": "無播放內容",
|
|
||||||
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
|
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Navidrome 快捷鍵",
|
"title": "Navidrome 快捷鍵",
|
||||||
@ -704,10 +707,15 @@
|
|||||||
"toggle_play": "播放/暫停",
|
"toggle_play": "播放/暫停",
|
||||||
"prev_song": "上一首歌",
|
"prev_song": "上一首歌",
|
||||||
"next_song": "下一首歌",
|
"next_song": "下一首歌",
|
||||||
"current_song": "前往目前歌曲",
|
|
||||||
"vol_up": "提高音量",
|
"vol_up": "提高音量",
|
||||||
"vol_down": "降低音量",
|
"vol_down": "降低音量",
|
||||||
"toggle_love": "新增此歌曲至收藏"
|
"toggle_love": "新增此歌曲至收藏",
|
||||||
|
"current_song": "前往目前歌曲"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"nowPlaying": {
|
||||||
|
"title": "正在播放",
|
||||||
|
"empty": "無播放內容",
|
||||||
|
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
@ -31,7 +32,7 @@ var _ = Describe("Controller", func() {
|
|||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||||
ds.MockedProperty = &tests.MockedPropertyRepo{}
|
ds.MockedProperty = &tests.MockedPropertyRepo{}
|
||||||
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("includes last scan error", func() {
|
It("includes last scan error", func() {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
@ -40,7 +41,7 @@ func BenchmarkScan(b *testing.B) {
|
|||||||
ds := persistence.New(db.Db())
|
ds := persistence.New(db.Db())
|
||||||
conf.Server.DevExternalScanner = false
|
conf.Server.DevExternalScanner = false
|
||||||
s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
|
||||||
|
|
||||||
fs := storagetest.FakeFS{}
|
fs := storagetest.FakeFS{}
|
||||||
storagetest.Register("fake", &fs)
|
storagetest.Register("fake", &fs)
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
@ -77,7 +78,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
|
|||||||
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
||||||
|
|
||||||
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
|
||||||
|
|
||||||
// Create two test libraries (let DB auto-assign IDs)
|
// Create two test libraries (let DB auto-assign IDs)
|
||||||
lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"}
|
lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
@ -63,7 +64,7 @@ var _ = Describe("ScanFolders", Ordered, func() {
|
|||||||
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
||||||
|
|
||||||
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
|
||||||
|
|
||||||
lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
|
lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
|
||||||
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
|
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
@ -84,7 +85,7 @@ var _ = Describe("Scanner", Ordered, func() {
|
|||||||
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
|
||||||
|
|
||||||
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
|
||||||
|
|
||||||
lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
|
lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
|
||||||
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
|
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
|
||||||
|
|||||||
@ -442,7 +442,7 @@ var _ = BeforeSuite(func() {
|
|||||||
|
|
||||||
buildTestFS()
|
buildTestFS()
|
||||||
s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||||
playlists.NewPlaylists(initDS), metrics.NewNoopInstance())
|
playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance())
|
||||||
_, err = s.ScanAll(ctx, true)
|
_, err = s.ScanAll(ctx, true)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
@ -479,7 +479,7 @@ func setupTestDB() {
|
|||||||
streamerSpy = &spyStreamer{}
|
streamerSpy = &spyStreamer{}
|
||||||
decider := stream.NewTranscodeDecider(ds, noopFFmpeg{})
|
decider := stream.NewTranscodeDecider(ds, noopFFmpeg{})
|
||||||
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
|
||||||
router = subsonic.New(
|
router = subsonic.New(
|
||||||
ds,
|
ds,
|
||||||
noopArtwork{},
|
noopArtwork{},
|
||||||
@ -489,7 +489,7 @@ func setupTestDB() {
|
|||||||
noopProvider{},
|
noopProvider{},
|
||||||
s,
|
s,
|
||||||
events.NoopBroker(),
|
events.NoopBroker(),
|
||||||
playlists.NewPlaylists(ds),
|
playlists.NewPlaylists(ds, core.NewImageUploadService()),
|
||||||
noopPlayTracker{},
|
noopPlayTracker{},
|
||||||
core.NewShare(ds),
|
core.NewShare(ds),
|
||||||
playback.PlaybackServer(nil),
|
playback.PlaybackServer(nil),
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
@ -53,7 +54,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() {
|
|||||||
|
|
||||||
// Run incremental scan to import lib2 content (lib1 files unchanged → skipped)
|
// Run incremental scan to import lib2 content (lib1 files unchanged → skipped)
|
||||||
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
|
||||||
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
|
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
|
||||||
_, err = s.ScanAll(ctx, false)
|
_, err = s.ScanAll(ctx, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
|||||||
72
server/nativeapi/artists.go
Normal file
72
server/nativeapi/artists.go
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
package nativeapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/deluan/rest"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (api *Router) addArtistRoute(r chi.Router) {
|
||||||
|
constructor := func(ctx context.Context) rest.Repository {
|
||||||
|
return api.ds.Resource(ctx, model.Artist{})
|
||||||
|
}
|
||||||
|
r.Route("/artist", func(r chi.Router) {
|
||||||
|
r.Get("/", rest.GetAll(constructor))
|
||||||
|
r.Route("/{id}", func(r chi.Router) {
|
||||||
|
r.Use(server.URLParamsMiddleware)
|
||||||
|
r.Get("/", rest.Get(constructor))
|
||||||
|
r.Post("/image", api.uploadArtistImage())
|
||||||
|
r.Delete("/image", api.deleteArtistImage())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *Router) uploadArtistImage() http.HandlerFunc {
|
||||||
|
return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error {
|
||||||
|
artistID := chi.URLParamFromCtx(ctx, "id")
|
||||||
|
ar, err := api.ds.Artist(ctx).Get(artistID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
oldPath := ar.UploadedImagePath()
|
||||||
|
filename, err := api.imgUpload.SetImage(ctx, consts.EntityArtist, ar.ID, ar.Name, oldPath, reader, ext)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ar.UploadedImage = filename
|
||||||
|
now := time.Now()
|
||||||
|
ar.UpdatedAt = &now
|
||||||
|
return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *Router) deleteArtistImage() http.HandlerFunc {
|
||||||
|
return handleImageDelete(func(ctx context.Context) error {
|
||||||
|
artistID := chi.URLParamFromCtx(ctx, "id")
|
||||||
|
ar, err := api.ds.Artist(ctx).Get(artistID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
return model.ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := api.imgUpload.RemoveImage(ctx, ar.UploadedImagePath()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ar.UploadedImage = ""
|
||||||
|
now := time.Now()
|
||||||
|
ar.UpdatedAt = &now
|
||||||
|
return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at")
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -28,7 +28,7 @@ var _ = Describe("Config API", func() {
|
|||||||
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
auth.Init(ds)
|
auth.Init(ds)
|
||||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil)
|
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil)
|
||||||
router = server.JWTVerifier(nativeRouter)
|
router = server.JWTVerifier(nativeRouter)
|
||||||
|
|
||||||
// Create test users
|
// Create test users
|
||||||
|
|||||||
120
server/nativeapi/image_upload.go
Normal file
120
server/nativeapi/image_upload.go
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package nativeapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxImageSize = 10 << 20 // 10MB
|
||||||
|
|
||||||
|
func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
user, _ := request.UserFrom(r.Context())
|
||||||
|
if !conf.Server.EnableCoverArtUpload && !user.IsAdmin {
|
||||||
|
http.Error(w, "cover art upload is disabled", http.StatusForbidden)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
if !checkImageUploadPermission(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxImageSize)
|
||||||
|
if err := r.ParseMultipartForm(maxImageSize / 2); err != nil {
|
||||||
|
log.Error(ctx, "Error parsing multipart form", err)
|
||||||
|
http.Error(w, "file too large or invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if r.MultipartForm != nil {
|
||||||
|
if err := r.MultipartForm.RemoveAll(); err != nil {
|
||||||
|
log.Warn(ctx, "Error removing multipart temp files", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
file, header, err := r.FormFile("image")
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "Error reading uploaded file", err)
|
||||||
|
http.Error(w, "missing image file", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
_, format, err := image.DecodeConfig(file)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "Uploaded file is not a valid image", err)
|
||||||
|
http.Error(w, "invalid image file", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if seeker, ok := file.(io.Seeker); ok {
|
||||||
|
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
|
||||||
|
log.Error(ctx, "Error seeking file", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ext := "." + format
|
||||||
|
if ext == "." {
|
||||||
|
ext = strings.ToLower(filepath.Ext(header.Filename))
|
||||||
|
}
|
||||||
|
if ext == "" || ext == "." {
|
||||||
|
log.Error(ctx, "Could not determine image type", "filename", header.Filename)
|
||||||
|
http.Error(w, "could not determine image type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := saveFn(ctx, file, ext); err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotAuthorized) {
|
||||||
|
http.Error(w, "not authorized", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Error(ctx, "Error saving image", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"status":"ok"}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleImageDelete(deleteFn func(ctx context.Context) error) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
if !checkImageUploadPermission(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := deleteFn(ctx); err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotAuthorized) {
|
||||||
|
http.Error(w, "not authorized", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Error(ctx, "Error removing image", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"status":"ok"}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,7 +29,7 @@ var _ = Describe("Library API", func() {
|
|||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
auth.Init(ds)
|
auth.Init(ds)
|
||||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil)
|
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil)
|
||||||
router = server.JWTVerifier(nativeRouter)
|
router = server.JWTVerifier(nativeRouter)
|
||||||
|
|
||||||
// Create test users
|
// Create test users
|
||||||
|
|||||||
@ -44,10 +44,11 @@ type Router struct {
|
|||||||
users core.User
|
users core.User
|
||||||
maintenance core.Maintenance
|
maintenance core.Maintenance
|
||||||
pluginManager PluginManager
|
pluginManager PluginManager
|
||||||
|
imgUpload core.ImageUploadService
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager) *Router {
|
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload core.ImageUploadService) *Router {
|
||||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager}
|
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload}
|
||||||
r.Handler = r.routes()
|
r.Handler = r.routes()
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@ -66,7 +67,7 @@ func (api *Router) routes() http.Handler {
|
|||||||
api.RX(r, "/user", api.users.NewRepository, true)
|
api.RX(r, "/user", api.users.NewRepository, true)
|
||||||
api.R(r, "/song", model.MediaFile{}, false)
|
api.R(r, "/song", model.MediaFile{}, false)
|
||||||
api.R(r, "/album", model.Album{}, false)
|
api.R(r, "/album", model.Album{}, false)
|
||||||
api.R(r, "/artist", model.Artist{}, false)
|
api.addArtistRoute(r)
|
||||||
api.R(r, "/genre", model.Genre{}, false)
|
api.R(r, "/genre", model.Genre{}, false)
|
||||||
api.R(r, "/player", model.Player{}, true)
|
api.R(r, "/player", model.Player{}, true)
|
||||||
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||||
|
|||||||
@ -94,7 +94,7 @@ var _ = Describe("Song Endpoints", func() {
|
|||||||
mfRepo.SetData(testSongs)
|
mfRepo.SetData(testSongs)
|
||||||
|
|
||||||
// Create the native API router and wrap it with the JWTVerifier middleware
|
// Create the native API router and wrap it with the JWTVerifier middleware
|
||||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil)
|
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil)
|
||||||
router = server.JWTVerifier(nativeRouter)
|
router = server.JWTVerifier(nativeRouter)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,25 +5,17 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
|
||||||
_ "image/gif"
|
|
||||||
_ "image/jpeg"
|
|
||||||
_ "image/png"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
"github.com/navidrome/navidrome/utils/req"
|
"github.com/navidrome/navidrome/utils/req"
|
||||||
_ "golang.org/x/image/webp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
|
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
|
||||||
@ -234,110 +226,16 @@ func getSongPlaylists(svc playlists.Playlists) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxImageSize = 10 << 20 // 10MB
|
|
||||||
|
|
||||||
func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc {
|
func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error {
|
||||||
ctx := r.Context()
|
playlistId := chi.URLParamFromCtx(ctx, "id")
|
||||||
user, _ := request.UserFrom(ctx)
|
return pls.SetImage(ctx, playlistId, reader, ext)
|
||||||
if !conf.Server.EnableCoverArtUpload && !user.IsAdmin {
|
})
|
||||||
http.Error(w, "cover art upload is disabled", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p := req.Params(r)
|
|
||||||
playlistId, _ := p.String(":id")
|
|
||||||
|
|
||||||
if err := r.ParseMultipartForm(maxImageSize); err != nil { //nolint:gosec // size is limited by maxImageSize parameter
|
|
||||||
log.Error(ctx, "Error parsing multipart form", err)
|
|
||||||
http.Error(w, "file too large or invalid form", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
file, header, err := r.FormFile("image")
|
|
||||||
if err != nil {
|
|
||||||
log.Error(ctx, "Error reading uploaded file", err)
|
|
||||||
http.Error(w, "missing image file", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
// Validate the uploaded file is a valid image
|
|
||||||
_, format, err := image.DecodeConfig(file)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(ctx, "Uploaded file is not a valid image", err)
|
|
||||||
http.Error(w, "invalid image file", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset reader after DecodeConfig consumed some bytes
|
|
||||||
if seeker, ok := file.(io.Seeker); ok {
|
|
||||||
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
|
|
||||||
log.Error(ctx, "Error seeking file", err)
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine file extension from decoded format or original filename
|
|
||||||
ext := "." + format
|
|
||||||
if ext == "." {
|
|
||||||
ext = strings.ToLower(filepath.Ext(header.Filename))
|
|
||||||
}
|
|
||||||
if ext == "" || ext == "." {
|
|
||||||
log.Error(ctx, "Could not determine image type", "playlistId", playlistId, "filename", header.Filename)
|
|
||||||
http.Error(w, "could not determine image type", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = pls.SetImage(ctx, playlistId, file, ext)
|
|
||||||
if errors.Is(err, model.ErrNotAuthorized) {
|
|
||||||
log.Error(ctx, "Not authorized to upload playlist image", "playlistId", playlistId, err)
|
|
||||||
http.Error(w, "not authorized", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
|
||||||
log.Error(ctx, "Playlist not found for image upload", "playlistId", playlistId, err)
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Error(ctx, "Error saving playlist image", "playlistId", playlistId, err)
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(w, `{"status":"ok"}`) //nolint:gosec
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deletePlaylistImage(pls playlists.Playlists) http.HandlerFunc {
|
func deletePlaylistImage(pls playlists.Playlists) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return handleImageDelete(func(ctx context.Context) error {
|
||||||
ctx := r.Context()
|
playlistId := chi.URLParamFromCtx(ctx, "id")
|
||||||
user, _ := request.UserFrom(ctx)
|
return pls.RemoveImage(ctx, playlistId)
|
||||||
if !conf.Server.EnableCoverArtUpload && !user.IsAdmin {
|
})
|
||||||
http.Error(w, "cover art upload is disabled", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p := req.Params(r)
|
|
||||||
playlistId, _ := p.String(":id")
|
|
||||||
|
|
||||||
err := pls.RemoveImage(ctx, playlistId)
|
|
||||||
if errors.Is(err, model.ErrNotAuthorized) {
|
|
||||||
log.Error(ctx, "Not authorized to remove playlist image", "playlistId", playlistId, err)
|
|
||||||
http.Error(w, "not authorized", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
|
||||||
log.Error(ctx, "Playlist not found for image removal", "playlistId", playlistId, err)
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Error(ctx, "Error removing playlist image", "playlistId", playlistId, err)
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(w, `{"status":"ok"}`) //nolint:gosec
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,7 +98,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
|
|||||||
err := userRepo.Put(&testUser)
|
err := userRepo.Put(&testUser)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil)
|
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil)
|
||||||
router = server.JWTVerifier(nativeRouter)
|
router = server.JWTVerifier(nativeRouter)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -33,7 +33,7 @@ var _ = Describe("Plugin API", func() {
|
|||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
mockManager = &tests.MockPluginManager{}
|
mockManager = &tests.MockPluginManager{}
|
||||||
auth.Init(ds)
|
auth.Init(ds)
|
||||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager)
|
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil)
|
||||||
router = server.JWTVerifier(nativeRouter)
|
router = server.JWTVerifier(nativeRouter)
|
||||||
|
|
||||||
// Create test users
|
// Create test users
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { makeStyles } from '@material-ui/core/styles'
|
|||||||
import { useDrag } from 'react-dnd'
|
import { useDrag } from 'react-dnd'
|
||||||
import {
|
import {
|
||||||
ArtistLinkField,
|
ArtistLinkField,
|
||||||
|
CoverArtAvatar,
|
||||||
DurationField,
|
DurationField,
|
||||||
RangeField,
|
RangeField,
|
||||||
SimpleList,
|
SimpleList,
|
||||||
@ -161,12 +162,18 @@ const AlbumTableView = ({
|
|||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
leftIcon={(r) => (
|
||||||
|
<span style={{ marginRight: '8px' }}>
|
||||||
|
<CoverArtAvatar record={r} variant="square" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
linkType={'show'}
|
linkType={'show'}
|
||||||
rightIcon={(r) => <AlbumContextMenu record={r} />}
|
rightIcon={(r) => <AlbumContextMenu record={r} />}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AlbumDatagrid rowClick={'show'} classes={{ row: classes.row }} {...rest}>
|
<AlbumDatagrid rowClick={'show'} classes={{ row: classes.row }} {...rest}>
|
||||||
|
<CoverArtAvatar source="id" variant="square" />
|
||||||
<TextField source="name" />
|
<TextField source="name" />
|
||||||
{columns}
|
{columns}
|
||||||
<AlbumContextMenu
|
<AlbumContextMenu
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { useDrag } from 'react-dnd'
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
ArtistContextMenu,
|
ArtistContextMenu,
|
||||||
|
CoverArtAvatar,
|
||||||
List,
|
List,
|
||||||
QuickFilter,
|
QuickFilter,
|
||||||
useGetHandleArtistClick,
|
useGetHandleArtistClick,
|
||||||
@ -43,6 +44,10 @@ const useStyles = makeStyles({
|
|||||||
verticalAlign: 'text-top',
|
verticalAlign: 'text-top',
|
||||||
},
|
},
|
||||||
row: {
|
row: {
|
||||||
|
'& td': {
|
||||||
|
paddingTop: '4px !important',
|
||||||
|
paddingBottom: '4px !important',
|
||||||
|
},
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
'& $contextMenu': {
|
'& $contextMenu': {
|
||||||
visibility: 'visible',
|
visibility: 'visible',
|
||||||
@ -170,6 +175,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ArtistDatagrid rowClick={handleArtistLink} classes={{ row: classes.row }}>
|
<ArtistDatagrid rowClick={handleArtistLink} classes={{ row: classes.row }}>
|
||||||
|
<CoverArtAvatar source="id" />
|
||||||
<TextField source="name" />
|
<TextField source="name" />
|
||||||
<FunctionField
|
<FunctionField
|
||||||
source="albumCount"
|
source="albumCount"
|
||||||
|
|||||||
@ -2,12 +2,13 @@ import React from 'react'
|
|||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
import List from '@material-ui/core/List'
|
import List from '@material-ui/core/List'
|
||||||
import ListItem from '@material-ui/core/ListItem'
|
import ListItem from '@material-ui/core/ListItem'
|
||||||
|
import ListItemAvatar from '@material-ui/core/ListItemAvatar'
|
||||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||||
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
|
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
|
||||||
import ListItemText from '@material-ui/core/ListItemText'
|
import ListItemText from '@material-ui/core/ListItemText'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import { sanitizeListRestProps } from 'react-admin'
|
import { sanitizeListRestProps } from 'react-admin'
|
||||||
import { ArtistContextMenu, RatingField } from '../common'
|
import { ArtistContextMenu, CoverArtAvatar, RatingField } from '../common'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
|
|
||||||
const useStyles = makeStyles(
|
const useStyles = makeStyles(
|
||||||
@ -47,7 +48,11 @@ const ArtistSimpleList = ({
|
|||||||
data[id] && (
|
data[id] && (
|
||||||
<span key={id} onClick={() => linkType(id)}>
|
<span key={id} onClick={() => linkType(id)}>
|
||||||
<ListItem className={classes.listItem} button={true}>
|
<ListItem className={classes.listItem} button={true}>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<CoverArtAvatar record={data[id]} />
|
||||||
|
</ListItemAvatar>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
|
style={{ marginLeft: '8px' }}
|
||||||
primary={
|
primary={
|
||||||
<>
|
<>
|
||||||
<div className={classes.title}>{data[id].name}</div>
|
<div className={classes.title}>{data[id].name}</div>
|
||||||
|
|||||||
@ -6,12 +6,13 @@ import CardContent from '@material-ui/core/CardContent'
|
|||||||
import CardMedia from '@material-ui/core/CardMedia'
|
import CardMedia from '@material-ui/core/CardMedia'
|
||||||
import ArtistExternalLinks from './ArtistExternalLink'
|
import ArtistExternalLinks from './ArtistExternalLink'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
import { LoveButton, RatingField } from '../common'
|
import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
|
||||||
import Lightbox from 'react-image-lightbox'
|
import Lightbox from 'react-image-lightbox'
|
||||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||||
import AlbumInfo from '../album/AlbumInfo'
|
import AlbumInfo from '../album/AlbumInfo'
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
import { SafeHTML } from '../common/SafeHTML'
|
import { SafeHTML } from '../common/SafeHTML'
|
||||||
|
import useArtistImageState from './useArtistImageState'
|
||||||
|
|
||||||
const useStyles = makeStyles(
|
const useStyles = makeStyles(
|
||||||
(theme) => ({
|
(theme) => ({
|
||||||
@ -57,6 +58,7 @@ const useStyles = makeStyles(
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
|
position: 'relative',
|
||||||
},
|
},
|
||||||
artistDetail: {
|
artistDetail: {
|
||||||
flex: '1',
|
flex: '1',
|
||||||
@ -85,36 +87,15 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
|||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const title = record.name
|
const title = record.name
|
||||||
const [isLightboxOpen, setLightboxOpen] = React.useState(false)
|
const {
|
||||||
const [imageLoading, setImageLoading] = React.useState(false)
|
imageLoading,
|
||||||
const [imageError, setImageError] = React.useState(false)
|
imageError,
|
||||||
|
isLightboxOpen,
|
||||||
// Reset image state when artist changes
|
handleImageLoad,
|
||||||
React.useEffect(() => {
|
handleImageError,
|
||||||
setImageLoading(true)
|
handleOpenLightbox,
|
||||||
setImageError(false)
|
handleCloseLightbox,
|
||||||
}, [record.id])
|
} = useArtistImageState(record.id)
|
||||||
|
|
||||||
const handleImageLoad = React.useCallback(() => {
|
|
||||||
setImageLoading(false)
|
|
||||||
setImageError(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleImageError = React.useCallback(() => {
|
|
||||||
setImageLoading(false)
|
|
||||||
setImageError(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleOpenLightbox = React.useCallback(() => {
|
|
||||||
if (!imageError) {
|
|
||||||
setLightboxOpen(true)
|
|
||||||
}
|
|
||||||
}, [imageError])
|
|
||||||
|
|
||||||
const handleCloseLightbox = React.useCallback(
|
|
||||||
() => setLightboxOpen(false),
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.root}>
|
<div className={classes.root}>
|
||||||
@ -135,6 +116,11 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ImageUploadOverlay
|
||||||
|
entityType="artist"
|
||||||
|
entityId={record.id}
|
||||||
|
hasUploadedImage={!!record.uploadedImage}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
<div className={classes.details}>
|
<div className={classes.details}>
|
||||||
<CardContent className={classes.content}>
|
<CardContent className={classes.content}>
|
||||||
|
|||||||
@ -4,10 +4,11 @@ import { makeStyles } from '@material-ui/core/styles'
|
|||||||
import Card from '@material-ui/core/Card'
|
import Card from '@material-ui/core/Card'
|
||||||
import CardMedia from '@material-ui/core/CardMedia'
|
import CardMedia from '@material-ui/core/CardMedia'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
import { LoveButton, RatingField } from '../common'
|
import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
|
||||||
import Lightbox from 'react-image-lightbox'
|
import Lightbox from 'react-image-lightbox'
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
import { SafeHTML } from '../common/SafeHTML'
|
import { SafeHTML } from '../common/SafeHTML'
|
||||||
|
import useArtistImageState from './useArtistImageState'
|
||||||
|
|
||||||
const useStyles = makeStyles(
|
const useStyles = makeStyles(
|
||||||
(theme) => ({
|
(theme) => ({
|
||||||
@ -67,6 +68,7 @@ const useStyles = makeStyles(
|
|||||||
minWidth: '7rem',
|
minWidth: '7rem',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
borderRadius: '5em',
|
borderRadius: '5em',
|
||||||
|
position: 'relative',
|
||||||
},
|
},
|
||||||
loveButton: {
|
loveButton: {
|
||||||
top: theme.spacing(-0.2),
|
top: theme.spacing(-0.2),
|
||||||
@ -87,36 +89,15 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
|||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const classes = useStyles({ img, expanded })
|
const classes = useStyles({ img, expanded })
|
||||||
const title = record.name
|
const title = record.name
|
||||||
const [isLightboxOpen, setLightboxOpen] = React.useState(false)
|
const {
|
||||||
const [imageLoading, setImageLoading] = React.useState(false)
|
imageLoading,
|
||||||
const [imageError, setImageError] = React.useState(false)
|
imageError,
|
||||||
|
isLightboxOpen,
|
||||||
// Reset image state when artist changes
|
handleImageLoad,
|
||||||
React.useEffect(() => {
|
handleImageError,
|
||||||
setImageLoading(true)
|
handleOpenLightbox,
|
||||||
setImageError(false)
|
handleCloseLightbox,
|
||||||
}, [record.id])
|
} = useArtistImageState(record.id)
|
||||||
|
|
||||||
const handleImageLoad = React.useCallback(() => {
|
|
||||||
setImageLoading(false)
|
|
||||||
setImageError(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleImageError = React.useCallback(() => {
|
|
||||||
setImageLoading(false)
|
|
||||||
setImageError(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleOpenLightbox = React.useCallback(() => {
|
|
||||||
if (!imageError) {
|
|
||||||
setLightboxOpen(true)
|
|
||||||
}
|
|
||||||
}, [imageError])
|
|
||||||
|
|
||||||
const handleCloseLightbox = React.useCallback(
|
|
||||||
() => setLightboxOpen(false),
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -138,6 +119,11 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ImageUploadOverlay
|
||||||
|
entityType="artist"
|
||||||
|
entityId={record.id}
|
||||||
|
hasUploadedImage={!!record.uploadedImage}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
<div className={classes.details}>
|
<div className={classes.details}>
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
46
ui/src/artist/useArtistImageState.js
Normal file
46
ui/src/artist/useArtistImageState.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages image loading/error state and lightbox open/close for artist detail views.
|
||||||
|
* Resets when record.id changes.
|
||||||
|
*/
|
||||||
|
const useArtistImageState = (recordId) => {
|
||||||
|
const [imageLoading, setImageLoading] = useState(false)
|
||||||
|
const [imageError, setImageError] = useState(false)
|
||||||
|
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImageLoading(true)
|
||||||
|
setImageError(false)
|
||||||
|
}, [recordId])
|
||||||
|
|
||||||
|
const handleImageLoad = useCallback(() => {
|
||||||
|
setImageLoading(false)
|
||||||
|
setImageError(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleImageError = useCallback(() => {
|
||||||
|
setImageLoading(false)
|
||||||
|
setImageError(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleOpenLightbox = useCallback(() => {
|
||||||
|
if (!imageError) {
|
||||||
|
setLightboxOpen(true)
|
||||||
|
}
|
||||||
|
}, [imageError])
|
||||||
|
|
||||||
|
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageLoading,
|
||||||
|
imageError,
|
||||||
|
isLightboxOpen,
|
||||||
|
handleImageLoad,
|
||||||
|
handleImageError,
|
||||||
|
handleOpenLightbox,
|
||||||
|
handleCloseLightbox,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useArtistImageState
|
||||||
36
ui/src/common/CoverArtAvatar.jsx
Normal file
36
ui/src/common/CoverArtAvatar.jsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { useRecordContext } from 'react-admin'
|
||||||
|
import { Avatar } from '@material-ui/core'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import subsonic from '../subsonic'
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
avatar: {
|
||||||
|
width: '55px',
|
||||||
|
height: '55px',
|
||||||
|
},
|
||||||
|
square: {
|
||||||
|
borderRadius: '4px',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CoverArtAvatar = ({
|
||||||
|
record: recordProp,
|
||||||
|
variant = 'circular',
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const recordContext = useRecordContext()
|
||||||
|
const record = recordProp || recordContext
|
||||||
|
if (!record) return null
|
||||||
|
const square = variant !== 'circular'
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
src={subsonic.getCoverArtUrl(record, 80, square)}
|
||||||
|
variant={variant}
|
||||||
|
className={clsx(classes.avatar, square && classes.square)}
|
||||||
|
alt={record.name}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CoverArtAvatar.defaultProps = { label: '', sortable: false }
|
||||||
139
ui/src/common/ImageUploadOverlay.jsx
Normal file
139
ui/src/common/ImageUploadOverlay.jsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { IconButton, Tooltip } from '@material-ui/core'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import PhotoCameraIcon from '@material-ui/icons/PhotoCamera'
|
||||||
|
import DeleteIcon from '@material-ui/icons/Delete'
|
||||||
|
import { useTranslate, useNotify, useRefresh } from 'react-admin'
|
||||||
|
import { useCallback, useRef } from 'react'
|
||||||
|
import config from '../config'
|
||||||
|
import { REST_URL } from '../consts'
|
||||||
|
import { httpClient } from '../dataProvider'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(() => ({
|
||||||
|
coverOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
display: 'flex',
|
||||||
|
gap: '2px',
|
||||||
|
padding: '2px',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
borderRadius: '4px 0 0 0',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 0.2s ease-in-out',
|
||||||
|
'*:hover > &': {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overlayButton: {
|
||||||
|
color: '#fff',
|
||||||
|
padding: '4px',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overlayIcon: {
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const ImageUploadOverlay = ({
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
hasUploadedImage,
|
||||||
|
onImageChange,
|
||||||
|
}) => {
|
||||||
|
const translate = useTranslate()
|
||||||
|
const notify = useNotify()
|
||||||
|
const refresh = useRefresh()
|
||||||
|
const classes = useStyles()
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
|
const canEdit =
|
||||||
|
config.enableCoverArtUpload || localStorage.getItem('role') === 'admin'
|
||||||
|
|
||||||
|
const handleUploadClick = useCallback((e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.click()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
async (e) => {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (!file || !entityId) return
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('image', file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await httpClient(`${REST_URL}/${entityType}/${entityId}/image`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: new Headers({}),
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
notify(`message.coverUploaded`, 'success')
|
||||||
|
if (onImageChange) onImageChange()
|
||||||
|
refresh()
|
||||||
|
} catch (err) {
|
||||||
|
notify(`message.coverUploadError`, 'warning')
|
||||||
|
}
|
||||||
|
|
||||||
|
e.target.value = ''
|
||||||
|
},
|
||||||
|
[entityType, entityId, notify, refresh, onImageChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRemoveCover = useCallback(
|
||||||
|
async (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!entityId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await httpClient(`${REST_URL}/${entityType}/${entityId}/image`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
notify(`message.coverRemoved`, 'success')
|
||||||
|
if (onImageChange) onImageChange()
|
||||||
|
refresh()
|
||||||
|
} catch (err) {
|
||||||
|
notify(`message.coverRemoveError`, 'warning')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[entityType, entityId, notify, refresh, onImageChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!canEdit) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.coverOverlay}>
|
||||||
|
<Tooltip title={translate(`message.uploadCover`)}>
|
||||||
|
<IconButton
|
||||||
|
className={classes.overlayButton}
|
||||||
|
onClick={handleUploadClick}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<PhotoCameraIcon className={classes.overlayIcon} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
{hasUploadedImage && (
|
||||||
|
<Tooltip title={translate(`message.removeCover`)}>
|
||||||
|
<IconButton
|
||||||
|
className={classes.overlayButton}
|
||||||
|
onClick={handleRemoveCover}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<DeleteIcon className={classes.overlayIcon} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -43,3 +43,5 @@ export * from './PathField.jsx'
|
|||||||
export * from './ParticipantsInfo'
|
export * from './ParticipantsInfo'
|
||||||
export * from './OverflowTooltip'
|
export * from './OverflowTooltip'
|
||||||
export * from './useSearchRefocus'
|
export * from './useSearchRefocus'
|
||||||
|
export * from './ImageUploadOverlay'
|
||||||
|
export * from './CoverArtAvatar'
|
||||||
|
|||||||
@ -219,15 +219,9 @@
|
|||||||
"makePrivate": "Make Private",
|
"makePrivate": "Make Private",
|
||||||
"searchOrCreate": "Search playlists or type to create new...",
|
"searchOrCreate": "Search playlists or type to create new...",
|
||||||
"pressEnterToCreate": "Press Enter to create new playlist",
|
"pressEnterToCreate": "Press Enter to create new playlist",
|
||||||
"removeFromSelection": "Remove from selection",
|
"removeFromSelection": "Remove from selection"
|
||||||
"uploadCover": "Upload Cover",
|
|
||||||
"removeCover": "Remove Cover"
|
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"coverUploaded": "Cover art updated",
|
|
||||||
"coverRemoved": "Cover art removed",
|
|
||||||
"coverUploadError": "Error uploading cover art",
|
|
||||||
"coverRemoveError": "Error removing cover art",
|
|
||||||
"duplicate_song": "Add duplicated songs",
|
"duplicate_song": "Add duplicated songs",
|
||||||
"song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?",
|
"song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?",
|
||||||
"noPlaylistsFound": "No playlists found",
|
"noPlaylistsFound": "No playlists found",
|
||||||
@ -563,6 +557,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
|
"uploadCover": "Upload Cover",
|
||||||
|
"removeCover": "Remove Cover",
|
||||||
|
"coverUploaded": "Cover art updated",
|
||||||
|
"coverRemoved": "Cover art removed",
|
||||||
|
"coverUploadError": "Error uploading cover art",
|
||||||
|
"coverRemoveError": "Error removing cover art",
|
||||||
"note": "NOTE",
|
"note": "NOTE",
|
||||||
"transcodingDisabled": "Changing the transcoding configuration through the web interface is disabled for security reasons. If you would like to change (edit or add) transcoding options, restart the server with the %{config} configuration option.",
|
"transcodingDisabled": "Changing the transcoding configuration through the web interface is disabled for security reasons. If you would like to change (edit or add) transcoding options, restart the server with the %{config} configuration option.",
|
||||||
"transcodingEnabled": "Navidrome is currently running with %{config}, making it possible to run system commands from the transcoding settings using the web interface. We recommend to disable it for security reasons and only enable it when configuring Transcoding options.",
|
"transcodingEnabled": "Navidrome is currently running with %{config}, making it possible to run system commands from the transcoding settings using the web interface. We recommend to disable it for security reasons and only enable it when configuring Transcoding options.",
|
||||||
|
|||||||
@ -2,29 +2,23 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardMedia,
|
CardMedia,
|
||||||
IconButton,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
Typography,
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
} from '@material-ui/core'
|
} from '@material-ui/core'
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import PhotoCameraIcon from '@material-ui/icons/PhotoCamera'
|
import { useTranslate } from 'react-admin'
|
||||||
import DeleteIcon from '@material-ui/icons/Delete'
|
import { useCallback, useState, useEffect } from 'react'
|
||||||
import { useTranslate, useNotify, useRefresh } from 'react-admin'
|
|
||||||
import { useCallback, useRef, useState, useEffect } from 'react'
|
|
||||||
import Lightbox from 'react-image-lightbox'
|
import Lightbox from 'react-image-lightbox'
|
||||||
import 'react-image-lightbox/style.css'
|
import 'react-image-lightbox/style.css'
|
||||||
import {
|
import {
|
||||||
CollapsibleComment,
|
CollapsibleComment,
|
||||||
DurationField,
|
DurationField,
|
||||||
|
ImageUploadOverlay,
|
||||||
SizeField,
|
SizeField,
|
||||||
isWritable,
|
isWritable,
|
||||||
OverflowTooltip,
|
OverflowTooltip,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
import config from '../config'
|
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
import { REST_URL } from '../consts'
|
|
||||||
import { httpClient } from '../dataProvider'
|
|
||||||
|
|
||||||
const useStyles = makeStyles(
|
const useStyles = makeStyles(
|
||||||
(theme) => ({
|
(theme) => ({
|
||||||
@ -82,31 +76,6 @@ const useStyles = makeStyles(
|
|||||||
coverLoading: {
|
coverLoading: {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
coverOverlay: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
right: 0,
|
|
||||||
display: 'flex',
|
|
||||||
gap: '2px',
|
|
||||||
padding: '2px',
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
||||||
borderRadius: '4px 0 0 0',
|
|
||||||
opacity: 0,
|
|
||||||
transition: 'opacity 0.2s ease-in-out',
|
|
||||||
'$coverParent:hover &': {
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overlayButton: {
|
|
||||||
color: '#fff',
|
|
||||||
padding: '4px',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
overlayIcon: {
|
|
||||||
fontSize: '1.2rem',
|
|
||||||
},
|
|
||||||
title: {
|
title: {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
@ -125,20 +94,14 @@ const useStyles = makeStyles(
|
|||||||
const PlaylistDetails = (props) => {
|
const PlaylistDetails = (props) => {
|
||||||
const { record = {} } = props
|
const { record = {} } = props
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
const notify = useNotify()
|
|
||||||
const refresh = useRefresh()
|
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
|
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
|
||||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||||
const [imageLoading, setImageLoading] = useState(false)
|
const [imageLoading, setImageLoading] = useState(false)
|
||||||
const [imageError, setImageError] = useState(false)
|
const [imageError, setImageError] = useState(false)
|
||||||
const fileInputRef = useRef(null)
|
|
||||||
|
|
||||||
const imageUrl = subsonic.getCoverArtUrl(record, 300, true)
|
const imageUrl = subsonic.getCoverArtUrl(record, 300, true)
|
||||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||||
const canEdit =
|
|
||||||
isWritable(record.ownerId) &&
|
|
||||||
(config.enableCoverArtUpload || localStorage.getItem('role') === 'admin')
|
|
||||||
|
|
||||||
// Reset image state when playlist changes
|
// Reset image state when playlist changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -164,60 +127,6 @@ const PlaylistDetails = (props) => {
|
|||||||
|
|
||||||
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
|
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
|
||||||
|
|
||||||
const handleUploadClick = useCallback(
|
|
||||||
(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.click()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[fileInputRef],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleFileChange = useCallback(
|
|
||||||
async (e) => {
|
|
||||||
const file = e.target.files[0]
|
|
||||||
if (!file || !record.id) return
|
|
||||||
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('image', file)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await httpClient(`${REST_URL}/playlist/${record.id}/image`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: new Headers({}),
|
|
||||||
body: formData,
|
|
||||||
})
|
|
||||||
notify('resources.playlist.message.coverUploaded', 'success')
|
|
||||||
refresh()
|
|
||||||
} catch (err) {
|
|
||||||
notify('resources.playlist.message.coverUploadError', 'warning')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset file input so the same file can be re-selected
|
|
||||||
e.target.value = ''
|
|
||||||
},
|
|
||||||
[record.id, notify, refresh],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleRemoveCover = useCallback(
|
|
||||||
async (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (!record.id) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await httpClient(`${REST_URL}/playlist/${record.id}/image`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
notify('resources.playlist.message.coverRemoved', 'success')
|
|
||||||
refresh()
|
|
||||||
} catch (err) {
|
|
||||||
notify('resources.playlist.message.coverRemoveError', 'warning')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[record.id, notify, refresh],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={classes.root}>
|
<Card className={classes.root}>
|
||||||
<div className={classes.cardContents}>
|
<div className={classes.cardContents}>
|
||||||
@ -237,40 +146,12 @@ const PlaylistDetails = (props) => {
|
|||||||
cursor: imageError ? 'default' : 'pointer',
|
cursor: imageError ? 'default' : 'pointer',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{canEdit && (
|
{isWritable(record.ownerId) && (
|
||||||
<div className={classes.coverOverlay}>
|
<ImageUploadOverlay
|
||||||
<Tooltip
|
entityType="playlist"
|
||||||
title={translate('resources.playlist.actions.uploadCover')}
|
entityId={record.id}
|
||||||
>
|
hasUploadedImage={!!record.uploadedImage}
|
||||||
<IconButton
|
/>
|
||||||
className={classes.overlayButton}
|
|
||||||
onClick={handleUploadClick}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<PhotoCameraIcon className={classes.overlayIcon} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
{record.uploadedImage && (
|
|
||||||
<Tooltip
|
|
||||||
title={translate('resources.playlist.actions.removeCover')}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
className={classes.overlayButton}
|
|
||||||
onClick={handleRemoveCover}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<DeleteIcon className={classes.overlayIcon} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.details}>
|
<div className={classes.details}>
|
||||||
|
|||||||
@ -16,10 +16,10 @@ import {
|
|||||||
usePermissions,
|
usePermissions,
|
||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
import Switch from '@material-ui/core/Switch'
|
import Switch from '@material-ui/core/Switch'
|
||||||
import { Avatar } from '@material-ui/core'
|
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import { useMediaQuery } from '@material-ui/core'
|
import { useMediaQuery } from '@material-ui/core'
|
||||||
import {
|
import {
|
||||||
|
CoverArtAvatar,
|
||||||
DurationField,
|
DurationField,
|
||||||
List,
|
List,
|
||||||
Writable,
|
Writable,
|
||||||
@ -29,17 +29,11 @@ import {
|
|||||||
} from '../common'
|
} from '../common'
|
||||||
import PlaylistListActions from './PlaylistListActions'
|
import PlaylistListActions from './PlaylistListActions'
|
||||||
import ChangePublicStatusButton from './ChangePublicStatusButton'
|
import ChangePublicStatusButton from './ChangePublicStatusButton'
|
||||||
import subsonic from '../subsonic'
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
const useStyles = makeStyles((theme) => ({
|
||||||
button: {
|
button: {
|
||||||
color: theme.palette.type === 'dark' ? 'white' : undefined,
|
color: theme.palette.type === 'dark' ? 'white' : undefined,
|
||||||
},
|
},
|
||||||
coverArt: {
|
|
||||||
width: '40px',
|
|
||||||
height: '40px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const PlaylistFilter = (props) => {
|
const PlaylistFilter = (props) => {
|
||||||
@ -126,25 +120,6 @@ const ToggleAutoImport = ({ resource, source }) => {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const CoverArtField = () => {
|
|
||||||
const classes = useStyles()
|
|
||||||
const record = useRecordContext()
|
|
||||||
if (!record) return null
|
|
||||||
return (
|
|
||||||
<Avatar
|
|
||||||
src={subsonic.getCoverArtUrl(record, 80, true)}
|
|
||||||
variant="square"
|
|
||||||
className={classes.coverArt}
|
|
||||||
alt={record.name}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
CoverArtField.defaultProps = {
|
|
||||||
label: '',
|
|
||||||
sortable: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
const PlaylistListBulkActions = (props) => {
|
const PlaylistListBulkActions = (props) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
return (
|
return (
|
||||||
@ -204,7 +179,7 @@ const PlaylistList = (props) => {
|
|||||||
bulkActionButtons={!isXsmall && <PlaylistListBulkActions />}
|
bulkActionButtons={!isXsmall && <PlaylistListBulkActions />}
|
||||||
>
|
>
|
||||||
<Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}>
|
<Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}>
|
||||||
<CoverArtField source="id" />
|
<CoverArtAvatar source="id" variant="square" />
|
||||||
<TextField source="name" />
|
<TextField source="name" />
|
||||||
{columns}
|
{columns}
|
||||||
<Writable>
|
<Writable>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user