diff --git a/docs/uploads.md b/docs/uploads.md index cdd4155..a3a1860 100644 --- a/docs/uploads.md +++ b/docs/uploads.md @@ -98,6 +98,10 @@ передаётся отдельно от обложки. Ближайшая такая картинка наследуется вложенными папками альбомов; соседние исполнители не смешиваются. Например `Исполнитель/artist.jpg` и две папки альбомов дают две отдельные пачки с одной фотографией исполнителя и своими обложками. +- При импорте трека картинки кладутся и в хранилище: `cover.` — в папку альбома, `artist.` — в папку + исполнителя; папки определяются по `write_template` (сегмент с `{album}` и первый сегмент с `{albumartist}`/`{artist}`). + Если в папке уже есть такая картинка (`cover`/`folder`/`front`/`album`/`albumart` или `artist`), она не перезаписывается. + Если шаблон не позволяет определить папку (плоская раскладка), картинка не записывается. После одобрения и импорта трека фотография сохраняется в кеш WebP и ставится исполнителю альбома, только если у него нет ни локальной аватарки, ни изображения Genius. Гостям она не назначается. Существующая аватарка сохраняется, в том числе при одновременном импорте нескольких альбомов. diff --git a/services/api/internal/storageagent/files.go b/services/api/internal/storageagent/files.go index cd145f1..eed2f35 100644 --- a/services/api/internal/storageagent/files.go +++ b/services/api/internal/storageagent/files.go @@ -143,8 +143,8 @@ func (a *agent) writeFile(w http.ResponseWriter, r *http.Request) { } // 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) { dir := filepath.Dir(filepath.Join(root, filepath.FromSlash(rel))) diff --git a/services/api/internal/uploads/sidecar.go b/services/api/internal/uploads/sidecar.go new file mode 100644 index 0000000..46d4605 --- /dev/null +++ b/services/api/internal/uploads/sidecar.go @@ -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)) + } +} diff --git a/services/api/internal/uploads/sidecar_test.go b/services/api/internal/uploads/sidecar_test.go new file mode 100644 index 0000000..f8fa1f6 --- /dev/null +++ b/services/api/internal/uploads/sidecar_test.go @@ -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) +} diff --git a/services/api/internal/uploads/worker.go b/services/api/internal/uploads/worker.go index e3387a4..80b89c7 100644 --- a/services/api/internal/uploads/worker.go +++ b/services/api/internal/uploads/worker.go @@ -343,6 +343,7 @@ func (s *Service) importFile(ctx context.Context, f File) error { 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 { return err }