Compare commits

71 Commits

Author SHA1 Message Date
nk
e1bceb8bd1 feat(privacy): страница политики конфиденциальности на /privacy 2026-06-08 13:55:44 +03:00
nk
0dd52ddc3b ops: вернуть в compose конфиг авто-обновления (bind-mount appdist + env)
Регрессия: правки авто-обновления (mount /opt/radiola/appdist:/data/dist +
APP_VERSION_FILE/DOWNLOADS_DIR) жили только на сервере, в репо их не было. scp
docker-compose.yml при деплое Shazam затёр их → /app-version отдавал фолбэк.
Теперь они в репозитории — деплой их не теряет.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:27:23 +03:00
nk
791156f814 feat(shazam): глобальный лимит распознаваний (защита баланса коинов)
Скользящее окно 60с, максимум 30 реальных вызовов Shazam/мин (кэш-хиты не в счёт).
Превышение → 429. Защищает платный баланс от перебора станций.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:47:25 +03:00
nk
059ebc9c45 feat(shazam): реальный двухстадийный флоу shazam-api.com (recognize → poll)
- ShazamClient: POST /api/recognize (multipart file) → uuid, затем поллинг
  POST /api/results/{uuid} до status="completed" (12×1.2с ≈ до 15с)
- из ответа берём track.title (песня) и track.subtitle (исполнитель); обложки
  в API нет — подтягиваем из нашей БД по normKey (resolveCover в сервисе)
- авторизация Authorization: Bearer; база https://shazam-api.com/api по умолч.
- SHAZAM_API_KEY проброшен в docker-compose + .env.example (значение — на сервере)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:45:00 +03:00
nk
1616c231b7 feat(shazam): распознавание трека через Shazam API для станций без метаданных
- новый модуль shazam: POST /shazam/recognize/:stationId — тянет ~6с аудио из
  потока станции, отдаёт в изолированный ShazamClient, возвращает artist/song/cover
- ShazamClient — адаптер к shazam-api.com, ключ из env (SHAZAM_API_KEY); точный
  контракт запроса/ответа помечен TODO до получения доки из ЛК
- кэш результата по станции (15с) — троттлинг + экономия платных вызовов
- общий реестр не-музыкальных жанров (common/station-classification.ts);
  charts.service переведён на него, shazam использует для гейта «есть ли музыка»

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:37:53 +03:00
nk
05e3796b85 feat(app-version): эндпоинт /app-version + хостинг APK для авто-обновления
GET /app-version читает манифест с диска (data/app-version.json, путь — env
APP_VERSION_FILE) → {android:{version_name,version_code,download_url,force_update,
sha256,notes}}. Релиз = заменить APK в /downloads + отредактировать json, без
пересборки. При сбое файла отдаёт version_code:0 (апдейт не навязываем).
Статика /downloads/ (DOWNLOADS_DIR) — раздаёт APK.
2026-06-06 20:21:54 +03:00
nk
4aa3b55b5e perf(backend): ретенция track_plays, прун сирот-треков, проекция now-playing, пул БД
- MaintenanceService (@Cron daily 4:00): ретенция track_plays >180д чанками по 20k
  (без ретенции таблица растёт ~100k строк/сутки) + прун осиротевших треков
  (без проигрываний/лайков/обложки, >30д). Сейчас удаляет 0 (данным 4 дня) —
  только ограничивает будущий рост. ВНИМАНИЕ: 180д ограничивает и чарт period=all.
- getAllNowPlaying: select-проекция (stationId+name) вместо include station:true —
  не тянем всю строку Station (streamUrl, tags[], даты) на каждый ряд now_playing.
- PrismaService: connection_limit=20 в URL идемпотентно (дефолт ~5 мал под ~16
  конкурентных поллеров).
2026-06-06 17:08:36 +03:00
nk
944ec63df0 refactor(now-playing): единый IcyReader + реестр dedicated-источников
Убраны 3 копии state-machine разбора icy-metaint (icy/novoeby/love) → один
readIcyStreamTitle в icy-reader.ts (с опцией decode auto-1251 для cp1251-потоков
и корректным терминатором ';' — апострофы в названиях больше не обрезаются).

Ручной genre-notIn список в IcyNowPlayingService заменён центральным реестром
dedicated-sources.ts (host + genre), согласованным с селекторами самих сервисов:
добавил выделенный сервис — впиши host/genre в одно место, ICY его пропустит
автоматически. Исключение по хосту провабельно совпадает с тем, что сервис
реально обрабатывает (раньше genre легко забывали добавить).
2026-06-06 16:54:02 +03:00
nk
a3434ed894 perf(backend): индексы, кэш чартов, пропуск upsert, фикс N+1 обогащения
- track_plays(played_at,track_id,station_id) покрывающий + tracks(first_seen_at) WHERE
  pending частичный (применены CONCURRENTLY на проде + миграция idempotent)
- ChartsService.getTopTracks: in-memory TTL-кэш 90с по (period,genre,limit) → детальная
  страница и параллельные запросы не пересчитывают тяжёлые агрегации
- NowPlayingService.ingest: не пишем now_playing и не шлём сокет, если трек не изменился
  (было ~20k бесполезных upsert/час)
