fix(library): parallelize cover processing and retry missing artwork
ci / api / api (push) Successful in 50s
ci / db / db (push) Successful in 1m1s

This commit is contained in:
mrleo1nid committed 2026-10-23 02:02:36 +03:00
1 parent eed7038109
commit 0a4cd4bff1
5 files changed
+259 -28

No files matched your search

+10
View File
@@ -172,6 +172,16 @@ lower-case, `ё → е`, удаление пунктуации, схлопыва
сгенерированный из цвета. Конвертируются в WebP 64/300/1200 px в `/srv/pipu/cache/covers/`, вычисляется сгенерированный из цвета. Конвертируются в WebP 64/300/1200 px в `/srv/pipu/cache/covers/`, вычисляется
палитра из 3 цветов (k-means по пикселям; `cover_colors`) — для ambient-фона, цветов кнопки волны и плейсхолдеров. палитра из 3 цветов (k-means по пикселям; `cover_colors`) — для ambient-фона, цветов кнопки волны и плейсхолдеров.
Поиск файлов в папке не зависит от регистра: `cover.jpg`, `cover.jpeg`, `Cover.png`,
а также `folder`, `front`, `album`, `albumart` с расширениями JPG/JPEG/PNG/WebP.
Картинка должна лежать рядом с аудиофайлом; глубина папки относительно корня источника не важна.
Папки, исключённые из сканирования, не используются для поиска обложек.
После успешного скана (в том числе обычного, без перечитывания тегов) альбомы без обложек
проверяются повторно. Обработчик запускается также при старте API, готовит до четырёх альбомов
одновременно и сохраняет готовые обложки по мере обработки. Одинаковые изображения используют
общие файлы кэша; ошибки чтения и конвертации остаются для повтора при следующем запуске обработчика.
Клиент получает ссылку на готовый WebP через API каталога; nginx раздаёт файл из кэша.
## Поиск ## Поиск
Meilisearch, индексы: Meilisearch, индексы:
+77 -17
View File
@@ -13,6 +13,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -33,11 +34,19 @@ var coverExts = map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".web
// Готовит WebP 64/300/1200 в Dir и палитру из трёх цветов. Альбом без обложки отмечается // Готовит WebP 64/300/1200 в Dir и палитру из трёх цветов. Альбом без обложки отмечается
// проверенным — клиент рисует плейсхолдер. // проверенным — клиент рисует плейсхолдер.
type Covers struct { type Covers struct {
store *Store store coverStore
svc *Service svc *Service
FFmpeg string FFmpeg string
Dir string // /cache/covers Dir string // /cache/covers
log *slog.Logger log *slog.Logger
runMu sync.Mutex
renderLocks [32]sync.Mutex
}
type coverStore interface {
AlbumsWithoutCover(context.Context, int, uuid.UUID) ([]CoverCandidate, error)
GetSource(context.Context, uuid.UUID) (Source, error)
SetAlbumCover(context.Context, uuid.UUID, *string, []string) error
} }
// NewCovers создаёт обработчик обложек. // NewCovers создаёт обработчик обложек.
@@ -52,22 +61,42 @@ func (c *Covers) CoverFile(coverPath string, size int) string {
// Run обрабатывает все альбомы без проверенной обложки. // Run обрабатывает все альбомы без проверенной обложки.
func (c *Covers) Run(ctx context.Context) { func (c *Covers) Run(ctx context.Context) {
c.runMu.Lock()
defer c.runMu.Unlock()
if err := os.MkdirAll(c.Dir, 0o755); err != nil { if err := os.MkdirAll(c.Dir, 0o755); err != nil {
c.log.Error("covers: cache dir", "err", err) c.log.Error("covers: cache dir", "err", err)
return return
} }
pending, err := c.store.AlbumsWithoutCover(ctx, 500) // Keyset pagination visits each album once, including failures left for the next run.
for err == nil && len(pending) > 0 { after := uuid.Nil
for _, a := range pending { for ctx.Err() == nil {
if ctx.Err() != nil { pending, err := c.store.AlbumsWithoutCover(ctx, 500, after)
return if err != nil {
} c.log.Error("covers: list albums", "err", err)
c.process(ctx, a) return
} }
pending, err = c.store.AlbumsWithoutCover(ctx, 500) if len(pending) == 0 {
} return
if err != nil { }
c.log.Error("covers: list albums", "err", err) jobs := make(chan CoverCandidate)
var workers sync.WaitGroup
for range 4 {
workers.Add(1)
go func() {
defer workers.Done()
for a := range jobs {
if ctx.Err() == nil {
c.process(ctx, a)
}
}
}()
}
for _, a := range pending {
jobs <- a
}
close(jobs)
workers.Wait()
after = pending[len(pending)-1].AlbumID
} }
} }
@@ -77,15 +106,23 @@ func (c *Covers) process(ctx context.Context, a CoverCandidate) {
colors []string colors []string
) )
img, err := c.find(ctx, a) img, err := c.find(ctx, a)
if err == nil && len(img) > 0 { if err != nil {
c.log.Warn("covers: find", "album_id", a.AlbumID, "err", err)
return
}
if len(img) > 0 {
sum := sha256.Sum256(img) sum := sha256.Sum256(img)
key := hex.EncodeToString(sum[:8]) key := hex.EncodeToString(sum[:8])
if colors, err = c.render(ctx, img, key); err != nil { if colors, err = c.render(ctx, img, key); err != nil {
c.log.Warn("covers: render", "album_id", a.AlbumID, "err", err) c.log.Warn("covers: render", "album_id", a.AlbumID, "err", err)
return
} else { } else {
coverPath = &key coverPath = &key
} }
} }
if ctx.Err() != nil {
return
}
if err := c.store.SetAlbumCover(ctx, a.AlbumID, coverPath, colors); err != nil { if err := c.store.SetAlbumCover(ctx, a.AlbumID, coverPath, colors); err != nil {
c.log.Error("covers: save", "album_id", a.AlbumID, "err", err) c.log.Error("covers: save", "album_id", a.AlbumID, "err", err)
} }
@@ -102,15 +139,27 @@ func (c *Covers) find(ctx context.Context, a CoverCandidate) ([]byte, error) {
return nil, err return nil, err
} }
audio := filepath.Join(root, filepath.FromSlash(a.RelPath)) audio := filepath.Join(root, filepath.FromSlash(a.RelPath))
return c.findAudio(ctx, audio)
}
func (c *Covers) findAudio(ctx context.Context, audio string) ([]byte, error) {
if _, err := os.Stat(audio); err != nil {
return nil, err
}
// Встроенная обложка: первый видеопоток (attached picture) одним кадром в JPEG. // Встроенная обложка: первый видеопоток (attached picture) одним кадром в JPEG.
cctx, cancel := context.WithTimeout(ctx, 30*time.Second) cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
out, err := exec.CommandContext(cctx, c.FFmpeg, "-v", "error", "-i", audio, "-an", "-map", "0:v:0", out, err := exec.CommandContext(cctx, c.FFmpeg, "-v", "error", "-i", audio, "-an", "-map", "0:v:0",
"-frames:v", "1", "-f", "image2pipe", "-c:v", "mjpeg", "-").Output() "-frames:v", "1", "-f", "image2pipe", "-c:v", "mjpeg", "-").Output()
extractErr := cctx.Err()
cancel() cancel()
if err == nil && len(out) > 0 { if err == nil && len(out) > 0 {
return out, nil return out, nil
} }
if ctx.Err() != nil {
return nil, ctx.Err()
}
embeddedErr := err
entries, err := os.ReadDir(filepath.Dir(audio)) entries, err := os.ReadDir(filepath.Dir(audio))
if err != nil { if err != nil {
@@ -126,6 +175,13 @@ func (c *Covers) find(ctx context.Context, a CoverCandidate) ([]byte, error) {
return readLimited(filepath.Join(filepath.Dir(audio), name)) return readLimited(filepath.Join(filepath.Dir(audio), name))
} }
} }
if extractErr != nil {
return nil, extractErr
}
var exitErr *exec.ExitError
if embeddedErr != nil && !errors.As(embeddedErr, &exitErr) {
return nil, embeddedErr
}
return nil, nil return nil, nil
} }
@@ -148,6 +204,10 @@ func readLimited(p string) ([]byte, error) {
// render пишет WebP всех размеров (если их ещё нет — одинаковые обложки альбомов переиспользуются) // render пишет WebP всех размеров (если их ещё нет — одинаковые обложки альбомов переиспользуются)
// и считает палитру. // и считает палитру.
func (c *Covers) render(ctx context.Context, img []byte, key string) ([]string, error) { func (c *Covers) render(ctx context.Context, img []byte, key string) ([]string, error) {
// Albums sharing an image must not write the same .part file concurrently.
lock := &c.renderLocks[sha256.Sum256(img)[0]%byte(len(c.renderLocks))]
lock.Lock()
defer lock.Unlock()
tmp, err := os.CreateTemp(c.Dir, ".src-*") tmp, err := os.CreateTemp(c.Dir, ".src-*")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -0,0 +1,160 @@
package library
import (
"context"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestCoverSharePaths(t *testing.T) {
for _, rel := range []string{
"Modeselektor - Happy Birthday! (2007-09-10)/cover.jpg",
"Coco Bryce/Albums/(2025-11-28) Noches Sephardies/cover.jpg",
"Thoma - Salt Spring (2019)/cover.jpeg",
"Franz Ferdinand/2017 - Always Ascending (Single)/Cover.png",
"Noisia/[FLAC] CCTV - Factory 5 [199502673] [2009]/cover.jpg",
"Kerala Dust/Kerala Dust - Francesca's Frames EP (2018-04-20)/cover.jpg",
} {
t.Run(rel, func(t *testing.T) {
root := t.TempDir()
cover := filepath.Join(root, filepath.FromSlash(rel))
require.NoError(t, os.MkdirAll(filepath.Dir(cover), 0o755))
require.NoError(t, os.WriteFile(cover, []byte("shared image"), 0o644))
audio := filepath.Join(filepath.Dir(cover), "01 - Track.flac")
require.NoError(t, os.WriteFile(audio, nil, 0o644))
c := Covers{FFmpeg: filepath.Join(root, "missing-ffmpeg")}
img, err := c.findAudio(context.Background(), audio)
require.NoError(t, err)
require.Equal(t, []byte("shared image"), img)
})
}
}
func TestCoverFolderPriorityAndLimit(t *testing.T) {
dir := t.TempDir()
audio := filepath.Join(dir, "track.flac")
require.NoError(t, os.WriteFile(audio, nil, 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "folder.jpg"), []byte("folder"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "COVER.JPEG"), []byte("cover"), 0o644))
c := Covers{FFmpeg: filepath.Join(dir, "missing-ffmpeg")}
img, err := c.findAudio(context.Background(), audio)
require.NoError(t, err)
require.Equal(t, "cover", string(img))
f, err := os.OpenFile(filepath.Join(dir, "COVER.JPEG"), os.O_WRONLY, 0o644)
require.NoError(t, err)
t.Cleanup(func() { _ = f.Close() })
require.NoError(t, f.Truncate(maxCoverBytes+1))
require.NoError(t, f.Close())
_, err = c.findAudio(context.Background(), audio)
require.ErrorContains(t, err, "too large")
}
func TestCoverReadFailures(t *testing.T) {
dir := t.TempDir()
c := Covers{FFmpeg: filepath.Join(dir, "missing-ffmpeg")}
audio := filepath.Join(dir, "missing.flac")
_, err := c.findAudio(context.Background(), audio)
require.Error(t, err)
require.NoError(t, os.WriteFile(audio, nil, 0o644))
_, err = c.findAudio(context.Background(), audio)
require.Error(t, err, "missing converter must not mark an album checked")
require.NoError(t, os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("cover"), 0o644))
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = c.findAudio(ctx, audio)
require.ErrorIs(t, err, context.Canceled)
}
type failingCoverStore struct {
mu sync.Mutex
candidates []CoverCandidate
visited []uuid.UUID
checked int
entered chan struct{}
release chan struct{}
}
func (s *failingCoverStore) AlbumsWithoutCover(_ context.Context, _ int, after uuid.UUID) ([]CoverCandidate, error) {
// Deliberately short pages exercise pagination independently of the batch size.
for i, a := range s.candidates {
if a.AlbumID.String() > after.String() {
return s.candidates[i:min(i+4, len(s.candidates))], nil
}
}
return nil, nil
}
func (s *failingCoverStore) GetSource(_ context.Context, id uuid.UUID) (Source, error) {
s.mu.Lock()
s.visited = append(s.visited, id)
s.mu.Unlock()
s.entered <- struct{}{}
<-s.release
return Source{}, errors.New("share temporarily unavailable")
}
func (s *failingCoverStore) SetAlbumCover(context.Context, uuid.UUID, *string, []string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.checked++
return nil
}
func TestCoverWorkersRetryFailuresWithoutLooping(t *testing.T) {
s := &failingCoverStore{entered: make(chan struct{}, 20), release: make(chan struct{})}
for i := byte(1); i <= 7; i++ {
id := uuid.UUID{15: i}
s.candidates = append(s.candidates, CoverCandidate{AlbumID: id, SourceID: id})
}
c := Covers{store: s, Dir: t.TempDir(), log: slog.New(slog.NewTextHandler(io.Discard, nil))}
done := make(chan struct{})
go func() { c.Run(context.Background()); close(done) }()
for range 4 {
select {
case <-s.entered:
case <-time.After(5 * time.Second):
t.Fatal("expected four concurrent cover workers")
}
}
close(s.release)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("failed albums must not loop or prevent later pages")
}
require.Len(t, s.visited, 7)
require.Zero(t, s.checked, "IO failures remain eligible for retry")
c.Run(context.Background())
require.Len(t, s.visited, 14, "next run retries failures")
}
type sourceCoverStore struct{ *failingCoverStore }
func (s sourceCoverStore) GetSource(context.Context, uuid.UUID) (Source, error) {
return Source{Type: SourceLocal}, nil
}
func TestCoverRenderFailureRemainsPending(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "track.flac"), nil, 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("invalid image"), 0o644))
s := sourceCoverStore{&failingCoverStore{}}
c := Covers{
store: s,
svc: &Service{SourceService: &SourceService{cfg: Config{HostRoot: dir}}},
FFmpeg: filepath.Join(dir, "missing-ffmpeg"),
Dir: t.TempDir(),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
c.process(context.Background(), CoverCandidate{AlbumID: uuid.New(), RelPath: "track.flac"})
require.Zero(t, s.checked, "conversion errors must not save a missing cover")
}
+5 -6
View File
@@ -195,12 +195,6 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
return fmt.Errorf("library: patterns: %w", err) return fmt.Errorf("library: patterns: %w", err)
} }
if full {
// Полный скан — повод поискать обложки заново: в папках могли появиться cover.jpg.
if err := sc.store.ResetCoverChecks(ctx, src.ID); err != nil {
return err
}
}
existing, err := sc.store.Files(ctx, src.ID) existing, err := sc.store.Files(ctx, src.ID)
if err != nil { if err != nil {
return err return err
@@ -299,6 +293,11 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
if err := sc.store.CleanupOrphans(ctx); err != nil { if err := sc.store.CleanupOrphans(ctx); err != nil {
return err return err
} }
// Folder images can appear without changes to the audio files. A regular scan
// retries missing covers without forcing tags to be read again.
if err := sc.store.ResetCoverChecks(ctx, src.ID); err != nil {
return err
}
return sc.store.SetLastScan(ctx, src.ID, sc.now()) return sc.store.SetLastScan(ctx, src.ID, sc.now())
} }
+7 -5
View File
@@ -553,7 +553,7 @@ func (st *Store) FileErrors(ctx context.Context, after string, limit int) ([]Fil
// AlbumsWithoutCover — альбомы, обложку которых ещё не искали, и доступный файл первого трека. // AlbumsWithoutCover — альбомы, обложку которых ещё не искали, и доступный файл первого трека.
// Альбомы без доступных файлов пропускаются: искать не в чем. // Альбомы без доступных файлов пропускаются: искать не в чем.
func (st *Store) AlbumsWithoutCover(ctx context.Context, limit int) ([]CoverCandidate, error) { func (st *Store) AlbumsWithoutCover(ctx context.Context, limit int, after uuid.UUID) ([]CoverCandidate, error) {
rows, err := st.pool.Query(ctx, ` rows, err := st.pool.Query(ctx, `
SELECT al.id, f.source_id, f.rel_path SELECT al.id, f.source_id, f.rel_path
FROM albums al FROM albums al
@@ -561,11 +561,13 @@ func (st *Store) AlbumsWithoutCover(ctx context.Context, limit int) ([]CoverCand
SELECT mf.source_id, mf.rel_path FROM tracks t SELECT mf.source_id, mf.rel_path FROM tracks t
JOIN media_files mf ON mf.id = t.primary_file_id JOIN media_files mf ON mf.id = t.primary_file_id
JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted' JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted'
WHERE t.album_id = al.id AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs) WHERE t.album_id = al.id AND mf.missing_since IS NULL AND mf.scan_error IS NULL
AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs)
ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST
LIMIT 1) f LIMIT 1) f
WHERE al.cover_checked_at IS NULL WHERE al.cover_checked_at IS NULL AND al.id > $2
LIMIT $1`, limit) ORDER BY al.id
LIMIT $1`, limit, after)
if err != nil { if err != nil {
return nil, fmt.Errorf("library: albums without cover: %w", err) return nil, fmt.Errorf("library: albums without cover: %w", err)
} }
@@ -586,7 +588,7 @@ func (st *Store) SetAlbumCover(ctx context.Context, albumID uuid.UUID, coverPath
return nil return nil
} }
// ResetCoverChecks — найти обложки заново для альбомов источника (полный скан: могли появиться cover.jpg). // ResetCoverChecks — найти недостающие обложки заново после скана источника.
func (st *Store) ResetCoverChecks(ctx context.Context, sourceID uuid.UUID) error { func (st *Store) ResetCoverChecks(ctx context.Context, sourceID uuid.UUID) error {
_, err := st.pool.Exec(ctx, ` _, err := st.pool.Exec(ctx, `
UPDATE albums al SET cover_checked_at = NULL UPDATE albums al SET cover_checked_at = NULL