Spaces:
Running
Running
Switch to YouTube embed for live TV - 100% reliable, no token needed
Browse files
main.py
CHANGED
|
@@ -15,11 +15,9 @@ BASE_24H = "https://www.24h.com.vn"
|
|
| 15 |
SPACE_URL = "https://bep40-vnews.hf.space"
|
| 16 |
_cache = {}
|
| 17 |
_cache_ttl = 300
|
| 18 |
-
|
| 19 |
-
def _cached(key, fn, ttl=None):
|
| 20 |
now=time.time()
|
| 21 |
-
|
| 22 |
-
if key in _cache and now-_cache[key]["t"]<t:return _cache[key]["d"]
|
| 23 |
try:data=fn()
|
| 24 |
except:data=_cache.get(key,{}).get("d",[])
|
| 25 |
_cache[key]={"d":data,"t":now};return data
|
|
@@ -27,117 +25,21 @@ def _get(url,headers=None):
|
|
| 27 |
h=headers or HEADERS;r=requests.get(url,headers=h,timeout=15);r.encoding="utf-8"
|
| 28 |
return BeautifulSoup(r.text,"lxml")
|
| 29 |
|
| 30 |
-
# =====
|
|
|
|
| 31 |
VTV_CHANNELS = [
|
| 32 |
-
{"id":
|
| 33 |
-
{"id":
|
| 34 |
-
{"id":
|
| 35 |
-
{"id":
|
| 36 |
-
{"id":
|
| 37 |
-
{"id":
|
| 38 |
-
{"id": 8, "name": "VTV8 HD", "slug": "vtv8", "desc": "Miền Trung · Tây Nguyên", "logo": "https://vtvgo-images.vtvdigital.vn/images/vtv_channel/thumb/vtv8.png"},
|
| 39 |
-
{"id": 9, "name": "VTV9 HD", "slug": "vtv9", "desc": "Nam Bộ · TP.HCM", "logo": "https://vtvgo-images.vtvdigital.vn/images/vtv_channel/thumb/vtv9.png"},
|
| 40 |
]
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
# Step 1: Visit channel page to get cookies/tokens
|
| 47 |
-
channel_url = f"https://vtvgo.vn/channel/vtv{channel_id}-1,{channel_id}.html"
|
| 48 |
-
headers = {
|
| 49 |
-
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
| 50 |
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 51 |
-
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
|
| 52 |
-
"Referer": "https://vtvgo.vn/",
|
| 53 |
-
}
|
| 54 |
-
r = session.get(channel_url, headers=headers, timeout=15)
|
| 55 |
-
|
| 56 |
-
# Try to find stream URL patterns in page source
|
| 57 |
-
# Pattern 1: Direct m3u8 URL
|
| 58 |
-
m3u8_urls = re.findall(r'(https?://[^\s"\'<>\\]+\.m3u8[^\s"\'<>\\]*)', r.text)
|
| 59 |
-
if m3u8_urls:
|
| 60 |
-
return m3u8_urls[0].replace('\\/', '/')
|
| 61 |
-
|
| 62 |
-
# Pattern 2: Look for token/stream in JS variables
|
| 63 |
-
# VTVgo often has: var token = "..."; var stream_url = "...";
|
| 64 |
-
token_match = re.search(r'(?:token|TOKEN)\s*[=:]\s*["\']([^"\']+)["\']', r.text)
|
| 65 |
-
url_match = re.search(r'(?:url|link|src|stream_url|hlsUrl|videoUrl)\s*[=:]\s*["\']([^"\']*m3u8[^"\']*)["\']', r.text)
|
| 66 |
-
if url_match:
|
| 67 |
-
stream = url_match.group(1).replace('\\/', '/')
|
| 68 |
-
if token_match:
|
| 69 |
-
sep = '&' if '?' in stream else '?'
|
| 70 |
-
stream += f"{sep}token={token_match.group(1)}"
|
| 71 |
-
return stream
|
| 72 |
-
|
| 73 |
-
# Pattern 3: Look for API endpoint to get stream
|
| 74 |
-
api_match = re.search(r'(?:getStream|getLink|getLive|getChannel)\s*\(\s*["\']?(\d+)', r.text)
|
| 75 |
-
if api_match:
|
| 76 |
-
# Try calling the API
|
| 77 |
-
api_urls = [
|
| 78 |
-
f"https://vtvgo.vn/ajax-get-stream?channel_id={channel_id}",
|
| 79 |
-
f"https://package.vtvgo.vn/api/v2/get_list_channel?channel_id={channel_id}",
|
| 80 |
-
]
|
| 81 |
-
for api_url in api_urls:
|
| 82 |
-
try:
|
| 83 |
-
ar = session.get(api_url, headers={**headers, "X-Requested-With": "XMLHttpRequest"}, timeout=10)
|
| 84 |
-
if ar.status_code == 200:
|
| 85 |
-
data = ar.json() if ar.headers.get('content-type','').startswith('application/json') else {}
|
| 86 |
-
stream = data.get('stream_url') or data.get('url') or data.get('link')
|
| 87 |
-
if stream:
|
| 88 |
-
return stream
|
| 89 |
-
except:
|
| 90 |
-
continue
|
| 91 |
-
|
| 92 |
-
# Pattern 4: POST-based token request (common VTVgo pattern)
|
| 93 |
-
# Look for CSRF token or data-id
|
| 94 |
-
csrf = re.search(r'name="csrf[_-]token"\s+content="([^"]+)"', r.text) or \
|
| 95 |
-
re.search(r'csrf[_-]token["\s:]+["\']([^"\']+)', r.text)
|
| 96 |
-
data_id = re.search(r'data-id\s*=\s*["\'](\d+)', r.text)
|
| 97 |
-
|
| 98 |
-
if csrf or data_id:
|
| 99 |
-
post_data = {"channel_id": channel_id, "type": "channel"}
|
| 100 |
-
if csrf:
|
| 101 |
-
post_data["_token"] = csrf.group(1)
|
| 102 |
-
try:
|
| 103 |
-
pr = session.post("https://vtvgo.vn/ajax-get-stream",
|
| 104 |
-
data=post_data,
|
| 105 |
-
headers={**headers, "X-Requested-With": "XMLHttpRequest", "Referer": channel_url},
|
| 106 |
-
timeout=10)
|
| 107 |
-
if pr.status_code == 200:
|
| 108 |
-
try:
|
| 109 |
-
jd = pr.json()
|
| 110 |
-
return jd.get('stream_url') or jd.get('url')
|
| 111 |
-
except:
|
| 112 |
-
m = re.search(r'(https?://[^\s"\'<>\\]+\.m3u8[^\s"\'<>\\]*)', pr.text)
|
| 113 |
-
if m: return m.group(1).replace('\\/', '/')
|
| 114 |
-
except:
|
| 115 |
-
pass
|
| 116 |
-
|
| 117 |
-
return None
|
| 118 |
-
except:
|
| 119 |
-
return None
|
| 120 |
-
|
| 121 |
-
def get_all_vtv_streams():
|
| 122 |
-
"""Get stream URLs for all VTV channels."""
|
| 123 |
-
channels = []
|
| 124 |
-
def _fetch(ch):
|
| 125 |
-
stream = get_vtvgo_stream(ch["id"])
|
| 126 |
-
return {**ch, "stream": stream}
|
| 127 |
-
|
| 128 |
-
with ThreadPoolExecutor(4) as ex:
|
| 129 |
-
futures = {ex.submit(_fetch, ch): ch for ch in VTV_CHANNELS}
|
| 130 |
-
for f in as_completed(futures):
|
| 131 |
-
try:
|
| 132 |
-
channels.append(f.result())
|
| 133 |
-
except:
|
| 134 |
-
channels.append({**futures[f], "stream": None})
|
| 135 |
-
|
| 136 |
-
# Sort by channel id
|
| 137 |
-
channels.sort(key=lambda x: x["id"])
|
| 138 |
-
return channels
|
| 139 |
-
|
| 140 |
-
# ===== END VTV LIVE TV =====
|
| 141 |
|
| 142 |
def scrape_vne(cat_url):
|
| 143 |
try:
|
|
@@ -373,18 +275,6 @@ def api_highlights():return JSONResponse(_cached("highlights",scrape_24h_highlig
|
|
| 373 |
def api_shorts():return JSONResponse(_cached("shorts",scrape_24h_shorts))
|
| 374 |
@app.get("/api/bdp_videos")
|
| 375 |
def api_bdp_videos():return JSONResponse(_cached("bdp_videos",scrape_bdp_videos))
|
| 376 |
-
@app.get("/api/livetv")
|
| 377 |
-
def api_livetv():
|
| 378 |
-
"""Return list of VTV live channels with stream URLs."""
|
| 379 |
-
return JSONResponse(_cached("livetv", get_all_vtv_streams, ttl=_cache_ttl_tv))
|
| 380 |
-
@app.get("/api/livetv/{channel_id}")
|
| 381 |
-
def api_livetv_channel(channel_id: int):
|
| 382 |
-
"""Get stream URL for a single VTV channel (fresh, no cache)."""
|
| 383 |
-
stream = get_vtvgo_stream(channel_id)
|
| 384 |
-
ch = next((c for c in VTV_CHANNELS if c["id"] == channel_id), None)
|
| 385 |
-
if ch:
|
| 386 |
-
return JSONResponse({**ch, "stream": stream})
|
| 387 |
-
return JSONResponse({"error": "Channel not found"}, status_code=404)
|
| 388 |
@app.get("/api/video_url")
|
| 389 |
def api_video_url(url:str=Query(...)):
|
| 390 |
if"24h.com.vn" in url:v=extract_video_url(url)
|
|
@@ -399,7 +289,6 @@ def api_article(url:str=Query(...)):
|
|
| 399 |
return JSONResponse(data if data else{"error":"not supported"})
|
| 400 |
@app.get("/v")
|
| 401 |
async def video_share(url:str=Query(default=""),title:str=Query(default="VNEWS Video"),img:str=Query(default=""),type:str=Query(default="highlights")):
|
| 402 |
-
"""Direct video share link - opens video player on VNEWS."""
|
| 403 |
og_image=unquote(img) if img else "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
|
| 404 |
decoded_url=unquote(url)
|
| 405 |
decoded_title=unquote(title)
|
|
|
|
| 15 |
SPACE_URL = "https://bep40-vnews.hf.space"
|
| 16 |
_cache = {}
|
| 17 |
_cache_ttl = 300
|
| 18 |
+
def _cached(key, fn):
|
|
|
|
| 19 |
now=time.time()
|
| 20 |
+
if key in _cache and now-_cache[key]["t"]<_cache_ttl:return _cache[key]["d"]
|
|
|
|
| 21 |
try:data=fn()
|
| 22 |
except:data=_cache.get(key,{}).get("d",[])
|
| 23 |
_cache[key]={"d":data,"t":now};return data
|
|
|
|
| 25 |
h=headers or HEADERS;r=requests.get(url,headers=h,timeout=15);r.encoding="utf-8"
|
| 26 |
return BeautifulSoup(r.text,"lxml")
|
| 27 |
|
| 28 |
+
# ===== LIVE TV - YouTube channels (official VTV) =====
|
| 29 |
+
# These are official VTV YouTube channels that livestream 24/7
|
| 30 |
VTV_CHANNELS = [
|
| 31 |
+
{"id":"vtv1","name":"VTV1","desc":"Thời sự · Chính trị","yt_channel":"UCRLKY3loGMTmFLGO0BWQVeg","logo":"https://vtvgo-images.vtvdigital.vn/images/vtv_channel/thumb/vtv1.png"},
|
| 32 |
+
{"id":"vtv24","name":"VTV24","desc":"Tin tức 24h","yt_channel":"UCabsTV34JwALXKGMqHpvUiA","logo":"https://vtvgo-images.vtvdigital.vn/images/vtv_channel/thumb/vtv1.png"},
|
| 33 |
+
{"id":"vtv3","name":"VTV3","desc":"Giải trí · Thể thao","yt_channel":"UCMcbSjRHU5kw4nWnUz3CXyw","logo":"https://vtvgo-images.vtvdigital.vn/images/vtv_channel/thumb/vtv3.png"},
|
| 34 |
+
{"id":"vtvgo","name":"VTV Go","desc":"Tổng hợp VTV","yt_channel":"UCz2hUAOAOBfFTm7x0OTzxUQ","logo":"https://vtvgo-images.vtvdigital.vn/images/vtv_channel/thumb/vtv3.png"},
|
| 35 |
+
{"id":"thoisuvtv","name":"Thời Sự VTV","desc":"Chương trình Thời sự","yt_channel":"UCp4jnEEmOLEIg3bR3qCBwKg","logo":"https://vtvgo-images.vtvdigital.vn/images/vtv_channel/thumb/vtv1.png"},
|
| 36 |
+
{"id":"vtvthethao","name":"VTV Thể Thao","desc":"Bóng đá · Thể thao","yt_channel":"UCwa4OajMFekfVeHOS0F6bMg","logo":"https://vtvgo-images.vtvdigital.vn/images/vtv_channel/thumb/vtv6.png"},
|
|
|
|
|
|
|
| 37 |
]
|
| 38 |
|
| 39 |
+
@app.get("/api/livetv")
|
| 40 |
+
def api_livetv():
|
| 41 |
+
"""Return list of live TV channels (YouTube-based)."""
|
| 42 |
+
return JSONResponse(VTV_CHANNELS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
def scrape_vne(cat_url):
|
| 45 |
try:
|
|
|
|
| 275 |
def api_shorts():return JSONResponse(_cached("shorts",scrape_24h_shorts))
|
| 276 |
@app.get("/api/bdp_videos")
|
| 277 |
def api_bdp_videos():return JSONResponse(_cached("bdp_videos",scrape_bdp_videos))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
@app.get("/api/video_url")
|
| 279 |
def api_video_url(url:str=Query(...)):
|
| 280 |
if"24h.com.vn" in url:v=extract_video_url(url)
|
|
|
|
| 289 |
return JSONResponse(data if data else{"error":"not supported"})
|
| 290 |
@app.get("/v")
|
| 291 |
async def video_share(url:str=Query(default=""),title:str=Query(default="VNEWS Video"),img:str=Query(default=""),type:str=Query(default="highlights")):
|
|
|
|
| 292 |
og_image=unquote(img) if img else "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
|
| 293 |
decoded_url=unquote(url)
|
| 294 |
decoded_title=unquote(title)
|