- enrichNowPlaying: вместо N+1 (300 upsert/мин) — один batched findMany по normKey

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:20:25 +03:00
nk
924a4a0ab1 fix(charts): отсев заглушек Online-Radio и джинглов FX-NN 2026-06-06 15:52:41 +03:00
nk
0084177d15 fix(charts): отсев мусора и разговорных/шуточных станций из чарта
recordPlay теперь не считает: разговорные/шуточные жанры (Кассиопея, Юмор ФМ,
Рассказы, Радио Вера, Comedy Radio, ВГТРК, Старое радио) и мусорные названия
(хекс-плейсхолдеры с цифрой, URL, числовые коды, artist==song). Исторический мусор
почищен напрямую в БД (−4273 трека, −13274 плея талк/комеди-станций).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:49:17 +03:00
nk
38e380a59f fix(now-playing): Radio 7 главные каналы — slug не съедает '7'
Баг: strip /\d+$/ у маунта radio7128 резал '7128' → 'radio' (пусто в meta).
Теперь slug = radio7(_слово)? без битрейта: radio7128→radio7, radio7_love64→radio7-love.
2026-06-06 09:50:18 +03:00
nk
cc30422d8d feat(now-playing): 101.ru (Comedy Radio + Радио Energy) и Radio 7 через EMG
101.ru (Comedy, NRJ/Energy, ~15 каналов): id канала = последний сегмент потока
pub*.101.ru/.../{id}; трек GET 101.ru/api/channel/getTrackOnAir/{id}/?idcity=1 →
result.short {titleExecutorFull, titleTrack, cover.coverOriginal}; обложка cdn0.101.ru.
Radio 7 — это ЕМГ на старых мейнах radio7.hostingradio.ru: расширил EmgNowPlayingService
(slug radio7128→radio7, radio7_love64→radio7-love). Три жанра исключены из ICY-поллера.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 09:29:41 +03:00
nk
c4c475544a feat(now-playing): Радио Ваня + Русская Волна; Питер объединён в SpbRadio
Радио Ваня (20 каналов) — тот же движок/API, что Питер ФМ (один разработчик):
объединил в SpbRadioNowPlayingService (NETWORKS=[piterfm, radiovanya]), матч
станции по МАУНТУ из поля link (у Вани slug≠маунт). Обложки iTunes.
Русская Волна (~27, amgradio.ru, ICY нет) — VolnaNowPlayingService: единый
info.volna.top/radio.json, поля {prefix}_title, маунт→префикс (RusRock128→rusrock,
ChillaFM128→chilla). Обложки через обогащение. Оба жанра исключены из ICY-поллера.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 09:18:23 +03:00
nk
3c4f349f71 fix(now-playing): Орфей — чиним кодировку (двойная мойибейк cp1251) + режем хекс-хвосты
status-json смешанной кодировки: часть тайтлов нормальный UTF-8, часть — cp1251-
байты, прочитанные как latin1 и завёрнутые в UTF-8 (мойибейк «Íèêîëà»→«Никола»).
fixEncoding: реальную кириллицу не трогаем, мойибейк (À-ÿ) восстанавливаем
latin1→windows-1251. Срезаем приклеенный служебный id трека (- f0098627), фильтр
hex/числовых плейсхолдеров усилен. Каналы без реального произведения — без подписи.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 09:01:03 +03:00
nk
c87a0caa5c feat(now-playing): Питер ФМ и Орфей
Питер ФМ (16 каналов, cdnvideo) — ICY пуст; берём трек+обложку из их API
radiopiterfm.ru: /api/v1/streams/ (slug↔id) + /api/v5/playlists/{id}/ →
items[0].track {name, artist.name, imglarge}. Обложки готовые (iTunes).
Орфей (классика, radio.orpheus.ru) — через Icecast status-json.xsl по маунтам
(Chan_N), парсим «Композитор — Произведение», отсекаем мусор (hex/URL/undefined);
обложка через обогащение. Оба жанра исключены из общего ICY-поллера.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 08:34:32 +03:00
nk
426fd0e197 feat(now-playing): Новое Радио BY — now-playing с правильной кодировкой + Wake Up
5 мейнов live.novoeradio.by отдают ICY, но кириллица в windows-1251 (общий
поллер читал UTF-8 → каша). NovoeByNowPlayingService (@Interval 30с): свой ICY-
ридер с декодом UTF-8→fallback 1251, джинглы без трека пропускаем, обложка через
обогащение. Исключён из общего ICY-поллера. Маунт Wake Up исправлен
(wakeupshow→wakeup, был 404/offline) в seed и прод-БД.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:20:06 +03:00
nk
d5f30cd05d fix(now-playing): ГУСЬ — не использовать битый art AzuraCast, обложки через обогащение
song.art (/api/station/{slug}/art/{hash}) на radiogoose.ru отдаёт 404 — route
обложек не включён. Передаём coverUrl=null → обложку подтянет iTunes/Deezer по
normKey, как у ICY-станций.
2026-06-05 20:36:45 +03:00
nk
d8b6a6024f fix(now-playing): ГУСЬ Технорейв — алиас slug harddance→technorave
У этого канала mount потока (harddance) не совпадает с ключом AzuraCast API
(technorave). Остальные 15 — совпадают.
2026-06-05 20:27:01 +03:00
nk
cb0e401854 feat(now-playing): ГУСЬ (radiogoose) — now-playing + обложки, починка потоков
Сеть ГУСЬ (16 каналов) на AzuraCast (radiogoose.ru). Потоки в каталоге были
многострочными (url1\nurl2) → клиент рвал воспроизведение, health-check метил
offline, станции скрывались. Почищены на канонический https://radiogoose.ru/listen/{slug}/play.
GooseNowPlayingService (@Interval 30с): AzuraCast API /api/nowplaying/{slug} →
now_playing.song {artist,title,art}, ingest + isOnline=true. Исключён из ICY-поллера.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:22:28 +03:00
nk
fd26e4df57 chore(stations): удалены мёртвые каналы Зайцева New year/Hvilya
Потоки zaycevfm.cdnvideo.ru мертвы (000), станции оффлайн. Удалены из seed
и из прод-БД (station_id 399, 402).
2026-06-05 20:07:20 +03:00
nk
a06a9b2a2b feat(now-playing): now-playing + обложки для Зайцев ФМ
19 каналов Зайцев ФМ (MP3 abs.zaycev.fm) не отдают ICY → трека не было.
ZaycevNowPlayingService (@Interval 30с): тянет текущий трек из API сайта
GET https://www.zaycev.fm/api/v1/recent?channel={slug}&limit=1 (slug = буквенная
часть имени потока, pop256k→pop), берёт artist/title и готовую обложку
(radio2.zaycev.fm/artistimages), фильтр is_music. Зайцев исключён из ICY-поллера.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:52:37 +03:00
nk
4d9fd24074 feat(now-playing): now-playing + обложки для каналов Unistar
8 HLS-каналов Unistar (Беларусь) не отдают ICY, поэтому трек брали неоткуда.
Добавлен UnistarNowPlayingService (@Interval 30с): тянет текущий трек из их API
https://api3.unistar.by/client/latest/{slug} (slug = сегмент /hls/{slug}/ потока),
берёт Artist/Title и имя файла обложки (unistar.by/upload/music/photos/), ingest'ит.
Только Type=Music. Unistar исключён из ICY-поллера.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:37:17 +03:00
nk
1f67e01ac8 feat(covers): POST /covers/submit — приём обложки, найденной клиентом
Клиент (со своего IP, не забанен Apple) ищет арт в iTunes и шлёт ссылку.
Сервер качает (CDN из РФ доступен), конвертит в WebP (CoverStorageService),
кладёт к себе, апсертит Track по normKey. Защита: host-whitelist
(mzstatic/dzcdn, против SSRF), идемпотентность, кап одновременных загрузок.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:59:36 +03:00
nk
52c8c3f69f fix(enrich): быстрый now-playing проход — только Deezer (без затыка iTunes)
iTunes-фолбэк в coverFast с троттлингом 3.5с сериализовал проход — он не
успевал за сменой треков, покрытие падало. Теперь быстрый проход = только
Deezer (параллельно, без троттла). Промахи Deezer добирает фоновый enrichOne
(iTunes через прокси с троттлингом).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:40:06 +03:00
nk
28487a7911 fix(enrich): iTunes/Deezer через DE-прокси + троттлинг (RU-IP забанен)
Корневая причина пропавших обложек: RU-IP сервера ЗАБАНЕН Apple (iTunes search
→ 429 «Rate limit ... 121.127.37.212»), а Deezer из РФ отдаёт ПУСТОЙ каталог.
Оба источника с сервера не работали. Теперь iTunes/Deezer-поиск ходит через тот
же DE-прокси, что и Discogs (DISCOGS_PROXY): с DE-IP iTunes доступен, Deezer
отдаёт каталог. Deezer сделан первичным (высокий лимит), iTunes — фолбэк с
сериализацией (3.5с интервал), чтобы не забанить общий DE-IP. Скачивание самих
картинок (mzstatic/dzcdn) — напрямую, они из РФ доступны.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:30:06 +03:00
nk
59aa23ff77 fix(enrich): coverFast — очищенный iTunes + Deezer (не множить лимит)
Прошлый вариант делал 2 запроса iTunes на каждый now-playing-трек без обложки
(170+ играющих) → упирался в лимит iTunes ~20/мин, обложки не наливались.
coverFast теперь: 1 запрос iTunes по ОЧИЩЕННОМУ названию (чаще матчит ремиксы/
«(Original Mix)») → на промахе/429 фолбэк Deezer (отдельный лимит). Полный
fetchItunes (full→clean→deezer) остаётся для фонового enrichOne.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:06:43 +03:00
nk
ba9b4054e8 feat(enrich): больше обложек — очистка запроса iTunes + фолбэк Deezer
Суффиксы названий («(Original Mix)», «(SEA)», «[... Dub]», «feat. X»)
ломали точный матч iTunes (limit=1) — у многих треков (особенно электроника/
лаунж/ремиксы Royal Radio и др.) обложка не находилась, хотя в iTunes/Deezer
она есть. Теперь fetchItunes: (1) запрос как есть → (2) очищенный (без скобок/
feat) → (3) фолбэк Deezer (публичный API, без ключа) только за обложкой.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:50:20 +03:00
nk
35f9a2b7cc chore(stations): Royal Radio — https вместо http (301-редирект)
10 каналов royalradio.space переведены на https (http отдавал 301→https,
ExoPlayer не шёл по кросс-протокольному редиректу). Прод-БД обновлена точечно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:31:13 +03:00
nk
c2e941f1c3 chore(stations): Romantika (711) — рабочий HLS вместо мёртвого srv21
Главный Радио Романтика был в offline-ids (мёртвый srv21.gpmradio). Обновлён
на hls-01-gpm.hostingradio.ru/romantika495. Прод-БД поправлена точечно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:18:25 +03:00
nk
326bbbc0ee chore(stations): Like FM (718) — рабочий HLS, группа Like FM
Старый поток srv21.gpmradio мёртв (станция была в offline-ids). Обновлён на
hls-01-gpm.hostingradio.ru/likefm495 (главный Like FM), groupId 27. Прод-БД
обновлена точечно (без полного reseed, чтобы не сбросить жанры now-playing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:05:09 +03:00
nk
87cc67072c chore(scripts): пробер битрейт-вариантов потоков станций
Перебирает соседние битрейты в маунте каждого потока, проверяет живость
(заголовки + content-type), пишет массив qualities в stations.json клиента.
Пропускает HLS (emgsound) и Love (UID). Использован для фичи переключения
качества в плеере.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:37:24 +03:00
nk
3c6dbed659 feat(now-playing): Radio ROKS через TavR Media API (трек + обложки)
Главный канал ROKS не отдаёт трек по ICY (StreamTitle пустой), сабканалы —
без обложек. Новый RoksNowPlayingService опрашивает o.tavr.media/roks
(главный) и /roks4songs (сабканалы по type ukr/bal/new/har), отдаёт и трек,
и обложку static.radioroks.ua. Исключил genre='Radio ROKS' из ICY-поллера.
2026-06-04 11:44:30 +03:00
nk
51576f7198 feat(now-playing): Радио Монте-Карло через Крутой Медиа API
Все 21 канал Монте-Карло — сеть Крутой Медиа (dfm.ru/api/n/current).
Добавил genre='Radio Monte Carlo' в DfmNowPlayingService, матчинг по
слагу из маута потока (basename без битрейта: blues96.aacp -> blues),
исключил из ICY-поллера. Чинит 5 каналов, залипших на 'Дух — Тишина'
(Blues, Chill Lounge, Italiano, Meditation, Summertime).
2026-06-04 10:55:09 +03:00
nk
d6b8be124e perf(enrich): 3-й IP Discogs (token3 через форс-IPv4 RU) + concurrency 12
token3 ходит напрямую с RU, но форсирован IPv4 (121.127.37.212) — для Discogs
это 3-й IP (token1=RU-v6, token2=DE-v4, token3=RU-v4). ~162/мин потолок. Без
доп. инфры. concurrency 12 чтобы задействовать 3 IP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:21:37 +03:00
nk
7457498f5b perf(enrich): concurrency 8 — задействовать оба IP Discogs (RU+DE)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:10:39 +03:00
nk
e982fde730 feat(enrich): 2-й токен Discogs через DE-прокси (2 IP → ~108/мин жанров)
Discogs лимитит по IP. token1 идёт напрямую (IP RU), token2 — через форвард-прокси
на DE (IP DE, tinyproxy, доступ только с RU). Два IP, у каждого свой слот ~54/мин
→ суммарно ~108/мин жанров без 429. undici ProxyAgent. Без DISCOGS_PROXY — только
token1 (54/мин).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:01:37 +03:00
nk
dfdfb7e4ab fix(enrich): Discogs троттлит по IP — общий лимит ~54/мин (стоп 429-шторм)
Два токена с одного IP не помогают (Discogs лимитит по IP, не по токену) —
вызывало 100% 429. Вернул общий интервал 1100мс (~54/мин). 2-й токен полезен
только на втором IP (DE-воркер).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:52:39 +03:00
nk
94e7f46b39 perf(enrich): ротация двух токенов Discogs (~108/мин жанров)
Discogs лимит ~60/мин на токен. Поддержка нескольких токенов (DISCOGS_TOKEN,
DISCOGS_TOKEN2) — у каждого свой слот, берём наименее загруженный → суммарно
вдвое быстрее жанры/стили/лейблы, без 429. Токены — в env (не в гите).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:45:35 +03:00
nk
ed94bd73d7 perf(enrich): cover-проход эфира 8 параллельно + защита от наложения
~447 живых станций, смена ~120 треков/мин — проход по 4 не успевал. Теперь 8
параллельно + guard против перекрытия крон-запусков.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:02:33 +03:00
nk
36043c32b0 perf(enrich): быстрый cover-only проход эфира через iTunes (без Discogs-гейта)
Discogs-лимитер делал Discogs узким местом (54/мин) для ВСЕХ треков, тормозя
обложки. Теперь крон now-playing красит эфир обложками напрямую через iTunes
(4 параллельно, без Discogs), а полное обогащение жанрами идёт фоном. Обложки
живого набора появляются быстро.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:56:50 +03:00
nk
5164843824 perf(enrich): rate-limiter на Discogs + concurrency 5 (обложки быстрее)
Discogs ограничен ≥1.1с между вызовами (≤54/мин, без 429) независимо от
параллельности. Параллельность 5 → обложки (iTunes, без лимита) и скачивание
льются быстрее. Решает медленное наполнение обложек живого набора.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:51:28 +03:00
nk
588857a73e feat(now-playing): MAXIMUM через тот же Крутой-API (dfm.ru/api/n/current)
MAXIMUM — сеть Крутой Медиа, её каналы в том же /api/n/current. Поллер расширен
на genre IN (DFM, MAXIMUM) + алиасы (maxbritpop, max80, maximum90, rockhits и т.д.).
ICY исключает MAXIMUM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:36:48 +03:00
nk
d46020bd37 chore(stations): Love Radio -> потоки n340 (синхр. с клиентом)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:14:26 +03:00
nk
bd2cd36f1e fix(now-playing): Love Radio — ICY авторизованных n340-потоков (per-channel)
player/online кэширует один трек на все каналы. Берём трек из ICY самих потоков
(каждый поток физически разный), читая их с сессионным UID (бэкенд берёт из config).
Теперь у каждого Love-канала свой трек.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:09:01 +03:00
nk
68c919c8ba fix(now-playing): Love Radio — online?musicStreamId (per-channel), не history
history/list игнорирует musicStreamId (всегда главный эфир) → все каналы показывали
один трек. player/online?musicStreamId отдаёт верный трек канала.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:23:56 +03:00
nk
fa7742d06e feat(now-playing): Love Radio через api.loveradio.ru (ICY шлёт мусор onlinestop56k)
ICY-потоки Love Radio отдают 'onlinestop56k' вместо трека. Берём текущий трек из
их API (player/history/list?musicStreamId=N&limit=1, data[0]). Статичный маппинг
наших станций -> musicStreamId. ICY-поллер исключает genre='Love Radio'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:19:12 +03:00
nk
338f189f33 fix(enrich): не помечать done при сбое запроса iTunes (промах не застывает)
Отличаем сбой запроса (сеть/HTTP-ошибка → ретраить, оставляем pending) от чистого
'не найдено' (done). Раньше транзиентный сбой iTunes под нагрузкой навсегда лишал
трек обложки. fetchItunes теперь бросает при !res.ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:09:54 +03:00
nk
40a9f3968f feat(stations): корректный health-check + эндпоинт offline-ids
health-check переписан: живой = пришли заголовки 200-399 (рвём соединение сразу,
не ждём бесконечное тело аудиопотока), параллельно, прогон при старте + ежечасно.
Раньше GET висел на живых потоках до таймаута → ложный offline. Новый GET /stations/offline-ids
отдаёт station_id оффлайн-станций — клиент их скрывает.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:56:59 +03:00
nk
c2f638e1a1 chore(stations): синхр. — отключены 67 мёртвых станций
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:48:45 +03:00
nk
7ff48fff29 fix(now-playing): EMG-фолбэк slug europaplus-{x} (Fresh застрял)
Slug из хоста потока не всегда = meta-slug: hls-01-fresh → meta это europaplus-fresh.
Если по основному slug пусто — пробуем europaplus-{slug}. Fresh теперь обновляется.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:26:51 +03:00
nk
8f0ec8a5b8 perf(enrich): снизить параллельность 3->2 (уложиться в лимит Discogs 60/мин)
Темп 83/мин давал Discogs 429 (жанры не дотягивались). concurrency=2, throttle 1.2с
→ ~40/мин, под лимитом. Обложки (iTunes) и жанры (Discogs) перестают падать по rate-limit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:17:12 +03:00
nk
db09274060 fix(enrich): нормализовать пунктуацию в поиске iTunes (St.Thomas → St Thomas)
Пунктуация без пробела (St.Thomas, feat.) ломала запрос к iTunes — обложка
существующего трека не находилась. Заменяем не-буквенно-цифровые символы на
пробел перед поиском.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:08:25 +03:00
nk
982c42cdf2 chore(stations): синхр. — отключены мёртвые EP-каналы Acoustic/ResiDance
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:05:15 +03:00
nk
7e6b0c8dc6 feat(now-playing): DFM/Крутой Медиа через dfm.ru/api/n/current
Сабканалы DFM (Skrillex, Daft Punk, K-Pop, Игромания и др.) не отдают ICY-метаданные.
Единый веб-API dfm.ru/api/n/current даёт текущий трек + WebP-обложку по всем ~147
каналам (ключ slug). DfmNowPlayingService матчит наши DFM-станции по нормализованному
имени (+ числовой префикс, + алиасы для годов/Игромании/Pioneer). ICY-поллер
исключает genre=DFM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:24:33 +03:00
nk
3215dd5a4e fix(now-playing): отсекать JSON-статус в ICY StreamTitle (101.ru)
Потоки 101.ru (Comedy Club, StandUp, Женский StandUp и др.) шлют в StreamTitle
JSON {"status":1,...} вместо трека — он попадал в now_playing как название.
ICY-парсер и ingest теперь отсекают значения, начинающиеся с { или [.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:11:26 +03:00
nk
3049b1ec89 chore(stations): актуальные emgsound HLS-потоки для 8 каналов Европы Плюс
Europa Plus/Top 40/New/Party/Urban/Acoustic/ResiDance/Fresh переведены со старых
потоков на emgsound HLS — теперь их ловит EMG-поллер (now-playing + обложки).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:47:27 +03:00
nk
499863744f fix(now-playing): EMG-поллер не фильтрует по isOnline + чинит ошибочный offline
health-check ошибочно метит HLS-потоки emgsound как offline → поллер их пропускал.
Теперь поллим все emgsound-станции и при успешном получении трека ставим isOnline=true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:34:05 +03:00
nk
38b2aee26d feat(now-playing): EMG (Европа Плюс и др.) now-playing через meta.hostingradio
Станции группы ЕМГ (emgsound.ru) получают текущий трек + готовую WebP-обложку
из единого meta.hostingradio.ru/emg/{slug}/history (slug из хоста потока,
order=desc → первый = сейчас). Заводится через NowPlayingService.ingest
(чарты + обогащение). ICY-поллер теперь пропускает emgsound (там HLS без ICY).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:28:06 +03:00
nk
dcc2f599f9 fix(enrich): непрерывный бэкафилл (пополнять очередь когда почти пуста)
Прежнее условие «очередь пуста» почти не срабатывало — now-playing-крон держал
очередь занятой, и холодный бэклог (~21k) не двигался. Теперь раз в минуту
подкидываем 100 pending когда очередь почти пуста; играющие треки идут вперёд
по приоритету.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:05:51 +03:00
nk
5bd7bfb923 feat(enrich): iTunes-фолбэк для жанра/альбома/года (гибрид с Discogs)
iTunes-запрос (уже делается ради обложки) теперь отдаёт и альбом/год/жанр.
Жанр: Discogs (тонкий) → iTunes (грубый фолбэк) — поднимает покрытие жанров
в чартах. Альбом и дата релиза заполняются из iTunes. Стили и лейбл — по-прежнему
только Discogs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:00:02 +03:00
nk
554c1730a3 perf(enrich): параллельная обработка очереди (3 трека) — быстрее покрывать живой набор
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:49:25 +03:00
nk
bb74d631c1 feat(enrich): крон — гарантировать обложку играющим сейчас трекам
Раз в минуту проходим now_playing: создаём Track при отсутствии (без записи
проигрывания) и приоритетно обогащаем тех, у кого нет обложки. Now-playing-обложки
(DFM и др.) появляются быстро у всех станций, не дожидаясь смены трека/бэкафилла.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:39:13 +03:00
nk
916fc301e4 feat(enrich): обложки через iTunes Search + приоритет играющим трекам
Покрытие обложек у Discogs низкое (нет не-электроники, нишевого). Добавлен
iTunes Search API (без ключа, Apple-арт — как у Record) основным источником
обложки: iTunes → Discogs → существующая, далее WebP. Играющие сейчас треки
(recordPlay) ставятся в НАЧАЛО очереди обогащения — обложка успевает появиться,
пока трек звучит. Троттлинг 1.5с.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:33:52 +03:00
nk
96fabac7f5 fix(now-playing): резолвить обложку трека на чтении /now-playing
Обложка ICY-станций (DFM) теперь подтягивается из обогащённого трека по normKey
в момент ответа API, а не записи now_playing — появляется сразу после обогащения,
без ожидания следующего опроса станции (~6 мин).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:20:41 +03:00
nk
f379110975 feat(now-playing): DFM и др. ICY-станции — обложки + чарты + ротация
ICY-станции (DFM и пр.) теперь полноценно «как Record»:
- ICY-поллер вызывает recordPlay → треки идут в чарты и обогащаются Discogs,
  откуда берётся обложка (раньше now_playing писался напрямую, мимо чартов)
- обложка now-playing: если источник не дал (ICY всегда null) — подставляем
  обложку обогащённого трека из нашей БД по normKey (NowPlayingService.resolveCover)
- ротация курсора по всем станциям (окно 70) вместо первых 50 по кругу —
  раньше 363 из 413 станций не опрашивались
- общий NowPlayingService.ingest для Record и ICY (дедуп логики)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:08:07 +03:00
nk
149421740f perf(enrich): ускорить бэкафилл (батч 240 каждые 5 мин, ~50 треков/мин)
В базе ~22к треков — прежние 30/10мин слишком медленно. Батч подобран под
троттлинг очереди (~50 запросов/мин, под лимитом Discogs 60/мин), пропускаем
тик если прошлый батч ещё не дожёван.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:43:19 +03:00
nk
0efba7c691 feat(enrich): обогащение треков через Discogs + самохостинг обложек (WebP)
При первом появлении трека подтягиваем жанр/стиль/лейбл/год из Discogs
и сохраняем обложку в едином формате WebP 500x500 у себя (/covers). Дальше
пользователю отдаём только из своей БД — внешние сервисы в рантайме не дёргаем.

- Track: +genre/styles/label/year/discogsId/enrichStatus (миграция)
- EnrichModule: DiscogsService (поиск), CoverStorageService (sharp->webp),
  EnrichmentService (очередь с троттлингом + бэкафилл-крон каждые 10 мин)
- charts: фильтр чартов по жанру (?genre=), GET /charts/genres,
  жанр/стиль/лейбл/год в выдаче чарта и детальной странице
- main: раздача /covers статикой; docker: volume covers_data + env
  DISCOGS_TOKEN/PUBLIC_BASE_URL/COVERS_DIR
- убран MusicBrainz-фолбэк (заменён Discogs)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:28:22 +03:00
nk
24ed44e8ab feat(now-playing): добавить name станции в ответ (для матча обложек на клиенте) 2026-06-03 12:10:04 +03:00
nk
df20e0fac6 feat(now-playing): REST GET /now-playing (ключ — id станции каталога)
Клиенту нужен now-playing с правильным маппингом id (Record now-эндпоинт
использует id now-слотов, не каталога). Отдаём текущие треки по станциям
с stationId = catalog id, чтобы клиент сопоставлял по station.id.
2026-06-03 10:51:49 +03:00
nk
e0990540b9 fix(charts): отсекать джинглы/шоу/названия станций из сбора чартов
Record API при эфире шоу/джингла возвращает название станции/сети как трек
(пустой artist/song или совпадение с названием станции). Такие записи
накапливали проигрывания и забивали топ. Теперь recordPlay их пропускает
(кэш названий станций).
2026-06-03 10:38:05 +03:00
53 changed files with 15478 additions and 307 deletions

View File

@@ -19,6 +19,17 @@ MAIL_FROM=radiOLA <noreply@example.com>
FRONTEND_URL=https://radiola.app
PORT=3000
# Обогащение треков (Discogs): личный токен из discogs.com → Settings → Developers
DISCOGS_TOKEN=
# Распознавание треков (shazam-api.com): ключ из ЛК (Authorization: Bearer)
SHAZAM_API_KEY=
# База API Shazam (необязательно, по умолчанию https://shazam-api.com/api)
# SHAZAM_API_URL=https://shazam-api.com/api
# Базовый публичный URL бэкенда — для абсолютных ссылок на обложки (/covers/*.webp)
PUBLIC_BASE_URL=http://121.127.37.212:3000
# Каталог для сохранённых обложек (в docker — volume /data/covers)
COVERS_DIR=/data/covers
# Postgres (for docker-compose)
POSTGRES_USER=radiola
POSTGRES_PASSWORD=radiola_pass

10
app-version.example.json Normal file
View File

@@ -0,0 +1,10 @@
{
"android": {
"version_name": "1.1",
"version_code": 2,
"download_url": "http://121.127.37.212:3000/downloads/radiola-latest.apk",
"force_update": false,
"sha256": "",
"notes": "Тёмные цветовые темы, фикс таймера сна, авто-обновление."
}
}

View File

@@ -20,6 +20,22 @@ services:
- SMTP_PASS=${SMTP_PASS}
- MAIL_FROM=${MAIL_FROM:-noreply@radiola.app}
- FRONTEND_URL=${FRONTEND_URL:-https://radiola.app}
# Обогащение треков
- DISCOGS_TOKEN=${DISCOGS_TOKEN}
- DISCOGS_TOKEN2=${DISCOGS_TOKEN2}
- DISCOGS_TOKEN3=${DISCOGS_TOKEN3}
- DISCOGS_PROXY=${DISCOGS_PROXY}
# Распознавание треков (shazam-api.com). Ключ — только в .env на сервере.
- SHAZAM_API_KEY=${SHAZAM_API_KEY}
- COVERS_DIR=/data/covers
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://121.127.37.212:3000}
# Авто-обновление APK: манифест версии + каталог раздачи APK
# (хост-bind /opt/radiola/appdist смонтирован как /data/dist)
- APP_VERSION_FILE=/data/dist/app-version.json
- DOWNLOADS_DIR=/data/dist/downloads
volumes:
- covers_data:/data/covers
- /opt/radiola/appdist:/data/dist
depends_on:
postgres:
condition: service_healthy
@@ -62,6 +78,7 @@ services:
volumes:
postgres_data:
redis_data:
covers_data:
networks:
radiola:

455
package-lock.json generated
View File

@@ -29,7 +29,9 @@
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"sharp": "^0.33.5",
"undici": "^7.27.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
@@ -781,7 +783,6 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1022,6 +1023,367 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.2.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@inquirer/ansi": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
@@ -5096,11 +5458,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -5113,9 +5487,18 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -5462,7 +5845,6 @@
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -9739,6 +10121,45 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.3",
"semver": "^7.6.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-linux-arm": "0.33.5",
"@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-wasm32": "0.33.5",
"@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-win32-x64": "0.33.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -9847,6 +10268,21 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-swizzle/node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -10903,6 +11339,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz",
"integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",

View File

@@ -44,7 +44,9 @@
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"sharp": "^0.33.5",
"undici": "^7.27.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",

View File

@@ -0,0 +1,12 @@
-- Обогащение треков (Discogs): жанр/стили/лейбл/год + состояние обогащения
ALTER TABLE "tracks" ADD COLUMN "genre" TEXT;
ALTER TABLE "tracks" ADD COLUMN "styles" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
ALTER TABLE "tracks" ADD COLUMN "label" TEXT;
ALTER TABLE "tracks" ADD COLUMN "year" INTEGER;
ALTER TABLE "tracks" ADD COLUMN "discogs_id" INTEGER;
-- Все треки стартуют как pending: бэкафилл добавит жанр/стиль/лейбл из Discogs
-- и сохранит обложку в WebP (в т.ч. накопленным ранее трекам)
ALTER TABLE "tracks" ADD COLUMN "enrich_status" TEXT NOT NULL DEFAULT 'pending';
-- CreateIndex
CREATE INDEX "tracks_genre_idx" ON "tracks"("genre");

View File

@@ -0,0 +1,5 @@
-- Покрывающий индекс под агрегацию чартов (идемпотентно — на проде уже создан CONCURRENTLY).
CREATE INDEX IF NOT EXISTS "track_plays_window_idx" ON "track_plays" ("played_at", "track_id", "station_id");
-- Частичный индекс под бэкфилл обогащения (Prisma не выражает partial WHERE в схеме).
CREATE INDEX IF NOT EXISTS "tracks_enrich_pending_idx" ON "tracks" ("first_seen_at" DESC) WHERE "enrich_status" = 'pending';

View File

@@ -124,12 +124,23 @@ model Track {
coverUrl String? @map("cover_url")
album String?
releaseDate DateTime? @map("release_date")
// Обогащение через Discogs (своя БД — в рантайме к Discogs не ходим)
genre String?
styles String[] @default([])
label String?
year Int?
discogsId Int? @map("discogs_id")
// Состояние обогащения: pending | done | failed
enrichStatus String @default("pending") @map("enrich_status")
firstSeenAt DateTime @default(now()) @map("first_seen_at")
enrichedAt DateTime? @map("enriched_at")
plays TrackPlay[]
likes TrackLike[]
@@index([genre])
// Частичный индекс под бэкфилл обогащения создаётся миграцией (Prisma не умеет
// partial WHERE): tracks_enrich_pending_idx (first_seen_at DESC) WHERE enrich_status='pending'.
@@map("tracks")
}
@@ -145,6 +156,8 @@ model TrackPlay {
@@index([trackId, playedAt])
@@index([playedAt])
@@index([stationId])
// Покрывающий индекс под агрегацию чартов (WHERE played_at>=X → GROUP BY track_id, COUNT DISTINCT station_id)
@@index([playedAt, trackId, stationId], map: "track_plays_window_idx")
@@map("track_plays")
}

11422
prisma/stations.json Normal file

File diff suppressed because it is too large Load Diff

190
scripts/probe-qualities.mjs Normal file
View File

@@ -0,0 +1,190 @@
// Пробер качества потоков: обогащает app/src/main/assets/stations.json полем `qualities`.
//
// Идея: у многих станций маунт потока кодирует битрейт в конце имени
// (rr_main96.aacp, dfm32.aacp, live128.aac, pop256k, Chan_8_192.mp3).
// Подставляем соседние битрейты из белого списка, проверяем живость потока
// (только заголовки: статус 200/206 + content-type audio/*), и записываем
// список реально работающих качеств. Пропускаем HLS (emgsound, *.m3u8),
// Love Radio (n340.com — UID-привязка) и станции без распознанного битрейта.
//
// Запуск: node backend/scripts/probe-qualities.mjs [--dry]
import { readFileSync, writeFileSync } from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const STATIONS_PATH = resolve(__dirname, '../../app/src/main/assets/stations.json');
const DRY = process.argv.includes('--dry');
// Стандартные битрейты Icecast/SHOUTcast (kbps). От высокого к низкому.
const BITRATES = [320, 256, 192, 160, 128, 112, 96, 64, 48, 32];
const CONCURRENCY = 24;
const TIMEOUT_MS = 7000;
// Хосты/форматы, которые НЕ переключаем по битрейту:
// - emgsound.ru / *.m3u8 — HLS, адаптивный сам по себе
// - n340.com — Love Radio, поток привязан к сессионному UID
const SKIP_HOST = (host) =>
host.includes('emgsound.ru') || host.includes('n340.com');
/** Разобрать URL потока и выделить «битрейтный» хвост маунта.
* Возвращает {build(bitrate)->url, currentBitrate, type} или null. */
function parseStream(streamUrl) {
let u;
try { u = new URL(streamUrl); } catch { return null; }
if (!/^https?:$/.test(u.protocol)) return null;
if (SKIP_HOST(u.hostname)) return null;
const path = u.pathname;
if (/\.(m3u8|m3u|pls)$/i.test(path)) return null; // плейлисты/HLS — пропуск
// Последний сегмент пути — имя маунта (rr_main96.aacp, live128.aac, pop256k)
const lastSlash = path.lastIndexOf('/');
const prefixPath = path.slice(0, lastSlash + 1);
const seg = path.slice(lastSlash + 1);
// Отделяем расширение
const extMatch = seg.match(/\.(aacp|aac|mp3|ogg)$/i);
const ext = extMatch ? extMatch[0] : '';
const core = ext ? seg.slice(0, -ext.length) : seg;
// Хвостовая группа цифр + возможный нецифровой суффикс (pop256[k], ..._192[kbps])
const m = core.match(/(\d+)(\D*)$/);
if (!m) return null;
const digitRun = m[1];
const tailLetters = m[2]; // напр. "k", "kbps"
const head = core.slice(0, core.length - digitRun.length - tailLetters.length);
// Битрейт = самый длинный элемент белого списка, являющийся суффиксом digitRun
// (отсекает приклеенные к бренду цифры: studio2196 → 96, dancegold9096 → 96)
let bitrate = null;
for (const b of BITRATES) {
const bs = String(b);
if (digitRun.endsWith(bs) && (bitrate === null || bs.length > String(bitrate).length)) {
bitrate = b;
}
}
if (bitrate === null) return null;
const brandDigits = digitRun.slice(0, digitRun.length - String(bitrate).length);
const type = /mp3/i.test(ext) ? 'mp3' : /aac/i.test(ext) ? 'aac' : null;
const build = (b) =>
`${u.protocol}//${u.host}${prefixPath}${head}${brandDigits}${b}${tailLetters}${ext}${u.search}`;
return { build, currentBitrate: bitrate, type, origin: streamUrl };
}
/** Жив ли поток: пришли заголовки 200/206 с аудийным content-type. */
function checkAlive(url) {
return new Promise((resolve) => {
let done = false;
const finish = (val) => { if (!done) { done = true; resolve(val); } };
const lib = url.startsWith('https') ? https : http;
let req;
try {
req = lib.get(url, {
timeout: TIMEOUT_MS,
headers: { 'Icy-MetaData': '1', 'User-Agent': 'radiOLA-probe/1.0' },
}, (res) => {
const ct = String(res.headers['content-type'] || '').toLowerCase();
const ok = (res.statusCode === 200 || res.statusCode === 206) &&
(ct.startsWith('audio') || ct.includes('aac') || ct.includes('mpeg') || ct.includes('ogg'));
res.destroy();
req.destroy();
finish(ok);
});
} catch { return finish(false); }
req.on('error', () => finish(false));
req.on('timeout', () => { req.destroy(); finish(false); });
});
}
async function pool(items, worker, concurrency) {
const results = new Array(items.length);
let idx = 0;
const runners = Array.from({ length: concurrency }, async () => {
while (idx < items.length) {
const i = idx++;
results[i] = await worker(items[i], i);
}
});
await Promise.all(runners);
return results;
}
async function main() {
const raw = readFileSync(STATIONS_PATH, 'utf-8');
const data = JSON.parse(raw);
const stations = data.stations.filter(
(s) => s.enabled && !s.notWorked && s.stream,
);
console.log(`Рабочих станций: ${stations.length}`);
let multi = 0, single = 0, skipped = 0;
const probedSlots = [];
// Собираем все (станция × кандидат) для проверки
const tasks = [];
for (const s of stations) {
const parsed = parseStream(s.stream);
if (!parsed) { skipped++; s.__skip = true; continue; }
s.__parsed = parsed;
const seen = new Set();
for (const b of BITRATES) {
const url = parsed.build(b);
if (seen.has(b)) continue;
seen.add(b);
tasks.push({ s, b, url, type: parsed.type });
}
}
console.log(`Проб кандидатов: ${tasks.length} (пропущено станций: ${skipped})`);
const alive = await pool(tasks, async (t) => (await checkAlive(t.url)) ? t : null, CONCURRENCY);
// Группируем живые качества по станции
const byStation = new Map();
for (const t of alive) {
if (!t) continue;
if (!byStation.has(t.s.id)) byStation.set(t.s.id, []);
byStation.get(t.s.id).push({ bitrate: t.b, url: t.url, type: t.type || 'aac' });
}
for (const s of stations) {
delete s.__parsed;
delete s.__skip;
const list = (byStation.get(s.id) || []).sort((a, b) => b.bitrate - a.bitrate);
if (list.length >= 2) {
s.qualities = list;
multi++;
} else {
// Один (или ноль — поток мог не ответить на пробу) → без переключателя
if (s.qualities) delete s.qualities;
single++;
}
}
console.log(`С переключателем (>=2 качества): ${multi}`);
console.log(`Одно качество / без вариантов: ${single}`);
// Топ распределения числа качеств
const dist = {};
for (const [, list] of byStation) {
if (list.length >= 2) dist[list.length] = (dist[list.length] || 0) + 1;
}
console.log('Распределение (кол-во качеств → станций):', dist);
if (DRY) {
console.log('--dry: файл не изменён');
return;
}
writeFileSync(STATIONS_PATH, JSON.stringify(data, null, 2) + '\n', 'utf-8');
console.log(`Записано: ${STATIONS_PATH}`);
}
main().catch((e) => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,49 @@
import { Controller, Get, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import * as fs from 'fs';
import * as path from 'path';
interface PlatformVersion {
version_name: string;
version_code: number;
download_url: string;
force_update: boolean;
sha256?: string;
notes?: string;
}
/**
* Манифест последней версии приложения для авто-обновления (как в nkVPN).
* Читается с диска при каждом запросе, поэтому релиз = заменить APK в /downloads
* и отредактировать data/app-version.json — без пересборки бэкенда.
* Путь к файлу — env APP_VERSION_FILE, иначе data/app-version.json.
*/
@ApiTags('app-version')
@Controller('app-version')
export class AppVersionController {
private readonly logger = new Logger(AppVersionController.name);
private readonly file =
process.env.APP_VERSION_FILE ||
path.join(process.cwd(), 'data', 'app-version.json');
@Get()
@ApiOperation({ summary: 'Манифест последней версии приложения' })
getVersion(): { android: PlatformVersion } {
try {
const raw = fs.readFileSync(this.file, 'utf-8');
return JSON.parse(raw) as { android: PlatformVersion };
} catch (e) {
this.logger.warn(`app-version.json недоступен: ${(e as Error).message}`);
// Безопасный фолбэк: version_code 0 ⇒ установленное приложение (code ≥ 1)
// никогда не увидит «обновление», т.е. при сбое файла апдейт не навязываем.
return {
android: {
version_name: '0',
version_code: 0,
download_url: '',
force_update: false,
},
};
}
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { AppVersionController } from './app-version.controller';
@Module({
controllers: [AppVersionController],
})
export class AppVersionModule {}

View File

@@ -8,6 +8,9 @@ import { UsersModule } from './users/users.module';
import { NowPlayingModule } from './now-playing/now-playing.module';
import { HealthCheckModule } from './health-check/health-check.module';
import { ChartsModule } from './charts/charts.module';
import { AppVersionModule } from './app-version/app-version.module';
import { ShazamModule } from './shazam/shazam.module';
import { PrivacyModule } from './privacy/privacy.module';
@Module({
imports: [
@@ -20,6 +23,9 @@ import { ChartsModule } from './charts/charts.module';
NowPlayingModule,
HealthCheckModule,
ChartsModule,
AppVersionModule,
ShazamModule,
PrivacyModule,
],
})
export class AppModule {}

View File

@@ -26,13 +26,21 @@ export class ChartsController {
async getTopTracks(
@Query('period') period: string = 'week',
@Query('limit') limit: string = '100',
@Query('genre') genre?: string,
) {
const validPeriod: ChartPeriod =
period === 'day' || period === 'week' || period === 'month' || period === 'all'
? (period as ChartPeriod)
: 'week';
const parsedLimit = Math.min(Math.max(parseInt(limit, 10) || 100, 1), 200);
return this.chartsService.getTopTracks(validPeriod, parsedLimit);
const genreFilter = genre?.trim() ? genre.trim() : undefined;
return this.chartsService.getTopTracks(validPeriod, parsedLimit, genreFilter);
}
@Get('genres')
@ApiOperation({ summary: 'Список доступных жанров для фильтра' })
async getGenres() {
return this.chartsService.getGenres();
}
@Get('tracks/:trackId')

View File

@@ -1,12 +1,14 @@
import { Module } from '@nestjs/common';
import { ChartsController } from './charts.controller';
import { ChartsService } from './charts.service';
import { MaintenanceService } from './maintenance.service';
import { AuthModule } from '../auth/auth.module';
import { EnrichModule } from '../enrich/enrich.module';
@Module({
imports: [AuthModule],
imports: [AuthModule, EnrichModule],
controllers: [ChartsController],
providers: [ChartsService],
providers: [ChartsService, MaintenanceService],
exports: [ChartsService],
})
export class ChartsModule {}

View File

@@ -1,5 +1,13 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EnrichmentService } from '../enrich/enrichment.service';
import { NON_MUSIC_GENRES } from '../common/station-classification';
// Жанры, исключённые из чарта: разговорные/шуточные/без названий треков.
// Их «треки» — это названия передач/реприз/спектаклей, не музыка.
// Единый список — в station-classification.ts (там же используется для флага
// `musical` станции, по которому клиент показывает кнопку распознавания).
const EXCLUDED_CHART_GENRES: string[] = [...NON_MUSIC_GENRES];
// Период чарта
export type ChartPeriod = 'day' | 'week' | 'month' | 'all';
@@ -13,6 +21,10 @@ export interface ChartEntry {
artist: string;
song: string;
coverUrl: string | null;
genre: string | null;
styles: string[];
label: string | null;
year: number | null;
plays: number;
stationsCount: number;
likes: number;
@@ -53,7 +65,10 @@ interface RawStationRow {
export class ChartsService {
private readonly logger = new Logger(ChartsService.name);
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly enrichment: EnrichmentService,
) {}
// Возвращает метку начала периода
private periodStart(period: ChartPeriod): Date {
@@ -85,31 +100,108 @@ export class ChartsService {
}
}
// Кэш названий станций — чтобы отсеивать джинглы/шоу из чарта
private stationNames = new Set<string>();
private stationNamesAt = 0;
private async getStationNames(): Promise<Set<string>> {
const now = Date.now();
if (now - this.stationNamesAt > 10 * 60 * 1000 || this.stationNames.size === 0) {
const rows = await this.prisma.station.findMany({ select: { name: true } });
this.stationNames = new Set(
rows.map((r) => r.name.trim().toLowerCase()).filter((n) => n.length > 0),
);
this.stationNamesAt = now;
}
return this.stationNames;
}
// Кэш id станций исключённых жанров (разговорные/шуточные — не в чарт)
private excludedStationIds = new Set<string>();
private excludedStationIdsAt = 0;
private async getExcludedStationIds(): Promise<Set<string>> {
const now = Date.now();
if (now - this.excludedStationIdsAt > 10 * 60 * 1000 || this.excludedStationIds.size === 0) {
const rows = await this.prisma.station.findMany({
where: { genre: { in: EXCLUDED_CHART_GENRES } },
select: { id: true },
});
this.excludedStationIds = new Set(rows.map((r) => r.id));
this.excludedStationIdsAt = now;
}
return this.excludedStationIds;
}
// Мусорный «трек»: хекс-плейсхолдер, URL, числовой код, или artist == song
// (целый тайтл без нормального разбиения на исполнителя/название).
private isJunkTrack(artist: string, song: string): boolean {
const a = artist.trim();
const s = song.trim();
if (a.toLowerCase() === s.toLowerCase()) return true;
// Известные заглушки эфира / джинглы-свиперы.
if (a.toLowerCase() === 'online' && s.toLowerCase() === 'radio') return true;
if (/^fx$/i.test(a) && /^fx-?\d+$/i.test(s)) return true;
const hex = (v: string) => /^[0-9a-f]{6,}$/i.test(v) && /[0-9]/.test(v);
const code = (v: string) => /^[0-9]+-[0-9]+/.test(v);
const url = (v: string) => /\.(ru|fm|by|com|ua)$/i.test(v) || /^https?:/i.test(v);
return [a, s].some((v) => hex(v) || code(v) || url(v));
}
// Записывает факт смены трека на станции (вызывается из NowPlayingService)
async recordPlay(params: RecordPlayParams): Promise<void> {
try {
const { artist, song, coverUrl, stationDbId } = params;
const artist = (params.artist ?? '').trim();
const song = (params.song ?? '').trim();
const { coverUrl, stationDbId } = params;
// Отсекаем не-музыкальные записи: пустые поля, либо когда артист/песня
// совпадает с названием станции (это джинглы, шоу, сетевые промо).
if (!artist || !song) return;
// Разговорные/шуточные станции — не в чарт.
const excluded = await this.getExcludedStationIds();
if (excluded.has(stationDbId)) return;
// Мусорные названия (хекс/URL/код/artist==song) — не в чарт.
if (this.isJunkTrack(artist, song)) return;
const stationNames = await this.getStationNames();
if (
stationNames.has(artist.toLowerCase()) ||
stationNames.has(song.toLowerCase())
) {
return;
}
// Нормализованный ключ: нижний регистр, схлопнуть пробелы
const normKey =
artist.trim().toLowerCase().replace(/\s+/g, ' ') +
artist.toLowerCase().replace(/\s+/g, ' ') +
'|' +
song.trim().toLowerCase().replace(/\s+/g, ' ');
song.toLowerCase().replace(/\s+/g, ' ');
// Не перетираем уже сохранённую (self-hosted) обложку сырым Record-URL
const track = await this.prisma.track.upsert({
where: { normKey },
create: { normKey, artist, song, coverUrl: coverUrl ?? null },
update: { coverUrl: coverUrl ?? null },
update: {},
});
// Если у трека ещё нет обложки, а Record прислал — подставим как стартовую
if (!track.coverUrl && coverUrl) {
await this.prisma.track.update({
where: { id: track.id },
data: { coverUrl },
});
}
await this.prisma.trackPlay.create({
data: { trackId: track.id, stationId: stationDbId },
});
this.logger.debug(`Записан трек: "${artist}${song}"`);
// Асинхронное обогащение нового трека (fire-and-forget)
if (!track.enrichedAt) {
void this.enrichTrack(track.id, artist, song);
// Асинхронное обогащение (iTunes/Discogs + WebP-обложка, fire-and-forget).
// priority — трек играет прямо сейчас, обложка нужна быстро.
if (track.enrichStatus !== 'done') {
this.enrichment.enqueue(track.id, { priority: true });
}
} catch (error) {
// Ошибка сбора не должна ронять поллер
@@ -117,72 +209,58 @@ export class ChartsService {
}
}
// Обогащение трека через MusicBrainz (fire-and-forget, best-effort)
private async enrichTrack(
trackId: string,
artist: string,
song: string,
): Promise<void> {
try {
const query = encodeURIComponent(`recording:"${song}" AND artist:"${artist}"`);
const url = `https://musicbrainz.org/ws/2/recording/?query=${query}&fmt=json&limit=1`;
const res = await fetch(url, {
headers: {
'User-Agent': 'radiOLA/1.0 ( blinnafeg@gmail.com )',
Accept: 'application/json',
},
});
if (!res.ok) return;
// In-memory TTL-кэш чартов: чарт меняется медленно, а агрегации тяжёлые.
// Один пересчёт на (period, genre, limit) раз в CHART_TTL.
private chartCache = new Map<string, { at: number; data: { items: ChartEntry[] } }>();
private static readonly CHART_TTL = 90_000;
const data = (await res.json()) as {
recordings?: Array<{
releases?: Array<{
title: string;
date?: string;
}>;
}>;
};
const recording = data.recordings?.[0];
if (!recording) return;
const release = recording.releases?.[0];
const album = release?.title ?? null;
const releaseDate = release?.date ? new Date(release.date) : null;
// Проверяем, что дата валидна
const validDate =
releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate : null;
await this.prisma.track.update({
where: { id: trackId },
data: { album, releaseDate: validDate, enrichedAt: new Date() },
});
this.logger.debug(`Трек ${trackId} обогащён: альбом="${album}"`);
} catch (error) {
// Игнорируем ошибки обогащения — не критично
this.logger.debug(`Обогащение трека ${trackId} не удалось: ${error.message}`);
// Чарт треков за период (с опциональным фильтром по жанру)
async getTopTracks(
period: ChartPeriod,
limit: number,
genre?: string,
): Promise<{ items: ChartEntry[] }> {
const cacheKey = `${period}|${genre ?? ''}|${limit}`;
const cached = this.chartCache.get(cacheKey);
if (cached && Date.now() - cached.at < ChartsService.CHART_TTL) {
return cached.data;
}
}
// Чарт треков за период
async getTopTracks(period: ChartPeriod, limit: number): Promise<{ items: ChartEntry[] }> {
const since = this.periodStart(period);
const duration = this.periodDuration(period);
const prevSince = new Date(since.getTime() - duration);
// Фильтр по жанру: ограничиваем набор треков
let genreTrackIds: string[] | undefined;
if (genre) {
const matched = await this.prisma.track.findMany({
where: { genre: { equals: genre, mode: 'insensitive' } },
select: { id: true },
});
genreTrackIds = matched.map((t) => t.id);
if (genreTrackIds.length === 0) {
const empty = { items: [] };
this.chartCache.set(cacheKey, { at: Date.now(), data: empty });
return empty;
}
}
// Топ текущего периода: группировка по trackId
const currentGroups = await this.prisma.trackPlay.groupBy({
by: ['trackId'],
where: { playedAt: { gte: since } },
where: {
playedAt: { gte: since },
...(genreTrackIds ? { trackId: { in: genreTrackIds } } : {}),
},
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
take: limit,
});
if (currentGroups.length === 0) {
return { items: [] };
const empty = { items: [] };
this.chartCache.set(cacheKey, { at: Date.now(), data: empty });
return empty;
}
const trackIds = currentGroups.map((g) => g.trackId);
@@ -235,7 +313,16 @@ export class ChartsService {
// Получаем данные треков
const tracks = await this.prisma.track.findMany({
where: { id: { in: trackIds } },
select: { id: true, artist: true, song: true, coverUrl: true },
select: {
id: true,
artist: true,
song: true,
coverUrl: true,
genre: true,
styles: true,
label: true,
year: true,
},
});
const tracksMap = new Map(tracks.map((t) => [t.id, t]));
@@ -260,6 +347,10 @@ export class ChartsService {
artist: track?.artist ?? '',
song: track?.song ?? '',
coverUrl: track?.coverUrl ?? null,
genre: track?.genre ?? null,
styles: track?.styles ?? [],
label: track?.label ?? null,
year: track?.year ?? null,
plays: g._count.id,
stationsCount: stationsMap.get(g.trackId) ?? 0,
likes: likesMap.get(g.trackId) ?? 0,
@@ -268,7 +359,9 @@ export class ChartsService {
};
});
return { items };
const result = { items };
this.chartCache.set(cacheKey, { at: Date.now(), data: result });
return result;
}
// Детальная страница трека
@@ -281,6 +374,10 @@ export class ChartsService {
song: string;
album: string | null;
coverUrl: string | null;
genre: string | null;
styles: string[];
label: string | null;
year: number | null;
releaseDate: string | null;
firstSeen: string | null;
totalPlays: number;
@@ -373,6 +470,10 @@ export class ChartsService {
song: track.song,
album: track.album ?? null,
coverUrl: track.coverUrl ?? null,
genre: track.genre ?? null,
styles: track.styles ?? [],
label: track.label ?? null,
year: track.year ?? null,
releaseDate: track.releaseDate ? track.releaseDate.toISOString() : null,
firstSeen: track.firstSeenAt ? track.firstSeenAt.toISOString() : null,
totalPlays: totalPlaysResult,
@@ -408,4 +509,15 @@ export class ChartsService {
});
return {};
}
// Список доступных жанров (для фильтра в чартах)
async getGenres(): Promise<{ genres: string[] }> {
const rows = await this.prisma.track.findMany({
where: { genre: { not: null } },
select: { genre: true },
distinct: ['genre'],
orderBy: { genre: 'asc' },
});
return { genres: rows.map((r) => r.genre).filter((g): g is string => !!g) };
}
}

View File

@@ -0,0 +1,87 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
/**
* Фоновое обслуживание данных чартов. track_plays растёт ~100k строк/сутки
* (≈700 станций × смены треков), поэтому без ретенции таблица разрастается
* безгранично. Раз в сутки:
* 1) удаляем проигрывания старше RETENTION_DAYS (чанками, чтобы не лочить таблицу);
* 2) подчищаем «осиротевшие» треки (без проигрываний/лайков/обложки, старые).
*
* ⚠️ RETENTION_DAYS ограничивает и чарт period='all' (он становится «за последние
* N дней») и totalPlays трека на детальной странице. 180 дней — компромисс между
* осмысленной историей и размером таблицы (~18M строк потолок). Меняется здесь.
*/
@Injectable()
export class MaintenanceService {
private readonly logger = new Logger(MaintenanceService.name);
// Сколько дней храним факты проигрывания (см. предупреждение выше).
private static readonly RETENTION_DAYS = 180;
// Возраст «осиротевшего» трека для удаления.
private static readonly ORPHAN_DAYS = 30;
// Размер чанка удаления (чтобы один DELETE не держал долгий лок).
private static readonly CHUNK = 20_000;
constructor(private readonly prisma: PrismaService) {}
@Cron(CronExpression.EVERY_DAY_AT_4AM)
async runMaintenance(): Promise<void> {
await this.pruneOldPlays();
await this.pruneOrphanTracks();
}
/** Удаляет проигрывания старше RETENTION_DAYS чанками. */
private async pruneOldPlays(): Promise<void> {
const cutoff = new Date(
Date.now() - MaintenanceService.RETENTION_DAYS * 86_400_000,
);
try {
let total = 0;
let removed: number;
do {
// ctid + LIMIT — удаляем порциями, не лочим всю таблицу разом.
removed = await this.prisma.$executeRaw`
DELETE FROM track_plays
WHERE ctid IN (
SELECT ctid FROM track_plays
WHERE played_at < ${cutoff}
LIMIT ${MaintenanceService.CHUNK}
)`;
total += removed;
} while (removed > 0);
if (total > 0) {
this.logger.log(
`Ретенция: удалено ${total} track_plays старше ${MaintenanceService.RETENTION_DAYS}д`,
);
}
} catch (e) {
this.logger.error(`Ретенция track_plays упала: ${(e as Error).message}`);
}
}
/**
* Удаляет треки без следов использования: нет проигрываний, нет лайков, нет
* обложки и созданы давно (artefact-строки, которые иначе копятся навсегда).
* Каскад снимает зависимые plays (их и так нет).
*/
private async pruneOrphanTracks(): Promise<void> {
const cutoff = new Date(
Date.now() - MaintenanceService.ORPHAN_DAYS * 86_400_000,
);
try {
const removed = await this.prisma.$executeRaw`
DELETE FROM tracks t
WHERE t.first_seen_at < ${cutoff}
AND t.cover_url IS NULL
AND NOT EXISTS (SELECT 1 FROM track_plays p WHERE p.track_id = t.id)
AND NOT EXISTS (SELECT 1 FROM track_likes l WHERE l.track_id = t.id)`;
if (removed > 0) {
this.logger.log(`Прун сирот: удалено ${removed} tracks`);
}
} catch (e) {
this.logger.error(`Прун сирот упал: ${(e as Error).message}`);
}
}
}

View File

@@ -0,0 +1,28 @@
/**
* Единый признак «музыкальная ли станция». Используется в двух местах:
* • ChartsService — НЕ засчитывать «треки» разговорных станций в чарт;
* • StationsService — выставить флаг `musical` в ответе /stations, по которому
* клиент показывает кнопку «Распознать трек» (Shazam) только для музыки.
*
* Жанры разговорных/юмористических/новостных станций: их «треки» — это названия
* передач/реприз/спектаклей, не музыка, распознавать там нечего.
*
* ⚠️ Добавил разговорную станцию — впиши её genre сюда (одно место на весь проект).
*/
export const NON_MUSIC_GENRES = [
'Станция Кассиопея',
'Юмор ФМ',
'Рассказы',
'Радио Вера',
'Comedy Radio',
'ВГТРК',
'Старое радио',
] as const;
const NON_MUSIC_SET = new Set<string>(NON_MUSIC_GENRES);
/** true — на станции играет музыка (а не разговор/юмор/новости). */
export function isMusicStation(genre?: string | null): boolean {
if (!genre) return true; // без жанра считаем музыкальной (консервативно)
return !NON_MUSIC_SET.has(genre.trim());
}

View File

@@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
import { promises as fs } from 'fs';
import { join } from 'path';
import sharp from 'sharp';
/**
* Хранилище обложек треков.
* Скачивает картинку из любого источника, приводит к единому формату —
* WebP фиксированного размера (качество без видимых потерь, малый вес) —
* и сохраняет локально. В рантайме отдаём со своего домена, не зависим от чужих CDN.
*/
@Injectable()
export class CoverStorageService {
private readonly logger = new Logger(CoverStorageService.name);
private readonly dir =
process.env.COVERS_DIR || join(process.cwd(), 'data', 'covers');
private readonly publicBase = (process.env.PUBLIC_BASE_URL || '').replace(
/\/$/,
'',
);
private readonly size = 500; // квадрат 500×500 — хватает и карточке, и детальной
/**
* Скачивает и сохраняет обложку как WebP.
* key — стабильный ключ (normKey трека), чтобы имя файла было детерминированным.
* Возвращает публичный URL обложки или null.
*/
async store(sourceUrl: string, key: string): Promise<string | null> {
try {
const hash = createHash('sha1').update(key).digest('hex').slice(0, 16);
const fileName = `${hash}.webp`;
const filePath = join(this.dir, fileName);
const publicPath = `/covers/${fileName}`;
// Уже сохранена — повторно не качаем
try {
await fs.access(filePath);
return this.toPublicUrl(publicPath);
} catch {
// файла нет — продолжаем
}
const res = await fetch(sourceUrl, {
headers: { 'User-Agent': 'radiOLA/1.0 +https://radiola.app' },
});
if (!res.ok) return null;
const ctype = res.headers.get('content-type') ?? '';
if (!ctype.startsWith('image/')) return null;
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length === 0 || buf.length > 8 * 1024 * 1024) return null;
await fs.mkdir(this.dir, { recursive: true });
await sharp(buf)
.resize(this.size, this.size, { fit: 'cover', position: 'centre' })
.webp({ quality: 80 })
.toFile(filePath);
return this.toPublicUrl(publicPath);
} catch (e) {
this.logger.debug(`Не удалось сохранить обложку: ${(e as Error).message}`);
return null;
}
}
// Если задан PUBLIC_BASE_URL — отдаём абсолютный URL, иначе относительный путь
private toPublicUrl(path: string): string {
return this.publicBase ? `${this.publicBase}${path}` : path;
}
}

View File

@@ -0,0 +1,32 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { IsString, MaxLength } from 'class-validator';
import { EnrichmentService } from './enrichment.service';
class SubmitCoverDto {
@IsString()
@MaxLength(300)
artist!: string;
@IsString()
@MaxLength(300)
song!: string;
@IsString()
@MaxLength(1000)
artworkUrl!: string;
}
@ApiTags('covers')
@Controller('covers')
export class CoversController {
constructor(private readonly enrichment: EnrichmentService) {}
// Клиент прислал ссылку на найденную им (со своего IP) обложку iTunes.
// Сервер скачивает её и кладёт WebP к себе; возвращает наш coverUrl.
@Post('submit')
@ApiOperation({ summary: 'Принять обложку, найденную клиентом' })
async submit(@Body() dto: SubmitCoverDto) {
return this.enrichment.submitCover(dto.artist, dto.song, dto.artworkUrl);
}
}

View File

@@ -0,0 +1,121 @@
import { Injectable, Logger } from '@nestjs/common';
import { ProxyAgent, Agent } from 'undici';
// Результат обогащения из Discogs
export interface DiscogsResult {
discogsId: number | null;
genre: string | null;
styles: string[];
label: string | null;
year: number | null;
coverImageUrl: string | null;
}
// Сырой результат поиска Discogs (нужные поля)
interface DiscogsSearchItem {
id?: number;
genre?: string[];
style?: string[];
label?: string[];
year?: string | number;
cover_image?: string;
thumb?: string;
}
/**
* Клиент Discogs Database Search.
* Один запрос поиска уже отдаёт genre/style/label/year/cover_image —
* детальный запрос релиза не нужен.
* Токен берётся из env DISCOGS_TOKEN (личный токен из Settings → Developers).
*/
@Injectable()
export class DiscogsService {
private readonly logger = new Logger(DiscogsService.name);
private readonly userAgent = 'radiOLA/1.0 +https://radiola.app';
// Discogs троттлит ПО IP. Делаем несколько маршрутов с РАЗНЫМИ IP, каждый со
// своим токеном и слотом ~54/мин → суммарно N×54/мин без 429:
// • token1 — напрямую (RU, IPv6 по умолчанию)
// • token2 — через DE-прокси (выход с IP DE)
// • token3 — напрямую, но форсируем IPv4 (RU, другой IP, чем IPv6)
private readonly minIntervalMs = 1100;
private readonly routeList = this.buildRoutes();
private readonly slots: number[] = this.routeList.map(() => 0);
private buildRoutes(): { token: string; dispatcher: unknown }[] {
const routes: { token: string; dispatcher: unknown }[] = [];
const t1 = process.env.DISCOGS_TOKEN ?? '';
const t2 = process.env.DISCOGS_TOKEN2 ?? '';
const t3 = process.env.DISCOGS_TOKEN3 ?? '';
const proxy = process.env.DISCOGS_PROXY ?? '';
if (t1) routes.push({ token: t1, dispatcher: undefined });
if (t2 && proxy) routes.push({ token: t2, dispatcher: new ProxyAgent(proxy) });
if (t3) routes.push({ token: t3, dispatcher: new Agent({ connect: { family: 4 } }) });
return routes;
}
// Без токена обогащение жанрами не работает (поиск требует авторизации)
get enabled(): boolean {
return this.routeList.length > 0;
}
// Резервирует слот наименее загруженного маршрута, возвращает токен + dispatcher
private async pickRoute(): Promise<{ token: string; dispatcher: unknown }> {
let idx = 0;
for (let i = 1; i < this.slots.length; i++) {
if (this.slots[i] < this.slots[idx]) idx = i;
}
const now = Date.now();
const start = Math.max(now, this.slots[idx]);
this.slots[idx] = start + this.minIntervalMs;
if (start > now) await new Promise((res) => setTimeout(res, start - now));
return this.routeList[idx];
}
async lookup(artist: string, song: string): Promise<DiscogsResult | null> {
if (!this.enabled) return null;
const { token, dispatcher } = await this.pickRoute();
const params = new URLSearchParams({
artist,
track: song,
type: 'release',
per_page: '5',
token,
});
const url = `https://api.discogs.com/database/search?${params.toString()}`;
const init: Record<string, unknown> = {
headers: { 'User-Agent': this.userAgent, Accept: 'application/json' },
};
if (dispatcher) init.dispatcher = dispatcher;
const res = await fetch(url, init);
if (!res.ok) {
this.logger.debug(`Discogs ${res.status} для "${artist}${song}"`);
return null;
}
const data = (await res.json()) as { results?: DiscogsSearchItem[] };
const item = data.results?.[0];
if (!item) return null;
const cover =
item.cover_image && !item.cover_image.includes('spacer.gif')
? item.cover_image
: item.thumb && !item.thumb.includes('spacer.gif')
? item.thumb
: null;
const yearNum =
item.year != null ? parseInt(String(item.year), 10) || null : null;
return {
discogsId: typeof item.id === 'number' ? item.id : null,
genre: item.genre?.[0] ?? null,
styles: Array.isArray(item.style) ? item.style.slice(0, 6) : [],
label: item.label?.[0] ?? null,
year: yearNum,
coverImageUrl: cover,
};
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DiscogsService } from './discogs.service';
import { CoverStorageService } from './cover-storage.service';
import { EnrichmentService } from './enrichment.service';
import { CoversController } from './covers.controller';
@Module({
controllers: [CoversController],
providers: [DiscogsService, CoverStorageService, EnrichmentService],
exports: [EnrichmentService],
})
export class EnrichModule {}

View File

@@ -0,0 +1,475 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ProxyAgent } from 'undici';
import { PrismaService } from '../prisma/prisma.service';
import { DiscogsService } from './discogs.service';
import { CoverStorageService } from './cover-storage.service';
/**
* Оркестратор обогащения трека: при первом появлении трека подтягиваем
* жанр/стиль/лейбл/год из Discogs и сохраняем обложку в едином формате (WebP)
* у себя. Дальше пользователю отдаём только из своей БД — внешние сервисы
* в рантайме не дёргаем.
*/
@Injectable()
export class EnrichmentService {
private readonly logger = new Logger(EnrichmentService.name);
// Очередь обогащения с троттлингом (под лимиты Discogs/iTunes)
private readonly queue: string[] = [];
private running = false;
// Discogs сам себя лимитирует (rate-limiter в DiscogsService), поэтому можно
// выше параллельность: обложки (iTunes, без лимита) льются быстрее.
private readonly throttleMs = 150;
private readonly concurrency = 12;
// RU-IP сервера забанен Apple (429) и Deezer из РФ отдаёт пустой каталог —
// поэтому iTunes/Deezer ходят через тот же DE-прокси, что и Discogs.
private readonly proxyDispatcher = process.env.DISCOGS_PROXY
? new ProxyAgent(process.env.DISCOGS_PROXY)
: undefined;
// iTunes лимитирует ПО IP (~20/мин) и легко банит общий DE-IP (его делит
// Discogs) — сериализуем запросы к iTunes с интервалом.
private itunesGate: Promise<void> = Promise.resolve();
private readonly itunesMinIntervalMs = 3500;
constructor(
private readonly prisma: PrismaService,
private readonly discogs: DiscogsService,
private readonly covers: CoverStorageService,
) {}
// Поставить трек в очередь. priority — играющие сейчас треки (в начало очереди),
// чтобы обложка успела появиться, пока трек звучит.
enqueue(trackId: string, opts?: { priority?: boolean }): void {
const idx = this.queue.indexOf(trackId);
if (idx !== -1) {
if (opts?.priority && idx > 0) {
this.queue.splice(idx, 1);
this.queue.unshift(trackId);
}
return;
}
if (opts?.priority) this.queue.unshift(trackId);
else this.queue.push(trackId);
void this.drain();
}
// Непрерывно добираем холодный бэклог: когда очередь почти пуста — подкидываем
// батч pending (не-приоритетно, играющие треки всё равно идут вперёд).
// Раз в минуту, чтобы конвейер не простаивал между всплесками now-playing.
@Cron(CronExpression.EVERY_MINUTE)
async backfill(): Promise<void> {
if (!this.discogs.enabled) return; // без токена смысла нет — не крутим вхолостую
if (this.queue.length > this.concurrency) return; // ещё есть что жевать
const pending = await this.prisma.track.findMany({
where: { enrichStatus: 'pending' },
select: { id: true },
orderBy: { firstSeenAt: 'desc' },
take: 100,
});
for (const t of pending) this.enqueue(t.id);
}
// Раз в минуту обеспечиваем ОБЛОЖКУ у играющих СЕЙЧАС треков — быстрый проход
// ТОЛЬКО через iTunes (без Discogs, который лимитирован 54/мин и тормозил бы
// обложки). Полное обогащение (жанр/стили) идёт фоном через backfill/enqueue.
private nowPlayingRunning = false;
@Cron(CronExpression.EVERY_MINUTE)
async enrichNowPlaying(): Promise<void> {
if (this.nowPlayingRunning) return; // не накладываем проходы
this.nowPlayingRunning = true;
try {
await this.runEnrichNowPlaying();
} finally {
this.nowPlayingRunning = false;
}
}
private async runEnrichNowPlaying(): Promise<void> {
const rows = await this.prisma.nowPlaying.findMany({
select: { artist: true, song: true },
});
// Треки уже созданы в ChartsService.recordPlay — не upsert'им построчно (был
// N+1 на ~300 строк/мин), а читаем пачкой по normKey. Мусор/исключённые
// станции трек не создавали → их и не обогащаем (это правильно).
const normKeys = new Set<string>();
for (const r of rows) {
const artist = (r.artist ?? '').trim();
const song = (r.song ?? '').trim();
if (artist && song) normKeys.add(this.buildNormKey(artist, song));
}
if (normKeys.size === 0) return;
const tracks = await this.prisma.track.findMany({
where: { normKey: { in: [...normKeys] } },
select: {
id: true,
artist: true,
song: true,
normKey: true,
coverUrl: true,
enrichStatus: true,
},
});
const todo: { id: string; artist: string; song: string; normKey: string }[] = [];
for (const track of tracks) {
if (!track.coverUrl) {
todo.push({
id: track.id,
artist: track.artist,
song: track.song,
normKey: track.normKey,
});
}
// полное обогащение (жанр) — в общую очередь, если ещё не сделано
if (track.enrichStatus !== 'done') this.enqueue(track.id);
}
// Быстрый cover-only проход, по 8 параллельно — чтобы успевать за сменой
// треков по всем сетям (~120/мин)
for (let i = 0; i < todo.length; i += 8) {
await Promise.all(todo.slice(i, i + 8).map((t) => this.coverFast(t)));
}
}
// Только обложка — для быстрого покрытия эфира. Чтобы не множить нагрузку на
// iTunes (лимит ~20/мин на 170+ играющих треков), делаем ОДИН запрос iTunes по
// очищенному названию (он чаще матчит ремиксы/«(Original Mix)»), а на промахе/
// лимите идём в Deezer (отдельный лимит, хорошее покрытие электроники).
private async coverFast(t: {
id: string;
artist: string;
song: string;
normKey: string;
}): Promise<void> {
try {
const cover = await this.fetchCover(t.artist, t.song);
if (!cover?.coverUrl) return;
const stored = await this.covers.store(cover.coverUrl, t.normKey);
if (!stored) return;
await this.prisma.track.update({
where: { id: t.id },
data: {
coverUrl: stored,
genre: cover.genre ?? undefined,
album: cover.album ?? undefined,
},
});
} catch {
// сбой — добёрём на следующем тике
}
}
/** Только обложка для быстрого now-playing-прохода — ТОЛЬКО Deezer (через
* DE-прокси, высокий лимит, параллельно). iTunes здесь НЕ дёргаем: его жёсткий
* троттлинг (3.5с) затыкал бы проход. Промахи Deezer добирает фоновый
* enrichOne (там iTunes через прокси с троттлингом). */
private async fetchCover(
artist: string,
song: string,
): Promise<{ coverUrl: string | null; genre: string | null; album: string | null } | null> {
const dz = await this.fetchDeezerCover(artist, song);
if (dz) return { coverUrl: dz, genre: null, album: null };
return null;
}
// ===== Клиентский сабмит обложки =====
// Клиент (со своего IP) делает iTunes-поиск (наш серверный IP забанен Apple)
// и присылает ССЫЛКУ на арт. Сервер качает её (CDN из РФ доступен) и кладёт
// WebP к себе. SSRF-защита: только доверенные CDN. Идемпотентно (first-write-wins).
private static readonly COVER_HOST_ALLOW = ['mzstatic.com', 'dzcdn.net'];
private submitInFlight = 0;
private readonly submitMaxInFlight = 6;
async submitCover(
artist: string,
song: string,
artworkUrl: string,
): Promise<{ coverUrl: string | null }> {
const a = (artist ?? '').trim();
const s = (song ?? '').trim();
if (!a || !s || !artworkUrl) return { coverUrl: null };
let host = '';
try {
host = new URL(artworkUrl).hostname.toLowerCase();
} catch {
return { coverUrl: null };
}
const allowed = EnrichmentService.COVER_HOST_ALLOW.some(
(h) => host === h || host.endsWith('.' + h),
);
if (!allowed) return { coverUrl: null };
const normKey = this.buildNormKey(a, s);
// Уже есть — отдаём существующую (не качаем повторно, защита от перезаписи).
const existing = await this.prisma.track.findUnique({
where: { normKey },
select: { coverUrl: true },
});
if (existing?.coverUrl) return { coverUrl: existing.coverUrl };
if (this.submitInFlight >= this.submitMaxInFlight) return { coverUrl: null };
this.submitInFlight++;
try {
const stored = await this.covers.store(artworkUrl, normKey);
if (!stored) return { coverUrl: null };
await this.prisma.track.upsert({
where: { normKey },
create: { normKey, artist: a, song: s, coverUrl: stored },
update: { coverUrl: stored },
});
return { coverUrl: stored };
} finally {
this.submitInFlight--;
}
}
// Нормализованный ключ — как в ChartsService.recordPlay
private buildNormKey(artist: string, song: string): string {
return (
artist.toLowerCase().replace(/\s+/g, ' ') +
'|' +
song.toLowerCase().replace(/\s+/g, ' ')
);
}
private async drain(): Promise<void> {
if (this.running) return;
this.running = true;
try {
while (this.queue.length > 0) {
const batch = this.queue.splice(0, this.concurrency);
await Promise.all(batch.map((id) => this.enrichOne(id)));
await this.sleep(this.throttleMs);
}
} finally {
this.running = false;
}
}
private async enrichOne(trackId: string): Promise<void> {
try {
const track = await this.prisma.track.findUnique({
where: { id: trackId },
});
if (!track || track.enrichStatus === 'done') return;
const data = this.discogs.enabled
? await this.discogs.lookup(track.artist, track.song)
: null;
// iTunes: обложка (покрытие почти как у Record) + альбом/год/жанр как
// фолбэк к Discogs. Гибрид: стили и лейбл — только Discogs.
// Отличаем сбой запроса (ретраить) от чистого «не найдено» (done).
let itunes: Awaited<ReturnType<typeof this.fetchItunes>> = null;
let itunesFailed = false;
try {
itunes = await this.fetchItunes(track.artist, track.song);
} catch {
itunesFailed = true;
}
// Обложка → WebP к себе (если ещё не наша)
let coverUrl = track.coverUrl;
const candidate = itunes?.coverUrl ?? data?.coverImageUrl ?? track.coverUrl;
if (candidate && !this.isSelfHosted(candidate)) {
const stored = await this.covers.store(candidate, track.normKey);
if (stored) coverUrl = stored;
}
// Жанр: Discogs приоритетнее (тонкий), затем iTunes (грубый фолбэк)
const genre = data?.genre ?? itunes?.genre ?? track.genre;
const year = data?.year ?? itunes?.year ?? track.year;
const album = track.album ?? itunes?.album ?? null;
const releaseDate =
track.releaseDate ??
itunes?.releaseDate ??
(data?.year ? new Date(Date.UTC(data.year, 0, 1)) : null);
// Помечаем done, если обогатились. НЕ помечаем (оставляем pending для
// ретрая), если: нет токена Discogs, ИЛИ запрос к iTunes упал И обложку
// так и не получили (транзиентный сбой — промах не должен застывать).
const enriched = this.discogs.enabled && !(itunesFailed && !coverUrl);
await this.prisma.track.update({
where: { id: trackId },
data: {
genre,
styles: data?.styles?.length ? data.styles : track.styles,
label: data?.label ?? track.label,
year,
album,
discogsId: data?.discogsId ?? track.discogsId,
coverUrl,
releaseDate,
enrichStatus: enriched ? 'done' : 'pending',
enrichedAt: enriched ? new Date() : track.enrichedAt,
},
});
this.logger.debug(
`Обогащён "${track.artist}${track.song}": genre=${genre ?? '—'}, label=${data?.label ?? '—'}`,
);
} catch (e) {
this.logger.debug(`Обогащение ${trackId} не удалось: ${(e as Error).message}`);
await this.prisma.track
.update({ where: { id: trackId }, data: { enrichStatus: 'failed' } })
.catch(() => undefined);
}
}
// iTunes Search API (без ключа, высокое покрытие): обложка (600×600) +
// альбом/год/жанр/дата релиза.
private async fetchItunes(
artist: string,
song: string,
): Promise<{
coverUrl: string | null;
album: string | null;
year: number | null;
releaseDate: Date | null;
genre: string | null;
} | null> {
// Попытка 1: как есть. Многие треки несут суффиксы «(Original Mix)»,
// «(SEA)», «[... Dub]», «feat. X» — они ломают точный матч iTunes (limit=1).
let r = await this.itunesSearch(`${artist} ${song}`);
// Попытка 2: очищенный запрос (без скобок/квадратных/feat) — даёт обложку
// базового трека, когда точный ремикс не нашёлся.
if (!r?.coverUrl) {
const cleaned = `${this.stripNoise(artist)} ${this.stripNoise(song)}`
.replace(/\s+/g, ' ')
.trim();
const original = `${artist} ${song}`.toLowerCase();
if (cleaned && cleaned.toLowerCase() !== original) {
const r2 = await this.itunesSearch(cleaned);
if (r2?.coverUrl) r = r2;
}
}
// Попытка 3: Deezer (публичный API, без ключа) — у него хорошее покрытие
// электроники/ремиксов/лаунжа, которых нет в iTunes. Берём только обложку.
if (!r?.coverUrl) {
const dz = await this.fetchDeezerCover(artist, song);
if (dz) {
r = {
coverUrl: dz,
album: r?.album ?? null,
year: r?.year ?? null,
releaseDate: r?.releaseDate ?? null,
genre: r?.genre ?? null,
};
}
}
return r;
}
/** Убирает «шумовые» суффиксы названия, мешающие матчу обложки. */
private stripNoise(s: string): string {
return s
.replace(/\([^)]*\)/g, ' ') // (Original Mix), (SEA), (feat. X)
.replace(/\[[^\]]*\]/g, ' ') // [Luxar Brooklyn Dub]
.replace(/\b(?:feat|ft|featuring)\.?\s+.*$/gi, ' ') // feat. X …
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/** Сериализует запросы к iTunes с минимальным интервалом (защита от 429/бана). */
private async itunesThrottle(): Promise<void> {
const prev = this.itunesGate;
let release!: () => void;
this.itunesGate = new Promise<void>((r) => (release = r));
await prev;
setTimeout(release, this.itunesMinIntervalMs);
}
/** Один поиск в iTunes по уже собранному запросу. Бросает при сбое сети/HTTP
* (отличаем сбой от чистого «не найдено» → null). */
private async itunesSearch(rawTerm: string): Promise<{
coverUrl: string | null;
album: string | null;
year: number | null;
releaseDate: Date | null;
genre: string | null;
} | null> {
const clean = rawTerm
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!clean) return null;
const term = encodeURIComponent(clean);
const url = `https://itunes.apple.com/search?term=${term}&entity=song&limit=1`;
await this.itunesThrottle();
const init: RequestInit & { dispatcher?: unknown } = {
headers: { 'User-Agent': 'radiOLA/1.0 +https://radiola.app' },
};
if (this.proxyDispatcher) init.dispatcher = this.proxyDispatcher;
const res = await fetch(url, init);
if (!res.ok) throw new Error(`iTunes ${res.status}`);
const data = (await res.json()) as {
results?: Array<{
artworkUrl100?: string;
collectionName?: string;
releaseDate?: string;
primaryGenreName?: string;
}>;
};
const r = data.results?.[0];
if (!r) return null;
const cover = r.artworkUrl100
? r.artworkUrl100.replace(/\/\d+x\d+bb\./, '/600x600bb.')
: null;
const rd = r.releaseDate ? new Date(r.releaseDate) : null;
const validDate = rd && !isNaN(rd.getTime()) ? rd : null;
return {
coverUrl: cover,
album: r.collectionName ?? null,
year: validDate ? validDate.getUTCFullYear() : null,
releaseDate: validDate,
genre: r.primaryGenreName ?? null,
};
}
/** Обложка из Deezer (фолбэк). Best-effort: при любой ошибке → null. */
private async fetchDeezerCover(
artist: string,
song: string,
): Promise<string | null> {
try {
const q = `${this.stripNoise(artist)} ${this.stripNoise(song)}`
.replace(/\s+/g, ' ')
.trim();
if (!q) return null;
const url = `https://api.deezer.com/search?limit=1&q=${encodeURIComponent(q)}`;
const init: RequestInit & { dispatcher?: unknown } = {
headers: { 'User-Agent': 'radiOLA/1.0 +https://radiola.app' },
};
if (this.proxyDispatcher) init.dispatcher = this.proxyDispatcher;
const res = await fetch(url, init);
if (!res.ok) return null;
const data = (await res.json()) as {
data?: Array<{ album?: { cover_xl?: string; cover_big?: string } }>;
};
const al = data.data?.[0]?.album;
return al?.cover_xl ?? al?.cover_big ?? null;
} catch {
return null;
}
}
private isSelfHosted(url: string): boolean {
return url.includes('/covers/');
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -1,72 +1,104 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import * as http from 'http';
import * as https from 'https';
@Injectable()
export class HealthCheckService {
export class HealthCheckService implements OnModuleInit {
private readonly logger = new Logger(HealthCheckService.name);
constructor(private readonly prisma: PrismaService) {}
// Один прогон вскоре после старта, чтобы isOnline был актуален после деплоя
async onModuleInit() {
setTimeout(() => {
void this.checkAllStations();
}, 15000);
}
@Cron(CronExpression.EVERY_HOUR)
async checkAllStations() {
this.logger.log('Starting hourly station health check...');
const stations = await this.prisma.station.findMany();
let onlineCount = 0;
let offlineCount = 0;
this.logger.log('Проверка доступности станций...');
const stations = await this.prisma.station.findMany({
select: { id: true, streamUrl: true, isOnline: true },
});
for (const station of stations) {
try {
const isOnline = await this.checkStation(station.streamUrl);
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline, lastCheckAt: new Date() },
});
if (isOnline) onlineCount++;
else offlineCount++;
} catch (error) {
this.logger.warn(
`Failed to check station ${station.name}: ${error.message}`,
);
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: false, lastCheckAt: new Date() },
});
offlineCount++;
}
let online = 0;
let offline = 0;
const CONC = 24;
for (let i = 0; i < stations.length; i += CONC) {
const batch = stations.slice(i, i + CONC);
await Promise.all(
batch.map(async (s) => {
const isOnline = await this.isAlive(s.streamUrl);
if (isOnline) online++;
else offline++;
// Пишем только при изменении статуса — меньше нагрузка на БД
if (isOnline !== s.isOnline) {
await this.prisma.station
.update({
where: { id: s.id },
data: { isOnline, lastCheckAt: new Date() },
})
.catch(() => undefined);
}
}),
);
}
this.logger.log(
`Health check complete. Online: ${onlineCount}, Offline: ${offlineCount}`,
);
this.logger.log(`Проверка завершена. Online: ${online}, Offline: ${offline}`);
}
private async checkStation(url: string): Promise<boolean> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(url, {
method: 'HEAD',
signal: controller.signal,
});
clearTimeout(timeout);
return response.status >= 200 && response.status < 400;
} catch {
clearTimeout(timeout);
// Fallback to GET if HEAD fails
try {
const controller2 = new AbortController();
const timeout2 = setTimeout(() => controller2.abort(), 10000);
const response = await fetch(url, {
method: 'GET',
signal: controller2.signal,
});
clearTimeout(timeout2);
return response.status >= 200 && response.status < 400;
} catch {
return false;
}
/**
* Живость потока: живой = пришли заголовки со статусом 200399.
* Аудиопоток отдаёт тело бесконечно, поэтому сразу после заголовков рвём
* соединение (req.destroy). Ошибка/4xx/5xx/таймаут = мёртв. 2 попытки.
*/
private async isAlive(url: string): Promise<boolean> {
for (let attempt = 0; attempt < 2; attempt++) {
if (await this.probe(url)) return true;
await this.sleep(300);
}
return false;
}
private probe(url: string): Promise<boolean> {
return new Promise((resolve) => {
let done = false;
const finish = (v: boolean) => {
if (!done) {
done = true;
resolve(v);
}
};
try {
const lib = url.startsWith('https') ? https : http;
const req = lib.get(
url,
{
timeout: 8000,
headers: { 'User-Agent': 'Mozilla/5.0', 'Icy-MetaData': '1' },
},
(res) => {
const code = res.statusCode ?? 0;
req.destroy();
finish(code >= 200 && code < 400);
},
);
req.on('error', () => finish(false));
req.on('timeout', () => {
req.destroy();
finish(false);
});
} catch {
finish(false);
}
});
}
private sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
}

View File

@@ -1,10 +1,25 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { join } from 'path';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Раздача сохранённых обложек треков (/covers/*.webp) — свой CDN
const coversDir = process.env.COVERS_DIR || join(process.cwd(), 'data', 'covers');
app.useStaticAssets(coversDir, {
prefix: '/covers/',
maxAge: '30d',
immutable: true,
});
// Раздача APK приложения для авто-обновления (/downloads/radiola-latest.apk).
const downloadsDir =
process.env.DOWNLOADS_DIR || join(process.cwd(), 'data', 'downloads');
app.useStaticAssets(downloadsDir, { prefix: '/downloads/' });
app.useGlobalPipes(
new ValidationPipe({

View File

@@ -0,0 +1,36 @@
/**
* Единый реестр станций, у которых ЕСТЬ выделенный now-playing-сервис (берёт трек
* из их API или особого ICY). Общий ICY-поллер (IcyNowPlayingService) обязан их
* ПРОПУСКАТЬ — иначе двойной опрос и перезапись точных данных «сырым» ICY.
*
* ⚠️ Добавил новый dedicated-сервис — впиши сюда его признак:
* • host — если сервис выбирает станции по `streamUrl.contains(host)`
* • genre — если сервис выбирает станции по `genre`
* Список один-в-один повторяет селекторы сервисов, поэтому исключение в ICY
* всегда согласовано с тем, что реально обрабатывает выделенный сервис
* (раньше это был ручной genre-список, который легко забывали обновить).
*/
// Сервисы, выбирающие станции по хосту потока (streamUrl.contains)
export const DEDICATED_STREAM_HOSTS = [
'emgsound.ru', // EmgNowPlayingService
'radio7.hostingradio.ru', // EmgNowPlayingService (старые мейны Radio 7)
'unistar.by', // UnistarNowPlayingService
'abs.zaycev.fm', // ZaycevNowPlayingService
'radiogoose.ru', // GooseNowPlayingService
'novoeradio.by', // NovoeByNowPlayingService
'radio.orpheus.ru', // OrpheusNowPlayingService
'.101.ru', // Radio101NowPlayingService (Comedy Radio, Радио Energy)
'amgradio.ru', // VolnaNowPlayingService (Русская Волна)
'piterfm.cdnvideo.ru', // SpbRadioNowPlayingService (Питер ФМ)
'radiovanya.cdnvideo.ru', // SpbRadioNowPlayingService (Радио Ваня)
] as const;
// Сервисы, выбирающие станции по жанру (genre)
export const DEDICATED_GENRES = [
'DFM', // DfmNowPlayingService
'MAXIMUM', // DfmNowPlayingService
'Radio Monte Carlo', // DfmNowPlayingService
'Love Radio', // LoveNowPlayingService
'Radio ROKS', // RoksNowPlayingService
] as const;

View File

@@ -0,0 +1,149 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
interface DfmCurrent {
artist?: string;
title?: string;
cover?: string;
genres?: string;
}
/**
* Now-playing для станций Крутой Медиа (DFM и все его сабканалы — Skrillex,
* Daft Punk, K-Pop, Игромания и т.д.). Единый веб-API dfm.ru/api/n/current
* отдаёт текущий трек + WebP-обложку по всем ~147 каналам (ключ — slug).
* Сопоставляем наши станции (группа DFM) по нормализованному имени.
*/
@Injectable()
export class DfmNowPlayingService {
private readonly logger = new Logger(DfmNowPlayingService.name);
private readonly headers = {
'User-Agent': 'Mozilla/5.0',
Referer: 'https://dfm.ru/',
};
// Стойкие случаи: нормализованное имя нашей станции -> slug в API
private readonly alias: Record<string, string> = {
'dance-gold-00-s': 'dance-gold-2000s',
'dance-gold-10-s': 'dance-gold-2010s',
'dance-gold-20-s': 'dance-gold-2020s',
'dance-gold-90-s': 'dance-gold-1990s',
'pop-gold-00-s': 'pop-gold-2000s',
'pop-gold-10-s': 'pop-gold-2010s',
'pop-gold-20-s': 'pop-gold2020s',
'pop-gold-90-s': 'pop-gold-1990s',
'festival-gold': 'festivals-gold',
pioneer: '59-dfm-pioneer',
игромания: '61-igromaniq',
'vocal-trance': 'trance',
'disco-90th': 'diskach-90h',
// MAXIMUM (тот же Крутой Медиа, тот же /api/n/current)
britpop: '130-maxbritpop',
covers: '129-maxcover',
'heavy-80-s': '131-max80',
'heavy-monday': '141-heavymonday',
'maximum-90th': '145-maximum90',
millenium: '140-millenium',
'new-russians': '125-maxnewrussians',
punk: '132-maxpunk',
rhcp: '123-maxrhcp',
'rock-hits': '144-rockhits',
rugby: '139-rugby',
'russian-rock': '90-russkijrok',
soft: '127-maxsoft',
};
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollDfmNowPlaying() {
// DFM, MAXIMUM и Радио Монте-Карло — все сети Крутой Медиа, общий API
// dfm.ru/api/n/current
const stations = await this.prisma.station.findMany({
where: { genre: { in: ['DFM', 'MAXIMUM', 'Radio Monte Carlo'] } },
});
if (stations.length === 0) return;
const res = await fetch('https://dfm.ru/api/n/current', {
headers: this.headers,
});
if (!res.ok) {
this.logger.warn(`DFM api/n/current вернул ${res.status}`);
return;
}
const json = (await res.json()) as {
result?: { data?: Record<string, { current?: DfmCurrent }> };
};
const data = json.result?.data;
if (!data) return;
// Индекс: slug и его варианты (без дефисов, без числового префикса) -> current
const idx = new Map<string, DfmCurrent>();
for (const slug of Object.keys(data)) {
const cur = data[slug].current;
if (!cur?.artist || !cur?.title) continue;
const base = slug.replace(/^\d+-/, '');
for (const key of [slug, slug.replace(/-/g, ''), base, base.replace(/-/g, '')]) {
if (!idx.has(key)) idx.set(key, cur);
}
}
let updated = 0;
for (const station of stations) {
const n = this.norm(station.name);
const aliasSlug = this.alias[n];
// Слаг из маута потока (basename без битрейта) — основной ключ для
// Монте-Карло (имя станции не совпадает с API-слагом, а маут совпадает:
// `mccovers96.aacp` → `mccovers`, `blues96.aacp` → `blues`).
const mount = this.mountSlug(station.streamUrl);
const cur =
idx.get(n) ??
idx.get(n.replace(/-/g, '')) ??
(mount ? idx.get(mount) : undefined) ??
(aliasSlug ? data[aliasSlug]?.current : undefined);
if (!cur?.artist || !cur?.title) continue;
const cover = cur.cover
? cur.cover.startsWith('http')
? cur.cover
: `https://dfm.ru${cur.cover}`
: null;
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist: cur.artist.trim(),
song: cur.title.trim(),
coverUrl: cover,
});
if (!station.isOnline) {
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: true },
});
}
updated++;
}
this.logger.log(`Krutoy poll: ${updated}/${stations.length} обновлено`);
}
private norm(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9а-я]+/gi, '-')
.replace(/^-|-$/g, '');
}
// Слаг из маута потока: basename пути без расширения и хвостового битрейта.
// `http://mc-blues.hostingradio.ru/blues96.aacp` → `blues`.
private mountSlug(streamUrl: string): string | null {
const m = streamUrl.match(/\/([a-z0-9_-]+?)\d*\.(?:aacp|aac|mp3|m3u8)/i);
return m ? m[1].toLowerCase() : null;
}
}

View File

@@ -0,0 +1,134 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
// Элемент истории meta.hostingradio.ru (нужные поля)
interface EmgHistoryItem {
artist?: string;
title?: string;
type?: number;
coverImageWebpUrl600?: string;
coverImageUrl600?: string;
}
/**
* Now-playing для станций группы ЕМГ (Европа Плюс, Ретро FM, Дорожное, Радио 7,
* Studio 21, Эльдорадио и их сабканалы). Все они вещают через emgsound.ru, а текущий
* трек с обложкой отдаёт единый сервис meta.hostingradio.ru/emg/{slug}/history.
* slug берём из хоста потока: hls-NN-{slug}.emgsound.ru. order=desc → первый = сейчас.
*/
@Injectable()
export class EmgNowPlayingService {
private readonly logger = new Logger(EmgNowPlayingService.name);
private readonly headers = {
'User-Agent': 'Mozilla/5.0',
Origin: 'https://europaplus.ru',
Referer: 'https://europaplus.ru/',
};
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollEmgNowPlaying() {
// Не фильтруем по isOnline: health-check ошибочно метит HLS-потоки offline.
// Radio 7 — тоже ЕМГ, но на старых мейнах radio7.hostingradio.ru (slug в meta).
const stations = await this.prisma.station.findMany({
where: {
OR: [
{ streamUrl: { contains: 'emgsound.ru' } },
{ streamUrl: { contains: 'radio7.hostingradio.ru' } },
],
},
});
if (stations.length === 0) return;
const { date, from, to } = this.mskWindow();
let updated = 0;
await Promise.allSettled(
stations.map(async (station) => {
const slug = this.extractSlug(station.streamUrl);
if (!slug) return;
// Slug из хоста потока не всегда = slug в meta (напр. hls-01-fresh →
// в meta это europaplus-fresh). Если по основному пусто — пробуем префикс.
const candidates = [slug];
if (!slug.startsWith('europaplus') && slug !== 'dfm') {
candidates.push(`europaplus-${slug}`);
}
let cur: EmgHistoryItem | null = null;
for (const c of candidates) {
const url =
`https://meta.hostingradio.ru/emg/${c}/history` +
`?format=native&types=3&order=desc&date=${date}&from=${from}&to=${to}`;
const res = await fetch(url, { headers: this.headers });
if (!res.ok) continue;
const items = (await res.json()) as EmgHistoryItem[] | unknown;
const first = Array.isArray(items) ? (items[0] as EmgHistoryItem) : null;
if (first?.artist && first?.title) {
cur = first;
break;
}
}
if (!cur?.artist || !cur?.title) return;
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist: cur.artist.trim(),
song: cur.title.trim(),
coverUrl: cur.coverImageWebpUrl600 ?? cur.coverImageUrl600 ?? null,
});
// Станция явно в эфире — поправим ошибочный offline-флаг
if (!station.isOnline) {
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: true },
});
}
updated++;
}),
);
this.logger.log(`EMG poll: ${updated}/${stations.length} обновлено`);
}
// hls-01-europaplus-kpop.emgsound.ru → europaplus-kpop
// radio7.hostingradio.ru:8040/radio7_love64.mp3 → radio7-love (strip bitrate, _→-)
private extractSlug(streamUrl: string): string | null {
const emg = streamUrl.match(/hls-\d+-([a-z0-9-]+)\.emgsound\.ru/i);
if (emg) return emg[1].toLowerCase();
// Маунты radio7: radio7{br}, radio7_love{br}, radio7_happiness{br} (br=64/128/256).
// ВАЖНО: нельзя резать цифры с конца «\d+$» — у «radio7128» это съест «7» (→radio).
// Берём «radio7» + опциональный «_слово» (бренд), отбрасывая битрейт.
const r7 = streamUrl.match(/radio7\.hostingradio\.ru[:0-9]*\/(radio7(?:_[a-z]+)?)/i);
if (r7) return r7[1].toLowerCase().replace(/_/g, '-');
return null;
}
// Дата и окно времени по Москве (контейнер может быть в UTC)
private mskWindow(): { date: string; from: string; to: string } {
const fmt = (d: Date) => {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Moscow',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(d);
const g = (t: string) => parts.find((p) => p.type === t)?.value ?? '00';
return { date: `${g('year')}-${g('month')}-${g('day')}`, time: `${g('hour')}:${g('minute')}` };
};
const now = new Date();
const cur = fmt(now);
const start = fmt(new Date(now.getTime() - 120 * 60 * 1000));
return { date: cur.date, from: start.time, to: cur.time };
}
}

