fix(library): survive a music disk that is not mounted
ci / api / api (push) Successful in 29s
ci / db / db (push) Successful in 47s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 52s
release / release (версия и заметки) (push) Successful in 4s
release / android (APK в релиз) (push) Successful in 3m20s

On the test server the host music folder was not mounted, so every stream and
fingerprint failed with no such file, and a scan of the empty folder would have
marked the whole library missing and purged it after 30 days.

- a scan of an empty source folder with known files aborts with a clear error
- fingerprinting skips such a source with one warning instead of one per file
- LIBRARY_HOST mounts use rslave, so a disk mounted after start shows up
- update.sh refuses to deploy with an empty LIBRARY_HOST

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 23:34:52 +03:00
1 parent 421b1a3eb9
commit 3da82e902a
7 files changed
+66 -1

No files matched your search

@@ -103,7 +103,8 @@ func TestLibraryAndUploads(t *testing.T) {
assert.True(t, src.AcceptsUploads)
require.NotNil(t, src.WriteTemplate)
scanner := library.NewScanner(lib, fakeProber{}, library.NewProgressBoard(), log)
board := library.NewProgressBoard()
scanner := library.NewScanner(lib, fakeProber{}, board, log)
var fileIDs []uuid.UUID
for _, rel := range []string{"Кино/Группа крови/Кино - Группа крови.flac", "Кино/Группа крови/Кино - Закрой за мной дверь.flac"} {
id, err := scanner.ScanFile(ctx, src, root, rel)
@@ -114,6 +115,15 @@ func TestLibraryAndUploads(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, fileIDs[0], again, "rescan keeps the file row")
// Пустой каталог при известных файлах (диск не смонтирован) — скан прерывается и ничего не помечает пропавшим.
require.NoError(t, scanner.Start(src, t.TempDir(), false))
require.Eventually(t, func() bool { p, ok := board.Get(src.ID); return ok && !p.Running }, 10*time.Second, 50*time.Millisecond)
p, _ := board.Get(src.ID)
assert.Contains(t, p.Error, "is the disk mounted")
var missing int
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM media_files WHERE source_id = $1 AND missing_since IS NOT NULL`, src.ID).Scan(&missing))
assert.Zero(t, missing)
// --- Отпечатки ---
todo, err := lib.FilesToFingerprint(ctx, true, uuid.Nil, 10)
require.NoError(t, err)
@@ -198,6 +198,7 @@ func (fp *Fingerprinter) chromaprint() bool {
func (fp *Fingerprinter) Run(ctx context.Context) {
withFP := fp.chromaprint()
roots := map[uuid.UUID]string{}
unavailable := map[uuid.UUID]bool{} // каталог источника пуст или не открывается — диск не смонтирован
var after uuid.UUID
for ctx.Err() == nil {
todo, err := fp.store.FilesToFingerprint(ctx, withFP, after, 200)
@@ -213,6 +214,9 @@ func (fp *Fingerprinter) Run(ctx context.Context) {
return
}
after = f.ID
if unavailable[f.SourceID] {
continue
}
root, ok := roots[f.SourceID]
if !ok {
if root, err = fp.rootOf(ctx, f.SourceID); err != nil {
@@ -220,12 +224,31 @@ func (fp *Fingerprinter) Run(ctx context.Context) {
continue
}
roots[f.SourceID] = root
if empty, err := dirEmpty(root); empty || err != nil {
fp.log.Warn("fingerprints: source folder is empty or unreadable, skipped — is the disk mounted?",
"source_id", f.SourceID, "root", root, "err", err)
unavailable[f.SourceID] = true
continue
}
}
fp.process(ctx, f, filepath.Join(root, filepath.FromSlash(f.RelPath)), withFP)
}
}
}
func dirEmpty(dir string) (bool, error) {
d, err := os.Open(dir)
if err != nil {
return false, err
}
defer func() { _ = d.Close() }()
names, err := d.Readdirnames(1)
if errors.Is(err, io.EOF) {
return true, nil
}
return len(names) == 0, err
}
func (fp *Fingerprinter) process(ctx context.Context, f FingerprintTodo, abs string, withFP bool) {
sum := f.SHA256
if sum == nil {
+17
View File
@@ -119,6 +119,15 @@ type walked struct {
hash []byte
}
func hasPresent(files map[string]FileRow) bool {
for _, f := range files {
if !f.Missing {
return true
}
}
return false
}
// job — файл, который нужно прочитать.
type job struct {
walked
@@ -135,6 +144,11 @@ type probed struct {
// ErrScanRunning — скан источника уже идёт.
var ErrScanRunning = errors.New("library: scan already running")
// ErrSourceEmpty — в каталоге источника нет ни одного файла, а в библиотеке они есть: скорее всего, диск
// не смонтирован. Скан прерывается, чтобы не пометить всю библиотеку отсутствующей (и через MissingRetention —
// не удалить треки вместе с историей).
var ErrSourceEmpty = errors.New("library: source folder is empty but the library has its files — is the disk mounted?")
// Start запускает скан в фоне. root — каталог источника в файловой системе api.
func (sc *Scanner) Start(src Source, root string, full bool) error {
if !sc.board.start(src.ID, full, sc.now()) {
@@ -192,6 +206,9 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
if err != nil {
return err
}
if len(found) == 0 && hasPresent(existing) {
return ErrSourceEmpty
}
sc.board.update(src.ID, func(p *Progress) { p.Found = len(found) })
var (