로컬 IPTV 플레이리스트 편집기 아키텍처: FastAPI와 React 19가 동기화 및 재생 문제를 어떻게 해결하는가
수천 개 채널을 가진 IPTV 플레이리스트를 편집하려면 복잡한 상태 동기화와 기술적 제한에 대한 대처가 필요합니다. FastAPI와 React 19로 구축된 오픈 소스 프로젝트 m3u Studio의 아키텍처 솔루션을 살펴보겠습니다.
대형 플레이리스트 관리
표준 m3u 파일 에디터는 공급자로부터의 동적 변경을 처리할 수 없습니다. 채널 목록을 업데이트하면 큐레이션이 사라집니다: 순서, 그룹화, 즐겨찾기. 주요 도전 과제:
- 공급자를 변경할 때 스트림 URL이 바뀌어 식별자가 깨짐
- IPTV 소스의 CORS 부재로 브라우저 직접 재생 불가
- Chrome/Safari에서 오디오 코덱 호환성 문제 (AC-3/E-AC-3)
- 저장 버튼 없이 두 패널 동기화
m3u Studio는 드래그 앤 드롭이 가능한 로컬 웹 인터페이스, 내장 HLS 플레이어, EPG를 통해 이러한 문제를 해결합니다. 스택은 백엔드에 FastAPI + Pydantic v2, 프론트엔드에 React 19 + TypeScript + Tailwind CSS를 사용합니다.
ID가 아닌 이름으로 상태 저장
전통적인 접근법은 URL을 해싱해 안정적인 식별자를 생성합니다:
def _stable_id(url: str) -> str:
digest = hashlib.sha1(url.encode("utf-8"), usedforsecurity=False).hexdigest()
return digest[:12]
이 방법은 공급자를 변경할 때 실패합니다: URL이 완전히 바뀌어 식별자가 재계산되고 큐레이션이 사라집니다. 해결책은 채널 이름으로 순서를 저장하는 것으로, 공급자 간에 안정적입니다. 내부 메커니즘은 동적 딕셔너리를 통해 이름 ↔ ID를 변환합니다:
@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는 ID로 계속 작동하지만, 새 플레이리스트를 로드할 때 이름이 일치하는 채널에 큐레이션이 자동 적용됩니다. 이렇게 하면 데이터 소스를 변경할 때 사용자 경험의 연속성이 보장됩니다.
저장 버튼 없이 Main과 Source 동기화
큐레이션된 "Main" 목록과 소스 "main" 그룹은 원자적으로 업데이트되어야 합니다. 채널을 Main으로 드래그할 때 다음이 필요합니다:
- 큐레이션 상태 업데이트
- 새 순서로 m3u8 파일 다시 작성
- 미래 임포트를 위한 이름 목록 저장
각 변경 후 호출되는 중앙화된 헬퍼 _sync_main_to_source로 구현됩니다:
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)
핵심: bind_playlist()으로 직접 재바인딩해 state.json 재읽기를 피함으로써 레이스 컨디션을 방지합니다. 프론트엔드에서는 React Query가 변경 후 소스 캐시를 무효화합니다:
onSettled: (server) => {
if (server) client.setQueryData(KEY_MAIN, server)
client.invalidateQueries({ queryKey: KEY_SOURCE })
}
결과: 명시적 저장 없이 즉시 패널 동기화.
CORS 우회를 위한 HLS 프록시
IPTV 공급자는 CORS 헤더를 거의 보내지 않아 브라우저 직접 재생이 불가능합니다. 프록시 서버는 마스터 매니페스트뿐만 아니라 모든 중첩 세그먼트 링크를 다시 작성합니다:
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)
이 40줄 핸들러는 m3u8 파일의 모든 상대 경로를 재귀적으로 프록시 URL로 교체해 앱 서버를 통해 올바른 세그먼트 로딩을 보장합니다.
실시간 오디오 트랜스코딩
브라우저에서 지원되지 않는 AC-3/E-AC-3 오디오 채널에 대한 대체 메커니즘을 구현했습니다. "오디오 수정" 버튼을 활성화하면 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"),
)
플레이어는 /api/transcode/{channel_id}/index.m3u8로 전환해 3-4초 (HLS 세그먼트 지연) 내에 오디오를 복원합니다. ffmpeg 프로세스는 백그라운드 정리 작업으로 관리해 리소스 누수를 방지합니다.
다크 전용 베이스에 라이트 테마 구현
라이트 테마 지원을 위해 수천 줄 코드를 다시 작성하는 것은 비현실적이었습니다. 대신 index.css에 유틸리티 클래스 오버라이드를 추가합니다:
[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); }
--tint-bg-sm 및 --tint-border-sm 같은 의미적 변수로 고도화 계층을 만듭니다. [data-theme="light"] .class (0,2,0)의 선택자 특이도가 표준 Tailwind 클래스 (0,1,0)를 오버라이드해 컴포넌트 변경 없이 적용됩니다.
주요 요점
- 큐레이션 안정성: ID가 아닌 채널 이름으로 순서 저장, 공급자 변경 시 설정 보존
- 원자적 동기화: 상태 업데이트, m3u8 파일 재작성, 이름 목록 저장을 하나의 작업으로 결합
- 프록시 변환: HLS 매니페스트의 모든 URL 재귀 재작성으로 CORS 우회
- 오디오 대체: ffmpeg를 통한 주문형 AC-3 to AAC 트랜스코딩과 프로세스 관리
- 리팩토링 없는 테마링: 데이터 속성을 통한 유틸리티 클래스 오버라이드와 특이도 제어
추가 프로젝트 구성 요소로는 EXTGRP 및 tvg 속성을 지원하는 m3u 파서, 로고 리졸버 (로컬 오버라이드 → iptv-org/database → CDN), 이름 정규화를 통한 중복 감지기, 캐싱과 아카이브 점프를 지원하는 XMLTV EPG 로더가 있습니다. 드래그 앤 드롭은 @dnd-kit과 커스텀 충돌 감지를 사용합니다.
프로젝트 실행은 간단합니다:
git clone https://github.com/stepanovandrey89/m3ustudio.git
cd m3ustudio
docker compose up -d
http://127.0.0.1:8000을 열고 m3u8 파일을 업로드한 후 편집을 시작할 수 있습니다. 프로젝트는 PR과 아키텍처 논의에 열려 있습니다.
— Editorial Team
아직 댓글이 없습니다.