263 lines
7.7 KiB
Go
263 lines
7.7 KiB
Go
package library
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// CoverSizes — размеры WebP-обложек (docs/library-and-storage.md#обложки).
|
|
var CoverSizes = []int{64, 300, 1200}
|
|
|
|
// maxCoverBytes — больше этого обложку из папки не читаем.
|
|
const maxCoverBytes = 20 << 20
|
|
|
|
// Имена файлов обложек в папке альбома по приоритету (без расширения).
|
|
var coverNames = []string{"cover", "folder", "front", "album", "albumart"}
|
|
|
|
var coverExts = map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".webp": true}
|
|
|
|
// Covers ищет обложки новых альбомов: встроенная в файл → картинка в папке альбома.
|
|
// Готовит WebP 64/300/1200 в Dir и палитру из трёх цветов. Альбом без обложки отмечается
|
|
// проверенным — клиент рисует плейсхолдер.
|
|
type Covers struct {
|
|
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 создаёт обработчик обложек.
|
|
func NewCovers(store *Store, svc *Service, ffmpeg, dir string, log *slog.Logger) *Covers {
|
|
return &Covers{store: store, svc: svc, FFmpeg: ffmpeg, Dir: dir, log: log}
|
|
}
|
|
|
|
// CoverFile — путь к файлу обложки размера size.
|
|
func (c *Covers) CoverFile(coverPath string, size int) string {
|
|
return filepath.Join(c.Dir, fmt.Sprintf("%s-%d.webp", coverPath, size))
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// 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
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
func (c *Covers) process(ctx context.Context, a CoverCandidate) {
|
|
var (
|
|
coverPath *string
|
|
colors []string
|
|
)
|
|
img, err := c.find(ctx, a)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// find возвращает байты исходной картинки или nil, если обложки нет.
|
|
func (c *Covers) find(ctx context.Context, a CoverCandidate) ([]byte, error) {
|
|
src, err := c.store.GetSource(ctx, a.SourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
root, err := c.svc.Root(src)
|
|
if err != nil {
|
|
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 {
|
|
return nil, err
|
|
}
|
|
for _, want := range coverNames {
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
ext := strings.ToLower(filepath.Ext(name))
|
|
if e.IsDir() || !coverExts[ext] || strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name))) != want {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
|
|
func readLimited(p string) ([]byte, error) {
|
|
f, err := os.Open(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
b, err := io.ReadAll(io.LimitReader(f, maxCoverBytes+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(b) > maxCoverBytes {
|
|
return nil, errors.New("cover image is too large")
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
defer func() { _ = os.Remove(tmp.Name()) }()
|
|
if _, err := tmp.Write(img); err != nil {
|
|
_ = tmp.Close()
|
|
return nil, err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, size := range CoverSizes {
|
|
dst := c.CoverFile(key, size)
|
|
if _, err := os.Stat(dst); err == nil {
|
|
continue
|
|
}
|
|
part := dst + ".part"
|
|
filter := fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d", size, size, size, size)
|
|
cctx, cancel := context.WithTimeout(ctx, time.Minute)
|
|
out, err := exec.CommandContext(cctx, c.FFmpeg, "-v", "error", "-y", "-i", tmp.Name(), "-vf", filter,
|
|
"-c:v", "libwebp", "-quality", "82", "-f", "webp", part).CombinedOutput()
|
|
cancel()
|
|
if err != nil {
|
|
_ = os.Remove(part)
|
|
return nil, fmt.Errorf("ffmpeg webp %d: %s", size, strings.TrimSpace(string(out)))
|
|
}
|
|
if err := os.Rename(part, dst); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
var stderr bytes.Buffer
|
|
cmd := exec.CommandContext(cctx, c.FFmpeg, "-v", "error", "-i", tmp.Name(), "-vf", "scale=32:32",
|
|
"-f", "rawvideo", "-pix_fmt", "rgb24", "-")
|
|
cmd.Stderr = &stderr
|
|
raw, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ffmpeg palette: %s", strings.TrimSpace(stderr.String()))
|
|
}
|
|
return Palette(raw), nil
|
|
}
|
|
|
|
// CoverCandidate — альбом без проверенной обложки и файл, из которого её брать.
|
|
type CoverCandidate struct {
|
|
AlbumID uuid.UUID
|
|
SourceID uuid.UUID
|
|
RelPath string
|
|
}
|