Разработка платформы стриминга музыки
Стриминг музыки — это не просто «отдать mp3 через HTTP». Платформа, которая должна выдерживать нагрузку, обеспечивать low-latency воспроизведение, управлять правами и монетизацией, — инженерная задача с десятком нетривиальных узлов. Ниже — архитектура и реализация с реальными компромиссами.
Протоколы доставки аудио
Три варианта, каждый под свою задачу.
Progressive download (псевдостриминг) — самый простой. Файл отдаётся через обычный HTTP с поддержкой Range-запросов. Браузер буферизует и воспроизводит. Подходит для небольших библиотек без строгих ограничений на скачивание.
location /audio/ {
root /var/media;
add_header Accept-Ranges bytes;
add_header Cache-Control "no-store"; # для DRM
# X-Accel-Redirect если файлы за авторизацией
}
HLS (HTTP Live Streaming) — стандарт для продакшна. Файл нарезается на сегменты по 5–10 секунд, клиент подгружает по манифесту. Позволяет адаптивный битрейт (ABR): клиент переключается между 128/256/320 kbps в зависимости от канала.
# FFmpeg: нарезка в HLS с тремя качествами
ffmpeg -i input.flac \
-filter_complex "[0:a]asplit=3[a1][a2][a3]" \
-map "[a1]" -codec:a aac -b:a 128k -vn \
-hls_time 6 -hls_list_size 0 \
-hls_segment_filename "out/128k_%03d.aac" out/128k.m3u8 \
-map "[a2]" -codec:a aac -b:a 256k -vn \
-hls_time 6 -hls_list_size 0 \
-hls_segment_filename "out/256k_%03d.aac" out/256k.m3u8 \
-map "[a3]" -codec:a aac -b:a 320k -vn \
-hls_time 6 -hls_list_size 0 \
-hls_segment_filename "out/320k_%03d.aac" out/320k.m3u8
Манифест верхнего уровня (master.m3u8) перечисляет варианты, клиент выбирает сам.
MPEG-DASH — альтернатива HLS, лучше поддерживает DRM через EME (Encrypted Media Extensions). Если нужна защита контента уровня лейблов — DASH + Widevine/FairPlay.
Архитектура обработки контента
Загрузка трека — это пайплайн, а не просто сохранение файла.
Upload → Validation → Transcoding → Waveform → Fingerprint → CDN → DB
# Celery task: полный пайплайн обработки
from celery import chain
@app.task
def process_upload(track_id: int, raw_path: str):
pipeline = chain(
validate_audio.s(track_id, raw_path),
transcode_variants.s(), # 128/256/320 + HLS segments
generate_waveform.s(), # peaks.json для визуализации
fingerprint_audio.s(), # AcoustID / Chromaprint
push_to_cdn.s(),
update_track_status.s('ready')
)
pipeline.delay()
@app.task
def transcode_variants(track_id: int, validated_path: str):
qualities = [
('128k', '128k', 'aac'),
('256k', '256k', 'aac'),
('320k', '320k', 'mp3'), # для офлайн-скачивания
('lossless', None, 'flac'), # для hi-fi тира
]
results = []
for name, bitrate, codec in qualities:
out = transcode(validated_path, bitrate, codec)
segment_hls(out, name, track_id)
results.append((name, out))
return track_id, results
Генерация волновой формы
Waveform — стандартный UI элемент. Библиотека audiowaveform от BBC:
audiowaveform -i track.mp3 -o peaks.json \
--pixels-per-second 10 \
--bits 8
# peaks.json: { "bits": 8, "length": 1234, "data": [-12, 15, -8, 22, ...] }
На фронтенде — WaveSurfer.js или кастомный Canvas-рендер:
function drawWaveform(peaks: number[], canvas: HTMLCanvasElement, progress: number) {
const ctx = canvas.getContext('2d')!;
const { width, height } = canvas;
const mid = height / 2;
const barWidth = width / peaks.length;
ctx.clearRect(0, 0, width, height);
peaks.forEach((peak, i) => {
const x = i * barWidth;
const h = (Math.abs(peak) / 128) * mid;
const played = x / width < progress;
ctx.fillStyle = played ? '#6366f1' : '#94a3b8';
ctx.fillRect(x, mid - h, barWidth - 1, h * 2);
});
}
Система прав и лицензирование
Без управления правами платформу не запустить легально. Минимальная модель:
CREATE TABLE tracks (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
duration_sec INT,
isrc CHAR(12), -- International Standard Recording Code
status TEXT DEFAULT 'processing'
);
CREATE TABLE track_rights (
track_id BIGINT REFERENCES tracks(id),
territory CHAR(2), -- ISO 3166-1 alpha-2, NULL = worldwide
right_type TEXT, -- 'stream', 'download', 'sync'
holder_id BIGINT,
expires_at TIMESTAMPTZ,
PRIMARY KEY (track_id, territory, right_type)
);
-- Проверка права перед выдачей URL
CREATE OR REPLACE FUNCTION can_stream(p_track_id BIGINT, p_territory CHAR(2))
RETURNS BOOLEAN AS $$
SELECT EXISTS (
SELECT 1 FROM track_rights
WHERE track_id = p_track_id
AND right_type = 'stream'
AND (territory IS NULL OR territory = p_territory)
AND (expires_at IS NULL OR expires_at > NOW())
);
$$ LANGUAGE sql STABLE;
Signed URLs и защита стримов
Прямые ссылки на файлы отдавать нельзя — их сохранят и распространят. Подписанные URL с коротким TTL:
// Laravel: генерация подписанного URL через CDN (Cloudflare / AWS CloudFront)
class StreamController extends Controller
{
public function stream(Request $request, int $trackId): JsonResponse
{
$track = Track::findOrFail($trackId);
// Проверяем территорию по IP
$territory = $this->geoService->getCountry($request->ip());
if (!$track->canStream($territory)) {
return response()->json(['error' => 'not_available'], 451);
}
// Signed URL на 60 секунд — достаточно для начала буферизации
$url = $this->cdn->signedUrl(
path: "hls/{$trackId}/master.m3u8",
ttl: 60,
ip: $request->ip() // привязка к IP
);
// Логируем прослушивание для роялти
StreamEvent::dispatch($trackId, $request->user()->id, now());
return response()->json(['url' => $url]);
}
}
Офлайн и Progressive Web App
Для мобильного PWA — кеширование через Service Worker:
// sw.js: кешируем сегменты треков для офлайн
const AUDIO_CACHE = 'audio-v1';
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.pathname.includes('/hls/') && url.pathname.endsWith('.aac')) {
event.respondWith(
caches.open(AUDIO_CACHE).then(async cache => {
const cached = await cache.match(event.request);
if (cached) return cached;
const response = await fetch(event.request);
// Кешируем только треки из плейлиста пользователя
if (isInUserLibrary(url)) {
cache.put(event.request, response.clone());
}
return response;
})
);
}
});
Масштабирование и CDN
HLS-сегменты — статические файлы, идеально для CDN. Но есть нюанс: при пиковой нагрузке (новый релиз популярного исполнителя) нужен origin shield — промежуточный кеш между CDN и origin, чтобы не положить S3/хранилище.
User → CDN Edge (Cloudflare/CloudFront) → Origin Shield → S3/MinIO
Манифесты .m3u8 кешируются с коротким TTL (5–30 сек) — они меняются при live-трансляциях. Сегменты .aac/.ts кешируются агрессивно (365 дней, immutable), потому что имена включают хеш.
Роялти и статистика
Для каждого прослушивания нужно считать секунды (не просто факт воспроизведения). Threshold обычно 30 секунд по стандартам IFPI.
# Агрегация стримов для роялти-отчётов
# Kafka consumer: каждые 30 секунд плеер шлёт heartbeat
@dataclass
class PlaybackEvent:
track_id: int
user_id: int
seconds_played: int
quality: str # '128k', '256k', 'lossless'
timestamp: datetime
def aggregate_streams(events: list[PlaybackEvent]) -> dict:
"""
Агрегируем за месяц: только полные прослушивания (>= 30 сек)
учитываются как monetizable stream
"""
from collections import defaultdict
counts = defaultdict(int)
for e in events:
if e.seconds_played >= 30:
counts[e.track_id] += 1
return dict(counts)
Поиск по каталогу
Elasticsearch для полнотекстового поиска с анализом транслитерации и фонетики:
PUT /tracks
{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "standard",
"fields": {
"phonetic": { "type": "text", "analyzer": "phonetic_analyzer" },
"autocomplete": { "type": "search_as_you_type" }
}
},
"artist": { "type": "text", "fields": { "keyword": { "type": "keyword" } } },
"genre": { "type": "keyword" },
"release_date": { "type": "date" },
"play_count": { "type": "long" }
}
}
}
Сроки
Базовый стриминг с каталогом, плеером (HLS, waveform), плейлистами и авторизацией — 10–14 недель. Добавление системы прав, роялти-учёта и DRM — ещё 6–8 недель. Рекомендательный движок, офлайн-режим PWA, мобильные приложения — отдельные треки, оцениваются независимо.







