Spaces:
Running
Running
Fix VTV loading, match detail, homepage speed
Browse files1. VTV: reduce API timeout to 2.5s, HLS fail-fast (no retry), 8s per-source timeout
2. Match detail: show basic info even when scraping fails, display events if available
3. Homepage: add per-API timeouts (8-15s) so slow APIs don't block everything
- static/app_v2.js +92 -24
static/app_v2.js
CHANGED
|
@@ -80,15 +80,14 @@ function buildVTVBlockHTML(){
|
|
| 80 |
}
|
| 81 |
|
| 82 |
async function loadVTVStreams(){
|
| 83 |
-
// Safety: always hide loader after
|
| 84 |
-
const loadEl = document.getElementById('vtv-load');
|
| 85 |
const hideLoader = () => { const el = document.getElementById('vtv-load'); if(el) el.style.display = 'none'; };
|
| 86 |
-
setTimeout(hideLoader,
|
| 87 |
|
| 88 |
-
|
| 89 |
try {
|
| 90 |
const ctrl = new AbortController();
|
| 91 |
-
const tid = setTimeout(() => ctrl.abort(),
|
| 92 |
const r = await fetch('/api/vtv/streams', {signal: ctrl.signal});
|
| 93 |
clearTimeout(tid);
|
| 94 |
if(r.ok){
|
|
@@ -98,7 +97,6 @@ async function loadVTVStreams(){
|
|
| 98 |
const sources = (info && info.all_sources && info.all_sources.length > 0) ? info.all_sources : (info && info.stream_url ? [info.stream_url] : []);
|
| 99 |
if(sources.length > 0){
|
| 100 |
_vtvStreams[ch.id] = sources.map(u => '/api/proxy/m3u8/vtv?url=' + encodeURIComponent(u));
|
| 101 |
-
apiOk = true;
|
| 102 |
}
|
| 103 |
});
|
| 104 |
}
|
|
@@ -112,7 +110,6 @@ async function loadVTVStreams(){
|
|
| 112 |
const fallback = VTV_STATIC_SOURCES[ch.id] || [];
|
| 113 |
_vtvStreams[ch.id] = fallback.map(u => '/api/proxy/m3u8/vtv?url=' + encodeURIComponent(u));
|
| 114 |
}
|
| 115 |
-
// Update tab: always show as available (opacity 1) since we have static fallback
|
| 116 |
const tab = document.getElementById('vtvt-'+ch.id);
|
| 117 |
if(tab){
|
| 118 |
tab.classList.remove('off');
|
|
@@ -166,21 +163,34 @@ function _vtvTryPlay(video, urls, idx, name, loadEl, errEl, errMsg){
|
|
| 166 |
const src = urls[idx];
|
| 167 |
loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang kết nối ' + name + ' (' + (idx+1) + '/' + urls.length + ')...';
|
| 168 |
if(typeof Hls !== 'undefined' && Hls.isSupported()){
|
| 169 |
-
const hls = new Hls({enableWorker:true,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:
|
| 170 |
_vtvHls = hls;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
hls.loadSource(src);
|
| 172 |
hls.attachMedia(video);
|
| 173 |
-
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
hls.on(Hls.Events.ERROR, (ev, data) => {
|
| 176 |
if(data.fatal){
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
try { hls.recoverMediaError(); } catch(e) {}
|
| 183 |
-
} else { hls.destroy(); _vtvHls = null; _vtvTryPlay(video, urls, idx+1, name, loadEl, errEl, errMsg); }
|
| 184 |
}
|
| 185 |
});
|
| 186 |
} else if(video.canPlayType('application/vnd.apple.mpegurl')){
|
|
@@ -197,13 +207,20 @@ async function loadHome(){
|
|
| 197 |
// Load VTV streams in parallel (non-blocking)
|
| 198 |
loadVTVStreams();
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
const[featured,sh,wall,hlLeagues,ai,wcData]=await Promise.all([
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
]);
|
| 208 |
_hlLeagueData=hlLeagues;
|
| 209 |
_wc2026Data=wcData;
|
|
@@ -414,7 +431,58 @@ function bindMatchClicks(el){
|
|
| 414 |
}
|
| 415 |
function openMatch(id,url){if(!id)return;_currentEventId=id;if(url)_currentMatchUrl=url;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('detail')}
|
| 416 |
function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
|
| 417 |
-
async function loadMatchTab(tab){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
async function doInteract(videoId,type){try{const r=await fetch('/api/v2/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,type})});return await r.json();}catch(e){return null;}}
|
| 419 |
async function getInteractions(videoId){try{return await fetch('/api/v2/interactions?id='+encodeURIComponent(videoId)).then(r=>r.json());}catch(e){return{views:0,likes:0,comments:0};}}
|
| 420 |
async function getComments(videoId){try{const j=await fetch('/api/v2/comments?id='+encodeURIComponent(videoId)).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
|
|
|
|
| 80 |
}
|
| 81 |
|
| 82 |
async function loadVTVStreams(){
|
| 83 |
+
// Safety: always hide loader after 3s max
|
|
|
|
| 84 |
const hideLoader = () => { const el = document.getElementById('vtv-load'); if(el) el.style.display = 'none'; };
|
| 85 |
+
setTimeout(hideLoader, 3000);
|
| 86 |
|
| 87 |
+
// Try API with short timeout
|
| 88 |
try {
|
| 89 |
const ctrl = new AbortController();
|
| 90 |
+
const tid = setTimeout(() => ctrl.abort(), 2500);
|
| 91 |
const r = await fetch('/api/vtv/streams', {signal: ctrl.signal});
|
| 92 |
clearTimeout(tid);
|
| 93 |
if(r.ok){
|
|
|
|
| 97 |
const sources = (info && info.all_sources && info.all_sources.length > 0) ? info.all_sources : (info && info.stream_url ? [info.stream_url] : []);
|
| 98 |
if(sources.length > 0){
|
| 99 |
_vtvStreams[ch.id] = sources.map(u => '/api/proxy/m3u8/vtv?url=' + encodeURIComponent(u));
|
|
|
|
| 100 |
}
|
| 101 |
});
|
| 102 |
}
|
|
|
|
| 110 |
const fallback = VTV_STATIC_SOURCES[ch.id] || [];
|
| 111 |
_vtvStreams[ch.id] = fallback.map(u => '/api/proxy/m3u8/vtv?url=' + encodeURIComponent(u));
|
| 112 |
}
|
|
|
|
| 113 |
const tab = document.getElementById('vtvt-'+ch.id);
|
| 114 |
if(tab){
|
| 115 |
tab.classList.remove('off');
|
|
|
|
| 163 |
const src = urls[idx];
|
| 164 |
loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang kết nối ' + name + ' (' + (idx+1) + '/' + urls.length + ')...';
|
| 165 |
if(typeof Hls !== 'undefined' && Hls.isSupported()){
|
| 166 |
+
const hls = new Hls({enableWorker:true,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:8,maxMaxBufferLength:15,maxFragLookUpTolerance:0.5});
|
| 167 |
_vtvHls = hls;
|
| 168 |
+
|
| 169 |
+
// Timeout: if not loaded in 8s, try next source
|
| 170 |
+
const loadTimeout = setTimeout(() => {
|
| 171 |
+
if(_vtvHls === hls) {
|
| 172 |
+
console.warn(`VTV ${name} source ${idx+1} timeout, trying next...`);
|
| 173 |
+
hls.destroy();
|
| 174 |
+
_vtvHls = null;
|
| 175 |
+
_vtvTryPlay(video, urls, idx+1, name, loadEl, errEl, errMsg);
|
| 176 |
+
}
|
| 177 |
+
}, 8000);
|
| 178 |
+
|
| 179 |
hls.loadSource(src);
|
| 180 |
hls.attachMedia(video);
|
| 181 |
+
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
| 182 |
+
clearTimeout(loadTimeout);
|
| 183 |
+
video.play().catch(()=>{});
|
| 184 |
+
loadEl.style.display='none';
|
| 185 |
+
video.style.display='block';
|
| 186 |
+
});
|
| 187 |
hls.on(Hls.Events.ERROR, (ev, data) => {
|
| 188 |
if(data.fatal){
|
| 189 |
+
clearTimeout(loadTimeout);
|
| 190 |
+
// For any fatal error, try next source immediately
|
| 191 |
+
hls.destroy();
|
| 192 |
+
_vtvHls = null;
|
| 193 |
+
_vtvTryPlay(video, urls, idx+1, name, loadEl, errEl, errMsg);
|
|
|
|
|
|
|
| 194 |
}
|
| 195 |
});
|
| 196 |
} else if(video.canPlayType('application/vnd.apple.mpegurl')){
|
|
|
|
| 207 |
// Load VTV streams in parallel (non-blocking)
|
| 208 |
loadVTVStreams();
|
| 209 |
|
| 210 |
+
// Helper: fetch with timeout
|
| 211 |
+
const fetchWithTimeout = (url, ms=12000) => {
|
| 212 |
+
const ctrl = new AbortController();
|
| 213 |
+
const tid = setTimeout(() => ctrl.abort(), ms);
|
| 214 |
+
return fetch(url, {signal: ctrl.signal}).finally(() => clearTimeout(tid));
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
const[featured,sh,wall,hlLeagues,ai,wcData]=await Promise.all([
|
| 218 |
+
fetchWithTimeout('/api/livescore/featured', 8000).then(r=>r.json()).catch(()=>null),
|
| 219 |
+
fetchWithTimeout('/api/shorts', 15000).then(r=>r.json()).catch(()=>[]),
|
| 220 |
+
fetchWithTimeout('/api/wall', 8000).then(r=>r.json()).catch(()=>({posts:[]})),
|
| 221 |
+
fetchWithTimeout('/api/highlights/leagues', 15000).then(r=>r.json()).catch(()=>({})),
|
| 222 |
+
fetchWithTimeout('/api/genk_ai', 12000).then(r=>r.json()).catch(()=>[]),
|
| 223 |
+
fetchWithTimeout('/api/wc2026', 15000).then(r=>r.json()).catch(()=>null)
|
| 224 |
]);
|
| 225 |
_hlLeagueData=hlLeagues;
|
| 226 |
_wc2026Data=wcData;
|
|
|
|
| 431 |
}
|
| 432 |
function openMatch(id,url){if(!id)return;_currentEventId=id;if(url)_currentMatchUrl=url;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('detail')}
|
| 433 |
function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
|
| 434 |
+
async function loadMatchTab(tab){
|
| 435 |
+
document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));
|
| 436 |
+
document.querySelectorAll('.mo-tab').forEach(t=>{
|
| 437 |
+
if((tab==='comm'&&t.textContent==='Diễn biến')||(tab==='stats'&&t.textContent==='Thống kê')||(tab==='detail'&&t.textContent.includes('Chi tiết')))t.classList.add('active')
|
| 438 |
+
});
|
| 439 |
+
const el=document.getElementById('mo-body');
|
| 440 |
+
if(!el)return;
|
| 441 |
+
el.innerHTML='<div class="loading">Đang tải...</div>';
|
| 442 |
+
try{
|
| 443 |
+
let apiUrl;
|
| 444 |
+
if(tab==='stats')apiUrl=`/api/match/${_currentEventId}/stats`;
|
| 445 |
+
else if(tab==='comm')apiUrl=`/api/match/${_currentEventId}/commentaries`;
|
| 446 |
+
else{
|
| 447 |
+
apiUrl=`/api/match/${_currentEventId}/detail`;
|
| 448 |
+
if(_currentMatchUrl)apiUrl+='?url='+encodeURIComponent(_currentMatchUrl);
|
| 449 |
+
}
|
| 450 |
+
const r=await fetch(apiUrl);
|
| 451 |
+
if(!r.ok){el.innerHTML='<div class="loading">Lỗi máy chủ ('+r.status+')</div>';return}
|
| 452 |
+
const d=await r.json();
|
| 453 |
+
if(d.error){el.innerHTML='<div class="loading">'+esc(d.error)+'</div>';return}
|
| 454 |
+
|
| 455 |
+
// For detail tab: show match info even if scraping "found:false"
|
| 456 |
+
if(tab==='detail'){
|
| 457 |
+
if(typeof renderMatchDetail==='function'){
|
| 458 |
+
renderMatchDetail(el,d);
|
| 459 |
+
} else if(d.info && (d.info.home_team || d.info.home)){
|
| 460 |
+
// Basic info display when renderMatchDetail not available
|
| 461 |
+
const info = d.info;
|
| 462 |
+
const home = info.home_team || info.home || '';
|
| 463 |
+
const away = info.away_team || info.away || '';
|
| 464 |
+
const score = info.score || 'VS';
|
| 465 |
+
const league = info.league || info.status_label || '';
|
| 466 |
+
el.innerHTML = `<div class="match-basic-info">
|
| 467 |
+
<div class="mbl-league">${esc(league)}</div>
|
| 468 |
+
<div class="mbl-teams">
|
| 469 |
+
<div class="mbl-team">${esc(home)}</div>
|
| 470 |
+
<div class="mbl-score">${esc(score)}</div>
|
| 471 |
+
<div class="mbl-team">${esc(away)}</div>
|
| 472 |
+
</div>
|
| 473 |
+
${d.found===false ? '<div class="mbl-notice">⚠️ Chi tiết trận đấu chưa có sẵn. Vui lòng thử lại sau.</div>' : ''}
|
| 474 |
+
${d.events ? '<div class="mbl-events"><h4>Diễn biến</h4>' + d.events.map(ev => `<div class="mbl-ev ${ev.team}"><span class="mbl-ev-time">${ev.time||''}</span> ${ev.type==='goal'?'⚽':ev.type==='redcard'?'🟥':ev.type==='yellowcard'?'🟨':'↔️'} ${ev.players||''}</div>`).join('') + '</div>' : ''}
|
| 475 |
+
</div>`;
|
| 476 |
+
} else {
|
| 477 |
+
el.innerHTML='<div class="loading">Không tìm thấy dữ liệu trận đấu</div>';
|
| 478 |
+
}
|
| 479 |
+
return;
|
| 480 |
+
}
|
| 481 |
+
el.innerHTML=d.html||'<div class="loading">Không có dữ liệu</div>';
|
| 482 |
+
}catch(e){
|
| 483 |
+
el.innerHTML='<div class="loading">Lỗi kết nối</div>';
|
| 484 |
+
}
|
| 485 |
+
}
|
| 486 |
async function doInteract(videoId,type){try{const r=await fetch('/api/v2/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,type})});return await r.json();}catch(e){return null;}}
|
| 487 |
async function getInteractions(videoId){try{return await fetch('/api/v2/interactions?id='+encodeURIComponent(videoId)).then(r=>r.json());}catch(e){return{views:0,likes:0,comments:0};}}
|
| 488 |
async function getComments(videoId){try{const j=await fetch('/api/v2/comments?id='+encodeURIComponent(videoId)).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
|