Files
radiola-backend/scripts/probe-qualities.mjs
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

191 lines
7.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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