bep40 commited on
Commit
effcb01
·
1 Parent(s): 939b41e

Restore main to commit 0cc342c (#17)

Browse files

- Upload main.py (086fe3ad0e052b0e0ded65187c9533bbd244197a)
- Restore README.md from 0cc342c (5c187791c671b742e48ec45d546e40dec5dced33)
- Restore app_v2_entry.py from 0cc342c (48dd5bcc1a710520531c708ce3c333a9e6799b80)
- Restore main.py from 0cc342c (ed786d76259fc9d08bafe72d472e08e63d74effa)
- Restore static/app_v2.js from 0cc342c (ce16b6052f38c50a4d32bd6ed8788f2d66fb3357)
- Restore static/index_v2.html from 0cc342c (e9642a30a475801db0302798a9366ee2dcc37883)
- Restore static/wc_shorts_inject.js from 0cc342c (90e304f837cb3b149d906eac95b089870a14cb33)
- Restore static/wc_shorts_slide.js from 0cc342c (fadc98259762ee58607328ffbc6fb5acc851378e)
- Restore static/yt_live.js from 0cc342c (1b5364650afc6a29b0ce5941f6a16dcdbadb6807)
- Restore vtv_api.py from 0cc342c (14679a6bf84446832cbe53ac8d74bad689c88ab2)
- Restore yt_scraper.py from 0cc342c (4ca19c39190a4c64f279cc348b55daef705544b3)

README.md CHANGED
@@ -1,9 +1,9 @@
1
  ---
2
- sdk: docker
3
  title: VNEWS
4
  emoji: 📰
5
  colorFrom: green
6
  colorTo: yellow
 
7
  pinned: false
8
  tags:
9
  - ml-intern
 
1
  ---
 
2
  title: VNEWS
3
  emoji: 📰
4
  colorFrom: green
5
  colorTo: yellow
6
+ sdk: docker
7
  pinned: false
8
  tags:
9
  - ml-intern
app_v2_entry.py CHANGED
@@ -350,11 +350,7 @@ def _scrape_generic(url):
350
 
351
  @app.get('/api/article')
352
  def api_article_v2(url:str=Query(...)):
353
- from main import scrape_vne as scrape_vne_article, scrape_genk_ai as scrape_genk_article
354
- # Stubs for missing functions
355
- def scrape_bbc_article(url): return scrape_vne_article(url)
356
- def scrape_dantri_article(url): return scrape_vne_article(url)
357
- def scrape_ttvh_article(url): return scrape_vne_article(url)
358
  if 'vnexpress.net' in url:data=scrape_vne_article(url)
359
  elif 'bbc.com' in url:data=scrape_bbc_article(url)
360
  elif 'dantri.com.vn' in url:data=scrape_dantri_article(url)
 
350
 
351
  @app.get('/api/article')
352
  def api_article_v2(url:str=Query(...)):
353
+ from main import scrape_vne_article,scrape_bbc_article,scrape_dantri_article,scrape_genk_article,scrape_ttvh_article
 
 
 
 
354
  if 'vnexpress.net' in url:data=scrape_vne_article(url)
355
  elif 'bbc.com' in url:data=scrape_bbc_article(url)
356
  elif 'dantri.com.vn' in url:data=scrape_dantri_article(url)
static/app_v2.js CHANGED
@@ -21,22 +21,22 @@ async function loadHome(){
21
  +'<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>'
22
  +'<div id="hashtag-box"></div>'
23
  +'<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>'
24
-
25
  +'<div id="home-after-wc"></div>';
26
 
27
  const afterEl = homeEl.querySelector('#home-after-wc');
28
 
29
  loadLivescore('today');
30
- if(window.loadHotTopics){window.loadHotTopics();}else{loadHotTopics();}
31
 
32
- // Shorts load separately don't block main render
33
- _fetchWithTimeout('/api/shorts', 3000).then(d=>{_shortsData=d||[];_renderShortsIn(afterEl);}).catch(()=>{_shortsData=[];});
34
-
35
- const [featuredData, wallData, hlLeagues, aiData] = await Promise.allSettled([
36
- _fetchWithTimeout('/api/livescore/featured', 4000),
37
- _fetchWithTimeout('/api/wall', 4000),
38
- _fetchWithTimeout('/api/highlights/leagues', 6000),
39
- _fetchWithTimeout('/api/genk_ai', 4000),
40
  ]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
41
 
42
  if(featuredData && featuredData.home){
@@ -46,15 +46,19 @@ async function loadHome(){
46
  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>`;
47
  }
48
 
 
49
  _wallPosts = (wallData && wallData.posts) || [];
50
  _hlLeagueData = hlLeagues || {};
 
 
 
 
51
 
 
52
  _renderWallIn(afterEl);
53
  _renderHLIn(afterEl);
54
  if(aiData && aiData.length) _renderSlidesIn('ai-articles','Ứng dụng AI','🤖',aiData,afterEl);
55
  }
56
- window.loadHome = loadHome;
57
-
58
 
59
  function _renderSlidesIn(key, label, emoji, vids, afterEl){
60
  if(!vids||!vids.length||!afterEl) return;
@@ -212,6 +216,7 @@ function prependWallPost(post){
212
 
213
  let _shortsData=[];
214
  let _wallPosts=[];
 
215
  let _currentView='home';
216
  let _currentEventId=null;
217
  let _currentMatchUrl=null;
@@ -332,9 +337,4 @@ async function loadCat(id){const el=document.getElementById('view-cat');el.inner
332
  fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){const home=document.getElementById('view-home');if(home){const w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật.';home.prepend(w)}}}).catch(()=>{});
333
  (function(){try{var hash=window.location.hash;if(hash&&hash.length>1){var articleUrl=decodeURIComponent(hash.substring(1));if(articleUrl.startsWith('http')){history.replaceState(null,'',window.location.pathname);setTimeout(function(){if(typeof readArticle==='function')readArticle(articleUrl);},1500);}}}catch(e){}})();
334
  (function(){try{const pa=localStorage.getItem('pending_article');const pv=localStorage.getItem('pending_video');if(pa){localStorage.removeItem('pending_article');setTimeout(()=>{if(typeof readArticle==='function')readArticle(pa);},1500);}if(pv){localStorage.removeItem('pending_video');try{const v=JSON.parse(pv);if(v&&v.url)setTimeout(()=>{window.open(v.url,'_blank')},1500);}catch(e){}}}catch(e){}})();
335
- // Auto-call loadHome on first load via window (respects yt_live.js override)
336
- if(document.readyState==='loading'){
337
- document.addEventListener('DOMContentLoaded',function(){if(typeof window.loadHome==='function')window.loadHome();});
338
- }else{
339
- if(typeof window.loadHome==='function')window.loadHome();
340
- }
 
21
  +'<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>'
22
  +'<div id="hashtag-box"></div>'
23
  +'<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>'
24
+ +'<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(\'shorts\')">📱 Shorts</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>'
25
  +'<div id="home-after-wc"></div>';
26
 
27
  const afterEl = homeEl.querySelector('#home-after-wc');
28
 
29
  loadLivescore('today');
30
+ loadHotTopics();
31
 
32
+ const [featuredData, shortsData, wallData, hlLeagues, aiData, wcData, vtvShortsData] = await Promise.allSettled([
33
+ _fetchWithTimeout('/api/livescore/featured', 5000),
34
+ _fetchWithTimeout('/api/shorts', 8000),
35
+ _fetchWithTimeout('/api/wall', 5000),
36
+ _fetchWithTimeout('/api/highlights/leagues', 10000),
37
+ _fetchWithTimeout('/api/genk_ai', 8000),
38
+ _fetchWithTimeout('/api/wc2026', 8000),
39
+ _fetchWithTimeout('/api/shorts/vtvnamo?count=50', 12000),
40
  ]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
41
 
42
  if(featuredData && featuredData.home){
 
46
  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>`;
47
  }
48
 
49
+ _shortsData = shortsData || [];
50
  _wallPosts = (wallData && wallData.posts) || [];
51
  _hlLeagueData = hlLeagues || {};
52
+ _wc2026Data = wcData;
53
+ _wcShortsData = vtvShortsData || [];
54
+
55
+ if(wcData) switchWCTab('news');
56
 
57
+ _renderShortsIn(afterEl);
58
  _renderWallIn(afterEl);
59
  _renderHLIn(afterEl);
60
  if(aiData && aiData.length) _renderSlidesIn('ai-articles','Ứng dụng AI','🤖',aiData,afterEl);
61
  }
 
 
62
 
63
  function _renderSlidesIn(key, label, emoji, vids, afterEl){
64
  if(!vids||!vids.length||!afterEl) return;
 
216
 
217
  let _shortsData=[];
218
  let _wallPosts=[];
219
+ let _wcShortsData=[];
220
  let _currentView='home';
221
  let _currentEventId=null;
222
  let _currentMatchUrl=null;
 
337
  fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){const home=document.getElementById('view-home');if(home){const w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật.';home.prepend(w)}}}).catch(()=>{});
338
  (function(){try{var hash=window.location.hash;if(hash&&hash.length>1){var articleUrl=decodeURIComponent(hash.substring(1));if(articleUrl.startsWith('http')){history.replaceState(null,'',window.location.pathname);setTimeout(function(){if(typeof readArticle==='function')readArticle(articleUrl);},1500);}}}catch(e){}})();
339
  (function(){try{const pa=localStorage.getItem('pending_article');const pv=localStorage.getItem('pending_video');if(pa){localStorage.removeItem('pending_article');setTimeout(()=>{if(typeof readArticle==='function')readArticle(pa);},1500);}if(pv){localStorage.removeItem('pending_video');try{const v=JSON.parse(pv);if(v&&v.url)setTimeout(()=>{window.open(v.url,'_blank')},1500);}catch(e){}}}catch(e){}})();
340
+ if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',function(){if(typeof loadHome==='function')loadHome();});}else{if(typeof loadHome==='function')loadHome();}
 
 
 
 
 
static/index_v2.html CHANGED
@@ -30,7 +30,7 @@
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');if(typeof loadHome==='function')loadHome();}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)});}
@@ -39,8 +39,11 @@ var SPACE=location.origin;
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/live_mode.js"></script>
43
  <script src="/static/match_detail_v6.js"></script>
 
 
44
  <script>init();</script>
45
  </body>
46
  </html>
 
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)});}
 
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/wc_shorts_slide.js"></script>
46
+ <script src="/static/wc_shorts_inject.js"></script>
47
  <script>init();</script>
48
  </body>
49
  </html>
static/wc_shorts_inject.js CHANGED
@@ -1 +1,76 @@
1
- // removed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * WC Shorts Inject — Chạy SAU app_v2.js
3
+ * Tự tạo anchor DIV rồi render WC Shorts slide TRÊN World Cup section
4
+ */
5
+ (function(){
6
+ 'use strict';
7
+
8
+ function inject(){
9
+ // Tìm WC section
10
+ const wcSection = document.getElementById('wc2026-live-section');
11
+ const homeEl = document.getElementById('view-home');
12
+ if(!homeEl) return;
13
+
14
+ // Đã inject?
15
+ if(document.getElementById('wc-shorts-slide-section')) return;
16
+
17
+ // Đảm bảo renderWCSSlideSection đã load
18
+ if(typeof renderWCSSlideSection !== 'function'){
19
+ // Chờ rồi thử lại
20
+ setTimeout(inject, 500);
21
+ return;
22
+ }
23
+
24
+ // Tạo container rồi render vào
25
+ const div = document.createElement('div');
26
+ div.id = 'wc-shorts-slide-container-tmp';
27
+ div.style.display = 'none';
28
+
29
+ // Insert TRƯỚC WC section, hoặc vào đầu home
30
+ if(wcSection && wcSection.parentNode){
31
+ wcSection.parentNode.insertBefore(div, wcSection);
32
+ } else {
33
+ homeEl.insertBefore(div, homeEl.firstChild);
34
+ }
35
+
36
+ // Render slide section vào div
37
+ renderWCSSlideSection(div);
38
+
39
+ // Bỏ wrapper thừa, di chuyển section ra đúng vị trí
40
+ const section = div.querySelector('.wc-shorts-slide-section');
41
+ if(section){
42
+ section.id = 'wc-shorts-slide-section';
43
+ if(wcSection && wcSection.parentNode){
44
+ wcSection.parentNode.insertBefore(section, wcSection);
45
+ }
46
+ div.remove();
47
+ }
48
+ }
49
+
50
+ // Chạy khi DOM sẵn sàng
51
+ if(document.readyState === 'loading'){
52
+ document.addEventListener('DOMContentLoaded', ()=> setTimeout(inject, 800));
53
+ } else {
54
+ setTimeout(inject, 800);
55
+ }
56
+
57
+ // Cũng chạy sau khi loadHome xong
58
+ const origLoadHome = window.loadHome;
59
+ if(origLoadHome){
60
+ window.loadHome = function(){
61
+ const result = origLoadHome.apply(this, arguments);
62
+ setTimeout(inject, 200);
63
+ setTimeout(inject, 800);
64
+ setTimeout(inject, 2000);
65
+ return result;
66
+ };
67
+ }
68
+
69
+ // Fallback: retry nhiều lần
70
+ let retries = 0;
71
+ const retryIv = setInterval(()=>{
72
+ retries++;
73
+ inject();
74
+ if(retries > 20) clearInterval(retryIv);
75
+ }, 1000);
76
+ })();
static/wc_shorts_slide.js CHANGED
@@ -1 +1,597 @@
1
- // removed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * WC Shorts Slide — A standalone slide section ABOVE the World Cup section
3
+ * TikTok-style vertical feed with autoplay, titles, interaction buttons
4
+ * Each short = 1 fullscreen slide with scroll-snap
5
+ */
6
+ (function(){
7
+ 'use strict';
8
+
9
+ // ===== Inject CSS =====
10
+ if(document.getElementById('wc-shorts-slide-css')) return;
11
+ const style = document.createElement('style');
12
+ style.id = 'wc-shorts-slide-css';
13
+ style.textContent = `
14
+ /* === WC SHORTS SLIDE SECTION (above WC section) === */
15
+ .wc-shorts-slide-section {
16
+ margin: 6px 4px;
17
+ background: linear-gradient(135deg, #000 0%, #0a0a2e 50%, #0d1117 100%);
18
+ border: 1px solid #1a3a5a;
19
+ border-radius: 10px;
20
+ overflow: hidden;
21
+ }
22
+ .wc-shorts-slide-header {
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: space-between;
26
+ padding: 10px 12px 6px;
27
+ }
28
+ .wc-shorts-slide-header h3 {
29
+ font-size: 15px;
30
+ color: #fff;
31
+ font-weight: 800;
32
+ margin: 0;
33
+ display: flex;
34
+ align-items: center;
35
+ gap: 6px;
36
+ }
37
+ .wc-shorts-slide-header h3 .emoji { font-size: 18px; }
38
+ .wc-shorts-slide-header .badge-live {
39
+ font-size: 9px;
40
+ color: #e74c3c;
41
+ font-weight: 700;
42
+ animation: wc-slide-pulse 1.5s infinite;
43
+ }
44
+ @keyframes wc-slide-pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
45
+ .wc-shorts-slide-header .view-all-btn {
46
+ background: #0b6bcb;
47
+ border: none;
48
+ color: #fff;
49
+ padding: 5px 12px;
50
+ border-radius: 12px;
51
+ font-size: 10px;
52
+ font-weight: 700;
53
+ cursor: pointer;
54
+ white-space: nowrap;
55
+ }
56
+ .wc-shorts-slide-header .view-all-btn:active { opacity: .8; }
57
+
58
+ /* Horizontal thumbnail slider */
59
+ .wc-shorts-h-slider {
60
+ display: flex;
61
+ overflow-x: auto;
62
+ gap: 8px;
63
+ padding: 4px 10px 12px;
64
+ scroll-snap-type: x mandatory;
65
+ scrollbar-width: none;
66
+ }
67
+ .wc-shorts-h-slider::-webkit-scrollbar { display: none; }
68
+ .wc-shorts-h-item {
69
+ flex: 0 0 130px;
70
+ scroll-snap-align: start;
71
+ cursor: pointer;
72
+ border-radius: 8px;
73
+ overflow: hidden;
74
+ background: #0a1520;
75
+ border: 1px solid #1a3a5a;
76
+ transition: border-color .2s, transform .2s;
77
+ position: relative;
78
+ }
79
+ .wc-shorts-h-item:hover { border-color: #0b6bcb; transform: scale(1.03); }
80
+ .wc-shorts-h-item:active { transform: scale(.97); }
81
+ .wc-shorts-h-thumb {
82
+ position: relative;
83
+ width: 100%;
84
+ aspect-ratio: 9/16;
85
+ background: #1a2a3a;
86
+ overflow: hidden;
87
+ }
88
+ .wc-shorts-h-thumb img {
89
+ width: 100%; height: 100%; object-fit: cover;
90
+ }
91
+ .wc-shorts-h-thumb .h-play-icon {
92
+ position: absolute; left: 50%; top: 50%;
93
+ transform: translate(-50%, -50%);
94
+ width: 36px; height: 36px;
95
+ border-radius: 50%;
96
+ background: rgba(0,0,0,.6);
97
+ display: flex; align-items: center; justify-content: center;
98
+ color: #fff; font-size: 14px;
99
+ }
100
+ .wc-shorts-h-thumb .h-duration {
101
+ position: absolute; bottom: 4px; right: 4px;
102
+ background: rgba(0,0,0,.7);
103
+ font-size: 8px; color: #fff;
104
+ padding: 1px 4px; border-radius: 3px;
105
+ }
106
+ .wc-shorts-h-thumb .h-vtv-badge {
107
+ position: absolute; top: 4px; left: 4px;
108
+ background: rgba(11,107,203,.85);
109
+ font-size: 7px; color: #fff;
110
+ padding: 1px 5px; border-radius: 3px;
111
+ font-weight: 700;
112
+ }
113
+ .wc-shorts-h-info { padding: 5px 6px; }
114
+ .wc-shorts-h-info .h-title {
115
+ font-size: 9px; color: #ccc; line-height: 1.25;
116
+ display: -webkit-box; -webkit-line-clamp: 2;
117
+ -webkit-box-orient: vertical; overflow: hidden;
118
+ }
119
+ .wc-shorts-empty-slider {
120
+ text-align: center; padding: 20px 10px; color: #666; font-size: 12px;
121
+ width: 100%;
122
+ }
123
+ .wc-shorts-empty-slider .reload-btn {
124
+ background: #0b6bcb; border: none; color: #fff;
125
+ padding: 6px 16px; border-radius: 14px; font-size: 11px;
126
+ font-weight: 700; cursor: pointer; margin-top: 10px;
127
+ }
128
+
129
+ /* === FULL-SCREEN TIKTOK OVERLAY (opened from slide) === */
130
+ .wc-st-overlay {
131
+ position: fixed; inset: 0; background: #000;
132
+ z-index: 99999; display: none; flex-direction: column;
133
+ }
134
+ .wc-st-overlay.active { display: flex; }
135
+ .wc-st-top-bar {
136
+ position: absolute; top: 0; left: 0; right: 0;
137
+ z-index: 10; padding: 10px 12px;
138
+ display: flex; align-items: center; justify-content: space-between;
139
+ background: linear-gradient(rgba(0,0,0,.7), transparent);
140
+ }
141
+ .wc-st-top-bar .back-btn {
142
+ background: rgba(255,255,255,.15);
143
+ border: none; color: #fff;
144
+ width: 36px; height: 36px; border-radius: 50%;
145
+ font-size: 18px; cursor: pointer;
146
+ display: flex; align-items: center; justify-content: center;
147
+ }
148
+ .wc-st-top-bar .st-counter {
149
+ color: #fff; font-size: 12px; font-weight: 600;
150
+ background: rgba(0,0,0,.4); padding: 3px 10px;
151
+ border-radius: 10px; backdrop-filter: blur(4px);
152
+ }
153
+
154
+ .wc-st-feed {
155
+ flex: 1; overflow-y: scroll;
156
+ scroll-snap-type: y mandatory;
157
+ scrollbar-width: none;
158
+ }
159
+ .wc-st-feed::-webkit-scrollbar { display: none; }
160
+
161
+ .wc-st-slide {
162
+ height: 100vh; width: 100%;
163
+ scroll-snap-align: start;
164
+ position: relative;
165
+ background: #000;
166
+ display: flex; align-items: center; justify-content: center;
167
+ overflow: hidden;
168
+ }
169
+ .wc-st-slide iframe {
170
+ width: 100%; height: 100%; border: none; background: #000;
171
+ }
172
+
173
+ /* Title & info overlay */
174
+ .wc-st-info {
175
+ position: absolute; bottom: 0; left: 0; right: 65px;
176
+ padding: 12px 14px 18px;
177
+ background: linear-gradient(transparent 0%, rgba(0,0,0,.65) 35%, rgba(0,0,0,.9) 100%);
178
+ z-index: 3; pointer-events: none;
179
+ }
180
+ .wc-st-info .st-channel {
181
+ display: inline-block; font-size: 9px; font-weight: 700;
182
+ padding: 2px 8px; border-radius: 10px; color: #fff;
183
+ background: #0b6bcb; margin-bottom: 8px;
184
+ }
185
+ .wc-st-info .st-title {
186
+ font-size: 14px; color: #fff; font-weight: 700;
187
+ line-height: 1.35; text-shadow: 0 1px 4px rgba(0,0,0,.7);
188
+ display: -webkit-box; -webkit-line-clamp: 3;
189
+ -webkit-box-orient: vertical; overflow: hidden;
190
+ }
191
+ .wc-st-info .st-link {
192
+ display: inline-block; margin-top: 8px;
193
+ font-size: 11px; color: #8ab4d8; text-decoration: underline;
194
+ pointer-events: auto; cursor: pointer;
195
+ }
196
+
197
+ /* Right-side action buttons */
198
+ .wc-st-actions {
199
+ position: absolute; right: 8px; bottom: 90px;
200
+ display: flex; flex-direction: column; align-items: center;
201
+ gap: 16px; z-index: 5;
202
+ }
203
+ .wc-st-action {
204
+ display: flex; flex-direction: column; align-items: center; gap: 2px;
205
+ background: none; border: none; color: #fff; cursor: pointer;
206
+ font-size: 10px; text-shadow: 0 1px 3px rgba(0,0,0,.5);
207
+ }
208
+ .wc-st-action .a-icon {
209
+ width: 46px; height: 46px; border-radius: 50%;
210
+ background: rgba(255,255,255,.15);
211
+ display: flex; align-items: center; justify-content: center;
212
+ font-size: 20px; backdrop-filter: blur(4px);
213
+ transition: background .2s, transform .15s;
214
+ }
215
+ .wc-st-action:active .a-icon { transform: scale(.88); background: rgba(255,255,255,.3); }
216
+ .wc-st-action .a-count { font-size: 10px; color: #eee; font-weight: 600; }
217
+
218
+ /* Loading state */
219
+ .wc-st-loading {
220
+ position: absolute; inset: 0; display: flex;
221
+ flex-direction: column; align-items: center; justify-content: center;
222
+ gap: 12px; color: #888; font-size: 13px;
223
+ }
224
+ .wc-st-loading .spinner {
225
+ width: 28px; height: 28px;
226
+ border: 3px solid #333; border-top-color: #0b6bcb;
227
+ border-radius: 50%; animation: wcst-spin .8s linear infinite;
228
+ }
229
+ @keyframes wcst-spin { to { transform: rotate(360deg); } }
230
+
231
+ /* No shorts message */
232
+ .wc-st-nosho {
233
+ position: absolute; inset: 0; display: flex;
234
+ flex-direction: column; align-items: center; justify-content: center;
235
+ gap: 10px; color: #666; font-size: 14px; text-align: center; padding: 20px;
236
+ }
237
+ .wc-st-nosho .icon { font-size: 48px; }
238
+ .wc-st-nosho .retry-btn {
239
+ background: #0b6bcb; border: none; color: #fff;
240
+ padding: 10px 28px; border-radius: 20px;
241
+ font-size: 13px; font-weight: 700; cursor: pointer;
242
+ }
243
+ `;
244
+ document.head.appendChild(style);
245
+
246
+ // ===== State =====
247
+ let _wcSCached = null; // cached shorts data
248
+ let _wcSOverlayTimer = null; // auto-advance timer
249
+ let _wcSManualScroll = false;
250
+
251
+ // ===== Fetch shorts =====
252
+ async function fetchShorts(){
253
+ const endpoints = [
254
+ '/api/shorts/wc?count=50',
255
+ '/api/shorts/vtvnamo?count=50',
256
+ '/api/shorts?channel=vtvnambo',
257
+ ];
258
+ for(const ep of endpoints){
259
+ try{
260
+ const r = await fetch(ep, {signal: AbortSignal.timeout(12000)});
261
+ const data = await r.json();
262
+ if(Array.isArray(data) && data.length > 0){
263
+ // Deduplicate
264
+ const seen = new Set(), deduped = [];
265
+ for(const s of data){
266
+ if(!seen.has(s.id)){ seen.add(s.id); deduped.push(s); }
267
+ }
268
+ if(deduped.length > 0) return deduped;
269
+ }
270
+ }catch(e){ console.warn('WC Shorts fetch failed for', ep, e); }
271
+ }
272
+ return [];
273
+ }
274
+
275
+ // ===== Render the HORIZONTAL SLIDE (placed above WC section) =====
276
+ window.renderWCSSlideSection = async function(container){
277
+ const section = document.createElement('div');
278
+ section.className = 'wc-shorts-slide-section';
279
+ section.id = 'wc-shorts-slide-section';
280
+
281
+ // Header
282
+ section.innerHTML = `
283
+ <div class="wc-shorts-slide-header">
284
+ <h3><span class="emoji">📱</span> Shorts World Cup <span class="badge-live">● LIVE</span></h3>
285
+ <button class="view-all-btn" id="wc-st-viewall">Xem tất cả →</button>
286
+ </div>
287
+ <div class="wc-shorts-h-slider" id="wc-st-hslider">
288
+ <div style="width:100%;text-align:center;padding:20px;color:#666;font-size:12px">
289
+ <div class="spinner" style="width:20px;height:20px;border:2px solid #333;border-top-color:#0b6bcb;border-radius:50%;animation:wcst-spin .8s linear infinite;margin:0 auto 8px"></div>
290
+ Đang tải shorts...
291
+ </div>
292
+ </div>
293
+ `;
294
+ container.appendChild(section);
295
+
296
+ const slider = section.querySelector('#wc-st-hslider');
297
+ const viewAllBtn = section.querySelector('#wc-st-viewall');
298
+
299
+ // Fetch data
300
+ let shorts = await fetchShorts();
301
+ _wcSCached = shorts;
302
+
303
+ if(!shorts || !shorts.length){
304
+ slider.innerHTML = `<div class="wc-shorts-empty-slider">
305
+ <div>📱</div><div style="margin-top:6px;color:#888">Đang cập nhật shorts...</div>
306
+ <button class="reload-btn" onclick="renderWCSSlideSection(document.getElementById('wc-shorts-slide-section').parentNode)">Thử lại</button>
307
+ </div>`;
308
+ viewAllBtn.onclick = () => openWCSF(shorts || [], 0);
309
+ return;
310
+ }
311
+
312
+ // Render horizontal slider items
313
+ let h = '';
314
+ shorts.forEach((s, i)=>{
315
+ const thumbImg = s.img || `https://i.ytimg.com/vi/${s.id}/hqdefault.jpg`;
316
+ h += `<div class="wc-shorts-h-item" data-idx="${i}" onclick="openWCSF(_wcSCached, ${i})">`;
317
+ h += `<div class="wc-shorts-h-thumb">`;
318
+ h += `<img src="${escHtml(thumbImg)}" loading="lazy" onerror="this.style.display='none'">`;
319
+ h += `<span class="h-vtv-badge">VTV Nam Bộ</span>`;
320
+ h += `<div class="h-play-icon">▶</div>`;
321
+ h += `</div>`;
322
+ h += `<div class="wc-shorts-h-info"><div class="h-title">${escHtml(s.title)}</div></div>`;
323
+ h += `</div>`;
324
+ });
325
+ slider.innerHTML = h;
326
+
327
+ viewAllBtn.onclick = () => openWCSF(shorts, 0);
328
+ };
329
+
330
+ // ===== FULL-SCREEN TIKTOK OVERLAY =====
331
+ window.openWCSF = function(shorts, startIdx){
332
+ if(!shorts || !shorts.length){
333
+ // Try to fetch fresh
334
+ openWCSFLoading(startIdx);
335
+ return;
336
+ }
337
+ buildWCSF(shorts, startIdx);
338
+ };
339
+
340
+ function openWCSFLoading(startIdx){
341
+ const overlay = getOrCreateWCSFOverlay();
342
+ overlay.classList.add('active');
343
+ overlay.innerHTML = `<div class="wc-st-loading" style="position:absolute;inset:0;background:#000;z-index:100">
344
+ <div class="spinner"></div>Đang tải shorts...
345
+ </div>`;
346
+ fetchShorts().then(shorts => {
347
+ _wcSCached = shorts;
348
+ if(shorts && shorts.length){
349
+ buildWCSF(shorts, startIdx || 0);
350
+ } else {
351
+ overlay.innerHTML = `<div class="wc-st-nosho">
352
+ <div class="icon">📱</div><div>Không tải được shorts</div>
353
+ <button class="retry-btn" onclick="openWCSF(_wcSCached, ${startIdx||0})">Thử lại</button>
354
+ <button class="retry-btn" style="background:#333;margin-top:4px" onclick="closeWCSF()">Đóng</button>
355
+ </div>`;
356
+ }
357
+ }).catch(() => {
358
+ overlay.innerHTML = `<div class="wc-st-nosho">
359
+ <div class="icon">📱</div><div>Lỗi kết nối</div>
360
+ <button class="retry-btn" onclick="openWCSF(_wcSCached, ${startIdx||0})">Thử lại</button>
361
+ </div>`;
362
+ });
363
+ }
364
+
365
+ function getOrCreateWCSFOverlay(){
366
+ let overlay = document.getElementById('wc-st-overlay');
367
+ if(!overlay){
368
+ overlay = document.createElement('div');
369
+ overlay.id = 'wc-st-overlay';
370
+ overlay.className = 'wc-st-overlay';
371
+ document.body.appendChild(overlay);
372
+ }
373
+ return overlay;
374
+ }
375
+
376
+ window.closeWCSF = function(){
377
+ const overlay = document.getElementById('wc-st-overlay');
378
+ if(!overlay) return;
379
+ overlay.classList.remove('active');
380
+ overlay.innerHTML = '';
381
+ if(_wcSOverlayTimer){ clearInterval(_wcSOverlayTimer); _wcSOverlayTimer = null; }
382
+ // Stop any playing iframes
383
+ overlay.querySelectorAll('iframe').forEach(f => { f.src = ''; });
384
+ };
385
+
386
+ function buildWCSF(shorts, startIdx){
387
+ const overlay = getOrCreateWCSFOverlay();
388
+ overlay.classList.add('active');
389
+
390
+ // Rotate so startIdx is first
391
+ const ordered = startIdx > 0
392
+ ? [...shorts.slice(startIdx), ...shorts.slice(0, startIdx)]
393
+ : [...shorts];
394
+ const total = ordered.length;
395
+
396
+ // Build HTML
397
+ let h = '';
398
+ // Top bar
399
+ h += `<div class="wc-st-top-bar">
400
+ <button class="back-btn" onclick="closeWCSF()">←</button>
401
+ <span class="st-counter" id="wc-st-counter">1/${total}</span>
402
+ </div>`;
403
+
404
+ // Feed
405
+ h += `<div class="wc-st-feed" id="wc-st-feed">`;
406
+
407
+ ordered.forEach((s, i)=>{
408
+ const id = s.id || '';
409
+ const embedSrc = `https://www.youtube.com/embed/${id}?autoplay=0&rel=0&playsinline=1&mute=1&loop=1&playlist=${id}&enablejsapi=1&origin=${encodeURIComponent(location.origin)}`;
410
+
411
+ h += `<div class="wc-st-slide" data-vid="${escHtml('wcst-'+id)}" data-idx="${i}" data-ytid="${escHtml(id)}">`;
412
+ // iframe — lazy loaded, autoplay handled by scroll observer
413
+ h += `<iframe data-yt-src="${embedSrc}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;fullscreen"></iframe>`;
414
+
415
+ // Info overlay (title)
416
+ h += `<div class="wc-st-info">`;
417
+ h += `<span class="st-channel">VTV Nam Bộ</span>`;
418
+ h += `<div class="st-title">${escHtml(s.title)}</div>`;
419
+ h += `<span class="st-link" onclick="window.open('https://youtube.com/shorts/${escHtml(id)}','_blank')">Mở trên YouTube →</span>`;
420
+ h += `</div>`;
421
+
422
+ // Right action buttons
423
+ h += `<div class="wc-st-actions">`;
424
+ h += `<button class="wc-st-action" title="Xem" onclick="event.stopPropagation();wcSTTrack('${escHtml(id)}','view',${i})"><div class="a-icon">👁</div><span class="a-count" id="wcst-vc-${i}">0</span></button>`;
425
+ h += `<button class="wc-st-action" title="Thích" onclick="event.stopPropagation();wcSTTrack('${escHtml(id)}','like',${i})"><div class="a-icon">❤️</div><span class="a-count" id="wcst-lc-${i}">0</span></button>`;
426
+ h += `<button class="wc-st-action" title="Chia sẻ" onclick="event.stopPropagation();wcSTShare('${escHtml(id)}')"><div class="a-icon">📤</div></button>`;
427
+ h += `<button class="wc-st-action" title="YouTube" onclick="event.stopPropagation();window.open('https://youtube.com/shorts/${escHtml(id)}','_blank')"><div class="a-icon">🔗</div></button>`;
428
+ h += `</div>`;
429
+
430
+ h += `</div>`;
431
+ });
432
+
433
+ h += `</div>`; // end feed
434
+ overlay.innerHTML = h;
435
+
436
+ // Init autoplay on scroll
437
+ initWCSFAutoplay(ordered);
438
+
439
+ // Load stats
440
+ loadWCSFStats(ordered);
441
+ }
442
+
443
+ // ===== Autoplay on scroll (intersection observer) =====
444
+ function initWCSFAutoplay(shorts){
445
+ const feed = document.getElementById('wc-st-feed');
446
+ if(!feed) return;
447
+ const slides = feed.querySelectorAll('.wc-st-slide');
448
+ let curIdx = -1;
449
+ _wcSManualScroll = false;
450
+ let manualTimer = null;
451
+
452
+ function activate(idx){
453
+ if(idx === curIdx || idx < 0 || idx >= slides.length) return;
454
+ slides.forEach((sl, j)=>{
455
+ const fr = sl.querySelector('iframe');
456
+ if(j === idx){
457
+ // Load iframe if needed
458
+ if(fr && !fr.src && fr.dataset.ytSrc){ fr.src = fr.dataset.ytSrc; }
459
+ // Play (with mute to satisfy autoplay policy)
460
+ if(fr && fr.src){
461
+ setTimeout(()=>{
462
+ try{
463
+ fr.contentWindow.postMessage(JSON.stringify({event:'command',func:'mute',args:''}),'*');
464
+ fr.contentWindow.postMessage(JSON.stringify({event:'command',func:'playVideo',args:''}),'*');
465
+ }catch(e){}
466
+ }, 400);
467
+ }
468
+ } else {
469
+ // Pause others
470
+ if(fr && fr.src){
471
+ try{ fr.contentWindow.postMessage(JSON.stringify({event:'command',func:'pauseVideo',args:''}),'*'); }catch(e){}
472
+ }
473
+ }
474
+ });
475
+ curIdx = idx;
476
+ // Update counter
477
+ const counter = document.getElementById('wc-st-counter');
478
+ if(counter) counter.textContent = `${idx+1}/${shorts.length}`;
479
+ }
480
+
481
+ // Use IntersectionObserver for precise snap detection
482
+ const io = new IntersectionObserver((entries)=>{
483
+ if(_wcSManualScroll) return;
484
+ entries.forEach(entry=>{
485
+ if(entry.isIntersecting && entry.intersectionRatio > 0.6){
486
+ const idx = parseInt(entry.target.dataset.idx);
487
+ if(!isNaN(idx)) activate(idx);
488
+ }
489
+ });
490
+ }, {threshold: [0.6], root: feed});
491
+
492
+ slides.forEach(sl => io.observe(sl));
493
+
494
+ // Scroll event for manual detection (fallback)
495
+ let scrollTimer;
496
+ feed.addEventListener('scroll', ()=>{
497
+ _wcSManualScroll = true;
498
+ clearTimeout(manualTimer);
499
+ manualTimer = setTimeout(()=>{ _wcSManualScroll = false; }, 3000);
500
+ clearTimeout(scrollTimer);
501
+ scrollTimer = setTimeout(()=>{
502
+ const rect = feed.getBoundingClientRect();
503
+ const ctr = rect.top + rect.height / 2;
504
+ let best = -1, bestD = 1e9;
505
+ slides.forEach((sl,i)=>{
506
+ const r = sl.getBoundingClientRect();
507
+ const d = Math.abs(r.top + r.height/2 - ctr);
508
+ if(d < bestD){ bestD = d; best = i; }
509
+ });
510
+ if(best >= 0) activate(best);
511
+ }, 100);
512
+ }, {passive: true});
513
+
514
+ // Auto-advance every 12 seconds
515
+ if(_wcSOverlayTimer) clearInterval(_wcSOverlayTimer);
516
+ _wcSOverlayTimer = setInterval(()=>{
517
+ if(_wcSManualScroll) return;
518
+ if(!document.getElementById('wc-st-feed')){ clearInterval(_wcSOverlayTimer); _wcSOverlayTimer = null; return; }
519
+ const next = curIdx + 1;
520
+ if(next < slides.length){
521
+ slides[next].scrollIntoView({behavior:'smooth', block:'start'});
522
+ } else {
523
+ slides[0].scrollIntoView({behavior:'smooth', block:'start'});
524
+ }
525
+ }, 12000);
526
+
527
+ // Activate first after short delay
528
+ setTimeout(()=>{
529
+ if(slides.length > 0){ slides[0].scrollIntoView({behavior:'start'}); activate(0); }
530
+ }, 300);
531
+ }
532
+
533
+ // ===== Interaction tracking =====
534
+ window.wcSTTrack = async function(videoId, type, idx){
535
+ try{
536
+ const r = await fetch('/api/short-action', {
537
+ method:'POST', headers:{'Content-Type':'application/json'},
538
+ body: JSON.stringify({id:videoId, action:type})
539
+ });
540
+ const j = await r.json();
541
+ if(j && j.stats){
542
+ if(idx !== undefined){
543
+ const vc = document.getElementById(`wcst-vc-${idx}`);
544
+ const lc = document.getElementById(`wcst-lc-${idx}`);
545
+ if(type==='view' && vc) vc.textContent = fmtNum(j.stats.views);
546
+ if(type==='like' && lc) lc.textContent = fmtNum(j.stats.likes);
547
+ }
548
+ }
549
+ }catch(e){ console.warn('Track error:', e); }
550
+ };
551
+
552
+ window.wcSTShare = function(videoId){
553
+ const url = 'https://youtube.com/shorts/' + videoId;
554
+ if(navigator.share){
555
+ navigator.share({title:'Short World Cup', url}).catch(()=>{});
556
+ } else {
557
+ navigator.clipboard.writeText(url).then(()=>alert('Đã sao chép link!')).catch(()=>{});
558
+ }
559
+ };
560
+
561
+ async function loadWCSFStats(shorts){
562
+ const ids = shorts.map(s=>s.id).filter(Boolean);
563
+ if(!ids.length) return;
564
+ // Batch in groups of 20
565
+ for(let i=0;i<ids.length;i+=20){
566
+ const batch = ids.slice(i,i+20).join(',');
567
+ try{
568
+ const r = await fetch('/api/short-stats?ids='+batch);
569
+ const j = await r.json();
570
+ if(j.stats){
571
+ shorts.forEach((s,idx)=>{
572
+ const st = j.stats[s.id];
573
+ if(st){
574
+ const vc = document.getElementById(`wcst-vc-${idx}`);
575
+ const lc = document.getElementById(`wcst-lc-${idx}`);
576
+ if(vc) vc.textContent = fmtNum(st.views);
577
+ if(lc) lc.textContent = fmtNum(st.likes);
578
+ }
579
+ });
580
+ }
581
+ }catch(e){}
582
+ }
583
+ }
584
+
585
+ function fmtNum(n){
586
+ if(!n) return '0';
587
+ if(n>=1e6) return (n/1e6).toFixed(1)+'M';
588
+ if(n>=1e3) return (n/1e3).toFixed(1)+'K';
589
+ return String(n);
590
+ }
591
+
592
+ // ===== Helpers =====
593
+ function escHtml(s){
594
+ return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
595
+ }
596
+
597
+ })();
static/yt_live.js CHANGED
@@ -1,5 +1,3 @@
1
-
2
-
3
  // === VNEWS — VTV LIVE + Inline Recorder v6 ===
4
  // Features: PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
5
  // FIX v6: audio from captureStream (unmute during record), video.ended for crop, AI title generation
@@ -750,7 +748,7 @@
750
  function activateMiniPlayer(){
751
  if(!_currentCh) return; createMiniPlayer(); _miniActive=true;
752
  const mini=document.getElementById('vtv-mini'); const mv=document.getElementById('vtv-mini-vid'); const v=document.getElementById('vtv-player');
753
- if(_hls){const h=new Hls({enableWorker:false,lowLatencyMode:true,startLevel:0,capLevelToPlayerSize:true,maxBufferLength:6,maxMaxBufferLength:10,debug:false}); h.loadSource(STREAMS[_currentCh][0]); h.attachMedia(mv); mv.play().catch(e=>console.log('[VTV] mini play rejected:',e));}
754
  else if(v&&v.src){mv.src=v.src; mv.play().catch(()=>{});}
755
  const ch=CHANNELS.find(c=>c.id===_currentCh); if(ch) document.getElementById('vtv-mini-ch').textContent=ch.name;
756
  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(()=>{});
@@ -761,9 +759,8 @@
761
  async function loadAllStreams(){
762
  if(_loading) return; _loading=true;
763
  const el=document.getElementById('vtv-load'); if(el) el.innerHTML='<div class="vtv-spinner"></div>Đang tải kênh...';
764
- let d={};
765
- try{const r=await fetch('/api/vtv/streams',{signal:AbortSignal.timeout(10000)}); if(r.ok){d=await r.json(); CHANNELS.forEach(ch=>{const i=d[ch.id]; if(i&&i.stream_url){let u='/api/proxy/stream/vtv?channel_id='+ch.id; STREAMS[ch.id]=[u];} else {STREAMS[ch.id]=[]; if(i&&i.status==='offline') STREAMS[ch.id]=['OFFLINE'];}});}}catch(e){}
766
- CHANNELS.forEach(ch=>{const t=document.getElementById('vtvt-'+ch.id); if(t){const data=d[ch.id]; if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){t.classList.remove('off'); t.textContent=ch.name;}else if(data&&data.status==='unavailable'){t.style.opacity='0.35'; t.textContent=ch.name+' 🔒';}else{t.style.opacity='0.35'; t.textContent=ch.name+' ✕';}}});
767
  _loading=false; _streamsLoaded=true;
768
  }
769
 
@@ -772,7 +769,7 @@
772
  let tabs=''; CHANNELS.forEach(ch=>{tabs+='<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>';});
773
  w.innerHTML='<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>'+
774
  '<div class="vtv-tabs">'+tabs+'</div>'+
775
- '<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:block;opacity:0.3" onclick="this.play()"></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>'+
776
  '<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>'+
777
  '<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>';
778
  return w;
@@ -796,79 +793,28 @@
796
 
797
  window._vtvRetry=function(){if(_currentCh)window._vtvPlay(_currentCh);};
798
 
799
- window._vtvPlay=function(chId){try{
800
  const ch=CHANNELS.find(c=>c.id===chId); if(!ch) return; _currentCh=chId;
801
  document.querySelectorAll('.vtv-tab').forEach(t=>t.classList.remove('on'));
802
  const tab=document.getElementById('vtvt-'+chId); if(tab) tab.classList.add('on');
803
  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');
804
- video.style.display='block'; video.style.opacity='0.3'; errEl.style.display='none'; loadEl.style.display='flex';
805
  loadEl.innerHTML='<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
806
  if(_hls){_hls.destroy();_hls=null;}
807
  const urls=STREAMS[chId]||[];
808
- if(!urls.length||urls[0]==='OFFLINE'){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=chId==='vtvprime'?'VTVPrime: Kênh trả phí/chưa hỗ trợ.':chId==='vtv10'?'VTV Cần Thơ: Chưa có luồng public.':chId==='vtv3'||chId==='vtv9'?ch.name+': Kênh đang bảo trì, vui lòng thử lại sau.':ch.name+': Không có luồng.';return;}
809
  _tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);
810
  loadEpg(chId);
811
- }catch(e){console.log('[VTV] Play error:',e);} 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(()=>{});}
812
  };