View File

@@ -0,0 +1,101 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
// Ответ AzuraCast https://radiogoose.ru/api/nowplaying/{slug}
interface AzuraNowPlaying {
is_online?: boolean;
now_playing?: {
song?: {
artist?: string;
title?: string;
art?: string;
};
};
}
/**
* Now-playing для сети ГУСЬ (radiogoose.ru) — это AzuraCast, у него штатный
* публичный API текущего трека с обложкой:
* GET https://radiogoose.ru/api/nowplaying/{slug} → now_playing.song {artist,title,art}.
* slug = сегмент потока /listen/{slug}/play. is_online из ответа поправляет
* ошибочный offline-флаг (health-check ранее спотыкался о многострочный URL).
*/
@Injectable()
export class GooseNowPlayingService {
private readonly logger = new Logger(GooseNowPlayingService.name);
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollGooseNowPlaying() {
const stations = await this.prisma.station.findMany({
where: { streamUrl: { contains: 'radiogoose.ru' } },
});
if (stations.length === 0) return;
let updated = 0;
await Promise.allSettled(
stations.map(async (station) => {
const slug = this.extractSlug(station.streamUrl);
if (!slug) return;
const res = await fetch(
`https://radiogoose.ru/api/nowplaying/${slug}`,
{ headers: this.headers },
);
if (!res.ok) return;
const data = (await res.json()) as AzuraNowPlaying;
if (data.is_online === false) return;
const song = data.now_playing?.song;
const artist = (song?.artist ?? '').trim();
const title = (song?.title ?? '').trim();
if (!artist || !title) return;
// ВНИМАНИЕ: art-URL AzuraCast (`song.art`) на radiogoose.ru отдаёт 404
// (route обложек на сервере не включён) — НЕ используем его, иначе на
// карточке битая картинка. Отдаём null → обложку подтянет наше обогащение
// (iTunes/Deezer по normKey), как у обычных ICY-станций.
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist,
song: title,
coverUrl: null,
});
if (!station.isOnline) {
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: true },
});
}
updated++;
}),
);
this.logger.log(`Goose poll: ${updated}/${stations.length} обновлено`);
}
// У большинства каналов mount потока == ключ now-playing API. Исключение:
// Технорейв — поток /listen/harddance/, а в API он /api/nowplaying/technorave.
private readonly slugAliases: Record<string, string> = {
harddance: 'technorave',
};
// https://radiogoose.ru/listen/bigroom/play → bigroom
private extractSlug(streamUrl: string): string | null {
const m = streamUrl.match(/\/listen\/([a-z0-9]+)\/play/i);
if (!m) return null;
const slug = m[1].toLowerCase();
return this.slugAliases[slug] ?? slug;
}
}

