Zpět na domů

Architektura IPTV editoru na FastAPI a React 19 | Technický rozbor

Analýza architektonických řešení open-source editoru IPTV playlistů m3u Studio. Zahrnuty metody synchronizace stavu, obcházení CORS přes HLS proxy, on-demand transkodování audia a implementace tématizace bez refaktoringu kódu.

Hloubková technická analýza: jak FastAPI a React 19 spravují IPTV playlisty
Advertisement 728x90

Architektura lokálního editoru IPTV playlistů: jak FastAPI a React 19 řeší úkoly synchronizace a přehrávání

Editace IPTV playlistů s tisíci kanály vyžaduje složitou synchronizaci stavu a obcházení technických omezení. Podívejme se na architektonická řešení open-source projektu m3u Studio, realizovaného na FastAPI a React 19.

Problém správy velkých playlistů

Standardní editory m3u souborů nezvládají dynamické změny poskytovatelů. Při aktualizaci seznamu kanálů se ztrácí kurace: pořadí, seskupení, oblíbené kanály. Klíčové obtíže:

  • URL adresy streamů se mění při výměně poskytovatele, což ničí identifikátory
  • Chybějící CORS v IPTV zdrojích blokuje přímé přehrávání v prohlížeči
  • Nekompatibilita audiokodeků (AC-3/E-AC-3) v Chrome/Safari
  • Synchronizace dvou panelů bez tlačítka uložení

m3u Studio tyto problémy řeší prostřednictvím lokálního webového rozhraní s drag-and-drop, vestavěným HLS přehrávačem a EPG. Stack zahrnuje FastAPI + Pydantic v2 na backendu, React 19 + TypeScript + Tailwind CSS na frontendu.

Google AdInline article slot

Uložení stavu podle názvů, ne ID

Tradiční přístup — hašování URL pro generování stabilních identifikátorů:

def _stable_id(url: str) -> str:
    digest = hashlib.sha1(url.encode("utf-8"), usedforsecurity=False).hexdigest()
    return digest[:12]

Tato metoda selhává při výměně poskytovatele: URL se mění úplně, identifikátory se přepočítávají, kurace se ztrácí. Řešení — ukládat pořadí podle názvů kanálů, které zůstávají stabilní mezi dodavateli. Vnitřní mechanismus převádí název ↔ ID prostřednictvím dynamického slovníku:

@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 nadále pracuje s ID, ale při načtení nového playlistu se kurace automaticky aplikuje na kanály s shodnými názvy. To zajišťuje kontinuitu uživatelského zážitku při výměně zdroje dat.

Google AdInline article slot

Synchronizace Main a Source bez tlačítka uložení

Kurátorský seznam „Main“ a zdrojová skupina „hlavní“ musí být aktualizovány atomicky. Při přetažení kanálu do Main je potřeba:

  • Aktualizovat stav kurace
  • Přepsat m3u8 soubor s novým pořadím
  • Uložit seznam názvů pro budoucí importy

Realizováno prostřednictvím centralizovaného pomocníka _sync_main_to_source, volaného po každé mutaci:

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)

Kritický moment: přímé přebíndání prostřednictvím bind_playlist() místo přečtení state.json zabraňuje race conditions. Na frontendu React Query neplatní cache zdroje po mutaci:

Google AdInline article slot
onSettled: (server) => {
  if (server) client.setQueryData(KEY_MAIN, server)
  client.invalidateQueries({ queryKey: KEY_SOURCE })
}

Výsledek — okamžitá synchronizace panelů bez explicitního uložení.

HLS proxy pro obcházení CORS

IPTV poskytovatelé zřídka posílají CORS hlavičky, což znemožňuje přímé přehrávání v prohlížeči. Proxy server přepisuje nejen hlavní manifest, ale i všechny vnořené odkazy na segmenty:

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)

Tento 40řádkový handler rekurzivně nahrazuje všechny relativní cesty v m3u8 souborech za proxy URL, což zajišťuje správné načítání segmentů přes aplikační server.

Transkodování audia za letu

Pro kanály s AC-3/E-AC-3 audiom, které prohlížeče nepodporují, je implementován fallback mechanismus. Při aktivaci tlačítka „Opravit audio“ se spustí ffmpeg transkodování:

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"),
)

Přehrávač se přepne na /api/transcode/{channel_id}/index.m3u8, čímž obnoví zvuk během 3–4 sekund (latence HLS segmentu). Procesy ffmpeg spravuje úloha na čištění na pozadí pro prevenci úniků zdrojů.

Implementace světlého tématu na dark-only bázi

Přepsání tisíců řádků kódu pro podporu světlého tématu se ukázalo jako nepraktické. Místo toho byly v index.css přidány přepsání utility tříd:

[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); }

Semantické proměnné --tint-bg-sm a --tint-border-sm tvoří hierarchii elevace. Specificita selektorů [data-theme="light"] .class (0,2,0) překrývá standardní Tailwind třídy (0,1,0), což umožňuje vyhnout se změnám komponent.

Co je důležité

  • Stabilita kurace: Uložení pořadí podle názvů kanálů, ne ID, zachovává nastavení při výměně poskytovatele
  • Atomová synchronizace: Sloučení aktualizace stavu, m3u8 souboru a seznamu názvů do jedné operace
  • Proxy transformace: Rekurzivní přepsání všech URL v HLS manifestech pro obcházení CORS
  • Audio fallback: On-demand transkodování AC-3 do AAC přes ffmpeg se správou procesů
  • Thematizace bez refaktoringu: Přepsání utility tříd přes data atributy s kontrolou specificity

Další komponenty projektu zahrnují parser m3u s podporou EXTGRP a tvg atributů, resolver loga (lokální override → iptv-org/database → CDN), detektor duplicitat přes normalizaci názvů, XMLTV EPG načítání s cachováním a skokem do archivu. Drag-and-drop je realizován na @dnd-kit s custom collision detection.

Spouštění projektu je triviální:

git clone https://github.com/stepanovandrey89/m3ustudio.git
cd m3ustudio
docker compose up -d

Po otevření http://127.0.0.1:8000 uživatel nahraje svůj m3u8 soubor a začne editovat. Projekt je otevřený pro PR a diskuzi o architektonických řešeních.

— Editorial Team

Advertisement 728x90

Číst dál