VNEWS / vtv_api.py
bep40's picture
Restore to b136659 - WC 2026 + fix homepage
753cc8d verified
Raw
History Blame
22.6 kB
"""
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),
})