Spaces:
Running
Running
| # 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 ===================== | |
| 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) | |
| 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" | |
| }) | |
| 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 | |
| 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}) | |
| 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())}) |