813
 
814
- function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){try{
815
  if(idx>=urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';return;}
816
  const src=urls[idx]; loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
817
  if(typeof Hls!=='undefined'&&Hls.isSupported()){
818
- const hls=new Hls({enableWorker:false,lowLatencyMode:true,startLevel:0,capLevelToPlayerSize:true,maxBufferLength:8,maxMaxBufferLength:16,debug:false}); _hls=hls;
819
- // Log ALL events for debugging
820
- hls.on(Hls.Events.MEDIA_ATTACHING,()=>console.log('[VTV] MEDIA_ATTACHING'));
821
- hls.on(Hls.Events.MEDIA_ATTACHED,()=>console.log('[VTV] MEDIA_ATTACHED'));
822
- hls.on(Hls.Events.MANIFEST_LOADING,()=>console.log('[VTV] MANIFEST_LOADING',src));
823
- hls.on(Hls.Events.MANIFEST_LOADED,()=>console.log('[VTV] MANIFEST_LOADED'));
824
- hls.on(Hls.Events.MANIFEST_PARSED,(ev,data)=>{
825
- const st=document.getElementById('vtv-status');
826
- if(st) st.textContent='✅ Manifest OK | '+data.levels.length+' levels | '+(data.levels[0]?data.levels[0].width+'x'+data.levels[0].height:'?');
827
- console.log('[VTV] MANIFEST_PARSED levels:',data.levels.length);
828
- loadEl.style.display='none';video.style.opacity='1';video.style.display='block';setTimeout(()=>{
829
- video.play().then(()=>{
830
- if(st) st.textContent+=' | ▶ Playing';
831
- console.log('[VTV] play OK');
832
- }).catch(e=>{
833
- if(st) st.textContent+=' | ⚠️ Play blocked: '+e;
834
- console.log('[VTV] play:',e);
835
- });
836
- },100);
837
- });
838
- hls.on(Hls.Events.LEVEL_LOADING,(ev,l)=>console.log('[VTV] LEVEL_LOADING',l));
839
- hls.on(Hls.Events.LEVEL_LOADED,(ev,l)=>console.log('[VTV] LEVEL_LOADED',l));
840
- hls.on(Hls.Events.FRAG_LOADING,(ev,f)=>console.log('[VTV] FRAG_LOADING',f&&f.url&&f.url.substring(0,80)));
841
- hls.on(Hls.Events.FRAG_LOADED,(ev,f)=>console.log('[VTV] FRAG_LOADED size:',f&&f.frag&&f.frag.data&&f.frag.data.length));
842
- hls.on(Hls.Events.FRAG_BUFFERED,(ev,f)=>{
843
- try{
844
- const _n=f&&f.frag&&f.frag.sn;
845
- const _st2=document.getElementById('vtv-status');
846
- if(_st2&&_n) _st2.textContent='📦 Buffered frag #'+_n;
847
- console.log('[VTV] FRAG_BUFFERED',f&&f.frag&&f.frag.url&&f.frag.url.substring(0,60));
848
- }catch(e){}});
849
- hls.on(Hls.Events.FRAG_PARSING_INIT_SEGMENT,()=>console.log('[VTV] FRAG_PARSING_INIT_SEGMENT'));
850
- hls.on(Hls.Events.FRAG_PARSING_DATA,()=>console.log('[VTV] FRAG_PARSING_DATA'));
851
- hls.on(Hls.Events.FRAG_PARSED,()=>console.log('[VTV] FRAG_PARSED'));
852
- hls.on(Hls.Events.BUFFER_CREATED,()=>console.log('[VTV] BUFFER_CREATED'));
853
- hls.on(Hls.Events.BUFFER_APPENDING,()=>console.log('[VTV] BUFFER_APPENDING'));
854
- hls.on(Hls.Events.BUFFER_APPENDED,()=>console.log('[VTV] BUFFER_APPENDED'));
855
- hls.on(Hls.Events.BUFFER_EOS,()=>console.log('[VTV] BUFFER_EOS'));
856
- hls.on(Hls.Events.BUFFER_FLUSHING,()=>console.log('[VTV] BUFFER_FLUSHING'));
857
- hls.on(Hls.Events.LEVEL_SWITCHING,(ev,d)=>console.log('[VTV] LEVEL_SWITCHING',d));
858
- hls.on(Hls.Events.LEVEL_SWITCHED,(ev,d)=>console.log('[VTV] LEVEL_SWITCHED',d));
859
- let rec=0; hls.on(Hls.Events.ERROR,(ev,data)=>{
860
- const _st=document.getElementById('vtv-status');
861
- if(_st) _st.textContent='❌ '+data.type+' | '+data.details+(data.fatal?' [FATAL]':'');
862
- console.log('[VTV] ERROR type:',data.type,'details:',data.details,'fatal:',data.fatal,'reason:',data.reason,'url:',data.url&&data.url.substring(0,80));
863
- if(data.fatal){
864
- if(data.type===Hls.ErrorTypes.NETWORK_ERROR){
865
- rec++;if(rec<=3){console.log('[VTV] retry',rec);setTimeout(()=>hls.startLoad(),2000);}
866
- else{hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}
867
- }else if(data.type===Hls.ErrorTypes.MEDIA_ERROR){
868
- console.log('[VTV] MEDIA_ERROR, trying recover');
869
- try{hls.recoverMediaError();}catch(e){console.log('[VTV] recover failed',e);hls.destroy();_hls=null;}
870
- }else{console.log('[VTV] other fatal error');hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}
871
- }});
872
  }else if(video.canPlayType('application/vnd.apple.mpegurl')){
873
  video.src=src;
874
  video.addEventListener('loadedmetadata',()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';},{once:true});
@@ -876,44 +822,15 @@
876
  }else{loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent='Không hỗ trợ HLS';}
877
  }
878
 
879
- }catch(e){console.log('[VTV] _tryPlay error:',e);}
880
-
881
  // ===== NAV HOOKS =====
882
  const _origShowView=window.showView;
883
  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);};
