bep40 commited on
Commit
fa01fdf
·
verified ·
1 Parent(s): 0f00360

Upload vtv_api.py

Browse files
Files changed (1) hide show
  1. vtv_api.py +59 -120
vtv_api.py CHANGED
@@ -1,11 +1,9 @@
1
  """
2
- VTV Channels API v2 Multi-source with auto-failover
3
- Sources per channel (priority order):
4
- 1. xemtv.net scraping (fresh, changes dynamically)
5
- 2. VTVGo failover CDN (vtvgolive-failover.vtvdigital.vn)
6
- 3. fptplay CDN (live247 / fnxch2 / epzhd1)
7
- 4. fptplay ABR (.smil/chunklist)
8
- Every source is verified with HTTP HEAD before returning.
9
  """
10
  import re, time, threading
11
  import requests
@@ -26,9 +24,8 @@ CHANNEL_NAMES = {
26
  "vtv9": "VTV9", "vtv10": "VTV10", "vtvprime": "VTVPrime",
27
  }
28
 
29
- # ===== SOURCE LISTS — multiple backups per channel =====
30
- # Each channel has a list of URLs to try in order
31
- CHANNEL_SOURCES = {
32
  "vtv1": [
33
  "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
34
  "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
@@ -45,10 +42,11 @@ CHANNEL_SOURCES = {
45
  "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8",
46
  ],
47
  "vtv4": [
 
48
  "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
49
- "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8",
50
  ],
51
  "vtv5": [
 
52
  "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
53
  "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8",
54
  ],
@@ -57,16 +55,17 @@ CHANNEL_SOURCES = {
57
  "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
58
  ],
59
  "vtv7": [
 
60
  "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
61
  "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8",
62
  ],
63
  "vtv8": [
64
- "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
65
  "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8",
 
66
  ],
67
  "vtv9": [
68
- "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
69
  "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8",
 
70
  ],
71
  "vtv10": [
72
  "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/vtv10-avc1_5600000=10000-mp4a_131600=20000.m3u8",
@@ -77,7 +76,6 @@ CHANNEL_SOURCES = {
77
  ],
78
  }
79
 
80
- # xemtv.net PHP endpoints for dynamic scraping
81
  XEMTV_PHP = {
82
  "vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
83
  "vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
@@ -92,56 +90,33 @@ XEMTV_PHP = {
92
  "vtvprime": "https://hd.xemtv.net/kenh/vtvprime.php",
93
  }
94
 
95
- _vtv_cache = {}
96
  _vtv_lock = threading.Lock()
97
- _CACHE_TTL = 120 # 2 min cache
98
 
99
 
100
- def _cached(key):
101
  with _vtv_lock:
102
- if key in _vtv_cache and time.time() - _vtv_cache[key]['t'] < _CACHE_TTL:
103
- return _vtv_cache[key]['d']
 
104
  return None
105
 
106
 
107
- def _set_cache(key, data):
108
  with _vtv_lock:
109
- _vtv_cache[key] = {'t': time.time(), 'd': data}
110
-
111
-
112
- def _verify_url(url, timeout=5):
113
- """Verify a stream URL is reachable."""
114
- try:
115
- headers = {"User-Agent": UA["User-Agent"]}
116
- if "fptplay" in url:
117
- headers["Referer"] = "https://fptplay.vn/"
118
- headers["Origin"] = "https://fptplay.vn"
119
- # Try GET with range first (more reliable than HEAD for CDNs)
120
- headers["Range"] = "bytes=0-0"
121
- r = requests.get(url, headers=headers, timeout=timeout, allow_redirects=True)
122
- return r.status_code in (200, 206)
123
- except:
124
- return False
125
 
126
 
127
  def extract_m3u8_from_html(html):
128
- """Extract m3u8/smil URL from xemtv PHP page."""
129
  if not html:
130
  return None
131
- # jwplayer file: 'URL'
132
  m = re.search(r"file\s*:\s*['\"]([^'\"]+\.(?:m3u8|smil)[^'\"]*)['\"]", html, re.IGNORECASE)
133
  if m:
134
  url = m.group(1).strip()
135
  if len(url) > 20:
136
  return url
137
- # Generic m3u8 URL
138
- m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
139
- if m:
140
- url = m.group(1).strip()
141
- if len(url) > 20:
142
- return url
143
- # Generic smil URL
144
- m = re.search(r"(https?://[^\s\"'<>\\]+\.smil[^\s\"'<>\\]*)", html, re.IGNORECASE)
145
  if m:
146
  url = m.group(1).strip()
147
  if len(url) > 20:
@@ -149,90 +124,73 @@ def extract_m3u8_from_html(html):
149
  return None
150
 
151
 
152
- def fetch_xemtv_stream(channel_id):
153
- """Scrape xemtv.net PHP page for fresh stream URL."""
154
- php_url = XEMTV_PHP.get(channel_id)
155
  if not php_url:
156
  return None
157
  try:
158
- r = requests.get(php_url, headers=UA, timeout=10, allow_redirects=True)
159
  if r.status_code == 200:
160
  url = extract_m3u8_from_html(r.text)
161
- if url and _verify_url(url):
 
162
  return url
163
  except:
164
  pass
165
  return None
166
 
167
 
168
- def fetch_vtv_stream(channel_id):
169
- """
170
- Fetch m3u8 stream URL with multi-source failover.
171
- Returns (url, num_sources_tried) or (None, 0).
172
- """
173
- channel_id = channel_id.lower().strip()
174
- name_map = {
175
- 'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
176
- 'vtv_can_tho': 'vtv10', 'cantho': 'vtv10',
177
- }
178
- channel_id = name_map.get(channel_id, channel_id)
179
-
180
- cached = _cached(channel_id)
181
- if cached is not None:
182
- return cached
183
-
184
- sources_tried = []
185
-
186
- # 1. Try xemtv.net scraping first (fresh, dynamic)
187
- xemtv_url = fetch_xemtv_stream(channel_id)
188
  if xemtv_url:
189
- result = {"url": xemtv_url, "source": "xemtv", "all_sources": [xemtv_url]}
190
- _set_cache(channel_id, result)
191
- return result
192
-
193
- # 2. Try all static sources in order
194
- static_sources = CHANNEL_SOURCES.get(channel_id, [])
195
- for url in static_sources:
196
- sources_tried.append(url)
197
- if _verify_url(url):
198
- result = {"url": url, "source": "static", "all_sources": static_sources}
199
- _set_cache(channel_id, result)
200
- return result
201
-
202
- # 3. VTVPrime special: no free sources
203
- result = {"url": None, "source": "none", "all_sources": []}
204
- _set_cache(channel_id, result)
205
- return result
206
 
207
 
208
  @router.get("/api/vtv/streams")
209
  def api_vtv_streams():
210
- """Get all VTV channel stream URLs with multi-source info."""
211
  result = {}
212
  for ch_id in CHANNEL_NAMES:
213
- stream = fetch_vtv_stream(ch_id)
214
  result[ch_id] = {
215
  "name": CHANNEL_NAMES[ch_id],
216
- "stream_url": stream.get("url") if stream else None,
217
- "source": stream.get("source", "none") if stream else "none",
218
- "all_sources": stream.get("all_sources", []) if stream else [],
219
- "status": "ok" if stream and stream.get("url") else "offline",
220
  }
 
221
  return JSONResponse(result)
222
 
223
 
224
  @router.get("/api/vtv/stream/{channel_id}")
225
  def api_vtv_stream(channel_id: str):
226
- """Get stream URL for a specific VTV channel."""
227
- stream = fetch_vtv_stream(channel_id)
228
- if stream and stream.get("url"):
229
- return JSONResponse({"stream_url": stream["url"], "source": stream["source"], "status": "ok"})
230
  return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
231
 
232
 
233
  @router.get("/api/proxy/m3u8/vtv")
234
  def proxy_vtv_m3u8(url: str = Query(...)):
235
- """Proxy an m3u8 stream URL with proper headers."""
236
  try:
237
  headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
238
  if "fptplay" in url:
@@ -242,16 +200,13 @@ def proxy_vtv_m3u8(url: str = Query(...)):
242
  headers["Referer"] = "https://vtvgo.vn/"
243
  elif "xemtv" in url:
244
  headers["Referer"] = "https://hd.xemtv.net/"
245
-
246
  r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
247
  if r.status_code != 200:
248
  return Response(status_code=502, content=f"upstream error: {r.status_code}")
249
-
250
  content = r.text
251
  lines = content.split('\n')
252
  rewritten = []
253
  base_url = url.rsplit('/', 1)[0] + '/'
254
-
255
  for line in lines:
256
  line = line.strip()
257
  if not line or line.startswith('#'):
@@ -261,19 +216,13 @@ def proxy_vtv_m3u8(url: str = Query(...)):
261
  if not seg_url.startswith('http'):
262
  seg_url = base_url + seg_url
263
  rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
264
-
265
- return Response(
266
- content='\n'.join(rewritten).encode('utf-8'),
267
- media_type="application/vnd.apple.mpegurl",
268
- headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache"}
269
- )
270
  except Exception as e:
271
  return Response(status_code=502, content="proxy error: " + str(e))
272
 
273
 
274
  @router.get("/api/proxy/seg/vtv")
275
  def proxy_vtv_segment(url: str = Query(...)):
276
- """Proxy a video segment with proper headers."""
277
  try:
278
  headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
279
  if "fptplay" in url:
@@ -281,22 +230,12 @@ def proxy_vtv_segment(url: str = Query(...)):
281
  headers["Origin"] = "https://fptplay.vn"
282
  elif "vtvgolive" in url or "vtvdigital" in url:
283
  headers["Referer"] = "https://vtvgo.vn/"
284
-
285
  r = requests.get(url, headers=headers, timeout=30, allow_redirects=True, stream=True)
286
  if r.status_code != 200:
287
  return Response(status_code=502, content=f"upstream error: {r.status_code}")
288
-
289
  data = r.content
290
- # Strip PNG header if present (some CDNs prepend it)
291
  if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
292
  data = data[188:]
293
-
294
- return Response(
295
- content=data,
296
- media_type="video/mp2t",
297
- headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600"}
298
- )
299
  except:
300
  return Response(status_code=502, content="proxy error")
301
-
302
- # Updated: 1781062125
 
1
  """
2
+ VTV Channels API v3 — Fast, multi-source, parallel scraping
3
+ Strategy:
4
+ - Return cached/static sources immediately (< 500ms)
5
+ - Scrape xemtv.net in background thread for next request
6
+ - Frontend has full source list for client-side failover
 
 
7
  """
8
  import re, time, threading
9
  import requests
 
24
  "vtv9": "VTV9", "vtv10": "VTV10", "vtvprime": "VTVPrime",
25
  }
26
 
27
+ # ===== STATIC SOURCE LISTS =====
28
+ STATIC_SOURCES = {
 
29
  "vtv1": [
30
  "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
31
  "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
 
42
  "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8",
43
  ],
44
  "vtv4": [
45
+ "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
46
  "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
 
47
  ],
48
  "vtv5": [
49
+ "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
50
  "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
51
  "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8",
52
  ],
 
55
  "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
56
  ],
57
  "vtv7": [
58
+ "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
59
  "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
60
  "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8",
61
  ],
62
  "vtv8": [
 
63
  "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8",
64
+ "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
65
  ],
66
  "vtv9": [
 
67
  "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8",
68
+ "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
69
  ],
70
  "vtv10": [
71
  "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/vtv10-avc1_5600000=10000-mp4a_131600=20000.m3u8",
 
76
  ],
77
  }
78
 
 
79
  XEMTV_PHP = {
80
  "vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
81
  "vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
 
90
  "vtvprime": "https://hd.xemtv.net/kenh/vtvprime.php",
91
  }
92
 
93
+ _vtv_xemtv_cache = {}
94
  _vtv_lock = threading.Lock()
95
+ _XEMTV_CACHE_TTL = 300
96
 
97
 
98
+ def _get_cached_xemtv(ch_id):
99
  with _vtv_lock:
100
+ entry = _vtv_xemtv_cache.get(ch_id)
101
+ if entry and time.time() - entry['t'] < _XEMTV_CACHE_TTL:
102
+ return entry['url']
103
  return None
104
 
105
 
106
+ def _set_cached_xemtv(ch_id, url):
107
  with _vtv_lock:
108
+ _vtv_xemtv_cache[ch_id] = {'t': time.time(), 'url': url}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
 
111
  def extract_m3u8_from_html(html):
 
112
  if not html:
113
  return None
 
114
  m = re.search(r"file\s*:\s*['\"]([^'\"]+\.(?:m3u8|smil)[^'\"]*)['\"]", html, re.IGNORECASE)
115
  if m:
116
  url = m.group(1).strip()
117
  if len(url) > 20:
118
  return url
119
+ m = re.search(r"(https?://[^\s\"'<>\\]+\.(?:m3u8|smil)[^\s\"'<>\\]*)", html, re.IGNORECASE)
 
 
 
 
 
 
 
120
  if m:
121
  url = m.group(1).strip()
122
  if len(url) > 20:
 
124
  return None
125
 
126
 
127
+ def scrape_xemtv_one(ch_id):
128
+ php_url = XEMTV_PHP.get(ch_id)
 
129
  if not php_url:
130
  return None
131
  try:
132
+ r = requests.get(php_url, headers=UA, timeout=8, allow_redirects=True)
133
  if r.status_code == 200:
134
  url = extract_m3u8_from_html(r.text)
135
+ if url:
136
+ _set_cached_xemtv(ch_id, url)
137
  return url
138
  except:
139
  pass
140
  return None
141
 
142
 
143
+ def scrape_xemtv_all_parallel():
144
+ from concurrent.futures import ThreadPoolExecutor, as_completed
145
+ with ThreadPoolExecutor(max_workers=6) as ex:
146
+ futs = {ex.submit(scrape_xemtv_one, ch): ch for ch in XEMTV_PHP}
147
+ for f in as_completed(futs, timeout=15):
148
+ try:
149
+ f.result()
150
+ except:
151
+ pass
152
+
153
+
154
+ def get_stream_url(ch_id):
155
+ ch_id = ch_id.lower().strip()
156
+ name_map = {'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'cantho': 'vtv10'}
157
+ ch_id = name_map.get(ch_id, ch_id)
158
+ all_sources = STATIC_SOURCES.get(ch_id, [])
159
+ if not all_sources:
160
+ return None, "none", []
161
+ xemtv_url = _get_cached_xemtv(ch_id)
 
162
  if xemtv_url:
163
+ sources = [xemtv_url] + [s for s in all_sources if s != xemtv_url]
164
+ return xemtv_url, "xemtv", sources
165
+ return all_sources[0], "static", all_sources
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
 
168
  @router.get("/api/vtv/streams")
169
  def api_vtv_streams():
 
170
  result = {}
171
  for ch_id in CHANNEL_NAMES:
172
+ url, source, all_sources = get_stream_url(ch_id)
173
  result[ch_id] = {
174
  "name": CHANNEL_NAMES[ch_id],
175
+ "stream_url": url,
176
+ "source": source,
177
+ "all_sources": all_sources,
178
+ "status": "ok" if url else "offline",
179
  }
180
+ threading.Thread(target=scrape_xemtv_all_parallel, daemon=True).start()
181
  return JSONResponse(result)
182
 
183
 
184
  @router.get("/api/vtv/stream/{channel_id}")
185
  def api_vtv_stream(channel_id: str):
186
+ url, source, all_sources = get_stream_url(channel_id)
187
+ if url:
188
+ return JSONResponse({"stream_url": url, "source": source, "all_sources": all_sources, "status": "ok"})
 
189
  return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
190
 
191
 
192
  @router.get("/api/proxy/m3u8/vtv")
193
  def proxy_vtv_m3u8(url: str = Query(...)):
 
194
  try:
195
  headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
196
  if "fptplay" in url:
 
200
  headers["Referer"] = "https://vtvgo.vn/"
201
  elif "xemtv" in url:
202
  headers["Referer"] = "https://hd.xemtv.net/"
 
203
  r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
204
  if r.status_code != 200:
205
  return Response(status_code=502, content=f"upstream error: {r.status_code}")
 
206
  content = r.text
207
  lines = content.split('\n')
208
  rewritten = []
209
  base_url = url.rsplit('/', 1)[0] + '/'
 
210
  for line in lines:
211
  line = line.strip()
212
  if not line or line.startswith('#'):
 
216
  if not seg_url.startswith('http'):
217
  seg_url = base_url + seg_url
218
  rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
219
+ return Response(content='\n'.join(rewritten).encode('utf-8'), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache"})
 
 
 
 
 
220
  except Exception as e:
221
  return Response(status_code=502, content="proxy error: " + str(e))
222
 
223
 
224
  @router.get("/api/proxy/seg/vtv")
225
  def proxy_vtv_segment(url: str = Query(...)):
 
226
  try:
227
  headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
228
  if "fptplay" in url:
 
230
  headers["Origin"] = "https://fptplay.vn"
231
  elif "vtvgolive" in url or "vtvdigital" in url:
232
  headers["Referer"] = "https://vtvgo.vn/"
 
233
  r = requests.get(url, headers=headers, timeout=30, allow_redirects=True, stream=True)
234
  if r.status_code != 200:
235
  return Response(status_code=502, content=f"upstream error: {r.status_code}")
 
236
  data = r.content
 
237
  if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
238
  data = data[188:]
239
+ return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600"})
 
 
 
 
 
240
  except:
241
  return Response(status_code=502, content="proxy error")