View File

@@ -1,26 +1,53 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingGateway } from './now-playing.gateway';
import * as http from 'http';
import * as https from 'https';
import { NowPlayingService } from './now-playing.service';
import { readIcyStreamTitle } from './icy-reader';
import { DEDICATED_GENRES, DEDICATED_STREAM_HOSTS } from './dedicated-sources';
/**
* Сбор now-playing для не-Record станций (DFM и др.) через ICY-метаданные потока.
* Станций много (сотни), поэтому за один тик опрашиваем окно и сдвигаем курсор —
* за несколько минут проходим все по кругу. Обложку и зачёт в чарты/обогащение
* берёт на себя NowPlayingService.ingest (обложка подтянется из нашей БД).
*/
@Injectable()
export class IcyNowPlayingService {
private readonly logger = new Logger(IcyNowPlayingService.name);
private cursor = 0;
private readonly windowSize = 70;
constructor(
private readonly prisma: PrismaService,
private readonly gateway: NowPlayingGateway,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(60000)
async pollIcyNowPlaying() {
this.logger.log('Starting ICY now playing poll...');
// Станции с выделенным now-playing-сервисом (через их API) исключаем из ICY,
// чтобы не тратить слоты впустую и не перезаписывать точные данные сырым ICY.
// Источник исключений — единый реестр dedicated-sources (host + genre),
// согласованный с селекторами самих сервисов.
const where = {
recordStationId: null,
isOnline: true,
genre: { notIn: [...DEDICATED_GENRES] },
AND: DEDICATED_STREAM_HOSTS.map((host) => ({
NOT: { streamUrl: { contains: host } },
})),
};
const total = await this.prisma.station.count({ where });
if (total === 0) return;
if (this.cursor >= total) this.cursor = 0;
const offset = this.cursor;
const stations = await this.prisma.station.findMany({
where: { recordStationId: null, isOnline: true },
take: 50,
where,
orderBy: { stationId: 'asc' },
skip: offset,
take: this.windowSize,
});
this.cursor += this.windowSize;
let successCount = 0;
@@ -28,146 +55,42 @@ export class IcyNowPlayingService {
const batch = stations.slice(i, i + 10);
const results = await Promise.allSettled(
batch.map(async (station) => {
const track = await this.parseIcyMetadata(station.streamUrl);
const track = await this.parseIcyTrack(station.streamUrl);
if (!track) return null;
const updated = await this.prisma.nowPlaying.upsert({
where: { stationId: station.id },
create: {
stationId: station.id,
song: track.song,
artist: track.artist,
coverUrl: null,
},
update: {
song: track.song,
artist: track.artist,
coverUrl: null,
},
});
this.gateway.broadcastNowPlaying(station.stationId.toString(), {
song: track.song,
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist: track.artist,
song: track.song,
coverUrl: null,
updatedAt: updated.updatedAt,
});
return track;
}),
);
for (let j = 0; j < results.length; j++) {
const result = results[j];
if (result.status === 'fulfilled' && result.value) {
successCount++;
} else if (result.status === 'rejected') {
this.logger.warn(
`ICY failed for ${batch[j].name}: ${result.reason?.message || result.reason}`,
);
}
for (const result of results) {
if (result.status === 'fulfilled' && result.value) successCount++;
}
}
this.logger.log(
`ICY poll complete: ${successCount}/${stations.length} stations updated`,
`ICY poll: ${successCount}/${stations.length} updated (offset ${offset}/${total})`,
);
}
private async parseIcyMetadata(
/** Читает StreamTitle через общий ICY-ридер и разбирает «Артист - Песня». */
private async parseIcyTrack(
url: string,
): Promise<{ artist: string; song: string } | null> {
return new Promise((resolve, reject) => {
const client = url.startsWith('https') ? https : http;
const req = client.get(
url,
{ headers: { 'Icy-MetaData': '1' }, timeout: 5000 },
(res) => {
const metaint = parseInt(
(res.headers['icy-metaint'] as string) || '0',
);
if (!metaint) {
req.destroy();
resolve(null);
return;
}
let audioBytesRead = 0;
let metaLength = 0;
let metaBytesRead = 0;
let metaBuffer = Buffer.alloc(0);
let state: 'audio' | 'meta-length' | 'meta' = 'audio';
res.on('data', (chunk: Buffer) => {
let offset = 0;
while (offset < chunk.length) {
if (state === 'audio') {
const need = metaint - audioBytesRead;
const available = chunk.length - offset;
const take = Math.min(need, available);
audioBytesRead += take;
offset += take;
if (audioBytesRead >= metaint) {
state = 'meta-length';
}
} else if (state === 'meta-length') {
metaLength = chunk[offset] * 16;
offset++;
if (metaLength === 0) {
audioBytesRead = 0;
state = 'audio';
} else {
metaBuffer = Buffer.alloc(0);
metaBytesRead = 0;
state = 'meta';
}
} else if (state === 'meta') {
const need = metaLength - metaBytesRead;
const available = chunk.length - offset;
const take = Math.min(need, available);
metaBuffer = Buffer.concat([
metaBuffer,
chunk.slice(offset, offset + take),
]);
metaBytesRead += take;
offset += take;
if (metaBytesRead >= metaLength) {
const metaStr = metaBuffer
.toString('utf-8')
.replace(/\x00/g, '');
const match = metaStr.match(/StreamTitle='([^']+)'/);
req.destroy();
if (!match) {
resolve(null);
return;
}
const parts = match[1].split(' - ', 2);
if (parts.length < 2) {
resolve({ artist: match[1], song: match[1] });
} else {
resolve({
artist: parts[0].trim(),
song: parts[1].trim(),
});
}
return;
}
}
}
});
res.on('error', (err) => {
req.destroy();
reject(err);
});
res.on('end', () => resolve(null));
},
);
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('Timeout'));
});
});
const raw = await readIcyStreamTitle(url, { timeoutMs: 5000 });
if (!raw) return null;
// Некоторые потоки (101.ru и др.) шлют в StreamTitle JSON-статус, а не трек.
if (raw.startsWith('{') || raw.startsWith('[')) return null;
const parts = raw.split(' - ', 2);
const artist = parts.length < 2 ? raw : parts[0].trim();
const song = parts.length < 2 ? raw : parts[1].trim();
if (!artist || !song) return null;
return { artist, song };
}
}