884
  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);};}
885
- const _origSwitchCat=window.switchCat;
886
- if(_origSwitchCat){
887
- window.switchCat=function(id){
888
- if(id==='home'){if(_miniActive)closeMiniPlayer();}
889
- else if(_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();
890
- const r=_origSwitchCat.apply(this,arguments);
891
- // Pin VTV block whenever home tab becomes active
892
- if(id==='home'){setTimeout(()=>pinBlock(),100);}
893
- return r;
894
- };
895
- }
896
 
897
- // ===== SELF-PIN: pin VTV block directly, no dependency on loadHome =====
898
- // Try immediately if DOM ready, otherwise wait for DOMContentLoaded
899
- let _pinAttempts=0;
900
- function _tryPin(){
901
- if(_blockInserted) return;
902
- _pinAttempts++;
903
- if(_pinAttempts>30) return; // give up after ~15s
904
- const h=document.getElementById('view-home');
905
- if(h && h.children.length>0 && document.getElementById('vtv-block')){
906
- _blockInserted=true; return; // already pinned
907
- }
908
- if(h && h.children.length>0){
909
- pinBlock();
910
- } else {
911
- setTimeout(_tryPin, 500);
912
- }
913
- }
914
- if(document.readyState==='loading'){
915
- document.addEventListener('DOMContentLoaded', _tryPin);
916
- } else {
917
- _tryPin();
918
  }
919
  })();
 
 
 
