bep40 commited on
Commit
1141532
·
verified ·
1 Parent(s): be2576c

Upload vtv_api.py

Browse files
Files changed (1) hide show
  1. vtv_api.py +152 -726
vtv_api.py CHANGED
@@ -1,746 +1,172 @@
1
  """
2
- VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
3
- Fetches stream URLs from multiple CDN sources with aggressive extraction and fast failover.
4
-
5
- Sources (ordered by stability):
6
- 1. VTV Digital CDN (ssaimh) - FAST check, skip if not responding quickly
7
- 2. xemtv.us PHP endpoints
8
- 3. sv2.xemtivitop.com PHP endpoints (main source for VTV2, VTV3, VTV6, VTV9)
9
- 4. xemtivitop.com blogspot pages
10
- 5. FPTPlay CDN
11
- 6. VTVGo CDN
12
- 7. xemtv.net legacy
13
  """
14
- import re, time, threading, json
15
- import requests
16
- from fastapi import APIRouter, Query
17
- from fastapi.responses import JSONResponse, Response
18
- from bs4 import BeautifulSoup
19
- from datetime import datetime, timedelta, timezone
20
-
21
- VN_TZ = timezone(timedelta(hours=7))
22
-
23
- router = APIRouter()
24
-
25
- UA = {
26
- "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",
27
- "Accept-Language": "vi-VN,vi;q=0.9",
28
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
29
- "Sec-Fetch-Dest": "document",
30
- "Sec-Fetch-Mode": "navigate",
31
- "Sec-Fetch-Site": "none",
32
- "Upgrade-Insecure-Requests": "1",
33
- }
34
-
35
- # ===== VTV Digital CDN (ssaimh) - may have expired token, check quickly =====
36
- # NOTE: vtv6 CDN URL actually delivered VTV10 (Can Tho). VTV2/VTV3/VTV6/VTV9 removed from CDN.
37
- VTV_DIGITAL_SSAIMH_URLS = {
38
- "vtv1": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv1/master.m3u8",
39
- "vtv2": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv2/master.m3u8",
40
- "vtv3": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv3/master.m3u8",
41
- "vtv4": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv4/master.m3u8",
42
- "vtv5": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv5/master.m3u8",
43
- # VTV6 intentionally omitted from CDN -- uses sv2.xemtivitop.com for real VTV6 signal
44
- "vtv7": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv7/master.m3u8",
45
- "vtv8": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv8/master.m3u8",
46
- "vtv9": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv9/master.m3u8",
47
- "vtv10": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv6/master.m3u8",
48
- }
49
-
50
- # ===== xemtv.us PHP endpoints =====
51
- XEMTV_US_ENDPOINTS = {
52
- "vtv1": "https://xemtv.us/tv/vtv1.php",
53
- "vtv2": "https://xemtv.us/tv/vtv2.php",
54
- "vtv3": "https://xemtv.us/tv/vtv3.php",
55
- "vtv4": "https://xemtv.us/tv/vtv4.php",
56
- "vtv5": "https://xemtv.us/tv/vtv5.php",
57
- "vtv6": "https://xemtv.us/tv/vtv6.php",
58
- "vtv7": "https://xemtv.us/tv/vtv7.php",
59
- "vtv8": "https://xemtv.us/tv/vtv8.php",
60
- "vtv9": "https://xemtv.us/tv/vtv9.php",
61
- "vtv10": "https://xemtv.us/tv/vtv10.php",
62
- "vtvprime": "https://xemtv.us/tv/vtvprime.php",
63
- }
64
-
65
- # ===== sv2.xemtivitop.com PHP endpoints (primary for VTV2, VTV3, VTV6, VTV9) =====
66
- SV2_XEMTIVITOP_ENDPOINTS = {
67
- "vtv1": "https://sv2.xemtivitop.com/live/hot/vtv1.php",
68
- "vtv2": "https://sv2.xemtivitop.com/live/hot/vtv2.php",
69
- "vtv3": "https://sv2.xemtivitop.com/live/hot/vtv3.php",
70
- "vtv4": "https://sv2.xemtivitop.com/live/hot/vtv4.php",
71
- "vtv5": "https://sv2.xemtivitop.com/live/hot/vtv5.php",
72
- "vtv6": "https://sv2.xemtivitop.com/live/hot/vtv6.php",
73
- "vtv7": "https://sv2.xemtivitop.com/live/hot/vtv7.php",
74
- "vtv8": "https://sv2.xemtivitop.com/live/hot/vtv8.php",
75
- "vtv9": "https://sv2.xemtivitop.com/live/hot/vtv9.php",
76
- "vtv10": "https://sv2.xemtivitop.com/live/hot/vtv10.php",
77
- }
78
-
79
- # ===== xemtivitop.com blogspot pages =====
80
- XEMTIVITOP_ENDPOINTS = {
81
- "vtv1": "https://www.xemtivitop.com/2018/10/vtv1-online.html?m=1",
82
- "vtv2": "https://www.xemtivitop.com/2018/10/vtv2-online.html?m=1",
83
- "vtv3": "https://www.xemtivitop.com/2018/10/vtv3-online.html?m=1",
84
- "vtv4": "https://www.xemtivitop.com/2018/10/vtv4-online.html?m=1",
85
- "vtv5": "https://www.xemtivitop.com/2018/10/vtv5-online.html?m=1",
86
- "vtv6": "https://www.xemtivitop.com/2018/10/vtv6-online.html?m=1",
87
- "vtv7": "https://www.xemtivitop.com/2018/10/vtv7-online.html?m=1",
88
- "vtv8": "https://www.xemtivitop.com/2018/10/vtv8-online.html?m=1",
89
- "vtv9": "https://www.xemtivitop.com/2018/10/vtv9-online.html?m=1",
90
- "vtv10": "https://www.xemtivitop.com/2020/05/vtv10-can-tho-online.html?m=1",
91
- }
92
-
93
- # ===== xemtv.net legacy =====
94
- XEMTV_LEGACY_ENDPOINTS = {
95
- "vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
96
- "vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
97
- "vtv3": "https://hd.xemtv.net/kenh/vtv3.php",
98
- "vtv4": "https://hd.xemtv.net/kenh/vtv4.php",
99
- "vtv5": "https://hd.xemtv.net/kenh/vtv5.php",
100
- "vtv6": "https://hd.xemtv.net/kenh/vtv6.php",
101
- "vtv7": "https://hd.xemtv.net/kenh/vtv7.php",
102
- "vtv8": "https://hd.xemtv.net/kenh/vtv8.php",
103
- "vtv9": "https://hd.xemtv.net/kenh/vtv9.php",
104
- "vtv10": "https://hd.xemtv.net/kenh/vtv10.php",
105
- "vtvprime": "https://hd.xemtv.net/kenh/vtvprime.php",
106
- }
107
-
108
- CHANNEL_NAMES = {
109
- "vtv1": "VTV1", "vtv2": "VTV2", "vtv3": "VTV3", "vtv4": "VTV4",
110
- "vtv5": "VTV5", "vtv6": "VTV6", "vtv7": "VTV7", "vtv8": "VTV8",
111
- "vtv9": "VTV9", "vtv10": "VTV10", "vtvprime": "VTVPrime",
112
- }
113
-
114
- # ===== FPTPlay CDN =====
115
- FPTPLAY_URLS = {
116
- "vtv1": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
117
- "vtv2": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
118
- "vtv3": "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8",
119
- "vtv4": "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
120
- "vtv5": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
121
- "vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
122
- "vtv7": "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
123
- "vtv8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
124
- "vtv9": "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
125
- "vtv10": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
126
- }
127
-
128
- # ===== VTVGo CDN =====
129
- VTVGO_FAILOVER = {
130
- "vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
131
- "vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
132
- "vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
133
- "vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
134
- "vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
135
- "vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
136
- "vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
137
- "vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
138
- "vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
139
- }
140
-
141
- # ===== New backup CDN sources for Vietnamese TV =====
142
- BACKUP_CDN_URLS = {
143
- # acmcdn - alternative Vietnamese CDN
144
- "vtv2": "http://tv.mediacdn.vn/live/hls/vtv2.m3u8",
145
- "vtv3": "http://tv.mediacdn.vn/live/hls/vtv3.m3u8",
146
- "vtv6": "http://tv.mediacdn.vn/live/hls/vtv6.m3u8",
147
- "vtv9": "http://tv.mediacdn.vn/live/hls/vtv9.m3u8",
148
- }
149
-
150
- # Which channels have KNOWN working ssaimh CDN (not expired token)
151
- # VTV1, VTV4, VTV5, VTV7, VTV8 have been reported working
152
- # VTV2, VTV3, VTV6, VTV9 are problematic on ssaimh
153
- SSAIMH_WORKING_CHANNELS = {"vtv1", "vtv4", "vtv5", "vtv7", "vtv8", "vtv10"}
154
-
155
- _vtv_cache = {}
156
- _vtv_lock = threading.Lock()
157
- _CACHE_TTL = 180 # 3 minutes
158
-
159
- FAST_TIMEOUT = 5 # Quick check for CDN URLs
160
- NORMAL_TIMEOUT = 12 # Normal fetch timeout
161
-
162
- def _cached(key):
163
- with _vtv_lock:
164
- if key in _vtv_cache and time.time() - _vtv_cache[key]['t'] < _CACHE_TTL:
165
- return _vtv_cache[key]['d']
166
- return None
167
-
168
- def _set_cache(key, data):
169
- with _vtv_lock:
170
- _vtv_cache[key] = {'t': time.time(), 'd': data}
171
-
172
- def extract_m3u8_aggressive(html):
173
- """Aggressively extract m3u8 URL from HTML using ALL possible patterns.
174
- Tries 10+ patterns including JS config, iframe recursive, video tag, etc."""
175
- if not html:
176
- return None
177
-
178
- # Skip HTML comments
179
- html = re.sub(r'<!--.*?-->', '', html, flags=re.DOTALL)
180
-
181
- # Pattern 1: file: '...m3u8' (jwplayer/javascript config)
182
- m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
183
- if m:
184
- url = m.group(1).strip()
185
- if len(url) > 20:
186
- return url
187
-
188
- # Pattern 2: file: "..." (double quotes variant)
189
- m = re.search(r"file\s*=\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
190
- if m:
191
- url = m.group(1).strip()
192
- if len(url) > 20:
193
- return url
194
-
195
- # Pattern 3: src="...m3u8"
196
- m = re.search(r'src\s*=\s*["\']([^"\']*\.m3u8[^"\']*)["\']', html, re.IGNORECASE)
197
- if m:
198
- url = m.group(1).strip()
199
- if len(url) > 20:
200
- return url
201
-
202
- # Pattern 4: data-src="...m3u8"
203
- m = re.search(r'data-src\s*=\s*["\']([^"\']*\.m3u8[^"\']*)["\']', html, re.IGNORECASE)
204
- if m:
205
- url = m.group(1).strip()
206
- if len(url) > 20:
207
- return url
208
-
209
- # Pattern 5: data-lazy-src="...m3u8"
210
- m = re.search(r'data-lazy-src\s*=\s*["\']([^"\']*\.m3u8[^"\']*)["\']', html, re.IGNORECASE)
211
- if m:
212
- url = m.group(1).strip()
213
- if len(url) > 20:
214
- return url
215
-
216
- # Pattern 6: player.src("...m3u8") or player.source("...")
217
- m = re.search(r'player\.(?:src|source)\s*\(\s*["\']([^"\']*\.m3u8[^"\']*)["\']', html, re.IGNORECASE)
218
- if m:
219
- url = m.group(1).strip()
220
- if len(url) > 20:
221
- return url
222
-
223
- # Pattern 7: hls.loadSource("...")
224
- m = re.search(r'(?:hls|video)\.(?:loadSource|src)\s*\(\s*["\']([^"\']*\.m3u8[^"\']*)["\']', html, re.IGNORECASE)
225
- if m:
226
- url = m.group(1).strip()
227
- if len(url) > 20:
228
- return url
229
-
230
- # Pattern 8: source: '...m3u8' (JSON config)
231
- m = re.search(r"""['"]source['"]\s*:\s*['"]([^'"]*\.m3u8[^'"]*)['"]""", html, re.IGNORECASE)
232
- if m:
233
- url = m.group(1).strip()
234
- if len(url) > 20:
235
- return url
236
-
237
- # Pattern 9: url: '...m3u8' (generic JS config)
238
- m = re.search(r"""['"]url['"]\s*:\s*['"]([^'"]*\.m3u8[^'"]*)['"]""", html, re.IGNORECASE)
239
- if m:
240
- url = m.group(1).strip()
241
- if len(url) > 20:
242
- return url
243
-
244
- # Pattern 10: bare m3u8 URL in text
245
- m = re.search(r"(https?://[^\s\"\"'<>\\]+\.m3u8[^\s\"\"'<>\\]*)", html, re.IGNORECASE)
246
- if m:
247
- url = m.group(1).strip()
248
- if len(url) > 20:
249
- return url
250
-
251
- # Pattern 11: <video><source src="...m3u8">
252
- m = re.search(r'<source\s+[^>]*src\s*=\s*["\']([^"\']*\.m3u8[^"\']*)["\']', html, re.IGNORECASE)
253
- if m:
254
- url = m.group(1).strip()
255
- if len(url) > 20:
256
- return url
257
-
258
- # Pattern 12: href="...m3u8" (direct link)
259
- m = re.search(r'href\s*=\s*["\']([^"\']*\.m3u8[^"\']*)["\']', html, re.IGNORECASE)
260
- if m:
261
- url = m.group(1).strip()
262
- if len(url) > 20:
263
- return url
264
-
265
- # Pattern 13: iframe src="..." - follow it recursively
266
- m = re.search(r'<iframe[^>]*src\s*=\s*["\']([^"\']*)["\']', html, re.IGNORECASE)
267
- if m:
268
- iframe_url = m.group(1).strip()
269
- if iframe_url and '.php' in iframe_url and len(iframe_url) > 10:
270
- try:
271
- headers = {
272
- **UA,
273
- "Referer": "https://sv2.xemtivitop.com/",
274
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
275
- }
276
- r2 = requests.get(iframe_url, headers=headers, timeout=NORMAL_TIMEOUT, allow_redirects=True, verify=False)
277
- if r2.status_code == 200:
278
- result = extract_m3u8_aggressive(r2.text)
279
- if result:
280
- return result
281
- except:
282
- pass
283
-
284
- # Pattern 14: window.location.href or similar redirect
285
- m = re.search(r'(?:window\.)?location\s*[:=]\s*["\']([^"\']*\.m3u8[^"\']*)["\']', html, re.IGNORECASE)
286
- if m:
287
- url = m.group(1).strip()
288
- if len(url) > 20:
289
- return url
290
-
291
- # Pattern 15: decodeURIComponent/atob encoded URLs
292
- m = re.search(r'(?:decodeURIComponent|atob)\s*\(\s*["\']([A-Za-z0-9+/=]{50,})["\']', html)
293
- if m:
294
- try:
295
- import base64
296
- decoded = base64.b64decode(m.group(1)).decode('utf-8', errors='ignore')
297
- m2 = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", decoded, re.IGNORECASE)
298
- if m2:
299
- url = m2.group(1).strip()
300
- if len(url) > 20:
301
- return url
302
- except:
303
- pass
304
-
305
  return None
 
 
306
 
307
- def verify_hls_url(url, referer="https://vtvgo.vn/", timeout=FAST_TIMEOUT):
308
- """Quickly verify a URL returns valid HLS content"""
309
- if not url:
310
- return None
311
  try:
312
- headers = {
313
- **UA,
314
- "Referer": referer,
315
- "Origin": referer.rstrip("/"),
316
- "Accept": "*/*",
317
- }
318
- r = requests.get(url, headers=headers, timeout=timeout, allow_redirects=True, verify=False)
319
- if r.status_code == 200 and '#EXTM3U' in r.text[:500]:
320
- return url
321
- except:
322
- pass
323
  return None
324
 
325
- def fetch_url_with_follow(php_url, referer, timeout=NORMAL_TIMEOUT):
326
- """Fetch a PHP page, follow redirects, parse html deeply."""
327
- try:
328
- headers = {
329
- **UA,
330
- "Referer": referer,
331
- }
332
- r = requests.get(php_url, headers=headers, timeout=timeout, allow_redirects=True, verify=False)
333
- if r.status_code == 200:
334
- # Check redirect chain first
335
- if r.history:
336
- for resp in reversed(r.history):
337
- loc = resp.headers.get('Location', '')
338
- if '.m3u8' in loc and len(loc) > 20:
339
- return loc, "redirect"
340
- # Aggressive extraction
341
- m3u8 = extract_m3u8_aggressive(r.text)
342
- if m3u8:
343
- return m3u8, "extracted"
344
- # Try multiple iframe following (depth 2)
345
- soup = BeautifulSoup(r.text, 'html.parser')
346
- seen_iframes = set()
347
- for _ in range(3): # follow iframe chain up to 3 levels
348
- iframe = soup.find('iframe', src=True)
349
- if not iframe:
350
- break
351
- src = iframe.get('src', '').strip()
352
- if not src or src in seen_iframes:
353
- break
354
- seen_iframes.add(src)
355
- if not src.startswith('http'):
356
- # Resolve relative URL
357
- from urllib.parse import urljoin
358
- src = urljoin(php_url, src)
359
- try:
360
- headers2 = {**UA, "Referer": referer}
361
- r2 = requests.get(src, headers=headers2, timeout=NORMAL_TIMEOUT, allow_redirects=True, verify=False)
362
- if r2.status_code == 200:
363
- m3u8 = extract_m3u8_aggressive(r2.text)
364
- if m3u8:
365
- return m3u8, "iframe_follow"
366
- soup = BeautifulSoup(r2.text, 'html.parser')
367
- except:
368
- break
369
- return None, "not_found"
370
- except Exception as e:
371
- return None, f"error:{str(e)[:50]}"
372
-
373
- def fetch_vtv_stream(channel_id):
374
- channel_id = channel_id.lower().strip()
375
- name_map = {
376
- 'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
377
- 'vtv_can_tho': 'vtv10', 'cantho': 'vtv10',
378
- 'vietnam_vtv1': 'vtv1', 'vietnam_vtv2': 'vtv2', 'vietnam_vtv3': 'vtv3',
379
- 'vietnam_vtv4': 'vtv4', 'vietnam_vtv5': 'vtv5', 'vietnam_vtv6': 'vtv6',
380
- 'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
381
- }
382
- channel_id = name_map.get(channel_id, channel_id)
383
-
384
- # Check cache
385
- cached = _cached(channel_id)
386
- if cached is not None:
387
- return cached
388
-
389
- result = None
390
-
391
- # ===== PRIORITY 1: VTV Digital CDN (ssaimh) - only for channels known to work =====
392
- if channel_id in VTV_DIGITAL_SSAIMH_URLS and channel_id in SSAIMH_WORKING_CHANNELS:
393
- result = verify_hls_url(VTV_DIGITAL_SSAIMH_URLS[channel_id], "https://vtvgo.vn/", timeout=FAST_TIMEOUT)
394
-
395
- # ===== PRIORITY 2: vtvprime uses xemtv.us =====
396
- if not result and channel_id == 'vtvprime':
397
- url, _ = fetch_url_with_follow(XEMTV_US_ENDPOINTS.get(channel_id, ""), "https://xemtv.us/")
398
- result = verify_hls_url(url, timeout=NORMAL_TIMEOUT) if url else None
399
- if not result:
400
- url, _ = fetch_url_with_follow(SV2_XEMTIVITOP_ENDPOINTS.get(channel_id, ""), "https://sv2.xemtivitop.com/")
401
- result = verify_hls_url(url, timeout=NORMAL_TIMEOUT) if url else None
402
- if not result:
403
- url, _ = fetch_url_with_follow(XEMTV_LEGACY_ENDPOINTS.get(channel_id, ""), "https://hd.xemtv.net/")
404
- result = verify_hls_url(url, timeout=NORMAL_TIMEOUT) if url else None
405
-
406
- # ===== PRIORITY 3: sv2.xemtivitop.com PHP (main source for VTV2/VTV3/VTV6/VTV9) =====
407
- if not result and channel_id in SV2_XEMTIVITOP_ENDPOINTS:
408
- php_url = SV2_XEMTIVITOP_ENDPOINTS[channel_id]
409
- url, method = fetch_url_with_follow(php_url, "https://sv2.xemtivitop.com/")
410
- if url:
411
- result = verify_hls_url(url, "https://sv2.xemtivitop.com/", timeout=NORMAL_TIMEOUT) if '.m3u8' in url else url
412
- if not result and url:
413
- result = url # use even if not verified (might be non-standard HLS)
414
-
415
- # ===== PRIORITY 4: xemtv.us PHP endpoints =====
416
- if not result and channel_id in XEMTV_US_ENDPOINTS:
417
- php_url = XEMTV_US_ENDPOINTS[channel_id]
418
- url, method = fetch_url_with_follow(php_url, "https://xemtv.us/")
419
- if url:
420
- result = verify_hls_url(url, "https://xemtv.us/", timeout=NORMAL_TIMEOUT) if '.m3u8' in url else url
421
- if not result and url:
422
- result = url
423
 
424
- # ===== PRIORITY 5: xemtivitop.com blogspot pages =====
425
- if not result and channel_id in XEMTIVITOP_ENDPOINTS:
426
- page_url = XEMTIVITOP_ENDPOINTS[channel_id]
 
427
  try:
428
- headers = {**UA, "Referer": "https://www.xemtivitop.com/"}
429
- r = requests.get(page_url, headers=headers, timeout=NORMAL_TIMEOUT, allow_redirects=True, verify=False)
430
- if r.status_code == 200:
431
- url = extract_m3u8_aggressive(r.text)
432
- if url:
433
- result = verify_hls_url(url, "https://www.xemtivitop.com/", timeout=NORMAL_TIMEOUT) if '.m3u8' in url else url
434
- if not result and url:
435
- result = url
436
- except:
437
- pass
438
-
439
- # ===== FALLBACK 1: FPTPlay CDN =====
440
- if not result and channel_id in FPTPLAY_URLS:
441
- result = verify_hls_url(FPTPLAY_URLS[channel_id], "https://fptplay.vn/", timeout=NORMAL_TIMEOUT)
442
-
443
- # ===== FALLBACK 2: VTVGo CDN =====
444
- if not result and channel_id in VTVGO_FAILOVER:
445
- result = verify_hls_url(VTVGO_FAILOVER[channel_id], "https://vtvgo.vn/", timeout=NORMAL_TIMEOUT)
446
-
447
- # ===== FALLBACK 3: Backup CDN sources (mediacdn) =====
448
- if not result and channel_id in BACKUP_CDN_URLS:
449
- result = verify_hls_url(BACKUP_CDN_URLS[channel_id], "https://tv.mediacdn.vn/", timeout=NORMAL_TIMEOUT)
450
-
451
- # ===== LAST RESORT: xemtv.net legacy =====
452
- if not result and channel_id in XEMTV_LEGACY_ENDPOINTS:
453
- php_url = XEMTV_LEGACY_ENDPOINTS[channel_id]
454
- url, method = fetch_url_with_follow(php_url, "https://hd.xemtv.net/")
455
- if url and '.m3u8' in url:
456
- result = url
457
-
458
- _set_cache(channel_id, result)
459
- return result
460
-
461
 
462
- # ===================== API ENDPOINTS =====================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
 
464
  @router.get("/api/vtv/streams")
465
- def api_vtv_streams():
466
- result = {}
467
- for ch_id in CHANNEL_NAMES:
468
- stream_url = fetch_vtv_stream(ch_id)
469
- result[ch_id] = {
470
- "name": CHANNEL_NAMES[ch_id],
471
- "stream_url": stream_url,
472
- "status": "ok" if stream_url else "offline"
473
- }
474
- return JSONResponse(result)
475
-
476
- @router.get("/api/vtv/stream/{channel_id}")
477
- def api_vtv_stream(channel_id: str):
478
- stream_url = fetch_vtv_stream(channel_id)
479
- if stream_url:
480
- return JSONResponse({"stream_url": stream_url, "status": "ok"})
481
- return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
482
-
483
- @router.get("/api/proxy/page")
484
- def proxy_page(url: str = Query(...)):
485
- try:
486
- headers = {**UA}
487
- if "xemtv.us" in url: headers["Referer"] = "https://xemtv.us/"
488
- elif "xemtv.net" in url: headers["Referer"] = "https://hd.xemtv.net/"
489
- elif "sv2.xemtivitop.com" in url: headers["Referer"] = "https://sv2.xemtivitop.com/"
490
- elif "xemtivitop.com" in url: headers["Referer"] = "https://www.xemtivitop.com/"
491
- r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
492
- if r.status_code != 200:
493
- return Response(status_code=502, content="upstream error")
494
- return Response(
495
- content=r.text.encode("utf-8"),
496
- media_type="text/html; charset=utf-8",
497
- headers={"Access-Control-Allow-Origin": "*"}
498
- )
499
- except Exception as e:
500
- return Response(status_code=502, content=f"proxy error: {str(e)[:100]}")
501
 
502
  @router.get("/api/proxy/m3u8/vtv")
503
- def proxy_vtv_m3u8(url: str = Query(...)):
504
  try:
505
- headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
506
- if "fptplay" in url:
507
- headers["Referer"] = "https://fptplay.vn/"
508
- headers["Origin"] = "https://fptplay.vn"
509
- elif "xemtv" in url:
510
- headers["Referer"] = "https://xemtv.us/"
511
- elif "vtvdigital" in url or "vtvgo" in url:
512
- headers["Referer"] = "https://vtvgo.vn/"
513
- elif "sv2.xemtivitop" in url:
514
- headers["Referer"] = "https://sv2.xemtivitop.com/"
515
- elif "mediacdn" in url:
516
- headers["Referer"] = "http://tv.mediacdn.vn/"
517
- r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
518
- if r.status_code != 200:
519
- return Response(status_code=502, content="upstream error")
520
- content = r.text
521
- lines = content.split('\n')
522
- base_url = url.rsplit('/', 1)[0] + '/' if '/' in url else url
523
- rewritten = []
524
- for line in lines:
525
- line = line.strip()
526
- if not line or line.startswith('#'):
527
- rewritten.append(line)
528
  else:
529
- seg_url = line if line.startswith('http') else base_url + line
530
- if seg_url.endswith('.m3u8'):
531
- rewritten.append("/api/proxy/m3u8/vtv?url=" + requests.utils.quote(seg_url, safe=""))
532
- else:
533
- rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
534
- return Response(
535
- content='\n'.join(rewritten).encode("utf-8"),
536
- media_type="application/vnd.apple.mpegurl",
537
- headers={
538
- "Access-Control-Allow-Origin": "*",
539
- "Cache-Control": "no-cache"
540
- }
541
- )
542
- except Exception as e:
543
- return Response(status_code=502, content=f"proxy error: {str(e)[:100]}")
544
 
545
  @router.get("/api/proxy/seg/vtv")
546
- def proxy_vtv_segment(url: str = Query(...)):
547
  try:
548
- headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
549
- if "fptplay" in url:
550
- headers["Referer"] = "https://fptplay.vn/"
551
- headers["Origin"] = "https://fptplay.vn"
552
- elif "mediacdn" in url:
553
- headers["Referer"] = "http://tv.mediacdn.vn/"
554
- r = requests.get(url, headers=headers, timeout=30, allow_redirects=True, verify=False)
555
- if r.status_code != 200:
556
- return Response(status_code=502, content="upstream error")
557
- data = r.content
558
- # Strip PNG wrapper if present (some CDNs wrap MPEG-TS in PNG)
559
- if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
560
- data = data[188:]
561
- return Response(
562
- content=data,
563
- media_type="video/mp2t",
564
- headers={
565
- "Access-Control-Allow-Origin": "*",
566
- "Cache-Control": "public, max-age=3600"
567
- }
568
- )
569
- except:
570
- return Response(status_code=502, content="proxy error")
571
-
572
-
573
- # ===================== EPG =====================
574
-
575
- _epg_cache = {}
576
- _epg_cache_time = 0
577
- _EPG_CACHE_TTL = 600
578
-
579
- VTV_CHANNEL_MAP = {
580
- "vtv1": "vtv1", "vtv2": "vtv2", "vtv3": "vtv3", "vtv4": "vtv4",
581
- "vtv5": "vtv5", "vtv5-tay-nam-bo": "vtv5", "vtv5-tay-nguyen": "vtv5",
582
- "vtv7": "vtv7", "vtv8": "vtv8", "vtv6": "vtv6", "vtv9": "vtv9",
583
- "vtv-can-tho": "vtv10",
584
- }
585
-
586
- def _parse_time(time_str, reference_date=None):
587
- if not time_str:
588
- return None
589
- time_str = time_str.strip().replace("h", ":").replace("H", ":")
590
- m = re.search(r'(\d{1,2}):(\d{2})', time_str)
591
- if m:
592
- try:
593
- hour, minute = int(m.group(1)), int(m.group(2))
594
- base_date = reference_date or datetime.now(VN_TZ)
595
- if hour < 5:
596
- tv_date = base_date - timedelta(days=1) if base_date.hour >= 5 else base_date
597
- else:
598
- tv_date = base_date
599
- result = tv_date.replace(hour=hour, minute=minute, second=0, microsecond=0)
600
- if result.tzinfo is None:
601
- result = result.replace(tzinfo=VN_TZ)
602
- return result
603
- except:
604
- pass
605
- return None
606
-
607
- def _fetch_epg_from_vtv():
608
- global _epg_cache, _epg_cache_time
609
- now_ts = time.time()
610
- if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL:
611
- return _epg_cache
612
-
613
- epg_data = {}
614
- now_vn = datetime.now(VN_TZ)
615
-
616
  try:
617
- headers = {
618
- "User-Agent": UA["User-Agent"],
619
- "Accept-Language": "vi-VN,vi;q=0.9",
620
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
621
- "Referer": "https://vtv.vn/",
622
- }
623
- r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=headers, timeout=20)
624
- if r.status_code != 200:
625
- return epg_data
626
- r.encoding = "utf-8"
627
- soup = BeautifulSoup(r.text, "lxml")
628
-
629
- # Extract channel order from navigation
630
- channel_order = []
631
- list_channel = soup.find(class_=re.compile(r'list-channel'))
632
- if list_channel:
633
- for link in list_channel.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
634
- ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
635
- if ch_id:
636
- channel_order.append(ch_id.group(1))
637
-
638
- if not channel_order:
639
- for link in soup.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
640
- ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
641
- if ch_id and ch_id.group(1) not in channel_order:
642
- channel_order.append(ch_id.group(1))
643
-
644
- prog_containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b'))
645
- for i, container in enumerate(prog_containers):
646
- if i >= len(channel_order):
647
- break
648
- vtv_ch_id = channel_order[i]
649
- our_ch_id = VTV_CHANNEL_MAP.get(vtv_ch_id, vtv_ch_id)
650
- if our_ch_id not in epg_data:
651
- epg_data[our_ch_id] = []
652
-
653
- for li in container.find_all('li', class_=re.compile(r'\bprogram\b')):
654
- time_span = li.find('span', class_=re.compile(r'\btime\b'))
655
- title_span = li.find('span', class_=re.compile(r'\btitle\b'))
656
- genre_a = li.find('a', class_=re.compile(r'\bgenre\b'))
657
- time_str = time_span.get_text(strip=True) if time_span else ""
658
- title = ""
659
- if genre_a:
660
- title = genre_a.get_text(strip=True)
661
- if not title and title_span:
662
- title = title_span.get_text(strip=True)
663
- if not time_str or not title:
664
- continue
665
-
666
- start_dt = _parse_time(time_str, reference_date=now_vn)
667
- if not start_dt:
668
- continue
669
-
670
- epg_data[our_ch_id].append({
671
- "time": time_str[:5],
672
- "title": title[:80],
673
- "start_dt": start_dt
674
- })
675
-
676
- # Sort and deduplicate programmes
677
- for ch_id in epg_data:
678
- epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min)
679
- seen = set()
680
- unique = []
681
- for p in epg_data[ch_id]:
682
- key = (p["time"], p["title"])
683
- if key not in seen:
684
- seen.add(key)
685
- unique.append(p)
686
- epg_data[ch_id] = unique
687
-
688
- except Exception as e:
689
- print(f"EPG vtv.vn error: {e}")
690
-
691
- _epg_cache = epg_data
692
- _epg_cache_time = now_ts
693
- return epg_data
694
-
695
- def _get_epg_for_channel(channel_id):
696
- epg_data = _fetch_epg_from_vtv()
697
- programmes = epg_data.get(channel_id, [])
698
- if not programmes:
699
- return []
700
-
701
- now = datetime.now(VN_TZ)
702
- today = now.date()
703
- result = []
704
-
705
- today_programmes = [p for p in programmes if p.get("start_dt") and p["start_dt"].date() == today]
706
- if not today_programmes:
707
- today_programmes = programmes
708
-
709
- for i, p in enumerate(today_programmes):
710
- start_dt = p.get("start_dt")
711
- stop_dt = today_programmes[i + 1].get("start_dt") if i + 1 < len(today_programmes) else None
712
- is_now = bool(start_dt and ((stop_dt and start_dt <= now < stop_dt) or start_dt <= now))
713
- end_time = stop_dt.strftime("%H:%M") if stop_dt else ""
714
- result.append({
715
- "time": p["time"],
716
- "title": p["title"],
717
- "end_time": end_time,
718
- "now": is_now
719
- })
720
-
721
- return result
722
-
723
- @router.get("/api/vtv/epg/{channel_id}")
724
- def api_vtv_epg(channel_id: str):
725
- channel_id = channel_id.lower().strip()
726
- if channel_id not in CHANNEL_NAMES:
727
- return JSONResponse({"error": "channel not found"}, status_code=404)
728
- programs = _get_epg_for_channel(channel_id)
729
- return JSONResponse({
730
- "channel": channel_id,
731
- "channel_name": CHANNEL_NAMES.get(channel_id, channel_id),
732
- "date": datetime.now(VN_TZ).strftime("%Y-%m-%d"),
733
- "programs": programs,
734
- })
735
-
736
- @router.get("/api/vtv/epg")
737
- def api_vtv_epg_refresh():
738
- global _epg_cache, _epg_cache_time
739
- _epg_cache = {}
740
- _epg_cache_time = 0
741
- epg_data = _fetch_epg_from_vtv()
742
- return JSONResponse({
743
- "status": "refreshed",
744
- "channels": len(epg_data),
745
- "total_programmes": sum(len(v) for v in epg_data)
746
- })
 
