diff --git a/docs/library-and-storage.md b/docs/library-and-storage.md index 876efe2..5bd62a7 100644 --- a/docs/library-and-storage.md +++ b/docs/library-and-storage.md @@ -172,6 +172,16 @@ lower-case, `ё → е`, удаление пунктуации, схлопыва сгенерированный из цвета. Конвертируются в WebP 64/300/1200 px в `/srv/pipu/cache/covers/`, вычисляется палитра из 3 цветов (k-means по пикселям; `cover_colors`) — для ambient-фона, цветов кнопки волны и плейсхолдеров. +Поиск файлов в папке не зависит от регистра: `cover.jpg`, `cover.jpeg`, `Cover.png`, +а также `folder`, `front`, `album`, `albumart` с расширениями JPG/JPEG/PNG/WebP. +Картинка должна лежать рядом с аудиофайлом; глубина папки относительно корня источника не важна. +Папки, исключённые из сканирования, не используются для поиска обложек. +После успешного скана (в том числе обычного, без перечитывания тегов) альбомы без обложек +проверяются повторно. Обработчик запускается также при старте API, готовит до четырёх альбомов +одновременно и сохраняет готовые обложки по мере обработки. Одинаковые изображения используют +общие файлы кэша; ошибки чтения и конвертации остаются для повтора при следующем запуске обработчика. +Клиент получает ссылку на готовый WebP через API каталога; nginx раздаёт файл из кэша. + ## Поиск Meilisearch, индексы: diff --git a/services/api/internal/library/covers.go b/services/api/internal/library/covers.go index 73c6b1d..4fc33a2 100644 --- a/services/api/internal/library/covers.go +++ b/services/api/internal/library/covers.go @@ -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 diff --git a/services/api/internal/library/covers_test.go b/services/api/internal/library/covers_test.go new file mode 100644 index 0000000..d96abbc --- /dev/null +++ b/services/api/internal/library/covers_test.go @@ -0,0 +1,160 @@ +package library + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestCoverSharePaths(t *testing.T) { + for _, rel := range []string{ + "Modeselektor - Happy Birthday! (2007-09-10)/cover.jpg", + "Coco Bryce/Albums/(2025-11-28) Noches Sephardies/cover.jpg", + "Thoma - Salt Spring (2019)/cover.jpeg", + "Franz Ferdinand/2017 - Always Ascending (Single)/Cover.png", + "Noisia/[FLAC] CCTV - Factory 5 [199502673] [2009]/cover.jpg", + "Kerala Dust/Kerala Dust - Francesca's Frames EP (2018-04-20)/cover.jpg", + } { + t.Run(rel, func(t *testing.T) { + root := t.TempDir() + cover := filepath.Join(root, filepath.FromSlash(rel)) + require.NoError(t, os.MkdirAll(filepath.Dir(cover), 0o755)) + require.NoError(t, os.WriteFile(cover, []byte("shared image"), 0o644)) + audio := filepath.Join(filepath.Dir(cover), "01 - Track.flac") + require.NoError(t, os.WriteFile(audio, nil, 0o644)) + c := Covers{FFmpeg: filepath.Join(root, "missing-ffmpeg")} + img, err := c.findAudio(context.Background(), audio) + require.NoError(t, err) + require.Equal(t, []byte("shared image"), img) + }) + } +} + +func TestCoverFolderPriorityAndLimit(t *testing.T) { + dir := t.TempDir() + audio := filepath.Join(dir, "track.flac") + require.NoError(t, os.WriteFile(audio, nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "folder.jpg"), []byte("folder"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "COVER.JPEG"), []byte("cover"), 0o644)) + c := Covers{FFmpeg: filepath.Join(dir, "missing-ffmpeg")} + img, err := c.findAudio(context.Background(), audio) + require.NoError(t, err) + require.Equal(t, "cover", string(img)) + f, err := os.OpenFile(filepath.Join(dir, "COVER.JPEG"), os.O_WRONLY, 0o644) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + require.NoError(t, f.Truncate(maxCoverBytes+1)) + require.NoError(t, f.Close()) + _, err = c.findAudio(context.Background(), audio) + require.ErrorContains(t, err, "too large") +} + +func TestCoverReadFailures(t *testing.T) { + dir := t.TempDir() + c := Covers{FFmpeg: filepath.Join(dir, "missing-ffmpeg")} + audio := filepath.Join(dir, "missing.flac") + _, err := c.findAudio(context.Background(), audio) + require.Error(t, err) + require.NoError(t, os.WriteFile(audio, nil, 0o644)) + _, err = c.findAudio(context.Background(), audio) + require.Error(t, err, "missing converter must not mark an album checked") + require.NoError(t, os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("cover"), 0o644)) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = c.findAudio(ctx, audio) + require.ErrorIs(t, err, context.Canceled) +} + +type failingCoverStore struct { + mu sync.Mutex + candidates []CoverCandidate + visited []uuid.UUID + checked int + entered chan struct{} + release chan struct{} +} + +func (s *failingCoverStore) AlbumsWithoutCover(_ context.Context, _ int, after uuid.UUID) ([]CoverCandidate, error) { + // Deliberately short pages exercise pagination independently of the batch size. + for i, a := range s.candidates { + if a.AlbumID.String() > after.String() { + return s.candidates[i:min(i+4, len(s.candidates))], nil + } + } + return nil, nil +} + +func (s *failingCoverStore) GetSource(_ context.Context, id uuid.UUID) (Source, error) { + s.mu.Lock() + s.visited = append(s.visited, id) + s.mu.Unlock() + s.entered <- struct{}{} + <-s.release + return Source{}, errors.New("share temporarily unavailable") +} + +func (s *failingCoverStore) SetAlbumCover(context.Context, uuid.UUID, *string, []string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.checked++ + return nil +} + +func TestCoverWorkersRetryFailuresWithoutLooping(t *testing.T) { + s := &failingCoverStore{entered: make(chan struct{}, 20), release: make(chan struct{})} + for i := byte(1); i <= 7; i++ { + id := uuid.UUID{15: i} + s.candidates = append(s.candidates, CoverCandidate{AlbumID: id, SourceID: id}) + } + c := Covers{store: s, Dir: t.TempDir(), log: slog.New(slog.NewTextHandler(io.Discard, nil))} + done := make(chan struct{}) + go func() { c.Run(context.Background()); close(done) }() + for range 4 { + select { + case <-s.entered: + case <-time.After(5 * time.Second): + t.Fatal("expected four concurrent cover workers") + } + } + close(s.release) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("failed albums must not loop or prevent later pages") + } + require.Len(t, s.visited, 7) + require.Zero(t, s.checked, "IO failures remain eligible for retry") + c.Run(context.Background()) + require.Len(t, s.visited, 14, "next run retries failures") +} + +type sourceCoverStore struct{ *failingCoverStore } + +func (s sourceCoverStore) GetSource(context.Context, uuid.UUID) (Source, error) { + return Source{Type: SourceLocal}, nil +} + +func TestCoverRenderFailureRemainsPending(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "track.flac"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("invalid image"), 0o644)) + s := sourceCoverStore{&failingCoverStore{}} + c := Covers{ + store: s, + svc: &Service{SourceService: &SourceService{cfg: Config{HostRoot: dir}}}, + FFmpeg: filepath.Join(dir, "missing-ffmpeg"), + Dir: t.TempDir(), + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + c.process(context.Background(), CoverCandidate{AlbumID: uuid.New(), RelPath: "track.flac"}) + require.Zero(t, s.checked, "conversion errors must not save a missing cover") +} diff --git a/services/api/internal/library/scanner.go b/services/api/internal/library/scanner.go index 0cd9588..5e23cc7 100644 --- a/services/api/internal/library/scanner.go +++ b/services/api/internal/library/scanner.go @@ -195,12 +195,6 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool) return fmt.Errorf("library: patterns: %w", err) } - if full { - // Полный скан — повод поискать обложки заново: в папках могли появиться cover.jpg. - if err := sc.store.ResetCoverChecks(ctx, src.ID); err != nil { - return err - } - } existing, err := sc.store.Files(ctx, src.ID) if err != nil { return err @@ -299,6 +293,11 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool) if err := sc.store.CleanupOrphans(ctx); err != nil { return err } + // Folder images can appear without changes to the audio files. A regular scan + // retries missing covers without forcing tags to be read again. + if err := sc.store.ResetCoverChecks(ctx, src.ID); err != nil { + return err + } return sc.store.SetLastScan(ctx, src.ID, sc.now()) } diff --git a/services/api/internal/library/store.go b/services/api/internal/library/store.go index 365b7be..f6a9274 100644 --- a/services/api/internal/library/store.go +++ b/services/api/internal/library/store.go @@ -553,7 +553,7 @@ func (st *Store) FileErrors(ctx context.Context, after string, limit int) ([]Fil // AlbumsWithoutCover — альбомы, обложку которых ещё не искали, и доступный файл первого трека. // Альбомы без доступных файлов пропускаются: искать не в чем. -func (st *Store) AlbumsWithoutCover(ctx context.Context, limit int) ([]CoverCandidate, error) { +func (st *Store) AlbumsWithoutCover(ctx context.Context, limit int, after uuid.UUID) ([]CoverCandidate, error) { rows, err := st.pool.Query(ctx, ` SELECT al.id, f.source_id, f.rel_path FROM albums al @@ -561,11 +561,13 @@ func (st *Store) AlbumsWithoutCover(ctx context.Context, limit int) ([]CoverCand SELECT mf.source_id, mf.rel_path FROM tracks t JOIN media_files mf ON mf.id = t.primary_file_id JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted' - WHERE t.album_id = al.id AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs) + WHERE t.album_id = al.id AND mf.missing_since IS NULL AND mf.scan_error IS NULL + AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs) ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST LIMIT 1) f - WHERE al.cover_checked_at IS NULL - LIMIT $1`, limit) + WHERE al.cover_checked_at IS NULL AND al.id > $2 + ORDER BY al.id + LIMIT $1`, limit, after) if err != nil { return nil, fmt.Errorf("library: albums without cover: %w", err) } @@ -586,7 +588,7 @@ func (st *Store) SetAlbumCover(ctx context.Context, albumID uuid.UUID, coverPath return nil } -// ResetCoverChecks — найти обложки заново для альбомов источника (полный скан: могли появиться cover.jpg). +// ResetCoverChecks — найти недостающие обложки заново после скана источника. func (st *Store) ResetCoverChecks(ctx context.Context, sourceID uuid.UUID) error { _, err := st.pool.Exec(ctx, ` UPDATE albums al SET cover_checked_at = NULL