1
  // === VNEWS — VTV LIVE + Inline Recorder v6 ===
2
  // Features: PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
3
  // FIX v6: audio from captureStream (unmute during record), video.ended for crop, AI title generation
 
748
  function activateMiniPlayer(){
749
  if(!_currentCh) return; createMiniPlayer(); _miniActive=true;
750
  const mini=document.getElementById('vtv-mini'); const mv=document.getElementById('vtv-mini-vid'); const v=document.getElementById('vtv-player');
751
+ if(_hls){const h=new Hls({enableWorker:true,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:10}); h.loadSource(STREAMS[_currentCh][0]); h.attachMedia(mv); mv.play().catch(()=>{});}
752
  else if(v&&v.src){mv.src=v.src; mv.play().catch(()=>{});}
753
  const ch=CHANNELS.find(c=>c.id===_currentCh); if(ch) document.getElementById('vtv-mini-ch').textContent=ch.name;
754
  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(()=>{});
 
759
  async function loadAllStreams(){
760
  if(_loading) return; _loading=true;
761
  const el=document.getElementById('vtv-load'); if(el) el.innerHTML='<div class="vtv-spinner"></div>Đang tải kênh...';
762
+ try{const r=await fetch('/api/vtv/streams',{signal:AbortSignal.timeout(10000)}); 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){}
763
+ CHANNELS.forEach(ch=>{const t=document.getElementById('vtvt-'+ch.id); if(t){if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){t.classList.remove('off'); t.textContent=ch.name;}else{t.style.opacity='0.35'; t.textContent=ch.name+'';}}});
 
764
  _loading=false; _streamsLoaded=true;
765
  }
