chore(scripts): пробер битрейт-вариантов потоков станций

Перебирает соседние битрейты в маунте каждого потока, проверяет живость
(заголовки + content-type), пишет массив qualities в stations.json клиента.
Пропускает HLS (emgsound) и Love (UID). Использован для фичи переключения
качества в плеере.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 12:37:11 +03:00
parent 3c6dbed659
commit 87cc67072c

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); });