Хабы: React, FastAPI, TypeScript, Tailwind CSS, Open source, IPTV, Python
Теги: m3u, m3u8, iptv, fastapi, react, hls, epg, drag-and-drop, self-hosted
Введение
У меня был плейлист на 1000+ IPTV-каналов и стойкая привычка править его на свой вкус. Менять порядок каналов, чистить дубликаты, добавлять любимое в группу «Основное» — всё очень неудобно. Каждый раз при обновлении списка каналов/провайдера — заново.
В какой-то момент мне это надоело, и я вдохновленный идеей с помощью ИИ написали m3u Studio — локальный веб-редактор плейлистов с drag-and-drop, встроенным HLS-плеером, EPG и автоматическим подтягиванием логотипов.
Исходники (MIT)
В этом посте расскажу про интересные архитектурные решения, которые всплыли по ходу работы.
Что это вообще такое
Запускаешь docker compose up -d, открываешь http://127.0.0.1:8000, загружаешь свой .m3u8. Получаешь двухпанельный интерфейс: слева — исходный плейлист по группам, справа — твой курируемый список «Main». Перетаскиваешь каналы между панелями, внутри Main — переупорядочиваешь drag’n’drop’ом, любой канал кликом открывается в плеере. Экспорт — один клик: скачивается очищенный .m3u8 с твоим порядком.
Стек: FastAPI + httpx + Pydantic v2 на бэке, React 19 + TypeScript + Tailwind v4 + @dnd-kit + hls.js + TanStack Query на фронте. ~10k LOC суммарно.
Решение 1: состояние хранится по именам, а не id
Когда парсишь m3u-файл, каждому каналу нужен стабильный идентификатор. Очевидный выбор — хешировать URL потока:
def _stable_id(url: str) -> str: digest = hashlib.sha1(url.encode("utf-8"), usedforsecurity=False).hexdigest() return digest[:12]
Проблема всплывает, когда пользователь меняет провайдера. URL-ы меняются полностью — значит, меняются все id-шники, и вся твоя курация («вот мои любимые 50 каналов в таком-то порядке») идёт к чёрту.
Решение: хранить курируемый порядок по именам каналов, а не id. Имена стабильны между провайдерами. Внутренний store переводит имя ↔ id на границе через словарь, построенный из текущего плейлиста:
@dataclass(frozen=True, slots=True)class MainState: main_names: tuple[str, ...]class StateStore: def current_ids(self) -> list[str]: """Stored names → current playlist ids.""" with self._lock: name_to_id = self._name_to_id_map() return [name_to_id[n] for n in self._state.main_names if n in name_to_id]
Фронту это незаметно — API отдаёт id-шники, как и раньше. Но загрузишь новый плейлист от другого провайдера — твоя курация автоматически перенесётся на новые каналы с теми же именами.
Решение 2: зеркалирование Main ↔ Source
Курируемый список «Main» и группа «основное» в исходном плейлисте — это, по сути, одно и то же. Когда пользователь перетаскивает канал в Main, мне нужно:
-
Обновить
state.json(курируемый список) -
Переписать
playlist.m3u8так, чтобы группа «основное» отражала новый порядок (нужно для экспорта и для того, чтобы видеть изменения в левой панели) -
Обновить
default_names.txt(список имён, который используется как «семя» при первом импорте)
Всё это делается одним хелпером syncmain_to_source, который вызывается из каждого PATCH /api/main:
def _sync_main_to_source() -> None: main_ids = _state.store.current_ids() text = build_with_main_group( header=_state.playlist.header, all_channels=_state.playlist.channels, main_ids=main_ids, group_name=MAIN_GROUP_NAME, ) PLAYLIST_PATH.write_text(text, encoding="utf-8") _state.playlist = parse_playlist(PLAYLIST_PATH) _state.store.bind_playlist(_state.playlist) current_names = _state.store.state.main_names if current_names: DEFAULT_NAMES_PATH.write_text("\n".join(current_names), encoding="utf-8") _state.store.set_default_names(current_names)
Важный нюанс: я специально не вызываю reload_playlist() (который перечитал бы state.json), а напрямую ребиндю playlist через bind_playlist(). Иначе получается race condition: drag-and-drop возвращает старый ответ, потому что load_or_bootstrap читает state.json, который ещё не до конца записан.
На фронте React Query инвалидирует кэш источника после каждой мутации:
onSettled: (server) => { if (server) client.setQueryData(KEY_MAIN, server) client.invalidateQueries({ queryKey: KEY_SOURCE })}
Результат — обе панели всегда синхронны, без «save»-кнопки.
Решение 3: HLS-прокси, который переписывает манифесты
IPTV-провайдеры в 90% случаев не отдают CORS-заголовки, поэтому браузер отказывается проигрывать их потоки напрямую. Классический подход — сделать прокси, который пропускает запрос через свой сервер.
Тонкость: если просто проксировать master.m3u8, в нём URL-ы на variant-манифесты, а внутри variant-манифестов — URL-ы на .ts-сегменты. Их все нужно переписать на прокси-URL, иначе плеер запросит сегменты напрямую и снова упрётся в CORS.
~40 строк Python:
async def proxy_stream(upstream_url: str) -> Response: async with httpx.AsyncClient() as client: resp = await client.get(upstream_url, follow_redirects=True) content_type = resp.headers.get("content-type", "") if "mpegurl" in content_type.lower() or upstream_url.endswith(".m3u8"): # Rewrite every non-comment line to go through our proxy. base = urljoin(upstream_url, ".") rewritten = [] for line in resp.text.splitlines(): if line.startswith("#") or not line.strip(): rewritten.append(line) else: absolute = urljoin(base, line) rewritten.append(f"/api/proxy?u={quote(absolute)}") return Response("\n".join(rewritten), media_type=content_type) return Response(resp.content, media_type=content_type)
Решение 4: AC-3 → AAC на лету
Некоторые провайдеры гонят AC-3 / E-AC-3 аудио, которое Chrome и Safari упорно отказываются декодировать. Видео играет, звука нет.
Решение — fallback-кнопка «Fix audio», которая на бэке запускает ffmpeg:
proc = await asyncio.create_subprocess_exec( FFMPEG_BIN, "-i", upstream_url, "-c:v", "copy", # видео не трогаем "-c:a", "aac", # только аудио ремуксируем "-f", "hls", "-hls_time", "4", "-hls_list_size", "6", "-hls_flags", "delete_segments", str(output_dir / "index.m3u8"),)
Плеер переключается на /api/transcode/{channel_id}/index.m3u8 и звук появляется через ~3 секунды (латентность одного HLS-сегмента). Процессы ffmpeg’а трекаются и убиваются на background-задаче cleanup’а.
Решение 5: светлая тема поверх dark-only кодовой базы
Фронт изначально писался только под тёмную тему, и в куче мест захардкожены классы text-white, bg-white/5, border-white/10. Переписывать тысячи строк на семантические токены — долго.
Пошёл другим путём: добавил в index.css переопределения всех этих utility-классов для [data-theme="light"]:
[data-theme="light"] .text-white { color: var(--color-fog-300); }[data-theme="light"] .bg-white\/5 { background-color: var(--tint-bg-sm); }[data-theme="light"] .bg-white\/10 { background-color: var(--tint-bg-md); }[data-theme="light"] .border-white\/10 { border-color: var(--tint-border-sm); }[data-theme="light"] .hover\:bg-white\/5:hover { background-color: var(--tint-bg-sm); }/* … и так далее */
Семантические токены --tint-bg-sm / --tint-border-sm / … меняются в зависимости от темы и дают реальную архитектуру elevation’а. Это работает потому что [data-theme="light"] .class имеет specificity (0,2,0), что перебивает обычный .class (0,1,0) из Tailwind.
Не идеально, но работает — и не требует трогать ни одного компонента.
Что ещё внутри
-
Парсер m3u с поддержкой
#EXTGRP,tvg-logo,tvg-id,tvg-rec(catchup) -
Резолвер логотипов, который идёт по цепочке: локальный override →
iptv-org/database→tv-logo/tv-logosCDN → EPG<icon> -
Детектор дубликатов каналов на основе нормализованных имён (отстригает суффиксы качества вроде
HD,FHD,UHD,+4) -
XMLTV EPG-загрузчик с кэшированием, день-по-дню раскладкой, jump’ом в архив по клику на программу
-
Drag-and-drop на
@dnd-kitс кастомной collision detection’ом, который предпочитает строки над контейнером при drag’е из Source в Main -
Встроенный HLS-плеер на
hls.jsс keyboard shortcut’ами, fullscreen’ом, архивной перемоткой и записью в MKV
Как запустить
git clone https://github.com/stepanovandrey89/m3ustudio.gitcd m3ustudiodocker compose up -d
Открываешь http://127.0.0.1:8000, кидаешь свой .m3u8 через UI, начинаешь править.
Код открыт, issues и PR’ы приветствуются. Если какая-то из перечисленных архитектурных идей показалась интересной — расскажу подробнее в комментариях.
ссылка на оригинал статьи https://habr.com/ru/articles/1024902/