766
 
 
769
  let tabs=''; CHANNELS.forEach(ch=>{tabs+='<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>';});
770
  w.innerHTML='<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>'+
771
  '<div class="vtv-tabs">'+tabs+'</div>'+
772
+ '<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>'+
773
  '<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>'+
774
  '<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>';
775
  return w;
 
793
 
794
  window._vtvRetry=function(){if(_currentCh)window._vtvPlay(_currentCh);};
795
 
796
+ window._vtvPlay=function(chId){
797
  const ch=CHANNELS.find(c=>c.id===chId); if(!ch) return; _currentCh=chId;
798
  document.querySelectorAll('.vtv-tab').forEach(t=>t.classList.remove('on'));
799
  const tab=document.getElementById('vtvt-'+chId); if(tab) tab.classList.add('on');
800
  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');
801
+ video.style.display='none'; errEl.style.display='none'; loadEl.style.display='flex';
802
  loadEl.innerHTML='<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
803
  if(_hls){_hls.destroy();_hls=null;}
804
  const urls=STREAMS[chId]||[];
805
+ 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;}
806
  _tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);
807
  loadEpg(chId);
808
+ 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(()=>{});}
809
  };
810
 
811
+ function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
812
  if(idx>=urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';return;}
813
  const src=urls[idx]; loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
814
  if(typeof Hls!=='undefined'&&Hls.isSupported()){
815
+ const hls=new Hls({enableWorker:true,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:20}); _hls=hls; hls.loadSource(src); hls.attachMedia(video);
816
+ hls.on(Hls.Events.MANIFEST_PARSED,()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';});
817
+ 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);}}});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
818
  }else if(video.canPlayType('application/vnd.apple.mpegurl')){
819
  video.src=src;
820
  video.addEventListener('loadedmetadata',()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';},{once:true});
 
822
  }else{loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent='Không hỗ trợ HLS';}
823
  }
