# # Architektur eines lokalen IPTV-Playlist-Editors: Wie FastAPI und React 19 Synchronisations- und Wiedergabeherausforderungen meistern
Das Bearbeiten von IPTV-Playlists mit Tausenden von Kanälen erfordert komplexe Zustandssynchronisation und Workarounds für technische Einschränkungen. Schauen wir uns die architektonischen Lösungen im Open-Source-Projekt m3u Studio an, das mit FastAPI und React 19 aufgebaut ist.
Umgang mit großen Playlists
Standard-Editoren für m3u-Dateien können dynamische Änderungen der Anbieter nicht bewältigen. Beim Aktualisieren der Kanalliste gehen Kuratierungen verloren: Reihenfolge, Gruppierung, Favoriten. Wichtige Herausforderungen:
- Stream-URLs ändern sich beim Wechsel des Anbieters und machen Identifikatoren kaputt
- Fehlende CORS-Unterstützung in IPTV-Quellen blockiert direkte Wiedergabe im Browser
- Inkompatibilität von Audio-Codecs (AC-3/E-AC-3) in Chrome/Safari
- Synchronisation zweier Panels ohne Speichern-Button
m3u Studio löst diese Probleme über eine lokale Weboberfläche mit Drag-and-Drop, einem integrierten HLS-Player und EPG. Der Tech-Stack umfasst FastAPI + Pydantic v2 auf dem Backend sowie React 19 + TypeScript + Tailwind CSS auf dem Frontend.
Zustandsspeicherung nach Namen, nicht nach IDs
Der klassische Ansatz hasht URLs, um stabile Identifikatoren zu erzeugen:
def _stable_id(url: str) -> str:
digest = hashlib.sha1(url.encode("utf-8"), usedforsecurity=False).hexdigest()
return digest[:12]
Diese Methode scheitert beim Wechsel des Anbieters: URLs ändern sich komplett, Identifikatoren werden neu berechnet, und Kuratierungen gehen verloren. Die Lösung speichert die Reihenfolge nach Kanalnamen, die über Anbieter hinweg stabil bleiben. Ein interner Mechanismus wandelt Name ↔ ID über ein dynamisches Dictionary um:
@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]
Die API arbeitet weiterhin mit IDs, aber beim Laden einer neuen Playlist wird die Kuratierung automatisch auf Kanäle mit passenden Namen angewendet. So bleibt die Benutzererfahrung kontinuierlich, auch beim Wechsel der Datenquelle.
Synchronisation von Haupt- und Quellenliste ohne Speichern-Button
Die kuratierte „Main“-Liste und die Quellen-„main“-Gruppe müssen atomar aktualisiert werden. Beim Ziehen eines Kanals in Main ist Folgendes nötig:
- Aktualisierung des Kuratierungs-Zustands
- Umschreiben der m3u8-Datei mit neuer Reihenfolge
- Speichern der Namensliste für zukünftige Imports
Umgesetzt über einen zentralen Helfer _sync_main_to_source, der nach jeder Mutation aufgerufen wird:
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)
Kritischer Punkt: Direkte Neubindung via bind_playlist() statt erneutes Einlesen von state.json verhindert Race Conditions. Auf dem Frontend invalidiert React Query den Quellen-Cache nach der Mutation:
onSettled: (server) => {
if (server) client.setQueryData(KEY_MAIN, server)
client.invalidateQueries({ queryKey: KEY_SOURCE })
}
Ergebnis: Sofortige Panelsynchronisation ohne explizites Speichern.
HLS-Proxy zur Umgehung von CORS
IPTV-Anbieter versenden selten CORS-Header, was direkte Browser-Wiedgabe unmöglich macht. Der Proxy-Server umschreibt nicht nur das Master-Manifest, sondern alle verschachtelten Segment-Links:
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)
Dieser 40-Zeilen-Handler ersetzt rekursiv alle relativen Pfade in m3u8-Dateien durch Proxy-URLs und sorgt für korrektes Segment-Laden über den App-Server.
Audio-Transcoding on the Fly
Für Kanäle mit AC-3/E-AC-3-Audio, das Browser nicht unterstützen, gibt es einen Fallback-Mechanismus. Beim Aktivieren des „Fix audio“-Buttons springt ffmpeg-Transcoding an:
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"),
)
Der Player wechselt zu /api/transcode/{channel_id}/index.m3u8 und stellt das Audio in 3-4 Sekunden wieder her (HLS-Segment-Latenz). ffmpeg-Prozesse werden von einer Hintergrund-Cleanup-Aufgabe verwaltet, um Ressourcenlecks zu vermeiden.
Helles Theme auf dunkler Basis umsetzen
Tausende Codezeilen für Light-Theme-Unterstützung umzuschreiben war unpraktikabel. Stattdessen fügt index.css Overrides für Utility-Klassen hinzu:
[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); }
Semantische Variablen wie --tint-bg-sm und --tint-border-sm erzeugen eine Hierarchie der Erhöhung. Die Selektor-Spezifität von [data-theme="light"] .class (0,2,0) überschreibt Standard-Tailwind-Klassen (0,1,0), ohne Komponenten zu ändern.
Wichtige Erkenntnisse
- Kuratierungsstabilität: Reihenfolge nach Kanalnamen speichern, nicht nach IDs, erhält Einstellungen beim Anbieterwechsel
- Atomare Synchronisation: Zustandsupdates, m3u8-Umschreibung und Namenslisten-Speicherung in einer Operation kombinieren
- Proxy-Transformation: Rekursives Umschreiben aller URLs in HLS-Manifesten zur CORS-Umgehung
- Audio-Fallback: Bedarfsweises AC-3-zu-AAC-Transcoding via ffmpeg mit Prozessverwaltung
- Theming ohne Refactoring: Utility-Klassen via Data-Attribute mit Spezifitätssteuerung überschreiben
Weitere Projektkomponenten: m3u-Parser mit EXTGRP- und tvg-Attribut-Support, Logo-Resolver (lokale Overrides → iptv-org/database → CDN), Duplikat-Detektor via Namensnormalisierung, XMLTV-EPG-Loader mit Caching und Archiv-Sprung. Drag-and-Drop nutzt @dnd-kit mit custom Collision Detection.
Das Projekt starten ist einfach:
git clone https://github.com/stepanovandrey89/m3ustudio.git
cd m3ustudio
docker compose up -d
Nach dem Öffnen von http://127.0.0.1:8000 lädt der Nutzer seine m3u8-Datei hoch und beginnt mit dem Editieren. Das Projekt ist offen für PRs und architektonische Diskussionen.
— Editorial Team
Noch keine Kommentare.