bep40 commited on
Commit
d0dba35
·
verified ·
1 Parent(s): 709596c

Fix VTV: stable HLS config, EPG API, proxy m3u8 fix

Browse files
Files changed (1) hide show
  1. vtv_api.py +120 -4
vtv_api.py CHANGED
@@ -1,11 +1,13 @@
1
  """
2
  VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
3
  Fetches stream URLs from hd.xemtv.net PHP endpoints and VTVPrime
 
4
  """
5
  import re, time, threading
6
  import requests
7
  from fastapi import APIRouter, Query
8
  from fastapi.responses import JSONResponse, Response
 
9
 
10
  router = APIRouter()
11
 
@@ -15,6 +17,102 @@ UA = {
15
  "Referer": "https://hd.xemtv.net/",
16
  }
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  # Channel ID -> xemtv.net PHP endpoint mapping
19
  XEMTV_PHP_ENDPOINTS = {
20
  "vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
@@ -58,7 +156,6 @@ VTVGO_FAILOVER = {
58
  }
59
 
60
  # ===== Channels that should use xemtv.net scraping (not VTVGo failover) =====
61
- # VTV6, VTV10: xemtv.net scraping (returns fptplay CDN URLs that need proxying)
62
  XEMTV_ONLY_CHANNELS = {"vtv6", "vtv10"}
63
 
64
  # ===== LAST RESORT: fptplay CDN URLs (need proxy for referer) =====
@@ -170,7 +267,6 @@ def fetch_vtv_stream(channel_id):
170
  # --- VTV10: xemtv returns vtvcantho (403), skip to fptplay CDN directly ---
171
  if channel_id in XEMTV_ONLY_CHANNELS:
172
  if channel_id == "vtv10":
173
- # VTV10: xemtv.net returns dead vtvcantho URL (403), use fptplay CDN directly
174
  fpt_url = FPTPLAY_URLS.get(channel_id)
175
  if fpt_url:
176
  _set_cache(channel_id, fpt_url)
@@ -242,6 +338,16 @@ def api_vtv_stream(channel_id: str):
242
  return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
243
 
244
 
 
 
 
 
 
 
 
 
 
 
245
  @router.get("/api/proxy/page")
246
  def proxy_page(url: str = Query(...)):
247
  """Proxy a web page."""
@@ -263,7 +369,12 @@ def proxy_page(url: str = Query(...)):
263
 
264
  @router.get("/api/proxy/m3u8/vtv")
265
  def proxy_vtv_m3u8(url: str = Query(...)):
266
- """Proxy an m3u8 stream URL with proper headers for fptplay CDN."""
 
 
 
 
 
267
  try:
268
  headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
269
  if "fptplay" in url:
@@ -286,7 +397,12 @@ def proxy_vtv_m3u8(url: str = Query(...)):
286
  seg_url = line
287
  if not seg_url.startswith('http'):
288
  seg_url = base_url + seg_url
289
- rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
 
 
 
 
 
290
  return Response(
291
  content='\n'.join(rewritten).encode('utf-8'),
292
  media_type="application/vnd.apple.mpegurl",
 
1
  """
2
  VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
3
  Fetches stream URLs from hd.xemtv.net PHP endpoints and VTVPrime
4
+ EPG schedule scraped from vtv.vn
5
  """
6
  import re, time, threading
7
  import requests
8
  from fastapi import APIRouter, Query
9
  from fastapi.responses import JSONResponse, Response
10
+ from bs4 import BeautifulSoup
11
 
12
  router = APIRouter()
13
 
 
17
  "Referer": "https://hd.xemtv.net/",
18
  }
19
 
20
+ # ===== EPG: VTV.vn schedule scraping =====
21
+ VTV_SCHEDULE_SLUGS = {
22
+ "vtv1": "vtv1", "vtv2": "vtv2", "vtv3": "vtv3", "vtv4": "vtv4",
23
+ "vtv5": "vtv5", "vtv6": "vtv6", "vtv7": "vtv7", "vtv8": "vtv8",
24
+ "vtv9": "vtv9", "vtv10": "vtv-can-tho", "vtvprime": "vtvprime",
25
+ }
26
+
27
+ _epg_cache = {}
28
+ _epg_lock = threading.Lock()
29
+ _EPG_CACHE_TTL = 3600 # 1 hour
30
+
31
+
32
+ def _get_cached_epg(ch_id):
33
+ with _epg_lock:
34
+ if ch_id in _epg_cache and time.time() - _epg_cache[ch_id]['t'] < _EPG_CACHE_TTL:
35
+ return _epg_cache[ch_id]['d']
36
+ return None
37
+
38
+
39
+ def _set_epg_cache(ch_id, data):
40
+ with _epg_lock:
41
+ _epg_cache[ch_id] = {'t': time.time(), 'd': data}
42
+
43
+
44
+ def fetch_vtv_epg(channel_id):
45
+ """Fetch EPG schedule for a VTV channel from vtv.vn."""
46
+ cached = _get_cached_epg(channel_id)
47
+ if cached is not None:
48
+ return cached
49
+
50
+ slug = VTV_SCHEDULE_SLUGS.get(channel_id, channel_id)
51
+ urls = [
52
+ f"https://vtv.vn/lich-phat-song/{slug}.htm",
53
+ f"https://vtv.vn/lich-phat-song.htm",
54
+ ]
55
+
56
+ for url in urls:
57
+ try:
58
+ r = requests.get(url, headers={
59
+ "User-Agent": UA["User-Agent"],
60
+ "Accept-Language": "vi-VN,vi;q=0.9",
61
+ }, timeout=10, allow_redirects=True)
62
+ if r.status_code != 200:
63
+ continue
64
+ r.encoding = "utf-8"
65
+ soup = BeautifulSoup(r.text, "lxml")
66
+ schedule = []
67
+
68
+ # Method 1: table rows with time + program
69
+ for table in soup.find_all("table"):
70
+ for row in table.find_all("tr"):
71
+ cells = row.find_all(["td", "th"])
72
+ if len(cells) >= 2:
73
+ time_text = cells[0].get_text(strip=True)
74
+ prog_text = cells[1].get_text(strip=True)
75
+ if re.match(r'\d{1,2}:\d{2}', time_text) and prog_text and len(prog_text) > 2:
76
+ schedule.append({"t": time_text, "n": prog_text[:80]})
77
+
78
+ if schedule:
79
+ _set_epg_cache(channel_id, schedule)
80
+ return schedule
81
+
82
+ # Method 2: list items with time dash program
83
+ for ul in soup.find_all("ul"):
84
+ for li in ul.find_all("li"):
85
+ text = li.get_text(" ", strip=True)
86
+ m = re.match(r'(\d{1,2}:\d{2})\s*[–\-:]\s*(.+)', text)
87
+ if m and len(m.group(2)) > 2:
88
+ schedule.append({"t": m.group(1), "n": m.group(2).strip()[:80]})
89
+
90
+ if schedule:
91
+ _set_epg_cache(channel_id, schedule)
92
+ return schedule
93
+
94
+ # Method 3: divs with time/program children
95
+ time_els = soup.find_all(text=re.compile(r'^\d{1,2}:\d{2}$'))
96
+ for te in time_els:
97
+ parent = te.parent
98
+ if parent:
99
+ prog_el = parent.find_next_sibling() or parent.parent
100
+ if prog_el:
101
+ prog_text = prog_el.get_text(strip=True)
102
+ if prog_text and len(prog_text) > 2:
103
+ schedule.append({"t": te.strip(), "n": prog_text[:80]})
104
+
105
+ if schedule:
106
+ _set_epg_cache(channel_id, schedule)
107
+ return schedule
108
+
109
+ except Exception:
110
+ continue
111
+
112
+ _set_epg_cache(channel_id, None)
113
+ return None
114
+
115
+
116
  # Channel ID -> xemtv.net PHP endpoint mapping
117
  XEMTV_PHP_ENDPOINTS = {
118
  "vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
 
156
  }
157
 
158
  # ===== Channels that should use xemtv.net scraping (not VTVGo failover) =====
 
159
  XEMTV_ONLY_CHANNELS = {"vtv6", "vtv10"}
160
 
161
  # ===== LAST RESORT: fptplay CDN URLs (need proxy for referer) =====
 
267
  # --- VTV10: xemtv returns vtvcantho (403), skip to fptplay CDN directly ---
268
  if channel_id in XEMTV_ONLY_CHANNELS:
269
  if channel_id == "vtv10":
 
270
  fpt_url = FPTPLAY_URLS.get(channel_id)
271
  if fpt_url:
272
  _set_cache(channel_id, fpt_url)
 
338
  return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
339
 
340
 
341
+ @router.get("/api/vtv/epg/{channel_id}")
342
+ def api_vtv_epg(channel_id: str):
343
+ """Get EPG schedule for a specific VTV channel."""
344
+ channel_id = channel_id.lower().strip()
345
+ epg = fetch_vtv_epg(channel_id)
346
+ if epg:
347
+ return JSONResponse({"channel_id": channel_id, "schedule": epg})
348
+ return JSONResponse({"channel_id": channel_id, "schedule": [], "source": "none"})
349
+
350
+
351
  @router.get("/api/proxy/page")
352
  def proxy_page(url: str = Query(...)):
353
  """Proxy a web page."""
 
369
 
370
  @router.get("/api/proxy/m3u8/vtv")
371
  def proxy_vtv_m3u8(url: str = Query(...)):
372
+ """Proxy an m3u8 stream URL with proper headers for fptplay CDN.
373
+
374
+ CRITICAL FIX: Only proxy segment URLs (.ts, .aac, etc.), NOT sub-manifest URLs (.m3u8).
375
+ Sub-manifests must be fetched directly by the client (HLS.js) to avoid
376
+ rewriting issues with relative paths inside nested playlists.
377
+ """
378
  try:
379
  headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
380
  if "fptplay" in url:
 
397
  seg_url = line
398
  if not seg_url.startswith('http'):
399
  seg_url = base_url + seg_url
400
+ # Only proxy segment files (.ts, .aac, .mp4, .webvtt, .jpg, .png etc.)
401
+ # Do NOT proxy .m3u8 sub-manifests — let HLS.js fetch them directly
402
+ if seg_url.endswith('.m3u8') or seg_url.endswith('.m3u'):
403
+ rewritten.append(seg_url)
404
+ else:
405
+ rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
406
  return Response(
407
  content='\n'.join(rewritten).encode('utf-8'),
408
  media_type="application/vnd.apple.mpegurl",