824
 
 
 
825
  // ===== NAV HOOKS =====
826
  const _origShowView=window.showView;
827
  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);};
828
  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);};}
829
+ 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);};}
 
 
 
 
 
 
 
 
 
 
830
 
831
+ const _origLoadHome=window.loadHome;
832
+ if(_origLoadHome&&!_origLoadHome.__vtvWrapped){
833
+ window.loadHome=async function(){const old=document.getElementById('vtv-block');if(old)old.remove();_blockInserted=false;const r=await _origLoadHome.apply(this,arguments);try{pinBlock();}catch(e){}return r;};
834
+ window.loadHome.__vtvWrapped=true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
835
  }
836
  })();
vtv_api.py CHANGED
@@ -1,19 +1,14 @@
1
  """
2
  VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
3
- Stream sources:
4
- 1. VTVGo CDN (vtvgolive-failover.vtvdigital.vn) — PRIMARY, works for vtv1-vtv9
5
- 2. fptplay CDN — FALLBACK for vtv6 only (others blocked)
6
- 3. xemtv.net PHP — DEAD (all 403 from Cloudflare)
7
  EPG data scraped from https://vtv.vn/lich-phat-song.htm
8
  """
9
  import re, time, threading
10
  import requests
11
  from fastapi import APIRouter, Query
12
- from fastapi.responses import JSONResponse, Response, HTMLResponse
13
  from bs4 import BeautifulSoup
14
  from datetime import datetime, timedelta, timezone
15
- import warnings
16
- warnings.filterwarnings("ignore")
17
 
18
  VN_TZ = timezone(timedelta(hours=7))
19
 
@@ -22,10 +17,23 @@ router = APIRouter()
22
  UA = {
23
  "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",
24
  "Accept-Language": "vi-VN,vi;q=0.9",
25
- "Referer": "https://vtv.vn/",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  }
27
 
28
- # ===== CHANNEL DEFINITIONS =====
29
  CHANNEL_NAMES = {
30
  "vtv1": "VTV1",
31
  "vtv2": "VTV2",
@@ -36,37 +44,36 @@ CHANNEL_NAMES = {
36
  "vtv7": "VTV7",
37
  "vtv8": "VTV8",
38
  "vtv9": "VTV9",
39
- "vtv10": "VTV Cần Thơ",
40
  "vtvprime": "VTVPrime",
41
  }
42
 
43
- # ===== PRIMARY: VTVGo CDN (self-signed cert, need verify=False) =====
44
- # Confirmed working (2026-06-16): vtv1, vtv2, vtv4, vtv5, vtv6, vtv7, vtv8
45
- # Not available: vtv3 (404), vtv9 (404), vtv10 (404), vtvprime (404)
46
- VTVGO_CDN = {
47
  "vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
48
  "vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
 
49
  "vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
50
  "vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
51
- "vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
52
  "vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
53
  "vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
 
54
  }
55
 
56
- # ===== FALLBACK: fptplay CDN =====
57
- # Only vtv6 confirmed working (others return 403)
58
  FPTPLAY_URLS = {
 
 
 
 
 
59
  "vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/vtv6-avc1_5600000=10000-mp4a_131600=20000.m3u8",
 
 
 
 
60
  }
61
 
62
- # ===== DEAD: xemtv.net (all 403 from Cloudflare, kept for reference) =====
63
- # XEMTV_PHP_ENDPOINTS = { ... } # All dead as of 2026-06
64
-
65
- # Channels with no public stream available on any CDN
66
- # vtv3, vtv9: VTVGo CDN returned 404 as of 2026-06-16
67
- # vtv10, vtvprime: never had public streams
68
- UNAVAILABLE_CHANNELS = {"vtv3", "vtv9", "vtv10", "vtvprime"}
69
-
70
  _vtv_cache = {}
71
  _vtv_lock = threading.Lock()
72
  _CACHE_TTL = 180
@@ -81,32 +88,50 @@ def _set_cache(key, data):
81
  with _vtv_lock:
82
  _vtv_cache[key] = {'t': time.time(), 'd': data}
83
 
84
- def check_vtvgo_stream(url):
85
- """Check if a VTVGo CDN URL returns valid m3u8. Uses verify=False for self-signed cert."""
86
- try:
87
- r = requests.get(url, headers=UA, timeout=10, allow_redirects=True, verify=False)
88
- if r.status_code == 200 and "#EXTM3U" in (r.text[:200] if r.text else ""):
 
 
89
  return url
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  except:
91
  pass
92
  return None
93
 
94
- def check_fptplay_stream(url):
95
- """Check if a fptplay URL returns valid m3u8."""
 
 
96
  try:
97
  headers = {"User-Agent": UA["User-Agent"], "Referer": "https://fptplay.vn/", "Origin": "https://fptplay.vn"}
98
- r = requests.get(url, headers=headers, timeout=10, allow_redirects=True)
99
- if r.status_code == 200 and "#EXTM3U" in (r.text[:200] if r.text else ""):
100
  return url
101
  except:
102
  pass
103
  return None
104
 
105
  def fetch_vtv_stream(channel_id):
106
- """Fetch stream URL for a VTV channel. Tries VTVGo CDN first, then fptplay fallback."""
107
  channel_id = channel_id.lower().strip()
108
-
109
- # Normalize name
110
  name_map = {
111
  'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
112
  'vtv_can_tho': 'vtv10', 'cantho': 'vtv10',
@@ -115,63 +140,65 @@ def fetch_vtv_stream(channel_id):
115
  'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
116
  }
117
  channel_id = name_map.get(channel_id, channel_id)
118
-
119
  cached = _cached(channel_id)
120
  if cached is not None:
121
  return cached
122
 
123
- # Check if channel is known to be unavailable
124
- if channel_id in UNAVAILABLE_CHANNELS:
 
 
 
 
 
 
 
125
  _set_cache(channel_id, None)
126
  return None
127
 
128
- # PRIMARY: VTVGo CDN (vtv1-vtv9)
129
- if channel_id in VTVGO_CDN:
130
- result = check_vtvgo_stream(VTVGO_CDN[channel_id])
131
- if result:
132
- _set_cache(channel_id, result)
133
- return result
134
 
135
- # FALLBACK: fptplay CDN
136
- if channel_id in FPTPLAY_URLS:
137
- result = check_fptplay_stream(FPTPLAY_URLS[channel_id])
138
- if result:
139
- _set_cache(channel_id, result)
140
- return result
 
 
 
 
 
 
 
 
 
 
 
141
 
142
  _set_cache(channel_id, None)
143
  return None
144
 
145
  @router.get("/api/vtv/streams")
146
  def api_vtv_streams():
147
- """Get all VTV channel streams. Returns status for each channel."""
148
  result = {}
149
- for ch_id, name in CHANNEL_NAMES.items():
150
- stream_url = None
151
- if ch_id not in UNAVAILABLE_CHANNELS:
152
- stream_url = fetch_vtv_stream(ch_id)
153
-
154
- if stream_url:
155
- result[ch_id] = {"name": name, "stream_url": stream_url, "status": "ok"}
156
- elif ch_id in UNAVAILABLE_CHANNELS:
157
- result[ch_id] = {"name": name, "stream_url": None, "status": "unavailable"}
158
- else:
159
- result[ch_id] = {"name": name, "stream_url": None, "status": "offline"}
160
  return JSONResponse(result)
161
 
162
  @router.get("/api/vtv/stream/{channel_id}")
163
  def api_vtv_stream(channel_id: str):
164
- """Get stream URL for a specific VTV channel."""
165
  stream_url = fetch_vtv_stream(channel_id)
166
  if stream_url:
167
  return JSONResponse({"stream_url": stream_url, "status": "ok"})
168
- if channel_id.lower().strip() in UNAVAILABLE_CHANNELS:
169
- return JSONResponse({"error": "channel not available (premium/geo-restricted)", "status": "unavailable"}, status_code=404)
170
  return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
171
 
172
  @router.get("/api/proxy/page")
173
  def proxy_page(url: str = Query(...)):
174
- """Proxy HTML pages (CORS workaround)."""
175
  try:
176
  headers = {**UA}
177
  if "xemtv.net" in url:
@@ -185,22 +212,20 @@ def proxy_page(url: str = Query(...)):
185
 
186
  @router.get("/api/proxy/m3u8/vtv")
187
  def proxy_vtv_m3u8(url: str = Query(...)):
188
- """Proxy m3u8 playlist. Rewrites .ts segments and .m3u8 variants through our proxy chain.
189
- Uses fully-qualified absolute URLs so HLS.js resolves them correctly."""
190
  try:
191
- headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*", "Referer": "https://vtv.vn/"}
192
- # Determine if we need verify=False (self-signed cert on some CDNs)
193
- verify = "vtvdigital.vn" not in url
194
- r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=verify)
 
 
 
195
  if r.status_code != 200:
196
  return Response(status_code=502, content="upstream error")
197
  content = r.text
198
  lines = content.split('\n')
199
  rewritten = []
200
  base_url = url.rsplit('/', 1)[0] + '/'
201
- # Build the base URL for this proxy endpoint
202
- proxy_base = "/api/proxy/m3u8/vtv?url="
203
- seg_proxy_base = "/api/proxy/seg/vtv?url="
204
  for line in lines:
205
  line = line.strip()
206
  if not line or line.startswith('#'):
@@ -209,156 +234,29 @@ def proxy_vtv_m3u8(url: str = Query(...)):
209
  seg_url = line
210
  if not seg_url.startswith('http'):
211
  seg_url = base_url + seg_url
