""" VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime Fetches stream URLs from xemtv.us PHP endpoints (primary) Fallback: FPTPlay CDN → VTVGo CDN → xemtv.net (legacy) 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", } # ===== PRIMARY: xemtv.us (new domain, works 2025-2026) ===== XEMTV_US_ENDPOINTS = { "vtv1": "https://xemtv.us/tv/vtv1.php", "vtv2": "https://xemtv.us/tv/vtv2.php", "vtv3": "https://xemtv.us/tv/vtv3.php", "vtv4": "https://xemtv.us/tv/vtv4.php", "vtv5": "https://xemtv.us/tv/vtv5.php", "vtv6": "https://xemtv.us/tv/vtv6.php", "vtv7": "https://xemtv.us/tv/vtv7.php", "vtv8": "https://xemtv.us/tv/vtv8.php", "vtv9": "https://xemtv.us/tv/vtv9.php", "vtv10": "https://xemtv.us/tv/vtv10.php", "vtvprime": "https://xemtv.us/tv/vtvprime.php", } # ===== LEGACY: xemtv.net (may return 403, keep as last resort) ===== XEMTV_LEGACY_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", } # ===== FALLBACK 1: FPTPlay CDN (new URLs 2025-2026) ===== FPTPLAY_URLS = { "vtv1": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8", "vtv2": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8", "vtv3": "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8", "vtv4": "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8", "vtv5": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8", "vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8", "vtv7": "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8", "vtv8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8", "vtv9": "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8", "vtv10": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8", } # ===== FALLBACK 2: VTVGo CDN ===== 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", "vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-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", } _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_us_stream(channel_id): php_url = XEMTV_US_ENDPOINTS.get(channel_id) if not php_url: return None try: headers = {**UA, "Referer": "https://xemtv.us/"} r = requests.get(php_url, headers=headers, timeout=15, allow_redirects=True, verify=False) if r.status_code == 200: m3u8 = extract_m3u8_from_html(r.text) if m3u8: return m3u8 except: pass return None def fetch_xemtv_legacy_stream(channel_id): php_url = XEMTV_LEGACY_ENDPOINTS.get(channel_id) if not php_url: return None try: headers = {**UA, "Referer": "https://hd.xemtv.net/"} r = requests.get(php_url, headers=headers, timeout=15, allow_redirects=True, verify=False) 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, verify=False) if r.status_code == 200 and '#EXTM3U' in r.text[:200]: return url except: pass return None def fetch_vtvgo_stream(channel_id): url = VTVGO_FAILOVER.get(channel_id) if not url: return None try: headers = {**UA, "Referer": "https://vtvgo.vn/"} r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False) if r.status_code == 200 and '#EXTM3U' in r.text[:200]: return url except: pass return None def normalize_fptplay_url(url): """Replace old/broken FPTPlay URLs with new working ones""" if not url: return url old_to_new = { "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8", "https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8", "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8", "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8", "https://live.fptplay53.net/fnxhd1/vtv5hd_vhls.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8", "https://live.fptplay53.net/fnxhd1/vtv6hd_vhls.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8", "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8": "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8", "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/c.hunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8", "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8", "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8", "https://live.fptplay53.net/fnxhd1/vtv10hd_vhls.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8", "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8", } return old_to_new.get(url, url) def fetch_vtv_stream(channel_id): """Fetch VTV stream with multi-source fallback chain: 1. xemtv.us (primary - new domain, most reliable) 2. FPTPlay CDN (fallback - new URLs) 3. VTVGo CDN (fallback) 4. xemtv.net legacy (last resort) """ 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 == 'vtvprime': url = fetch_xemtv_us_stream('vtvprime') or fetch_xemtv_legacy_stream('vtvprime') if url: url = normalize_fptplay_url(url) _set_cache(channel_id, url) return url # Source 1: xemtv.us (primary) url = fetch_xemtv_us_stream(channel_id) if url: url = normalize_fptplay_url(url) _set_cache(channel_id, url) return url # Source 2: FPTPlay CDN url = fetch_fptplay_stream(channel_id) if url: _set_cache(channel_id, url) return url # Source 3: VTVGo CDN url = fetch_vtvgo_stream(channel_id) if url: _set_cache(channel_id, url) return url # Source 4: xemtv.net legacy (last resort) url = fetch_xemtv_legacy_stream(channel_id) if url: url = normalize_fptplay_url(url) _set_cache(channel_id, url) return 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.us" in url: headers["Referer"] = "https://xemtv.us/" elif "xemtv.net" in url: headers["Referer"] = "https://hd.xemtv.net/" r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False) 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://xemtv.us/" elif "vtvgo" in url or "vtvdigital" in url: headers["Referer"] = "https://vtvgo.vn/" r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False) 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 if seg_url.endswith('.m3u8'): rewritten.append("/api/proxy/m3u8/vtv?url=" + requests.utils.quote(seg_url, safe="")) else: 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, verify=False) 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_cache = {} _epg_cache_time = 0 _EPG_CACHE_TTL = 600 # Giảm từ 30 phút xuống 10 phút, đảm bảo dữ liệu mới 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", "vtv6": "vtv6", "vtv9": "vtv9", "vtv-can-tho": "vtv10", } def _fetch_epg_from_vtv(): 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 = {} # Lấy thời gian VN hiện tại để truyền vào parse_time now_vn = datetime.now(VN_TZ) 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: return epg_data r.encoding = "utf-8" soup = BeautifulSoup(r.text, "lxml") 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 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)) prog_containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b')) for i, container in enumerate(prog_containers): if i >= len(channel_order): break vtv_ch_id = channel_order[i] our_ch_id = VTV_CHANNEL_MAP.get(vtv_ch_id, vtv_ch_id) if our_ch_id not in epg_data: epg_data[our_ch_id] = [] for li in container.find_all('li', class_=re.compile(r'\bprogram\b')): time_span = li.find('span', class_=re.compile(r'\btime\b')) title_span = li.find('span', class_=re.compile(r'\btitle\b')) genre_a = li.find('a', class_=re.compile(r'\bgenre\b')) time_str = time_span.get_text(strip=True) if time_span else "" title = "" if genre_a: title = genre_a.get_text(strip=True) if not title and title_span: title = title_span.get_text(strip=True) if not time_str or not title: continue # Truyền reference_date để xử lý quy tắc ngày truyền hình VTV start_dt = _parse_time(time_str, reference_date=now_vn) if not start_dt: continue epg_data[our_ch_id].append({"time": time_str[:5], "title": title[:80], "start_dt": start_dt}) for ch_id in epg_data: epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min) seen = set() unique = [] for p in epg_data[ch_id]: key = (p["time"], p["title"]) if key not in seen: seen.add(key) unique.append(p) epg_data[ch_id] = unique except Exception as e: print(f"EPG vtv.vn error: {e}") _epg_cache = epg_data _epg_cache_time = now_ts return epg_data def _parse_time(time_str, reference_date=None): """ Parse giờ từ lịch phát sóng VTV (đã là giờ VN UTC+7) sang datetime có timezone. VTV hiển thị lịch theo ngày dương lịch (không phải ngày truyền hình). Ví dụ: Lịch ngày 17/06 sẽ hiển thị tất cả chương trình từ 00:00 đến 23:59 ngày 17/06. Logic: - Giờ 00:00-04:59: Có thể là đêm khuya của ngày hôm trước HOẶC sáng sớm của ngày mới - Giờ 05:00-23:59: Luôn thuộc ngày hiện tại """ if not time_str: return None time_str = time_str.strip().replace("h", ":").replace("H", ":") m = re.search(r'(\d{1,2}):(\d{2})', time_str) if m: try: hour, minute = int(m.group(1)), int(m.group(2)) if reference_date: base_date = reference_date else: # Lấy ngày hiện tại theo giờ VN (UTC+7) now_vn = datetime.now(VN_TZ) base_date = now_vn from datetime import timedelta # Xác định ngày cho giờ program if hour < 5: # Giờ 00:00-04:59: Cần xem giờ hiện tại để quyết định if base_date.hour < 5: # Nếu hiện tại cũng < 5:00 (đang trong khoảng sáng sớm) # → Giờ program thuộc CÙNG NGÀY với giờ hiện tại tv_date = base_date else: # Nếu hiện tại >= 5:00 (đã qua sáng sớm) # → Giờ 00:00-04:59 thuộc NGÀY HÔM TRƯỚC tv_date = base_date - timedelta(days=1) else: # Giờ 05:00-23:59: Luôn thuộc ngày hiện tại tv_date = base_date # Tạo datetime với giờ từ lịch result = tv_date.replace(hour=hour, minute=minute, second=0, microsecond=0) # Đảm bảo result có timezone if result.tzinfo is None: result = result.replace(tzinfo=VN_TZ) return result except: pass return None def _get_epg_for_channel(channel_id): epg_data = _fetch_epg_from_vtv() programmes = epg_data.get(channel_id, []) if not programmes: return [] now = datetime.now(VN_TZ) today = now.date() result = [] # Lọc chỉ lấy programs của ngày hôm nay (theo lịch VTV) today_programmes = [] for p in programmes: start_dt = p.get("start_dt") if start_dt and start_dt.date() == today: today_programmes.append(p) # Nếu không có programs cho hôm nay, dùng tất cả (fallback) if not today_programmes: today_programmes = programmes for i, p in enumerate(today_programmes): start_dt = p.get("start_dt") stop_dt = None if i + 1 < len(today_programmes): stop_dt = today_programmes[i + 1].get("start_dt") is_now = False if start_dt: if stop_dt: is_now = start_dt <= now < stop_dt else: is_now = start_dt <= now end_time = "" if stop_dt: end_time = stop_dt.strftime("%H:%M") result.append({"time": p["time"], "title": p["title"], "end_time": end_time, "now": is_now}) return result @router.get("/api/vtv/epg/{channel_id}") def api_vtv_epg(channel_id: str): channel_id = channel_id.lower().strip() if channel_id not in CHANNEL_NAMES: return JSONResponse({"error": "channel not found"}, status_code=404) programs = _get_epg_for_channel(channel_id) return JSONResponse({ "channel": channel_id, "channel_name": CHANNEL_NAMES.get(channel_id, channel_id), "date": datetime.now(VN_TZ).strftime("%Y-%m-%d"), "programs": programs, }) @router.get("/api/vtv/epg") def api_vtv_epg_refresh(): global _epg_cache, _epg_cache_time _epg_cache = {} _epg_cache_time = 0 epg_data = _fetch_epg_from_vtv() return JSONResponse({ "status": "refreshed", "channels": len(epg_data), "total_programmes": sum(len(v) for v in epg_data), })