fix(library): parallelize cover processing and retry missing artwork
This commit is contained in:
1 parent
eed7038109
commit
0a4cd4bff1
5 files changed
+259
-28
No files matched your search
@@ -172,6 +172,16 @@ lower-case, `ё → е`, удаление пунктуации, схлопыва
|
|||||||
сгенерированный из цвета. Конвертируются в WebP 64/300/1200 px в `/srv/pipu/cache/covers/`, вычисляется
|
сгенерированный из цвета. Конвертируются в WebP 64/300/1200 px в `/srv/pipu/cache/covers/`, вычисляется
|
||||||
палитра из 3 цветов (k-means по пикселям; `cover_colors`) — для ambient-фона, цветов кнопки волны и плейсхолдеров.
|
палитра из 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, индексы:
|
Meilisearch, индексы:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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 и палитру из трёх цветов. Альбом без обложки отмечается
|
// Готовит WebP 64/300/1200 в Dir и палитру из трёх цветов. Альбом без обложки отмечается
|
||||||
// проверенным — клиент рисует плейсхолдер.
|
// проверенным — клиент рисует плейсхолдер.
|
||||||
type Covers struct {
|
type Covers struct {
|
||||||
store *Store
|
store coverStore
|
||||||
svc *Service
|
svc *Service
|
||||||
FFmpeg string
|
FFmpeg string
|
||||||
Dir string // /cache/covers
|
Dir string // /cache/covers
|
||||||
log *slog.Logger
|
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 создаёт обработчик обложек.
|
// NewCovers создаёт обработчик обложек.
|
||||||
@@ -52,22 +61,42 @@ func (c *Covers) CoverFile(coverPath string, size int) string {
|
|||||||
|
|
||||||
// Run обрабатывает все альбомы без проверенной обложки.
|
// Run обрабатывает все альбомы без проверенной обложки.
|
||||||
func (c *Covers) Run(ctx context.Context) {
|
func (c *Covers) Run(ctx context.Context) {
|
||||||
|
c.runMu.Lock()
|
||||||
|
defer c.runMu.Unlock()
|
||||||
if err := os.MkdirAll(c.Dir, 0o755); err != nil {
|
if err := os.MkdirAll(c.Dir, 0o755); err != nil {
|
||||||
c.log.Error("covers: cache dir", "err", err)
|
c.log.Error("covers: cache dir", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pending, err := c.store.AlbumsWithoutCover(ctx, 500)
|
// Keyset pagination visits each album once, including failures left for the next run.
|
||||||
for err == nil && len(pending) > 0 {
|
after := uuid.Nil
|
||||||
for _, a := range pending {
|
for ctx.Err() == nil {
|
||||||
if ctx.Err() != nil {
|
pending, err := c.store.AlbumsWithoutCover(ctx, 500, after)
|
||||||
return
|
if err != nil {
|
||||||
}
|
c.log.Error("covers: list albums", "err", err)
|
||||||
c.process(ctx, a)
|
return
|
||||||
}
|
}
|
||||||
pending, err = c.store.AlbumsWithoutCover(ctx, 500)
|
if len(pending) == 0 {
|
||||||
}
|
return
|
||||||
if err != nil {
|
}
|
||||||
c.log.Error("covers: list albums", "err", err)
|
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
|
colors []string
|
||||||
)
|
)
|
||||||
img, err := c.find(ctx, a)
|
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)
|
sum := sha256.Sum256(img)
|
||||||
key := hex.EncodeToString(sum[:8])
|
key := hex.EncodeToString(sum[:8])
|
||||||
if colors, err = c.render(ctx, img, key); err != nil {
|
if colors, err = c.render(ctx, img, key); err != nil {
|
||||||
c.log.Warn("covers: render", "album_id", a.AlbumID, "err", err)
|
c.log.Warn("covers: render", "album_id", a.AlbumID, "err", err)
|
||||||
|
return
|
||||||
} else {
|
} else {
|
||||||
coverPath = &key
|
coverPath = &key
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := c.store.SetAlbumCover(ctx, a.AlbumID, coverPath, colors); err != nil {
|
if err := c.store.SetAlbumCover(ctx, a.AlbumID, coverPath, colors); err != nil {
|
||||||
c.log.Error("covers: save", "album_id", a.AlbumID, "err", err)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
audio := filepath.Join(root, filepath.FromSlash(a.RelPath))
|
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.
|
// Встроенная обложка: первый видеопоток (attached picture) одним кадром в JPEG.
|
||||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
out, err := exec.CommandContext(cctx, c.FFmpeg, "-v", "error", "-i", audio, "-an", "-map", "0:v:0",
|
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()
|
"-frames:v", "1", "-f", "image2pipe", "-c:v", "mjpeg", "-").Output()
|
||||||
|
extractErr := cctx.Err()
|
||||||
cancel()
|
cancel()
|
||||||
if err == nil && len(out) > 0 {
|
if err == nil && len(out) > 0 {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
embeddedErr := err
|
||||||
|
|
||||||
entries, err := os.ReadDir(filepath.Dir(audio))
|
entries, err := os.ReadDir(filepath.Dir(audio))
|
||||||
if err != nil {
|
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))
|
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
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +204,10 @@ func readLimited(p string) ([]byte, error) {
|
|||||||
// render пишет WebP всех размеров (если их ещё нет — одинаковые обложки альбомов переиспользуются)
|
// render пишет WebP всех размеров (если их ещё нет — одинаковые обложки альбомов переиспользуются)
|
||||||
// и считает палитру.
|
// и считает палитру.
|
||||||
func (c *Covers) render(ctx context.Context, img []byte, key string) ([]string, error) {
|
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-*")
|
tmp, err := os.CreateTemp(c.Dir, ".src-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -195,12 +195,6 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
|
|||||||
return fmt.Errorf("library: patterns: %w", err)
|
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)
|
existing, err := sc.store.Files(ctx, src.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err := sc.store.CleanupOrphans(ctx); err != nil {
|
||||||
return err
|
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())
|
return sc.store.SetLastScan(ctx, src.ID, sc.now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -553,7 +553,7 @@ func (st *Store) FileErrors(ctx context.Context, after string, limit int) ([]Fil
|
|||||||
|
|
||||||
// AlbumsWithoutCover — альбомы, обложку которых ещё не искали, и доступный файл первого трека.
|
// 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, `
|
rows, err := st.pool.Query(ctx, `
|
||||||
SELECT al.id, f.source_id, f.rel_path
|
SELECT al.id, f.source_id, f.rel_path
|
||||||
FROM albums al
|
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
|
SELECT mf.source_id, mf.rel_path FROM tracks t
|
||||||
JOIN media_files mf ON mf.id = t.primary_file_id
|
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'
|
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
|
ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST
|
||||||
LIMIT 1) f
|
LIMIT 1) f
|
||||||
WHERE al.cover_checked_at IS NULL
|
WHERE al.cover_checked_at IS NULL AND al.id > $2
|
||||||
LIMIT $1`, limit)
|
ORDER BY al.id
|
||||||
|
LIMIT $1`, limit, after)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("library: albums without cover: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetCoverChecks — найти обложки заново для альбомов источника (полный скан: могли появиться cover.jpg).
|
// ResetCoverChecks — найти недостающие обложки заново после скана источника.
|
||||||
func (st *Store) ResetCoverChecks(ctx context.Context, sourceID uuid.UUID) error {
|
func (st *Store) ResetCoverChecks(ctx context.Context, sourceID uuid.UUID) error {
|
||||||
_, err := st.pool.Exec(ctx, `
|
_, err := st.pool.Exec(ctx, `
|
||||||
UPDATE albums al SET cover_checked_at = NULL
|
UPDATE albums al SET cover_checked_at = NULL
|
||||||
|
|||||||
Reference in new issue
Block a user