212
- # Distinguish between .m3u8 variant playlists and .ts segments
213
- if seg_url.endswith('.m3u8'):
214
- # Variant playlist → proxy through this same m3u8 endpoint (full URL)
215
- rewritten.append(proxy_base + requests.utils.quote(seg_url, safe=""))
216
- else:
217
- # .ts segment → proxy through segment endpoint (full URL)
218
- rewritten.append(seg_proxy_base + requests.utils.quote(seg_url, safe=""))
219
- return Response(content='\n'.join(rewritten).encode("utf-8"), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache, no-store", "Access-Control-Allow-Headers": "*"})
220
  except Exception as e:
221
  return Response(status_code=502, content="proxy error: " + str(e))
222
 
223
  @router.get("/api/proxy/seg/vtv")
224
  def proxy_vtv_segment(url: str = Query(...)):
225
- """Proxy video segments (.ts files) and other binary content."""
226
  try:
227
- headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*", "Referer": "https://vtv.vn/"}
228
- # Determine if we need verify=False (self-signed cert on some CDNs)
229
- verify = "vtvdigital.vn" not in url
230
- r = requests.get(url, headers=headers, timeout=30, allow_redirects=True, verify=verify)
 
231
  if r.status_code != 200:
232
  return Response(status_code=502, content="upstream error")
233
  data = r.content
234
- # Determine content type from URL
235
- content_type = "video/mp2t" # default for .ts
236
- if url.endswith('.m3u8') or url.endswith('.m3u'):
237
- content_type = "application/vnd.apple.mpegurl"
238
- elif url.endswith('.mp4'):
239
- content_type = "video/mp4"
240
- elif url.endswith('.aac'):
241
- content_type = "audio/aac"
242
- elif url.endswith('.webm'):
243
- content_type = "video/webm"
244
- # Fix: some TS segments start with PNG header (188 bytes offset)
245
- if content_type == "video/mp2t" and len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
246
  data = data[188:]
247
- return Response(content=data, media_type=content_type, headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600", "Access-Control-Allow-Headers": "*"})
248
  except:
249
  return Response(status_code=502, content="proxy error")
250
 
251
 
252
- @router.get("/api/proxy/stream/vtv")
253
- def proxy_vtv_stream(channel_id: str = Query(...)):
254
- """Unified streaming proxy: fetches VTV manifest, rewrites ALL segment URLs
255
- to go through this same endpoint (self-referencing).
256
-
257
- HLS.js loads ONE URL and all segments are automatically proxied.
258
- This avoids the double-hop latency issue.
259
- """
260
- # Resolve channel to VTVGo manifest URL
261
- ch = channel_id.lower().strip()
262
- name_map = {
263
- 'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
264
- 'vietnam_vtv1': 'vtv1', 'vietnam_vtv2': 'vtv2', 'vietnam_vtv3': 'vtv3',
265
- }
266
- ch = name_map.get(ch, ch)
267
-
268
- manifest_url = VTVGO_CDN.get(ch)
269
- if not manifest_url:
270
- return JSONResponse({"error": "channel not found"}, status_code=404)
271
-
272
- # Stream the variant through ourselves
273
- # Return a variant m3u8 with segments pointing to this same endpoint
274
- try:
275
- headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*", "Referer": "https://vtv.vn/"}
276
- r = requests.get(manifest_url, headers=headers, timeout=15, allow_redirects=True, verify=False)
277
- if r.status_code != 200:
278
- return Response(status_code=502, content="upstream error")
279
-
280
- content = r.text
281
- lines = content.split('\n')
282
-
283
- # For each variant in the master, return its segment list with self-referencing URLs
284
- rewritten = []
285
- base_url = manifest_url.rsplit('/', 1)[0] + '/'
286
-
287
- for line in lines:
288
- line_stripped = line.strip()
289
- if not line_stripped or line_stripped.startswith('#EXTM3U') or line_stripped.startswith('#EXT-X-VERSION'):
290
- rewritten.append(line_stripped)
291
- elif line_stripped.startswith('#EXT-X-STREAM-INF'):
292
- rewritten.append(line_stripped)
293
- elif line_stripped.startswith('#'):
294
- rewritten.append(line_stripped)
295
- else:
296
- # This could be a variant .m3u8 URL (from master) or segment (from variant)
297
- seg_url = line_stripped
298
- if not seg_url.startswith('http'):
299
- seg_url = base_url + seg_url
300
-
301
- if seg_url.endswith('.m3u8'):
302
- # This is a variant playlist URL from master manifest
303
- # Return a rewritten variant with self-referencing segment URLs
304
- rewritten.append("/api/proxy/stream/vtv/" + ch + "/variant?url=" + requests.utils.quote(seg_url, safe=""))
305
- else:
306
- # TS segment - proxy through seg endpoint
307
- rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
308
-
309
- return Response(
310
- content='\n'.join(rewritten).encode("utf-8"),
311
- media_type="application/vnd.apple.mpegurl",
312
- headers={
313
- "Access-Control-Allow-Origin": "*",
314
- "Cache-Control": "no-cache, no-store",
315
- "Access-Control-Allow-Headers": "*"
316
- }
317
- )
318
- except Exception as e:
319
- return Response(status_code=502, content="proxy error: " + str(e))
320
-
321
-
322
- @router.get("/api/proxy/stream/vtv/{channel_id}/variant")
323
- def proxy_vtv_variant(channel_id: str, url: str = Query(...)):
324
- """Fetch a variant m3u8 and rewrite its .ts segments to go through seg proxy."""
325
- try:
326
- headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*", "Referer": "https://vtv.vn/"}
327
- r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
328
- if r.status_code != 200:
329
- return Response(status_code=502, content="upstream error")
330
-
331
- content = r.text
332
- lines = content.split('\n')
333
- rewritten = []
334
- base_url = url.rsplit('/', 1)[0] + '/'
335
-
336
- for line in lines:
337
- line_stripped = line.strip()
338
- if not line_stripped or line_stripped.startswith('#'):
339
- rewritten.append(line_stripped)
340
- else:
341
- seg_url = line_stripped
342
- if not seg_url.startswith('http'):
343
- seg_url = base_url + seg_url
344
- if seg_url.endswith('.ts'):
345
- rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
346
- else:
347
- rewritten.append(seg_url)
348
-
349
- return Response(
350
- content='\n'.join(rewritten).encode("utf-8"),
351
- media_type="application/vnd.apple.mpegurl",
352
- headers={
353
- "Access-Control-Allow-Origin": "*",
354
- "Cache-Control": "no-cache, no-store",
355
- "Access-Control-Allow-Headers": "*"
356
- }
357
- )
358
- except Exception as e:
359
- return Response(status_code=502, content="proxy error: " + str(e))
360
-
361
-
362
  # ===== EPG from vtv.vn/lich-phat-song.htm =====
363
 
364
  _epg_cache = {}
@@ -564,78 +462,4 @@ def api_vtv_epg_refresh():
564
  "status": "refreshed",
565
  "channels": len(epg_data),
566
  "total_programmes": sum(len(v) for v in epg_data),
567
- })
568
-
569
- @router.get("/api/vtv/test", response_class=HTMLResponse)
570
- def vtv_test_page():
571
- """VTV stream test page for debugging."""
572
- return """
573
- <!DOCTYPE html>
574
- <html>
575
- <head>
576
- <meta charset="utf-8">
577
- <meta name="viewport" content="width=device-width, initial-scale=1">
578
- <title>VTV Test</title>
579
- <style>
580
- body{background:#111;color:#eee;font-family:monospace;padding:16px;margin:0}
581
- h2{color:#5cb87a;font-size:16px;margin:0 0 10px}
582
- video{width:100%;max-width:640px;background:#000;margin:10px 0;border:2px solid #333}
583
- #log{background:#1a1a1a;border:1px solid #333;padding:10px;max-height:250px;overflow-y:auto;white-space:pre-wrap;font-size:11px;line-height:1.5;margin-top:10px}
584
- .ok{color:#5cb87a}.err{color:#e74c3c}.warn{color:#f0c040}.info{color:#888}
585
- button{background:#2d8659;border:0;color:#fff;padding:8px 16px;border-radius:8px;cursor:pointer;margin:4px;font-size:12px}
586
- select{background:#222;color:#eee;border:1px solid #444;padding:6px;border-radius:6px;font-size:12px}
587
- </style>
588
- </head>
589
- <body>
590
- <h2>📺 VTV Stream Test</h2>
591
- <div>
592
- <select id="ch">
593
- <option value="vtv1">VTV1</option><option value="vtv2">VTV2</option>
594
- <option value="vtv4">VTV4</option><option value="vtv5">VTV5</option>
595
- <option value="vtv6" selected>VTV6</option><option value="vtv7">VTV7</option>
596
- <option value="vtv8">VTV8</option>
597
- </select>
598
- <button onclick="playHLS()">▶ Play (HLS.js)</button>
599
- <button onclick="playNative()">▶ Play (Native)</button>
600
- </div>
601
- <video id="v" controls autoplay muted playsinline></video>
602
- <div id="log"></div>
603
- <script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js"></script>
604
- <script>
605
- const LOG=(msg,t)=>{const e=document.getElementById('log');e.innerHTML+='<span class="'+(t||'info')+'">['+new Date().toISOString().substr(11,12)+'] '+msg+'\\n</span>';e.scrollTop=e.scrollHeight;};
606
- const V={"vtv1":"/api/proxy/stream/vtv?channel_id=vtv1","vtv2":"/api/proxy/stream/vtv?channel_id=vtv2","vtv4":"/api/proxy/stream/vtv?channel_id=vtv4","vtv5":"/api/proxy/stream/vtv?channel_id=vtv5","vtv6":"/api/proxy/stream/vtv?channel_id=vtv6","vtv7":"/api/proxy/stream/vtv?channel_id=vtv7","vtv8":"/api/proxy/stream/vtv?channel_id=vtv8"};
607
- const video=document.getElementById('v');
608
- let hls=null;
609
- LOG('Ready. HLS.js '+(Hls.version||'unknown')+' | supported: '+Hls.isSupported(),'ok');
610
- function playHLS(){
611
- const ch=document.getElementById('ch').value,url=V[ch];
612
- LOG('\\n--- HLS.js: '+ch+' ---','info');
613
- if(hls){hls.destroy();hls=null;}
614
- video.pause();video.src='';
615
- if(!Hls.isSupported()){LOG('HLS.js not supported, trying native','warn');playNative();return;}
616
- hls=new Hls({enableWorker:false,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:6});
617
- hls.on(Hls.Events.MANIFEST_PARSED,(ev,d)=>{LOG('✅ MANIFEST '+d.levels.length+' levels '+(d.levels[0]?d.levels[0].width+'x'+d.levels[0].height:''),'ok');video.play().then(()=>LOG('▶ Playing','ok')).catch(e=>LOG('Play: '+e,'warn'));});
618
- hls.on(Hls.Events.FRAG_BUFFERED,(ev,d)=>{LOG('📦 Buf #'+d.frag.sn+' video.w='+video.videoWidth,'ok');});
619
- hls.on(Hls.Events.ERROR,(ev,d)=>{LOG('❌ '+d.type+' '+d.details+' fatal='+d.fatal,'err');if(d.fatal){hls.destroy();hls=null;}});
620
- hls.loadSource(url);
621
- hls.attachMedia(video);
622
- let c=0;const iv=setInterval(()=>{c++;if(c>15)clearInterval(iv);
623
- LOG('State '+c+': readyState='+video.readyState+' videoWidth='+video.videoWidth+' paused='+video.paused,'info');
624
- if(video.videoWidth>0){LOG('🎉 VIDEO HAS FRAMES!','ok');clearInterval(iv);}
625
- },1000);
626
- }
627
- function playNative(){
628
- const ch=document.getElementById('ch').value,url=V[ch];
629
- LOG('\\n--- Native: '+ch+' ---','info');
630
- if(hls){hls.destroy();hls=null;}
631
- video.pause();video.src=url;
632
- video.play().then(()=>LOG('▶ Native playing','ok')).catch(e=>LOG('Native play: '+e,'warn'));
633
- let c=0;const iv=setInterval(()=>{c++;if(c>15)clearInterval(iv);
634
- LOG('State '+c+': readyState='+video.readyState+' videoWidth='+video.videoWidth,'info');
635
- if(video.videoWidth>0){LOG('🎉 VIDEO HAS FRAMES!','ok');clearInterval(iv);}
636
- },1000);
637
- }
638
- </script>
639
- </body>
640
- </html>
641
- """
 