View File

@@ -0,0 +1,135 @@
import * as http from 'http';
import * as https from 'https';
export interface IcyReadOptions {
/** Таймаут сокета, мс (по умолчанию 8000). */
timeoutMs?: number;
/**
* Декодирование StreamTitle:
* - 'utf8' — как есть (по умолчанию);
* - 'auto-1251' — при битом UTF-8 (символ <20>) перечитать байты как windows-1251
* (нужно потокам с кириллицей в cp1251, напр. «Новое Радио BY»).
*/
decode?: 'utf8' | 'auto-1251';
/** Доп. заголовки запроса (User-Agent и т.п.). */
headers?: Record<string, string>;
}
/**
* Единая реализация чтения первого StreamTitle из ICY-метаданных потока.
* Раньше один и тот же state-machine разбора icy-metaint был скопирован в трёх
* сервисах (icy/novoeby/love) — теперь источник один.
*
* Возвращает очищенный заголовок (без \x00, trim) либо null. Никогда не реджектит
* (ошибки сети/таймаут → null), чтобы вызов был безопасен в Promise.allSettled.
*/
export function readIcyStreamTitle(
url: string,
opts: IcyReadOptions = {},
): Promise<string | null> {
const timeoutMs = opts.timeoutMs ?? 8000;
const decode = opts.decode ?? 'utf8';
return new Promise((resolve) => {
let done = false;
const finish = (v: string | null) => {
if (!done) {
done = true;
resolve(v);
}
};
try {
const lib = url.startsWith('https') ? https : http;
const req = lib.get(
url,
{
headers: { 'Icy-MetaData': '1', ...(opts.headers ?? {}) },
timeout: timeoutMs,
},
(res) => {
const metaint = parseInt(
(res.headers['icy-metaint'] as string) || '0',
);
if (!metaint) {
req.destroy();
finish(null);
return;
}
let audio = 0;
let metaLen = 0;
let metaBuf = Buffer.alloc(0);
let state: 'audio' | 'len' | 'meta' = 'audio';
res.on('data', (chunk: Buffer) => {
let off = 0;
while (off < chunk.length) {
if (state === 'audio') {
const take = Math.min(metaint - audio, chunk.length - off);
audio += take;
off += take;
if (audio >= metaint) state = 'len';
} else if (state === 'len') {
metaLen = chunk[off] * 16;
off++;
if (metaLen === 0) {
audio = 0;
state = 'audio';
} else {
metaBuf = Buffer.alloc(0);
state = 'meta';
}
} else {
const take = Math.min(
metaLen - metaBuf.length,
chunk.length - off,
);
metaBuf = Buffer.concat([
metaBuf,
chunk.slice(off, off + take),
]);
off += take;
if (metaBuf.length >= metaLen) {
req.destroy();
finish(extractTitle(metaBuf, decode));
return;
}
}
}
});
res.on('error', () => finish(null));
res.on('end', () => finish(null));
},
);
req.on('error', () => finish(null));
req.on('timeout', () => {
req.destroy();
finish(null);
});
} catch {
finish(null);
}
});
}
/**
* Достаёт StreamTitle из блока ICY-метаданных. Границы ищем побайтово (latin1),
* чтобы мультибайтовая кириллица не сбила смещения; терминатор — `';` (а не первый
* апостроф — иначе названия с апострофом, напр. «Song's Name», обрезались бы).
*/
function extractTitle(buf: Buffer, decode: 'utf8' | 'auto-1251'): string | null {
const latin = buf.toString('latin1');
const start = latin.indexOf("StreamTitle='");
if (start < 0) return null;
const from = start + "StreamTitle='".length;
const end = latin.indexOf("';", from);
if (end < 0) return null;
const titleBytes = buf.slice(from, end);
const utf8 = titleBytes.toString('utf8');
// <20> (<28>) — признак невалидного UTF-8 → перечитываем как windows-1251.
const decoded =
decode === 'auto-1251' && utf8.includes('<27>')
? new TextDecoder('windows-1251').decode(titleBytes)
: utf8;
const clean = decoded.replace(/\x00/g, '').trim();
return clean || null;
}

