# Библиотека: хранилища, шаблоны путей, сканер, поиск ## Хранилища (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` для такого источника дополнительно проверяет запись: создаёт и удаляет `.pipu-write-test` в корне. - У источника задаётся `write_template` — шаблон пути для новых файлов, с автоподбором по существующей структуре и проверкой обратимости через регэкспы источника (подробно — [uploads.md](uploads.md#7-раскладка-в-хранилище-как-принято-на-сервере)). ## Шаблоны путей (регэкспы) Для каждого источника — упорядоченный список регулярных выражений (синтаксис 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`) — для проверки дублей при загрузке; низкий приоритет | | `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 — чтение тегов). ### Обложки Источники по приоритету: встроенная в файл → `cover.jpg|folder.jpg|front.*` в папке альбома → плейсхолдер, сгенерированный из цвета. Конвертируются в WebP 64/300/1200 px в `/srv/pipu/cache/covers/`, вычисляется палитра из 3 цветов (k-means по пикселям; `cover_colors`) — для ambient-фона, цветов кнопки волны и плейсхолдеров. ## Поиск 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` — транслитерация (кириллица ↔ латиница), плюс поле с **исправлением раскладки** (`ghbdtn` → `привет`): при пустом результате клиентский запрос повторяется в альтернативной раскладке на стороне `api`. - Ранжирование: стандартные правила Meilisearch + `popularity:desc` (глобальные засчитанные прослушивания за 90 дней). - `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс, отменяет предыдущий запрос (`AbortController`). - Персонализация (опционально, этап 2): треки, которые пользователь лайкал / часто слушал, поднимаются выше в выдаче `suggest` (re-rank первых 20 на стороне `api`). - Синхронизация: `api` после изменения каталога ставит документы в батч-очередь; полная переиндексация — задача `reindex_search` (индекс собирается во временном индексе и подменяется через `swap-indexes`, без даунтайма).