# Библиотека: хранилища, шаблоны путей, сканер, поиск ## Хранилища (sources) Источник — корень, из которого берутся файлы. Управляет только `admin`. | Тип | Как подключается | |---|---| | `nfs` | Админ указывает `server`, `export`, опции. `storage-agent` выполняет `mount -t nfs -o server:export /library/`. | | `local` | Папка на диске сервера, заранее смонтированная на хосте (например, диск `/mnt/music` через fstab) и указанная в `LIBRARY_HOST`. В контейнерах видна как `/library-host`; админ выбирает подпапку. | ### Монтирование (storage-agent) - Общий каталог хоста `/srv/pipu/library` монтируется: - в `storage-agent` — `bind`, `propagation: rshared`; - в `api`, `nginx`, `analyzer` — `bind`, `read_only`, `propagation: rslave`. - Опции NFS по умолчанию: `ro,nfsvers=4.1,soft,timeo=150,retrans=3,noatime`. `ro` принудительно добавляется для всех источников, кроме отмеченных `accepts_uploads` (см. [ниже](#запись-в-хранилище)). - Валидация ввода: `server` — hostname/IP, `export` — абсолютный путь без `..`, опции — только из whitelist (`nfsvers, port, timeo, retrans, soft, hard, rsize, wsize, noatime, nolock, sec`). Никаких произвольных строк в команду. - `POST /admin/sources/{id}/test`: агент монтирует во временную точку, проверяет чтение листинга и одного файла, возвращает число найденных аудиофайлов в первых N каталогах. - Health-check: агент раз в минуту делает `stat` с таймаутом; при сбое — `status=error`, сообщение в админке; треки источника помечаются недоступными (не выдаются в волну, в UI серые), но **не удаляются**. - При рестарте агента — перемонтирование всех `status != disabled` источников. ### Запись в хранилище Единственный сценарий записи — импорт одобренных треков из [очереди на добавление](uploads.md). - Источник с `accepts_uploads = true` агент монтирует **дважды**: - `/library/` — `ro`, как обычно (его видят `api`, `nginx`, `analyzer`); - `/library-rw/` — `rw`, виден **только самому агенту** (каталог без propagation наружу). - Пишет только `storage-agent` по команде `api` (`POST /internal/files`): создание каталогов, запись во временный файл, `fsync`, сверка SHA-256, атомарный `rename`. Перезапись существующих файлов и удаление запрещены на уровне кода агента. - Права на NFS-сервере: экспорт должен разрешать запись для UID/GID агента (`PIPU_FILE_UID` / `PIPU_FILE_GID`, задаются в `.env` под владельца музыки на файловом сервере); новые файлы — `0644`, каталоги — `0755`. - `POST /admin/sources/{id}/test` для такого источника дополнительно проверяет право записи — без создания файлов. - У источника задаётся `write_template` — шаблон пути для новых файлов, с автоподбором по существующей структуре и проверкой обратимости через регэкспы источника (подробно — [uploads.md](uploads.md#7-раскладка-в-хранилище-как-принято-на-сервере)). **Реализация.** - Агент: `POST /internal/files` `{source_id, local_path?, rel_path, staging_path, sha256}` → `{rel_path, size, renamed}`; `POST /internal/files/test` — проверка записи. NFS с `accepts_uploads` монтируется rw в `/library-rw/` (каталог внутри контейнера агента), а в `/library/` — bind этой точки с `remount,ro`. Local-источники пишутся через `/library-host-rw` (тот же `LIBRARY_HOST`, на запись — только у агента). На музыкальный диск не пишется ничего, кроме самих треков: файл создаётся сразу под итоговым именем (`O_EXCL` — существующий не перезаписывается; занято — ` (2)`, ` (3)`…), `fsync`, сверка SHA-256; при ошибке агент удаляет свой недописанный файл и созданные для него пустые каталоги. Проверка записи — `access(W_OK)` и флаг read-only файловой системы, без пробного файла. Остальные сервисы видят музыку только на чтение (`read_only` в compose). Владелец новых файлов и каталогов — `PIPU_FILE_UID` / `PIPU_FILE_GID` (`-1` — не менять). - Шаблон: `meta.ParseTemplate` / `Render` (очистка имён, пределы длины), `meta.SuggestTemplate` (регэксп → шаблон через `regexp/syntax`: именованные группы → поля, `(?:(?P\d)-)?` → `{disc:1-}`, необязательная группа → `[ ]`, хвост `\.\w+$` → `.{ext}`, ширина `{track:0N}` — по выборке), `meta.CheckTemplate` (50 случайных треков источника). - API: `accepts_uploads`, `write_template` в `Source`; `POST /admin/sources/{id}/write-template/suggest|check`; проверка подключения для принимающего источника дополнительно возвращает `write_ok`. UI — страница шаблонов путей. ## Шаблоны путей (регэкспы) Для каждого источника — упорядоченный список регулярных выражений (синтаксис RE2 / Go `regexp`) с именованными группами. Применяются к пути **относительно корня источника**, с `/` как разделителем. Первое совпавшее выражение побеждает. Поддерживаемые группы: | Группа | Поле | |---|---| | `artist` | Исполнитель трека | | `albumartist` | Исполнитель альбома | | `album` | Альбом | | `year` | Год (4 цифры) | | `disc` | Номер диска | | `track` | Номер трека | | `title` | Название | | `genre` | Жанр | | `skip` | Если совпала — файл игнорируется (например, `^(?P_incoming)/`) | Примеры: ```regex # Исполнитель/2019 - Альбом/01 - Название.flac ^(?P[^/]+)/(?P\d{4}) - (?P[^/]+)/(?:(?P\d)-)?(?P\d{1,3}) - (?P[^/]+)\.\w+$ # Жанр/Исполнитель/Альбом (2019)/03. Название.mp3 ^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \((?P<year>\d{4})\)/(?P<track>\d+)\. (?P<title>[^/]+)\.\w+$ # Сборники: Compilations/Альбом/01 - Исполнитель - Название.mp3 ^Compilations/(?P<album>[^/]+)/(?P<track>\d+) - (?P<artist>.+?) - (?P<title>[^/]+)\.\w+$ ``` ### Слияние с тегами `tag_priority` источника: - `tags_first` (по умолчанию): значение из тега, если тег пуст — из шаблона; - `path_first`: значение из шаблона, если группа не совпала — из тега. Если нет ни тега, ни шаблона: `title` = имя файла без расширения и номера, `artist` = «Unknown artist», `album` = имя родительской папки. Нормализация: обрезка пробелов, `_` → пробел, Unicode NFC; для ключей сопоставления (`name_norm`) — lower-case, `ё → е`, удаление пунктуации, схлопывание пробелов. Множественные исполнители разбираются по разделителям `;`, ` / `, ` feat. `, ` ft. `, ` & ` (последний — только если такого составного имени нет в библиотеке). ### Предпросмотр `POST /admin/sources/{id}/patterns/preview` берёт случайную выборку (по умолчанию 50) путей из `media_files` (или из листинга, если скана ещё не было) и возвращает для каждого: какое выражение совпало, извлечённые поля, значения из тегов, итоговый результат после слияния. UI показывает таблицу с подсветкой групп и счётчик «не совпало ни одно выражение». Регэксп валидируется при вводе (компиляция на сервере, ошибка с позицией). Ограничение длины выражения — 1000 символов (RE2 линейный, ReDoS невозможен). ## Сканер Задачи River в `api`: | Задача | Когда | |---|---| | `scan_source(full=false)` | По расписанию источника, вручную, после изменения шаблонов (с `full=true`) | | `scan_source(full=true)` | Перечитать теги / шаблоны у всех файлов | | `scan_path(source_id, rel_path)` | Точечный скан одного файла после импорта из очереди | | `extract_cover(album_id)` | Новый альбом | | `fingerprint_file(file_id)` | Новый / изменённый файл: полный SHA-256 + Chromaprint (`fpcalc -raw`, первые 120 с) — для проверки дублей при загрузке; низкий приоритет. Реализация — `library.Fingerprinter`: один проход по файлам с `sha256 IS NULL` / `fingerprinted_at IS NULL` после каждого скана и при старте, по одному файлу. Без `fpcalc` (`FPCALC_PATH`) считается только SHA-256. Изменение содержимого файла сбрасывает отпечатки. Сравнение — `library.Similarity` (доля совпавших бит лучшего выравнивания, сдвиг ±80 точек; ≥ 0.75 — тот же трек) | | `reindex_search` | Вручную / после крупного скана | Алгоритм инкрементального скана: 1. Обход дерева (`filepath.WalkDir`), фильтр по расширениям `flac mp3 m4a aac ogg opus wav aiff wv ape`. Параллелизм — 8 воркеров (NFS любит параллельные запросы, но не сотни). 2. Для каждого файла: если `(rel_path, size, mtime)` не изменились и `full=false` — пропуск. 3. Иначе — `ffprobe` (длительность, кодек, битрейт) + чтение тегов, применение шаблонов, `content_hash`. 4. Сопоставление: по `content_hash` ищем «пропавший» файл — если нашли, это переименование / перенос: обновляем путь, `track_id` сохраняется (лайки и история не теряются). 5. Upsert исполнителей / альбомов / треков; дубли (одинаковые artist+album+title+duration ±2 с) сводятся к одному треку, `primary_file_id` — файл лучшего качества (lossless > битрейт). 6. Файлы, не встреченные при полном обходе, получают `missing_since`; через 30 дней без появления трек удаляется (лайки / история сохраняются в событиях, в UI — «трек недоступен»). 7. Новые треки → `analysis_jobs`, батчевое обновление Meilisearch (по 1000 документов). 8. Прогресс публикуется в Redis (`scan:<source_id>`), админка опрашивает `GET /admin/library/status` раз в 2 с (или SSE `/admin/library/events`). Ожидаемое время первого скана 11k файлов по NFS — 5–15 минут (bottleneck — чтение тегов). В редакторе хранилища поле **«Не сканировать папки»** задаёт до 100 исключений: по одному пути относительно корня хранилища в строке, например `Downloads_PumpEars` или `Artist/Samples`. Исключается вся ветка; соседняя `Downloads_PumpEars-copy` не затрагивается. Пути чувствительны к регистру, шаблоны и абсолютные пути не используются. Настройка хранится в `library_sources.excluded_dirs`; пустой список снимает исключения. Во время активного скана менять список нельзя. Исключённые ветки не обходятся; точечный скан такого пути отклоняется. Уже записанные файлы остаются в каталоге и не помечаются пропавшими. Сразу после сохранения исключений ошибки чтения и задачи аудиоанализа этих файлов перестают попадать в диагностику и повтор. Анализатор не забирает их в работу; если у трека есть другая разрешённая копия, использует её. После снятия исключений сохранённые задачи снова учитываются; неудавшиеся можно повторить. Файлы на диске не изменяются. Во время обхода админка показывает число посещённых папок (включая корень), найденных аудиофайлов и текущую папку относительно корня хранилища. До завершения обхода общее число файлов неизвестно, поэтому индикатор показывает активность без процента. Затем отображаются этапы сопоставления, обработки файлов с известным общим числом и завершения скана. API передаёт их в `ScanProgress.phase`, `walked_dirs`, `current_dir`. Ошибки в итогах скана относятся только к этому запуску. Общий счётчик и список ошибок чтения учитывают сохранённые ошибки только присутствующих файлов: после успешного скана пропавшие файлы исключаются из них, сохраняя запись на срок `MissingRetention`. В блоке аудиоанализа администратор может открыть «Показать файлы»: окно загружает ошибки анализа страницами через `GET /api/v1/admin/library/analysis/errors` и показывает трек, хранилище, относительный путь из текущего каталога и причину сбоя. ### Обложки Источники по приоритету: встроенная в файл → `cover.jpg|folder.jpg|front.*` в папке альбома → плейсхолдер, сгенерированный из цвета. Конвертируются в 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 раздаёт файл из кэша. В карточке источника показано «Локальные обложки: X из Y файлов» и число альбомов, ожидающих обработки. Статистика обновляется каждые 10 секунд через `GET /admin/sources/{id}/cover-stats`. Считаются аудиофайлы каталога без ошибок чтения, пропавших файлов и исключённых папок, включая альтернативные форматы одного трека. Числитель — файлы, у альбома которых есть готовая обложка из тегов или папки; внешние URL Genius не учитываются. Это покрытие файлов обложками альбомов, а не количество отдельных `cover.jpg` на диске. ## Поиск Meilisearch, индексы: | Индекс | Поля поиска (по убыванию веса) | Фильтры / сортировка | |---|---|---| | `artists` | `name`, `name_translit`, `aliases` | `popularity` | | `albums` | `title`, `artist_name`, `title_translit` | `year`, `popularity` | | `tracks` | `title`, `artist_names`, `album_title`, `title_translit` | `genre`, `year`, `popularity`, `available` | - `*_translit` — варианты транслитерации (кириллица ↔ латиница), включая словарь устоявшихся написаний (`Boulevard` → `бульвар`) наряду с побуквенным вариантом. **Исправление раскладки** (`ghbdtn` → `привет`): при пустом результате клиентский запрос повторяется в альтернативной раскладке на стороне `api`. - Запрос учитывает все слова (`matchingStrategy=all`), не отбрасывая последнее ради заполнения списка. Опечатки и поиск по префиксу по-прежнему обрабатывает Meilisearch. - Ранжирование: `words`, `typo`, `exactness`, `proximity`, `attribute`, `sort`, `popularity:desc`. Точное имя исполнителя (включая вариант транслитерации) закрепляется перед составными именами в подсказках и не опускается из-за персонализации. Новые варианты появляются после переиндексации; API запускает её при старте. - `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс, отменяет предыдущий запрос (`AbortController`). - Персонализация: `suggest` берёт из Meilisearch втрое больше кандидатов и переранжирует их в `api` (`internal/search/personal.go`). Вес объекта — прослушивания + 2 × доигрывания − ранние скипы, лайк трека ±10, реакция на исполнителя ±20; альбом и исполнитель суммируют вес своих треков. Знакомое поднимается на 2·ln(1+вес) позиций, нелюбимое опускается на 3 — релевантность остаётся основой порядка. - Синхронизация: `api` после изменения каталога ставит документы в батч-очередь; полная переиндексация — задача `reindex_search` (индекс собирается во временном индексе и подменяется через `swap-indexes`, без даунтайма).