# Architektura lokalnego edytora list odtwarzania IPTV: jak FastAPI i React 19 rozwiązują problemy synchronizacji i odtwarzania
Edytowanie list odtwarzania IPTV z tysiącem kanałów wymaga skomplikowanej synchronizacji stanu i omijania ograniczeń technicznych. Przyjrzyjmy się rozwiązaniom architektonicznym open-source'owego projektu m3u Studio, zaimplementowanego w FastAPI i React 19.
Problem zarządzania dużymi playlistami
Standardowe edytory plików m3u nie radzą sobie ze zmianami dynamicznymi dostawców. Podczas aktualizacji listy kanałów tracona jest kuracja: kolejność, grupowanie, ulubione kanały. Kluczowe trudności:
- Adresy URL strumieni zmieniają się przy zmianie dostawcy, niszcząc identyfikatory
- Brak CORS w źródłach IPTV blokuje bezpośrednie odtwarzanie w przeglądarce
- Niekompatybilność kodeków audio (AC-3/E-AC-3) w Chrome/Safari
- Synchronizacja dwóch paneli bez przycisku zapisu
m3u Studio rozwiązuje te problemy poprzez lokalny interfejs webowy z drag-and-drop, wbudowanym playerem HLS i EPG. Stack obejmuje FastAPI + Pydantic v2 na backendzie, React 19 + TypeScript + Tailwind CSS na frontendzie.
Przechowywanie stanu według nazw, a nie ID
Tradycyjne podejście — hashowanie URL w celu generowania stabilnych identyfikatorów:
def _stable_id(url: str) -> str:
digest = hashlib.sha1(url.encode("utf-8"), usedforsecurity=False).hexdigest()
return digest[:12]
Ta metoda zawodzi przy zmianie dostawcy: URL całkowicie się zmieniają, identyfikatory są przeliczane, kuracja ginie. Rozwiązanie — przechowywanie kolejności według nazw kanałów, które pozostają stabilne między dostawcami. Wewnętrzny mechanizm konwertuje nazwę ↔ ID za pomocą dynamicznego słownika:
@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 nadal działa z ID, ale przy ładowaniu nowej playlisty kuracja jest automatycznie stosowana do kanałów o zbieżnych nazwach. To zapewnia ciągłość doświadczenia użytkownika przy zmianie źródła danych.
Synchronizacja Main i Source bez przycisku zapisu
Kuratowana lista „Main” i źródłowa grupa „główne” muszą być aktualizowane atomowo. Przy przeciąganiu kanału do Main wymagane jest:
- Aktualizacja stanu kuraacji
- Nadpisanie pliku m3u8 z nową kolejnością
- Zapisanie listy nazw na potrzeby przyszłych importów
Zaimplementowano to za pomocą scentralizowanego pomocnika _sync_main_to_source, wywoływanego po każdej mutacji:
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)
Kluczowy moment: bezpośrednie rebindowanie przez bind_playlist() zamiast ponownego odczytu state.json zapobiega race conditions. Na frontendzie React Query unieważnia cache źródła po mutacji:
onSettled: (server) => {
if (server) client.setQueryData(KEY_MAIN, server)
client.invalidateQueries({ queryKey: KEY_SOURCE })
}
Rezultat — natychmiastowa synchronizacja paneli bez jawnego zapisu.
Proxy HLS do omijania CORS
Dostawcy IPTV rzadko wysyłają nagłówki CORS, uniemożliwiając bezpośrednie odtwarzanie w przeglądarce. Serwer proxy przepisuje nie tylko manifest główny, ale wszystkie zagnieżdżone linki do segmentów:
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"):
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)
Ten 40-wierszowy handler rekurencyjnie zastępuje wszystkie względne ścieżki w plikach m3u8 proxy-URL, zapewniając poprawną ładowanie segmentów przez serwer aplikacji.
Transkodowanie audio na bieżąco
Dla kanałów z audio AC-3/E-AC-3, nieobsługiwanym przez przeglądarki, zaimplementowano mechanizm fallback. Po aktywacji przycisku „Napraw audio” uruchamiane jest transkodowanie 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"),
)
Player przełącza się na /api/transcode/{channel_id}/index.m3u8, przywracając dźwięk po 3-4 sekundach (opóźnienie segmentu HLS). Procesy ffmpeg są zarządzane przez zadanie w tle do czyszczenia, zapobiegając wyciekom zasobów.
Implementacja motywu jasnego na bazie dark-only
Przepisywanie tysięcy linii kodu na wsparcie motywu jasnego okazało się niepraktyczne. Zamiast tego w index.css dodano nadpisy utility-klas:
[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); }
Semantyczne zmienne --tint-bg-sm i --tint-border-sm tworzą hierarchię elevation. Specyficzność selektorów [data-theme="light"] .class (0,2,0) nadpisuje standardowe klasy Tailwind (0,1,0), co pozwala uniknąć zmian w komponentach.
Co jest ważne
- Stabilność kuraacji: Przechowywanie kolejności według nazw kanałów, a nie ID, zachowuje ustawienia przy zmianie dostawcy
- Atomowa synchronizacja: Połączenie aktualizacji stanu, pliku m3u8 i listy nazw w jedną operację
- Proxy-transformacja: Rekurencyjne przepisanie wszystkich URL w manifestach HLS do omijania CORS
- Audio-fallback: Transkodowanie AC-3 do AAC na żądanie przez ffmpeg z zarządzaniem procesami
- Tematowanie bez refaktoryzacji: Nadpisywanie utility-klas przez atrybuty data z kontrolą specyficzności
Dodatkowe komponenty projektu obejmują parser m3u z wsparciem EXTGRP i atrybutów tvg, resolver logo (lokalne override → iptv-org/database → CDN), detektor duplikatów przez normalizację nazw, ładowarkę EPG XMLTV z cache'owaniem i skokiem do archiwum. Drag-and-drop zaimplementowano w @dnd-kit z niestandardową detekcją kolizji.
Uruchomienie projektu jest proste:
git clone https://github.com/stepanovandrey89/m3ustudio.git
cd m3ustudio
docker compose up -d
Po otwarciu http://127.0.0.1:8000 użytkownik ładuje swój plik m3u8 i zaczyna edytowanie. Projekt jest otwarty na PR i dyskusje o rozwiązaniach architektonicznych.
— Editorial Team
Brak komentarzy.