Разработка платформы для подкастов
Подкасты — деceptively simple: аудиофайл + RSS. Но как только добавляется монетизация, аналитика, динамическое вставление рекламы и поддержка нескольких хостов на шоу — сложность резко растёт. Ниже — архитектура реальной платформы, а не очередной RSS-хостинг.
RSS-лента как core API
Подкаст-клиенты (Apple Podcasts, Spotify, Overcast) потребляют RSS. Лента должна соответствовать спецификации Apple Podcasts и Podcast Namespace (podcastindex.org):
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:podcast="https://podcastindex.org/namespace/1.0"
xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>My Podcast</title>
<link>https://example.com/podcast</link>
<language>ru</language>
<itunes:author>John Doe</itunes:author>
<itunes:category text="Technology"/>
<itunes:image href="https://cdn.example.com/covers/show-1.jpg"/>
<podcast:locked>yes</podcast:locked>
<podcast:guid>urn:uuid:f8d3e2a1-...</podcast:guid>
<item>
<title>Ep. 42: Redis Internals</title>
<guid isPermaLink="false">ep-42-redis-internals</guid>
<pubDate>Fri, 14 Mar 2025 10:00:00 +0000</pubDate>
<enclosure url="https://cdn.example.com/audio/ep42.mp3"
length="48291840" type="audio/mpeg"/>
<itunes:duration>3456</itunes:duration>
<itunes:episodeType>full</itunes:episodeType>
<itunes:season>2</itunes:season>
<itunes:episode>42</itunes:episode>
<podcast:chapters type="application/json+chapters"
url="https://cdn.example.com/chapters/ep42.json"/>
<podcast:transcript url="https://cdn.example.com/transcripts/ep42.vtt"
type="text/vtt"/>
</item>
</channel>
</rss>
Генерация ленты — не статический файл, а динамический эндпоинт с кешированием:
class PodcastFeedController extends Controller
{
public function feed(string $slug): Response
{
$show = Show::with(['episodes' => function ($q) {
$q->where('status', 'published')
->orderByDesc('published_at')
->limit(100); // большинство клиентов не берут больше
}])->where('slug', $slug)->firstOrFail();
$xml = $this->feedBuilder->build($show);
return response($xml, 200)
->header('Content-Type', 'application/rss+xml; charset=utf-8')
->header('Cache-Control', 'public, max-age=3600');
}
}
Динамическое вставление рекламы (DAI)
Dynamic Ad Insertion — главный источник монетизации. Две модели: server-side (файл перекодируется с рекламой) и client-side (плеер загружает рекламу отдельно по VAST).
Server-side через FFmpeg — надёжнее, работает в любом клиенте:
import subprocess
from pathlib import Path
def insert_ads(episode_path: str, ad_slots: list[dict]) -> str:
"""
ad_slots: [{"position_sec": 0, "ad_path": "preroll.mp3"},
{"position_sec": 600, "ad_path": "midroll.mp3"}]
"""
parts = []
prev = 0
# Нарезаем эпизод вокруг рекламных вставок
for slot in sorted(ad_slots, key=lambda x: x['position_sec']):
pos = slot['position_sec']
segment = f"/tmp/seg_{prev}_{pos}.mp3"
subprocess.run([
'ffmpeg', '-i', episode_path,
'-ss', str(prev), '-to', str(pos),
'-acodec', 'copy', segment, '-y'
], check=True)
parts.extend([segment, slot['ad_path']])
prev = pos
# Хвост после последней рекламы
tail = f"/tmp/seg_{prev}_end.mp3"
subprocess.run([
'ffmpeg', '-i', episode_path, '-ss', str(prev),
'-acodec', 'copy', tail, '-y'
], check=True)
parts.append(tail)
# Конкатенация
list_file = "/tmp/concat_list.txt"
with open(list_file, 'w') as f:
for p in parts:
f.write(f"file '{p}'\n")
out = f"/tmp/episode_with_ads_{Path(episode_path).stem}.mp3"
subprocess.run([
'ffmpeg', '-f', 'concat', '-safe', '0',
'-i', list_file, '-acodec', 'copy', out, '-y'
], check=True)
return out
Транскрипция эпизодов
Транскрипция нужна для SEO, доступности и поиска по содержимому. OpenAI Whisper — лучший баланс качества и стоимости:
import whisper
import json
def transcribe_episode(audio_path: str, language: str = 'ru') -> dict:
model = whisper.load_model('large-v3')
result = model.transcribe(
audio_path,
language=language,
word_timestamps=True,
verbose=False
)
# Конвертируем в WebVTT для podcast:transcript
vtt_lines = ['WEBVTT\n']
for seg in result['segments']:
start = format_timestamp(seg['start'])
end = format_timestamp(seg['end'])
vtt_lines.append(f"{start} --> {end}")
vtt_lines.append(seg['text'].strip())
vtt_lines.append('')
# Chapters JSON (podcastindex.org/namespace)
chapters = detect_chapters(result['segments'])
return {
'vtt': '\n'.join(vtt_lines),
'chapters': chapters,
'full_text': result['text'],
'duration': result['segments'][-1]['end'] if result['segments'] else 0
}
def format_timestamp(seconds: float) -> str:
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = seconds % 60
return f"{h:02d}:{m:02d}:{s:06.3f}"
Аналитика прослушиваний
IAB Podcast Measurement Standards v2.1 — индустриальный стандарт. Главное правило: один уникальный IP + User-Agent за 24 часа = один download, независимо от количества запросов.
-- Дедупликация по IAB v2.1
CREATE TABLE download_events (
id BIGSERIAL PRIMARY KEY,
episode_id BIGINT NOT NULL,
ip_hash TEXT NOT NULL, -- SHA-256 для GDPR
user_agent TEXT,
bytes_sent BIGINT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Уникальные загрузки за период
SELECT
episode_id,
COUNT(DISTINCT (ip_hash, LEFT(user_agent, 50))) AS unique_downloads
FROM download_events
WHERE created_at BETWEEN $1 AND $2
AND bytes_sent > 0 -- отсекаем прерванные
GROUP BY episode_id;
Геоаналитика через MaxMind GeoIP2 — не по IP в чистом виде (GDPR), а по предобработанным геометкам.
Субтитровые плееры и главы
Chapter markers — killer feature для длинных эпизодов. Формат podcast:chapters:
{
"version": "1.2.0",
"title": "Episode 42",
"chapters": [
{ "startTime": 0, "title": "Intro", "img": "https://cdn.../ch0.jpg" },
{ "startTime": 120, "title": "Redis Data Structures" },
{ "startTime": 1800, "title": "Clustering", "url": "https://redis.io/docs/cluster" },
{ "startTime": 3000, "title": "Outro" }
]
}
Монетизация: подписки и Patreon-интеграция
Приватный RSS для платных подписчиков — токен в URL:
// Приватная лента с токеном подписчика
Route::get('/feed/{show}/{token}', function (string $show, string $token) {
$subscriber = Subscriber::where('feed_token', $token)
->where('status', 'active')
->firstOrFail();
// Логируем доступ к ленте (аналитика клиентов)
FeedAccess::create([
'subscriber_id' => $subscriber->id,
'user_agent' => request()->userAgent(),
'ip_hash' => hash('sha256', request()->ip()),
]);
$show = Show::where('slug', $show)->firstOrFail();
// Включаем bonusный контент для платных
$episodes = $show->episodes()
->where('status', 'published')
->when(!$subscriber->is_premium, fn($q) => $q->where('is_premium', false))
->orderByDesc('published_at')
->get();
return response($this->buildFeed($show, $episodes, $subscriber))
->header('Content-Type', 'application/rss+xml');
});
Хранение и доставка аудио
Подкасты — большие файлы с неравномерной нагрузкой (спайк после выхода эпизода). S3 + CloudFront — стандартное решение. Важный момент: большинство клиентов при воспроизведении делают Range-запросы для перемотки — убедитесь, что хранилище их поддерживает.
# Проксирование с подписанным токеном через Nginx
location /episode/ {
set $signed 0;
# Проверяем подпись через Lua или auth_request
auth_request /validate-episode-access;
proxy_pass https://s3.example.com/podcast-audio/;
proxy_set_header Authorization ""; # убираем наши credentials
add_header X-Robots-Tag "noindex"; # аудио не нужно индексировать
}
Сроки
Платформа с публичными шоу, RSS по стандарту Apple/Spotify, загрузкой файлов, базовой аналитикой (IAB-совместной) и встроенным плеером — 8–10 недель. DAI (динамическая реклама), Whisper-транскрипция, приватный RSS для платных подписчиков, интеграция со Stripe — ещё 5–7 недель. Мобильное приложение под iOS/Android — отдельная история.







