""" VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime Fetches stream URLs from hd.xemtv.net PHP endpoints EPG data scraped from https://vtv.vn/lich-phat-song.htm """ import re, time, threading import requests from fastapi import APIRouter, Query from fastapi.responses import JSONResponse, Response from bs4 import BeautifulSoup from datetime import datetime, timedelta, timezone VN_TZ = timezone(timedelta(hours=7)) router = APIRouter() UA = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", "Accept-Language": "vi-VN,vi;q=0.9", "Referer": "https://hd.xemtv.net/", } XEMTV_PHP_ENDPOINTS = { "vtv1": "https://hd.xemtv.net/kenh/vtv1.php", "vtv2": "https://hd.xemtv.net/kenh/vtv2.php", "vtv3": "https://hd.xemtv.net/kenh/vtv3.php", "vtv4": "https://hd.xemtv.net/kenh/vtv4.php", "vtv5": "https://hd.xemtv.net/kenh/vtv5.php", "vtv6": "https://hd.xemtv.net/kenh/vtv6.php", "vtv7": "https://hd.xemtv.net/kenh/vtv7.php", "vtv8": "https://hd.xemtv.net/kenh/vtv8.php", "vtv9": "https://hd.xemtv.net/kenh/vtv9.php", "vtv10": "https://hd.xemtv.net/kenh/vtv10.php", "vtvprime": "https://hd.xemtv.net/kenh/vtvprime.php", } CHANNEL_NAMES = { "vtv1": "VTV1", "vtv2": "VTV2", "vtv3": "VTV3", "vtv4": "VTV4", "vtv5": "VTV5", "vtv6": "VTV6", "vtv7": "VTV7", "vtv8": "VTV8", "vtv9": "VTV9", "vtv10": "VTV10", "vtvprime": "VTVPrime", } VTVGO_FAILOVER = { "vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8", "vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8", "vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8", "vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8", "vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8", "vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8", "vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8", "vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8", } XEMTV_ONLY_CHANNELS = {"vtv6", "vtv10"} FPTPLAY_URLS = { "vtv1": "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8", "vtv2": "https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8", "vtv3": "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8", "vtv4": "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8", "vtv5": "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8", "vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/vtv6-avc1_5600000=10000-mp4a_131600=20000.m3u8", "vtv7": "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8", "vtv8": "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8", "vtv9": "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8", "vtv10": "https://live.fptplay53.net/fnxch2/vtvcantho_abr.smil/chunklist.m3u8", } _vtv_cache = {} _vtv_lock = threading.Lock() _CACHE_TTL = 180 def _cached(key): with _vtv_lock: if key in _vtv_cache and time.time() - _vtv_cache[key]['t'] < _CACHE_TTL: return _vtv_cache[key]['d'] return None def _set_cache(key, data): with _vtv_lock: _vtv_cache[key] = {'t': time.time(), 'd': data} def extract_m3u8_from_html(html): if not html: return None m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE) if m: url = m.group(1).strip() if len(url) > 20: return url m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE) if m: url = m.group(1).strip() if len(url) > 20: return url return None def fetch_xemtv_stream(channel_id): php_url = XEMTV_PHP_ENDPOINTS.get(channel_id) if not php_url: return None try: r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True) if r.status_code == 200: m3u8 = extract_m3u8_from_html(r.text) if m3u8: return m3u8 except: pass return None def fetch_fptplay_stream(channel_id): url = FPTPLAY_URLS.get(channel_id) if not url: return None try: headers = {"User-Agent": UA["User-Agent"], "Referer": "https://fptplay.vn/", "Origin": "https://fptplay.vn"} r = requests.get(url, headers=headers, timeout=15, allow_redirects=True) if r.status_code == 200: return url except: pass return None def fetch_vtv_stream(channel_id): channel_id = channel_id.lower().strip() name_map = { 'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10', 'vtv_can_tho': 'vtv10', 'cantho': 'vtv10', 'vietnam_vtv1': 'vtv1', 'vietnam_vtv2': 'vtv2', 'vietnam_vtv3': 'vtv3', 'vietnam_vtv4': 'vtv4', 'vietnam_vtv5': 'vtv5', 'vietnam_vtv6': 'vtv6', 'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9', } channel_id = name_map.get(channel_id, channel_id) cached = _cached(channel_id) if cached is not None: return cached if channel_id in XEMTV_ONLY_CHANNELS: xemtv_url = fetch_xemtv_stream(channel_id) if xemtv_url: _set_cache(channel_id, xemtv_url) return xemtv_url fpt_url = FPTPLAY_URLS.get(channel_id) if fpt_url: _set_cache(channel_id, fpt_url) return fpt_url _set_cache(channel_id, None) return None if channel_id in VTVGO_FAILOVER: result = VTVGO_FAILOVER[channel_id] _set_cache(channel_id, result) return result if channel_id == 'vtvprime': xemtv_url = fetch_xemtv_stream('vtvprime') if xemtv_url: _set_cache(channel_id, xemtv_url) return xemtv_url _set_cache(channel_id, None) return None xemtv_url = fetch_xemtv_stream(channel_id) if xemtv_url: _set_cache(channel_id, xemtv_url) return xemtv_url fpt_url = fetch_fptplay_stream(channel_id) if fpt_url: _set_cache(channel_id, fpt_url) return fpt_url _set_cache(channel_id, None) return None @router.get("/api/vtv/streams") def api_vtv_streams(): result = {} for ch_id in CHANNEL_NAMES: stream_url = fetch_vtv_stream(ch_id) result[ch_id] = {"name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "status": "ok" if stream_url else "offline"} return JSONResponse(result) @router.get("/api/vtv/stream/{channel_id}") def api_vtv_stream(channel_id: str): stream_url = fetch_vtv_stream(channel_id) if stream_url: return JSONResponse({"stream_url": stream_url, "status": "ok"}) return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404) @router.get("/api/proxy/page") def proxy_page(url: str = Query(...)): try: headers = {**UA} if "xemtv.net" in url: headers["Referer"] = "https://hd.xemtv.net/" r = requests.get(url, headers=headers, timeout=15, allow_redirects=True) if r.status_code != 200: return Response(status_code=502, content="upstream error") return Response(content=r.text.encode("utf-8"), media_type="text/html; charset=utf-8", headers={"Access-Control-Allow-Origin": "*"}) except: return Response(status_code=502, content="proxy error") @router.get("/api/proxy/m3u8/vtv") def proxy_vtv_m3u8(url: str = Query(...)): try: headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"} if "fptplay" in url: headers["Referer"] = "https://fptplay.vn/" headers["Origin"] = "https://fptplay.vn" elif "xemtv" in url: headers["Referer"] = "https://hd.xemtv.net/" r = requests.get(url, headers=headers, timeout=15, allow_redirects=True) if r.status_code != 200: return Response(status_code=502, content="upstream error") content = r.text lines = content.split('\n') rewritten = [] base_url = url.rsplit('/', 1)[0] + '/' for line in lines: line = line.strip() if not line or line.startswith('#'): rewritten.append(line) else: seg_url = line if not seg_url.startswith('http'): seg_url = base_url + seg_url rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe="")) return Response(content='\n'.join(rewritten).encode("utf-8"), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache"}) except Exception as e: return Response(status_code=502, content="proxy error: " + str(e)) @router.get("/api/proxy/seg/vtv") def proxy_vtv_segment(url: str = Query(...)): try: headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"} if "fptplay" in url: headers["Referer"] = "https://fptplay.vn/" headers["Origin"] = "https://fptplay.vn" r = requests.get(url, headers=headers, timeout=30, allow_redirects=True) if r.status_code != 200: return Response(status_code=502, content="upstream error") data = r.content if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47: data = data[188:] return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600"}) except: return Response(status_code=502, content="proxy error") # ===== EPG from vtv.vn/lich-phat-song.htm ===== _epg_cache = {} _epg_cache_time = 0 _EPG_CACHE_TTL = 1800 # 30 minutes # Map vtv.vn channel IDs to our channel IDs VTV_CHANNEL_MAP = { "vtv1": "vtv1", "vtv2": "vtv2", "vtv3": "vtv3", "vtv4": "vtv4", "vtv5": "vtv5", "vtv5-tay-nam-bo": "vtv5", "vtv5-tay-nguyen": "vtv5", "vtv7": "vtv7", "vtv8": "vtv8", "vtv9": "vtv9", "vtv-can-tho": "vtv10", } def _fetch_epg_from_vtv(): """Fetch EPG from https://vtv.vn/lich-phat-song.htm""" global _epg_cache, _epg_cache_time now_ts = time.time() if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL: return _epg_cache epg_data = {} try: headers = { "User-Agent": UA["User-Agent"], "Accept-Language": "vi-VN,vi;q=0.9", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Referer": "https://vtv.vn/", } r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=headers, timeout=20) if r.status_code != 200: print(f"EPG vtv.vn fetch failed: {r.status_code}") return epg_data r.encoding = "utf-8" soup = BeautifulSoup(r.text, "lxml") # Get channel order from list-channel section channel_order = [] list_channel = soup.find(class_=re.compile(r'list-channel')) if list_channel: for link in list_channel.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')): ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', '')) if ch_id: channel_order.append(ch_id.group(1)) # If no list-channel found, try to find channel links elsewhere if not channel_order: for link in soup.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')): ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', '')) if ch_id and ch_id.group(1) not in channel_order: channel_order.append(ch_id.group(1)) # Find all