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:
190
scripts/probe-qualities.mjs
Normal file
190
scripts/probe-qualities.mjs
Normal 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); });
|
||||
Reference in New Issue
Block a user