Place cover and artist images in library folders on upload import
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
82f3eed9a0
commit
3edbf489a3
5 files changed
+161
-2
No files matched your search
@@ -98,6 +98,10 @@
|
|||||||
передаётся отдельно от обложки. Ближайшая такая картинка наследуется вложенными папками альбомов;
|
передаётся отдельно от обложки. Ближайшая такая картинка наследуется вложенными папками альбомов;
|
||||||
соседние исполнители не смешиваются. Например `Исполнитель/artist.jpg` и две папки альбомов
|
соседние исполнители не смешиваются. Например `Исполнитель/artist.jpg` и две папки альбомов
|
||||||
дают две отдельные пачки с одной фотографией исполнителя и своими обложками.
|
дают две отдельные пачки с одной фотографией исполнителя и своими обложками.
|
||||||
|
- При импорте трека картинки кладутся и в хранилище: `cover.<ext>` — в папку альбома, `artist.<ext>` — в папку
|
||||||
|
исполнителя; папки определяются по `write_template` (сегмент с `{album}` и первый сегмент с `{albumartist}`/`{artist}`).
|
||||||
|
Если в папке уже есть такая картинка (`cover`/`folder`/`front`/`album`/`albumart` или `artist`), она не перезаписывается.
|
||||||
|
Если шаблон не позволяет определить папку (плоская раскладка), картинка не записывается.
|
||||||
После одобрения и импорта трека фотография сохраняется в кеш WebP и ставится исполнителю альбома,
|
После одобрения и импорта трека фотография сохраняется в кеш WebP и ставится исполнителю альбома,
|
||||||
только если у него нет ни локальной аватарки, ни изображения Genius. Гостям она не назначается.
|
только если у него нет ни локальной аватарки, ни изображения Genius. Гостям она не назначается.
|
||||||
Существующая аватарка сохраняется, в том числе при одновременном импорте нескольких альбомов.
|
Существующая аватарка сохраняется, в том числе при одновременном импорте нескольких альбомов.
|
||||||
|
|||||||
@@ -143,8 +143,8 @@ func (a *agent) writeFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// write копирует staging сразу под итоговым именем (O_EXCL — существующий файл не перезаписывается, при
|
// write копирует staging сразу под итоговым именем (O_EXCL — существующий файл не перезаписывается, при
|
||||||
// конфликте — « (2)»), fsync, сверяет SHA-256 копии. На музыкальный диск не попадает ничего, кроме самого трека:
|
// конфликте — « (2)»), fsync, сверяет SHA-256 копии. На музыкальный диск не попадает ничего, кроме трека и его
|
||||||
// ни временных файлов, ни служебных пометок. При любой ошибке недописанный файл и созданные для него пустые
|
// картинок cover/artist: ни временных файлов, ни служебных пометок. При любой ошибке недописанный файл и созданные для него пустые
|
||||||
// каталоги удаляются — агент убирает только то, что создал сам.
|
// каталоги удаляются — агент убирает только то, что создал сам.
|
||||||
func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) {
|
func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) {
|
||||||
dir := filepath.Dir(filepath.Join(root, filepath.FromSlash(rel)))
|
dir := filepath.Dir(filepath.Join(root, filepath.FromSlash(rel)))
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package uploads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"pipu/api/internal/library"
|
||||||
|
"pipu/api/internal/storageagent"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
albumFieldRe = regexp.MustCompile(`\{album[:}]`)
|
||||||
|
artistFieldRe = regexp.MustCompile(`\{(albumartist|artist)[:}]`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Имена картинок, которые сканер библиотеки уже считает обложкой альбома / фотографией исполнителя.
|
||||||
|
var (
|
||||||
|
coverSidecarNames = []string{"cover", "folder", "front", "album", "albumart"}
|
||||||
|
artistSidecarNames = []string{"artist"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// sidecarDirs — каталоги альбома и исполнителя внутри rel по структуре шаблона записи.
|
||||||
|
// "" — шаблон не позволяет определить такой каталог (плоская раскладка, необязательные сегменты).
|
||||||
|
func sidecarDirs(template, rel string) (albumDir, artistDir string) {
|
||||||
|
tpl, got := strings.Split(template, "/"), strings.Split(rel, "/")
|
||||||
|
if len(tpl) != len(got) {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
album, artist := -1, -1
|
||||||
|
for i, seg := range tpl[:len(tpl)-1] {
|
||||||
|
if album < 0 && albumFieldRe.MatchString(seg) {
|
||||||
|
album = i
|
||||||
|
}
|
||||||
|
if artist < 0 && artistFieldRe.MatchString(seg) {
|
||||||
|
artist = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if album >= 0 {
|
||||||
|
albumDir = strings.Join(got[:album+1], "/")
|
||||||
|
}
|
||||||
|
if artist >= 0 && artist != album {
|
||||||
|
artistDir = strings.Join(got[:artist+1], "/")
|
||||||
|
}
|
||||||
|
return albumDir, artistDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageExt(data []byte) string {
|
||||||
|
switch http.DetectContentType(data) {
|
||||||
|
case "image/jpeg":
|
||||||
|
return ".jpg"
|
||||||
|
case "image/png":
|
||||||
|
return ".png"
|
||||||
|
case "image/webp":
|
||||||
|
return ".webp"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasSidecar(dir string, names []string) bool {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||||
|
base := strings.ToLower(strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())))
|
||||||
|
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
if base == n {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeSidecars кладёт cover.* в папку альбома и artist.* в папку исполнителя рядом с импортированным треком.
|
||||||
|
// Существующие картинки не трогаются; сбой не отменяет импорт трека — картинку добавит следующий файл пачки.
|
||||||
|
func (s *Service) placeSidecars(ctx context.Context, f File, pl placement, rel string) {
|
||||||
|
if pl.src.WriteTemplate == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
albumDir, artistDir := sidecarDirs(*pl.src.WriteTemplate, rel)
|
||||||
|
if albumDir != "" && f.CoverPath != nil && *f.CoverPath != "" {
|
||||||
|
s.placeSidecar(ctx, pl, *f.CoverPath, albumDir, "cover", coverSidecarNames)
|
||||||
|
}
|
||||||
|
if artistDir != "" && f.ArtistImagePath != nil && *f.ArtistImagePath != "" {
|
||||||
|
s.placeSidecar(ctx, pl, *f.ArtistImagePath, artistDir, "artist", artistSidecarNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) placeSidecar(ctx context.Context, pl placement, staging, dir, name string, existing []string) {
|
||||||
|
log := s.log.With("source_id", pl.src.ID, "dir", dir, "image", name)
|
||||||
|
data, err := os.ReadFile(s.stagingAbs(staging))
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("upload sidecar: read staging", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ext := imageExt(data)
|
||||||
|
if ext == "" || hasSidecar(filepath.Join(pl.root, filepath.FromSlash(dir)), existing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
req := storageagent.WriteRequest{SourceID: pl.src.ID.String(), RelPath: path.Join(dir, name+ext), StagingPath: staging, SHA256: hex.EncodeToString(sum[:])}
|
||||||
|
if pl.src.Type == library.SourceLocal {
|
||||||
|
lp := ""
|
||||||
|
if pl.src.LocalPath != nil {
|
||||||
|
lp = *pl.src.LocalPath
|
||||||
|
}
|
||||||
|
req.LocalPath = &lp
|
||||||
|
}
|
||||||
|
if _, err := s.agent.WriteFile(ctx, req); err != nil {
|
||||||
|
log.Warn("upload sidecar: write", "err", fmt.Errorf("storage-agent: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package uploads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSidecarDirs(t *testing.T) {
|
||||||
|
tpl := "{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}"
|
||||||
|
album, artist := sidecarDirs(tpl, "Artist/2020 - Album/01 - Song.flac")
|
||||||
|
require.Equal(t, "Artist/2020 - Album", album)
|
||||||
|
require.Equal(t, "Artist", artist)
|
||||||
|
|
||||||
|
album, artist = sidecarDirs("{albumartist}/{album}/{disc:1}/{track:02} - {title}.{ext}", "A/B/1/01 - S.flac")
|
||||||
|
require.Equal(t, "A/B", album)
|
||||||
|
require.Equal(t, "A", artist)
|
||||||
|
|
||||||
|
album, artist = sidecarDirs("{albumartist} - {album}/{title}.{ext}", "A - B/S.flac")
|
||||||
|
require.Equal(t, "A - B", album)
|
||||||
|
require.Empty(t, artist)
|
||||||
|
|
||||||
|
album, artist = sidecarDirs("{albumartist} - {title}.{ext}", "A - S.flac")
|
||||||
|
require.Empty(t, album)
|
||||||
|
require.Empty(t, artist)
|
||||||
|
}
|
||||||
@@ -343,6 +343,7 @@ func (s *Service) importFile(ctx context.Context, f File) error {
|
|||||||
return fmt.Errorf("artist image: %w", err)
|
return fmt.Errorf("artist image: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
s.placeSidecars(ctx, f, pl, rel)
|
||||||
if err := s.store.CompleteImport(ctx, f.ID, pl.src.ID, rel, fileID); err != nil {
|
if err := s.store.CompleteImport(ctx, f.ID, pl.src.ID, rel, fileID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user