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 }