From 3da82e902ae4b0858158f571de29cbde4f3c52f6 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:34:52 +0300 Subject: [PATCH] fix(library): survive a music disk that is not mounted 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 --- deploy/compose.yaml | 3 +++ deploy/update.sh | 9 ++++++++ docs/admin-guide.md | 1 + docs/install-guide.md | 2 ++ .../api/internal/dbtest/integration_test.go | 12 +++++++++- services/api/internal/library/fingerprint.go | 23 +++++++++++++++++++ services/api/internal/library/scanner.go | 17 ++++++++++++++ 7 files changed, 66 insertions(+), 1 deletion(-) diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 32252d8..5bc2f2c 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -20,11 +20,13 @@ x-library-ro: &library-ro # Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music. # Всем сервисам — только чтение; на запись (импорт загрузок) его видит только storage-agent — /library-host-rw # (docs/library-and-storage.md#запись-в-хранилище). +# rslave: диск, смонтированный на хосте уже после старта контейнеров, становится виден внутри без перезапуска. x-library-host-ro: &library-host-ro type: bind source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host} target: /library-host read_only: true + bind: { propagation: rslave } x-restart: &restart restart: unless-stopped @@ -96,6 +98,7 @@ services: - type: bind source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host} target: /library-host-rw + bind: { propagation: rslave } - ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro recommender: diff --git a/deploy/update.sh b/deploy/update.sh index 348aec5..d6e9ac2 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -26,6 +26,15 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do fi done +# Папка с музыкой пуста — почти всегда это несмонтированный диск: api отдавал бы ошибки воспроизведения. +LIBRARY_HOST="$(sed -n 's/^LIBRARY_HOST=//p' .env)" +if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [ "${PIPU_ALLOW_EMPTY_LIBRARY:-}" != 1 ]; then + echo "LIBRARY_HOST=$LIBRARY_HOST пуст или не существует — диск с музыкой не смонтирован?" >&2 + echo " проверьте: findmnt $LIBRARY_HOST; смонтируйте (sudo mount -a) и повторите." >&2 + echo " Если папка и должна быть пустой: PIPU_ALLOW_EMPTY_LIBRARY=1 ./update.sh ${1:-}" >&2 + exit 1 +fi + REF="${1:-}" git fetch --tags --prune --quiet origin diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 6a8e667..7b3428f 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -122,6 +122,7 @@ Android-приложение обновляется само: при запус | Ситуация | Что делать | |---|---| +| Ни один трек не играет («Не удалось воспроизвести трек»), в логе api `no such file or directory` | Диск с музыкой не смонтирован: `findmnt `, `sudo mount -a`. Контейнеры увидят диск сами; скан на пустой папке прерывается с ошибкой «is the disk mounted?» и ничего не помечает пропавшим | | Трек не находится поиском | Есть ли он в «Библиотеке» (скан завершён, файл не в «ошибках»); затем «Переиндексировать поиск» | | Неверный исполнитель или альбом | Поправьте теги файла или шаблон путей и запустите скан | | Хранилище в статусе «Ошибка» | Текст ошибки на карточке; диск смонтирован (`findmnt`), NFS доступен с сервера; «Проверить» | diff --git a/docs/install-guide.md b/docs/install-guide.md index 28bde0e..2ba1e53 100644 --- a/docs/install-guide.md +++ b/docs/install-guide.md @@ -348,6 +348,8 @@ cd /opt/pipu-music/deploy && docker compose down | Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша | | Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` | | `permission denied … docker.sock` | Пользователь не в группе `docker` (шаг 3) или вы не перелогинились | +| Сервисы не стартуют: `… is not a shared or slave mount` | `sudo mount --make-rshared /` (и добавьте в автозагрузку); на systemd-системах корень и так shared | +| `update.sh`: «LIBRARY_HOST … пуст» | Диск с музыкой не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; если папка и должна быть пустой — `PIPU_ALLOW_EMPTY_LIBRARY=1 ./update.sh` | | Сервисы не стартуют: `bind source path does not exist: /mnt/music` | Диск не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; путь в `LIBRARY_HOST` в `.env` | | Хранилище «Локальная папка» пустое или ошибка доступа | Диск смонтирован (`ls /mnt/music`); права на чтение для всех — `sudo chmod -R a+rX /mnt/music` (шаг 4) | | `storage-agent` не стартует, ошибка про propagation / shared mount | Ещё раз `sudo ./install.sh`; на хосте `findmnt -o TARGET,PROPAGATION /srv/pipu/library` должно показать `shared` | diff --git a/services/api/internal/dbtest/integration_test.go b/services/api/internal/dbtest/integration_test.go index 553bd24..4048432 100644 --- a/services/api/internal/dbtest/integration_test.go +++ b/services/api/internal/dbtest/integration_test.go @@ -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) diff --git a/services/api/internal/library/fingerprint.go b/services/api/internal/library/fingerprint.go index f0505b1..8d7b1ce 100644 --- a/services/api/internal/library/fingerprint.go +++ b/services/api/internal/library/fingerprint.go @@ -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 { diff --git a/services/api/internal/library/scanner.go b/services/api/internal/library/scanner.go index 3d02c0b..a836963 100644 --- a/services/api/internal/library/scanner.go +++ b/services/api/internal/library/scanner.go @@ -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 (