VNEWS / vtv_api.py
bep40's picture
Upload vtv_api.py with huggingface_hub
43c4bab verified
Raw
History Blame
16.8 kB
"""
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 <ul class="programs"> containers
prog_containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b'))
print(f"EPG: {len(channel_order)} channels, {len(prog_containers)} program containers")
# Map each container to channel by order
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] = []
# Parse each <li class="program"> in this container
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: prefer genre (specific program name), fallback to title (category)
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
# Parse time to datetime
start_dt = _parse_time(time_str)
if not start_dt:
continue
epg_data[our_ch_id].append({
"time": time_str[:5],
"title": title[:80],
"start_dt": start_dt,
})
# Sort by time and calculate end times
for ch_id in epg_data:
epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min)
# Remove duplicates
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
print(f"EPG parsed: {len(epg_data)} channels, {sum(len(v) for v in epg_data)} programmes")
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):
"""Parse HH:MM to datetime today"""
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))
now = datetime.now(VN_TZ)
return now.replace(hour=hour, minute=minute, second=0, microsecond=0)
except:
pass
return None
def _get_epg_for_channel(channel_id):
"""Get EPG for a channel with current program marking."""
epg_data = _fetch_epg_from_vtv()
programmes = epg_data.get(channel_id, [])
if not programmes:
return []
now = datetime.now(VN_TZ)
result = []
for i, p in enumerate(programmes):
start_dt = p.get("start_dt")
stop_dt = None
if i + 1 < len(programmes):
stop_dt = 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):
"""Get EPG for a VTV channel from vtv.vn."""
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():
"""Force refresh EPG cache."""
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),
})