1
  """
2
+ VTV Channels API - Backend for VTV1-VTV10
3
+ Sources: iptv-org/iptv playlist -> longhai/iptv -> FPTPlay CDN -> blogspot -> ssaimh -> VTVGo -> mediacdn
 
 
 
 
 
 
 
 
 
4
  """
5
+ import re,time,threading,requests
6
+ from fastapi import APIRouter,Query
7
+ from fastapi.responses import JSONResponse,Response
8
+ from datetime import datetime
9
+ from concurrent.futures import ThreadPoolExecutor,as_completed
10
+
11
+ router=APIRouter()
12
+ UA={"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
13
+
14
+ FPT={"1":"https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8","2":"https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8","3":"https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8","4":"https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8","5":"https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8","6":"https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8","7":"https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8","8":"https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8","9":"https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8","10":"https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8"}
15
+ SSA={"1":"https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv1/master.m3u8","4":"https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv4/master.m3u8","5":"https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv5/master.m3u8","7":"https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv7/master.m3u8","8":"https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv8/master.m3u8","10":"https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv6/master.m3u8"}
16
+ VGO={"2":"https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8","3":"https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8","6":"https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8","9":"https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8"}
17
+ MED={"2":"http://tv.mediacdn.vn/live/hls/vtv2.m3u8","3":"http://tv.mediacdn.vn/live/hls/vtv3.m3u8","6":"http://tv.mediacdn.vn/live/hls/vtv6.m3u8","9":"http://tv.mediacdn.vn/live/hls/vtv9.m3u8"}
18
+ BLG={"2":"https://www.xemtivitop.com/2018/10/vtv2-online.html?m=1","3":"https://www.xemtivitop.com/2018/10/vtv3-online.html?m=1","6":"https://www.xemtivitop.com/2018/10/vtv6-online.html?m=1","9":"https://www.xemtivitop.com/2018/10/vtv9-online.html?m=1"}
19
+ PL={"o":"https://raw.githubusercontent.com/iptv-org/iptv/master/streams/vn.m3u","h":"https://raw.githubusercontent.com/longhai/iptv/master/vtvgo.m3u"}
20
+ CH={"vtv1":"VTV1","vtv2":"VTV2","vtv3":"VTV3","vtv4":"VTV4","vtv5":"VTV5","vtv6":"VTV6","vtv7":"VTV7","vtv8":"VTV8","vtv9":"VTV9","vtv10":"VTV10"}
21
+ SID={"vtv1":"1","vtv2":"2","vtv3":"3","vtv4":"4","vtv5":"5","vtv6":"6","vtv7":"7","vtv8":"8","vtv9":"9","vtv10":"10"}
22
+
23
+ _cache={};_lock=threading.Lock();_TTL=300
24
+ def _gc(k):
25
+ with _lock:
26
+ if k in _cache and time.time()-_cache[k]['t']<_TTL: return _cache[k]['d']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  return None
28
+ def _sc(k,d):
29
+ with _lock: _cache[k]={'t':time.time(),'d':d}
30
 
31
+ def _v(u,ref="https://vtvgo.vn/",t=5):
32
+ if not u: return None
 
 
33
  try:
34
+ r=requests.get(u,headers={**UA,"Referer":ref,"Accept":"*/*"},timeout=t)
35
+ if r.status_code==200 and ('#EXTM3U' in r.text[:500] or '#EXT-X-STREAM-INF' in r.text[:500]): return u
36
+ except: pass
 
 
 
 
 
 
 
 
37
  return None
38
 
39
+ def _ex(h):
40
+ if not h: return None
41
+ h=re.sub(r'<!--.*?-->','',h,flags=re.DOTALL)
42
+ for p in [r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]",r'src\s*=\s*["\']([^"\']*\.m3u8[^"\']*)["\']",r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)']:
43
+ m=re.search(p,h,re.IGNORECASE)
44
+ if m:
45
+ u=m.group(1).strip()
46
+ if len(u)>20: return u
47
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
+ def _pl():
50
+ u={}
51
+ def f(name,url):
52
+ r=[]
53
  try:
54
+ resp=requests.get(url,headers=UA,timeout=15)
55
+ if resp.status_code!=200: return r
56
+ for l in resp.text.split('\n'):
57
+ l=l.strip()
58
+ if l.startswith('http') and '.m3u8' in l:
59
+ c=l.split('|')[0].strip()
60
+ for k in CH:
61
+ if k in c.lower(): r.append((k,c));break
62
+ except: pass
63
+ return r
64
+ with ThreadPoolExecutor(2) as ex:
65
+ for fu in as_completed({ex.submit(f,n,url):n for n,url in PL.items()},20):
66
+ for k,v in fu.result():
67
+ if k not in u: u[k]=v
68
+ return u
69
+
70
+ def _bl(ch):
71
+ u=BLG.get(SID.get(ch))
72
+ if not u: return None
73
+ try:
74
+ r=requests.get(u,headers={**UA,"Referer":"https://www.xemtivitop.com/"},timeout=12)
75
+ if r.status_code==200: return _ex(r.text)
76
+ except: pass
77
+ return None
 
 
 
 
 
 
 
 
 
78
 
79
+ def fetch(ch):
80
+ ch=ch.lower().strip()
81
+ m={'vtvct':'vtv10','vtv-can-tho':'vtv10','cantho':'vtv10'}
82
+ ch=m.get(ch,ch)
83
+ if ch not in CH: return None
84
+ c=_gc(ch)
85
+ if c is not None: return c
86
+ r=None;s=SID.get(ch)
87
+ if not r:
88
+ pl=_pl()
89
+ if ch in pl: r=_v(pl[ch],t=10)
90
+ if not r and s in FPT: r=_v(FPT[s],"https://fptplay.vn/",8)
91
+ if not r and ch in BLG:
92
+ u=_bl(ch)
93
+ if u: r=_v(u,t=10)
94
+ if not r and s in SSA: r=_v(SSA[s],t=5)
95
+ if not r and s in VGO: r=_v(VGO[s],"https://vtvgo.vn/",8)
96
+ if not r and s in MED: r=_v(MED[s],"https://tv.mediacdn.vn/",8)
97
+ _sc(ch,r)
98
+ return r
99
 
