Spaces:
Running
Running
Upload vtv_api.py
Browse files- vtv_api.py +41 -47
vtv_api.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
|
| 3 |
Fetches stream URLs from xemtv.us PHP endpoints (primary)
|
|
|
|
| 4 |
Fallback: FPTPlay CDN → VTVGo CDN → xemtv.net (legacy)
|
| 5 |
EPG data scraped from https://vtv.vn/lich-phat-song.htm
|
| 6 |
"""
|
|
@@ -35,6 +36,20 @@ XEMTV_US_ENDPOINTS = {
|
|
| 35 |
"vtvprime": "https://xemtv.us/tv/vtvprime.php",
|
| 36 |
}
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
# ===== LEGACY: xemtv.net (may return 403, keep as last resort) =====
|
| 39 |
XEMTV_LEGACY_ENDPOINTS = {
|
| 40 |
"vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
|
|
@@ -135,6 +150,22 @@ def fetch_xemtv_us_stream(channel_id):
|
|
| 135 |
pass
|
| 136 |
return None
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
def fetch_xemtv_legacy_stream(channel_id):
|
| 139 |
php_url = XEMTV_LEGACY_ENDPOINTS.get(channel_id)
|
| 140 |
if not php_url:
|
|
@@ -181,7 +212,6 @@ def fetch_vtvgo_stream(channel_id):
|
|
| 181 |
return None
|
| 182 |
|
| 183 |
def normalize_fptplay_url(url):
|
| 184 |
-
"""Replace old/broken FPTPlay URLs with new working ones"""
|
| 185 |
if not url:
|
| 186 |
return url
|
| 187 |
old_to_new = {
|
|
@@ -213,12 +243,6 @@ def normalize_fptplay_url(url):
|
|
| 213 |
return old_to_new.get(url, url)
|
| 214 |
|
| 215 |
def fetch_vtv_stream(channel_id):
|
| 216 |
-
"""Fetch VTV stream with multi-source fallback chain:
|
| 217 |
-
1. xemtv.us (primary - new domain, most reliable)
|
| 218 |
-
2. FPTPlay CDN (fallback - new URLs)
|
| 219 |
-
3. VTVGo CDN (fallback)
|
| 220 |
-
4. xemtv.net legacy (last resort)
|
| 221 |
-
"""
|
| 222 |
channel_id = channel_id.lower().strip()
|
| 223 |
name_map = {
|
| 224 |
'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
|
|
@@ -233,32 +257,34 @@ def fetch_vtv_stream(channel_id):
|
|
| 233 |
return cached
|
| 234 |
|
| 235 |
if channel_id == 'vtvprime':
|
| 236 |
-
url = fetch_xemtv_us_stream('vtvprime') or fetch_xemtv_legacy_stream('vtvprime')
|
| 237 |
if url:
|
| 238 |
url = normalize_fptplay_url(url)
|
| 239 |
_set_cache(channel_id, url)
|
| 240 |
return url
|
| 241 |
|
| 242 |
-
# Source 1: xemtv.us (primary)
|
| 243 |
url = fetch_xemtv_us_stream(channel_id)
|
| 244 |
if url:
|
| 245 |
url = normalize_fptplay_url(url)
|
| 246 |
_set_cache(channel_id, url)
|
| 247 |
return url
|
| 248 |
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
url = fetch_fptplay_stream(channel_id)
|
| 251 |
if url:
|
| 252 |
_set_cache(channel_id, url)
|
| 253 |
return url
|
| 254 |
|
| 255 |
-
# Source 3: VTVGo CDN
|
| 256 |
url = fetch_vtvgo_stream(channel_id)
|
| 257 |
if url:
|
| 258 |
_set_cache(channel_id, url)
|
| 259 |
return url
|
| 260 |
|
| 261 |
-
# Source 4: xemtv.net legacy (last resort)
|
| 262 |
url = fetch_xemtv_legacy_stream(channel_id)
|
| 263 |
if url:
|
| 264 |
url = normalize_fptplay_url(url)
|
|
@@ -314,8 +340,8 @@ def proxy_vtv_m3u8(url: str = Query(...)):
|
|
| 314 |
return Response(status_code=502, content="upstream error")
|
| 315 |
content = r.text
|
| 316 |
lines = content.split('\n')
|
|
|
|
| 317 |
rewritten = []
|
| 318 |
-
base_url = url.rsplit('/', 1)[0] + '/'
|
| 319 |
for line in lines:
|
| 320 |
line = line.strip()
|
| 321 |
if not line or line.startswith('#'):
|
|
@@ -351,7 +377,7 @@ def proxy_vtv_segment(url: str = Query(...)):
|
|
| 351 |
|
| 352 |
_epg_cache = {}
|
| 353 |
_epg_cache_time = 0
|
| 354 |
-
_EPG_CACHE_TTL = 600
|
| 355 |
|
| 356 |
VTV_CHANNEL_MAP = {
|
| 357 |
"vtv1": "vtv1", "vtv2": "vtv2", "vtv3": "vtv3", "vtv4": "vtv4",
|
|
@@ -366,7 +392,6 @@ def _fetch_epg_from_vtv():
|
|
| 366 |
if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL:
|
| 367 |
return _epg_cache
|
| 368 |
epg_data = {}
|
| 369 |
-
# Lấy thời gian VN hiện tại để truyền vào parse_time
|
| 370 |
now_vn = datetime.now(VN_TZ)
|
| 371 |
try:
|
| 372 |
headers = {
|
|
@@ -411,7 +436,6 @@ def _fetch_epg_from_vtv():
|
|
| 411 |
title = title_span.get_text(strip=True)
|
| 412 |
if not time_str or not title:
|
| 413 |
continue
|
| 414 |
-
# Truyền reference_date để xử lý quy tắc ngày truyền hình VTV
|
| 415 |
start_dt = _parse_time(time_str, reference_date=now_vn)
|
| 416 |
if not start_dt:
|
| 417 |
continue
|
|
@@ -433,16 +457,6 @@ def _fetch_epg_from_vtv():
|
|
| 433 |
return epg_data
|
| 434 |
|
| 435 |
def _parse_time(time_str, reference_date=None):
|
| 436 |
-
"""
|
| 437 |
-
Parse giờ từ lịch phát sóng VTV (đã là giờ VN UTC+7) sang datetime có timezone.
|
| 438 |
-
|
| 439 |
-
VTV hiển thị lịch theo ngày dương lịch (không phải ngày truyền hình).
|
| 440 |
-
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.
|
| 441 |
-
|
| 442 |
-
Logic:
|
| 443 |
-
- 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
|
| 444 |
-
- Giờ 05:00-23:59: Luôn thuộc ngày hiện tại
|
| 445 |
-
"""
|
| 446 |
if not time_str:
|
| 447 |
return None
|
| 448 |
time_str = time_str.strip().replace("h", ":").replace("H", ":")
|
|
@@ -450,35 +464,20 @@ def _parse_time(time_str, reference_date=None):
|
|
| 450 |
if m:
|
| 451 |
try:
|
| 452 |
hour, minute = int(m.group(1)), int(m.group(2))
|
| 453 |
-
|
| 454 |
if reference_date:
|
| 455 |
base_date = reference_date
|
| 456 |
else:
|
| 457 |
-
# Lấy ngày hiện tại theo giờ VN (UTC+7)
|
| 458 |
now_vn = datetime.now(VN_TZ)
|
| 459 |
base_date = now_vn
|
| 460 |
-
|
| 461 |
from datetime import timedelta
|
| 462 |
-
|
| 463 |
-
# Xác định ngày cho giờ program
|
| 464 |
if hour < 5:
|
| 465 |
-
# Giờ 00:00-04:59: Cần xem giờ hiện tại để quyết định
|
| 466 |
if base_date.hour < 5:
|
| 467 |
-
# Nếu hiện tại cũng < 5:00 (đang trong khoảng sáng sớm)
|
| 468 |
-
# → Giờ program thuộc CÙNG NGÀY với giờ hiện tại
|
| 469 |
tv_date = base_date
|
| 470 |
else:
|
| 471 |
-
# Nếu hiện tại >= 5:00 (đã qua sáng sớm)
|
| 472 |
-
# → Giờ 00:00-04:59 thuộc NGÀY HÔM TRƯỚC
|
| 473 |
tv_date = base_date - timedelta(days=1)
|
| 474 |
else:
|
| 475 |
-
# Giờ 05:00-23:59: Luôn thuộc ngày hiện tại
|
| 476 |
tv_date = base_date
|
| 477 |
-
|
| 478 |
-
# Tạo datetime với giờ từ lịch
|
| 479 |
result = tv_date.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
| 480 |
-
|
| 481 |
-
# Đảm bảo result có timezone
|
| 482 |
if result.tzinfo is None:
|
| 483 |
result = result.replace(tzinfo=VN_TZ)
|
| 484 |
return result
|
|
@@ -494,18 +493,13 @@ def _get_epg_for_channel(channel_id):
|
|
| 494 |
now = datetime.now(VN_TZ)
|
| 495 |
today = now.date()
|
| 496 |
result = []
|
| 497 |
-
|
| 498 |
-
# Lọc chỉ lấy programs của ngày hôm nay (theo lịch VTV)
|
| 499 |
today_programmes = []
|
| 500 |
for p in programmes:
|
| 501 |
start_dt = p.get("start_dt")
|
| 502 |
if start_dt and start_dt.date() == today:
|
| 503 |
today_programmes.append(p)
|
| 504 |
-
|
| 505 |
-
# Nếu không có programs cho hôm nay, dùng tất cả (fallback)
|
| 506 |
if not today_programmes:
|
| 507 |
today_programmes = programmes
|
| 508 |
-
|
| 509 |
for i, p in enumerate(today_programmes):
|
| 510 |
start_dt = p.get("start_dt")
|
| 511 |
stop_dt = None
|
|
@@ -543,4 +537,4 @@ def api_vtv_epg_refresh():
|
|
| 543 |
return JSONResponse({
|
| 544 |
"status": "refreshed", "channels": len(epg_data),
|
| 545 |
"total_programmes": sum(len(v) for v in epg_data),
|
| 546 |
-
})
|
|
|
|
| 1 |
"""
|
| 2 |
VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
|
| 3 |
Fetches stream URLs from xemtv.us PHP endpoints (primary)
|
| 4 |
+
Alternative: xemtivitop.com (backup)
|
| 5 |
Fallback: FPTPlay CDN → VTVGo CDN → xemtv.net (legacy)
|
| 6 |
EPG data scraped from https://vtv.vn/lich-phat-song.htm
|
| 7 |
"""
|
|
|
|
| 36 |
"vtvprime": "https://xemtv.us/tv/vtvprime.php",
|
| 37 |
}
|
| 38 |
|
| 39 |
+
# ===== ALTERNATIVE: xemtivitop.com (backup source) =====
|
| 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",
|
| 43 |
+
"vtv3": "https://www.xemtivitop.com/2018/10/vtv3-online.html?m=1",
|
| 44 |
+
"vtv4": "https://www.xemtivitop.com/2018/10/vtv4-online.html?m=1",
|
| 45 |
+
"vtv5": "https://www.xemtivitop.com/2018/10/vtv5-online.html?m=1",
|
| 46 |
+
"vtv6": "https://www.xemtivitop.com/2018/10/vtv6-online.html?m=1",
|
| 47 |
+
"vtv7": "https://www.xemtivitop.com/2018/10/vtv7-online.html?m=1",
|
| 48 |
+
"vtv8": "https://www.xemtivitop.com/2018/10/vtv8-online.html?m=1",
|
| 49 |
+
"vtv9": "https://www.xemtivitop.com/2018/10/vtv9-online.html?m=1",
|
| 50 |
+
"vtv10": "https://www.xemtivitop.com/2020/05/vtv10-can-tho-online.html?m=1",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
# ===== LEGACY: xemtv.net (may return 403, keep as last resort) =====
|
| 54 |
XEMTV_LEGACY_ENDPOINTS = {
|
| 55 |
"vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
|
|
|
|
| 150 |
pass
|
| 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
|
| 158 |
+
try:
|
| 159 |
+
headers = {**UA, "Referer": "https://www.xemtivitop.com/"}
|
| 160 |
+
r = requests.get(page_url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 161 |
+
if r.status_code == 200:
|
| 162 |
+
m3u8 = extract_m3u8_from_html(r.text)
|
| 163 |
+
if m3u8:
|
| 164 |
+
return m3u8
|
| 165 |
+
except:
|
| 166 |
+
pass
|
| 167 |
+
return None
|
| 168 |
+
|
| 169 |
def fetch_xemtv_legacy_stream(channel_id):
|
| 170 |
php_url = XEMTV_LEGACY_ENDPOINTS.get(channel_id)
|
| 171 |
if not php_url:
|
|
|
|
| 212 |
return None
|
| 213 |
|
| 214 |
def normalize_fptplay_url(url):
|
|
|
|
| 215 |
if not url:
|
| 216 |
return url
|
| 217 |
old_to_new = {
|
|
|
|
| 243 |
return old_to_new.get(url, url)
|
| 244 |
|
| 245 |
def fetch_vtv_stream(channel_id):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
channel_id = channel_id.lower().strip()
|
| 247 |
name_map = {
|
| 248 |
'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
|
|
|
|
| 257 |
return cached
|
| 258 |
|
| 259 |
if channel_id == 'vtvprime':
|
| 260 |
+
url = fetch_xemtv_us_stream('vtvprime') or fetch_xemtivitop_stream('vtvprime') or fetch_xemtv_legacy_stream('vtvprime')
|
| 261 |
if url:
|
| 262 |
url = normalize_fptplay_url(url)
|
| 263 |
_set_cache(channel_id, url)
|
| 264 |
return url
|
| 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)
|
|
|
|
| 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('#'):
|
|
|
|
| 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",
|
|
|
|
| 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 = {
|
|
|
|
| 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
|
|
|
|
| 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", ":")
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
| 537 |
return JSONResponse({
|
| 538 |
"status": "refreshed", "channels": len(epg_data),
|
| 539 |
"total_programmes": sum(len(v) for v in epg_data),
|
| 540 |
+
})
|