bep40 commited on
Commit
e9c6962
·
verified ·
1 Parent(s): d393750

Upload vtv_api.py

Browse files
Files changed (1) hide show
  1. vtv_api.py +363 -64
vtv_api.py CHANGED
@@ -1,13 +1,12 @@
1
- # VTV Stream Fix - Add direct CDN verification + retry logic
2
- # This patch adds verified CDN URLs that should work when sv2 obfuscation fails.
3
-
4
- import re, time, threading, json, base64, requests
5
  from fastapi import APIRouter, Query
6
  from fastapi.responses import JSONResponse, Response
7
  from bs4 import BeautifulSoup
8
  from datetime import datetime, timedelta, timezone
9
 
10
- VN_TZ = timezone(timedelta(hours=17))
11
  router = APIRouter()
12
 
13
  UA = {
@@ -20,37 +19,18 @@ UA = {
20
  "Upgrade-Insecure-Requests": "1",
21
  }
22
 
23
- # ===== CDN Sources - Optimized for stable streaming =====
24
- FPTPLAY_URLS = {
25
- "vtv1": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
26
- "vtv2": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
27
- "vtv3": "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8",
28
- "vtv4": "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
29
- "vtv5": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
30
- "vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
31
- "vtv7": "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
32
- "vtv8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
33
- "vtv9": "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
34
- "vtv10": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
35
- }
36
- VTVGO_FAILOVER = {
37
- "vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
38
- "vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
39
- "vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
40
- "vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
41
- "vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
42
- "vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
43
- "vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
44
- "vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
45
- "vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
46
- "vtv10": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv10-manifest.m3u8",
47
- }
48
- MEDIACDN_URLS = {
49
- "vtv2": "https://tv.mediacdn.vn/live/hls/vtv2.m3u8",
50
- "vtv3": "https://tv.mediacdn.vn/live/hls/vtv3.m3u8",
51
- "vtv4": "https://tv.mediacdn.vn/live/hls/vtv4.m3u8",
52
- "vtv6": "https://tv.mediacdn.vn/live/hls/vtv6.m3u8",
53
- "vtv9": "https://tv.mediacdn.vn/live/hls/vtv9.m3u8",
54
  }
55
 
56
  CHANNEL_NAMES = {
@@ -62,7 +42,7 @@ CHANNEL_NAMES = {
62
  # Cache
63
  _cache = {}
64
  _lock = threading.Lock()
65
- _CACHE_TTL = 120 # Reduced TTL for more responsive failover
66
 
67
  def _cached(k):
68
  with _lock:
@@ -73,50 +53,296 @@ def _set_cache(k, d):
73
  with _lock:
74
  _cache[k] = {'t': time.time(), 'd': d}
75
 
76
- def verify_hls(url, referer="", timeout=10):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  """Verify HLS stream is accessible and valid"""
78
- if not url: return None
 
79
  try:
80
  h = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
81
- if referer: h["Referer"] = referer
 
82
  r = requests.get(url, headers=h, timeout=timeout, allow_redirects=True, verify=False)
83
  if r.status_code == 200 and '#EXTM3U' in r.text[:500]:
84
  return url
85
- except: pass
 
86
  return None
87
 
 
88
  def fetch_vtv_stream(channel_id):
89
- """Fetch VTV stream with priority order for maximum stability"""
90
  channel_id = channel_id.lower().strip()
91
  cached = _cached(channel_id)
92
- if cached is not None: return cached
 
93
 
94
  result = None
95
 
96
- # Priority 1: FPTPlay CDN (most stable, direct HLS)
97
- if channel_id in FPTPLAY_URLS:
98
- result = verify_hls(FPTPLAY_URLS[channel_id], "https://fptplay.vn/", timeout=10)
99
- if result:
100
- _set_cache(channel_id, result)
101
- return result
102
-
103
- # Priority 2: VTVGo failover (official backup)
104
- if channel_id in VTVGO_FAILOVER:
105
- result = verify_hls(VTVGO_FAILOVER[channel_id], "https://vtvgo.vn/", timeout=10)
106
- if result:
107
- _set_cache(channel_id, result)
108
- return result
109
-
110
- # Priority 3: MediaCDN (Viettel backup)
111
- if channel_id in MEDIACDN_URLS:
112
- result = verify_hls(MEDIACDN_URLS[channel_id], "https://tv.mediacdn.vn/", timeout=10)
113
- if result:
114
- _set_cache(channel_id, result)
115
- return result
116
 
117
  _set_cache(channel_id, result)
118
  return result
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  # ===================== API ENDPOINTS =====================
121
 
122
  @router.get("/api/vtv/streams")
@@ -127,9 +353,82 @@ def api_vtv_streams():
127
  result[ch_id] = {"name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "status": "ok" if stream_url else "offline"}
128
  return JSONResponse(result)
129
 
 
130
  @router.get("/api/vtv/stream/{channel_id}")
131
  def api_vtv_stream(channel_id: str):
132
  stream_url = fetch_vtv_stream(channel_id)
133
  if stream_url:
134
  return JSONResponse({"stream_url": stream_url, "status": "ok"})
135
- return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VTV Stream Fix - sv2.xemtivitop.com ONLY source + EPG + proxy + timezone +10
2
+ # Streams from https://sv2.xemtivitop.com/live/hot/vtv1.php ... vtv10.php
3
+ import re, time, threading, json, base64, requests, urllib.parse
 
4
  from fastapi import APIRouter, Query
5
  from fastapi.responses import JSONResponse, Response
6
  from bs4 import BeautifulSoup
7
  from datetime import datetime, timedelta, timezone
8
 
9
+ VN_TZ = timezone(timedelta(hours=10)) # Timezone +10 as requested
10
  router = APIRouter()
11
 
12
  UA = {
 
19
  "Upgrade-Insecure-Requests": "1",
20
  }
21
 
22
+ # ===== SOLE SOURCE: sv2.xemtivitop.com PHP endpoints =====
23
+ SV2_ENDPOINTS = {
24
+ "vtv1": "https://sv2.xemtivitop.com/live/hot/vtv1.php",
25
+ "vtv2": "https://sv2.xemtivitop.com/live/hot/vtv2.php",
26
+ "vtv3": "https://sv2.xemtivitop.com/live/hot/vtv3.php",
27
+ "vtv4": "https://sv2.xemtivitop.com/live/hot/vtv4.php",
28
+ "vtv5": "https://sv2.xemtivitop.com/live/hot/vtv5.php",
29
+ "vtv6": "https://sv2.xemtivitop.com/live/hot/vtv6.php",
30
+ "vtv7": "https://sv2.xemtivitop.com/live/hot/vtv7.php",
31
+ "vtv8": "https://sv2.xemtivitop.com/live/hot/vtv8.php",
32
+ "vtv9": "https://sv2.xemtivitop.com/live/hot/vtv9.php",
33
+ "vtv10": "https://sv2.xemtivitop.com/live/hot/vtv10.php",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  }
35
 
36
  CHANNEL_NAMES = {
 
42
  # Cache
43
  _cache = {}
44
  _lock = threading.Lock()
45
+ _CACHE_TTL = 90 # Shorter TTL to handle obfuscation changes faster
46
 
47
  def _cached(k):
48
  with _lock:
 
53
  with _lock:
54
  _cache[k] = {'t': time.time(), 'd': d}
55
 
56
+ # ===== SV2 OBFUSCATION DECODING =====
57
+
58
+ def _extract_from_js_obfuscation(html):
59
+ """Extract m3u8 URL from sv2.xemtivitop.com PHP obfuscated JS"""
60
+ if not html:
61
+ return None
62
+
63
+ urls = []
64
+
65
+ # Method 1: Direct m3u8 URL in HTML (sometimes they just embed it)
66
+ for m in re.finditer(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', html):
67
+ url = m.group(1).strip().rstrip('.,;)\'\"')
68
+ if len(url) > 20 and url not in urls:
69
+ urls.append(url)
70
+
71
+ # Method 2: file: "..." pattern (jwplayer/flowplayer)
72
+ for m in re.finditer(r"""['"]file['"]\s*:\s*['"]([^'"]*\.m3u8[^'"]*)['"]""", html, re.IGNORECASE):
73
+ url = m.group(1).strip()
74
+ if url.startswith('//'):
75
+ url = 'https:' + url
76
+ if len(url) > 20 and url not in urls:
77
+ urls.append(url)
78
+
79
+ # Method 3: src: "..." or source: "..."
80
+ for m in re.finditer(r"""['"]src['"]\s*:\s*['"]([^'"]*\.m3u8[^'"]*)['"]""", html, re.IGNORECASE):
81
+ url = m.group(1).strip()
82
+ if url.startswith('//'):
83
+ url = 'https:' + url
84
+ if len(url) > 20 and url not in urls:
85
+ urls.append(url)
86
+
87
+ # Method 4: atob decode patterns (base64 encoded m3u8 URL)
88
+ for m in re.finditer(r'atob\s*\(\s*["\']([A-Za-z0-9+/=]+)["\']\s*\)', html):
89
+ try:
90
+ decoded = base64.b64decode(m.group(1)).decode('utf-8', errors='ignore')
91
+ if '.m3u8' in decoded:
92
+ um = re.search(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', decoded)
93
+ if um and um.group(1) not in urls:
94
+ urls.append(um.group(1))
95
+ except:
96
+ pass
97
+
98
+ # Method 5: Handle obfuscation where URL is split/encoded
99
+ try:
100
+ scripts = re.findall(r'<script[^>]*>(.*?)</script>', html, re.DOTALL | re.IGNORECASE)
101
+ for script in scripts:
102
+ # Look for m3u8 after string manipulation
103
+ if '.m3u8' in script:
104
+ for m in re.finditer(r"""["']([^"']*\.m3u8[^"']*)["']""", script):
105
+ url = m.group(1)
106
+ if url.startswith('//'):
107
+ url = 'https:' + url
108
+ if url not in urls and len(url) > 20:
109
+ urls.append(url)
110
+
111
+ # Try String.fromCharCode(...) chains
112
+ fcc_matches = re.findall(r'String\.fromCharCode\s*\(([^)]+)\)', script)
113
+ if fcc_matches:
114
+ for fcc in fcc_matches:
115
+ try:
116
+ codes = [int(x.strip()) for x in fcc.split(',') if x.strip().isdigit()]
117
+ decoded = ''.join(chr(c) for c in codes)
118
+ if '.m3u8' in decoded:
119
+ um = re.search(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', decoded)
120
+ if um and um.group(1) not in urls:
121
+ urls.append(um.group(1))
122
+ except:
123
+ pass
124
+
125
+ # Method 6: Look for video element with source
126
+ soup = BeautifulSoup(html, 'lxml')
127
+ for vid in soup.find_all('video'):
128
+ src = vid.get('src', '')
129
+ if src and '.m3u8' in src:
130
+ if src.startswith('//'):
131
+ src = 'https:' + src
132
+ if src not in urls:
133
+ urls.append(src)
134
+ for source in vid.find_all('source'):
135
+ s = source.get('src', '')
136
+ if s and '.m3u8' in s:
137
+ if s.startswith('//'):
138
+ s = 'https:' + s
139
+ if s not in urls:
140
+ urls.append(s)
141
+
142
+ # Method 7: iframe source
143
+ for iframe in soup.find_all('iframe'):
144
+ src = iframe.get('src', '')
145
+ if src and '.m3u8' in src:
146
+ if src.startswith('//'):
147
+ src = 'https:' + src
148
+ if src not in urls:
149
+ urls.append(src)
150
+
151
+ # Method 8: "link" variable or similar JS var
152
+ for m in re.finditer(r"""['"]?link['"]?\s*[:=]\s*['"]([^'"]*)['"]""", html, re.IGNORECASE):
153
+ url = m.group(1).strip()
154
+ if '.m3u8' in url:
155
+ if url.startswith('//'):
156
+ url = 'https:' + url
157
+ if url not in urls and len(url) > 20:
158
+ urls.append(url)
159
+
160
+ # Deduplicate and return first
161
+ seen = set()
162
+ unique_urls = []
163
+ for u in urls:
164
+ u_clean = u.split('?')[0].split('#')[0]
165
+ if u_clean not in seen:
166
+ seen.add(u_clean)
167
+ unique_urls.append(u)
168
+
169
+ return unique_urls[0] if unique_urls else None
170
+
171
+
172
+ def verify_hls(url, referer="https://sv2.xemtivitop.com/", timeout=10):
173
  """Verify HLS stream is accessible and valid"""
174
+ if not url:
175
+ return None
176
  try:
177
  h = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
178
+ if referer:
179
+ h["Referer"] = referer
180
  r = requests.get(url, headers=h, timeout=timeout, allow_redirects=True, verify=False)
181
  if r.status_code == 200 and '#EXTM3U' in r.text[:500]:
182
  return url
183
+ except:
184
+ pass
185
  return None
186
 
187
+
188
  def fetch_vtv_stream(channel_id):
189
+ """Fetch VTV stream ONLY from sv2.xemtivitop.com PHP endpoints"""
190
  channel_id = channel_id.lower().strip()
191
  cached = _cached(channel_id)
192
+ if cached is not None:
193
+ return cached
194
 
195
  result = None
196
 
197
+ # ONLY SOURCE: sv2.xemtivitop.com PHP
198
+ php_url = SV2_ENDPOINTS.get(channel_id)
199
+ if php_url:
200
+ try:
201
+ h = dict(UA)
202
+ h["Referer"] = "https://sv2.xemtivitop.com/"
203
+ r = requests.get(php_url, headers=h, timeout=15, allow_redirects=True, verify=False)
204
+ if r.status_code == 200:
205
+ extracted = _extract_from_js_obfuscation(r.text)
206
+ if extracted:
207
+ verified = verify_hls(extracted, "https://sv2.xemtivitop.com/", timeout=10)
208
+ if verified:
209
+ result = verified
210
+ else:
211
+ result = extracted
212
+ except Exception as e:
213
+ print(f"[vtv_api] sv2 error for {channel_id}: {e}")
 
 
 
214
 
215
  _set_cache(channel_id, result)
216
  return result
217
 
218
+
219
+ # ===================== EPG (LICH PHAT SONG) =====================
220
+
221
+ def fetch_epg(channel_id):
222
+ """Fetch TV schedule for a VTV channel from vtv.vn"""
223
+ channel_id = channel_id.lower().strip()
224
+
225
+ epg_map = {
226
+ 'vtv1': 'vtv1', 'vtv2': 'vtv2', 'vtv3': 'vtv3', 'vtv4': 'vtv4',
227
+ 'vtv5': 'vtv5', 'vtv6': 'vtv6', 'vtv7': 'vtv7', 'vtv8': 'vtv8',
228
+ 'vtv9': 'vtv9', 'vtv10': 'vtv10', 'vtvprime': 'vtvprime',
229
+ }
230
+ epg_ch = epg_map.get(channel_id)
231
+ if not epg_ch:
232
+ return {"programs": [], "channel": channel_id, "date": ""}
233
+
234
+ today = datetime.now(VN_TZ).strftime("%Y-%m-%d")
235
+
236
+ cache_key = f"epg_{epg_ch}_{today}"
237
+ cached = _cached(cache_key)
238
+ if cached is not None:
239
+ return cached
240
+
241
+ programs = []
242
+
243
+ # Source 1: vtv.vn general schedule page
244
+ try:
245
+ h = {"User-Agent": UA["User-Agent"], "Accept-Language": "vi-VN,vi;q=0.9"}
246
+
247
+ # Try channel-specific page first
248
+ ch_url = f"https://vtv.vn/lich-phat-song-{epg_ch}.htm"
249
+ r = requests.get(ch_url, headers=h, timeout=10)
250
+ r.encoding = 'utf-8'
251
+
252
+ if r.status_code != 200:
253
+ # Fallback to main schedule
254
+ ch_url = "https://vtv.vn/lich-phat-song.htm"
255
+ r = requests.get(ch_url, headers=h, timeout=10)
256
+ r.encoding = 'utf-8'
257
+
258
+ if r.status_code == 200:
259
+ soup = BeautifulSoup(r.text, 'lxml')
260
+
261
+ # Pattern 1: JSON data in script with "time" keys
262
+ for script in soup.find_all('script'):
263
+ text = script.string or ''
264
+ if 'time' in text.lower() and ('title' in text.lower() or 'program' in text.lower()):
265
+ for m in re.finditer(r'\[.*?\]', text, re.DOTALL):
266
+ try:
267
+ data = json.loads(m.group(0))
268
+ if isinstance(data, list) and len(data) > 0:
269
+ for item in data:
270
+ if isinstance(item, dict) and 'time' in item:
271
+ programs.append({
272
+ 'time': item.get('time', ''),
273
+ 'title': item.get('title', item.get('name', item.get('program', ''))),
274
+ })
275
+ except:
276
+ pass
277
+
278
+ # Pattern 2: Tables
279
+ for table in soup.find_all('table'):
280
+ for row in table.find_all('tr'):
281
+ cells = row.find_all(['td', 'th'])
282
+ if len(cells) >= 2:
283
+ time_text = cells[0].get_text(strip=True)
284
+ title_text = cells[1].get_text(strip=True)
285
+ if re.match(r'^\d{1,2}:\d{2}', time_text) and len(title_text) >= 3:
286
+ programs.append({'time': time_text[:5], 'title': title_text})
287
+
288
+ # Pattern 3: Time-stamped text elements
289
+ for el in soup.find_all(['div', 'li', 'p', 'span', 'td']):
290
+ text = el.get_text(strip=True)
291
+ tm = re.match(r'^(\d{1,2}:\d{2})\s*[-–—:]\s*(.+)', text)
292
+ if tm and len(tm.group(2)) >= 3:
293
+ programs.append({'time': tm.group(1), 'title': tm.group(2).strip()})
294
+
295
+ # Pattern 4: Schedule blocks by class
296
+ for cls_pattern in [r'schedule', r'program', r'lich', r'epg', r'timeline', r'table']:
297
+ for el in soup.find_all(class_=re.compile(cls_pattern, re.I)):
298
+ for item in el.find_all(['li', 'div', 'p']):
299
+ text = item.get_text(strip=True)
300
+ tm = re.match(r'^(\d{1,2}:\d{2})\s*[-–—:]\s*(.+)', text)
301
+ if tm and len(tm.group(2)) >= 3:
302
+ programs.append({'time': tm.group(1), 'title': tm.group(2).strip()})
303
+
304
+ except Exception as e:
305
+ print(f"[vtv_api] EPG error for {channel_id}: {e}")
306
+
307
+ # If still no programs, try Source 2: direct API or alternative EPG source
308
+ if len(programs) < 3:
309
+ try:
310
+ # Try vtvgo.vn API
311
+ api_url = f"https://vtvgo.vn/api/schedule?channel={epg_ch}&date={today}"
312
+ r = requests.get(api_url, headers={"User-Agent": UA["User-Agent"]}, timeout=10)
313
+ if r.status_code == 200:
314
+ data = r.json()
315
+ for item in data if isinstance(data, list) else data.get('data', data.get('schedule', [])):
316
+ if isinstance(item, dict):
317
+ programs.append({
318
+ 'time': item.get('time', item.get('start_time', ''))[:5],
319
+ 'title': item.get('title', item.get('name', item.get('program', ''))),
320
+ })
321
+ except:
322
+ pass
323
+
324
+ # Deduplicate and sort
325
+ seen = set()
326
+ unique = []
327
+ for p in programs:
328
+ key = f"{p['time']}|{p['title']}"
329
+ if key not in seen:
330
+ seen.add(key)
331
+ unique.append(p)
332
+
333
+ unique.sort(key=lambda x: x['time'])
334
+
335
+ result = {
336
+ "programs": unique[:50], # Limit to 50 entries
337
+ "channel": channel_id,
338
+ "date": today,
339
+ "timezone": "+10",
340
+ }
341
+
342
+ _set_cache(cache_key, result)
343
+ return result
344
+
345
+
346
  # ===================== API ENDPOINTS =====================
347
 
348
  @router.get("/api/vtv/streams")
 
353
  result[ch_id] = {"name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "status": "ok" if stream_url else "offline"}
354
  return JSONResponse(result)
355
 
356
+
357
  @router.get("/api/vtv/stream/{channel_id}")
358
  def api_vtv_stream(channel_id: str):
359
  stream_url = fetch_vtv_stream(channel_id)
360
  if stream_url:
361
  return JSONResponse({"stream_url": stream_url, "status": "ok"})
362
+ return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
363
+
364
+
365
+ @router.get("/api/vtv/epg/{channel_id}")
366
+ def api_vtv_epg(channel_id: str):
367
+ """Get TV schedule for a VTV channel"""
368
+ channel_id = channel_id.lower().strip()
369
+ if channel_id not in CHANNEL_NAMES:
370
+ channel_id = "vtv1"
371
+ data = fetch_epg(channel_id)
372
+ return JSONResponse(data)
373
+
374
+
375
+ # ===== PROXY ENDPOINTS for VTV HLS streams =====
376
+
377
+ @router.get("/api/proxy/m3u8/vtv")
378
+ def proxy_m3u8_vtv(url: str = Query(...)):
379
+ """Proxy HLS manifest for VTV streams, rewriting segment URLs"""
380
+ try:
381
+ h = {"User-Agent": UA["User-Agent"], "Accept": "*/*", "Referer": "https://sv2.xemtivitop.com/"}
382
+ r = requests.get(url, headers=h, timeout=15, verify=False)
383
+ if r.status_code != 200:
384
+ return Response(status_code=502, content="upstream error")
385
+
386
+ base_url = url[:url.rfind('/')]
387
+ lines = r.text.strip().split('\n')
388
+ rewritten = []
389
+ for line in lines:
390
+ stripped = line.strip()
391
+ if stripped.startswith('#') or not stripped:
392
+ rewritten.append(line)
393
+ elif stripped.startswith('http'):
394
+ seg_url = stripped
395
+ rewritten.append(f"/api/proxy/seg/vtv?url={urllib.parse.quote(seg_url, safe='')}")
396
+ else:
397
+ seg_url = f"{base_url}/{stripped}"
398
+ rewritten.append(f"/api/proxy/seg/vtv?url={urllib.parse.quote(seg_url, safe='')}")
399
+
400
+ return Response(
401
+ content='\n'.join(rewritten).encode('utf-8'),
402
+ media_type="application/vnd.apple.mpegurl",
403
+ headers={
404
+ "Access-Control-Allow-Origin": "*",
405
+ "Cache-Control": "public, max-age=60",
406
+ }
407
+ )
408
+ except Exception as e:
409
+ return Response(status_code=502, content=f"proxy error: {e}")
410
+
411
+
412
+ @router.get("/api/proxy/seg/vtv")
413
+ def proxy_seg_vtv(url: str = Query(...)):
414
+ """Proxy TS segment for VTV streams"""
415
+ try:
416
+ h = {"User-Agent": UA["User-Agent"], "Accept": "*/*", "Referer": "https://sv2.xemtivitop.com/"}
417
+ r = requests.get(url, headers=h, timeout=30, verify=False)
418
+ if r.status_code != 200:
419
+ return Response(status_code=502, content="upstream error")
420
+
421
+ data = r.content
422
+ if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
423
+ data = data[188:]
424
+
425
+ return Response(
426
+ content=data,
427
+ media_type="video/mp2t",
428
+ headers={
429
+ "Access-Control-Allow-Origin": "*",
430
+ "Cache-Control": "public, max-age=3600",
431
+ }
432
+ )
433
+ except Exception as e:
434
+ return Response(status_code=502, content=f"proxy error: {e}")