100
  @router.get("/api/vtv/streams")
101
+ def api_s():
102
+ res={}
103
+ with ThreadPoolExecutor(len(CH)) as ex:
104
+ futs={ex.submit(fetch,c):c for c in CH}
105
+ for f in as_completed(futs,60):
106
+ c=futs[f];v=f.result();res[c]={"name":CH[c],"stream_url":v,"status":"ok" if v else "offline"}
107
+ return JSONResponse(res)
108
+
109
+ @router.get("/api/vtv/stream/{ch}")
110
+ def api_s2(ch:str):
111
+ u=fetch(ch)
112
+ if u: return JSONResponse({"stream_url":u,"status":"ok"})
113
+ return JSONResponse({"error":"stream not found","status":"offline"},404)
114
+
115
+ @router.get("/api/vtv/debug/{ch}")
116
+ def api_d(ch:str):
117
+ ch=ch.lower().strip()
118
+ if ch not in CH: return JSONResponse({"error":"not found"},404)
119
+ s=SID.get(ch);res={}
120
+ if s in FPT: res["fptplay"]=str(_v(FPT[s],"https://fptplay.vn/",8))
121
+ if s in SSA: res["ssaimh"]=str(_v(SSA[s],t=5))
122
+ res["blogspot"]=_bl(ch) or "failed"
123
+ if s in VGO: res["vtvgo"]=str(_v(VGO[s],"https://vtvgo.vn/",8))
124
+ if s in MED: res["mediacdn"]=str(_v(MED[s],"https://tv.mediacdn.vn/",8))
125
+ pl=_pl();res["iptv_playlist"]=pl.get(ch,"not_found")
126
+ return JSONResponse({"channel":ch,"results":res})
 
 
 
 
 
 
 
 
 
 
127
 
