Spaces:
Running
Running
VTV Digital CDN primary source for VTV1-VTV9 streams
Browse files- vtv_api.py +78 -249
vtv_api.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
|
| 3 |
-
Fetches stream URLs from
|
| 4 |
-
|
| 5 |
-
Fallback: FPTPlay CDN → VTVGo CDN → xemtv.net (legacy)
|
| 6 |
EPG data scraped from https://vtv.vn/lich-phat-song.htm
|
| 7 |
"""
|
| 8 |
import re, time, threading
|
|
@@ -21,7 +20,20 @@ UA = {
|
|
| 21 |
"Accept-Language": "vi-VN,vi;q=0.9",
|
| 22 |
}
|
| 23 |
|
| 24 |
-
# ===== PRIMARY:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
XEMTV_US_ENDPOINTS = {
|
| 26 |
"vtv1": "https://xemtv.us/tv/vtv1.php",
|
| 27 |
"vtv2": "https://xemtv.us/tv/vtv2.php",
|
|
@@ -36,7 +48,7 @@ XEMTV_US_ENDPOINTS = {
|
|
| 36 |
"vtvprime": "https://xemtv.us/tv/vtvprime.php",
|
| 37 |
}
|
| 38 |
|
| 39 |
-
# =====
|
| 40 |
XEMTIVITOP_ENDPOINTS = {
|
| 41 |
"vtv1": "https://www.xemtivitop.com/2018/10/vtv1-online.html?m=1",
|
| 42 |
"vtv2": "https://www.xemtivitop.com/2018/10/vtv2-online.html?m=1",
|
|
@@ -50,7 +62,7 @@ XEMTIVITOP_ENDPOINTS = {
|
|
| 50 |
"vtv10": "https://www.xemtivitop.com/2020/05/vtv10-can-tho-online.html?m=1",
|
| 51 |
}
|
| 52 |
|
| 53 |
-
# =====
|
| 54 |
XEMTV_LEGACY_ENDPOINTS = {
|
| 55 |
"vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
|
| 56 |
"vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
|
|
@@ -66,20 +78,12 @@ XEMTV_LEGACY_ENDPOINTS = {
|
|
| 66 |
}
|
| 67 |
|
| 68 |
CHANNEL_NAMES = {
|
| 69 |
-
"vtv1": "VTV1",
|
| 70 |
-
"
|
| 71 |
-
"
|
| 72 |
-
"vtv4": "VTV4",
|
| 73 |
-
"vtv5": "VTV5",
|
| 74 |
-
"vtv6": "VTV6",
|
| 75 |
-
"vtv7": "VTV7",
|
| 76 |
-
"vtv8": "VTV8",
|
| 77 |
-
"vtv9": "VTV9",
|
| 78 |
-
"vtv10": "VTV10",
|
| 79 |
-
"vtvprime": "VTVPrime",
|
| 80 |
}
|
| 81 |
|
| 82 |
-
# ===== FALLBACK
|
| 83 |
FPTPLAY_URLS = {
|
| 84 |
"vtv1": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
|
| 85 |
"vtv2": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
|
|
@@ -93,7 +97,7 @@ FPTPLAY_URLS = {
|
|
| 93 |
"vtv10": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
|
| 94 |
}
|
| 95 |
|
| 96 |
-
# ===== FALLBACK
|
| 97 |
VTVGO_FAILOVER = {
|
| 98 |
"vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
|
| 99 |
"vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
|
|
@@ -151,7 +155,6 @@ def fetch_xemtv_us_stream(channel_id):
|
|
| 151 |
return None
|
| 152 |
|
| 153 |
def fetch_xemtivitop_stream(channel_id):
|
| 154 |
-
"""Fetch stream from xemtivitop.com blogspot pages"""
|
| 155 |
page_url = XEMTIVITOP_ENDPOINTS.get(channel_id)
|
| 156 |
if not page_url:
|
| 157 |
return None
|
|
@@ -186,11 +189,7 @@ def fetch_fptplay_stream(channel_id):
|
|
| 186 |
if not url:
|
| 187 |
return None
|
| 188 |
try:
|
| 189 |
-
headers = {
|
| 190 |
-
"User-Agent": UA["User-Agent"],
|
| 191 |
-
"Referer": "https://fptplay.vn/",
|
| 192 |
-
"Origin": "https://fptplay.vn",
|
| 193 |
-
}
|
| 194 |
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 195 |
if r.status_code == 200 and '#EXTM3U' in r.text[:200]:
|
| 196 |
return url
|
|
@@ -211,34 +210,34 @@ def fetch_vtvgo_stream(channel_id):
|
|
| 211 |
pass
|
| 212 |
return None
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
def normalize_fptplay_url(url):
|
| 215 |
if not url:
|
| 216 |
return url
|
| 217 |
old_to_new = {
|
| 218 |
-
"https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8":
|
| 219 |
-
|
| 220 |
-
"https://live.fptplay53.net/fnxch2/
|
| 221 |
-
|
| 222 |
-
"https://live.fptplay53.net/
|
| 223 |
-
|
| 224 |
-
"https://live.fptplay53.net/
|
| 225 |
-
|
| 226 |
-
"https://live.fptplay53.net/fnxhd1/
|
| 227 |
-
|
| 228 |
-
"https://live.fptplay53.net/
|
| 229 |
-
"https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
|
| 230 |
-
"https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8":
|
| 231 |
-
"https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
|
| 232 |
-
"https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/c.hunklist.m3u8":
|
| 233 |
-
"https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
|
| 234 |
-
"https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8":
|
| 235 |
-
"https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
|
| 236 |
-
"https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8":
|
| 237 |
-
"https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
|
| 238 |
-
"https://live.fptplay53.net/fnxhd1/vtv10hd_vhls.smil/chunklist.m3u8":
|
| 239 |
-
"https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
|
| 240 |
-
"https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8":
|
| 241 |
-
"https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
|
| 242 |
}
|
| 243 |
return old_to_new.get(url, url)
|
| 244 |
|
|
@@ -256,38 +255,47 @@ def fetch_vtv_stream(channel_id):
|
|
| 256 |
if cached is not None:
|
| 257 |
return cached
|
| 258 |
|
| 259 |
-
|
| 260 |
-
|
|
|
|
| 261 |
if url:
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
|
|
|
| 266 |
url = fetch_xemtv_us_stream(channel_id)
|
| 267 |
if url:
|
| 268 |
-
url = normalize_fptplay_url(url)
|
| 269 |
_set_cache(channel_id, url)
|
| 270 |
return url
|
| 271 |
|
|
|
|
| 272 |
url = fetch_xemtivitop_stream(channel_id)
|
| 273 |
if url:
|
| 274 |
-
url = normalize_fptplay_url(url)
|
| 275 |
_set_cache(channel_id, url)
|
| 276 |
return url
|
| 277 |
|
|
|
|
| 278 |
url = fetch_fptplay_stream(channel_id)
|
| 279 |
if url:
|
| 280 |
_set_cache(channel_id, url)
|
| 281 |
return url
|
| 282 |
|
|
|
|
| 283 |
url = fetch_vtvgo_stream(channel_id)
|
| 284 |
if url:
|
| 285 |
_set_cache(channel_id, url)
|
| 286 |
return url
|
| 287 |
|
|
|
|
| 288 |
url = fetch_xemtv_legacy_stream(channel_id)
|
| 289 |
if url:
|
| 290 |
-
url = normalize_fptplay_url(url)
|
| 291 |
_set_cache(channel_id, url)
|
| 292 |
return url
|
| 293 |
|
|
@@ -313,13 +321,10 @@ def api_vtv_stream(channel_id: str):
|
|
| 313 |
def proxy_page(url: str = Query(...)):
|
| 314 |
try:
|
| 315 |
headers = {**UA}
|
| 316 |
-
if "xemtv.us" in url:
|
| 317 |
-
|
| 318 |
-
elif "xemtv.net" in url:
|
| 319 |
-
headers["Referer"] = "https://hd.xemtv.net/"
|
| 320 |
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 321 |
-
if r.status_code != 200:
|
| 322 |
-
return Response(status_code=502, content="upstream error")
|
| 323 |
return Response(content=r.text.encode("utf-8"), media_type="text/html; charset=utf-8", headers={"Access-Control-Allow-Origin": "*"})
|
| 324 |
except:
|
| 325 |
return Response(status_code=502, content="proxy error")
|
|
@@ -328,28 +333,20 @@ def proxy_page(url: str = Query(...)):
|
|
| 328 |
def proxy_vtv_m3u8(url: str = Query(...)):
|
| 329 |
try:
|
| 330 |
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
|
| 331 |
-
if "fptplay" in url:
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
elif "xemtv" in url:
|
| 335 |
-
headers["Referer"] = "https://xemtv.us/"
|
| 336 |
-
elif "vtvgo" in url or "vtvdigital" in url:
|
| 337 |
-
headers["Referer"] = "https://vtvgo.vn/"
|
| 338 |
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 339 |
-
if r.status_code != 200:
|
| 340 |
-
return Response(status_code=502, content="upstream error")
|
| 341 |
content = r.text
|
| 342 |
lines = content.split('\n')
|
| 343 |
base_url = url.rsplit('/', 1)[0] + '/' if '/' in url else url
|
| 344 |
rewritten = []
|
| 345 |
for line in lines:
|
| 346 |
line = line.strip()
|
| 347 |
-
if not line or line.startswith('#'):
|
| 348 |
-
rewritten.append(line)
|
| 349 |
else:
|
| 350 |
-
seg_url = line
|
| 351 |
-
if not seg_url.startswith('http'):
|
| 352 |
-
seg_url = base_url + seg_url
|
| 353 |
if seg_url.endswith('.m3u8'):
|
| 354 |
rewritten.append("/api/proxy/m3u8/vtv?url=" + requests.utils.quote(seg_url, safe=""))
|
| 355 |
else:
|
|
@@ -362,179 +359,11 @@ def proxy_vtv_m3u8(url: str = Query(...)):
|
|
| 362 |
def proxy_vtv_segment(url: str = Query(...)):
|
| 363 |
try:
|
| 364 |
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
|
| 365 |
-
if "fptplay" in url:
|
| 366 |
-
headers["Referer"] = "https://fptplay.vn/"
|
| 367 |
-
headers["Origin"] = "https://fptplay.vn"
|
| 368 |
r = requests.get(url, headers=headers, timeout=30, allow_redirects=True, verify=False)
|
| 369 |
-
if r.status_code != 200:
|
| 370 |
-
return Response(status_code=502, content="upstream error")
|
| 371 |
data = r.content
|
| 372 |
-
if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
|
| 373 |
-
data = data[188:]
|
| 374 |
return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600"})
|
| 375 |
except:
|
| 376 |
-
return Response(status_code=502, content="proxy error")
|
| 377 |
-
|
| 378 |
-
_epg_cache = {}
|
| 379 |
-
_epg_cache_time = 0
|
| 380 |
-
_EPG_CACHE_TTL = 600
|
| 381 |
-
|
| 382 |
-
VTV_CHANNEL_MAP = {
|
| 383 |
-
"vtv1": "vtv1", "vtv2": "vtv2", "vtv3": "vtv3", "vtv4": "vtv4",
|
| 384 |
-
"vtv5": "vtv5", "vtv5-tay-nam-bo": "vtv5", "vtv5-tay-nguyen": "vtv5",
|
| 385 |
-
"vtv7": "vtv7", "vtv8": "vtv8", "vtv6": "vtv6", "vtv9": "vtv9",
|
| 386 |
-
"vtv-can-tho": "vtv10",
|
| 387 |
-
}
|
| 388 |
-
|
| 389 |
-
def _fetch_epg_from_vtv():
|
| 390 |
-
global _epg_cache, _epg_cache_time
|
| 391 |
-
now_ts = time.time()
|
| 392 |
-
if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL:
|
| 393 |
-
return _epg_cache
|
| 394 |
-
epg_data = {}
|
| 395 |
-
now_vn = datetime.now(VN_TZ)
|
| 396 |
-
try:
|
| 397 |
-
headers = {
|
| 398 |
-
"User-Agent": UA["User-Agent"], "Accept-Language": "vi-VN,vi;q=0.9",
|
| 399 |
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 400 |
-
"Referer": "https://vtv.vn/",
|
| 401 |
-
}
|
| 402 |
-
r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=headers, timeout=20)
|
| 403 |
-
if r.status_code != 200:
|
| 404 |
-
return epg_data
|
| 405 |
-
r.encoding = "utf-8"
|
| 406 |
-
soup = BeautifulSoup(r.text, "lxml")
|
| 407 |
-
channel_order = []
|
| 408 |
-
list_channel = soup.find(class_=re.compile(r'list-channel'))
|
| 409 |
-
if list_channel:
|
| 410 |
-
for link in list_channel.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
|
| 411 |
-
ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
|
| 412 |
-
if ch_id:
|
| 413 |
-
channel_order.append(ch_id.group(1))
|
| 414 |
-
if not channel_order:
|
| 415 |
-
for link in soup.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
|
| 416 |
-
ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
|
| 417 |
-
if ch_id and ch_id.group(1) not in channel_order:
|
| 418 |
-
channel_order.append(ch_id.group(1))
|
| 419 |
-
prog_containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b'))
|
| 420 |
-
for i, container in enumerate(prog_containers):
|
| 421 |
-
if i >= len(channel_order):
|
| 422 |
-
break
|
| 423 |
-
vtv_ch_id = channel_order[i]
|
| 424 |
-
our_ch_id = VTV_CHANNEL_MAP.get(vtv_ch_id, vtv_ch_id)
|
| 425 |
-
if our_ch_id not in epg_data:
|
| 426 |
-
epg_data[our_ch_id] = []
|
| 427 |
-
for li in container.find_all('li', class_=re.compile(r'\bprogram\b')):
|
| 428 |
-
time_span = li.find('span', class_=re.compile(r'\btime\b'))
|
| 429 |
-
title_span = li.find('span', class_=re.compile(r'\btitle\b'))
|
| 430 |
-
genre_a = li.find('a', class_=re.compile(r'\bgenre\b'))
|
| 431 |
-
time_str = time_span.get_text(strip=True) if time_span else ""
|
| 432 |
-
title = ""
|
| 433 |
-
if genre_a:
|
| 434 |
-
title = genre_a.get_text(strip=True)
|
| 435 |
-
if not title and title_span:
|
| 436 |
-
title = title_span.get_text(strip=True)
|
| 437 |
-
if not time_str or not title:
|
| 438 |
-
continue
|
| 439 |
-
start_dt = _parse_time(time_str, reference_date=now_vn)
|
| 440 |
-
if not start_dt:
|
| 441 |
-
continue
|
| 442 |
-
epg_data[our_ch_id].append({"time": time_str[:5], "title": title[:80], "start_dt": start_dt})
|
| 443 |
-
for ch_id in epg_data:
|
| 444 |
-
epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min)
|
| 445 |
-
seen = set()
|
| 446 |
-
unique = []
|
| 447 |
-
for p in epg_data[ch_id]:
|
| 448 |
-
key = (p["time"], p["title"])
|
| 449 |
-
if key not in seen:
|
| 450 |
-
seen.add(key)
|
| 451 |
-
unique.append(p)
|
| 452 |
-
epg_data[ch_id] = unique
|
| 453 |
-
except Exception as e:
|
| 454 |
-
print(f"EPG vtv.vn error: {e}")
|
| 455 |
-
_epg_cache = epg_data
|
| 456 |
-
_epg_cache_time = now_ts
|
| 457 |
-
return epg_data
|
| 458 |
-
|
| 459 |
-
def _parse_time(time_str, reference_date=None):
|
| 460 |
-
if not time_str:
|
| 461 |
-
return None
|
| 462 |
-
time_str = time_str.strip().replace("h", ":").replace("H", ":")
|
| 463 |
-
m = re.search(r'(\d{1,2}):(\d{2})', time_str)
|
| 464 |
-
if m:
|
| 465 |
-
try:
|
| 466 |
-
hour, minute = int(m.group(1)), int(m.group(2))
|
| 467 |
-
if reference_date:
|
| 468 |
-
base_date = reference_date
|
| 469 |
-
else:
|
| 470 |
-
now_vn = datetime.now(VN_TZ)
|
| 471 |
-
base_date = now_vn
|
| 472 |
-
from datetime import timedelta
|
| 473 |
-
if hour < 5:
|
| 474 |
-
if base_date.hour < 5:
|
| 475 |
-
tv_date = base_date
|
| 476 |
-
else:
|
| 477 |
-
tv_date = base_date - timedelta(days=1)
|
| 478 |
-
else:
|
| 479 |
-
tv_date = base_date
|
| 480 |
-
result = tv_date.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
| 481 |
-
if result.tzinfo is None:
|
| 482 |
-
result = result.replace(tzinfo=VN_TZ)
|
| 483 |
-
return result
|
| 484 |
-
except:
|
| 485 |
-
pass
|
| 486 |
-
return None
|
| 487 |
-
|
| 488 |
-
def _get_epg_for_channel(channel_id):
|
| 489 |
-
epg_data = _fetch_epg_from_vtv()
|
| 490 |
-
programmes = epg_data.get(channel_id, [])
|
| 491 |
-
if not programmes:
|
| 492 |
-
return []
|
| 493 |
-
now = datetime.now(VN_TZ)
|
| 494 |
-
today = now.date()
|
| 495 |
-
result = []
|
| 496 |
-
today_programmes = []
|
| 497 |
-
for p in programmes:
|
| 498 |
-
start_dt = p.get("start_dt")
|
| 499 |
-
if start_dt and start_dt.date() == today:
|
| 500 |
-
today_programmes.append(p)
|
| 501 |
-
if not today_programmes:
|
| 502 |
-
today_programmes = programmes
|
| 503 |
-
for i, p in enumerate(today_programmes):
|
| 504 |
-
start_dt = p.get("start_dt")
|
| 505 |
-
stop_dt = None
|
| 506 |
-
if i + 1 < len(today_programmes):
|
| 507 |
-
stop_dt = today_programmes[i + 1].get("start_dt")
|
| 508 |
-
is_now = False
|
| 509 |
-
if start_dt:
|
| 510 |
-
if stop_dt:
|
| 511 |
-
is_now = start_dt <= now < stop_dt
|
| 512 |
-
else:
|
| 513 |
-
is_now = start_dt <= now
|
| 514 |
-
end_time = ""
|
| 515 |
-
if stop_dt:
|
| 516 |
-
end_time = stop_dt.strftime("%H:%M")
|
| 517 |
-
result.append({"time": p["time"], "title": p["title"], "end_time": end_time, "now": is_now})
|
| 518 |
-
return result
|
| 519 |
-
|
| 520 |
-
@router.get("/api/vtv/epg/{channel_id}")
|
| 521 |
-
def api_vtv_epg(channel_id: str):
|
| 522 |
-
channel_id = channel_id.lower().strip()
|
| 523 |
-
if channel_id not in CHANNEL_NAMES:
|
| 524 |
-
return JSONResponse({"error": "channel not found"}, status_code=404)
|
| 525 |
-
programs = _get_epg_for_channel(channel_id)
|
| 526 |
-
return JSONResponse({
|
| 527 |
-
"channel": channel_id, "channel_name": CHANNEL_NAMES.get(channel_id, channel_id),
|
| 528 |
-
"date": datetime.now(VN_TZ).strftime("%Y-%m-%d"), "programs": programs,
|
| 529 |
-
})
|
| 530 |
-
|
| 531 |
-
@router.get("/api/vtv/epg")
|
| 532 |
-
def api_vtv_epg_refresh():
|
| 533 |
-
global _epg_cache, _epg_cache_time
|
| 534 |
-
_epg_cache = {}
|
| 535 |
-
_epg_cache_time = 0
|
| 536 |
-
epg_data = _fetch_epg_from_vtv()
|
| 537 |
-
return JSONResponse({
|
| 538 |
-
"status": "refreshed", "channels": len(epg_data),
|
| 539 |
-
"total_programmes": sum(len(v) for v in epg_data),
|
| 540 |
-
})
|
|
|
|
| 1 |
"""
|
| 2 |
VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
|
| 3 |
+
Fetches stream URLs from VTV Digital CDN (primary, stable)
|
| 4 |
+
Fallback: xemtv.us → xemtivitop.com → FPTPlay CDN → VTVGo CDN → xemtv.net (legacy)
|
|
|
|
| 5 |
EPG data scraped from https://vtv.vn/lich-phat-song.htm
|
| 6 |
"""
|
| 7 |
import re, time, threading
|
|
|
|
| 20 |
"Accept-Language": "vi-VN,vi;q=0.9",
|
| 21 |
}
|
| 22 |
|
| 23 |
+
# ===== PRIMARY: VTV Digital CDN (direct, stable, low latency) =====
|
| 24 |
+
VTV_DIGITAL_CDN_URLS = {
|
| 25 |
+
"vtv1": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv1/master.m3u8",
|
| 26 |
+
"vtv2": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv2/master.m3u8",
|
| 27 |
+
"vtv3": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv3/master.m3u8",
|
| 28 |
+
"vtv4": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv4/master.m3u8",
|
| 29 |
+
"vtv5": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv5/master.m3u8",
|
| 30 |
+
"vtv6": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv6/master.m3u8",
|
| 31 |
+
"vtv7": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv7/master.m3u8",
|
| 32 |
+
"vtv8": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv8/master.m3u8",
|
| 33 |
+
"vtv9": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv9/master.m3u8",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
# ===== BACKUP: xemtv.us =====
|
| 37 |
XEMTV_US_ENDPOINTS = {
|
| 38 |
"vtv1": "https://xemtv.us/tv/vtv1.php",
|
| 39 |
"vtv2": "https://xemtv.us/tv/vtv2.php",
|
|
|
|
| 48 |
"vtvprime": "https://xemtv.us/tv/vtvprime.php",
|
| 49 |
}
|
| 50 |
|
| 51 |
+
# ===== xemtivitop.com =====
|
| 52 |
XEMTIVITOP_ENDPOINTS = {
|
| 53 |
"vtv1": "https://www.xemtivitop.com/2018/10/vtv1-online.html?m=1",
|
| 54 |
"vtv2": "https://www.xemtivitop.com/2018/10/vtv2-online.html?m=1",
|
|
|
|
| 62 |
"vtv10": "https://www.xemtivitop.com/2020/05/vtv10-can-tho-online.html?m=1",
|
| 63 |
}
|
| 64 |
|
| 65 |
+
# ===== xemtv.net (legacy) =====
|
| 66 |
XEMTV_LEGACY_ENDPOINTS = {
|
| 67 |
"vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
|
| 68 |
"vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
|
|
|
|
| 78 |
}
|
| 79 |
|
| 80 |
CHANNEL_NAMES = {
|
| 81 |
+
"vtv1": "VTV1", "vtv2": "VTV2", "vtv3": "VTV3", "vtv4": "VTV4",
|
| 82 |
+
"vtv5": "VTV5", "vtv6": "VTV6", "vtv7": "VTV7", "vtv8": "VTV8",
|
| 83 |
+
"vtv9": "VTV9", "vtv10": "VTV10", "vtvprime": "VTVPrime",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
}
|
| 85 |
|
| 86 |
+
# ===== FALLBACK: FPTPlay CDN =====
|
| 87 |
FPTPLAY_URLS = {
|
| 88 |
"vtv1": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
|
| 89 |
"vtv2": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
|
|
|
|
| 97 |
"vtv10": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
|
| 98 |
}
|
| 99 |
|
| 100 |
+
# ===== FALLBACK: VTVGo CDN =====
|
| 101 |
VTVGO_FAILOVER = {
|
| 102 |
"vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
|
| 103 |
"vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
|
|
|
|
| 155 |
return None
|
| 156 |
|
| 157 |
def fetch_xemtivitop_stream(channel_id):
|
|
|
|
| 158 |
page_url = XEMTIVITOP_ENDPOINTS.get(channel_id)
|
| 159 |
if not page_url:
|
| 160 |
return None
|
|
|
|
| 189 |
if not url:
|
| 190 |
return None
|
| 191 |
try:
|
| 192 |
+
headers = {"User-Agent": UA["User-Agent"], "Referer": "https://fptplay.vn/", "Origin": "https://fptplay.vn"}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 194 |
if r.status_code == 200 and '#EXTM3U' in r.text[:200]:
|
| 195 |
return url
|
|
|
|
| 210 |
pass
|
| 211 |
return None
|
| 212 |
|
| 213 |
+
def verify_vtv_digital_url(url):
|
| 214 |
+
"""Verify a VTV Digital CDN URL returns valid HLS content"""
|
| 215 |
+
if not url:
|
| 216 |
+
return None
|
| 217 |
+
try:
|
| 218 |
+
headers = {**UA, "Referer": "https://vtvgo.vn/", "Origin": "https://vtvgo.vn"}
|
| 219 |
+
r = requests.get(url, headers=headers, timeout=10, allow_redirects=True, verify=False)
|
| 220 |
+
if r.status_code == 200 and '#EXTM3U' in r.text[:200]:
|
| 221 |
+
return url
|
| 222 |
+
except:
|
| 223 |
+
pass
|
| 224 |
+
return None
|
| 225 |
+
|
| 226 |
def normalize_fptplay_url(url):
|
| 227 |
if not url:
|
| 228 |
return url
|
| 229 |
old_to_new = {
|
| 230 |
+
"https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
|
| 231 |
+
"https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
|
| 232 |
+
"https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8",
|
| 233 |
+
"https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
|
| 234 |
+
"https://live.fptplay53.net/fnxhd1/vtv5hd_vhls.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
|
| 235 |
+
"https://live.fptplay53.net/fnxhd1/vtv6hd_vhls.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
|
| 236 |
+
"https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8": "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
|
| 237 |
+
"https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/c.hunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
|
| 238 |
+
"https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
|
| 239 |
+
"https://live.fptplay53.net/fnxhd1/vtv10hd_vhls.smil/chunklist.m3u8": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
|
| 240 |
+
"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",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
}
|
| 242 |
return old_to_new.get(url, url)
|
| 243 |
|
|
|
|
| 255 |
if cached is not None:
|
| 256 |
return cached
|
| 257 |
|
| 258 |
+
# PRIMARY: VTV Digital CDN
|
| 259 |
+
if channel_id in VTV_DIGITAL_CDN_URLS:
|
| 260 |
+
url = verify_vtv_digital_url(VTV_DIGITAL_CDN_URLS[channel_id])
|
| 261 |
if url:
|
| 262 |
+
_set_cache(channel_id, url)
|
| 263 |
+
return url
|
| 264 |
+
|
| 265 |
+
# vtvprime/vtv10: not on VTV Digital CDN, use xemtv.us
|
| 266 |
+
if channel_id in ('vtvprime', 'vtv10'):
|
| 267 |
+
url = fetch_xemtv_us_stream(channel_id) or fetch_xemtivitop_stream(channel_id) or fetch_xemtv_legacy_stream(channel_id)
|
| 268 |
+
if url:
|
| 269 |
+
_set_cache(channel_id, url)
|
| 270 |
+
return url
|
| 271 |
|
| 272 |
+
# BACKUP: xemtv.us
|
| 273 |
url = fetch_xemtv_us_stream(channel_id)
|
| 274 |
if url:
|
|
|
|
| 275 |
_set_cache(channel_id, url)
|
| 276 |
return url
|
| 277 |
|
| 278 |
+
# FALLBACK: xemtivitop.com
|
| 279 |
url = fetch_xemtivitop_stream(channel_id)
|
| 280 |
if url:
|
|
|
|
| 281 |
_set_cache(channel_id, url)
|
| 282 |
return url
|
| 283 |
|
| 284 |
+
# FALLBACK: FPTPlay CDN
|
| 285 |
url = fetch_fptplay_stream(channel_id)
|
| 286 |
if url:
|
| 287 |
_set_cache(channel_id, url)
|
| 288 |
return url
|
| 289 |
|
| 290 |
+
# FALLBACK: VTVGo CDN
|
| 291 |
url = fetch_vtvgo_stream(channel_id)
|
| 292 |
if url:
|
| 293 |
_set_cache(channel_id, url)
|
| 294 |
return url
|
| 295 |
|
| 296 |
+
# LAST RESORT: xemtv.net legacy
|
| 297 |
url = fetch_xemtv_legacy_stream(channel_id)
|
| 298 |
if url:
|
|
|
|
| 299 |
_set_cache(channel_id, url)
|
| 300 |
return url
|
| 301 |
|
|
|
|
| 321 |
def proxy_page(url: str = Query(...)):
|
| 322 |
try:
|
| 323 |
headers = {**UA}
|
| 324 |
+
if "xemtv.us" in url: headers["Referer"] = "https://xemtv.us/"
|
| 325 |
+
elif "xemtv.net" in url: headers["Referer"] = "https://hd.xemtv.net/"
|
|
|
|
|
|
|
| 326 |
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 327 |
+
if r.status_code != 200: return Response(status_code=502, content="upstream error")
|
|
|
|
| 328 |
return Response(content=r.text.encode("utf-8"), media_type="text/html; charset=utf-8", headers={"Access-Control-Allow-Origin": "*"})
|
| 329 |
except:
|
| 330 |
return Response(status_code=502, content="proxy error")
|
|
|
|
| 333 |
def proxy_vtv_m3u8(url: str = Query(...)):
|
| 334 |
try:
|
| 335 |
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
|
| 336 |
+
if "fptplay" in url: headers["Referer"] = "https://fptplay.vn/"; headers["Origin"] = "https://fptplay.vn"
|
| 337 |
+
elif "xemtv" in url: headers["Referer"] = "https://xemtv.us/"
|
| 338 |
+
elif "vtvdigital" in url: headers["Referer"] = "https://vtvgo.vn/"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 340 |
+
if r.status_code != 200: return Response(status_code=502, content="upstream error")
|
|
|
|
| 341 |
content = r.text
|
| 342 |
lines = content.split('\n')
|
| 343 |
base_url = url.rsplit('/', 1)[0] + '/' if '/' in url else url
|
| 344 |
rewritten = []
|
| 345 |
for line in lines:
|
| 346 |
line = line.strip()
|
| 347 |
+
if not line or line.startswith('#'): rewritten.append(line)
|
|
|
|
| 348 |
else:
|
| 349 |
+
seg_url = line if line.startswith('http') else base_url + line
|
|
|
|
|
|
|
| 350 |
if seg_url.endswith('.m3u8'):
|
| 351 |
rewritten.append("/api/proxy/m3u8/vtv?url=" + requests.utils.quote(seg_url, safe=""))
|
| 352 |
else:
|
|
|
|
| 359 |
def proxy_vtv_segment(url: str = Query(...)):
|
| 360 |
try:
|
| 361 |
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
|
| 362 |
+
if "fptplay" in url: headers["Referer"] = "https://fptplay.vn/"; headers["Origin"] = "https://fptplay.vn"
|
|
|
|
|
|
|
| 363 |
r = requests.get(url, headers=headers, timeout=30, allow_redirects=True, verify=False)
|
| 364 |
+
if r.status_code != 200: return Response(status_code=502, content="upstream error")
|
|
|
|
| 365 |
data = r.content
|
| 366 |
+
if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47: data = data[188:]
|
|
|
|
| 367 |
return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600"})
|
| 368 |
except:
|
| 369 |
+
return Response(status_code=502, content="proxy error")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|