# VTV Stream — SV2/sv.xemtivitop iframe URLs + M3U fallback + EPG import re, time, threading, json, requests from fastapi import APIRouter, Query from fastapi.responses import JSONResponse, Response, StreamingResponse from bs4 import BeautifulSoup from datetime import datetime, timedelta, timezone from urllib.parse import quote 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", } HARDCODED_URLS = { "vtv6": "https://freem3u.xyz/api/live/play.m3u8?vid=10043", "vtv10": "https://live.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8", } CHANNEL_URLS = {k: (HARDCODED_URLS[k] if k in HARDCODED_URLS else None) for k in ["vtv1","vtv2","vtv3","vtv4","vtv5","vtv6","vtv7","vtv8","vtv9","vtv10"]} CHANNEL_NAMES = {"vtv1":"VTV1","vtv2":"VTV2","vtv3":"VTV3","vtv4":"VTV4","vtv5":"VTV5","vtv6":"VTV6","vtv7":"VTV7","vtv8":"VTV8","vtv9":"VTV9","vtv10":"VTV10","vtvprime":"VTVPrime"} # Iframe URLs cho từng kênh — lấy từ xemtivitop.com # Với VTV7: LINK 1 = blogspot (lỗi), LINK 2 = sv.xemtivitop.com (JWPlayer, hoạt động) IFRAME_URLS = { "vtv1": "https://sv2.xemtivitop.com/live/hot/vtv1.php", "vtv2": "https://sv2.xemtivitop.com/live/hot/vtv2.php", "vtv3": "https://sv2.xemtivitop.com/live/hot/vtv3.php", "vtv4": "https://sv2.xemtivitop.com/live/hot/vtv4.php", "vtv5": "https://sv2.xemtivitop.com/live/hot/vtv5.php", "vtv6": "https://sv2.xemtivitop.com/live/hot/vtv6.php", # VTV7: LINK 2 trên xemtivitop.com — sv.xemtivitop.com (ko phải sv2), JWPlayer, cho phép iframe "vtv7": "https://sv.xemtivitop.com/live/vtv/vtv7.php", "vtv8": "https://sv2.xemtivitop.com/live/hot/vtv8.php", "vtv9": "https://sv2.xemtivitop.com/live/hot/vtv9.php", "vtv10": "https://sv2.xemtivitop.com/live/hot/vtv10.php", } M3U_URLS = [ "https://raw.githubusercontent.com/Love4vn/Test/refs/heads/main/IPTV.m3u", "https://raw.githubusercontent.com/Love4vn/love4vn/main/Out_Iptv_CXT.m3u", "https://raw.githubusercontent.com/konanda-sg/Test_Love4vn/main/IPTV.m3u", "https://raw.githubusercontent.com/iptv-org/iptv/master/streams/vn.m3u", ] _channel_urls_lock = threading.Lock() _last_m3u_fetch = 0 _M3U_TTL = 900 def _fetch_m3u(): global _last_m3u_fetch now = time.time() if now - _last_m3u_fetch < _M3U_TTL: return _last_m3u_fetch = now all_urls = {} for m3u_url in M3U_URLS: try: r = requests.get(m3u_url, headers=UA, timeout=10) if r.status_code != 200: continue text = r.text lines = text.strip().split("\n") current_channel = None for line in lines: s = line.strip() if s.startswith("#EXTINF"): m = re.search(r'tvg-id="?(\w+)"?', s) if m: current_channel = m.group(1).lower() else: m2 = re.search(r'(VTV\d+|vtv\d+)', s, re.IGNORECASE) if m2: current_channel = m2.group(1).lower() else: current_channel = None elif s.startswith("http") and current_channel: ch_id = current_channel ch_map = {"vtv1":"vtv1","vtv2":"vtv2","vtv3":"vtv3","vtv4":"vtv4","vtv5":"vtv5","vtv6":"vtv6","vtv7":"vtv7","vtv8":"vtv8","vtv9":"vtv9","vtv10":"vtv10","vtvcầnthơ":"vtv10","vtvcantho":"vtv10","vtvcan-tho":"vtv10"} if ch_id in ch_map: ch_id = ch_map[ch_id] if ch_id in CHANNEL_URLS and ch_id not in all_urls: if CHANNEL_URLS[ch_id] and HARDCODED_URLS.get(ch_id): current_channel = None; continue if ".flv" in s: all_urls[ch_id] = s elif ch_id not in all_urls or ".flv" not in all_urls.get(ch_id,""): if ch_id not in all_urls: all_urls[ch_id] = s current_channel = None if len(all_urls) >= 10: break except: pass with _channel_urls_lock: found = 0 for ch_id in CHANNEL_URLS: if CHANNEL_URLS[ch_id] and HARDCODED_URLS.get(ch_id): continue if ch_id in all_urls: CHANNEL_URLS[ch_id] = all_urls[ch_id]; found += 1 print(f"[M3U] Found {found}/10 channels (hardcoded: vtv6, vtv10)") def get_channel_url(channel_id): _fetch_m3u() with _channel_urls_lock: return CHANNEL_URLS.get(channel_id) # ===================== API ENDPOINTS ===================== @router.get("/api/vtv/streams") def api_vtv_streams(): urls = dict(CHANNEL_URLS) result = {} for ch_id in CHANNEL_NAMES: stream_url = urls.get(ch_id) iframe_url = IFRAME_URLS.get(ch_id) is_flv = stream_url and ".flv" in stream_url result[ch_id] = { "name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "", "proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "", "is_flv": is_flv, "iframe_url": iframe_url, "status": "ok" } return JSONResponse(result) @router.get("/api/vtv/stream/{channel_id}") def api_vtv_stream(channel_id: str): channel_id = channel_id.lower().strip() if channel_id not in CHANNEL_NAMES: return JSONResponse({"error":"not found"}, status_code=404) stream_url = get_channel_url(channel_id) iframe_url = IFRAME_URLS.get(channel_id) is_flv = stream_url and ".flv" in stream_url m3u8_proxy = "" if stream_url and not is_flv and stream_url.startswith("http"): m3u8_proxy = f"/api/proxy/m3u8?url={quote(stream_url, safe='')}" return JSONResponse({ "name": CHANNEL_NAMES[channel_id], "stream_url": stream_url, "m3u8_proxy_url": m3u8_proxy, "proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "", "proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "", "is_flv": is_flv, "iframe_url": iframe_url, "status": "ok" }) @router.get("/api/vtv/m3u/refresh") def api_vtv_m3u_refresh(): global _last_m3u_fetch _last_m3u_fetch = 0 _fetch_m3u() urls = dict(CHANNEL_URLS) found = sum(1 for v in urls.values() if v) return JSONResponse({"status":"refreshed","channels_found":found,"channels":{k:v for k,v in urls.items() if v}}) # ===================== EPG ===================== _epg_cache = {}; _epg_cache_time = 0; _EPG_CACHE_TTL = 600 VTV_CHANNEL_MAP = {"vtv1":"vtv1","vtv2":"vtv2","vtv3":"vtv3","vtv4":"vtv4","vtv5":"vtv5","vtv5-tay-nam-bo":"vtv5","vtv5-tay-nguyen":"vtv5","vtv6":"vtv6","vtv7":"vtv7","vtv8":"vtv8","vtv9":"vtv9","vtv-can-tho":"vtv10"} def _parse_time(time_str, reference_date=None): 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)) base = reference_date or datetime.now(VN_TZ) if hour < 5: base = base - timedelta(days=1) if base.hour >= 5 else base dt = base.replace(hour=hour, minute=minute, second=0, microsecond=0) if dt.tzinfo is None: dt = dt.replace(tzinfo=VN_TZ) return dt except: pass return None 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 = {} now_vn = datetime.now(VN_TZ) try: h = {"User-Agent": UA["User-Agent"], "Accept-Language": "vi-VN,vi;q=0.9", "Referer": "https://vtv.vn/"} r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=h, timeout=20) if r.status_code != 200: return epg_data r.encoding = "utf-8" soup = BeautifulSoup(r.text, "lxml") 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)) containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b')) for i, container in enumerate(containers): if i >= len(channel_order): break vtv_id = channel_order[i] our_id = VTV_CHANNEL_MAP.get(vtv_id, vtv_id) if our_id not in epg_data: epg_data[our_id] = [] for li in container.find_all('li', class_=re.compile(r'\bprogram\b')): t = li.find('span', class_=re.compile(r'\btime\b')) title_el = li.find('span', class_=re.compile(r'\btitle\b')) genre = li.find('a', class_=re.compile(r'\bgenre\b')) time_str = t.get_text(strip=True) if t else "" title = genre.get_text(strip=True) if genre else "" if not title and title_el: title = title_el.get_text(strip=True) if not time_str or not title: continue start_dt = _parse_time(time_str, reference_date=now_vn) if not start_dt: continue epg_data[our_id].append({"time":time_str[:5],"title":title[:80],"start_dt":start_dt,"date":start_dt.strftime("%d/%m/%Y")}) for ch_id in epg_data: epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min) except Exception as e: print(f"EPG error: {e}") _epg_cache = epg_data; _epg_cache_time = now_ts return epg_data @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) epg_data = _fetch_epg_from_vtv() programmes = epg_data.get(channel_id, []) now = datetime.now(VN_TZ) today = now.date() result = [] today_progs = [p for p in programmes if p.get("start_dt") and p["start_dt"].date() == today] or programmes for i, p in enumerate(today_progs): start_dt = p.get("start_dt") stop_dt = today_progs[i+1].get("start_dt") if i+1 < len(today_progs) else None is_now = bool(start_dt and ((stop_dt and start_dt <= now < stop_dt) or start_dt <= now)) end_time = stop_dt.strftime("%H:%M") if stop_dt else "" result.append({"time":p["time"],"title":p["title"],"end_time":end_time,"now":is_now,"date":p.get("date","")}) return JSONResponse({"channel":channel_id,"channel_name":CHANNEL_NAMES.get(channel_id,channel_id),"date":now.strftime("%Y-%m-%d"),"programs":result}) @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":sum(len(v) for v in epg_data.values())})