Fix: Built-in 13-voice + 8-emotion selector, emotion in makeShortVideo, slide preview. Fixes JS conflicts.

#28
by bep40 - opened
.docker_rebuild DELETED
@@ -1 +0,0 @@
1
- trigger
 
 
.huggingface/rebuild CHANGED
@@ -1 +1 @@
1
- rebuild
 
1
+ trigger rebuild
.rebuild CHANGED
@@ -1 +1 @@
1
- Rebuild: vtv_api.py VTVGo proxy (player thật, no XFO) cho tất cả kênh
 
1
+ 1782552642
.rebuild_flag DELETED
@@ -1 +0,0 @@
1
- rebuild
 
 
.rebuild_v2 DELETED
@@ -1 +0,0 @@
1
- rebuild
 
 
.restart_trigger CHANGED
@@ -1 +1 @@
1
- rebuild
 
1
+ # restart trigger - v5.1 rewrite fix 2026-06-27
CHANGELOG.md CHANGED
@@ -1,47 +1,3 @@
1
- # VNEWS v6.5 - Resilient Shorts Auto-Updater
2
-
3
- ## Changes
4
-
5
- ### Critical Fix: Shorts timeout and homepage load stability
6
- **Root cause**: YouTube shorts fetching in `main.py` using `scrape_shorts()` and `_yt_channel_shorts_requests()` could hang indefinitely when YouTube blocks requests or yt-dlp times out, causing:
7
- - Homepage `/api/shorts` endpoint to time out (30s limit)
8
- - Space to appear unresponsive on first load
9
- - No fallback when sources fail
10
-
11
- **Fix applied**:
12
- 1. **shorts_updater.py** (NEW) — Resilient background updater:
13
- - Hard timeout (25s) per channel using subprocess isolation
14
- - Stale-while-revalidate pattern: returns cached data immediately, updates in background
15
- - Automatic fallback to hardcoded short URLs when all sources fail
16
- - Persistent storage in `/data/shorts_cache.json` for cache across restarts
17
- - Background scheduler runs every 10 minutes automatically
18
- - No blocking on first homepage load
19
-
20
- 2. **_run.py** — Integrated resilient shorts endpoint:
21
- - Overrides `/api/shorts` with non-blocking version
22
- - Returns cached/fallback data in <100ms guaranteed
23
- - Triggers background update if cache is stale or empty
24
- - Never hangs - always returns valid JSON response
25
-
26
- 3. **FALLBACK_SHORTS** — 6 hardcoded viral shorts as emergency fallback:
27
- - baodantri7941 (Dân trí) headlines
28
- - baosuckhoedoisongboyte (Sức khỏe & đời sống) stories
29
- - vtvnambo (VTV Nam Bộ) news
30
-
31
- ### Benefits
32
- - Homepage loads in <2 seconds always
33
- - Shorts data auto-updates every 10 minutes
34
- - Never times out - graceful degradation to fallback
35
- - Persistent cache survives Space restarts
36
- - Uses bucket `bep40/VNEWS-storage` for cache storage
37
-
38
- ### Channels monitored
39
- - baodantri7941 (Dân trí)
40
- - baosuckhoedoisongboyte (Sức khỏe & đời sống)
41
- - vtvnambo (VTV Nam Bộ)
42
-
43
- ---
44
-
45
  # VNEWS v5.1 - Rewrite Fix
46
 
47
  ## Changes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # VNEWS v5.1 - Rewrite Fix
2
 
3
  ## Changes
Dockerfile CHANGED
@@ -12,5 +12,4 @@ RUN pip install --no-cache-dir -r requirements.txt || true
12
  COPY . .
13
  EXPOSE 7860
14
 
15
- CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860"]
16
- # v2
 
12
  COPY . .
13
  EXPOSE 7860
14
 
15
+ CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"]
 
README.md CHANGED
@@ -9,25 +9,17 @@ tags:
9
  - ml-intern
10
  ---
11
 
12
- # VNEWS - Tin Tức Việt Nam
13
-
14
- **v18 - FIXED VTV2/VTV3/VTV6/VTV9 stream hanging**
15
-
16
- ## 🔧 Changes in v18 (2026-07-06)
17
- - **VTV2, VTV3, VTV6, VTV9**: Skip expired ssaimh CDN token → immediately fall through to sv2.xemtivitop.com
18
- - **15+ extraction patterns** for m3u8 URL (up from 5), including: file:, src=, source:, player.src(), hls.loadSource(), href=, `<source src>`, url:, window.location, iframe follow (3 levels deep), base64 decode
19
- - **Backup CDN** `tv.mediacdn.vn` for VTV2/VTV3/VTV6/VTV9
20
- - **Fast timeout** 5s for CDN, 12s for PHP endpoints (was 15s each = 60s+ total)
21
- - **sv2.xemtivitop.com** re-prioritized to check BEFORE xemtv.us
22
- - **Iframe chain following**: if a PHP page returns an iframe → follow it up to 3 levels to find the m3u8
23
-
24
- ## Features:
25
- - 📰 News from VnExpress (10 categories) + GenK AI
26
- - ⚽ Livescore from bongda.com.vn (live, today, upcoming, results, standings)
27
- - 🎬 Football highlights from xemlaibongda.top (8 leagues)
28
- - 📺 VTV live channels (VTV1→VTV10, VTV Prime)
29
- - Priority: ssaimh CDN → sv2.xemtivitop.com → xemtv.us → xemtivitop blogspot → FPTPlay → VTVGo → mediacdn → xemtv.net
30
- - 🏆 World Cup 2026 (news, fixtures, standings, stats, highlights)
31
- - 🤖 AI article writing + TTS (multilingual, emotion-aware)
32
- - 🔍 Topic search (8 news sources)
33
- - 🎤 TTS: voice selector + emotion selector + speed control
 
9
  - ml-intern
10
  ---
11
 
12
+ # bep40/vnews
13
+ <!-- build: 2026-06-12T06:45:00 -->
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+ <!-- rebuild: v6.5-final-1781173463 -->
 
 
 
 
 
 
 
 
_run.py CHANGED
@@ -1 +1 @@
1
- from app_v2_entry import app # v5-stable inline bongda proxy
 
1
+ from app_v2_entry import app # v5-stable inline bongda proxy
ai_ext.py CHANGED
@@ -13,16 +13,7 @@ from bs4 import BeautifulSoup
13
  from fastapi import Request, Query
14
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
15
 
16
- # Try to import main app, but don't fail if it doesn't exist
17
- try:
18
- from main import app
19
- except ImportError:
20
- # Create a minimal FastAPI app for standalone testing
21
- try:
22
- from fastapi import FastAPI
23
- app = FastAPI()
24
- except Exception:
25
- app = None
26
 
27
  # Import wall store from main.py so we read/write the SAME file
28
  try:
@@ -50,10 +41,6 @@ except ImportError:
50
  def _web_context(topic):
51
  return ""
52
 
53
- # ai_ext alias for backward compatibility
54
- _load_ai_wall = _load_wall
55
- _save_ai_wall = _save_wall
56
-
57
  try:
58
  from huggingface_hub import AsyncInferenceClient
59
  except Exception:
@@ -79,136 +66,6 @@ def _hf_token():
79
  return v
80
  return ""
81
 
82
-
83
- def _clean_text(s: str) -> str:
84
- """Clean text for processing."""
85
- s = html_lib.unescape(s or "")
86
- s = re.sub(r"\s+", " ", s)
87
- return s.strip()
88
-
89
-
90
- def _domain(url: str) -> str:
91
- """Extract domain from URL."""
92
- try:
93
- return urlparse(url or "").netloc.replace("www.", "")
94
- except Exception:
95
- return ""
96
-
97
-
98
- async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
99
- """Generate text using Qwen models via Hugging Face Inference API.
100
-
101
- This function provides a resilient implementation that:
102
- 1. First tries the SDK-based inference client if available
103
- 2. Falls back to REST API calls to HF router endpoint
104
- 3. Returns a fallback summary if all else fails
105
- """
106
- token = _hf_token()
107
- errors = []
108
-
109
- # Try HF router API with multiple models
110
- if token:
111
- models = [
112
- os.getenv("QWEN_VL_MODEL", ""),
113
- "Qwen/Qwen2.5-VL-7B-Instruct",
114
- "Qwen/Qwen2.5-VL-3B-Instruct",
115
- "Qwen/Qwen2.5-7B-Instruct",
116
- "Qwen/Qwen2.5-3B-Instruct",
117
- "Qwen/Qwen2.5-1.5B-Instruct",
118
- "Qwen/Qwen2.5-72B-Instruct",
119
- "meta-llama/Llama-3.3-70B-Instruct",
120
- ]
121
- # Deduplicate while preserving order
122
- seen = set()
123
- models = [m for m in models if m and m not in seen and not seen.add(m)]
124
-
125
- headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
126
-
127
- for model in models:
128
- try:
129
- is_vl = "VL" in model and image_url
130
- if is_vl:
131
- user_content = [
132
- {"type": "image_url", "image_url": {"url": image_url}},
133
- {"type": "text", "text": prompt}
134
- ]
135
- else:
136
- user_content = prompt
137
-
138
- payload = {
139
- "model": model,
140
- "messages": [
141
- {"role": "system", "content": "Bạn là trợ lý AI tiếng Việt. Trả lời tự nhiên, ngắn gọn, chính xác."},
142
- {"role": "user", "content": user_content},
143
- ],
144
- "max_tokens": min(int(max_tokens or 900), 1400),
145
- "temperature": 0.35,
146
- "top_p": 0.85,
147
- }
148
-
149
- r = requests.post(
150
- "https://router.huggingface.co/v1/chat/completions",
151
- headers=headers,
152
- json=payload,
153
- timeout=95
154
- )
155
-
156
- if r.status_code >= 300:
157
- errors.append(f"{model}: HTTP {r.status_code}")
158
- continue
159
-
160
- j = r.json()
161
- txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
162
-
163
- if txt:
164
- return txt
165
-
166
- errors.append(f"{model}: empty response")
167
-
168
- except Exception as e:
169
- errors.append(f"{model}: {type(e).__name__}")
170
-
171
- # Fallback: extractive summary from prompt
172
- LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
173
- return _fallback_summary_from_prompt(prompt, max_units=6)
174
-
175
-
176
- def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
177
- """Generate a simple fallback summary when AI is unavailable."""
178
- text = prompt or ""
179
- for marker in ["Nội dung nguồn:", "Nội dung bài:", "Nội dung gốc:", "Nội dung:", "Nguồn/bối cảnh internet:"]:
180
- if marker in text:
181
- text = text.split(marker, 1)[1]
182
- break
183
- text = re.sub(r"https?://\S+", "", text)
184
- text = re.sub(r"\s+", " ", text).strip()
185
-
186
- # Split into sentences - extract ALL valid sentences, not just first few
187
- sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
188
- units = []
189
- for s in sentences:
190
- s = _clean_text(s)
191
- if len(s) >= 30: # Lower threshold to capture more content
192
- units.append(s)
193
-
194
- if units:
195
- # Take up to max_units valid sentences
196
- result_units = units[:max_units]
197
- return "\n".join("• " + u for u in result_units)
198
- if text:
199
- # Fallback: take chunks if no sentence boundaries found
200
- chunks = []
201
- for i in range(0, min(len(text), max_units * 300), 280):
202
- chunk = _clean_text(text[i:i+300])
203
- if chunk and chunk not in chunks:
204
- chunks.append(chunk)
205
- if len(chunks) >= max_units:
206
- break
207
- if chunks:
208
- return "\n".join("• " + c for c in chunks)
209
- return "• Không có đủ nội dung để tóm tắt."
210
-
211
-
212
  HF_TOKEN = _hf_token()
213
  QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
214
  QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
@@ -225,82 +82,57 @@ HEADERS = {
225
  }
226
  LAST_QWEN_ERROR = ""
227
 
228
-
229
  # ===== MULTILINGUAL VOICES FOR TTS =====
230
- # Maps voice IDs to edge-tts voice names (only MultilingualNeural voices)
231
  MULTILINGUAL_VOICES = {
232
- # Vietnamese - Native voices
233
- "vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural",
234
- "vi-vn-namminhneural": "vi-VN-NamMinhNeural",
235
- "hoaimy": "vi-VN-HoaiMyNeural",
236
- "namminh": "vi-VN-NamMinhNeural",
237
- "vi_female": "vi-VN-HoaiMyNeural",
238
  "vi_male": "vi-VN-NamMinhNeural",
239
- "nu": "vi-VN-HoaiMyNeural",
240
- "male": "vi-VN-NamMinhNeural",
241
- "female": "vi-VN-HoaiMyNeural",
242
- "mien-nam": "vi-VN-HoaiMyNeural",
243
- # English - Multilingual
244
- "en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
245
- "en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
246
- "en_andrew": "en-US-AndrewMultilingualNeural",
247
- "andrew": "en-US-AndrewMultilingualNeural",
248
- "en_jenny": "en-US-AndrewMultilingualNeural",
249
- "jenny": "en-US-AndrewMultilingualNeural",
250
- # Portuguese - Thalita Multilingual ONLY
251
- "pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
252
  "pt_thalita": "pt-BR-ThalitaMultilingualNeural",
253
  "thalita": "pt-BR-ThalitaMultilingualNeural",
254
- "pt_francisco": "pt-BR-ThalitaMultilingualNeural",
255
- "pt": "pt-BR-ThalitaMultilingualNeural",
256
- # French - Multilingual
257
- "fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
258
- "fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
259
- "fr_denise": "fr-FR-VivienneMultilingualNeural",
260
- "denise": "fr-FR-VivienneMultilingualNeural",
261
- "fr": "fr-FR-VivienneMultilingualNeural",
262
- # German - Multilingual
263
- "de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
264
- "de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
265
- "de_katja": "de-DE-SeraphinaMultilingualNeural",
266
- "katja": "de-DE-SeraphinaMultilingualNeural",
267
- "de": "de-DE-SeraphinaMultilingualNeural",
268
- # Korean - Hyunsu Multilingual (NOT SunHee)
269
- "ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
270
- "ko_sunhee": "ko-KR-HyunsuMultilingualNeural",
271
- "sunhee": "ko-KR-HyunsuMultilingualNeural",
272
- "ko": "ko-KR-HyunsuMultilingualNeural",
273
- # Italian - Multilingual
274
- "it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
275
- # Spanish (fallback to English multilingual)
276
- "es_ela": "en-US-AndrewMultilingualNeural",
277
- "ela": "en-US-AndrewMultilingualNeural",
278
- "es_carlos": "en-US-AndrewMultilingualNeural",
279
- "es": "en-US-AndrewMultilingualNeural",
280
- # Japanese (fallback to English multilingual)
281
- "ja_nanami": "en-US-AndrewMultilingualNeural",
282
- "nanami": "en-US-AndrewMultilingualNeural",
283
- "ja": "en-US-AndrewMultilingualNeural",
284
- # Chinese (fallback to English multilingual)
285
- "zh_xiaochen": "en-US-AndrewMultilingualNeural",
286
- "xiaochen": "en-US-AndrewMultilingualNeural",
287
- "zh": "en-US-AndrewMultilingualNeural",
288
  }
289
 
290
-
291
  def _detect_voice_emotion(title, text):
292
  """Detect appropriate voice and emotion based on content for multilingual TTS."""
293
  content = ((title or "") + " " + (text or "")).lower()
294
 
295
  # World Cup / Football content - use Andrew multilingual
296
  if any(kw in content for kw in ["world cup", "wc 2026", "fifa", "bóng đá", "trận đấu", "bóng bóng", "đội tuyển", "cầu thủ"]):
297
- return ("andrew", "excited")
298
 
299
  # News categories - choose appropriate voice
300
  if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
301
  return ("jenny", "calm")
302
  if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
303
- return ("thalita", "serious")
304
  if any(kw in content for kw in ["giải trí", "showbiz", "entertainment", "hài hước"]):
305
  return ("ela", "happy")
306
  if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
@@ -308,267 +140,3 @@ def _detect_voice_emotion(title, text):
308
 
309
  # Default Vietnamese
310
  return ("hoaimy", "trung_tinh")
311
-
312
-
313
- def _safe_name(s: str) -> str:
314
- """Create safe filename from string."""
315
- s = re.sub(r"[^\w\-.]", "_", s)
316
- return s[:100] if len(s) > 100 else s
317
-
318
-
319
- def _download_image(url: str, fallback_title: str, out_path: str) -> bool:
320
- """Download image from URL to path."""
321
- if not url:
322
- return False
323
- try:
324
- r = requests.get(url, headers=HEADERS, timeout=15)
325
- if r.status_code == 200:
326
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
327
- with open(out_path, "wb") as f:
328
- f.write(r.content)
329
- return True
330
- except Exception:
331
- pass
332
- return False
333
-
334
-
335
- def pollination_image_url(topic: str) -> str:
336
- """Generate image URL from Pollinations.ai."""
337
- return f"https://image.pollinations.ai/prompt/{quote(topic)}?width=1024&height=768&nologo=true&model=flux"
338
-
339
-
340
- # Use the same wall file as app_v2_entry.py for consistency
341
- WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
342
-
343
- def _load_ai_wall():
344
- """Load AI wall posts from JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
345
- try:
346
- if os.path.exists(WALL_FILE):
347
- with open(WALL_FILE, "r", encoding="utf-8") as f:
348
- return json.load(f)
349
- except Exception:
350
- pass
351
- return []
352
-
353
-
354
- def _save_ai_wall(posts):
355
- """Save AI wall posts to JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
356
- try:
357
- os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
358
- tmp = WALL_FILE + ".tmp"
359
- with open(tmp, "w", encoding="utf-8") as f:
360
- json.dump(posts[:100], f, ensure_ascii=False)
361
- os.replace(tmp, WALL_FILE)
362
- except Exception:
363
- pass
364
-
365
-
366
- # Helper functions for wall operations
367
- def _load_wall_posts():
368
- """Alias for _load_ai_wall for consistency with app_v2_entry.py."""
369
- return _load_ai_wall()
370
-
371
-
372
- def _save_wall_posts(posts):
373
- """Alias for _save_ai_wall for consistency with app_v2_entry.py."""
374
- return _save_ai_wall(posts)
375
-
376
-
377
- def make_post(title: str, text: str, img: str, url: str, kind: str, sources=None):
378
- """Create a post dict with standard fields."""
379
- return {
380
- "id": str(int(time.time() * 1000)),
381
- "title": title,
382
- "text": text,
383
- "img": img,
384
- "url": url,
385
- "kind": kind,
386
- "sources": sources or [],
387
- "ts": int(time.time())
388
- }
389
-
390
-
391
- def _short_script(post) -> str:
392
- """Extract clean text for TTS from post."""
393
- text = post.get("text", "") or post.get("title", "")
394
- text = re.sub(r"^[•\-\*]\s*", "", text, flags=re.M)
395
- text = re.sub(r"\s*\n\s*", ". ", text)
396
- return _clean_text(text)[:2000] # Increased from 1000 to 2000 for full content
397
-
398
-
399
- # ===== SCRAPER FUNCTIONS (required by ai_patch.py) =====
400
- def scrape_any_url(url: str) -> dict:
401
- """Scrape any URL and extract article content.
402
-
403
- Returns dict with: title, summary, text, image, og_image, via (domain)
404
- """
405
- try:
406
- r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
407
- r.encoding = 'utf-8'
408
- soup = BeautifulSoup(r.text, 'lxml')
409
-
410
- # Remove scripts, styles, nav, footer
411
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
412
- tag.decompose()
413
-
414
- # Extract title
415
- h1 = soup.find('h1')
416
- ogt = soup.find('meta', property='og:title')
417
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else url)
418
-
419
- # Extract OG image
420
- ogi = soup.find('meta', property='og:image')
421
- og_image = ogi.get('content', '') if ogi else ''
422
-
423
- # Extract article body
424
- block = None
425
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
426
- el = soup.select_one(sel)
427
- if el and len(el.find_all('p')) >= 2:
428
- block = el
429
- break
430
- if not block:
431
- block = soup.body or soup
432
-
433
- # Extract text from paragraphs
434
- paragraphs = []
435
- for el in block.find_all(['p', 'h2', 'h3'], recursive=True):
436
- t = _clean_text(el.get_text(strip=True))
437
- if t and len(t) > 40:
438
- paragraphs.append(t)
439
-
440
- # Extract images
441
- images = []
442
- for el in block.find_all(['figure', 'img'], recursive=True):
443
- im = el if el.name == 'img' else el.find('img')
444
- if im:
445
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
446
- if src and 'base64' not in src:
447
- if src.startswith('//'):
448
- src = 'https:' + src
449
- images.append(src)
450
-
451
- # Prefer OG image as main image
452
- image = og_image or (images[0] if images else '')
453
-
454
- return {
455
- 'title': title,
456
- 'summary': paragraphs[0] if paragraphs else '',
457
- 'text': '\n'.join(paragraphs),
458
- 'image': image,
459
- 'og_image': og_image,
460
- 'via': _domain(url),
461
- 'images': images
462
- }
463
- except Exception as e:
464
- return {'title': url, 'summary': '', 'text': '', 'image': '', 'og_image': '', 'via': _domain(url), 'error': str(e)}
465
-
466
-
467
- def web_context(topic: str, limit: int = 5) -> tuple:
468
- """Get web context for a topic. Returns (context_text, sources_list)."""
469
- sources = []
470
- try:
471
- # Try Google News RSS
472
- rss_url = f"https://news.google.com/rss/search?q={quote_plus(topic)}&hl=vi&gl=VN&ceid=VN:vi"
473
- r = requests.get(rss_url, headers=HEADERS, timeout=15)
474
- r.encoding = 'utf-8'
475
- soup = BeautifulSoup(r.text, 'xml')
476
- for it in soup.find_all('item')[:limit]:
477
- title = it.find('title').get_text(' ', strip=True) if it.find('title') else ''
478
- link = it.find('link').get_text(strip=True) if it.find('link') else ''
479
- if title and link:
480
- sources.append({'title': title, 'url': link, 'via': _domain(link)})
481
- except Exception:
482
- pass
483
-
484
- context = f'Trên mạng có nhiều bài viết về "{topic}". Một số nguồn: ' + ', '.join([s.get('title', '') for s in sources[:3]])
485
- return context, sources
486
-
487
-
488
- # ===== SHORT FRAME FUNCTION (required by ai_patch.py) =====
489
- def _make_short_frame(post, img_path, out_path):
490
- """Create a short video frame from post and image.
491
-
492
- Called by ai_patch.py _make_short_frame_full when Image is available.
493
- """
494
- if Image is None:
495
- # Create a minimal frame without PIL - just return success
496
- # The caller should handle this case
497
- return False
498
-
499
- W, H = 1080, 1920
500
- bg = Image.new("RGB", (W, H), (14, 14, 14))
501
-
502
- try:
503
- im = Image.open(img_path).convert("RGB")
504
- target = (1080, 760)
505
- im_ratio = im.width / max(1, im.height)
506
- target_ratio = target[0] / target[1]
507
-
508
- if im_ratio > target_ratio:
509
- new_h = target[1]
510
- new_w = int(new_h * im_ratio)
511
- else:
512
- new_w = target[0]
513
- new_h = int(new_w / im_ratio)
514
-
515
- im = im.resize((new_w, new_h))
516
- left = (new_w - target[0]) // 2
517
- top = (new_h - target[1]) // 2
518
- im = im.crop((left, top, left + target[0], top + target[1]))
519
- bg.paste(im, (0, 0))
520
- except Exception:
521
- pass
522
-
523
- draw = ImageDraw.Draw(bg)
524
-
525
- try:
526
- font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
527
- font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
528
- except Exception:
529
- font_title = font_body = None
530
-
531
- draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
532
- margin = 48
533
- maxw = W - margin * 2
534
-
535
- y = 830
536
- for ln in _wrap_text(draw, post.get("title", ""), font_title, maxw, 4):
537
- draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
538
- y += 66
539
-
540
- y += 18
541
- text = post.get("text", "")
542
- text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
543
- body_lines = _wrap_text(draw, text, font_body, maxw, 14)
544
- for ln in body_lines:
545
- draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
546
- y += 50
547
- if y > 1640:
548
- break
549
-
550
- bg.save(out_path, quality=92)
551
- return True
552
-
553
-
554
- def _wrap_text(draw, text, font, max_width, max_lines):
555
- """Helper for wrapping text in frames."""
556
- words = _clean_text(text).split()
557
- lines, cur = [], ""
558
- for w in words:
559
- test = (cur + " " + w).strip()
560
- try:
561
- width = draw.textbbox((0, 0), test, font=font)[2]
562
- except Exception:
563
- width = len(test) * 20
564
- if width <= max_width:
565
- cur = test
566
- else:
567
- if cur:
568
- lines.append(cur)
569
- cur = w
570
- if len(lines) >= max_lines:
571
- break
572
- if cur and len(lines) < max_lines:
573
- lines.append(cur)
574
- return lines
 
13
  from fastapi import Request, Query
14
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
15
 
16
+ from main import app
 
 
 
 
 
 
 
 
 
17
 
18
  # Import wall store from main.py so we read/write the SAME file
19
  try:
 
41
  def _web_context(topic):
42
  return ""
43
 
 
 
 
 
44
  try:
45
  from huggingface_hub import AsyncInferenceClient
46
  except Exception:
 
66
  return v
67
  return ""
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  HF_TOKEN = _hf_token()
70
  QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
71
  QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
 
82
  }
83
  LAST_QWEN_ERROR = ""
84
 
 
85
  # ===== MULTILINGUAL VOICES FOR TTS =====
 
86
  MULTILINGUAL_VOICES = {
87
+ # Vietnamese
88
+ "vi_female": "vi-VN-HoaMyNeural",
 
 
 
 
89
  "vi_male": "vi-VN-NamMinhNeural",
90
+ "hoaimy": "vi-VN-HoaMyNeural",
91
+ "namminh": "vi-VN-NamMinhNeural",
92
+ # Multilingual - Andrew (hỗ trợ tiếng Việt)
93
+ "en_andrew": "en-US-AndrewNeural",
94
+ "andrew": "en-US-AndrewNeural",
95
+ # Multilingual - Jenny
96
+ "en_jenny": "en-US-JennyNeural",
97
+ "jenny": "en-US-JennyNeural",
98
+ # Portuguese - Thalita Multilingual
 
 
 
 
99
  "pt_thalita": "pt-BR-ThalitaMultilingualNeural",
100
  "thalita": "pt-BR-ThalitaMultilingualNeural",
101
+ "pt_francisco": "pt-BR-FranciscoNeural",
102
+ # Spanish
103
+ "es_ela": "es-ES-ElaNeural",
104
+ "ela": "es-ES-ElaNeural",
105
+ "es_carlos": "es-ES-CarlosNeural",
106
+ # French
107
+ "fr_denise": "fr-FR-DeniseNeural",
108
+ "denise": "fr-FR-DeniseNeural",
109
+ # German
110
+ "de_katja": "de-DE-KatjaNeural",
111
+ "katja": "de-DE-KatjaNeural",
112
+ # Japanese
113
+ "ja_nanami": "ja-JP-NanamiNeural",
114
+ "nanami": "ja-JP-NanamiNeural",
115
+ # Korean
116
+ "ko_sunhee": "ko-KR-SunHeeNeural",
117
+ "sunhee": "ko-KR-SunHeeNeural",
118
+ # Chinese
119
+ "zh_xiaochen": "zh-CN-XiaochenNeural",
120
+ "xiaochen": "zh-CN-XiaochenNeural",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  }
122
 
 
123
  def _detect_voice_emotion(title, text):
124
  """Detect appropriate voice and emotion based on content for multilingual TTS."""
125
  content = ((title or "") + " " + (text or "")).lower()
126
 
127
  # World Cup / Football content - use Andrew multilingual
128
  if any(kw in content for kw in ["world cup", "wc 2026", "fifa", "bóng đá", "trận đấu", "bóng bóng", "đội tuyển", "cầu thủ"]):
129
+ return ("andrew", "excited") # Andrew multilingual hỗ trợ tiếng Việt
130
 
131
  # News categories - choose appropriate voice
132
  if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
133
  return ("jenny", "calm")
134
  if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
135
+ return ("thalita", "serious") # Portuguese multilingual
136
  if any(kw in content for kw in ["giải trí", "showbiz", "entertainment", "hài hước"]):
137
  return ("ela", "happy")
138
  if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
 
140
 
141
  # Default Vietnamese
142
  return ("hoaimy", "trung_tinh")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ai_patch.py CHANGED
@@ -42,17 +42,17 @@ def _similar(a, b):
42
  return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
43
 
44
 
45
- def _dedupe_units(units, max_units=25):
46
- """Deduplicate units - only skip exact matches to ensure all bullet points are read."""
47
  out, seen = [], set()
48
  for u in units:
49
  u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
50
  if len(u) < 18:
51
  continue
52
  nu = _norm(u)
53
- # Only skip exact matches, NOT similar content (to avoid skipping valid bullet points)
54
  if nu in seen:
55
  continue
 
 
56
  seen.add(nu)
57
  out.append(u)
58
  if len(out) >= max_units:
@@ -60,7 +60,7 @@ def _dedupe_units(units, max_units=25):
60
  return out
61
 
62
 
63
- def _postprocess_ai_text(text, max_units=20):
64
  text = _clean(text)
65
  if not text:
66
  return text
@@ -79,9 +79,10 @@ def _postprocess_ai_text(text, max_units=20):
79
  raw_lines.append(line)
80
  units = []
81
  for line in raw_lines:
82
- # KEEP FULL bullet point - don't truncate or split into segments
83
- if len(line) >= 18:
84
- units.append(_clean(re.sub(r"^[-•*\d\.\)\s]+", "", line)))
 
85
  units = _dedupe_units(units, max_units=max_units)
86
  if not units:
87
  return text[:900]
@@ -268,7 +269,7 @@ async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int =
268
  errors.append("missing HF_TOKEN")
269
  base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
270
  print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
271
- return _fallback_summary_from_prompt(prompt, max_units=12)
272
 
273
 
274
  if not hasattr(base, "_original_qwen_generate"):
@@ -321,8 +322,8 @@ Yêu cầu bắt buộc:
321
 
322
  Nội dung bài:
323
  {art['raw'][:14000]}"""
324
- text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=1500)
325
- text = _postprocess_ai_text(text, max_units=20)
326
  src = [art['source']]
327
  if 'Nguồn tham khảo:' not in text:
328
  text += "\n\n" + _source_line(src)
@@ -347,8 +348,8 @@ async def compat_url_wall(request: Request):
347
  if len(raw) < 120:
348
  return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
349
  prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
350
- text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
351
- text = _postprocess_ai_text(text, max_units=20)
352
  src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
353
  if 'Nguồn tham khảo:' not in text:
354
  text += "\n\n" + _source_line(src)
@@ -357,116 +358,6 @@ async def compat_url_wall(request: Request):
357
  return JSONResponse({'post': post})
358
 
359
 
360
- def _is_relevant_image(img_url, title, text):
361
- """Check if an image is relevant to the article content."""
362
- if not img_url:
363
- return False
364
- skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
365
- 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
366
- 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
367
- img_lower = img_url.lower()
368
- for p in skip_patterns:
369
- if p in img_lower:
370
- return False
371
- if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
372
- return False
373
- return True
374
-
375
-
376
- def _filter_relevant_images(images, title, text, max_images=8):
377
- """Filter and rank images by relevance to article content."""
378
- if not images:
379
- return []
380
- seen = set()
381
- relevant = []
382
- for img in images:
383
- if img in seen:
384
- continue
385
- seen.add(img)
386
- if _is_relevant_image(img, title, text):
387
- relevant.append(img)
388
- return relevant[:max_images]
389
-
390
-
391
- def _extract_key_points_for_slides(paragraphs, max_points=12):
392
- """Extract key points from paragraphs for slides - extracts ALL sentences, not just first one."""
393
- points = []
394
- for p in paragraphs:
395
- if len(points) >= max_points:
396
- break
397
- p = _clean(p)
398
- if not p:
399
- continue
400
- # Split paragraph into sentences using Vietnamese + English punctuation - GET ALL SENTENCES
401
- sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
402
- sentences = [s.strip() for s in sentences if s.strip()]
403
-
404
- for sentence in sentences:
405
- if len(points) >= max_points:
406
- break
407
- sentence = _clean(sentence)
408
- if len(sentence) < 30:
409
- continue
410
- if any(sentence[:60] in existing for existing in points):
411
- continue
412
- if not sentence.endswith(('.', '!', '?')):
413
- sentence = sentence + '.'
414
- points.append(sentence)
415
- return points
416
-
417
-
418
- def _scrape_article_images(url):
419
- """Scrape article page and return only relevant images."""
420
- try:
421
- headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
422
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
423
- r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
424
- r.encoding = 'utf-8'
425
- soup = BeautifulSoup(r.text, 'lxml')
426
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
427
- tag.decompose()
428
- h1 = soup.find('h1')
429
- ogt = soup.find('meta', property='og:title')
430
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
431
- ogi = soup.find('meta', property='og:image')
432
- og_img = ogi.get('content', '') if ogi else ''
433
- if og_img and og_img.startswith('//'):
434
- og_img = 'https:' + og_img
435
- block = None
436
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
437
- el = soup.select_one(sel)
438
- if el and len(el.find_all('p')) >= 2:
439
- block = el
440
- break
441
- if not block:
442
- block = soup.body or soup
443
- paragraphs = []
444
- all_images = []
445
- seen_imgs = set()
446
- if og_img and og_img not in seen_imgs:
447
- all_images.append(og_img)
448
- seen_imgs.add(og_img)
449
- for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
450
- if el.name == 'p':
451
- t = _clean(el.get_text(strip=True))
452
- if t and len(t) > 40:
453
- paragraphs.append(t)
454
- elif el.name in ('figure', 'img'):
455
- im = el if el.name == 'img' else el.find('img')
456
- if im:
457
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
458
- if src and 'base64' not in src:
459
- if src.startswith('//'):
460
- src = 'https:' + src
461
- if src not in seen_imgs:
462
- all_images.append(src)
463
- seen_imgs.add(src)
464
- relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
465
- return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
466
- except Exception:
467
- return None
468
-
469
-
470
  @app.post('/api/rewrite_share')
471
  async def compat_rewrite_share(request: Request):
472
  body = await request.json()
@@ -481,39 +372,31 @@ async def compat_rewrite_share(request: Request):
481
  if len(raw) < 120:
482
  return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
483
  prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
484
- text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
485
- text = _postprocess_ai_text(text, max_units=20)
486
  src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
487
  if 'Nguồn tham khảo:' not in text:
488
  text += "\n\n" + _source_line(src)
489
  post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
490
  posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
491
-
492
- # Generate slides with relevant images only
493
- slides = []
494
- page_data = _scrape_article_images(url)
495
- if page_data and page_data.get('paragraphs'):
496
- key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
497
- if key_points:
498
- relevant_imgs = page_data.get('images', [])
499
- if not relevant_imgs and page_data.get('og_img'):
500
- relevant_imgs = [page_data['og_img']]
501
- for i, point in enumerate(key_points):
502
- img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
503
- slides.append({'text': point, 'image': img, 'index': i + 1})
504
-
505
- return JSONResponse({'post': post, 'slides': slides})
506
 
507
 
508
  def _emotion_script(text, emotion):
509
- """Prepend emotion-appropriate prefix to text based on emotion type.
510
-
511
- NOTE: Prefix is NOT added to avoid cluttering Short AI speech.
512
- The emotion is still used for voice selection but content is read cleanly.
513
- """
514
  text = _clean(text)
515
- # REMOVED: No prefix added to keep content clean and natural
516
- return text
 
 
 
 
 
 
 
 
 
 
517
 
518
 
519
  def _tts_script_smart(post, emotion):
@@ -522,12 +405,11 @@ def _tts_script_smart(post, emotion):
522
  raw = re.sub(r"\s*\n\s*", ". ", raw)
523
  raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
524
  raw = re.sub(r"\n{2,}", "\n", raw).strip()
525
- # REMOVED: _emotion_script call - read content cleanly without prefix
526
- # INCREASED to 3000 to read full content of all bullet points
527
- if len(raw) > 3000:
528
- raw = raw[:3000]
529
  cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
530
- if cut > 700:
531
  raw = raw[:cut + 1]
532
  return raw
533
 
@@ -642,7 +524,7 @@ def _make_short_frame_full(post, img_path, out_path):
642
 
643
 
644
 
645
- def _summary_segments_from_post(post, max_segments=25):
646
  raw = _clean(post.get('text') or post.get('title') or '')
647
  raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
648
  raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
@@ -653,7 +535,7 @@ def _summary_segments_from_post(post, max_segments=25):
653
  low=ln.lower()
654
  if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
655
  if len(ln)>=18: lines.append(ln)
656
- if len(lines)<3:
657
  lines=[]
658
  for s in re.split(r'(?<=[\.\!\?])\s+', raw):
659
  s=_clean(s)
@@ -705,8 +587,7 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
705
  draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
706
  draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
707
  y=940; maxw=W-96
708
- # INCREASED from 12 to 18 for full content display - each key point can span multiple lines
709
- for ln in _wrap_text_px(draw, segment, font_seg, maxw, 18):
710
  draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
711
  y+=74
712
  if y>1500: break
@@ -718,11 +599,10 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
718
  bg.save(out_path, quality=92)
719
 
720
 
721
- def _estimate_audio_duration(path, fallback=15.0):
722
- """Estimate audio duration with 15s minimum per segment for complete bullet reading."""
723
  try:
724
  pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
725
- return max(12.0, float((pr.stdout or b'').decode().strip() or fallback))
726
  except Exception:
727
  return fallback
728
 
@@ -735,7 +615,7 @@ async def patched_ai_short(post_id: str, request: Request):
735
  body = {}
736
  voice = str(body.get('voice', 'nu')).strip().lower()
737
  emotion = str(body.get('emotion', 'neutral')).strip().lower()
738
- speed = float(body.get('speed', 1.0) or 1.0)
739
  speed = max(0.85, min(1.35, speed))
740
 
741
  posts = base._load_ai_wall()
@@ -743,7 +623,7 @@ async def patched_ai_short(post_id: str, request: Request):
743
  if not post:
744
  return JSONResponse({'error': 'post not found'}, status_code=404)
745
 
746
- segments = _summary_segments_from_post(post, max_segments=25)
747
  seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
748
  os.makedirs(base.SHORTS_DIR, exist_ok=True)
749
  suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
@@ -767,62 +647,50 @@ async def patched_ai_short(post_id: str, request: Request):
767
  base._download_image(post.get('img'), post.get('title', 'AI news'), img)
768
  edge_voice = {
769
  # Vietnamese
770
- 'vi-vn-hoaimyneural': 'vi-VN-HoaiMyNeural',
771
- 'vi-vn-namminhneural': 'vi-VN-NamMinhNeural',
772
- 'hoaimy': 'vi-VN-HoaiMyNeural',
773
- 'namminh': 'vi-VN-NamMinhNeural',
774
  'nam': 'vi-VN-NamMinhNeural',
775
  'male': 'vi-VN-NamMinhNeural',
776
  'nu': 'vi-VN-HoaiMyNeural',
777
  'female': 'vi-VN-HoaiMyNeural',
778
  'mien-nam': 'vi-VN-HoaiMyNeural',
779
- # English - Multilingual
780
- 'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural',
781
- 'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural',
782
- 'andrew': 'en-US-AndrewMultilingualNeural',
783
- 'en_andrew': 'en-US-AndrewMultilingualNeural',
784
- 'jenny': 'en-US-AndrewMultilingualNeural',
785
- 'en_jenny': 'en-US-AndrewMultilingualNeural',
786
- # Portuguese - Multilingual (ONLY Thalita)
787
- 'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural',
788
  'thalita': 'pt-BR-ThalitaMultilingualNeural',
789
  'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
790
  'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
791
- 'pt': 'pt-BR-ThalitaMultilingualNeural',
792
- 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
793
- # French - Multilingual
794
- 'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural',
795
- 'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural',
796
- 'denise': 'fr-FR-VivienneMultilingualNeural',
797
- 'fr': 'fr-FR-VivienneMultilingualNeural',
798
- 'fr_denise': 'fr-FR-VivienneMultilingualNeural',
799
- # German - Multilingual
800
- 'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural',
801
- 'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural',
802
- 'katja': 'de-DE-SeraphinaMultilingualNeural',
803
- 'de': 'de-DE-SeraphinaMultilingualNeural',
804
- 'de_katja': 'de-DE-SeraphinaMultilingualNeural',
805
- # Korean - Multilingual (Hyunsu, NOT SunHee)
806
- 'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural',
807
- 'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural',
808
- 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
809
- 'ko': 'ko-KR-HyunsuMultilingualNeural',
810
- 'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural',
811
- # Italian - Multilingual
812
- 'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural',
813
- # Spanish (keep for backward compat)
814
- 'ela': 'en-US-AndrewMultilingualNeural',
815
- 'es_ela': 'en-US-AndrewMultilingualNeural',
816
- 'es': 'en-US-AndrewMultilingualNeural',
817
- 'es_carlos': 'en-US-AndrewMultilingualNeural',
818
- # Japanese (keep for backward compat)
819
- 'nanami': 'en-US-AndrewMultilingualNeural',
820
- 'ja': 'en-US-AndrewMultilingualNeural',
821
- 'ja_nanami': 'en-US-AndrewMultilingualNeural',
822
- # Chinese (keep for backward compat)
823
- 'xiaochen': 'en-US-AndrewMultilingualNeural',
824
- 'zh': 'en-US-AndrewMultilingualNeural',
825
- 'zh_xiaochen': 'en-US-AndrewMultilingualNeural',
826
  }.get(voice, 'vi-VN-HoaiMyNeural')
827
  part_files=[]
828
  for idx, seg in enumerate(segments):
@@ -841,7 +709,7 @@ async def patched_ai_short(post_id: str, request: Request):
841
  except TypeError:
842
  base.gTTS(spoken, lang='vi', slow=False).save(aud)
843
  subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
844
- dur=_estimate_audio_duration(aud_fast, fallback=15.0)+0.35
845
  subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
846
  part_files.append(part)
847
  concat=os.path.join(work,'concat.txt')
 
42
  return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
43
 
44
 
45
+ def _dedupe_units(units, max_units=7):
 
46
  out, seen = [], set()
47
  for u in units:
48
  u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
49
  if len(u) < 18:
50
  continue
51
  nu = _norm(u)
 
52
  if nu in seen:
53
  continue
54
+ if any(_similar(u, old) for old in out):
55
+ continue
56
  seen.add(nu)
57
  out.append(u)
58
  if len(out) >= max_units:
 
60
  return out
61
 
62
 
63
+ def _postprocess_ai_text(text, max_units=7):
64
  text = _clean(text)
65
  if not text:
66
  return text
 
79
  raw_lines.append(line)
80
  units = []
81
  for line in raw_lines:
82
+ if len(line) > 260:
83
+ units.extend(re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", line))
84
+ else:
85
+ units.append(line)
86
  units = _dedupe_units(units, max_units=max_units)
87
  if not units:
88
  return text[:900]
 
269
  errors.append("missing HF_TOKEN")
270
  base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
271
  print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
272
+ return _fallback_summary_from_prompt(prompt, max_units=6)
273
 
274
 
275
  if not hasattr(base, "_original_qwen_generate"):
 
322
 
323
  Nội dung bài:
324
  {art['raw'][:14000]}"""
325
+ text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=900)
326
+ text = _postprocess_ai_text(text, max_units=6)
327
  src = [art['source']]
328
  if 'Nguồn tham khảo:' not in text:
329
  text += "\n\n" + _source_line(src)
 
348
  if len(raw) < 120:
349
  return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
350
  prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
351
+ text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
352
+ text = _postprocess_ai_text(text, max_units=6)
353
  src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
354
  if 'Nguồn tham khảo:' not in text:
355
  text += "\n\n" + _source_line(src)
 
358
  return JSONResponse({'post': post})
359
 
360
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  @app.post('/api/rewrite_share')
362
  async def compat_rewrite_share(request: Request):
363
  body = await request.json()
 
372
  if len(raw) < 120:
373
  return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
374
  prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
375
+ text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
376
+ text = _postprocess_ai_text(text, max_units=6)
377
  src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
378
  if 'Nguồn tham khảo:' not in text:
379
  text += "\n\n" + _source_line(src)
380
  post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
381
  posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
382
+ return JSONResponse({'post': post})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
 
384
 
385
  def _emotion_script(text, emotion):
386
+ """Prepend emotion-appropriate prefix to text based on emotion type."""
 
 
 
 
387
  text = _clean(text)
388
+ prefixes = {
389
+ 'urgent': 'Tin nhanh. ',
390
+ 'warm': 'Câu chuyện đáng chú ý. ',
391
+ 'serious': 'Bản tin nghiêm túc. ',
392
+ 'energetic': 'Cập nhật nổi bật. ',
393
+ 'happy': 'Tin vui! ',
394
+ 'sad': 'Tin buồn. ',
395
+ 'humorous': 'Chút hài hước. ',
396
+ 'neutral': '',
397
+ }
398
+ prefix = prefixes.get(emotion, '')
399
+ return prefix + text
400
 
401
 
402
  def _tts_script_smart(post, emotion):
 
405
  raw = re.sub(r"\s*\n\s*", ". ", raw)
406
  raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
407
  raw = re.sub(r"\n{2,}", "\n", raw).strip()
408
+ raw = _emotion_script(raw, emotion)
409
+ if len(raw) > 1000:
410
+ raw = raw[:1000]
 
411
  cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
412
+ if cut > 350:
413
  raw = raw[:cut + 1]
414
  return raw
415
 
 
524
 
525
 
526
 
527
+ def _summary_segments_from_post(post, max_segments=7):
528
  raw = _clean(post.get('text') or post.get('title') or '')
529
  raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
530
  raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
 
535
  low=ln.lower()
536
  if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
537
  if len(ln)>=18: lines.append(ln)
538
+ if len(lines)<2:
539
  lines=[]
540
  for s in re.split(r'(?<=[\.\!\?])\s+', raw):
541
  s=_clean(s)
 
587
  draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
588
  draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
589
  y=940; maxw=W-96
590
+ for ln in _wrap_text_px(draw, segment, font_seg, maxw, 8):
 
591
  draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
592
  y+=74
593
  if y>1500: break
 
599
  bg.save(out_path, quality=92)
600
 
601
 
602
+ def _estimate_audio_duration(path, fallback=4.0):
 
603
  try:
604
  pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
605
+ return max(1.5, float((pr.stdout or b'').decode().strip() or fallback))
606
  except Exception:
607
  return fallback
608
 
 
615
  body = {}
616
  voice = str(body.get('voice', 'nu')).strip().lower()
617
  emotion = str(body.get('emotion', 'neutral')).strip().lower()
618
+ speed = float(body.get('speed', 1.2) or 1.2)
619
  speed = max(0.85, min(1.35, speed))
620
 
621
  posts = base._load_ai_wall()
 
623
  if not post:
624
  return JSONResponse({'error': 'post not found'}, status_code=404)
625
 
626
+ segments = _summary_segments_from_post(post, max_segments=7)
627
  seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
628
  os.makedirs(base.SHORTS_DIR, exist_ok=True)
629
  suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
 
647
  base._download_image(post.get('img'), post.get('title', 'AI news'), img)
648
  edge_voice = {
649
  # Vietnamese
 
 
 
 
650
  'nam': 'vi-VN-NamMinhNeural',
651
  'male': 'vi-VN-NamMinhNeural',
652
  'nu': 'vi-VN-HoaiMyNeural',
653
  'female': 'vi-VN-HoaiMyNeural',
654
  'mien-nam': 'vi-VN-HoaiMyNeural',
655
+ 'hoaimy': 'vi-VN-HoaiMyNeural',
656
+ 'namminh': 'vi-VN-NamMinhNeural',
657
+ # Multilingual - Andrew
658
+ 'andrew': 'en-US-AndrewNeural',
659
+ 'en_andrew': 'en-US-AndrewNeural',
660
+ # Multilingual - Jenny
661
+ 'jenny': 'en-US-JennyNeural',
662
+ 'en_jenny': 'en-US-JennyNeural',
663
+ # Portuguese - Thalita Multilingual
664
  'thalita': 'pt-BR-ThalitaMultilingualNeural',
665
  'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
666
  'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
667
+ 'pt': 'pt-BR-FranciscoNeural',
668
+ 'pt_francisco': 'pt-BR-FranciscoNeural',
669
+ # Spanish
670
+ 'ela': 'es-ES-ElaNeural',
671
+ 'es_ela': 'es-ES-ElaNeural',
672
+ 'es': 'es-ES-CarlosNeural',
673
+ 'es_carlos': 'es-ES-CarlosNeural',
674
+ # French
675
+ 'denise': 'fr-FR-DeniseNeural',
676
+ 'fr': 'fr-FR-DeniseNeural',
677
+ 'fr_denise': 'fr-FR-DeniseNeural',
678
+ # German
679
+ 'katja': 'de-DE-KatjaNeural',
680
+ 'de': 'de-DE-KatjaNeural',
681
+ 'de_katja': 'de-DE-KatjaNeural',
682
+ # Japanese
683
+ 'nanami': 'ja-JP-NanamiNeural',
684
+ 'ja': 'ja-JP-NanamiNeural',
685
+ 'ja_nanami': 'ja-JP-NanamiNeural',
686
+ # Korean
687
+ 'sunhee': 'ko-KR-SunHeeNeural',
688
+ 'ko': 'ko-KR-SunHeeNeural',
689
+ 'ko_sunhee': 'ko-KR-SunHeeNeural',
690
+ # Chinese
691
+ 'xiaochen': 'zh-CN-XiaochenNeural',
692
+ 'zh': 'zh-CN-XiaochenNeural',
693
+ 'zh_xiaochen': 'zh-CN-XiaochenNeural',
 
 
 
 
 
 
 
 
694
  }.get(voice, 'vi-VN-HoaiMyNeural')
695
  part_files=[]
696
  for idx, seg in enumerate(segments):
 
709
  except TypeError:
710
  base.gTTS(spoken, lang='vi', slow=False).save(aud)
711
  subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
712
+ dur=_estimate_audio_duration(aud_fast, fallback=4.0)+0.35
713
  subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
714
  part_files.append(part)
715
  concat=os.path.join(work,'concat.txt')
app_v2_entry.py CHANGED
@@ -21,7 +21,6 @@ from bs4 import BeautifulSoup
21
  import re, html as html_lib, json, threading, time, uuid
22
  from concurrent.futures import ThreadPoolExecutor, as_completed
23
  from urllib.parse import quote
24
- import asyncio
25
 
26
  HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
27
 
@@ -889,142 +888,55 @@ def detect_language_and_emotion(title, text):
889
  emotion = detect_emotion(combined, lang)
890
  return lang, emotion
891
 
892
- # Voice selection based on language and emotion (using MultilingualNeural voices)
893
  VOICE_BY_LANG_EMOTION = {
894
  'vietnamese': {
895
- 'happy': ('vi-VN-HoaiMyNeural', 'vui'),
896
- 'sad': ('vi-VN-NamMinhNeural', 'buồn'),
897
- 'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
898
- 'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
899
- 'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
900
- 'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
901
  },
902
  'portuguese': {
903
- 'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
904
- 'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
905
- 'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
906
- 'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
907
- 'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
908
- 'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
909
  },
910
  'english': {
911
- 'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
912
- 'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
913
- 'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
914
- 'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
915
- 'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
916
- 'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
917
  },
918
- 'french': {
919
- 'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
920
- 'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
921
- 'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
922
- 'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
923
- 'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
924
- 'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
925
- },
926
- 'german': {
927
- 'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
928
- 'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
929
- 'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
930
- 'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
931
- 'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
932
- 'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
933
- },
934
- 'korean': {
935
- 'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
936
- 'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
937
- 'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
938
- 'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
939
- 'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
940
- 'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
941
- },
942
- 'italian': {
943
- 'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
944
- 'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
945
- 'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
946
- 'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
947
- 'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
948
- 'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
949
  },
950
  }
951
 
952
- # All valid voice IDs (new MultilingualNeural format)
953
- VALID_VOICES = {
954
- 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
955
- 'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
956
- 'pt-BR-ThalitaMultilingualNeural',
957
- 'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
958
- 'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
959
- 'ko-KR-HyunsuMultilingualNeural',
960
- 'it-IT-GiuseppeMultilingualNeural',
961
- }
962
-
963
  def get_voice_for_content(title, text, preferred_voice=None):
964
  """Get appropriate voice based on content language and emotion."""
965
- # Accept the new MultilingualNeural voices directly
966
- if preferred_voice and preferred_voice in VALID_VOICES:
967
  return preferred_voice
968
 
969
- # Also accept old shorthand voice IDs and map them to new format
970
- old_voice_map = {
971
- 'hoaimy': 'vi-VN-HoaiMyNeural',
972
- 'namminh': 'vi-VN-NamMinhNeural',
973
- 'andrew': 'en-US-AndrewMultilingualNeural',
974
- 'jenny': 'en-US-AndrewMultilingualNeural',
975
- 'thalita': 'pt-BR-ThalitaMultilingualNeural',
976
- 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
977
- 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
978
- 'ela': 'en-US-AndrewMultilingualNeural',
979
- 'es_carlos': 'en-US-AndrewMultilingualNeural',
980
- 'denise': 'fr-FR-VivienneMultilingualNeural',
981
- 'katja': 'de-DE-SeraphinaMultilingualNeural',
982
- 'nanami': 'en-US-AndrewMultilingualNeural',
983
- 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
984
- 'xiaochen': 'en-US-AndrewMultilingualNeural',
985
- }
986
- if preferred_voice and preferred_voice in old_voice_map:
987
- return old_voice_map[preferred_voice]
988
-
989
  lang, emotion = detect_language_and_emotion(title, text)
990
  lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
991
  voice, _ = lang_map.get(emotion, lang_map['neutral'])
992
  return voice
993
 
994
 
995
- def _is_relevant_image(img_url, title, text):
996
- """Check if an image is relevant to the article content."""
997
- if not img_url:
998
- return False
999
- skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
1000
- 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
1001
- 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
1002
- img_lower = img_url.lower()
1003
- for p in skip_patterns:
1004
- if p in img_lower:
1005
- return False
1006
- if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
1007
- return False
1008
- return True
1009
-
1010
-
1011
- def _filter_relevant_images(images, title, text, max_images=8):
1012
- """Filter and rank images by relevance to article content."""
1013
- if not images:
1014
- return []
1015
- seen = set()
1016
- relevant = []
1017
- for img in images:
1018
- if img in seen:
1019
- continue
1020
- seen.add(img)
1021
- if _is_relevant_image(img, title, text):
1022
- relevant.append(img)
1023
- return relevant[:max_images]
1024
-
1025
-
1026
  def _scrape_article_for_rewrite(url):
1027
- """Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
1028
  try:
1029
  r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
1030
  r.encoding = 'utf-8'
@@ -1047,10 +959,10 @@ def _scrape_article_for_rewrite(url):
1047
  if not block:
1048
  block = soup.body or soup
1049
  paragraphs = []
1050
- all_images = []
1051
  seen_imgs = set()
1052
  if og_img and og_img not in seen_imgs:
1053
- all_images.append(og_img)
1054
  seen_imgs.add(og_img)
1055
  for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
1056
  if el.name == 'p':
@@ -1065,65 +977,29 @@ def _scrape_article_for_rewrite(url):
1065
  if src.startswith('//'):
1066
  src = 'https:' + src
1067
  if src not in seen_imgs:
1068
- all_images.append(src)
1069
  seen_imgs.add(src)
1070
- # Filter to relevant images only
1071
- relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
1072
- return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
1073
  except Exception:
1074
  return None
1075
 
1076
 
1077
  def _extract_key_points_rw(paragraphs, max_points=5):
1078
- """Extract key points from paragraphs - extracts ALL sentences, not just first one.
1079
-
1080
- Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
1081
- Now splits on all sentence boundaries and takes valid sentences until max_points.
1082
- """
1083
  points = []
1084
-
1085
  for p in paragraphs:
1086
  if len(points) >= max_points:
1087
  break
1088
-
1089
- p = _clean(p)
1090
- if not p:
 
 
 
1091
  continue
1092
-
1093
- # Split paragraph into sentences using Vietnamese + English punctuation
1094
- sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
1095
- sentences = [s.strip() for s in sentences if s.strip()]
1096
-
1097
- for sentence in sentences:
1098
- if len(points) >= max_points:
1099
- break
1100
-
1101
- # Clean sentence - remove extra whitespace
1102
- sentence = _clean(sentence)
1103
-
1104
- if len(sentence) < 30:
1105
- continue
1106
-
1107
- # Check for duplicates
1108
- if any(sentence[:60] in existing for existing in points):
1109
- continue
1110
-
1111
- # Ensure sentence ends with punctuation
1112
- if not sentence.endswith(('.', '!', '?')):
1113
- sentence = sentence + '.'
1114
-
1115
- points.append(sentence)
1116
-
1117
- # If no valid sentences found, take chunks from raw text
1118
- if not points:
1119
- raw = '\n'.join(paragraphs)
1120
- for i in range(0, min(len(raw), max_points * 300), 280):
1121
- chunk = _clean(raw[i:i+280])
1122
- if len(chunk) >= 30 and chunk not in points:
1123
- points.append(chunk + ('.' if not chunk.endswith('.') else ''))
1124
- if len(points) >= max_points:
1125
- break
1126
-
1127
  return points
1128
 
1129
 
@@ -1133,7 +1009,6 @@ async def api_rewrite_slide(request: Request):
1133
  body = await request.json()
1134
  url = _clean(body.get("url", ""))
1135
  context = body.get("context", "")
1136
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1137
  if not url and not context:
1138
  return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1139
  data = None
@@ -1144,7 +1019,7 @@ async def api_rewrite_slide(request: Request):
1144
  data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1145
  if not data or not data.get('paragraphs'):
1146
  return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1147
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1148
  if not points:
1149
  return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
1150
  images = data.get('images', [])
@@ -1158,8 +1033,7 @@ async def api_rewrite_slide(request: Request):
1158
 
1159
  # Auto-detect language and emotion
1160
  lang, emotion = detect_language_and_emotion(data['title'], summary_text)
1161
- # Use preferred voice if provided, otherwise auto-detect
1162
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
1163
 
1164
  post = {
1165
  "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
@@ -1188,7 +1062,6 @@ async def api_rewrite_share(request: Request):
1188
  body = await request.json()
1189
  url = _clean(body.get("url", ""))
1190
  ctx = _clean(body.get("context", ""))
1191
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1192
  if not url and not ctx:
1193
  return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1194
  data = None
@@ -1221,14 +1094,14 @@ async def api_rewrite_share(request: Request):
1221
  except Exception:
1222
  pass
1223
  if not ai_text or len(ai_text) < 80:
1224
- key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12)
1225
  if key_pts:
1226
  ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
1227
  else:
1228
  ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
1229
 
1230
  # Build slides from key points (FIX: include slides in rewrite_share too!)
1231
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1232
  images = data.get('images', [])
1233
  slides = []
1234
  for i, point in enumerate(points):
@@ -1239,8 +1112,7 @@ async def api_rewrite_share(request: Request):
1239
 
1240
  # Auto-detect language and emotion
1241
  lang, emotion = detect_language_and_emotion(data['title'], ai_text)
1242
- # Use preferred voice if provided, otherwise auto-detect
1243
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
1244
 
1245
  post = {
1246
  "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
@@ -1283,279 +1155,4 @@ def _bg():
1283
  time.sleep(90)
1284
  threading.Thread(target=_bg,daemon=True).start()
1285
 
1286
- # ===== AUTO SCHEDULER: rewrite AI + short at 7/13/19 VN time =====
1287
- _AUTO_SCHEDULE_TIMES = [(7, '07:00'), (13, '13:00'), (19, '19:00')]
1288
- _AUTO_LOG = os.path.join(DATA_DIR, 'auto_rewrite_log.json')
1289
-
1290
- def _load_auto_log():
1291
- try:
1292
- if os.path.exists(_AUTO_LOG):
1293
- with open(_AUTO_LOG, 'r') as f:
1294
- return json.load(f)
1295
- except: pass
1296
- return {}
1297
-
1298
- def _save_auto_log(log):
1299
- try:
1300
- tmp = _AUTO_LOG + '.tmp'
1301
- with open(tmp, 'w') as f:
1302
- json.dump(log, f)
1303
- os.replace(tmp, _AUTO_LOG)
1304
- except: pass
1305
-
1306
- async def _auto_fetch_short(post_id):
1307
- """Try to auto-generate a short for a post."""
1308
- try:
1309
- import httpx
1310
- async with httpx.AsyncClient(timeout=180) as cl:
1311
- r = await cl.post(
1312
- f"http://localhost:7860/api/ai/short/{post_id}",
1313
- json={"voice":"vi-VN-HoaiMyNeural","emotion":"neutral","speed":1.2},
1314
- headers={"Content-Type":"application/json"}
1315
- )
1316
- if r.status_code < 300:
1317
- sj = r.json()
1318
- if sj.get('video'):
1319
- posts = _load_wall_posts()
1320
- for p in posts:
1321
- if p.get('id') == post_id:
1322
- p['video'] = sj['video']
1323
- break
1324
- _save_wall_posts(posts)
1325
- return True
1326
- except: pass
1327
- return False
1328
-
1329
- async def _auto_rewrite_one(topic, slot_label, used_urls=None):
1330
- """Rewrite one topic: find articles, summarize, post to wall, trigger short.
1331
- used_urls: shared set to avoid duplicate articles across topics."""
1332
- from urllib.parse import quote as _q
1333
- items = _search_all(topic, limit=6)
1334
- # Skip URLs already used by another topic
1335
- if used_urls is not None:
1336
- filtered = [it for it in items if it.get('url') not in used_urls]
1337
- if filtered:
1338
- items = filtered
1339
- if not items:
1340
- # fallback: Google RSS
1341
- try:
1342
- r = req.get(f"https://news.google.com/rss/search?q={_q(topic)}&hl=vi&gl=VN&ceid=VN:vi",
1343
- headers={'User-Agent':'Mozilla/5.0'}, timeout=8)
1344
- r.encoding = 'utf-8'
1345
- soup = BeautifulSoup(r.text, 'xml')
1346
- for it in soup.find_all('item')[:5]:
1347
- t = _clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
1348
- lk = it.find('link').get_text(strip=True) if it.find('link') else ''
1349
- if t and lk: items.append({'title':t,'url':lk,'via':'Google News'})
1350
- except: pass
1351
- if not items:
1352
- return False
1353
-
1354
- item = items[0] # best match
1355
- url = item.get('url', '')
1356
- title = item.get('title', topic)
1357
- if url and used_urls is not None:
1358
- used_urls.add(url)
1359
- if not url.startswith('http'):
1360
- return False
1361
-
1362
- data = _scrape_article_for_rewrite(url)
1363
- if not data or not data.get('paragraphs'):
1364
- return False
1365
-
1366
- raw_text = '\n'.join(data['paragraphs'])
1367
- ai_text = None
1368
-
1369
- # Try AI generation
1370
- try:
1371
- import ai_ext
1372
- prompt = f"Tóm tắt tin tức (tự động {slot_label}):\nTiêu đề: {data['title']}\n{raw_text[:10000]}\n\n4-6 ý chính dạng bullet. Cuối ghi nguồn."
1373
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1374
- except: pass
1375
-
1376
- if not ai_text or len(ai_text) < 80:
1377
- pts = data['paragraphs'][:6]
1378
- ai_text = '\n\n'.join([f"• {p[:300]}" for p in pts])
1379
- via = item.get('via', '') or urlparse(url).netloc.replace('www.', '')
1380
- ai_text += f"\n\nNguồn tham khảo: {via}"
1381
-
1382
- # Build slides
1383
- images = data.get('images', [])
1384
- pts = data['paragraphs'][:10]
1385
- slides = []
1386
- for i, p in enumerate(pts[:8]):
1387
- img = images[i] if i < len(images) else (images[-1] if images else data.get('og_img', ''))
1388
- slides.append({'text': p[:300], 'image': img, 'index': i + 1})
1389
-
1390
- post_id = str(int(time.time() * 1000)) + str(_random2.randint(100, 999))
1391
- post = {
1392
- "id": post_id, "title": data.get('title', title)[:200],
1393
- "text": ai_text, "img": images[0] if images else data.get('og_img', ''),
1394
- "url": url, "kind": "auto_rewrite", "slides": slides,
1395
- "images": images[:10], "video": "",
1396
- "voice": "vi-VN-HoaiMyNeural", "emotion": "neutral",
1397
- "language": "vietnamese", "ts": int(time.time()),
1398
- "auto_scheduled": True, "slot": slot_label,
1399
- }
1400
-
1401
- posts = _load_wall_posts()
1402
- posts.insert(0, post)
1403
- _save_wall_posts(posts)
1404
-
1405
- # Trigger short generation async
1406
- threading.Thread(target=lambda: asyncio.run(_auto_fetch_short(post_id)), daemon=True).start()
1407
- return True
1408
-
1409
- async def _do_scheduled_run(slot_label):
1410
- """Main scheduled run: exactly 3 posts from 3 different HOT topics, no duplicates."""
1411
- print(f"[auto] Starting scheduled rewrite for {slot_label}")
1412
-
1413
- # Get top hot topics, skip duplicates
1414
- all_topics = _get_hot_topics()
1415
- seen_topics = set()
1416
- unique_topics = []
1417
- for t in all_topics:
1418
- kw = t.get('topic', '').lower().strip()
1419
- if kw and len(kw) > 5 and kw not in seen_topics:
1420
- is_dup = False
1421
- for s in seen_topics:
1422
- # Check if one topic is substring of another
1423
- if kw in s or s in kw:
1424
- is_dup = True
1425
- break
1426
- if not is_dup:
1427
- seen_topics.add(kw)
1428
- unique_topics.append(t)
1429
- if len(unique_topics) >= 3:
1430
- break
1431
-
1432
- job_topics = [t['topic'] for t in unique_topics[:3] if t.get('topic')]
1433
- if not job_topics:
1434
- print(f"[auto] No hot topics found, skipping")
1435
- return
1436
-
1437
- print(f"[auto] Running 3 topics: {job_topics}")
1438
-
1439
- # Track used URLs to avoid cross-topic duplicates
1440
- _used_urls = set()
1441
- results = []
1442
-
1443
- for jt in job_topics:
1444
- try:
1445
- ok = await asyncio.wait_for(_auto_rewrite_one(jt, slot_label, _used_urls), timeout=120)
1446
- results.append((jt, ok))
1447
- except Exception as e:
1448
- print(f"[auto] Error on '{jt}': {e}")
1449
- results.append((jt, False))
1450
- await asyncio.sleep(2)
1451
-
1452
- # Log
1453
- from datetime import datetime, timezone, timedelta
1454
- VN_TZ_SCHED = timezone(timedelta(hours=7))
1455
- today_str = datetime.now(VN_TZ_SCHED).strftime('%Y-%m-%d')
1456
- log = _load_auto_log()
1457
- if today_str not in log: log[today_str] = {}
1458
- log[today_str][slot_label] = {
1459
- 'time': datetime.now(VN_TZ_SCHED).strftime('%H:%M:%S'),
1460
- 'count': sum(1 for _, ok in results if ok),
1461
- 'total': len(results),
1462
- }
1463
- _save_auto_log(log)
1464
- print(f"[auto] Done {slot_label}: {sum(1 for _, ok in results if ok)}/{len(results)} posts")
1465
-
1466
- def _scheduler_loop():
1467
- """Check every 60s; trigger at 7:00, 13:00, 19:00 VN time.
1468
- On startup, check for any missed slots today and run them immediately."""
1469
- time.sleep(35)
1470
- from datetime import datetime, timezone, timedelta
1471
- VN_TZ_SCHED = timezone(timedelta(hours=7))
1472
-
1473
- _last_run_date = ""
1474
- _last_run_slots = set()
1475
-
1476
- # On startup: check log for missed slots today
1477
- try:
1478
- start_now = datetime.now(VN_TZ_SCHED)
1479
- today_str = start_now.strftime('%Y-%m-%d')
1480
- current_hour = start_now.hour
1481
- current_minute = start_now.minute
1482
- log = _load_auto_log()
1483
- today_log = log.get(today_str, {})
1484
- for h, label in _AUTO_SCHEDULE_TIMES:
1485
- # Run if slot is past (either strictly earlier hour, or same hour but window has passed)
1486
- should_run = False
1487
- if h < current_hour:
1488
- should_run = True
1489
- elif h == current_hour and current_minute > 10:
1490
- should_run = True
1491
- if should_run and label not in today_log:
1492
- print(f"[auto] Detected missed slot {label} (h={h} < now={current_hour}:{current_minute}), running catch-up now")
1493
- _run_scheduled_sync(label)
1494
- _last_run_slots.add(label)
1495
- except Exception as e:
1496
- print(f"[auto] Catch-up check error: {e}")
1497
-
1498
- while True:
1499
- try:
1500
- now = datetime.now(VN_TZ_SCHED)
1501
- today = now.strftime('%Y-%m-%d')
1502
- hour = now.hour
1503
- minute = now.minute
1504
-
1505
- if today != _last_run_date:
1506
- _last_run_date = today
1507
- _last_run_slots = set()
1508
-
1509
- slot = None
1510
- for h, label in _AUTO_SCHEDULE_TIMES:
1511
- if hour == h and 0 <= minute < 5:
1512
- slot = label
1513
- break
1514
-
1515
- if slot and slot not in _last_run_slots:
1516
- _last_run_slots.add(slot)
1517
- _run_scheduled_sync(slot)
1518
- except Exception as e:
1519
- print(f"[auto] Loop error: {e}")
1520
-
1521
- time.sleep(60)
1522
-
1523
- threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
1524
-
1525
- @app.get('/api/debug/auto_schedule')
1526
- async def debug_auto_schedule(slot: str = '07:00'):
1527
- """Manually trigger auto scheduler for debugging."""
1528
- try:
1529
- # Check if we can access the data directory
1530
- log = _load_auto_log()
1531
- topics = _get_hot_topics()[:3]
1532
- job_topics = [t['topic'] for t in topics if t.get('topic')]
1533
- return JSONResponse({
1534
- "slot": slot,
1535
- "log": log,
1536
- "hot_topics": job_topics,
1537
- "wall_posts_count": len(_load_wall_posts()),
1538
- "data_dir_writable": os.access(DATA_DIR, os.W_OK) if os.path.isdir(DATA_DIR) else False,
1539
- "data_dir_exists": os.path.isdir(DATA_DIR),
1540
- })
1541
- except Exception as e:
1542
- return JSONResponse({"error": str(e)}, status_code=500)
1543
-
1544
- def _run_scheduled_sync(slot):
1545
- """Run _do_scheduled_run in a separate event loop (for background thread)."""
1546
- loop = asyncio.new_event_loop()
1547
- asyncio.set_event_loop(loop)
1548
- try:
1549
- loop.run_until_complete(_do_scheduled_run(slot))
1550
- except Exception as e:
1551
- print(f"[auto] Background run error: {e}")
1552
- finally:
1553
- loop.close()
1554
-
1555
- @app.get('/api/debug/trigger_auto')
1556
- async def debug_trigger_auto(slot: str = '19:00'):
1557
- """Trigger _do_scheduled_run in background thread (non-blocking)."""
1558
- threading.Thread(target=_run_scheduled_sync, args=(slot,), daemon=True).start()
1559
- return JSONResponse({"status": "started", "slot": slot})
1560
-
1561
- app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
 
21
  import re, html as html_lib, json, threading, time, uuid
22
  from concurrent.futures import ThreadPoolExecutor, as_completed
23
  from urllib.parse import quote
 
24
 
25
  HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
26
 
 
888
  emotion = detect_emotion(combined, lang)
889
  return lang, emotion
890
 
891
+ # Voice selection based on language and emotion
892
  VOICE_BY_LANG_EMOTION = {
893
  'vietnamese': {
894
+ 'happy': ('hoaimy', 'vui'),
895
+ 'sad': ('namminh', 'buồn'),
896
+ 'excited': ('hoaimy', 'hào hứng'),
897
+ 'humorous': ('hoaimy', 'vui'),
898
+ 'serious': ('namminh', 'nghiêm túc'),
899
+ 'neutral': ('hoaimy', 'trung_tinh'),
900
  },
901
  'portuguese': {
902
+ 'happy': ('pt_thalita', 'feliz'),
903
+ 'sad': ('thalita', 'triste'),
904
+ 'excited': ('pt_francisco', 'animado'),
905
+ 'humorous': ('pt_thalita', 'engraçado'),
906
+ 'serious': ('thalita', 'sério'),
907
+ 'neutral': ('pt_thalita', 'neutro'),
908
  },
909
  'english': {
910
+ 'happy': ('jenny', 'happy'),
911
+ 'sad': ('jenny', 'sad'),
912
+ 'excited': ('andrew', 'excited'),
913
+ 'humorous': ('jenny', 'funny'),
914
+ 'serious': ('andrew', 'serious'),
915
+ 'neutral': ('jenny', 'neutral'),
916
  },
917
+ 'spanish': {
918
+ 'happy': ('ela', 'feliz'),
919
+ 'sad': ('es_carlos', 'triste'),
920
+ 'excited': ('ela', 'emocionado'),
921
+ 'humorous': ('ela', 'gracioso'),
922
+ 'serious': ('es_carlos', 'serio'),
923
+ 'neutral': ('ela', 'neutro'),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
924
  },
925
  }
926
 
 
 
 
 
 
 
 
 
 
 
 
927
  def get_voice_for_content(title, text, preferred_voice=None):
928
  """Get appropriate voice based on content language and emotion."""
929
+ if preferred_voice and preferred_voice in ('hoaimy', 'namminh', 'andrew', 'jenny', 'thalita', 'pt_thalita', 'pt_francisco', 'ela', 'es_carlos', 'denise', 'katja', 'nanami', 'sunhee', 'xiaochen'):
 
930
  return preferred_voice
931
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
932
  lang, emotion = detect_language_and_emotion(title, text)
933
  lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
934
  voice, _ = lang_map.get(emotion, lang_map['neutral'])
935
  return voice
936
 
937
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
938
  def _scrape_article_for_rewrite(url):
939
+ """Scrape article: extract title, paragraphs, images, OG image."""
940
  try:
941
  r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
942
  r.encoding = 'utf-8'
 
959
  if not block:
960
  block = soup.body or soup
961
  paragraphs = []
962
+ images = []
963
  seen_imgs = set()
964
  if og_img and og_img not in seen_imgs:
965
+ images.append(og_img)
966
  seen_imgs.add(og_img)
967
  for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
968
  if el.name == 'p':
 
977
  if src.startswith('//'):
978
  src = 'https:' + src
979
  if src not in seen_imgs:
980
+ images.append(src)
981
  seen_imgs.add(src)
982
+ return {'title': _clean(title), 'paragraphs': paragraphs, 'images': images, 'og_img': og_img}
 
 
983
  except Exception:
984
  return None
985
 
986
 
987
  def _extract_key_points_rw(paragraphs, max_points=5):
988
+ """Extract key points from paragraphs."""
 
 
 
 
989
  points = []
 
990
  for p in paragraphs:
991
  if len(points) >= max_points:
992
  break
993
+ m = re.match(r'^(.+?[.!?])\s', p)
994
+ if m:
995
+ sentence = m.group(1)
996
+ else:
997
+ sentence = p[:150] + ('.' if not p.endswith('.') else '')
998
+ if len(sentence) < 30:
999
  continue
1000
+ if any(sentence[:50] in existing for existing in points):
1001
+ continue
1002
+ points.append(sentence)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1003
  return points
1004
 
1005
 
 
1009
  body = await request.json()
1010
  url = _clean(body.get("url", ""))
1011
  context = body.get("context", "")
 
1012
  if not url and not context:
1013
  return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1014
  data = None
 
1019
  data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1020
  if not data or not data.get('paragraphs'):
1021
  return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1022
+ points = _extract_key_points_rw(data['paragraphs'], max_points=6)
1023
  if not points:
1024
  return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
1025
  images = data.get('images', [])
 
1033
 
1034
  # Auto-detect language and emotion
1035
  lang, emotion = detect_language_and_emotion(data['title'], summary_text)
1036
+ voice = get_voice_for_content(data['title'], summary_text)
 
1037
 
1038
  post = {
1039
  "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
 
1062
  body = await request.json()
1063
  url = _clean(body.get("url", ""))
1064
  ctx = _clean(body.get("context", ""))
 
1065
  if not url and not ctx:
1066
  return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1067
  data = None
 
1094
  except Exception:
1095
  pass
1096
  if not ai_text or len(ai_text) < 80:
1097
+ key_pts = _extract_key_points_rw(data['paragraphs'], max_points=6)
1098
  if key_pts:
1099
  ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
1100
  else:
1101
  ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
1102
 
1103
  # Build slides from key points (FIX: include slides in rewrite_share too!)
1104
+ points = _extract_key_points_rw(data['paragraphs'], max_points=6)
1105
  images = data.get('images', [])
1106
  slides = []
1107
  for i, point in enumerate(points):
 
1112
 
1113
  # Auto-detect language and emotion
1114
  lang, emotion = detect_language_and_emotion(data['title'], ai_text)
1115
+ voice = get_voice_for_content(data['title'], ai_text)
 
1116
 
1117
  post = {
1118
  "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
 
1155
  time.sleep(90)
1156
  threading.Thread(target=_bg,daemon=True).start()
1157
 
1158
+ app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
main.py CHANGED
@@ -1,4 +1,4 @@
1
- """VNEWS - FastAPI backend with livescore + xemlaibongda highlights + VTV channels"""
2
  import re, time, subprocess, json, os, threading
3
  import html as html_lib
4
  from datetime import datetime, timezone, timedelta
@@ -14,29 +14,38 @@ from bs4 import BeautifulSoup
14
 
15
  app = FastAPI()
16
 
 
 
17
  # ===== WORLD CUP 2026 SCRAPER =====
18
  from wc2026_scraper import get_wc2026_all, scrape_fixtures, scrape_standings, scrape_stats, scrape_wc_news
19
 
20
  # ===== RATE LIMITING =====
21
- _rate_limit_data = defaultdict(list)
22
  _rate_limit_lock = threading.Lock()
23
- RATE_LIMIT_MAX = 60
24
- RATE_LIMIT_WINDOW = 60
25
 
26
  def _check_rate_limit(ip: str) -> bool:
 
27
  with _rate_limit_lock:
28
  now = time.time()
 
29
  _rate_limit_data[ip] = [t for t in _rate_limit_data[ip] if now - t < RATE_LIMIT_WINDOW]
30
- if len(_rate_limit_data[ip]) >= RATE_LIMIT_MAX: return False
 
31
  _rate_limit_data[ip].append(now)
32
  return True
33
 
34
  @app.middleware("http")
35
  async def rate_limit_middleware(request: Request, call_next):
 
 
36
  if request.url.path.startswith("/api/"):
37
  ip = request.client.host
38
- if not _check_rate_limit(ip): return JSONResponse({"error": "rate limit exceeded"}, status_code=429)
39
- return await call_next(request)
 
 
40
 
41
  # ===== VTV CHANNELS API =====
42
  from vtv_api import router as vtv_router
@@ -50,6 +59,63 @@ _cache_ttl = 300
50
  _cache_ttl_live = 60
51
  _cache_ttl_yt = 1800
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
54
  LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
55
  HL_LEAGUES = {
@@ -136,84 +202,215 @@ def proxy_video(url: str = Query(...), request: Request = None):
136
  @app.get("/api/proxy/img")
137
  def proxy_img(url: str = Query(...)):
138
  try:
139
- from urllib.parse import urlparse
140
- _u = urlparse(url); _host = _u.netloc.lower()
141
- _referer = "https://dantri.com.vn/"
142
- if "refooty" in _host or "xemlaibongda" in _host: _referer = "https://xemlaibongda.top/"
143
- elif "ytimg" in _host or "youtube" in _host: _referer = "https://www.youtube.com/"
144
- elif "vncecdn" in _host or "vnexpress" in _host: _referer = "https://vnexpress.net/"
145
- r = requests.get(url, headers={**HEADERS, "Referer": _referer}, timeout=10)
146
  if r.status_code != 200: return Response(status_code=502)
147
- return Response(content=r.content, media_type=r.headers.get("Content-Type", "image/jpeg"), headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"})
 
148
  except: return Response(status_code=502)
149
 
150
  # ===== XEMLAIBONGDA HIGHLIGHTS =====
151
  def _scrape_xemlaibongda_page(page_path, limit=20):
 
 
 
 
152
  try:
153
  url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
154
  r = requests.get(url, headers=HEADERS, timeout=15)
155
- if r.status_code != 200: return []
 
156
  r.encoding = "utf-8"
157
  soup = BeautifulSoup(r.text, "lxml")
158
- videos = []; seen = set()
 
 
159
  for a in soup.find_all("a", href=True):
160
  href = a.get("href", "")
161
- if "/video/" not in href and "/xem-lai/" not in href: continue
162
- if not href.startswith("http"): href = "https://xemlaibongda.top" + href
 
 
 
 
 
163
  clean_href = href.split("?")[0].split("#")[0]
164
- if clean_href in seen: continue
 
165
  seen.add(clean_href)
 
 
166
  img_src = ""
167
  img = a.find("img")
168
- if not img and a.parent: img = a.parent.find("img")
 
169
  if not img:
170
  p = a.parent
171
  for _ in range(4):
172
- if p and p.find("img"): img = p.find("img"); break
173
- p = p.parent if p else None
174
- if img:
175
- img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", "") or img.get("data-thumb", "") or img.get("data-image", ""))
176
- if img_src.startswith("//"): img_src = "https:" + img_src
177
- elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
178
- if not img_src:
179
- p = a.parent
180
- for _ in range(5):
181
- if p is None: break
182
- style = p.get("style", "")
183
- bg_match = re.search(r'url\(["\']?(.*?)["\']?\)', style)
184
- if bg_match:
185
- img_src = bg_match.group(1)
186
- if img_src.startswith("//"): img_src = "https:" + img_src
187
- elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
188
  break
189
  p = p.parent if p else None
 
 
 
 
 
 
 
 
 
 
190
  title = ""
 
191
  for attr in ["title", "aria-label"]:
192
  val = a.get(attr, "")
193
- if val and len(val) >= 5: title = val; break
 
 
 
 
194
  if not title:
195
  for selector in ["h3", "h2", "h4", ".title", ".video-title", "strong"]:
196
  try:
197
  el = a.select_one(selector)
198
- if el: t = el.get_text(strip=True)
199
- if t and len(t) >= 5: title = t; break
200
- except: pass
 
 
 
 
 
 
201
  if not title:
202
  text = a.get_text(strip=True)
203
- if text and len(text) >= 5: title = text[:100]
 
 
 
204
  if not title or len(title) < 3:
205
  slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
206
  title = slug.replace("-", " ").replace("_", " ").title()
207
  title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip()
208
- if not title or len(title) < 3: continue
 
 
 
 
209
  if not img_src:
210
  slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
211
  img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg"
212
- videos.append({"title": title[:100], "link": clean_href, "img": img_src, "source": "xemlaibongda"})
213
- if len(videos) >= limit: break
 
 
 
 
 
 
 
 
 
214
  return videos
215
  except Exception as e:
216
- print(f"[xemlaibongda] Error: {e}"); return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
  def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
219
  def scrape_highlights_by_league(league_key):
@@ -225,34 +422,95 @@ def scrape_all_league_highlights():
225
  with ThreadPoolExecutor(8) as ex:
226
  futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
227
  for f in as_completed(futs, timeout=25):
228
- try: key, vids = f.result()
229
- except: continue
230
- if vids: results[key] = vids
 
231
  return results
232
 
233
  def extract_xemlaibongda_video(url):
234
  try:
235
  r=requests.get(url, headers=HEADERS, timeout=15)
236
  if r.status_code!=200: return None
237
- r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml")
238
- og=soup.find("meta",property="og:image")
239
- og_poster=og.get("content","") if og else ""
240
- if og_poster.startswith("//"): og_poster="https:"+og_poster
241
- video=soup.find("video")
242
  if video:
243
  src=video.get("src",""); poster=video.get("poster","")
244
  if not src:
245
  source=video.find("source")
246
  if source: src=source.get("src","")
247
- if not poster: poster=og_poster
248
  if src: return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"}
249
  m3u8s=re.findall(r'(https?://[^\s"\'<>]+\.m3u8)',r.text)
250
- if m3u8s: return{"src":m3u8s[0],"poster":og_poster,"type":"hls"}
251
- yt_iframe = soup.find("iframe", src=re.compile(r"youtube\.com/embed|youtube-nocookie\.com/embed"))
252
- if yt_iframe: return{"src":yt_iframe.get("src",""),"poster":og_poster,"type":"youtube"}
253
  return None
254
  except: return None
255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  # ===== LIVESCORE =====
257
  @app.get("/api/livescore/live")
258
  def api_livescore_live(): return JSONResponse({"html":_cached("ls_live",lambda:fetch_bongda_api("/api/fixtures/live"),ttl=_cache_ttl_live)})
@@ -305,6 +563,41 @@ def api_livescore_featured():
305
  return None
306
  return JSONResponse(_cached("ls_featured",_f,ttl=30))
307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  @app.get("/api/highlights")
309
  def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
310
  @app.get("/api/highlights/leagues")
@@ -315,7 +608,7 @@ def api_highlights_league(league:str):
315
  return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
316
 
317
  @app.get("/api/video_url")
318
- def api_video_url(url:str=Query(...), img:str=Query(default="")):
319
  if "youtube.com" in url or "youtu.be" in url:
320
  m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
321
  if m: vid=m.group(1); return JSONResponse({"src":f"https://www.youtube.com/embed/{vid}?autoplay=1&rel=0&enablejsapi=1","poster":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","type":"youtube"})
@@ -323,39 +616,69 @@ def api_video_url(url:str=Query(...), img:str=Query(default="")):
323
  v=extract_xemlaibongda_video(url)
324
  if v:
325
  if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
326
- if not v.get("poster") and img: v["poster"] = img
327
  return JSONResponse(v)
328
  return JSONResponse({"error":"not found"})
329
 
330
  # ===== WORLD CUP 2026 API =====
331
- _wc_request_times = []; _wc_rate_limit_lock = threading.Lock()
332
- _WC_RATE_LIMIT = 10
 
 
 
333
  def _wc_rate_limit():
 
334
  global _wc_request_times
335
  with _wc_rate_limit_lock:
336
  now = time.time()
 
337
  _wc_request_times = [t for t in _wc_request_times if now - t < 60]
338
- if len(_wc_request_times) >= _WC_RATE_LIMIT: return False
 
339
  _wc_request_times.append(now)
340
  return True
341
 
342
  @app.get("/api/wc2026")
343
  def api_wc2026():
 
344
  return JSONResponse(_cached("wc2026", get_wc2026_all, ttl=_cache_ttl))
345
 
346
  @app.get("/api/wc2026/{tab}")
347
  def api_wc2026_tab(tab: str):
 
348
  valid_tabs = ["news", "fixtures", "standings", "stats", "highlights"]
349
- if tab not in valid_tabs: return JSONResponse({"error": "invalid tab"}, status_code=400)
 
 
350
  def _fetch_tab():
351
- if tab == "highlights": return scrape_highlights_by_league("world-cup")
352
- elif tab == "news": return scrape_wc_news()
353
- elif tab == "fixtures": return scrape_fixtures()
354
- elif tab == "standings": return scrape_standings()
355
- elif tab == "stats": return scrape_stats()
 
 
 
 
 
356
  return []
 
357
  return JSONResponse(_cached(f"wc2026_{tab}", _fetch_tab, ttl=_cache_ttl))
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  @app.get("/api/bdp_videos")
360
  def api_bdp_videos():
361
  def _f():
@@ -412,7 +735,8 @@ def scrape_genk_ai():
412
  for img in container.find_all("img"):
413
  s=img.get("data-src","") or img.get("src","")
414
  if s and "mediacdn" in s and "avatar" not in s and "logo" not in s: img_src=s; break
415
- if img_src: break; container=container.parent
 
416
  seen.add(href)
417
  if not img_src:
418
  try:
@@ -455,19 +779,23 @@ def api_categories():
455
  for k,(u,n) in VNE_CATS.items(): cats.append({"id":k,"name":n,"source":"vne"})
456
  return JSONResponse(cats)
457
 
 
458
  @app.get("/api/proxy/xlb")
459
- def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
460
  try:
461
  url = f"https://xemlaibongda.top/{path}" if path else "https://xemlaibongda.top/"
462
  r = requests.get(url, headers=HEADERS, timeout=15)
463
- if r.status_code != 200: return JSONResponse({"videos": []})
 
464
  r.encoding = "utf-8"
465
  soup = BeautifulSoup(r.text, "lxml")
466
  videos, seen = [], set()
467
  for a in soup.find_all("a", href=True):
468
  href = a.get("href", "")
469
- if "/video/" not in href and "/xem-lai/" not in href: continue
470
- if not href.startswith("http"): href = "https://xemlaibongda.top" + href
 
 
471
  clean = href.split("?")[0].split("#")[0]
472
  if clean in seen: continue
473
  seen.add(clean)
@@ -476,10 +804,13 @@ def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
476
  if not img:
477
  p = a.parent
478
  for _ in range(5):
479
- if p and p.find("img"): img = p.find("img"); break
 
 
480
  p = p.parent if p else None
481
  if img:
482
- img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", ""))
 
483
  if img_src.startswith("//"): img_src = "https:" + img_src
484
  elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
485
  title = a.find("h3")
@@ -494,7 +825,6 @@ def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
494
  return JSONResponse({"videos": videos})
495
  except Exception as e:
496
  return JSONResponse({"videos": [], "error": str(e)})
497
-
498
  @app.get("/api/article")
499
  def api_article(url:str=Query(...)):
500
  try:
@@ -517,4 +847,5 @@ def api_hot_topics():
517
 
518
  @app.get("/", response_class=HTMLResponse)
519
  async def root():
520
- return HTMLResponse("<h1>VNEWS v17</h1><p>VTV Digital CDN ssaimh · No shorts Dantri/SKDS · Homepage full content</p>")
 
 
1
+ """VNEWS - FastAPI backend with livescore + xemlaibongda highlights + YouTube VTV shorts"""
2
  import re, time, subprocess, json, os, threading
3
  import html as html_lib
4
  from datetime import datetime, timezone, timedelta
 
14
 
15
  app = FastAPI()
16
 
17
+ # ===== RATE LIMITING =====app = FastAPI()
18
+
19
  # ===== WORLD CUP 2026 SCRAPER =====
20
  from wc2026_scraper import get_wc2026_all, scrape_fixtures, scrape_standings, scrape_stats, scrape_wc_news
21
 
22
  # ===== RATE LIMITING =====
23
+ _rate_limit_data = defaultdict(list) # {ip: [timestamp1, timestamp2, ...]}
24
  _rate_limit_lock = threading.Lock()
25
+ RATE_LIMIT_MAX = 60 # Max requests per minute per IP
26
+ RATE_LIMIT_WINDOW = 60 # seconds
27
 
28
  def _check_rate_limit(ip: str) -> bool:
29
+ """Kiểm tra rate limit, return True nếu OK, False nếu bị limit"""
30
  with _rate_limit_lock:
31
  now = time.time()
32
+ # Xóa các request cũ
33
  _rate_limit_data[ip] = [t for t in _rate_limit_data[ip] if now - t < RATE_LIMIT_WINDOW]
34
+ if len(_rate_limit_data[ip]) >= RATE_LIMIT_MAX:
35
+ return False
36
  _rate_limit_data[ip].append(now)
37
  return True
38
 
39
  @app.middleware("http")
40
  async def rate_limit_middleware(request: Request, call_next):
41
+ """Middleware để kiểm tra rate limit"""
42
+ # Chỉ rate limit API endpoints
43
  if request.url.path.startswith("/api/"):
44
  ip = request.client.host
45
+ if not _check_rate_limit(ip):
46
+ return JSONResponse({"error": "rate limit exceeded"}, status_code=429)
47
+ response = await call_next(request)
48
+ return response
49
 
50
  # ===== VTV CHANNELS API =====
51
  from vtv_api import router as vtv_router
 
59
  _cache_ttl_live = 60
60
  _cache_ttl_yt = 1800
61
 
62
+ # ===== VTV NAM BO SHORTS FALLBACK =====
63
+ SHORTS_FALLBACK = [
64
+ {"id":"nqlLH6chLRo","title":"Tin nóng VTV Nam Bộ | #shorts","channel":"vtvnambo"},
65
+ {"id":"E7Kq0v3hG6w","title":"VTV Nam Bộ - Tin tức miền Nam | #shorts","channel":"vtvnambo"},
66
+ {"id":"Lu_iCQ5YwNM","title":"Công an lập hồ sơ xử lý người phụ nữ chửi bới tát nam tài xế ô tô ở Hà Nội","channel":"baodantri7941"},
67
+ {"id":"CwWvijF8BOA","title":"Chú rể Ninh Bình bật khóc nhận món quà bí mật người cha quá cố gửi 26 năm trước","channel":"baodantri7941"},
68
+ {"id":"tvPewsc2ph4","title":"Tính năng ẩn trên iPhone giúp giảm mỏi mắt","channel":"baodantri7941"},
69
+ {"id":"b1Nxzv9ixlU","title":"Y án 3 năm tù với nữ tài xế uống 8 lon bia lái xe tông chủ tịch xã tử vong","channel":"baodantri7941"},
70
+ {"id":"Xp5eTwAZAis","title":"Người đánh hàng xóm tại chung cư ở Hà Nội bị tuyên hơn 4 tháng tù","channel":"baodantri7941"},
71
+ {"id":"Htzvwg6iOBM","title":"Xe điện Audi S6 Sportback e-tron có gì đặc biệt?","channel":"baodantri7941"},
72
+ {"id":"iMdFmWvYdlo","title":"Cô gái người Nga yêu thời trang và đất nước Việt Nam","channel":"baodantri7941"},
73
+ {"id":"IVaRc6moEv8","title":"Người nông dân Trung Quốc đột quỵ bệnh viện giúp bán sạch 4 tấn táo","channel":"baodantri7941"},
74
+ {"id":"uVxqPxToItU","title":"Công an vào cuộc vụ người phụ nữ chửi bới hành hung tài xế ô tô ở Hà Nội","channel":"baodantri7941"},
75
+ {"id":"VAfgNNgZDRs","title":"Khởi tố 4 đối tượng ném bom xăng vào nhà dân ở Đồng Nai","channel":"baodantri7941"},
76
+ {"id":"sBH_-zGh0Xw","title":"Vì sao Times New Roman vẫn nổi tiếng sau hàng chục năm?","channel":"baodantri7941"},
77
+ {"id":"woKn5f2bLHM","title":"Quảng Ninh ngập sâu diện rộng sau đợt mưa lớn","channel":"baodantri7941"},
78
+ {"id":"bcpgRoxbLPw","title":"Giông lốc quật bay mái tôn ở TP.HCM","channel":"baodantri7941"},
79
+ {"id":"ZIIC5osy544","title":"Bé trai Trung Quốc rơi từ tầng 11 vẫn sống sót kỳ diệu","channel":"baodantri7941"},
80
+ {"id":"uTMJ49NQpyc","title":"Sau lớp mascot 40kg Câu chuyện mưu sinh của người trẻ ở TPHCM","channel":"baodantri7941"},
81
+ {"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tìm kiếm 5 học sinh tử vong ở sông Lô","channel":"baosuckhoedoisongboyte"},
82
+ {"id":"SlHLt_ZyPiE","title":"Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc Nam","channel":"baosuckhoedoisongboyte"},
83
+ {"id":"IUOprcJyYr4","title":"Phụ nữ táo bón có phải do lười ăn rau?","channel":"baosuckhoedoisongboyte"},
84
+ {"id":"YY8ojFNE-AU","title":"Quái xế tự quay clip nẹt pô đánh võng đăng TikTok bị xử lý","channel":"baosuckhoedoisongboyte"},
85
+ {"id":"OV7_oGdQGII","title":"Bố cô dâu khóc sụt sùi rồi quẩy cực sung gây bão mạng","channel":"baosuckhoedoisongboyte"},
86
+ {"id":"FoxhFyz2skY","title":"Người đàn ông nước ngoài đập phá ô tô bẻ cần gạt nước ở Đà Nẵng","channel":"baosuckhoedoisongboyte"},
87
+ {"id":"R1oC_I8dFPU","title":"Thanh niên buông tay lái đứng trên xe máy khi đổ đèo ở Đắk Lắk","channel":"baosuckhoedoisongboyte"},
88
+ {"id":"U0Ft6ChWAIo","title":"Cô giáo kể phút tháo chạy khỏi xe khách trước khi bị lũ vò nát ở Cao Bằng","channel":"baosuckhoedoisongboyte"},
89
+ {"id":"hH0ANeze_4E","title":"Liên tiếp hàng chục con bò bị sét đánh chết trong ngày mưa dông","channel":"baosuckhoedoisongboyte"},
90
+ {"id":"pXWt0QbAzRQ","title":"Va chạm giao thông người phụ nữ lăng mạ tài xế ô tô","channel":"baosuckhoedoisongboyte"},
91
+ {"id":"UWWLPY1OYt4","title":"CSGT chặn xe khách khống chế đối tượng cướp dây chuyền tại Gia Lai","channel":"baosuckhoedoisongboyte"},
92
+ {"id":"AxhVTQutsuo","title":"Xuất tinh sớm và những hiểu lầm thường gặp","channel":"baosuckhoedoisongboyte"},
93
+ {"id":"cNy6FgaNxYM","title":"Cô dâu khóc sưng mắt vì 6 chỉ vàng không cánh mày bay trong ngày cưới","channel":"baosuckhoedoisongboyte"},
94
+ {"id":"IDt_S6q59Ro","title":"Chở bạn gái không đội mũ bảo hiểm thanh niên đấm CSGT","channel":"baosuckhoedoisongboyte"},
95
+ {"id":"LFxJ9Ik6W0A","title":"Mệnh lệnh từ trái tim CSGT Hà Nội mở đường đưa bé 5 tháng tuổi đi cấp cứu","channel":"baosuckhoedoisongboyte"},
96
+ ]
97
+ for _v in SHORTS_FALLBACK:
98
+ _v.setdefault("link", "https://www.youtube.com/watch?v="+_v["id"])
99
+ _v.setdefault("img", "https://i.ytimg.com/vi/"+_v["id"]+"/hqdefault.jpg")
100
+ _v.setdefault("source", "yt")
101
+
102
+ SHORT_STATS_FILE = "/data/short_stats.json" if os.path.isdir("/data") else "/app/short_stats.json"
103
+ _short_lock = threading.Lock()
104
+ def _load_short_db():
105
+ try:
106
+ if os.path.exists(SHORT_STATS_FILE):
107
+ with open(SHORT_STATS_FILE,"r",encoding="utf-8") as f: return json.load(f)
108
+ except: pass
109
+ return {}
110
+ def _save_short_db(db):
111
+ try:
112
+ os.makedirs(os.path.dirname(SHORT_STATS_FILE), exist_ok=True)
113
+ tmp = SHORT_STATS_FILE + ".tmp"
114
+ with open(tmp,"w",encoding="utf-8") as f: json.dump(db, f, ensure_ascii=False)
115
+ os.replace(tmp, SHORT_STATS_FILE)
116
+ except: pass
117
+ def _short_default(): return {"views":0,"likes":0,"shares":0,"comments":[]}
118
+
119
  PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
120
  LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
121
  HL_LEAGUES = {
 
202
  @app.get("/api/proxy/img")
203
  def proxy_img(url: str = Query(...)):
204
  try:
205
+ r = requests.get(url, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=10)
 
 
 
 
 
 
206
  if r.status_code != 200: return Response(status_code=502)
207
+ ct = r.headers.get("Content-Type", "image/jpeg")
208
+ return Response(content=r.content, media_type=ct, headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"})
209
  except: return Response(status_code=502)
210
 
211
  # ===== XEMLAIBONGDA HIGHLIGHTS =====
212
  def _scrape_xemlaibongda_page(page_path, limit=20):
213
+ """
214
+ Scrape video từ xemlaibongda.top - Simple & Reliable
215
+ Dùng logic cũ đã test, không fetch từng trang (tránh timeout)
216
+ """
217
  try:
218
  url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
219
  r = requests.get(url, headers=HEADERS, timeout=15)
220
+ if r.status_code != 200:
221
+ return []
222
  r.encoding = "utf-8"
223
  soup = BeautifulSoup(r.text, "lxml")
224
+ videos = []
225
+ seen = set()
226
+
227
  for a in soup.find_all("a", href=True):
228
  href = a.get("href", "")
229
+ if "/video/" not in href and "/xem-lai/" not in href:
230
+ continue
231
+
232
+ if not href.startswith("http"):
233
+ href = "https://xemlaibongda.top" + href
234
+
235
+ # Bỏ query params
236
  clean_href = href.split("?")[0].split("#")[0]
237
+ if clean_href in seen:
238
+ continue
239
  seen.add(clean_href)
240
+
241
+ # ===== Lấy THUMBNAIL =====
242
  img_src = ""
243
  img = a.find("img")
244
+ if not img and a.parent:
245
+ img = a.parent.find("img")
246
  if not img:
247
  p = a.parent
248
  for _ in range(4):
249
+ if p and p.find("img"):
250
+ img = p.find("img")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  break
252
  p = p.parent if p else None
253
+
254
+ if img:
255
+ img_src = (img.get("data-src", "") or img.get("src", "") or
256
+ img.get("data-lazy", "") or img.get("data-original", ""))
257
+ if img_src.startswith("//"):
258
+ img_src = "https:" + img_src
259
+ elif img_src.startswith("/"):
260
+ img_src = "https://xemlaibongda.top" + img_src
261
+
262
+ # ===== Lấy TITLE =====
263
  title = ""
264
+ # Thử attribute
265
  for attr in ["title", "aria-label"]:
266
  val = a.get(attr, "")
267
+ if val and len(val) >= 5:
268
+ title = val
269
+ break
270
+
271
+ # Thử các selector
272
  if not title:
273
  for selector in ["h3", "h2", "h4", ".title", ".video-title", "strong"]:
274
  try:
275
  el = a.select_one(selector)
276
+ if el:
277
+ t = el.get_text(strip=True)
278
+ if len(t) >= 5:
279
+ title = t
280
+ break
281
+ except:
282
+ pass
283
+
284
+ # Thử text content
285
  if not title:
286
  text = a.get_text(strip=True)
287
+ if text and len(text) >= 5:
288
+ title = text[:100]
289
+
290
+ # Fallback: tạo title từ slug
291
  if not title or len(title) < 3:
292
  slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
293
  title = slug.replace("-", " ").replace("_", " ").title()
294
  title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip()
295
+
296
+ if not title or len(title) < 3:
297
+ continue
298
+
299
+ # Fallback thumbnail từ slug
300
  if not img_src:
301
  slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
302
  img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg"
303
+
304
+ videos.append({
305
+ "title": title[:100],
306
+ "link": clean_href,
307
+ "img": img_src,
308
+ "source": "xemlaibongda"
309
+ })
310
+
311
+ if len(videos) >= limit:
312
+ break
313
+
314
  return videos
315
  except Exception as e:
316
+ print(f"[xemlaibongda] Error: {e}")
317
+ return []
318
+
319
+ def _extract_img_src(a_tag):
320
+ """Extract image URL từ thẻ <a> và parent elements"""
321
+ img = a_tag.find("img")
322
+ if not img and a_tag.parent:
323
+ img = a_tag.parent.find("img")
324
+ if not img:
325
+ p = a_tag.parent
326
+ for _ in range(5): # Tìm sâu hơn
327
+ if p and p.find("img"):
328
+ img = p.find("img")
329
+ break
330
+ p = p.parent if p else None
331
+
332
+ if not img:
333
+ return ""
334
+
335
+ # Thử tất cả các attribute có thể chứa img URL
336
+ attrs = ["data-src", "src", "data-lazy", "data-original", "data-srcset", "data-thumb", "data-image"]
337
+ for attr in attrs:
338
+ val = img.get(attr, "")
339
+ if val:
340
+ if attr == "data-srcset":
341
+ val = val.split(",")[0].strip().split(" ")[0]
342
+ break
343
+ else:
344
+ val = ""
345
+
346
+ # Thử background-image từ style
347
+ if not val:
348
+ style = img.get("style", "") or img.get("data-bg", "")
349
+ bg_match = re.search(r'url\(["\']?(.*?)["\']?\)', style)
350
+ if bg_match:
351
+ val = bg_match.group(1)
352
+
353
+ # Normalize URL
354
+ if val.startswith("//"):
355
+ val = "https:" + val
356
+ elif val.startswith("/"):
357
+ val = "https://xemlaibongda.top" + val
358
+
359
+ return val
360
+
361
+ def _extract_title(a_tag, href):
362
+ """Extract title từ thẻ <a> và child/parent elements"""
363
+ title = ""
364
+
365
+ # 1. Thử các selector phổ biến cho title
366
+ title_selectors = [
367
+ "h3", "h2", "h4", "h5",
368
+ ".title", ".post-title", ".entry-title", ".video-title",
369
+ ".card-title", ".item-title", ".news-title",
370
+ "span.title", "strong", "b",
371
+ ".name", ".caption"
372
+ ]
373
+ for tag in title_selectors:
374
+ try:
375
+ t = a_tag.select_one(tag) if hasattr(a_tag, 'select_one') else None
376
+ if t:
377
+ title = t.get_text(" ", strip=True)
378
+ if len(title) >= 3:
379
+ return title
380
+ except:
381
+ pass
382
+
383
+ # 2. Thử attribute của <a>
384
+ for attr in ["title", "aria-label", "data-title"]:
385
+ val = a_tag.get(attr, "")
386
+ if val and len(val) >= 3:
387
+ return val
388
+
389
+ # 3. Thử alt text của img
390
+ img = a_tag.find("img")
391
+ if img:
392
+ alt = img.get("alt", "")
393
+ if alt and len(alt) >= 3:
394
+ return alt
395
+
396
+ # 4. Thử text content của <a> (loại bỏ quá dài)
397
+ text = a_tag.get_text(" ", strip=True)
398
+ if text and len(text) >= 3:
399
+ # Lấy dòng đầu tiên nếu có nhiều dòng
400
+ lines = [l.strip() for l in text.split("\n") if l.strip()]
401
+ if lines:
402
+ first_line = lines[0]
403
+ if len(first_line) >= 3:
404
+ return first_line[:100]
405
+
406
+ # 5. Fallback: tạo title từ slug
407
+ slug = href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
408
+ title = slug.replace("-", " ").replace("_", " ")
409
+ title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip()
410
+ if title:
411
+ return title.title()
412
+
413
+ return ""
414
 
415
  def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
416
  def scrape_highlights_by_league(league_key):
 
422
  with ThreadPoolExecutor(8) as ex:
423
  futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
424
  for f in as_completed(futs, timeout=25):
425
+ try:
426
+ key, vids = f.result()
427
+ if vids: results[key] = vids
428
+ except: pass
429
  return results
430
 
431
  def extract_xemlaibongda_video(url):
432
  try:
433
  r=requests.get(url, headers=HEADERS, timeout=15)
434
  if r.status_code!=200: return None
435
+ r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml"); video=soup.find("video")
 
 
 
 
436
  if video:
437
  src=video.get("src",""); poster=video.get("poster","")
438
  if not src:
439
  source=video.find("source")
440
  if source: src=source.get("src","")
 
441
  if src: return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"}
442
  m3u8s=re.findall(r'(https?://[^\s"\'<>]+\.m3u8)',r.text)
443
+ if m3u8s:
444
+ og=soup.find("meta",property="og:image"); poster=og.get("content","") if og else ""
445
+ return{"src":m3u8s[0],"poster":poster,"type":"hls"}
446
  return None
447
  except: return None
448
 
449
+ # ===== YOUTUBE SHORTS SCRAPING =====
450
+ def _yt_channel_shorts_requests(channel, count=15):
451
+ try:
452
+ url=f"https://www.youtube.com/@{channel}/shorts"
453
+ r=requests.get(url, headers={**HEADERS,"Accept-Language":"vi,en;q=0.8"}, timeout=15)
454
+ if r.status_code!=200: return []
455
+ html=r.text; ids=[]; items=[]
456
+ for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
457
+ vid=m.group(1)
458
+ if vid in ids: continue
459
+ ids.append(vid)
460
+ snip=html[max(0,m.start()-900):m.start()+1600]
461
+ title=""
462
+ mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip)
463
+ if not mt: mt=re.search(r'"accessibilityText":"([^"]+)"',snip)
464
+ if mt: title=html_lib.unescape(mt.group(1)).replace('\n',' ').strip()
465
+ if not title: title="YouTube Short"
466
+ items.append({"title":title,"link":f"https://www.youtube.com/watch?v={vid}","img":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","source":"yt","id":vid,"channel":channel})
467
+ if len(items)>=count: break
468
+ return items
469
+ except: return []
470
+
471
+ def scrape_shorts():
472
+ vids=[]
473
+ with ThreadPoolExecutor(3) as ex:
474
+ futs=[ex.submit(_yt_channel_shorts_requests,ch,24) for ch in ["baodantri7941","baosuckhoedoisongboyte","vtvnambo"]]
475
+ for f in as_completed(futs):
476
+ try:
477
+ r=f.result()
478
+ if r: vids.extend(r)
479
+ except: pass
480
+ merged=[]; seen=set()
481
+ for v in vids:
482
+ vid=v.get("id")
483
+ if not vid or vid in seen: continue
484
+ seen.add(vid); merged.append(v)
485
+ for v in SHORTS_FALLBACK:
486
+ vid=v.get("id")
487
+ if not vid or vid in seen: continue
488
+ seen.add(vid); merged.append(v)
489
+ return merged[:60]
490
+
491
+ # ===== VTV NAM BO & WC SHORTS - using yt-dlp =====
492
+ from yt_scraper import get_vtvnambo_shorts, get_wc_related_shorts
493
+
494
+ @app.get("/api/shorts/vtvnamo")
495
+ def api_shorts_vtvnamo(count: int = Query(default=50, le=100)):
496
+ items = get_vtvnambo_shorts(count)
497
+ if not items:
498
+ items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
499
+ nql = [v for v in items if v.get("id") == "nqlLH6chLRo"]
500
+ rest = [v for v in items if v.get("id") != "nqlLH6chLRo"]
501
+ items = nql + rest
502
+ return JSONResponse(items)
503
+
504
+ @app.get("/api/shorts/wc")
505
+ def api_shorts_wc(count: int = Query(default=50, le=100)):
506
+ items = get_wc_related_shorts(count)
507
+ if not items:
508
+ items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
509
+ nql = [v for v in items if v.get("id") == "nqlLH6chLRo"]
510
+ rest = [v for v in items if v.get("id") != "nqlLH6chLRo"]
511
+ items = nql + rest
512
+ return JSONResponse(items)
513
+
514
  # ===== LIVESCORE =====
515
  @app.get("/api/livescore/live")
516
  def api_livescore_live(): return JSONResponse({"html":_cached("ls_live",lambda:fetch_bongda_api("/api/fixtures/live"),ttl=_cache_ttl_live)})
 
563
  return None
564
  return JSONResponse(_cached("ls_featured",_f,ttl=30))
565
 
566
+ @app.get("/api/shorts")
567
+ def api_shorts(channel: str = Query(default="")):
568
+ if channel == "vtvnambo": return api_shorts_vtvnamo()
569
+ if channel == "wc": return api_shorts_wc()
570
+ return JSONResponse(_cached("yt_shorts_v3",scrape_shorts,ttl=_cache_ttl_yt))
571
+
572
+ @app.get("/api/short-stats")
573
+ def api_short_stats(ids:str=Query(default="")):
574
+ arr=[x for x in ids.split(",") if x]
575
+ with _short_lock:
576
+ db=_load_short_db();out={}
577
+ for vid in arr:
578
+ st=db.get(vid) or _short_default()
579
+ out[vid]={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]}
580
+ return JSONResponse({"stats":out})
581
+
582
+ @app.post("/api/short-action")
583
+ async def api_short_action(request:Request):
584
+ try: body=await request.json()
585
+ except: body={}
586
+ vid=str(body.get("id","")).strip(); action=str(body.get("action","")).strip(); txt=str(body.get("text","")).strip()
587
+ if not vid: return JSONResponse({"error":"missing id"},status_code=400)
588
+ with _short_lock:
589
+ db=_load_short_db(); st=db.get(vid) or _short_default()
590
+ if action=="view": st["views"]=int(st.get("views",0))+1
591
+ elif action=="like": st["likes"]=int(st.get("likes",0))+1
592
+ elif action=="share": st["shares"]=int(st.get("shares",0))+1
593
+ elif action=="comment" and txt:
594
+ comments=st.get("comments",[])
595
+ comments.insert(0,{"text":txt[:180],"ts":int(time.time())})
596
+ st["comments"]=comments[:80]
597
+ st["updated"]=int(time.time()); db[vid]=st; _save_short_db(db)
598
+ out={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]}
599
+ return JSONResponse({"stats":out})
600
+
601
  @app.get("/api/highlights")
602
  def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
603
  @app.get("/api/highlights/leagues")
 
608
  return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
609
 
610
  @app.get("/api/video_url")
611
+ def api_video_url(url:str=Query(...)):
612
  if "youtube.com" in url or "youtu.be" in url:
613
  m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
614
  if m: vid=m.group(1); return JSONResponse({"src":f"https://www.youtube.com/embed/{vid}?autoplay=1&rel=0&enablejsapi=1","poster":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","type":"youtube"})
 
616
  v=extract_xemlaibongda_video(url)
617
  if v:
618
  if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
 
619
  return JSONResponse(v)
620
  return JSONResponse({"error":"not found"})
621
 
622
  # ===== WORLD CUP 2026 API =====
623
+ # Rate limiting cho WC API
624
+ _wc_request_times = []
625
+ _wc_rate_limit_lock = threading.Lock()
626
+ _WC_RATE_LIMIT = 10 # Max 10 requests per minute
627
+
628
  def _wc_rate_limit():
629
+ """Kiểm tra rate limit cho WC API"""
630
  global _wc_request_times
631
  with _wc_rate_limit_lock:
632
  now = time.time()
633
+ # Xóa các request cũ hơn 60 giây
634
  _wc_request_times = [t for t in _wc_request_times if now - t < 60]
635
+ if len(_wc_request_times) >= _WC_RATE_LIMIT:
636
+ return False
637
  _wc_request_times.append(now)
638
  return True
639
 
640
  @app.get("/api/wc2026")
641
  def api_wc2026():
642
+ """Trả về tất cả dữ liệu World Cup 2026"""
643
  return JSONResponse(_cached("wc2026", get_wc2026_all, ttl=_cache_ttl))
644
 
645
  @app.get("/api/wc2026/{tab}")
646
  def api_wc2026_tab(tab: str):
647
+ """Trả về từng tab của World Cup"""
648
  valid_tabs = ["news", "fixtures", "standings", "stats", "highlights"]
649
+ if tab not in valid_tabs:
650
+ return JSONResponse({"error": "invalid tab"}, status_code=400)
651
+
652
  def _fetch_tab():
653
+ if tab == "highlights":
654
+ return scrape_highlights_by_league("world-cup")
655
+ elif tab == "news":
656
+ return scrape_wc_news()
657
+ elif tab == "fixtures":
658
+ return scrape_fixtures()
659
+ elif tab == "standings":
660
+ return scrape_standings()
661
+ elif tab == "stats":
662
+ return scrape_stats()
663
  return []
664
+
665
  return JSONResponse(_cached(f"wc2026_{tab}", _fetch_tab, ttl=_cache_ttl))
666
 
667
+ # Note: WC functions (scrape_wc_news, scrape_fixtures, scrape_stats, scrape_standings)
668
+ # are imported from wc2026_scraper.py at the top of this file
669
+
670
+ @app.get("/api/video_url")
671
+ def api_video_url(url:str=Query(...)):
672
+ if "youtube.com" in url or "youtu.be" in url:
673
+ m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
674
+ if m: vid=m.group(1); return JSONResponse({"src":f"https://www.youtube.com/embed/{vid}?autoplay=1&rel=0&enablejsapi=1","poster":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","type":"youtube"})
675
+ if "xemlaibongda.top" in url:
676
+ v=extract_xemlaibongda_video(url)
677
+ if v:
678
+ if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
679
+ return JSONResponse(v)
680
+ return JSONResponse({"error":"not found"})
681
+
682
  @app.get("/api/bdp_videos")
683
  def api_bdp_videos():
684
  def _f():
 
735
  for img in container.find_all("img"):
736
  s=img.get("data-src","") or img.get("src","")
737
  if s and "mediacdn" in s and "avatar" not in s and "logo" not in s: img_src=s; break
738
+ if img_src: break
739
+ container=container.parent
740
  seen.add(href)
741
  if not img_src:
742
  try:
 
779
  for k,(u,n) in VNE_CATS.items(): cats.append({"id":k,"name":n,"source":"vne"})
780
  return JSONResponse(cats)
781
 
782
+
783
  @app.get("/api/proxy/xlb")
784
+ def api_xlb(path: str = Query(default="", description="Path after xemlaibongda.top"), limit: int = Query(default=20)):
785
  try:
786
  url = f"https://xemlaibongda.top/{path}" if path else "https://xemlaibongda.top/"
787
  r = requests.get(url, headers=HEADERS, timeout=15)
788
+ if r.status_code != 200:
789
+ return JSONResponse({"videos": []})
790
  r.encoding = "utf-8"
791
  soup = BeautifulSoup(r.text, "lxml")
792
  videos, seen = [], set()
793
  for a in soup.find_all("a", href=True):
794
  href = a.get("href", "")
795
+ if "/video/" not in href and "/xem-lai/" not in href:
796
+ continue
797
+ if not href.startswith("http"):
798
+ href = "https://xemlaibongda.top" + href
799
  clean = href.split("?")[0].split("#")[0]
800
  if clean in seen: continue
801
  seen.add(clean)
 
804
  if not img:
805
  p = a.parent
806
  for _ in range(5):
807
+ if p and p.find("img"):
808
+ img = p.find("img")
809
+ break
810
  p = p.parent if p else None
811
  if img:
812
+ img_src = (img.get("data-src", "") or img.get("src", "") or
813
+ img.get("data-lazy", "") or img.get("data-original", ""))
814
  if img_src.startswith("//"): img_src = "https:" + img_src
815
  elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
816
  title = a.find("h3")
 
825
  return JSONResponse({"videos": videos})
826
  except Exception as e:
827
  return JSONResponse({"videos": [], "error": str(e)})
 
828
  @app.get("/api/article")
829
  def api_article(url:str=Query(...)):
830
  try:
 
847
 
848
  @app.get("/", response_class=HTMLResponse)
849
  async def root():
850
+ return HTMLResponse("<h1>VNEWS</h1><p>Running</p>")
851
+ # v14 rebuild 2026-06-18T12:21:41.503230
rewrite_fix_v2.js CHANGED
@@ -1,2 +1,240 @@
1
- // No-op - all functionality built into app_v2.js
2
- (function(){})();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Fix rewriteArticle - call correct endpoint for VNEWS
2
+ // Uses /api/rewrite_share which now returns {post, slides}
3
+ // Includes full multilingual voice + emotion UI
4
+
5
+ (function(){
6
+ // Full voice list with languages and emotions
7
+ const VOICE_OPTIONS = [
8
+ {id:'hoaimy', label:'🎙️ Hoài My (VI)', lang:'vi'},
9
+ {id:'namminh', label:'🎙️ Nam Minh (VI)', lang:'vi'},
10
+ {id:'andrew', label:'🎙️ Andrew (Multilingual)', lang:'en'},
11
+ {id:'jenny', label:'🎙️ Jenny (EN)', lang:'en'},
12
+ {id:'thalita', label:'🎙️ Thalita (PT)', lang:'pt'},
13
+ {id:'pt_francisco', label:'🎙️ Francisco (PT)', lang:'pt'},
14
+ {id:'ela', label:'🎙️ Ela (ES)', lang:'es'},
15
+ {id:'es_carlos', label:'🎙️ Carlos (ES)', lang:'es'},
16
+ {id:'denise', label:'🎙️ Denise (FR)', lang:'fr'},
17
+ {id:'katja', label:'🎙️ Katja (DE)', lang:'de'},
18
+ {id:'nanami', label:'🎙️ Nanami (JA)', lang:'ja'},
19
+ {id:'sunhee', label:'🎙️ SunHee (KO)', lang:'ko'},
20
+ {id:'xiaochen', label:'🎙️ XiaoChen (ZH)', lang:'zh'},
21
+ ];
22
+
23
+ const EMOTION_OPTIONS = [
24
+ {id:'neutral', label:'😐 Trung tính', emoji:'😐'},
25
+ {id:'happy', label:'😊 Vui vẻ', emoji:'😊'},
26
+ {id='excited', label:'🔥 Hào hứng', emoji:'🔥'},
27
+ {id:'sad', label:'😢 Buồn', emoji:'😢'},
28
+ {id:'humorous', label:'😂 Hài hước', emoji:'😂'},
29
+ {id:'serious', label:'⚠️ Nghiêm túc', emoji:'⚠️'},
30
+ {id:'urgent', label:'🚨 Khẩn cấp', emoji:'🚨'},
31
+ {id:'warm', label:'💖 Ấm áp', emoji:'💖'},
32
+ ];
33
+
34
+ window.rewriteArticle = async function(){
35
+ var url = window._currentArticle && window._currentArticle.url;
36
+ if(!url) return;
37
+ toast('⏳ Đang tạo bài đăng Tường AI...');
38
+ try {
39
+ var r = await fetch('/api/rewrite_share', {
40
+ method: 'POST',
41
+ headers: {'Content-Type': 'application/json'},
42
+ body: JSON.stringify({url: url, context: document.querySelector('.article-view') ? document.querySelector('.article-view').innerText.slice(0,14000) : ''})
43
+ });
44
+ var j = await r.json();
45
+ if (!r.ok || j.error) throw new Error(j.error);
46
+ toast('✅ Đã đăng Tường AI!');
47
+ if (j.post) prependWallPost(j.post);
48
+ if (j.post && typeof goToWallPost === 'function') goToWallPost(j.post.id);
49
+ // Show slides if available
50
+ if (j.slides && j.slides.length) {
51
+ setTimeout(function(){ showSlidePreview(j.slides, j.post ? j.post.title : ''); }, 500);
52
+ }
53
+ // Show voice + emotion selector
54
+ if (j.post && !j.post.video) {
55
+ setTimeout(function(){ showVoiceEmotionSelector(j.post.id, j.post.title, j.post.text); }, 1000);
56
+ }
57
+ } catch(e) {
58
+ toast('❌ ' + e.message);
59
+ }
60
+ };
61
+
62
+ // Full voice + emotion selector popup
63
+ window.showVoiceEmotionSelector = function(postId, title, text) {
64
+ var overlay = document.createElement('div');
65
+ overlay.id = 'voice-emotion-selector';
66
+ overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px';
67
+
68
+ var box = document.createElement('div');
69
+ box.style.cssText = 'background:#1a1a1a;border:2px solid #2d8659;border-radius:16px;padding:20px;max-width:400px;width:100%;max-height:80vh;overflow-y:auto';
70
+
71
+ var h = '<h3 style="color:#5cb87a;margin-bottom:12px;font-size:16px">🎬 Tạo Short AI</h3>';
72
+
73
+ // Voice selection
74
+ h += '<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">🎙️ Chọn giọng đọc:</div>';
75
+ VOICE_OPTIONS.forEach(function(v){
76
+ h += '<button class="ve-voice-btn" data-voice="'+v.id+'" style="display:inline-block;background:#222;border:1px solid #333;color:#ccc;padding:5px 10px;border-radius:12px;font-size:10px;margin:2px;cursor:pointer" onmouseover="this.style.background=\'#333\'" onmouseout="this.style.background=\'#222\'">'+esc(v.label)+'</button>';
77
+ });
78
+ h += '</div>';
79
+
80
+ // Emotion selection
81
+ h += '<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">😊 Chọn cảm xúc:</div>';
82
+ EMOTION_OPTIONS.forEach(function(e){
83
+ h += '<button class="ve-emotion-btn" data-emotion="'+e.id+'" style="display:inline-block;background:#222;border:1px solid #333;color:#ccc;padding:5px 10px;border-radius:12px;font-size:10px;margin:2px;cursor:pointer" onmouseover="this.style.background=\'#333\'" onmouseout="this.style.background=\'#222\'">'+esc(e.label)+'</button>';
84
+ });
85
+ h += '</div>';
86
+
87
+ // Speed
88
+ h += '<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">⚡ Tốc độ:</div>';
89
+ h += '<select id="ve-speed" style="background:#222;border:1px solid #333;color:#ccc;padding:6px 12px;border-radius:10px;font-size:11px">';
90
+ h += '<option value="0.85">0.85x — Chậm</option>';
91
+ h += '<option value="1.0">1.0x — Bình thường</option>';
92
+ h += '<option value="1.2" selected>1.2x — Nhanh</option>';
93
+ h += '<option value="1.35">1.35x — Rất nhanh</option>';
94
+ h += '</select></div>';
95
+
96
+ // Actions
97
+ h += '<div style="display:flex;gap:8px;margin-top:12px">';
98
+ h += '<button id="ve-create-btn" style="flex:1;background:#2d8659;border:0;color:#fff;padding:10px;border-radius:12px;font-size:12px;font-weight:700;cursor:pointer">🎬 Tạo Short</button>';
99
+ h += '<button id="ve-cancel-btn" style="background:#333;border:0;color:#ccc;padding:10px 16px;border-radius:12px;font-size:11px;cursor:pointer">✕</button>';
100
+ h += '</div>';
101
+
102
+ // Status
103
+ h += '<div id="ve-status" style="color:#888;font-size:10px;margin-top:8px;display:none"></div>';
104
+
105
+ box.innerHTML = h;
106
+ overlay.appendChild(box);
107
+ document.body.appendChild(overlay);
108
+
109
+ var selectedVoice = 'hoaimy';
110
+ var selectedEmotion = 'neutral';
111
+
112
+ // Voice click handlers
113
+ box.querySelectorAll('.ve-voice-btn').forEach(function(btn){
114
+ btn.addEventListener('click', function(){
115
+ box.querySelectorAll('.ve-voice-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});
116
+ this.style.borderColor='#5cb87a';
117
+ this.style.background='#1a2a1f';
118
+ selectedVoice = this.dataset.voice;
119
+ });
120
+ });
121
+ // Select first voice by default
122
+ box.querySelector('.ve-voice-btn').style.borderColor = '#5cb87a';
123
+ box.querySelector('.ve-voice-btn').style.background = '#1a2a1f';
124
+
125
+ // Emotion click handlers
126
+ box.querySelectorAll('.ve-emotion-btn').forEach(function(btn){
127
+ btn.addEventListener('click', function(){
128
+ box.querySelectorAll('.ve-emotion-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});
129
+ this.style.borderColor='#5cb87a';
130
+ this.style.background='#1a2a1f';
131
+ selectedEmotion = this.dataset.emotion;
132
+ });
133
+ });
134
+ // Select first emotion by default
135
+ box.querySelector('.ve-emotion-btn').style.borderColor = '#5cb87a';
136
+ box.querySelector('.ve-emotion-btn').style.background = '#1a2a1f';
137
+
138
+ // Cancel
139
+ box.querySelector('#ve-cancel-btn').addEventListener('click', function(){
140
+ overlay.remove();
141
+ });
142
+
143
+ // Create short
144
+ box.querySelector('#ve-create-btn').addEventListener('click', async function(){
145
+ this.disabled = true;
146
+ this.textContent = '⏳ Đang tạo...';
147
+ box.querySelector('#ve-status').style.display = 'block';
148
+ box.querySelector('#ve-status').textContent = 'Đang tạo video shorts (có thể mất 1-3 phút)...';
149
+
150
+ try {
151
+ var speed = parseFloat(box.querySelector('#ve-speed').value) || 1.2;
152
+ var r = await fetch('/api/ai/short/' + encodeURIComponent(postId), {
153
+ method: 'POST',
154
+ headers: {'Content-Type': 'application/json'},
155
+ body: JSON.stringify({voice: selectedVoice, emotion: selectedEmotion, speed: speed})
156
+ });
157
+ var j = await r.json();
158
+ if (!r.ok || j.error) throw new Error(j.error || 'Lỗi tạo video');
159
+ toast('✅ Đã tạo Short AI!');
160
+ overlay.remove();
161
+ // Update wall
162
+ var p = _wallPosts.find(function(x){return String(x.id)===String(postId);});
163
+ if (p) {
164
+ p.video = j.video;
165
+ p.voice = j.voice;
166
+ p.emotion = j.emotion;
167
+ }
168
+ // Refresh wall view
169
+ var track = document.getElementById('ai-wall-track');
170
+ if (track) {
171
+ var idx = _wallPosts.findIndex(function(x){return String(x.id)===String(postId);});
172
+ if (idx >= 0) {
173
+ var el = track.children[idx];
174
+ if (el) el.outerHTML = makeWallItem(p, idx);
175
+ }
176
+ }
177
+ refreshShortAISlider();
178
+ } catch(e) {
179
+ this.disabled = false;
180
+ this.textContent = '🎬 Tạo Short';
181
+ box.querySelector('#ve-status').textContent = '❌ ' + e.message;
182
+ }
183
+ });
184
+ };
185
+
186
+ // Show slides as fullscreen overlay
187
+ window.showSlidePreview = function(slides, title) {
188
+ if (!slides || !slides.length) return;
189
+ var overlay = document.createElement('div');
190
+ overlay.id = 'slide-preview';
191
+ overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:99999;display:flex;flex-direction:column;overflow:hidden';
192
+
193
+ var currentSlide = 0;
194
+ function renderSlide(idx) {
195
+ var s = slides[idx];
196
+ overlay.innerHTML = '<div style="position:absolute;top:10px;left:10px;right:10px;display:flex;justify-content:space-between;align-items:center;z-index:2">' +
197
+ '<button onclick="document.getElementById(\'slide-preview\').remove()" style="background:rgba(0,0,0,.6);border:0;color:#fff;padding:8px 14px;border-radius:20px;font-size:12px;cursor:pointer">✕ Đóng</button>' +
198
+ '<span style="color:#fff;font-size:11px;background:rgba(0,0,0,.6);padding:4px 10px;border-radius:10px">' + (idx+1) + '/' + slides.length + '</span>' +
199
+ '<button onclick="document.getElementById(\'slide-preview\').remove()" style="background:rgba(0,0,0,.6);border:0;color:#fff;padding:8px 14px;border-radius:20px;font-size:12px;cursor:pointer">✕</button>' +
200
+ '</div>' +
201
+ '<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:20px">' +
202
+ (s.image ? '<img src="' + esc(s.image) + '" style="max-width:100%;max-height:60vh;border-radius:10px;object-fit:contain" onerror="this.style.display=\'none\'">' : '') +
203
+ '</div>' +
204
+ '<div style="padding:16px 20px;background:linear-gradient(transparent,rgba(0,0,0,.9));min-height:100px">' +
205
+ '<p style="color:#fff;font-size:14px;line-height:1.6;text-align:center">' + esc(s.text) + '</p>' +
206
+ '</div>' +
207
+ '<div style="display:flex;gap:10px;padding:10px 20px 20px;justify-content:center">' +
208
+ '<button onclick="prevSlide()" style="background:#333;border:0;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;cursor:pointer"' + (idx===0?' disabled style="opacity:.3"':'') + '>← Trước</button>' +
209
+ '<button onclick="nextSlideRip(currentSlide)" id="slide-next-btn" style="background:#2d8659;border:0;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;cursor:pointer"' + (idx===slides.length-1?' disabled style="opacity:.3"':'') + '>Tiếp →</button>' +
210
+ '</div>';
211
+ }
212
+
213
+ window.nextSlideRip = function(currentIdx) { if (currentIdx < slides.length - 1) { currentSlide = currentIdx + 1; renderSlide(currentSlide); } };
214
+ window.prevSlide = function() { if (currentSlide > 0) { currentSlide--; renderSlide(currentSlide); } };
215
+
216
+ // Fix: update next button onclick properly
217
+ var origRender = renderSlide;
218
+ renderSlide = function(idx) {
219
+ currentSlide = idx;
220
+ origRender(idx);
221
+ var nextBtn = document.getElementById('slide-next-btn');
222
+ if (nextBtn) {
223
+ nextBtn.onclick = function(){ if (idx < slides.length - 1) { currentSlide = idx + 1; renderSlide(currentSlide); } };
224
+ nextBtn.disabled = (idx === slides.length - 1);
225
+ nextBtn.style.opacity = (idx === slides.length - 1) ? '.3' : '1';
226
+ }
227
+ };
228
+
229
+ renderSlide(0);
230
+ document.body.appendChild(overlay);
231
+
232
+ var startX = 0;
233
+ overlay.addEventListener('touchstart', function(e) { startX = e.touches[0].clientX; });
234
+ overlay.addEventListener('touchend', function(e) {
235
+ var diff = e.changedTouches[0].clientX - startX;
236
+ if (diff < -50) window.nextSlideRip(currentSlide);
237
+ else if (diff > 50) window.prevSlide();
238
+ });
239
+ };
240
+ })();
shorts_cache.py DELETED
@@ -1,86 +0,0 @@
1
- """
2
- VNEWS Shorts Runtime Cache - External Updater Module
3
- GitHub Actions fetches YouTube shorts via yt-dlp -> POST to /api/shorts/update
4
- Space saves to RAM cache + persistent file if /data available
5
- """
6
- import os
7
- import json
8
- import time
9
- import threading
10
-
11
- # Runtime cache (RAM)
12
- _shorts_runtime_cache = None
13
- _shorts_cache_ts = 0
14
- _shorts_cache_lock = threading.Lock()
15
-
16
- # Secret for authenticating update requests
17
- SHORTS_UPDATE_SECRET = os.environ.get("SHORTS_UPDATE_SECRET", "vnews-shorts-2026")
18
-
19
- # Paths
20
- SHORTS_CACHE_FILE = "/data/shorts_runtime_cache.json" if os.path.isdir("/data") else "/app/shorts_runtime_cache.json"
21
-
22
-
23
- def get_runtime_cache():
24
- """Get cached shorts (from RAM or file fallback)"""
25
- global _shorts_runtime_cache, _shorts_cache_ts
26
- with _shorts_cache_lock:
27
- if _shorts_runtime_cache is not None:
28
- age = time.time() - _shorts_cache_ts
29
- if age < 7200: # 2h fresh
30
- return _shorts_runtime_cache
31
-
32
- # Try file fallback
33
- try:
34
- if os.path.exists(SHORTS_CACHE_FILE):
35
- with open(SHORTS_CACHE_FILE, "r", encoding="utf-8") as f:
36
- data = json.load(f)
37
- age = time.time() - data.get("ts", 0)
38
- if age < 86400: # 24h stale limit
39
- items = data.get("items", [])
40
- with _shorts_cache_lock:
41
- _shorts_runtime_cache = items
42
- _shorts_cache_ts = data.get("ts", time.time())
43
- return items
44
- except Exception as e:
45
- print(f"[cache] read error: {e}")
46
-
47
- return None
48
-
49
-
50
- def set_runtime_cache(items):
51
- """Update runtime cache from external data"""
52
- global _shorts_runtime_cache, _shorts_cache_ts
53
- ts = time.time()
54
- with _shorts_cache_lock:
55
- _shorts_runtime_cache = items
56
- _shorts_cache_ts = ts
57
-
58
- # Also write to file (persistent if /data mounted)
59
- try:
60
- os.makedirs(os.path.dirname(SHORTS_CACHE_FILE), exist_ok=True)
61
- payload = {"items": items, "ts": ts, "count": len(items)}
62
- with open(SHORTS_CACHE_FILE, "w", encoding="utf-8") as f:
63
- json.dump(payload, f, ensure_ascii=False, indent=2)
64
- print(f"[cache] saved {len(items)} shorts to {SHORTS_CACHE_FILE}")
65
- except Exception as e:
66
- print(f"[cache] write skipped: {e}")
67
-
68
- return len(items)
69
-
70
-
71
- def get_cache_status():
72
- """Return status dict for the cache"""
73
- cache = None
74
- with _shorts_cache_lock:
75
- if _shorts_runtime_cache is not None:
76
- cache = _shorts_runtime_cache
77
- age = int(time.time() - _shorts_cache_ts)
78
- else:
79
- age = -1
80
- return {
81
- "cached": cache is not None,
82
- "count": len(cache) if cache else 0,
83
- "age_seconds": age,
84
- "has_persistent": os.path.isdir("/data"),
85
- "cache_file_exists": os.path.exists(SHORTS_CACHE_FILE),
86
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
shorts_rss_proxy.py DELETED
@@ -1,114 +0,0 @@
1
- """
2
- YouTube RSS Proxy - Fetches YouTube channel RSS feeds server-side
3
- Avoids CORS issues when client tries to fetch YouTube directly
4
- """
5
- import requests as req
6
- from fastapi import Query
7
- from fastapi.responses import Response
8
-
9
- HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
10
-
11
- YOUTUBE_CHANNELS = {
12
- "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg",
13
- "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g",
14
- }
15
-
16
- def setup_rss_proxy(app):
17
- """Add RSS proxy endpoints to the FastAPI app"""
18
-
19
- @app.get("/api/proxy/rss")
20
- def proxy_rss(url: str = Query(...)):
21
- """Proxy YouTube RSS feed to avoid CORS"""
22
- try:
23
- r = req.get(url, headers=HEADERS, timeout=15)
24
- if r.status_code == 200:
25
- return Response(
26
- content=r.content,
27
- media_type="application/xml",
28
- headers={"Access-Control-Allow-Origin": "*"}
29
- )
30
- return Response(status_code=r.status_code)
31
- except Exception as e:
32
- return Response(status_code=502, content=str(e))
33
-
34
- @app.get("/api/shorts/rss")
35
- def shorts_via_rss():
36
- """Get shorts from YouTube RSS feeds server-side"""
37
- import xml.etree.ElementTree as ET
38
- import html as html_lib
39
- import re
40
-
41
- shorts = []
42
- seen = set()
43
-
44
- for handle, channel_id in YOUTUBE_CHANNELS.items():
45
- try:
46
- rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
47
- r = req.get(rss_url, headers=HEADERS, timeout=15)
48
- if r.status_code != 200:
49
- continue
50
-
51
- root = ET.fromstring(r.text)
52
- ns = {
53
- 'atom': 'http://www.w3.org/2005/Atom',
54
- 'yt': 'http://www.youtube.com/xml/schemas/2015',
55
- 'media': 'http://search.yahoo.com/mrss/'
56
- }
57
-
58
- for entry in root.findall('atom:entry', ns)[:30]:
59
- title_el = entry.find('atom:title', ns)
60
- title = html_lib.unescape(title_el.text) if title_el is not None and title_el.text else ''
61
-
62
- link_el = entry.find('atom:link', ns)
63
- link = link_el.get('href', '') if link_el is not None else ''
64
-
65
- vid_el = entry.find('yt:videoId', ns)
66
- vid = vid_el.text if vid_el is not None else ''
67
-
68
- if not vid:
69
- m = re.search(r'(?:v=|shorts/)([A-Za-z0-9_-]{11})', link)
70
- if m:
71
- vid = m.group(1)
72
-
73
- if not vid or vid in seen:
74
- continue
75
-
76
- # Check if it's a short
77
- is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link
78
-
79
- if not is_short:
80
- desc_el = entry.find('media:description', ns)
81
- if desc_el is not None and desc_el.text:
82
- if '#shorts' in desc_el.text.lower():
83
- is_short = True
84
-
85
- if not is_short:
86
- continue
87
-
88
- seen.add(vid)
89
-
90
- # Get thumbnail
91
- thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
92
- media_group = entry.find('media:group', ns)
93
- if media_group is not None:
94
- thumb_el = media_group.find('media:thumbnail', ns)
95
- if thumb_el is not None:
96
- thumb = thumb_el.get('url', thumb)
97
-
98
- shorts.append({
99
- 'id': vid,
100
- 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120],
101
- 'img': thumb,
102
- 'link': f'https://www.youtube.com/shorts/{vid}',
103
- 'channel': handle,
104
- 'source': 'yt'
105
- })
106
-
107
- if len(shorts) >= 40:
108
- break
109
-
110
- except Exception as e:
111
- print(f"RSS error for {handle}: {e}")
112
- continue
113
-
114
- return {"shorts": shorts, "count": len(shorts)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/app_v2.js CHANGED
@@ -1,18 +1,6 @@
1
- /**
2
- * VNEWS Frontend v2 - Shorts Dantri/SKDS removed, VTV Digital CDN
3
- * v2.5 - Added 'Tạo lại' button on wall cards with video
4
- * v2.4 - Fixed: prependWallPost detached-element bug, makeShortVideo UI update, slide viewer for rewrite posts
5
- */
6
- function _proxyImg(url){
7
- if(!url || typeof url !== 'string') return '';
8
- if(url.startsWith('http') && !url.includes(location.host)){
9
- return '/api/proxy/img?url='+encodeURIComponent(url);
10
- }
11
- return url;
12
- }
13
-
14
- var _ttsSelections = {};
15
 
 
16
  function _fetchWithTimeout(url, ms){
17
  return new Promise((resolve,reject)=>{
18
  const ctrl=new AbortController();
@@ -29,10 +17,10 @@ async function loadHome(){
29
  const homeEl = document.getElementById('view-home');
30
  if(!homeEl) return;
31
 
 
32
  homeEl.innerHTML =
33
  '<div id="home-featured-area"></div>'
34
  +'<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>'
35
- +'<div id="ai-wall-under-compose"></div>'
36
  +'<div id="hashtag-box"></div>'
37
  +'<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="today" onclick="loadLivescore(\'today\')">📅 Hôm nay</span><span class="ls-tab" data-tab="live" onclick="loadLivescore(\'live\')">🔴 Live</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore(\'incoming\')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore(\'results\')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore(\'bxh_nha\')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore(\'bxh_laliga\')">🏆 La Liga</span></div><div class="ls-content" id="ls-content"><div class="loading">Đang tải...</div></div></div>'
38
  +'<div id="wc2026-live-section" class="wc2026-section"><div class="wc-header"><h2>🏆 World Cup 2026</h2><span class="wc-live-badge">● LIVE</span></div><div class="wc-tabs"><span class="wc-tab active" onclick="switchWCTab(\'news\')">📰 Tin tức</span><span class="wc-tab" onclick="switchWCTab(\'fixtures\')">📅 Lịch thi đấu</span><span class="wc-tab" onclick="switchWCTab(\'standings\')">🏆 BXH</span><span class="wc-tab" onclick="switchWCTab(\'highlights\')">🎬 Highlight</span><span class="wc-tab" onclick="switchWCTab(\'stats\')">📊 Thống kê</span></div><div class="wc-content" id="wc-content"><div class="loading">Đang tải World Cup 2026...</div></div></div>'
@@ -40,433 +28,40 @@ async function loadHome(){
40
 
41
  const afterEl = homeEl.querySelector('#home-after-wc');
42
 
 
43
  loadLivescore('today');
44
  loadHotTopics();
45
 
46
- const [featuredData, wallData, hlLeagues, wcData] = await Promise.allSettled([
 
47
  _fetchWithTimeout('/api/livescore/featured', 5000),
 
48
  _fetchWithTimeout('/api/wall', 5000),
49
  _fetchWithTimeout('/api/highlights/leagues', 10000),
 
50
  _fetchWithTimeout('/api/wc2026', 8000),
51
  ]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
52
 
 
53
  if(featuredData && featuredData.home){
54
  const sc=featuredData.status==='live'?'':'upcoming';
55
  const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`;
56
  const area=document.getElementById('home-featured-area');
57
- if(area) area.innerHTML=`<div class="featured-match" onclick="openMatch('${featuredData.event_id}')"><div class="fm-league">${featuredData.league}</div><div class="fm-teams"><div class="fm-team"><img src="${_proxyImg(featuredData.home_logo)}" onerror="this.style.display='none'"><span>${featuredData.home}</span></div><div class="fm-score">${featuredData.score||'VS'}</div><div class="fm-team"><img src="${_proxyImg(featuredData.away_logo)}" onerror="this.style.display='none'"><span>${featuredData.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;
58
  }
59
 
 
 
60
  _wallPosts = (wallData && wallData.posts) || [];
61
  _hlLeagueData = hlLeagues || {};
62
  _wc2026Data = wcData;
63
 
 
64
  if(wcData) switchWCTab('news');
65
 
 
 
66
  _renderWallIn(afterEl);
67
  _renderHLIn(afterEl);
 
68
  }
69
-
70
- function _renderSlidesIn(key, label, emoji, vids, afterEl){
71
- if(!vids||!vids.length||!afterEl) return;
72
- const wrap=document.createElement('div');
73
- wrap.className='slider-wrap';
74
- let h=`<div class="slider-header"><span class="slider-label">${emoji} ${label}</span></div><div class="slider-track">`;
75
- const isHL = key==='world-cup'||key==='premier-league'||key==='champions-league'||key==='la-liga'||key==='serie-a'||key==='bundesliga'||key==='friendly';
76
- vids.slice(0,isHL?8:12).forEach((a,i)=>{
77
- if(isHL){
78
- h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`;
79
- } else {
80
- h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy">`:''}</div><div class="slider-title">${esc(a.title)}</div></div>`;
81
- }
82
- });
83
- h+='</div>';
84
- wrap.innerHTML=h;
85
- afterEl.parentNode.insertBefore(wrap, afterEl);
86
- }
87
-
88
- function _renderWallIn(afterEl){
89
- if(!_wallPosts||!_wallPosts.length) return;
90
- const posts=_wallPosts;
91
- const wrap=document.createElement('div');
92
- wrap.className='slider-wrap';wrap.id='ai-wall-wrap';
93
- let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">';
94
- posts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i);});
95
- h+='</div>';wrap.innerHTML=h;
96
- const target=document.getElementById('ai-wall-under-compose');
97
- if(target) target.appendChild(wrap);
98
- else if(afterEl) afterEl.parentNode.insertBefore(wrap,afterEl);
99
- }
100
-
101
- function _renderHLIn(afterEl){
102
- if(!_hlLeagueData||Object.keys(_hlLeagueData).length===0||!afterEl) return;
103
- const HL_CONFIG={"world-cup":{name:"World Cup 2026",emoji:"🌍"},"premier-league":{name:"Premier League",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"},"friendly":{name:"Giao hữu",emoji:"🤝"}};
104
- for(const[key,cfg] of Object.entries(HL_CONFIG)){
105
- const vids=_hlLeagueData[key];
106
- if(!vids||!vids.length) continue;
107
- _renderSlidesIn(key,cfg.name,cfg.emoji,vids,afterEl);
108
- }
109
- }
110
-
111
- // === WALL POST HELPERS ===
112
- function makeWallItem(p,i){
113
- var hasVideo = p.video && p.video.length > 0;
114
- var thumbContent = p.img
115
- ? '<img src="/api/proxy/img?url='+encodeURIComponent(p.img)+'" loading="lazy" onerror="this.style.display=\'none\'">'
116
- : (hasVideo ? '<video src="'+esc(p.video)+'" muted></video>' : '');
117
- var videoBadge = hasVideo ? '<div class="wall-video-badge">🎬</div>' : '';
118
- var vid = p.id||i;
119
- var lang = p.language || detectLanguage(p.title + ' ' + (p.text||''));
120
- var curVoice = p.voice || getAutoVoice(lang);
121
- var curEmotion = p.emotion || detectEmotion(p.title + ' ' + (p.text||''));
122
- var selKey = 'inline-' + vid;
123
- if(!_ttsSelections[selKey]) _ttsSelections[selKey] = {voice: curVoice, emotion: curEmotion};
124
- var voiceOpts = '';
125
- VOICE_LIST.forEach(function(v){
126
- voiceOpts += '<option value="'+v.id+'"'+(v.id===_ttsSelections[selKey].voice?' selected':'')+'>'+v.label+'</option>';
127
- });
128
- var emotOpts = '';
129
- EMOTION_LIST.forEach(function(e){
130
- emotOpts += '<option value="'+e.id+'"'+(e.id===_ttsSelections[selKey].emotion?' selected':'')+'>'+e.label+'</option>';
131
- });
132
- var spd = p.short_speed || '1.2';
133
- var voiceBar = '<div class="wall-tts-bar" style="margin:6px 0 4px;display:grid;grid-template-columns:1fr auto auto;gap:3px">'
134
- +'<select class="wvs" data-selkey="'+selKey+'" style="background:#1a1a1a;border:1px solid #333;color:#ccc;padding:3px;border-radius:6px;font-size:9px;min-width:0" onchange="_ttsSelections[this.dataset.selkey].voice=this.value">'
135
- +voiceOpts+'</select>'
136
- +'<select class="wes" data-selkey="'+selKey+'" style="background:#1a1a1a;border:1px solid #333;color:#ccc;padding:3px;border-radius:6px;font-size:9px;min-width:0" onchange="_ttsSelections[this.dataset.selkey].emotion=this.value">'
137
- +emotOpts+'</select>'
138
- +'<select class="wss" data-selkey="'+selKey+'" style="background:#1a1a1a;border:1px solid #333;color:#ccc;padding:3px;border-radius:6px;font-size:9px;min-width:0" onchange="_ttsSelections[this.dataset.selkey].speed=parseFloat(this.value)">'
139
- +'<option value="0.85"'+(spd==='0.85'?' selected':'')+'>0.85x</option>'
140
- +'<option value="1.0"'+(spd==='1.0'?' selected':'')+'>1.0x</option>'
141
- +'<option value="1.2"'+(spd==='1.2'?' selected':'')+'>1.2x</option>'
142
- +'<option value="1.35"'+(spd==='1.35'?' selected':'')+'>1.35x</option>'
143
- +'</select>'
144
- +'</div>';
145
- var makeBtn = hasVideo
146
- ? '<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed('+i+')">▶ Xem Short</button><button class="wall-btn-make" style="margin-left:4px" onclick="event.stopPropagation();makeShortVideo(\''+esc(vid)+'\',this,_ttsSelections[\'inline-'+esc(vid)+'\'].voice,parseFloat(document.querySelector(\'.wss[data-selkey=inline-'+esc(vid)+']\').value)||1.2,_ttsSelections[\'inline-'+esc(vid)+'\'].emotion)">🎬 Tạo lại</button>'
147
- : '<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo(\''+esc(vid)+'\',this,_ttsSelections[\'inline-'+esc(vid)+'\'].voice,parseFloat(document.querySelector(\'.wss[data-selkey=inline-'+esc(vid)+']\').value)||1.2,_ttsSelections[\'inline-'+esc(vid)+'\'].emotion)">🎬 Tạo Short</button>';
148
- return '<div class="wall-item" id="wall-item-'+esc(vid)+'"><div class="wall-thumb">'+thumbContent+videoBadge+'</div><div class="wall-title">'+esc(p.title)+'</div><div class="wall-text">'+esc((p.text||'').slice(0,180))+'</div>'+voiceBar+'<div class="wall-actions"><button class="primary" onclick="readWallPost('+i+')">Xem</button>'+makeBtn+'</div></div>';
149
- }
150
-
151
- async function makeShortVideo(postId, btn, voice, speed, emotion){
152
- if(!postId)return;
153
- const origText = btn ? btn.textContent : '🎬 Tạo Video';
154
- if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
155
- toast('⏳ Đang tạo video shorts...');
156
- if(!voice || !emotion){
157
- const selKey = 'inline-'+postId;
158
- const container = document.querySelector('.tts-selector[data-post-id="'+postId+'"]');
159
- if(container){
160
- if(_ttsSelections[selKey]){
161
- voice = voice || _ttsSelections[selKey].voice;
162
- emotion = emotion || _ttsSelections[selKey].emotion;
163
- }
164
- if(!voice){
165
- const selectedVoiceBtn = container.querySelector('.tts-voice-btn.selected') || container.querySelector('.tts-voice-btn[style*="5cb87a"]') || container.querySelector('.tts-voice-btn');
166
- voice = selectedVoiceBtn ? selectedVoiceBtn.dataset.voice : 'vi-VN-HoaiMyNeural';
167
- }
168
- if(!emotion){
169
- const selectedEmotionBtn = container.querySelector('.tts-emotion-btn.selected') || container.querySelector('.tts-emotion-btn[style*="5cb87a"]') || container.querySelector('.tts-emotion-btn');
170
- emotion = selectedEmotionBtn ? selectedEmotionBtn.dataset.emotion : 'neutral';
171
- }
172
- const speedSelect = container.querySelector('.tts-speed');
173
- speed = speedSelect ? parseFloat(speedSelect.value) || 1.2 : (speed || 1.2);
174
- } else {
175
- voice = voice || 'vi-VN-HoaiMyNeural';
176
- emotion = emotion || 'neutral';
177
- speed = speed || 1.2;
178
- }
179
- }
180
- try{
181
- const r = await fetch('/api/ai/short/' + encodeURIComponent(postId), {method:'POST',headers:{'Content-Type':'application/json'},body: JSON.stringify({voice:voice, emotion:emotion, speed:speed})});
182
- const j = await r.json();
183
- if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
184
- toast('✅ Đã tạo video shorts!');
185
- const p = _wallPosts.find(x => String(x.id) === String(postId));
186
- if(p){
187
- p.video = j.video;
188
- p.voice = j.voice;
189
- p.emotion = j.emotion;
190
- const itemId = 'wall-item-'+postId;
191
- const el = document.getElementById(itemId);
192
- if(el){
193
- const idx = _wallPosts.indexOf(p);
194
- el.insertAdjacentHTML('afterend', makeWallItem(p, idx));
195
- el.remove();
196
- }
197
- }
198
- }catch(e){
199
- toast('❌ '+e.message);
200
- if(btn){btn.disabled=false;btn.textContent=origText;}
201
- }
202
- }
203
-
204
- var VOICE_LIST = [
205
- {id:'vi-VN-HoaiMyNeural', label:'🎙️ Hoài My (VI)', lang:'vi'},
206
- {id:'vi-VN-NamMinhNeural', label:'🎙️ Nam Minh (VI)', lang:'vi'},
207
- {id:'en-US-AndrewMultilingualNeural', label:'🎙️ Andrew (EN)', lang:'en'},
208
- {id:'en-AU-WilliamMultilingualNeural', label:'🎙️ William (EN)', lang:'en'},
209
- {id:'pt-BR-ThalitaMultilingualNeural', label:'🎙️ Thalita (PT)', lang:'pt'},
210
- {id:'fr-FR-VivienneMultilingualNeural', label:'🎙️ Vivienne (FR)', lang:'fr'},
211
- {id:'fr-FR-RemyMultilingualNeural', label:'🎙️ Rémy (FR)', lang:'fr'},
212
- {id:'de-DE-SeraphinaMultilingualNeural', label:'🎙️ Seraphina (DE)', lang:'de'},
213
- {id:'de-DE-FlorianMultilingualNeural', label:'🎙️ Florian (DE)', lang:'de'},
214
- {id:'ko-KR-HyunsuMultilingualNeural', label:'🎙️ Hyunsu (KO)', lang:'ko'},
215
- {id:'it-IT-GiuseppeMultilingualNeural', label:'🎙️ Giuseppe (IT)', lang:'it'},
216
- ];
217
- var EMOTION_LIST = [
218
- {id:'neutral', label:'😐 Trung tính'},
219
- {id:'happy', label:'😊 Vui vẻ'},
220
- {id:'excited', label:'🔥 Hào hứng'},
221
- {id:'sad', label:'😢 Buồn'},
222
- {id:'humorous', label:'😂 Hài hước'},
223
- {id:'serious', label:'⚠️ Nghiêm túc'},
224
- {id:'urgent', label:'🚨 Khẩn cấp'},
225
- {id:'warm', label:'💖 Ấm áp'},
226
- ];
227
-
228
- document.addEventListener('click',function(e){
229
- var btn = e.target.closest('.tts-voice-btn');
230
- if(btn){
231
- var container = btn.closest('.tts-selector');
232
- if(container){
233
- var selKey = 'inline-'+container.dataset.postId;
234
- if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:btn.dataset.voice,emotion:'neutral'};
235
- var allBtns = container.querySelectorAll('.tts-voice-btn');
236
- for(var i=0;i<allBtns.length;i++){allBtns[i].style.borderColor='#333';allBtns[i].style.background='#222';allBtns[i].classList.remove('selected');}
237
- btn.style.borderColor='#5cb87a';btn.style.background='#1a2a1f';btn.classList.add('selected');
238
- _ttsSelections[selKey].voice = btn.dataset.voice;
239
- }
240
- return;
241
- }
242
- var ebtn = e.target.closest('.tts-emotion-btn');
243
- if(ebtn){
244
- var container = ebtn.closest('.tts-selector');
245
- if(container){
246
- var selKey = 'inline-'+container.dataset.postId;
247
- if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:'vi-VN-HoaiMyNeural',emotion:ebtn.dataset.emotion};
248
- var allBtns = container.querySelectorAll('.tts-emotion-btn');
249
- for(var i=0;i<allBtns.length;i++){allBtns[i].style.borderColor='#333';allBtns[i].style.background='#222';allBtns[i].classList.remove('selected');}
250
- ebtn.style.borderColor='#5cb87a';ebtn.style.background='#1a2a1f';ebtn.classList.add('selected');
251
- _ttsSelections[selKey].emotion = ebtn.dataset.emotion;
252
- }
253
- return;
254
- }
255
- var cbtn = e.target.closest('.tts-create-btn');
256
- if(cbtn){
257
- var container = cbtn.closest('.tts-selector');
258
- if(container){
259
- var selKey = 'inline-'+container.dataset.postId;
260
- var selVoice = _ttsSelections[selKey] ? _ttsSelections[selKey].voice : 'vi-VN-HoaiMyNeural';
261
- var selEmotion = _ttsSelections[selKey] ? _ttsSelections[selKey].emotion : 'neutral';
262
- var speedSel = container.querySelector('.tts-speed');
263
- var speed = speedSel ? parseFloat(speedSel.value)||1.2 : 1.2;
264
- window.makeShortVideo(container.dataset.postId, cbtn, selVoice, speed, selEmotion);
265
- }
266
- return;
267
- }
268
- });
269
- function detectLanguage(text){
270
- if(!text) return 'vi';
271
- var t=text.toLowerCase(), chars=new Set(t);
272
- var vnChars='đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý';
273
- var vnCount=0; for(var c of vnChars){if(chars.has(c)) vnCount++;}
274
- if(vnCount>=2) return 'vi';
275
- if(chars.has('ñ')||chars.has('¿')||chars.has('¡')) return 'es';
276
- if(chars.has('ã')||chars.has('õ')) return 'pt';
277
- var words=t.split(/\s+/);
278
- var enWords=['the','is','at','which','on','and','or','but','this','that','with','from','have','been'];
279
- var enCount=words.filter(function(w){return enWords.indexOf(w)>=0;}).length;
280
- if(enCount>=2) return 'en';
281
- return 'vi';
282
- }
283
- function detectEmotion(text){
284
- if(!text) return 'neutral';
285
- var t=text.toLowerCase();
286
- var kws={
287
- happy:['vui','hạnh phúc','tuyệt','thành công','chiến thắng','feliz','maravilloso','happy','joy','wonderful','great','amazing','love','excellent'],
288
- excited:['hào hứng','phấn khích','đột phá','kỷ lục','đỉnh cao','emocionante','increíble','excited','thrilling','unbelievable','awesome','breakthrough'],
289
- sad:['buồn','đau','mất','thảm họa','khủng hoảng','triste','terrible','sad','unhappy','tragic','painful','death'],
290
- humorous:['hài hước','buồn cười','haha','đùa','engraçado','gracioso','funny','hilarious','joke','lol'],
291
- serious:['nghiêm trọng','khẩn cấp','quan trọng','lo ngại','sério','crítico','serious','critical','urgent','severe','crisis'],
292
- urgent:['khẩn cấp','báo động','ngay lập tức','urgent','breaking','alert','emergency'],
293
- warm:['ấm áp','tình cảm','yêu thương','warm','love','heart','touching']
294
- };
295
- var bestScore=0, bestEmotion='neutral';
296
- for(var em in kws){var score=0; for(var kw of kws[em]){if(t.indexOf(kw)>=0) score++;} if(score>bestScore){bestScore=score;bestEmotion=em;}}
297
- return bestEmotion;
298
- }
299
- function getAutoVoice(lang){var map={vi:'vi-VN-HoaiMyNeural',pt:'pt-BR-ThalitaMultilingualNeural',en:'en-US-AndrewMultilingualNeural',fr:'fr-FR-VivienneMultilingualNeural',de:'de-DE-SeraphinaMultilingualNeural',ko:'ko-KR-HyunsuMultilingualNeural',it:'it-IT-GiuseppeMultilingualNeural'};return map[lang]||'vi-VN-HoaiMyNeural';}
300
- function buildVoiceEmotionSelector(post){
301
- var lang=post.language||detectLanguage(post.title+' '+(post.text||''));
302
- var _oldVoiceMap = {'hoaimy':'vi-VN-HoaiMyNeural','namminh':'vi-VN-NamMinhNeural','andrew':'en-US-AndrewMultilingualNeural','jenny':'en-US-AndrewMultilingualNeural','thalita':'pt-BR-ThalitaMultilingualNeural','pt_thalita':'pt-BR-ThalitaMultilingualNeural','vivienne':'fr-FR-VivienneMultilingualNeural','remy':'fr-FR-RemyMultilingualNeural','seraphina':'de-DE-SeraphinaMultilingualNeural','florian':'de-DE-FlorianMultilingualNeural','sunhee':'ko-KR-HyunsuMultilingualNeural','hyunsu':'ko-KR-HyunsuMultilingualNeural','giuseppe':'it-IT-GiuseppeMultilingualNeural','ela':'en-US-AndrewMultilingualNeural','denise':'fr-FR-VivienneMultilingualNeural','katja':'de-DE-SeraphinaMultilingualNeural','nanami':'en-US-AndrewMultilingualNeural','xiaochen':'en-US-AndrewMultilingualNeural','es_carlos':'en-US-AndrewMultilingualNeural','pt_francisco':'pt-BR-ThalitaMultilingualNeural'};
303
- var _postVoice = post.voice ? (_oldVoiceMap[post.voice] || post.voice) : '';
304
- var autoVoice= _postVoice || getAutoVoice(lang);
305
- var autoEmotion=post.emotion||detectEmotion(post.title+' '+(post.text||''));
306
- var selKey = 'inline-'+post.id;
307
- if(!_ttsSelections[selKey]){_ttsSelections[selKey] = {voice: autoVoice, emotion: autoEmotion};}
308
- var h='<div class="tts-selector" data-post-id="'+post.id+'" style="margin-top:10px;padding:10px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px">';
309
- h+='<div style="font-size:11px;color:#888;margin-bottom:6px">🎙️ Giọng đọc (ngôn ngữ: '+lang.toUpperCase()+'):</div><div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">';
310
- VOICE_LIST.forEach(function(v){var sel=v.id===_ttsSelections[selKey].voice?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="tts-voice-btn '+(v.id===_ttsSelections[selKey].voice?'selected':'')+'" data-voice="'+v.id+'" data-post-id="'+post.id+'" style="'+sel+';border:1px solid;color:#ccc;padding:4px 8px;border-radius:10px;font-size:10px;cursor:pointer">'+v.label+'</button>';});
311
- h+='</div><div style="font-size:11px;color:#888;margin-bottom:6px">😊 Cảm xúc:</div><div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">';
312
- EMOTION_LIST.forEach(function(e){var sel=e.id===_ttsSelections[selKey].emotion?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="tts-emotion-btn '+(e.id===_ttsSelections[selKey].emotion?'selected':'')+'" data-emotion="'+e.id+'" data-post-id="'+post.id+'" style="'+sel+';border:1px solid;color:#ccc;padding:4px 8px;border-radius:10px;font-size:10px;cursor:pointer">'+e.label+'</button>';});
313
- h+='</div><div style="display:flex;align-items:center;gap:6px;margin-bottom:8px"><span style="font-size:11px;color:#888">⚡ Tốc độ:</span>';
314
- h+='<select class="tts-speed" style="background:#222;border:1px solid #333;color:#ccc;padding:3px 8px;border-radius:8px;font-size:10px"><option value="0.85">0.85x Chậm</option><option value="1.0">1.0x Bình thường</option><option value="1.2" selected>1.2x Nhanh</option><option value="1.35">1.35x Rất nhanh</option></select></div>';
315
- h+='<button class="tts-create-btn" style="width:100%;background:#2d8659;border:0;color:#fff;padding:8px;border-radius:10px;font-size:11px;font-weight:700;cursor:pointer">🎬 Tạo Short AI</button></div>';
316
- return h;
317
- }
318
- window.showVoiceEmotionSelector=function(postId,title,text){
319
- var overlay=document.createElement('div');
320
- overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px';
321
- var box=document.createElement('div');box.style.cssText='background:#1a1a1a;border:2px solid #2d8659;border-radius:16px;padding:20px;max-width:400px;width:100%;max-height:80vh;overflow-y:auto';
322
- var lang=detectLanguage(title+' '+text);var autoEmotion=detectEmotion(title+' '+text);
323
- var h='<h3 style="color:#5cb87a;margin-bottom:12px;font-size:16px">🎬 Tạo Short AI (ngôn ngữ: '+lang.toUpperCase()+')</h3>';
324
- h+='<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">🎙️ Chọn giọng đọc:</div>';
325
- VOICE_LIST.forEach(function(v){var sel=v.id===getAutoVoice(lang)?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="ve-voice-btn" data-voice="'+v.id+'" style="display:inline-block;'+sel+';border:1px solid;color:#ccc;padding:5px 10px;border-radius:12px;font-size:10px;margin:2px;cursor:pointer">'+v.label+'</button>';});
326
- h+='</div><div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">😊 Chọn cảm xúc:</div>';
327
- EMOTION_LIST.forEach(function(e){var sel=e.id===autoEmotion?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="ve-emotion-btn" data-emotion="'+e.id+'" style="display:inline-block;'+sel+';border:1px solid;color:#ccc;padding:5px 10px;border-radius:12px;font-size:10px;margin:2px;cursor:pointer">'+e.label+'</button>';});
328
- h+='</div><div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">⚡ Tốc độ:</div>';
329
- h+='<select id="ve-speed" style="background:#222;border:1px solid #333;color:#ccc;padding:6px 12px;border-radius:10px;font-size:11px"><option value="0.85">0.85x Chậm</option><option value="1.0">1.0x Bình thường</option><option value="1.2" selected>1.2x Nhanh</option><option value="1.35">1.35x Rất nhanh</option></select></div>';
330
- h+='<div style="display:flex;gap:8px"><button id="ve-create-btn" style="flex:1;background:#2d8659;border:0;color:#fff;padding:10px;border-radius:12px;font-size:12px;font-weight:700;cursor:pointer">🎬 Tạo Short</button>';
331
- h+='<button id="ve-cancel-btn" style="background:#333;border:0;color:#ccc;padding:10px 16px;border-radius:12px;font-size:11px;cursor:pointer">✕</button></div>';
332
- h+='<div id="ve-status" style="color:#888;font-size:10px;margin-top:8px;display:none"></div>';
333
- box.innerHTML=h;overlay.appendChild(box);document.body.appendChild(overlay);
334
- var selectedVoice=getAutoVoice(lang),selectedEmotion=autoEmotion;
335
- box.querySelectorAll('.ve-voice-btn').forEach(function(btn){btn.addEventListener('click',function(){box.querySelectorAll('.ve-voice-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';selectedVoice=this.dataset.voice;});});
336
- box.querySelectorAll('.ve-emotion-btn').forEach(function(btn){btn.addEventListener('click',function(){box.querySelectorAll('.ve-emotion-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';selectedEmotion=this.dataset.emotion;});});
337
- box.querySelector('#ve-cancel-btn').addEventListener('click',function(){overlay.remove();});
338
- box.querySelector('#ve-create-btn').addEventListener('click',async function(){
339
- this.disabled=true;this.textContent='⏳ Đang tạo...';
340
- box.querySelector('#ve-status').style.display='block';box.querySelector('#ve-status').textContent='Đang tạo video shorts...';
341
- try{
342
- var speed=parseFloat(box.querySelector('#ve-speed').value)||1.2;
343
- if(!_ttsSelections["inline-"+postId]) _ttsSelections["inline-"+postId]={voice:"vi-VN-HoaiMyNeural",emotion:"neutral"};
344
- _ttsSelections["inline-"+postId].voice=selectedVoice;_ttsSelections["inline-"+postId].emotion=selectedEmotion;
345
- var r=await fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:selectedVoice,emotion:selectedEmotion,speed:speed})});
346
- var j=await r.json();
347
- if(!r.ok||j.error) throw new Error(j.error||'Lỗi tạo video');
348
- toast('✅ Đã tạo Short AI!');overlay.remove();
349
- var p=_wallPosts.find(function(x){return String(x.id)===String(postId);});
350
- if(p){p.video=j.video;p.voice=j.voice;p.emotion=j.emotion;}
351
- }catch(e){this.disabled=false;this.textContent='🎬 Tạo Short';box.querySelector('#ve-status').textContent='❌ '+e.message;}
352
- });
353
- };
354
-
355
- function prependWallPost(post){
356
- _wallPosts.unshift(post);
357
- const track=document.getElementById('ai-wall-track');
358
- const wrap=document.getElementById('ai-wall-wrap');
359
- const target=document.getElementById('ai-wall-under-compose');
360
- if(target && (!track||!wrap)){
361
- const newWrap=document.createElement('div');
362
- newWrap.className='slider-wrap';newWrap.id='ai-wall-wrap';
363
- newWrap.innerHTML=`<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">${makeWallItem(post,0)}</div>`;
364
- target.appendChild(newWrap);
365
- const firstItem=newWrap.querySelector('.wall-item');
366
- if(firstItem)firstItem.className='wall-item wall-item-new';
367
- return;
368
- }
369
- if(track){
370
- track.insertAdjacentHTML('afterbegin', makeWallItem(post, 0));
371
- track.scrollTo({left:0,behavior:'smooth'});
372
- }
373
- }
374
-
375
- let _wallPosts=[];
376
- let _currentView='home';
377
- let _currentEventId=null;
378
- let _currentMatchUrl=null;
379
- let _htPage=0,_htTopic='';
380
- async function loadHotTopics(){const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));const el=document.getElementById('hot-topics');if(!el)return;el.innerHTML=(j.topics||[]).slice(0,18).map(t=>{const topicText=t.topic||t.label.replace(/^#/,'');return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;}).join('');}
381
- function searchTopic(topic){if(!topic){topic=document.getElementById('topic-input')?.value.trim();if(!topic){alert('Nhập chủ đề');return;}}document.getElementById('topic-input').value='';_htTopic=topic;_htPage=0;showHashtagSources(topic,0);}
382
- async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Đang tìm...</div></div>`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#888;padding:8px">Không tìm được bài viết liên quan</div></div>`;return;}let h='';if(page===0)h=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)} <span style="font-size:10px;color:#888">(${j.total} bài từ 8 nguồn)</span></h3><div id="ht-list">`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${idx}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')}</div></div></div>`;});if(page===0){h+=`</div><button class="hashtag-rewrite-btn" onclick="rewriteHashtag('${esc(topic).replace(/'/g,"\\'")}')">🤖 Rewrite AI tổng hợp & đăng tường</button>`;if(j.has_more)h+=`<button class="hashtag-load-more" id="ht-more" onclick="loadMoreHashtag()">Tải thêm ▼</button>`;h+=`</div>`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;const ctrl=new AbortController();setTimeout(()=>ctrl.abort(),4000);fetch('/api/article?url='+encodeURIComponent(s.url),{signal:ctrl.signal}).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=`<img src="${_proxyImg(esc(d.og_image||d.img))}" onerror="this.style.display='none'">`;}}).catch(()=>{});});}catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#e74c3c;padding:8px">Lỗi: ${esc(e.message)}</div></div>`;}}
383
- function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
384
- async function rewriteHashtag(topic){const btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{const r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã đăng Tường AI!');if(btn)btn.textContent='✅ Đăng thành công!';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Rewrite AI';}}}
385
- async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const r=await fetch(ep);const d=await r.json();el.innerHTML=d.html&&d.html.length>50?d.html:'<div class="loading">Không có dữ liệu</div>';bindMatchClicks(el);}catch(e){el.innerHTML='<div class="loading">Lỗi</div>';}}
386
- function bindMatchClicks(el){el.querySelectorAll('.match-detail').forEach(md=>{md.style.cursor='pointer';md.addEventListener('click',function(e){const statusA=this.querySelector('.status a');const teamA=this.querySelector('.teams a[href*="/tran-dau/"]');const a = statusA || teamA;if(a){e.preventDefault();e.stopPropagation();const href=a.getAttribute('href')||'';const m=href.match(/\/tran-dau\/(\d+)\//);if(m){const fullUrl=href.startsWith('http')?href:'https://bongda.com.vn'+href;openMatch(m[1],fullUrl);}}});});el.querySelectorAll('a').forEach(a=>{a.addEventListener('click',e=>{e.preventDefault();e.stopPropagation()});});}
387
- function openMatch(id,url){if(!id)return;_currentEventId=id;if(url)_currentMatchUrl=url;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('detail')}
388
- function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
389
- async function loadMatchTab(tab){document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));document.querySelectorAll('.mo-tab').forEach(t=>{if((tab==='comm'&&t.textContent==='Diễn biến')||(tab==='stats'&&t.textContent==='Thống kê')||(tab==='detail'&&t.textContent.includes('Chi tiết')))t.classList.add('active')});const el=document.getElementById('mo-body');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';try{let apiUrl;if(tab==='stats')apiUrl=`/api/match/${_currentEventId}/stats`;else if(tab==='comm')apiUrl=`/api/match/${_currentEventId}/commentaries`;else{apiUrl=`/api/match/${_currentEventId}/detail`;if(_currentMatchUrl)apiUrl+='?url='+encodeURIComponent(_currentMatchUrl)}const r=await fetch(apiUrl);if(!r.ok){el.innerHTML='<div class="loading">Lỗi máy chủ ('+r.status+')</div>';return}const d=await r.json();if(d.error){el.innerHTML='<div class="loading">'+esc(d.error)+'</div>';return}if(tab==='detail'&&typeof renderMatchDetail==='function'){renderMatchDetail(el,d);return}el.innerHTML=d.html||'<div class="loading">Không có dữ liệu</div>'}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
390
- function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
391
- function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
392
- function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector(`[data-cat="${id}"]`)?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
393
- function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
394
- function doShare(title,url,img){const shareUrl=SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});else navigator.clipboard.writeText(shareUrl).then(()=>toast('Đã sao chép!')).catch(()=>{})}
395
- async function doInteract(videoId,type){try{const r=await fetch('/api/v2/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,type})});return await r.json();}catch(e){return null;}}
396
- async function getInteractions(videoId){try{return await fetch('/api/v2/interactions?id='+encodeURIComponent(videoId)).then(r=>r.json());}catch(e){return{views:0,likes:0,comments:0};}}
397
- async function getComments(videoId){try{const j=await fetch('/api/v2/comments?id='+encodeURIComponent(videoId)).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
398
- async function postComment(videoId,text){try{const j=await fetch('/api/v2/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,text})}).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
399
- function buildTikTokSlide(opts){return`<div class="tiktok-slide" data-vid="${esc(opts.videoId)}">${opts.vtag}<div class="tiktok-bottom"><span class="badge ${opts.badgeClass||'badge-fpt'}">${opts.badge||''}</span><p class="tiktok-title">${esc(opts.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation();doView('${esc(opts.videoId)}',this)"><div class="icon">👁</div><div class="count" id="vc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doLike('${esc(opts.videoId)}',this)"><div class="icon">❤️</div><div class="count" id="lc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();toggleComments('${esc(opts.videoId)}',${opts.idx})"><div class="icon">💬</div><div class="count" id="cc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doShare('${esc(opts.title)}','${esc(opts.shareUrl||'')}','')"><div class="icon">📤</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();toggle169View('${esc(opts.videoId)}','169-toggle-${opts.idx}')"><div class="icon" id="169-toggle-${opts.idx}">🖥️</div></button>${opts.extraBtn||''}</div><span class="tiktok-counter">${opts.idx+1}/${opts.total}</span><div class="inline-comments" id="cmt-inline-${opts.idx}" style="display:none"></div></div>`;}
400
- async function doView(videoId,btn){const j=await doInteract(videoId,'view');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.views);}}
401
- async function doLike(videoId,btn){const j=await doInteract(videoId,'like');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.likes);}}
402
- function fmtNum(n){if(!n)return'0';if(n>=1000000)return(n/1000000).toFixed(1)+'M';if(n>=1000)return(n/1000).toFixed(1)+'K';return String(n);}
403
- async function loadCounters(videoIds){for(let i=0;i<videoIds.length;i++){const id=videoIds[i];if(!id)continue;const j=await getInteractions(id);const vc=document.getElementById('vc-'+i);if(vc)vc.textContent=fmtNum(j.views);const lc=document.getElementById('lc-'+i);if(lc)lc.textContent=fmtNum(j.likes);const cc=document.getElementById('cc-'+i);if(cc)cc.textContent=fmtNum(j.comments);}}
404
- async function toggleComments(videoId,idx){const panel=document.getElementById('cmt-inline-'+idx);if(!panel)return;if(panel.style.display!=='none'){panel.style.display='none';return;}panel.style.display='block';panel.innerHTML='<div style="padding:8px;color:#888;font-size:11px">Đang tải...</div>';const cmts=await getComments(videoId);renderInlineComments(panel,videoId,idx,cmts);}
405
- function renderInlineComments(panel,videoId,idx,cmts){let h='<div class="inline-cmt-header"><span>💬 Bình luận</span><button onclick="document.getElementById(\'cmt-inline-'+idx+'\').style.display=\'none\'">✕</button></div><div class="inline-cmt-list">';if(cmts.length){cmts.slice(-30).forEach(c=>{h+=`<div class="inline-cmt-item"><span class="inline-cmt-time">${c.time||''}</span>${esc(c.text)}</div>`;});}else{h+='<div style="color:#777;font-size:11px;padding:4px">Chưa có bình luận</div>';}h+=`</div><div class="inline-cmt-input"><input id="cmt-input-${idx}" placeholder="Viết bình luận..." onkeydown="if(event.key==='Enter')submitInlineCmt('${esc(videoId)}',${idx})"><button onclick="submitInlineCmt('${esc(videoId)}',${idx})">Gửi</button></div>`;panel.innerHTML=h;const list=panel.querySelector('.inline-cmt-list');if(list)list.scrollTop=list.scrollHeight;}
406
- async function submitInlineCmt(videoId,idx){const inp=document.getElementById('cmt-input-'+idx);if(!inp)return;const text=inp.value.trim();if(!text)return;inp.value='';inp.disabled=true;const cmts=await postComment(videoId,text);inp.disabled=false;const panel=document.getElementById('cmt-inline-'+idx);if(panel)renderInlineComments(panel,videoId,idx,cmts);const cc=document.getElementById('cc-'+idx);if(cc)cc.textContent=fmtNum(cmts.length);}
407
- function toggle169View(videoId,iconId){const slide=videoId?document.querySelector(`.tiktok-slide[data-vid="${videoId}"]`):null;if(slide){slide.classList.toggle('ratio-wide');const iconEl=iconId?document.getElementById(iconId):null;if(iconEl)iconEl.textContent=slide.classList.contains('ratio-wide')?'📺':'🖥️';else{const btn=slide.querySelector('.tiktok-right-btn .icon');if(btn)btn.textContent=slide.classList.contains('ratio-wide')?'📺':'🖥️';}return}document.querySelectorAll('.tiktok-slide.ratio-wide').forEach(s=>s.classList.remove('ratio-wide'));document.querySelectorAll('.tiktok-slide').forEach(s=>s.classList.add('ratio-wide'));document.querySelectorAll('.tiktok-right-btn .icon').forEach(b=>{if(b.textContent==='🖥️')b.textContent='📺';})}
408
- function initTikTokFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{const v=sl.querySelector('video');const fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){const hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));v._hls=hls}else if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;const vid=sl.dataset.vid;if(vid&&!sl._viewed){sl._viewed=true;doInteract(vid,'view').then(j=>{if(j){const vc=document.getElementById('vc-'+idx);if(vc)vc.textContent=fmtNum(j.views);}});}}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(fr&&fr.src)fr.src=''}});cur=i}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<bestD){bestD=d;best=i}});if(best>=0)act(best)},150)});setTimeout(()=>act(0),400);slides.forEach(sl=>{const v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});const ids=[...slides].map(sl=>sl.dataset.vid||'');loadCounters(ids)}
409
- async function openHighlightFeed(league,idx,forceUrl){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link)+'&img='+encodeURIComponent(a.img||''));const v=await r.json();if(v&&v.src){return{_idx:i,title:a.title||v.title||'',link:a.link||'',img:a.img||v.poster||'',src:v.src,type:v.type||'',poster:v.poster||a.img||''}}return null}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return}let ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Highlight</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${esc(v.poster)}"`:'';const vtag=isYT?`<iframe data-yt-src="${v.src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`:isHLS?`<video playsinline preload="none"${poster} data-hls="${v.src}" loop controls></video>`:`<video playsinline preload="none"${poster} loop controls><source src="${v.src}" type="video/mp4"></video>`;const videoId='hl-'+league+'-'+v._idx;h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();openHighlightFeed('${league}',${(i+1)%ordered.length})"><div class="icon">⏭️</div></button>`})});h+='</div></div>';el.innerHTML=h;setTimeout(()=>initTikTokFeed(),200);}
410
- async function openYTShortsFeed(idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đã xóa Shorts Dân trí/SKĐS</div>';}
411
- async function openShortAIFeed(idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');if(!_wallPosts||!_wallPosts.length){el.innerHTML='<div class="loading">Không có Short AI</div>';return}const aiPosts=_wallPosts.filter(p=>p.video);if(!aiPosts.length||idx>=aiPosts.length){el.innerHTML='<div class="loading">Không có Short AI</div>';return}const ordered=aiPosts.slice(idx).concat(aiPosts.slice(0,idx));let h=`<button class="back-btn" onclick="switchCat('home')">← Tường AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((p,i)=>{const baseIdx=_wallPosts.indexOf(p);const vtag=p.video?`<video playsinline preload="none" src="${esc(p.video)}" loop controls></video>`:'';h+=buildTikTokSlide({vtag,title:p.title,badge:'Short AI',badgeClass:'badge-ai',videoId:p.id||'ai-'+i,idx:i,total:ordered.length,shareUrl:'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();showVoiceEmotionSelector('${esc(p.id)}','${esc(p.title)}','${esc((p.text||'').slice(0,200))}')"><div class="icon">🎤</div></button>`})});h+='</div></div>';el.innerHTML=h;setTimeout(()=>initTikTokFeed(),200);}
412
- function readWallPost(idx){const p=_wallPosts&&_wallPosts[idx];if(!p)return;if(p.slides&&p.slides.length){readSlidePost(idx);return}readArticle(p.url||'','','',p.title,p.text);}
413
- /** Show rewrite slide viewer - vertical slides with text+image */
414
- function readSlidePost(idx){const p=_wallPosts[idx];if(!p||!p.slides)return;showView('view-article');const el=document.getElementById('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Tường AI</button><div class="slide-viewer" style="padding:12px;max-width:600px;margin:0 auto">`;p.slides.forEach((s,i)=>{h+=`<div class="slide-card" style="background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px"><div class="slide-num" style="color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px">Slide ${s.index||i+1}/${p.slides.length}</div>${s.image?`<img src="${_proxyImg(s.image)}" style="width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px" loading="lazy" onerror="this.style.display=\'none\'">`:''}<p style="color:#ddd;font-size:14px;line-height:1.6;margin:0">${esc(s.text)}</p></div>`;});h+=`</div>`;el.innerHTML=h;}
415
- function readNewsTab(tab){loadNewsTab();}
416
- function loadNewsTab(){const el=document.getElementById('view-cat');if(!el)return;el.innerHTML='<div class="loading">Đang tải tin tức...</div>';fetch('/api/homepage').then(r=>r.json()).then(articles=>{if(!articles||!articles.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';articles.forEach(a=>{const src=a.source||'vne';const badge=a.group||a.source||'';h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy" onerror="this.style.display=\'none\'">`:''}</div><div class="card-body"><span class="badge badge-${src}">${esc(badge)}</span><div class="card-title">${esc(a.title)}</div></div></div>`;});h+='</div>';el.innerHTML=h;}).catch(()=>{el.innerHTML='<div class="loading">Lỗi tải</div>';});}
417
-
418
- function readArticle(url,title,img,presetTitle,presetText){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div><button class="back-btn" onclick="switchCat(\'home\')">← Quay lại</button>';if(presetTitle){el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${esc(presetTitle)}</h1>${presetText?`<div class="article-summary" style="white-space:pre-wrap">${esc(presetText)}</div>`:''}</div><div class="article-actions"><button onclick="doShare('${esc(presetTitle)}','${esc(url||'')}','')">📤 Chia sẻ</button></div>`;return;}if(!url)return;fetch('/api/article?url='+encodeURIComponent(url)).then(r=>r.json()).then(d=>{let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view">`;if(d.title)h+=`<h1 class="article-title">${esc(d.title)}</h1>`;if(d.summary)h+=`<div class="article-summary">${esc(d.summary)}</div>`;if(d.body)d.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${esc(b.text)}</p>`;else if(b.type==='heading')h+=`<h2 class="article-h2">${esc(b.text)}</h2>`;else if(b.type==='img'&&b.src)h+=`<img class="article-img" src="${_proxyImg(b.src)}" loading="lazy" onerror="this.style.display=\'none\'">`;});h+=`</div><div class="article-actions"><button onclick="doShare('${esc(d.title||'')}','${esc(url)}','${esc(d.og_image||'')}')">📤 Chia sẻ</button><button class="primary" onclick="rewriteSlide('${esc(url)}')">🤖 Slide Rewrite AI</button></div>`;el.innerHTML=h;}).catch(()=>{el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><p>Không thể tải bài viết</p></div>`;});}
419
- async function rewriteSlide(url){if(!url)return;const btn=document.querySelector('.article-actions .primary')||event?.target;if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo slides...';}toast('⏳ Đang tạo slide rewrite...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã tạo bài! Đang tạo Short AI...');if(btn)btn.textContent='✅ Hoàn tất';if(j.post){j.post.slides=j.slides||[];prependWallPost(j.post);readArticle(url,'','',j.post.title,j.post.text);}if(j.post&&j.post.id){setTimeout(()=>{makeShortVideo(j.post.id,null,'vi-VN-HoaiMyNeural',1.2,'neutral')},500);}}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Slide Rewrite AI';}}}
420
- async function rewriteUrl(){const url=document.getElementById('url-input')?.value.trim();if(!url){alert('Nhập URL');return;}const btn=document.querySelector('.ai-compose-row button.secondary');if(btn){btn.disabled=true;btn.textContent='⏳ Đang rewrite...';}try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi rewrite');toast('✅ Đã tạo bài AI! Đang tạo Short...');if(j.post)prependWallPost(j.post);if(j.slides&&j.post){j.post.slides=j.slides;}readArticle(url,'','',j.post.title,j.post.text);if(j.post&&j.post.id){setTimeout(()=>{makeShortVideo(j.post.id,null,'vi-VN-HoaiMyNeural',1.2,'neutral')},500);}}catch(e){toast('❌ '+e.message);readArticle(url);}finally{if(btn){btn.disabled=false;btn.textContent='Rewrite';}}}
421
-
422
- // ===== Real-time wall polling: detect new auto-scheduled posts every 30s =====
423
- var _wallPollTimer = null;
424
- var _wallPollKnownIds = new Set();
425
- var _wallPollStarted = false;
426
-
427
- function _startWallPolling(){
428
- if(_wallPollStarted) return;
429
- _wallPollStarted = true;
430
- // Seed known IDs from current wall
431
- if(_wallPosts) _wallPosts.forEach(function(p){ if(p.id) _wallPollKnownIds.add(p.id); });
432
- _wallPollTimer = setInterval(_pollWall, 30000);
433
- // Also do an immediate check after 5s
434
- setTimeout(_pollWall, 5000);
435
- }
436
-
437
- async function _pollWall(){
438
- try{
439
- var r = await fetch('/api/wall', {signal: AbortSignal.timeout(8000)});
440
- var j = await r.json();
441
- var posts = j.posts || [];
442
- var newPosts = [];
443
- for(var i = 0; i < posts.length; i++){
444
- var p = posts[i];
445
- if(p.id && !_wallPollKnownIds.has(p.id)){
446
- _wallPollKnownIds.add(p.id);
447
- if(p.auto_scheduled || p.kind === 'auto_rewrite'){
448
- newPosts.push(p);
449
- }
450
- }
451
- }
452
- for(var ni = 0; ni < newPosts.length; ni++){
453
- prependWallPost(newPosts[ni]);
454
- if(!newPosts[ni].video && newPosts[ni].id){
455
- setTimeout(function(id){ makeShortVideo(id, null, 'vi-VN-HoaiMyNeural', 1.2, 'neutral'); }, 1000 + ni * 2000, newPosts[ni].id);
456
- }
457
- }
458
- if(newPosts.length > 0){
459
- toast('📰 ' + newPosts.length + ' bài AI mới vừa được đăng!');
460
- }
461
- }catch(e){}
462
- }
463
-
464
- // Auto-start polling once wall data is loaded
465
- (function _initPoll(){
466
- if(typeof _wallPosts !== 'undefined' && _wallPosts.length > 0){
467
- setTimeout(_startWallPolling, 2000);
468
- } else {
469
- setTimeout(_initPoll, 1500);
470
- }
471
- })();
472
- function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';fetch('/api/category/'+id).then(r=>r.json()).then(articles=>{if(!articles||!articles.length){el.innerHTML='<div class="loading">Không có bài viết</div>';return}let h='<div class="grid">';articles.forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy" onerror="this.style.display=\'none\'">`:''}</div><div class="card-body"><span class="badge badge-${a.source||'vne'}">${esc(a.source||'')}</span><div class="card-title">${esc(a.title)}</div></div></div>`;});h+='</div>';el.innerHTML=h;}).catch(()=>{el.innerHTML='<div class="loading">Lỗi tải</div>';});}
 
1
+ // === VNEWS Frontend v2 - Optimized for speed ===
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ // === LOAD HOME - Fast: immediate shell + parallel fetch ===
4
  function _fetchWithTimeout(url, ms){
5
  return new Promise((resolve,reject)=>{
6
  const ctrl=new AbortController();
 
17
  const homeEl = document.getElementById('view-home');
18
  if(!homeEl) return;
19
 
20
+ // Build shell IMMEDIATELY — no skeleton, no delay
21
  homeEl.innerHTML =
22
  '<div id="home-featured-area"></div>'
23
  +'<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>'
 
24
  +'<div id="hashtag-box"></div>'
25
  +'<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="today" onclick="loadLivescore(\'today\')">📅 Hôm nay</span><span class="ls-tab" data-tab="live" onclick="loadLivescore(\'live\')">🔴 Live</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore(\'incoming\')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore(\'results\')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore(\'bxh_nha\')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore(\'bxh_laliga\')">🏆 La Liga</span></div><div class="ls-content" id="ls-content"><div class="loading">Đang tải...</div></div></div>'
26
  +'<div id="wc2026-live-section" class="wc2026-section"><div class="wc-header"><h2>🏆 World Cup 2026</h2><span class="wc-live-badge">● LIVE</span></div><div class="wc-tabs"><span class="wc-tab active" onclick="switchWCTab(\'news\')">📰 Tin tức</span><span class="wc-tab" onclick="switchWCTab(\'fixtures\')">📅 Lịch thi đấu</span><span class="wc-tab" onclick="switchWCTab(\'standings\')">🏆 BXH</span><span class="wc-tab" onclick="switchWCTab(\'highlights\')">🎬 Highlight</span><span class="wc-tab" onclick="switchWCTab(\'stats\')">📊 Thống kê</span></div><div class="wc-content" id="wc-content"><div class="loading">Đang tải World Cup 2026...</div></div></div>'
 
28
 
29
  const afterEl = homeEl.querySelector('#home-after-wc');
30
 
31
+ // Start critical loads immediately
32
  loadLivescore('today');
33
  loadHotTopics();
34
 
35
+ // Fetch all data in parallel with shorter timeouts
36
+ const [featuredData, shortsData, wallData, hlLeagues, aiData, wcData] = await Promise.allSettled([
37
  _fetchWithTimeout('/api/livescore/featured', 5000),
38
+ _fetchWithTimeout('/api/shorts', 8000),
39
  _fetchWithTimeout('/api/wall', 5000),
40
  _fetchWithTimeout('/api/highlights/leagues', 10000),
41
+ _fetchWithTimeout('/api/genk_ai', 8000),
42
  _fetchWithTimeout('/api/wc2026', 8000),
43
  ]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
44
 
45
+ // Render featured match
46
  if(featuredData && featuredData.home){
47
  const sc=featuredData.status==='live'?'':'upcoming';
48
  const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`;
49
  const area=document.getElementById('home-featured-area');
50
+ if(area) area.innerHTML=`<div class="featured-match" onclick="openMatch('${featuredData.event_id}')"><div class="fm-league">${featuredData.league}</div><div class="fm-teams"><div class="fm-team"><img src="${featuredData.home_logo}" onerror="this.style.display='none'"><span>${featuredData.home}</span></div><div class="fm-score">${featuredData.score||'VS'}</div><div class="fm-team"><img src="${featuredData.away_logo}" onerror="this.style.display='none'"><span>${featuredData.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;
51
  }
52
 
53
+ // Store globally
54
+ _shortsData = shortsData || [];
55
  _wallPosts = (wallData && wallData.posts) || [];
56
  _hlLeagueData = hlLeagues || {};
57
  _wc2026Data = wcData;
58
 
59
+ // Render WC if data arrived
60
  if(wcData) switchWCTab('news');
61
 
62
+ // Render sections into the after-wc area
63
+ _renderShortsIn(afterEl);
64
  _renderWallIn(afterEl);
65
  _renderHLIn(afterEl);
66
+ if(aiData && aiData.length) _renderSlidesIn('ai-articles','Ứng dụng AI','🤖',aiData,afterEl);
67
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/app_v2_shorts_fix.js CHANGED
@@ -1,2 +1,383 @@
1
- // No-op - all functionality built into app_v2.js
2
- (function(){})();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // VNEWS Short AI Fix - Multilingual voices + emotion selector
2
+ // This file patches makeShortVideo and readWallPost to support full voice list
3
+
4
+ (function(){
5
+ // Full voice list
6
+ const VOICE_LIST = [
7
+ {id:'hoaimy', label:'🎙️ Hoài My (VI)', lang:'vi'},
8
+ {id:'namminh', label:'🎙️ Nam Minh (VI)', lang:'vi'},
9
+ {id:'andrew', label:'🎙️ Andrew (Multilingual)', lang:'en'},
10
+ {id:'jenny', label:'🎙️ Jenny (EN)', lang:'en'},
11
+ {id:'thalita', label:'🎙️ Thalita (PT)', lang:'pt'},
12
+ {id:'pt_francisco', label:'🎙️ Francisco (PT)', lang:'pt'},
13
+ {id:'ela', label:'🎙️ Ela (ES)', lang:'es'},
14
+ {id:'es_carlos', label:'🎙️ Carlos (ES)', lang:'es'},
15
+ {id:'denise', label:'🎙️ Denise (FR)', lang:'fr'},
16
+ {id:'katja', label:'🎙️ Katja (DE)', lang:'de'},
17
+ {id:'nanami', label:'🎙️ Nanami (JA)', lang:'ja'},
18
+ {id:'sunhee', label:'🎙️ SunHee (KO)', lang:'ko'},
19
+ {id:'xiaochen', label:'🎙️ XiaoChen (ZH)', lang:'zh'},
20
+ ];
21
+
22
+ const EMOTION_LIST = [
23
+ {id:'neutral', label:'😐 Trung tính'},
24
+ {id:'happy', label:'😊 Vui vẻ'},
25
+ {id:'excited', label:'🔥 Hào hứng'},
26
+ {id:'sad', label:'😢 Buồn'},
27
+ {id:'humorous', label:'😂 Hài hước'},
28
+ {id:'serious', label:'⚠️ Nghiêm túc'},
29
+ {id:'urgent', label:'🚨 Khẩn cấp'},
30
+ {id:'warm', label:'💖 Ấm áp'},
31
+ ];
32
+
33
+ // Language detection (lightweight)
34
+ function detectLanguage(text) {
35
+ if (!text) return 'vi';
36
+ var t = text.toLowerCase();
37
+ var chars = new Set(t);
38
+ // Vietnamese
39
+ var vnChars = 'đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựỳỷỹỵý';
40
+ var vnCount = 0;
41
+ for (var c of vnChars) { if (chars.has(c)) vnCount++; }
42
+ if (vnCount >= 2) return 'vi';
43
+
44
+ // Spanish markers
45
+ if (chars.has('ñ') || chars.has('¿') || chars.has('¡')) return 'es';
46
+
47
+ // Portuguese markers
48
+ if (chars.has('ã') || chars.has('õ')) return 'pt';
49
+
50
+ // English default
51
+ var words = t.split(/\s+/);
52
+ var enWords = ['the','is','at','which','on','and','or','but','this','that','with','from','have','been'];
53
+ var enCount = words.filter(function(w){return enWords.indexOf(w)>=0;}).length;
54
+ if (enCount >= 2) return 'en';
55
+
56
+ return 'vi';
57
+ }
58
+
59
+ // Auto-detect emotion
60
+ function detectEmotion(text) {
61
+ if (!text) return 'neutral';
62
+ var t = text.toLowerCase();
63
+ var kws = {
64
+ happy: ['vui','hạnh phúc','tuyệt','thành công','chiến thắng','feliz','maravilloso','happy','joy','wonderful','great','amazing','love','excellent'],
65
+ excited: ['hào hứng','phấn khích','đột phá','kỷ lục','đỉnh cao','emocionante','increíble','excited','thrilling','unbelievable','awesome','breakthrough'],
66
+ sad: ['buồn','đau','mất','thảm họa','khủng hoảng','triste','terrible','sad','unhappy','tragic','painful','death'],
67
+ humorous: ['hài hước','buồn cười','haha','đùa','engraçado','gracioso','funny','hilarious','joke','lol'],
68
+ serious: ['nghiêm trọng','khẩn cấp','quan trọng','lo ngại','sério','crítico','serious','critical','urgent','severe','crisis'],
69
+ urgent: ['khẩn cấp','báo động','ngay lập tức','urgent','breaking','alert','emergency'],
70
+ warm: ['ấm áp','tình cảm','yêu thương','warm','love','heart','touching','beautiful story']
71
+ };
72
+ var bestScore = 0;
73
+ var bestEmotion = 'neutral';
74
+ for (var em in kws) {
75
+ var score = 0;
76
+ for (var kw of kws[em]) {
77
+ if (t.indexOf(kw) >= 0) score++;
78
+ }
79
+ if (score > bestScore) { bestScore = score; bestEmotion = em; }
80
+ }
81
+ return bestEmotion;
82
+ }
83
+
84
+ // Auto-select voice based on language
85
+ function getAutoVoice(lang) {
86
+ var map = {vi:'hoaimy', pt:'thalita', es:'ela', en:'jenny', fr:'denise', de:'katja', ja:'nanami', ko:'sunhee', zh:'xiaochen'};
87
+ return map[lang] || 'hoaimy';
88
+ }
89
+
90
+ // Build voice+emotion selector HTML
91
+ function buildVoiceEmotionSelector(post) {
92
+ var lang = post.language || detectLanguage(post.title + ' ' + post.text);
93
+ var autoVoice = post.voice || getAutoVoice(lang);
94
+ var autoEmotion = post.emotion || detectEmotion(post.title + ' ' + post.text);
95
+
96
+ var h = '<div class="tts-selector" style="margin-top:10px;padding:10px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px">';
97
+ h += '<div class="tts-selector-label" style="font-size:11px;color:#888;margin-bottom:6px">🎙️ Giọng đọc (ngôn ngữ: ' + lang.toUpperCase() + '):</div>';
98
+ h += '<div class="tts-voice-btns" style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">';
99
+ VOICE_LIST.forEach(function(v){
100
+ var sel = v.id === autoVoice ? 'border-color:#5cb87a;background:#1a2a1f' : 'border-color:#333;background:#222';
101
+ h += '<button class="tts-voice-btn" data-voice="'+v.id+'" style="'+sel+';border:1px solid;color:#ccc;padding:4px 8px;border-radius:10px;font-size:10px;cursor:pointer">'+v.label+'</button>';
102
+ });
103
+ h += '</div>';
104
+ h += '<div class="tts-selector-label" style="font-size:11px;color:#888;margin-bottom:6px">😊 Cảm xúc:</div>';
105
+ h += '<div class="tts-emotion-btns" style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">';
106
+ EMOTION_LIST.forEach(function(e){
107
+ var sel = e.id === autoEmotion ? 'border-color:#5cb87a;background:#1a2a1f' : 'border-color:#333;background:#222';
108
+ h += '<button class="tts-emotion-btn" data-emotion="'+e.id+'" style="'+sel+';border:1px solid;color:#ccc;padding:4px 8px;border-radius:10px;font-size:10px;cursor:pointer">'+e.label+'</button>';
109
+ });
110
+ h += '</div>';
111
+ h += '<div class="tts-speed-row" style="display:flex;align-items:center;gap:6px;margin-bottom:8px"><span style="font-size:11px;color:#888">⚡ Tốc độ:</span>';
112
+ h += '<select class="tts-speed" style="background:#222;border:1px solid #333;color:#ccc;padding:3px 8px;border-radius:8px;font-size:10px">';
113
+ h += '<option value="0.85">0.85x Chậm</option><option value="1.0">1.0x Bình thường</option><option value="1.2" selected>1.2x Nhanh</option><option value="1.35">1.35x Rất nhanh</option>';
114
+ h += '</select></div>';
115
+ h += '<button class="tts-create-btn" style="width:100%;background:#2d8659;border:0;color:#fff;padding:8px;border-radius:10px;font-size:11px;font-weight:700;cursor:pointer">🎬 Tạo Short AI</button>';
116
+ h += '<input type="hidden" class="tts-selected-voice" value="' + autoVoice + '">';
117
+ h += '<input type="hidden" class="tts-selected-emotion" value="' + autoEmotion + '">';
118
+ h += '</div>';
119
+ return h;
120
+ }
121
+
122
+ // Patch readWallPost to include full voice+emotion selector
123
+ var origReadWallPost = window.readWallPost;
124
+ window.readWallPost = function(i){
125
+ var p = _wallPosts[i];
126
+ if(!p) return;
127
+ showView('view-article');
128
+ var images = p.images || [];
129
+ var imgGallery = '';
130
+ if(images.length > 0){
131
+ imgGallery = '<div style="display:flex;gap:4px;flex-wrap:wrap;margin:8px 0">';
132
+ images.slice(0,6).forEach(function(imgUrl){
133
+ imgGallery += '<img src="'+esc(imgUrl)+'" style="width:80px;height:60px;object-fit:cover;border-radius:4px" onerror="this.style.display=\'none\'">';
134
+ });
135
+ imgGallery += '</div>';
136
+ }
137
+ var hasVideo = p.video && p.video.length > 0;
138
+ var voiceEmotionHtml = '';
139
+ if(!hasVideo){
140
+ voiceEmotionHtml = buildVoiceEmotionSelector(p);
141
+ } else {
142
+ voiceEmotionHtml = '<div style="margin-top:10px;padding:10px;background:#1a2a1f;border-radius:8px;font-size:11px;color:#5cb87a">🎬 Voice: ' + (p.voice||'hoaimy') + ' | 😊 Emotion: ' + (p.emotion||'neutral') + ' | ⚡ Speed: ' + (p.short_speed||'1.2') + 'x</div>';
143
+ voiceEmotionHtml += buildVoiceEmotionSelector(p);
144
+ }
145
+
146
+ document.getElementById('view-article').innerHTML =
147
+ '<button class="back-btn" onclick="switchCat(\'home\')">← Quay lại</button>' +
148
+ '<div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">'+esc(p.title)+'</h1>' +
149
+ imgGallery +
150
+ '<p class="article-p" style="white-space:pre-wrap">'+esc(p.text)+'</p>' +
151
+ (hasVideo ? '<video class="article-img" src="'+esc(p.video)+'" controls playsinline style="max-height:400px"></video>' : '') +
152
+ '<div class="article-actions">' +
153
+ (hasVideo ? '<button onclick="openShortAIFeed('+i+')">🎬 Xem Short</button>' : '') +
154
+ '</div>' +
155
+ voiceEmotionHtml +
156
+ '</div>';
157
+ window.scrollTo(0,0);
158
+
159
+ // Bind voice/emotion selectors
160
+ setTimeout(function(){
161
+ var container = document.querySelector('.tts-selector');
162
+ if(!container) return;
163
+
164
+ container.querySelectorAll('.tts-voice-btn').forEach(function(btn){
165
+ btn.addEventListener('click', function(){
166
+ container.querySelectorAll('.tts-voice-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});
167
+ this.style.borderColor='#5cb87a';
168
+ this.style.background='#1a2a1f';
169
+ container.querySelector('.tts-selected-voice').value = this.dataset.voice;
170
+ });
171
+ });
172
+ container.querySelectorAll('.tts-emotion-btn').forEach(function(btn){
173
+ btn.addEventListener('click', function(){
174
+ container.querySelectorAll('.tts-emotion-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});
175
+ this.style.borderColor='#5cb87a';
176
+ this.style.background='#1a2a1f';
177
+ container.querySelector('.tts-selected-emotion').value = this.dataset.emotion;
178
+ });
179
+ });
180
+ var createBtn = container.querySelector('.tts-create-btn');
181
+ if(createBtn){
182
+ createBtn.addEventListener('click', function(){
183
+ var postId = p.id;
184
+ var voice = container.querySelector('.tts-selected-voice').value;
185
+ var emotion = container.querySelector('.tts-selected-emotion').value;
186
+ var speed = parseFloat(container.querySelector('.tts-speed').value) || 1.2;
187
+ window.makeShortVideo(postId, this, voice, speed, emotion);
188
+ });
189
+ }
190
+ }, 100);
191
+ };
192
+
193
+ // Patch makeShortVideo to accept emotion parameter
194
+ var origMakeShortVideo = window.makeShortVideo;
195
+ window.makeShortVideo = function(postId, btn, voice, speed, emotion){
196
+ if(!postId) return;
197
+ var origText = btn ? btn.textContent : '🎬 Tạo Video';
198
+ if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
199
+ toast('⏳ Đang tạo video shorts...');
200
+ try{
201
+ var url = '/api/ai/short/' + encodeURIComponent(postId);
202
+ var params = [];
203
+ if(voice) params.push('voice='+encodeURIComponent(voice));
204
+ if(speed) params.push('speed='+encodeURIComponent(speed));
205
+ if(emotion) params.push('emotion='+encodeURIComponent(emotion));
206
+ if(params.length) url += '?' + params.join('&');
207
+
208
+ fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({voice:voice||'hoaimy', emotion:emotion||'neutral', speed:speed||1.2})})
209
+ .then(function(r){return r.json().then(function(j){return {ok:r.ok, j:j};});})
210
+ .then(function(result){
211
+ if(!result.ok || result.j.error) throw new Error(result.j.error||'Lỗi tạo video');
212
+ toast('✅ Đã tạo video shorts!');
213
+ var p = _wallPosts.find(function(x){return String(x.id)===String(postId);});
214
+ if(p){
215
+ p.video = result.j.video;
216
+ p.voice = result.j.voice;
217
+ p.emotion = result.j.emotion;
218
+ var itemId = 'wall-item-'+postId;
219
+ var el = document.getElementById(itemId);
220
+ if(el){
221
+ var idx = _wallPosts.indexOf(p);
222
+ el.outerHTML = makeWallItem(p, idx);
223
+ var newEl = document.getElementById(itemId);
224
+ if(newEl) newEl.className = 'wall-item wall-item-new';
225
+ }
226
+ }
227
+ refreshShortAISlider();
228
+ })
229
+ .catch(function(e){
230
+ toast('❌ '+e.message);
231
+ if(btn){btn.disabled=false;btn.textContent=origText;}
232
+ });
233
+ }catch(e){
234
+ toast('❌ '+e.message);
235
+ if(btn){btn.disabled=false;btn.textContent=origText;}
236
+ }
237
+ };
238
+
239
+ // Also patch rewriteArticle in app_v2.js to use rewrite_share (not rewrite_slide)
240
+ window.rewriteArticle = async function(){
241
+ var url = window._currentArticle && window._currentArticle.url;
242
+ if(!url) return;
243
+ toast('⏳ Đang tạo bài đăng Tường AI...');
244
+ try {
245
+ var r = await fetch('/api/rewrite_share', {
246
+ method: 'POST',
247
+ headers: {'Content-Type': 'application/json'},
248
+ body: JSON.stringify({url: url, context: document.querySelector('.article-view') ? document.querySelector('.article-view').innerText.slice(0,14000) : ''})
249
+ });
250
+ var j = await r.json();
251
+ if (!r.ok || j.error) throw new Error(j.error);
252
+ toast('✅ Đã đăng Tường AI!');
253
+ if (j.post) prependWallPost(j.post);
254
+ if (j.post && typeof goToWallPost === 'function') goToWallPost(j.post.id);
255
+ // Show slides if available
256
+ if (j.slides && j.slides.length) {
257
+ setTimeout(function(){ showSlidePreview(j.slides, j.post ? j.post.title : ''); }, 500);
258
+ }
259
+ // Show voice + emotion selector
260
+ if (j.post && !j.post.video) {
261
+ setTimeout(function(){ showVoiceEmotionSelector(j.post.id, j.post.title, j.post.text); }, 1000);
262
+ }
263
+ } catch(e) {
264
+ toast('❌ ' + e.message);
265
+ }
266
+ };
267
+
268
+ // Show voice+emotion selector popup (same as in rewrite_fix_v2.js)
269
+ window.showVoiceEmotionSelector = function(postId, title, text) {
270
+ var overlay = document.createElement('div');
271
+ overlay.id = 'voice-emotion-selector';
272
+ overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px';
273
+ var box = document.createElement('div');
274
+ box.style.cssText = 'background:#1a1a1a;border:2px solid #2d8659;border-radius:16px;padding:20px;max-width:400px;width:100%;max-height:80vh;overflow-y:auto';
275
+ var lang = detectLanguage(title + ' ' + text);
276
+ var autoEmotion = detectEmotion(title + ' ' + text);
277
+ var h = '<h3 style="color:#5cb87a;margin-bottom:12px;font-size:16px">🎬 Tạo Short AI (ngôn ngữ: '+lang.toUpperCase()+')</h3>';
278
+ h += '<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">🎙️ Chọn giọng đọc:</div>';
279
+ VOICE_LIST.forEach(function(v){
280
+ var sel = v.id === getAutoVoice(lang) ? 'border-color:#5cb87a;background:#1a2a1f' : 'border-color:#333;background:#222';
281
+ h += '<button class="ve-voice-btn" data-voice="'+v.id+'" style="display:inline-block;'+sel+';border:1px solid;color:#ccc;padding:5px 10px;border-radius:12px;font-size:10px;margin:2px;cursor:pointer">'+v.label+'</button>';
282
+ });
283
+ h += '</div>';
284
+ h += '<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">😊 Chọn cảm xúc:</div>';
285
+ EMOTION_LIST.forEach(function(e){
286
+ var sel = e.id === autoEmotion ? 'border-color:#5cb87a;background:#1a2a1f' : 'border-color:#333;background:#222';
287
+ h += '<button class="ve-emotion-btn" data-emotion="'+e.id+'" style="display:inline-block;'+sel+';border:1px solid;color:#ccc;padding:5px 10px;border-radius:12px;font-size:10px;margin:2px;cursor:pointer">'+e.label+'</button>';
288
+ });
289
+ h += '</div>';
290
+ h += '<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">⚡ Tốc độ:</div>';
291
+ h += '<select id="ve-speed" style="background:#222;border:1px solid #333;color:#ccc;padding:6px 12px;border-radius:10px;font-size:11px">';
292
+ h += '<option value="0.85">0.85x Chậm</option><option value="1.0">1.0x Bình thường</option><option value="1.2" selected>1.2x Nhanh</option><option value="1.35">1.35x Rất nhanh</option></select></div>';
293
+ h += '<div style="display:flex;gap:8px"><button id="ve-create-btn" style="flex:1;background:#2d8659;border:0;color:#fff;padding:10px;border-radius:12px;font-size:12px;font-weight:700;cursor:pointer">🎬 Tạo Short</button>';
294
+ h += '<button id="ve-cancel-btn" style="background:#333;border:0;color:#ccc;padding:10px 16px;border-radius:12px;font-size:11px;cursor:pointer">✕</button></div>';
295
+ h += '<div id="ve-status" style="color:#888;font-size:10px;margin-top:8px;display:none"></div>';
296
+ box.innerHTML = h;
297
+ overlay.appendChild(box);
298
+ document.body.appendChild(overlay);
299
+ var selectedVoice = getAutoVoice(lang);
300
+ var selectedEmotion = autoEmotion;
301
+ box.querySelectorAll('.ve-voice-btn').forEach(function(btn){
302
+ btn.addEventListener('click', function(){
303
+ box.querySelectorAll('.ve-voice-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});
304
+ this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';
305
+ selectedVoice = this.dataset.voice;
306
+ });
307
+ });
308
+ box.querySelectorAll('.ve-emotion-btn').forEach(function(btn){
309
+ btn.addEventListener('click', function(){
310
+ box.querySelectorAll('.ve-emotion-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});
311
+ this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';
312
+ selectedEmotion = this.dataset.emotion;
313
+ });
314
+ });
315
+ box.querySelector('#ve-cancel-btn').addEventListener('click', function(){overlay.remove();});
316
+ box.querySelector('#ve-create-btn').addEventListener('click', async function(){
317
+ this.disabled = true; this.textContent = '⏳ Đang tạo...';
318
+ box.querySelector('#ve-status').style.display = 'block';
319
+ box.querySelector('#ve-status').textContent = 'Đang tạo video shorts...';
320
+ try {
321
+ var speed = parseFloat(box.querySelector('#ve-speed').value) || 1.2;
322
+ var r = await fetch('/api/ai/short/' + encodeURIComponent(postId), {
323
+ method: 'POST', headers: {'Content-Type': 'application/json'},
324
+ body: JSON.stringify({voice: selectedVoice, emotion: selectedEmotion, speed: speed})
325
+ });
326
+ var j = await r.json();
327
+ if (!r.ok || j.error) throw new Error(j.error || 'Lỗi tạo video');
328
+ toast('✅ Đã tạo Short AI!');
329
+ overlay.remove();
330
+ var p = _wallPosts.find(function(x){return String(x.id)===String(postId);});
331
+ if (p) { p.video = j.video; p.voice = j.voice; p.emotion = j.emotion; }
332
+ var track = document.getElementById('ai-wall-track');
333
+ if (track) {
334
+ var idx = _wallPosts.findIndex(function(x){return String(x.id)===String(postId);});
335
+ if (idx >= 0 && track.children[idx]) {
336
+ track.children[idx].outerHTML = makeWallItem(p, idx);
337
+ }
338
+ }
339
+ refreshShortAISlider();
340
+ } catch(e) {
341
+ this.disabled = false; this.textContent = '🎬 Tạo Short';
342
+ box.querySelector('#ve-status').textContent = '❌ ' + e.message;
343
+ }
344
+ });
345
+ };
346
+
347
+ // Show slides fullscreen overlay
348
+ window.showSlidePreview = function(slides, title) {
349
+ if (!slides || !slides.length) return;
350
+ var overlay = document.createElement('div');
351
+ overlay.id = 'slide-preview';
352
+ overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:99999;display:flex;flex-direction:column;overflow:hidden';
353
+ var currentSlide = 0;
354
+ function renderSlide(idx) {
355
+ var s = slides[idx];
356
+ overlay.innerHTML = '<div style="position:absolute;top:10px;left:10px;right:10px;display:flex;justify-content:space-between;align-items:center;z-index:2">' +
357
+ '<button onclick="document.getElementById(\'slide-preview\').remove()" style="background:rgba(0,0,0,.6);border:0;color:#fff;padding:8px 14px;border-radius:20px;font-size:12px;cursor:pointer">✕ Đóng</button>' +
358
+ '<span style="color:#fff;font-size:11px;background:rgba(0,0,0,.6);padding:4px 10px;border-radius:10px">' + (idx+1) + '/' + slides.length + '</span>' +
359
+ '</div>' +
360
+ '<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:20px">' +
361
+ (s.image ? '<img src="' + esc(s.image) + '" style="max-width:100%;max-height:60vh;border-radius:10px;object-fit:contain" onerror="this.style.display=\'none\'">' : '') +
362
+ '</div>' +
363
+ '<div style="padding:16px 20px;background:linear-gradient(transparent,rgba(0,0,0,.9));min-height:100px">' +
364
+ '<p style="color:#fff;font-size:14px;line-height:1.6;text-align:center">' + esc(s.text) + '</p>' +
365
+ '</div>' +
366
+ '<div style="display:flex;gap:10px;padding:10px 20px 20px;justify-content:center">' +
367
+ '<button onclick="prevSlide()" style="background:#333;border:0;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;cursor:pointer"' + (idx===0?' disabled style="opacity:.3"':'') + '>← Trước</button>' +
368
+ '<button onclick="nextSlideRip('+idx+')" id="slide-next-btn" style="background:#2d8659;border:0;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;cursor:pointer"' + (idx===slides.length-1?' disabled style="opacity:.3"':'') + '>Tiếp →</button>' +
369
+ '</div>';
370
+ }
371
+ window.nextSlideRip = function(currentIdx) { if (currentIdx < slides.length - 1) { currentSlide = currentIdx + 1; renderSlide(currentSlide); } };
372
+ window.prevSlide = function() { if (currentSlide > 0) { currentSlide--; renderSlide(currentSlide); } };
373
+ renderSlide(0);
374
+ document.body.appendChild(overlay);
375
+ var startX = 0;
376
+ overlay.addEventListener('touchstart', function(e) { startX = e.touches[0].clientX; });
377
+ overlay.addEventListener('touchend', function(e) {
378
+ var diff = e.changedTouches[0].clientX - startX;
379
+ if (diff < -50) nextSlideRip(currentSlide);
380
+ else if (diff > 50) prevSlide();
381
+ });
382
+ };
383
+ })();
static/index_v2.html CHANGED
@@ -10,12 +10,10 @@
10
  <link rel="canonical" href="https://bep40-vnews.hf.space">
11
  <link rel="stylesheet" href="/static/wc2026.css">
12
  <script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
13
- <style>
14
- *{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px;color:#fff}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cats::-webkit-scrollbar{display:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer;flex-shrink:0}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}.slider-track::-webkit-scrollbar{display:none}.slider-item{flex:0 0 160px;cursor:pointer}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-img img{width:100%;height:100%;object-fit:cover}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-genk{background:#6a1b9a}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60;cursor:pointer}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px;color:#eee}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px;cursor:pointer}.article-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;font-size:12px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px;font-size:11px;cursor:pointer}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;scrollbar-width:none}.tiktok-feed::-webkit-scrollbar{display:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center}.tiktok-slide video,.tiktok-slide iframe{width:100%;height:100%;object-fit:cover;border:none}.tiktok-slide.ratio-wide video,.tiktok-slide.ratio-wide iframe{object-fit:contain}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}.tiktok-title{font-size:12px;color:#fff}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height:50%;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:hidden;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1;overflow-y:auto;padding:6px 10px;max-height:180px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4d8;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}.hot-topic-row::-webkit-scrollbar{display:none}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap}.hot-chip:active{transform:scale(.96)}.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}.hashtag-load-more{width:100%;margin-top:8px;background:#222;border:1px solid #333;color:#ccc;padding:9px;border-radius:10px;font-size:12px;cursor:pointer}.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}@keyframes ht-spin{to{transform:rotate(360deg)}}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-item-new{animation:wall-flash 1.8s ease-out}@keyframes wall-flash{0%{border-color:#f0c040;box-shadow:0 0 18px rgba(240,192,64,.35)}30%{border-color:#f0c040;box-shadow:0 0 12px rgba(240,192,64,.2)}100%{border-color:#2b2b2b;box-shadow:none}}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px;position:relative}.wall-thumb img{width:100%;height:100%;object-fit:cover}.wall-video-badge{position:absolute;top:4px;right:4px;background:rgba(45,134,89,.9);color:#fff;font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;cursor:pointer}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}#progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
15
- </style>
16
  </head>
17
  <body>
18
- <div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Highlight · AI · World Cup 2026</p></div>
19
  <div class="cats" id="cat-bar"></div>
20
  <div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
21
  <div id="view-cat" class="view"></div>
@@ -32,19 +30,20 @@
32
  var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
33
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
34
  function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
35
- function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector('[data-cat="'+id+'"]')?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
36
  function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
37
  function doShare(title,url,img){const shareUrl=SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});else navigator.clipboard.writeText(shareUrl).then(()=>alert('Đã sao chép!')).catch(()=>{})}
38
- async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="news-all">📰 Tin tức</div>';_cats.forEach(c=>{bar+='<div class="cat" data-cat="'+c.id+'">'+c.name+'</div>'});document.getElementById('cat-bar').innerHTML=bar;document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat)});}
39
  var SPACE=location.origin;
40
  </script>
41
- <script src="/static/app_v2.js?v=20260707"></script>
42
  <script src="/static/yt_live.js"></script>
43
- <script src="/static/vtv_init.js"></script>
44
- <script src="/static/hot_multi.js?v=1"></script>
45
  <script src="/static/wc2026_v2.js"></script>
46
  <script src="/static/live_mode.js"></script>
47
  <script src="/static/match_detail_v6.js"></script>
48
- <script>init();loadHome();</script>
 
 
49
  </body>
50
- </html>
 
10
  <link rel="canonical" href="https://bep40-vnews.hf.space">
11
  <link rel="stylesheet" href="/static/wc2026.css">
12
  <script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
13
+ <style>*{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px;color:#fff}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cats::-webkit-scrollbar{display:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer;flex-shrink:0}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}.slider-track::-webkit-scrollbar{display:none}.slider-item{flex:0 0 160px;cursor:pointer}.shorts-item{flex:0 0 110px!important}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.shorts-thumb{aspect-ratio:3/4!important;border-radius:8px!important}.slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-img img{width:100%;height:100%;object-fit:cover}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-bbc{background:#b80000}.badge-dt{background:#1565c0}.badge-genk{background:#6a1b9a}.badge-fpt{background:#f26522}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60;cursor:pointer}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px;color:#eee}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px;cursor:pointer}.article-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;font-size:12px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px;font-size:11px;cursor:pointer}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;scrollbar-width:none}.tiktok-feed::-webkit-scrollbar{display:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center}.tiktok-slide video,.tiktok-slide iframe{width:100%;height:100%;object-fit:cover;border:none}.tiktok-slide.ratio-wide video,.tiktok-slide.ratio-wide iframe{object-fit:contain}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}.tiktok-title{font-size:12px;color:#fff}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height:50%;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:hidden;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1;overflow-y:auto;padding:6px 10px;max-height:180px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4d8;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow-hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}.hot-topic-row::-webkit-scrollbar{display:none}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap}.hot-chip:active{transform:scale(.96)}.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}.hashtag-load-more{width:100%;margin-top:8px;background:#222;border:1px solid #333;color:#ccc;padding:9px;border-radius:10px;font-size:12px;cursor:pointer}.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}@keyframes ht-spin{to{transform:rotate(360deg)}}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-item-new{animation:wall-flash 1.8s ease-out}@keyframes wall-flash{0%{border-color:#f0c040;box-shadow:0 0 18px rgba(240,192,64,.35)}30%{border-color:#f0c040;box-shadow:0 0 12px rgba(240,192,64,.2)}100%{border-color:#2b2b2b;box-shadow:none}}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px;position:relative}.wall-thumb img{width:100%;height:100%;object-fit:cover}.wall-video-badge{position:absolute;top:4px;right:4px;background:rgba(45,134,89,.9);color:#fff;font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;cursor:pointer}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}#progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}</style>
 
 
14
  </head>
15
  <body>
16
+ <div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Video · AI · World Cup 2026</p></div>
17
  <div class="cats" id="cat-bar"></div>
18
  <div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
19
  <div id="view-cat" class="view"></div>
 
30
  var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
31
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
32
  function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
33
+ function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector(`[data-cat="${id}"]`)?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
34
  function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
35
  function doShare(title,url,img){const shareUrl=SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});else navigator.clipboard.writeText(shareUrl).then(()=>alert('Đã sao chép!')).catch(()=>{})}
36
+ async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="news-all">📰 Tin tức</div>';_cats.forEach(c=>{bar+=`<div class="cat" data-cat="${c.id}">${c.name}</div>`});document.getElementById('cat-bar').innerHTML=bar;document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat)});}
37
  var SPACE=location.origin;
38
  </script>
39
+ <script src="/static/app_v2.js"></script>
40
  <script src="/static/yt_live.js"></script>
41
+ <script src="/static/hot_multi.js"></script>
 
42
  <script src="/static/wc2026_v2.js"></script>
43
  <script src="/static/live_mode.js"></script>
44
  <script src="/static/match_detail_v6.js"></script>
45
+ <script src="/static/rewrite_fix_v2.js"></script>
46
+ <script src="/static/app_v2_shorts_fix.js"></script>
47
+ <script>init();</script>
48
  </body>
49
+ </html>
static/rewrite_fix_v2.js DELETED
@@ -1,164 +0,0 @@
1
- /**
2
- * VNEWS Rewrite Fix v2 — OVERRIDES loadHome() with fast non-blocking rendering
3
- *
4
- * FIXES:
5
- * 1. News grid loads FIRST (homepage API returns in ~100ms)
6
- * 2. All other API calls are non-blocking — render what's available immediately
7
- * 3. WC2026 (14s) / highlights (5s) / livescore (2s) load in background
8
- * 4. rewriteUrl() → rewriteSlide() (auto create slides + Short AI)
9
- * 5. No page reload needed after Short AI creation
10
- */
11
-
12
- // ===== OVERRIDE: fast non-blocking loadHome =====
13
- async function loadHome(){
14
- const homeEl = document.getElementById('view-home');
15
- if(!homeEl) return;
16
-
17
- // Render structure immediately
18
- homeEl.innerHTML =
19
- '<div id="home-featured-area"></div>'
20
- +'<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>'
21
- +'<div id="hashtag-box"></div>'
22
- +'<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="today" onclick="loadLivescore(\'today\')">📅 Hôm nay</span><span class="ls-tab" data-tab="live" onclick="loadLivescore(\'live\')">🔴 Live</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore(\'incoming\')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore(\'results\')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore(\'bxh_nha\')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore(\'bxh_laliga\')">🏆 La Liga</span></div><div class="ls-content" id="ls-content"><div class="loading">Đang tải...</div></div></div>'
23
- // News grid on homepage!
24
- +'<div id="home-news-grid"><div class="loading" style="padding:8px;font-size:11px">📰 Đang tải tin tức...</div></div>'
25
- +'<div id="wc2026-live-section" class="wc2026-section" style="display:none"><div class="wc-header"><h2>🏆 World Cup 2026</h2><span class="wc-live-badge">● LIVE</span></div><div class="wc-tabs"><span class="wc-tab active" onclick="switchWCTab(\'news\')">📰 Tin tức</span><span class="wc-tab" onclick="switchWCTab(\'fixtures\')">📅 Lịch thi đấu</span><span class="wc-tab" onclick="switchWCTab(\'standings\')">🏆 BXH</span><span class="wc-tab" onclick="switchWCTab(\'highlights\')">🎬 Highlight</span><span class="wc-tab" onclick="switchWCTab(\'stats\')">📊 Thống kê</span></div><div class="wc-content" id="wc-content"><div class="loading">Đang tải World Cup 2026...</div></div></div>'
26
- +'<div id="home-after-wc"></div>';
27
-
28
- const afterEl = homeEl.querySelector('#home-after-wc');
29
- const newsEl = homeEl.querySelector('#home-news-grid');
30
-
31
- // 1. FAST: load livescore + hot topics (both < 1s)
32
- loadLivescore('today');
33
- loadHotTopics();
34
-
35
- // 2. FAST: load news grid (homepage API ~108ms) — renders immediately
36
- (async ()=>{
37
- try{
38
- const r = await fetch('/api/homepage');
39
- const articles = await r.json();
40
- if(articles && articles.length){
41
- let h = '<div class="section-title">📰 Tin tức mới nhất</div><div class="grid">';
42
- articles.slice(0, 20).forEach(a=>{
43
- const src = a.source||'vne';
44
- const badge = a.group||a.source||'';
45
- h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy" onerror="this.style.display=\'none\'">`:''}</div><div class="card-body"><span class="badge badge-${src}">${esc(badge)}</span><div class="card-title">${esc(a.title)}</div></div></div>`;
46
- });
47
- h+='</div>';
48
- if(newsEl) newsEl.innerHTML = h;
49
- } else if(newsEl) newsEl.style.display='none';
50
- } catch(e){
51
- if(newsEl) newsEl.innerHTML = '<div style="padding:12px;color:#888;font-size:11px">📰 Nhấn "📰 Tin tức" để xem tin mới</div>';
52
- }
53
- })();
54
-
55
- // 3. BACKGROUND: featured match, wall, highlights, WC2026
56
- Promise.allSettled([
57
- _fetchWithTimeout('/api/livescore/featured', 5000),
58
- _fetchWithTimeout('/api/wall', 5000),
59
- _fetchWithTimeout('/api/highlights/leagues', 10000),
60
- _fetchWithTimeout('/api/wc2026', 8000),
61
- ]).then(results => {
62
- const resultsArr = results.map(r => r.status === 'fulfilled' ? r.value : null);
63
- const [featuredData, wallData, hlLeagues, wcData] = resultsArr;
64
-
65
- // Featured match
66
- if(featuredData && featuredData.home){
67
- const sc=featuredData.status==='live'?'':'upcoming';
68
- const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`;
69
- const area=document.getElementById('home-featured-area');
70
- if(area) area.innerHTML=`<div class="featured-match" onclick="openMatch('${featuredData.event_id}')"><div class="fm-league">${featuredData.league}</div><div class="fm-teams"><div class="fm-team"><img src="${_proxyImg(featuredData.home_logo)}" onerror="this.style.display='none'"><span>${featuredData.home}</span></div><div class="fm-score">${featuredData.score||'VS'}</div><div class="fm-team"><img src="${_proxyImg(featuredData.away_logo)}" onerror="this.style.display='none'"><span>${featuredData.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;
71
- }
72
-
73
- _wallPosts = (wallData && wallData.posts) || [];
74
- _hlLeagueData = hlLeagues || {};
75
- _wc2026Data = wcData;
76
-
77
- // Show WC2026 section
78
- const wcSection = document.getElementById('wc2026-live-section');
79
- if(wcSection) wcSection.style.display = '';
80
- if(wcData) switchWCTab('news');
81
-
82
- _renderWallIn(afterEl);
83
- _renderHLIn(afterEl);
84
- });
85
- }
86
-
87
- // ===== OVERRIDE rewriteUrl() =====
88
- async function rewriteUrl(){
89
- const url = document.getElementById('url-input')?.value.trim();
90
- if(!url){ alert('Nhập URL bài viết'); return; }
91
- rewriteSlide(url);
92
- }
93
-
94
- // ===== AUTO Short AI + show slides (replaces app_v2.js rewriteSlide) =====
95
- async function rewriteSlide(url){
96
- if(!url) return;
97
- const btn = document.querySelector('.article-actions .primary') || event?.target;
98
- if(btn){ btn.disabled=true; btn.textContent='⏳ Đang tạo slides...'; }
99
- toast('⏳ Đang tạo slide rewrite...');
100
- try{
101
- const r = await fetch('/api/rewrite_slide',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});
102
- const j = await r.json();
103
- if(!r.ok||j.error) throw new Error(j.error||'Lỗi');
104
- toast('✅ Đã tạo slide! Đang tự động tạo Short AI...');
105
- if(btn) btn.textContent='✅ Đang tạo Short AI...';
106
-
107
- // Show slides immediately
108
- if(j.post && j.post.slides && j.post.slides.length){
109
- showView('view-article');
110
- const el = document.getElementById('view-article');
111
- let h = `<button class="back-btn" onclick="switchCat('home')">← Tường AI</button><div class="slide-viewer" style="padding:12px;max-width:600px;margin:0 auto">`;
112
- j.post.slides.forEach(s=>{
113
- h += `<div class="slide-card" style="background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px"><div class="slide-num" style="color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px">Slide ${s.index}/${j.post.slides.length}</div>${
114
- s.image ? `<img src="${s.image.startsWith('/api/')?s.image:'/api/proxy/img?url='+encodeURIComponent(s.image)}" style="width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px" loading="lazy" onerror="this.style.display=\'none\'">` : ''}<p style="color:#ddd;font-size:14px;line-height:1.6;margin:0">${esc(s.text)}</p></div>`;
115
- });
116
- h += `</div><div style="text-align:center;padding:12px"><div id="short-ai-status" style="color:#888;font-size:12px">⏳ Đang tạo video Short AI...</div></div>`;
117
- el.innerHTML = h;
118
- }
119
-
120
- // Add to wall
121
- if(j.post) prependWallPost(j.post);
122
-
123
- // Auto-create Short AI
124
- const postId = j.post && j.post.id;
125
- if(postId){
126
- const voice = j.post.voice || 'hoaimy';
127
- const emotion = j.post.emotion || 'trung_tinh';
128
- try{
129
- const sr = await fetch('/api/ai/short/'+encodeURIComponent(postId),{
130
- method:'POST',
131
- headers:{'Content-Type':'application/json'},
132
- body:JSON.stringify({voice,emotion,speed:1.2})
133
- });
134
- const sj = await sr.json();
135
- if(!sr.ok||sj.error) throw new Error(sj.error||'Lỗi tạo Short AI');
136
- toast('✅ Short AI đã tạo xong! Xem trong Tường AI.');
137
- const statusEl = document.getElementById('short-ai-status');
138
- if(statusEl) statusEl.innerHTML = '✅ Short AI đã sẵn sàng! <button class="primary" onclick="openShortAIFeed(0)" style="background:#2d8659;border:0;color:#fff;padding:6px 12px;border-radius:10px;font-size:11px;cursor:pointer;margin-left:8px">▶ Xem ngay</button>';
139
- // Update wall card
140
- if(sj.video && typeof _wallPosts!=='undefined'){
141
- const p = _wallPosts.find(x => String(x.id) === String(postId));
142
- if(p){
143
- p.video = sj.video;
144
- const itemId = 'wall-item-'+postId;
145
- const el2 = document.getElementById(itemId);
146
- if(el2){
147
- const idx = _wallPosts.indexOf(p);
148
- el2.insertAdjacentHTML('afterend', makeWallItem(p, idx));
149
- el2.remove();
150
- }
151
- }
152
- }
153
- } catch(e2){
154
- console.log('Auto Short AI failed:', e2.message);
155
- const statusEl = document.getElementById('short-ai-status');
156
- if(statusEl) statusEl.innerHTML = '❌ '+e2.message;
157
- }
158
- }
159
- if(btn) btn.textContent='✅ Hoàn tất';
160
- } catch(e){
161
- toast('❌ '+e.message);
162
- if(btn){ btn.disabled=false; btn.textContent='🤖 Slide Rewrite AI'; }
163
- }
164
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/shorts_fresh.js DELETED
@@ -1,5 +0,0 @@
1
- /**
2
- * VNEWS Shorts — REMOVED per user request
3
- * Dantri/SKDS slides removed from homepage entirely
4
- */
5
- (function(){'use strict';console.log('[Shorts] Disabled - Dantri/SKDS removed');})();
 
 
 
 
 
 
static/vtv_init.js DELETED
@@ -1,370 +0,0 @@
1
- // ===== VNEWS VTV Player v11 — SV2 iframe + HLS.js native player toggle =====
2
- (function() {
3
- if (window._vtvInitLoaded) return;
4
- window._vtvInitLoaded = true;
5
-
6
- var CHANNELS = [
7
- {id:'vtv1',name:'VTV1',badge:'Tin tức'},{id:'vtv2',name:'VTV2',badge:'Khoa học'},
8
- {id:'vtv3',name:'VTV3',badge:'Giải trí'},{id:'vtv4',name:'VTV4',badge:'Quốc Tế'},
9
- {id:'vtv5',name:'VTV5',badge:'Miền Nam'},{id:'vtv6',name:'VTV6',badge:'Thanh Niên'},
10
- {id:'vtv7',name:'VTV7',badge:'Giáo Dục'},{id:'vtv8',name:'VTV8',badge:'Miền Trung'},
11
- {id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
12
- {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
13
- ];
14
-
15
- var _currentCh = null;
16
- var _epgTimer = null;
17
- var _epgData = [];
18
- var _timeTimer = null;
19
- var _serverDate = '';
20
- var _nativeMode = false; // false=iframe, true=HLSjs
21
- var _hls = null;
22
-
23
- // ===== CSS =====
24
- (function injectCSS() {
25
- if (document.getElementById('vtv-pro-css')) return;
26
- var s = document.createElement('style');
27
- s.id = 'vtv-pro-css';
28
- s.textContent = [
29
- '#vtv-player-section{display:block!important;margin:8px 0 10px;background:linear-gradient(180deg,#0a0e17,#111622);border:1px solid rgba(0,150,255,.15);border-radius:14px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}',
30
- '#vtv-player-section .vtv-pro-head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:linear-gradient(90deg,#0a1628,rgba(0,102,204,.08))}',
31
- '#vtv-player-section .vtv-pro-logo{width:22px;height:22px;border-radius:50%;background:linear-gradient(135deg,#0066cc,#00ccff);display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff;font-weight:800;flex-shrink:0}',
32
- '#vtv-player-section .vtv-pro-title{font-size:13px;font-weight:700;color:#e8eaed;letter-spacing:.3px}',
33
- '#vtv-player-section .vtv-pro-live{font-size:9px;font-weight:700;color:#00cc88;background:rgba(0,204,136,.12);padding:2px 8px;border-radius:10px;display:flex;align-items:center;gap:4px;margin-left:auto}',
34
- '#vtv-player-section .vtv-pro-live-dot{width:5px;height:5px;border-radius:50%;background:#00cc88;animation:vtv-pro-pulse 1.2s infinite}',
35
- '@keyframes vtv-pro-pulse{0%,100%{opacity:1}50%{opacity:.3}}',
36
- '#vtv-player-section .vtv-pro-tabs{display:flex;flex-wrap:wrap;gap:2px;padding:6px 10px 8px;overflow-x:auto;scrollbar-width:none}',
37
- '#vtv-player-section .vtv-pro-tabs::-webkit-scrollbar{display:none}',
38
- '#vtv-player-section .vtv-pro-tab{padding:5px 10px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);border-radius:8px;color:#9aa0a6;font-size:10px;font-weight:500;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s ease}',
39
- '#vtv-player-section .vtv-pro-tab:hover{background:rgba(0,102,204,.1);border-color:rgba(0,102,204,.3);color:#e8eaed}',
40
- '#vtv-player-section .vtv-pro-tab.on{background:rgba(0,102,204,.2);border-color:#0066cc;color:#fff;font-weight:600}',
41
- '#vtv-player-section .vtv-pro-tab .b{font-size:7px;opacity:.5;display:block;margin-top:1px}',
42
- '#vtv-player-section .vtv-pro-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:200px;border-top:1px solid rgba(255,255,255,.04)}',
43
- '#vtv-player-section .vtv-pro-frame iframe{position:absolute;inset:0;width:100%!important;height:100%!important;border:none;background:#000}',
44
- '#vtv-player-section .vtv-pro-frame video{position:absolute;inset:0;width:100%;height:100%;background:#000}',
45
- '#vtv-player-section .vtv-pro-load{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;min-height:200px;gap:12px;color:#9aa0a6;font-size:12px}',
46
- '#vtv-player-section .vtv-pro-spinner{width:28px;height:28px;border:2px solid rgba(255,255,255,.06);border-top-color:#0066cc;border-radius:50%;animation:vtv-pro-spin .7s linear infinite}',
47
- '@keyframes vtv-pro-spin{to{transform:rotate(360deg)}}',
48
- '#vtv-player-section .vtv-pro-err{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;min-height:200px;gap:10px;color:#9aa0a6;font-size:12px;text-align:center;padding:20px}',
49
- '#vtv-player-section .vtv-pro-err .icon{font-size:28px;opacity:.5}',
50
- '#vtv-player-section .vtv-pro-err .msg{color:#9aa0a6}',
51
- '#vtv-player-section .vtv-pro-err button{background:rgba(0,102,204,.15);border:1px solid rgba(0,102,204,.3);color:#8ab4f8;padding:7px 16px;border-radius:8px;font-size:11px;cursor:pointer;transition:all .15s}',
52
- '#vtv-player-section .vtv-pro-err button:hover{background:rgba(0,102,204,.25)}',
53
- '#vtv-player-section .vtv-pro-controls{display:flex;align-items:center;gap:6px;padding:6px 12px;background:rgba(0,0,0,.3);border-top:1px solid rgba(255,255,255,.04)}',
54
- '#vtv-player-section .vtv-pro-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);color:#9aa0a6;font-size:10px;padding:4px 10px;border-radius:6px;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:4px}',
55
- '#vtv-player-section .vtv-pro-btn:hover{background:rgba(0,102,204,.12);color:#e8eaed}',
56
- '#vtv-player-section .vtv-pro-switch{font-size:9px;padding:3px 8px;background:rgba(0,150,255,.08);border:1px solid rgba(0,150,255,.2);color:#8ab4f8;border-radius:6px;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:3px;white-space:nowrap}',
57
- '#vtv-player-section .vtv-pro-switch:hover{background:rgba(0,150,255,.2)}',
58
- '#vtv-player-section .vtv-pro-switch.active{background:rgba(0,204,136,.12);border-color:rgba(0,204,136,.3);color:#00cc88}',
59
- '#vtv-player-section .vtv-pro-epg{border-top:1px solid rgba(255,255,255,.04);background:rgba(0,0,0,.15)}',
60
- '#vtv-player-section .vtv-pro-epg-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 12px 4px}',
61
- '#vtv-player-section .vtv-pro-epg-title{font-size:10px;font-weight:600;color:#9aa0a6;letter-spacing:.5px;text-transform:uppercase}',
62
- '#vtv-player-section .vtv-pro-epg-time{font-size:9px;color:#5f6368;font-variant-numeric:tabular-nums}',
63
- '#vtv-player-section .vtv-pro-epg-list{padding:2px 8px 10px;display:flex;flex-direction:column;gap:2px;max-height:200px;overflow-y:auto;scrollbar-width:thin}',
64
- '#vtv-player-section .vtv-pro-epg-list::-webkit-scrollbar{width:3px}',
65
- '#vtv-player-section .vtv-pro-epg-list::-webkit-scrollbar-thumb{background:rgba(255,255,255,.08);border-radius:3px}',
66
- '#vtv-player-section .vtv-pro-epg-row{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;transition:all .2s;border-left:2px solid transparent;font-size:11px}',
67
- '#vtv-player-section .vtv-pro-epg-row:hover{background:rgba(255,255,255,.03)}',
68
- '#vtv-player-section .vtv-pro-epg-row.now{border-left-color:#00cc88;background:rgba(0,204,136,.06)}',
69
- '#vtv-player-section .vtv-pro-epg-row.passed{opacity:.35}',
70
- '#vtv-player-section .vtv-pro-epg-row .t{font-size:10px;color:#5f6368;min-width:38px;font-variant-numeric:tabular-nums}',
71
- '#vtv-player-section .vtv-pro-epg-row.now .t{color:#00cc88;font-weight:600}',
72
- '#vtv-player-section .vtv-pro-epg-row .n{color:#e8eaed;line-height:1.3}',
73
- '#vtv-player-section .vtv-pro-epg-row.now .n{color:#fff;font-weight:500}',
74
- '#vtv-player-section .vtv-pro-epg-row .bar{flex:0 0 2px;height:12px;border-radius:1px;background:rgba(255,255,255,.08)}',
75
- '#vtv-player-section .vtv-pro-epg-row.now .bar{background:#00cc88}',
76
- '#vtv-player-section .vtv-pro-epg-row .d{font-size:8px;color:#5f6368;min-width:30px;text-align:right}',
77
- '#vtv-player-section .vtv-pro-epg-empty{color:#5f6368;font-size:11px;padding:12px 14px;text-align:center}',
78
- '#vtv-player-section .vtv-pro-epg-load{color:#5f6368;font-size:11px;padding:12px 14px;text-align:center;display:flex;align-items:center;justify-content:center;gap:6px}',
79
- ].join('');
80
- document.head.appendChild(s);
81
- })();
82
-
83
- function formatTime(d) {
84
- var h = d.getHours(), m = d.getMinutes();
85
- return (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
86
- }
87
-
88
- function getVNTime() {
89
- var now = new Date();
90
- try {
91
- var opts = { timeZone: 'Asia/Ho_Chi_Minh', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, year: 'numeric', month: '2-digit', day: '2-digit' };
92
- var parts = new Intl.DateTimeFormat('en-CA', opts).formatToParts(now);
93
- var hh=0,mm=0,ss=0,yy=2026,mo=1,dd=1;
94
- for (var i = 0; i < parts.length; i++) {
95
- var p = parts[i];
96
- if (p.type === 'hour') hh = parseInt(p.value);
97
- else if (p.type === 'minute') mm = parseInt(p.value);
98
- else if (p.type === 'second') ss = parseInt(p.value);
99
- else if (p.type === 'year') yy = parseInt(p.value);
100
- else if (p.type === 'month') mo = parseInt(p.value);
101
- else if (p.type === 'day') dd = parseInt(p.value);
102
- }
103
- return new Date(yy, mo - 1, dd, hh, mm, ss);
104
- } catch(e) {
105
- var utc = new Date(now.getTime() + now.getTimezoneOffset() * 60000);
106
- return new Date(utc.getTime() + 7 * 3600000);
107
- }
108
- }
109
-
110
- // ===== EPG =====
111
- function loadEPG(chId) {
112
- var epgEl = document.getElementById('vtv-pro-epg-body');
113
- if (!epgEl) return;
114
- epgEl.innerHTML = '<div class="vtv-pro-epg-load"><div class="vtv-pro-spinner" style="width:12px;height:12px;border-width:1.5px;flex-shrink:0"></div>Đang tải lịch...</div>';
115
- var xhr = new XMLHttpRequest();
116
- xhr.open('GET', '/api/vtv/epg/' + chId, true);
117
- xhr.timeout = 15000;
118
- xhr.onload = function() {
119
- try {
120
- var data = JSON.parse(xhr.responseText);
121
- _serverDate = data.date || '';
122
- _epgData = data.programs || [];
123
- renderEPG();
124
- } catch(e) { epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không có lịch phát sóng</div>'; }
125
- };
126
- xhr.onerror = xhr.ontimeout = function() {
127
- epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không tải được lịch phát sóng</div>';
128
- };
129
- xhr.send();
130
- }
131
-
132
- function renderEPG() {
133
- var epgEl = document.getElementById('vtv-pro-epg-body');
134
- if (!epgEl || !_epgData || !_epgData.length) {
135
- if (epgEl) epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Chưa có lịch phát sóng</div>';
136
- return;
137
- }
138
- var vnNow = getVNTime();
139
- var nowStr = formatTime(vnNow);
140
- _epgData.sort(function(a, b) { return a.time.localeCompare(b.time); });
141
- for (var i = 0; i < _epgData.length; i++) {
142
- _epgData[i].now = false;
143
- var p = _epgData[i], nxt = _epgData[i + 1];
144
- if (p.time <= nowStr && (!nxt || nxt.time > nowStr)) { p.now = true; p.end_time = nxt ? nxt.time : ''; }
145
- }
146
- var html = '', prevDate = '';
147
- for (var i = 0; i < _epgData.length; i++) {
148
- var p = _epgData[i];
149
- var cls = 'vtv-pro-epg-row';
150
- if (p.now) cls += ' now';
151
- else if (p.time < nowStr) cls += ' passed';
152
- var xt = p.end_time ? ' → ' + p.end_time : (i < _epgData.length - 1 ? ' → ' + _epgData[i+1].time : '');
153
- var db = '';
154
- if (p.date && p.date !== prevDate) { db = '<span class="d">' + p.date + '</span>'; prevDate = p.date; }
155
- html += '<div class="' + cls + '"><span class="t">' + p.time + xt + '</span><span class="bar"></span><span class="n">' + p.title + '</span>' + db + '</div>';
156
- }
157
- epgEl.innerHTML = html;
158
- var nowEl = epgEl.querySelector('.now');
159
- if (nowEl) nowEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
160
- }
161
-
162
- function startEpgRefresh() {
163
- if (_epgTimer) clearInterval(_epgTimer);
164
- _epgTimer = setInterval(function() { if (_currentCh) loadEPG(_currentCh); }, 60000);
165
- }
166
-
167
- function updateClock() {
168
- var timeEl = document.getElementById('vtv-pro-time');
169
- if (timeEl) {
170
- var vn = getVNTime();
171
- timeEl.textContent = formatTime(vn) + ' GMT+7' + (_serverDate ? ' - ' + _serverDate : '');
172
- }
173
- }
174
-
175
- // ===== Destroy HLS player =====
176
- function destroyHLS() {
177
- if (_hls) {
178
- try { _hls.destroy(); } catch(e) {}
179
- _hls = null;
180
- }
181
- }
182
-
183
- // ===== Play native HLS via proxy =====
184
- function playNativeHLS(chId, m3u8ProxyUrl) {
185
- destroyHLS();
186
- var frame = document.getElementById('vtv-pro-frame');
187
- if (!frame) return;
188
- frame.innerHTML = '<video id="vtv-native-video" controls autoplay playsinline webkit-playsinline style="position:absolute;inset:0;width:100%;height:100%;background:#000"></video>';
189
-
190
- var video = document.getElementById('vtv-native-video');
191
- if (!video) return;
192
-
193
- // Try HLS.js if available (injected via index_v2.html)
194
- if (typeof Hls !== 'undefined' && Hls.isSupported()) {
195
- _hls = new Hls({ enableWorker: false });
196
- _hls.loadSource(m3u8ProxyUrl);
197
- _hls.attachMedia(video);
198
- _hls.on(Hls.Events.MANIFEST_PARSED, function() {
199
- video.play().catch(function(){});
200
- });
201
- _hls.on(Hls.Events.ERROR, function(evt, data) {
202
- if (data.fatal) {
203
- // Fallback: direct video src
204
- destroyHLS();
205
- video.src = m3u8ProxyUrl;
206
- video.play().catch(function(){});
207
- }
208
- });
209
- } else {
210
- // Native HLS fallback (Safari/iOS)
211
- video.src = m3u8ProxyUrl;
212
- }
213
- }
214
-
215
- // ===== Toggle iframe / native HLS =====
216
- window._vtvToggleNative = function(btn) {
217
- if (!_currentCh) return;
218
- _nativeMode = !_nativeMode;
219
- btn.classList.toggle('active', _nativeMode);
220
- btn.innerHTML = _nativeMode ? '📡 HLS' : '🔌 JWPlayer';
221
-
222
- var frame = document.getElementById('vtv-pro-frame');
223
- if (!frame) return;
224
-
225
- // Re-fetch stream data then switch
226
- var xhr = new XMLHttpRequest();
227
- xhr.open('GET', '/api/vtv/stream/' + _currentCh, true);
228
- xhr.timeout = 15000;
229
- xhr.onload = function() {
230
- try {
231
- var data = JSON.parse(xhr.responseText);
232
- if (_nativeMode) {
233
- var m3u8Url = data.m3u8_proxy_url || data.proxy_url_hls || data.stream_url;
234
- if (m3u8Url) {
235
- playNativeHLS(_currentCh, m3u8Url);
236
- } else {
237
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">📡</div><div class="msg">Kênh không hỗ trợ HLS</div></div>';
238
- _nativeMode = false;
239
- btn.classList.remove('active');
240
- btn.innerHTML = '🔌 JWPlayer';
241
- }
242
- } else {
243
- var iframeUrl = data.iframe_url;
244
- if (iframeUrl) {
245
- destroyHLS();
246
- var iframe = document.createElement('iframe');
247
- iframe.src = iframeUrl;
248
- iframe.setAttribute('allowfullscreen', 'true');
249
- iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
250
- iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none;background:#000';
251
- frame.innerHTML = '';
252
- frame.appendChild(iframe);
253
- }
254
- }
255
- } catch(e) {
256
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">⚠️</div></div>';
257
- }
258
- };
259
- xhr.onerror = function() {
260
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">🔌</div><div class="msg">Lỗi kết nối</div></div>';
261
- };
262
- xhr.send();
263
- };
264
-
265
- // ===== Switch channel =====
266
- window._vtvProSwitch = function(chId) {
267
- _currentCh = chId;
268
- _nativeMode = false;
269
-
270
- var tabs = document.querySelectorAll('#vtv-player-section .vtv-pro-tab');
271
- for (var i = 0; i < tabs.length; i++)
272
- tabs[i].className = 'vtv-pro-tab' + (tabs[i].getAttribute('data-ch') === chId ? ' on' : '');
273
-
274
- // Reset toggle button
275
- var toggleBtn = document.getElementById('vtv-pro-toggle-btn');
276
- if (toggleBtn) { toggleBtn.classList.remove('active'); toggleBtn.innerHTML = '🔌 JWPlayer'; }
277
-
278
- updateClock();
279
- if (_timeTimer) clearInterval(_timeTimer);
280
- _timeTimer = setInterval(updateClock, 60000);
281
-
282
- destroyHLS();
283
-
284
- var frame = document.getElementById('vtv-pro-frame');
285
- if (!frame) return;
286
- frame.innerHTML = '<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Đang kết nối ' + chId.toUpperCase() + '...</span></div>';
287
-
288
- loadEPG(chId);
289
- startEpgRefresh();
290
-
291
- var xhr = new XMLHttpRequest();
292
- xhr.open('GET', '/api/vtv/stream/' + chId, true);
293
- xhr.timeout = 15000;
294
- xhr.onload = function() {
295
- try {
296
- var data = JSON.parse(xhr.responseText);
297
- var iframeUrl = data.iframe_url;
298
- if (!iframeUrl) {
299
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">📡</div><div class="msg">Kênh không khả dụng</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
300
- return;
301
- }
302
- var iframe = document.createElement('iframe');
303
- iframe.src = iframeUrl;
304
- iframe.setAttribute('allowfullscreen', 'true');
305
- iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
306
- iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none;background:#000';
307
- frame.innerHTML = '';
308
- frame.appendChild(iframe);
309
- } catch(e) {
310
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">⚠️</div><div class="msg">Lỗi tải dữ liệu</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
311
- }
312
- };
313
- xhr.onerror = xhr.ontimeout = function() {
314
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">🔌</div><div class="msg">Mất kết nối máy chủ</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
315
- };
316
- xhr.send();
317
- };
318
-
319
- // ===== HTML builder =====
320
- function buildHTML() {
321
- var tabs = '';
322
- for (var i = 0; i < CHANNELS.length; i++) {
323
- var ch = CHANNELS[i];
324
- tabs += '<span class="vtv-pro-tab" data-ch="'+ch.id+'" onclick="_vtvProSwitch(\''+ch.id+'\')">' +
325
- ch.name + '<span class="b">'+ch.badge+'</span></span>';
326
- }
327
- return '<div id="vtv-player-section">' +
328
- '<div class="vtv-pro-head">' +
329
- '<div class="vtv-pro-logo">V</div>' +
330
- '<span class="vtv-pro-title">VTV Player</span>' +
331
- '<span class="vtv-pro-live"><span class="vtv-pro-live-dot"></span>TRỰC TIẾP</span>' +
332
- '</div>' +
333
- '<div class="vtv-pro-tabs">'+tabs+'</div>' +
334
- '<div class="vtv-pro-frame" id="vtv-pro-frame">' +
335
- '<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Chọn kênh để xem trực tiếp</span></div>' +
336
- '</div>' +
337
- '<div class="vtv-pro-controls">' +
338
- '<span id="vtv-pro-time" class="vtv-pro-btn" style="background:none;border:none;font-size:10px;color:#5f6368;margin-right:auto"></span>' +
339
- '<span class="vtv-pro-switch" id="vtv-pro-toggle-btn" onclick="_vtvToggleNative(this)">🔌 JWPlayer</span>' +
340
- '</div>' +
341
- '<div class="vtv-pro-epg">' +
342
- '<div class="vtv-pro-epg-hdr"><span class="vtv-pro-epg-title">Lịch phát sóng — nguồn VTV.vn</span></div>' +
343
- '<div class="vtv-pro-epg-list" id="vtv-pro-epg-body"><div class="vtv-pro-epg-empty">Chọn kênh để xem lịch phát sóng</div></div>' +
344
- '</div>' +
345
- '</div>';
346
- }
347
-
348
- function inject() {
349
- var homeEl = document.getElementById('view-home');
350
- if (!homeEl || document.getElementById('vtv-player-section')) return;
351
- var featured = document.getElementById('home-featured-area') || homeEl.querySelector('.featured-match, .fm-section, .slider-wrap');
352
- if (featured && featured.parentNode) featured.insertAdjacentHTML('afterend', buildHTML());
353
- else if (homeEl.firstChild) homeEl.insertAdjacentHTML('afterbegin', buildHTML());
354
- setTimeout(function() { if (window._vtvProSwitch) window._vtvProSwitch('vtv6'); }, 500);
355
- }
356
-
357
- var orig = window.loadHome;
358
- if (typeof orig === 'function') {
359
- window.loadHome = function() {
360
- var r = orig.apply(this, arguments);
361
- if (r && typeof r.then === 'function') return r.then(function(v) { setTimeout(inject, 2000); return v; });
362
- else { setTimeout(inject, 2000); return r; }
363
- };
364
- } else {
365
- (function waitAndInject() {
366
- if (document.getElementById('view-home') && !document.getElementById('vtv-player-section')) inject();
367
- else if (!document.getElementById('vtv-player-section')) setTimeout(waitAndInject, 1000);
368
- })();
369
- }
370
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/vtv_init_loader.html DELETED
@@ -1,2 +0,0 @@
1
- <script src="/static/yt_live.js"></script>
2
- <script src="/static/vtv_init.js"></script>
 
 
 
static/wc2026_v2.js CHANGED
@@ -314,4 +314,4 @@ function startWCLiveRefresh(){
314
  }catch(e){}
315
  },90000);
316
  }
317
- setTimeout(startWCLiveRefresh,5000);
 
314
  }catch(e){}
315
  },90000);
316
  }
317
+ setTimeout(startWCLiveRefresh,5000);
static/yt_live.js CHANGED
@@ -1,7 +1,6 @@
1
  // === VNEWS — VTV LIVE + Inline Recorder v9 ===
2
  // Features: PiP, mini-player, INLINE RECORDER, VTVGO POPUP players for each channel
3
- // FIX v10: Correct VTVGO popup URLs for all channels + World Cup 2026 TS stream links
4
- // FIX v11: Allow clicking off tabs (always clickable), better stream status handling
5
 
6
  (function(){
7
  if(window._ytLiveLoaded) return;
@@ -16,18 +15,18 @@
16
  {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
17
  ];
18
 
19
- // VTVGO popup URLs for each channel (FIXED v10 - verified correct URLs)
20
  const VTV_POPUP_URLS = {
21
  vtv1: 'https://package.vtvgo.vn/channel/vtv1-1,1.html',
22
  vtv2: 'https://package.vtvgo.vn/channel/vtv2-1,2.html',
23
- vtv3: 'https://package.vtvgo.vn/channel/vtv3-1,3.html',
24
  vtv4: 'https://package.vtvgo.vn/channel/vtv4-1,4.html',
25
- vtv5: 'https://package.vtvgo.vn/channel/vtv5-1,5.html',
26
  vtv6: 'https://package.vtvgo.vn/channel/vtv6-1,13.html',
27
  vtv7: 'https://package.vtvgo.vn/channel/vtv7-1,27.html',
28
- vtv8: 'https://package.vtvgo.vn/channel/vtv8-1,36.html',
29
- vtv9: 'https://package.vtvgo.vn/channel/vtv9-1,39.html',
30
- vtv10: 'https://package.vtvgo.vn/channel/vtv10-1,6.html',
31
  };
32
 
33
  const DEFAULT_CHANNEL = 'vtv3';
@@ -56,7 +55,7 @@
56
  .vtv-tab{padding:4px 8px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:10px;color:#8ab4d8;font-size:9px;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s}
57
  .vtv-tab:hover{background:#0b4a7a;color:#fff}
58
  .vtv-tab.on{background:#0066cc;border-color:#00ccff;color:#fff;font-weight:700}
59
- .vtv-tab.off{opacity:.35}
60
  .vtv-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:180px}
61
  .vtv-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
62
  .vtv-err{display:flex;align-items:center;justify-content:center;height:180px;color:#888;font-size:12px;text-align:center;padding:20px;flex-direction:column;gap:8px}
@@ -172,4 +171,845 @@
172
  .vtv-popup-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;margin-left:4px}
173
  .vtv-popup-btn:hover{background:#0b4a7a;color:#fff}
174
  `;
175
- document.head.appendChild(style);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  // === VNEWS — VTV LIVE + Inline Recorder v9 ===
2
  // Features: PiP, mini-player, INLINE RECORDER, VTVGO POPUP players for each channel
3
+ // FIX v9: Correct VTVGO popup URLs for all channels + World Cup 2026 TS stream links
 
4
 
5
  (function(){
6
  if(window._ytLiveLoaded) return;
 
15
  {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
16
  ];
17
 
18
+ // VTVGO popup URLs for each channel (FIXED v9 - verified correct URLs)
19
  const VTV_POPUP_URLS = {
20
  vtv1: 'https://package.vtvgo.vn/channel/vtv1-1,1.html',
21
  vtv2: 'https://package.vtvgo.vn/channel/vtv2-1,2.html',
22
+ vtv3: 'https://vtvgo.vn/channel/vtv3-1,3.html',
23
  vtv4: 'https://package.vtvgo.vn/channel/vtv4-1,4.html',
24
+ vtv5: 'https://vtvgo.vn/channel/vtv5-1,5.html',
25
  vtv6: 'https://package.vtvgo.vn/channel/vtv6-1,13.html',
26
  vtv7: 'https://package.vtvgo.vn/channel/vtv7-1,27.html',
27
+ vtv8: 'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv8-36.html',
28
+ vtv9: 'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv9-11.html',
29
+ vtv10: 'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv10-6.html',
30
  };
31
 
32
  const DEFAULT_CHANNEL = 'vtv3';
 
55
  .vtv-tab{padding:4px 8px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:10px;color:#8ab4d8;font-size:9px;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s}
56
  .vtv-tab:hover{background:#0b4a7a;color:#fff}
57
  .vtv-tab.on{background:#0066cc;border-color:#00ccff;color:#fff;font-weight:700}
58
+ .vtv-tab.off{opacity:.35;pointer-events:none}
59
  .vtv-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:180px}
60
  .vtv-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
61
  .vtv-err{display:flex;align-items:center;justify-content:center;height:180px;color:#888;font-size:12px;text-align:center;padding:20px;flex-direction:column;gap:8px}
 
171
  .vtv-popup-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;margin-left:4px}
172
  .vtv-popup-btn:hover{background:#0b4a7a;color:#fff}
173
  `;
174
+ document.head.appendChild(style);
175
+
176
+ // ===== HELPERS =====
177
+ function fmtSec(s){ return String(Math.floor(s/60)).padStart(2,'0')+':'+String(Math.floor(s%60)).padStart(2,'0'); }
178
+ function escH(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
179
+
180
+ function recStatus(msg, type) {
181
+ const el = document.getElementById('vtv-rec-status');
182
+ if (el) { el.textContent = msg; el.className = 'rec-status' + (type ? ' ' + type : ''); }
183
+ }
184
+
185
+ function downloadBlob(blob, filename) {
186
+ const a = document.createElement('a');
187
+ a.href = URL.createObjectURL(blob); a.download = filename; a.click();
188
+ URL.revokeObjectURL(a.href);
189
+ recStatus('✅ Đã tải: ' + filename, 'ok');
190
+ }
191
+
192
+ function showProc(text) {
193
+ const el = document.getElementById('vtv-rec-proc');
194
+ if (el) { el.style.display = 'block'; document.getElementById('vtv-rec-proc-text').textContent = text; }
195
+ }
196
+ function hideProc() {
197
+ const el = document.getElementById('vtv-rec-proc');
198
+ if (el) el.style.display = 'none';
199
+ }
200
+
201
+ // ===== UPLOAD TO WALL (Short AI) — v7: robust JSON parse =====
202
+ function uploadToWall(blob, title, source) {
203
+ const formData = new FormData();
204
+ formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
205
+ formData.append('title', title || `Record LIVE ${_currentCh?.toUpperCase()} - ${new Date().toLocaleString('vi-VN')}`);
206
+ formData.append('source', source || 'vtv_inline_recorder');
207
+ showProc('⏳ Đang upload lên Short AI...');
208
+ return fetch('/api/wall', { method: 'POST', body: formData })
209
+ .then(r => {
210
+ console.log('[VTV Upload] HTTP status:', r.status, r.statusText);
211
+ return r.text().then(text => {
212
+ console.log('[VTV Upload] Response text:', text.substring(0, 200));
213
+ let data;
214
+ try { data = JSON.parse(text); } catch(e) {
215
+ console.error('[VTV Upload] JSON parse failed:', e, 'Raw:', text.substring(0, 500));
216
+ throw new Error('Server trả về lỗi (không phải JSON). Status: ' + r.status);
217
+ }
218
+ return data;
219
+ });
220
+ })
221
+ .then(data => {
222
+ hideProc();
223
+ console.log('[VTV Upload] Parsed data:', data);
224
+ if (data && data.ok === true) {
225
+ recStatus('✅ Đã tạo Short AI! Xem trong mục Tường AI.', 'ok');
226
+ return data;
227
+ } else {
228
+ throw new Error(data && data.error ? data.error : 'Upload thất bại (ok=false)');
229
+ }
230
+ })
231
+ .catch(e => {
232
+ hideProc();
233
+ console.error('[VTV Upload] Error:', e);
234
+ recStatus('⚠️ ' + e.message + '. Đang tải về...', 'err');
235
+ setTimeout(() => downloadBlob(blob, `vtv-${_currentCh}-${Date.now()}.webm`), 800);
236
+ throw e;
237
+ });
238
+ }
239
+
240
+ // ===== AI TITLE GENERATION =====
241
+ async function generateAITitle(blob, channelName) {
242
+ return new Promise((resolve) => {
243
+ const video = document.createElement('video');
244
+ video.src = URL.createObjectURL(blob);
245
+ video.muted = true;
246
+ video.onloadedmetadata = function() {
247
+ URL.revokeObjectURL(video.src);
248
+ const dur = Math.round(video.duration || 0);
249
+ const now = new Date();
250
+ const timeStr = now.toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'});
251
+ const dateStr = now.toLocaleDateString('vi-VN',{day:'2-digit',month:'2-digit'});
252
+ const titles = [
253
+ `🔴 LIVE ${channelName} - ${timeStr} ${dateStr}`,
254
+ `📺 ${channelName} - Video ngắn ${dur}s`,
255
+ `🎬 ${channelName} Record - ${timeStr}`,
256
+ `⚡ ${channelName} Highlights - ${dateStr}`,
257
+ `📱 ${channelName} Short - ${timeStr} ${dateStr}`,
258
+ ];
259
+ const idx = dur < 15 ? 4 : dur < 30 ? 1 : dur < 45 ? 2 : 0;
260
+ resolve(titles[idx % titles.length]);
261
+ };
262
+ video.onerror = function() {
263
+ URL.revokeObjectURL(video.src);
264
+ resolve(`📺 Record ${channelName} - ${new Date().toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'})}`);
265
+ };
266
+ });
267
+ }
268
+
269
+ // ===== BUILD INLINE RECORDER =====
270
+ function buildInlineRecorder() {
271
+ const controls = document.querySelector('.vtv-controls');
272
+ if (!controls || document.getElementById('vtv-rec-btn')) return;
273
+
274
+ const recBtn = document.createElement('button');
275
+ recBtn.id = 'vtv-rec-btn';
276
+ recBtn.className = 'vtv-rec-btn';
277
+ recBtn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
278
+ recBtn.title = 'Record video ngắn từ LIVE (max 60s)';
279
+ recBtn.onclick = toggleRecPanel;
280
+ controls.appendChild(recBtn);
281
+
282
+ const inlineRec = document.createElement('div');
283
+ inlineRec.id = 'vtv-inline-rec';
284
+ inlineRec.className = 'vtv-inline-rec';
285
+ inlineRec.innerHTML = `
286
+ <div class="rec-time">
287
+ <span id="vtv-rec-time-start">00:00</span>
288
+ <span id="vtv-rec-time-dur">Duration: 0s</span>
289
+ <span id="vtv-rec-time-end">01:00</span>
290
+ </div>
291
+ <div class="rec-bar" id="vtv-rec-bar">
292
+ <div class="rec-progress" id="vtv-rec-progress"></div>
293
+ <div class="rec-marker s" id="vtv-rec-mk-start" style="display:none"></div>
294
+ <div class="rec-marker e" id="vtv-rec-mk-end" style="display:none"></div>
295
+ </div>
296
+ <div class="rec-controls">
297
+ <button class="rec-set-start" id="vtv-rec-set-start" onclick="window._vtvRecSetStart()">🔵 Điểm đầu</button>
298
+ <button class="rec-set-end" id="vtv-rec-set-end" onclick="window._vtvRecSetEnd()" disabled>🔴 Điểm cuối</button>
299
+ <button class="rec-go" id="vtv-rec-go" onclick="window._vtvRecGo()" disabled>⏺ Record!</button>
300
+ <button class="rec-reset" onclick="window._vtvRecReset()">↺ Reset</button>
301
+ </div>
302
+ <div class="rec-hint" id="vtv-rec-hint">Chọn điểm bắt đầu → kết thúc (tối đa 60s)</div>
303
+ <div class="rec-status" id="vtv-rec-status">Sẵn sàng</div>
304
+ `;
305
+ controls.parentNode.insertBefore(inlineRec, controls.nextSibling);
306
+
307
+ const recPanel = document.createElement('div');
308
+ recPanel.id = 'vtv-rec-panel';
309
+ recPanel.className = 'vtv-rec-panel';
310
+ recPanel.innerHTML = `
311
+ <div class="rec-panel-title">🎬 Đã record xong!</div>
312
+ <div class="rec-preview-wrap" id="vtv-rec-preview-wrap" style="display:none">
313
+ <video id="vtv-rec-preview-vid" controls style="max-width:100%;max-height:180px;border-radius:6px;background:#000"></video>
314
+ </div>
315
+ <input type="text" class="rec-title-input" id="vtv-rec-title-input" placeholder="Tiêu đề video (để trống = AI tự tạo)">
316
+ <div class="rec-ai-title-row">
317
+ <button onclick="window._vtvRecGenAITitle()" id="vtv-rec-ai-btn">🤖 AI tạo tiêu đề</button>
318
+ </div>
319
+ <div class="rec-ratio-row">
320
+ <button class="active" data-ratio="original" onclick="window._vtvRecSetRatio('original')">📺 Gốc</button>
321
+ <button data-ratio="1:1" onclick="window._vtvRecSetRatio('1:1')">⬜ 1:1</button>
322
+ <button data-ratio="9:16" onclick="window._vtvRecSetRatio('9:16')">📱 9:16</button>
323
+ </div>
324
+ <div class="rec-actions">
325
+ <button class="rec-download" onclick="window._vtvRecDownload()">💾 Lưu về máy</button>
326
+ <button class="rec-share" onclick="window._vtvRecShare()">��� Tạo Short AI</button>
327
+ </div>
328
+ <div class="rec-proc" id="vtv-rec-proc">
329
+ <div class="vtv-spinner" style="width:20px;height:20px;margin:0 auto 6px"></div>
330
+ <span id="vtv-rec-proc-text">Đang xử lý...</span>
331
+ </div>
332
+ `;
333
+ inlineRec.parentNode.insertBefore(recPanel, inlineRec.nextSibling);
334
+
335
+ document.getElementById('vtv-rec-bar').addEventListener('click', function(e) {
336
+ if (!_rec.isRecording) handleRecBarClick(e);
337
+ });
338
+
339
+ document.getElementById('vtv-rec-mk-start').addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'start'; });
340
+ document.getElementById('vtv-rec-mk-start').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'start'; }, {passive:false});
341
+ document.getElementById('vtv-rec-mk-end').addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; });
342
+ document.getElementById('vtv-rec-mk-end').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; }, {passive:false});
343
+ }
344
+
345
+ // Global drag
346
+ document.addEventListener('mousemove', function(e) { if (_rec._dragMarker) dragMarker(e.clientX); });
347
+ document.addEventListener('touchmove', function(e) { if (_rec._dragMarker) dragMarker(e.touches[0].clientX); }, {passive:false});
348
+ document.addEventListener('mouseup', function(){ _rec._dragMarker = null; });
349
+ document.addEventListener('touchend', function(){ _rec._dragMarker = null; });
350
+
351
+ function dragMarker(clientX) {
352
+ const bar = document.getElementById('vtv-rec-bar');
353
+ if (!bar) return;
354
+ const rect = bar.getBoundingClientRect();
355
+ const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
356
+ const time = pct * 60;
357
+ if (_rec._dragMarker === 'start') {
358
+ if (_rec.endTime !== null && time >= _rec.endTime - 1) return;
359
+ _rec.startTime = time;
360
+ const mk = document.getElementById('vtv-rec-mk-start');
361
+ mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
362
+ document.getElementById('vtv-rec-time-start').textContent = fmtSec(time);
363
+ } else {
364
+ if (_rec.startTime !== null && time <= _rec.startTime + 1) return;
365
+ _rec.endTime = time;
366
+ const mk = document.getElementById('vtv-rec-mk-end');
367
+ mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
368
+ document.getElementById('vtv-rec-time-end').textContent = fmtSec(time);
369
+ }
370
+ updateRecBar();
371
+ }
372
+
373
+ function handleRecBarClick(e) {
374
+ const video = document.getElementById('vtv-player');
375
+ if (!video || !video.src) { recStatus('⚠️ Chưa có stream!'); return; }
376
+ const bar = document.getElementById('vtv-rec-bar');
377
+ const rect = bar.getBoundingClientRect();
378
+ const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
379
+ const time = pct * 60;
380
+ if (_rec.startTime === null) {
381
+ _rec.startTime = time;
382
+ const mk = document.getElementById('vtv-rec-mk-start');
383
+ mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
384
+ document.getElementById('vtv-rec-time-start').textContent = fmtSec(time);
385
+ document.getElementById('vtv-rec-set-end').disabled = false;
386
+ recStatus('Điểm đầu: ' + fmtSec(time) + '. Chọn điểm cuối.', '');
387
+ } else if (_rec.endTime === null) {
388
+ if (time <= _rec.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
389
+ const dur = time - _rec.startTime;
390
+ if (dur > 60) { recStatus('⚠️ Tối đa 60s!', 'err'); return; }
391
+ _rec.endTime = time;
392
+ const mk = document.getElementById('vtv-rec-mk-end');
393
+ mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
394
+ document.getElementById('vtv-rec-time-end').textContent = fmtSec(time);
395
+ updateRecBar();
396
+ recStatus('✅ ' + Math.round(dur) + 's. Nhấn Record!', 'ok');
397
+ document.getElementById('vtv-rec-go').disabled = false;
398
+ }
399
+ }
400
+
401
+ function updateRecBar() {
402
+ const s = _rec.startTime || 0, e = _rec.endTime || 60;
403
+ const dur = e - s;
404
+ document.getElementById('vtv-rec-progress').style.left = (s/60*100) + '%';
405
+ document.getElementById('vtv-rec-progress').style.width = (dur/60*100) + '%';
406
+ document.getElementById('vtv-rec-time-dur').textContent = 'Duration: ' + Math.round(dur) + 's';
407
+ }
408
+
409
+ // ===== RECORDER CONTROLS =====
410
+ window._vtvRecSetStart = function() {
411
+ const video = document.getElementById('vtv-player');
412
+ if (!video || !video.src) { recStatus('⚠️ Chưa có stream!', 'err'); return; }
413
+ _rec.startTime = video.currentTime || 0;
414
+ const pct = _rec.startTime / 60 * 100;
415
+ const mk = document.getElementById('vtv-rec-mk-start');
416
+ mk.style.display = 'block'; mk.style.left = pct + '%';
417
+ document.getElementById('vtv-rec-time-start').textContent = fmtSec(_rec.startTime);
418
+ document.getElementById('vtv-rec-set-end').disabled = false;
419
+ recStatus('Điểm đầu: ' + fmtSec(_rec.startTime), '');
420
+ };
421
+
422
+ window._vtvRecSetEnd = function() {
423
+ if (_rec.startTime === null) { recStatus('⚠️ Chọn điểm đầu trước!', 'err'); return; }
424
+ const video = document.getElementById('vtv-player');
425
+ if (!video) return;
426
+ const cur = video.currentTime || 0;
427
+ if (cur <= _rec.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
428
+ const dur = cur - _rec.startTime;
429
+ if (dur > 60) { recStatus('⚠️ Tối đa 60s!', 'err'); return; }
430
+ _rec.endTime = cur;
431
+ const pct = cur / 60 * 100;
432
+ const mk = document.getElementById('vtv-rec-mk-end');
433
+ mk.style.display = 'block'; mk.style.left = pct + '%';
434
+ document.getElementById('vtv-rec-time-end').textContent = fmtSec(cur);
435
+ updateRecBar();
436
+ recStatus('✅ ' + Math.round(dur) + 's. Nhấn Record!', 'ok');
437
+ document.getElementById('vtv-rec-go').disabled = false;
438
+ };
439
+
440
+ window._vtvRecReset = function() {
441
+ _rec.startTime = null; _rec.endTime = null; _rec.isRecording = false; _rec.blob = null;
442
+ if (_rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
443
+ clearTimeout(_rec._recTimer);
444
+ document.getElementById('vtv-rec-mk-start').style.display = 'none';
445
+ document.getElementById('vtv-rec-mk-end').style.display = 'none';
446
+ document.getElementById('vtv-rec-progress').style.width = '0%';
447
+ document.getElementById('vtv-rec-time-start').textContent = '00:00';
448
+ document.getElementById('vtv-rec-time-end').textContent = '01:00';
449
+ document.getElementById('vtv-rec-time-dur').textContent = 'Duration: 0s';
450
+ document.getElementById('vtv-rec-set-start').disabled = false;
451
+ document.getElementById('vtv-rec-set-end').disabled = true;
452
+ document.getElementById('vtv-rec-go').disabled = true;
453
+ document.getElementById('vtv-rec-panel').classList.remove('show');
454
+ document.getElementById('vtv-rec-title-input').value = '';
455
+ recStatus('Đã reset.', '');
456
+ const btn = document.getElementById('vtv-rec-btn');
457
+ if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
458
+ };
459
+
460
+ window._vtvRecSetRatio = function(ratio) {
461
+ _rec.ratio = ratio;
462
+ document.querySelectorAll('#vtv-rec-panel .rec-ratio-row button').forEach(b => b.classList.toggle('active', b.dataset.ratio === ratio));
463
+ };
464
+
465
+ // ===== RECORD — captureStream with audio (unmute during record) =====
466
+ window._vtvRecGo = function() {
467
+ if (_rec.startTime === null || _rec.endTime === null) return;
468
+ if (_rec.isRecording) {
469
+ if (_rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
470
+ _rec.isRecording = false;
471
+ clearTimeout(_rec._recTimer);
472
+ const btn = document.getElementById('vtv-rec-btn');
473
+ if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
474
+ return;
475
+ }
476
+
477
+ const video = document.getElementById('vtv-player');
478
+ if (!video) { recStatus('⚠️ Không tìm thấy video!', 'err'); return; }
479
+
480
+ _rec.chunks = [];
481
+ _rec.isRecording = true;
482
+
483
+ const wasMuted = video.muted;
484
+ video.muted = false;
485
+ const wasVolume = video.volume;
486
+ video.volume = 0.3;
487
+
488
+ let stream;
489
+ try {
490
+ stream = video.captureStream ? video.captureStream() : video.mozCaptureStream();
491
+ if (!stream) throw new Error('captureStream not supported');
492
+ } catch(e) {
493
+ video.muted = wasMuted; video.volume = wasVolume;
494
+ recStatus('❌ ' + e.message, 'err');
495
+ _rec.isRecording = false;
496
+ return;
497
+ }
498
+
499
+ const audioTracks = stream.getAudioTracks();
500
+ console.log('[VTV Rec] Audio tracks:', audioTracks.length, 'Video tracks:', stream.getVideoTracks().length);
501
+
502
+ let mimeType = 'video/webm;codecs=vp9,opus';
503
+ if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm;codecs=vp8,opus';
504
+ if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm';
505
+
506
+ try { _rec.recorder = new MediaRecorder(stream, {mimeType}); }
507
+ catch(e) {
508
+ video.muted = wasMuted; video.volume = wasVolume;
509
+ recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err');
510
+ _rec.isRecording = false;
511
+ return;
512
+ }
513
+
514
+ _rec.recorder.ondataavailable = e => { if(e.data && e.data.size > 0) _rec.chunks.push(e.data); };
515
+ _rec.recorder.onstop = function() {
516
+ _rec.isRecording = false;
517
+ video.muted = wasMuted;
518
+ video.volume = wasVolume;
519
+ _rec.blob = new Blob(_rec.chunks, {type: 'video/webm'});
520
+ const btn = document.getElementById('vtv-rec-btn');
521
+ if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
522
+ document.getElementById('vtv-rec-panel').classList.add('show');
523
+ const pv = document.getElementById('vtv-rec-preview-vid');
524
+ pv.src = URL.createObjectURL(_rec.blob);
525
+ document.getElementById('vtv-rec-preview-wrap').style.display = 'block';
526
+ const sizeMB = (_rec.blob.size/1024/1024).toFixed(1);
527
+ recStatus('✅ Record xong! ' + sizeMB + 'MB' + (audioTracks.length > 0 ? ' (có audio)' : ' (video only)'), 'ok');
528
+ };
529
+ _rec.recorder.onerror = e => {
530
+ _rec.isRecording = false;
531
+ video.muted = wasMuted; video.volume = wasVolume;
532
+ recStatus('❌ Lỗi: ' + (e.error?.message||'?'), 'err');
533
+ };
534
+
535
+ _rec.recorder.start(500);
536
+ video.currentTime = _rec.startTime;
537
+
538
+ const btn = document.getElementById('vtv-rec-btn');
539
+ if (btn) { btn.classList.add('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Dừng'; }
540
+ recStatus('🔴 ĐANG RECORD...' + (audioTracks.length > 0 ? ' (có audio)' : ''), 'recording');
541
+
542
+ const dur = (_rec.endTime - _rec.startTime) * 1000;
543
+ _rec._recTimer = setTimeout(() => {
544
+ if (_rec.isRecording && _rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
545
+ }, dur + 500);
546
+ };
547
+
548
+ // ===== AI TITLE =====
549
+ window._vtvRecGenAITitle = async function() {
550
+ if (!_rec.blob) { recStatus('⚠️ Chưa record!', 'err'); return; }
551
+ const btn = document.getElementById('vtv-rec-ai-btn');
552
+ const input = document.getElementById('vtv-rec-title-input');
553
+ if (!btn || !input) return;
554
+
555
+ btn.disabled = true;
556
+ btn.textContent = '⏳ Đang tạo...';
557
+
558
+ try {
559
+ const ch = CHANNELS.find(c => c.id === _currentCh);
560
+ const channelName = ch ? ch.name : (_currentCh || 'VTV');
561
+ const title = await generateAITitle(_rec.blob, channelName);
562
+ input.value = title;
563
+ recStatus('✅ Đã tạo tiêu đề AI: ' + title, 'ok');
564
+ } catch(e) {
565
+ recStatus('⚠️ Lỗi AI: ' + e.message, 'err');
566
+ } finally {
567
+ btn.disabled = false;
568
+ btn.textContent = '🤖 AI tạo tiêu đề';
569
+ }
570
+ };
571
+
572
+ // ===== DOWNLOAD =====
573
+ window._vtvRecDownload = function() {
574
+ if (!_rec.blob) return;
575
+ const title = document.getElementById('vtv-rec-title-input')?.value || '';
576
+ if (_rec.ratio === 'original') {
577
+ downloadBlob(_rec.blob, `vtv-${_currentCh}-${Date.now()}.webm`);
578
+ } else {
579
+ processRatio(_rec.blob, _rec.ratio, 'download', title);
580
+ }
581
+ };
582
+
583
+ // ===== SHARE TO SHORT AI =====
584
+ window._vtvRecShare = function() {
585
+ if (!_rec.blob) return;
586
+ const title = document.getElementById('vtv-rec-title-input')?.value || '';
587
+ if (_rec.ratio !== 'original') {
588
+ processRatio(_rec.blob, _rec.ratio, 'upload', title);
589
+ } else {
590
+ uploadToWall(_rec.blob, title);
591
+ }
592
+ };
593
+
594
+ // ===== RATIO CROP — v7: reliable render with timeout fallback =====
595
+ function processRatio(blob, ratio, action, title) {
596
+ showProc(`🔄 Đang xử lý ${ratio}...`);
597
+
598
+ const blobUrl = URL.createObjectURL(blob);
599
+ const video = document.createElement('video');
600
+ video.src = blobUrl;
601
+ video.volume = 0;
602
+ video.preload = 'auto';
603
+ video.playsInline = true;
604
+
605
+ function onReady() {
606
+ video.removeEventListener('loadedmetadata', onReady);
607
+ video.removeEventListener('canplay', onReady);
608
+
609
+ const vw = video.videoWidth, vh = video.videoHeight;
610
+ if (!vw || !vh) {
611
+ hideProc(); recStatus('❌ Video không có kích thước hợp lệ.', 'err');
612
+ URL.revokeObjectURL(blobUrl); return;
613
+ }
614
+
615
+ let cw, ch, sx, sy;
616
+ if (ratio === '1:1') {
617
+ const size = Math.min(vw, vh); cw = ch = size;
618
+ sx = Math.floor((vw - size) / 2); sy = Math.floor((vh - size) / 2);
619
+ } else if (ratio === '9:16') {
620
+ ch = vh; cw = Math.min(vw, Math.round(vh * 9 / 16));
621
+ sx = Math.floor((vw - cw) / 2); sy = 0;
622
+ } else { cw = vw; ch = vh; sx = sy = 0; }
623
+
624
+ showProc(`🔄 Render ${cw}x${ch}...`);
625
+
626
+ const canvas = document.createElement('canvas');
627
+ canvas.width = cw; canvas.height = ch;
628
+ const ctx = canvas.getContext('2d');
629
+ const canvasStream = canvas.captureStream(30);
630
+
631
+ let recordStream = canvasStream;
632
+ const audioVideo = document.createElement('video');
633
+ audioVideo.src = blobUrl;
634
+ audioVideo.muted = true;
635
+ audioVideo.volume = 0;
636
+ audioVideo.preload = 'auto';
637
+ audioVideo.playsInline = true;
638
+
639
+ audioVideo.onloadedmetadata = function() {
640
+ try {
641
+ const aStr = audioVideo.captureStream ? audioVideo.captureStream() : null;
642
+ if (aStr) {
643
+ const tracks = aStr.getAudioTracks();
644
+ if (tracks.length > 0) {
645
+ recordStream = new MediaStream([...canvasStream.getVideoTracks(), ...tracks]);
646
+ console.log('[VTV Crop] Audio merged:', tracks.length, 'tracks');
647
+ }
648
+ }
649
+ } catch(e) { console.warn('[VTV Crop] Audio merge failed:', e); }
650
+ };
651
+
652
+ const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
653
+ ? 'video/webm;codecs=vp9,opus'
654
+ : MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')
655
+ ? 'video/webm;codecs=vp8,opus' : 'video/webm';
656
+
657
+ let rec;
658
+ try { rec = new MediaRecorder(recordStream, {mimeType: mime}); }
659
+ catch(e) { hideProc(); recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err'); URL.revokeObjectURL(blobUrl); return; }
660
+
661
+ const chunks = [];
662
+ rec.ondataavailable = e => { if(e.data && e.data.size > 0) chunks.push(e.data); };
663
+ rec.onstop = () => {
664
+ hideProc();
665
+ URL.revokeObjectURL(blobUrl);
666
+ if (chunks.length === 0) {
667
+ recStatus('❌ Render thất bại (không có data). Thử lại hoặc chọn "Gốc".', 'err');
668
+ return;
669
+ }
670
+ const out = new Blob(chunks, {type: 'video/webm'});
671
+ if (action === 'download') {
672
+ downloadBlob(out, `vtv-${_currentCh}-${ratio.replace(':','x')}-${Date.now()}.webm`);
673
+ } else {
674
+ uploadToWall(out, title);
675
+ }
676
+ };
677
+ rec.onerror = e => { hideProc(); recStatus('❌ Lỗi render: ' + (e.error?.message || '?'), 'err'); URL.revokeObjectURL(blobUrl); };
678
+
679
+ rec.start(200);
680
+
681
+ audioVideo.play().catch(() => {});
682
+
683
+ video.play().then(() => {
684
+ let finished = false;
685
+ function finish() {
686
+ if (finished) return;
687
+ finished = true;
688
+ console.log('[VTV Crop] finish() called');
689
+ try { rec.requestData(); } catch(e) {}
690
+ setTimeout(() => {
691
+ if (rec.state !== 'inactive') rec.stop();
692
+ try { audioVideo.pause(); } catch(e) {}
693
+ }, 300);
694
+ }
695
+ video.onended = finish;
696
+ audioVideo.onended = finish;
697
+
698
+ const expectedDur = (video.duration || 0) * 1000;
699
+ if (expectedDur > 0 && isFinite(expectedDur)) {
700
+ setTimeout(() => {
701
+ if (!finished) {
702
+ console.warn('[VTV Crop] Timeout fallback after', expectedDur + 3000, 'ms');
703
+ finish();
704
+ }
705
+ }, expectedDur + 3000);
706
+ } else {
707
+ console.warn('[VTV Crop] Duration unknown, using 60s timeout');
708
+ setTimeout(() => {
709
+ if (!finished) {
710
+ console.warn('[VTV Crop] 60s timeout fallback');
711
+ finish();
712
+ }
713
+ }, 60000);
714
+ }
715
+
716
+ function draw() {
717
+ if (video.paused || video.ended) return;
718
+ ctx.drawImage(video, sx, sy, cw, ch, 0, 0, cw, ch);
719
+ requestAnimationFrame(draw);
720
+ }
721
+ draw();
722
+ }).catch(e => {
723
+ hideProc(); recStatus('❌ Lỗi phát video: ' + e.message, 'err');
724
+ URL.revokeObjectURL(blobUrl);
725
+ });
726
+ }
727
+
728
+ video.onerror = function() {
729
+ hideProc(); recStatus('❌ Không đọc được video.', 'err');
730
+ URL.revokeObjectURL(blobUrl);
731
+ };
732
+
733
+ video.addEventListener('loadedmetadata', onReady);
734
+ video.addEventListener('canplay', onReady);
735
+ }
736
+
737
+ // ===== TOGGLE REC PANEL =====
738
+ function toggleRecPanel() {
739
+ const panel = document.getElementById('vtv-inline-rec');
740
+ if (!panel) { buildInlineRecorder(); setTimeout(toggleRecPanel, 200); return; }
741
+ _rec.active = !_rec.active;
742
+ panel.classList.toggle('show', _rec.active);
743
+ const btn = document.getElementById('vtv-rec-btn');
744
+ if (btn) {
745
+ if (_rec.active) {
746
+ btn.style.background = '#3a1a1a';
747
+ btn.innerHTML = '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Đóng';
748
+ } else {
749
+ btn.style.background = '';
750
+ btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
751
+ document.getElementById('vtv-rec-panel').classList.remove('show');
752
+ }
753
+ }
754
+ }
755
+
756
+ // ===== PIN BUTTON =====
757
+ function setupPinButton(){
758
+ const block = document.getElementById('vtv-block');
759
+ if(!block) return;
760
+ const header = block.querySelector('.vtv-head');
761
+ if(!header || document.getElementById('vtv-pin-btn')) return;
762
+ const btn = document.createElement('button');
763
+ btn.id = 'vtv-pin-btn'; btn.className = 'vtv-pin-btn';
764
+ btn.innerHTML = '📌 Ghim';
765
+ btn.title = 'Ghim VTV cố định trên đầu trang';
766
+ btn.onclick = function(e){
767
+ e.stopPropagation(); _vtvPinned = !_vtvPinned;
768
+ if(_vtvPinned){ block.classList.add('vtv-sticky'); btn.classList.add('pinned'); btn.innerHTML = '📌 Đã ghim'; }
769
+ else { block.classList.remove('vtv-sticky'); btn.classList.remove('pinned'); btn.innerHTML = '📌 Ghim'; }
770
+ };
771
+ header.style.position = 'relative';
772
+ header.appendChild(btn);
773
+ }
774
+
775
+ // ===== MINI PLAYER =====
776
+ function createMiniPlayer(){
777
+ if(document.getElementById('vtv-mini')) return;
778
+ const mini = document.createElement('div');
779
+ mini.className = 'vtv-mini'; mini.id = 'vtv-mini';
780
+ mini.innerHTML = `<div class="vtv-mini-frame"><video id="vtv-mini-vid" playsinline muted preload="auto"></video></div>
781
+ <div class="vtv-mini-bar"><div style="display:flex;align-items:center;flex:1;min-width:0"><span class="vtv-mini-ch" id="vtv-mini-ch">VTV</span><span class="vtv-mini-epg" id="vtv-mini-epg"></span></div>
782
+ <div class="vtv-mini-btns"><button class="vtv-mini-btn" id="vtv-mini-pip">📺</button><button class="vtv-mini-btn" id="vtv-mini-expand">⬆️</button><button class="vtv-mini-btn" id="vtv-mini-hide">⬇️</button><button class="vtv-mini-btn x" id="vtv-mini-close">✕</button></div></div>
783
+ <div class="vtv-mini-peek" id="vtv-mini-peek">📺 Xem</div>`;
784
+ document.body.appendChild(mini);
785
+ document.getElementById('vtv-mini-pip').onclick = async function(){
786
+ const v = document.getElementById('vtv-mini-vid');
787
+ try{ if(document.pictureInPictureElement === v) await document.exitPictureInPicture(); else await v.requestPictureInPicture(); }catch(e){}
788
+ };
789
+ document.getElementById('vtv-mini-expand').onclick = function(){ closeMiniPlayer(); if(typeof switchCat==='function') switchCat('home'); };
790
+ document.getElementById('vtv-mini-hide').onclick = ()=>{ mini.classList.add('hidden'); };
791
+ document.getElementById('vtv-mini-close').onclick = closeMiniPlayer;
792
+ document.getElementById('vtv-mini-peek').onclick = ()=>{ mini.classList.remove('hidden'); };
793
+ }
794
+ function closeMiniPlayer(){ const m=document.getElementById('vtv-mini'); if(m){const v=document.getElementById('vtv-mini-vid'); if(v){v.pause();v.src='';} m.remove();} _miniActive=false; }
795
+ function activateMiniPlayer(){
796
+ if(!_currentCh) return; createMiniPlayer(); _miniActive=true;
797
+ const mini=document.getElementById('vtv-mini'); const mv=document.getElementById('vtv-mini-vid'); const v=document.getElementById('vtv-player');
798
+ if(_hls){const h=new Hls({enableWorker:true,lowLatencyMode:false,startLevel:0,capLevelToPlayerSize:true,maxBufferLength:4,fragLoadingTimeOut:8000,manifestLoadingTimeOut:8000}); h.loadSource(STREAMS[_currentCh][0]); h.attachMedia(mv); mv.play().catch(()=>{});}
799
+ else if(v&&v.src){mv.src=v.src; mv.play().catch(()=>{});}
800
+ const ch=CHANNELS.find(c=>c.id===_currentCh); if(ch) document.getElementById('vtv-mini-ch').textContent=ch.name;
801
+ fetch('/api/vtv/epg/'+_currentCh).then(r=>r.json()).then(d=>{const p=d.programs||[]; const n=p.find(x=>x.now); document.getElementById('vtv-mini-epg').textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});
802
+ mini.classList.add('show'); mini.classList.remove('hidden');
803
+ }
804
+
805
+ // ===== VTVGO POPUP PLAYER =====
806
+ function openVtvPopup(chId) {
807
+ const popupUrl = VTV_POPUP_URLS[chId];
808
+ if (!popupUrl) return;
809
+ const ch = CHANNELS.find(c => c.id === chId);
810
+ const channelName = ch ? ch.name : chId;
811
+ openVtvPopupWithUrl(popupUrl, '📺 ' + channelName);
812
+ }
813
+
814
+ function closeVtvPopup() {
815
+ const popup = document.getElementById('vtv-popup');
816
+ if (popup) {
817
+ popup.classList.remove('show');
818
+ const iframe = popup.querySelector('iframe');
819
+ if (iframe) iframe.src = '';
820
+ }
821
+ _vtvPopupActive = false;
822
+ }
823
+
824
+ // ===== WORLD CUP 2026 REPLAY SECTION =====
825
+ const WC2026_LINKS = [
826
+ {id:'wc-vtv3', name:'⚽ WC2026 - VTV3', popupUrl:'https://vtvgo.vn/channel/vtv3-1,3.html', badge:'VTV3'},
827
+ {id:'wc-vtv6', name:'⚽ WC2026 - VTV6', popupUrl:'https://package.vtvgo.vn/channel/vtv6-1,13.html', badge:'VTV6'},
828
+ ];
829
+
830
+ // TS token links for World Cup replay (user can add more)
831
+ const WC2026_TS_LINKS = [
832
+ {id:'wc-ts-1', name:'⚽ WC2026 - Xem lại (TS Token)', tsUrl:'https://package.vtvgo.vn/ts/62FPb0qUU6-20260626-328751', badge:'Replay'},
833
+ ];
834
+
835
+ function buildWc2026Section(){
836
+ const w=document.createElement('div'); w.className='vtv-wrap'; w.id='vtv-wc2026';
837
+ w.style.borderColor='#e6b800';
838
+ let tabs='';
839
+ WC2026_LINKS.forEach(ch=>{
840
+ tabs+='<div style="display:flex;align-items:center"><button class="vtv-tab" id="vtvt-wc-'+ch.id+'" onclick="window._vtvOpenUrlPopup(\''+ch.popupUrl+'\')" style="background:#1a2a1a;border-color:#2d4a2d;color:#8ad88a">'+ch.name+'</button></div>';
841
+ });
842
+ WC2026_TS_LINKS.forEach(ch=>{
843
+ tabs+='<div style="display:flex;align-items:center"><button class="vtv-tab" id="vtvt-wc-ts-'+ch.id+'" onclick="window._vtvOpenTsPopup(\''+ch.tsUrl+'\')" style="background:#2a1a1a;border-color:#4a2d2d;color:#d88a8a">'+ch.name+'</button></div>';
844
+ });
845
+ w.innerHTML='<div class="vtv-head" style="background:linear-gradient(90deg,#4a3a00,#1a1a1a)"><span class="vtv-title" style="color:#ffcc00">⚽ World Cup 2026</span><span class="vtv-badge" style="color:#ffcc00">FIFA</span></div>'+
846
+ '<div class="vtv-tabs" style="flex-wrap:wrap">'+tabs+'</div>'+
847
+ '<div class="vtv-wc-info" style="padding:6px 10px;background:#0a1628;border-top:1px solid #2a2a1a;font-size:9px;color:#888;text-align:center">Nhấn nút để mở player VTVGO xem trực tiếp / xem lại World Cup 2026</div>';
848
+ return w;
849
+ }
850
+
851
+ // Open any URL in popup (for WC2026 direct links)
852
+ window._vtvOpenUrlPopup=function(popupUrl) {
853
+ if (!popupUrl) return;
854
+ openVtvPopupWithUrl(popupUrl, '', true);
855
+ };
856
+
857
+ window._vtvOpenPopup=function(chId) {
858
+ openVtvPopup(chId);
859
+ };
860
+
861
+ function openVtvPopupWithUrl(popupUrl, title, hideHeader) {
862
+ let popup = document.getElementById('vtv-popup');
863
+ if (!popup) {
864
+ popup = document.createElement('div');
865
+ popup.id = 'vtv-popup';
866
+ popup.className = 'vtv-popup';
867
+ popup.innerHTML = `
868
+ <div class="vtv-popup-header" id="vtv-popup-hdr">
869
+ <span class="vtv-popup-title" id="vtv-popup-title">📺 VTV Player</span>
870
+ <button class="vtv-popup-close" id="vtv-popup-close">✕</button>
871
+ </div>
872
+ <div class="vtv-popup-frame" id="vtv-popup-frame"></div>
873
+ `;
874
+ document.body.appendChild(popup);
875
+ document.getElementById('vtv-popup-close').onclick = closeVtvPopup;
876
+ }
877
+
878
+ _vtvPopupActive = true;
879
+ const hdr = document.getElementById('vtv-popup-hdr');
880
+ if (hdr) hdr.style.display = hideHeader ? 'none' : 'flex';
881
+ document.getElementById('vtv-popup-title').textContent = title || '📺 VTV Player';
882
+ const frame = document.getElementById('vtv-popup-frame');
883
+
884
+ // Show loader
885
+ frame.innerHTML = '<div class="vtv-popup-loader" id="vtv-popup-loader"><div class="vtv-spinner"></div>Đang tải player...</div>';
886
+
887
+ // Use iframe but handle blocked embeds gracefully
888
+ const iframe = document.createElement('iframe');
889
+ iframe.id = 'vtv-popup-iframe';
890
+ iframe.setAttribute('allowfullscreen', 'true');
891
+ iframe.setAttribute('webkitallowfullscreen', 'true');
892
+ iframe.setAttribute('mozallowfullscreen', 'true');
893
+ iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
894
+ iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none;display:none';
895
+
896
+ // Timeout to detect blocked iframe (onload fires even for blocked, so use timeout)
897
+ let loaded = false;
898
+ iframe.onload = () => { loaded = true; const l = document.getElementById('vtv-popup-loader'); if(l) l.style.display='none'; iframe.style.display = 'block'; };
899
+ frame.appendChild(iframe);
900
+ popup.classList.add('show');
901
+
902
+ // Set src after append to ensure events fire
903
+ setTimeout(() => { iframe.src = popupUrl; }, 50);
904
+
905
+ // If still not loaded after 4s, fallback to new window
906
+ setTimeout(() => {
907
+ if (!loaded && popup.classList.contains('show')) {
908
+ window.open(popupUrl, '_blank');
909
+ closeVtvPopup();
910
+ }
911
+ }, 4000);
912
+ }
913
+
914
+ // Open TS token URL in popup
915
+ window._vtvOpenTsPopup=function(tsUrl) {
916
+ openVtvPopupWithUrl(tsUrl, '', true);
917
+ };
918
+
919
+ window._vtvClosePopup=function() { closeVtvPopup(); };
920
+
921
+ // ===== MAIN PLAYER =====
922
+ async function loadAllStreams(){
923
+ if(_loading) return; _loading=true;
924
+ const el=document.getElementById('vtv-load'); if(el) el.innerHTML='<div class="vtv-spinner"></div>Đang tải kênh...';
925
+ try{const r=await fetch('/api/vtv/streams'); if(r.ok){const d=await r.json(); CHANNELS.forEach(ch=>{const i=d[ch.id]; if(i&&i.stream_url){let u=i.stream_url; if(NEEDS_PROXY.test(u)) u='/api/proxy/m3u8/vtv?url='+encodeURIComponent(u); STREAMS[ch.id]=[u];} else STREAMS[ch.id]=[];});}}catch(e){}
926
+ CHANNELS.forEach(ch=>{
927
+ const t=document.querySelector('#vtvt-'+ch.id);
928
+ if(t){
929
+ if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){
930
+ t.classList.remove('off');
931
+ }else{
932
+ t.style.opacity='0.35';
933
+ }
934
+ }
935
+ });
936
+ _loading=false; _streamsLoaded=true;
937
+ }
938
+
939
+ function buildBlock(){
940
+ const w=document.createElement('div'); w.className='vtv-wrap'; w.id='vtv-block';
941
+ let tabs=''; CHANNELS.forEach(ch=>{
942
+ tabs+='<div style="display:flex;align-items:center"><button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button><button class="vtv-popup-btn" onclick="window._vtvOpenPopup(\''+ch.id+'\')" title="Mở player VTVGO">📺</button></div>';
943
+ });
944
+ w.innerHTML='<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>'+
945
+ '<div class="vtv-tabs" style="flex-wrap:wrap">'+tabs+'</div>'+
946
+ '<div class="vtv-frame"><div class="vtv-load" id="vtv-load"><div class="vtv-spinner"></div>Đang tải kênh...</div><video id="vtv-player" playsinline muted controls preload="auto" style="display:none"></video><div class="vtv-err" id="vtv-err" style="display:none"><span id="vtv-err-msg">Không thể tải kênh</span><button onclick="window._vtvRetry()">Thử lại</button></div></div>'+
947
+ '<div class="vtv-controls"><button class="vtv-pip-btn" id="vtv-pip-btn" onclick="window._vtvTogglePiP()"><svg viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><rect x="12" y="10" width="8" height="6" rx="1" fill="currentColor"/></svg>PiP</button><span style="flex:1"></span><span style="font-size:9px;color:#666" id="vtv-status"></span></div>'+
948
+ '<div class="vtv-epg" id="vtv-epg"><div class="vtv-epg-header"><span class="vtv-epg-title">📋 Lịch phát sóng</span><button class="vtv-epg-toggle" id="vtv-epg-toggle" onclick="window._vtvToggleEpg()">Ẩn</button></div><div class="vtv-epg-list" id="vtv-epg-list"><div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải...</div></div></div>';
949
+ return w;
950
+ }
951
+
952
+ async function loadEpg(chId){
953
+ const el=document.getElementById('vtv-epg-list'); if(!el) return;
954
+ el.innerHTML='<div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải lịch...</div>';
955
+ try{const r=await fetch('/api/vtv/epg/'+chId); if(!r.ok) throw new Error(); const d=await r.json(); const p=d.programs||[]; if(!p.length){el.innerHTML='<div class="vtv-epg-empty">Chưa có lịch</div>';return;} let h=''; p.forEach(x=>{const n=x.now?' now':''; h+='<div class="vtv-epg-item'+n+'"><span class="t">'+(x.time||'')+(x.end_time?'-'+x.end_time:'')+'</span> <span class="n">'+(x.title||'')+'</span></div>';}); el.innerHTML=h; const ni=el.querySelector('.vtv-epg-item.now'); if(ni) ni.scrollIntoView({behavior:'smooth',inline:'center',block:'nearest'});}catch(e){el.innerHTML='<div class="vtv-epg-empty">Không tải được lịch</div>';}
956
+ }
957
+
958
+ window._vtvToggleEpg=function(){const l=document.getElementById('vtv-epg-list'),b=document.getElementById('vtv-epg-toggle'); if(!l||!b)return; if(l.style.display==='none'){l.style.display='flex';b.textContent='Ẩn';}else{l.style.display='none';b.textContent='Hiện';}};
959
+ window._vtvTogglePiP=async function(){const v=document.getElementById('vtv-player'); const b=document.getElementById('vtv-pip-btn'); if(!v)return; try{if(document.pictureInPictureElement===v){await document.exitPictureInPicture();_pipActive=false;if(b)b.classList.remove('on');}else{await v.requestPictureInPicture();_pipActive=true;if(b)b.classList.add('on');}}catch(e){}};
960
+
961
+ function pinBlock(){
962
+ const h=document.getElementById('view-home'); if(!h||document.getElementById('vtv-block')) return;
963
+ h.insertBefore(buildBlock(),h.firstChild); _blockInserted=true;
964
+ // Add World Cup 2026 section below VTV block
965
+ if(!document.getElementById('vtv-wc2026')){
966
+ const wcBlock = buildWc2026Section();
967
+ h.insertBefore(wcBlock, document.getElementById('vtv-block').nextSibling);
968
+ }
969
+ setTimeout(()=>{setupPinButton();buildInlineRecorder();},500);
970
+ if(!_streamsLoaded){loadAllStreams().then(()=>{setTimeout(()=>window._vtvPlay(DEFAULT_CHANNEL),300);});}
971
+ }
972
+
973
+ window._vtvRetry=function(){if(_currentCh)window._vtvPlay(_currentCh);};
974
+
975
+ window._vtvPlay=function(chId){
976
+ const ch=CHANNELS.find(c=>c.id===chId); if(!ch) return; _currentCh=chId;
977
+ document.querySelectorAll('.vtv-tab').forEach(t=>t.classList.remove('on'));
978
+ const tab=document.getElementById('vtvt-'+chId); if(tab) tab.classList.add('on');
979
+ const video=document.getElementById('vtv-player'); const errEl=document.getElementById('vtv-err'); const loadEl=document.getElementById('vtv-load'); const errMsg=document.getElementById('vtv-err-msg');
980
+ video.style.display='none'; errEl.style.display='none'; loadEl.style.display='flex';
981
+ loadEl.innerHTML='<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
982
+ if(_hls){_hls.destroy();_hls=null;}
983
+ const urls=STREAMS[chId]||[];
984
+ if(!urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=chId==='vtvprime'?'VTVPrime: Kênh trả phí.':ch.name+': Không có luồng.';return;}
985
+ _tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);
986
+ loadEpg(chId);
987
+ if(_miniActive){document.getElementById('vtv-mini-ch').textContent=ch.name;fetch('/api/vtv/epg/'+_currentCh).then(r=>r.json()).then(d=>{const p=d.programs||[];const n=p.find(x=>x.now);document.getElementById('vtv-mini-epg').textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});}
988
+ };
989
+
990
+ function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
991
+ if(idx>=urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';return;}
992
+ const src=urls[idx]; loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
993
+ if(typeof Hls!=='undefined'&&Hls.isSupported()){
994
+ const hls=new Hls({enableWorker:true,lowLatencyMode:false,startLevel:0,capLevelToPlayerSize:true,maxBufferLength:6,maxMaxBufferLength:10,liveSyncDurationCount:2,liveMaxLatencyDurationCount:3,fragLoadingTimeOut:10000,manifestLoadingTimeOut:10000}); _hls=hls; hls.loadSource(src); hls.attachMedia(video);
995
+ hls.on(Hls.Events.MANIFEST_PARSED,()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';});
996
+ let rec=0; hls.on(Hls.Events.ERROR,(ev,data)=>{if(data.fatal){if(data.type===Hls.ErrorTypes.NETWORK_ERROR){rec++;if(rec<=3)setTimeout(()=>hls.startLoad(),2000);else{hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}}else if(data.type===Hls.ErrorTypes.MEDIA_ERROR){try{hls.recoverMediaError();}catch(e){}}else{hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}}});
997
+ }else if(video.canPlayType('application/vnd.apple.mpegurl')){
998
+ video.src=src;
999
+ video.addEventListener('loadedmetadata',()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';},{once:true});
1000
+ video.addEventListener('error',()=>{_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);},{once:true});
1001
+ }else{loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent='Không hỗ trợ HLS';}
1002
+ }
1003
+
1004
+ // ===== NAV HOOKS =====
1005
+ const _origShowView=window.showView;
1006
+ window.showView=function(id){if(id==='view-home'&&_miniActive)closeMiniPlayer();else if(id!=='view-home'&&_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origShowView.apply(this,arguments);};
1007
+ const _origReadArticle=window.readArticle; if(_origReadArticle){window.readArticle=function(url){if(_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origReadArticle.apply(this,arguments);};}
1008
+ const _origSwitchCat=window.switchCat; if(_origSwitchCat){window.switchCat=function(id){if(id==='home'){if(_miniActive)closeMiniPlayer();}else if(_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origSwitchCat.apply(this,arguments);};}
1009
+
1010
+ const _origLoadHome=window.loadHome;
1011
+ if(_origLoadHome&&!_origLoadHome.__vtvWrapped){
1012
+ window.loadHome=async function(){const old=document.getElementById('vtv-block');if(old)old.remove();const wc=document.getElementById('vtv-wc2026');if(wc)wc.remove();_blockInserted=false;const r=await _origLoadHome.apply(this,arguments);try{pinBlock();}catch(e){}return r;};
1013
+ window.loadHome.__vtvWrapped=true;
1014
+ }
1015
+ })();
vtv_api.py CHANGED
@@ -1,12 +1,18 @@
1
- # VTV Stream — SV2/sv.xemtivitop iframe URLs + M3U fallback + EPG
2
- import re, time, threading, json, requests
 
 
 
 
 
 
3
  from fastapi import APIRouter, Query
4
- from fastapi.responses import JSONResponse, Response, StreamingResponse
5
  from bs4 import BeautifulSoup
6
  from datetime import datetime, timedelta, timezone
7
- from urllib.parse import quote
8
 
9
  VN_TZ = timezone(timedelta(hours=7))
 
10
  router = APIRouter()
11
 
12
  UA = {
@@ -14,214 +20,527 @@ UA = {
14
  "Accept-Language": "vi-VN,vi;q=0.9",
15
  }
16
 
17
- HARDCODED_URLS = {
18
- "vtv6": "https://freem3u.xyz/api/live/play.m3u8?vid=10043",
19
- "vtv10": "https://live.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
 
 
 
 
 
 
 
 
 
 
20
  }
21
- CHANNEL_URLS = {k: (HARDCODED_URLS[k] if k in HARDCODED_URLS else None) for k in ["vtv1","vtv2","vtv3","vtv4","vtv5","vtv6","vtv7","vtv8","vtv9","vtv10"]}
22
- CHANNEL_NAMES = {"vtv1":"VTV1","vtv2":"VTV2","vtv3":"VTV3","vtv4":"VTV4","vtv5":"VTV5","vtv6":"VTV6","vtv7":"VTV7","vtv8":"VTV8","vtv9":"VTV9","vtv10":"VTV10","vtvprime":"VTVPrime"}
23
-
24
- # Iframe URLs cho từng kênh — lấy từ xemtivitop.com
25
- # Với VTV7: LINK 1 = blogspot (lỗi), LINK 2 = sv.xemtivitop.com (JWPlayer, hoạt động)
26
- IFRAME_URLS = {
27
- "vtv1": "https://sv2.xemtivitop.com/live/hot/vtv1.php",
28
- "vtv2": "https://sv2.xemtivitop.com/live/hot/vtv2.php",
29
- "vtv3": "https://sv2.xemtivitop.com/live/hot/vtv3.php",
30
- "vtv4": "https://sv2.xemtivitop.com/live/hot/vtv4.php",
31
- "vtv5": "https://sv2.xemtivitop.com/live/hot/vtv5.php",
32
- "vtv6": "https://sv2.xemtivitop.com/live/hot/vtv6.php",
33
- # VTV7: LINK 2 trên xemtivitop.com — sv.xemtivitop.com (ko phải sv2), JWPlayer, cho phép iframe
34
- "vtv7": "https://sv.xemtivitop.com/live/vtv/vtv7.php",
35
- "vtv8": "https://sv2.xemtivitop.com/live/hot/vtv8.php",
36
- "vtv9": "https://sv2.xemtivitop.com/live/hot/vtv9.php",
37
- "vtv10": "https://sv2.xemtivitop.com/live/hot/vtv10.php",
38
  }
39
 
40
- M3U_URLS = [
41
- "https://raw.githubusercontent.com/Love4vn/Test/refs/heads/main/IPTV.m3u",
42
- "https://raw.githubusercontent.com/Love4vn/love4vn/main/Out_Iptv_CXT.m3u",
43
- "https://raw.githubusercontent.com/konanda-sg/Test_Love4vn/main/IPTV.m3u",
44
- "https://raw.githubusercontent.com/iptv-org/iptv/master/streams/vn.m3u",
45
- ]
46
-
47
- _channel_urls_lock = threading.Lock()
48
- _last_m3u_fetch = 0
49
- _M3U_TTL = 900
50
-
51
- def _fetch_m3u():
52
- global _last_m3u_fetch
53
- now = time.time()
54
- if now - _last_m3u_fetch < _M3U_TTL: return
55
- _last_m3u_fetch = now
56
- all_urls = {}
57
- for m3u_url in M3U_URLS:
58
- try:
59
- r = requests.get(m3u_url, headers=UA, timeout=10)
60
- if r.status_code != 200: continue
61
- text = r.text
62
- lines = text.strip().split("\n")
63
- current_channel = None
64
- for line in lines:
65
- s = line.strip()
66
- if s.startswith("#EXTINF"):
67
- m = re.search(r'tvg-id="?(\w+)"?', s)
68
- if m: current_channel = m.group(1).lower()
69
- else:
70
- m2 = re.search(r'(VTV\d+|vtv\d+)', s, re.IGNORECASE)
71
- if m2: current_channel = m2.group(1).lower()
72
- else: current_channel = None
73
- elif s.startswith("http") and current_channel:
74
- ch_id = current_channel
75
- ch_map = {"vtv1":"vtv1","vtv2":"vtv2","vtv3":"vtv3","vtv4":"vtv4","vtv5":"vtv5","vtv6":"vtv6","vtv7":"vtv7","vtv8":"vtv8","vtv9":"vtv9","vtv10":"vtv10","vtvcầnthơ":"vtv10","vtvcantho":"vtv10","vtvcan-tho":"vtv10"}
76
- if ch_id in ch_map: ch_id = ch_map[ch_id]
77
- if ch_id in CHANNEL_URLS and ch_id not in all_urls:
78
- if CHANNEL_URLS[ch_id] and HARDCODED_URLS.get(ch_id): current_channel = None; continue
79
- if ".flv" in s: all_urls[ch_id] = s
80
- elif ch_id not in all_urls or ".flv" not in all_urls.get(ch_id,""):
81
- if ch_id not in all_urls: all_urls[ch_id] = s
82
- current_channel = None
83
- if len(all_urls) >= 10: break
84
- except: pass
85
- with _channel_urls_lock:
86
- found = 0
87
- for ch_id in CHANNEL_URLS:
88
- if CHANNEL_URLS[ch_id] and HARDCODED_URLS.get(ch_id): continue
89
- if ch_id in all_urls: CHANNEL_URLS[ch_id] = all_urls[ch_id]; found += 1
90
- print(f"[M3U] Found {found}/10 channels (hardcoded: vtv6, vtv10)")
91
-
92
- def get_channel_url(channel_id):
93
- _fetch_m3u()
94
- with _channel_urls_lock: return CHANNEL_URLS.get(channel_id)
95
-
96
- # ===================== API ENDPOINTS =====================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  @router.get("/api/vtv/streams")
99
  def api_vtv_streams():
100
- urls = dict(CHANNEL_URLS)
101
  result = {}
102
  for ch_id in CHANNEL_NAMES:
103
- stream_url = urls.get(ch_id)
104
- iframe_url = IFRAME_URLS.get(ch_id)
105
- is_flv = stream_url and ".flv" in stream_url
106
- result[ch_id] = {
107
- "name": CHANNEL_NAMES[ch_id],
108
- "stream_url": stream_url,
109
- "proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "",
110
- "proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "",
111
- "is_flv": is_flv,
112
- "iframe_url": iframe_url,
113
- "status": "ok"
114
- }
115
  return JSONResponse(result)
116
 
117
  @router.get("/api/vtv/stream/{channel_id}")
118
  def api_vtv_stream(channel_id: str):
119
- channel_id = channel_id.lower().strip()
120
- if channel_id not in CHANNEL_NAMES: return JSONResponse({"error":"not found"}, status_code=404)
121
- stream_url = get_channel_url(channel_id)
122
- iframe_url = IFRAME_URLS.get(channel_id)
123
- is_flv = stream_url and ".flv" in stream_url
124
- m3u8_proxy = ""
125
- if stream_url and not is_flv and stream_url.startswith("http"):
126
- m3u8_proxy = f"/api/proxy/m3u8?url={quote(stream_url, safe='')}"
127
- return JSONResponse({
128
- "name": CHANNEL_NAMES[channel_id],
129
- "stream_url": stream_url,
130
- "m3u8_proxy_url": m3u8_proxy,
131
- "proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "",
132
- "proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "",
133
- "is_flv": is_flv,
134
- "iframe_url": iframe_url,
135
- "status": "ok"
136
- })
137
 
138
- @router.get("/api/vtv/m3u/refresh")
139
- def api_vtv_m3u_refresh():
140
- global _last_m3u_fetch
141
- _last_m3u_fetch = 0
142
- _fetch_m3u()
143
- urls = dict(CHANNEL_URLS)
144
- found = sum(1 for v in urls.values() if v)
145
- return JSONResponse({"status":"refreshed","channels_found":found,"channels":{k:v for k,v in urls.items() if v}})
 
 
 
 
 
 
146
 
147
- # ===================== EPG =====================
148
- _epg_cache = {}; _epg_cache_time = 0; _EPG_CACHE_TTL = 600
149
- VTV_CHANNEL_MAP = {"vtv1":"vtv1","vtv2":"vtv2","vtv3":"vtv3","vtv4":"vtv4","vtv5":"vtv5","vtv5-tay-nam-bo":"vtv5","vtv5-tay-nguyen":"vtv5","vtv6":"vtv6","vtv7":"vtv7","vtv8":"vtv8","vtv9":"vtv9","vtv-can-tho":"vtv10"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
- def _parse_time(time_str, reference_date=None):
152
- if not time_str: return None
153
- time_str = time_str.strip().replace("h",":").replace("H",":")
154
- m = re.search(r'(\d{1,2}):(\d{2})', time_str)
155
- if m:
156
- try:
157
- hour, minute = int(m.group(1)), int(m.group(2))
158
- base = reference_date or datetime.now(VN_TZ)
159
- if hour < 5: base = base - timedelta(days=1) if base.hour >= 5 else base
160
- dt = base.replace(hour=hour, minute=minute, second=0, microsecond=0)
161
- if dt.tzinfo is None: dt = dt.replace(tzinfo=VN_TZ)
162
- return dt
163
- except: pass
164
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
  def _fetch_epg_from_vtv():
167
  global _epg_cache, _epg_cache_time
168
  now_ts = time.time()
169
- if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL: return _epg_cache
 
170
  epg_data = {}
 
171
  now_vn = datetime.now(VN_TZ)
172
  try:
173
- h = {"User-Agent": UA["User-Agent"], "Accept-Language": "vi-VN,vi;q=0.9", "Referer": "https://vtv.vn/"}
174
- r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=h, timeout=20)
175
- if r.status_code != 200: return epg_data
 
 
 
 
 
176
  r.encoding = "utf-8"
177
  soup = BeautifulSoup(r.text, "lxml")
178
  channel_order = []
179
- for link in soup.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
180
- ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href',''))
181
- if ch_id and ch_id.group(1) not in channel_order: channel_order.append(ch_id.group(1))
182
- containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b'))
183
- for i, container in enumerate(containers):
184
- if i >= len(channel_order): break
185
- vtv_id = channel_order[i]
186
- our_id = VTV_CHANNEL_MAP.get(vtv_id, vtv_id)
187
- if our_id not in epg_data: epg_data[our_id] = []
 
 
 
 
 
 
 
 
 
 
188
  for li in container.find_all('li', class_=re.compile(r'\bprogram\b')):
189
- t = li.find('span', class_=re.compile(r'\btime\b'))
190
- title_el = li.find('span', class_=re.compile(r'\btitle\b'))
191
- genre = li.find('a', class_=re.compile(r'\bgenre\b'))
192
- time_str = t.get_text(strip=True) if t else ""
193
- title = genre.get_text(strip=True) if genre else ""
194
- if not title and title_el: title = title_el.get_text(strip=True)
195
- if not time_str or not title: continue
 
 
 
 
 
196
  start_dt = _parse_time(time_str, reference_date=now_vn)
197
- if not start_dt: continue
198
- epg_data[our_id].append({"time":time_str[:5],"title":title[:80],"start_dt":start_dt,"date":start_dt.strftime("%d/%m/%Y")})
199
- for ch_id in epg_data: epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min)
200
- except Exception as e: print(f"EPG error: {e}")
201
- _epg_cache = epg_data; _epg_cache_time = now_ts
 
 
 
 
 
 
 
 
 
 
 
 
202
  return epg_data
203
 
204
- @router.get("/api/vtv/epg/{channel_id}")
205
- def api_vtv_epg(channel_id: str):
206
- channel_id = channel_id.lower().strip()
207
- if channel_id not in CHANNEL_NAMES: return JSONResponse({"error":"channel not found"}, status_code=404)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  epg_data = _fetch_epg_from_vtv()
209
  programmes = epg_data.get(channel_id, [])
 
 
210
  now = datetime.now(VN_TZ)
211
  today = now.date()
212
  result = []
213
- today_progs = [p for p in programmes if p.get("start_dt") and p["start_dt"].date() == today] or programmes
214
- for i, p in enumerate(today_progs):
 
 
215
  start_dt = p.get("start_dt")
216
- stop_dt = today_progs[i+1].get("start_dt") if i+1 < len(today_progs) else None
217
- is_now = bool(start_dt and ((stop_dt and start_dt <= now < stop_dt) or start_dt <= now))
218
- end_time = stop_dt.strftime("%H:%M") if stop_dt else ""
219
- result.append({"time":p["time"],"title":p["title"],"end_time":end_time,"now":is_now,"date":p.get("date","")})
220
- return JSONResponse({"channel":channel_id,"channel_name":CHANNEL_NAMES.get(channel_id,channel_id),"date":now.strftime("%Y-%m-%d"),"programs":result})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
  @router.get("/api/vtv/epg")
223
  def api_vtv_epg_refresh():
224
  global _epg_cache, _epg_cache_time
225
- _epg_cache = {}; _epg_cache_time = 0
 
226
  epg_data = _fetch_epg_from_vtv()
227
- return JSONResponse({"status":"refreshed","channels":len(epg_data),"total":sum(len(v) for v in epg_data.values())})
 
 
 
 
1
+ """
2
+ VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
3
+ Fetches stream URLs from xemtv.us PHP endpoints (primary)
4
+ Fallback: FPTPlay CDN → VTVGo CDN → xemtv.net (legacy)
5
+ EPG data scraped from https://vtv.vn/lich-phat-song.htm
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
  VN_TZ = timezone(timedelta(hours=7))
15
+
16
  router = APIRouter()
17
 
18
  UA = {
 
20
  "Accept-Language": "vi-VN,vi;q=0.9",
21
  }
22
 
23
+ # ===== PRIMARY: xemtv.us (new domain, works 2025-2026) =====
24
+ XEMTV_US_ENDPOINTS = {
25
+ "vtv1": "https://xemtv.us/tv/vtv1.php",
26
+ "vtv2": "https://xemtv.us/tv/vtv2.php",
27
+ "vtv3": "https://xemtv.us/tv/vtv3.php",
28
+ "vtv4": "https://xemtv.us/tv/vtv4.php",
29
+ "vtv5": "https://xemtv.us/tv/vtv5.php",
30
+ "vtv6": "https://xemtv.us/tv/vtv6.php",
31
+ "vtv7": "https://xemtv.us/tv/vtv7.php",
32
+ "vtv8": "https://xemtv.us/tv/vtv8.php",
33
+ "vtv9": "https://xemtv.us/tv/vtv9.php",
34
+ "vtv10": "https://xemtv.us/tv/vtv10.php",
35
+ "vtvprime": "https://xemtv.us/tv/vtvprime.php",
36
  }
37
+
38
+ # ===== LEGACY: xemtv.net (may return 403, keep as last resort) =====
39
+ XEMTV_LEGACY_ENDPOINTS = {
40
+ "vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
41
+ "vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
42
+ "vtv3": "https://hd.xemtv.net/kenh/vtv3.php",
43
+ "vtv4": "https://hd.xemtv.net/kenh/vtv4.php",
44
+ "vtv5": "https://hd.xemtv.net/kenh/vtv5.php",
45
+ "vtv6": "https://hd.xemtv.net/kenh/vtv6.php",
46
+ "vtv7": "https://hd.xemtv.net/kenh/vtv7.php",
47
+ "vtv8": "https://hd.xemtv.net/kenh/vtv8.php",
48
+ "vtv9": "https://hd.xemtv.net/kenh/vtv9.php",
49
+ "vtv10": "https://hd.xemtv.net/kenh/vtv10.php",
50
+ "vtvprime": "https://hd.xemtv.net/kenh/vtvprime.php",
 
 
 
51
  }
52
 
53
+ CHANNEL_NAMES = {
54
+ "vtv1": "VTV1",
55
+ "vtv2": "VTV2",
56
+ "vtv3": "VTV3",
57
+ "vtv4": "VTV4",
58
+ "vtv5": "VTV5",
59
+ "vtv6": "VTV6",
60
+ "vtv7": "VTV7",
61
+ "vtv8": "VTV8",
62
+ "vtv9": "VTV9",
63
+ "vtv10": "VTV10",
64
+ "vtvprime": "VTVPrime",
65
+ }
66
+
67
+ # ===== FALLBACK 1: FPTPlay CDN (new URLs 2025-2026) =====
68
+ FPTPLAY_URLS = {
69
+ "vtv1": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
70
+ "vtv2": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
71
+ "vtv3": "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8",
72
+ "vtv4": "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
73
+ "vtv5": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
74
+ "vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
75
+ "vtv7": "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
76
+ "vtv8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
77
+ "vtv9": "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
78
+ "vtv10": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
79
+ }
80
+
81
+ # ===== FALLBACK 2: VTVGo CDN =====
82
+ VTVGO_FAILOVER = {
83
+ "vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
84
+ "vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
85
+ "vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
86
+ "vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
87
+ "vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
88
+ "vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
89
+ "vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
90
+ "vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
91
+ "vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
92
+ }
93
+
94
+ _vtv_cache = {}
95
+ _vtv_lock = threading.Lock()
96
+ _CACHE_TTL = 180
97
+
98
+ def _cached(key):
99
+ with _vtv_lock:
100
+ if key in _vtv_cache and time.time() - _vtv_cache[key]['t'] < _CACHE_TTL:
101
+ return _vtv_cache[key]['d']
102
+ return None
103
+
104
+ def _set_cache(key, data):
105
+ with _vtv_lock:
106
+ _vtv_cache[key] = {'t': time.time(), 'd': data}
107
+
108
+ def extract_m3u8_from_html(html):
109
+ if not html:
110
+ return None
111
+ m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
112
+ if m:
113
+ url = m.group(1).strip()
114
+ if len(url) > 20:
115
+ return url
116
+ m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
117
+ if m:
118
+ url = m.group(1).strip()
119
+ if len(url) > 20:
120
+ return url
121
+ return None
122
+
123
+ def fetch_xemtv_us_stream(channel_id):
124
+ php_url = XEMTV_US_ENDPOINTS.get(channel_id)
125
+ if not php_url:
126
+ return None
127
+ try:
128
+ headers = {**UA, "Referer": "https://xemtv.us/"}
129
+ r = requests.get(php_url, headers=headers, timeout=15, allow_redirects=True, verify=False)
130
+ if r.status_code == 200:
131
+ m3u8 = extract_m3u8_from_html(r.text)
132
+ if m3u8:
133
+ return m3u8
134
+ except:
135
+ pass
136
+ return None
137
+
138
+ def fetch_xemtv_legacy_stream(channel_id):
139
+ php_url = XEMTV_LEGACY_ENDPOINTS.get(channel_id)
140
+ if not php_url:
141
+ return None
142
+ try:
143
+ headers = {**UA, "Referer": "https://hd.xemtv.net/"}
144
+ r = requests.get(php_url, headers=headers, timeout=15, allow_redirects=True, verify=False)
145
+ if r.status_code == 200:
146
+ m3u8 = extract_m3u8_from_html(r.text)
147
+ if m3u8:
148
+ return m3u8
149
+ except:
150
+ pass
151
+ return None
152
+
153
+ def fetch_fptplay_stream(channel_id):
154
+ url = FPTPLAY_URLS.get(channel_id)
155
+ if not url:
156
+ return None
157
+ try:
158
+ headers = {
159
+ "User-Agent": UA["User-Agent"],
160
+ "Referer": "https://fptplay.vn/",
161
+ "Origin": "https://fptplay.vn",
162
+ }
163
+ r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
164
+ if r.status_code == 200 and '#EXTM3U' in r.text[:200]:
165
+ return url
166
+ except:
167
+ pass
168
+ return None
169
+
170
+ def fetch_vtvgo_stream(channel_id):
171
+ url = VTVGO_FAILOVER.get(channel_id)
172
+ if not url:
173
+ return None
174
+ try:
175
+ headers = {**UA, "Referer": "https://vtvgo.vn/"}
176
+ r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
177
+ if r.status_code == 200 and '#EXTM3U' in r.text[:200]:
178
+ return url
179
+ except:
180
+ pass
181
+ return None
182
+
183
+ def normalize_fptplay_url(url):
184
+ """Replace old/broken FPTPlay URLs with new working ones"""
185
+ if not url:
186
+ return url
187
+ old_to_new = {
188
+ "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8":
189
+ "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
190
+ "https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8":
191
+ "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
192
+ "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8":
193
+ "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8",
194
+ "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8":
195
+ "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
196
+ "https://live.fptplay53.net/fnxhd1/vtv5hd_vhls.smil/chunklist.m3u8":
197
+ "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
198
+ "https://live.fptplay53.net/fnxhd1/vtv6hd_vhls.smil/chunklist.m3u8":
199
+ "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
200
+ "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8":
201
+ "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
202
+ "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/c.hunklist.m3u8":
203
+ "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
204
+ "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8":
205
+ "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
206
+ "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8":
207
+ "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
208
+ "https://live.fptplay53.net/fnxhd1/vtv10hd_vhls.smil/chunklist.m3u8":
209
+ "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
210
+ "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8":
211
+ "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
212
+ }
213
+ return old_to_new.get(url, url)
214
+
215
+ def fetch_vtv_stream(channel_id):
216
+ """Fetch VTV stream with multi-source fallback chain:
217
+ 1. xemtv.us (primary - new domain, most reliable)
218
+ 2. FPTPlay CDN (fallback - new URLs)
219
+ 3. VTVGo CDN (fallback)
220
+ 4. xemtv.net legacy (last resort)
221
+ """
222
+ channel_id = channel_id.lower().strip()
223
+ name_map = {
224
+ 'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
225
+ 'vtv_can_tho': 'vtv10', 'cantho': 'vtv10',
226
+ 'vietnam_vtv1': 'vtv1', 'vietnam_vtv2': 'vtv2', 'vietnam_vtv3': 'vtv3',
227
+ 'vietnam_vtv4': 'vtv4', 'vietnam_vtv5': 'vtv5', 'vietnam_vtv6': 'vtv6',
228
+ 'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
229
+ }
230
+ channel_id = name_map.get(channel_id, channel_id)
231
+ cached = _cached(channel_id)
232
+ if cached is not None:
233
+ return cached
234
+
235
+ if channel_id == 'vtvprime':
236
+ url = fetch_xemtv_us_stream('vtvprime') or fetch_xemtv_legacy_stream('vtvprime')
237
+ if url:
238
+ url = normalize_fptplay_url(url)
239
+ _set_cache(channel_id, url)
240
+ return url
241
+
242
+ # Source 1: xemtv.us (primary)
243
+ url = fetch_xemtv_us_stream(channel_id)
244
+ if url:
245
+ url = normalize_fptplay_url(url)
246
+ _set_cache(channel_id, url)
247
+ return url
248
+
249
+ # Source 2: FPTPlay CDN
250
+ url = fetch_fptplay_stream(channel_id)
251
+ if url:
252
+ _set_cache(channel_id, url)
253
+ return url
254
+
255
+ # Source 3: VTVGo CDN
256
+ url = fetch_vtvgo_stream(channel_id)
257
+ if url:
258
+ _set_cache(channel_id, url)
259
+ return url
260
+
261
+ # Source 4: xemtv.net legacy (last resort)
262
+ url = fetch_xemtv_legacy_stream(channel_id)
263
+ if url:
264
+ url = normalize_fptplay_url(url)
265
+ _set_cache(channel_id, url)
266
+ return url
267
+
268
+ _set_cache(channel_id, None)
269
+ return None
270
 
271
  @router.get("/api/vtv/streams")
272
  def api_vtv_streams():
 
273
  result = {}
274
  for ch_id in CHANNEL_NAMES:
275
+ stream_url = fetch_vtv_stream(ch_id)
276
+ result[ch_id] = {"name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "status": "ok" if stream_url else "offline"}
 
 
 
 
 
 
 
 
 
 
277
  return JSONResponse(result)
278
 
279
  @router.get("/api/vtv/stream/{channel_id}")
280
  def api_vtv_stream(channel_id: str):
281
+ stream_url = fetch_vtv_stream(channel_id)
282
+ if stream_url:
283
+ return JSONResponse({"stream_url": stream_url, "status": "ok"})
284
+ return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
 
286
+ @router.get("/api/proxy/page")
287
+ def proxy_page(url: str = Query(...)):
288
+ try:
289
+ headers = {**UA}
290
+ if "xemtv.us" in url:
291
+ headers["Referer"] = "https://xemtv.us/"
292
+ elif "xemtv.net" in url:
293
+ headers["Referer"] = "https://hd.xemtv.net/"
294
+ r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
295
+ if r.status_code != 200:
296
+ return Response(status_code=502, content="upstream error")
297
+ return Response(content=r.text.encode("utf-8"), media_type="text/html; charset=utf-8", headers={"Access-Control-Allow-Origin": "*"})
298
+ except:
299
+ return Response(status_code=502, content="proxy error")
300
 
301
+ @router.get("/api/proxy/m3u8/vtv")
302
+ def proxy_vtv_m3u8(url: str = Query(...)):
303
+ try:
304
+ headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
305
+ if "fptplay" in url:
306
+ headers["Referer"] = "https://fptplay.vn/"
307
+ headers["Origin"] = "https://fptplay.vn"
308
+ elif "xemtv" in url:
309
+ headers["Referer"] = "https://xemtv.us/"
310
+ elif "vtvgo" in url or "vtvdigital" in url:
311
+ headers["Referer"] = "https://vtvgo.vn/"
312
+ r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
313
+ if r.status_code != 200:
314
+ return Response(status_code=502, content="upstream error")
315
+ content = r.text
316
+ lines = content.split('\n')
317
+ rewritten = []
318
+ base_url = url.rsplit('/', 1)[0] + '/'
319
+ for line in lines:
320
+ line = line.strip()
321
+ if not line or line.startswith('#'):
322
+ rewritten.append(line)
323
+ else:
324
+ seg_url = line
325
+ if not seg_url.startswith('http'):
326
+ seg_url = base_url + seg_url
327
+ if seg_url.endswith('.m3u8'):
328
+ rewritten.append("/api/proxy/m3u8/vtv?url=" + requests.utils.quote(seg_url, safe=""))
329
+ else:
330
+ rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
331
+ return Response(content='\n'.join(rewritten).encode("utf-8"), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache"})
332
+ except Exception as e:
333
+ return Response(status_code=502, content="proxy error: " + str(e))
334
 
335
+ @router.get("/api/proxy/seg/vtv")
336
+ def proxy_vtv_segment(url: str = Query(...)):
337
+ try:
338
+ headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
339
+ if "fptplay" in url:
340
+ headers["Referer"] = "https://fptplay.vn/"
341
+ headers["Origin"] = "https://fptplay.vn"
342
+ r = requests.get(url, headers=headers, timeout=30, allow_redirects=True, verify=False)
343
+ if r.status_code != 200:
344
+ return Response(status_code=502, content="upstream error")
345
+ data = r.content
346
+ if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
347
+ data = data[188:]
348
+ return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600"})
349
+ except:
350
+ return Response(status_code=502, content="proxy error")
351
+
352
+ _epg_cache = {}
353
+ _epg_cache_time = 0
354
+ _EPG_CACHE_TTL = 600 # Giảm từ 30 phút xuống 10 phút, đảm bảo dữ liệu mới
355
+
356
+ VTV_CHANNEL_MAP = {
357
+ "vtv1": "vtv1", "vtv2": "vtv2", "vtv3": "vtv3", "vtv4": "vtv4",
358
+ "vtv5": "vtv5", "vtv5-tay-nam-bo": "vtv5", "vtv5-tay-nguyen": "vtv5",
359
+ "vtv7": "vtv7", "vtv8": "vtv8", "vtv6": "vtv6", "vtv9": "vtv9",
360
+ "vtv-can-tho": "vtv10",
361
+ }
362
 
363
  def _fetch_epg_from_vtv():
364
  global _epg_cache, _epg_cache_time
365
  now_ts = time.time()
366
+ if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL:
367
+ return _epg_cache
368
  epg_data = {}
369
+ # Lấy thời gian VN hiện tại để truyền vào parse_time
370
  now_vn = datetime.now(VN_TZ)
371
  try:
372
+ headers = {
373
+ "User-Agent": UA["User-Agent"], "Accept-Language": "vi-VN,vi;q=0.9",
374
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
375
+ "Referer": "https://vtv.vn/",
376
+ }
377
+ r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=headers, timeout=20)
378
+ if r.status_code != 200:
379
+ return epg_data
380
  r.encoding = "utf-8"
381
  soup = BeautifulSoup(r.text, "lxml")
382
  channel_order = []
383
+ list_channel = soup.find(class_=re.compile(r'list-channel'))
384
+ if list_channel:
385
+ for link in list_channel.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
386
+ ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
387
+ if ch_id:
388
+ channel_order.append(ch_id.group(1))
389
+ if not channel_order:
390
+ for link in soup.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
391
+ ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
392
+ if ch_id and ch_id.group(1) not in channel_order:
393
+ channel_order.append(ch_id.group(1))
394
+ prog_containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b'))
395
+ for i, container in enumerate(prog_containers):
396
+ if i >= len(channel_order):
397
+ break
398
+ vtv_ch_id = channel_order[i]
399
+ our_ch_id = VTV_CHANNEL_MAP.get(vtv_ch_id, vtv_ch_id)
400
+ if our_ch_id not in epg_data:
401
+ epg_data[our_ch_id] = []
402
  for li in container.find_all('li', class_=re.compile(r'\bprogram\b')):
403
+ time_span = li.find('span', class_=re.compile(r'\btime\b'))
404
+ title_span = li.find('span', class_=re.compile(r'\btitle\b'))
405
+ genre_a = li.find('a', class_=re.compile(r'\bgenre\b'))
406
+ time_str = time_span.get_text(strip=True) if time_span else ""
407
+ title = ""
408
+ if genre_a:
409
+ title = genre_a.get_text(strip=True)
410
+ if not title and title_span:
411
+ title = title_span.get_text(strip=True)
412
+ if not time_str or not title:
413
+ continue
414
+ # Truyền reference_date để xử lý quy tắc ngày truyền hình VTV
415
  start_dt = _parse_time(time_str, reference_date=now_vn)
416
+ if not start_dt:
417
+ continue
418
+ epg_data[our_ch_id].append({"time": time_str[:5], "title": title[:80], "start_dt": start_dt})
419
+ for ch_id in epg_data:
420
+ epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min)
421
+ seen = set()
422
+ unique = []
423
+ for p in epg_data[ch_id]:
424
+ key = (p["time"], p["title"])
425
+ if key not in seen:
426
+ seen.add(key)
427
+ unique.append(p)
428
+ epg_data[ch_id] = unique
429
+ except Exception as e:
430
+ print(f"EPG vtv.vn error: {e}")
431
+ _epg_cache = epg_data
432
+ _epg_cache_time = now_ts
433
  return epg_data
434
 
435
+ def _parse_time(time_str, reference_date=None):
436
+ """
437
+ Parse giờ từ lịch phát sóng VTV (đã là giờ VN UTC+7) sang datetime có timezone.
438
+
439
+ VTV hiển thị lịch theo ngày dương lịch (không phải ngày truyền hình).
440
+ Ví dụ: Lịch ngày 17/06 sẽ hiển thị tất cả chương trình từ 00:00 đến 23:59 ngày 17/06.
441
+
442
+ Logic:
443
+ - Giờ 00:00-04:59: Có thể là đêm khuya của ngày hôm trước HOẶC sáng sớm của ngày mới
444
+ - Giờ 05:00-23:59: Luôn thuộc ngày hiện tại
445
+ """
446
+ if not time_str:
447
+ return None
448
+ time_str = time_str.strip().replace("h", ":").replace("H", ":")
449
+ m = re.search(r'(\d{1,2}):(\d{2})', time_str)
450
+ if m:
451
+ try:
452
+ hour, minute = int(m.group(1)), int(m.group(2))
453
+
454
+ if reference_date:
455
+ base_date = reference_date
456
+ else:
457
+ # Lấy ngày hiện tại theo giờ VN (UTC+7)
458
+ now_vn = datetime.now(VN_TZ)
459
+ base_date = now_vn
460
+
461
+ from datetime import timedelta
462
+
463
+ # Xác định ngày cho giờ program
464
+ if hour < 5:
465
+ # Giờ 00:00-04:59: Cần xem giờ hiện tại để quyết định
466
+ if base_date.hour < 5:
467
+ # Nếu hiện tại cũng < 5:00 (đang trong khoảng sáng sớm)
468
+ # → Giờ program thuộc CÙNG NGÀY với giờ hiện tại
469
+ tv_date = base_date
470
+ else:
471
+ # Nếu hiện tại >= 5:00 (đã qua sáng sớm)
472
+ # → Giờ 00:00-04:59 thuộc NGÀY HÔM TRƯỚC
473
+ tv_date = base_date - timedelta(days=1)
474
+ else:
475
+ # Giờ 05:00-23:59: Luôn thuộc ngày hiện tại
476
+ tv_date = base_date
477
+
478
+ # Tạo datetime với giờ từ lịch
479
+ result = tv_date.replace(hour=hour, minute=minute, second=0, microsecond=0)
480
+
481
+ # Đảm bảo result có timezone
482
+ if result.tzinfo is None:
483
+ result = result.replace(tzinfo=VN_TZ)
484
+ return result
485
+ except:
486
+ pass
487
+ return None
488
+
489
+ def _get_epg_for_channel(channel_id):
490
  epg_data = _fetch_epg_from_vtv()
491
  programmes = epg_data.get(channel_id, [])
492
+ if not programmes:
493
+ return []
494
  now = datetime.now(VN_TZ)
495
  today = now.date()
496
  result = []
497
+
498
+ # Lọc chỉ lấy programs của ngày hôm nay (theo lịch VTV)
499
+ today_programmes = []
500
+ for p in programmes:
501
  start_dt = p.get("start_dt")
502
+ if start_dt and start_dt.date() == today:
503
+ today_programmes.append(p)
504
+
505
+ # Nếu không có programs cho hôm nay, dùng tất cả (fallback)
506
+ if not today_programmes:
507
+ today_programmes = programmes
508
+
509
+ for i, p in enumerate(today_programmes):
510
+ start_dt = p.get("start_dt")
511
+ stop_dt = None
512
+ if i + 1 < len(today_programmes):
513
+ stop_dt = today_programmes[i + 1].get("start_dt")
514
+ is_now = False
515
+ if start_dt:
516
+ if stop_dt:
517
+ is_now = start_dt <= now < stop_dt
518
+ else:
519
+ is_now = start_dt <= now
520
+ end_time = ""
521
+ if stop_dt:
522
+ end_time = stop_dt.strftime("%H:%M")
523
+ result.append({"time": p["time"], "title": p["title"], "end_time": end_time, "now": is_now})
524
+ return result
525
+
526
+ @router.get("/api/vtv/epg/{channel_id}")
527
+ def api_vtv_epg(channel_id: str):
528
+ channel_id = channel_id.lower().strip()
529
+ if channel_id not in CHANNEL_NAMES:
530
+ return JSONResponse({"error": "channel not found"}, status_code=404)
531
+ programs = _get_epg_for_channel(channel_id)
532
+ return JSONResponse({
533
+ "channel": channel_id, "channel_name": CHANNEL_NAMES.get(channel_id, channel_id),
534
+ "date": datetime.now(VN_TZ).strftime("%Y-%m-%d"), "programs": programs,
535
+ })
536
 
537
  @router.get("/api/vtv/epg")
538
  def api_vtv_epg_refresh():
539
  global _epg_cache, _epg_cache_time
540
+ _epg_cache = {}
541
+ _epg_cache_time = 0
542
  epg_data = _fetch_epg_from_vtv()
543
+ return JSONResponse({
544
+ "status": "refreshed", "channels": len(epg_data),
545
+ "total_programmes": sum(len(v) for v in epg_data),
546
+ })
vtv_epg_data.json DELETED
@@ -1 +0,0 @@
1
- {"vtv1":[{"time":"00:00","title":"PHIM TRUYỆN: MẸ BIỂN - TẬP 24"},{"time":"00:00","title":"MẸ BIỂN - TẬP 26"},{"time":"00:00","title":"PHIM TRUYỆN: MẸ BIỂN - TẬP 25"},{"time":"00:30","title":"ÁNH SÁNG TRI THỨC: KHƠI NGUỒN SÁNG TẠO"},{"time":"00:30","title":"HỌC VÀ LÀM THEO BÁC: CÓ CHÍ THÌ NÊN"},{"time":"00:30","title":"CHỐNG GIAN LẬN-BẢO VỆ NGƯỜI DÙNG"},{"time":"00:45","title":"VĂN HỌC NGHỆ THUẬT: NSND DƯƠNG MINH ĐỨC"},{"time":"00:45","title":"SỰ LỰA CHỌN"},{"time":"00:45","title":"SỰ KIỆN VÀ BÌNH LUẬN"},{"time":"01:10","title":"PHIM TÀI LIỆU: KHI VỸ HÁT"},{"time":"01:10","title":"TƯƠNG LAI XANH: CHẤN CHỈNH KHAI THÁC KHOÁNG SẢN"},{"time":"01:10","title":"GIAI ĐIỆU KẾT NỐI"},{"time":"01:40","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN ĐÀO XÁ"},{"time":"01:40","title":"KHÁM PHÁ VIỆT NAM: NGƯỜI BAHNAR BÊN DÒNG ĐẮK BLA"},{"time":"02:00","title":"THỂ THAO: KẾT NỐI THỂ THAO"},{"time":"02:00","title":"THỂ THAO: GIỜ VÀNG THỂ THAO"},{"time":"02:00","title":"THỂ THAO"},{"time":"02:30","title":"TỪ NHỮNG MIỀN QUÊ: BÌNH YÊN BẢO LỘC"},{"time":"02:30","title":"TỪ NHỮNG MIỀN QUÊ: SẮC MÀU TRÊN ĐẤT MƯỜNG VÀ"},{"time":"02:30","title":"TỪ NHỮNG MIỀN QUÊ - VÙNG ĐẤT CẨM NAM"},{"time":"02:45","title":"VTV SỐNG KHỎE: KHI TRÁI TIM LOẠN NHỊP"},{"time":"02:45","title":"VTV SỐNG KHỎE: VIÊM TAI GIỮA, NHỮNG NGUY CƠ NGÀY HÈ"},{"time":"02:45","title":"VTV SỐNG KHỎE"},{"time":"03:30","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 39"},{"time":"03:30","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 41"},{"time":"03:30","title":"GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 43"},{"time":"04:15","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 40"},{"time":"04:15","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 42"},{"time":"04:15","title":"GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 44"},{"time":"05:05","title":"S - VIỆT NAM: BÀ NÀ TINH HOA ẨM THỰC CHÂU ÂU"},{"time":"05:05","title":"S - VIỆT NAM: GIA LAI - MÙA LỄ HỘI CẦU NGƯ"},{"time":"05:05","title":"S - VIỆT NAM"},{"time":"05:10","title":"KINH TẾ BẠC: NGÔI NHÀ THỨ 2"},{"time":"05:10","title":"VÌ CỘNG ĐỒNG: DỆT HOA SẮC MÀU TRUYỀN THỐNG"},{"time":"05:10","title":"HÀNH TRÌNH HY VỌNG"},{"time":"05:25","title":"HẢI QUAN VIỆT NAM"},{"time":"05:30","title":"CHÀO BUỔI SÁNG"},{"time":"07:00","title":"TIÊU ĐIỂM CHÍNH SÁCH"},{"time":"07:00","title":"BÁO CHÍ TOÀN CẢNH"},{"time":"07:00","title":"TÀI CHÍNH - KINH DOANH"},{"time":"07:15","title":"KHÔNG GIAN VĂN HÓA NGHỆ THUẬT"},{"time":"07:25","title":"VIỆT NAM ĐA SẮC"},{"time":"07:30","title":"PHÓNG SỰ: CHUYỆN LÀNG TRONG VẬN NƯỚC"},{"time":"07:30","title":"NẺO VỀ NGUỒN CỘI - ÂM VANG TRỐNG ĐỒNG"},{"time":"07:45","title":"DÁM SỐNG: RỰC RỠ GIỮA BÓNG TỐI"},{"time":"07:45","title":"KHÁM PHÁ VIỆT NAM - NGHỀ XƯA GIỮA NHỊP SỐNG MỚI"},{"time":"08:00","title":"VTV KẾT NỐI"},{"time":"08:00","title":"SỐNG MỚI"},{"time":"08:00","title":"HÀNH TRÌNH HY VỌNG"},{"time":"08:15","title":"SÁCH HAY THAY ĐỔI CUỘC ĐỜI"},{"time":"08:15","title":"SỐNG XANH"},{"time":"08:30","title":"TẠP CHÍ KINH TẾ CUỐI TUẦN"},{"time":"08:40","title":"ĐI CÙNG CHÚNG TÔI"},{"time":"08:45","title":"CHỐNG GIAN LẬN-BẢO VỆ NGƯỜI DÙNG"},{"time":"09:00","title":"THỜI SỰ"},{"time":"09:05","title":"DU LỊCH VIỆT NAM"},{"time":"09:15","title":"SỰ KIỆN VÀ BÌNH LUẬN"},{"time":"09:15","title":"TOÀN CẢNH THẾ GIỚI"},{"time":"09:15","title":"TẠP CHÍ KINH TẾ CUỐI TUẦN"},{"time":"09:45","title":"DÁM SỐNG: KIẾN TRÚC SƯ MAI HƯNG TRUNG"},{"time":"09:45","title":"VIETNAM 360: QUY HOẠCH HÀ NỘI TẦM NHÌN 100 NĂM"},{"time":"09:45","title":"VTV KẾT NỐI"},{"time":"10:00","title":"ĐIỂM TỰA CUỘC SỐNG: BẢO VỆ TRẺ EM TRÊN KHÔNG GIAN MẠNG"},{"time":"10:00","title":"THỂ THAO"},{"time":"10:15","title":"VTV KẾT NỐI"},{"time":"10:25","title":"TOÀN CẢNH THẾ GIỚI"},{"time":"10:30","title":"TƯƠNG LAI XANH: CHẤN CHỈNH KHAI THÁC KHOÁNG SẢN"},{"time":"10:30","title":"CẶP LÁ YÊU THƯƠNG"},{"time":"11:00","title":"SỐNG AN TOÀN"},{"time":"11:00","title":"SỐNG XANH"},{"time":"11:00","title":"TÀI CHÍNH - KINH DOANH"},{"time":"11:30","title":"CUỘC SỐNG SỐ"},{"time":"11:45","title":"GÓC NHÌN VĂN HÓA"},{"time":"12:00","title":"THỜI SỰ"},{"time":"12:40","title":"SỰ LỰA CHỌN"},{"time":"12:40","title":"NÔNG NGHIỆP XANH: CANH TÁC SỐ"},{"time":"12:45","title":"NHỊP SỐNG TUỔI BẠC"},{"time":"12:55","title":"CẶP LÁ YÊU THƯƠNG"},{"time":"13:00","title":"VTV SỐNG KHỎE - DINH DƯỠNG CHO NGƯỜI VIỆT: ĐẢM BẢO DINH DƯỠNG CHO TRẺ TRONG THỜI BÃO GIÁ"},{"time":"13:00","title":"VTV SỐNG KHỎE: ĐIỀU TRỊ HIỆU QUẢ UNG THƯ VÚ"},{"time":"13:00","title":"VTV SỐNG KHỎE"},{"time":"13:45","title":"PHỤ NỮ VÀ CUỘC SỐNG: NỮ ĐẠI SỨ DU LỊCH LÀNG NGHỀ"},{"time":"13:45","title":"80 NĂM QUỐC HỘI VIỆT NAM"},{"time":"13:45","title":"TỪ NHỮNG MIỀN QUÊ - VÙNG ĐẤT CẨM NAM"},{"time":"14:00","title":"GÓC NHÌN VĂN HÓA"},{"time":"14:15","title":"HỌC VÀ LÀM THEO BÁC: CÓ CHÍ THÌ NÊN"},{"time":"14:15","title":"HÀNH TRÌNH DI SẢN: SỨC SỐNG BÊN DÒNG SUỐI MƯỜNG HOA"},{"time":"14:15","title":"SỐNG MỚI"},{"time":"14:30","title":"VĂN HỌC NGHỆ THUẬT: NHỮNG CÂY CẦU CỦA VĂN CHƯƠNG"},{"time":"14:55","title":"ÁNH SÁNG TRI THỨC - AI TRONG GIÁO DỤC"},{"time":"15:00","title":"DOANH NGHIỆP - DOANH NHÂN: CHUYỂN DỊCH"},{"time":"15:00","title":"GIAI ĐIỆU KẾT NỐI"},{"time":"15:10","title":"DOANH NGHIỆP - DOANH NHÂN - CHUYỂN DỊCH"},{"time":"15:25","title":"VTV KẾT NỐI"},{"time":"15:30","title":"TRÁI TIM CHO EM: ƯỚC MƠ TỪ NHỊP TIM NHỎ"},{"time":"15:30","title":"CẶP LÁ YÊU THƯƠNG"},{"time":"15:40","title":"THƯƠNG HIỆU QUỐC GIA VIỆT NAM: THƯƠNG HIỆU QUỐC GIA VÀ SỞ HỮU TRÍ TUỆ"},{"time":"15:45","title":"KHUYẾN HỌC - HÀNH TRÌNH TRI THỨC: DÒNG HỌ KHOA BẢNG NGUYỄN VŨ"},{"time":"15:55","title":"VỀ QUÊ: LIÊN KẾT BỀN VỮNG NƠI VÙNG CAO"},{"time":"15:55","title":"CÙNG EM ĐẾN TRƯỜNG: NHỮNG MÓN QUÀ TIẾP SỨC"},{"time":"16:00","title":"THỜI SỰ"},{"time":"16:15","title":"VĂN HOÁ CAND: BẢN LĨNH VÀ KHÁT VỌNG"},{"time":"16:15","title":"VĂN HOÁ QĐND"},{"time":"16:15","title":"NHÂN ĐẠO - ĐIỂM TỰA AN SINH Xà HỘI"},{"time":"16:30","title":"SỰ KIỆN VÀ BÌNH LUẬN"},{"time":"16:45","title":"VÌ CỘNG ĐỒNG: DỆT HOA SẮC MÀU TRUYỀN THỐNG"},{"time":"16:45","title":"KINH TẾ BẠC: NHỮNG NGƯỜI TIÊN PHONG"},{"time":"17:00","title":"NHẬT KÝ NGƯỜI VIỆT: LÚA LAI HAI DÒNG Ở VIỆT NAM"},{"time":"17:00","title":"NHẬT KÝ NGƯỜI VIỆT: GIỮ HỒN NHẠC CỤ ĐÀO XÁ"},{"time":"17:00","title":"VIỆT NAM ĐA SẮC"},{"time":"17:05","title":"KHÁM PHÁ VIỆT NAM: NGƯỜI BAHNAR BÊN DÒNG ĐẮK BLA"},{"time":"17:05","title":"KHÁM PHÁ VIỆT NAM: THẠNH HÓA -BẢN HÒA CA TỪ BIỂN"},{"time":"17:10","title":"CẢI CÁCH HÀNH CHÍNH"},{"time":"17:20","title":"HÀNH TRÌNH VẺ ĐẸP: ĐỜN CA TÀI TỬ MIỀN TÂY XỨ DỪA"},{"time":"17:20","title":"HÀNH TRÌNH VẺ ĐẸP"},{"time":"17:30","title":"CHUYỂN ĐỘNG 24H"},{"time":"18:00","title":"VIỆT NAM HÔM NAY"},{"time":"18:25","title":"VÌ TẦM VÓC VIỆT"},{"time":"19:00","title":"THỜI SỰ"},{"time":"19:40","title":"THỜI TIẾT + THỂ THAO 24/7"},{"time":"19:40","title":"THỂ THAO 24/7"},{"time":"19:55","title":"ĐIỂM TIN"},{"time":"20:00","title":"S - VIỆT NAM: GIA LAI - MÙA LỄ HỘI CẦU NGƯ"},{"time":"20:00","title":"S - VIỆT NAM: DẤU ẤN ĐÔNG DƯƠNG GIỮA LÒNG HẢI PHÒNG"},{"time":"20:00","title":"VIỆT NAM - ĐIỂM HẸN"},{"time":"20:05","title":"VIỆT NAM VUI KHỎE"},{"time":"20:10","title":"TRUYỀN HÌNH TRỰC TIẾP: KHAI MẠC LỄ HỘI VÌ HÒA BÌNH NĂM 2026"},{"time":"20:10","title":"TRUYỀN HÌNH QUÂN ĐỘI NHÂN DÂN"},{"time":"20:10","title":"PHÓNG SỰ"},{"time":"20:30","title":"QUỐC DÂN HIỂU THUẾ"},{"time":"20:30","title":"PHIM TÀI LIỆU"},{"time":"20:45","title":"TIÊU ĐIỂM"},{"time":"20:55","title":"THUẾ VÀ ĐỜI SỐNG"},{"time":"21:00","title":"PHIM TÀI LIỆU: HÀNH TRÌNH CỦA NHỮNG GIÁ TRỊ SỐNG"},{"time":"21:00","title":"PHÍA BÊN KIA THÀNH PHỐ - TẬP 27"},{"time":"21:30","title":"ÁNH SÁNG TRI THỨC: AI TRONG GIÁO DỤC"},{"time":"21:30","title":"TÀI CHÍNH - KINH DOANH"},{"time":"21:45","title":"CÂU CHUYỆN QUỐC TẾ: RANH GIỚI MÀN HÌNH"},{"time":"21:45","title":"DÁM SỐNG: KIẾN TRÚC SƯ MAI HƯNG TRUNG"},{"time":"21:55","title":"VIỆC TỬ TẾ"},{"time":"22:00","title":"CHUYỂN ĐỘNG CUỐI NGÀY"},{"time":"22:30","title":"NHẬT KÝ FIFA WORLD CUP 2026"},{"time":"22:45","title":"TÁC PHẨM MỚI: NGÀY ĐẤT NƯỚC CHUYỂN MÌNH"},{"time":"22:45","title":"HÒA NHẠC THÍNH PHÒNG: HÒA NHẠC VIỄN PHƯƠNG - PHẦN 2"},{"time":"22:45","title":"VTV KẾT NỐI"},{"time":"23:00","title":"GIỜ VÀNG THỂ THAO"},{"time":"23:00","title":"SỐNG MỚI"},{"time":"23:15","title":"TỔ QUỐC TRONG TIM"},{"time":"23:15","title":"GIỜ VÀNG THỂ THAO"},{"time":"23:45","title":"KHÁM PHÁ VIỆT NAM: NGƯỜI BAHNAR BÊN DÒNG ĐẮK BLA"},{"time":"23:45","title":"KHÁM PHÁ VIỆT NAM: THẠNH HÓA -BẢN HÒA CA TỪ BIỂN"}],"vtv2":[{"time":"00:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 39"},{"time":"00:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 40"},{"time":"00:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 41"},{"time":"00:45","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"00:45","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"00:45","title":"KHÁM PHÁ VIỆT NAM: HƯƠNG VỊ GIỮ HỒN QUÊ"},{"time":"01:00","title":"BẠN CỦA NHÀ NÔNG"},{"time":"01:45","title":"DÁM SỐNG: HOÀNG VẼ CUỘC ĐỜI MÌNH"},{"time":"01:45","title":"DÁM SỐNG: KÌNH NGƯ"},{"time":"01:45","title":"DÁM SỐNG: CHÀNG ĐAM - SAN THỜI HIỆN ĐẠI"},{"time":"02:00","title":"KHÁM PHÁ THẾ GIỚI: SỰ SỐNG TRONG ĐẠI DƯƠNG - TẬP 5"},{"time":"02:00","title":"KHÁM PHÁ THẾ GIỚI: SỰ SỐNG TRONG ĐẠI DƯƠNG - TẬP 6"},{"time":"02:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"02:30","title":"HIỂU SÂU - SỐNG CHẤT: KHI MÂU THUẪN VƯỢT NGƯỠNG"},{"time":"02:30","title":"HIỂU SÂU - SỐNG CHẤT: ÔNG BÀ THỜI CHUYỂN ĐỔI SỐ"},{"time":"03:00","title":"S - TECH"},{"time":"03:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"04:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 40"},{"time":"04:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 41"},{"time":"04:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 42"},{"time":"04:45","title":"DÁM SỐNG: KÌNH NGƯ"},{"time":"04:45","title":"DÁM SỐNG: CHÀNG ĐAM - SAN THỜI HIỆN ĐẠI"},{"time":"04:45","title":"DÁM SỐNG: ĐỂ MẠCH NGUỒN TUỒNG CHẢY MÃI"},{"time":"05:00","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: TRƯỜNG CHUYÊN TIỆM CẬN CHUẨN QUỐC TẾ"},{"time":"05:00","title":"BÍ ẨN TỰ NHIÊN: BÍ ẨN NÚI LỬA ĐĂK NÔNG"},{"time":"05:00","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"05:30","title":"BẠN CỦA NHÀ NÔNG"},{"time":"06:15","title":"VTV SỐNG KHỎE: SỐNG CHUNG VỚI TRÁI TIM SUY"},{"time":"06:15","title":"VTV SỐNG KHỎE: YOGA VỚI SỨC KHỎE NGƯỜI CAO TUỔI"},{"time":"06:15","title":"VTV SỐNG KHỎE: RỐI LOẠN TIC - HIỂU ĐỂ CHỮA LÀNH"},{"time":"06:45","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"06:45","title":"VTV KẾT NỐI"},{"time":"06:45","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"07:00","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"07:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 1"},{"time":"07:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 2"},{"time":"07:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 3"},{"time":"08:00","title":"VTV SỐNG KHỎE: VIÊM TAI GIỮA, NHỮNG NGUY CƠ NGÀY HÈ"},{"time":"08:00","title":"TƯ VẤN TUYỂN SINH"},{"time":"08:00","title":"VTV SỐNG KHỎE: UNG THƯ TRỰC TRÀNG"},{"time":"08:45","title":"DÁM SỐNG: CHÀNG ĐAM - SAN THỜI HIỆN ĐẠI"},{"time":"08:45","title":"DÁM SỐNG: HÀNH TRÌNH TỎA SÁNG THẾ GIỚI"},{"time":"09:00","title":"BÍ ẨN TỰ NHIÊN: BÍ ẨN NÚI LỬA ĐĂK NÔNG"},{"time":"09:00","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"09:25","title":"TRÁI TIM CHO EM: ƯỚC MƠ TỪ NHỊP TIM NHỎ"},{"time":"09:30","title":"S - TECH"},{"time":"10:00","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"10:00","title":"KHỞI NGHIỆP KIẾN QUỐC: CHỮ TÍN - NỀN MÓNG CỦA CÔNG TRÌNH"},{"time":"10:00","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"10:15","title":"VTV SỐNG KHỎE: RỐI LOẠN TIC - HIỂU ĐỂ CHỮA LÀNH"},{"time":"10:15","title":"VTV SỐNG KHỎE: SỐNG CHUNG VỚI TRÁI TIM SUY"},{"time":"10:15","title":"VTV SỐNG KHỎE: YOGA VỚI SỨC KHỎE NGƯỜI CAO TUỔI"},{"time":"10:45","title":"KHÁT VỌNG XANH: THÀNH PHỐ TÍCH NHIỆT"},{"time":"10:45","title":"KHÁT VỌNG XANH: HỆ LỤY TỪ MỘT THÚ CHƠI"},{"time":"10:45","title":"KHÁM PHÁ THẾ GIỚI: KHÁM PHÁ THÁI BÌNH DƯƠNG - TẬP 2"},{"time":"11:00","title":"PHIM HOẠT HÌNH: POKÉMON CHÂN TRỜI MỚI - TẬP 11"},{"time":"11:00","title":"PHIM HOẠT HÌNH: POKÉMON CHÂN TRỜI MỚI - TẬP 12"},{"time":"11:30","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 41"},{"time":"11:30","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 42"},{"time":"11:30","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 43"},{"time":"12:15","title":"TỪ NHỮNG MIỀN QUÊ: NGƯỜI THÁI Ở SƠN LA"},{"time":"12:15","title":"TỪ NHỮNG MIỀN QUÊ: NGHỀ NGÓI ÂM DƯƠNG BẮC SƠN"},{"time":"12:15","title":"TỪ NHỮNG MIỀN QUÊ: SẮC MÀU TRÊN ĐẤT MƯỜNG VÀ"},{"time":"12:30","title":"HIỂU SÂU - SỐNG CHẤT: VỈA HÈ CHUNG - LỢI ÍCH RIÊNG"},{"time":"12:30","title":"HIỂU SÂU - SỐNG CHẤT: HỘI NHÓM VÀ BÓC PHỐT TRÊN MẠNG"},{"time":"12:30","title":"HIỂU SÂU - SỐNG CHẤT: KHI MÂU THUẪN VƯỢT NGƯỠNG"},{"time":"13:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"13:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 2"},{"time":"13:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 3"},{"time":"13:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 2"},{"time":"13:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 3"},{"time":"13:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 4"},{"time":"14:00","title":"SẮC MÀU CÁC DÂN TỘC: CHUYỆN TỪ BẢN VẶT"},{"time":"14:00","title":"PHỤ NỮ LÀ ĐỂ YÊU THƯƠNG: BẠN ĐƯỢC QUYỀN HẠNH PHÚC"},{"time":"14:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"15:00","title":"TƯ VẤN TUYỂN SINH"},{"time":"15:00","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 4"},{"time":"15:00","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: BỆNH DA HIẾM GẶP - TẬP 1"},{"time":"15:30","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: CHIẾN THUẬT CHỌN NGUYỆN VỌNG"},{"time":"15:30","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"16:00","title":"CẢNH GIÁC 247: NGUY CƠ RỬA TIỀN TỪ VÍ ĐIỆN TỬ"},{"time":"16:15","title":"VTV KẾT NỐI"},{"time":"16:20","title":"CHECK IN VIỆT NAM: CÓ HẸN VỚI XỨ MƯỜNG"},{"time":"16:30","title":"KHÁM PHÁ VIỆT NAM: HƯƠNG VỊ GIỮ HỒN QUÊ"},{"time":"16:30","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"16:30","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"16:43","title":"THÔNG BÁO - GHI ƠN"},{"time":"16:45","title":"BẠN CỦA NHÀ NÔNG"},{"time":"17:25","title":"NHỊP ĐẬP VIỆT NAM: LÀNG CHÀI XUÂN HẢI"},{"time":"17:25","title":"NHỊP ĐẬP VIỆT NAM: NÉT ĐẸP CỔ KÍNH CHÙA TRÔNG"},{"time":"17:25","title":"NHỊP ĐẬP VIỆT NAM: CUỘC SỐNG LÀNG CHÀI CỬA NHƯỢNG"},{"time":"17:30","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"17:30","title":"BÍ ẨN TỰ NHIÊN: BÍ ẨN NÚI LỬA ĐĂK NÔNG"},{"time":"17:30","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: CHIẾN THUẬT CHỌN NGUYỆN VỌNG"},{"time":"18:00","title":"KIẾN THỨC CỘNG ĐỒNG: LIVESTREAM VÀ QUYỀN RIÊNG TƯ CÁ NHÂN"},{"time":"18:00","title":"KIẾN THỨC CỘNG ĐỒNG: HIỂU LUẬT MỖI NGÀY"},{"time":"18:00","title":"KIẾN THỨC CỘNG ĐỒNG: MIỄN GIẤY PHÉP XÂY DỰNG ÁP DỤNG RA SAO?"},{"time":"18:15","title":"NHỮNG BÔNG HOA NHỎ"},{"time":"18:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"18:30","title":"CHUYỆN NHÀ THỜI NAY: CHUYỆN NHÀ THỜI NAY - TẬP 33"},{"time":"19:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"19:30","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"19:30","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 2"},{"time":"19:30","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 3"},{"time":"20:00","title":"LÁT CẮT FIFA WORLD CUP 2026"},{"time":"20:05","title":"HIỂU SÂU - SỐNG CHẤT: VỈA HÈ CHUNG - LỢI ÍCH RIÊNG"},{"time":"20:10","title":"CẢNH GIÁC 247: NGUY CƠ RỬA TIỀN TỪ VÍ ĐIỆN TỬ"},{"time":"20:10","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: CHIẾN THUẬT CHỌN NGUYỆN VỌNG"},{"time":"20:30","title":"SÁCH HAY THAY ĐỔI CUỘC ĐỜI"},{"time":"20:30","title":"S - TECH"},{"time":"20:35","title":"PHỤ NỮ LÀ ĐỂ YÊU THƯƠNG: BẠN ĐƯỢC QUYỀN HẠNH PHÚC"},{"time":"20:40","title":"BÍ ẨN TỰ NHIÊN: BÃO - CỖ MÁY CUỒNG PHONG - TẬP 1"},{"time":"21:00","title":"CHECK IN VIỆT NAM: CÓ HẸN VỚI XỨ MƯỜNG"},{"time":"21:00","title":"PHIM TÀI LIỆU NƯỚC NGOÀI: NHỮNG BÍ MẬT CHƯA KHÉP LẠI CỦA THỜI APARTHEID - TẬP 2"},{"time":"21:00","title":"KHÁT VỌNG XANH: GIA TĂNG LŨ CỰC ĐOAN"},{"time":"21:15","title":"VTV SỐNG KHỎE: RỐI LOẠN TIC - HIỂU ĐỂ CHỮA LÀNH"},{"time":"21:15","title":"VTV SỐNG KHỎE: SỐNG CHUNG VỚI TRÁI TIM SUY"},{"time":"21:45","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 4"},{"time":"21:45","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: BỆNH DA HIẾM GẶP - TẬP 1"},{"time":"21:45","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 5"},{"time":"22:15","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"22:15","title":"CHUYỆN NHÀ THỜI NAY: CHUYỆN NHÀ THỜI NAY - TẬP 33"},{"time":"22:45","title":"KHÁM PHÁ VIỆT NAM: HƯƠNG VỊ GIỮ HỒN QUÊ"},{"time":"22:45","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"22:45","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"23:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"23:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 2"},{"time":"23:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 3"},{"time":"23:30","title":"BÍ ẨN TỰ NHIÊN: BÃO - CỖ MÁY CUỒNG PHONG - TẬP 1"},{"time":"23:30","title":"S - TECH"}],"vtv3":[{"time":"00:00","title":"THANH ÂM TỪ ĐẤT VIỆT: ĐẤT HÁT"},{"time":"00:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: CANADA - MA RỐC"},{"time":"00:10","title":"PHIM TRUYỆN: TÌNH YÊU CỦA ĐỜI TÔI - TẬP 39"},{"time":"00:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"01:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 32 ĐỘI: AUSTRALIA - AI CẬP"},{"time":"01:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"01:50","title":"SAO CHECK: CA SĨ VŨ THẢO MY"},{"time":"02:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"03:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: BRAZIL - NA UY"},{"time":"03:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"04:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: PARAGUAY - PHÁP"},{"time":"04:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"05:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 32 ĐỘI: ARGENTINA - CAPE VERDE"},{"time":"06:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"07:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: MEXICO - ANH"},{"time":"07:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"07:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"07:55","title":"VIỆT NAM XANH: NGÀY HỘI ĐỔI RÁC LẤY QUÀ"},{"time":"08:00","title":"VTV KẾT NỐI"},{"time":"08:10","title":"THỂ THAO"},{"time":"08:30","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 32 ĐỘI: COLOMBIA - GHANA"},{"time":"08:40","title":"CẨM NANG VÀNG CHO SỨC KHỎE"},{"time":"09:00","title":"BỘ BA TRANH TÀI: HỒNG LUYẾN, THANH HUYỀN, ĐỨC THÀNH"},{"time":"10:00","title":"PHỤ NỮ SỐ"},{"time":"10:30","title":"NHẬT KÝ TRÊN KHÓA SOL: CA SĨ HOÀNG ANH"},{"time":"10:30","title":"HIỂU SÂU - SỐNG CHẤT: CHUYỆN ĐỌC SÁCH THỜI ĐẠI SỐ"},{"time":"10:55","title":"THANH ÂM TỪ ĐẤT VIỆT: NHỊP ĐIỆU ĐÔNG HỒ"},{"time":"11:00","title":"NHÀ MÌNH QUÁ ĐỈNH"},{"time":"11:10","title":"THÔNG TIN 260"},{"time":"11:15","title":"ĐIỀU NHỎ BÉ KỲ DIỆU"},{"time":"11:30","title":"GÌ THẾ NHỈ"},{"time":"11:45","title":"GIA ĐÌNH VUI VẺ"},{"time":"11:50","title":"QUÀ TẶNG CUỘC SỐNG: SỰ TÍCH CÁI DÂY LƯNG"},{"time":"12:00","title":"ÚM BA LA RA CHỮ GÌ?"},{"time":"12:00","title":"KHÁCH SẠN 5 SAO"},{"time":"12:00","title":"PHIM TRUYỆN: SỚM TỐI CÓ NHAU - TẬP 7"},{"time":"12:45","title":"ALO! ĐÂY LÀ..."},{"time":"12:50","title":"HẠNH PHÚC LÀ GÌ?: VÕ CAO ĐỈNH"},{"time":"12:50","title":"HẠNH PHÚC LÀ GÌ?: TỐNG THỊ LÂM"},{"time":"13:00","title":"VUA TIẾNG VIỆT"},{"time":"13:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"13:00","title":"PHIM TRUYỆN: CÂU CHUYỆN HOA HỒNG - TẬP 22"},{"time":"13:50","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"14:00","title":"PHIM TRUYỆN: DỊU DÀNG MÀU NẮNG - TẬP 11"},{"time":"14:00","title":"PHIM TRUYỆN: DỊU DÀNG MÀU NẮNG - TẬP 12"},{"time":"14:30","title":"ÚM BA LA RA CHỮ GÌ?"},{"time":"14:50","title":"CẢ NHÀ CÙNG VUI"},{"time":"15:00","title":"BẬT MÍ BÍ MẬT"},{"time":"15:20","title":"THỂ THAO: GIỜ VÀNG THỂ THAO"},{"time":"15:40","title":"ALO! ĐÂY LÀ..."},{"time":"15:50","title":"ALO! ĐÂY LÀ..."},{"time":"15:50","title":"GÌ THẾ NHỈ"},{"time":"16:00","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"16:10","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"16:10","title":"PHIM TRUYỆN: CUỘC ĐỜI VẪN ĐẸP SAO - TẬP 41"},{"time":"16:30","title":"SAO CHECK: DIỄN VIÊN NGỌC THANH TÂM"},{"time":"16:55","title":"THỜI TIẾT"},{"time":"16:55","title":"PHÚT GIÂY THƯ GIÃN: THẦN DƯỢC CHANH MUỐI"},{"time":"17:00","title":"S - VIỆT NAM (15)"},{"time":"17:00","title":"ĐIỀU NHỎ BÉ KỲ DIỆU"},{"time":"17:14","title":"THỜI TIẾT"},{"time":"17:15","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"17:15","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"17:20","title":"THÔNG TIN 260"},{"time":"17:29","title":"THỜI TIẾT"},{"time":"17:30","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"17:45","title":"VTV KẾT NỐI"},{"time":"18:00","title":"PHIM TRUYỆN: TRÁI TIM KHÔNG THỂ NGỪNG YÊU - TẬP 10"},{"time":"18:00","title":"PHIM TRUYỆN: TRÁI TIM KHÔNG THỂ NGỪNG YÊU - TẬP 11"},{"time":"18:00","title":"PHIM TRUYỆN: TRÁI TIM KHÔNG THỂ NGỪNG YÊU - TẬP 12"},{"time":"18:35","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"19:00","title":"THỜI SỰ"},{"time":"19:55","title":"SAO 24H"},{"time":"20:00","title":"ANH TRAI VƯỢT NGÀN CHÔNG GAI"},{"time":"20:00","title":"STUDIO 3"},{"time":"20:00","title":"PHIM TRUYỆN (20H00): DƯỚI Ô CỬA SÁNG ĐÈN - TẬP 22"},{"time":"20:50","title":"ĐIỀU NHỎ BÉ KỲ DIỆU"},{"time":"20:55","title":"FANZONE FIFA WORLD CUP 2026"},{"time":"21:00","title":"GAMEBOX - HỘP TRÒ CHƠI"},{"time":"21:00","title":"FANZONE FIFA WORLD CUP 2026"},{"time":"21:05","title":"ĐẦU BẾP THƯỢNG ĐỈNH"},{"time":"21:25","title":"FANZONE FIFA WORLD CUP 2026"},{"time":"21:30","title":"PHIM TRUYỆN: TIỂU TAM KHÔNG CÓ LỖI? - TẬP 18"},{"time":"21:55","title":"QUÀ TẶNG CUỘC SỐNG: CON VOI TRẮNG"},{"time":"22:00","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"22:35","title":"VTV KẾT NỐI"},{"time":"22:40","title":"PHIM TRUYỆN: NƠI TUYẾN LỬA - TẬP 9"},{"time":"22:50","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"23:00","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"23:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"23:30","title":"THỂ THAO"}],"vtv4":[{"time":"00:00","title":"BẢN TIN THỜI SỰ"},{"time":"00:20","title":"SỨC SỐNG THỂ THAO"},{"time":"00:25","title":"THỜI TIẾT DU LỊCH"},{"time":"00:30","title":"PHIM TRUYỆN: HOA HỒNG TRÊN NGỰC TRÁI - TẬP 46"},{"time":"00:30","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 1 - PHẦN 1"},{"time":"00:30","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 2 - PHẦN 1"},{"time":"01:15","title":"NGƯỜI VIỆT BỐN PHƯƠNG"},{"time":"01:15","title":"S - VIỆT NAM"},{"time":"01:15","title":"CHECK IN VIỆT NAM: HƠI THỞ CỦA BIỂN"},{"time":"01:30","title":"NHÀ HÁT TRUYỀN HÌNH: VỞ CẢI LƯƠNG: HÀO KIỆT VỚI GIANG SƠN"},{"time":"01:30","title":"STUDIO 3"},{"time":"01:30","title":"PHIM TRUYỆN: PHỐ TRONG LÀNG - TẬP 31"},{"time":"02:00","title":"GIAI ĐIỆU CUỘC SỐNG: NEO ĐẬU BẾN QUÊ"},{"time":"02:15","title":"MIỀN ĐẤT VÕ: VOVINAM - NGHỆ THUẬT CẬN CHIẾN"},{"time":"02:30","title":"THẾ GIỚI TUỔI THƠ: VẦNG TRĂNG CỦA EM"},{"time":"02:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"03:00","title":"TỌA ĐÀM: CHÍNH PHỦ KIẾN TẠO PHÁT TRIỂN - SỐ 1"},{"time":"03:00","title":"CUỘC SỐNG VẪN TƯƠI ĐẸP: NHỮNG KHUNG HÌNH HẠNH PHÚC"},{"time":"03:00","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"03:30","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"03:30","title":"VĂN HỌC NGHỆ THUẬT: NSND HÀ THỦY - GỌI TÊN NHỮNG THANH ÂM"},{"time":"03:30","title":"TIẾNG VIỆT DIỆU KÌ: CÂU ĐỐ VỀ CON VẬT"},{"time":"03:45","title":"GÓC ĐỒNG HÀNH: ĐIỂM TỰA Y TẾ TẠI HÀN QUỐC"},{"time":"03:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"04:00","title":"GIAI ĐIỆU CUỘC SỐNG: NHẠT NẮNG"},{"time":"04:00","title":"PHIM CUỐI TUẦN: TRẢ GIÁ"},{"time":"04:00","title":"GIAI ĐIỆU CUỘC SỐNG: CỎ VÀ MƯA"},{"time":"04:15","title":"MIỀN ĐẤT VÕ: VOVINAM - DĨ NHU CHẾ CƯƠNG"},{"time":"04:15","title":"PHÓNG SỰ: PHÁT TRIỂN DU LỊCH SỨC KHOẺ TỪ DI SẢN"},{"time":"04:30","title":"ÁNH SÁNG TRI THỨC: TINH HOA KIẾN TRÚC"},{"time":"04:30","title":"GIAI ĐIỆU CUỘC SỐNG: LỜI MẸ RU"},{"time":"04:45","title":"TÁC PHẨM MỚI: NẾP NHÀ MIỀN XANH"},{"time":"05:00","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 24"},{"time":"05:00","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 25"},{"time":"05:45","title":"THỜI TIẾT DU LỊCH"},{"time":"05:50","title":"TRÁI TIM CHO EM: ƯỚC MƠ TỪ NHỊP TIM NHỎ"},{"time":"06:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"06:00","title":"NHÀ MÌNH QUÁ ĐỈNH"},{"time":"06:45","title":"PHIM TRUYỆN: HOA HỒNG TRÊN NGỰC TRÁI - TẬP 46"},{"time":"06:45","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 1 - PHẦN 1"},{"time":"06:45","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 2 - PHẦN 1"},{"time":"07:30","title":"NGƯỜI VIỆT BỐN PHƯƠNG"},{"time":"07:30","title":"HƯỚNG VỀ TỔ QUỐC"},{"time":"07:30","title":"CUỘC SỐNG PHƯƠNG XA: LAN TỎA NGHỆ THUẬT MÚA VIỆT TẠI PHÁP"},{"time":"07:45","title":"GIAI ĐIỆU CUỘC SỐNG: NHẠT NẮNG"},{"time":"07:45","title":"TÁC PHẨM MỚI: NẾP NHÀ MIỀN XANH"},{"time":"07:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"08:00","title":"TỌA ĐÀM: CHÍNH PHỦ KIẾN TẠO PHÁT TRIỂN - SỐ 1"},{"time":"08:00","title":"SAO CHECK: CA SĨ VƯƠNG BÌNH"},{"time":"08:00","title":"TIẾNG VIỆT DIỆU KÌ: CÂU ĐỐ VỀ CON VẬT"},{"time":"08:15","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"08:30","title":"WHEN IN VIETNAM: NHỮNG NGƯỜI YÊU ĐỘNG VẬT"},{"time":"08:30","title":"VIETNAM DISCOVERY: KHÁM PHÁ DI SẢN THẾ GIỚI TẠI QUẢNG NINH"},{"time":"09:00","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"09:00","title":"VĂN HỌC NGHỆ THUẬT: NSND HÀ THỦY - GỌI TÊN NHỮNG THANH ÂM"},{"time":"09:00","title":"DU LỊCH VÀ ẨM THỰC: VĨNH LONG - NHỮNG CÂU CHUYỆN BÊN DÒNG PHÙ SA"},{"time":"09:30","title":"NHỊP SỐNG CỘNG ĐỒNG"},{"time":"09:30","title":"PHIM TÀI LIỆU: NHỮNG ĐỨA TRẺ HẠNH PHÚC - TẬP 3: KHI VỸ HÁT"},{"time":"09:30","title":"TỔ QUỐC TRONG TIM"},{"time":"09:35","title":"THẾ GIỚI TUỔI THƠ: ĐI ĐỂ LỚN LÊN"},{"time":"10:00","title":"NHÀ HÁT TRUYỀN HÌNH: VỞ CẢI LƯƠNG: HÀO KI���T VỚI GIANG SƠN"},{"time":"10:00","title":"PHIM CUỐI TUẦN: TRẢ GIÁ"},{"time":"10:00","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ SỨ XỞ"},{"time":"10:10","title":"S - VIỆT NAM"},{"time":"10:25","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"10:30","title":"GIAI ĐIỆU CUỘC SỐNG: LỜI MẸ RU"},{"time":"11:00","title":"ĐIỂM HẸN NGƯỜI VIỆT"},{"time":"11:15","title":"PHIM TRUYỆN: PHỐ TRONG LÀNG - TẬP 31"},{"time":"11:35","title":"ĐI ĐỂ BIẾT: MỘT NGÀY Ở ĐẢO NGỌC VỪNG"},{"time":"11:45","title":"DÁM SỐNG: HÀNH TRÌNH KỲ DIỆU"},{"time":"11:45","title":"GIAI ĐIỆU CUỘC SỐNG: MỖI KHI ANH NHÌN EM"},{"time":"12:00","title":"BẢN TIN THỜI SỰ"},{"time":"12:25","title":"SỨC SỐNG THỂ THAO"},{"time":"12:30","title":"NHÀ MÌNH QUÁ ĐỈNH"},{"time":"12:30","title":"STUDIO 3"},{"time":"12:30","title":"SẮC MÀU CÁC DÂN TỘC: CHUYỆN KỂ TỪ THANH ÂM"},{"time":"13:00","title":"PHÓNG SỰ: PHÁT TRIỂN DU LỊCH SỨC KHOẺ TỪ DI SẢN"},{"time":"13:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 24"},{"time":"13:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 25"},{"time":"14:00","title":"GÓC ĐỒNG HÀNH: ĐIỂM TỰA Y TẾ TẠI HÀN QUỐC"},{"time":"14:00","title":"HƯỚNG VỀ TỔ QUỐC"},{"time":"14:00","title":"ĐIỂM HẸN NGƯỜI VIỆT"},{"time":"14:15","title":"PHIM CA NHẠC: HÀ NỘI NGÀY TRỞ VỀ"},{"time":"14:15","title":"VUI - KHỎE - CÓ ÍCH"},{"time":"14:30","title":"NET ZERO - GỬI TƯƠNG LAI"},{"time":"15:00","title":"BẢN TIN THỜI SỰ"},{"time":"15:25","title":"NHỊP SỐNG CỘNG ĐỒNG"},{"time":"15:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 2"},{"time":"15:30","title":"SẮC MÀU CÁC DÂN TỘC: BẢO TỒN QUẦN THỂ DI TÍCH CỐ ĐÔ HUẾ"},{"time":"15:30","title":"GÓC NHÌN CỘNG ĐỒNG: ỨNG XỬ NƠI CÔNG CỘNG - CHUYỆN KHÔNG NHỎ"},{"time":"15:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"16:00","title":"PHIM CUỐI TUẦN: TRẢ GIÁ"},{"time":"16:00","title":"CHUYẾN XE ÂM NHẠC XANH"},{"time":"16:00","title":"PHIM TRUYỆN: PHỐ TRONG LÀNG - TẬP 31"},{"time":"16:30","title":"GIAI ĐIỆU CUỘC SỐNG: TÌNH EM"},{"time":"16:45","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ SỨ XỞ"},{"time":"17:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"17:15","title":"ÁNH SÁNG TRI THỨC: TINH HOA KIẾN TRÚC"},{"time":"17:30","title":"KHÁM PHÁ VIỆT NAM: SẮC MÀU VĂN HÓA LAI CHÂU"},{"time":"17:30","title":"KHÁM PHÁ VIỆT NAM: CÁT BÀ - BẢN GIAO HƯỞNG CỦA BIỂN VÀ RỪNG"},{"time":"17:45","title":"TỪ NHỮNG MIỀN QUÊ: LÀNG RAU DƯỚI CHÂN NÚI TAM ĐẢO"},{"time":"17:45","title":"TỪ NHỮNG MIỀN QUÊ: ĐỘC ĐÁO VŨ ĐIỆU CỦA NGƯỜI SÁN CHỈ"},{"time":"17:45","title":"ÁNH SÁNG TRI THỨC: TRỞ VỀ ĐỂ KIẾN TẠO"},{"time":"18:00","title":"GIA ĐÌNH VUI VẺ"},{"time":"18:00","title":"NGƯỜI VIỆT BỐN PHƯƠNG"},{"time":"18:15","title":"VIỆT NAM KẾT NỐI THIÊN NHIÊN"},{"time":"19:00","title":"VIỆT NAM - ĐIỂM HẸN: THẮP SÁNG BẦU TRỜI"},{"time":"19:00","title":"TOÀN CẢNH THẾ GIỚI"},{"time":"19:00","title":"S - TECH"},{"time":"19:05","title":"HITECH CÔNG NGHỆ TƯƠNG LAI"},{"time":"19:30","title":"GÓC NHÌN CỘNG ĐỒNG: ỨNG XỬ NƠI CÔNG CỘNG - CHUYỆN KHÔNG NHỎ"},{"time":"19:30","title":"TIẾNG VIỆT KHÔNG KHÓ: DẠO QUANH THẢO CẦM VIÊN"},{"time":"19:30","title":"KẾT NỐI THỂ THAO"},{"time":"19:45","title":"NHÌN TỪ HÀ NỘI: KHÁT VỌNG TĂNG TRƯỞNG VÀ NIỀM TIN PHÁT TRIỂN"},{"time":"19:55","title":"RẠNG RỠ VIỆT NAM"},{"time":"20:00","title":"DU LỊCH VÀ ẨM THỰC: VĨNH LONG - NHỮNG CÂU CHUYỆN BÊN DÒNG PHÙ SA"},{"time":"20:00","title":"NET ZERO - GỬI TƯƠNG LAI"},{"time":"20:00","title":"MÔI TRƯỜNG - GÓC NHÌN TỪ QUỐC TẾ: KHI TRÁI ĐẤT NÓNG LÊN"},{"time":"20:30","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"20:30","title":"PHIM TÀI LIỆU: NHỮNG ĐỨA TRẺ HẠNH PHÚC - TẬP 3: KHI VỸ HÁT"},{"time":"20:30","title":"MIỀN ĐẤT VÕ: VOVINAM - NGHỆ THUẬT CẬN CHIẾN"},{"time":"20:45","title":"VIỆT NAM QUA GÓC NHÌN QUỐC TẾ"},{"time":"21:00","title":"BẢN TIN THỜI SỰ"},{"time":"21:30","title":"SỨC SỐNG THỂ THAO"},{"time":"21:35","title":"THỜI TIẾT DU LỊCH"},{"time":"21:40","title":"TIỂU PHẨM HÀI: HẺM 168 - TẬP 132"},{"time":"21:40","title":"VĂN HỌC NGHỆ THUẬT: NSND HÀ THỦY - GỌI TÊN NHỮNG THANH ÂM"},{"time":"21:40","title":"TIỂU PHẨM HÀI: HẺM 168 - TẬP 133"},{"time":"21:55","title":"GÓC ĐỒNG HÀNH: ĐIỂM TỰA Y TẾ TẠI HÀN QUỐC"},{"time":"21:55","title":"ĐIỂM HẸN NGƯỜI VIỆT"},{"time":"22:10","title":"NHỊP ĐẬP VIỆT NAM: TẾT KHU CÙ TÊ CỦA NGƯỜI LA CHÍ"},{"time":"22:10","title":"NHỊP ĐẬP VIỆT NAM: LÀNG NGHỀ MỘC CÚC BỒ"},{"time":"22:10","title":"NHỊP ĐẬP VIỆT NAM: HƯƠNG THU TRÊN BẢN TÀY"},{"time":"22:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 24"},{"time":"22:15","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"22:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 25"},{"time":"23:00","title":"GIAI ĐIỆU CUỘC SỐNG: CHIẾC LÁ VÔ TÌNH"},{"time":"23:00","title":"GIAI ĐIỆU CUỘC SỐNG: HOA NẮNG TÔI"},{"time":"23:00","title":"GIAI ĐIỆU CUỘC SỐNG: CỎ VÀ MƯA"},{"time":"23:15","title":"NHỊP SỐNG CỘNG ĐỒNG"},{"time":"23:15","title":"VIỆT NAM - ĐIỂM HẸN: THẮP SÁNG BẦU TRỜI"},{"time":"23:15","title":"S - VIỆT NAM: MÙA XANH Ở CÁT BÀ"},{"time":"23:20","title":"THẾ GIỚI TUỔI THƠ: ĐI ĐỂ LỚN LÊN"},{"time":"23:20","title":"NET ZERO - GỬI TƯƠNG LAI"},{"time":"23:20","title":"TỔ QUỐC TRONG TIM"},{"time":"23:45","title":"QUYẾN RŨ VIỆT NAM: THANH ÂM XƯA NINH BÌNH"},{"time":"23:45","title":"KHÁM PHÁ VIỆT NAM: HÀNH TRÌNH XANH TRÊN ĐẤT QUẢNG NGÃI"}],"vtv5":[{"time":"00:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 9"},{"time":"00:05","title":"PHIM TRUYỆN: MỸ NHÂN TẦNG 22- TẬP 9"},{"time":"00:05","title":"PHIM TRUYỆN: MỸ NHÂN TẦNG 22 - TẬP 10"},{"time":"00:05","title":"PHIM TRUYỆN: MỸ NHÂN TẦNG 22 - TẬP 11"},{"time":"00:45","title":"PHÓNG SỰ: BƯỚC TRÊN CON ĐƯỜNG TRI THỨC"},{"time":"00:45","title":"NHỊP SỐNG HÔM NAY"},{"time":"00:45","title":"PHÓNG SỰ: NGHỊ QUYẾT 14- ĐÒN BẨY ĐỂ VÙNG CAO BỨT PHÁ BẰNG CÔNG NGHỆ SỐ"},{"time":"01:00","title":"VTV5 KẾT NỐI"},{"time":"01:00","title":"VTV SỐNG KHỎE: KHI HUYẾT ÁP ÂM THẦM TĂNG CAO"},{"time":"01:15","title":"NÔNG NGHIỆP XANH: THÚC ĐẨY NÔNG NGHIỆP HÀNG HOÁ"},{"time":"01:15","title":"VTV5 KẾT NỐI"},{"time":"01:30","title":"SỔ TAY CÔNG NGHỆ: SÁNG TẠO NỘI DUNG SỐ VỀ LÀNG QUÊ"},{"time":"01:30","title":"KINH TẾ NÔNG THÔN: LÀNG NGHỀ TRƯỚC SỨC ÉP CỦA THỊ TRƯỜNG"},{"time":"01:45","title":"CHÍNH SÁCH VÀ CUỘC SỐNG: PHÁT TRIỂN LÀNG DU LỊCH CỘNG ĐỒNG VEN BIỂN"},{"time":"01:45","title":"THANH ÂM VIỆT: EM GÁI NƠI BẢN DAO"},{"time":"01:45","title":"THƯƠNG NHỚ MIỀN TÂY"},{"time":"02:00","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 32"},{"time":"02:00","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 33"},{"time":"02:00","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 34"},{"time":"02:45","title":"THÔNG TIN CHÍNH SÁCH PHÁP LUẬT"},{"time":"02:45","title":"KIẾN THỨC VÀ CUỘC SỐNG"},{"time":"02:45","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ XỨ SỞ"},{"time":"03:00","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 46"},{"time":"03:00","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 47"},{"time":"03:00","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 48"},{"time":"03:15","title":"NHỮNG NGƯỜI CON ĐẤT VIỆT: NƠI SÁNG KIẾN NẢY MẦM"},{"time":"03:15","title":"CHECK IN VN: CHÍN TẦNG MÂY GIỮA ĐẠI NGÀN"},{"time":"03:15","title":"VTV5 KẾT NỐI"},{"time":"03:30","title":"AN TOÀN GIAO THÔNG"},{"time":"03:30","title":"DÂN TỘC TÔN GIÁO: GIEO DUYÊN LÀNH CHO ĐỜI"},{"time":"03:30","title":"KHÁM PHÁ VIỆT NAM"},{"time":"03:45","title":"PHỤ NỮ SỐ: HÀNH TRÌNH NÂNG TẦM TRÀ VIỆT"},{"time":"03:45","title":"SẮC MÀU CÁC DÂN TỘC: GIỮA MIỀN SỚN CƯỚC NẶM ĐẶM"},{"time":"03:45","title":"DÂN TỘC PHÁT TRIỂN: LAI CHÂU PHÁT TRIỂN CÔNG NGHIỆP NĂNG LƯỢNG"},{"time":"04:15","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 3"},{"time":"04:15","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 4"},{"time":"04:15","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 5"},{"time":"05:00","title":"CHƯƠNG TRÌNH TIẾNG MƯỜNG"},{"time":"05:00","title":"CHƯƠNG TRÌNH TIẾNG SÁN CHÍ"},{"time":"05:30","title":"CHƯƠNG TRÌNH TIẾNG TÀY"},{"time":"06:00","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"06:30","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"07:00","title":"CHƯƠNG TRÌNH TIẾNG THÁI"},{"time":"07:00","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"07:30","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"08:00","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"08:30","title":"CHƯƠNG TRÌNH TIẾNG THÁI"},{"time":"08:30","title":"CHÀO TUẦN MỚI"},{"time":"09:00","title":"CHÍNH SÁCH VÀ CUỘC SỐNG: SÁT CÁNH CÙNG HỘ NGHÈO"},{"time":"09:00","title":"VTV SỐNG KHỎE: NGUY CƠ NẮNG NÓNG KÉO DÀI"},{"time":"09:00","title":"NÔNG THÔN MỚI: GIỮ CHUẨN NÔNG THÔN MỚI SAU SÁP NHẬP"},{"time":"09:15","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ XỨ SỞ"},{"time":"09:15","title":"VTV5 KẾT NỐI"},{"time":"09:30","title":"VTV5 KẾT NỐI"},{"time":"09:30","title":"PHÁT HUY VAI TRÒ CỦA MẶT TRẬN"},{"time":"09:35","title":"QUÀ TẶNG CUỘC SỐNG: TÌNH YÊU CỦA VỊT MÁI"},{"time":"09:45","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"09:45","title":"VTV5 KẾT NỐI"},{"time":"09:45","title":"VUA TIẾNG VIỆT"},{"time":"10:00","title":"SAO CHECK: DIỄN VIÊN NGỌC THANH TÂM"},{"time":"10:30","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 33"},{"time":"10:30","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 34"},{"time":"10:30","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 35"},{"time":"11:00","title":"VĂN HÓA TÂY NGUYÊN: ĐỂ TIẾNG CHIÊNG MÃI NGÂN VANG"},{"time":"11:00","title":"SẮC MÀU VIỆT NAM: GIỮ HỒN MẠNH NGUỒN VĂN HOÁ SƠN LA"},{"time":"11:00","title":"GÌ THẾ NHỈ?"},{"time":"11:15","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 47"},{"time":"11:15","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 48"},{"time":"11:15","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 49"},{"time":"11:30","title":"THỜI SỰ"},{"time":"11:50","title":"BẢN TIN THỂ THAO"},{"time":"11:55","title":"BẢN TIN THỊ TRƯỜNG"},{"time":"11:55","title":"SỨC SỐNG NGHỊ QUYẾT"},{"time":"12:00","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 55"},{"time":"12:00","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 57"},{"time":"12:00","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 59"},{"time":"12:45","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 56"},{"time":"12:45","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 58"},{"time":"12:45","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 60"},{"time":"13:30","title":"CHƯƠNG TRÌNH TIẾNG CAO LAN"},{"time":"13:30","title":"CHƯƠNG TRÌNH TIẾNG HÀ NHÌ"},{"time":"13:30","title":"CHƯƠNG TRÌNH TIẾNG MƯỜNG"},{"time":"14:00","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"14:30","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"15:00","title":"CHƯƠNG TRÌNH TIẾNG THÁI"},{"time":"15:30","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"16:00","title":"CHƯƠNG TRÌNH TIẾNG TÀY"},{"time":"16:30","title":"PHIM TRUYỆN: KHOẢNG CÁCH - TẬP 4"},{"time":"16:30","title":"PHIM TRUYỆN: KHOẢNG CÁCH - TẬP 5"},{"time":"16:30","title":"PHIM TRUYỆN: KHOẢNG CÁCH - TẬP 6"},{"time":"17:15","title":"KIẾN THỨC VÀ CUỘC SỐNG"},{"time":"17:15","title":"HÀNH TRÌNH MỞ LỐI"},{"time":"17:15","title":"THÔNG TIN CHÍNH SÁCH PHÁP LUẬT"},{"time":"17:30","title":"NHỊP SỐNG HÔM NAY"},{"time":"17:30","title":"TRẠM YÊU THƯƠNG: KHI NGHỀ XƯA THẮP LÊN HY VỌNG"},{"time":"17:45","title":"PHÓNG SỰ: HỢP NHẤT BA CHƯƠNG TRÌNH MỤC TIÊU QUỐC GIA"},{"time":"18:00","title":"THỜI SỰ"},{"time":"18:30","title":"THỜI TIẾT"},{"time":"18:35","title":"SẮC MÀU THỂ THAO"},{"time":"18:35","title":"BẢN TIN THỊ TRƯỜNG"},{"time":"18:35","title":"CHÀO TUẦN MỚI"},{"time":"18:40","title":"THIẾU NHI"},{"time":"18:40","title":"CẬN CẢNH THỂ THAO"},{"time":"19:00","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 73"},{"time":"19:00","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 75"},{"time":"19:00","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 77"},{"time":"19:45","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 74"},{"time":"19:45","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 76"},{"time":"19:45","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 78"},{"time":"20:30","title":"VUA TIẾNG VIỆT"},{"time":"20:30","title":"KHÁCH SẠN 5 SAO: CA SĨ ĐÔNG HÙNG - VÕ HẠ TRÂM"},{"time":"20:30","title":"PHIM TÀI LIỆU: HỒ TÙNG MẬU- TRỌN ĐỜI VÌ ĐẢNG, VÌ DÂN"},{"time":"21:00","title":"LÀM GIÀU TRÊN QUÊ HƯƠNG: HIỆU QUẢ MÔ HÌNH NUÔI CÁ GIỐNG"},{"time":"21:15","title":"VTV5 KẾT NỐI"},{"time":"21:15","title":"ĐIỂM HẸN BẢN SẮC: VANG XA VÓ NGỰA BẮC HÀ"},{"time":"21:15","title":"TRANG VĂN HÓA"},{"time":"21:30","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 4"},{"time":"21:30","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 5"},{"time":"21:30","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 6"},{"time":"22:15","title":"BẠN KỂ TÔI NGHE"},{"time":"22:15","title":"VTV5 KẾT NỐI"},{"time":"22:15","title":"NHÌN RA THẾ GIỚI"},{"time":"22:30","title":"VĂN HỌC NGHỆ THUẬT: NGƯỜI GÓI MƯA MIỀN TRUNG"},{"time":"22:45","title":"XEM VÀ NGHĨ"},{"time":"22:45","title":"VTV5 KẾT NỐI"},{"time":"23:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 8"},{"time":"23:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 9"},{"time":"23:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 10"}],"vtv6":[{"time":"00:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 32: COLOMBIA VS GHANA"},{"time":"00:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"00:20","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY VS PHÁP"},{"time":"02:20","title":"TRUYỀN HÌNH TRỰC TIẾP FIFA WORLD CUP 2026 - VÒNG 1/8: BRAZIL VS NA UY"},{"time":"02:30","title":"360° THỂ THAO"},{"time":"03:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY - PHÁP"},{"time":"03:30","title":"360° THỂ THAO"},{"time":"04:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32: ARGENTINA - CABO VERDE"},{"time":"06:20","title":"TRUYỀN HÌNH TRỰC TIẾP FIFA WORLD CUP 2026 - VÒNG 1/8: MEXICO VS ANH"},{"time":"07:30","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"07:50","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32: COLOMBIA - GHANA"},{"time":"09:20","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY - PHÁP"},{"time":"10:15","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: BRAZIL VS NA UY"},{"time":"11:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"11:30","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"11:45","title":"VTV SPORTS NEWS"},{"time":"12:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"12:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: TÂY BAN NHA - ÁO"},{"time":"12:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: MEXICO VS ANH"},{"time":"14:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ARGENTINA - CABO VERDE"},{"time":"14:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"14:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY VS PHÁP"},{"time":"15:50","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"16:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"16:05","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"16:30","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"16:35","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: COLOMBIA - GHANA"},{"time":"16:35","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"18:30","title":"VTV SPORTS NEWS"},{"time":"18:45","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"18:45","title":"CẢM HỨNG FIFA WORLD CUP 2026"},{"time":"19:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"19:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY - PHÁP"},{"time":"19:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: BRAZIL VS NA UY"},{"time":"21:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"21:30","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"22:00","title":"360° THỂ THAO"},{"time":"22:30","title":"GIỜ VÀNG THỂ THAO"},{"time":"22:30","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"22:30","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: MEXICO VS ANH"},{"time":"22:50","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"23:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"}],"vtv7":[{"time":"06:03","title":"7 PHÚT CHO BỮA SÁNG: CÀ TÍM NHÚNG TRỨNG CHIÊN"},{"time":"06:03","title":"7 PHÚT CHO BỮA SÁNG: BÁNH MÌ NƯỚNG MUỐI ỚT"},{"time":"06:03","title":"7 PHÚT CHO BỮA SÁNG: CƠM HỘP BENTO CHO BÉ"},{"time":"06:13","title":"ĐẸP HƠN MỖI NGÀY: SỐ 7 - CHĂM SÓC MÙI HƯƠNG CƠ THỂ"},{"time":"06:13","title":"ĐẸP HƠN MỖI NGÀY: SỐ 8 - DỌN TỦ ĐỒ THEO PHONG CÁCH NHẬT BẢN"},{"time":"06:13","title":"ĐẸP HƠN MỖI NGÀY: SỐ 9 - CÁCH CHỌN ÁO SƠ MI CHO NAM"},{"time":"06:20","title":"CÙNG NHAU TA VẬN ĐỘNG: SỐ 8 - NGÔI NHÀ BIỂN XANH"},{"time":"06:20","title":"CÙNG NHAU TA VẬN ĐỘNG: SỐ 9 - TRÒ CHƠI GIẢI CỨU"},{"time":"06:20","title":"CÙNG NHAU TA VẬN ĐỘNG: SỐ 10 - CÙNG LẮC LƯ"},{"time":"06:30","title":"NHỮNG NGƯỜI BẠN DIỆU KỲ: TẬP 3"},{"time":"06:30","title":"BẠN LÀ HÌNH GÌ: VŨ ĐIỆU NGÔI SAO"},{"time":"06:30","title":"BẠN LÀ HÌNH GÌ: HÒN ĐẢO KỲ LẠ"},{"time":"06:35","title":"ĐỘI CỨU HỘ BIỂN XANH: TẬP 1"},{"time":"06:35","title":"ĐỘI CỨU HỘ BIỂN XANH - TẬP 2"},{"time":"06:45","title":"Ú ÒA: SỐ 14 - CHƠI VỚI BÓNG THỔI"},{"time":"06:45","title":"Ú ÒA: SỐ 15 - HAI BÀN TAY"},{"time":"06:45","title":"Ú ÒA: SỐ 16 - CẢM ƠN - XIN LỖI"},{"time":"07:00","title":"XỨ SỞ CẦU VỒNG: SỐ 18"},{"time":"07:00","title":"XỨ SỞ CẦU VỒNG: SỐ 19"},{"time":"07:00","title":"XỨ SỞ CẦU VỒNG - SỐ 20"},{"time":"07:30","title":"CRACK EM UP: SỐ 5"},{"time":"07:30","title":"CRACK EM UP: SỐ 6"},{"time":"07:30","title":"IELTS FACE OFF: SỐ 6 - GLOBETROTTER"},{"time":"08:00","title":"KHÁM PHÁ KHOA HỌC: SỐ 39 - TOÀ THÁP LỚN LÊN"},{"time":"08:00","title":"KHÁM PHÁ KHOA HỌC: SỐ 40 - DẠO BƯỚC TRÊN CẦU VỒNG"},{"time":"08:00","title":"KHÁM PHÁ KHOA HỌC: SỐ 41 - TỰ LÀM Ô TÔ ĐỘNG CƠ KHÍ NÉN"},{"time":"08:30","title":"BIỆT ĐỘI BICHILI: SỐ 2 - CÔNG CHÚA HOÁ HỌC"},{"time":"08:30","title":"CHUYỆN HỌC TRÒ: SỐ 7 - CÂU CHUYỆN VỚI HÌNH XĂM"},{"time":"08:30","title":"VĂN VUI VẺ: SỐ 1 - BỨC THƯ CỦA THỦ LĨNH DA ĐỎ"},{"time":"09:00","title":"HỌC SAO CHO TỐT: SỐ 3 - BƯỚC NGOẶT"},{"time":"09:00","title":"HỌC SAO CHO TỐT: SỐ 4 - MÔN TOÁN ĐÁNG SỢ"},{"time":"09:00","title":"HEO ĐẤT: SỐ 1 - TIỀN LÀ GÌ"},{"time":"09:20","title":"SÁNG TẠO 102: SỐ 10 - SA MẠC"},{"time":"09:35","title":"MỘT VÒNG TIẾNG VIỆT - SỐ 9"},{"time":"09:45","title":"ĐƯỜNG ĐẾN TRƯỜNG: TẤM VÉ TỚI TRƯỜNG"},{"time":"09:45","title":"ĐƯỜNG ĐẾN TRƯỜNG: NHỮNG CÂU CHUYỆN TRÊN ĐẢO"},{"time":"09:50","title":"MATH DORM: SỐ 5 - PHÉP NHÂN"},{"time":"10:00","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 1"},{"time":"10:00","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 2"},{"time":"10:05","title":"NHỮNG NGƯỜI BẠN CẦU VỒNG: HOA - TẬP 1"},{"time":"10:30","title":"CHUYẾN XE HẠT VỪNG: SỐ 16"},{"time":"10:30","title":"CHUYẾN XE HẠT VỪNG: SỐ 17"},{"time":"10:30","title":"CHUYẾN XE HẠT VỪNG - SỐ 18"},{"time":"11:00","title":"CHA MẸ THAY ĐỔI: SỐ 1 - ÂM THANH CỦA NHỮNG BẢN NHẠC BUỒN"},{"time":"11:00","title":"TRƯỜNG TEEN"},{"time":"11:00","title":"CÙNG LĂN VÀO BẾP - SỐ 23"},{"time":"11:15","title":"ĐẸP HƠN MỖI NGÀY: SỐ 8 - DỌN TỦ ĐỒ THEO PHONG CÁCH NHẬT BẢN"},{"time":"11:20","title":"5 KÝ HIỆU NGÔN NGỮ MỖI NGÀY: CẢM XÚC - PHẦN 2"},{"time":"11:30","title":"MẸ ƠI TẠI SAO: SỐ 3 - TẬP TRUNG"},{"time":"11:45","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 11"},{"time":"12:00","title":"XỨ SỞ CẦU VỒNG: SỐ 17"},{"time":"12:00","title":"XỨ SỞ CẦU VỒNG: SỐ 18"},{"time":"12:00","title":"XỨ SỞ CẦU VỒNG - SỐ 19"},{"time":"12:30","title":"ENGLISH BY STORIES: SỐ 10 - BẢY ĐIỀU ƯỚC"},{"time":"12:30","title":"ENGLISH BY STORIES: SỐ 1 - SỰ TÍCH SỌ DỪA"},{"time":"12:30","title":"VĂN VUI VẺ: SỐ 1 - BỨC THƯ CỦA THỦ LĨNH DA ĐỎ"},{"time":"12:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: MÀU SẮC"},{"time":"12:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: GIA VỊ"},{"time":"13:00","title":"JUMPING WITH TOEIC: SỐ 53"},{"time":"13:00","title":"JUMPING WITH TOEIC: SỐ 54"},{"time":"13:00","title":"JUMPING WITH TOEIC - SỐ 55"},{"time":"13:45","title":"FOLLOW US: SỐ 24 - LET ME GO HOME"},{"time":"13:45","title":"FOLLOW US: SỐ 25 - SKIN CARE"},{"time":"13:45","title":"FOLLOW US: SỐ 26 - SHOPAHOLICS"},{"time":"14:00","title":"CRACK EM UP: SỐ 5"},{"time":"14:00","title":"CRACK EM UP: SỐ 6"},{"time":"14:00","title":"IELTS FACE OFF: SỐ 6 - GLOBETROTTER"},{"time":"14:30","title":"KHÁM PHÁ KHOA HỌC: SỐ 39 - TOÀ THÁP LỚN LÊN"},{"time":"14:30","title":"KHÁM PHÁ KHOA HỌC: SỐ 40 - DẠO BƯỚC TRÊN CẦU VỒNG"},{"time":"14:30","title":"KHÁM PHÁ KHOA HỌC: SỐ 41 - TỰ LÀM Ô TÔ ĐỘNG CƠ KHÍ NÉN"},{"time":"15:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 3"},{"time":"15:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 5"},{"time":"15:00","title":"HEO ĐẤT: SỐ 1 - TIỀN LÀ GÌ"},{"time":"15:20","title":"SÁNG TẠO 102: SỐ 10 - SA MẠC"},{"time":"15:35","title":"MỘT VÒNG TIẾNG VIỆT - SỐ 9"},{"time":"15:45","title":"CON ĐƯỜNG NGHỀ NGHIỆP: SỐ 27 - CHĂM SÓC SẮC ĐẸP"},{"time":"15:45","title":"CON ĐƯỜNG NGHỀ NGHIỆP: SỐ 33 - KỸ SƯ CƠ KHÍ"},{"time":"15:50","title":"MATH DORM: SỐ 5 - PHÉP NHÂN"},{"time":"16:00","title":"CHA MẸ THAY ĐỔI: SỐ 1 - ÂM THANH CỦA NHỮNG BẢN NHẠC BUỒN"},{"time":"16:00","title":"TRƯỜNG TEEN"},{"time":"16:05","title":"CÙNG LĂN Vào Bếp - SỐ 23"},{"time":"16:20","title":"CUỐN SÁCH CỦA TÔI: SỐ 8 - RICO VÀ OSKAR"},{"time":"16:30","title":"MẸ ƠI TẠI SAO: SỐ 3 - TẬP TRUNG"},{"time":"16:45","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 11"},{"time":"17:00","title":"XỨ SỞ CẦU VỒNG: SỐ 18"},{"time":"17:00","title":"XỨ SỞ CẦU VỒNG: SỐ 19"},{"time":"17:00","title":"XỨ SỞ CẦU VỒNG - SỐ 20"},{"time":"17:30","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 9"},{"time":"17:30","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 10"},{"time":"17:30","title":"VĂN VUI VẺ: SỐ 1 - BỨC THƯ CỦA THỦ LĨNH DA ĐỎ"},{"time":"17:45","title":"EM YÊU VIỆT NAM: SỐ 6"},{"time":"17:45","title":"EM YÊU VIỆT NAM: SỐ 2 - VÀO MÙA"},{"time":"18:00","title":"THỬ THÁCH KHOA HỌC: SỐ 7 - HÓA HỌC ỨNG DỤNG"},{"time":"18:00","title":"THỬ THÁCH KHOA HỌC: SỐ 8 - KHÁM PHÁ CHẤT CHỈ THỊ"},{"time":"18:00","title":"THỬ THÁCH KHOA HỌC: SỐ 9 - ÁP SUẤT RẤT THÚ VỊ"},{"time":"18:15","title":"STREAM TOÁN HỌC: SỐ 8"},{"time":"18:15","title":"STREAM TOÁN HỌC: SỐ 9"},{"time":"18:15","title":"STREAM TOÁN HỌC - SỐ 10"},{"time":"18:30","title":"ENGLISH BY STORIES: SỐ 10 - BẢY ĐIỀU Ư��C"},{"time":"18:30","title":"ENGLISH BY STORIES: SỐ 1 - SỰ TÍCH SỌ DỪA"},{"time":"18:30","title":"CUỐN SÁCH CỦA EM: SỐ 3 - PIPPY TẤT DÀI"},{"time":"18:45","title":"LÀ LA LÁ: SỐ 4 - CUỘC THI CAO ĐỘ"},{"time":"18:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: MÀU SẮC"},{"time":"18:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: GIA VỊ"},{"time":"19:00","title":"CHUYẾN XE HẠT VỪNG: SỐ 16"},{"time":"19:00","title":"CHUYẾN XE HẠT VỪNG: SỐ 17"},{"time":"19:00","title":"CHUYẾN XE HẠT VỪNG - SỐ 18"},{"time":"19:30","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 1"},{"time":"19:30","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 2"},{"time":"19:30","title":"NHỮNG NGƯỜI BẠN CẦU VỒNG: HOA - TẬP 1"},{"time":"19:50","title":"CHÔM CHÔM VÀ NHỮNG NGƯỜI BẠN: GIẢI CỨU CHÔM CHÔM"},{"time":"20:00","title":"123 TA CÙNG ĐẾM: SỐ 0"},{"time":"20:00","title":"123 TA CÙNG ĐẾM: SỐ 1"},{"time":"20:00","title":"KỸ NĂNG AN TOÀN CHO BÉ - SỐ 6"},{"time":"20:10","title":"HỌC VẼ CÙNG ẾCH CỐM: SỐ 39"},{"time":"20:10","title":"HỌC VẼ CÙNG ẾCH CỐM: SỐ 40"},{"time":"20:10","title":"NHỮNG NGƯỜI BẠN DIỆU KỲ - TẬP 4"},{"time":"20:20","title":"KIDS VOCAB: SỐ 12 - HEALTH PROBLEMS"},{"time":"20:20","title":"KIDS VOCAB: SỐ 13 - POSITIONS"},{"time":"20:20","title":"LÀ LA LÁ: SỐ 5 - CƯỜNG ĐỘ TO NHỎ CỦA ÂM THANH"},{"time":"20:35","title":"NGÀY XƯA CỔ TÍCH: SỐ 4 - QUẠ VÀ CÔNG"},{"time":"20:50","title":"CHUYỆN KỂ CỦA NHỮNG CHÚ CỪU: SỐ 20 - SỰ ĐOÀN KẾT CỦA BẦY CHIM"},{"time":"20:50","title":"CHUYỆN KỂ CỦA NHỮNG CHÚ CỪU: SỐ 21 - ĐÀN KIẾN TRẢ ƠN"},{"time":"20:50","title":"CHUYỆN KỂ CỦA NHỮNG CHÚ CỪU: SỐ 22 - CHÚ RÙA TẬP BAY"},{"time":"21:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 4"},{"time":"21:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 6"},{"time":"21:00","title":"GÕ CỬA NGHỀ NGHIỆP - SỐ 7"},{"time":"21:45","title":"HỌC TIẾNG ANH QUA BÀI HÁT: SỐ 23 - GIRL ON FIRE"},{"time":"21:45","title":"HỌC TIẾNG ANH QUA BÀI HÁT: SỐ 24 - WE DONT TALK ANYMORE"},{"time":"21:45","title":"HỌC TIẾNG ANH QUA BÀI HÁT: SỐ 25 - LA LA LA"},{"time":"22:00","title":"JUMPING WITH TOEIC: SỐ 53"},{"time":"22:00","title":"JUMPING WITH TOEIC: SỐ 54"},{"time":"22:00","title":"JUMPING WITH TOEIC - SỐ 55"},{"time":"22:45","title":"FOLLOW US: SỐ 24 - LET ME GO HOME"},{"time":"22:45","title":"FOLLOW US: SỐ 25 - SKIN CARE"},{"time":"22:45","title":"FOLLOW US: SỐ 26 - SHOPAHOLICS"},{"time":"23:00","title":"CUỐN SÁCH CỦA TÔI: SỐ 5 - NHÀ GIẢ KIM"},{"time":"23:00","title":"CUỐN SÁCH CỦA TÔI: SỐ 7 - DỐC HẾT TRÁI TIM"},{"time":"23:00","title":"CUỐN SÁCH CỦA TÔI: SỐ 8 - RICO VÀ OSKAR"},{"time":"23:15","title":"CHUYỆN HỌC TRÒ: SỐ 6 - ƯỚC MƠ TRỞ THÀNH GAME THỦ"},{"time":"23:15","title":"CHUYỆN HỌC TRÒ: SỐ 8 - NÓI DỐI"},{"time":"23:15","title":"CHUYỆN HỌC TRÒ: SỐ 9 - NHÀ BÁO TẬP SỰ"},{"time":"23:40","title":"KHÔNG THÌ THẦM: SỐ 11 - ẢO TƯỞNG"},{"time":"23:40","title":"KHÔNG THÌ THẦM: SỐ 12 - XA NHÀ"},{"time":"23:40","title":"KHÔNG THÌ THẦM: SỐ 13 - HẬU LẦN ĐẦU LÀM CHUYỆN ẤY"}],"vtv8":[{"time":"00:00","title":"KÝ SỰ: VỀ MIỀN DI SẢN: NGHỆ NHÂN - NGƯỜI GIỮ HỒN DI SẢN"},{"time":"00:00","title":"KÝ SỰ: PHÁT HUY GIÁ TRỊ CÁC DI SẢN VĂN HÓA MIỀN KINH BẮC"},{"time":"00:00","title":"KÝ SỰ"},{"time":"00:15","title":"TRƯỜNG SƠN VẠN DẶM: TINH HOA THỔ CẨM GIỮA ĐẠI NGÀN TRƯỜNG SƠN"},{"time":"00:15","title":"QUYẾN RŨ VIỆT NAM: ÂM SẮC LÂM BÌNH"},{"time":"00:15","title":"ĐIỂM TỰA BÌNH YÊN"},{"time":"00:30","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 22"},{"time":"00:30","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 23"},{"time":"00:30","title":"PHIM TRUYỆN"},{"time":"01:00","title":"PHÓNG SỰ: ĐẮK LẮK - MỞ CỬA TIỀM NĂNG, ĐÓN SÓNG ĐẦU TƯ"},{"time":"01:00","title":"GIAI ĐIỆU KẾT NỐI: MÙA HÈ YÊU THƯƠNG"},{"time":"01:00","title":"ĐI ĐỂ BIẾT"},{"time":"01:15","title":"KẾT NỐI VTV8"},{"time":"01:15","title":"PHÓNG SỰ: NÂNG CAO CHẤT LƯỢNG PHỤC VỤ TRẢI NGHIỆM DI SẢN HUẾ"},{"time":"01:30","title":"CÀ PHÊ TÁM: CHÂN DUNG HẠNH PHÚC"},{"time":"01:30","title":"ĐI ĐỂ BIẾT: NHỮNG NGÀY Ở ĐẢO TRẦN - ĐẢO TIỀN TIÊU CỦA TỔ QUỐC"},{"time":"01:30","title":"VĂN HỌC - NGHỆ THUẬT"},{"time":"01:45","title":"MIỀN ĐẤT VÕ: VOVINAM - DĨ NHU CHẾ CƯƠNG"},{"time":"01:55","title":"ẨM THỰC ĐỘC ĐÁO"},{"time":"02:00","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 21"},{"time":"02:00","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 22"},{"time":"02:00","title":"PHIM SITCOM"},{"time":"02:15","title":"KHÁM PHÁ THẾ GIỚI: BẢO TỒN CÁC LOÀI VẬT - TẬP 4"},{"time":"02:15","title":"KHÁM PHÁ THẾ GIỚI: BẢO TỒN CÁC LOÀI VẬT - TẬP 5"},{"time":"02:15","title":"KHÁM PHÁ THẾ GIỚI"},{"time":"02:45","title":"PHỐ TÀI CHÍNH: KỲ VỌNG THỊ TRƯỜNG TRONG TRUNG VÀ DÀI HẠN"},{"time":"02:45","title":"CHÉM GIÓ - GIÓ CHÉM: NGHỆ THUẬT GÓP Ý"},{"time":"02:45","title":"GIẢI Mà CUỘC SỐNG"},{"time":"03:00","title":"ĐI ĐỂ BIẾT: TRẢI NGHIỆM CHÀI LƯỚI CÙNG NGƯ DÂN ĐẢO THANH LÂN"},{"time":"03:00","title":"GIAO LƯU - TỌA ĐÀM: TỪ TINH GỌN BỘ MÁY ĐẾN HIỆU QUẢ VẬN HÀNH"},{"time":"03:00","title":"ATLAS"},{"time":"03:25","title":"PHÓNG SỰ"},{"time":"03:30","title":"TỪ NHỮNG MIỀN QUÊ: NGƯỜI MÔNG NƠI ĐỈNH TRỜI ĐÁ XÁM"},{"time":"03:30","title":"PHÓNG SỰ: BẮC NINH - CÁC TÔN GIÁO ĐỒNG LÒNG CHUNG TAY XÂY DỰNG QUÊ HƯƠNG"},{"time":"03:35","title":"TỪ NHỮNG MIỀN QUÊ"},{"time":"03:45","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 62"},{"time":"03:45","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 63"},{"time":"03:45","title":"PHIM TRUYỆN"},{"time":"04:15","title":"PHIM TÀI LIỆU: DẤU ẤN PHẬT HOÀNG - DI SẢN NGÀN NĂM"},{"time":"04:15","title":"PHIM TÀI LIỆU: THIÊN NHIÊN HOANG Dà PHONG NHA - KẺ BÀNG - PHẦN 1"},{"time":"04:15","title":"PHIM TÀI LIỆU"},{"time":"04:45","title":"PHÓNG SỰ: CHÍNH QUYỀN CẤP Xà - BIẾN ÁP LỰC THÀNH ĐỘNG LỰC PHÁT TRIỂN"},{"time":"04:45","title":"TRƯỜNG SƠN VẠN DẶM: HÙNG SƠN - NƠI RỪNG KỂ CHUYỆN"},{"time":"04:45","title":"QUYẾN RŨ VIỆT NAM"},{"time":"05:00","title":"KHẾ ƯỚC THỜI GIAN: CỒNG CHIÊNG, TỪ ĐÁ ĐẾN ĐỒNG"},{"time":"05:00","title":"ATLAS: NGA SƠN - MIỀN QUÊ HUYỀN THOẠI"},{"time":"05:00","title":"DẤU ẤN LỊCH SỬ"},{"time":"05:30","title":"NẺO VỀ NGUỒN CỘI: DẤU ẤN VĂN HÓA CỔ ĐỒNG NAI"},{"time":"05:30","title":"GIẢI Mà CUỘC SỐNG: NGHỀ VẼ TRUYỀN THẦN VÀ NHỮNG ĐIỀU CHƯA BIẾT"},{"time":"05:30","title":"NẺO VỀ NGUỒN CỘI"},{"time":"05:45","title":"KÝ SỰ: PHÁT HUY GIÁ TRỊ CÁC DI SẢN VĂN HÓA MIỀN KINH BẮC"},{"time":"05:45","title":"KÝ SỰ: CHÙA THIÊNG XỨ KINH BẮC: CHÙA DÂU"},{"time":"05:45","title":"KÝ SỰ"},{"time":"06:00","title":"CÂU CHUYỆN TỪ NHỮNG BÀI CA: LÁ THƯ TRUÔNG BỒN"},{"time":"06:00","title":"GIAI ĐIỆU KẾT NỐI: ƯỚC GÌ"},{"time":"06:00","title":"GIAI ĐIỆU KẾT NỐI"},{"time":"06:15","title":"NÔNG SẢN KỂ CHUYỆN: HƯƠNG QUẾ GIỮ RỪNG"},{"time":"06:20","title":"SẮC MÀU CÁC DÂN TỘC"},{"time":"06:25","title":"NẺO VỀ NGUỒN CỘI: DẤU ẤN VĂN HÓA CỔ ĐỒNG NAI"},{"time":"06:40","title":"THÔNG TIN - DỊCH VỤ"},{"time":"06:45","title":"HÀNH TRÌNH DI SẢN: MƯỜNG VANG TIẾNG ĐẤT"},{"time":"06:45","title":"HÀNH TRÌNH DI SẢN: HÀO KHÍ LAM SƠN"},{"time":"06:45","title":"GIẢI Mà SỨC KHỎE"},{"time":"07:30","title":"NẺO VỀ NGUỒN CỘI: TRỐNG SÀNH CỦA NGƯỜI CAO LAN"},{"time":"07:30","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: HÌNH TƯỢNG NGỰA TRIỀU NGUYỄN"},{"time":"07:30","title":"NẺO VỀ NGUỒN CỘI"},{"time":"07:45","title":"GIẢI Mà SỨC KHỎE: CẢI THIỆN ĐAU NHỨC XƯƠNG KHỚP THƯỜNG XUYÊN TÁI PHÁT Ở NGƯỜI LỚN TUỔI"},{"time":"07:45","title":"GIẢI Mà SỨC KHỎE: BÍ QUYẾT NÂNG CAO SỨC KHỎE TIM MẠCH, CHỐNG LÃO HÓA Ở NGƯỜI CAO TUỔI"},{"time":"07:45","title":"GIẢI Mà SỨC KHỎE"},{"time":"08:45","title":"CÂU CHUYỆN TỪ NHỮNG BÀI CA: THỜI HOA ĐỎ"},{"time":"08:45","title":"SỰ SỐNG DIỆU KỲ: YJEK NIÊ KDĂM - NGƯỜI SƯU TẦM SỬ THI TÂY NGUYÊN"},{"time":"08:45","title":"SỰ SỐNG DIỆU KỲ"},{"time":"09:00","title":"HIỂU ĐÚNG - SỐNG KHỎE: ĐÔNG TRÙNG HẠ THẢO VỚI SỨC KHỎE TIM MẠCH PHÒNG NGỪA ĐỘT QUỴ"},{"time":"09:00","title":"HIỂU ĐÚNG - SỐNG KHỎE: ĐIỀU HÒA HUYẾT ÁP, BẢO VỆ SỨC KHỎE TIM MẠCH NHỜ SÂM DÂN GIAN"},{"time":"09:00","title":"HIỂU ĐÚNG - SỐNG KHỎE"},{"time":"10:00","title":"KẾT NỐI VTV8"},{"time":"10:00","title":"TỪ NHỮNG MIỀN QUÊ: NGƯỜI LÀO Ở NÚA NGAM"},{"time":"10:00","title":"TỪ NHỮNG MIỀN QUÊ"},{"time":"10:15","title":"PHIM TÀI LIỆU: GIẢM KÉP, ÁP LỰC GẤP BA"},{"time":"10:15","title":"PHIM CA NHẠC: VỌNG NGUYỆT"},{"time":"10:15","title":"CHẤT LƯỢNG CUỘC SỐNG"},{"time":"11:00","title":"NÓNG CÙNG V8: NÓNG CÙNG V8 (20)"},{"time":"11:00","title":"NÓNG CÙNG V8"},{"time":"11:15","title":"KINH TẾ KẾT NỐI"},{"time":"11:30","title":"24H ONLINE"},{"time":"11:45","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"11:45","title":"DỰ BÁO THỜI TIẾT"},{"time":"11:50","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 22"},{"time":"11:50","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 23"},{"time":"11:50","title":"PHIM SITCOM"},{"time":"12:05","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 23"},{"time":"12:05","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 24"},{"time":"12:05","title":"PHIM TRUYỆN"},{"time":"12:35","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"12:35","title":"DỰ BÁO THỜI TIẾT"},{"time":"12:40","title":"THỂ THAO"},{"time":"12:45","title":"PHIM TRUYỆN: SỨ MỆNH VẪY GỌI - TẬP 52"},{"time":"12:45","title":"PHIM TRUYỆN: SỨ MỆNH VẪY GỌI - TẬP 53"},{"time":"12:45","title":"PHIM TRUYỆN"},{"time":"13:15","title":"QUYẾN RŨ VIỆT NAM: ÂM SẮC LÂM BÌNH"},{"time":"13:15","title":"PHIM TÀI LIỆU: TRÚC LÂM YÊN TỬ - DÒNG THIỀN THUẦN VIỆT"},{"time":"13:15","title":"NƠI ĐÂU CŨNG LÀ NHÀ"},{"time":"13:30","title":"GIẢI Mà SỨC KHỎE: BÍ QUYẾT NÂNG CAO SỨC KHỎE TIM MẠCH, CHỐNG LÃO HÓA Ở NGƯỜI CAO TUỔI"},{"time":"13:30","title":"GIẢI Mà SỨC KHỎE: SUY GIẢM SINH LÝ NAM - XU HƯỚNG ĐIỀU TRỊ TỪ THẢO DƯỢC"},{"time":"13:30","title":"GIẢI Mà SỨC KHỎE"},{"time":"14:30","title":"MIỀN ĐẤT VÕ: VOVINAM - DĨ NHU CHẾ CƯƠNG"},{"time":"14:30","title":"CHECK IN VIỆT NAM: VIÊN NGỌC BIỂN KHƠI"},{"time":"14:30","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG"},{"time":"14:50","title":"HIỂU ĐÚNG - SỐNG KHỎE: ĐIỀU HÒA HUYẾT ÁP, BẢO VỆ SỨC KHỎE TIM MẠCH NHỜ SÂM DÂN GIAN"},{"time":"14:50","title":"HIỂU ĐÚNG - SỐNG KHỎE: TĂNG CƯỜNG SỨC KHỎE TIM MẠCH, ỔN ĐỊNH HUYẾT ÁP"},{"time":"14:50","title":"HIỂU ĐÚNG - SỐNG KHỎE"},{"time":"15:50","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: GỐM VIỆT - TẬP 3: GỐM BÀU TRÚC"},{"time":"15:50","title":"KINH TẾ KẾT NỐI"},{"time":"15:50","title":"PHỐ TÀI CHÍNH"},{"time":"16:05","title":"MUÔN MÀU CUỘC SỐNG: MÙA SEN HUẾ"},{"time":"16:05","title":"MUÔN MÀU CUỘC SỐNG"},{"time":"16:15","title":"PHIM TÀI LIỆU: THIÊN NHIÊN HOANG Dà PHONG NHA - KẺ BÀNG - PHẦN 1"},{"time":"16:15","title":"CHẤT LƯỢNG CUỘC SỐNG: CHỦ ĐỘNG PHÒNG NGỪA SỐT XUẤT HUYẾT TRONG MÙA CAO ĐIỂM"},{"time":"16:15","title":"PHIM TÀI LIỆU"},{"time":"16:45","title":"KINH TẾ KẾT NỐI"},{"time":"17:00","title":"SỐNG KHỎE: SUY GIẢM SINH LÝ NAM - PHƯƠNG PHÁP ĐIỀU TRỊ TỪ GỐC"},{"time":"17:00","title":"PHIM TÀI LIỆU: DÒNG NƯỚC KHÔNG LỐI VỀ"},{"time":"17:00","title":"HÀNH TRÌNH DI SẢN"},{"time":"17:45","title":"THẾ GIỚI THỂ THAO"},{"time":"18:00","title":"TRẠM 18H"},{"time":"18:30","title":"CHUYỂN ĐỘNG HÔM NAY"},{"time":"18:45","title":"THỂ THAO"},{"time":"18:50","title":"NHÂN TÀI ĐẠI VIỆT: LŨ CHIM QUÁI ÁC"},{"time":"18:50","title":"NHÂN TÀI ĐẠI VIỆT: ĐỘI QUÂN BÙ NHÌN"},{"time":"18:50","title":"NHÂN TÀI ĐẠI VIỆT"},{"time":"19:00","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 63"},{"time":"19:00","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 64"},{"time":"19:00","title":"PHIM TRUYỆN"},{"time":"19:30","title":"CA NHẠC: LỜI TRÁI TIM: KỂ CHUYỆN ĐÊM MƯA"},{"time":"19:30","title":"ĐI ĐỂ BIẾT: CHUYỆN Ở ĐẢO NHỎ THANH LÂN"},{"time":"19:30","title":"ATLAS"},{"time":"20:00","title":"TRUYỀN HÌNH TRỰC TIẾP: BẾ MẠC LIÊN HOAN PHIM CHÂU Á ĐÀ NẴNG LẦN THỨ IV"},{"time":"20:00","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"20:00","title":"DỰ BÁO THỜI TIẾT"},{"time":"20:05","title":"CHÉM GIÓ - GIÓ CHÉM: TÌNH YÊU TƯƠNG ĐỒNG"},{"time":"20:05","title":"KẾT NỐI VTV8"},{"time":"20:20","title":"ẨM THỰC ĐỘC ĐÁO: CÀ MUỐI HÀ TĨNH"},{"time":"20:20","title":"ẨM THỰC ĐỘC ĐÁO"},{"time":"20:25","title":"TÌNH CA BẤT HỦ: TÌNH KHÚC PHÚ QUANG"},{"time":"20:25","title":"PHIM TRUYỆN"},{"time":"21:05","title":"PHIM TÀI LIỆU: MỘT NĂM SẮP XẾP LẠI GIANG SƠN"},{"time":"21:10","title":"PHIM TRUYỆN"},{"time":"21:35","title":"GIẢI Mà SỨC KHỎE: XU HƯỚNG SỬ DỤNG THẢO DƯỢC TRONG HỖ TRỢ TĂNG CƯỜNG SINH LÝ NAM"},{"time":"21:35","title":"DẤU ẤN LỊCH SỬ"},{"time":"22:00","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"22:05","title":"DẤU ẤN LỊCH SỬ: CHUYỆN LÀNG TRONG VẬN NƯỚC"},{"time":"22:05","title":"VĂN HỌC - NGHỆ THUẬT"},{"time":"22:35","title":"MUÔN MÀU CUỘC SỐNG"},{"time":"22:40","title":"TRẠM 18H"},{"time":"23:10","title":"CHUYỂN ĐỘNG HÔM NAY"},{"time":"23:30","title":"KHÁM PHÁ THẾ GIỚI: BẢO TỒN CÁC LOÀI VẬT - TẬP 5"},{"time":"23:30","title":"KHÁM PHÁ THẾ GIỚI: NHỮNG ANH HÙNG ĐỜI THƯỜNG - TẬP 1"},{"time":"23:30","title":"KHÁM PHÁ THẾ GIỚI"}],"vtv9":[{"time":"00:00","title":"ĐƯỜNG DÂY NÓNG VTV9"},{"time":"00:00","title":"PHIM TRUYỆN"},{"time":"00:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32 ĐỘI - AUSTRALIA – AI CẬP"},{"time":"00:45","title":"PHIM TRUYỆN"},{"time":"01:30","title":"NẺO VỀ NGUỒN CỘI"},{"time":"01:45","title":"TIÊU ĐIỂM CHÍNH SÁCH"},{"time":"02:00","title":"VÌ NHÂN DÂN QUÊN MÌNH"},{"time":"02:15","title":"PHIM TÀI LIỆU"},{"time":"02:45","title":"THTT FIFA WORLD CUP 2026 - VÒNG 16 ĐỘI: BRAZIL VS NA UY"},{"time":"03:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8 - PARAGUAY - PHÁP"},{"time":"04:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32 ĐỘI - ARGENTINA - CAPE VERDE"},{"time":"05:25","title":"TÁM CÔNG SỞ"},{"time":"05:35","title":"CHUYỆN PHỐ PHƯỜNG"},{"time":"05:40","title":"THỊ TRƯỜNG 360 ĐỘ"},{"time":"05:45","title":"VÕ THUẬT TỔNG HỢP MMA"},{"time":"06:15","title":"CÂU CHUYỆN TỪ CUỘC SỐNG"},{"time":"06:25","title":"THÀNH PHỐ ẤM ÁP TÌNH NGƯỜI"},{"time":"06:30","title":"THTT FIFA WORLD CUP 2026 - VÒNG 16 ĐỘI: MEXICO VS ANH"},{"time":"07:35","title":"TIẾU LÂM DU KÝ"},{"time":"07:50","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32 ĐỘI - COLOMBIA - GHANA"},{"time":"07:50","title":"KÍNH ĐA CHIỀU"},{"time":"08:00","title":"NỘI THẤT KHÔNG GIỚI HẠN: NỘI THẤT TỐI GIẢN - GIÁ TRỊ CỦA KHÔNG GIAN"},{"time":"08:15","title":"CHỮA BỆNH CÙNG CHUYÊN GIA"},{"time":"09:00","title":"PHIM TÀI LIỆU: MỘT NĂM SẮP XẾP LẠI GIANG SƠN – TINH GỌN, GẦN DÂN, HIỆU QUẢ"},{"time":"09:20","title":"CÂU CHUYỆN TỪ CUỘC SỐNG"},{"time":"09:30","title":"CÂU CHUYỆN NHÂN VẬT"},{"time":"09:30","title":"ALO DOCTOR CUỐI TUẦN"},{"time":"10:00","title":"CUỐI TUẦN KỂ CHUYỆN"},{"time":"10:00","title":"VÕ THUẬT TỔNG HỢP MMA"},{"time":"10:30","title":"DẠO QUANH THỊ TRƯỜNG"},{"time":"10:45","title":"THỊ TRƯỜNG 360 ĐỘ"},{"time":"10:50","title":"GÓC NHÌN NGƯỜI TIÊU DÙNG"},{"time":"10:50","title":"THÀNH PHỐ ẤM ÁP TÌNH NGƯỜI"},{"time":"10:55","title":"CHUYỆN PHỐ PHƯỜNG"},{"time":"11:00","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI: NHỮNG ĐIỂM ĐẾN ẤN TƯỢNG TẠI VĨNH LONG"},{"time":"11:00","title":"PHIM TRUYỆN"},{"time":"11:20","title":"THUẬN VỢ THUẬN CHỒNG"},{"time":"11:30","title":"QUẢ CẦU THÔNG THÁI"},{"time":"11:50","title":"ALO DOCTOR"},{"time":"12:00","title":"PHƯƠNG NAM HÔM NAY"},{"time":"12:30","title":"PHIM TRUYỆN: NỮ BÁC SĨ TÂM LÝ - TẬP 32"},{"time":"12:30","title":"PHIM TRUYỆN: NỮ BÁC SĨ TÂM LÝ - TẬP 33"},{"time":"12:30","title":"PHIM TRUYỆN"},{"time":"13:15","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 24"},{"time":"13:15","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 25"},{"time":"13:15","title":"PHIM TRUYỆN"},{"time":"14:00","title":"ĐẤU TRƯỜNG ẨM THỰC NHÍ"},{"time":"14:00","title":"CHECK IN VIỆT NAM: VỀ VÙNG ĐẤT CỔ Ô DIÊN"},{"time":"14:00","title":"KỶ NIỆM THANH XUÂN"},{"time":"14:15","title":"CUỐI TUẦN KỂ CHUYỆN"},{"time":"14:15","title":"GAMESHOW NGƯỜI ĐỨNG THẲNG"},{"time":"14:20","title":"CHỊ EM GỠ RỐI"},{"time":"14:40","title":"GIA ĐÌNH HẾT SẢY"},{"time":"15:00","title":"NẺO VỀ NGUỒN CỘI: THANH ÂM CUNG ĐÌNH HUẾ"},{"time":"15:00","title":"NẺO VỀ NGUỒN CỘI"},{"time":"15:15","title":"BÍ MẬT THẾ KỶ"},{"time":"15:15","title":"BÍ MẬT THẾ KỶ: VƯỢT THÁI BÌNH DƯƠNG - PHẦN 4"},{"time":"15:15","title":"CHỮA BỆNH CÙNG CHUYÊN GIA"},{"time":"15:45","title":"ĐƯỜNG DÂY NÓNG VTV9"},{"time":"15:45","title":"PHÓNG SỰ: ỨNG DỤNG CÔNG NGHỆ HƯỚNG ĐẾN CHÍNH QUYỀN ĐÔ THỊ THÔNG MINH"},{"time":"16:00","title":"PHIM TRUYỆN: GẠO NẾP GẠO TẺ - PHẦN 2 - TẬP 10"},{"time":"16:00","title":"PHIM TRUYỆN: GẠO NẾP GẠO TẺ - PHẦN 2 - TẬP 11"},{"time":"16:00","title":"PHIM TRUYỆN"},{"time":"16:30","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI: CHUYỆN BÊN HỒ"},{"time":"16:30","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI: ĐẾN THĂM VÙNG ĐẤT ĐỒNG THÁP"},{"time":"16:30","title":"VIỆT NAM - 365 NGÀY THÚ VỊ"},{"time":"16:45","title":"VÌ NHÂN DÂN QUÊN MÌNH"},{"time":"16:45","title":"DẠO QUANH THỊ TRƯỜNG"},{"time":"16:45","title":"HÀNH TRÌNH NET ZERO"},{"time":"17:00","title":"NỘI THẤT KHÔNG GIỚI HẠN: XU HƯỚNG THIẾT KẾ NỘI THẤT KHÔNG GIAN BẾP HIỆN ĐẠI"},{"time":"17:00","title":"CANH TÁC THÔNG MINH"},{"time":"17:00","title":"VÌ NHÂN DÂN QUÊN MÌNH"},{"time":"17:15","title":"THỊ TRƯỜNG 360 ĐỘ"},{"time":"17:20","title":"ALO DOCTOR CUỐI TUẦN"},{"time":"17:20","title":"CÂU CHUYỆN NHÂN VẬT"},{"time":"17:20","title":"TRAI ĐẸP VÀO BẾP"},{"time":"17:50","title":"TÁM CÔNG SỞ: HỌP KÍN GIỜ HÀNH CHÍNH - TẬP 167"},{"time":"17:50","title":"TÁM CÔNG SỞ: HỌP KÍN GIỜ HÀNH CHÍNH - TẬP 168"},{"time":"17:50","title":"TÁM CÔNG SỞ"},{"time":"18:00","title":"TOÀN CẢNH 24H"},{"time":"18:30","title":"NHẬT KÝ FIFA WORLD CUP 2026"},{"time":"18:30","title":"TẦM NHÌN BẤT ĐỘNG SẢN"},{"time":"18:45","title":"PHIM TRUYỆN: ƯỚC MÌNH CÙNG BAY - TẬP 72"},{"time":"18:45","title":"PHIM TRUYỆN: ƯỚC MÌNH CÙNG BAY - TẬP 73"},{"time":"18:50","title":"KÍNH ĐA CHIỀU"},{"time":"19:00","title":"PHIM TRUYỆN"},{"time":"19:15","title":"ĐỜI RẤT ĐẸP"},{"time":"19:15","title":"KỶ NIỆM THANH XUÂN"},{"time":"19:30","title":"ĐỜI NGHỆ SĨ"},{"time":"19:30","title":"VÒNG XOAY LỐC XOÁY"},{"time":"19:35","title":"CHỊ EM GỠ RỐI"},{"time":"20:00","title":"TIẾU LÂM DU KÝ"},{"time":"20:00","title":"THỨC TỈNH TÂM HỒN: HIỂU LẦM CON"},{"time":"20:00","title":"CÂU CHUYỆN TỪ CUỘC SỐNG"},{"time":"20:10","title":"PHIM TRUYỆN"},{"time":"20:15","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 26"},{"time":"20:15","title":"PHIM TRUYỆN: ÂN OÁN TÌNH THÙ - TẬP 29"},{"time":"21:00","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 27"},{"time":"21:00","title":"PHIM TRUYỆN: ÂN OÁN TÌNH THÙ - TẬP 30"},{"time":"21:00","title":"ĐƯỜNG DÂY NÓNG VTV9"},{"time":"21:15","title":"PHIM TRUYỆN"},{"time":"21:45","title":"GAMESHOW NGƯỜI ĐỨNG THẲNG"},{"time":"21:45","title":"ĐỜI NGHỆ SỸ"},{"time":"22:00","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI"},{"time":"22:15","title":"ĐIỀU CON MUỐN NÓI"},{"time":"22:25","title":"CHECK IN VIỆT NAM: CỬU THÁC GIỮA ĐẠI NGÀN"},{"time":"22:30","title":"VIỆT NAM - 365 NGÀY THÚ VỊ"},{"time":"22:35","title":"MẢNH GHÉP HOÀN HẢO"},{"time":"22:40","title":"VÒNG XOAY LỐC XOÁY"},{"time":"22:45","title":"TRAI ĐẸP VÀO BẾP"},{"time":"23:00","title":"TIẾU LÂM DU KÝ"},{"time":"23:10","title":"BƯỚC CHÂN KHÁM PHÁ"},{"time":"23:15","title":"GIẢI Mà CUỘC SỐNG"},{"time":"23:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8 - CANADA - MOROCO"},{"time":"23:30","title":"ALO DOCTOR CUỐI TUẦN"},{"time":"23:30","title":"VÕ THUẬT TỔNG HỢP MMA"}],"vtv10":[{"time":"00:00","title":"TÌNH KHÚC VƯỢT THỜI GIAN - ĐÊM TÂM SỰ"},{"time":"00:20","title":"TRUYỀN HÌNH TRỰC TIẾP: WORLD CUP 2026: AUSTRALIA - AI CẬP"},{"time":"00:45","title":"DÂN CA NHẠC CỔ: DUYÊN DÁNG ĐỒNG BẰNG"},{"time":"00:45","title":"MẢNH GHÉP HOÀN HẢO - SỐ 102"},{"time":"01:15","title":"LẬP TRÌNH TRÁI TIM: NGƯỜI EM BẤT HẢO - PHẦN 1"},{"time":"01:15","title":"LẬP TRÌNH TRÁI TIM - NGƯỜI EM BẤT HẢO - PHẦN 2"},{"time":"01:30","title":"THỂ THAO: WORLD CUP 2026: TÂY BAN NHA - ÁO"},{"time":"01:30","title":"BẠN CỦA NHÀ NÔNG - SỐ 7"},{"time":"02:20","title":"TRUYỀN HÌNH TRỰC TIẾP - WORLD CUP 2026: BRAZIL - NA UY"},{"time":"03:00","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: VƯỜN QUỐC GIA CÔN ĐẢO - TẬP 1"},{"time":"03:30","title":"KÝ SỰ: VỀ LẠI CỨ ĐỊA XƯA - TẬP 5"},{"time":"03:45","title":"KÝ ỨC MIỀN TÂY: CỎ BÀNG BẤT TẬN"},{"time":"04:00","title":"PHIM TRUYỆN: CHÚNG TA PHẢI HẠNH PHÚC - TẬP 34"},{"time":"04:20","title":"LIÊN KẾT VÀ HỘI NHẬP: TÁI ĐỊNH VỊ DU LỊCH ĐỒNG BẰNG"},{"time":"04:45","title":"THỂ THAO"},{"time":"04:45","title":"THỂ THAO: KẾT NỐI THỂ THAO"},{"time":"05:15","title":"KÝ SỰ: VỀ LẠI CỨ ĐỊA XƯA - TẬP 5"},{"time":"05:15","title":"KÝ ỨC MIỀN TÂY: HỒI ĐÓ LẤM LEM"},{"time":"05:30","title":"CA NHẠC: CHƠI VƠI"},{"time":"05:30","title":"CA NHẠC: NỖI NHỚ DỊU ÊM"},{"time":"06:00","title":"BẢN TIN NÔNG NGHIỆP"},{"time":"06:10","title":"BẢN TIN THỂ THAO"},{"time":"06:15","title":"KÝ ỨC MIỀN TÂY: CỎ BÀNG BẤT TẬN"},{"time":"06:15","title":"NÔNG DÂN SỐ: SỐ 1"},{"time":"06:30","title":"PHIM HOẠT HÌNH: CHUYỆN CỔ TÍCH - PHẦN 3"},{"time":"06:30","title":"PHIM HOẠT HÌNH: CHUYỆN CỔ TÍCH - PHẦN 4"},{"time":"06:30","title":"THƯƠNG NHỚ MIỀN TÂY - NGHỀ SÔNG NƯỚC"},{"time":"06:45","title":"THẾ GIỚI QUANH TA - SỐ 27"},{"time":"07:00","title":"THỂ THAO: WORLD CUP 2026: TÂY BAN NHA - ÁO"},{"time":"07:00","title":"THỂ THAO: WORLD CUP 2026: ARGENTINA - CAPE VERDE"},{"time":"07:00","title":"THỂ THAO - WORLD CUP 2026: PARAGUAY - PHÁP"},{"time":"08:30","title":"PHIM TRUYỆN: NỮ HOÀNG XU HƯỚNG - TẬP 41"},{"time":"08:30","title":"PHIM TRUYỆN: NỮ HOÀNG XU HƯỚNG - TẬP 42"},{"time":"08:30","title":"NỮ HOÀNG XU HƯỚNG - TẬP 43"},{"time":"09:15","title":"SỐNG KHỎE MỖI NGÀY: ỔN ĐỊNH ĐƯỜNG HUYẾT"},{"time":"09:15","title":"SỐNG KHỎE MỖI NGÀY: BẢO VỆ TIM MẠCH Ở NGƯỜI CAO TUỔI"},{"time":"09:15","title":"SỐNG KHỎE MỖI NGÀY - PHÌ ĐẠI TUYẾN TIỀN LIỆT"},{"time":"10:15","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: VƯỜN QUỐC GIA CÔN ĐẢO - TẬP 1"},{"time":"10:15","title":"TẠP CHÍ KINH TẾ CUỐI TUẦN"},{"time":"10:15","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG - VƯỜN QUỐC GIA CÔN ĐẢO - TẬP 1"},{"time":"10:45","title":"PHÓNG SỰ: BÀI TOÁN CHO Y TẾ CƠ SỞ"},{"time":"10:45","title":"KÝ ỨC MIỀN TÂY: CỎ BÀNG BẤT TẬN"},{"time":"10:45","title":"MIỀN TÂY NĂNG ĐỘNG - VĨNH LONG - SỐ 5"},{"time":"11:00","title":"MIỀN TÂY HÔM NAY"},{"time":"11:30","title":"SÂN KHẤU - CẢI LƯƠNG: MỘT THỜI ĐỂ NHỚ"},{"time":"11:35","title":"PHIM TRUYỆN: CHÚNG TA PHẢI HẠNH PHÚC - TẬP 34"},{"time":"11:35","title":"ĐỪNG NÓI KHI YÊU - TẬP 1"},{"time":"12:15","title":"PHIM TRUYỆN: ANH TRAI NHÀ ĐỐI DIỆN - TẬP 14"},{"time":"12:15","title":"ANH TRAI NHÀ ĐỐI DIỆN - TẬP 15"},{"time":"13:00","title":"CHƯƠNG TRÌNH VỀ SỨC KHỎE: DƯỠNG TIM, BỔ NÃO"},{"time":"13:00","title":"TỌA ĐÀM: SỐT XUẤT HUYẾT NHẬN DIỆN SỚM, XỬ TRÍ ĐÚNG"},{"time":"13:00","title":"CHƯƠNG TRÌNH VỀ SỨC KHỎE"},{"time":"14:00","title":"THỂ THAO: KẾT NỐI THỂ THAO"},{"time":"14:00","title":"BẠN CỦA NHÀ NÔNG: SỐ 7"},{"time":"14:00","title":"THỂ THAO - GIỜ VÀNG THỂ THAO"},{"time":"14:30","title":"DÂN CA NHẠC CỔ: DUYÊN DÁNG ĐỒNG BẰNG"},{"time":"14:30","title":"DÂN CA NHẠC CỔ - MIỀN TÂY BAO THƯƠNG NHỚ"},{"time":"14:45","title":"PHÓNG SỰ: BÀI TOÁN CHO Y TẾ CƠ SỞ"},{"time":"15:00","title":"ĐẤT KHỎE – CÂY TRỒNG KHỎE: GIẢM CHI PHÍ, TĂNG NĂNG SUẤT, CHẤT LƯỢNG LÚA HÈ THU"},{"time":"15:00","title":"KHỎE CÙNG CHUYÊN GIA: PHÒNG NGỪA ĐỘT QUỴ"},{"time":"15:00","title":"KHỎE CÙNG CHUYÊN GIA - PHÒNG NGỪA ĐỘT QUỴ NÃO"},{"time":"16:00","title":"BẢN TIN NÔNG NGHIỆP"},{"time":"16:10","title":"XỔ SỐ KIẾN THIẾT: HẬU GIANG - LONG AN"},{"time":"16:10","title":"XỔ SỐ KIẾN THIẾT: KIÊN GIANG - TIỀN GIANG"},{"time":"16:10","title":"XỔ SỐ KIẾN THIẾT - ĐỒNG THÁP - CÀ MAU"},{"time":"16:40","title":"NHÀ NÔNG VÀ CÔNG NGHỆ: SỐ 27"},{"time":"16:45","title":"LẬP TRÌNH TRÁI TIM: NGƯỜI EM BẤT HẢO - PHẦN 1"},{"time":"16:45","title":"LẬP TRÌNH TRÁI TIM: NGƯỜI EM BẤT HẢO - PHẦN 2"},{"time":"16:45","title":"LẬP TRÌNH TRÁI TIM - NGƯỜI EM BẤT HẢO - PHẦN 3"},{"time":"17:00","title":"PHIM TRUYỆN: BA ƠI MẸ CÓ VỀ KHÔNG - TẬP 20"},{"time":"17:00","title":"PHIM TRUYỆN: BA ƠI MẸ CÓ VỀ KHÔNG - TẬP 21"},{"time":"17:00","title":"BA ƠI MẸ CÓ VỀ KHÔNG - TẬP 22"},{"time":"17:45","title":"CHƯƠNG TRÌNH THIẾU NHI: TRƯỞNG THÀNH CÙNG TIAN TIAN - TẬP 22"},{"time":"17:45","title":"CHƯƠNG TRÌNH THIẾU NHI: TRƯỞNG THÀNH CÙNG TIAN TIAN - TẬP 23"},{"time":"17:45","title":"KÝ SỰ - VỀ LẠI CỨ ĐỊA XƯA - TẬP 6"},{"time":"18:00","title":"MIỀN TÂY HÔM NAY"},{"time":"18:30","title":"THẾ GIỚI QUANH TA: SỐ 27"},{"time":"18:30","title":"360 ĐỘ MIỀN TÂY: SỐ 25"},{"time":"18:30","title":"BẢN TIN THỂ THAO"},{"time":"18:45","title":"THƯƠNG NHỚ MIỀN TÂY: NGHỀ SÔNG NƯỚC"},{"time":"18:45","title":"QUỐC PHÒNG TOÀN DÂN QK9: SỐ 27"},{"time":"18:45","title":"BIẾN ĐỔI KHÍ HẬU - TRẢ LẠI HƠI THỞ CHO TRÀM CHIM"},{"time":"19:00","title":"THỜI SỰ"},{"time":"19:45","title":"PHIM TRUYỆN: KẾ HOẠCH TRÁI TIM - TẬP 24"},{"time":"19:45","title":"TỌA ĐÀM: BẢO HIỂM Y TẾ - LÁ CHẮN SỨC KHỎE CHO MỌI NGƯỜI"},{"time":"19:45","title":"KẾ HOẠCH TRÁI TIM - TẬP 25"},{"time":"20:30","title":"PHIM HOẠT HÌNH: CHUYỆN CỔ TÍCH - PHẦN 4"},{"time":"20:30","title":"PHIM HOẠT HÌNH"},{"time":"20:45","title":"KÝ ỨC MIỀN TÂY: HỒI ĐÓ LẤM LEM"},{"time":"21:00","title":"NHẬT KÝ WORLD CUP 2026"},{"time":"21:15","title":"SỐNG KHỎE - ĐẸP: SỐ 40"},{"time":"21:15","title":"PHIM TÀI LIỆU: TƯỚNG VỀ HƯU VÀ 1.000 CĂN NHÀ ĐỒNG ĐỘI"},{"time":"21:15","title":"CẢNH GIÁC 247"},{"time":"21:45","title":"PHIM TRUYỆN: NHỮNG NẺO ĐƯỜNG GẦN XA - TẬP 23"},{"time":"21:45","title":"PHIM TRUYỆN: NHỮNG NẺO ĐƯỜNG GẦN XA - TẬP 24"},{"time":"21:45","title":"NHỮNG NẺO ĐƯỜNG GẦN XA - TẬP 25"},{"time":"22:30","title":"CHUYỆN CUỐI TUẦN: NSND KIM XUÂN"},{"time":"22:30","title":"MẢNH GHÉP HOÀN HẢO: SỐ 102"},{"time":"22:30","title":"PHIM TÀI LIỆU - TƯỚNG VỀ HƯU VÀ 1.000 CĂN NHÀ ĐỒNG ĐỘI"},{"time":"23:00","title":"TÌNH KHÚC VƯỢT THỜI GIAN: ĐÊM TÂM SỰ"},{"time":"23:00","title":"ĐỜI NGHỆ SỸ: NHẠC SỸ ĐÀI PHƯƠNG TRANG"},{"time":"23:00","title":"SẮC MÀU CÁC DÂN TỘC - CHUYỆN KỂ TỪ THANH ÂM"},{"time":"23:30","title":"THỂ THAO - KẾT NỐI THỂ THAO"},{"time":"23:45","title":"KÝ ỨC MIỀN TÂY: HỒI ĐÓ LẤM LEM"},{"time":"23:45","title":"THƯƠNG NHỚ MIỀN TÂY: DU LỊCH XUYÊN RỪNG XUYÊN ĐÊM"}]}
 
 
vtv_scraper.py CHANGED
@@ -1,11 +1,8 @@
1
  """
2
- VTV Channels Scraper - Optimized for stable streaming
3
- Fetches stream URLs from multiple CDN sources for VTV1-VTV10 + VTV Cần Thơ
4
  """
5
  import requests, re, time, threading
6
- from datetime import datetime, timedelta, timezone
7
-
8
- VN_TZ = timezone(timedelta(hours=17))
9
 
10
  UA = {
11
  "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",
@@ -13,9 +10,6 @@ UA = {
13
  "Referer": "https://hd.xemtv.net/",
14
  }
15
 
16
- # ===== PRIMARY CDN SOURCES (Optimized for stability) =====
17
- # Priority order: FPTPlay > VTVGo > MediaCDN
18
-
19
  XEMTV_PHP_ENDPOINTS = {
20
  "vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
21
  "vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
@@ -32,34 +26,30 @@ XEMTV_PHP_ENDPOINTS = {
32
  CHANNEL_NAMES = {
33
  "vtv1": "VTV1", "vtv2": "VTV2", "vtv3": "VTV3", "vtv4": "VTV4",
34
  "vtv5": "VTV5", "vtv6": "VTV6", "vtv7": "VTV7", "vtv8": "VTV8",
35
- "vtv9": "VTV9", "vtv10": "VTV10",
36
  }
37
 
38
- # ===== RELIABLE CDN BACKUPS (verified working URLs) =====
39
- CDN_STREAMS = {
40
- # FPTPlay - Primary (most stable)
41
- "vtv1": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
42
- "vtv2": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
43
- "vtv3": "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8",
44
- "vtv4": "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
45
- "vtv5": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
46
- "vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
47
- "vtv7": "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
48
- "vtv8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
49
- "vtv9": "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
50
- "vtv10": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
51
-
52
- # VTVGo Failover
53
- "vtv1_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
54
- "vtv2_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
55
- "vtv3_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
56
- "vtv4_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
57
- "vtv5_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
58
- "vtv6_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
59
- "vtv7_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
60
- "vtv8_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
61
- "vtv9_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
62
- "vtv10_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv10-manifest.m3u8",
63
  }
64
 
65
  _vtv_cache = {}
@@ -77,34 +67,22 @@ def _set_cache(key, data):
77
  _vtv_cache[key] = {'t': time.time(), 'd': data}
78
 
79
  def extract_m3u8_from_html(html):
80
- if not html: return None
81
- # Look for file: "..." pattern
82
  m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
83
  if m:
84
  url = m.group(1).strip()
85
- if len(url) > 20: return url
86
- # Look for direct m3u8 URL
87
  m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
88
  if m:
89
  url = m.group(1).strip()
90
- if len(url) > 20: return url
91
- return None
92
-
93
- def verify_cdn(url, referer="", timeout=8):
94
- """Quick verify CDN is working"""
95
- if not url: return None
96
- try:
97
- r = requests.get(url, headers={"User-Agent": UA["User-Agent"], "Referer": referer}, timeout=timeout, allow_redirects=True, verify=False)
98
- if r.status_code == 200 and '#EXTM3U' in r.text[:500]:
99
  return url
100
- except: pass
101
  return None
102
 
103
  def fetch_vtv_stream(channel_id):
104
- """Fetch VTV stream with priority order for maximum stability"""
105
  channel_id = channel_id.lower().strip()
106
-
107
- # Normalize channel ID
108
  name_map = {
109
  'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
110
  'vtv_can_tho': 'vtv10', 'cantho': 'vtv10', 'cần thơ': 'vtv10',
@@ -113,35 +91,41 @@ def fetch_vtv_stream(channel_id):
113
  'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
114
  }
115
  channel_id = name_map.get(channel_id, channel_id)
116
-
117
- # Try direct CDN URLs first (most stable)
118
- if channel_id in CDN_STREAMS:
119
- return CDN_STREAMS[channel_id]
120
-
121
- # Try PHP endpoints as fallback
 
122
  php_url = XEMTV_PHP_ENDPOINTS.get(channel_id)
123
  if php_url:
124
  try:
125
  r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True, verify=False)
126
  if r.status_code == 200:
127
  m3u8 = extract_m3u8_from_html(r.text)
128
- if m3u8: return m3u8
129
- except: pass
130
-
131
- # Try failover backup
132
- fb_key = f"{channel_id}_fb"
133
- if fb_key in CDN_STREAMS:
134
- return CDN_STREAMS[fb_key]
135
-
 
 
136
  return None
137
 
138
  def get_all_vtv_streams():
139
  channels = []
140
- for ch_id in CHANNEL_NAMES:
141
  stream_url = fetch_vtv_stream(ch_id)
142
  channels.append({
143
  'id': ch_id,
144
  'name': CHANNEL_NAMES.get(ch_id, ch_id.upper()),
145
  'stream_url': stream_url,
146
  })
147
- return channels
 
 
 
 
1
  """
2
+ VTV Channels Scraper
3
+ Fetches stream URLs from hd.xemtv.net PHP endpoints for VTV1-VTV10 + VTV Cần Thơ
4
  """
5
  import requests, re, time, threading
 
 
 
6
 
7
  UA = {
8
  "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",
 
10
  "Referer": "https://hd.xemtv.net/",
11
  }
12
 
 
 
 
13
  XEMTV_PHP_ENDPOINTS = {
14
  "vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
15
  "vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
 
26
  CHANNEL_NAMES = {
27
  "vtv1": "VTV1", "vtv2": "VTV2", "vtv3": "VTV3", "vtv4": "VTV4",
28
  "vtv5": "VTV5", "vtv6": "VTV6", "vtv7": "VTV7", "vtv8": "VTV8",
29
+ "vtv9": "VTV9", "vtv10": "VTV Cần Thơ",
30
  }
31
 
32
+ CDN_FALLBACK = {
33
+ "vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
34
+ "vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
35
+ "vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
36
+ "vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
37
+ "vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
38
+ "vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
39
+ "vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
40
+ "vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
41
+ "vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
42
+ "vtv10": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv10-manifest.m3u8",
43
+ "_fpt_vtv1": "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8",
44
+ "_fpt_vtv2": "https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8",
45
+ "_fpt_vtv3": "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8",
46
+ "_fpt_vtv4": "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8",
47
+ "_fpt_vtv5": "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8",
48
+ "_fpt_vtv6": "https://live.fptplay53.net/fnxch2/vtv6hd_abr.smil/chunklist.m3u8",
49
+ "_fpt_vtv7": "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8",
50
+ "_fpt_vtv8": "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8",
51
+ "_fpt_vtv9": "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8",
52
+ "_fpt_vtv10": "https://live.fptplay53.net/fnxch2/vtvcantho_abr.smil/chunklist.m3u8",
 
 
 
 
53
  }
54
 
55
  _vtv_cache = {}
 
67
  _vtv_cache[key] = {'t': time.time(), 'd': data}
68
 
69
  def extract_m3u8_from_html(html):
70
+ if not html:
71
+ return None
72
  m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
73
  if m:
74
  url = m.group(1).strip()
75
+ if len(url) > 20:
76
+ return url
77
  m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
78
  if m:
79
  url = m.group(1).strip()
80
+ if len(url) > 20:
 
 
 
 
 
 
 
 
81
  return url
 
82
  return None
83
 
84
  def fetch_vtv_stream(channel_id):
 
85
  channel_id = channel_id.lower().strip()
 
 
86
  name_map = {
87
  'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
88
  'vtv_can_tho': 'vtv10', 'cantho': 'vtv10', 'cần thơ': 'vtv10',
 
91
  'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
92
  }
93
  channel_id = name_map.get(channel_id, channel_id)
94
+ cached = _cached(channel_id)
95
+ if cached:
96
+ return cached
97
+ vtvgourl = CDN_FALLBACK.get(channel_id)
98
+ if vtvgourl:
99
+ _set_cache(channel_id, vtvgourl)
100
+ return vtvgourl
101
  php_url = XEMTV_PHP_ENDPOINTS.get(channel_id)
102
  if php_url:
103
  try:
104
  r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True, verify=False)
105
  if r.status_code == 200:
106
  m3u8 = extract_m3u8_from_html(r.text)
107
+ if m3u8:
108
+ _set_cache(channel_id, m3u8)
109
+ return m3u8
110
+ except:
111
+ pass
112
+ fpt_key = f"_fpt_{channel_id}"
113
+ fpt_url = CDN_FALLBACK.get(fpt_key)
114
+ if fpt_url:
115
+ _set_cache(channel_id, fpt_url)
116
+ return fpt_url
117
  return None
118
 
119
  def get_all_vtv_streams():
120
  channels = []
121
+ for ch_id, php_url in XEMTV_PHP_ENDPOINTS.items():
122
  stream_url = fetch_vtv_stream(ch_id)
123
  channels.append({
124
  'id': ch_id,
125
  'name': CHANNEL_NAMES.get(ch_id, ch_id.upper()),
126
  'stream_url': stream_url,
127
  })
128
+ return channels
129
+
130
+ XEMTV_CHANNELS = {v: k for k, v in CHANNEL_NAMES.items()}
131
+ CDN_STREAMS = {v: k for k, v in CDN_FALLBACK.items()}
yt_scraper_fixed.py DELETED
@@ -1,162 +0,0 @@
1
- """
2
- YouTube Shorts Scraper using yt-dlp (already installed on Space)
3
- Optimized for fast load with long cache + fallback
4
- """
5
- import subprocess
6
- import json
7
- import time
8
- import threading
9
- import os
10
- import re as re_mod
11
-
12
- _cache = {}
13
- _lock = threading.Lock()
14
- CACHE_TTL = 1800 # 30 min cache - longer to reduce timeout issues
15
-
16
- def _get_cached(key):
17
- """Get cached data if still valid"""
18
- with _lock:
19
- if key in _cache:
20
- entry = _cache[key]
21
- if time.time() - entry['t'] < CACHE_TTL:
22
- return entry['d']
23
- return None
24
-
25
- def _set_cached(key, data):
26
- """Set cache with timestamp"""
27
- with _lock:
28
- _cache[key] = {'t': time.time(), 'd': data}
29
-
30
- def run_yt_dlp(args, timeout=45):
31
- """Run yt-dlp and return parsed JSON lines - with shorter timeout"""
32
- try:
33
- result = subprocess.run(
34
- ["yt-dlp"] + args,
35
- capture_output=True, text=True, timeout=timeout
36
- )
37
- if result.returncode != 0 and not result.stdout.strip():
38
- return []
39
- lines = result.stdout.strip().split('\n')
40
- items = []
41
- for line in lines:
42
- line = line.strip()
43
- if not line:
44
- continue
45
- try:
46
- items.append(json.loads(line))
47
- except json.JSONDecodeError:
48
- continue
49
- return items
50
- except subprocess.TimeoutExpired:
51
- print("yt-dlp timeout (this is OK - using fallback)")
52
- return []
53
- except FileNotFoundError:
54
- print("yt-dlp not found - using fallback")
55
- return []
56
- except Exception as e:
57
- print(f"yt-dlp exception: {e}")
58
- return []
59
-
60
- def get_channel_shorts_fast(channel_username, max_count=25):
61
- """Get shorts fast - prioritize /shorts page only to avoid timeout"""
62
- shorts = []
63
-
64
- url = f"https://www.youtube.com/@{channel_username}/shorts"
65
- items = run_yt_dlp([
66
- "--dump-json",
67
- "--flat-playlist",
68
- "--no-download",
69
- "--playlist-end", str(max_count),
70
- "--no-check-certificates",
71
- "--quiet", # Reduce output for speed
72
- "--no-warnings",
73
- url
74
- ], timeout=35) # Increased timeout but still reasonable
75
-
76
- seen_ids = set()
77
- for item in items:
78
- vid = item.get('id', '')
79
- if not vid or vid in seen_ids:
80
- continue
81
- seen_ids.add(vid)
82
-
83
- title = item.get('title', f'{channel_username} Short')
84
-
85
- shorts.append({
86
- 'id': vid,
87
- 'title': title,
88
- 'channel': channel_username,
89
- 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
90
- })
91
-
92
- if len(shorts) >= max_count:
93
- break
94
-
95
- return shorts
96
-
97
- def get_dantri_shorts(max_count=25):
98
- """Get Dantri shorts - fast, no fallback needed, cache for 30min"""
99
- cached = _get_cached('dantri_shorts_yt')
100
- if cached is not None:
101
- return cached
102
-
103
- shorts = get_channel_shorts_fast('baodantri7941', max_count)
104
-
105
- if shorts:
106
- _set_cached('dantri_shorts_yt', shorts)
107
- return shorts
108
-
109
- # Fallback to static list if scrape fails
110
- return [
111
- {"id":"Lu_iCQ5YwNM","title":"Công an lập hồ sơ xử lý người phụ nữ chửi bới tát nam tài xế ô tô ở Hà Nội","channel":"baodantri7941"},
112
- {"id":"CwWvijF8BOA","title":"Chú rể Ninh Bình bật khóc nhận món quà bí mật người cha","channel":"baodantri7941"},
113
- ]
114
-
115
- def get_skds_shorts(max_count=25):
116
- """Get SKĐS shorts - fast, no fallback needed, cache for 30min"""
117
- cached = _get_cached('skds_shorts_yt')
118
- if cached is not None:
119
- return cached
120
-
121
- shorts = get_channel_shorts_fast('baosuckhoedoisongboyte', max_count)
122
-
123
- if shorts:
124
- _set_cached('skds_shorts_yt', shorts)
125
- return shorts
126
-
127
- # Fallback to static list if scrape fails
128
- return [
129
- {"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tìm kiếm 5 học sinh tử vong","channel":"baosuckhoedoisongboyte"},
130
- {"id":"SlHLt_ZyPiE","title":"Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc Nam","channel":"baosuckhoedoisongboyte"},
131
- ]
132
-
133
- def get_dantri_skds_shorts(max_count=50):
134
- """Get interleaved Dantri + SKĐS shorts - optimized with separate caching"""
135
- # Get each channel's shorts separately (allows partial fallback)
136
- dantri = get_dantri_shorts(max_count // 2 + 10)
137
- skds = get_skds_shorts(max_count // 2 + 10)
138
-
139
- # Interleave them
140
- result = []
141
- seen = set()
142
- i, j = 0, 0
143
-
144
- while (i < len(dantri) or j < len(skds)) and len(result) < max_count:
145
- if i < len(dantri):
146
- item = dantri[i]
147
- if item.get('id') not in seen:
148
- seen.add(item.get('id'))
149
- result.append(item)
150
- i += 1
151
- if j < len(skds):
152
- item = skds[j]
153
- if item.get('id') not in seen:
154
- seen.add(item.get('id'))
155
- result.append(item)
156
- j += 1
157
-
158
- return result
159
-
160
- # For backward compatibility
161
- get_vtvnambo_shorts = get_dantri_skds_shorts
162
- get_wc_related_shorts = get_dantri_skds_shorts