View File

@@ -0,0 +1,112 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
import { readIcyStreamTitle } from './icy-reader';
/**
* Now-playing для Love Radio. Их API (player/online) кэширует один трек на все
* каналы (игнорит musicStreamId), поэтому берём метаданные из САМИХ потоков:
* каждый n340-поток физически разный и несёт свой трек в ICY StreamTitle.
* Потоки защищены — нужен сессионный UID из player/config (привязан к IP сервера,
* бэкенд и читает поток со своего IP). Обложек нет — их даёт обогащение.
*/
@Injectable()
export class LoveNowPlayingService {
private readonly logger = new Logger(LoveNowPlayingService.name);
private uid: string | null = null;
// Имя нашей станции -> mount потока на n340
private readonly mount: Record<string, string> = {
'Love Radio': '12_love_128',
'Love RnB': '6_rnb_24',
'Love Top40': '9_top40_24',
'Love Dance': '7_dance_24',
'Love Gold': '3_gold_56',
'Love Russian': '8_russian_24',
'Love KPOP': '11_kpop_28',
'Love Power': '15_power_24',
'Love Chill': '4_chill_24',
'Love Summer': '5_summer_24',
};
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollLoveNowPlaying() {
const stations = await this.prisma.station.findMany({
where: { genre: 'Love Radio' },
});
if (stations.length === 0) return;
const uid = await this.getUid();
if (!uid) return;
let updated = 0;
await Promise.allSettled(
stations.map(async (station) => {
const m = this.mount[station.name];
if (!m) return;
const url = `https://stream2.n340.com/${m}?type=aac&UID=${uid}`;
const title = await readIcyStreamTitle(url, {
headers: { 'User-Agent': 'Mozilla/5.0' },
});
if (!title) return;
// «onlinestop56k» = заглушка (UID протух) — сбросим, добёрём на след. цикле
if (title === 'onlinestop56k') {
this.uid = null;
return;
}
const parts = title.split(' - ');
if (parts.length < 2) return;
const artist = parts[0].trim();
const song = parts.slice(1).join(' - ').trim();
if (!artist || !song) return;
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist,
song,
coverUrl: null,
});
if (!station.isOnline) {
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: true },
});
}
updated++;
}),
);
this.logger.log(`Love poll: ${updated}/${stations.length} обновлено`);
}
// Сессионный UID из player/config (кэшируем; сбрасываем при заглушке)
private async getUid(): Promise<string | null> {
if (this.uid) return this.uid;
try {
const res = await fetch(
'https://api.loveradio.ru/api/v1/love-radio/player/config',
{
headers: {
'User-Agent': 'Mozilla/5.0',
Referer: 'https://www.loveradio.ru/',
Origin: 'https://www.loveradio.ru',
},
},
);
if (!res.ok) return null;
const json = (await res.json()) as { data?: { uid?: string } };
this.uid = json.data?.uid ?? null;
return this.uid;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,65 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
import { readIcyStreamTitle } from './icy-reader';
/**
* Now-playing для «Новое Радио BY» (Беларусь, live.novoeradio.by). Их Icecast-мейны
* отдают ICY StreamTitle, НО кириллица в нём — windows-1251 (общий ICY-поллер читает
* как UTF-8 → каша «<><C2AB><EFBFBD><EFBFBD>»). Поэтому отдельный сервис: читаем ICY и декодируем UTF-8,
* а при «битых» байтах — windows-1251. Опрос 30с (общий поллер крутит всё по кругу
* ~9 мин — для now-playing слишком лениво). Обложку даёт обогащение (iTunes/Deezer).
*/
@Injectable()
export class NovoeByNowPlayingService {
private readonly logger = new Logger(NovoeByNowPlayingService.name);
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollNovoeByNowPlaying() {
const stations = await this.prisma.station.findMany({
where: { streamUrl: { contains: 'novoeradio.by' } },
});
if (stations.length === 0) return;
let updated = 0;
await Promise.allSettled(
stations.map(async (station) => {
// Кириллица в их ICY — windows-1251 (decode: auto-1251).
const title = await readIcyStreamTitle(station.streamUrl, {
decode: 'auto-1251',
});
if (!title || title.startsWith('{') || title.startsWith('[')) return;
// Джинглы/заставки без трека («NOVOE RADIO MEGAMIX» и т.п.) — пропускаем.
const sep = title.indexOf(' - ');
if (sep < 0) return;
const artist = title.slice(0, sep).trim();
const song = title.slice(sep + 3).trim();
if (!artist || !song) return;
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist,
song,
coverUrl: null,
});
if (!station.isOnline) {
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: true },
});
}
updated++;
}),
);
this.logger.log(`NovoeBY poll: ${updated}/${stations.length} обновлено`);
}
}

View File

@@ -0,0 +1,23 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { NowPlayingService } from './now-playing.service';
@ApiTags('now-playing')
@Controller('now-playing')
export class NowPlayingController {
constructor(private readonly nowPlayingService: NowPlayingService) {}
// Текущие треки по всем станциям, ключ — числовой id станции (как в каталоге).
@Get()
@ApiOperation({ summary: 'Текущие треки по всем станциям' })
async getAll() {
const list = await this.nowPlayingService.getAllNowPlaying();
return list.map((np) => ({
stationId: np.station.stationId,
name: np.station.name,
song: np.song,
artist: np.artist,
coverUrl: np.coverUrl,
}));
}
}

View File

@@ -1,17 +1,43 @@
import { Module, forwardRef } from '@nestjs/common';
import { NowPlayingGateway } from './now-playing.gateway';
import { NowPlayingController } from './now-playing.controller';
import { NowPlayingService } from './now-playing.service';
import { RecordStationSyncService } from './record-station-sync.service';
import { IcyNowPlayingService } from './icy-now-playing.service';
import { EmgNowPlayingService } from './emg-now-playing.service';
import { DfmNowPlayingService } from './dfm-now-playing.service';
import { LoveNowPlayingService } from './love-now-playing.service';
import { RoksNowPlayingService } from './roks-now-playing.service';
import { UnistarNowPlayingService } from './unistar-now-playing.service';
import { ZaycevNowPlayingService } from './zaycev-now-playing.service';
import { GooseNowPlayingService } from './goose-now-playing.service';
import { NovoeByNowPlayingService } from './novoeby-now-playing.service';
import { SpbRadioNowPlayingService } from './spb-radio-now-playing.service';
import { VolnaNowPlayingService } from './volna-now-playing.service';
import { Radio101NowPlayingService } from './radio101-now-playing.service';
import { OrpheusNowPlayingService } from './orpheus-now-playing.service';
import { ChartsModule } from '../charts/charts.module';
@Module({
imports: [forwardRef(() => ChartsModule)],
controllers: [NowPlayingController],
providers: [
NowPlayingGateway,
NowPlayingService,
RecordStationSyncService,
IcyNowPlayingService,
EmgNowPlayingService,
DfmNowPlayingService,
LoveNowPlayingService,
RoksNowPlayingService,
UnistarNowPlayingService,
ZaycevNowPlayingService,
GooseNowPlayingService,
NovoeByNowPlayingService,
SpbRadioNowPlayingService,
VolnaNowPlayingService,
Radio101NowPlayingService,
OrpheusNowPlayingService,
],
exports: [NowPlayingService],
})

View File

