bep40 commited on
Commit
080896b
·
verified ·
1 Parent(s): 78c9059

Update vtv_api.py

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