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 }