bep40 commited on
Commit
6a8668d
·
verified ·
1 Parent(s): c1299d2

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
Files changed (3) hide show
  1. static/yt_live.js +61 -31
  2. vtv_api.py +21 -20
  3. vtv_scraper.py +48 -35
static/yt_live.js CHANGED
@@ -1,6 +1,6 @@
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
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 = 'vtv6';
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 => r.json())
 
 
 
 
 
 
 
 
 
 
 
 
183
  .then(data => {
184
  hideProc();
185
- if (data.ok) {
 
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 || 'Upload lỗi');
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); // 500ms timeslice
503
  video.currentTime = _rec.startTime;
504
 
505
  const btn = document.getElementById('vtv-rec-btn');
@@ -558,22 +566,22 @@
558
  }
559
  };
560
 
561
- // ===== RATIO CROP — v6: reliable render =====
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 = URL.createObjectURL(blob);
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(video.src); return;
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 = video.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(video.src); return; }
633
 
634
  const chunks = [];
635
  rec.ondataavailable = e => { if(e.data && e.data.size > 0) chunks.push(e.data); };
636
  rec.onstop = () => {
637
- hideProc(); URL.revokeObjectURL(video.src);
 
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(video.src); };
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(video.src);
678
  });
679
  }
680
 
681
  video.onerror = function() {
682
  hideProc(); recStatus('❌ Không đọc được video.', 'err');
683
- URL.revokeObjectURL(video.src);
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 = {"vtv6", "vtv10"}
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-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",
@@ -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
- 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:
@@ -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
- fpt_url = fetch_fptplay_stream(channel_id)
 
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://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8",
46
- "vtv2": "https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8",
47
- "vtv3": "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8",
48
- "vtv4": "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8",
49
- "vtv5": "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8",
50
- "vtv7": "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8",
51
- "vtv8": "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8",
52
- "vtv9": "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8",
53
- "vtv10": "https://live.fptplay53.net/fnxch2/vtvcantho_abr.smil/chunklist.m3u8",
54
- # VTV6 fallback: try canthotv as alternative
55
- "vtv6": "https://live.canthotv.vn/live/tv/chunklist.m3u8",
 
 
 
 
 
 
 
 
 
 
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 by scraping xemtv.net PHP endpoint."""
 
 
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
- php_url = XEMTV_PHP_ENDPOINTS.get(channel_id)
114
- if not php_url:
115
- # Fallback
116
- fallback = CDN_FALLBACK.get(channel_id)
117
- if fallback:
118
- _set_cache(channel_id, fallback)
119
- return fallback
120
- return None
121
 
122
- try:
123
- r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True)
124
- if r.status_code == 200:
125
- m3u8 = extract_m3u8_from_html(r.text)
126
- if m3u8:
127
- _set_cache(channel_id, m3u8)
128
- return m3u8
129
- except:
130
- pass
131
-
132
- # Fallback to CDN
133
- fallback = CDN_FALLBACK.get(channel_id)
134
- if fallback:
135
- _set_cache(channel_id, fallback)
136
- return fallback
 
 
 
 
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