## Architecture de l'éditeur local de playlists IPTV : Comment FastAPI et React 19 résolvent les défis de synchronisation et de lecture
La modification de playlists IPTV comptant des milliers de chaînes nécessite une synchronisation d'état complexe et des solutions de contournement pour les limitations techniques. Examinons les solutions architecturales du projet open-source m3u Studio, construit avec FastAPI et React 19.
Gestion des grandes playlists
Les éditeurs standards de fichiers m3u ne gèrent pas les changements dynamiques des fournisseurs. Lors de la mise à jour de la liste des chaînes, la curation est perdue : ordre, regroupement, favoris. Principaux défis :
- Les URL des flux changent lors du passage à un autre fournisseur, ce qui casse les identifiants
- L'absence de CORS dans les sources IPTV bloque la lecture directe dans le navigateur
- Incompatibilité des codecs audio (AC-3/E-AC-3) dans Chrome/Safari
- Synchronisation de deux panneaux sans bouton de sauvegarde
m3u Studio résout ces problèmes grâce à une interface web locale avec glisser-déposer, un lecteur HLS intégré et un EPG. La pile technologique comprend FastAPI + Pydantic v2 côté backend, React 19 + TypeScript + Tailwind CSS côté frontend.
Stockage de l'état par noms, pas par ID
L'approche traditionnelle consiste à hacher les URL pour générer des identifiants stables :
def _stable_id(url: str) -> str:
digest = hashlib.sha1(url.encode("utf-8"), usedforsecurity=False).hexdigest()
return digest[:12]
Cette méthode échoue lors du changement de fournisseur : les URL changent complètement, les identifiants sont recalculés et la curation est perdue. La solution stocke l'ordre par noms de chaînes, qui restent stables d'un fournisseur à l'autre. Un mécanisme interne convertit nom ↔ ID via un dictionnaire dynamique :
@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]
L'API continue de travailler avec les ID, mais lors du chargement d'une nouvelle playlist, la curation est automatiquement appliquée aux chaînes aux noms correspondants. Cela garantit la continuité de l'expérience utilisateur lors du changement de source de données.
Synchronisation Main et Source sans bouton de sauvegarde
La liste "Main" curatée et le groupe source "main" doivent se mettre à jour de manière atomique. Lors du glisser-déposer d'une chaîne vers Main, cela nécessite :
- Mise à jour de l'état de curation
- Réécriture du fichier m3u8 avec le nouvel ordre
- Sauvegarde de la liste des noms pour les imports futurs
Implémenté via un assistant centralisé _sync_main_to_source, appelé après chaque 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)
Point critique : la rebinding directe via bind_playlist() au lieu de relire state.json évite les conditions de course. Côté frontend, React Query invalide le cache source après mutation :
onSettled: (server) => {
if (server) client.setQueryData(KEY_MAIN, server)
client.invalidateQueries({ queryKey: KEY_SOURCE })
}
Résultat : synchronisation instantanée des panneaux sans sauvegarde explicite.
Proxy HLS pour contourner CORS
Les fournisseurs IPTV envoient rarement les en-têtes CORS, rendant la lecture directe dans le navigateur impossible. Le serveur proxy réécrit non seulement le manifeste principal mais tous les liens de segments imbriqués :
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)
Ce gestionnaire de 40 lignes remplace récursivement tous les chemins relatifs dans les fichiers m3u8 par des URL de proxy, assurant le chargement correct des segments via le serveur d'application.
Transcodage audio à la volée
Pour les chaînes avec audio AC-3/E-AC-3 non pris en charge par les navigateurs, un mécanisme de fallback est implémenté. Quand le bouton "Corriger l'audio" est activé, le transcodage ffmpeg se déclenche :
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"),
)
Le lecteur bascule sur /api/transcode/{channel_id}/index.m3u8, restaurant l'audio en 3-4 secondes (latence des segments HLS). Les processus ffmpeg sont gérés par une tâche de nettoyage en arrière-plan pour éviter les fuites de ressources.
Implémentation du thème clair sur une base sombre uniquement
Réécrire des milliers de lignes de code pour supporter le thème clair s'est avéré impraticable. À la place, index.css ajoute des surcharges pour les classes utilitaires :
[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); }
Des variables sémantiques comme --tint-bg-sm et --tint-border-sm créent une hiérarchie d'élévation. La spécificité du sélecteur [data-theme="light"] .class (0,2,0) surcharge les classes Tailwind standard (0,1,0), évitant les modifications de composants.
Enseignements clés
- Stabilité de la curation : Stockage de l'ordre par noms de chaînes, pas par ID, préserve les paramètres lors du changement de fournisseur
- Synchronisation atomique : Combinaison des mises à jour d'état, réécriture du fichier m3u8 et sauvegarde de la liste des noms en une seule opération
- Transformation proxy : Réécriture récursive de toutes les URL dans les manifestes HLS pour contourner CORS
- Fallback audio : Transcodage AC-3 vers AAC à la demande via ffmpeg avec gestion des processus
- Thématisation sans refactoring : Surcharge des classes utilitaires via attributs data avec contrôle de spécificité
Les composants supplémentaires du projet incluent un parseur m3u avec support EXTGRP et attributs tvg, résolveur de logos (surcharges locales → iptv-org/database → CDN), détecteur de doublons via normalisation des noms, chargeur XMLTV EPG avec mise en cache et saut d'archive. Le glisser-déposer utilise @dnd-kit avec détection de collision personnalisée.
Le lancement du projet est simple :
git clone https://github.com/stepanovandrey89/m3ustudio.git
cd m3ustudio
docker compose up -d
Après ouverture de http://127.0.0.1:8000, l'utilisateur charge son fichier m3u8 et commence l'édition. Le projet est ouvert aux PR et discussions architecturales.
— Editorial Team
Aucun commentaire pour le moment.