Powrót do strony głównej

Architektura edytora IPTV na FastAPI i React 19 | Analiza techniczna

Analiza rozwiązań architektonicznych open-source edytora playlist IPTV m3u Studio. Omówiono metody synchronizacji stanu, obejścia CORS przez proxy HLS, transkodowania audio on-demand i implementacji tematyzacji bez refaktoryzacji kodu.

Głęboka analiza techniczna: jak FastAPI i React 19 zarządzają playlistami IPTV
Advertisement 728x90

# 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.

Google AdInline article slot

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.

Google AdInline article slot

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:

Google AdInline article slot
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

Advertisement 728x90

Czytaj dalej