# Local IPTV Playlist Editor Architecture: How FastAPI and React 19 Solve Synchronization and Playback Challenges
Editing IPTV playlists with thousands of channels requires complex state synchronization and workarounds for technical limitations. Let's examine the architectural solutions in the open-source project m3u Studio, built with FastAPI and React 19.
Managing Large Playlists
Standard m3u file editors can't handle dynamic changes from providers. When updating the channel list, curation is lost: order, grouping, favorites. Key challenges:
- Stream URLs change when switching providers, breaking identifiers
- Lack of CORS in IPTV sources blocks direct browser playback
- Audio codec incompatibility (AC-3/E-AC-3) in Chrome/Safari
- Synchronizing two panels without a save button
m3u Studio addresses these issues via a local web interface with drag-and-drop, a built-in HLS player, and EPG. The stack includes FastAPI + Pydantic v2 on the backend, React 19 + TypeScript + Tailwind CSS on the frontend.
Storing State by Names, Not IDs
The traditional approach hashes URLs to generate stable identifiers:
def _stable_id(url: str) -> str:
digest = hashlib.sha1(url.encode("utf-8"), usedforsecurity=False).hexdigest()
return digest[:12]
This method fails when switching providers: URLs change completely, identifiers are recalculated, and curation is lost. The solution stores order by channel names, which remain stable across providers. An internal mechanism converts name ↔ ID via a dynamic dictionary:
@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]
The API continues working with IDs, but when loading a new playlist, curation is automatically applied to channels with matching names. This ensures continuity of the user experience when switching data sources.
Synchronizing Main and Source Without a Save Button
The curated "Main" list and the source "main" group must update atomically. When dragging a channel to Main, it requires:
- Updating curation state
- Rewriting the m3u8 file with the new order
- Saving the name list for future imports
Implemented via a centralized helper _sync_main_to_source, called after each mutation:
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)
Critical point: direct rebinding via bind_playlist() instead of rereading state.json prevents race conditions. On the frontend, React Query invalidates the source cache after mutation:
onSettled: (server) => {
if (server) client.setQueryData(KEY_MAIN, server)
client.invalidateQueries({ queryKey: KEY_SOURCE })
}
Result: instant panel synchronization without explicit saving.
HLS Proxy to Bypass CORS
IPTV providers rarely send CORS headers, making direct browser playback impossible. The proxy server rewrites not only the master manifest but all nested 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)
This 40-line handler recursively replaces all relative paths in m3u8 files with proxy URLs, ensuring correct segment loading through the app server.
On-the-Fly Audio Transcoding
For channels with AC-3/E-AC-3 audio unsupported by browsers, a fallback mechanism is implemented. When the "Fix audio" button is activated, ffmpeg transcoding kicks in:
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"),
)
The player switches to /api/transcode/{channel_id}/index.m3u8, restoring audio in 3-4 seconds (HLS segment latency). ffmpeg processes are managed by a background cleanup task to prevent resource leaks.
Implementing Light Theme on a Dark-Only Base
Rewriting thousands of lines of code for light theme support proved impractical. Instead, index.css adds overrides for utility classes:
[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); }
Semantic variables like --tint-bg-sm and --tint-border-sm create an elevation hierarchy. Selector specificity of [data-theme="light"] .class (0,2,0) overrides standard Tailwind classes (0,1,0), avoiding component changes.
Key Takeaways
- Curation Stability: Storing order by channel names, not IDs, preserves settings when switching providers
- Atomic Synchronization: Combining state updates, m3u8 file rewrite, and name list save into one operation
- Proxy Transformation: Recursive rewriting of all URLs in HLS manifests to bypass CORS
- Audio Fallback: On-demand AC-3 to AAC transcoding via ffmpeg with process management
- Theming Without Refactoring: Overriding utility classes via data attributes with specificity control
Additional project components include an m3u parser with EXTGRP and tvg attribute support, logo resolver (local overrides → iptv-org/database → CDN), duplicate detector via name normalization, XMLTV EPG loader with caching and archive jump. Drag-and-drop uses @dnd-kit with custom collision detection.
Launching the project is straightforward:
git clone https://github.com/stepanovandrey89/m3ustudio.git
cd m3ustudio
docker compose up -d
After opening http://127.0.0.1:8000, the user uploads their m3u8 file and starts editing. The project is open for PRs and architectural discussions.
— Editorial Team
No comments yet.