fix(library): parallelize cover processing and retry missing artwork
This commit is contained in:
1 parent
eed7038109
commit
0a4cd4bff1
5 files changed
+259
-28
No files matched your search
@@ -13,6 +13,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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 и палитру из трёх цветов. Альбом без обложки отмечается
|
||||
// проверенным — клиент рисует плейсхолдер.
|
||||
type Covers struct {
|
||||
store *Store
|
||||
svc *Service
|
||||
FFmpeg string
|
||||
Dir string // /cache/covers
|
||||
log *slog.Logger
|
||||
store coverStore
|
||||
svc *Service
|
||||
FFmpeg string
|
||||
Dir string // /cache/covers
|
||||
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 создаёт обработчик обложек.
|
||||
@@ -52,22 +61,42 @@ func (c *Covers) CoverFile(coverPath string, size int) string {
|
||||
|
||||
// Run обрабатывает все альбомы без проверенной обложки.
|
||||
func (c *Covers) Run(ctx context.Context) {
|
||||
c.runMu.Lock()
|
||||
defer c.runMu.Unlock()
|
||||
if err := os.MkdirAll(c.Dir, 0o755); err != nil {
|
||||
c.log.Error("covers: cache dir", "err", err)
|
||||
return
|
||||
}
|
||||
pending, err := c.store.AlbumsWithoutCover(ctx, 500)
|
||||
for err == nil && len(pending) > 0 {
|
||||
for _, a := range pending {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
c.process(ctx, a)
|
||||
// Keyset pagination visits each album once, including failures left for the next run.
|
||||
after := uuid.Nil
|
||||
for ctx.Err() == nil {
|
||||
pending, err := c.store.AlbumsWithoutCover(ctx, 500, after)
|
||||
if err != nil {
|
||||
c.log.Error("covers: list albums", "err", err)
|
||||
return
|
||||
}
|
||||
pending, err = c.store.AlbumsWithoutCover(ctx, 500)
|
||||
}
|
||||
if err != nil {
|
||||
c.log.Error("covers: list albums", "err", err)
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
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
|
||||
)
|
||||
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)
|
||||
key := hex.EncodeToString(sum[:8])
|
||||
if colors, err = c.render(ctx, img, key); err != nil {
|
||||
c.log.Warn("covers: render", "album_id", a.AlbumID, "err", err)
|
||||
return
|
||||
} else {
|
||||
coverPath = &key
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := c.store.SetAlbumCover(ctx, a.AlbumID, coverPath, colors); err != nil {
|
||||
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
|
||||
}
|
||||
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.
|
||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
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()
|
||||
extractErr := cctx.Err()
|
||||
cancel()
|
||||
if err == nil && len(out) > 0 {
|
||||
return out, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
embeddedErr := err
|
||||
|
||||
entries, err := os.ReadDir(filepath.Dir(audio))
|
||||
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))
|
||||
}
|
||||
}
|
||||
if extractErr != nil {
|
||||
return nil, extractErr
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if embeddedErr != nil && !errors.As(embeddedErr, &exitErr) {
|
||||
return nil, embeddedErr
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -148,6 +204,10 @@ func readLimited(p string) ([]byte, error) {
|
||||
// render пишет WebP всех размеров (если их ещё нет — одинаковые обложки альбомов переиспользуются)
|
||||
// и считает палитру.
|
||||
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-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in new issue
Block a user