Files
pipu-music/services/api/internal/library/covers.go
T
mrleo1nidandClaude Opus 5.5 8b76aa3964
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Successful in 1m0s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 34s
ci / api / api (push) Successful in 26s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 34s
feat: album covers with palettes and signed links (M2)
- covers are taken from the embedded picture of the first track or a
  cover/folder/front image in the album folder, converted by ffmpeg to
  WebP 64/300/1200 and named by content hash, so albums sharing a cover
  share the files; a full scan looks again for albums without one
- a 3-colour palette (dominant, second, most saturated accent) from a
  deterministic k-means feeds placeholders, page tints and the wave
- /covers/{id} is public but HMAC-signed and immutable; nginx serves
  the file via X-Accel-Redirect, X_ACCEL=false serves it from api
- the web client picks the size by display size and pixel density
- install.sh and update.sh generate STREAM_SIGNING_KEY

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:06:15 +03:00

203 lines
6.2 KiB
Go

package library
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"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 *Store
svc *Service
FFmpeg string
Dir string // /cache/covers
log *slog.Logger
}
// 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) {
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)
}
pending, err = c.store.AlbumsWithoutCover(ctx, 500)
}
if err != nil {
c.log.Error("covers: list albums", "err", err)
}
}
func (c *Covers) process(ctx context.Context, a CoverCandidate) {
var (
coverPath *string
colors []string
)
img, err := c.find(ctx, a)
if err == nil && 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)
} else {
coverPath = &key
}
}
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))
// Встроенная обложка: первый видеопоток (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()
cancel()
if err == nil && len(out) > 0 {
return out, nil
}
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))
}
}
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) {
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
}