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