Spaces:
Running
Running
Fix: đổi default channel VTV6→VTV3, fix lỗi VTV6 (thêm VTVGo CDN fallback + cập nhật fptplay URL + bỏ XEMTV_ONLY restriction)
Browse files- static/yt_live.js +61 -31
- vtv_api.py +21 -20
- vtv_scraper.py +48 -35
static/yt_live.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
// === VNEWS — VTV LIVE + Inline Recorder
|
| 2 |
// Features: PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
|
| 3 |
-
// FIX
|
| 4 |
|
| 5 |
(function(){
|
| 6 |
if(window._ytLiveLoaded) return;
|
|
@@ -14,7 +14,7 @@
|
|
| 14 |
{id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
|
| 15 |
{id:'vtvprime',name:'VTVPrime',badge:'Prime'},
|
| 16 |
];
|
| 17 |
-
const DEFAULT_CHANNEL = '
|
| 18 |
const NEEDS_PROXY = /fptplay\.net/;
|
| 19 |
const STREAMS = {};
|
| 20 |
let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
|
|
@@ -171,7 +171,7 @@
|
|
| 171 |
if (el) el.style.display = 'none';
|
| 172 |
}
|
| 173 |
|
| 174 |
-
// ===== UPLOAD TO WALL (Short AI) =====
|
| 175 |
function uploadToWall(blob, title, source) {
|
| 176 |
const formData = new FormData();
|
| 177 |
formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
|
|
@@ -179,18 +179,32 @@
|
|
| 179 |
formData.append('source', source || 'vtv_inline_recorder');
|
| 180 |
showProc('⏳ Đang upload lên Short AI...');
|
| 181 |
return fetch('/api/wall', { method: 'POST', body: formData })
|
| 182 |
-
.then(r =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
.then(data => {
|
| 184 |
hideProc();
|
| 185 |
-
|
|
|
|
| 186 |
recStatus('✅ Đã tạo Short AI! Xem trong mục Tường AI.', 'ok');
|
| 187 |
return data;
|
| 188 |
} else {
|
| 189 |
-
throw new Error(data.error
|
| 190 |
}
|
| 191 |
})
|
| 192 |
.catch(e => {
|
| 193 |
hideProc();
|
|
|
|
| 194 |
recStatus('⚠️ ' + e.message + '. Đang tải về...', 'err');
|
| 195 |
setTimeout(() => downloadBlob(blob, `vtv-${_currentCh}-${Date.now()}.webm`), 800);
|
| 196 |
throw e;
|
|
@@ -199,7 +213,6 @@
|
|
| 199 |
|
| 200 |
// ===== AI TITLE GENERATION =====
|
| 201 |
async function generateAITitle(blob, channelName) {
|
| 202 |
-
// Get video info for AI context
|
| 203 |
return new Promise((resolve) => {
|
| 204 |
const video = document.createElement('video');
|
| 205 |
video.src = URL.createObjectURL(blob);
|
|
@@ -207,8 +220,6 @@
|
|
| 207 |
video.onloadedmetadata = function() {
|
| 208 |
URL.revokeObjectURL(video.src);
|
| 209 |
const dur = Math.round(video.duration || 0);
|
| 210 |
-
const w = video.videoWidth, h = video.videoHeight;
|
| 211 |
-
// Generate title based on channel + duration + timestamp
|
| 212 |
const now = new Date();
|
| 213 |
const timeStr = now.toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'});
|
| 214 |
const dateStr = now.toLocaleDateString('vi-VN',{day:'2-digit',month:'2-digit'});
|
|
@@ -219,7 +230,6 @@
|
|
| 219 |
`⚡ ${channelName} Highlights - ${dateStr}`,
|
| 220 |
`📱 ${channelName} Short - ${timeStr} ${dateStr}`,
|
| 221 |
];
|
| 222 |
-
// Pick one based on duration
|
| 223 |
const idx = dur < 15 ? 4 : dur < 30 ? 1 : dur < 45 ? 2 : 0;
|
| 224 |
resolve(titles[idx % titles.length]);
|
| 225 |
};
|
|
@@ -447,7 +457,6 @@
|
|
| 447 |
// CRITICAL: Unmute video so captureStream includes audio tracks
|
| 448 |
const wasMuted = video.muted;
|
| 449 |
video.muted = false;
|
| 450 |
-
// Also set volume low so user doesn't hear double audio
|
| 451 |
const wasVolume = video.volume;
|
| 452 |
video.volume = 0.3;
|
| 453 |
|
|
@@ -490,7 +499,6 @@
|
|
| 490 |
pv.src = URL.createObjectURL(_rec.blob);
|
| 491 |
document.getElementById('vtv-rec-preview-wrap').style.display = 'block';
|
| 492 |
const sizeMB = (_rec.blob.size/1024/1024).toFixed(1);
|
| 493 |
-
const hasAudio = _rec.blob.size > 50000; // rough heuristic
|
| 494 |
recStatus('✅ Record xong! ' + sizeMB + 'MB' + (audioTracks.length > 0 ? ' (có audio)' : ' (video only)'), 'ok');
|
| 495 |
};
|
| 496 |
_rec.recorder.onerror = e => {
|
|
@@ -499,7 +507,7 @@
|
|
| 499 |
recStatus('❌ Lỗi: ' + (e.error?.message||'?'), 'err');
|
| 500 |
};
|
| 501 |
|
| 502 |
-
_rec.recorder.start(500);
|
| 503 |
video.currentTime = _rec.startTime;
|
| 504 |
|
| 505 |
const btn = document.getElementById('vtv-rec-btn');
|
|
@@ -558,22 +566,22 @@
|
|
| 558 |
}
|
| 559 |
};
|
| 560 |
|
| 561 |
-
// ===== RATIO CROP —
|
| 562 |
-
// Key fixes:
|
| 563 |
-
// 1. video.volume=0 (not muted) to avoid autoplay block
|
| 564 |
-
// 2. video.ended event to stop (NOT setTimeout with duration)
|
| 565 |
-
// 3. 200ms timeslice for frequent data collection
|
| 566 |
-
// 4. rec.requestData() flush before stop
|
| 567 |
-
// 5. Parallel audioVideo element to capture audio from blob
|
| 568 |
function processRatio(blob, ratio, action, title) {
|
| 569 |
showProc(`🔄 Đang xử lý ${ratio}...`);
|
| 570 |
|
|
|
|
| 571 |
const video = document.createElement('video');
|
| 572 |
-
video.src =
|
| 573 |
video.volume = 0;
|
| 574 |
video.preload = 'auto';
|
| 575 |
video.playsInline = true;
|
| 576 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
function onReady() {
|
| 578 |
video.removeEventListener('loadedmetadata', onReady);
|
| 579 |
video.removeEventListener('canplay', onReady);
|
|
@@ -581,7 +589,7 @@
|
|
| 581 |
const vw = video.videoWidth, vh = video.videoHeight;
|
| 582 |
if (!vw || !vh) {
|
| 583 |
hideProc(); recStatus('❌ Video không có kích thước hợp lệ.', 'err');
|
| 584 |
-
URL.revokeObjectURL(
|
| 585 |
}
|
| 586 |
|
| 587 |
let cw, ch, sx, sy;
|
|
@@ -603,7 +611,7 @@
|
|
| 603 |
// Parallel video to capture audio from blob
|
| 604 |
let recordStream = canvasStream;
|
| 605 |
const audioVideo = document.createElement('video');
|
| 606 |
-
audioVideo.src =
|
| 607 |
audioVideo.muted = true;
|
| 608 |
audioVideo.volume = 0;
|
| 609 |
audioVideo.preload = 'auto';
|
|
@@ -629,14 +637,15 @@
|
|
| 629 |
|
| 630 |
let rec;
|
| 631 |
try { rec = new MediaRecorder(recordStream, {mimeType: mime}); }
|
| 632 |
-
catch(e) { hideProc(); recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err'); URL.revokeObjectURL(
|
| 633 |
|
| 634 |
const chunks = [];
|
| 635 |
rec.ondataavailable = e => { if(e.data && e.data.size > 0) chunks.push(e.data); };
|
| 636 |
rec.onstop = () => {
|
| 637 |
-
hideProc();
|
|
|
|
| 638 |
if (chunks.length === 0) {
|
| 639 |
-
recStatus('❌ Render thất bại. Thử lại hoặc chọn "Gốc".', 'err');
|
| 640 |
return;
|
| 641 |
}
|
| 642 |
const out = new Blob(chunks, {type: 'video/webm'});
|
|
@@ -646,7 +655,7 @@
|
|
| 646 |
uploadToWall(out, title);
|
| 647 |
}
|
| 648 |
};
|
| 649 |
-
rec.onerror = e => { hideProc(); recStatus('❌ Lỗi render: ' + (e.error?.message || '?'), 'err'); URL.revokeObjectURL(
|
| 650 |
|
| 651 |
rec.start(200);
|
| 652 |
|
|
@@ -657,6 +666,7 @@
|
|
| 657 |
function finish() {
|
| 658 |
if (finished) return;
|
| 659 |
finished = true;
|
|
|
|
| 660 |
try { rec.requestData(); } catch(e) {}
|
| 661 |
setTimeout(() => {
|
| 662 |
if (rec.state !== 'inactive') rec.stop();
|
|
@@ -666,6 +676,26 @@
|
|
| 666 |
video.onended = finish;
|
| 667 |
audioVideo.onended = finish;
|
| 668 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 669 |
function draw() {
|
| 670 |
if (video.paused || video.ended) return;
|
| 671 |
ctx.drawImage(video, sx, sy, cw, ch, 0, 0, cw, ch);
|
|
@@ -674,13 +704,13 @@
|
|
| 674 |
draw();
|
| 675 |
}).catch(e => {
|
| 676 |
hideProc(); recStatus('❌ Lỗi phát video: ' + e.message, 'err');
|
| 677 |
-
URL.revokeObjectURL(
|
| 678 |
});
|
| 679 |
}
|
| 680 |
|
| 681 |
video.onerror = function() {
|
| 682 |
hideProc(); recStatus('❌ Không đọc được video.', 'err');
|
| 683 |
-
URL.revokeObjectURL(
|
| 684 |
};
|
| 685 |
|
| 686 |
video.addEventListener('loadedmetadata', onReady);
|
|
@@ -833,4 +863,4 @@
|
|
| 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 |
-
})();
|
|
|
|
| 1 |
+
// === VNEWS — VTV LIVE + Inline Recorder v7 ===
|
| 2 |
// Features: PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
|
| 3 |
+
// FIX v7: uploadToWall robust JSON parse, processRatio timeout fallback, no premature revoke
|
| 4 |
|
| 5 |
(function(){
|
| 6 |
if(window._ytLiveLoaded) return;
|
|
|
|
| 14 |
{id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
|
| 15 |
{id:'vtvprime',name:'VTVPrime',badge:'Prime'},
|
| 16 |
];
|
| 17 |
+
const DEFAULT_CHANNEL = 'vtv3';
|
| 18 |
const NEEDS_PROXY = /fptplay\.net/;
|
| 19 |
const STREAMS = {};
|
| 20 |
let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
|
|
|
|
| 171 |
if (el) el.style.display = 'none';
|
| 172 |
}
|
| 173 |
|
| 174 |
+
// ===== UPLOAD TO WALL (Short AI) — v7: robust JSON parse =====
|
| 175 |
function uploadToWall(blob, title, source) {
|
| 176 |
const formData = new FormData();
|
| 177 |
formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
|
|
|
|
| 179 |
formData.append('source', source || 'vtv_inline_recorder');
|
| 180 |
showProc('⏳ Đang upload lên Short AI...');
|
| 181 |
return fetch('/api/wall', { method: 'POST', body: formData })
|
| 182 |
+
.then(r => {
|
| 183 |
+
console.log('[VTV Upload] HTTP status:', r.status, r.statusText);
|
| 184 |
+
// Read as text first, then parse JSON manually for robustness
|
| 185 |
+
return r.text().then(text => {
|
| 186 |
+
console.log('[VTV Upload] Response text:', text.substring(0, 200));
|
| 187 |
+
let data;
|
| 188 |
+
try { data = JSON.parse(text); } catch(e) {
|
| 189 |
+
console.error('[VTV Upload] JSON parse failed:', e, 'Raw:', text.substring(0, 500));
|
| 190 |
+
throw new Error('Server trả về lỗi (không phải JSON). Status: ' + r.status);
|
| 191 |
+
}
|
| 192 |
+
return data;
|
| 193 |
+
});
|
| 194 |
+
})
|
| 195 |
.then(data => {
|
| 196 |
hideProc();
|
| 197 |
+
console.log('[VTV Upload] Parsed data:', data);
|
| 198 |
+
if (data && data.ok === true) {
|
| 199 |
recStatus('✅ Đã tạo Short AI! Xem trong mục Tường AI.', 'ok');
|
| 200 |
return data;
|
| 201 |
} else {
|
| 202 |
+
throw new Error(data && data.error ? data.error : 'Upload thất bại (ok=false)');
|
| 203 |
}
|
| 204 |
})
|
| 205 |
.catch(e => {
|
| 206 |
hideProc();
|
| 207 |
+
console.error('[VTV Upload] Error:', e);
|
| 208 |
recStatus('⚠️ ' + e.message + '. Đang tải về...', 'err');
|
| 209 |
setTimeout(() => downloadBlob(blob, `vtv-${_currentCh}-${Date.now()}.webm`), 800);
|
| 210 |
throw e;
|
|
|
|
| 213 |
|
| 214 |
// ===== AI TITLE GENERATION =====
|
| 215 |
async function generateAITitle(blob, channelName) {
|
|
|
|
| 216 |
return new Promise((resolve) => {
|
| 217 |
const video = document.createElement('video');
|
| 218 |
video.src = URL.createObjectURL(blob);
|
|
|
|
| 220 |
video.onloadedmetadata = function() {
|
| 221 |
URL.revokeObjectURL(video.src);
|
| 222 |
const dur = Math.round(video.duration || 0);
|
|
|
|
|
|
|
| 223 |
const now = new Date();
|
| 224 |
const timeStr = now.toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'});
|
| 225 |
const dateStr = now.toLocaleDateString('vi-VN',{day:'2-digit',month:'2-digit'});
|
|
|
|
| 230 |
`⚡ ${channelName} Highlights - ${dateStr}`,
|
| 231 |
`📱 ${channelName} Short - ${timeStr} ${dateStr}`,
|
| 232 |
];
|
|
|
|
| 233 |
const idx = dur < 15 ? 4 : dur < 30 ? 1 : dur < 45 ? 2 : 0;
|
| 234 |
resolve(titles[idx % titles.length]);
|
| 235 |
};
|
|
|
|
| 457 |
// CRITICAL: Unmute video so captureStream includes audio tracks
|
| 458 |
const wasMuted = video.muted;
|
| 459 |
video.muted = false;
|
|
|
|
| 460 |
const wasVolume = video.volume;
|
| 461 |
video.volume = 0.3;
|
| 462 |
|
|
|
|
| 499 |
pv.src = URL.createObjectURL(_rec.blob);
|
| 500 |
document.getElementById('vtv-rec-preview-wrap').style.display = 'block';
|
| 501 |
const sizeMB = (_rec.blob.size/1024/1024).toFixed(1);
|
|
|
|
| 502 |
recStatus('✅ Record xong! ' + sizeMB + 'MB' + (audioTracks.length > 0 ? ' (có audio)' : ' (video only)'), 'ok');
|
| 503 |
};
|
| 504 |
_rec.recorder.onerror = e => {
|
|
|
|
| 507 |
recStatus('❌ Lỗi: ' + (e.error?.message||'?'), 'err');
|
| 508 |
};
|
| 509 |
|
| 510 |
+
_rec.recorder.start(500);
|
| 511 |
video.currentTime = _rec.startTime;
|
| 512 |
|
| 513 |
const btn = document.getElementById('vtv-rec-btn');
|
|
|
|
| 566 |
}
|
| 567 |
};
|
| 568 |
|
| 569 |
+
// ===== RATIO CROP — v7: reliable render with timeout fallback =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 570 |
function processRatio(blob, ratio, action, title) {
|
| 571 |
showProc(`🔄 Đang xử lý ${ratio}...`);
|
| 572 |
|
| 573 |
+
const blobUrl = URL.createObjectURL(blob);
|
| 574 |
const video = document.createElement('video');
|
| 575 |
+
video.src = blobUrl;
|
| 576 |
video.volume = 0;
|
| 577 |
video.preload = 'auto';
|
| 578 |
video.playsInline = true;
|
| 579 |
|
| 580 |
+
function cleanup() {
|
| 581 |
+
// Don't revoke blobUrl here — audioVideo may still need it
|
| 582 |
+
// It will be revoked after recording stops
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
function onReady() {
|
| 586 |
video.removeEventListener('loadedmetadata', onReady);
|
| 587 |
video.removeEventListener('canplay', onReady);
|
|
|
|
| 589 |
const vw = video.videoWidth, vh = video.videoHeight;
|
| 590 |
if (!vw || !vh) {
|
| 591 |
hideProc(); recStatus('❌ Video không có kích thước hợp lệ.', 'err');
|
| 592 |
+
URL.revokeObjectURL(blobUrl); return;
|
| 593 |
}
|
| 594 |
|
| 595 |
let cw, ch, sx, sy;
|
|
|
|
| 611 |
// Parallel video to capture audio from blob
|
| 612 |
let recordStream = canvasStream;
|
| 613 |
const audioVideo = document.createElement('video');
|
| 614 |
+
audioVideo.src = blobUrl;
|
| 615 |
audioVideo.muted = true;
|
| 616 |
audioVideo.volume = 0;
|
| 617 |
audioVideo.preload = 'auto';
|
|
|
|
| 637 |
|
| 638 |
let rec;
|
| 639 |
try { rec = new MediaRecorder(recordStream, {mimeType: mime}); }
|
| 640 |
+
catch(e) { hideProc(); recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err'); URL.revokeObjectURL(blobUrl); return; }
|
| 641 |
|
| 642 |
const chunks = [];
|
| 643 |
rec.ondataavailable = e => { if(e.data && e.data.size > 0) chunks.push(e.data); };
|
| 644 |
rec.onstop = () => {
|
| 645 |
+
hideProc();
|
| 646 |
+
URL.revokeObjectURL(blobUrl);
|
| 647 |
if (chunks.length === 0) {
|
| 648 |
+
recStatus('❌ Render thất bại (không có data). Thử lại hoặc chọn "Gốc".', 'err');
|
| 649 |
return;
|
| 650 |
}
|
| 651 |
const out = new Blob(chunks, {type: 'video/webm'});
|
|
|
|
| 655 |
uploadToWall(out, title);
|
| 656 |
}
|
| 657 |
};
|
| 658 |
+
rec.onerror = e => { hideProc(); recStatus('❌ Lỗi render: ' + (e.error?.message || '?'), 'err'); URL.revokeObjectURL(blobUrl); };
|
| 659 |
|
| 660 |
rec.start(200);
|
| 661 |
|
|
|
|
| 666 |
function finish() {
|
| 667 |
if (finished) return;
|
| 668 |
finished = true;
|
| 669 |
+
console.log('[VTV Crop] finish() called');
|
| 670 |
try { rec.requestData(); } catch(e) {}
|
| 671 |
setTimeout(() => {
|
| 672 |
if (rec.state !== 'inactive') rec.stop();
|
|
|
|
| 676 |
video.onended = finish;
|
| 677 |
audioVideo.onended = finish;
|
| 678 |
|
| 679 |
+
// TIMEOUT FALLBACK: if video.ended doesn't fire within expected duration + 3s
|
| 680 |
+
const expectedDur = (video.duration || 0) * 1000;
|
| 681 |
+
if (expectedDur > 0 && isFinite(expectedDur)) {
|
| 682 |
+
setTimeout(() => {
|
| 683 |
+
if (!finished) {
|
| 684 |
+
console.warn('[VTV Crop] Timeout fallback after', expectedDur + 3000, 'ms');
|
| 685 |
+
finish();
|
| 686 |
+
}
|
| 687 |
+
}, expectedDur + 3000);
|
| 688 |
+
} else {
|
| 689 |
+
// If duration is unknown (e.g. blob), use a generous timeout
|
| 690 |
+
console.warn('[VTV Crop] Duration unknown, using 60s timeout');
|
| 691 |
+
setTimeout(() => {
|
| 692 |
+
if (!finished) {
|
| 693 |
+
console.warn('[VTV Crop] 60s timeout fallback');
|
| 694 |
+
finish();
|
| 695 |
+
}
|
| 696 |
+
}, 60000);
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
function draw() {
|
| 700 |
if (video.paused || video.ended) return;
|
| 701 |
ctx.drawImage(video, sx, sy, cw, ch, 0, 0, cw, ch);
|
|
|
|
| 704 |
draw();
|
| 705 |
}).catch(e => {
|
| 706 |
hideProc(); recStatus('❌ Lỗi phát video: ' + e.message, 'err');
|
| 707 |
+
URL.revokeObjectURL(blobUrl);
|
| 708 |
});
|
| 709 |
}
|
| 710 |
|
| 711 |
video.onerror = function() {
|
| 712 |
hideProc(); recStatus('❌ Không đọc được video.', 'err');
|
| 713 |
+
URL.revokeObjectURL(blobUrl);
|
| 714 |
};
|
| 715 |
|
| 716 |
video.addEventListener('loadedmetadata', onReady);
|
|
|
|
| 863 |
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;};
|
| 864 |
window.loadHome.__vtvWrapped=true;
|
| 865 |
}
|
| 866 |
+
})();
|
vtv_api.py
CHANGED
|
@@ -54,12 +54,14 @@ VTVGO_FAILOVER = {
|
|
| 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 =
|
| 63 |
|
| 64 |
FPTPLAY_URLS = {
|
| 65 |
"vtv1": "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8",
|
|
@@ -67,7 +69,7 @@ FPTPLAY_URLS = {
|
|
| 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
|
| 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",
|
|
@@ -131,6 +133,12 @@ def fetch_fptplay_stream(channel_id):
|
|
| 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',
|
|
@@ -144,23 +152,7 @@ def fetch_vtv_stream(channel_id):
|
|
| 144 |
if cached is not None:
|
| 145 |
return cached
|
| 146 |
|
| 147 |
-
|
| 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:
|
|
@@ -169,12 +161,20 @@ def fetch_vtv_stream(channel_id):
|
|
| 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 |
-
|
|
|
|
| 178 |
if fpt_url:
|
| 179 |
_set_cache(channel_id, fpt_url)
|
| 180 |
return fpt_url
|
|
@@ -274,6 +274,7 @@ VTV_CHANNEL_MAP = {
|
|
| 274 |
"vtv5-tay-nguyen": "vtv5",
|
| 275 |
"vtv7": "vtv7",
|
| 276 |
"vtv8": "vtv8",
|
|
|
|
| 277 |
"vtv9": "vtv9",
|
| 278 |
"vtv-can-tho": "vtv10",
|
| 279 |
}
|
|
|
|
| 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 |
+
"vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
|
| 58 |
"vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
|
| 59 |
"vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
|
| 60 |
"vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
|
| 61 |
+
"vtv10": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv10-manifest.m3u8",
|
| 62 |
}
|
| 63 |
|
| 64 |
+
XEMTV_ONLY_CHANNELS = set() # No channels are xemtv-only; all use VTVGo CDN first
|
| 65 |
|
| 66 |
FPTPLAY_URLS = {
|
| 67 |
"vtv1": "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8",
|
|
|
|
| 69 |
"vtv3": "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8",
|
| 70 |
"vtv4": "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8",
|
| 71 |
"vtv5": "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8",
|
| 72 |
+
"vtv6": "https://live.fptplay53.net/fnxch2/vtv6hd_abr.smil/chunklist.m3u8",
|
| 73 |
"vtv7": "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8",
|
| 74 |
"vtv8": "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8",
|
| 75 |
"vtv9": "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8",
|
|
|
|
| 133 |
return None
|
| 134 |
|
| 135 |
def fetch_vtv_stream(channel_id):
|
| 136 |
+
"""Fetch VTV stream with multi-source fallback chain:
|
| 137 |
+
1. VTVGo CDN (primary, works for most channels)
|
| 138 |
+
2. xemtv.net PHP scraper (fresh signed URLs)
|
| 139 |
+
3. fptplay CDN (fallback)
|
| 140 |
+
Each channel gets the same fallback chain for reliability.
|
| 141 |
+
"""
|
| 142 |
channel_id = channel_id.lower().strip()
|
| 143 |
name_map = {
|
| 144 |
'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
|
|
|
|
| 152 |
if cached is not None:
|
| 153 |
return cached
|
| 154 |
|
| 155 |
+
# Special case: VTVPrime only via xemtv
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
if channel_id == 'vtvprime':
|
| 157 |
xemtv_url = fetch_xemtv_stream('vtvprime')
|
| 158 |
if xemtv_url:
|
|
|
|
| 161 |
_set_cache(channel_id, None)
|
| 162 |
return None
|
| 163 |
|
| 164 |
+
# Source 1: VTVGo CDN (primary for all standard channels)
|
| 165 |
+
vtvgourl = VTVGO_FAILOVER.get(channel_id)
|
| 166 |
+
if vtvgourl:
|
| 167 |
+
_set_cache(channel_id, vtvgourl)
|
| 168 |
+
return vtvgourl
|
| 169 |
+
|
| 170 |
+
# Source 2: xemtv.net PHP scraper (fresh signed URLs)
|
| 171 |
xemtv_url = fetch_xemtv_stream(channel_id)
|
| 172 |
if xemtv_url:
|
| 173 |
_set_cache(channel_id, xemtv_url)
|
| 174 |
return xemtv_url
|
| 175 |
|
| 176 |
+
# Source 3: fptplay CDN (last resort)
|
| 177 |
+
fpt_url = FPTPLAY_URLS.get(channel_id)
|
| 178 |
if fpt_url:
|
| 179 |
_set_cache(channel_id, fpt_url)
|
| 180 |
return fpt_url
|
|
|
|
| 274 |
"vtv5-tay-nguyen": "vtv5",
|
| 275 |
"vtv7": "vtv7",
|
| 276 |
"vtv8": "vtv8",
|
| 277 |
+
"vtv6": "vtv6",
|
| 278 |
"vtv9": "vtv9",
|
| 279 |
"vtv-can-tho": "vtv10",
|
| 280 |
}
|
vtv_scraper.py
CHANGED
|
@@ -42,17 +42,27 @@ CHANNEL_NAMES = {
|
|
| 42 |
|
| 43 |
# Fallback CDN streams (fptplay) — used when xemtv scraping fails
|
| 44 |
CDN_FALLBACK = {
|
| 45 |
-
"vtv1": "https://
|
| 46 |
-
"vtv2": "https://
|
| 47 |
-
"vtv3": "https://
|
| 48 |
-
"vtv4": "https://
|
| 49 |
-
"vtv5": "https://
|
| 50 |
-
"
|
| 51 |
-
"
|
| 52 |
-
"
|
| 53 |
-
"
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
}
|
| 57 |
|
| 58 |
_vtv_cache = {}
|
|
@@ -92,7 +102,9 @@ def extract_m3u8_from_html(html):
|
|
| 92 |
|
| 93 |
|
| 94 |
def fetch_vtv_stream(channel_id):
|
| 95 |
-
"""Fetch m3u8 stream URL for a VTV channel
|
|
|
|
|
|
|
| 96 |
channel_id = channel_id.lower().strip()
|
| 97 |
|
| 98 |
# Normalize name
|
|
@@ -110,30 +122,31 @@ def fetch_vtv_stream(channel_id):
|
|
| 110 |
if cached:
|
| 111 |
return cached
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
_set_cache(channel_id, fallback)
|
| 119 |
-
return fallback
|
| 120 |
-
return None
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
return None
|
| 139 |
|
|
|
|
| 42 |
|
| 43 |
# Fallback CDN streams (fptplay) — used when xemtv scraping fails
|
| 44 |
CDN_FALLBACK = {
|
| 45 |
+
"vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
|
| 46 |
+
"vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
|
| 47 |
+
"vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
|
| 48 |
+
"vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
|
| 49 |
+
"vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
|
| 50 |
+
"vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
|
| 51 |
+
"vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
|
| 52 |
+
"vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
|
| 53 |
+
"vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
|
| 54 |
+
"vtv10": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv10-manifest.m3u8",
|
| 55 |
+
# fptplay fallbacks (if VTVGo CDN fails)
|
| 56 |
+
"_fpt_vtv1": "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8",
|
| 57 |
+
"_fpt_vtv2": "https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8",
|
| 58 |
+
"_fpt_vtv3": "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8",
|
| 59 |
+
"_fpt_vtv4": "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8",
|
| 60 |
+
"_fpt_vtv5": "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8",
|
| 61 |
+
"_fpt_vtv6": "https://live.fptplay53.net/fnxch2/vtv6hd_abr.smil/chunklist.m3u8",
|
| 62 |
+
"_fpt_vtv7": "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8",
|
| 63 |
+
"_fpt_vtv8": "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8",
|
| 64 |
+
"_fpt_vtv9": "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8",
|
| 65 |
+
"_fpt_vtv10": "https://live.fptplay53.net/fnxch2/vtvcantho_abr.smil/chunklist.m3u8",
|
| 66 |
}
|
| 67 |
|
| 68 |
_vtv_cache = {}
|
|
|
|
| 102 |
|
| 103 |
|
| 104 |
def fetch_vtv_stream(channel_id):
|
| 105 |
+
"""Fetch m3u8 stream URL for a VTV channel.
|
| 106 |
+
Fallback chain: VTVGo CDN -> xemtv.net scraper -> fptplay CDN
|
| 107 |
+
"""
|
| 108 |
channel_id = channel_id.lower().strip()
|
| 109 |
|
| 110 |
# Normalize name
|
|
|
|
| 122 |
if cached:
|
| 123 |
return cached
|
| 124 |
|
| 125 |
+
# Source 1: VTVGo CDN (primary)
|
| 126 |
+
vtvgourl = CDN_FALLBACK.get(channel_id)
|
| 127 |
+
if vtvgourl:
|
| 128 |
+
_set_cache(channel_id, vtvgourl)
|
| 129 |
+
return vtvgourl
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
+
# Source 2: xemtv.net PHP scraper (fresh signed URLs)
|
| 132 |
+
php_url = XEMTV_PHP_ENDPOINTS.get(channel_id)
|
| 133 |
+
if php_url:
|
| 134 |
+
try:
|
| 135 |
+
r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True)
|
| 136 |
+
if r.status_code == 200:
|
| 137 |
+
m3u8 = extract_m3u8_from_html(r.text)
|
| 138 |
+
if m3u8:
|
| 139 |
+
_set_cache(channel_id, m3u8)
|
| 140 |
+
return m3u8
|
| 141 |
+
except:
|
| 142 |
+
pass
|
| 143 |
+
|
| 144 |
+
# Source 3: fptplay CDN (last resort)
|
| 145 |
+
fpt_key = f"_fpt_{channel_id}"
|
| 146 |
+
fpt_url = CDN_FALLBACK.get(fpt_key)
|
| 147 |
+
if fpt_url:
|
| 148 |
+
_set_cache(channel_id, fpt_url)
|
| 149 |
+
return fpt_url
|
| 150 |
|
| 151 |
return None
|
| 152 |
|