128
  @router.get("/api/proxy/m3u8/vtv")
129
+ def pm(url:str=Query(...)):
130
  try:
131
+ h={"User-Agent":UA["User-Agent"],"Accept":"*/*"}
132
+ if "fptplay" in url: h["Referer"]="https://fptplay.vn/"
133
+ r=requests.get(url,headers=h,timeout=15)
134
+ if r.status_code!=200: return Response(status_code=502,content="upstream error")
135
+ base=url.rsplit('/',1)[0]+'/';out=[]
136
+ for l in r.text.split('\n'):
137
+ l=l.strip()
138
+ if not l or l.startswith('#'): out.append(l)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  else:
140
+ s=l if l.startswith('http') else base+l
141
+ q=requests.utils.quote(s,safe="")
142
+ out.append(f"/api/proxy/seg/vtv?url={q}" if not s.endswith('.m3u8') else f"/api/proxy/m3u8/vtv?url={q}")
143
+ return Response(content='\n'.join(out).encode(),media_type="application/vnd.apple.mpegurl",headers={"Access-Control-Allow-Origin":"*","Cache-Control":"no-cache"})
144
+ except Exception as e: return Response(status_code=502,content=f"error: {str(e)[:100]}")
 
 
 
 
 
 
 
 
 
 
145
 
