bep40 commited on
Commit
495756e
·
verified ·
1 Parent(s): 7c6bbda

Upload vtv_api.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. vtv_api.py +463 -0
vtv_api.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
3
+ Fetches stream URLs from hd.xemtv.net PHP endpoints
4
+ EPG data scraped from https://vtv.vn/lich-phat-song.htm
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
+ from datetime import datetime, timedelta
12
+
13
+ router = APIRouter()
14
+
15
+ UA = {
16
+ "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",
17
+ "Accept-Language": "vi-VN,vi;q=0.9",
18
+ "Referer": "https://hd.xemtv.net/",
19
+ }
20
+
21
+ XEMTV_PHP_ENDPOINTS = {
22
+ "vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
23
+ "vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
24
+ "vtv3": "https://hd.xemtv.net/kenh/vtv3.php",
25
+ "vtv4": "https://hd.xemtv.net/kenh/vtv4.php",
26
+ "vtv5": "https://hd.xemtv.net/kenh/vtv5.php",
27
+ "vtv6": "https://hd.xemtv.net/kenh/vtv6.php",
28
+ "vtv7": "https://hd.xemtv.net/kenh/vtv7.php",
29
+ "vtv8": "https://hd.xemtv.net/kenh/vtv8.php",
30
+ "vtv9": "https://hd.xemtv.net/kenh/vtv9.php",
31
+ "vtv10": "https://hd.xemtv.net/kenh/vtv10.php",
32
+ "vtvprime": "https://hd.xemtv.net/kenh/vtvprime.php",
33
+ }
34
+
35
+ CHANNEL_NAMES = {
36
+ "vtv1": "VTV1",
37
+ "vtv2": "VTV2",
38
+ "vtv3": "VTV3",
39
+ "vtv4": "VTV4",
40
+ "vtv5": "VTV5",
41
+ "vtv6": "VTV6",
42
+ "vtv7": "VTV7",
43
+ "vtv8": "VTV8",
44
+ "vtv9": "VTV9",
45
+ "vtv10": "VTV10",
46
+ "vtvprime": "VTVPrime",
47
+ }
48
+
49
+ VTVGO_FAILOVER = {
50
+ "vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
51
+ "vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
52
+ "vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
53
+ "vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
54
+ "vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
55
+ "vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
56
+ "vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
57
+ "vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
58
+ }
59
+
60
+ XEMTV_ONLY_CHANNELS = {"vtv6", "vtv10"}
61
+
62
+ FPTPLAY_URLS = {
63
+ "vtv1": "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8",
64
+ "vtv2": "https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8",
65
+ "vtv3": "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8",
66
+ "vtv4": "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8",
67
+ "vtv5": "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8",
68
+ "vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/vtv6-avc1_5600000=10000-mp4a_131600=20000.m3u8",
69
+ "vtv7": "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8",
70
+ "vtv8": "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8",
71
+ "vtv9": "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8",
72
+ "vtv10": "https://live.fptplay53.net/fnxch2/vtvcantho_abr.smil/chunklist.m3u8",
73
+ }
74
+
75
+ _vtv_cache = {}
76
+ _vtv_lock = threading.Lock()
77
+ _CACHE_TTL = 180
78
+
79
+ def _cached(key):
80
+ with _vtv_lock:
81
+ if key in _vtv_cache and time.time() - _vtv_cache[key]['t'] < _CACHE_TTL:
82
+ return _vtv_cache[key]['d']
83
+ return None
84
+
85
+ def _set_cache(key, data):
86
+ with _vtv_lock:
87
+ _vtv_cache[key] = {'t': time.time(), 'd': data}
88
+
89
+ def extract_m3u8_from_html(html):
90
+ if not html:
91
+ return None
92
+ m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
93
+ if m:
94
+ url = m.group(1).strip()
95
+ if len(url) > 20:
96
+ return url
97
+ m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
98
+ if m:
99
+ url = m.group(1).strip()
100
+ if len(url) > 20:
101
+ return url
102
+ return None
103
+
104
+ def fetch_xemtv_stream(channel_id):
105
+ php_url = XEMTV_PHP_ENDPOINTS.get(channel_id)
106
+ if not php_url:
107
+ return None
108
+ try:
109
+ r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True)
110
+ if r.status_code == 200:
111
+ m3u8 = extract_m3u8_from_html(r.text)
112
+ if m3u8:
113
+ return m3u8
114
+ except:
115
+ pass
116
+ return None
117
+
118
+ def fetch_fptplay_stream(channel_id):
119
+ url = FPTPLAY_URLS.get(channel_id)
120
+ if not url:
121
+ return None
122
+ try:
123
+ headers = {"User-Agent": UA["User-Agent"], "Referer": "https://fptplay.vn/", "Origin": "https://fptplay.vn"}
124
+ r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
125
+ if r.status_code == 200:
126
+ return url
127
+ except:
128
+ pass
129
+ return None
130
+
131
+ def fetch_vtv_stream(channel_id):
132
+ channel_id = channel_id.lower().strip()
133
+ name_map = {
134
+ 'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
135
+ 'vtv_can_tho': 'vtv10', 'cantho': 'vtv10',
136
+ 'vietnam_vtv1': 'vtv1', 'vietnam_vtv2': 'vtv2', 'vietnam_vtv3': 'vtv3',
137
+ 'vietnam_vtv4': 'vtv4', 'vietnam_vtv5': 'vtv5', 'vietnam_vtv6': 'vtv6',
138
+ 'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
139
+ }
140
+ channel_id = name_map.get(channel_id, channel_id)
141
+ cached = _cached(channel_id)
142
+ if cached is not None:
143
+ return cached
144
+
145
+ if channel_id in XEMTV_ONLY_CHANNELS:
146
+ xemtv_url = fetch_xemtv_stream(channel_id)
147
+ if xemtv_url:
148
+ _set_cache(channel_id, xemtv_url)
149
+ return xemtv_url
150
+ fpt_url = FPTPLAY_URLS.get(channel_id)
151
+ if fpt_url:
152
+ _set_cache(channel_id, fpt_url)
153
+ return fpt_url
154
+ _set_cache(channel_id, None)
155
+ return None
156
+
157
+ if channel_id in VTVGO_FAILOVER:
158
+ result = VTVGO_FAILOVER[channel_id]
159
+ _set_cache(channel_id, result)
160
+ return result
161
+
162
+ if channel_id == 'vtvprime':
163
+ xemtv_url = fetch_xemtv_stream('vtvprime')
164
+ if xemtv_url:
165
+ _set_cache(channel_id, xemtv_url)
166
+ return xemtv_url
167
+ _set_cache(channel_id, None)
168
+ return None
169
+
170
+ xemtv_url = fetch_xemtv_stream(channel_id)
171
+ if xemtv_url:
172
+ _set_cache(channel_id, xemtv_url)
173
+ return xemtv_url
174
+
175
+ fpt_url = fetch_fptplay_stream(channel_id)
176
+ if fpt_url:
177
+ _set_cache(channel_id, fpt_url)
178
+ return fpt_url
179
+
180
+ _set_cache(channel_id, None)
181
+ return None
182
+
183
+ @router.get("/api/vtv/streams")
184
+ def api_vtv_streams():
185
+ result = {}
186
+ for ch_id in CHANNEL_NAMES:
187
+ stream_url = fetch_vtv_stream(ch_id)
188
+ result[ch_id] = {"name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "status": "ok" if stream_url else "offline"}
189
+ return JSONResponse(result)
190
+
191
+ @router.get("/api/vtv/stream/{channel_id}")
192
+ def api_vtv_stream(channel_id: str):
193
+ stream_url = fetch_vtv_stream(channel_id)
194
+ if stream_url:
195
+ return JSONResponse({"stream_url": stream_url, "status": "ok"})
196
+ return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
197
+
198
+ @router.get("/api/proxy/page")
199
+ def proxy_page(url: str = Query(...)):
200
+ try:
201
+ headers = {**UA}
202
+ if "xemtv.net" in url:
203
+ headers["Referer"] = "https://hd.xemtv.net/"
204
+ r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
205
+ if r.status_code != 200:
206
+ return Response(status_code=502, content="upstream error")
207
+ return Response(content=r.text.encode("utf-8"), media_type="text/html; charset=utf-8", headers={"Access-Control-Allow-Origin": "*"})
208
+ except:
209
+ return Response(status_code=502, content="proxy error")
210
+
211
+ @router.get("/api/proxy/m3u8/vtv")
212
+ def proxy_vtv_m3u8(url: str = Query(...)):
213
+ try:
214
+ headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
215
+ if "fptplay" in url:
216
+ headers["Referer"] = "https://fptplay.vn/"
217
+ headers["Origin"] = "https://fptplay.vn"
218
+ elif "xemtv" in url:
219
+ headers["Referer"] = "https://hd.xemtv.net/"
220
+ r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
221
+ if r.status_code != 200:
222
+ return Response(status_code=502, content="upstream error")
223
+ content = r.text
224
+ lines = content.split('\n')
225
+ rewritten = []
226
+ base_url = url.rsplit('/', 1)[0] + '/'
227
+ for line in lines:
228
+ line = line.strip()
229
+ if not line or line.startswith('#'):
230
+ rewritten.append(line)
231
+ else:
232
+ seg_url = line
233
+ if not seg_url.startswith('http'):
234
+ seg_url = base_url + seg_url
235
+ rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
236
+ return Response(content='\n'.join(rewritten).encode("utf-8"), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache"})
237
+ except Exception as e:
238
+ return Response(status_code=502, content="proxy error: " + str(e))
239
+
240
+ @router.get("/api/proxy/seg/vtv")
241
+ def proxy_vtv_segment(url: str = Query(...)):
242
+ try:
243
+ headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
244
+ if "fptplay" in url:
245
+ headers["Referer"] = "https://fptplay.vn/"
246
+ headers["Origin"] = "https://fptplay.vn"
247
+ r = requests.get(url, headers=headers, timeout=30, allow_redirects=True)
248
+ if r.status_code != 200:
249
+ return Response(status_code=502, content="upstream error")
250
+ data = r.content
251
+ if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
252
+ data = data[188:]
253
+ return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600"})
254
+ except:
255
+ return Response(status_code=502, content="proxy error")
256
+
257
+
258
+ # ===== EPG from vtv.vn/lich-phat-song.htm =====
259
+
260
+ _epg_cache = {}
261
+ _epg_cache_time = 0
262
+ _EPG_CACHE_TTL = 1800 # 30 minutes
263
+
264
+ # Map vtv.vn channel IDs to our channel IDs
265
+ VTV_CHANNEL_MAP = {
266
+ "vtv1": "vtv1",
267
+ "vtv2": "vtv2",
268
+ "vtv3": "vtv3",
269
+ "vtv4": "vtv4",
270
+ "vtv5": "vtv5",
271
+ "vtv5-tay-nam-bo": "vtv5",
272
+ "vtv5-tay-nguyen": "vtv5",
273
+ "vtv7": "vtv7",
274
+ "vtv8": "vtv8",
275
+ "vtv9": "vtv9",
276
+ "vtv-can-tho": "vtv10",
277
+ }
278
+
279
+ def _fetch_epg_from_vtv():
280
+ """Fetch EPG from https://vtv.vn/lich-phat-song.htm"""
281
+ global _epg_cache, _epg_cache_time
282
+ now_ts = time.time()
283
+ if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL:
284
+ return _epg_cache
285
+
286
+ epg_data = {}
287
+ try:
288
+ headers = {
289
+ "User-Agent": UA["User-Agent"],
290
+ "Accept-Language": "vi-VN,vi;q=0.9",
291
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
292
+ "Referer": "https://vtv.vn/",
293
+ }
294
+ r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=headers, timeout=20)
295
+ if r.status_code != 200:
296
+ print(f"EPG vtv.vn fetch failed: {r.status_code}")
297
+ return epg_data
298
+
299
+ r.encoding = "utf-8"
300
+ soup = BeautifulSoup(r.text, "lxml")
301
+
302
+ # Get channel order from list-channel section
303
+ channel_order = []
304
+ list_channel = soup.find(class_=re.compile(r'list-channel'))
305
+ if list_channel:
306
+ for link in list_channel.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
307
+ ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
308
+ if ch_id:
309
+ channel_order.append(ch_id.group(1))
310
+
311
+ # If no list-channel found, try to find channel links elsewhere
312
+ if not channel_order:
313
+ for link in soup.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
314
+ ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
315
+ if ch_id and ch_id.group(1) not in channel_order:
316
+ channel_order.append(ch_id.group(1))
317
+
318
+ # Find all <ul class="programs"> containers
319
+ prog_containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b'))
320
+
321
+ print(f"EPG: {len(channel_order)} channels, {len(prog_containers)} program containers")
322
+
323
+ # Map each container to channel by order
324
+ for i, container in enumerate(prog_containers):
325
+ if i >= len(channel_order):
326
+ break
327
+ vtv_ch_id = channel_order[i]
328
+ our_ch_id = VTV_CHANNEL_MAP.get(vtv_ch_id, vtv_ch_id)
329
+
330
+ if our_ch_id not in epg_data:
331
+ epg_data[our_ch_id] = []
332
+
333
+ # Parse each <li class="program"> in this container
334
+ for li in container.find_all('li', class_=re.compile(r'\bprogram\b')):
335
+ time_span = li.find('span', class_=re.compile(r'\btime\b'))
336
+ title_span = li.find('span', class_=re.compile(r'\btitle\b'))
337
+ genre_a = li.find('a', class_=re.compile(r'\bgenre\b'))
338
+
339
+ time_str = time_span.get_text(strip=True) if time_span else ""
340
+ # Title: prefer genre (specific program name), fallback to title (category)
341
+ title = ""
342
+ if genre_a:
343
+ title = genre_a.get_text(strip=True)
344
+ if not title and title_span:
345
+ title = title_span.get_text(strip=True)
346
+
347
+ if not time_str or not title:
348
+ continue
349
+
350
+ # Parse time to datetime
351
+ start_dt = _parse_time(time_str)
352
+ if not start_dt:
353
+ continue
354
+
355
+ epg_data[our_ch_id].append({
356
+ "time": time_str[:5],
357
+ "title": title[:80],
358
+ "start_dt": start_dt,
359
+ })
360
+
361
+ # Sort by time and calculate end times
362
+ for ch_id in epg_data:
363
+ epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min)
364
+ # Remove duplicates
365
+ seen = set()
366
+ unique = []
367
+ for p in epg_data[ch_id]:
368
+ key = (p["time"], p["title"])
369
+ if key not in seen:
370
+ seen.add(key)
371
+ unique.append(p)
372
+ epg_data[ch_id] = unique
373
+
374
+ print(f"EPG parsed: {len(epg_data)} channels, {sum(len(v) for v in epg_data)} programmes")
375
+
376
+ except Exception as e:
377
+ print(f"EPG vtv.vn error: {e}")
378
+
379
+ _epg_cache = epg_data
380
+ _epg_cache_time = now_ts
381
+ return epg_data
382
+
383
+
384
+ def _parse_time(time_str):
385
+ """Parse HH:MM to datetime today"""
386
+ if not time_str:
387
+ return None
388
+ time_str = time_str.strip().replace("h", ":").replace("H", ":")
389
+ m = re.search(r'(\d{1,2}):(\d{2})', time_str)
390
+ if m:
391
+ try:
392
+ hour, minute = int(m.group(1)), int(m.group(2))
393
+ now = datetime.now()
394
+ return now.replace(hour=hour, minute=minute, second=0, microsecond=0)
395
+ except:
396
+ pass
397
+ return None
398
+
399
+
400
+ def _get_epg_for_channel(channel_id):
401
+ """Get EPG for a channel with current program marking."""
402
+ epg_data = _fetch_epg_from_vtv()
403
+ programmes = epg_data.get(channel_id, [])
404
+
405
+ if not programmes:
406
+ return []
407
+
408
+ now = datetime.now()
409
+ result = []
410
+ for i, p in enumerate(programmes):
411
+ start_dt = p.get("start_dt")
412
+ stop_dt = None
413
+ if i + 1 < len(programmes):
414
+ stop_dt = programmes[i + 1].get("start_dt")
415
+
416
+ is_now = False
417
+ if start_dt:
418
+ if stop_dt:
419
+ is_now = start_dt <= now < stop_dt
420
+ else:
421
+ is_now = start_dt <= now
422
+
423
+ end_time = ""
424
+ if stop_dt:
425
+ end_time = stop_dt.strftime("%H:%M")
426
+
427
+ result.append({
428
+ "time": p["time"],
429
+ "title": p["title"],
430
+ "end_time": end_time,
431
+ "now": is_now,
432
+ })
433
+
434
+ return result
435
+
436
+
437
+ @router.get("/api/vtv/epg/{channel_id}")
438
+ def api_vtv_epg(channel_id: str):
439
+ """Get EPG for a VTV channel from vtv.vn."""
440
+ channel_id = channel_id.lower().strip()
441
+ if channel_id not in CHANNEL_NAMES:
442
+ return JSONResponse({"error": "channel not found"}, status_code=404)
443
+ programs = _get_epg_for_channel(channel_id)
444
+ return JSONResponse({
445
+ "channel": channel_id,
446
+ "channel_name": CHANNEL_NAMES.get(channel_id, channel_id),
447
+ "date": datetime.now().strftime("%Y-%m-%d"),
448
+ "programs": programs,
449
+ })
450
+
451
+
452
+ @router.get("/api/vtv/epg")
453
+ def api_vtv_epg_refresh():
454
+ """Force refresh EPG cache."""
455
+ global _epg_cache, _epg_cache_time
456
+ _epg_cache = {}
457
+ _epg_cache_time = 0
458
+ epg_data = _fetch_epg_from_vtv()
459
+ return JSONResponse({
460
+ "status": "refreshed",
461
+ "channels": len(epg_data),
462
+ "total_programmes": sum(len(v) for v in epg_data),
463
+ })