diff --git a/scripts/probe-qualities.mjs b/scripts/probe-qualities.mjs new file mode 100644 index 0000000..78d4d0c --- /dev/null +++ b/scripts/probe-qualities.mjs @@ -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); });