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
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:
1 parent
421b1a3eb9
commit
3da82e902a
7 files changed
+66
-1
No files matched your search
@@ -20,11 +20,13 @@ x-library-ro: &library-ro
|
|||||||
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
|
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
|
||||||
# Всем сервисам — только чтение; на запись (импорт загрузок) его видит только storage-agent — /library-host-rw
|
# Всем сервисам — только чтение; на запись (импорт загрузок) его видит только storage-agent — /library-host-rw
|
||||||
# (docs/library-and-storage.md#запись-в-хранилище).
|
# (docs/library-and-storage.md#запись-в-хранилище).
|
||||||
|
# rslave: диск, смонтированный на хосте уже после старта контейнеров, становится виден внутри без перезапуска.
|
||||||
x-library-host-ro: &library-host-ro
|
x-library-host-ro: &library-host-ro
|
||||||
type: bind
|
type: bind
|
||||||
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
||||||
target: /library-host
|
target: /library-host
|
||||||
read_only: true
|
read_only: true
|
||||||
|
bind: { propagation: rslave }
|
||||||
|
|
||||||
x-restart: &restart
|
x-restart: &restart
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -96,6 +98,7 @@ services:
|
|||||||
- type: bind
|
- type: bind
|
||||||
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
||||||
target: /library-host-rw
|
target: /library-host-rw
|
||||||
|
bind: { propagation: rslave }
|
||||||
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
|
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
|
||||||
|
|
||||||
recommender:
|
recommender:
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
|
|||||||
fi
|
fi
|
||||||
done
|
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:-}"
|
REF="${1:-}"
|
||||||
git fetch --tags --prune --quiet origin
|
git fetch --tags --prune --quiet origin
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ Android-приложение обновляется само: при запус
|
|||||||
|
|
||||||
| Ситуация | Что делать |
|
| Ситуация | Что делать |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| Ни один трек не играет («Не удалось воспроизвести трек»), в логе api `no such file or directory` | Диск с музыкой не смонтирован: `findmnt <LIBRARY_HOST>`, `sudo mount -a`. Контейнеры увидят диск сами; скан на пустой папке прерывается с ошибкой «is the disk mounted?» и ничего не помечает пропавшим |
|
||||||
| Трек не находится поиском | Есть ли он в «Библиотеке» (скан завершён, файл не в «ошибках»); затем «Переиндексировать поиск» |
|
| Трек не находится поиском | Есть ли он в «Библиотеке» (скан завершён, файл не в «ошибках»); затем «Переиндексировать поиск» |
|
||||||
| Неверный исполнитель или альбом | Поправьте теги файла или шаблон путей и запустите скан |
|
| Неверный исполнитель или альбом | Поправьте теги файла или шаблон путей и запустите скан |
|
||||||
| Хранилище в статусе «Ошибка» | Текст ошибки на карточке; диск смонтирован (`findmnt`), NFS доступен с сервера; «Проверить» |
|
| Хранилище в статусе «Ошибка» | Текст ошибки на карточке; диск смонтирован (`findmnt`), NFS доступен с сервера; «Проверить» |
|
||||||
|
|||||||
@@ -348,6 +348,8 @@ cd /opt/pipu-music/deploy && docker compose down
|
|||||||
| Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша |
|
| Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша |
|
||||||
| Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` |
|
| Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` |
|
||||||
| `permission denied … docker.sock` | Пользователь не в группе `docker` (шаг 3) или вы не перелогинились |
|
| `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` |
|
| Сервисы не стартуют: `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) |
|
| Хранилище «Локальная папка» пустое или ошибка доступа | Диск смонтирован (`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` |
|
| `storage-agent` не стартует, ошибка про propagation / shared mount | Ещё раз `sudo ./install.sh`; на хосте `findmnt -o TARGET,PROPAGATION /srv/pipu/library` должно показать `shared` |
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ func TestLibraryAndUploads(t *testing.T) {
|
|||||||
assert.True(t, src.AcceptsUploads)
|
assert.True(t, src.AcceptsUploads)
|
||||||
require.NotNil(t, src.WriteTemplate)
|
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
|
var fileIDs []uuid.UUID
|
||||||
for _, rel := range []string{"Кино/Группа крови/Кино - Группа крови.flac", "Кино/Группа крови/Кино - Закрой за мной дверь.flac"} {
|
for _, rel := range []string{"Кино/Группа крови/Кино - Группа крови.flac", "Кино/Группа крови/Кино - Закрой за мной дверь.flac"} {
|
||||||
id, err := scanner.ScanFile(ctx, src, root, rel)
|
id, err := scanner.ScanFile(ctx, src, root, rel)
|
||||||
@@ -114,6 +115,15 @@ func TestLibraryAndUploads(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, fileIDs[0], again, "rescan keeps the file row")
|
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)
|
todo, err := lib.FilesToFingerprint(ctx, true, uuid.Nil, 10)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ func (fp *Fingerprinter) chromaprint() bool {
|
|||||||
func (fp *Fingerprinter) Run(ctx context.Context) {
|
func (fp *Fingerprinter) Run(ctx context.Context) {
|
||||||
withFP := fp.chromaprint()
|
withFP := fp.chromaprint()
|
||||||
roots := map[uuid.UUID]string{}
|
roots := map[uuid.UUID]string{}
|
||||||
|
unavailable := map[uuid.UUID]bool{} // каталог источника пуст или не открывается — диск не смонтирован
|
||||||
var after uuid.UUID
|
var after uuid.UUID
|
||||||
for ctx.Err() == nil {
|
for ctx.Err() == nil {
|
||||||
todo, err := fp.store.FilesToFingerprint(ctx, withFP, after, 200)
|
todo, err := fp.store.FilesToFingerprint(ctx, withFP, after, 200)
|
||||||
@@ -213,6 +214,9 @@ func (fp *Fingerprinter) Run(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
after = f.ID
|
after = f.ID
|
||||||
|
if unavailable[f.SourceID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
root, ok := roots[f.SourceID]
|
root, ok := roots[f.SourceID]
|
||||||
if !ok {
|
if !ok {
|
||||||
if root, err = fp.rootOf(ctx, f.SourceID); err != nil {
|
if root, err = fp.rootOf(ctx, f.SourceID); err != nil {
|
||||||
@@ -220,12 +224,31 @@ func (fp *Fingerprinter) Run(ctx context.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
roots[f.SourceID] = root
|
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)
|
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) {
|
func (fp *Fingerprinter) process(ctx context.Context, f FingerprintTodo, abs string, withFP bool) {
|
||||||
sum := f.SHA256
|
sum := f.SHA256
|
||||||
if sum == nil {
|
if sum == nil {
|
||||||
|
|||||||
@@ -119,6 +119,15 @@ type walked struct {
|
|||||||
hash []byte
|
hash []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasPresent(files map[string]FileRow) bool {
|
||||||
|
for _, f := range files {
|
||||||
|
if !f.Missing {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// job — файл, который нужно прочитать.
|
// job — файл, который нужно прочитать.
|
||||||
type job struct {
|
type job struct {
|
||||||
walked
|
walked
|
||||||
@@ -135,6 +144,11 @@ type probed struct {
|
|||||||
// ErrScanRunning — скан источника уже идёт.
|
// ErrScanRunning — скан источника уже идёт.
|
||||||
var ErrScanRunning = errors.New("library: scan already running")
|
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.
|
// Start запускает скан в фоне. root — каталог источника в файловой системе api.
|
||||||
func (sc *Scanner) Start(src Source, root string, full bool) error {
|
func (sc *Scanner) Start(src Source, root string, full bool) error {
|
||||||
if !sc.board.start(src.ID, full, sc.now()) {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if len(found) == 0 && hasPresent(existing) {
|
||||||
|
return ErrSourceEmpty
|
||||||
|
}
|
||||||
sc.board.update(src.ID, func(p *Progress) { p.Found = len(found) })
|
sc.board.update(src.ID, func(p *Progress) { p.Found = len(found) })
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
Reference in new issue
Block a user