@@ -56,49 +56,17 @@ export class NowPlayingService {
const mapping = this.recordSync.getStationByNowPlayingId(np.id);
if (!mapping) continue;
const coverUrl = np.track.image600 ?? np.track.image200 ?? np.track.image100;
const coverUrl =
np.track.image600 ?? np.track.image200 ?? np.track.image100;
// Получаем текущее состояние до апдейта, чтобы определить смену трека
const prev = await this.prisma.nowPlaying.findUnique({
where: { stationId: mapping.dbId },
});
const updated = await this.prisma.nowPlaying.upsert({
where: { stationId: mapping.dbId },
create: {
stationId: mapping.dbId,
song: np.track.song,
artist: np.track.artist,
coverUrl,
},
update: {
song: np.track.song,
artist: np.track.artist,
coverUrl,
},
});
this.gateway.broadcastNowPlaying(mapping.stationId.toString(), {
song: np.track.song,
await this.ingest({
stationDbId: mapping.dbId,
stationNumericId: mapping.stationId,
artist: np.track.artist,
song: np.track.song,
coverUrl,
updatedAt: updated.updatedAt,
});
updatedCount++;
// Засчитываем проигрывание только при смене трека
const trackChanged =
!prev ||
prev.song !== np.track.song ||
prev.artist !== np.track.artist;
if (trackChanged) {
void this.chartsService.recordPlay({
artist: np.track.artist,
song: np.track.song,
coverUrl,
stationDbId: mapping.dbId,
});
}
}
this.logger.log(
@@ -113,6 +81,12 @@ export class NowPlayingService {
stationId: string,
data: { song: string; artist: string; coverUrl?: string },
) {
const coverUrl = await this.resolveCover(
data.artist,
data.song,
data.coverUrl,
);
// Получаем текущее состояние до апдейта, чтобы определить смену трека
const prev = await this.prisma.nowPlaying.findUnique({
where: { stationId },
@@ -120,23 +94,14 @@ export class NowPlayingService {
const nowPlaying = await this.prisma.nowPlaying.upsert({
where: { stationId },
create: {
stationId,
song: data.song,
artist: data.artist,
coverUrl: data.coverUrl,
},
update: {
song: data.song,
artist: data.artist,
coverUrl: data.coverUrl,
},
create: { stationId, song: data.song, artist: data.artist, coverUrl },
update: { song: data.song, artist: data.artist, coverUrl },
});
this.gateway.broadcastNowPlaying(stationId, {
song: data.song,
artist: data.artist,
coverUrl: data.coverUrl,
coverUrl,
updatedAt: nowPlaying.updatedAt,
});
@@ -147,7 +112,7 @@ export class NowPlayingService {
void this.chartsService.recordPlay({
artist: data.artist,
song: data.song,
coverUrl: data.coverUrl,
coverUrl,
stationDbId: stationId,
});
}
@@ -155,6 +120,96 @@ export class NowPlayingService {
return nowPlaying;
}
/**
* Универсальный приём now-playing из любого источника (Record / ICY).
* Если источник не дал обложку — подставляем обложку обогащённого трека
* из нашей БД (по normKey). Обновляет now_playing, шлёт сокет, засчитывает
* проигрывание при смене трека (что запускает обогащение через Discogs).
*/
async ingest(params: {
stationDbId: string;
stationNumericId: number;
artist: string;
song: string;
coverUrl?: string | null;
}): Promise<void> {
const { stationDbId, stationNumericId, artist, song } = params;
// Отсекаем мусор: пустое или JSON-статус в полях (некоторые потоки шлют
// в метаданных {"status":...} вместо трека).
const a = (artist ?? '').trim();
const s = (song ?? '').trim();
if (!a || !s || a.startsWith('{') || a.startsWith('[') || s.startsWith('{')) {
return;
}
const coverUrl = await this.resolveCover(artist, song, params.coverUrl);
const prev = await this.prisma.nowPlaying.findUnique({
where: { stationId: stationDbId },
});
// Ничего не изменилось (станцию опросили, трек тот же) — не пишем в БД и не
// шлём сокет: иначе ~20k бесполезных upsert/час и лишний churn индексов.
if (
prev &&
prev.song === song &&
prev.artist === artist &&
prev.coverUrl === coverUrl
) {
return;
}
const updated = await this.prisma.nowPlaying.upsert({
where: { stationId: stationDbId },
create: { stationId: stationDbId, song, artist, coverUrl },
update: { song, artist, coverUrl },
});
this.gateway.broadcastNowPlaying(stationNumericId.toString(), {
song,
artist,
coverUrl,
updatedAt: updated.updatedAt,
});
const trackChanged = !prev || prev.song !== song || prev.artist !== artist;
if (trackChanged) {
void this.chartsService.recordPlay({
artist,
song,
coverUrl,
stationDbId,
});
}
}
// Нормализованный ключ трека — совпадает с ChartsService.recordPlay
private buildNormKey(artist: string, song: string): string {
return (
artist.trim().toLowerCase().replace(/\s+/g, ' ') +
'|' +
song.trim().toLowerCase().replace(/\s+/g, ' ')
);
}
// Обложка: если источник дал — берём её, иначе обложку из обогащённого трека
private async resolveCover(
artist: string,
song: string,
provided?: string | null,
): Promise<string | null> {
if (provided) return provided;
const a = (artist ?? '').trim();
const s = (song ?? '').trim();
if (!a || !s) return null;
const track = await this.prisma.track.findUnique({
where: { normKey: this.buildNormKey(a, s) },
select: { coverUrl: true },
});
return track?.coverUrl ?? null;
}
async getNowPlaying(stationId: string) {
return this.prisma.nowPlaying.findUnique({
where: { stationId },
@@ -162,8 +217,38 @@ export class NowPlayingService {
}
async getAllNowPlaying() {
return this.prisma.nowPlaying.findMany({
include: { station: true },
// Проекция: контроллеру нужны только stationId+name станции и трек —
// не тянем всю строку Station (streamUrl, tags[], даты и т.д.) на каждый ряд.
const list = await this.prisma.nowPlaying.findMany({
select: {
artist: true,
song: true,
coverUrl: true,
station: { select: { stationId: true, name: true } },
},
});
// Для записей без своей обложки (ICY-станции типа DFM) подтягиваем обложку
// обогащённого трека из нашей БД по normKey — на чтении, чтобы она появлялась
// сразу после обогащения, не дожидаясь следующего опроса станции.
const missing = list.filter((np) => !np.coverUrl && np.artist && np.song);
if (missing.length > 0) {
const keys = [
...new Set(missing.map((np) => this.buildNormKey(np.artist, np.song))),
];
const tracks = await this.prisma.track.findMany({
where: { normKey: { in: keys }, coverUrl: { not: null } },
select: { normKey: true, coverUrl: true },
});
const coverByKey = new Map(tracks.map((t) => [t.normKey, t.coverUrl]));
for (const np of list) {
if (!np.coverUrl && np.artist && np.song) {
const cover = coverByKey.get(this.buildNormKey(np.artist, np.song));
if (cover) np.coverUrl = cover;
}
}
}
return list;
}
}

View File

@@ -0,0 +1,140 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
interface IceSource {
listenurl?: string;
title?: string;
}
/**
* Now-playing для «Орфей» (классика, radio.orpheus.ru). ICY в потоке часто пуст или
* с хекс-плейсхолдером, но Icecast `status-json.xsl` отдаёт title по всем маунтам.
* Качество разное: у части каналов нормальный «Композитор — Произведение», у части
* мусор (hex/URL/undefined) — его пропускаем. Обложки у классики в iTunes почти нет,
* поэтому coverUrl=null (что найдётся — подтянет обогащение). Каналы на
* orfeyfm.hostingradio.ru (главный FM) трека не дают — остаются без подписи.
*/
@Injectable()
export class OrpheusNowPlayingService {
private readonly logger = new Logger(OrpheusNowPlayingService.name);
private readonly statusUrl = 'https://radio.orpheus.ru:8000/status-json.xsl';
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollOrpheusNowPlaying() {
const stations = await this.prisma.station.findMany({
where: { streamUrl: { contains: 'radio.orpheus.ru' } },
});
if (stations.length === 0) return;
const byMount = await this.loadStatus();
if (!byMount) return;
let updated = 0;
await Promise.allSettled(
stations.map(async (station) => {
const mount = this.extractMount(station.streamUrl);
const title = mount ? byMount[mount] : undefined;
const parsed = this.parseTitle(title);
if (!parsed) return;
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist: parsed.artist,
song: parsed.song,
coverUrl: null,
});
if (!station.isOnline) {
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: true },
});
}
updated++;
}),
);
this.logger.log(`Orpheus poll: ${updated}/${stations.length} обновлено`);
}
private async loadStatus(): Promise<Record<string, string> | null> {
try {
const res = await fetch(this.statusUrl, { headers: this.headers });
if (!res.ok) return null;
const data = (await res.json()) as { icestats?: { source?: IceSource | IceSource[] } };
const src = data.icestats?.source;
const arr = Array.isArray(src) ? src : src ? [src] : [];
const map: Record<string, string> = {};
for (const s of arr) {
const mount = (s.listenurl ?? '').split('/').pop();
if (mount && typeof s.title === 'string') map[mount] = s.title;
}
return map;
} catch {
return null;
}
}
// https://radio.orpheus.ru:8000/Chan_8_192.mp3 → Chan_8_192.mp3
private extractMount(streamUrl: string): string | null {
const m = streamUrl.match(/\/([A-Za-z0-9_]+\.(?:mp3|aac))(?:$|\?)/);
return m ? m[1] : null;
}
/**
* Чинит кодировку: реальную кириллицу (U+0400-04FF) не трогаем; «двойную
* мойибейк» (cp1251-байты, прочитанные как latin1 и завёрнутые в UTF-8 —
* признак латиницы-1 À-ÿ) восстанавливаем latin1→windows-1251.
*/
private fixEncoding(s: string): string {
if (/[Ѐ-ӿ]/.test(s)) return s; // уже корректная кириллица
if (/[À-ÿ]/.test(s)) {
try {
return new TextDecoder('windows-1251').decode(Buffer.from(s, 'latin1'));
} catch {
return s;
}
}
return s;
}
/** Разбирает title «Артист — Произведение», чиня кодировку и отсекая мусор/служебные id. */
private parseTitle(title?: string): { artist: string; song: string } | null {
if (!title) return null;
let t = this.fixEncoding(title).replace(/\s+/g, ' ').trim();
if (!t || t.toLowerCase() === 'undefined') return null;
// Срезаем хвостовой служебный id трека: « - f0098627», « - 147-1-10», « - 263-2-01»
t = t
.replace(/\s*[-—]\s*[0-9a-f]{6,}\s*$/i, '')
.replace(/\s*[-—]\s*\d+-\d+(?:-\d+)?\s*$/, '')
.trim();
if (!t) return null;
// Целиком мусор: hex-плейсхолдер, числовой код, URL, JSON
if (
/^[0-9a-f]{4,}$/i.test(t) ||
/^\d+-\d+/.test(t) ||
t.startsWith('http') ||
t.startsWith('{')
) {
return null;
}
// Разделитель — длинное тире или дефис с пробелами (оба длиной 3 символа: « X »)
const emIdx = t.indexOf(' — ');
const idx = emIdx >= 0 ? emIdx : t.indexOf(' - ');
if (idx < 0) return null;
const artist = t.slice(0, idx).trim();
const song = t.slice(idx + 3).trim();
if (!artist || !song) return null;
// Часть после чистки всё ещё мусорная
if (/^[0-9a-f]{4,}$/i.test(song) || /^[0-9a-f]{4,}$/i.test(artist)) return null;
return { artist, song };
}
}

View File

@@ -0,0 +1,84 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
interface Radio101Resp {
result?: {
short?: {
titleTrack?: string;
titleExecutorFull?: string;
cover?: { coverOriginal?: string };
};
};
}
/**
* Now-playing для станций на платформе 101.ru (Comedy Radio, Радио Energy/NRJ).
* Потоки pub*.101.ru/stream/.../{channelId} — id канала = последний сегмент.
* Трек: `https://101.ru/api/channel/getTrackOnAir/{id}/?dataFormat=json&idcity=1`
* → result.short {titleExecutorFull, titleTrack, cover.coverOriginal}. Обложка —
* cdn0.101.ru + coverOriginal. (Comedy: «комик - реприза» — это их «в эфире».)
*/
@Injectable()
export class Radio101NowPlayingService {
private readonly logger = new Logger(Radio101NowPlayingService.name);
private readonly coverBase = 'https://cdn0.101.ru';
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollRadio101NowPlaying() {
const stations = await this.prisma.station.findMany({
where: { streamUrl: { contains: '.101.ru' } },
});
if (stations.length === 0) return;
let updated = 0;
await Promise.allSettled(
stations.map(async (station) => {
const id = this.extractChannelId(station.streamUrl);
if (!id) return;
const res = await fetch(
`https://101.ru/api/channel/getTrackOnAir/${id}/?dataFormat=json&idcity=1`,
{ headers: this.headers, redirect: 'follow' },
);
if (!res.ok) return;
const short = ((await res.json()) as Radio101Resp).result?.short;
const artist = (short?.titleExecutorFull ?? '').trim();
const song = (short?.titleTrack ?? '').trim();
if (!artist || !song) return;
const coverPath = short?.cover?.coverOriginal;
const coverUrl = coverPath ? this.coverBase + coverPath : null;
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist,
song,
coverUrl,
});
if (!station.isOnline) {
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: true },
});
}
updated++;
}),
);
this.logger.log(`Radio101 poll: ${updated}/${stations.length} обновлено`);
}
// https://pub0302.101.ru:8000/stream/pro/aac/64/446 → 446
private extractChannelId(streamUrl: string): string | null {
const m = streamUrl.match(/\/(\d+)\/?$/);
return m ? m[1] : null;
}
}

View File

@@ -0,0 +1,97 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
interface TavrTrack {
singer?: string;
song?: string;
cover?: string;
type?: string;
}
/**
* Now-playing для Radio ROKS (TavR Media). Главный канал не отдаёт трек по ICY
* (StreamTitle пустой), а сабканалы — без обложек. У TavR есть JSON-API, который
* даёт и трек, и обложку (static.radioroks.ua, 500×500):
* • https://o.tavr.media/roks — главный канал, [0] = текущий трек
* • https://o.tavr.media/roks4songs — сабканалы, по полю type (ukr/bal/new/har)
*/
@Injectable()
export class RoksNowPlayingService {
private readonly logger = new Logger(RoksNowPlayingService.name);
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
// Подстрока в имени нашей станции -> type в roks4songs.
// HD-варианты ловятся теми же правилами (Ballads HD, New Rock HD и т.д.).
private readonly typeByName: { match: RegExp; type: string }[] = [
{ match: /ballads/i, type: 'bal' },
{ match: /hard/i, type: 'har' },
{ match: /new\s*rock/i, type: 'new' },
{ match: /ukrai|укра/i, type: 'ukr' },
];
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollRoks() {
const stations = await this.prisma.station.findMany({
where: { genre: 'Radio ROKS' },
});
if (stations.length === 0) return;
const [main, subs] = await Promise.all([
this.fetchTavr('https://o.tavr.media/roks'),
this.fetchTavr('https://o.tavr.media/roks4songs'),
]);
if (!main && !subs) return;
const mainCur = main?.[0];
// type -> текущий трек сабканала (первый по времени = играющий сейчас)
const subByType = new Map<string, TavrTrack>();
for (const s of subs ?? []) {
if (s.type && !subByType.has(s.type)) subByType.set(s.type, s);
}
let updated = 0;
for (const station of stations) {
const rule = this.typeByName.find((r) => r.match.test(station.name));
const cur = rule ? subByType.get(rule.type) : mainCur;
if (!cur?.singer || !cur?.song) continue;
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist: cur.singer.trim(),
song: cur.song.trim(),
coverUrl: cur.cover || null,
});
if (!station.isOnline) {
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: true },
});
}
updated++;
}
this.logger.log(`ROKS poll: ${updated}/${stations.length} обновлено`);
}
private async fetchTavr(url: string): Promise<TavrTrack[] | null> {
try {
const res = await fetch(url, { headers: this.headers });
if (!res.ok) {
this.logger.warn(`${url} вернул ${res.status}`);
return null;
}
return (await res.json()) as TavrTrack[];
} catch (e) {
this.logger.warn(`${url}: ${(e as Error).message}`);
return null;
}
}
}

View File

@@ -0,0 +1,117 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
interface SpbStream {
id: number;
link?: string;
}
interface SpbPlaylist {
items?: Array<{
track?: {
name?: string;
imglarge?: string;
imgsmall?: string;
artist?: { name?: string };
};
}>;
}
// Сети на одном движке (radiopiterfm.ru / radiovanya.ru — один разработчик, СПб):
// API `/api/v1/streams/` (link↔id) + `/api/v5/playlists/{id}/` → items[0].track.
const NETWORKS = [
{ base: 'https://radiopiterfm.ru', host: 'piterfm.cdnvideo.ru', label: 'PiterFM' },
{ base: 'https://radiovanya.ru', host: 'radiovanya.cdnvideo.ru', label: 'RadioVanya' },
];
/**
* Now-playing для Питер ФМ и Радио Ваня. Потоки (cdnvideo Icecast) НЕ дают трек по ICY
* (пусто / URL сайта), но у сайтов общий API. Матчим станцию по МАУНТУ из поля `link`
* стрима (у Вани slug≠маунт, поэтому не по slug). Обложки готовые (iTunes mzstatic).
*/
@Injectable()
export class SpbRadioNowPlayingService {
private readonly logger = new Logger(SpbRadioNowPlayingService.name);
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollSpbNowPlaying() {
for (const net of NETWORKS) {
await this.pollNetwork(net).catch((e) =>
this.logger.warn(`${net.label}: ${e?.message ?? e}`),
);
}
}
private async pollNetwork(net: (typeof NETWORKS)[number]) {
const stations = await this.prisma.station.findMany({
where: { streamUrl: { contains: net.host } },
});
if (stations.length === 0) return;
const mountToId = await this.loadStreamMap(net.base);
if (!mountToId) return;
let updated = 0;
await Promise.allSettled(
stations.map(async (station) => {
const mount = this.extractMount(station.streamUrl);
const id = mount ? mountToId[mount.toLowerCase()] : undefined;
if (id === undefined) return;
const res = await fetch(`${net.base}/api/v5/playlists/${id}/`, {
headers: this.headers,
});
if (!res.ok) return;
const t = ((await res.json()) as SpbPlaylist).items?.[0]?.track;
const artist = (t?.artist?.name ?? '').trim();
const song = (t?.name ?? '').trim();
if (!artist || !song) return;
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist,
song,
coverUrl: t?.imglarge ?? t?.imgsmall ?? null,
});
if (!station.isOnline) {
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: true },
});
}
updated++;
}),
);
this.logger.log(`${net.label} poll: ${updated}/${stations.length} обновлено`);
}
private async loadStreamMap(base: string): Promise<Record<string, number> | null> {
try {
const res = await fetch(`${base}/api/v1/streams/`, { headers: this.headers });
if (!res.ok) return null;
const data = (await res.json()) as { items?: SpbStream[] };
const map: Record<string, number> = {};
for (const s of data.items ?? []) {
const mount = this.extractMount(s.link ?? '');
if (mount) map[mount.toLowerCase()] = s.id;
}
return map;
} catch {
return null;
}
}
// .../cdnvideo.ru/pfm_ddt → pfm_ddt ; .../radiovanya → radiovanya
private extractMount(url: string): string | null {
const m = url.match(/cdnvideo\.ru\/([A-Za-z0-9_]+)/i);
return m ? m[1] : null;
}
}

View File

@@ -0,0 +1,106 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
// Ответ https://api3.unistar.by/client/latest/{slug}
interface UnistarLatest {
latest: {
channel_name?: string;
element_data?: {
Type?: string; // Music | Commercial | Program | Jingle | News...
Title?: string;
Artist?: string;
PictureFile?: string;
FullPictureUrl?: string;
};
} | null;
}
/**
* Now-playing для каналов Unistar (Беларусь). Все 8 каналов вещают через HLS
* (usp.unistar.by) — ICY-метаданных нет, поэтому трек берём из их API:
* GET https://api3.unistar.by/client/latest/{slug}, где slug = alt_name канала
* (unistar_main, unistar_top, ...), он же сегмент пути потока /hls/{slug}/master.m3u8.
* API отдаёт исполнителя, название и имя файла обложки (своя картинка трека).
*/
@Injectable()
export class UnistarNowPlayingService {
private readonly logger = new Logger(UnistarNowPlayingService.name);
// База для имён файлов обложек (pics_path из appData плеера Unistar)
private readonly picsBase = 'https://unistar.by/upload/music/photos/';
private readonly headers = {
'User-Agent': 'Mozilla/5.0',
Origin: 'https://unistar.by',
Referer: 'https://unistar.by/',
};
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollUnistarNowPlaying() {
// Не фильтруем по isOnline: health-check ошибочно метит HLS-потоки offline.
const stations = await this.prisma.station.findMany({
where: { streamUrl: { contains: 'unistar.by' } },
});
if (stations.length === 0) return;
let updated = 0;
await Promise.allSettled(
stations.map(async (station) => {
const slug = this.extractSlug(station.streamUrl);
if (!slug) return;
const res = await fetch(
`https://api3.unistar.by/client/latest/${slug}`,
{ headers: this.headers },
);
if (!res.ok) return;
const data = (await res.json()) as UnistarLatest;
const el = data.latest?.element_data;
// Только музыка: рекламу/программы/джинглы не показываем как трек.
if (!el || el.Type !== 'Music') return;
const artist = (el.Artist ?? '').trim();
const song = (el.Title ?? '').trim();
if (!artist || !song) return;
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist,
song,
coverUrl: this.buildCoverUrl(el.PictureFile ?? el.FullPictureUrl),
});
if (!station.isOnline) {
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: true },
});
}
updated++;
}),
);
this.logger.log(`Unistar poll: ${updated}/${stations.length} обновлено`);
}
// http://edge1.usp.unistar.by/hls/unistar_top/master.m3u8 → unistar_top
private extractSlug(streamUrl: string): string | null {
const m = streamUrl.match(/\/hls\/([a-z0-9_]+)\//i);
return m ? m[1].toLowerCase() : null;
}
// Имя файла обложки → полный URL (или абсолютный URL как есть)
private buildCoverUrl(pic?: string): string | null {
const p = (pic ?? '').trim();
if (!p) return null;
return p.startsWith('http') ? p : this.picsBase + p;
}
}

