Files
lampac/mods/Music/README.md
T

360 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Music
Музыкальный модуль для [Lampac] — превращает Lampa в полноценный музыкальный сервис: главный экран с полками и чартами, поиск, экраны артистов и альбомов, пользовательские плейлисты, импорт из Spotify / Apple Music / SoundCloud, синхронизированные тексты песен и отдельный музыкальный плеер для iOS, Android и Windows.
Модуль серверный (C# / ASP.NET, динамическая компиляция при старте Lampac) с единым клиентом для Lampa ([plugin.js](./plugin.js)), который отдаётся как `/music.js`.
## Возможности
- **Главный экран** — недавно прослушанное, недавние альбомы/артисты/поиски, пользовательские плейлисты и discovery-полки (Apple Music, Spotify, VK Top, SoundCloud).
- **Поиск** — артисты, альбомы, треки; каноническая база метаданных MusicBrainz, дополнительные секции из YouTube Music.
- **Воспроизведение** — автоматический подбор playable-источника (YouTube, Sefon, SoundCloud, опционально Z3Fm) с точным поиском по ISRC, когда каталог его предоставляет, и fallback по названию/артисту; выбранный вручную источник закрепляется (`pinned`) и переживает перезапуски.
- **Пользовательские плейлисты** — создание, наполнение из любого экрана, перестановка треков, хранение на сервере с привязкой к профилю браузера.
- **Импорт плейлистов и альбомов по ссылке** — Spotify, Apple Music, SoundCloud; без API-ключей и без логина. Повторная синхронизация — слияние: локальный порядок и ручные правки сохраняются, новые треки источника добавляются в начало.
- **Управление очередью отовсюду** — лонг-тап по треку в любом списке: «Играть следующим», «Добавить в очередь», «Убрать из очереди»; перестановка и удаление также из шита очереди плеера. Очередь переживает перезапуск клиента (снапшот с восстановлением позиции).
- **Радио** — «Радио от трека» из меню любого трека (волна от выбранной песни) и автоподборка в конец очереди; движок похожести — рекомендации SoundCloud (related/station) со слоистым пулом и жёстким дедупом.
- **Статистика прослушиваний** — «Твой топ»: играбельный альбом из самых прослушиваемых треков, появляется после накопления данных; счётчики по дням в SQLite.
- **Выбор плеера для музыки** — отдельная настройка в «Настройки → Плеер», независимая от глобального плеера Lampa (встроенный, Music Player на Android/Windows, iOS, VLC и другие внешние).
- **Тексты песен** — синхронизированная (караоке) и обычная лирика через lrclib с фоллбеком на YouTube Music; ручная подгонка тайминга на телефоне и с пульта телевизора.
- **iOS lock screen** — в standalone-режиме плеера: play/pause, next/prev, перемотка скраббером, корректные метаданные и обложка, автопереход треков, устойчивое возобновление на заблокированном экране.
- **Стриминг через сервер** — клиент никогда не ходит на внешние CDN напрямую: единый relay `/music/stream` с поддержкой Range-запросов, тикетами и автоматическим повторным резолвом протухших ссылок.
## Архитектура
Модуль не пытается сделать «универсальный парсер любого сайта». Каждый внешний источник подключается в одной или нескольких строго разделённых ролях:
| Роль | Ответственность | Текущие источники |
| --- | --- | --- |
| **Metadata** | канонические артисты, альбомы, треки | MusicBrainz |
| **Discovery** | полки, чарты, подборки, секции поиска | Apple Music, Spotify, VK Top, SoundCloud, YouTube Music |
| **Audio** | playable match и stream sources | YouTube (основной), Sefon, SoundCloud, Z3Fm (опц.) |
| **Auth** | внешние пользовательские аккаунты | SoundCloud (каркас, выключен по умолчанию) |
| **Images** | обогащение картинок | Discogs (фото артистов) |
| **Import** | импорт плейлистов по ссылке | Spotify, Apple Music, SoundCloud |
Ключевые принципы:
- **Metadata-база одна** — MusicBrainz. Discovery- и audio-источники не становятся источником правды о каталоге.
- **Home всегда быстрый** — discovery-провайдеры опрашиваются параллельно с бюджетом времени; медленный или упавший источник не блокирует ответ, его секция догревается в фоне.
- **Durable отделён от volatile** — SQLite хранит только результат действий пользователя (история, плейлисты, pinned-источники, учётные данные); все кэши живут в `HybridFileCache` и памяти.
```mermaid
flowchart TD
Client["plugin.js / Lampa UI"] --> Api["Controllers"]
Api --> Catalog["MusicCatalogService"]
Api --> Playback["MusicPlaybackService / MusicResolver"]
Api --> Playlists["MusicUserPlaylistService"]
Api --> Lyrics["MusicLyricsService"]
Catalog --> Metadata["Metadata providers"]
Catalog --> Discovery["Discovery providers"]
Playback --> Audio["Audio providers"]
Playback --> Relay["/music/stream relay"]
Playlists --> DB["Music.sql (SQLite)"]
Lyrics --> Cache["HybridFileCache"]
Metadata --> Cache
Audio --> Cache
```
### Структура каталога
```
Controllers/ HTTP API: ApiController, SearchController, PlaybackController, AuthController
Models/ Contracts.cs — DTO контрактов API
Providers/
Abstractions/ интерфейсы провайдеров
Metadata/ MusicBrainz
Discovery/ AppleMusic, Spotify, VkTopChart, SoundCloud, YouTubeMusic
Audio/ YouTube, Sefon, Z3Fm
SoundCloud/ общий site-support (partial: Catalog / Audio / Import / Auth)
Spotify/ импорт публичных плейлистов/альбомов (анонимный embed-токен)
AppleMusic/ импорт публичных плейлистов/альбомов (анонимный web-JWT)
Services/
Catalog/ оркестрация home / search / секций, реестр провайдеров
Playback/ резолвер источников, stream relay, тикеты, история
Playlists/ пользовательские и импортированные плейлисты
Lyrics/ lrclib + YouTube Music fallback
Images/ image proxy, Discogs
Cache/ metadata cache, source-match cache
SQL/ MusicContext.cs — durable SQLite с self-healing и safe backup
plugin.js весь клиент Lampa, отдаётся как /music.js
ModInit.cs регистрация модуля и значения конфигурации по умолчанию
ModuleConf.cs схема настроек
manifest.json dynamic: true, зависимость YoutubeExplode
```
## Установка
1. Поместите каталог `Music` в `Modules/` вашей установки Lampac. Модуль объявлен как `dynamic` и компилируется автоматически при старте.
2. Перезапустите Lampac. При первом старте создаётся `database/music/` с SQLite-базой.
3. Клиентский плагин доступен по адресу `{host}/music.js` — подключите его в Lampa штатным способом (`initPlugins` в конфигурации LampaWeb либо вручную как плагин).
Проверка, что сервер поднялся:
```bash
curl -fsS 'http://127.0.0.1:9118/music/home'
curl -fsS 'http://127.0.0.1:9118/music/providers'
```
## Конфигурация
Настройки задаются в `init.conf` Lampac под ключом `Music` и подхватываются без перезапуска. Пример:
```jsonc
{
"Music": {
"useproxy": false,
"useproxystream": false,
"applemusic_country": "ru",
"applemusic_album_resolver": "auto",
"spotify_discovery_enabled": true,
"spotify_country": "ua",
"soundcloud_country": "UK",
"z3fm_enabled": true,
"z3fm_audio_enabled": true
}
}
```
Полная схема ([ModuleConf.cs](./ModuleConf.cs)) со значениями по умолчанию ([ModInit.cs](./ModInit.cs)):
| Параметр | По умолчанию | Описание |
| --- | --- | --- |
| `default_metadata_provider` | `musicbrainz` | metadata-база каталога |
| `default_audio_provider` | `youtubeaudio` | основной audio-провайдер |
| `default_auth_provider` | *(пусто)* | auth-провайдер по умолчанию |
| `useproxy` | `false` | прокси для API, поиска и discovery; используется стандартный proxy Lampac |
| `useproxystream` | `false` | прокси для получения manifest и передачи audio-stream |
| `globalnameproxy` | *(пусто)* | имя proxy из глобального `globalproxy` Lampac |
| `proxy` | *(пусто)* | локальная настройка proxy Music; имеет приоритет над глобальной |
| `client_debug_enabled` | `false` | приём клиентских трейсов на `/music/clientlog` (только для отладки) |
| `stats_clear_enabled` | `false` | показывать сервисную кнопку очистки статистики «Твоего топа» в фильтре |
| `youtube_audio_enabled` | `true` | YouTube как audio-источник (через YoutubeExplode) |
| `spotify_search_fallback_enabled` | `false` | дополнительные результаты поиска Spotify |
| `spotify_discovery_enabled` | `true` | региональная полка плейлистов Spotify на главном экране |
| `spotify_country` | `us` | профиль плейлистов Spotify: `UA`, `CA`, `US`, `RU`, `PL`, `FR`, `DE`; для остальных ISO-кодов используется явно подписанный Global-профиль |
| `sefon_audio_enabled` | `true` | Sefon как дополнительный audio-источник |
| `soundcloud_enabled` | `true` | SoundCloud целиком |
| `soundcloud_discovery_enabled` | `true` | полки/подборки SoundCloud |
| `soundcloud_audio_enabled` | `true` | SoundCloud как audio-источник |
| `soundcloud_auth_enabled` | `false` | вход в аккаунт SoundCloud (OAuth) |
| `applemusic_country` | `us` | регион Apple Music Charts (ISO 3166-1 alpha-2) |
| `applemusic_album_resolver` | `auto` | открытие альбомов из Apple-полок: `auto`, `applemusic`, `spotify`, `soundcloud` или `musicbrainz` |
| `soundcloud_client_id` | *(пусто)* | собственный client_id; без него скрапится публичный |
| `soundcloud_client_secret` | *(пусто)* | для OAuth-потока |
| `soundcloud_redirect_uri` | *(пусто)* | для OAuth-потока |
| `soundcloud_country` | `US` | регион SoundCloud Charts: `US` или `UK` (`GB` нормализуется в `UK`) |
| `z3fm_enabled` | `false` | Z3Fm целиком (требует Playwright для антибота) |
| `z3fm_audio_enabled` | `false` | Z3Fm как audio-источник |
| `z3fm_proxy_enabled` / `z3fm_proxy_url` / `z3fm_proxy_username` / `z3fm_proxy_password` | `false` / *(пусто)* | прокси для Z3Fm |
| `limit_map` | `^/music`: 15 req/s | WAF rate-limit для эндпоинтов модуля |
Каждый профиль Spotify содержит одну полку с 15 плейлистами: пять основных региональных, пять дополнительных региональных тематических и пять глобальных тематических. Для `CA`, `US`, `PL`, `FR`, `DE` используются официальные подборки Spotify; профили `UA` и `RU` частично состоят из публичных локальных плейлистов. После смены `spotify_country` нужен перезапуск Lampac; недоступный плейлист пропускается, не блокируя остальные discovery-источники.
`applemusic_album_resolver` задаёт приоритет открытия карточек Apple Music Charts. `auto` использует цепочку Apple ID → Spotify → SoundCloud → MusicBrainz; `applemusic` — Apple ID → MusicBrainz; `spotify` — Spotify → MusicBrainz; `soundcloud` — SoundCloud → MusicBrainz; `musicbrainz` сохраняет старое поведение. В SoundCloud учитываются только результаты из секции альбомов, пользовательские плейлисты исключены. Spotify участвует при включённом `spotify_search_fallback_enabled`, SoundCloud — при включённом `soundcloud_discovery_enabled`; неизвестное значение строго нормализуется в `auto`.
## HTTP API
Все эндпоинты живут под префиксом `/music`. Внешние картинки в ответах проксируются через image-proxy, stream-ссылки — через relay: клиенту не нужны прямые запросы к внешним сервисам.
### Клиент и служебные
| Метод | Эндпоинт | Назначение |
| --- | --- | --- |
| GET | `/music.js`, `/music/js/{token}` | клиентский плагин |
| GET | `/music/providers` | список активных провайдеров |
| POST | `/music/clientlog` | приём клиентских трейсов (при `client_debug_enabled`) |
### Каталог и поиск
| Метод | Эндпоинт | Назначение |
| --- | --- | --- |
| GET | `/music`, `/music/home` | главный экран |
| GET | `/music/section?id=` | полный список секции («Ещё» / пагинация) |
| GET | `/music/search?q=` | поиск |
| GET | `/music/artist?id=` | экран артиста |
| GET | `/music/artistsection` | догрузка секций артиста |
| GET | `/music/artistimg` | lazy-обогащение фото артиста (Discogs) |
| GET | `/music/album?id=` | экран альбома |
| GET | `/music/track` | данные трека |
### Воспроизведение
| Метод | Эндпоинт | Назначение |
| --- | --- | --- |
| GET | `/music/play` | playable-ответ для трека (источники + stream URL) |
| GET | `/music/stream` | relay стрима (Range, тикеты, авто re-resolve) |
| GET | `/music/matches` | кандидаты audio-источников для трека |
| POST | `/music/match/select` | закрепить ручной выбор источника (`pinned`) |
| POST | `/music/match/reset` | сбросить закреплённый выбор |
| GET | `/music/playlist.m3u` | плейлист очереди в формате M3U |
| POST | `/music/history/mark` | обновить историю; `count_play=true` инкрементит счётчик статистики, `played_ms` добавляет фактически прослушанное время |
| POST | `/music/history/remove` | удалить запись истории |
| GET | `/music/stats/top` | топ треков профиля + условия разблокировки «Твоего топа» |
| POST | `/music/stats/clear` | очистить дневную статистику текущего профиля, не трогая историю прослушиваний |
### Плейлисты
| Метод | Эндпоинт | Назначение |
| --- | --- | --- |
| GET | `/music/playlists` | список плейлистов профиля |
| GET | `/music/playlists/tracks` | треки плейлиста |
| POST | `/music/playlists/create` | создать плейлист |
| POST | `/music/playlists/delete` | удалить плейлист |
| POST | `/music/playlists/import` | импорт по ссылке (Spotify / Apple Music / SoundCloud) |
| POST | `/music/playlists/sync` | пересинхронизировать импортированный плейлист |
| POST | `/music/playlists/track/add` | добавить трек |
| POST | `/music/playlists/track/remove` | убрать трек |
| POST | `/music/playlists/track/move` | переставить трек внутри плейлиста (`position` — финальный индекс) |
| POST | `/music/radio` | подобрать похожие треки (автоподборка очереди и «Радио от трека») |
| GET | `/music/daily` | микс дня недели (`day` 1–7, опц. `salt` — счётчик сброса): треки двух артистов из истории + похожего |
### Прочее
| Метод | Эндпоинт | Назначение |
| --- | --- | --- |
| GET | `/music/lyrics` | текст песни (synced/plain) |
| GET | `/music/auth/state` | состояние внешней авторизации |
| POST | `/music/auth/save` | сохранить учётные данные |
| POST | `/music/auth/logout` | выйти |
## Как это работает
### Резолв и воспроизведение трека
1. Клиент вызывает `/music/play` с идентификатором трека и fallback-параметрами (артист, название, длительность).
2. [MusicPlaybackService](./Services/Playback/MusicPlaybackService.cs) собирает track DTO, [MusicResolver](./Services/Playback/MusicResolver.cs) подбирает audio match: сначала закреплённый пользователем `pinned`-источник, затем кэшированный авто-матч, затем поиск по провайдерам с ранжированием.
3. Выбранный провайдер возвращает stream sources; сервер превращает их в тикетные URL `/music/stream?...` — прямые ссылки на CDN клиенту не отдаются.
4. Relay отдаёт поток с поддержкой Range. Если тикет жив, а upstream-ссылка умерла (403/404/410), выполняется прозрачный повторный резолв по fallback-параметрам — трек не прерывается ни при протухании ссылки, ни после перезапуска сервера.
Ручной выбор источника («Источники» в интерфейсе, включая поиск произвольным запросом) сохраняется как `pinned` в SQLite и имеет безусловный приоритет над эвристиками — это спасает треки с «грязными» метаданными из чартов.
### Режимы плеера
У клиента две принципиально разные реальности воспроизведения:
- **`inner`** — встроенный плеер Lampa. На iOS lock screen поддерживает play/pause, next/prev, метаданные и обложку; перемотка скраббером в этом режиме сознательно не поддерживается (подробный разбор: [docs/ios-embedded-lockscreen-seek.md](./docs/ios-embedded-lockscreen-seek.md)).
- **`ios`** — standalone audio-плеер модуля для iPhone: собственный полноэкранный интерфейс (очередь, shuffle/repeat, таймер сна, лирика, свайпы), полный Media Session (включая скраббер перемотки), keep-alive аудиосессии и восстановление воспроизведения на заблокированном экране.
Эти режимы не следует смешивать — у них разная логика Media Session и жизненного цикла audio-элемента.
### Плейлисты и импорт
Плейлисты хранятся на сервере (SQLite, таблица `user_playlists`) с привязкой к профилю браузера — той же identity, что и история прослушиваний. Payload плейлиста содержит полные объекты треков, поэтому плейлист самодостаточен и не зависит от доступности внешних каталогов.
Импорт по ссылке работает без ключей и логина:
- **Spotify** — анонимный токен со страницы embed-плеера + внутренний GraphQL API с пагинацией; импорт атомарный (сбой любой страницы отменяет импорт целиком).
- **Apple Music** — анонимный developer-JWT из веб-бандла music.apple.com + публичный catalog API; поддержаны плейлисты (включая пользовательские `pl.u-…`) и альбомы, все storefront'ы.
- **SoundCloud** — публичный client_id (скрапится автоматически либо задаётся в конфиге).
Импортированный плейлист хранит `source` и может быть пересинхронизирован. Синхронизация зеркалит источник: ручные изменения в таком плейлисте будут перезаписаны.
### Тексты песен
`/music/lyrics` возвращает синхронизированный (LRC, построчный тайминг) или обычный текст. Основной источник — [lrclib.net](https://lrclib.net); при отсутствии synced-варианта пробуется fallback через YouTube Music. Кэширование дифференцированное: synced-результаты живут долго, plain-only и «не найдено» — коротко, сетевые сбои не кэшируются вовсе и помечаются `retry` для клиента.
Для синхронизированного текста клиент позволяет подогнать тайминг в диапазоне от −10 до +10 секунд с шагом 0,5 секунды. `+` показывает текст позже, `−` — раньше; нажатие на значение сбрасывает сдвиг в ноль. В standalone-плеере управление находится в окне «Текст», во встроенном плеере — над лирикой и отдельными кнопками `−` / `+` на панели для управления пультом. Сдвиг действует только на текущий трек, сбрасывается при его смене и не показывается для обычного текста без таймингов.
### Радио
У радио два входа:
- **«Радио от трека»** — пункт в лонг-тап меню любого трека: выбранный трек стартует первым, дальше — волна из ~20 подобранных. При неудаче подбора текущая очередь не меняется.
- **Автоподборка** — opt-in тумблер («Автоподборка треков» в фильтре и чип «Подборка» в фулл-плеере — одна настройка, выключена по умолчанию): когда очередь подходит к концу, в неё догружаются похожие треки. В шите очереди граница отмечается разделителем «Дальше автоподборка».
Движок похожести — слоистый пул на сервере (`MusicRadioService`):
1. **Рекомендации SoundCloud по треку** — `related` и `track-station` (их коллаборативная фильтрация), основной слой;
2. **Топ-треки сид-артистов** (YouTube Music / SoundCloud поиск) — только backfill при недоборе.
Пулы чередуются round-robin, чтобы выдача не вырождалась в одного исполнителя.
Контракт:
- работает только для управляемой очереди модуля (`ios` / `inner`), не для внешних m3u-плееров;
- `POST /music/radio` принимает seed-треки и явный `exclude` текущей очереди; сиды всегда исключаются из выдачи;
- дедуп: `track.id`, нормализованные `artist + title`, история прослушиваний за сутки;
- шумофильтр: миксы/подкасты/реакции, треки короче 45с и длиннее 12мин; cover/karaoke/sped up/slowed/nightcore/remix — только если этих слов не было в сиде; при полностью кириллическом сид-контексте латинский мусор отсекается;
- триггер автоподборки — предпоследний трек по порядку воспроизведения (с учётом shuffle), не чаще одного запроса на поколение очереди;
- `repeat=all` отключает автоподборку: зацикленная очередь — осознанный выбор;
- если кандидатов мало, сервер честно отвечает `available:false` и клиент ничего не добавляет.
Главный риск фичи — не код, а качество рекомендаций; настройка ведётся по живым промахам, не «по формуле». SponsorBlock для YouTube-треков сознательно не в roadmap (нет реальной боли, внешний skip-сервис рискует ложными пропусками); YouTube Mix как движок рекомендаций проверен и отвергнут — анонимный доступ закрыт YouTube («Войдите в аккаунт»), логин-пути модуль не использует.
### Статистика: «Твой топ»
Статистика подаётся не экраном с цифрами, а **играбельным альбомом**: карточка «Твой топ» появляется на главном экране рядом с плейлистами только после накопления данных (10 разных треков и либо фаворит с 10 прослушиваниями, либо 100 прослушиваний суммарно). Внутри — топ треков с настоящими «Слушать»/«Перемешать», числом прослушиваний у каждой строки и сухой метой в духе «247 прослушиваний · примерно 18 ч · артисты заработали ≈ $0.99 · почти кофе».
Данные: таблица `track_stats_daily` (профиль, трек, день, счётчики, `last_played`) — только счётчики, витрина треков джойнится из `playback_history`. `play_count` инкрементится исключительно на честном play-пути (`count_play=true` после реальной задержки воспроизведения), а `total_ms` пополняется фактическими кусками `played_ms` без повторного увеличения счётчика; обновления payload статистику не трогают. Дневная гранулярность оставляет пространство для будущих окон («за месяц», «На повторе», «Вернуть в ротацию»).
### Хранение данных
**Durable (SQLite, `database/music/Music.sql`)** — только то, что выражает действие пользователя или долгоживущую identity: история прослушиваний, дневная статистика (`track_stats_daily`), пользовательские плейлисты, pinned-выборы источников, учётные данные внешних сервисов. База защищена self-healing (карантин повреждённого файла вместе с WAL/SHM-сайдкарами, восстановление из последнего валидного бэкапа) и безопасным бэкапом через SQLite backup API.
**Volatile (`HybridFileCache` + память)** — кэши метаданных, авто-матчей, лирики, картинок, stream-тикеты. Волатильный кэш никогда не попадает в SQLite.
## Расширение: новый источник
Источник подключается не «сайтом целиком», а конкретной ролью. Рекомендуемый порядок внедрения незнакомого сервиса: **Discovery → Audio → Auth** — сначала источник безопасно появляется в интерфейсе, затем добавляется воспроизведение.
Общий порядок:
1. Определить, что источник реально даёт (полки? стримы? аккаунт?).
2. Реализовать провайдер под соответствующий интерфейс из [Providers/Abstractions](./Providers/Abstractions).
3. Добавить флаги в [ModuleConf.cs](./ModuleConf.cs) и значения по умолчанию в [ModInit.cs](./ModInit.cs) (для audio-источников default — `false`, пока не проверено качество матчей).
4. Зарегистрировать провайдер в [MusicProviderRegistry.cs](./Services/Catalog/MusicProviderRegistry.cs).
5. Проверить через API, не трогая остальную архитектуру.
### Контракты по ролям
**Discovery** (`IMusicDiscoveryProvider`): `GetHomeSectionsAsync` — короткие секции для home, `GetSectionAsync` — полный список для «Ещё»/пагинации.
**Audio** (`IMusicAudioProvider`): `SearchAsync` — кандидаты `MusicAudioMatch` по `MusicTrack`; `GetStreamsAsync` — playable `MusicPlaybackSource`; `GetPreferredStreamAsync` — быстрый exact-id путь, если он есть, иначе `null`.
Проверка:
```bash
curl -fsS 'http://127.0.0.1:9118/music/home'
curl -fsS 'http://127.0.0.1:9118/music/section?id=examplemusic:top'
curl -fsS 'http://127.0.0.1:9118/music/matches?title=Song&artist_name=Artist&audio_provider=examplemusicaudio'
```
### Обязательные требования к провайдерам
- Не делать новый источник metadata-базой без отдельного архитектурного решения.
- Не блокировать `/music/home` долгими запросами — уважать бюджет времени.
- Не кэшировать сетевой сбой как «пусто навсегда»; пустые результаты кэшировать только коротко.
- Stream отдавать через `/music/stream`, а не напрямую клиенту.
- Уважать `pinned`-выбор пользователя.
- Внешние картинки возвращать в обычных полях ответа, чтобы их подхватил `MusicImageProxyService`.
## Разработка
После изменения серверного кода — пересборка (модуль компилируется при старте контейнера):
```bash
docker compose up -d --build lampac
```
После изменения только `plugin.js` пересборка не нужна (файл монтируется), но клиент Lampa кэширует плагин — нужно поднять revision в конфигурации подключения плагина и полностью перезапустить клиент.
Smoke-проверка:
```bash
curl -fsS 'http://127.0.0.1:9118/music/home'
curl -fsS 'http://127.0.0.1:9118/music/search?q=nirvana'
curl -fsS 'http://127.0.0.1:9118/music/providers'
```
Клиентская отладка: включить `client_debug_enabled`, трейсы клиента будут приходить на `/music/clientlog` и попадать в логи сервера. После отладки — выключить.
## Известные ограничения
- Discovery/импорт Spotify, импорт Apple Music и авто-получение SoundCloud client_id опираются на недокументированные публичные механизмы этих сервисов; при изменениях на их стороне интеграция может временно ломаться до обновления модуля (симптом для Spotify — `PersistedQueryNotFound` в логах).
- Перемотка скраббером на iOS lock screen поддерживается только в standalone-режиме плеера (`ios`), не во встроенном (`inner`).
- Синхронизация импортированного плейлиста — недеструктивное слияние: порядок и ручные правки сохраняются, новинки добавляются в начало; но треки, удалённые в источнике, локально не удаляются (убрать можно только вручную).
- Управление очередью (играть следующим, убрать, радио-догрузка) работает только для управляемой очереди модуля (`ios` / `inner`); внешние плееры получают разовый плейлист без возможности менять его после запуска.
- Статистика накапливается с момента включения фичи: прослушивания до неё известны только как «последний раз слушал», без счётчиков.
- Лайки/библиотека внешних сервисов переносятся через публичный плейлист (скопировать Liked Songs в публичный плейлист и импортировать по ссылке); прямой доступ к библиотеке требует логина и не реализован.