146
  @router.get("/api/proxy/seg/vtv")
147
+ def ps(url:str=Query(...)):
148
  try:
149
+ h={"User-Agent":UA["User-Agent"],"Accept":"*/*"}
150
+ r=requests.get(url,headers=h,timeout=30)
151
+ if r.status_code!=200: return Response(status_code=502,content="upstream error")
152
+ d=r.content
153
+ if len(d)>188 and d[0:4]==b'\x89PNG' and d[188]==0x47: d=d[188:]
154
+ return Response(content=d,media_type="video/mp2t",headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public,max-age=3600"})
155
+ except: return Response(status_code=502,content="proxy error")
156
+
157
+ @router.get("/api/vtv/epg/{ch}")
158
+ def ae(ch:str):
159
+ ch=ch.lower().strip()
160
+ if ch not in CH: return JSONResponse({"error":"not found"},404)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  try:
162
+ r=requests.get("https://vtv.vn/lich-phat-song.htm",headers={**UA,"Referer":"https://vtv.vn/"},timeout=20)
163
+ if r.status_code!=200: return JSONResponse({"programs":[]})
164
+ from bs4 import BeautifulSoup
165
+ s=BeautifulSoup(r.text,"html.parser");progs=[]
166
+ for ul in s.find_all('ul',class_=re.compile(r'programs')):
167
+ for li in ul.find_all('li',class_=re.compile(r'program')):
168
+ ts=li.find('span',class_=re.compile(r'time'))
169
+ tl=li.find('span',class_=re.compile(r'title'))
170
+ if ts and tl: progs.append({"time":ts.get_text(strip=True)[:5],"title":tl.get_text(strip=True)[:80]})
171
+ return JSONResponse({"channel":ch,"channel_name":CH.get(ch,ch),"date":datetime.now().strftime("%Y-%m-%d"),"programs":progs})
172
+ except: return JSONResponse({"error":"epg failed"},502)