View File

@@ -0,0 +1,99 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
/**
* Now-playing для «Русская Волна» (сеть Волна / amgradio.ru, ~27 каналов). Потоки
* mp3.amgradio.ru НЕ отдают ICY. Единый JSON со всеми каналами:
* `https://info.volna.top/radio.json` → поля `{prefix}_title` = «АРТИСТ - ПЕСНЯ».
* Маунт нашего потока (RusRock128, ChillaFM128…) приводим к префиксу volna
* (rusrock, chilla — с fallback на отброс «fm»). Обложек у них нет (covers 404) →
* подтянет обогащение (iTunes/Deezer).
*/
@Injectable()
export class VolnaNowPlayingService {
private readonly logger = new Logger(VolnaNowPlayingService.name);
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollVolnaNowPlaying() {
const stations = await this.prisma.station.findMany({
where: { streamUrl: { contains: 'amgradio.ru' } },
});
if (stations.length === 0) return;
let data: Record<string, unknown> | null = null;
try {
const res = await fetch('https://info.volna.top/radio.json', {
headers: this.headers,
});
if (res.ok) data = (await res.json()) as Record<string, unknown>;
} catch {
data = null;
}
if (!data) return;
const prefixes = new Set(
Object.keys(data)
.filter((k) => k.endsWith('_title'))
.map((k) => k.slice(0, -'_title'.length)),
);
let updated = 0;
await Promise.allSettled(
stations.map(async (station) => {
const prefix = this.resolvePrefix(station.streamUrl, prefixes);
if (!prefix) return;
const raw = data[`${prefix}_title`];
const parsed = this.parseTitle(typeof raw === 'string' ? raw : undefined);
if (!parsed) return;
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist: parsed.artist,
song: parsed.song,
coverUrl: null,
});
if (!station.isOnline) {
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: true },
});
}
updated++;
}),
);
this.logger.log(`Volna poll: ${updated}/${stations.length} обновлено`);
}
// mp3.amgradio.ru/RusRock128 → rusrock ; ChillaFM128 → chilla (fallback -fm)
private resolvePrefix(streamUrl: string, prefixes: Set<string>): string | null {
const m = streamUrl.match(/amgradio\.ru\/([A-Za-z0-9_]+)/i);
if (!m) return null;
const norm = m[1].replace(/\d+$/, '').toLowerCase();
if (prefixes.has(norm)) return norm;
if (norm.endsWith('fm') && prefixes.has(norm.slice(0, -2))) {
return norm.slice(0, -2);
}
return null;
}
private parseTitle(title?: string): { artist: string; song: string } | null {
if (!title) return null;
const t = title.trim();
if (!t || /listen radio|^https?:/i.test(t)) return null;
const idx = t.indexOf(' - ');
if (idx < 0) return null;
const artist = t.slice(0, idx).trim();
const song = t.slice(idx + 3).trim();
if (!artist || !song) return null;
return { artist, song };
}
}

View File

@@ -0,0 +1,100 @@
import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingService } from './now-playing.service';
// Ответ https://www.zaycev.fm/api/v1/recent?channel={slug}&limit=1
interface ZaycevRecentItem {
track?: {
artist?: string;
title?: string;
is_music?: boolean;
img?: string;
images?: {
medium?: string;
large?: string;
extralarge?: string;
};
};
station_alias?: string;
}
/**
* Now-playing для каналов Зайцев ФМ. Их MP3-потоки (abs.zaycev.fm/{slug}256k)
* НЕ отдают ICY-метаданных, поэтому трек берём из API сайта:
* GET https://www.zaycev.fm/api/v1/recent?channel={slug}&limit=1 — массив, [0] =
* текущий трек с artist/title и готовыми обложками (radio2.zaycev.fm/artistimages).
* slug = буквенная часть имени потока (pop256k → pop), совпадает со station_alias.
*/
@Injectable()
export class ZaycevNowPlayingService {
private readonly logger = new Logger(ZaycevNowPlayingService.name);
private readonly headers = {
'User-Agent': 'Mozilla/5.0',
Referer: 'https://www.zaycev.fm/',
};
constructor(
private readonly prisma: PrismaService,
private readonly nowPlayingService: NowPlayingService,
) {}
@Interval(30000)
async pollZaycevNowPlaying() {
const stations = await this.prisma.station.findMany({
where: { streamUrl: { contains: 'abs.zaycev.fm' } },
});
if (stations.length === 0) return;
let updated = 0;
await Promise.allSettled(
stations.map(async (station) => {
const slug = this.extractSlug(station.streamUrl);
if (!slug) return;
const res = await fetch(
`https://www.zaycev.fm/api/v1/recent?channel=${slug}&limit=1`,
{ headers: this.headers },
);
if (!res.ok) return;
const arr = (await res.json()) as ZaycevRecentItem[] | unknown;
const cur = Array.isArray(arr) ? arr[0] : null;
const t = cur?.track;
if (!t || t.is_music === false) return;
const artist = (t.artist ?? '').trim();
const song = (t.title ?? '').trim();
if (!artist || !song) return;
const coverUrl =
t.images?.large ?? t.images?.extralarge ?? t.images?.medium ?? t.img ?? null;
await this.nowPlayingService.ingest({
stationDbId: station.id,
stationNumericId: station.stationId,
artist,
song,
coverUrl,
});
if (!station.isOnline) {
await this.prisma.station.update({
where: { id: station.id },
data: { isOnline: true },
});
}
updated++;
}),
);
this.logger.log(`Zaycev poll: ${updated}/${stations.length} обновлено`);
}
// http://abs.zaycev.fm/pop256k → pop ; rurock256k → rurock
private extractSlug(streamUrl: string): string | null {
const m = streamUrl.match(/abs\.zaycev\.fm\/([a-z]+)\d/i);
return m ? m[1].toLowerCase() : null;
}
}

View File

@@ -1,11 +1,29 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
/**
* Добавляет connection_limit в URL, если он не задан явно. По умолчанию Prisma
* берёт num_cpu*2+1 (на мелком VPS ~5), а у нас ~16 now-playing-поллеров + чарты
* шлют запросы конкурентно — пул из 20 устойчивее под всплески. Идемпотентно:
* если параметр уже есть в DATABASE_URL — не трогаем.
*/
function withConnectionLimit(url?: string): string | undefined {
if (!url || /[?&]connection_limit=/.test(url)) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}connection_limit=20`;
}
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
constructor() {
super({
datasources: { db: { url: withConnectionLimit(process.env.DATABASE_URL) } },
});
}
async onModuleInit() {
await this.$connect();
}

View File

@@ -0,0 +1,88 @@
import { Controller, Get, Header } from '@nestjs/common';
// Статическая страница политики конфиденциальности (для карточки RuStore).
// HTML хранится константой — без внешних файлов, чтобы не зависеть от копирования
// ассетов в Docker-образ.
const PRIVACY_HTML = `<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Политика конфиденциальности radiOLA</title>
<style>
body{font:16px/1.6 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;max-width:760px;margin:40px auto;padding:0 18px;color:#1a1a1a;background:#fff}
h1{font-size:1.6rem;margin-bottom:.2rem}
h2{font-size:1.15rem;margin-top:2rem}
.date{color:#666;margin-top:0}
ul{padding-left:1.2rem}
a{color:#2b7a2b}
footer{margin-top:3rem;color:#888;font-size:.9rem}
</style>
</head>
<body>
<h1>Политика конфиденциальности приложения radiOLA</h1>
<p class="date">Дата вступления в силу: 8 июня 2026 г.</p>
<p>Настоящая Политика описывает, какие данные обрабатывает мобильное приложение
radiOLA («Приложение»), с какой целью и как вы можете ими управлять. Используя
Приложение, вы соглашаетесь с условиями настоящей Политики.</p>
<h2>1. Какие данные мы обрабатываем</h2>
<ul>
<li><b>Адрес электронной почты</b> — только если вы добровольно входите в аккаунт.
Вход не обязателен и нужен лишь для синхронизации избранного и истории между
устройствами. Авторизация выполняется по одноразовой ссылке (magic-link); пароль
мы не храним.</li>
<li><b>История прослушиваний и распознанных треков</b> — сохраняется локально на
вашем устройстве. При входе в аккаунт история станций также сохраняется на сервере
для синхронизации.</li>
<li><b>Технические данные об ошибках</b> — при сбоях Приложение может отправлять
обезличенный отчёт об ошибке (тип устройства, версия приложения, стек вызовов) в
нашу систему мониторинга для исправления проблем.</li>
</ul>
<p>Приложение <b>не</b> собирает ваши контакты, геолокацию, СМС и не отслеживает вас
в других приложениях.</p>
<h2>2. Сторонние сервисы</h2>
<ul>
<li><b>shazam-api.com</b> — при нажатии кнопки «Распознать трек» короткий фрагмент
аудио текущего радиопотока передаётся сервису распознавания музыки для определения
исполнителя и названия. Фрагмент не содержит ваших персональных данных.</li>
<li><b>Discogs</b> — для получения обложек и сведений о треках мы передаём название
и исполнителя композиции.</li>
<li><b>Радиостанции третьих лиц</b> — воспроизведение ведётся напрямую с серверов
вещателей; на их потоки распространяются их собственные условия.</li>
</ul>
<h2>3. Цели обработки</h2>
<p>Данные обрабатываются исключительно для работы функций Приложения: воспроизведение
радио, синхронизация избранного и истории, распознавание треков, отображение обложек и
исправление технических ошибок. Мы не продаём и не передаём ваши данные третьим лицам
в рекламных целях.</p>
<h2>4. Хранение и удаление</h2>
<p>Локальные данные (история, настройки) хранятся на вашем устройстве и удаляются при
удалении Приложения или очистке его данных. Данные аккаунта на сервере хранятся, пока
существует аккаунт. Вы можете запросить удаление аккаунта и связанных данных, написав
нам (см. контакты ниже).</p>
<h2>5. Изменения политики</h2>
<p>Мы можем обновлять настоящую Политику. Актуальная версия всегда доступна по этому
адресу.</p>
<h2>6. Контакты</h2>
<p>По вопросам обработки персональных данных и для запроса на их удаление:
<a href="mailto:blinnafeg@gmail.com">blinnafeg@gmail.com</a>.</p>
<footer>radiOLA — интернет-радио. © 2026</footer>
</body>
</html>`;
@Controller()
export class PrivacyController {
@Get('privacy')
@Header('Content-Type', 'text/html; charset=utf-8')
getPrivacy(): string {
return PRIVACY_HTML;
}
}

View File

@@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { PrivacyController } from './privacy.controller';
@Module({ controllers: [PrivacyController] })
export class PrivacyModule {}

113
src/shazam/shazam.client.ts Normal file
View File

@@ -0,0 +1,113 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface RecognitionResult {
artist: string;
title: string;
coverUrl: string | null;
album: string | null;
}
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Адаптер к shazam-api.com. API асинхронный (две стадии):
* 1) POST {base}/recognize (multipart: file) → { uuid, status:"processing" }
* 2) POST {base}/results/{uuid} — поллим, пока status != "completed";
* результат: results[0].track.{title, subtitle(=исполнитель)} (обложки нет).
*
* Авторизация: заголовок `Authorization: Bearer <key>`.
* Настройки через env:
* SHAZAM_API_KEY — ключ (ОБЯЗАТЕЛЬНО; в git НЕ коммитим, только env на сервере)
* SHAZAM_API_URL — база API (необязательно, по умолч. https://shazam-api.com/api)
*/
@Injectable()
export class ShazamClient {
private readonly logger = new Logger(ShazamClient.name);
private readonly DEFAULT_BASE = 'https://shazam-api.com/api';
// Бюджет поллинга: ~12 попыток × 1.2с ≈ до 15с ожидания распознавания.
private readonly POLL_ATTEMPTS = 12;
private readonly POLL_INTERVAL_MS = 1200;
constructor(private readonly config: ConfigService) {}
isConfigured(): boolean {
return Boolean(this.config.get<string>('SHAZAM_API_KEY'));
}
private base(): string {
return this.config.get<string>('SHAZAM_API_URL') ?? this.DEFAULT_BASE;
}
private authHeader(): Record<string, string> {
const key = this.config.get<string>('SHAZAM_API_KEY');
if (!key) throw new Error('Shazam API key is not configured');
return { Authorization: `Bearer ${key}` };
}
/**
* Распознать трек по аудио-фрагменту. null — сервис ничего не нашёл
* (тишина/реклама/джингл) или не успел за бюджет поллинга. Бросает при
* сетевой ошибке / отказе API (401/403/4xx-5xx).
*/
async recognize(
audio: Buffer,
contentType = 'audio/mpeg',
): Promise<RecognitionResult | null> {
const uuid = await this.submit(audio, contentType);
return this.pollResult(uuid);
}
/** Стадия 1: загрузка аудио, получение uuid задачи. */
private async submit(audio: Buffer, contentType: string): Promise<string> {
const form = new FormData();
const blob = new Blob([new Uint8Array(audio)], { type: contentType });
form.append('file', blob, 'sample.mp3');
const res = await fetch(`${this.base()}/recognize`, {
method: 'POST',
headers: this.authHeader(),
body: form,
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Shazam recognize ${res.status}: ${body.slice(0, 200)}`);
}
const data = (await res.json()) as { uuid?: string };
if (!data?.uuid) throw new Error('Shazam recognize: нет uuid в ответе');
return data.uuid;
}
/** Стадия 2: поллинг результата по uuid до status="completed". */
private async pollResult(uuid: string): Promise<RecognitionResult | null> {
for (let i = 0; i < this.POLL_ATTEMPTS; i++) {
await delay(this.POLL_INTERVAL_MS);
const res = await fetch(`${this.base()}/results/${uuid}`, {
method: 'POST',
headers: this.authHeader(),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Shazam results ${res.status}: ${body.slice(0, 200)}`);
}
const data = (await res.json()) as {
status?: string;
results?: Array<{ track?: { title?: string; subtitle?: string } }>;
};
if (data.status === 'completed') {
const track = data.results?.[0]?.track;
const title = track?.title?.trim();
const artist = track?.subtitle?.trim();
if (!title || !artist) return null; // completed, но матча нет
return { artist, title, coverUrl: null, album: null };
}
// status "processing" — ждём следующую попытку
}
// Не успели за бюджет — считаем, что не распознали.
this.logger.warn(`Shazam: поллинг ${uuid} истёк без результата`);
return null;
}
}

View File

@@ -0,0 +1,17 @@
import { Controller, Post, Param, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ShazamService } from './shazam.service';
@ApiTags('shazam')
@Controller('shazam')
export class ShazamController {
constructor(private readonly shazamService: ShazamService) {}
@Post('recognize/:stationId')
@ApiOperation({
summary: 'Распознать играющий сейчас трек на станции (по station_id)',
})
async recognize(@Param('stationId', ParseIntPipe) stationId: number) {
return this.shazamService.recognize(stationId);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ShazamController } from './shazam.controller';
import { ShazamService } from './shazam.service';
import { ShazamClient } from './shazam.client';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [ShazamController],
providers: [ShazamService, ShazamClient],
})
export class ShazamModule {}

View File

@@ -0,0 +1,129 @@
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
ServiceUnavailableException,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ShazamClient } from './shazam.client';
import { fetchStreamChunk } from './stream-audio';
import { isMusicStation } from '../common/station-classification';
export interface RecognizeResponse {
matched: boolean;
artist?: string;
song?: string;
coverUrl?: string | null;
album?: string | null;
}
interface CacheEntry {
at: number;
result: RecognizeResponse;
}
@Injectable()
export class ShazamService {
private readonly logger = new Logger(ShazamService.name);
// Кэш последнего результата по станции: троттлинг + экономия платных вызовов,
// когда несколько клиентов распознают одну станцию почти одновременно.
private readonly cache = new Map<number, CacheEntry>();
private readonly CACHE_TTL_MS = 15000;
// Глобальный лимит реальных вызовов Shazam (платные коины) — защита баланса
// от перебора станций. Кэш-хиты сюда не считаются.
private readonly recentCalls: number[] = [];
private readonly MAX_PER_MIN = 30;
constructor(
private readonly prisma: PrismaService,
private readonly shazam: ShazamClient,
) {}
async recognize(
stationId: number,
now: number = Date.now(),
): Promise<RecognizeResponse> {
if (!this.shazam.isConfigured()) {
throw new ServiceUnavailableException('Распознавание временно недоступно');
}
const cached = this.cache.get(stationId);
if (cached && now - cached.at < this.CACHE_TTL_MS) {
return cached.result;
}
const station = await this.prisma.station.findUnique({
where: { stationId },
select: { streamUrl: true, genre: true, name: true },
});
if (!station) throw new NotFoundException('Станция не найдена');
if (!isMusicStation(station.genre)) {
throw new BadRequestException('На этой станции нет музыки');
}
this.checkRateLimit(now);
let result: RecognizeResponse;
try {
const audio = await fetchStreamChunk(station.streamUrl);
const match = await this.shazam.recognize(audio);
if (!match) {
result = { matched: false };
} else {
const coverUrl =
match.coverUrl ??
(await this.resolveCover(match.artist, match.title));
result = {
matched: true,
artist: match.artist,
song: match.title,
coverUrl,
album: match.album,
};
}
} catch (err) {
this.logger.error(
`Распознавание «${station.name}» не удалось: ${(err as Error).message}`,
);
throw new ServiceUnavailableException('Не удалось распознать трек');
}
this.cache.set(stationId, { at: now, result });
return result;
}
// Скользящее окно 60с по реальным (не кэшированным) вызовам Shazam.
private checkRateLimit(now: number): void {
const cutoff = now - 60000;
while (this.recentCalls.length && this.recentCalls[0] < cutoff) {
this.recentCalls.shift();
}
if (this.recentCalls.length >= this.MAX_PER_MIN) {
throw new HttpException(
'Слишком много запросов на распознавание, попробуйте позже',
HttpStatus.TOO_MANY_REQUESTS,
);
}
this.recentCalls.push(now);
}
// Если у распознанного трека нет обложки от Shazam — пробуем взять обложку
// уже обогащённого трека из нашей БД (по тому же normKey, что и чарты).
private async resolveCover(
artist: string,
song: string,
): Promise<string | null> {
const normKey =
artist.trim().toLowerCase().replace(/\s+/g, ' ') +
'|' +
song.trim().toLowerCase().replace(/\s+/g, ' ');
const track = await this.prisma.track.findUnique({
where: { normKey },
select: { coverUrl: true },
});
return track?.coverUrl ?? null;
}
}

View File

@@ -0,0 +1,57 @@
/**
* Захват короткого аудио-фрагмента из вещательного потока (icecast/shoutcast,
* mp3/aac). Открываем поток, копим байты до целевого размера или таймаута, затем
* обрываем соединение. Этого фрагмента хватает Shazam для построения отпечатка.
*
* Размер по умолчанию рассчитан на ~6 сек при 128 kbps (≈96 KB). Точная
* длительность не важна — алгоритму распознавания достаточно нескольких секунд.
*/
export async function fetchStreamChunk(
streamUrl: string,
opts: { bytes?: number; timeoutMs?: number } = {},
): Promise<Buffer> {
const targetBytes = opts.bytes ?? 96 * 1024;
const timeoutMs = opts.timeoutMs ?? 12000;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(streamUrl, {
signal: controller.signal,
headers: {
// Часть icecast-серверов без User-Agent отдаёт 403/плейлист.
'User-Agent': 'radiOLA/1.0 (track recognition)',
// Просим НЕ слать ICY-метаданные — нам нужно чистое аудио.
'Icy-MetaData': '0',
},
});
if (!res.ok || !res.body) {
throw new Error(`Stream responded ${res.status}`);
}
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let collected = 0;
while (collected < targetBytes) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
collected += value.length;
}
}
// Обрываем чтение — больше байт не нужно.
await reader.cancel().catch(() => undefined);
if (collected === 0) {
throw new Error('Stream returned no audio');
}
return Buffer.concat(chunks);
} finally {
clearTimeout(timer);
}
}

View File

@@ -36,6 +36,12 @@ export class StationsController {
});
}
@Get('offline-ids')
@ApiOperation({ summary: 'station_id оффлайн-станций (для скрытия в клиенте)' })
async offlineIds() {
return this.stationsService.getOfflineStationIds();
}
@Get(':id')
@ApiOperation({ summary: 'Get station by ID' })
async findOne(@Param('id') id: string) {

View File

@@ -35,6 +35,15 @@ export class StationsService {
});
}
// station_id оффлайн-станций — для скрытия мёртвых плиток в клиенте
async getOfflineStationIds(): Promise<number[]> {
const rows = await this.prisma.station.findMany({
where: { isOnline: false },
select: { stationId: true },
});
return rows.map((r) => r.stationId);
}
async findOne(id: string) {
const station = await this.prisma.station.findUnique({
where: { id },