// Пробер качества потоков: обогащает 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); });