1
  """
2
  VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
3
+ Fetches stream URLs from hd.xemtv.net PHP endpoints
 
 
 
4
  EPG data scraped from https://vtv.vn/lich-phat-song.htm
5
  """
6
  import re, time, threading
7
  import requests
8
  from fastapi import APIRouter, Query
9
+ from fastapi.responses import JSONResponse, Response
10
  from bs4 import BeautifulSoup
11
  from datetime import datetime, timedelta, timezone
 
 
12
 
13
  VN_TZ = timezone(timedelta(hours=7))
14
 
 
17
  UA = {
18
  "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",
19
  "Accept-Language": "vi-VN,vi;q=0.9",
20
+ "Referer": "https://hd.xemtv.net/",
21
+ }
22
+
23
+ XEMTV_PHP_ENDPOINTS = {
24
+ "vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
25
+ "vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
26
+ "vtv3": "https://hd.xemtv.net/kenh/vtv3.php",
27
+ "vtv4": "https://hd.xemtv.net/kenh/vtv4.php",
28
+ "vtv5": "https://hd.xemtv.net/kenh/vtv5.php",
29
+ "vtv6": "https://hd.xemtv.net/kenh/vtv6.php",
30
+ "vtv7": "https://hd.xemtv.net/kenh/vtv7.php",
31
+ "vtv8": "https://hd.xemtv.net/kenh/vtv8.php",
32
+ "vtv9": "https://hd.xemtv.net/kenh/vtv9.php",
33
+ "vtv10": "https://hd.xemtv.net/kenh/vtv10.php",
34
+ "vtvprime": "https://hd.xemtv.net/kenh/vtvprime.php",
35
  }
36
 
 
37
  CHANNEL_NAMES = {
38
  "vtv1": "VTV1",
39
  "vtv2": "VTV2",
 
44
  "vtv7": "VTV7",
45
  "vtv8": "VTV8",
46
  "vtv9": "VTV9",
47
+ "vtv10": "VTV10",
48
  "vtvprime": "VTVPrime",
49
  }
50
 
51
+ VTVGO_FAILOVER = {
 
 
 
52
  "vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
53
  "vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
54
+ "vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
55
  "vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
56
  "vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
 
57
  "vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
58
  "vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
59
+ "vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
60
  }
61
 
62
+ XEMTV_ONLY_CHANNELS = {"vtv6", "vtv10"}
63
+
64
  FPTPLAY_URLS = {
65
+ "vtv1": "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8",
66
+ "vtv2": "https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8",
67
+ "vtv3": "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8",
68
+ "vtv4": "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8",
69
+ "vtv5": "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8",
70
  "vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/vtv6-avc1_5600000=10000-mp4a_131600=20000.m3u8",
71
+ "vtv7": "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8",
72
+ "vtv8": "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8",
73
+ "vtv9": "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8",
74
+ "vtv10": "https://live.fptplay53.net/fnxch2/vtvcantho_abr.smil/chunklist.m3u8",
75
  }
76
 
 
 
 
 
 
 
 
 
77
  _vtv_cache = {}
78
  _vtv_lock = threading.Lock()
79
  _CACHE_TTL = 180
 
88
  with _vtv_lock:
89
  _vtv_cache[key] = {'t': time.time(), 'd': data}
90
 
91
+ def extract_m3u8_from_html(html):
92
+ if not html:
93
+ return None
94
+ m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
95
+ if m:
96
+ url = m.group(1).strip()
97
+ if len(url) > 20:
98
  return url
99
+ m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
100
+ if m:
101
+ url = m.group(1).strip()
102
+ if len(url) > 20:
103
+ return url
104
+ return None
105
+
106
+ def fetch_xemtv_stream(channel_id):
107
+ php_url = XEMTV_PHP_ENDPOINTS.get(channel_id)
108
+ if not php_url:
109
+ return None
110
+ try:
111
+ r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True)
112
+ if r.status_code == 200:
113
+ m3u8 = extract_m3u8_from_html(r.text)
114
+ if m3u8:
115
+ return m3u8
116
  except:
117
  pass
118
  return None
119
 
120
+ def fetch_fptplay_stream(channel_id):
121
+ url = FPTPLAY_URLS.get(channel_id)
122
+ if not url:
123
+ return None
124
  try:
125
  headers = {"User-Agent": UA["User-Agent"], "Referer": "https://fptplay.vn/", "Origin": "https://fptplay.vn"}
126
+ r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
127
+ if r.status_code == 200:
128
  return url
129
  except:
130
  pass
131
  return None
132
 
133
  def fetch_vtv_stream(channel_id):
 
134
  channel_id = channel_id.lower().strip()
 
 
135
  name_map = {
136
  'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
137
  'vtv_can_tho': 'vtv10', 'cantho': 'vtv10',
 
140
  'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
141
  }
142
  channel_id = name_map.get(channel_id, channel_id)
 
143
  cached = _cached(channel_id)
144
  if cached is not None:
145
  return cached
146
 
147
+ if channel_id in XEMTV_ONLY_CHANNELS:
148
+ xemtv_url = fetch_xemtv_stream(channel_id)
149
+ if xemtv_url:
150
+ _set_cache(channel_id, xemtv_url)
151
+ return xemtv_url
152
+ fpt_url = FPTPLAY_URLS.get(channel_id)
153
+ if fpt_url:
154
+ _set_cache(channel_id, fpt_url)
155
+ return fpt_url
156
  _set_cache(channel_id, None)
157
  return None
158
 
159
+ if channel_id in VTVGO_FAILOVER:
160
+ result = VTVGO_FAILOVER[channel_id]
161
+ _set_cache(channel_id, result)
162
+ return result
 
 
163
 
164
+ if channel_id == 'vtvprime':
165
+ xemtv_url = fetch_xemtv_stream('vtvprime')
166
+ if xemtv_url:
167
+ _set_cache(channel_id, xemtv_url)
168
+ return xemtv_url
169
+ _set_cache(channel_id, None)
170
+ return None
171
+
172
+ xemtv_url = fetch_xemtv_stream(channel_id)
173
+ if xemtv_url:
174
+ _set_cache(channel_id, xemtv_url)
175
+ return xemtv_url
176
+
177
+ fpt_url = fetch_fptplay_stream(channel_id)
178
+ if fpt_url:
179
+ _set_cache(channel_id, fpt_url)
180
+ return fpt_url
181
 
182
  _set_cache(channel_id, None)
183
  return None
184
 
185
  @router.get("/api/vtv/streams")
186
  def api_vtv_streams():
 
187
  result = {}
188
+ for ch_id in CHANNEL_NAMES:
189
+ stream_url = fetch_vtv_stream(ch_id)
190
+ result[ch_id] = {"name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "status": "ok" if stream_url else "offline"}
 
 
 
 
 
 
 
 
191
  return JSONResponse(result)
192
 
193
  @router.get("/api/vtv/stream/{channel_id}")
194
  def api_vtv_stream(channel_id: str):
 
195
  stream_url = fetch_vtv_stream(channel_id)
196
  if stream_url:
197
  return JSONResponse({"stream_url": stream_url, "status": "ok"})
 
 
198
  return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
199
 
200
  @router.get("/api/proxy/page")
201
  def proxy_page(url: str = Query(...)):
 
202
  try:
203
  headers = {**UA}
204
  if "xemtv.net" in url:
 
212
 
213
  @router.get("/api/proxy/m3u8/vtv")
214
  def proxy_vtv_m3u8(url: str = Query(...)):
 
 
215
  try:
216
+ headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
217
+ if "fptplay" in url:
218
+ headers["Referer"] = "https://fptplay.vn/"
219
+ headers["Origin"] = "https://fptplay.vn"
220
+ elif "xemtv" in url:
221
+ headers["Referer"] = "https://hd.xemtv.net/"
222
+ r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
223
  if r.status_code != 200:
224
  return Response(status_code=502, content="upstream error")
225
  content = r.text
226
  lines = content.split('\n')
227
  rewritten = []
228
  base_url = url.rsplit('/', 1)[0] + '/'
 
 
 
229
  for line in lines:
230
  line = line.strip()
231
  if not line or line.startswith('#'):
 
234
  seg_url = line
235
  if not seg_url.startswith('http'):
236
  seg_url = base_url + seg_url
237
+ rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
238
+ return Response(content='\n'.join(rewritten).encode("utf-8"), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache"})
 
 
 
 
 
 
239
  except Exception as e:
240
  return Response(status_code=502, content="proxy error: " + str(e))
241
 
242
  @router.get("/api/proxy/seg/vtv")
243
  def proxy_vtv_segment(url: str = Query(...)):
 
244
  try:
245
+ headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
246
+ if "fptplay" in url:
247
+ headers["Referer"] = "https://fptplay.vn/"
248
+ headers["Origin"] = "https://fptplay.vn"
249
+ r = requests.get(url, headers=headers, timeout=30, allow_redirects=True)
250
  if r.status_code != 200:
251
  return Response(status_code=502, content="upstream error")
252
  data = r.content
253
+ if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
 
 
 
 
 
 
 
 
 
 
 
254
  data = data[188:]
255
+ return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600"})
256
  except:
257
  return Response(status_code=502, content="proxy error")
258
 
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  # ===== EPG from vtv.vn/lich-phat-song.htm =====
261
 
262
  _epg_cache = {}
 
462
  "status": "refreshed",
463
  "channels": len(epg_data),
464
  "total_programmes": sum(len(v) for v in epg_data),
465
+ })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
yt_scraper.py CHANGED
@@ -188,7 +188,7 @@ def get_vtvnambo_shorts(max_count=50):
188
  # Method 2: /videos page with duration filter
189
  if len(all_shorts) < 5:
190
  print(f"[yt-dlp] Fetching /videos page with filter...")
191
- videos_filtered = get_channel_videos_filter_shorts('vtvnambo', (max_count if isinstance(max_count, int) else 50) * 2)
192
  for s in videos_filtered:
193
  if s['id'] not in seen_ids:
194
  seen_ids.add(s['id'])
 
188
  # Method 2: /videos page with duration filter
189
  if len(all_shorts) < 5:
190
  print(f"[yt-dlp] Fetching /videos page with filter...")
191
+ videos_filtered = get_channel_videos_filter_shorts('vtvnambo', max_count * 2)
192
  for s in videos_filtered:
193
  if s['id'] not in seen_ids:
194
  seen_ids.add(s['id'])