Trigger Space rebuild

#49
.gitignore DELETED
@@ -1,6 +0,0 @@
1
- __pycache__/
2
- *.pyc
3
- data/
4
- .data
5
- .huggingface/
6
- .restart_trigger
 
 
 
 
 
 
 
.huggingface/main.json ADDED
File without changes
.restart_trigger ADDED
@@ -0,0 +1 @@
 
 
1
+ trigger rebuild 1785053712
CHANGELOG.md CHANGED
@@ -1,18 +1,74 @@
1
- # CHANGELOG
2
-
3
- ## v20 - Stream URL → HLS conversion (m3u channels)
4
- - `proxy_m3u_hls` now content-sniffs **every** stream URL instead of relying on
5
- the `.m3u8`-in-path suffix. This fixes `.ts`/`mp2t` channel links that actually
6
- serve an HLS playlist (common on Stalker portals) — they are now rewritten
7
- (not wrapped as raw TS), and vice-versa.
8
- - Master playlists are resolved to their first playable media variant before
9
- rewriting segment URIs through `/api/proxy/seg`.
10
- - Raw MPEG-TS / Stalker streams are now wrapped in a `#EXT-X-PLAYLIST-TYPE:EVENT`
11
- manifest with 3 repeating `#EXTINF` segments + `#EXT-X-DISCONTINUITY` (the old
12
- single-segment manifest caused Hls.js to stall after ~8s).
13
- - `proxy_m3u` (`.m3u`/`.m3u8`) now content-sniffs upstream and falls back to the
14
- HLS wrapper for non-playlist responses.
15
- - Probe timeouts widened (connect 10s) for servers that flag the HF Spaces egress IP.
16
-
17
- ## v19 - AI ảnh nền SenseNova-U1.5 + text overlay overhaul
18
- (see prev changelog)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VNEWS v2.8 - Icon Change
2
+ - Changed Short AI feed video share button icon from 📤 (upload/share) to 📥 (download) to distinguish from article share
3
+
4
+ # VNEWS v6.5 - Resilient Shorts Auto-Updater
5
+
6
+ ## Changes
7
+
8
+ ### Critical Fix: Shorts timeout and homepage load stability
9
+ **Root cause**: YouTube shorts fetching in `main.py` using `scrape_shorts()` and `_yt_channel_shorts_requests()` could hang indefinitely when YouTube blocks requests or yt-dlp times out, causing:
10
+ - Homepage `/api/shorts` endpoint to time out (30s limit)
11
+ - Space to appear unresponsive on first load
12
+ - No fallback when sources fail
13
+
14
+ **Fix applied**:
15
+ 1. **shorts_updater.py** (NEW) — Resilient background updater:
16
+ - Hard timeout (25s) per channel using subprocess isolation
17
+ - Stale-while-revalidate pattern: returns cached data immediately, updates in background
18
+ - Automatic fallback to hardcoded short URLs when all sources fail
19
+ - Persistent storage in `/data/shorts_cache.json` for cache across restarts
20
+ - Background scheduler runs every 10 minutes automatically
21
+ - No blocking on first homepage load
22
+
23
+ 2. **_run.py** — Integrated resilient shorts endpoint:
24
+ - Overrides `/api/shorts` with non-blocking version
25
+ - Returns cached/fallback data in <100ms guaranteed
26
+ - Triggers background update if cache is stale or empty
27
+ - Never hangs - always returns valid JSON response
28
+
29
+ 3. **FALLBACK_SHORTS** — 6 hardcoded viral shorts as emergency fallback:
30
+ - baodantri7941 (Dân trí) headlines
31
+ - baosuckhoedoisongboyte (Sức khỏe & đời sống) stories
32
+ - vtvnambo (VTV Nam Bộ) news
33
+
34
+ ### Benefits
35
+ - Homepage loads in <2 seconds always
36
+ - Shorts data auto-updates every 10 minutes
37
+ - Never times out - graceful degradation to fallback
38
+ - Persistent cache survives Space restarts
39
+ - Uses bucket `bep40/VNEWS-storage` for cache storage
40
+
41
+ ### Channels monitored
42
+ - baodantri7941 (Dân trí)
43
+ - baosuckhoedoisongboyte (Sức khỏe & đời sống)
44
+ - vtvnambo (VTV Nam Bộ)
45
+
46
+ ---
47
+
48
+ # VNEWS v5.1 - Rewrite Fix
49
+
50
+ ## Changes
51
+
52
+ ### Critical Fix: Rewrite button not creating posts on Tường AI
53
+ **Root cause**: `_run.py` imports from `app_v2_entry.py`, but the `/api/rewrite_share` endpoint was only defined in `ai_runtime_patch_fast.py` (loaded through `app_entry.py` which is NOT used). The frontend called a non-existent endpoint → 404 → silent failure.
54
+
55
+ **Fix applied**:
56
+ 1. **app_v2_entry.py** — Added 3 new endpoints:
57
+ - `POST /api/rewrite_slide` — Fast extractive summary (no AI needed), creates slides from article key points + images, saves to wall
58
+ - `POST /api/rewrite_share` — AI-powered rewrite with extractive fallback, saves to wall
59
+ - `POST /api/url_wall` — URL submission endpoint (alias for rewrite_share)
60
+ - All endpoints use the same `_load_wall_posts()` / `_save_wall_posts()` and `WALL_FILE` path as the existing `/api/wall` endpoint
61
+
62
+ 2. **static/index_v2.html** — Added `<script src="/static/rewrite_fix_v2.js"></script>` to load the rewrite fix
63
+
64
+ 3. **static/rewrite_fix_v2.js** — New file that overrides `rewriteArticle()` to:
65
+ - Call `/api/rewrite_slide` first (fast, no AI needed)
66
+ - Fallback to `/api/rewrite_share` if slide fails
67
+ - Show slide preview overlay after successful post
68
+ - Use `prependWallPost()` to add the new post to Tường AI
69
+
70
+ ### Previous changes (v5)
71
+ - Rewrote match_detail_v2.py with correct event parsing
72
+ - 2-tab layout for match detail (stats + timeline)
73
+ - Fixed _run.py import
74
+ - Dockerfile cache busting
Dockerfile CHANGED
@@ -20,7 +20,7 @@ RUN echo "[BUILD] step2: pip base pkgs (bs4/lxml)" && \
20
  echo "[BUILD] step2 done"
21
 
22
  RUN echo "[BUILD] step3: pip main pkgs" && \
23
- pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx pycryptodome && \
24
  echo "[BUILD] step3 done"
25
 
26
  COPY requirements.txt .
@@ -46,4 +46,3 @@ RUN echo "[BUILD] step5: setup Vietnamese font symlink" && \
46
 
47
  CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860"]
48
  # v3.0-vn-font-fix-short-video-2026-07-19
49
- # rebuild-trigger: m3u-perf-fix-20260829
 
20
  echo "[BUILD] step2 done"
21
 
22
  RUN echo "[BUILD] step3: pip main pkgs" && \
23
+ pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx && \
24
  echo "[BUILD] step3 done"
25
 
26
  COPY requirements.txt .
 
46
 
47
  CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860"]
48
  # v3.0-vn-font-fix-short-video-2026-07-19
 
README.md CHANGED
@@ -11,33 +11,7 @@ tags:
11
 
12
  # VNEWS - Tin Tức Việt Nam
13
 
14
- **v19 - AI ảnh nền SenseNova-U1.5 + text overlay overhaul**
15
-
16
- ## 🔧 Hotfix (2026-09-07) — chữ không hiện trên ảnh nền
17
-
18
- "🖼️ Tạo Ảnh Bài Viết" modal: khi tính fi "High contrast, no blur" (loái bỏ nền
19
- mờ/foggy band) đã giới thiệu, text overlay silently sp qué không vẽ — only the
20
- background image rendered and the chữ disappeared. Root cause:
21
- `_pidDrawTextWrapped` referenced `opts._deepBand` but `opts` was NOT a parameter
22
- of that function (under `'use strict'` this raised a ReferenceError that was
23
- swallowed by the try/catch in `_pidRenderSlide`). Fix: pass `opts` as the last
24
- argument through all 8 call sites.
25
-
26
- ## 🔧 Changes in v19 (2026-09-06)
27
- - **AI ảnh nền**: upgraded to **SenseNova-U1.5-8B-MoT** (same model family as
28
- the official `hugging-apps/sensenova-sensenova-u1-5-8b-mot` demo) with the
29
- reference-quality sampling recipe: cfg_scale=4.0, timestep_shift=3.0,
30
- 28 default / 50 max denoising steps (was U1-8B Q4 GGUF at 4-8 steps; now U1.5-8B-MoT Q6_K GGUF), native
31
- T2I resolution buckets (3:4 → 1760×2368, …) before resize.
32
- - **🔠 Size chữ (NEW)**: S/M/L/XL dropdown to adjust font size of the text on
33
- the image.
34
- - **Vị trí chữ (fixed)**: Giữa / Trên / Dưới is now ALWAYS honored — also when
35
- the "Chữ trên ảnh" overlay contains text (previously the overlay forced its
36
- own top band and ignored the position).
37
- - **High contrast, no blur (fixed)**: text now renders on a crisp solid dark
38
- band with black outline + optional glow — readable on any background, no more
39
- foggy striped scrim.
40
- - Backend requests 28 steps + seed through `/api/pid/gen_bg`.
41
 
42
  ## 🔧 Changes in v18 (2026-07-06)
43
  - **VTV2, VTV3, VTV6, VTV9**: Skip expired ssaimh CDN token → immediately fall through to sv2.xemtivitop.com
@@ -56,29 +30,4 @@ argument through all 8 call sites.
56
  - 🏆 World Cup 2026 (news, fixtures, standings, stats, highlights)
57
  - 🤖 AI article writing + TTS (multilingual, emotion-aware)
58
  - 🔍 Topic search (8 news sources)
59
- - 🎤 TTS: voice selector + emotion selector + speed control
60
-
61
- ## 🎬 Short AI — Video từ link (scrap YouTube / TikTok / tin tức)
62
-
63
- Short creator có chế độ **"🔗 Video từ link"**: dán link video YouTube / TikTok /
64
- VnExpress / Dân trí / Znews / 24h... → bấm "Lấy video" để xem trước → tạo short
65
- chạy video + ảnh đã chọn bù phần còn thiếu nếu video ngắn hơn giọng đọc.
66
-
67
- ### 🔑 Cài cookies cho YouTube (bỏ chặn "Sign in to confirm you're not a bot")
68
- YouTube đôi khi chặn IP datacenter. Cách khắc phục bằng cookies:
69
-
70
- 1. Cài extension trình duyệt **"Get cookies.txt LOCALLY"** (Chrome/Edge) hoặc
71
- **"cookies.txt"** (Firefox).
72
- 2. Mở `https://www.youtube.com` (đã đăng nhập) → bấm extension → **Export** → ra file `cookies.txt` (định dạng Netscape).
73
- 3. Đưa cookies vào Space bằng **một trong hai cách**:
74
- - **Cách A (khuyến nghị):** Vào Settings của Space
75
- `huggingface.co/spaces/bep40/VNEWS/settings` → **Variables and secrets** →
76
- tạo secret tên **`YT_COOKIES`**, giá trị = toàn bộ nội dung file `cookies.txt`.
77
- - **Cách B:** đặt file `cookies.txt` vào thư mục gốc repo `VNEWS/` và commit
78
- (chú ý: cookies sẽ công khai nếu repo public — ưu tiên Cách A).
79
- 4. Rebuild Space (mỗi lần đổi secret phải **Restart** Space).
80
-
81
- Backend tự đọc `YT_COOKIES` (secret) hoặc `/app/cookies.txt`, ghi thành file tạm
82
- và truyền cho yt-dlp qua `cookiefile`. Không cần sửa code.
83
-
84
- > Lưu ý: cookies có hạn (thường vài tuần). Khi hết hạn, export lại và cập nhật secret.
 
11
 
12
  # VNEWS - Tin Tức Việt Nam
13
 
14
+ **v18 - FIXED VTV2/VTV3/VTV6/VTV9 stream hanging**
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  ## 🔧 Changes in v18 (2026-07-06)
17
  - **VTV2, VTV3, VTV6, VTV9**: Skip expired ssaimh CDN token → immediately fall through to sv2.xemtivitop.com
 
30
  - 🏆 World Cup 2026 (news, fixtures, standings, stats, highlights)
31
  - 🤖 AI article writing + TTS (multilingual, emotion-aware)
32
  - 🔍 Topic search (8 news sources)
33
+ - 🎤 TTS: voice selector + emotion selector + speed control
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
RESTART_TRIGGER.md CHANGED
@@ -1 +1,6 @@
1
- pid-clean-v3 1788611499
 
 
 
 
 
 
1
+ trigger rebuild 2026-07-18T10:35 +0700 - add missing ai/short/ and ai/short-file/ endpoints
2
+
3
+ - Added POST /api/ai/short/{post_id} endpoint (was lost during route cleanup)
4
+ - Added GET /api/ai/short-file/{file_id} endpoint (file serving)
5
+ - Both were supposed to be in ai_patch.py but never existed there
6
+ - Also added FileResponse import
RESTORE_FROM_5B6000C.md DELETED
@@ -1,30 +0,0 @@
1
- # TRẠNG THÁI: ĐÃ KHÔI PHỤC ✅
2
-
3
- ## 2026-09-13 — Khôi phục hoàn tất (không cần làm gì thêm)
4
-
5
- **main.py** đã được khôi phục về đúng nội dung commit `5b6000c58f89723b3d228afb06c891ce909ac488`
6
- (64.2KB, FastAPI app đầy đủ) và **đã persist lên branch main** qua cơ chế self-heal
7
- trong `_run.py` (Space container dùng HF_TOKEN để upload_file trở lại repo).
8
-
9
- ## Fix HLS v21 (đã áp dụng + persist lên main)
10
-
11
- 1. **`main.py` — `/api/proxy/m3u_hls`** (raw MPEG-TS / Stalker stream):
12
- - Segment lặp: 3 → **8×** `#EXTINF:8.0`
13
- - Thêm `#EXT-X-DISCONTINUITY-SEQUENCE:0`
14
- - Giữ: `#EXT-X-VERSION:6`, `#EXT-X-TARGETDURATION:8`, `#EXT-X-MEDIA-SEQUENCE:0`,
15
- `#EXT-X-START:TIME-OFFSET=0,PRECISE=YES`
16
- - Không có `#EXT-X-ENDLIST`, không có `#EXT-X-DISCONTINUITY` cuối
17
- - Lý do: playlist 3 segment bị Hls.js cạn → stall sau ~8s; 8 segment + sliding window giữ live.
18
-
19
- 2. **`static/vtv_init.js` — `playNativeHLS()`** (2 call site `new Hls(...)`):
20
- - Thêm `liveDurationInfinity: true, liveSyncDurationCount: 5, maxBufferLength: 60, maxMaxBufferLength: 240`
21
-
22
- 3. **`_run.py`** — entrypoint self-heal + idempotent patch (giữ an toàn):
23
- - Nếu `main.py` <50KB hoặc chứa PLACEHOLDER → tự tải từ rev `5b6000c…` → ghi đè.
24
- - Tự patch manifest + vtv_init.js nếu thiếu → persist lên repo (nếu có HF_TOKEN).
25
-
26
- ## Lịch sử sự cố
27
- - `main.py` trên `main` bị ghi đè thành `"PLACEHOLDER"` (11B) → import chain
28
- `_run.py → app_v2_entry.py → main.py` crash → Space `RUNTIME_ERROR`.
29
- - Khôi phục: self-heal `_run.py` → container tự sửa khi boot → persist lên main.
30
- - SHA persist cuối: `f1562219b55c058092ea3c2eb19316269b74484e` (main).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
_run.py CHANGED
@@ -1,183 +1 @@
1
- """VNEWS boot entry — self-heal + HLS live fixes before import.
2
-
3
- 1) If main.py is the PLACEHOLDER stub (or otherwise damaged), restore the
4
- known-good version from commit 5b6000c via huggingface_hub.
5
- 2) Apply the m3u_hls live fix to main.py if it still has the old 3-segment
6
- manifest: bump to 8 repeating #EXTINF:8.0 segments, add
7
- #EXT-X-DISCONTINUITY-SEQUENCE:0, keep the playlist open (no ENDLIST, no
8
- trailing #EXT-X-DISCONTINUITY).
9
- 3) Apply Hls.js live tuning to static/vtv_init.js if not present:
10
- liveDurationInfinity / liveSyncDurationCount / maxBufferLength.
11
- 4) Persist any local changes back to the repo 'main' branch (best-effort,
12
- requires a write token in the Space env, e.g. HF_TOKEN secret).
13
- """
14
- import os
15
-
16
- _APP_DIR = os.path.dirname(os.path.abspath(__file__))
17
- _MAIN = os.path.join(_APP_DIR, "main.py")
18
- _VTV = os.path.join(_APP_DIR, "static", "vtv_init.js")
19
- _GOOD_REV = "5b6000c58f89723b3d228afb06c891ce909ac488"
20
- _REPO = "bep40/VNEWS"
21
-
22
-
23
- def _read(p):
24
- try:
25
- with open(p, encoding="utf-8", errors="replace") as f:
26
- return f.read()
27
- except Exception:
28
- return ""
29
-
30
-
31
- def _write(p, s):
32
- with open(p, "w", encoding="utf-8") as f:
33
- f.write(s)
34
-
35
-
36
- def _main_is_broken(path):
37
- try:
38
- if os.path.getsize(path) < 50000:
39
- return True
40
- head = _read(path)[:2048]
41
- return "PLACEHOLDER" in head or "app = FastAPI()" not in head
42
- except Exception:
43
- return True
44
-
45
-
46
- # --- old 3-segment manifest block in main.py (verified against 5b6000c) ---
47
- _OLD_MANIFEST = (
48
- ' _seg = seg_proxy_base\n'
49
- ' manifest = (\n'
50
- ' "#EXTM3U\\n"\n'
51
- ' "#EXT-X-VERSION:6\\n"\n'
52
- ' "#EXT-X-TARGETDURATION:8\\n"\n'
53
- ' "#EXT-X-MEDIA-SEQUENCE:0\\n"\n'
54
- ' "#EXT-X-START:TIME-OFFSET=0,PRECISE=YES\\n"\n'
55
- ' "#EXTINF:8.0,\\n"\n'
56
- ' + _seg + "\\n"\n'
57
- ' "#EXTINF:8.0,\\n"\n'
58
- ' + _seg + "\\n"\n'
59
- ' "#EXTINF:8.0,\\n"\n'
60
- ' + _seg + "\\n"\n'
61
- ' )\n'
62
- )
63
- _NEW_MANIFEST = (
64
- ' _seg = seg_proxy_base\n'
65
- ' manifest = (\n'
66
- ' "#EXTM3U\\n"\n'
67
- ' "#EXT-X-VERSION:6\\n"\n'
68
- ' "#EXT-X-TARGETDURATION:8\\n"\n'
69
- ' "#EXT-X-MEDIA-SEQUENCE:0\\n"\n'
70
- ' "#EXT-X-DISCONTINUITY-SEQUENCE:0\\n"\n'
71
- ' "#EXT-X-START:TIME-OFFSET=0,PRECISE=YES\\n"\n'
72
- + ''.join(' "#EXTINF:8.0,\\n"\n + _seg + "\\n"\n' for _ in range(8))
73
- + ' )\n'
74
- )
75
-
76
-
77
- def _fix_main_hls(content):
78
- if "#EXT-X-DISCONTINUITY-SEQUENCE" in content:
79
- return content, False
80
- if content.count("#EXTINF:8.0") >= 8:
81
- return content, False
82
- if _OLD_MANIFEST not in content:
83
- return content, False
84
- new = content.replace(_OLD_MANIFEST, _NEW_MANIFEST, 1)
85
- if new != content:
86
- return new, True
87
- return content, False
88
-
89
-
90
- _OLD_HLS_CONFIG = "new Hls({ enableWorker: false })"
91
- _NEW_HLS_CONFIG = (
92
- "new Hls({ enableWorker: false, liveDurationInfinity: true, "
93
- "liveSyncDurationCount: 5, maxBufferLength: 60, maxMaxBufferLength: 240 })"
94
- )
95
-
96
-
97
- def _fix_vtv(content):
98
- if "liveDurationInfinity" in content:
99
- return content, False
100
- if _OLD_HLS_CONFIG not in content:
101
- return content, False
102
- new = content.replace(_OLD_HLS_CONFIG, _NEW_HLS_CONFIG, 1)
103
- return new, new != content
104
-
105
-
106
- def _persist(fn, data):
107
- """Best-effort: push patched file back to the repo 'main' branch so the
108
- fix is permanent, not just container-local. Never raises."""
109
- try:
110
- token = os.environ.get("HF_TOKEN")
111
- if not token:
112
- print(f"[self-heal] no HF_TOKEN — skip persist {fn}", flush=True)
113
- return False
114
- from huggingface_hub import upload_file
115
-
116
- upload_file(
117
- path_or_fileobj=data.encode("utf-8"),
118
- path_in_repo=fn,
119
- repo_id=_REPO,
120
- repo_type="space",
121
- token=token,
122
- commit_message=f"[hls-fix] {fn}: 8 segments + live config",
123
- )
124
- print(f"[self-heal] persisted {fn}", flush=True)
125
- return True
126
- except Exception as e:
127
- print(f"[self-heal] persist {fn} failed: {e}", flush=True)
128
- return False
129
-
130
-
131
- def _restore_main():
132
- print(f"[self-heal] main.py damaged -> restoring from {_GOOD_REV}", flush=True)
133
- try:
134
- from huggingface_hub import hf_hub_download
135
-
136
- good = hf_hub_download(
137
- repo_id=_REPO,
138
- filename="main.py",
139
- revision=_GOOD_REV,
140
- repo_type="space",
141
- )
142
- with open(good, encoding="utf-8") as f:
143
- data = f.read()
144
- assert "app = FastAPI()" in data and "proxy_m3u_hls" in data
145
- _write(_MAIN, data)
146
- print(f"[self-heal] main.py restored ({len(data)} bytes)", flush=True)
147
- return True
148
- except Exception as e:
149
- print(f"[self-heal] FAILED to restore main.py: {e}", flush=True)
150
- return False
151
-
152
-
153
- def _apply_fixes():
154
- changed = []
155
- try:
156
- if _main_is_broken(_MAIN):
157
- _restore_main()
158
- mc = _read(_MAIN)
159
- if mc:
160
- nm, changed_m = _fix_main_hls(mc)
161
- if changed_m:
162
- _write(_MAIN, nm)
163
- print("[hls] main.py patched: 8 segments + DISCONTINUITY-SEQUENCE:0", flush=True)
164
- changed.append("main.py")
165
- vc = _read(_VTV)
166
- if vc:
167
- nv, changed_v = _fix_vtv(vc)
168
- if changed_v:
169
- _write(_VTV, nv)
170
- print("[hls] vtv_init.js patched: liveDurationInfinity config", flush=True)
171
- changed.append("static/vtv_init.js")
172
- except Exception as e:
173
- print(f"[hls] review fixes failed (non-fatal): {e}", flush=True)
174
- for fn in changed:
175
- try:
176
- _persist(fn, _read(os.path.join(_APP_DIR, fn)))
177
- except Exception as e:
178
- print(f"[hls] persist {fn} error: {e}", flush=True)
179
-
180
-
181
- _apply_fixes()
182
-
183
  from app_v2_entry import app # v5-stable inline bongda proxy
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from app_v2_entry import app # v5-stable inline bongda proxy
ai_ext.py CHANGED
@@ -54,154 +54,6 @@ except ImportError:
54
  _load_ai_wall = _load_wall
55
  _save_ai_wall = _save_wall
56
 
57
-
58
- # =========================================================================
59
- # Shared Tường AI wall view logic (sort + filter)
60
- # Used by /api/wall and /api/ai_wall so every consumer sees the SAME
61
- # newest-first, content-filtered feed.
62
- # =========================================================================
63
- _PLACEHOLDER_MARKERS = (
64
- "pollinations.ai", # AI-generated editorial illustration placeholder
65
- "logo_default.jpg", # vnexpress placeholder logo
66
- "logo.png",
67
- "/logo",
68
- "placeholder",
69
- "no-image",
70
- "blank",
71
- )
72
-
73
- def _is_placeholder_url(url):
74
- if not url or not isinstance(url, str):
75
- return True
76
- u = url.strip().lower()
77
- if not u or u.startswith(("about:blank", "data:")):
78
- return True
79
- return any(m in u for m in _PLACEHOLDER_MARKERS)
80
-
81
- def _is_real_url(url):
82
- return bool(url) and not _is_placeholder_url(url)
83
-
84
-
85
- def _wall_created_ts(p):
86
- """Best-effort creation timestamp, NORMALIZED TO SECONDS epoch.
87
- `created` is stored as Unix milliseconds (e.g. 1788444727727) by some
88
- paths while `ts` is Unix seconds (e.g. 1788696117) — mixing them without
89
- normalization breaks newest-first ordering. Falls back to id-derived Unix
90
- ms (converted to seconds), then 0. Never crashes."""
91
- if not isinstance(p, dict):
92
- return 0
93
- for field in ("created", "ts"):
94
- v = p.get(field)
95
- if v is not None:
96
- try:
97
- v = int(v)
98
- except (ValueError, TypeError):
99
- continue
100
- if v > 100000000000: # milliseconds -> seconds
101
- v = v // 1000
102
- return v
103
- pid = str(p.get("id", "")).strip()
104
- if pid.isdigit() and len(pid) >= 12:
105
- try:
106
- v = int(pid)
107
- if v > 100000000000:
108
- v = v // 1000
109
- return v
110
- except (ValueError, TypeError):
111
- pass
112
- return 0
113
-
114
-
115
- def _wall_post_has_valid_image(p):
116
- """True when the post has at least one REAL (non-placeholder) image:
117
- p.img, p.short_thumb, p.images[], p.slides[].image."""
118
- if _is_real_url(p.get("img")):
119
- return True
120
- if _is_real_url(p.get("short_thumb")):
121
- return True
122
- images = p.get("images") or []
123
- if any(_is_real_url(x) for x in images):
124
- return True
125
- slides = p.get("slides") or []
126
- if any(_is_real_url((s or {}).get("image")) for s in slides if isinstance(s, dict)):
127
- return True
128
- return False
129
-
130
-
131
- def _wall_post_has_content(p):
132
- """True when the post has meaningful content: non-empty text (>= 20 chars)
133
- OR at least one slide with a non-empty text. Posts with empty/whitespace
134
- text, empty slide lists, or slides that are all blank are removed."""
135
- if not isinstance(p, dict):
136
- return False
137
- text = (p.get("text") or "").strip()
138
- if len(text) >= 20:
139
- return True
140
- slides = p.get("slides") or []
141
- if len(slides) >= 1:
142
- for s in slides:
143
- if isinstance(s, dict) and len((s.get("text") or "").strip()) >= 8:
144
- return True
145
- # Video-only posts (e.g. FPT wall videos / user uploads) have content via video
146
- if (p.get("video") or "").strip():
147
- return True
148
- return False
149
-
150
-
151
- def _wall_post_has_slide_info(p):
152
- """True if the post has usable slide/rewrite AI content:
153
- - designed slides with text+image, OR
154
- - a generated short video (/api/ai/short-file/) with a real thumbnail, OR
155
- - an oEmbed iframe (YouTube/TikTok/FB) with a valid image."""
156
- slides = p.get("slides") or []
157
- if any((s.get("image") or "").strip() and (s.get("text") or "").strip() for s in slides if isinstance(s, dict)):
158
- return True
159
- vid = (p.get("video") or "").strip()
160
- if vid and "api/ai/short-file/" in vid and (p.get("short_thumb") or "").strip():
161
- return True
162
- if vid and ("youtube.com/embed" in vid or "tiktok.com/embed" in vid or
163
- "facebook.com/plugins" in vid or "instagram.com" in vid or
164
- "twitter.com" in vid or "x.com" in vid or "player.vimeo" in vid):
165
- if _wall_post_has_valid_image(p):
166
- return True
167
- return False
168
-
169
-
170
- def _wall_is_auto_post(p):
171
- kind = (p.get("kind") or "").strip().lower()
172
- source = (p.get("source") or "").strip().lower()
173
- return kind in ("hot_short", "slide_summary", "auto_rewrite", "topic_article", "summary") or \
174
- source in ("hot_short", "fptplay", "ai", "auto_rewrite", "topic_article")
175
-
176
-
177
- def wall_posts_for_view(posts=None, limit=200):
178
- """Unified Tường AI wall view:
179
- 1) drop posts with NO content at all (empty text AND empty slides
180
- AND no video) — the 'bài đăng rewrite fără nội dung';
181
- 2) drop auto-generated posts that BOTH have a placeholder/missing image
182
- (pollinations.ai, vnexpress logo, empty) AND no slide-AI content —
183
- these are the truly broken posts that show the wrong thumbnail;
184
- 3) sort newest-first mixing all sources;
185
- 4) cap at `limit` (default 200).
186
- Non-destructive: never touches the persistent store."""
187
- if posts is None:
188
- posts = _load_ai_wall()
189
- if not isinstance(posts, list):
190
- return []
191
- out = []
192
- for p in posts:
193
- if not isinstance(p, dict):
194
- continue
195
- if not _wall_post_has_content(p):
196
- continue
197
- if _wall_is_auto_post(p) and not _wall_post_has_valid_image(p) and not _wall_post_has_slide_info(p):
198
- # auto post with a wrong/placeholder image AND no slide AI content
199
- # -> hide from the wall (non-destructive)
200
- continue
201
- out.append(p)
202
- out.sort(key=_wall_created_ts, reverse=True)
203
- return out[:limit]
204
-
205
  try:
206
  from huggingface_hub import AsyncInferenceClient
207
  except Exception:
@@ -345,136 +197,4 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
345
  break
346
  if chunks:
347
  return "\n".join("• " + c for c in chunks)
348
- return "• Không có đủ nội dung để tóm tắt."
349
-
350
- # ===== URL scraping & article processing =====
351
- HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
352
-
353
- try:
354
- _shorts_base = "/data" if os.path.isdir("/data") else os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
355
- except Exception:
356
- _shorts_base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
357
- SHORTS_DIR = os.path.join(_shorts_base, "ai_shorts")
358
- os.makedirs(SHORTS_DIR, exist_ok=True)
359
-
360
- import random as _random2
361
- from datetime import datetime, timezone, timedelta
362
- _VN_TZ = timezone(timedelta(hours=7))
363
-
364
-
365
- def _safe_name(filename: str) -> str:
366
- """Sanitize filename."""
367
- return re.sub(r"[^a-zA-Z0-9_.-]", "_", filename)[:120]
368
-
369
-
370
- def pollinations_image_url(topic: str) -> str:
371
- """Generate a placeholder image URL via Pollinations."""
372
- try:
373
- return "https://image.pollinations.ai/prompt/" + quote("Vietnamese editorial illustration, " + topic, safe="") + "?width=1024&height=576&nologo=true"
374
- except Exception:
375
- return ""
376
-
377
-
378
- def _download_image(url: str, fallback_title: str, out_path: str) -> str:
379
- """Download an image from URL or create a placeholder."""
380
- if url:
381
- try:
382
- r = requests.get(url, headers=HEADERS, timeout=15)
383
- if r.status_code == 200 and len(r.content) > 1200:
384
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
385
- with open(out_path, "wb") as f:
386
- f.write(r.content)
387
- return out_path
388
- except Exception:
389
- pass
390
- # Fallback: create a placeholder image
391
- try:
392
- from PIL import Image, ImageDraw, ImageFont
393
- img = Image.new("RGB", (1080, 760), (24, 24, 24))
394
- draw = ImageDraw.Draw(img)
395
- try:
396
- font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48)
397
- except Exception:
398
- font = None
399
- text = (fallback_title or "VNEWS")[:40]
400
- try:
401
- bbox = draw.textbbox((0, 0), text, font=font)
402
- tw = bbox[2] - bbox[0]
403
- except Exception:
404
- tw = len(text) * 24
405
- draw.text(((1080 - tw) // 2, 330), text, fill=(255, 255, 255), font=font)
406
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
407
- img.save(out_path, quality=90)
408
- return out_path
409
- except Exception:
410
- return out_path
411
-
412
-
413
- def scrape_any_url(url: str) -> dict:
414
- """Scrape article content from any URL."""
415
- if not url or not url.startswith("http"):
416
- return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": ""}
417
- try:
418
- r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
419
- if r.status_code != 200 or not r.text:
420
- return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)}
421
- r.encoding = "utf-8"
422
- soup = BeautifulSoup(r.text, "lxml")
423
- for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript", "iframe", ".ads", ".ad", ".banner-ads", ".fb-comments", ".fb-root", ".social-share", ".related-news", ".breadcrumb"]):
424
- tag.decompose()
425
- title = ""
426
- ogt = soup.find("meta", property="og:title")
427
- if ogt:
428
- title = ogt.get("content", "")
429
- h1 = soup.find("h1")
430
- if not title and h1:
431
- title = h1.get_text(strip=True)
432
- if not title:
433
- t = soup.find("title")
434
- if t:
435
- title = t.get_text(strip=True)
436
- og_image = ""
437
- ogi = soup.find("meta", property="og:image")
438
- if ogi:
439
- og_image = ogi.get("content", "")
440
- if og_image.startswith("//"):
441
- og_image = "https:" + og_image
442
- summary = ""
443
- ogd = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
444
- if ogd:
445
- summary = ogd.get("content", "")[:500]
446
- body_text = []
447
- for sel in ["article", ".singular-content", ".detail-content", ".fck_detail", ".content-detail", ".knc-content", "main", ".cms-body", ".article__body", ".post-content", ".entry-content"]:
448
- el = soup.select_one(sel)
449
- if el and len(el.find_all("p")) >= 2:
450
- for p in el.find_all("p"):
451
- t = _clean_text(p.get_text(strip=True))
452
- if t and len(t) > 30:
453
- body_text.append(t)
454
- break
455
- if not body_text and soup.body:
456
- for p in soup.body.find_all("p"):
457
- t = _clean_text(p.get_text(strip=True))
458
- if t and len(t) > 30:
459
- body_text.append(t)
460
- text = "\n".join(body_text)
461
- return {"title": _clean_text(title), "text": text, "summary": _clean_text(summary), "image": og_image, "og_image": og_image, "via": _domain(url), "url": url}
462
- except Exception as e:
463
- return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)}
464
-
465
-
466
- def make_post(title: str, text: str, img: str, url: str, kind: str = "auto", sources: list = None) -> dict:
467
- """Create a wall post dict."""
468
- import random as _r2
469
- now = int(time.time() * 1000)
470
- return {
471
- "id": str(now) + str(_r2.randint(100, 999)),
472
- "title": (title or "Bài viết")[:200],
473
- "text": (text or "")[:5000],
474
- "img": img or "",
475
- "url": url or "",
476
- "kind": kind or "auto",
477
- "sources": sources or [],
478
- "created": now,
479
- "created_str": datetime.now(_VN_TZ).strftime("%H:%M %d/%m/%Y"),
480
- }
 
54
  _load_ai_wall = _load_wall
55
  _save_ai_wall = _save_wall
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  try:
58
  from huggingface_hub import AsyncInferenceClient
59
  except Exception:
 
197
  break
198
  if chunks:
199
  return "\n".join("• " + c for c in chunks)
200
+ return "• Không có đủ nội dung để tóm tắt."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ai_patch.py CHANGED
@@ -278,11 +278,7 @@ base.qwen_generate = qwen_generate_resilient
278
 
279
  @app.get('/api/wall')
280
  def compat_wall():
281
- try:
282
- from ai_ext import wall_posts_for_view
283
- return JSONResponse({'posts': wall_posts_for_view(limit=200)})
284
- except Exception:
285
- return JSONResponse({'posts': base._load_ai_wall()[:80]})
286
 
287
 
288
  _PATCHED_PATHS = {
 
278
 
279
  @app.get('/api/wall')
280
  def compat_wall():
281
+ return JSONResponse({'posts': base._load_ai_wall()[:80]})
 
 
 
 
282
 
283
 
284
  _PATCHED_PATHS = {
ai_runtime_patch_fast.py CHANGED
@@ -1,5 +1,5 @@
1
  """Final patch v2: fix topic rewrite, remove duplicate short slide, full short interaction buttons."""
2
- import re, threading, time, json, os, asyncio, requests
3
  import ai_runtime_final6 as f6
4
  from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query
5
  import html as html_lib
@@ -56,15 +56,7 @@ def _sh(refresh:int=Query(default=0)):
56
  if _bg_shorts['d'] and (not refresh or n-_bg_shorts['t']<120):(threading.Thread(target=_bg,daemon=True).start() if n-_bg_shorts['t']>600 else None);return JSONResponse(_bg_shorts['d'])
57
  return f6.api_shorts_final6(refresh=refresh) if hasattr(f6,'api_shorts_final6') else JSONResponse([])
58
  @app.get('/api/ai_wall')
59
- def _w():
60
- n=int(time.time())
61
- try:
62
- from ai_ext import wall_posts_for_view, _wall_created_ts
63
- posts=wall_posts_for_view(limit=200)
64
- posts=[p for p in posts if n-_wall_created_ts(p)<TTL_24H]
65
- except Exception:
66
- posts=[p for p in f5.base._load_ai_wall() if n-int(p.get('ts') or 0)<TTL_24H]
67
- return JSONResponse({'posts':posts,'persistent':HAS_PERSISTENT})
68
  @app.get('/api/storage_status')
69
  def _st():return JSONResponse({'persistent':HAS_PERSISTENT})
70
  @app.get('/api/short/comments')
@@ -129,69 +121,6 @@ async def _tp(request:Request):
129
  post=f5.base.make_post(topic,text,img,'','topic_focused',sources=[s for s in src if s.get('url')]);post['images']=[img];post['source_details']=det
130
  ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps);return JSONResponse({'post':post})
131
 
132
- # ===== M3U IPTV Channels (FPT Play Bóng Đá live streams) =====
133
- _M3U_URL = "https://raw.githubusercontent.com/Love4vn/Stalker2M3U/refs/heads/1/live_schedule_Optimize.m3u"
134
- _m3u_cache = {"t": 0, "d": []}
135
-
136
- def _parse_m3u():
137
- """Fetch & parse the M3U playlist. Returns list of channel dicts."""
138
- r = requests.get(_M3U_URL, headers={"User-Agent": "Mozilla/5.0"}, timeout=20)
139
- if r.status_code != 200:
140
- return []
141
- lines = r.text.splitlines()
142
- out = []
143
- cur = {}
144
- for line in lines:
145
- line = line.strip()
146
- if line.startswith("#EXTINF"):
147
- t_match = re.search(r',(.+)$', line)
148
- cur = {
149
- "title": t_match.group(1).strip() if t_match else "Channel",
150
- "id": "",
151
- "logo": "",
152
- "group": "",
153
- "url": "",
154
- "vlcopts": {},
155
- }
156
- for attr_match in re.finditer(r'(\w+)="([^"]*)"', line):
157
- k, v = attr_match.group(1), attr_match.group(2)
158
- if k == "tvg-id":
159
- cur["id"] = v
160
- elif k == "tvg-logo":
161
- cur["logo"] = v
162
- elif k == "group-title":
163
- cur["group"] = v
164
- elif line.startswith("#EXTVLCOPT:"):
165
- key_part = line[len("#EXTVLCOPT:"):]
166
- if "=" in key_part:
167
- k, v = key_part.split("=", 1)
168
- cur.setdefault("vlcopts", {})[k.strip()] = v.strip()
169
- elif line and not line.startswith("#") and cur:
170
- cur["url"] = line
171
- out.append(cur)
172
- cur = {}
173
- # Deduplicate by URL (same channel may have multiple stalker entries)
174
- seen = set()
175
- deduped = []
176
- for c in out:
177
- if c["url"] not in seen:
178
- seen.add(c["url"])
179
- deduped.append(c)
180
- return deduped
181
-
182
- @app.get('/api/m3u/channels')
183
- def api_m3u_channels(refresh: int = Query(default=0)):
184
- n = int(time.time())
185
- if not refresh and _m3u_cache["d"] and n - _m3u_cache["t"] < 300:
186
- return JSONResponse({"channels": _m3u_cache["d"], "updated": _m3u_cache["t"]})
187
- try:
188
- channels = _parse_m3u()
189
- _m3u_cache["t"] = n
190
- _m3u_cache["d"] = channels
191
- return JSONResponse({"channels": channels, "updated": n})
192
- except Exception as e:
193
- return JSONResponse({"channels": _m3u_cache["d"], "updated": _m3u_cache["t"], "error": str(e)})
194
-
195
  PATCH_INJECT=r'''
196
  <style>
197
  .short-cmt-panel{position:fixed;bottom:0;left:0;right:0;max-height:55vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow-y:auto}.short-cmt-panel.active{display:block}.short-cmt-panel textarea{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0;min-height:60px}.short-cmt-panel button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.cmt-item{background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px}
@@ -202,18 +131,6 @@ button[onclick*="rewriteCurrentArticle"]{display:none!important}
202
  /* Hide ALL old Short AI slides from previous layers */
203
  #ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
204
  .source-detail-box a[target="_blank"]{display:none!important}
205
- /* M3U IPTV grid */
206
- .m3u-grid{padding:12px}
207
- .m3u-group-label{color:#5cb87a;font-size:14px;font-weight:700;margin:10px 0 6px 4px}
208
- .m3u-group-track{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}
209
- .m3u-channel-card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:8px;cursor:pointer;transition:0.2s;position:relative}
210
- .m3u-channel-card:hover{border-color:#5cb87a;transform:translateY(-1px)}
211
- .m3u-channel-thumb{width:100%;height:60px;background:#222;border-radius:6px;overflow:hidden;display:flex;align-items:center;justify-content:center}
212
- .m3u-channel-thumb img{width:100%;height:100%;object-fit-cover}
213
- .m3u-no-logo{font-size:24px;color:#5cb87a}
214
- .m3u-channel-title{font-size:11px;color:#ccc;margin-top:4px;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
215
- .badge-live{background:#ff4444 !important;color:#fff !important;animation:pulse 1.5s infinite}
216
- @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}}
217
  </style>
218
  <div id="short-cmt-panel" class="short-cmt-panel"></div>
219
  <script>
@@ -258,163 +175,6 @@ let oldRA=window.readArticle;if(oldRA){window.readArticle=async function(){let r
258
  let _hl=false;function dH(){if(_hl)return;_hl=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
259
  if(document.readyState==='complete')dH();else window.addEventListener('load',dH);
260
  setInterval(()=>{patchArticle();patchShortBtns();patchOldSourceLinks();},1500);
261
-
262
- // === M3U IPTV Player — FPT Play Bóng Đá live streams ===
263
- var _m3uChannels = [];
264
- async function loadM3UChannels(){
265
- var el = document.getElementById('view-m3u');
266
- if(!el) return;
267
- el.innerHTML = '<div class="loading">Đang tải danh sách kênh trực tiếp...</div><button class="back-btn" onclick="switchCat(\'home\')">← Trang chủ</button>';
268
- try{
269
- var r = await fetch('/api/m3u/channels');
270
- var j = await r.json();
271
- _m3uChannels = j.channels || [];
272
- }catch(e){
273
- el.innerHTML = '<button class="back-btn" onclick="switchCat(\'home\')">← Trang chủ</button><div class="loading">Lỗi tải kênh: '+esc(e.message||e)+'</div>';
274
- return;
275
- }
276
- if(!_m3uChannels.length){
277
- el.innerHTML = '<button class="back-btn" onclick="switchCat(\'home\')">← Trang chủ</button><div class="loading">Không có kênh nào</div>';
278
- return;
279
- }
280
- // Group channels by group-title
281
- var groups = {};
282
- _m3uChannels.forEach(function(c){
283
- var g = c.group || 'Khác';
284
- if(!groups[g]) groups[g] = [];
285
- groups[g].push(c);
286
- });
287
- var html = '<button class="back-btn" onclick="switchCat(\'home\')">← Trang chủ</button>';
288
- html += '<div class="m3u-grid">';
289
- Object.keys(groups).forEach(function(gname){
290
- html += '<div class="m3u-group-label">'+esc(gname)+'</div>';
291
- html += '<div class="m3u-group-track">';
292
- groups[gname].forEach(function(ch, i){
293
- var logo = ch.logo || '';
294
- var imgTag = logo ? '<img src="'+esc(logo)+'" loading="lazy" onerror="this.style.display=\'none\'">' : '<div class="m3u-no-logo">📺</div>';
295
- var title = (ch.title || 'Kênh').replace(/\s{2,}/g, ' ').trim();
296
- html += '<div class="m3u-channel-card" onclick="openM3UPlayer('+_m3uChannels.indexOf(ch)+')" title="'+esc(title)+'">'
297
- + '<div class="m3u-channel-thumb">'+imgTag+'</div>'
298
- + '<div class="m3u-channel-title">'+esc(title)+'</div>'
299
- + '</div>';
300
- });
301
- html += '</div>';
302
- });
303
- html += '</div>';
304
- el.innerHTML = html;
305
- }
306
-
307
- function openM3UPlayer(chIdx){
308
- var ch = _m3uChannels[chIdx];
309
- if(!ch) return;
310
- var streamUrl = ch.url || '';
311
- if(!streamUrl) return toast('Không có link stream');
312
- // Build the full ordered list: clicked channel first
313
- var ordered = [];
314
- for(var i = chIdx; i < _m3uChannels.length; i++) ordered.push(_m3uChannels[i]);
315
- for(var i = 0; i < chIdx; i++) ordered.push(_m3uChannels[i]);
316
- // Build TikTok-style viewer with MPEG-TS streams proxied through /api/proxy/seg
317
- // MPEG-TS over HTTP is progressive — we use <video> with the proxied URL
318
- // We need to proxy the stream URL through our server to avoid CORS
319
- var h = '<button class="back-btn" onclick="closeM3UViewer()">← Kênh khác</button>';
320
- h += '<div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';
321
- ordered.forEach(function(ch, i){
322
- var streamUrl = ch.url || '';
323
- var proxyUrl = '';
324
- if(streamUrl){
325
- proxyUrl = '/api/proxy/seg?url=' + encodeURIComponent(streamUrl);
326
- }
327
- var logo = ch.logo || '';
328
- var imgTag = logo ? '<img src="'+esc(logo)+'" onerror="this.style.display=\'none\'">' : '';
329
- var vtag = '<div class="yt-thumb-wrap" style="'+(logo?('background-image:url(\''+esc(logo)+'\')'):'')+'">'
330
- + '<video playsinline preload="none" data-m3u="' + esc(proxyUrl) + '" loop'
331
- + (imgTag ? ' poster="' + esc(logo) + '"' : '')
332
- + ' style="width:100%;height:100%;object-fit:contain;background:#000">'
333
- + '</video></div>';
334
- var title = (ch.title || 'Kênh').replace(/\s{2,}/g, ' ').trim();
335
- h += buildTikTokSlide({
336
- vtag: vtag,
337
- title: title,
338
- badge: 'LIVE',
339
- badgeClass: 'badge-live',
340
- videoId: 'm3u-' + i,
341
- idx: i,
342
- total: ordered.length,
343
- shareUrl: streamUrl || location.href,
344
- postId: '',
345
- extraBtn: '<button class="tiktok-right-btn" onclick="event.stopPropagation();copyStreamLink(' + chIdx + ')" title="Sao chép link stream"><div class="icon">🔗</div></button>'
346
- });
347
- });
348
- h += '</div></div>';
349
- showView('view-tiktok');
350
- var el = document.getElementById('view-tiktok');
351
- if(el) el.innerHTML = h;
352
- setTimeout(function(){ if(typeof initTikTokFeed === 'function') initTikTokFeed(); }, 200);
353
- }
354
-
355
- function closeM3UViewer(){
356
- showView('view-m3u');
357
- var feed = document.getElementById('tiktok-feed');
358
- if(feed){ feed.innerHTML = ''; }
359
- document.querySelectorAll('video[data-m3u]').forEach(function(v){
360
- if(v._m3uRetry) clearTimeout(v._m3uRetry);
361
- if(v._hls){ v._hls.destroy(); v._hls = null; }
362
- v.pause();
363
- v.src = '';
364
- });
365
- document.querySelectorAll('iframe[data-yt-src]').forEach(function(f){ f.src = ''; });
366
- }
367
-
368
- function copyStreamLink(chIdx){
369
- var ch = _m3uChannels[chIdx];
370
- if(!ch || !ch.url) return toast('Không có link stream');
371
- var shareUrl = ch.url + (ch.vlcopts && ch.vlcopts['http-header'] ? ' (có Authorization header)' : '');
372
- if(navigator.clipboard && navigator.clipboard.writeText){
373
- navigator.clipboard.writeText(ch.url).then(function(){ toast('📋 Đã sao chép link stream!'); }).catch(function(){
374
- prompt('📋 Link stream:', ch.url);
375
- });
376
- }else{
377
- prompt('📋 Link stream:', ch.url);
378
- }
379
- }
380
-
381
- // Patch initTikTokFeed to handle MPEG-TS streams (video[data-m3u]) — same as Short AI <video>
382
- let _origInitTikTok = null;
383
- (function(){
384
- // Override: after initTikTokFeed builds the feed, also handle data-m3u videos
385
- var orig = window.initTikTokFeed;
386
- if(orig && !orig._patched){
387
- window.initTikTokFeed = function(){
388
- // Store reference to original initTikTokFeed's interval
389
- orig.call(this);
390
- // Patch the video play/pause logic to handle MPEG-TS streams
391
- // The existing initTikTokFeed already handles <video> elements via play/pause
392
- // so data-m3u videos will work — we just need to set src when active
393
- var feed = document.getElementById('tiktok-feed');
394
- if(!feed) return;
395
- var slides = feed.querySelectorAll('.tiktok-slide');
396
- var checkM3U = function(){
397
- slides.forEach(function(sl){
398
- var v = sl.querySelector('video[data-m3u]');
399
- if(!v) return;
400
- if(sl._active && !v.src && v.dataset.m3u){
401
- v.src = v.dataset.m3u;
402
- // For MPEG-TS, we rely on browser native support (Safari/Edge) or
403
- // the stream being playable via the proxy
404
- v.play().catch(function(){});
405
- } else if(!sl._active && v.src){
406
- v.pause();
407
- }
408
- });
409
- };
410
- checkM3U();
411
- var int = setInterval(checkM3U, 1000);
412
- // Clear after 60s
413
- setTimeout(function(){ clearInterval(int); }, 60000);
414
- };
415
- window.initTikTokFeed._patched = true;
416
- }
417
- })();
418
  })();
419
  </script>
420
  '''
 
1
  """Final patch v2: fix topic rewrite, remove duplicate short slide, full short interaction buttons."""
2
+ import re, threading, time, json, os, asyncio
3
  import ai_runtime_final6 as f6
4
  from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query
5
  import html as html_lib
 
56
  if _bg_shorts['d'] and (not refresh or n-_bg_shorts['t']<120):(threading.Thread(target=_bg,daemon=True).start() if n-_bg_shorts['t']>600 else None);return JSONResponse(_bg_shorts['d'])
57
  return f6.api_shorts_final6(refresh=refresh) if hasattr(f6,'api_shorts_final6') else JSONResponse([])
58
  @app.get('/api/ai_wall')
59
+ def _w():n=int(time.time());return JSONResponse({'posts':[p for p in f5.base._load_ai_wall() if n-int(p.get('ts') or 0)<TTL_24H],'persistent':HAS_PERSISTENT})
 
 
 
 
 
 
 
 
60
  @app.get('/api/storage_status')
61
  def _st():return JSONResponse({'persistent':HAS_PERSISTENT})
62
  @app.get('/api/short/comments')
 
121
  post=f5.base.make_post(topic,text,img,'','topic_focused',sources=[s for s in src if s.get('url')]);post['images']=[img];post['source_details']=det
122
  ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps);return JSONResponse({'post':post})
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  PATCH_INJECT=r'''
125
  <style>
126
  .short-cmt-panel{position:fixed;bottom:0;left:0;right:0;max-height:55vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow-y:auto}.short-cmt-panel.active{display:block}.short-cmt-panel textarea{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0;min-height:60px}.short-cmt-panel button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.cmt-item{background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px}
 
131
  /* Hide ALL old Short AI slides from previous layers */
132
  #ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
133
  .source-detail-box a[target="_blank"]{display:none!important}
 
 
 
 
 
 
 
 
 
 
 
 
134
  </style>
135
  <div id="short-cmt-panel" class="short-cmt-panel"></div>
136
  <script>
 
175
  let _hl=false;function dH(){if(_hl)return;_hl=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
176
  if(document.readyState==='complete')dH();else window.addEventListener('load',dH);
177
  setInterval(()=>{patchArticle();patchShortBtns();patchOldSourceLinks();},1500);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  })();
179
  </script>
180
  '''
ai_short_v2.py DELETED
@@ -1,2083 +0,0 @@
1
- """VNEWS Short AI v2 — Short creator with TikTok background music, uploaded audio,
2
- uploaded video/image background, and 'recreate from designed slides' (image-only,
3
- reusing previous short audio, NO text overlays) mode.
4
-
5
- Registered by app_v2_entry.py (import ai_short_v2). Uses the SAME wall store
6
- (WALL_FILE) as the wall endpoints so posts created by the designer survive and
7
- can be re-shorted purely from designed images.
8
- """
9
- import os
10
- import re
11
- import json
12
- import time
13
- import uuid
14
- import hashlib
15
- import subprocess
16
- import threading
17
-
18
- import requests
19
- from urllib.parse import quote as urllib_quote
20
- from fastapi import Request, UploadFile, File, Query
21
- from fastapi.responses import JSONResponse, FileResponse, Response, StreamingResponse
22
-
23
- try:
24
- import yt_dlp
25
- except Exception: # pragma: no cover
26
- yt_dlp = None
27
-
28
- try:
29
- from PIL import Image, ImageDraw, ImageFont
30
- except Exception: # pragma: no cover
31
- Image = ImageDraw = ImageFont = None
32
-
33
- try:
34
- from main import app
35
- except Exception: # pragma: no cover
36
- from fastapi import FastAPI
37
- app = FastAPI()
38
-
39
- DATA_DIR = "/data" if os.path.isdir("/data") else os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
40
- WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
41
- SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
42
- UPLOAD_DIR = os.path.join(DATA_DIR, "short_uploads")
43
- WALL_IMG_DIR = os.path.join(DATA_DIR, "wall_imgs")
44
- os.makedirs(SHORTS_DIR, exist_ok=True)
45
- os.makedirs(UPLOAD_DIR, exist_ok=True)
46
-
47
- _wl_lock = threading.Lock()
48
-
49
- UA_HEADERS = {
50
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
51
- "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
52
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
53
- }
54
-
55
- # ---------------------------------------------------------------------------
56
- # Video scraping (YouTube / TikTok / news sites) via yt-dlp + oEmbed fallback
57
- # ---------------------------------------------------------------------------
58
- _SCRAPE_CACHE = {} # url -> {ok, ...} (in-memory, TTL 30 min)
59
- _SCRAPE_TTL = 30 * 60
60
-
61
- YTDLP_OPTS = {
62
- "format": "best[height<=720][ext=mp4]/best[height<=720]/best",
63
- "quiet": True,
64
- "no_warnings": True,
65
- "noplaylist": True,
66
- "socket_timeout": 30,
67
- "retries": 5,
68
- "fragment_retries": 5,
69
- "ignoreerrors": False,
70
- "extractor_args": {"youtube": {"player_client": ["tv", "ios", "mweb"]}},
71
- "http_headers": UA_HEADERS,
72
- }
73
-
74
-
75
- # ---------------------------------------------------------------------------
76
- # YouTube cookies (bypasses the "Sign in to confirm you're not a bot" block).
77
- # Sources, in priority order:
78
- # 1. YT_COOKIES env var (HF Space secret — raw Netscape-format cookies.txt)
79
- # 2. /app/cookies.txt (a cookies.txt file baked into the repo/runtime)
80
- # yt-dlp reads a Netscape cookies.txt via the 'cookiefile' option.
81
- # ---------------------------------------------------------------------------
82
- _cookie_file = None
83
-
84
-
85
- def _ensure_cookies():
86
- """Materialise a cookies.txt (Netscape format) for yt-dlp if any cookie
87
- source is available. Returns the cookiefile path or None."""
88
- global _cookie_file
89
- if _cookie_file and os.path.exists(_cookie_file):
90
- return _cookie_file
91
- content = None
92
- # 1. HF Space secret YT_COOKIES (raw cookies.txt content)
93
- secret = os.environ.get("YT_COOKIES", "").strip()
94
- if secret and ("# Netscape" in secret or "#HTTP" in secret or "youtube.com" in secret or "\tTRUE" in secret):
95
- content = secret
96
- # 2. baked-in file
97
- if not content:
98
- for p in ("/app/cookies.txt", "cookies.txt"):
99
- if os.path.exists(p):
100
- content = open(p, encoding="utf-8", errors="ignore").read()
101
- break
102
- if not content:
103
- _cookie_file = None
104
- return None
105
- try:
106
- path = os.path.join(DATA_DIR, "yt_cookies.txt")
107
- os.makedirs(os.path.dirname(path), exist_ok=True)
108
- with open(path, "w", encoding="utf-8") as f:
109
- f.write(content)
110
- _cookie_file = path
111
- return path
112
- except Exception:
113
- return None
114
-
115
-
116
- def _ytdlp_opts():
117
- """YTDLP opts + cookiefile (only when cookies are available)."""
118
- opts = dict(YTDLP_OPTS)
119
- cf = _ensure_cookies()
120
- if cf:
121
- opts["cookiefile"] = cf
122
- # also add "cookiesfrombrowser" fallback? No — datacenter IPs can't read
123
- # a local browser. cookiefile is the reliable path.
124
- return opts
125
-
126
-
127
- def _oembed_probe(url):
128
- """Cheap metadata fallback for TikTok/Facebook/Instagram/YouTube (no direct URL)."""
129
- try:
130
- host = (re.sub(r"^https?://", "", url).split("/")[0] or "").lower()
131
- api = None
132
- if "tiktok" in host:
133
- api = "https://www.tiktok.com/oembed?url=" + urllib_quote(url, safe="")
134
- elif "facebook" in host or "fb.watch" in host:
135
- api = "https://www.facebook.com/plugins/video/oembed.json?url=" + urllib_quote(url, safe="")
136
- elif "youtube.com" in host or "youtu.be" in host:
137
- api = "https://www.youtube.com/oembed?url=" + urllib_quote(url, safe="") + "&format=json"
138
- if not api:
139
- return None
140
- # Facebook oEmbed often times out from datacenter IPs; use a shorter timeout
141
- _timeout = 8 if ("facebook" in host or "fb.watch" in host) else 12
142
- j = {}
143
- try:
144
- r = requests.get(api, headers=UA_HEADERS, timeout=_timeout)
145
- if r.status_code == 200:
146
- j = r.json() or {}
147
- except Exception:
148
- j = {}
149
- # YouTube: youtube.com/oembed is often SNI-blocked from datacenter IPs.
150
- # Fall back to _yt_meta (noembed.com cascade) which reliably returns
151
- # title+thumbnail+description from restricted networks.
152
- yt_meta = None
153
- if "youtube.com" in host or "youtu.be" in host:
154
- yt_meta = _yt_meta(url)
155
- title = ((j.get("title") or "") or (yt_meta or {}).get("title") or "").strip()[:200]
156
- thumb = ((j.get("thumbnail_url") or "") or (yt_meta or {}).get("thumbnail") or "").strip()
157
- desc = (yt_meta or {}).get("description") or ""
158
- if "youtube.com" in host or "youtu.be" in host:
159
- if not desc:
160
- desc = (yt_meta or {}).get("description") or ""
161
- embed_url = _oembed_embed_url(url)
162
- # oEmbed providers (notably TikTok short links like vm.tiktok.com) don't
163
- # expose the video ID in the URL, but the `html` field contains the
164
- # canonical <iframe src> — parse it so short links still embed.
165
- if not embed_url:
166
- try:
167
- m_html = re.search(r'<iframe[^>]+src="([^"]+)"', ((j or {}).get("html") or ""))
168
- if m_html:
169
- embed_url = m_html.group(1).replace("&amp;", "&")
170
- if "?" in embed_url and "autoplay" not in embed_url:
171
- embed_url += "&autoplay=1&rel=0"
172
- except Exception:
173
- pass
174
- return {
175
- "ok": True,
176
- "title": title[:200],
177
- "thumbnail": thumb,
178
- "duration": None,
179
- "extractor": "oembed",
180
- "direct_url": None,
181
- "previewable": False, # no direct streamable URL from oEmbed alone
182
- "embed_url": embed_url,
183
- "description": desc[:2000],
184
- "note": "Có thể lấy được thông tin, nhưng không tải được video trực tiếp từ link này.",
185
- }
186
- except Exception:
187
- return None
188
-
189
-
190
- def _yt_meta(url):
191
- """Fetch YouTube video metadata (title/description/thumbnail).
192
-
193
- Cascades over several providers so at least title+thumbnail always come
194
- back even from datacenter IPs where www.youtube.com is SNI-blocked:
195
- 1. noembed.com — reliable title+thumb (works from restricted IPs)
196
- 2. youtube oEmbed — title + thumbnail_url
197
- 3. watch page og:tags — title + description + thumbnail (best-effort)
198
- Description is best-effort (YT oEmbed has no description field); when
199
- unavailable the caller falls back to the title. Returns dict or None."""
200
- try:
201
- vid = _yt_id_from_url(url)
202
- if not vid:
203
- return None
204
- out = {"video_id": vid, "title": "", "description": "", "thumbnail": ""}
205
- watch = f"https://www.youtube.com/watch?v={vid}"
206
-
207
- # 1) noembed (title+thumb, no desc)
208
- try:
209
- r = requests.get(f"https://noembed.com/embed?url={urllib_quote(watch, safe='')}",
210
- headers=UA_HEADERS, timeout=15)
211
- if r.status_code == 200:
212
- j = r.json() or {}
213
- out["title"] = (j.get("title") or "").strip()[:250]
214
- out["thumbnail"] = (j.get("thumbnail_url") or "").strip()
215
- except Exception:
216
- pass
217
-
218
- # 2) youtube oEmbed (title+thumb)
219
- if not out["title"]:
220
- try:
221
- r2 = requests.get(f"https://www.youtube.com/oembed?url={urllib_quote(watch, safe='')}&format=json",
222
- headers=UA_HEADERS, timeout=15)
223
- if r2.status_code == 200:
224
- j2 = r2.json() or {}
225
- out["title"] = (j2.get("title") or "").strip()[:250]
226
- out["thumbnail"] = (j2.get("thumbnail_url") or "").strip()
227
- except Exception:
228
- pass
229
-
230
- # 3) watch page og:tags (title + description + thumb, best-effort)
231
- try:
232
- r3 = requests.get(watch, headers=UA_HEADERS, timeout=20)
233
- if r3.status_code == 200:
234
- h3 = r3.text
235
- def _og(prop):
236
- mm = re.search(r'<meta[^>]+property="og:' + prop + r'"[^>]+content="([^"]*)"', h3) or \
237
- re.search(r'<meta[^>]+content="([^"]*)"[^>]+property="og:' + prop + r'"', h3)
238
- return mm.group(1).strip() if mm else ""
239
- def _nm(name):
240
- mm = re.search(r'<meta[^>]+name="' + name + r'"[^>]+content="([^"]*)"', h3)
241
- return mm.group(1).strip() if mm else ""
242
- if not out["title"]:
243
- out["title"] = _og("title") or _nm("title")
244
- out["description"] = _og("description") or _nm("description")
245
- if not out["thumbnail"]:
246
- out["thumbnail"] = _og("image") or ""
247
- if out["thumbnail"].startswith("//"):
248
- out["thumbnail"] = "https:" + out["thumbnail"]
249
- except Exception:
250
- pass
251
-
252
- # 4) r.jina.ai reader proxy — server-side fetch that renders the watch
253
- # page and returns Title/Description/Thumbnail lines as markdown.
254
- # Requires the Space origin in the Origin header (CORS check). Works
255
- # from datacenter IPs where youtube.com is SNI-blocked.
256
- if not out["description"] or not out["title"]:
257
- try:
258
- _hdrs = dict(UA_HEADERS)
259
- _hdrs["Origin"] = "https://bep40-vnews.hf.space"
260
- r4 = requests.get(f"https://r.jina.ai/{watch}", headers=_hdrs, timeout=30)
261
- if r4.status_code == 200:
262
- h4 = r4.text
263
- _tm = re.search(r'(?:^|\n)Title:\s*(.+)', h4)
264
- _dm = re.search(r'(?:^|\n)Description:\s*(.+)', h4, re.S)
265
- _im = re.search(r'(?:^|\n)Thumbnail:\s*(\S+)', h4)
266
- if _tm:
267
- out["title"] = _tm.group(1).strip()[:250]
268
- if _dm:
269
- out["description"] = _dm.group(1).strip()[:2000]
270
- if _im:
271
- th = _im.group(1).strip()
272
- if th.startswith("//"):
273
- th = "https:" + th
274
- out["thumbnail"] = th
275
- except Exception:
276
- pass
277
-
278
- if not out["title"] and not out["description"]:
279
- return None
280
- return {
281
- "video_id": vid,
282
- "title": (out["title"] or "").strip()[:250],
283
- "description": (out["description"] or "").strip()[:2000],
284
- "thumbnail": out["thumbnail"],
285
- }
286
- except Exception:
287
- return None
288
-
289
-
290
- def _yt_id_from_url(url):
291
- m = re.search(r'(?:youtube\.com/(?:embed|watch)\?v=|youtu\.be/|youtube-nocookie\.com/embed/)([a-zA-Z0-9_-]{11})', (url or ""))
292
- return m.group(1) if m else ""
293
-
294
-
295
- def _is_known_social(url):
296
- """True for platforms whose content is embeddable via oEmbed iframes."""
297
- try:
298
- host = (re.sub(r"^https?://", "", url or "").split("/")[0] or "").lower()
299
- return any(k in host for k in ("tiktok", "facebook", "fb.watch", "instagram", "youtube", "youtu.be", "vimeo", "twitter", "x.com"))
300
- except Exception:
301
- return False
302
-
303
-
304
- def _oembed_embed_url(url):
305
- """Derive a browser-embeddable iframe URL for social video links.
306
-
307
- Used as a Short HOT fallback when the direct media cannot be downloaded:
308
- the post is still created, and the TikTok-style feed renders it as an
309
- oEmbed iframe slide (exactly like the football video slides on YouTube)
310
- while the title/description/thumb come from the oEmbed metadata.
311
- """
312
- try:
313
- host = (re.sub(r"^https?://", "", url).split("/")[0] or "").lower()
314
- u = (url or "").strip()
315
- if "tiktok" in host:
316
- m = re.search(r"/(?:video|@[\w.\-]+/video|embed/v2)/(\d+)", u)
317
- if m:
318
- return f"https://www.tiktok.com/embed/v2/{m.group(1)}"
319
- return ""
320
- if "facebook" in host or "fb.watch" in host or "fb.gg" in host:
321
- return "https://www.facebook.com/plugins/video.php?href=" + urllib_quote(u, safe="") + "&show_text=0"
322
- if "instagram" in host:
323
- m = re.search(r"/p/([\w\-]+)/?", u)
324
- if m:
325
- return f"https://www.instagram.com/p/{m.group(1)}/embed/"
326
- if "youtube.com" in host or "youtu.be" in host:
327
- m = re.search(r"(?:v=|shorts/|embed/|youtu\.be/)([a-zA-Z0-9_-]{11})", u)
328
- if m:
329
- return f"https://www.youtube.com/embed/{m.group(1)}?autoplay=1&rel=0"
330
- if "x.com" in host or "twitter" in host:
331
- m = re.search(r"/status/(\d+)", u)
332
- if m:
333
- return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(1)}&dnt=true"
334
- if "vimeo" in host:
335
- m = re.search(r"/(\d+)$", u)
336
- if m:
337
- return f"https://player.vimeo.com/video/{m.group(1)}"
338
- except Exception:
339
- pass
340
- return ""
341
-
342
-
343
- def _is_real_http(u):
344
- """True if a candidate URL is a real http(s) media URL (not a JS template)."""
345
- u = (u or "").strip()
346
- if not re.match(r"^https?://", u):
347
- return False
348
- if "'" in u or '"' in u or "+" in u or u.count("(") > 0:
349
- return False
350
- return True
351
-
352
-
353
- def _scrape_page_html(url):
354
- """Fallback scraper for news sites (24h, dantri, znews...): parse the article
355
- HTML for og:video / <video>/<source> / m3u8|mp4 regex inside JS configs
356
- (e.g. 24h 'playlistConf' blob). Returns info dict or None."""
357
- try:
358
- r = requests.get(url, headers=UA_HEADERS, timeout=25)
359
- if r.status_code != 200:
360
- return None
361
- r.encoding = "utf-8"
362
- html = r.text
363
- except Exception:
364
- return None
365
- title = ""
366
- mt = re.search(r'<meta[^>]+property="og:title"[^>]+content="([^"]+)"', html) or \
367
- re.search(r'<meta[^>]+content="([^"]+)"[^>]+property="og:title"', html) or \
368
- re.search(r"<title[^>]*>([^<]+)</title>", html)
369
- if mt:
370
- title = re.sub(r"\s+", " ", mt.group(1)).strip()[:250]
371
-
372
- # 1) og:video
373
- cands = []
374
- for pat in (r'property="og:video[^"]*"\s+content="([^"]+)"',
375
- r'content="([^"]+)"\s+property="og:video[^"]*"'):
376
- cands += re.findall(pat, html)
377
- # 2) <video>/<source> tags
378
- cands += re.findall(r'<source[^>]+src="([^"]+)"', html)
379
- cands += re.findall(r"<source[^>]+src='([^']+)'", html)
380
- cands += re.findall(r'<video[^>]+src="([^"]+)"', html)
381
- # 3) raw .mp4/.m3u8 URLs in the page (incl. JS playlistConf src:"...")
382
- cands += re.findall(r'https?://[^"\'\s<>\\]+?\.(?:m3u8|mp4)[^"\'\s<>\\]*', html)
383
- # 4) src: "..." inside JS (m3u8/mp4)
384
- cands += re.findall(r'src\s*:\s*["\']([^"\']+?\.(?:m3u8|mp4)[^"\']*)["\']', html)
385
-
386
- # 5) YouTube iframe embeds (e.g. bongdaplus.vn video pages embed highlights)
387
- for yt_iframe_src in re.findall(r'<iframe[^>]+src="([^"]*(?:youtube\.com/embed|youtu\.be/|youtube-nocookie\.com/embed)[^"]*)"[^>]*>', html, re.I):
388
- yt_iframe_src = yt_iframe_src.strip()
389
- if yt_iframe_src.startswith("//"): yt_iframe_src = "https:" + yt_iframe_src
390
- yt_id = re.search(r'(?:embed/|youtu\.be/|v=)([a-zA-Z0-9_-]{11})', yt_iframe_src)
391
- if yt_id:
392
- cands.append(f"https://www.youtube.com/embed/{yt_id.group(1)}?autoplay=1&rel=0&enablejsapi=1")
393
- else:
394
- cands.append(yt_iframe_src)
395
-
396
- # 6) YouTube embed URLs in JS strings (src: "https://youtube.com/embed/...")
397
- for yt_js in re.findall(r'src\s*:\s*["\']([^"\']*(?:youtube\.com/embed|youtu\.be/)[^"\']*)["\']', html, re.I):
398
- yt_js = yt_js.strip()
399
- if yt_js.startswith("//"): yt_js = "https:" + yt_js
400
- cands.append(yt_js)
401
-
402
- # normalize relative URLs + dedupe + prefer 720p m3u8 then mp4
403
- from urllib.parse import urljoin
404
- seen = set()
405
- good = []
406
- for c in cands:
407
- c = c.strip().strip('"').strip("'")
408
- if not c or not _is_real_http(c) and not c.startswith("/"):
409
- continue
410
- if c.startswith("/"):
411
- c = urljoin(url, c)
412
- if not c.startswith("http"):
413
- continue
414
- if c in seen:
415
- continue
416
- seen.add(c)
417
- good.append(c)
418
- if not good:
419
- return None
420
-
421
- # score: prefer m3u8 720p > m3u8 > mp4 720p > mp4
422
- def score(c):
423
- s = 0
424
- if "720p" in c or "720" in c:
425
- s += 20
426
- if ".m3u8" in c:
427
- s += 10
428
- if "1080" in c:
429
- s += 18
430
- if "480" in c:
431
- s += 8
432
- return s
433
- good.sort(key=score, reverse=True)
434
- direct = good[0]
435
-
436
- thumb = ""
437
- mt2 = re.search(r'<meta[^>]+property="og:image[^"]*"\s+content="([^"]+)"', html) or \
438
- re.search(r'<meta[^>]+content="([^"]+)"[^>]+property="og:image[^"]*"', html)
439
- if mt2:
440
- thumb = mt2.group(1).strip()
441
-
442
- is_hls = ".m3u8" in direct.lower()
443
- is_youtube = "youtube.com/embed" in direct.lower() or "youtu.be" in direct.lower() or "youtube-nocookie" in direct.lower()
444
- return {
445
- "ok": True,
446
- "url": url,
447
- "title": title,
448
- "duration": None,
449
- "thumbnail": thumb,
450
- "direct_url": direct,
451
- "extractor": "youtube" if is_youtube else "html-page",
452
- "previewable": True,
453
- "is_hls": is_hls,
454
- "note": "",
455
- }
456
-
457
-
458
- def _resolve_media_url(url):
459
- """Fetch the body of a media URL using the right headers (referer for news CDNs)."""
460
- HDRS = dict(UA_HEADERS)
461
- host = (re.sub(r"^https?://", "", url).split("/")[0] or "").lower()
462
- if "vnecdn" in host or "vnexpress" in host:
463
- HDRS["Referer"] = "https://vnexpress.net/"
464
- elif "cdn.24h" in host or "24h.com.vn" in host:
465
- HDRS["Referer"] = "https://www.24h.com.vn/"
466
- return HDRS
467
-
468
-
469
- def _scrape_video_info(url):
470
- """Extract direct video URL + metadata for a user-pasted link.
471
-
472
- Returns dict with keys: ok, url (original), title, duration, thumbnail,
473
- direct_url (proxy-able), extractor, previewable, note.
474
- """
475
- try:
476
- now = time.time()
477
- cached = _SCRAPE_CACHE.get(url)
478
- if cached and now - cached.get("_t", 0) < _SCRAPE_TTL:
479
- return cached
480
- except Exception:
481
- pass
482
-
483
- # Early-detect social/video platforms that yt-dlp handles slowly or not at all.
484
- # Facebook URLs block datacenter IPs → yt-dlp hangs for 15s+; skip it and
485
- # go straight to oEmbed metadata-only fallback.
486
- _host_l = (re.sub(r"^https?://", "", url).split("/")[0] or "").lower()
487
- _is_facebook = "facebook" in _host_l or "fb.watch" in _host_l
488
-
489
- out = None
490
- if yt_dlp is not None and not _is_facebook:
491
- try:
492
- with yt_dlp.YoutubeDL(_ytdlp_opts()) as ydl:
493
- info = ydl.extract_info(url, download=False)
494
- # pick best direct (non-m3u8) progressive URL
495
- direct = None
496
- if info:
497
- direct = info.get("url") or ""
498
- for f in info.get("formats") or []:
499
- proto = (f.get("protocol") or "")
500
- if f.get("url") and proto not in ("m3u8_native", "m3u8"):
501
- direct = f.get("url")
502
- break
503
- dur = info.get("duration") if info else None
504
- try:
505
- dur = float(dur) if dur is not None else None
506
- except Exception:
507
- dur = None
508
- extractor = (info.get("extractor_key") or info.get("extractor") or "generic") if info else "generic"
509
- is_hls = bool(direct) and ".m3u8" in direct.lower()
510
- # If yt-dlp returned a bogus JS template URL ('+d+' etc.), treat as
511
- # a scrape failure -> fall through to the HTML page parser.
512
- yt_bad = not _is_real_http(direct)
513
- out = {
514
- "ok": not yt_bad,
515
- "url": url,
516
- "title": (info.get("title") or "").strip()[:250] if info else "",
517
- "duration": dur,
518
- "thumbnail": (info.get("thumbnail") or "").strip() or "" if not yt_bad else "",
519
- "direct_url": direct or "",
520
- "extractor": extractor,
521
- "previewable": bool(direct) and not yt_bad,
522
- "is_hls": is_hls,
523
- "note": "",
524
- }
525
- except Exception as e:
526
- if getattr(e, "args", None):
527
- msg = str(e.args[0]) if isinstance(e.args[0], str) else str(e)
528
- else:
529
- msg = str(e)
530
- # split real error after '[xxx] '
531
- m = re.search(r"\]\s*(.+)$", msg)
532
- err = m.group(1) if m else msg[:200]
533
- out = {"ok": False, "url": url, "error": err[:200]}
534
-
535
- # YouTube-specific fallback: if yt-dlp fails (SSL/EOF, rate-limit),
536
- # extract video ID and return the embed iframe URL + thumbnail.
537
- # The frontend renders YouTube embed URLs as iframes.
538
- yt_id_match = re.search(r'(?:youtube\.com/(?:embed|watch)\?v=|youtu\.be/|youtube-nocookie\.com/embed/)([a-zA-Z0-9_-]{11})', url)
539
- if yt_id_match:
540
- yt_id = yt_id_match.group(1)
541
- out = {
542
- "ok": True,
543
- "url": url,
544
- "title": f"YouTube video {yt_id}",
545
- "duration": None,
546
- "thumbnail": f"https://i.ytimg.com/vi/{yt_id}/hqdefault.jpg",
547
- "direct_url": f"https://www.youtube.com/embed/{yt_id}?autoplay=1&rel=0&enablejsapi=1",
548
- "extractor": "youtube-fallback",
549
- "previewable": True,
550
- "is_hls": False,
551
- "note": "yt-dlp failed, using YouTube embed directly",
552
- }
553
-
554
- # YouTube enrichment: grab the watch-page meta tags (title/description/
555
- # thumbnail). yt-dlp often gives a title + mp4 URL but NO description, and
556
- # the fallback path gives a bare 'YouTube video {id}' — the og: tags from
557
- # the watch page fix both. Only fills gaps; keeps the existing direct_url.
558
- if "youtube.com" in _host_l or "youtu.be" in _host_l:
559
- _meta = _yt_meta(url)
560
- if _meta:
561
- if not out or not out.get("ok") or not (out.get("title") or "").strip() or "YouTube video" in (out.get("title") or ""):
562
- out_title = _meta.get("title") or ""
563
- else:
564
- out_title = (out.get("title") or "")
565
- desc = _meta.get("description") or ""
566
- thumb = _meta.get("thumbnail") or (out or {}).get("thumbnail", "")
567
- if not out:
568
- out = {"ok": True, "url": url}
569
- out["title"] = out_title or _meta.get("title") or (out.get("title") or "")
570
- out["description"] = desc
571
- if thumb:
572
- out["thumbnail"] = thumb
573
-
574
- # If yt-dlp failed / gave a bad URL, try parsing the article HTML directly
575
- # (handles 24h.com.vn JS configs, dantri, znews, some vnexpress pages).
576
- if not out or not out.get("ok") or not out.get("direct_url"):
577
- page = _scrape_page_html(url)
578
- if page:
579
- # keep yt-dlp title/duration for social sites, take media from page
580
- base = {"ok": True, "url": url,
581
- "title": page.get("title") or (out or {}).get("title", ""),
582
- "duration": (out or {}).get("duration") if (out or {}).get("direct_url") else page.get("duration"),
583
- "thumbnail": page.get("thumbnail") or (out or {}).get("thumbnail", ""),
584
- "direct_url": page.get("direct_url"),
585
- "extractor": page.get("extractor") or "html-page",
586
- "previewable": True,
587
- "is_hls": page.get("is_hls", False),
588
- "note": page.get("note", ""),
589
- }
590
- out = base
591
-
592
- # metadata-only fallback (oEmbed) — lets the user at least see title/thumb
593
- if not out or not out.get("ok"):
594
- probe = _oembed_probe(url)
595
- if probe:
596
- probe["url"] = url
597
- out = probe
598
-
599
- if not out:
600
- out = {"ok": False, "url": url, "error": "Không lấy được thông tin video từ link này."}
601
- if "is_hls" not in out:
602
- out["is_hls"] = bool(out.get("direct_url", "")) and ".m3u8" in (out.get("direct_url", "") or "").lower()
603
- try:
604
- out["_t"] = time.time()
605
- _SCRAPE_CACHE[url] = out
606
- if len(_SCRAPE_CACHE) > 200:
607
- for k in list(_SCRAPE_CACHE)[:50]:
608
- _SCRAPE_CACHE.pop(k, None)
609
- except Exception:
610
- pass
611
- return out
612
-
613
-
614
- def _load_wall_posts():
615
- try:
616
- with _wl_lock:
617
- if os.path.exists(WALL_FILE):
618
- with open(WALL_FILE, "r", encoding="utf-8") as f:
619
- return json.load(f) or []
620
- except Exception:
621
- pass
622
- return []
623
-
624
-
625
- def _save_wall_posts(posts):
626
- try:
627
- with _wl_lock:
628
- tmp = WALL_FILE + ".tmp"
629
- with open(tmp, "w", encoding="utf-8") as f:
630
- json.dump(posts, f, ensure_ascii=False)
631
- os.replace(tmp, WALL_FILE)
632
- except Exception:
633
- pass
634
-
635
-
636
- def _clean(s):
637
- s = re.sub(r"[ \t]+", " ", s or "")
638
- s = re.sub(r"\n{3,}", "\n\n", s)
639
- return s.strip()
640
-
641
-
642
- def _safe_name(name):
643
- return re.sub(r"[^a-zA-Z0-9_.-]", "_", name)[:120]
644
-
645
-
646
- # =========================================================================
647
- # TikTok / background MUSIC list — royalty-free tracks with verified direct
648
- # mp3 URLs (incompetech CC-BY 4.0, Pixabay, Archive.org).
649
- # =========================================================================
650
- MUSIC_LIST = [
651
- {"id": "none", "name": "🚫 Không có nhạc nền", "url": ""},
652
- {"id": "carefree", "name": "🎵 Carefree (Joyful)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Carefree.mp3"},
653
- {"id": "wholesome", "name": "🎵 Wholesome (Warm)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Wholesome.mp3"},
654
- {"id": "fluffing", "name": "🎵 Fluffing a Duck (Quirky)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Fluffing%20a%20Duck.mp3"},
655
- {"id": "loping", "name": "🎵 Loping Sting (Short)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Loping%20Sting.mp3"},
656
- {"id": "modern", "name": "🎵 Modern Vibes (Electronic)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Modern%20Vibes.mp3"},
657
- {"id": "cheezee", "name": "🎵 Chee Zee Jungle (Upbeat)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Chee%20Zee%20Jungle.mp3"},
658
- {"id": "constance", "name": "🎵 Constance (Cinematic)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Constance.mp3"},
659
- {"id": "edm", "name": "🎵 EDM Detection Mode (Energetic)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/EDM%20Detection%20Mode.mp3"},
660
- {"id": "spyglass", "name": "🎵 Spy Glass (Mystery)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Spy%20Glass.mp3"},
661
- {"id": "raving", "name": "🎵 Raving Energy (Dance)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Raving%20Energy.mp3"},
662
- {"id": "pixabay", "name": "🎵 Pixabay Sunset Vibes", "url": "https://cdn.pixabay.com/download/audio/2022/05/27/audio_1808fbf07a.mp3"},
663
- ]
664
-
665
- MUSIC_BY_ID = {m["id"]: m for m in MUSIC_LIST}
666
-
667
-
668
- # =========================================================================
669
- # local URL resolution (uploaded files / wall images) + remote fetch
670
- # =========================================================================
671
- def _fetch_bytes(url):
672
- """Fetch image/audio/video bytes from a URL. Handles local /api/ URLs."""
673
- if not url:
674
- return None
675
- if url.startswith("/api/ai-short/upload/"):
676
- p = os.path.join(UPLOAD_DIR, os.path.basename(url.rstrip("/")))
677
- if os.path.exists(p):
678
- with open(p, "rb") as f:
679
- return f.read()
680
- return None
681
- if url.startswith("/api/wall/img/"):
682
- p = os.path.join(WALL_IMG_DIR, os.path.basename(url.rstrip("/")))
683
- if os.path.exists(p):
684
- with open(p, "rb") as f:
685
- return f.read()
686
- return None
687
- try:
688
- r = requests.get(url, headers=UA_HEADERS, timeout=30)
689
- if r.status_code == 200 and len(r.content) > 900:
690
- return r.content
691
- except Exception:
692
- pass
693
- return None
694
-
695
-
696
- def _download_to_file(url, dst, max_mb=400):
697
- """Stream a remote file (video) to disk without loading it into RAM.
698
- Returns True on success. Handles same-origin /api/ URLs by copy.
699
- If the URL is an HLS playlist (.m3u8), downloads+decrypts all segments
700
- via _download_hls_to_ts (handles AES-128 encryption from news CDNs).
701
- YouTube URLs are downloaded via yt-dlp."""
702
- try:
703
- # YouTube embed / watch URLs → use yt-dlp to fetch best MP4
704
- yt_match = re.search(r'youtube\.com/(?:embed|watch)\?v=([a-zA-Z0-9_-]{11})|youtu\.be/([a-zA-Z0-9_-]{11})', (url or ""))
705
- if yt_dlp is not None and yt_match:
706
- yt_id = yt_match.group(1) or yt_match.group(2)
707
- tmp_out = dst + ".yt.mp4"
708
- cf = _ensure_cookies()
709
- ytdlp_args = ["yt-dlp", "-f", "best[ext=mp4][height<=720]/best[ext=mp4]/best",
710
- "--no-playlist", "--remux-video", "mp4", "-o", tmp_out,
711
- "--timeout", "30", "--no-warnings"]
712
- if cf:
713
- ytdlp_args += ["--cookiefile", cf]
714
- # use the video ID to construct the canonical watch URL
715
- ytdlp_args.append(f"https://www.youtube.com/watch?v={yt_id}")
716
- try:
717
- subprocess.run(ytdlp_args, check=True, stdout=subprocess.PIPE,
718
- stderr=subprocess.PIPE, timeout=180)
719
- if os.path.exists(tmp_out) and os.path.getsize(tmp_out) > 0:
720
- os.rename(tmp_out, dst)
721
- return True
722
- except Exception:
723
- pass
724
- return False
725
-
726
- # HLS playlists need segment-wise download + AES decryption
727
- if ".m3u8" in (url or "").lower():
728
- return _download_hls_to_ts(url, dst, max_mb=max_mb)
729
- if url.startswith("/api/ai-short/upload/"):
730
- p = os.path.join(UPLOAD_DIR, os.path.basename(url.rstrip("/")))
731
- if os.path.exists(p):
732
- with open(p, "rb") as f, open(dst, "wb") as g:
733
- g.write(f.read())
734
- return True
735
- return False
736
- if url.startswith("/api/wall/img/"):
737
- p = os.path.join(WALL_IMG_DIR, os.path.basename(url.rstrip("/")))
738
- if os.path.exists(p):
739
- with open(p, "rb") as f, open(dst, "wb") as g:
740
- g.write(f.read())
741
- return True
742
- return False
743
- with requests.get(url, headers=UA_HEADERS, timeout=60, stream=True,
744
- allow_redirects=True) as r:
745
- if r.status_code != 200:
746
- return False
747
- total = 0
748
- with open(dst, "wb") as f:
749
- for chunk in r.iter_content(chunk_size=256 * 1024):
750
- if not chunk:
751
- continue
752
- f.write(chunk)
753
- total += len(chunk)
754
- if total > max_mb * 1024 * 1024:
755
- return False
756
- return os.path.exists(dst) and os.path.getsize(dst) > 900
757
- except Exception:
758
- return False
759
-
760
-
761
- def _download_hls_to_ts(m3u8_url, dst, max_mb=400):
762
- """Download an HLS stream (incl. AES-128 encrypted) to a single .ts file,
763
- decrypting segments in Python. Returns True on success.
764
-
765
- Avoids relying on ffmpeg's network HLS demuxer (which can be fragile on
766
- news CDNs like vnecdn/24h that require referer + AES-128 keys)."""
767
- try:
768
- from urllib.parse import urljoin, urlparse
769
- try:
770
- from Crypto.Cipher import AES
771
- except Exception:
772
- AES = None
773
- HDRS = _resolve_media_url(m3u8_url)
774
- r = requests.get(m3u8_url, headers=HDRS, timeout=30)
775
- if r.status_code != 200:
776
- return False
777
- text = r.text
778
- if "#EXTM3U" not in text:
779
- # Not actually HLS — write raw bytes
780
- with open(dst, "wb") as f:
781
- f.write(r.content)
782
- return os.path.exists(dst) and os.path.getsize(dst) > 900
783
- # master playlist? pick a variant (prefer 720)
784
- if "#EXT-X-STREAM-INF" in text:
785
- ch = re.findall(r'#EXT-X-STREAM-INF[^\n]*\n([^\n]+)', text)
786
- variants = [v.strip() for v in ch if v.strip()]
787
- # pick one containing 720, else first
788
- chosen = next((v for v in variants if "720" in v), variants[0] if variants else None)
789
- if chosen:
790
- if not chosen.startswith("http"):
791
- chosen = urljoin(m3u8_url, chosen)
792
- return _download_hls_to_ts(chosen, dst, max_mb=max_mb)
793
-
794
- key = None
795
- key_uri = None
796
- km = re.search(r'#EXT-X-KEY:METHOD=AES-128,URI="([^"]+)"', text)
797
- if km:
798
- key_uri = km.group(1)
799
- if not key_uri.startswith("http"):
800
- key_uri = urljoin(m3u8_url, key_uri)
801
- kr = requests.get(key_uri, headers=HDRS, timeout=20)
802
- if kr.status_code == 200:
803
- key = kr.content
804
- use_aes = key is not None and AES is not None
805
-
806
- # media sequence drives the AES-CBC IV
807
- seq = 0
808
- mm = re.search(r'#EXT-X-MEDIA-SEQUENCE:\s*(\d+)', text)
809
- if mm:
810
- seq = int(mm.group(1))
811
- keyfm = re.search(r'#EXT-X-KEY:METHOD=AES-128,.*?IV=0x([0-9A-Fa-f]+)', text)
812
- explicit_iv = keyfm.group(1) if keyfm else None
813
-
814
- segs = [l.strip() for l in text.splitlines()
815
- if l.strip() and not l.startswith("#")]
816
- total = 0
817
- with open(dst, "wb") as out:
818
- for i, s in enumerate(segs):
819
- if not s.startswith("http"):
820
- s = urljoin(m3u8_url, s)
821
- sr = requests.get(s, headers=HDRS, timeout=60)
822
- if sr.status_code != 200:
823
- return False
824
- data = sr.content
825
- sr.close()
826
- if use_aes:
827
- # AES-128-CBC decrypt the ENTIRE segment with one call so
828
- # PKCS7 + CBC chaining stay correct (segment is self-contained).
829
- if explicit_iv:
830
- iv = bytes.fromhex(explicit_iv)
831
- else:
832
- iv = (seq + i).to_bytes(16, "big")
833
- data = AES.new(key, AES.MODE_CBC, iv).decrypt(data)
834
- out.write(data)
835
- total += len(data)
836
- if total > max_mb * 1024 * 1024:
837
- return False
838
- return os.path.exists(dst) and os.path.getsize(dst) > 900
839
- except Exception:
840
- return False
841
-
842
-
843
- def _probe_duration(path, fallback=15.0):
844
- try:
845
- pr = subprocess.run(
846
- ["ffprobe", "-v", "error", "-show_entries", "format=duration",
847
- "-of", "default=noprint_wrappers=1:no_key=1", path],
848
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
849
- return max(1.0, float((pr.stdout or b"").decode().strip() or fallback))
850
- except Exception:
851
- return fallback
852
-
853
-
854
- def _probe_has_audio(path):
855
- try:
856
- pr = subprocess.run(
857
- ["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries",
858
- "stream=index", "-of", "csv=p=0", path],
859
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
860
- return bool((pr.stdout or b"").decode().strip())
861
- except Exception:
862
- return False
863
-
864
-
865
- def _first_frame(video_path, thumb_path, t=0.5):
866
- """Extract a thumbnail frame from a video at time t seconds.
867
- Returns the served URL path or empty string."""
868
- try:
869
- os.makedirs(os.path.dirname(thumb_path), exist_ok=True)
870
- subprocess.run(
871
- ["ffmpeg", "-y", "-ss", str(t), "-i", video_path,
872
- "-frames:v", "1", "-q:v", "3", thumb_path],
873
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60)
874
- if os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 500:
875
- return f"/api/wall/img/{os.path.basename(thumb_path)}"
876
- except Exception:
877
- pass
878
- return ""
879
-
880
-
881
- # =========================================================================
882
- # Image helpers
883
- # =========================================================================
884
- def _wrap_text_px(draw, text, font, max_width, max_lines):
885
- words = _clean(text).split()
886
- lines, cur = [], ""
887
- for w in words:
888
- test = (cur + " " + w).strip()
889
- try:
890
- width = draw.textbbox((0, 0), test, font=font)[2]
891
- except Exception:
892
- width = len(test) * 20
893
- if width <= max_width:
894
- cur = test
895
- else:
896
- if cur:
897
- lines.append(cur)
898
- cur = w
899
- if len(lines) >= max_lines:
900
- break
901
- if cur and len(lines) < max_lines:
902
- lines.append(cur)
903
- return lines
904
-
905
-
906
- def _cover_resize(img, w, h):
907
- """Center-crop + resize to exactly (w, h)."""
908
- rw, rh = w / h, img.width / img.height
909
- if rw > rh: # image too tall -> crop bottom/top
910
- nh = int(img.width / rw)
911
- top = max(0, (img.height - nh) // 2)
912
- img = img.crop((0, top, img.width, top + nh))
913
- else: # image too wide -> crop left/right
914
- nw = int(img.height * rw)
915
- left = max(0, (img.width - nw) // 2)
916
- img = img.crop((left, 0, left + nw, img.height))
917
- return img.resize((w, h))
918
-
919
-
920
- def _frame_from_image(image_bytes, out_path, seg_idx=0, seg_total=1):
921
- """Full-bleed designed image background + tiny progress chip. NO text."""
922
- W, H = 1080, 1920
923
- bg = Image.new("RGB", (W, H), (10, 10, 10))
924
- try:
925
- im = Image.open(io_bytes(image_bytes)).convert("RGB")
926
- bg = _cover_resize(im, W, H)
927
- except Exception:
928
- pass
929
- draw = ImageDraw.Draw(bg)
930
- try:
931
- font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 26)
932
- except Exception:
933
- font_small = None
934
- if seg_total > 1:
935
- chip = f"{seg_idx + 1}/{seg_total}"
936
- try:
937
- tw = draw.textbbox((0, 0), chip, font=font_small)[2]
938
- except Exception:
939
- tw = len(chip) * 14
940
- draw.rounded_rectangle((W - 40 - tw - 24, 34, W - 22, 74), radius=18, fill=(0, 0, 0))
941
- draw.text((W - 40 - tw - 10, 40), chip, fill=(255, 255, 255), font=font_small)
942
- bg.save(out_path, quality=92)
943
-
944
-
945
- def _frame_with_text(image_bytes, out_path, segment, title, seg_idx=0, seg_total=1):
946
- """Designed image bg + centered key point + footer title (default design)."""
947
- W, H = 1080, 1920
948
- bg = Image.new("RGB", (W, H), (10, 10, 10))
949
- try:
950
- im = Image.open(io_bytes(image_bytes)).convert("RGB")
951
- cover = _cover_resize(im, W, H)
952
- bg = Image.blend(cover, Image.new("RGB", (W, H), (0, 0, 0)), 0.45)
953
- except Exception:
954
- pass
955
- draw = ImageDraw.Draw(bg)
956
- try:
957
- font_brand = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34)
958
- font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 28)
959
- font_seg = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 56)
960
- font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
961
- except Exception:
962
- font_brand = font_small = font_seg = font_title = None
963
- draw.rectangle((0, 620, W, H), fill=(12, 12, 12))
964
- y = 700
965
- dot_x, dot_y = 48, 762
966
- for i in range(max(1, seg_total)):
967
- fill = (92, 184, 122) if i == seg_idx else (70, 70, 70)
968
- draw.rounded_rectangle((dot_x + i * 38, dot_y, dot_x + i * 38 + 24, dot_y + 10), radius=5, fill=fill)
969
- draw.text((48, 800), "VNEWS AI SHORT", fill=(110, 231, 143), font=font_brand)
970
- draw.rounded_rectangle((48, 854, 260, 900), radius=20, fill=(28, 70, 45))
971
- draw.text((66, 862), f"Đoạn {seg_idx + 1}/{max(1, seg_total)}", fill=(235, 235, 235), font=font_small)
972
- y = 960
973
- maxw = W - 96
974
- for ln in _wrap_text_px(draw, segment, font_seg, maxw, 16):
975
- draw.text((48, y), ln, fill=(255, 255, 255), font=font_seg)
976
- y += 72
977
- if y > 1560:
978
- break
979
- y2 = 1700
980
- draw.line((48, y2 - 24, W - 48, y2 - 24), fill=(70, 70, 70), width=2)
981
- for ln in _wrap_text_px(draw, title, font_title, maxw, 3):
982
- draw.text((48, y2), ln, fill=(220, 220, 220), font=font_title)
983
- y2 += 44
984
- bg.save(out_path, quality=92)
985
-
986
-
987
- def io_bytes(b):
988
- import io
989
- return io.BytesIO(b)
990
-
991
-
992
- def _tts_one(text, voice, speed, out_path):
993
- """Generate one TTS segment (edge-tts with gTTS fallback), speed-adjusted.
994
- Returns the final file path or None on failure."""
995
- edge_voice = {
996
- "vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural", "vi-vn-namminhneural": "vi-VN-NamMinhNeural",
997
- "hoaimy": "vi-VN-HoaiMyNeural", "namminh": "vi-VN-NamMinhNeural", "nam": "vi-VN-NamMinhNeural",
998
- "male": "vi-VN-NamMinhNeural", "nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural",
999
- "mien-nam": "vi-VN-HoaiMyNeural",
1000
- "en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
1001
- "en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
1002
- "andrew": "en-US-AndrewMultilingualNeural", "en_andrew": "en-US-AndrewMultilingualNeural",
1003
- "jenny": "en-US-AndrewMultilingualNeural", "en_jenny": "en-US-AndrewMultilingualNeural",
1004
- "pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
1005
- "thalita": "pt-BR-ThalitaMultilingualNeural", "pt": "pt-BR-ThalitaMultilingualNeural",
1006
- "fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
1007
- "fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
1008
- "denise": "fr-FR-VivienneMultilingualNeural", "fr": "fr-FR-VivienneMultilingualNeural",
1009
- "de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
1010
- "de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
1011
- "katja": "de-DE-SeraphinaMultilingualNeural", "de": "de-DE-SeraphinaMultilingualNeural",
1012
- "ko-kr-hyusumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
1013
- "ko-kr-hyunsuneural": "ko-KR-HyunsuMultilingualNeural", "sunhee": "ko-KR-HyunsuMultilingualNeural",
1014
- "ko": "ko-KR-HyunsuMultilingualNeural",
1015
- "it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
1016
- "ela": "en-US-AndrewMultilingualNeural", "es": "en-US-AndrewMultilingualNeural",
1017
- }.get(voice.lower(), voice)
1018
- aud = out_path
1019
- aud_fast = re.sub(r"\.mp3$", "_fast.mp3", out_path)
1020
- try:
1021
- subprocess.run(
1022
- ["python", "-m", "edge_tts", "--voice", edge_voice, "--text", text,
1023
- "--write-media", aud],
1024
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
1025
- except Exception:
1026
- aud = None
1027
- try:
1028
- from gtts import gTTS
1029
- tld = "com.vn" if voice.lower() in ("nu", "female", "mien-nam", "hoaimy") else "com"
1030
- try:
1031
- gTTS(text, lang="vi", tld=tld, slow=False).save(aud)
1032
- except TypeError:
1033
- gTTS(text, lang="vi", slow=False).save(aud)
1034
- except Exception:
1035
- pass
1036
- if aud and os.path.exists(aud) and os.path.getsize(aud) > 900:
1037
- try:
1038
- subprocess.run(["ffmpeg", "-y", "-i", aud, "-filter:a", f"atempo={speed}", "-vn", aud_fast],
1039
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
1040
- if os.path.exists(aud_fast) and os.path.getsize(aud_fast) > 900:
1041
- return aud_fast
1042
- except Exception:
1043
- pass
1044
- return aud
1045
- return None
1046
-
1047
-
1048
- def _concat_audio(paths, out_path, pad=0.3):
1049
- """Concatenate audio files into one track (m4a/aac)."""
1050
- if not paths:
1051
- return None
1052
- lst = os.path.join(os.path.dirname(out_path), "_alist.txt")
1053
- with open(lst, "w", encoding="utf-8") as f:
1054
- for p in paths:
1055
- f.write("file '" + p.replace("'", "'\\''") + "'\n")
1056
- subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", lst,
1057
- "-c:a", "aac", "-b:a", "128k", out_path],
1058
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
1059
- return out_path if os.path.exists(out_path) and os.path.getsize(out_path) > 900 else None
1060
-
1061
-
1062
- def _assemble_video_short(work, video_path, video_dur, img_paths, segs,
1063
- reuse_audio_path, video_audio_path, voice, speed,
1064
- music_path, out_mp4, fallback_title):
1065
- """New video-first short assembly.
1066
-
1067
- Timeline: scraped/uploaded video plays first (up to min(video_dur, voice)),
1068
- then the designed/selected images fill the remainder of the voice duration
1069
- (or until the video covers the whole short). The voice track is
1070
- continuous TTS narration of the segments (or reused previous short audio).
1071
-
1072
- Returns (out_mp4, total_dur) or raises on failure.
1073
- """
1074
- # ---------- 1. narration track (continuous) ----------
1075
- narr = None
1076
- if reuse_audio_path and os.path.exists(reuse_audio_path):
1077
- narr = os.path.join(work, "narr.m4a")
1078
- subprocess.run(["ffmpeg", "-y", "-i", reuse_audio_path,
1079
- "-c:a", "aac", "-b:a", "128k", narr],
1080
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
1081
- narr = narr if os.path.exists(narr) and os.path.getsize(narr) > 900 else None
1082
- else:
1083
- voice_parts = []
1084
- for i, seg in enumerate(segs):
1085
- vf = _tts_one(seg, voice, speed, os.path.join(work, f"vf_{i:02d}.mp3"))
1086
- if vf:
1087
- voice_parts.append(vf)
1088
- if voice_parts:
1089
- narr = _concat_audio(voice_parts, os.path.join(work, "narr.m4a"))
1090
- if narr and os.path.exists(narr):
1091
- # small tail silence so the last word isn't cut
1092
- tail = os.path.join(work, "narr_tail.m4a")
1093
- subprocess.run(["ffmpeg", "-y", "-i", narr, "-af", "apad=pad_dur=0.6",
1094
- "-c:a", "aac", "-b:a", "128k", tail],
1095
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
1096
- if os.path.exists(tail) and os.path.getsize(tail) > 900:
1097
- narr = tail
1098
-
1099
- narr_dur = _probe_duration(narr, fallback=0.0) if narr else 0.0
1100
- total_dur = narr_dur
1101
- if total_dur < 1.0:
1102
- # no voice -> short is driven by video length (or music overlays later)
1103
- total_dur = max(1.0, video_dur)
1104
-
1105
- # ---------- 2. split timeline ----------
1106
- # When video is longer than narration (e.g. scraped YouTube video in HOT mode),
1107
- # let the video play at its full length and fill remaining time with images.
1108
- # When narration is longer, video plays first then images fill the remainder.
1109
- if video_dur > 0 and narr_dur < video_dur:
1110
- # Video is longer than narration → video plays first at full length,
1111
- # then images fill any remaining narration time (usually minimal).
1112
- use_video = video_dur
1113
- total_dur = max(use_video, narr_dur)
1114
- img_dur = total_dur - use_video
1115
- else:
1116
- use_video = max(0.0, min(video_dur, total_dur))
1117
- img_dur = total_dur - use_video
1118
- imgs = img_paths or []
1119
- # image durations: split remaining time across available images (min 1.2s,
1120
- # repeat cyclically if the voice is longer than all images)
1121
- img_plan = []
1122
- if img_dur > 0.5 and imgs:
1123
- per = max(1.2, min(8.0, img_dur / len(imgs)))
1124
- remaining = img_dur
1125
- i = 0
1126
- while remaining > 0.3:
1127
- img_plan.append((imgs[i % len(imgs)], min(per, remaining)))
1128
- remaining -= per
1129
- i += 1
1130
-
1131
- # ---------- 3. build ffmpeg command ----------
1132
- cmd = ["ffmpeg", "-y"]
1133
- # video input first (index 0)
1134
- cmd += ["-i", video_path]
1135
- # image loop inputs
1136
- for idx, (_, dur) in enumerate(img_plan):
1137
- cmd += ["-framerate", "25", "-loop", "1", "-t", str(max(0.4, dur)), "-i", img_plan[idx][0]]
1138
- # narration + optional video audio inputs
1139
- audio_inputs = []
1140
- narr_idx = None
1141
- va_idx = None
1142
- if narr:
1143
- narr_idx = 1 + len(img_plan)
1144
- cmd += ["-i", narr]
1145
- audio_inputs.append(narr_idx)
1146
- if video_audio_path and os.path.exists(video_audio_path):
1147
- va_idx = 1 + len(img_plan) + (1 if narr_idx is not None else 0)
1148
- cmd += ["-i", video_audio_path]
1149
- audio_inputs.append(va_idx)
1150
-
1151
- # filter graph
1152
- fc = []
1153
- # video inputs: [0:v] = scraped video, [k:v] = images
1154
- vlabels = []
1155
- # video: trim to use_video, scale/crop to 1080x1920 portrait, normalize fps
1156
- fc.append(
1157
- f"[0:v]trim=duration={use_video:.3f},setpts=PTS-STARTPTS,"
1158
- f"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,"
1159
- f"fps=25,format=yuv420p[v0]"
1160
- )
1161
- vlabels.append("[v0]")
1162
- for idx, (_, dur) in enumerate(img_plan):
1163
- lab = f"v{idx + 1}"
1164
- fc.append(
1165
- f"[{1 + idx}:v]trim=duration={dur:.3f},setpts=PTS-STARTPTS,"
1166
- f"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,"
1167
- f"fps=25,format=yuv420p[{lab}]"
1168
- )
1169
- vlabels.append(f"[{lab}]")
1170
- if len(vlabels) > 1:
1171
- concat_in = "".join(vlabels)
1172
- fc.append(f"{concat_in}concat=n={len(vlabels)}:v=1:a=0[vout]")
1173
- vmap = "[vout]"
1174
- else:
1175
- vmap = "[v0]"
1176
-
1177
- # audio graph
1178
- if audio_inputs:
1179
- amix_sources = []
1180
- if narr_idx is not None:
1181
- fc.append(f"[{narr_idx}:a]aresample=44100,atrim=duration={total_dur:.3f},asetpts=PTS-STARTPTS[n]")
1182
- amix_sources.append("[n]")
1183
- if va_idx is not None:
1184
- fc.append(f"[{va_idx}:a]aresample=44100,volume=0.85,atrim=duration={use_video:.3f},asetpts=PTS-STARTPTS[xva]")
1185
- amix_sources.append("[xva]")
1186
- fc.append(f"{''.join(amix_sources)}amix=inputs={len(amix_sources)}:duration=first:dropout_transition=0:normalize=0[aout]")
1187
- amap = "[aout]"
1188
- else:
1189
- amap = None
1190
-
1191
- fc_str = ";".join(fc)
1192
- cmd += ["-filter_complex", fc_str]
1193
- cmd += ["-map", vmap]
1194
- if amap:
1195
- cmd += ["-map", amap, "-c:a", "aac", "-b:a", "160k"]
1196
- else:
1197
- cmd += ["-an"]
1198
- cmd += ["-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
1199
- "-pix_fmt", "yuv420p", "-movflags", "+faststart",
1200
- "-t", f"{total_dur:.3f}", "-shortest", out_mp4]
1201
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=600)
1202
- return out_mp4, total_dur
1203
-
1204
-
1205
- def _segments_from_post(post, max_segments=25):
1206
- raw = _clean(post.get("text") or post.get("title") or "")
1207
- raw = re.sub(r"^Bản tin AI viết lại:\s*", "", raw, flags=re.I)
1208
- raw = re.sub(r"Nguồn tham khảo:.*$", "", raw, flags=re.I | re.S).strip()
1209
- lines = []
1210
- for ln in raw.splitlines():
1211
- ln = _clean(re.sub(r"^[•\-\*\d\.\)\s]+", "", ln))
1212
- if not ln:
1213
- continue
1214
- low = ln.lower()
1215
- if low.startswith(("điểm chính", "tiêu đề", "sapo", "nguồn tham khảo")):
1216
- continue
1217
- if len(ln) >= 18:
1218
- lines.append(ln)
1219
- if len(lines) < 3:
1220
- lines = []
1221
- for s in re.split(r"(?<=[\.\!\?])\s+", raw):
1222
- s = _clean(s)
1223
- if len(s) >= 25:
1224
- lines.append(s)
1225
- seen = set()
1226
- out = []
1227
- for u in lines[: max_segments * 3]:
1228
- nu = re.sub(r"[^\wÀ-ỹ\s]", "", u.lower())
1229
- if nu in seen:
1230
- continue
1231
- seen.add(nu)
1232
- out.append(u)
1233
- if len(out) >= max_segments:
1234
- break
1235
- return out[:max_segments] if out else [post.get("title", "Bản tin VNEWS")]
1236
-
1237
-
1238
- def _find_previous_short(post_id):
1239
- """Find the most recent short mp4 for this post (prior generation)."""
1240
- prefix = _safe_name(post_id)
1241
- best, best_mtime = None, 0
1242
- try:
1243
- for f in os.listdir(SHORTS_DIR):
1244
- # current naming: <post_id>v2_<...>.mp4
1245
- if f.startswith(prefix + "v2_") and f.endswith(".mp4"):
1246
- p = os.path.join(SHORTS_DIR, f)
1247
- mt = os.path.getmtime(p)
1248
- if mt > best_mtime:
1249
- best, best_mtime = p, mt
1250
- except Exception:
1251
- pass
1252
- # also legacy patched files: <post_id>_<voice>_..._scenes_nosub.mp4
1253
- if not best:
1254
- try:
1255
- for f in os.listdir(SHORTS_DIR):
1256
- if f.startswith(prefix + "_") and f.endswith("_scenes_nosub.mp4"):
1257
- p = os.path.join(SHORTS_DIR, f)
1258
- mt = os.path.getmtime(p)
1259
- if mt > best_mtime:
1260
- best, best_mtime = p, mt
1261
- except Exception:
1262
- pass
1263
- return best
1264
-
1265
-
1266
- # =========================================================================
1267
- # Endpoints
1268
- # =========================================================================
1269
- @app.get("/api/ai-short/music")
1270
- def api_short_music_list():
1271
- return JSONResponse({"music": MUSIC_LIST})
1272
-
1273
-
1274
- @app.get("/api/ai-short/music/stream")
1275
- def api_short_music_stream(url: str = Query(...)):
1276
- """Proxy một track nhạc nền với CORS headers để trình duyệt phát trước khi chọn."""
1277
- try:
1278
- r = requests.get(url, headers=UA_HEADERS, timeout=30,
1279
- stream=True, allow_redirects=True)
1280
- if r.status_code != 200:
1281
- return Response(status_code=502)
1282
- return Response(
1283
- content=r.content,
1284
- media_type="audio/mpeg",
1285
- headers={
1286
- "Cache-Control": "public, max-age=86400",
1287
- "Access-Control-Allow-Origin": "*",
1288
- "Accept-Ranges": "bytes",
1289
- "Content-Disposition": "inline",
1290
- },
1291
- )
1292
- except Exception:
1293
- return Response(status_code=502)
1294
-
1295
-
1296
- @app.post("/api/ai-short/upload")
1297
- async def api_short_upload(file: UploadFile = File(...)):
1298
- """Upload audio/video/image used as short background."""
1299
- try:
1300
- if not file or not hasattr(file, "filename"):
1301
- return JSONResponse({"error": "Thiếu file"}, status_code=400)
1302
- fname = file.filename or ""
1303
- ext = os.path.splitext(fname)[1].lower()
1304
- if ext not in (".mp3", ".wav", ".m4a", ".aac", ".ogg",
1305
- ".mp4", ".webm", ".mov",
1306
- ".jpg", ".jpeg", ".png", ".webp"):
1307
- return JSONResponse({"error": "Định dạng file không hỗ trợ"}, status_code=400)
1308
- content = await file.read()
1309
- if not content:
1310
- return JSONResponse({"error": "File rỗng"}, status_code=400)
1311
- if len(content) > 60 * 1024 * 1024:
1312
- return JSONResponse({"error": "File quá lớn (>60MB)"}, status_code=400)
1313
- fuid = str(uuid.uuid4())[:12]
1314
- out_name = f"su_{fuid}{ext}"
1315
- with open(os.path.join(UPLOAD_DIR, out_name), "wb") as f:
1316
- f.write(content)
1317
- kind = ("audio" if ext in (".mp3", ".wav", ".m4a", ".aac", ".ogg")
1318
- else ("video" if ext in (".mp4", ".webm", ".mov") else "image"))
1319
- return JSONResponse({"ok": True, "url": f"/api/ai-short/upload/{out_name}", "kind": kind})
1320
- except Exception as e:
1321
- return JSONResponse({"error": f"Lỗi upload: {str(e)[:150]}"}, status_code=500)
1322
-
1323
-
1324
- @app.get("/api/ai-short/upload/{fname}")
1325
- def api_short_upload_file(fname: str):
1326
- if ".." in fname or "/" in fname:
1327
- return JSONResponse({"error": "bad name"}, status_code=400)
1328
- p = os.path.join(UPLOAD_DIR, fname)
1329
- if not os.path.exists(p):
1330
- return JSONResponse({"error": "not found"}, status_code=404)
1331
- ext = os.path.splitext(fname)[1].lower()
1332
- mt = {
1333
- ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4",
1334
- ".aac": "audio/aac", ".ogg": "audio/ogg",
1335
- ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
1336
- ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
1337
- ".webp": "image/webp",
1338
- }.get(ext, "application/octet-stream")
1339
- return FileResponse(p, media_type=mt)
1340
-
1341
-
1342
- @app.get("/api/ai-short/scrape")
1343
- def api_scrape_video(url: str = Query(...)):
1344
- """Scrap video info from any link (YouTube/TikTok/news...).
1345
-
1346
- Returns direct media URL + duration + thumbnail so the frontend can
1347
- show a preview before generating the short.
1348
- """
1349
- if not url or not re.match(r"^https?://", url):
1350
- return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
1351
- info = _scrape_video_info(url.strip())
1352
- if not info.get("ok"):
1353
- return JSONResponse({"error": info.get("error", "Không lấy được video")}, status_code=422)
1354
- # strip cache-internal key before returning to the client
1355
- info = {k: v for k, v in info.items() if k != "_t"}
1356
- return JSONResponse(info)
1357
-
1358
-
1359
- @app.get("/api/ai-short/scrape/preview")
1360
- def api_scrape_video_preview(url: str = Query(...), request: Request = None):
1361
- """Stream the scraped video (or its direct mp4) so the browser can play
1362
- it inside the short creator modal. Range-capable proxy.
1363
- """
1364
- if not url or not re.match(r"^https?://", url):
1365
- return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
1366
- req_headers = dict(UA_HEADERS)
1367
- if request and request.headers.get("range"):
1368
- req_headers["Range"] = request.headers["range"]
1369
- try:
1370
- r = requests.get(url, headers=req_headers, timeout=30, stream=True,
1371
- allow_redirects=True)
1372
- except Exception:
1373
- return JSONResponse({"error": "Không tải được video" }, status_code=502)
1374
- if r.status_code >= 400:
1375
- return JSONResponse({"error": "Lỗi nguồn video"}, status_code=502)
1376
- resp_headers = {
1377
- "Access-Control-Allow-Origin": "*",
1378
- "Accept-Ranges": "bytes",
1379
- "Content-Type": r.headers.get("Content-Type", "video/mp4"),
1380
- }
1381
- if "Content-Range" in r.headers:
1382
- resp_headers["Content-Range"] = r.headers["Content-Range"]
1383
- if "Content-Length" in r.headers:
1384
- resp_headers["Content-Length"] = r.headers["Content-Length"]
1385
- if "Cache-Control" in r.headers:
1386
- resp_headers["Cache-Control"] = r.headers["Cache-Control"]
1387
- else:
1388
- resp_headers["Cache-Control"] = "public, max-age=3600"
1389
-
1390
- def gen():
1391
- try:
1392
- yield from r.iter_content(chunk_size=256 * 1024)
1393
- finally:
1394
- r.close()
1395
-
1396
- return StreamingResponse(gen(), status_code=r.status_code, headers=resp_headers)
1397
-
1398
-
1399
- @app.get("/api/ai-short/scrape/hls")
1400
- def api_scrape_video_hls(url: str = Query(...)):
1401
- """Proxy an HLS master/media playlist and rewrite segment + key URIs to
1402
- same-origin proxy routes so the browser (hls.js) can play AES-128 HLS
1403
- from news CDNs (vnexpress/24h) without CORS errors."""
1404
- if not url or not re.match(r"^https?://", url):
1405
- return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
1406
- try:
1407
- HDRS = _resolve_media_url(url)
1408
- r = requests.get(url, headers=HDRS, timeout=25)
1409
- if r.status_code != 200:
1410
- return JSONResponse({"error": "Lỗi nguồn HLS"}, status_code=502)
1411
- text = r.text
1412
- if "#EXTM3U" not in text:
1413
- # not a playlist; pass through as binary
1414
- return Response(content=r.content,
1415
- media_type=r.headers.get("Content-Type", "video/mp4"),
1416
- headers={"Access-Control-Allow-Origin": "*"})
1417
- from urllib.parse import urljoin, quote
1418
- out_lines = []
1419
- for line in text.splitlines():
1420
- s = line.strip()
1421
- if s.startswith("#"):
1422
- # rewrite #EXT-X-KEY URI to same-origin key proxy
1423
- if "#EXT-X-KEY" in s and "URI=" in s:
1424
- m = re.search(r'URI="([^"]+)"', s)
1425
- if m:
1426
- ku = m.group(1)
1427
- if not ku.startswith("http"):
1428
- ku = urljoin(url, ku)
1429
- s2 = s.replace(m.group(0), 'URI="/api/ai-short/scrape/key?url=' + urllib_quote(ku, safe="") + '"')
1430
- out_lines.append(s2)
1431
- continue
1432
- out_lines.append(s)
1433
- elif s.startswith("http"): # absolute segment URI
1434
- out_lines.append("/api/ai-short/scrape/seg?url=" + urllib_quote(s, safe=""))
1435
- elif s: # relative segment URI
1436
- out_lines.append("/api/ai-short/scrape/seg?url=" + urllib_quote(urljoin(url, s), safe=""))
1437
- return Response(content="\n".join(out_lines).encode("utf-8"),
1438
- media_type="application/vnd.apple.mpegurl",
1439
- headers={"Access-Control-Allow-Origin": "*",
1440
- "Cache-Control": "public, max-age=300"})
1441
- except Exception:
1442
- return JSONResponse({"error": "Lỗi proxy HLS"}, status_code=502)
1443
-
1444
-
1445
- @app.get("/api/ai-short/scrape/seg")
1446
- def api_scrape_video_seg(url: str = Query(...)):
1447
- """Stream a single HLS segment (.ts) with proper referer headers."""
1448
- if not url or not re.match(r"^https?://", url):
1449
- return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
1450
- try:
1451
- HDRS = _resolve_media_url(url)
1452
- r = requests.get(url, headers=HDRS, timeout=60, stream=True)
1453
- if r.status_code != 200:
1454
- return JSONResponse({"error": "Lỗi segment"}, status_code=502)
1455
- return StreamingResponse(r.iter_content(chunk_size=256 * 1024),
1456
- media_type="video/mp2t",
1457
- headers={"Access-Control-Allow-Origin": "*",
1458
- "Cache-Control": "public, max-age=3600"})
1459
- except Exception:
1460
- return JSONResponse({"error": "Lỗi segment"}, status_code=502)
1461
-
1462
-
1463
- @app.get("/api/ai-short/scrape/key")
1464
- def api_scrape_video_key(url: str = Query(...)):
1465
- """Proxy the AES-128 key URI for HLS playlists."""
1466
- if not url or not re.match(r"^https?://", url):
1467
- return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
1468
- try:
1469
- HDRS = _resolve_media_url(url)
1470
- r = requests.get(url, headers=HDRS, timeout=20)
1471
- if r.status_code != 200:
1472
- return JSONResponse({"error": "Lỗi key"}, status_code=502)
1473
- return Response(content=r.content, media_type="application/octet-stream",
1474
- headers={"Access-Control-Allow-Origin": "*",
1475
- "Cache-Control": "public, max-age=3600"})
1476
- except Exception:
1477
- return JSONResponse({"error": "Lỗi key"}, status_code=502)
1478
-
1479
-
1480
- @app.post("/api/ai-short")
1481
- async def api_short_generate(request: Request):
1482
- """Generate a Short AI video.
1483
-
1484
- JSON body:
1485
- post_id (str) wall post id (required)
1486
- use_slides (bool) one scene per designed slide; image = designed slide
1487
- image; NO text overlays; narration = audio of previous
1488
- short if available, else TTS of slide text (audio only).
1489
- images (list[str]) custom background image URLs (replaces default)
1490
- audio_music (str) music id from MUSIC_LIST ('' -> none)
1491
- audio_url (str) custom audio URL (uploaded /api/ai-short/upload/...)
1492
- voice/emotion/speed — TTS controls
1493
- """
1494
- try:
1495
- body = await request.json()
1496
- except Exception:
1497
- body = {}
1498
- post_id = _clean(str(body.get("post_id", "")))
1499
- create_new = bool(body.get("create_new"))
1500
- posts = _load_wall_posts()
1501
- if not isinstance(posts, list):
1502
- posts = []
1503
- post = None
1504
- if create_new:
1505
- # "Thêm Short HOT" from the homepage: no source wall post. Create a
1506
- # brand-new post now; the generated video is attached to it and it is
1507
- # posted to the AI wall. Retention: if generation fails, the draft is
1508
- # removed again so no empty post stays on the wall.
1509
- import uuid as _uuid
1510
- new_id = str(_uuid.uuid4())[:12]
1511
- hot_title = _clean(str(body.get("title", "")))[:200]
1512
- hot_text = _clean(str(body.get("text", "")))[:2000]
1513
- hot_imgs = [str(x) for x in (body.get("images") or []) if str(x).strip()][:10]
1514
- post = {
1515
- "id": new_id,
1516
- "title": hot_title or "Short HOT",
1517
- "text": hot_text,
1518
- "source": "hot_short",
1519
- "kind": "hot_short",
1520
- "video": None,
1521
- "img": hot_imgs[0] if hot_imgs else None,
1522
- "images": hot_imgs,
1523
- "url": _clean(str(body.get("url", ""))),
1524
- "voice": _clean(str(body.get("voice", ""))),
1525
- "emotion": _clean(str(body.get("emotion", ""))),
1526
- "language": _clean(str(body.get("language", "vi"))),
1527
- "created": int(time.time()),
1528
- "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
1529
- }
1530
- posts.insert(0, post)
1531
- post_id = new_id
1532
- else:
1533
- if not post_id:
1534
- return JSONResponse({"error": "Thiếu post_id"}, status_code=400)
1535
- post = next((p for p in posts if str(p.get("id")) == str(post_id)), None)
1536
- if not post:
1537
- return JSONResponse({"error": "Không tìm thấy bài viết"}, status_code=404)
1538
-
1539
- use_slides = bool(body.get("use_slides"))
1540
- reuse_audio = body.get("reuse_audio")
1541
- if reuse_audio is None:
1542
- # default: when recreating from slides, reuse the previous short's audio
1543
- reuse_audio = use_slides
1544
- custom_images = [str(x) for x in (body.get("images") or []) if str(x).strip()]
1545
- fixed_image = _clean(str(body.get("fixed_image", ""))) # 1 ảnh cố định cho nhiều slide
1546
- raw_indices = body.get("slide_indices") or []
1547
- slide_indices = []
1548
- for v in raw_indices:
1549
- try:
1550
- slide_indices.append(int(v))
1551
- except Exception:
1552
- pass
1553
- video_url = _clean(str(body.get("video_url", "")))
1554
- video_muted = bool(body.get("video_muted"))
1555
- # oEmbed fallback for Short HOT: passed by the frontend when the scraped
1556
- # link has metadata but the direct media download failed (e.g. TikTok/
1557
- # Facebook/Instagram which block datacenter IPs). The short is then saved
1558
- # as an embeddable slide (like the football video slides) with the
1559
- # metadata auto-filled from oEmbed.
1560
- embed_url = _clean(str(body.get("embed_url", "")))
1561
- embed_title = _clean(str(body.get("embed_title", "")))[:250]
1562
- embed_thumb = _clean(str(body.get("embed_thumb", "")))
1563
- embed_desc = _clean(str(body.get("embed_desc", "")))[:5000]
1564
- try:
1565
- video_dur = float(body.get("video_dur")) if body.get("video_dur") is not None else None
1566
- except Exception:
1567
- video_dur = None
1568
- scrape_mode = bool(body.get("scrape_mode"))
1569
- audio_music = _clean(str(body.get("audio_music", "")))
1570
- audio_url = _clean(str(body.get("audio_url", "")))
1571
- voice = _clean(str(body.get("voice", "vi-VN-HoaiMyNeural")))
1572
- emotion = _clean(str(body.get("emotion", "neutral")))
1573
- try:
1574
- speed = max(0.85, min(1.35, float(body.get("speed", 1.0) or 1.0)))
1575
- except Exception:
1576
- speed = 1.0
1577
-
1578
- os.makedirs(SHORTS_DIR, exist_ok=True)
1579
-
1580
- # ---------- scene images ----------
1581
- scene_images = []
1582
- slide_texts = []
1583
- if use_slides:
1584
- slides = post.get("slides") or []
1585
- if not slides:
1586
- return JSONResponse({"error": "Bài chưa có ảnh slide thiết kế. Hãy chọn '🎨 Thiết kế ảnh' trước."}, status_code=400)
1587
- # optional per-slide selection from the recreate modal
1588
- if slide_indices:
1589
- slides = [s for i, s in enumerate(slides) if i in slide_indices]
1590
- if not slides:
1591
- return JSONResponse({"error": "Slide đã chọn không hợp lệ."}, status_code=400)
1592
- for s in slides:
1593
- im = (s.get("image") or "").strip()
1594
- if im:
1595
- scene_images.append(im)
1596
- slide_texts.append(_clean(s.get("text", "")))
1597
- # fixed_image: 1 ảnh do người dùng chọn (trong danh sách rewrite hoặc upload)
1598
- # thay thế toàn bộ ảnh slide -> dùng ảnh này cho N slide (sau dedupe = 1 scene)
1599
- if fixed_image:
1600
- scene_images = [fixed_image]
1601
- slide_texts = [slide_texts[0] if slide_texts else ""]
1602
- # dedupe identical designed images globally: if the same image appears
1603
- # in multiple slides (e.g. only 1 slide was designed, others share the
1604
- # fallback cover), collapse to one scene so the slide isn't repeated
1605
- deduped = []
1606
- seen_imgs = set()
1607
- for im in scene_images:
1608
- if im not in seen_imgs:
1609
- seen_imgs.add(im)
1610
- deduped.append(im)
1611
- scene_images = deduped
1612
- if not scene_images:
1613
- return JSONResponse({"error": "Các slide chưa có ảnh đã thiết kế."}, status_code=400)
1614
- # align slide_texts to the deduped scenes (keep first text per image group)
1615
- if slide_texts and len(slide_texts) > len(scene_images):
1616
- grouped = []
1617
- seen = set()
1618
- for idx, s in enumerate(slides):
1619
- im = (s.get("image") or "").strip()
1620
- if not im or im in seen:
1621
- continue
1622
- seen.add(im)
1623
- grouped.append(slide_texts[idx] if idx < len(slide_texts) else "")
1624
- if grouped:
1625
- slide_texts = grouped
1626
- else:
1627
- slide_texts = slide_texts[:len(scene_images)]
1628
- else:
1629
- # fixed_image (chosen from rewrite list) replaces the bg for the short
1630
- if fixed_image:
1631
- scene_images = [fixed_image]
1632
- else:
1633
- for im in custom_images:
1634
- scene_images.append(im)
1635
- if not scene_images:
1636
- scene_images = [(x or "").strip() for x in (post.get("images") or []) if (x or "").strip()]
1637
- if not scene_images:
1638
- slides = post.get("slides") or []
1639
- for s in slides:
1640
- im = (s.get("image") or "").strip()
1641
- if im:
1642
- scene_images.append(im)
1643
- if not scene_images and post.get("img"):
1644
- scene_images = [post.get("img")]
1645
- # video-only (scraped/uploaded) short: video is the background, so
1646
- # images are OPTIONAL. Let the video-first assembler handle it.
1647
- if not scene_images and not video_url:
1648
- # Short HOT oEmbed fallback: no direct media AND no background
1649
- # image -> still create the embeddable slide with the oEmbed
1650
- # thumbnail as the visual (if any), so the wall/feed can show it.
1651
- if embed_url:
1652
- pass
1653
- else:
1654
- # AUTO oEmbed FALLBACK: the frontend couldn't get a video
1655
- # (or even embed metadata) from the link — derive the
1656
- # embed iframe URL from the source link itself (cheap, no
1657
- # network) and enrich with oEmbed metadata if available.
1658
- src_url = _clean(str(body.get("url", "")) or post.get("url") or "")
1659
- if src_url and (_is_known_social(src_url) or body.get("allow_oembed_fallback")):
1660
- embed_url = _oembed_embed_url(src_url)
1661
- _probe3 = _oembed_probe(src_url)
1662
- if _probe3:
1663
- embed_url = _probe3.get("embed_url") or embed_url
1664
- embed_title = _probe3.get("title") or embed_title
1665
- embed_thumb = _probe3.get("thumbnail") or embed_thumb
1666
- embed_desc = _probe3.get("description") or embed_desc
1667
- if not embed_url:
1668
- return JSONResponse({"error": "Bài viết không có ảnh nền"}, status_code=400)
1669
- segs0 = _segments_from_post(post)
1670
- if len(scene_images) > len(segs0):
1671
- scene_images = scene_images[:max(1, len(segs0))]
1672
-
1673
- # ---------- Short HOT oEmbed shortcut ----------
1674
- # When the source link is oEmbed-only (TikTok/FB/IG block datacenter IPs,
1675
- # or the scrape returned no direct media URL), save the post as an
1676
- # embeddable slide right away with the auto-fetched title/desc/thumb.
1677
- if not video_url and embed_url:
1678
- # Don't trust the client for metadata: when the post has a source URL
1679
- # and we still lack a thumbnail/title at save time (e.g. an older page
1680
- # cached in the browser didn't send embed_thumb), probe oEmbed NOW so
1681
- # the wall post always gets its thumbnail + title.
1682
- _src_url = _clean(str(body.get("url", "")) or post.get("url") or "")
1683
- if (not embed_thumb or not embed_title) and _src_url:
1684
- _probe_save = _oembed_probe(_src_url)
1685
- if _probe_save:
1686
- embed_title = embed_title or _probe_save.get("title") or ""
1687
- embed_thumb = embed_thumb or _probe_save.get("thumbnail") or ""
1688
- embed_desc = embed_desc or _probe_save.get("description") or ""
1689
- post["video"] = embed_url
1690
- post["embed_oembed"] = True
1691
- post["title"] = embed_title or post.get("title") or "Short HOT"
1692
- post["text"] = embed_desc or post.get("text") or embed_title or ""
1693
- post["description"] = embed_desc or ""
1694
- if embed_thumb:
1695
- post["img"] = embed_thumb
1696
- post["images"] = [embed_thumb] + [x for x in (post.get("images") or []) if x != embed_thumb]
1697
- post["short_thumb"] = embed_thumb
1698
- else:
1699
- post["short_thumb"] = post.get("img")
1700
- post["extractor"] = "oembed"
1701
- _save_wall_posts(posts)
1702
- return JSONResponse({"video": embed_url, "oembed": True, "post": post})
1703
-
1704
- # ---------- naming / cache ----------
1705
- scene_key = "slides" if use_slides else "norm"
1706
- sel_key = ("s" + "".join(str(i) for i in slide_indices)) if slide_indices else ""
1707
- vid_key = hashlib.md5(video_url.encode("utf-8")).hexdigest()[:8] if video_url else ""
1708
- img_key = hashlib.md5("|".join(scene_images).encode("utf-8")).hexdigest()[:8]
1709
- mus_key = audio_music or "nomus"
1710
- aud_key = hashlib.md5(audio_url.encode("utf-8")).hexdigest()[:8] if audio_url else "none"
1711
- suffix = f"v2_{scene_key}_{'v'+vid_key if vid_key else ''}{sel_key}{img_key}_{mus_key}_{aud_key}_{voice}_{emotion}_{str(speed).replace('.', 'p')}"
1712
- out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id + suffix) + ".mp4")
1713
- out_url = "/api/ai/short-file/" + post_id + suffix
1714
-
1715
- if os.path.exists(out_mp4):
1716
- post["video"] = out_url
1717
- post["short_voice"] = voice
1718
- post["short_emotion"] = emotion
1719
- post["short_speed"] = speed
1720
- post["short_music"] = audio_music
1721
- post["short_audio_url"] = audio_url
1722
- post["short_use_slides"] = use_slides
1723
- post["short_images"] = scene_images
1724
- _save_wall_posts(posts)
1725
- return JSONResponse({"video": out_url, "voice": voice, "emotion": emotion,
1726
- "speed": speed, "music": audio_music, "use_slides": use_slides,
1727
- "post": post})
1728
-
1729
- work = os.path.join(SHORTS_DIR, _safe_name(post_id + suffix))
1730
- os.makedirs(work, exist_ok=True)
1731
-
1732
- # ---------- download scene images ----------
1733
- local_imgs = []
1734
- for i, im_url in enumerate(scene_images):
1735
- dst = os.path.join(work, f"scene_{i:02d}.jpg")
1736
- data = _fetch_bytes(im_url)
1737
- if data:
1738
- with open(dst, "wb") as f:
1739
- f.write(data)
1740
- local_imgs.append(dst)
1741
- if not local_imgs:
1742
- img = Image.new("RGB", (1080, 1920), (24, 24, 24))
1743
- img.save(os.path.join(work, "scene_00.jpg"), quality=90)
1744
- local_imgs = [os.path.join(work, "scene_00.jpg")]
1745
-
1746
- # ---------- uploaded/scraped video background ----------
1747
- video_audio_path = None
1748
- using_video_first = False # video plays as-is (video-first assembly), images fill tail
1749
- if video_url:
1750
- vid_local = os.path.join(work, "bg_video.mp4")
1751
- dl_ok = _download_to_file(video_url, vid_local)
1752
- if not dl_ok:
1753
- # ---- Short HOT oEmbed fallback: can't download the media -> save
1754
- # the post as an embeddable slide with auto title/desc/thumb from
1755
- # the scrape metadata. The feed renders it as an iframe slide,
1756
- # exactly like the football video slides on YouTube.
1757
- fb_embed = embed_url or _oembed_embed_url(_clean(str(body.get("url", "")) or post.get("url") or ""))
1758
- fb_title = embed_title or post.get("title") or ""
1759
- fb_thumb = embed_thumb or post.get("img") or ""
1760
- fb_desc = embed_desc or ""
1761
- if not fb_title or not fb_thumb or not fb_desc:
1762
- _probe2 = _oembed_probe(_clean(str(body.get("url", "")) or post.get("url") or ""))
1763
- if _probe2:
1764
- fb_embed = fb_embed or _probe2.get("embed_url") or ""
1765
- fb_title = fb_title or _probe2.get("title") or ""
1766
- fb_thumb = fb_thumb or _probe2.get("thumbnail") or ""
1767
- fb_desc = fb_desc or (_probe2.get("description") or "")
1768
- if fb_embed:
1769
- post["video"] = fb_embed
1770
- post["embed_oembed"] = True
1771
- post["title"] = fb_title or post.get("title") or "Short HOT"
1772
- post["text"] = fb_desc or post.get("text") or fb_title or ""
1773
- post["description"] = fb_desc or ""
1774
- if fb_thumb:
1775
- post["img"] = fb_thumb
1776
- post["images"] = [fb_thumb] + [x for x in (post.get("images") or []) if x != fb_thumb]
1777
- post["short_thumb"] = fb_thumb
1778
- else:
1779
- post["short_thumb"] = post.get("img")
1780
- post["short_video_url"] = video_url
1781
- post["extractor"] = "oembed"
1782
- _save_wall_posts(posts)
1783
- return JSONResponse({"video": fb_embed, "oembed": True, "post": post})
1784
- return JSONResponse({"error": "Không tải được video từ link. Link đã hết hạn hoặc nguồn chặn."}, status_code=422)
1785
- try:
1786
- vdur = _probe_duration(vid_local, fallback=6.0)
1787
- except Exception:
1788
- vdur = 6.0
1789
- if video_dur and video_dur > 0:
1790
- vdur = float(video_dur)
1791
- # extract video's own audio (used as narration track)
1792
- vaudio = os.path.join(work, "vaudio.m4a")
1793
- subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", vid_local, "-vn",
1794
- "-c:a", "aac", "-b:a", "128k", vaudio],
1795
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
1796
- if os.path.exists(vaudio) and os.path.getsize(vaudio) > 900:
1797
- video_audio_path = vaudio
1798
-
1799
- # --- NEW: video-first mode (scraped video or muted uploaded video)
1800
- # Video plays first; designed/selected images fill the rest if the
1801
- # video is shorter than the voice. No per-frame mosaic.
1802
- using_video_first = scrape_mode or video_muted
1803
- if not using_video_first:
1804
- # legacy uploaded-video mode: extract up to N frames as the
1805
- # moving-background scenes
1806
- n_frames = max(1, min(5, len(local_imgs) if len(local_imgs) >= 1 else 5))
1807
- frame_imgs = []
1808
- for i in range(n_frames):
1809
- t = vdur * (i + 0.5) / n_frames
1810
- fimg = os.path.join(work, f"vc_{i:02d}.jpg")
1811
- subprocess.run(["ffmpeg", "-y", "-v", "error", "-ss", str(t), "-i", vid_local,
1812
- "-frames:v", "1", "-q:v", "3", fimg],
1813
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60)
1814
- if os.path.exists(fimg) and os.path.getsize(fimg) > 1200:
1815
- frame_imgs.append(fimg)
1816
- if frame_imgs:
1817
- local_imgs = frame_imgs
1818
-
1819
- # ---------- narration plan ----------
1820
- reuse_audio_path = None
1821
- if reuse_audio:
1822
- prev = _find_previous_short(post_id)
1823
- if prev:
1824
- try:
1825
- prev_audio = os.path.join(work, "prev_audio.m4a")
1826
- subprocess.run(["ffmpeg", "-y", "-i", prev, "-vn", "-acodec", "aac", "-b:a", "128k", prev_audio],
1827
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
1828
- if os.path.exists(prev_audio) and os.path.getsize(prev_audio) > 900:
1829
- reuse_audio_path = prev_audio
1830
- except Exception:
1831
- reuse_audio_path = None
1832
- if video_audio_path and not reuse_audio_path and not video_muted and not using_video_first:
1833
- # uploaded/scraped video has its own audio -> use it instead of TTS
1834
- # (only in legacy mosaic mode; video-first mode keeps video audio as a
1835
- # background layer mixed with the TTS narration)
1836
- reuse_audio_path = video_audio_path
1837
- segs = slide_texts if (use_slides and slide_texts) else _segments_from_post(post)
1838
- while len(segs) < len(local_imgs):
1839
- segs.append(segs[-1] if segs else post.get("title", "Bản tin VNEWS"))
1840
-
1841
- # ---------- NEW: video-first short assembly ----------
1842
- if using_video_first:
1843
- # images for the tail: designed slides first, then post images
1844
- tail_imgs = list(local_imgs) if (local_imgs and not (use_slides and fixed_image)) else local_imgs
1845
- # if user chose a fixed image -> only that image for the tail
1846
- if fixed_image:
1847
- fimg = os.path.join(work, "fixed_tail.jpg")
1848
- data = _fetch_bytes(fixed_image)
1849
- if data:
1850
- with open(fimg, "wb") as f:
1851
- f.write(data)
1852
- tail_imgs = [fimg] if os.path.getsize(fimg) > 1200 else (tail_imgs or [])
1853
- # ensure at least the post image exists as tail
1854
- if not tail_imgs:
1855
- fallback_img = os.path.join(work, "scene_00.jpg")
1856
- if os.path.exists(fallback_img):
1857
- tail_imgs = [fallback_img]
1858
- # TTS segments: for video-first, use per-slide texts if slides,
1859
- # else the whole article as segments (each read out)
1860
- tts_segs = slide_texts if (use_slides and slide_texts) else _segments_from_post(post)
1861
- if not tts_segs:
1862
- tts_segs = [post.get("title", "Bản tin VNEWS")]
1863
- try:
1864
- out_mp4, _total = _assemble_video_short(
1865
- work, vid_local, vdur,
1866
- tail_imgs, tts_segs,
1867
- reuse_audio_path,
1868
- (video_audio_path if not video_muted else None),
1869
- voice, speed,
1870
- None, out_mp4, post.get("title", ""),
1871
- )
1872
- # music overlay handled by the shared block below
1873
- music_url = ""
1874
- if audio_url:
1875
- music_url = audio_url
1876
- elif audio_music and audio_music in MUSIC_BY_ID:
1877
- music_url = MUSIC_BY_ID[audio_music].get("url", "")
1878
- if music_url:
1879
- music_path = os.path.join(work, "music.mp3")
1880
- data = _fetch_bytes(music_url)
1881
- if data:
1882
- with open(music_path, "wb") as f:
1883
- f.write(data)
1884
- if os.path.exists(music_path) and os.path.getsize(music_path) > 900:
1885
- muxed = os.path.join(work, "muxed.mp4")
1886
- vid_dur2 = _probe_duration(out_mp4, fallback=30.0)
1887
- if _probe_has_audio(out_mp4):
1888
- fc = (
1889
- f"[1:a]volume=0.5,aloop=loop=-1:size=2e9,atrim=duration={vid_dur2}[m];"
1890
- f"[0:a][m]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]"
1891
- )
1892
- cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
1893
- "-filter_complex", fc, "-map", "0:v", "-map", "[aout]",
1894
- "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
1895
- else:
1896
- fc = f"[1:a]volume=0.55,aloop=loop=-1:size=2e9,atrim=duration={vid_dur2}[a]"
1897
- cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
1898
- "-filter_complex", fc, "-map", "0:v", "-map", "[a]",
1899
- "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
1900
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
1901
- os.replace(muxed, out_mp4)
1902
- post["video"] = out_url
1903
- post["short_thumb"] = _first_frame(out_mp4, os.path.join(WALL_IMG_DIR, _safe_name(post_id + suffix + "_thumb") + ".jpg"))
1904
- post["short_voice"] = voice
1905
- post["short_emotion"] = emotion
1906
- post["short_speed"] = speed
1907
- post["short_music"] = audio_music
1908
- post["short_audio_url"] = audio_url
1909
- post["short_use_slides"] = use_slides
1910
- post["short_images"] = scene_images
1911
- post["short_video_url"] = video_url
1912
- post["short_video_muted"] = video_muted
1913
- _save_wall_posts(posts)
1914
- return JSONResponse({"video": out_url, "voice": voice, "emotion": emotion,
1915
- "speed": speed, "music": audio_music, "use_slides": use_slides,
1916
- "post": post})
1917
- except Exception as e:
1918
- if create_new:
1919
- _load_wall_posts() # ensure fresh list
1920
- posts2 = [p for p in _load_wall_posts() if str(p.get("id")) != str(post_id)]
1921
- _save_wall_posts(posts2)
1922
- return JSONResponse({"error": "Không tạo được shorts: " + str(e)[:220]}, status_code=500)
1923
-
1924
- # ---------- frame rendering ----------
1925
- frames = []
1926
- for idx in range(len(local_imgs)):
1927
- frame = os.path.join(work, f"frame_{idx:02d}.jpg")
1928
- with open(local_imgs[idx], "rb") as f:
1929
- data = f.read()
1930
- if use_slides:
1931
- _frame_from_image(data, frame, seg_idx=idx, seg_total=len(local_imgs))
1932
- else:
1933
- _frame_with_text(data, frame, segs[idx], post.get("title", ""), seg_idx=idx, seg_total=len(local_imgs))
1934
- frames.append(frame)
1935
-
1936
- part_files = []
1937
- try:
1938
- if reuse_audio_path:
1939
- # ---------- reuse previous short audio ----------
1940
- total_dur = _probe_duration(reuse_audio_path, fallback=30.0)
1941
- per = max(2.2, min(8.0, total_dur / max(1, len(frames))))
1942
- for idx, frame in enumerate(frames):
1943
- part = os.path.join(work, f"part_{idx:02d}.mp4")
1944
- subprocess.run(
1945
- ["ffmpeg", "-y", "-loop", "1", "-t", str(per), "-i", frame,
1946
- "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p", "-an", part],
1947
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
1948
- part_files.append(part)
1949
- concat = os.path.join(work, "concat.txt")
1950
- with open(concat, "w", encoding="utf-8") as f:
1951
- for p in part_files:
1952
- f.write("file '" + p.replace("'", "'\\''") + "'\n")
1953
- subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat, "-c", "copy", out_mp4],
1954
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
1955
- # mux previous audio (trim to video length)
1956
- vid_dur = _probe_duration(out_mp4, fallback=total_dur)
1957
- with_audio = os.path.join(work, "with_audio.mp4")
1958
- subprocess.run(
1959
- ["ffmpeg", "-y", "-i", out_mp4, "-i", reuse_audio_path,
1960
- "-filter_complex", f"[1:a]atrim=duration={vid_dur}[a]",
1961
- "-map", "0:v", "-map", "[a]", "-c:v", "copy", "-c:a", "aac",
1962
- "-b:a", "128k", "-shortest", with_audio],
1963
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
1964
- os.replace(with_audio, out_mp4)
1965
- else:
1966
- # ---------- fresh TTS narration ----------
1967
- edge_voice = {
1968
- "vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural", "vi-vn-namminhneural": "vi-VN-NamMinhNeural",
1969
- "hoaimy": "vi-VN-HoaiMyNeural", "namminh": "vi-VN-NamMinhNeural", "nam": "vi-VN-NamMinhNeural",
1970
- "male": "vi-VN-NamMinhNeural", "nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural",
1971
- "mien-nam": "vi-VN-HoaiMyNeural",
1972
- "en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
1973
- "en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
1974
- "andrew": "en-US-AndrewMultilingualNeural", "en_andrew": "en-US-AndrewMultilingualNeural",
1975
- "jenny": "en-US-AndrewMultilingualNeural", "en_jenny": "en-US-AndrewMultilingualNeural",
1976
- "pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
1977
- "thalita": "pt-BR-ThalitaMultilingualNeural", "pt": "pt-BR-ThalitaMultilingualNeural",
1978
- "fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
1979
- "fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
1980
- "denise": "fr-FR-VivienneMultilingualNeural", "fr": "fr-FR-VivienneMultilingualNeural",
1981
- "de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
1982
- "de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
1983
- "katja": "de-DE-SeraphinaMultilingualNeural", "de": "de-DE-SeraphinaMultilingualNeural",
1984
- "ko-kr-hyusumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
1985
- "ko-kr-hyunsuneural": "ko-KR-HyunsuMultilingualNeural", "sunhee": "ko-KR-HyunsuMultilingualNeural",
1986
- "ko": "ko-KR-HyunsuMultilingualNeural",
1987
- "it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
1988
- "ela": "en-US-AndrewMultilingualNeural", "es": "en-US-AndrewMultilingualNeural",
1989
- }.get(voice.lower(), voice)
1990
- for idx, frame in enumerate(frames):
1991
- aud = os.path.join(work, f"voice_{idx:02d}.mp3")
1992
- aud_fast = os.path.join(work, f"voice_{idx:02d}_fast.mp3")
1993
- part = os.path.join(work, f"part_{idx:02d}.mp4")
1994
- spoken = segs[idx] if idx < len(segs) else post.get("title", "Bản tin VNEWS")
1995
- try:
1996
- subprocess.run(
1997
- ["python", "-m", "edge_tts", "--voice", edge_voice, "--text", spoken,
1998
- "--write-media", aud],
1999
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
2000
- except Exception:
2001
- aud = None
2002
- try:
2003
- from gtts import gTTS
2004
- tld = "com.vn" if voice.lower() in ("nu", "female", "mien-nam", "hoaimy") else "com"
2005
- try:
2006
- gTTS(spoken, lang="vi", tld=tld, slow=False).save(aud)
2007
- except TypeError:
2008
- gTTS(spoken, lang="vi", slow=False).save(aud)
2009
- except Exception:
2010
- pass
2011
- if aud and os.path.exists(aud) and os.path.getsize(aud) > 900:
2012
- subprocess.run(["ffmpeg", "-y", "-i", aud, "-filter:a", f"atempo={speed}", "-vn", aud_fast],
2013
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
2014
- dur = _probe_duration(aud_fast, fallback=15.0) + 0.35
2015
- subprocess.run(
2016
- ["ffmpeg", "-y", "-loop", "1", "-t", str(dur), "-i", frame, "-i", aud_fast,
2017
- "-shortest", "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
2018
- "-c:a", "aac", "-b:a", "128k", part],
2019
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
2020
- else:
2021
- subprocess.run(
2022
- ["ffmpeg", "-y", "-loop", "1", "-t", "4", "-i", frame,
2023
- "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p", "-an", part],
2024
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
2025
- part_files.append(part)
2026
- concat = os.path.join(work, "concat.txt")
2027
- with open(concat, "w", encoding="utf-8") as f:
2028
- for p in part_files:
2029
- f.write("file '" + p.replace("'", "'\\''") + "'\n")
2030
- subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat, "-c", "copy", out_mp4],
2031
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
2032
-
2033
- # ---------- background music overlay ----------
2034
- music_url = ""
2035
- if audio_url:
2036
- music_url = audio_url
2037
- elif audio_music and audio_music in MUSIC_BY_ID:
2038
- music_url = MUSIC_BY_ID[audio_music].get("url", "")
2039
- if music_url:
2040
- music_path = os.path.join(work, "music.mp3")
2041
- data = _fetch_bytes(music_url)
2042
- if data:
2043
- with open(music_path, "wb") as f:
2044
- f.write(data)
2045
- if os.path.exists(music_path) and os.path.getsize(music_path) > 900:
2046
- muxed = os.path.join(work, "muxed.mp4")
2047
- vid_dur = _probe_duration(out_mp4, fallback=30.0)
2048
- if _probe_has_audio(out_mp4):
2049
- # narration at full volume + music at 0.5, no amix downscale (normalize=0)
2050
- fc = (
2051
- f"[1:a]volume=0.5,aloop=loop=-1:size=2e9,atrim=duration={vid_dur}[m];"
2052
- f"[0:a][m]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]"
2053
- )
2054
- cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
2055
- "-filter_complex", fc, "-map", "0:v", "-map", "[aout]",
2056
- "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
2057
- else:
2058
- fc = f"[1:a]volume=0.55,aloop=loop=-1:size=2e9,atrim=duration={vid_dur}[a]"
2059
- cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
2060
- "-filter_complex", fc, "-map", "0:v", "-map", "[a]",
2061
- "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
2062
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
2063
- os.replace(muxed, out_mp4)
2064
-
2065
- except Exception as e:
2066
- if create_new:
2067
- posts2 = [p for p in _load_wall_posts() if str(p.get("id")) != str(post_id)]
2068
- _save_wall_posts(posts2)
2069
- return JSONResponse({"error": "Không tạo được shorts: " + str(e)[:220]}, status_code=500)
2070
-
2071
- post["video"] = out_url
2072
- post["short_thumb"] = _first_frame(out_mp4, os.path.join(WALL_IMG_DIR, _safe_name(post_id + suffix + "_thumb") + ".jpg"))
2073
- post["short_voice"] = voice
2074
- post["short_emotion"] = emotion
2075
- post["short_speed"] = speed
2076
- post["short_music"] = audio_music
2077
- post["short_audio_url"] = audio_url
2078
- post["short_use_slides"] = use_slides
2079
- post["short_images"] = scene_images
2080
-
2081
- return JSONResponse({"video": out_url, "voice": voice, "emotion": emotion,
2082
- "speed": speed, "music": audio_music, "use_slides": use_slides,
2083
- "post": post})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_v2.js DELETED
The diff for this file is too large to render. See raw diff
 
app_v2_entry.py CHANGED
@@ -12,18 +12,7 @@ try:
12
  except Exception as e:
13
  print(f"[WARN] ai_patch import failed: {e}")
14
 
15
- try:
16
- import ai_short_v2 # Short AI v2: TikTok music, uploaded audio/video/img, recreate from slides
17
- except Exception as e:
18
- print(f"[WARN] ai_short_v2 import failed: {e}")
19
-
20
- try:
21
- import pid_gen
22
- pid_gen.patch_app(app)
23
- except Exception as e:
24
- print(f"[WARN] pid_gen import failed: {e}")
25
-
26
- from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response, StreamingResponse
27
  from fastapi.staticfiles import StaticFiles
28
  from starlette.routing import Mount
29
  from fastapi import Query, Request, UploadFile, File, Form
@@ -31,7 +20,7 @@ import requests as req
31
  from bs4 import BeautifulSoup
32
  import re, html as html_lib, json, threading, time, uuid
33
  from concurrent.futures import ThreadPoolExecutor, as_completed
34
- from urllib.parse import quote, urljoin
35
  import asyncio
36
 
37
  HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
@@ -486,39 +475,24 @@ def api_article_v2(url: str = Query(...)):
486
  return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
487
 
488
  _hot_cache={'t':0,'d':[]}
489
- def _parse_feed(feed_url):
490
- try:
491
- r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=4)
492
- r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
493
- out=[]
494
- for item in soup.find_all('item')[:12]:
495
- t=item.find('title')
496
- title=_clean(t.get_text() if t else '')
497
- if title: out.append(title)
498
- return out
499
- except Exception:
500
- return []
501
  def _get_hot_topics():
502
  now=time.time()
503
  if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
504
- feeds=['https://vnexpress.net/rss/tin-moi-nhat.rss','https://dantri.com.vn/rss/home.rss','https://vietnamnet.vn/rss/tin-moi-nhat.rss','https://thanhnien.vn/rss/home.rss','https://tuoitre.vn/rss/tin-moi-nhat.rss','https://genk.vn/rss','https://vnexpress.net/rss/the-thao.rss','https://thethaovanhoa.vn/rss/tin-nong.rss']
505
- titles=[]
506
- with ThreadPoolExecutor(8) as ex:
507
- futs=[ex.submit(_parse_feed,f) for f in feeds]
508
- for f in as_completed(futs,timeout=5):
509
- try:
510
- for t in f.result(): titles.append(t)
511
- except Exception: pass
512
  freq={};display={}
513
- for title in titles:
514
- if not title: continue
515
- title=re.sub(r'\s*[-|].*$','',title)
516
- words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in _STOP]
517
- if len(words)<2:continue
518
- for n in(3,4,2):
519
- for i in range(max(0,len(words)-n+1)):
520
- phrase=' '.join(words[i:i+n])
521
- if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
 
 
 
 
 
522
  ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
523
  for key,count in ranked:
524
  is_dup=any(len(set(e.split())&set(key.split()))/max(len(set(e.split())),len(set(key.split())),1)>0.6 for e in seen)
@@ -545,41 +519,17 @@ def _ht(topic:str=Query(...),page:int=Query(default=0)):
545
  items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
546
  return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
547
  @app.get('/api/categories')
548
- def _cat():
549
- try:
550
- from main import VNE_CATS
551
- cats=[{"id":k,"name":n,"source":"vne"} for k,(u,n) in VNE_CATS.items()]
552
- except Exception:
553
- cats = [{"id":"the-thao","name":"Thể Thao","source":"vne"}]
554
- # Prepend the M3U (live sports streams) category at the front of the bar
555
- cats.insert(0, {"id":"m3u","name":"⚽ Trực Tiếp","source":"m3u"})
556
- resp=JSONResponse(cats)
557
- resp.headers["Cache-Control"]="public, max-age=600"
558
- return resp
559
  @app.get('/api/storage_status')
560
  def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
561
  # ===== SHARE HELPERS: render content pages for shared links =====
562
- # Default image + description used when a shared link has no image/description
563
- # (e.g. M3U channel share links built from the player).
564
- _M3U_SHARE_DESC = "Xem trực tiếp bóng đá và sự kiện thể thao playlist m3u với trình xem Tiktok player!"
565
- _M3U_SHARE_IMG = f"{SPACE}/static/tv_default.png"
566
-
567
- def _m3u_og_parts(safe_img, safe_title):
568
- """Return (og_image, description) for share pages. If no image given, use a
569
- default TV icon so shared links always show an og:image preview."""
570
- img = _clean(safe_img) if safe_img else _M3U_SHARE_IMG
571
- desc = _M3U_SHARE_DESC if not safe_img else _clean(safe_title or '')
572
- return img, desc
573
-
574
  def _render_slides_page(post, safe_title, safe_img, safe_url):
575
  slides = post.get('slides', [])
576
  # Get image from post.img or first slide's image
577
  if not safe_img and slides and slides[0].get('image'):
578
  safe_img = slides[0].get('image', '')
579
- og_img, description = _m3u_og_parts(safe_img, safe_title)
580
  # Use text for description if available
581
- if safe_img:
582
- description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
583
 
584
  # Build canonical URL preserving original query format if url was provided
585
  if safe_url and safe_url != '/':
@@ -594,7 +544,7 @@ def _render_slides_page(post, safe_title, safe_img, safe_url):
594
  <meta name="viewport" content="width=device-width,initial-scale=1">
595
  <title>{_clean(safe_title)}</title>
596
  <meta property="og:title" content="{_clean(safe_title)}">
597
- <meta property="og:image" content="{og_img}">
598
  <meta property="og:description" content="{description}">
599
  <meta property="og:url" content="{canonical_url}">
600
  <link rel="canonical" href="{canonical_url}">
@@ -619,9 +569,8 @@ def _render_slides_page(post, safe_title, safe_img, safe_url):
619
 
620
  def _render_video_page(post, safe_title, safe_img, safe_url):
621
  video_url = post.get('video', '')
622
- og_img, _desc = _m3u_og_parts(safe_img, safe_title)
623
  # Use text for description if available
624
- description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026" if safe_img else _M3U_SHARE_DESC
625
 
626
  # Build canonical URL preserving original query format if url was provided
627
  if safe_url and safe_url != '/':
@@ -636,7 +585,7 @@ def _render_video_page(post, safe_title, safe_img, safe_url):
636
  <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
637
  <title>{_clean(safe_title)}</title>
638
  <meta property="og:title" content="{_clean(safe_title)}">
639
- <meta property="og:image" content="{og_img}">
640
  <meta property="og:description" content="{description}">
641
  <meta property="og:url" content="{canonical_url}">
642
  <link rel="canonical" href="{canonical_url}">
@@ -689,7 +638,6 @@ async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '',
689
  return _render_video_page(post, safe_title, safe_img, safe_url)
690
 
691
  # Otherwise redirect
692
- _fimg, _fdesc = _m3u_og_parts(safe_img, safe_title)
693
  return HTMLResponse(f'''<!DOCTYPE html>
694
  <html lang="vi">
695
  <head>
@@ -697,8 +645,8 @@ async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '',
697
  <meta name="viewport" content="width=device-width,initial-scale=1">
698
  <title>{_clean(safe_title)}</title>
699
  <meta property="og:title" content="{_clean(safe_title)}">
700
- <meta property="og:image" content="{_fimg}">
701
- <meta property="og:description" content="{_fdesc}">
702
  <meta property="og:url" content="{SPACE}/s/{slug}">
703
  <link rel="canonical" href="{SPACE}/s/{slug}">
704
  <meta http-equiv="refresh" content="0;url={safe_url}">
@@ -750,7 +698,6 @@ async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
750
  return _render_video_page(post, safe_title, safe_img, safe_url)
751
 
752
  # Fallback: redirect to original URL
753
- _fimg, _fdesc = _m3u_og_parts(safe_img, safe_title)
754
  return HTMLResponse(f'''<!DOCTYPE html>
755
  <html lang="vi">
756
  <head>
@@ -758,8 +705,8 @@ async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
758
  <meta name="viewport" content="width=device-width,initial-scale=1">
759
  <title>{safe_title}</title>
760
  <meta property="og:title" content="{safe_title}">
761
- <meta property="og:image" content="{_fimg}">
762
- <meta property="og:description" content="{_fdesc}">
763
  <meta property="og:url" content="{SPACE}/s?url={quote(safe_url)}">
764
  <link rel="canonical" href="{SPACE}/s?url={quote(safe_url)}">
765
  <meta http-equiv="refresh" content="0;url={safe_url}">
@@ -906,303 +853,15 @@ async def _pc(request:Request):
906
 
907
  def _load_wall_posts():
908
  with _wl_lock:
909
- posts = _lj(WALL_FILE)
910
- if not isinstance(posts, list):
911
- posts = []
912
- return posts
913
 
914
  def _save_wall_posts(posts):
915
  with _wl_lock:
916
  _sj(WALL_FILE, posts)
917
 
918
- def _post_has_valid_image(p):
919
- """A wall post is considered to have a 'valid' (non-placeholder) image if
920
- at least one of the following is a real, non-placeholder URL:
921
- - p.img (main thumbnail)
922
- - p.short_thumb (generated by Short AI / oEmbed fallback)
923
- - p.images[] (fallback image list)
924
- - p.slides[].image (designed slide images)
925
-
926
- Auto posts that failed metadata enrichment — e.g. pollinations.ai
927
- AI-generated placeholder images, vnexpress logo placeholders, or empty/None
928
- fields — are flagged as having NO valid image so they can be filtered out
929
- of the wall (they show the wrong picture vs the original article)."""
930
- PLACEHOLDER_MARKERS = (
931
- "pollinations.ai", # AI-generated editorial illustration placeholder
932
- "logo_default.jpg", # vnexpress placeholder logo
933
- "logo.png",
934
- "/logo",
935
- "placeholder",
936
- "no-image",
937
- "blank",
938
- )
939
-
940
- def _is_placeholder(url):
941
- if not url or not isinstance(url, str):
942
- return True
943
- u = url.strip().lower()
944
- if not u or u.startswith(("about:blank", "data:")):
945
- return True
946
- return any(m in u for m in PLACEHOLDER_MARKERS)
947
-
948
- def _is_real(url):
949
- return bool(url) and not _is_placeholder(url)
950
-
951
- img = p.get("img")
952
- if _is_real(img):
953
- return True
954
- thumb = p.get("short_thumb")
955
- if _is_real(thumb):
956
- return True
957
- images = p.get("images") or []
958
- if any(_is_real(x) for x in images):
959
- return True
960
- slides = p.get("slides") or []
961
- if any(_is_real(s.get("image")) for s in slides if isinstance(s, dict)):
962
- return True
963
- return False
964
-
965
- def _post_has_slide_info(p):
966
- """True if the post has usable slide/rewrite AI content:
967
- - designed slides with text+image, OR
968
- - a generated short video (/api/ai/short-file/) with a real thumbnail, OR
969
- - an oEmbed iframe (YouTube/TikTok/Facebook embed) with a real thumbnail
970
- (Short HOT fallback case — embeddable, viewable as a slide)."""
971
- slides = p.get("slides") or []
972
- if any((s.get("image") or "").strip() and (s.get("text") or "").strip() for s in slides if isinstance(s, dict)):
973
- return True
974
- # Generated short video with a real thumbnail
975
- vid = (p.get("video") or "").strip()
976
- if vid and "api/ai/short-file/" in vid and (p.get("short_thumb") or "").strip():
977
- return True
978
- # oEmbed iframe (YouTube/TikTok/FB) with a valid image -> viewable embed slide
979
- if vid and ("youtube.com/embed" in vid or "tiktok.com/embed" in vid or
980
- "facebook.com/plugins" in vid or "instagram.com" in vid or
981
- "twitter.com" in vid or "x.com" in vid or "player.vimeo" in vid):
982
- if _post_has_valid_image(p):
983
- return True
984
- return False
985
-
986
- def _is_auto_post(p):
987
- """Auto-generated wall posts: Short HOT, oEmbed fallback, FPT auto,
988
- slide-rewrite AI (auto_rewrite), topic auto-summary (topic_article), or
989
- posts with no meaningful source / created by the AI pipeline."""
990
- kind = (p.get("kind") or "").strip().lower()
991
- source = (p.get("source") or "").strip().lower()
992
- return kind in ("hot_short", "slide_summary", "auto_rewrite", "topic_article", "summary") or \
993
- source in ("hot_short", "fptplay", "ai", "auto_rewrite", "topic_article")
994
-
995
- def _post_has_content(p):
996
- """True when the post has meaningful content: non-empty text (>= 20 chars)
997
- OR at least one slide with non-empty text OR a video. Rewrite posts that
998
- failed to generate content (empty text+slides) get dropped from the feed."""
999
- if not isinstance(p, dict):
1000
- return False
1001
- text = (p.get("text") or "").strip()
1002
- if len(text) >= 20:
1003
- return True
1004
- slides = p.get("slides") or []
1005
- if len(slides) >= 1:
1006
- for s in slides:
1007
- if isinstance(s, dict) and len((s.get("text") or "").strip()) >= 8:
1008
- return True
1009
- if (p.get("video") or "").strip():
1010
- return True
1011
- return False
1012
-
1013
- def _created_ts(p):
1014
- """Best-effort creation timestamp NORMALIZED TO SECONDS. `created` is
1015
- stored as Unix milliseconds by some paths while `ts` is Unix seconds —
1016
- normalize both so newest-first ordering mixes them correctly. Falls back
1017
- to id-derived Unix ms (converted to seconds), then 0."""
1018
- for field in ("created", "ts"):
1019
- v = p.get(field)
1020
- if v is not None:
1021
- try:
1022
- v = int(v)
1023
- except (ValueError, TypeError):
1024
- pass
1025
- if v > 100000000000: # milliseconds -> seconds
1026
- v = v // 1000
1027
- return v
1028
- # try to derive from id (numeric prefix = Unix ms timestamp)
1029
- pid = str(p.get("id", "")).strip()
1030
- if pid.isdigit() and len(pid) >= 12:
1031
- try:
1032
- v = int(pid)
1033
- if v > 100000000000:
1034
- v = v // 1000
1035
- return v
1036
- except (ValueError, TypeError):
1037
- pass
1038
- return 0
1039
-
1040
- def _wall_posts_for_view():
1041
- """Apply the user-facing wall filtering + sorting (VIEW-ONLY — never
1042
- touches the persistent store):
1043
- 1) Drop posts with NO meaningful content (empty text AND empty slides
1044
- AND no video) — 'bài đăng rewrite fără nội dung';
1045
- 2) Hide auto-generated posts that have a wrong/placeholder image (no
1046
- valid image at all — pollinations.ai, vnexpress logo, empty) or have
1047
- no slide-AI content (no slides / no short-video / no embed);
1048
- 3) Sort newest-first by creation timestamp, mixing all sources."""
1049
- try:
1050
- from ai_ext import wall_posts_for_view as _shared_view
1051
- return _shared_view(limit=200)
1052
- except Exception:
1053
- pass
1054
- posts = _load_wall_posts()
1055
- if not posts:
1056
- return []
1057
- filtered = []
1058
- for p in posts:
1059
- if not isinstance(p, dict):
1060
- continue
1061
- if not _post_has_content(p):
1062
- continue
1063
- if _is_auto_post(p) and not _post_has_valid_image(p) and not _post_has_slide_info(p):
1064
- # auto post with a wrong/placeholder/missing image AND no slide AI
1065
- # content -> hide from the wall (non-destructive)
1066
- continue
1067
- filtered.append(p)
1068
- filtered.sort(key=_created_ts, reverse=True)
1069
- return filtered
1070
-
1071
- @app.get("/api/ai/wall")
1072
- def api_ai_wall():
1073
- return JSONResponse({"posts": _wall_posts_for_view()})
1074
-
1075
-
1076
- # ===== YOUTUBE RSS FEED PROXY (avoids browser CORS) =====
1077
- _YT_FEED_CACHE = {}
1078
- _YT_FEED_TTL = 600 # 10 minutes
1079
-
1080
-
1081
- def _fetch_youtube_rss_feed(channel_id: str = "UC4LvrpNXujjbGOS4RDvr41g"):
1082
- """Fetch & parse the YouTube RSS feed server-side.
1083
-
1084
- The browser cannot reach youtube.com directly (CORS / network block).
1085
- We try two sources in order:
1086
- 1) Direct feed XML (works if the server has outbound YouTube access)
1087
- 2) rss2json.com proxy (always works — it fetches the feed server-side)
1088
- Cached for _YT_FEED_TTL seconds."""
1089
- now = time.time()
1090
- cached = _YT_FEED_CACHE.get(channel_id)
1091
- if cached and now - cached.get('_ts', 0) < _YT_FEED_TTL:
1092
- return cached.get('videos', [])
1093
- out = []
1094
- try:
1095
- # Attempt 1: direct YouTube RSS
1096
- feed_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
1097
- try:
1098
- r = req.get(feed_url, headers=HEADERS, timeout=10)
1099
- if r.status_code == 200 and r.text.strip():
1100
- soup = BeautifulSoup(r.text, 'xml')
1101
- for e in soup.find_all('entry'):
1102
- out.extend(_parse_yt_entry(e))
1103
- except Exception as ex2:
1104
- print(f"[yt-feed] direct fetch failed, trying rss2json: {ex2}")
1105
- # Attempt 2: rss2json proxy (fallback / always)
1106
- if not out:
1107
- rss2json_url = f"https://api.rss2json.com/v1/api.json?rss_url={feed_url}"
1108
- r2 = req.get(rss2json_url, headers=HEADERS, timeout=15)
1109
- if r2.status_code == 200:
1110
- data = r2.json()
1111
- if data.get('status') == 'ok':
1112
- for item in data.get('items', []):
1113
- vid_m = re.search(r'([a-zA-Z0-9_-]{11})', item.get('id', ''))
1114
- if not vid_m:
1115
- continue
1116
- vid = vid_m.group(1)
1117
- title = item.get('title', 'Video') or 'Video'
1118
- # prefer the youtube watch URL, fallback to item link
1119
- link = item.get('link', '')
1120
- if '/watch' not in link:
1121
- link = f'https://www.youtube.com/watch?v={vid}'
1122
- # thumbnail: prefer media:thumbnail, fallback to youtube hqdefault
1123
- thumb = item.get('thumbnail', f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg')
1124
- published = item.get('pubDate', '')
1125
- out.append({
1126
- 'id': 'yt-' + vid,
1127
- 'videoId': vid,
1128
- 'title': title.strip()[:200],
1129
- 'link': link,
1130
- 'img': thumb,
1131
- 'published': published,
1132
- 'source': 'yt-feed'
1133
- })
1134
- if out:
1135
- _YT_FEED_CACHE[channel_id] = {'_ts': now, 'videos': out}
1136
- return out
1137
- except Exception as ex:
1138
- print(f"[yt-feed] server error: {ex}")
1139
- return []
1140
-
1141
-
1142
- def _parse_yt_entry(e):
1143
- """Parse a single YouTube RSS <entry> element into a video dict."""
1144
- out = []
1145
- try:
1146
- id_el = e.find('id')
1147
- if not id_el:
1148
- return out
1149
- vid_m = re.search(r'([a-zA-Z0-9_-]{11})', id_el.get_text(''))
1150
- if not vid_m:
1151
- return out
1152
- vid = vid_m.group(1)
1153
- title_el = e.find('title')
1154
- title = title_el.get_text('') if title_el else 'Video'
1155
- link_el = e.find('link', {'rel': 'alternate'})
1156
- link = link_el.get('href') if link_el else f'https://www.youtube.com/watch?v={vid}'
1157
- pub_el = e.find('published')
1158
- published = pub_el.get_text('') if pub_el else ''
1159
- ns = {'media': 'http://search.yahoo.com/mrss/'}
1160
- thumb_el = e.find('media:thumbnail', ns)
1161
- thumb_url = thumb_el.get('url') if thumb_el else f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg'
1162
- out.append({
1163
- 'id': 'yt-' + vid,
1164
- 'videoId': vid,
1165
- 'title': title.strip()[:200],
1166
- 'link': link,
1167
- 'img': thumb_url,
1168
- 'published': published,
1169
- 'source': 'yt-feed'
1170
- })
1171
- except Exception as ex:
1172
- print(f"[yt-feed] parse entry error: {ex}")
1173
- return out
1174
-
1175
-
1176
- @app.get("/api/yt/feed")
1177
- def api_yt_feed(channel_id: str = Query(default="UC4LvrpNXujjbGOS4RDvr41g")):
1178
- """Server-side YouTube RSS feed fetcher — avoids browser CORS.
1179
- Returns parsed feed videos as JSON for the Tường AI wall."""
1180
- videos = _fetch_youtube_rss_feed(channel_id)
1181
- return JSONResponse({"videos": videos, "count": len(videos)})
1182
- @app.post("/api/ai/short/{post_id}")
1183
- def api_ai_short(post_id: str):
1184
- """Generate a short video for a wall post.
1185
- Returns clear JSON error when video not available yet.
1186
- """
1187
- post_id_s = str(post_id)
1188
- posts = _load_wall_posts()
1189
- if not isinstance(posts, list):
1190
- posts = []
1191
- post = None
1192
- for p in posts:
1193
- pid = str(p.get("id", ""))
1194
- if pid == post_id_s or pid.startswith(post_id_s):
1195
- post = p
1196
- break
1197
- if not post:
1198
- return JSONResponse({"error": "Không tìm thấy bài viết", "post_id": post_id}, status_code=404)
1199
- if post.get("video"):
1200
- return JSONResponse({"video": post["video"], "post": post})
1201
- return JSONResponse({"error": "Chưa có video cho bài này. Vui lòng upload video trước."}, status_code=409)
1202
-
1203
  @app.get('/api/wall')
1204
  def api_wall():
1205
- posts = _wall_posts_for_view()
1206
  if not posts:
1207
  return JSONResponse({"posts": []})
1208
  return JSONResponse({"posts": posts})
@@ -1270,66 +929,22 @@ async def api_wall_post(request: Request):
1270
  text = body.get('text', '') or ''
1271
  img = body.get('img', None)
1272
  source = body.get('source', 'user') or 'user'
1273
- incoming_id = body.get('id') or '' # allow client to (re)publish an existing post by id
1274
  post_id = str(uuid.uuid4())[:12]
1275
- # Preserve slide-design post fields (slides, kind, url, images, video, voice, etc.)
1276
- # so that "Thiết kế ảnh" -> "Đăng lên Tường AI" keeps the selected slides.
1277
- slides = body.get('slides')
1278
- kind = body.get('kind') or 'user'
1279
- post_url = body.get('url') or ''
1280
- images = body.get('images') or []
1281
- video = body.get('video')
1282
- voice = body.get('voice') or ''
1283
- emotion = body.get('emotion') or ''
1284
- language = body.get('language') or ''
1285
  post = {
1286
  "id": post_id,
1287
  "title": title[:200],
1288
  "text": text[:2000],
1289
  "source": source,
1290
- "video": video,
1291
  "img": img,
1292
- "images": images[:10],
1293
- "url": post_url,
1294
- "kind": kind,
1295
- "slides": slides if slides is not None else None,
1296
- "voice": voice,
1297
- "emotion": emotion,
1298
- "language": language,
1299
  "created": int(time.time()),
1300
  "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
1301
  }
1302
- # Remove keys with None so the JSON stays lean (but keep structure)
1303
- post = {k: v for k, v in post.items() if v is not None}
1304
  posts = _load_wall_posts()
1305
  if not isinstance(posts, list):
1306
  posts = []
1307
- # If the client re-publishes (e.g. designer "Lưu & Đăng lên Tường AI" sends
1308
- # the original post id), update that post in place instead of creating a
1309
- # duplicate. This keeps the homepage wall clean (no duplicate slide posts).
1310
- updated = False
1311
- if incoming_id:
1312
- for _p in posts:
1313
- if str(_p.get('id')) == str(incoming_id):
1314
- # Merge new fields into the existing post, but PRESERVE the
1315
- # original id (and created timestamp) so the post identity is
1316
- # stable and no duplicate is created.
1317
- preserve_id = _p.get('id')
1318
- preserve_created = _p.get('created')
1319
- preserve_created_str = _p.get('created_str')
1320
- _p.update(post)
1321
- _p['id'] = preserve_id
1322
- if preserve_created is not None: _p['created'] = preserve_created
1323
- if preserve_created_str is not None: _p['created_str'] = preserve_created_str
1324
- updated = True
1325
- post = _p # return the merged post (with stable id) to the client
1326
- break
1327
- if not updated:
1328
- posts.insert(0, post)
1329
- else:
1330
- # Move the updated post to the top so it re-appears at the top of the wall.
1331
- posts = [p for p in posts if str(p.get('id')) != str(incoming_id)]
1332
- posts.insert(0, post)
1333
  posts = posts[:200]
1334
  _save_wall_posts(posts)
1335
  return JSONResponse({"post": post, "ok": True})
@@ -1345,57 +960,6 @@ def api_wall_video(filename: str):
1345
  media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
1346
  return FileResponse(video_path, media_type=media_type)
1347
 
1348
- WALL_IMG_DIR = os.path.join(DATA_DIR, 'wall_imgs')
1349
- os.makedirs(WALL_IMG_DIR, exist_ok=True)
1350
-
1351
- @app.post('/api/wall/img')
1352
- async def api_wall_img(request: Request):
1353
- """Upload a designed slide image (PNG) and return a served URL."""
1354
- global WALL_IMG_DIR
1355
- try:
1356
- form = await request.form()
1357
- f = form.get('file')
1358
- if not f or not hasattr(f, 'filename') or not f.filename:
1359
- return JSONResponse({"error": "Thiếu file ảnh"}, status_code=400)
1360
- ext = os.path.splitext(f.filename)[1].lower()
1361
- if ext not in ('.png', '.jpg', '.jpeg', '.webp'):
1362
- ext = '.png'
1363
- img_id = str(uuid.uuid4())[:12]
1364
- fname = f"wallimg_{img_id}{ext}"
1365
- fpath = os.path.join(WALL_IMG_DIR, fname)
1366
- content = await f.read()
1367
- if not content:
1368
- return JSONResponse({"error": "File rỗng"}, status_code=400)
1369
- if len(content) > 15 * 1024 * 1024:
1370
- return JSONResponse({"error": "Ảnh quá lớn (>15MB)"}, status_code=400)
1371
- with open(fpath, 'wb') as fh:
1372
- fh.write(content)
1373
- post_id = (form.get('post_id') or '').strip()
1374
- if post_id:
1375
- posts = _load_wall_posts()
1376
- if isinstance(posts, list):
1377
- for p in posts:
1378
- if str(p.get('id')) == str(post_id):
1379
- p['img'] = f"/api/wall/img/{fname}"
1380
- break
1381
- _save_wall_posts(posts)
1382
- url = f"/api/wall/img/{fname}"
1383
- return JSONResponse({"ok": True, "url": url, "img_id": img_id})
1384
- except Exception as e:
1385
- return JSONResponse({"error": f"Lỗi upload ảnh: {str(e)[:150]}"}, status_code=500)
1386
-
1387
-
1388
- @app.get('/api/wall/img/{fname}')
1389
- def api_wall_img_file(fname: str):
1390
- if '..' in fname or '/' in fname:
1391
- return Response(status_code=403)
1392
- img_path = os.path.join(WALL_IMG_DIR, fname)
1393
- if not os.path.exists(img_path):
1394
- return Response(status_code=404)
1395
- ext = os.path.splitext(fname)[1].lower()
1396
- media_type = 'image/png' if ext == '.png' else ('image/jpeg' if ext in ('.jpg', '.jpeg') else 'image/webp')
1397
- return FileResponse(img_path, media_type=media_type)
1398
-
1399
  @app.delete('/api/wall/{post_id}')
1400
  def api_wall_delete(post_id: str):
1401
  posts = _load_wall_posts()
@@ -1413,41 +977,6 @@ def api_wall_delete(post_id: str):
1413
  return JSONResponse({"ok": True})
1414
  return JSONResponse({"error": "Post not found"}, status_code=404)
1415
 
1416
- @app.post('/api/wall/cleanup')
1417
- def api_wall_cleanup():
1418
- """Permanently remove auto-generated wall posts that are completely
1419
- unrecoverable (no valid image, no video, no slides, no embed — i.e. a
1420
- post that can never be displayed), and sort the remaining posts
1421
- newest-first. Returns the number of removed posts and the new count.
1422
-
1423
- NOTE: Conservative — auto posts that HAVE a valid image or a video/embed
1424
- are preserved (they remain visible at view time via
1425
- _wall_posts_for_view). Only use this to purge genuine garbage."""
1426
- posts = _load_wall_posts()
1427
- if not isinstance(posts, list):
1428
- return JSONResponse({"error": "No posts"}, status_code=404)
1429
- before = len(posts)
1430
- kept = []
1431
- removed = []
1432
- for p in posts:
1433
- if not isinstance(p, dict):
1434
- removed.append(str(p))
1435
- continue
1436
- if _is_auto_post(p) and not _post_has_valid_image(p) and not _post_has_slide_info(p) and not (p.get("video") or "").strip():
1437
- removed.append(p.get('id', '?'))
1438
- else:
1439
- kept.append(p)
1440
- # sort newest-first
1441
- kept.sort(key=_created_ts, reverse=True)
1442
- _save_wall_posts(kept)
1443
- return JSONResponse({
1444
- "ok": True,
1445
- "before": before,
1446
- "removed_count": before - len(kept),
1447
- "removed_ids": removed,
1448
- "after": len(kept),
1449
- })
1450
-
1451
  # ===== LANGUAGE & EMOTION DETECTION =====
1452
  import random as _random2
1453
  from urllib.parse import quote as _quote2
@@ -2011,12 +1540,17 @@ async def api_personal_post_preview(request: Request):
2011
 
2012
  # Lấy keywords từ QUAN ĐIỂM CÁ NHÂN để tìm nguồn tin chính xác
2013
  keywords = _extract_keywords_from_opinion(opinion, max_keywords=5)
 
 
 
2014
  if keywords:
 
2015
  selected_topics = keywords[:3]
 
 
 
2016
  else:
2017
- # Fallback: hot topics
2018
- hot = _get_hot_topics()
2019
- selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")]
2020
 
2021
  # Tìm nguồn tin
2022
  all_sources = []
@@ -2125,7 +1659,7 @@ async def api_personal_post_preview(request: Request):
2125
  "- Mỗi slide phải khác nhau, không lặp ý\n"
2126
  "- Độ dài: 300-600 từ"
2127
  )
2128
- ai_text = None # Không dùng AI, để code tự kết hợp opinion + source
2129
  except:
2130
  pass
2131
 
@@ -2266,11 +1800,15 @@ async def api_personal_post(request: Request):
2266
  if not selected_topics:
2267
  # Lấy keywords từ QUAN ĐIỂM CÁ NHÂN để tìm nguồn tin chính xác
2268
  keywords = _extract_keywords_from_opinion(opinion, max_keywords=5)
 
 
2269
  if keywords:
2270
  selected_topics = keywords[:3]
 
 
 
2271
  else:
2272
- hot = _get_hot_topics()
2273
- selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")]
2274
 
2275
  all_sources = []
2276
  seen_urls = set()
@@ -2351,7 +1889,7 @@ async def api_personal_post(request: Request):
2351
  "\n"
2352
  "QUAN TRỌNG: Mỗi slide là 1 đoạn văn HOÀN CHỈNH, 2-4 câu, PHẢI KẾT THÚC BẰNG DẤU CHẤM (.). Kết hợp QUAN ĐIỂM + NGUỒN TIN. Không gạch đầu dòng. Viết liền mạch. 300-600 từ."
2353
  )
2354
- ai_text = None # Không dùng AI, để code tự kết hợp opinion + source
2355
  except:
2356
  pass
2357
 
@@ -2706,58 +2244,6 @@ def _scheduler_loop():
2706
 
2707
  threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
2708
 
2709
- def _wall_cleanup_once():
2710
- """One-time startup cleanup: sort the persistent wall store newest-first
2711
- and remove truly-garbage auto posts (no valid image at ALL, no video, no
2712
- slides, no embed URL — i.e. a post that can never be displayed).
2713
-
2714
- NOTE: This is intentionally conservative. Posts with a valid image or a
2715
- valid embed/video are NEVER deleted here — they are only hidden at view
2716
- time by _wall_posts_for_view() when the image is a placeholder. This
2717
- prevents accidental data loss of FPT Short AI posts whose image is valid
2718
- but may temporarily lack a short_thumb."""
2719
- try:
2720
- time.sleep(8)
2721
- posts = _load_wall_posts()
2722
- if not isinstance(posts, list):
2723
- return
2724
- kept = []
2725
- removed = 0
2726
- removed_ids = []
2727
- needs_sort = False
2728
- for p in posts:
2729
- if not isinstance(p, dict):
2730
- removed += 1
2731
- continue
2732
- if _is_auto_post(p):
2733
- has_img = _post_has_valid_image(p)
2734
- has_content = _post_has_slide_info(p)
2735
- vid = (p.get("video") or "").strip()
2736
- # Only delete auto posts that are completely unrecoverable:
2737
- # no valid image AND no video/slides/embed at all.
2738
- if not has_img and not has_content and not vid:
2739
- removed += 1
2740
- removed_ids.append(p.get('id', '?'))
2741
- continue
2742
- kept.append(p)
2743
- # Sort if any post lacks proper ordering
2744
- prev_ts = None
2745
- is_sorted = True
2746
- for p in kept:
2747
- ts = _created_ts(p)
2748
- if prev_ts is not None and ts > prev_ts:
2749
- is_sorted = False
2750
- break
2751
- prev_ts = ts
2752
- if removed > 0 or not is_sorted:
2753
- kept.sort(key=_created_ts, reverse=True)
2754
- _save_wall_posts(kept)
2755
- print(f"[wall] startup cleanup: removed {removed} garbage posts ({removed_ids[:10]}), kept {len(kept)}, sorted newest-first")
2756
- except Exception as e:
2757
- print(f"[wall] startup cleanup error: {e}")
2758
-
2759
- threading.Thread(target=_wall_cleanup_once, daemon=True, name='wall-cleanup').start()
2760
-
2761
  @app.get('/api/debug/auto_schedule')
2762
  async def debug_auto_schedule(slot: str = '07:00'):
2763
  """Manually trigger auto scheduler for debugging."""
@@ -2877,744 +2363,4 @@ def shorts_rss():
2877
 
2878
  return {"shorts": shorts, "count": len(shorts)}
2879
 
2880
- # ===== M3U IPTV Channels (vmttv list + existing bundled playlist) =====
2881
- _M3U_URL = "https://raw.githubusercontent.com/vuminhthanh12/vuminhthanh12/main/vmttv"
2882
- # Secondary source: the existing curated playlist bundled in the repo. We keep
2883
- # it as a second source so channels that work on the Space IP are preserved
2884
- # while vmttv adds new ones. Both are validated on the Space egress IP, dead
2885
- # ones are dropped, duplicates by URL are merged.
2886
- _M3U_URL_EXISTING = "https://huggingface.co/spaces/bep40/vnews/resolve/main/static/playlist.m3u"
2887
- # Event-only groups: these channels only have a working stream while an event
2888
- # / match is live (movie premieres, TV360/VTVPrime event slots, BLV co-commentator
2889
- # streams). When no event is running their URLs are dead -> they would be
2890
- # validated out anyway. We KEEP them (no longer dropped) so that live football /
2891
- # event streams are detected and grouped into the EVENT section at the top of
2892
- # the 📺 Trực tiếp tab. Dead event streams are still removed by the validator,
2893
- # so a group with no live event simply never appears.
2894
- _M3U_EVENT_GROUPS = {
2895
- "LIVE EVENTS 🔴",
2896
- "Sự Kiện TV360",
2897
- "Sự Kiện VTVPrime",
2898
- "🔴 ⚽ COLA TV",
2899
- "🔴 ⚽ 🏀 COLA TV SV2",
2900
- "🔴 ⚽ 🏐 PHÁO HOA TV",
2901
- }
2902
- _M3U_FOOTBALL_GROUPS = {
2903
- "🔴 ⚽ COLA TV",
2904
- "🔴 ⚽ 🏀 COLA TV SV2",
2905
- "🔴 ⚽ 🏐 PHÁO HOA TV",
2906
- "⚽| Thể thao quốc tế",
2907
- "THỂ THAO QUỐC TẾ",
2908
- }
2909
- # Title patterns that identify a live football match / event channel vs a
2910
- # regular 24/7 sports channel (e.g. "Bayern vs Dortmund", "01:45 02/09 Liverpool ...").
2911
- # NOTE: bare " - " / " – " separators are NOT used — too many normal channel
2912
- # names contain them (e.g. "On Movies - You TV"). Only strong match signals.
2913
- _M3U_MATCH_TITLE_RE = re.compile(
2914
- r'\bvs\b|\d{1,2}:\d{2}\s*\d{1,2}/\d{1,2}|\bđấu\b|\bgặp\b|trực tiếp',
2915
- re.IGNORECASE)
2916
- _M3U_FOOTBALL_TITLE_RE = re.compile(
2917
- r'\bđấu\b|\bgặp\b|trực tiếp bóng đá|\bvs\b|bóng đá|champions|premier|v-league|vleague|nha\b|liga|bundesliga|serie a|world cup',
2918
- re.IGNORECASE)
2919
-
2920
- _M3U_EVENT_SUFFIXES = ("TV360+", "VTVPrime", "⚽ COLA", "COLA TV", "PHÁO HOA")
2921
- # Sports-cluster groups: when international football is LIVE (per bongda
2922
- # livescore), ALL channels in these groups are surfaced together with the
2923
- # EVENT slide on the 📺 Trực tiếp tab (the user asked: "khi có livescore bóng
2924
- # đá Quốc Tế cho tất cả kênh từ thể thao và thể thao quốc tế").
2925
- _M3U_SPORTS_CLUSTER_GROUPS = {
2926
- "Thể Thao",
2927
- "THỂ THAO QUỐC TẾ",
2928
- "⚽| Thể thao quốc tế",
2929
- }
2930
- # Bongda livescore status labels: only matches that are actually in progress
2931
- # count as "live football". KT/FT/PEN etc = finished, VS = not started yet.
2932
- _M3U_LS_DONE_LABEL_RE = re.compile(r'^(KT|FT|ET|AET|PEN|VS|Hủy|Hoãn|Reported|Hủy bỏ)$', re.IGNORECASE)
2933
- # Vietnamese domestic competitions are NOT "bóng đá quốc tế" — exclude them.
2934
- _M3U_LS_VN_DOMESTIC_RE = re.compile(r'v[- ]?league|vleague|hạng nhất|hạng nhì|cúp quốc gia|siêu cúp|siêu cup|hạng ba|hạng tư', re.IGNORECASE)
2935
- _m3u_ls_cache = {"t": 0, "d": None}
2936
- _M3U_LS_TTL = 60
2937
-
2938
- def _m3u_livescore_football_state(force=False):
2939
- """Return whether international football is LIVE right now, based on the
2940
- bongda.com.vn live fixtures feed. Cached for 60s (matches' status can
2941
- change, so a short TTL is important).
2942
- -> {"live": bool, "leagues": [..], "matches_count": int, "live_matches": [{..}]}
2943
- """
2944
- now = time.time()
2945
- if not force and _m3u_ls_cache["d"] is not None and now - _m3u_ls_cache["t"] < _M3U_LS_TTL:
2946
- return _m3u_ls_cache["d"]
2947
- state = {"live": False, "leagues": [], "matches_count": 0, "live_matches": []}
2948
- try:
2949
- html_txt = fetch_bongda_api("/api/fixtures/live")
2950
- if html_txt:
2951
- soup = BeautifulSoup(html_txt, "lxml")
2952
- for li in soup.select("li.match-detail"):
2953
- status = li.select_one(".status")
2954
- if not status:
2955
- continue
2956
- label_el = status.select_one(".label")
2957
- label = label_el.get_text(" ", strip=True) if label_el else ""
2958
- if not label or _M3U_LS_DONE_LABEL_RE.match(label):
2959
- continue # KT / VS / FT -> finished or not started
2960
- league_el = li.select_one(".league")
2961
- league = league_el.get_text(" ", strip=True) if league_el else ""
2962
- if _M3U_LS_VN_DOMESTIC_RE.search(league):
2963
- continue # V-League etc is domestic, not "quốc tế"
2964
- home_el = li.select_one(".home-team .name")
2965
- away_el = li.select_one(".away-team .name")
2966
- state["live"] = True
2967
- state["matches_count"] += 1
2968
- state["live_matches"].append({
2969
- "home": home_el.get_text(" ", strip=True) if home_el else "",
2970
- "away": away_el.get_text(" ", strip=True) if away_el else "",
2971
- "league": league,
2972
- "label": label,
2973
- })
2974
- if league and league not in state["leagues"]:
2975
- state["leagues"].append(league)
2976
- except Exception:
2977
- pass
2978
- _m3u_ls_cache["t"] = now
2979
- _m3u_ls_cache["d"] = state
2980
- return state
2981
-
2982
- _m3u_cache = {"t": 0, "d": []}
2983
- _M3U_CACHE_FILE = os.path.join("/data" if os.path.isdir('/data') else "/app/data", 'm3u_cache.json')
2984
-
2985
- def _load_m3u_disk():
2986
- """Load M3U cache from disk (if available)."""
2987
- try:
2988
- if os.path.exists(_M3U_CACHE_FILE):
2989
- with open(_M3U_CACHE_FILE, 'r', encoding='utf-8') as f:
2990
- d = json.load(f)
2991
- if d and isinstance(d.get('channels'), list):
2992
- return d
2993
- except Exception:
2994
- pass
2995
- return None
2996
-
2997
- def _save_m3u_disk(channels, ts):
2998
- """Save M3U cache to disk."""
2999
- try:
3000
- os.makedirs(os.path.dirname(_M3U_CACHE_FILE), exist_ok=True)
3001
- with open(_M3U_CACHE_FILE, 'w', encoding='utf-8') as f:
3002
- json.dump({"channels": channels, "t": ts}, f, ensure_ascii=False)
3003
- except Exception:
3004
- pass
3005
-
3006
- def _check_m3u_stream(c, timeout=9):
3007
- """Probe a single M3U channel URL. Returns True only if it actually
3008
- delivers STREAM data — raw MPEG-TS bytes (0x47 sync) OR an HLS playlist
3009
- (#EXTM3U). Rejects HTTP error pages: some servers return HTTP 200 for
3010
- HTML/JSON error responses, and those used to pass the old >=2-bytes check
3011
- (e.g. CANAL+ portals returning <!DOCTYPE html>, 403 Forbidden pages).
3012
- Applies VLC http-user-agent/http-cookie when present."""
3013
- try:
3014
- h = dict(HEADERS)
3015
- vopts = c.get("vlcopts", {}) or {}
3016
- if vopts.get("http-user-agent"):
3017
- h["User-Agent"] = vopts["http-user-agent"]
3018
- if vopts.get("http-cookie"):
3019
- h["Cookie"] = vopts["http-cookie"]
3020
- if vopts.get("http-header"):
3021
- hdr = vopts.get("http-header")
3022
- if ":" in hdr:
3023
- kk, vv = hdr.split(":", 1)
3024
- h[kk.strip()] = vv.strip()
3025
- rr = req.get(c["url"], headers=h, timeout=timeout, stream=True, allow_redirects=True)
3026
- if rr.status_code != 200:
3027
- try: rr.close()
3028
- except Exception: pass
3029
- return False
3030
- try:
3031
- chunk = next(rr.iter_content(4096))
3032
- rr.close()
3033
- except Exception:
3034
- try: rr.close()
3035
- except Exception: pass
3036
- return False
3037
- if not chunk or len(chunk) < 2:
3038
- return False
3039
- head = chunk[:512].lstrip()
3040
- # Reject HTML/XML/JSON error pages (200-with-error-body servers).
3041
- if head[:1] in (b'<', b'{', b'['):
3042
- return False
3043
- # Accept real stream payloads: MPEG-TS sync bytes or an HLS playlist.
3044
- if head.startswith(b'#EXTM3U') or b'#EXT-X-' in head[:256]:
3045
- # For a MASTER playlist (#EXT-X-STREAM-INF) the URL alone being
3046
- # live is not enough — the variant(s) it points to must also be
3047
- # reachable. Follow variant chains recursively (up to 4 levels)
3048
- # until we hit a media playlist with #EXTINF or a stream; drop
3049
- # chains that 404 / return HTML / loop forever (e.g. gr.seetv.cc
3050
- # serves self-referencing nested master playlists whose depth-2
3051
- # variant is 404).
3052
- try:
3053
- cur = c["url"]
3054
- depth = 0
3055
- media_found = False
3056
- while depth < 4:
3057
- is_master = b'#EXT-X-STREAM-INF' in chunk
3058
- if not is_master:
3059
- media_found = True
3060
- break
3061
- body = ''
3062
- try:
3063
- fr = req.get(cur, headers=h, timeout=timeout)
3064
- if fr.status_code == 200:
3065
- body = fr.text
3066
- except Exception:
3067
- pass
3068
- variant = None
3069
- lines = body.split('\n')
3070
- for i, ln in enumerate(lines):
3071
- if ln.startswith('#EXT-X-STREAM-INF') and i + 1 < len(lines):
3072
- cand = lines[i + 1].strip()
3073
- if cand and not cand.startswith('#'):
3074
- variant = cand if cand.startswith('http') else urljoin(cur, cand)
3075
- break
3076
- if not variant:
3077
- return False
3078
- if variant == cur:
3079
- return False # self-referencing master -> dead CDN
3080
- try:
3081
- vr = req.get(variant, headers=h, timeout=timeout, stream=True, allow_redirects=True)
3082
- st2 = vr.status_code
3083
- try:
3084
- chunk = next(vr.iter_content(8192))
3085
- vr.close()
3086
- except Exception:
3087
- vr.close()
3088
- chunk = b''
3089
- except Exception:
3090
- return False
3091
- if st2 != 200 or not chunk:
3092
- return False
3093
- cur = variant
3094
- vhead = chunk[:512].lstrip()
3095
- if vhead[:1] in (b'<', b'{', b'['):
3096
- return False
3097
- depth += 1
3098
- # media_found: we have a media playlist (#EXTINF, not master).
3099
- # The segment(s) inside are what Hls.js actually loads — probe
3100
- # the FIRST segment through the same URL-resolution the proxy
3101
- # uses and require real media bytes (TS or another playlist).
3102
- # This drops channels whose playlist is alive but whose
3103
- # segments 404 (e.g. Canal Showsport lumpentv: playlist 200,
3104
- # segment -> upstream 404 on the Space egress IP).
3105
- if media_found:
3106
- body = ''
3107
- try:
3108
- fr = req.get(cur, headers=h, timeout=timeout)
3109
- if fr.status_code == 200:
3110
- body = fr.text
3111
- except Exception:
3112
- pass
3113
- first_seg = None
3114
- for ln in body.split('\n'):
3115
- s = ln.strip()
3116
- if s and not s.startswith('#') and not s.startswith('http'):
3117
- first_seg = urljoin(cur, s)
3118
- break
3119
- elif s and s.startswith('http'):
3120
- first_seg = s
3121
- break
3122
- if first_seg:
3123
- try:
3124
- sr = req.get(first_seg, headers=h, timeout=timeout, stream=True, allow_redirects=True)
3125
- st3 = sr.status_code
3126
- try:
3127
- schunk = next(sr.iter_content(2048))
3128
- sr.close()
3129
- except Exception:
3130
- sr.close()
3131
- schunk = b''
3132
- if st3 != 200 or not schunk:
3133
- return False
3134
- shead = schunk[:256].lstrip()
3135
- if shead[:1] in (b'<', b'{', b'['):
3136
- return False
3137
- # Playlist or TS/binary media both OK (seg proxy
3138
- # handles playlist-in-seg via rewrite.)
3139
- return True
3140
- except Exception:
3141
- return False
3142
- return True
3143
- except Exception:
3144
- pass
3145
- return True
3146
- if head[0] == 0x47:
3147
- # MPEG-TS: verify the 188-byte sync pattern if we have enough data
3148
- if len(head) > 188:
3149
- return head[188] == 0x47
3150
- return True
3151
- # Unknown binary but not HTML — accept (some servers send raw flv/ps)
3152
- return True
3153
- except Exception:
3154
- return False
3155
-
3156
- def _parse_m3u():
3157
- """Fetch & parse the M3U playlists (vmttv remote + existing bundled list).
3158
- Validates EVERY channel (all groups) with a real byte-read so dead
3159
- streams are removed, then groups channels by NAME (title) so the API
3160
- returns one entry per channel name with its working mirror streams:
3161
- [{name, group, logo, streams:[{url, vlcopts}, ...]}, ...]
3162
- Unreachable / broken channels and event-only groups are filtered out."""
3163
- out = []
3164
- seen_raw = set()
3165
- for src_url in (_M3U_URL, _M3U_URL_EXISTING):
3166
- try:
3167
- r = req.get(src_url, headers={"User-Agent": "Mozilla/5.0"}, timeout=20)
3168
- if r.status_code != 200:
3169
- continue
3170
- lines = r.text.splitlines()
3171
- cur = {}
3172
- for line in lines:
3173
- line = line.strip()
3174
- if line.startswith("#EXTINF"):
3175
- t_match = re.search(r',(.+)$', line)
3176
- cur = {
3177
- "title": t_match.group(1).strip() if t_match else "Channel",
3178
- "id": "",
3179
- "logo": "",
3180
- "group": "",
3181
- "url": "",
3182
- "vlcopts": {},
3183
- }
3184
- for attr_match in re.finditer(r'([a-zA-Z0-9-]+)="([^"]*)"', line):
3185
- k, v = attr_match.group(1), attr_match.group(2)
3186
- if k == "tvg-id":
3187
- cur["id"] = v
3188
- elif k == "tvg-logo":
3189
- cur["logo"] = v
3190
- elif k == "group-title":
3191
- cur["group"] = v
3192
- elif line.startswith("#EXTVLCOPT:"):
3193
- key_part = line[len("#EXTVLCOPT:"):]
3194
- if "=" in key_part:
3195
- k, v = key_part.split("=", 1)
3196
- cur.setdefault("vlcopts", {})[k.strip()] = v.strip()
3197
- elif line and not line.startswith("#") and cur:
3198
- cur["url"] = line
3199
- if cur["url"] not in seen_raw:
3200
- seen_raw.add(cur["url"])
3201
- out.append(cur)
3202
- cur = {}
3203
- except Exception:
3204
- continue
3205
- # Event channels are KEPT in the list (not pre-dropped): the validator
3206
- # below already removes streams that are dead, so a group with no live
3207
- # event simply never appears. Each channel is tagged below so the frontend
3208
- # can (a) place live events in the featured slide and (b) list event
3209
- # channels on top of the Trực tiếp tab.
3210
- def _event_kind(c):
3211
- g = (c.get("group") or "").strip()
3212
- t = (c.get("title") or "").strip()
3213
- if g in _M3U_EVENT_GROUPS or any(t.startswith(s) for s in _M3U_EVENT_SUFFIXES):
3214
- is_football = g in _M3U_FOOTBALL_GROUPS or bool(_M3U_FOOTBALL_TITLE_RE.search(t))
3215
- return "football" if is_football else "event"
3216
- if _M3U_MATCH_TITLE_RE.search(t):
3217
- return "football"
3218
- return None
3219
-
3220
- for c in out:
3221
- k = _event_kind(c)
3222
- if k:
3223
- c["is_event"] = True
3224
- c["event_kind"] = k
3225
- # Deduplicate by URL (safety net — the seen_raw pass already dedupes)
3226
- seen = set()
3227
- deduped = []
3228
- for c in out:
3229
- if c["url"] not in seen:
3230
- seen.add(c["url"])
3231
- deduped.append(c)
3232
- # Validate ALL groups (not just Thể Thao) with a real byte-read.
3233
- valid = []
3234
- with ThreadPoolExecutor(max_workers=24) as ex:
3235
- futures = {ex.submit(_check_m3u_stream, c): c for c in deduped}
3236
- for f in as_completed(futures):
3237
- if f.result():
3238
- valid.append(futures[f])
3239
- valid_urls = {c["url"] for c in valid}
3240
- good = [c for c in deduped if c["url"] in valid_urls]
3241
- # Group by channel NAME (title): each entry is one channel name with the
3242
- # list of working mirror streams so users can find a channel easily and
3243
- # the player can auto-failover between mirrors.
3244
- grouped = {}
3245
- order = []
3246
- for c in good:
3247
- key = (c.get("group") or "Khác", c.get("title") or "Kênh")
3248
- if key not in grouped:
3249
- grouped[key] = {
3250
- "name": c.get("title") or "Kênh",
3251
- "group": c.get("group") or "Khác",
3252
- "logo": c.get("logo") or "",
3253
- "streams": [],
3254
- }
3255
- order.append(key)
3256
- if _M3U_EVENT_GROUPS and (c.get("group") or "").strip() in _M3U_EVENT_GROUPS:
3257
- # Keep one "live event" flag per channel (merged mirrors)
3258
- grouped[key]["is_event"] = True
3259
- grouped[key]["event_kind"] = c.get("event_kind") or "event"
3260
- elif c.get("is_event"):
3261
- grouped[key]["is_event"] = True
3262
- grouped[key]["event_kind"] = c.get("event_kind") or "event"
3263
- grouped[key]["streams"].append({
3264
- "url": c["url"],
3265
- "vlcopts": c.get("vlcopts") or {},
3266
- })
3267
- # Sort: live events (football first, then movie/TV events) on top, then
3268
- # everything else in original order. Grouped dicts keep insertion order.
3269
- def _sort_key(entry):
3270
- if entry.get("is_event"):
3271
- return (0, 0) if entry.get("event_kind") == "football" else (0, 1)
3272
- return (1, 0)
3273
- entries = [grouped[k] for k in order]
3274
- entries.sort(key=_sort_key)
3275
- return entries
3276
-
3277
- def _m3u_refresh():
3278
- """Refresh M3U cache in background."""
3279
- n = int(time.time())
3280
- try:
3281
- channels = _parse_m3u()
3282
- _m3u_cache["t"] = n
3283
- _m3u_cache["d"] = channels
3284
- _save_m3u_disk(channels, n)
3285
- except Exception:
3286
- pass
3287
-
3288
- def _m3u_daily_updater():
3289
- """Auto-refresh the M3U list once per day. Re-fetches the remote list
3290
- (so channels added/removed upstream are picked up within 24h), re-validates
3291
- every channel (dead ones are dropped, tokens refreshed) and persists to
3292
- disk. Runs on a daemon thread."""
3293
- while True:
3294
- try:
3295
- time.sleep(24 * 3600) # 24h
3296
- _m3u_refresh()
3297
- print(f"[M3U] daily auto-update complete @ {time.strftime('%Y-%m-%d %H:%M:%S')}")
3298
- except Exception as e:
3299
- print(f"[M3U] daily auto-update error: {e}")
3300
- try:
3301
- time.sleep(3600)
3302
- except Exception:
3303
- pass
3304
-
3305
- def _m3u_load():
3306
- """Load M3U: return disk/in-memory cache immediately, refresh in background if stale."""
3307
- global _m3u_cache
3308
- n = int(time.time())
3309
- # Try in-memory cache first
3310
- if _m3u_cache["d"] and n - _m3u_cache["t"] < 180:
3311
- return _m3u_cache["d"], _m3u_cache["t"]
3312
- # Try disk cache
3313
- disk = _load_m3u_disk()
3314
- if disk and disk.get("channels"):
3315
- _m3u_cache = {"t": disk.get("t", 0), "d": disk["channels"]}
3316
- # Refresh in background if older than 3 min (Stalker play_tokens expire ~3-7 min)
3317
- if n - _m3u_cache["t"] >= 180:
3318
- threading.Thread(target=_m3u_refresh, daemon=True).start()
3319
- return _m3u_cache["d"], _m3u_cache["t"]
3320
- # No cache at all — fetch synchronously (first run)
3321
- _m3u_refresh()
3322
- return _m3u_cache["d"], _m3u_cache["t"]
3323
-
3324
- @app.get('/api/m3u/channels')
3325
- def api_m3u_channels(refresh: int = Query(default=0)):
3326
- if refresh:
3327
- # FIX: sync refresh — re-parse the M3U so stalker play_tokens are fresh
3328
- # before the frontend builds the /api/proxy/m3u_hls URL. The old code
3329
- # only kicked off a background thread and returned the STALE cache,
3330
- # so clients played with expired tokens -> black screen / 502.
3331
- try:
3332
- _m3u_refresh()
3333
- except Exception as e:
3334
- print(f"[M3U] sync refresh error: {e}")
3335
- channels = _m3u_cache["d"]
3336
- updated = _m3u_cache["t"]
3337
- else:
3338
- channels, updated = _m3u_load()
3339
- # LIVE international football (bongda livescore): surface ALL sports-cluster
3340
- # channels (Thể Thao / THỂ THAO QUỐC TẾ / ⚽| Thể thao quốc tế) together with
3341
- # the EVENT slide. This is computed per-request (NOT persisted to the disk
3342
- # cache) so when the last match ends the flag disappears automatically.
3343
- ls = _m3u_livescore_football_state()
3344
- if ls.get("live") and channels:
3345
- channels = list(channels)
3346
- for i, c in enumerate(channels):
3347
- if (c.get("group") or "").strip() in _M3U_SPORTS_CLUSTER_GROUPS:
3348
- channels[i] = dict(c)
3349
- channels[i]["is_event"] = True
3350
- channels[i]["event_kind"] = "football"
3351
- channels[i]["live_football_sports"] = True
3352
- return JSONResponse({
3353
- "channels": channels,
3354
- "updated": updated,
3355
- "live_football": ls,
3356
- })
3357
-
3358
- # Pre-warm M3U cache on startup + start the daily auto-update loop.
3359
- threading.Thread(target=lambda: _m3u_load(), daemon=True).start()
3360
- threading.Thread(target=_m3u_daily_updater, daemon=True).start()
3361
-
3362
- def _seg_url(base, ua=None, cookie=None, auth=None):
3363
- """Build an /api/proxy/seg URL carrying the stalker headers."""
3364
- q = urlencode({'url': base})
3365
- if ua: q += '&ua=' + quote(ua, safe="")
3366
- if cookie: q += '&cookie=' + quote(cookie, safe="")
3367
- if auth: q += '&auth=' + quote(auth, safe="")
3368
- return "/api/proxy/seg?" + q
3369
-
3370
- def _rewrite_media_playlist(text, base_url, ua=None, cookie=None, auth=None):
3371
- """Rewrite a MEDIA (not master) HLS playlist so every data line is proxied."""
3372
- out = []
3373
- for line in text.splitlines():
3374
- s = line.strip()
3375
- if not s or s.startswith('#'):
3376
- out.append(line)
3377
- continue
3378
- resolved = s if s.startswith('http') else urljoin(base_url, s)
3379
- out.append(_seg_url(resolved, ua, cookie, auth))
3380
- return '\n'.join(out)
3381
-
3382
- def _probe_upstream(url, headers):
3383
- """Best-effort HEAD/GET probe. Returns (is_playlist: bool|None, content, ctype, final_url).
3384
- None means 'unknown' (could not decide) -> caller treats as raw stream.
3385
- We NEVER block long: connect 10s, read 12s. A .ts stream that does not
3386
- respond in ~12s is treated as raw TS (wrap it). Only content that begins
3387
- with #EXTM3U / #EXT-X- or has an HLS content-type is treated as a playlist."""
3388
- try:
3389
- r = req.get(url, headers=headers, timeout=(10, 12), stream=True, allow_redirects=True)
3390
- final = r.url
3391
- if r.status_code != 200:
3392
- try: r.close()
3393
- except Exception: pass
3394
- return None, "", "", final
3395
- ct = r.headers.get("Content-Type", "").lower()
3396
- lead = b""
3397
- try:
3398
- lead = next(r.iter_content(4096))
3399
- except Exception:
3400
- pass
3401
- try: r.close()
3402
- except Exception: pass
3403
- if not lead:
3404
- # Empty 200 — ambiguous. Content-Type decides.
3405
- return ('m3u8' in ct or 'mpegurl' in ct), "", ct, final
3406
- head = lead[:512].lstrip()
3407
- is_pl = head.startswith(b'#EXTM3U') or b'#EXT-X-' in head[:256]
3408
- if is_pl:
3409
- # Re-fetch full body (small text) for rewriting.
3410
- body = ""
3411
- try:
3412
- rr = req.get(url, headers=headers, timeout=(10, 12), allow_redirects=True)
3413
- if rr.status_code == 200:
3414
- body = rr.text
3415
- except Exception:
3416
- pass
3417
- return True, body, ct, final
3418
- return False, lead, ct, final
3419
- except Exception:
3420
- return None, "", "", url
3421
-
3422
- def _master_to_media(url, headers):
3423
- """Follow a MASTER playlist to its first playable MEDIA playlist.
3424
- Returns (media_url, media_text) or (None, None) if it can't resolve."""
3425
- # Fetch up to 3 levels (rare nested masters).
3426
- cur = url
3427
- for _ in range(3):
3428
- try:
3429
- r = req.get(cur, headers=headers, timeout=(10, 12), allow_redirects=True)
3430
- if r.status_code != 200:
3431
- return None, None
3432
- body = r.text
3433
- except Exception:
3434
- return None, None
3435
- if '#EXT-X-STREAM-INF' not in body:
3436
- # Already a media playlist (or not a playlist at all — caller
3437
- # validates before calling).
3438
- return cur, body
3439
- variant = None
3440
- for i, ln in enumerate(body.splitlines()):
3441
- if ln.startswith('#EXT-X-STREAM-INF') and i + 1 < len(body.splitlines()):
3442
- cand = body.splitlines()[i + 1].strip()
3443
- if cand and not cand.startswith('#'):
3444
- variant = cand if cand.startswith('http') else urljoin(cur, cand)
3445
- break
3446
- if not variant or variant == cur:
3447
- return None, None
3448
- cur = variant
3449
- return cur, None
3450
-
3451
- @app.get('/api/proxy/m3u_hls')
3452
- def proxy_m3u_hls(url: str = Query(...), ua: str = Query(default=None), cookie: str = Query(default=None), auth: str = Query(default=None)):
3453
- """Convert ANY stream URL (MPEG-TS, Stalker .ts, .m3u8/.m3u/.m3u8 playlists,
3454
- MPD, raw) into an HLS live playlist that Hls.js can play in the browser.
3455
-
3456
- The old heuristic only probed URLs whose PATH contained '.m3u8'. That broke
3457
- the common case where a '.ts' / mp2t stream URL actually serves an .m3u8
3458
- manifest (many Stalker portals redirect/serve a playlist under a .ts path)
3459
- AND the case where a plain '.m3u' URL is an HLS playlist rather than raw
3460
- TS. Those URL-suffix mismatches caused Hls.js to receive playlist TEXT as
3461
- video/mp2t media, stalling playback.
3462
-
3463
- New behaviour: content-sniff probe on EVERY URL. If the upstream bytes start
3464
- with #EXTM3U / #EXT-X- we rewrite it into a proxied HLS playlist (resolving
3465
- masters to their first media variant). Otherwise we wrap the URL in a live
3466
- open-ended HLS manifest whose segments stream raw through /api/proxy/seg.
3467
- A fast probe failure (timeout / 5xx) is treated as a raw stream and wrapped,
3468
- so Stalker servers that never answer on port 80 from the egress IP still
3469
- play via the TS wrapper."""
3470
- if not url or not url.startswith('http'):
3471
- return Response(status_code=400, content="Invalid URL")
3472
- from urllib.parse import urlencode
3473
- headers = dict(HEADERS)
3474
- if ua: headers["User-Agent"] = ua
3475
- if cookie: headers["Cookie"] = cookie
3476
- if auth: headers["Authorization"] = auth
3477
- # Content-sniff probe (short timeout; failure -> assume raw TS & wrap).
3478
- is_pl, body, ct, final_url = _probe_upstream(url, headers)
3479
- if is_pl:
3480
- # Decide master vs media.
3481
- if '#EXT-X-STREAM-INF' in body:
3482
- media_url, media_text = _master_to_media(url, headers)
3483
- if media_url and media_text and '#EXT-X-STREAM-INF' not in media_text:
3484
- body = media_text
3485
- # Rewrite against the resolved media URL.
3486
- rewritten = _rewrite_media_playlist(body, media_url, ua, cookie, auth)
3487
- return Response(content=rewritten.encode('utf-8'),
3488
- media_type="application/vnd.apple.mpegurl",
3489
- headers={"Access-Control-Allow-Origin":"*", "Cache-Control":"no-cache, no-store, must-revalidate"})
3490
- elif media_url:
3491
- # media_text None but media_url resolved to itself (media playlist) -> rewrite.
3492
- rewritten = _rewrite_media_playlist(media_text or body, media_url, ua, cookie, auth)
3493
- return Response(content=rewritten.encode('utf-8'),
3494
- media_type="application/vnd.apple.mpegurl",
3495
- headers={"Access-Control-Allow-Origin":"*", "Cache-Control":"no-cache, no-store, must-revalidate"})
3496
- return Response(status_code=502, content="master playlist without playable variant")
3497
- # Media playlist (or a plain .m3u/.m3u8 that is itself a media list).
3498
- rewritten = _rewrite_media_playlist(body, final_url, ua, cookie, auth)
3499
- return Response(content=rewritten.encode('utf-8'),
3500
- media_type="application/vnd.apple.mpegurl",
3501
- headers={"Access-Control-Allow-Origin":"*", "Cache-Control":"no-cache, no-store, must-revalidate"})
3502
- # Raw MPEG-TS / Stalker / unknown: emit a live open-ended HLS wrapper whose
3503
- # segments stream raw bytes through /api/proxy/seg. Multiple repeating
3504
- # segments keep Hls.js buffer alive and polling for the next window, which
3505
- # fixes the stall observed with single-segment manifests on short TS bursts.
3506
- _seg = _seg_url(url, ua, cookie, auth)
3507
- manifest = (
3508
- "#EXTM3U\n"
3509
- "#EXT-X-VERSION:6\n"
3510
- "#EXT-X-PLAYLIST-TYPE:EVENT\n"
3511
- "#EXT-X-TARGETDURATION:8\n"
3512
- "#EXT-X-MEDIA-SEQUENCE:0\n"
3513
- "#EXT-X-START:TIME-OFFSET=0,PRECISE=YES\n"
3514
- "#EXTINF:8.0,\n"
3515
- f"{_seg}\n"
3516
- "#EXTINF:8.0,\n"
3517
- f"{_seg}\n"
3518
- "#EXTINF:8.0,\n"
3519
- f"{_seg}\n"
3520
- "#EXT-X-DISCONTINUITY\n"
3521
- )
3522
- return Response(content=manifest.encode('utf-8'),
3523
- media_type="application/vnd.apple.mpegurl",
3524
- headers={"Access-Control-Allow-Origin":"*", "Cache-Control":"no-cache, no-store, must-revalidate"})
3525
-
3526
-
3527
- @app.get('/api/proxy/m3u')
3528
- def proxy_m3u_stream(url: str = Query(...), ua: str = Query(default=None), cookie: str = Query(default=None), auth: str = Query(default=None)):
3529
- """Proxy MPEG-TS / HLS streams from stalker portals with custom headers.
3530
-
3531
- For MPEG-TS (.ts) / mp2t and other non-HLS URLs: redirects to the HLS
3532
- playlist generator (/api/proxy/m3u_hls) which content-sniffs the upstream
3533
- and emits a playable HLS manifest. For .m3u8 / .m3u / playlist URLs: proxies
3534
- segments through /api/proxy/seg with the stalker headers forwarded."""
3535
- if not url or not url.startswith('http'):
3536
- return Response(status_code=400, content="Invalid URL")
3537
- from urllib.parse import urlencode
3538
- low = url.lower()
3539
- # URLs that are HLS playlists (m3u8 or plain m3u): stream through seg proxy.
3540
- if low.endswith(('.m3u8', '.m3u')) or 'm3u8' in low or '/m3u8' in low:
3541
- headers = dict(HEADERS)
3542
- if ua: headers["User-Agent"] = ua
3543
- if cookie: headers["Cookie"] = cookie
3544
- if auth: headers["Authorization"] = auth
3545
- try:
3546
- r = req.get(url, headers=headers, timeout=15, stream=True)
3547
- if r.status_code != 200:
3548
- return Response(status_code=502, content="upstream error")
3549
- ct = r.headers.get("Content-Type", "")
3550
- # If a .m3u/.m3u8 URL actually serves raw TS (rare), fall back to the
3551
- # HLS wrapper instead of streaming mp2t bytes to Hls.js.
3552
- lead = b""
3553
- try:
3554
- lead = next(r.iter_content(4096))
3555
- except Exception:
3556
- pass
3557
- head = lead[:512].lstrip()
3558
- if head.startswith(b'#EXTM3U') or b'#EXT-X-' in head[:256] or 'mpegurl' in ct.lower():
3559
- # HLS playlist: stream it through /api/proxy/seg so the rewritten
3560
- # manifest (produced lazily by seg) reaches the browser CORS-free.
3561
- def _stream2(src=r):
3562
- if lead:
3563
- yield lead
3564
- for chunk in src.iter_content(chunk_size=65536):
3565
- yield chunk
3566
- return StreamingResponse(
3567
- _stream2(),
3568
- media_type="application/vnd.apple.mpegurl",
3569
- headers={"Access-Control-Allow-Origin":"*", "Cache-Control":"no-cache", "Accept-Ranges":"bytes"})
3570
- # Not actually a playlist — serve as TS through the seg proxy instead.
3571
- r.close()
3572
- except Exception:
3573
- pass
3574
- # Fall through to the HLS wrapper path below.
3575
- # MPEG-TS / stalker / non-playlist URL: redirect to the HLS playlist generator
3576
- # (content-sniffs upstream and wraps raw streams, or rewrites real playlists).
3577
- from fastapi.responses import RedirectResponse
3578
- params = {'url': url}
3579
- if ua: params['ua'] = ua
3580
- if cookie: params['cookie'] = cookie
3581
- if auth: params['auth'] = auth
3582
- return RedirectResponse(url="/api/proxy/m3u_hls?" + urlencode(params), status_code=302)
3583
-
3584
-
3585
- # ===== M3U LOGO FALLBACK =====
3586
- @app.get('/api/m3u/logo_fallback')
3587
- def m3u_logo_fallback(group: str = Query(default="")):
3588
- """Generate a fallback SVG logo for M3U channels when tvg-logo fails to load.
3589
- Uses the group-title to pick an appropriate emoji/badge."""
3590
- import html as _html
3591
- g = _html.escape((group or '').strip())
3592
- # Determine emoji based on group content
3593
- g_lower = g.lower()
3594
- if 'premier' in g_lower or 'premier league' in g_lower:
3595
- emoji, bg = '⚽', '#1e3a8a'
3596
- elif 'liga' in g_lower and 'la' in g_lower:
3597
- emoji, bg = '⚽', '#0f172a'
3598
- elif 'champions' in g_lower:
3599
- emoji, bg = '⭐', '#7c2d12'
3600
- elif 'bundes' in g_lower:
3601
- emoji, bg = '⚽', '#047857'
3602
- elif 'serie' in g_lower:
3603
- emoji, bg = '⚽', '#713f12'
3604
- elif 'ligue' in g_lower:
3605
- emoji, bg = '⚽', '#7e22ce'
3606
- elif 'world cup' in g_lower:
3607
- emoji, bg = '🌍', '#075985'
3608
- elif 'fpt' in g_lower:
3609
- emoji, bg = '📺', '#be123c'
3610
- else:
3611
- emoji, bg = '📺', '#374151'
3612
- svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120">
3613
- <rect width="120" height="120" rx="16" fill="{bg}"/>
3614
- <text x="60" y="72" font-size="42" text-anchor="middle" fill="white" font-family="Arial, sans-serif">{emoji}</text>
3615
- </svg>'''
3616
- return Response(content=svg.encode('utf-8'), media_type='image/svg+xml',
3617
- headers={"Access-Control-Allow-Origin":"*"})
3618
-
3619
-
3620
  app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
 
12
  except Exception as e:
13
  print(f"[WARN] ai_patch import failed: {e}")
14
 
15
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
 
 
 
 
 
 
 
 
 
 
 
16
  from fastapi.staticfiles import StaticFiles
17
  from starlette.routing import Mount
18
  from fastapi import Query, Request, UploadFile, File, Form
 
20
  from bs4 import BeautifulSoup
21
  import re, html as html_lib, json, threading, time, uuid
22
  from concurrent.futures import ThreadPoolExecutor, as_completed
23
+ from urllib.parse import quote
24
  import asyncio
25
 
26
  HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
 
475
  return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
476
 
477
  _hot_cache={'t':0,'d':[]}
 
 
 
 
 
 
 
 
 
 
 
 
478
  def _get_hot_topics():
479
  now=time.time()
480
  if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
 
 
 
 
 
 
 
 
481
  freq={};display={}
482
+ feeds=['https://vnexpress.net/rss/tin-moi-nhat.rss','https://dantri.com.vn/rss/home.rss','https://vietnamnet.vn/rss/tin-moi-nhat.rss','https://thanhnien.vn/rss/home.rss','https://tuoitre.vn/rss/tin-moi-nhat.rss','https://genk.vn/rss','https://vnexpress.net/rss/the-thao.rss','https://thethaovanhoa.vn/rss/tin-nong.rss']
483
+ for feed_url in feeds:
484
+ try:
485
+ r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
486
+ for item in soup.find_all('item')[:12]:
487
+ title=_clean(item.find('title').get_text() if item.find('title') else '')
488
+ if not title:continue
489
+ title=re.sub(r'\s*[-|].*$','',title);words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in _STOP]
490
+ if len(words)<2:continue
491
+ for n in(3,4,2):
492
+ for i in range(max(0,len(words)-n+1)):
493
+ phrase=' '.join(words[i:i+n])
494
+ if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
495
+ except:continue
496
  ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
497
  for key,count in ranked:
498
  is_dup=any(len(set(e.split())&set(key.split()))/max(len(set(e.split())),len(set(key.split())),1)>0.6 for e in seen)
 
519
  items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
520
  return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
521
  @app.get('/api/categories')
522
+ def _cat():return JSONResponse([])
 
 
 
 
 
 
 
 
 
 
523
  @app.get('/api/storage_status')
524
  def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
525
  # ===== SHARE HELPERS: render content pages for shared links =====
 
 
 
 
 
 
 
 
 
 
 
 
526
  def _render_slides_page(post, safe_title, safe_img, safe_url):
527
  slides = post.get('slides', [])
528
  # Get image from post.img or first slide's image
529
  if not safe_img and slides and slides[0].get('image'):
530
  safe_img = slides[0].get('image', '')
 
531
  # Use text for description if available
532
+ description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
 
533
 
534
  # Build canonical URL preserving original query format if url was provided
535
  if safe_url and safe_url != '/':
 
544
  <meta name="viewport" content="width=device-width,initial-scale=1">
545
  <title>{_clean(safe_title)}</title>
546
  <meta property="og:title" content="{_clean(safe_title)}">
547
+ <meta property="og:image" content="{_clean(safe_img)}">
548
  <meta property="og:description" content="{description}">
549
  <meta property="og:url" content="{canonical_url}">
550
  <link rel="canonical" href="{canonical_url}">
 
569
 
570
  def _render_video_page(post, safe_title, safe_img, safe_url):
571
  video_url = post.get('video', '')
 
572
  # Use text for description if available
573
+ description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
574
 
575
  # Build canonical URL preserving original query format if url was provided
576
  if safe_url and safe_url != '/':
 
585
  <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
586
  <title>{_clean(safe_title)}</title>
587
  <meta property="og:title" content="{_clean(safe_title)}">
588
+ <meta property="og:image" content="{_clean(safe_img)}">
589
  <meta property="og:description" content="{description}">
590
  <meta property="og:url" content="{canonical_url}">
591
  <link rel="canonical" href="{canonical_url}">
 
638
  return _render_video_page(post, safe_title, safe_img, safe_url)
639
 
640
  # Otherwise redirect
 
641
  return HTMLResponse(f'''<!DOCTYPE html>
642
  <html lang="vi">
643
  <head>
 
645
  <meta name="viewport" content="width=device-width,initial-scale=1">
646
  <title>{_clean(safe_title)}</title>
647
  <meta property="og:title" content="{_clean(safe_title)}">
648
+ <meta property="og:image" content="{_clean(safe_img)}">
649
+ <meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
650
  <meta property="og:url" content="{SPACE}/s/{slug}">
651
  <link rel="canonical" href="{SPACE}/s/{slug}">
652
  <meta http-equiv="refresh" content="0;url={safe_url}">
 
698
  return _render_video_page(post, safe_title, safe_img, safe_url)
699
 
700
  # Fallback: redirect to original URL
 
701
  return HTMLResponse(f'''<!DOCTYPE html>
702
  <html lang="vi">
703
  <head>
 
705
  <meta name="viewport" content="width=device-width,initial-scale=1">
706
  <title>{safe_title}</title>
707
  <meta property="og:title" content="{safe_title}">
708
+ <meta property="og:image" content="{safe_img}">
709
+ <meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
710
  <meta property="og:url" content="{SPACE}/s?url={quote(safe_url)}">
711
  <link rel="canonical" href="{SPACE}/s?url={quote(safe_url)}">
712
  <meta http-equiv="refresh" content="0;url={safe_url}">
 
853
 
854
  def _load_wall_posts():
855
  with _wl_lock:
856
+ return _lj(WALL_FILE)
 
 
 
857
 
858
  def _save_wall_posts(posts):
859
  with _wl_lock:
860
  _sj(WALL_FILE, posts)
861
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
862
  @app.get('/api/wall')
863
  def api_wall():
864
+ posts = _load_wall_posts()
865
  if not posts:
866
  return JSONResponse({"posts": []})
867
  return JSONResponse({"posts": posts})
 
929
  text = body.get('text', '') or ''
930
  img = body.get('img', None)
931
  source = body.get('source', 'user') or 'user'
 
932
  post_id = str(uuid.uuid4())[:12]
 
 
 
 
 
 
 
 
 
 
933
  post = {
934
  "id": post_id,
935
  "title": title[:200],
936
  "text": text[:2000],
937
  "source": source,
938
+ "video": None,
939
  "img": img,
940
+ "images": [],
 
 
 
 
 
 
941
  "created": int(time.time()),
942
  "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
943
  }
 
 
944
  posts = _load_wall_posts()
945
  if not isinstance(posts, list):
946
  posts = []
947
+ posts.insert(0, post)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
948
  posts = posts[:200]
949
  _save_wall_posts(posts)
950
  return JSONResponse({"post": post, "ok": True})
 
960
  media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
961
  return FileResponse(video_path, media_type=media_type)
962
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
963
  @app.delete('/api/wall/{post_id}')
964
  def api_wall_delete(post_id: str):
965
  posts = _load_wall_posts()
 
977
  return JSONResponse({"ok": True})
978
  return JSONResponse({"error": "Post not found"}, status_code=404)
979
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
980
  # ===== LANGUAGE & EMOTION DETECTION =====
981
  import random as _random2
982
  from urllib.parse import quote as _quote2
 
1540
 
1541
  # Lấy keywords từ QUAN ĐIỂM CÁ NHÂN để tìm nguồn tin chính xác
1542
  keywords = _extract_keywords_from_opinion(opinion, max_keywords=5)
1543
+ # Luôn lấy hot topics làm nguồn bổ sung, ưu tiên keywords liên quan đến opinion
1544
+ hot = _get_hot_topics()
1545
+ hot_topics = [t.get("topic", "") for t in hot[:10] if t.get("topic")]
1546
  if keywords:
1547
+ # Merge opinion keywords first, then hot topics that are relevant
1548
  selected_topics = keywords[:3]
1549
+ for ht in hot_topics:
1550
+ if ht not in selected_topics and len(selected_topics) < 6:
1551
+ selected_topics.append(ht)
1552
  else:
1553
+ selected_topics = hot_topics[:6]
 
 
1554
 
1555
  # Tìm nguồn tin
1556
  all_sources = []
 
1659
  "- Mỗi slide phải khác nhau, không lặp ý\n"
1660
  "- Độ dài: 300-600 từ"
1661
  )
1662
+ ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1663
  except:
1664
  pass
1665
 
 
1800
  if not selected_topics:
1801
  # Lấy keywords từ QUAN ĐIỂM CÁ NHÂN để tìm nguồn tin chính xác
1802
  keywords = _extract_keywords_from_opinion(opinion, max_keywords=5)
1803
+ hot = _get_hot_topics()
1804
+ hot_topics = [t.get("topic", "") for t in hot[:10] if t.get("topic")]
1805
  if keywords:
1806
  selected_topics = keywords[:3]
1807
+ for ht in hot_topics:
1808
+ if ht not in selected_topics and len(selected_topics) < 6:
1809
+ selected_topics.append(ht)
1810
  else:
1811
+ selected_topics = hot_topics[:6]
 
1812
 
1813
  all_sources = []
1814
  seen_urls = set()
 
1889
  "\n"
1890
  "QUAN TRỌNG: Mỗi slide là 1 đoạn văn HOÀN CHỈNH, 2-4 câu, PHẢI KẾT THÚC BẰNG DẤU CHẤM (.). Kết hợp QUAN ĐIỂM + NGUỒN TIN. Không gạch đầu dòng. Viết liền mạch. 300-600 từ."
1891
  )
1892
+ ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1893
  except:
1894
  pass
1895
 
 
2244
 
2245
  threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
2246
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2247
  @app.get('/api/debug/auto_schedule')
2248
  async def debug_auto_schedule(slot: str = '07:00'):
2249
  """Manually trigger auto scheduler for debugging."""
 
2363
 
2364
  return {"shorts": shorts, "count": len(shorts)}
2365
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2366
  app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
bdp_full.html DELETED
@@ -1,1250 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <title>Hull City vs MU: Quỷ Đỏ vào hang bắt hổ - Bongdaplus.vn</title>
5
-
6
-
7
- <meta name="MobileOptimized" content="100" />
8
- <meta name="viewport" content="width=device-width, initial-scale=1" />
9
- <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
10
- <meta http-equiv="content-language" content="vi" />
11
- <meta http-equiv="refresh" content="300" />
12
- <meta property="fb:pages" content="1738672379699789" />
13
- <meta property="dable:item_id" content="2015111202028" />
14
- <meta property="og:image" itemprop="thumbnailUrl" content="https://bongdaplus.vn/img/logo-daily.png" />
15
- <link href="/favicon.ico" rel="shortcut icon" type="image/x-icon" />
16
- <link href="/favicon.svg" rel="icon" sizes="any" type="image/svg+xml" />
17
- <link href="/apple-touch-icon.png" rel="apple-touch-icon" />
18
- <!--iPhone icons-->
19
- <link href="/logo-57.png" rel="apple-touch-icon-precomposed" sizes="57x57" />
20
- <link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="114x114" />
21
- <!--iPad icons-->
22
- <link href="/logo-72.png" rel="apple-touch-icon-precomposed" sizes="72x72" />
23
- <link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="114x114" />
24
- <!--Android icon-->
25
- <link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="1x1" />
26
- <!--Symbian icon-->
27
- <link href="/logo-57.png" rel="nokia-touch-icon" sizes="57x57" />
28
- <link rel="preconnect" href="https://fonts.googleapis.com">
29
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
30
- <link href="https://fonts.googleapis.com/css2?family=Mulish:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap" rel="stylesheet">
31
- <link href="https://fonts.googleapis.com/css2?family=Noto+Serif:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
32
- <link href="/css/premium.css?id=0000&amp;v=ivL0rSTnUIBx45p3hw7UIAzbXjyQ9rgPVSyjqPglN_o" rel="stylesheet">
33
-
34
- <script async src="https://www.googletagmanager.com/gtag/js?id=UA-20374657-1"></script>
35
- <script>
36
- window.dataLayer = window.dataLayer || [];
37
- function gtag() { dataLayer.push(arguments); }
38
- gtag('js', new Date());
39
-
40
- gtag('config', 'UA-20374657-1');
41
- </script>
42
- <!-- Google tag (gtag.js) -->
43
- <script async src="https://www.googletagmanager.com/gtag/js?id=G-Y1DBNQWMVW"></script>
44
- <script>
45
- window.dataLayer = window.dataLayer || [];
46
- function gtag() { dataLayer.push(arguments); }
47
- gtag('js', new Date());
48
-
49
- gtag('config', 'G-Y1DBNQWMVW');
50
- </script>
51
-
52
- <script async src="https://static.amcdn.vn/tka/cdn.js" type="text/javascript"></script>
53
- <script>var arfAsync = arfAsync || [];</script>
54
- <script id="arf-core-js" onerror="window.arferrorload=true;" src="//media1.admicro.vn/cms/Arf.min.js" async></script>
55
- <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3619133031508264" crossorigin="anonymous"></script>
56
- </head>
57
- <body>
58
- <script type="application/ld+json">
59
- {
60
- "@context": "https://schema.org",
61
- "@type": "Organization",
62
- "name": "TẠP CHÍ ĐIỆN TỬ BÓNG ĐÁ - BONGDAPLUS",
63
- "url": "https://bongdaplus.vn",
64
- "logo": "https://bongdaplus.vn/img/logo.png",
65
- "contactPoint": {
66
- "@type": "ContactPoint",
67
- "telephone": "(84.24)35541188",
68
- "contactType": "customer service",
69
- "areaServed": "VN",
70
- "availableLanguage": "Vietnamese"
71
- },
72
- "sameAs": [
73
- "https://www.facebook.com/baobongda",
74
- "https://www.youtube.com/channel/UCvDYMBE6GipX2VGacqOU2_Q",
75
- "https://bongdaplus.vn",
76
- "https://www.pinterest.com/bongdaplus",
77
- "https://soundcloud.com/baobongda"
78
- ]
79
- }
80
- </script>
81
- <header>
82
- <center><div id="ADMTOP" class="banner admob" banner-adpos="top"></div></center>
83
-
84
- <div class="cont-wrap">
85
- <div class="mmnu-nav">
86
- <button class="ico mnu-sch mmnu-trigger hide-w"></button>
87
- <button class="ico menubar mmnu-trigger hide-m"></button>
88
- <button class="ico close mmnu-closer hide"></button>
89
- </div>
90
- <a class="logo" href="/"></a>
91
- <button class="ico bell hide-w hide"></button>
92
-
93
- <div class="authen-nav dropper">
94
- <a class="member">
95
- <span class="avatar">
96
- </span>
97
- </a>
98
- <div class="drop-down rgt">
99
- <ul class="lst">
100
-
101
- <li><a href="#" data-showpaner="#loginview">Đăng nhập</a></li>
102
- <li><a href="https://member.bongdaplus.vn/Identity/Account/Register?returnUrl=%2F" target="_blank">Tạo tài khoản</a></li>
103
-
104
-
105
- </ul>
106
- </div>
107
- </div>
108
- </div>
109
- </header>
110
- <input type="hidden" id="catrefid" value="video" />
111
- <input type="hidden" id="objectid" value="98267" />
112
- <input type="hidden" id="objecttype" value="1" />
113
- <input type="hidden" id="parentid" value="0" />
114
- <input type="hidden" id="commentnum" value="0" />
115
- <img src="/imgthumbnail/98267/1/1" alt="" height="0" width="0" />
116
-
117
- <main>
118
- <div class="nav-menu">
119
- <div class="menu-bar">
120
- <div class="cont-wrap">
121
- <div class="menu-side">
122
- <ul class="bar-w">
123
- <li><a class="ico home" href="/"></a></li>
124
- <li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
125
- <li class="popover">
126
- <a title="Việt Nam" href="/bong-da-viet-nam">Việt Nam</a>
127
- <div class="drop-down">
128
- <ul class="lst">
129
- <li><a title="V-League" href="/v-league">V-League</a></li>
130
- <li><a title="Hạng Nhất" href="/hang-nhat-quoc-gia">Hạng Nhất</a></li>
131
- <li><a title="Cúp Quốc gia" href="/cup-quoc-gia">Cúp Quốc gia</a></li>
132
- <li><a title="ĐTQG" href="/doi-tuyen-quoc-gia-viet-nam">ĐTQG</a></li>
133
- <li><a title="Bóng đá Nữ" href="/bong-da-nu-viet-nam">Bóng đá Nữ</a></li>
134
- <li><a title="U17 Quốc gia" href="/u17-viet-nam">U17 Quốc gia</a></li>
135
- <li><a title="U19 Quốc Gia" href="/u19-viet-nam">U19 Quốc Gia</a></li>
136
- <li><a title="U21 Quốc Gia" href="/u21-viet-nam">U21 Quốc Gia</a></li>
137
- <li><a title="Các đội tuyển trẻ" href="/cac-doi-tuyen-tre-viet-nam">Các đội tuyển trẻ</a></li>
138
- <li><a title="Futsal" href="/futsal">Futsal</a></li>
139
- <li><a title="Phong trào" href="/bong-da-phong-trao">Phong trào</a></li>
140
- <li><a title="Đơn vị Kết nghĩa" href="/ket-nghia-anh-em">Đơn vị Kết nghĩa</a></li>
141
- </ul>
142
- </div>
143
- </li>
144
- <li class="popover">
145
- <a title="Chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a>
146
- <div class="drop-down">
147
- <ul class="lst">
148
- <li><a title="Tin chuyển nhượng Ngoại Hạng Anh" href="/tin-chuyen-nhuong-ngoai-hang-anh">Tin chuyển nhượng Ngoại Hạng Anh</a></li>
149
- <li><a title="Tin chuyển nhượng V-league" href="/tin-chuyen-nhuong-v-league">Tin chuyển nhượng V-league</a></li>
150
- </ul>
151
- </div>
152
- </li>
153
- <li><a title="Champions League" href="/champions-league-cup-c1">Champions League</a></li>
154
- <li class="popover">
155
- <a title="Tây Ban Nha" href="/bong-da-tay-ban-nha">Tây Ban Nha</a>
156
- <div class="drop-down">
157
- <ul class="lst">
158
- <li><a title="VĐQG Tây Ban Nha" href="/la-liga">VĐQG Tây Ban Nha</a></li>
159
- <li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
160
- <li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
161
- </ul>
162
- </div>
163
- </li>
164
- <li class="popover">
165
- <a title="Pháp" href="/bong-da-phap">Pháp</a>
166
- <div class="drop-down">
167
- <ul class="lst">
168
- <li><a title="VĐQG Pháp" href="/ligue-1">VĐQG Pháp</a></li>
169
- <li><a title="Cúp Quốc gia Pháp" href="/cup-quoc-gia-phap">Cúp Quốc gia Pháp</a></li>
170
- <li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
171
- </ul>
172
- </div>
173
- </li>
174
- <li class="popover">
175
- <a title="Italia" href="/bong-da-y">Italia</a>
176
- <div class="drop-down">
177
- <ul class="lst">
178
- <li><a title="VĐQG Italia" href="/serie-a">VĐQG Italia</a></li>
179
- <li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
180
- <li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
181
- </ul>
182
- </div>
183
- </li>
184
- <li class="popover">
185
- <a title="Thể thao" href="/the-thao">Thể thao</a>
186
- <div class="drop-down">
187
- <ul class="lst">
188
- <li><a title="eSports" href="/esports">eSports</a></li>
189
- <li><a title="Tennis" href="/tennis">Tennis</a></li>
190
- <li><a title="Pickleball" href="/pickleball">Pickleball</a></li>
191
- <li><a title="MMA" href="/mma">MMA</a></li>
192
- <li><a title="Đua xe F1" href="/dua-xe-f1">Đua xe F1</a></li>
193
- <li><a title="Bóng rổ" href="/bong-ro">Bóng rổ</a></li>
194
- <li><a title="Olympic 2028" href="/olympic">Olympic 2028</a></li>
195
- </ul>
196
- </div>
197
- </li>
198
- <li><a title="Nhận định bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
199
- <li><a title="Multimedia" href="/video">Multimedia</a></li>
200
- <li class="hide"><a title="Bongdaplus Premium" href="/premium">Premium <i class="ico crown"></i></a></li>
201
- </ul>
202
- </div>
203
- <div class="menu-nav">
204
- <a class="ico home hide-w" href="/"></a>
205
- <ul class="nav">
206
- <li class="hide-m"><i class="sepr"></i></li>
207
- <li><a href="/tin-moi">Mới nhất</a></li>
208
- <li class="hide-w"><a title="Bongdaplus Premium" href="/premium">Premium <i class="ico crown"></i></a></li>
209
- <li><a title="Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
210
- <li class="hide-w"><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
211
- <li class="hide-w"><a title="Việt Nam" href="/bong-da-viet-nam">Việt Nam</a></li>
212
- <li class="hide-w"><a title="Chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
213
- <li class="hide-w"><a title="Champions League" href="/champions-league-cup-c1">Champions League</a></li>
214
- <li class="hide-w"><a title="Tây Ban Nha" href="/bong-da-tay-ban-nha">Tây Ban Nha</a></li>
215
- <li class="hide-w"><a title="Pháp" href="/bong-da-phap">Pháp</a></li>
216
- <li class="hide-w"><a title="Italia" href="/bong-da-y">Italia</a></li>
217
- <li class="hide-w"><a title="Thể thao" href="/the-thao">Thể thao</a></li>
218
- <li class="hide-w"><a title="Nhận định" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
219
- <li class="hide-w"><a title="Thế giới" href="/the-gioi">Thế giới</a></li>
220
- <li class="hide-w"><a title="Multimedia" href="/video">Multimedia</a></li>
221
- <li class="hide-w"><a title="Bóng đá Cuộc sống" href="/bong-da-cuoc-song">Bóng đá Cuộc sống</a></li>
222
- </ul>
223
- <div class="srch-bar hide-m">
224
- <button class="ico close"></button>
225
- <form class="srch-form" action="/tim-kiem">
226
- <input class="pattern toggler" name="q" type="text" placeholder="Nhập nội dung cần tìm" />
227
- </form>
228
- </div>
229
- </div>
230
- </div>
231
- </div>
232
- <div class="clr"></div>
233
- </div>
234
- <div class="mob-menu">
235
- <div class="mmnu-cap">
236
- <button class="ico close" data-action="hide"></button>
237
- <a class="logo" href="/"></a>
238
- </div>
239
- <form class="mmnu-sch " action="/tim-kiem">
240
- <input type="text" name="q">
241
- <button class="ico search" type="submit"></button>
242
- </form>
243
- <div class="mmnu-lst">
244
- <div class="menu-row">
245
- <ul class="bar">
246
- <li class="exp">
247
- <a class="major" title="Tin mới nhất" href="/tin-moi"><span class="ico blinkr"></span> Mới nhất</a>
248
- </li>
249
- <li>
250
- <ul class="lst">
251
- <li><a class="fst" title="Nh���n định Bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
252
- </ul>
253
- </li>
254
- <li>
255
- <ul class="lst">
256
- <li><a class="fst" title="Lịch thi đấu bóng đá" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
257
- </ul>
258
- </li>
259
- <li class="exp"></li>
260
- <li class="exp"><a class="major" title="Bóng đá Plus Premium" href="/premium">Bóng đá Premium <i class="ico crown"></i></a></li>
261
- <li>
262
- <ul class="lst">
263
- <li><a class="fst" title="Bóng đá Việt Nam" href="/bong-da-viet-nam">Bóng đá Việt Nam</a></li>
264
- <li><a title="Đội tuyển Việt Nam" href="/doi-tuyen-quoc-gia-viet-nam">Đội tuyển Việt Nam</a></li>
265
- <li><a title="V.League" href="/v-league">V.League</a></li>
266
- <li><a title="Hạng Nhất" href="/hang-nhat-viet-nam">Hạng Nhất</a></li>
267
- <li><a title="Cúp Quốc gia" href="/cup-quoc-gia-viet-nam">Cúp Quốc gia</a></li>
268
- <li><a title="Bóng đá nữ" href="/bong-da-nu-viet-nam">Bóng đá nữ</a></li>
269
- <li><a title="Futsal" href="/futsal">Futsal</a></li>
270
- <li><a title="Phong trào" href="/bong-da-phong-trao">Bóng đá Phong trào</a></li>
271
- <li><a title="Chuyển nhượng V.League" href="/tin-chuyen-nhuong-v-league">Chuyển nhượng</a></li>
272
- </ul>
273
- </li>
274
- <li>
275
- <ul class="lst">
276
- <li><a class="fst" title="Bóng đá Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
277
- <li><a title="Đội tuyển Anh" href="/doi-tuyen-anh">Đội tuyển Anh</a></li>
278
- <li><a title="Ngoại hạng Anh" href="/ngoai-hang-anh">Ngoại hạng Anh</a></li>
279
- <li><a title="FA Cup" href="/fa-cup">FA Cup</a></li>
280
- <li><a title="League Cup" href="/cup-lien-doan-anh">League Cup</a></li>
281
- </ul>
282
- <div class="clx"></div>
283
- <ul class="lst">
284
- <li><a class="fst" title="Tây Ban nha" href="/bong-da-tay-ban-nha">Tây Ban nha</a></li>
285
- <li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
286
- <li><a title="La Liga" href="/la-liga">La Liga</a></li>
287
- <li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
288
- </ul>
289
- </li>
290
- <li class="exp"></li>
291
- <li>
292
- <ul class="lst">
293
- <li><a class="fst" title="Bóng đá Pháp" href="/bong-da-phap">Bóng đá Pháp</a></li>
294
- <li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
295
- <li><a title="Ligue 1" href="/ligue-1">Ligue 1</a></li>
296
- <li><a title="Cúp QG Pháp" href="/cup-quoc-gia-phap">Cúp QG Pháp</a></li>
297
- <li><a title="Cúp Liên đoàn Pháp" href="/cup-lien-doan-phap-tags">Cúp Liên đoàn Pháp</a></li>
298
- </ul>
299
- </li>
300
- <li>
301
- <ul class="lst">
302
- <li><a class="fst" href="#">Bóng đá Khu vực</a></li>
303
- <li><a title="Asian Cup" href="/asian-cup">Asian Cup</a></li>
304
- <li><a title="Asian Games" href="/asian-games">Asian Games</a></li>
305
- <li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
306
- <li><a title="SEA Games" href="/sea-games">SEA Games</a></li>
307
- </ul>
308
- </li>
309
- <li class="exp"></li>
310
- <li>
311
- <ul class="lst">
312
- <li><a class="fst" title="Bóng đá Italia" href="/bong-da-y">Bóng đá Italia</a></li>
313
- <li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
314
- <li><a title="Serie A" href="/serie-a">Serie A</a></li>
315
- <li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
316
- </ul>
317
- </li>
318
- <li>
319
- <ul class="lst">
320
- <li><a class="fst" title="Bóng đá Đức" href="/bong-da-duc">Bóng đá Đức</a></li>
321
- <li><a title="Đội tuyển Đức" href="/doi-tuyen-duc">Đội tuyển Đức</a></li>
322
- <li><a title="Bundesliga" href="/bundesliga">Bundesliga</a></li>
323
- <li><a title="Cúp QG Đức" href="/cup-quoc-gia-duc">Cúp QG Đức</a></li>
324
- </ul>
325
- </li>
326
- <li class="exp"></li>
327
- <li>
328
- <ul class="lst">
329
- <li><a class="fst" href="#">Nội dung Đặc biệt</a></li>
330
- <li><a title="Big Story" href="/emagazine">Big Story</a></li>
331
- <li><a title="X-File" href="/x-file">X-File</a></li>
332
- <li><a title="Infographic" href="/infographic">Infographic</a></li>
333
- <li><a title="Interactive" href="/interactive">Interactive</a></li>
334
- </ul>
335
- </li>
336
- <li>
337
- <ul class="lst">
338
- <li><a class="fst" href="#">Giải đấu CLB</a></li>
339
- <li><a title="Champions Leage" href="/champions-league-cup-c1">Champions League</a></li>
340
- <li><a title="Europa League" href="/europa-league">Europa League</a></li>
341
- <li><a title="Club World Cup" href="/fifa-club-world-cup-tags">Club World Cup</a></li>
342
- <li><a title="Europa Conference League" href="/europa-conference-league">Europa Conf League</a></li>
343
- </ul>
344
- </li>
345
- <li class="exp"></li>
346
- <li>
347
- <ul class="lst">
348
- <li><a class="fst" href="#">Chuyên đề</a></li>
349
- <li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
350
- <li><a title="Hậu trường bóng đá" href="/hau-truong-bong-da">Hậu trường</a></li>
351
- <li><a title="Điểm tin" href="/diem-tin">Điểm tin</a></li>
352
- <li><a title="Showbiz Ngôi sao" href="/ngoi-sao-showbiz">Showbiz Ngôi sao</a></li>
353
- <li><a title="Góc check var" href="/goc-check-var">Góc check var</a></li>
354
- <li><a title="Công nghệ" href="/cong-nghe">Công nghệ</a></li>
355
- <li><a title="Thông tin Thị trường" href="/thi-truong">Thị trường</a></li>
356
- <li><a title="Bóng đá & Cuộc sống" href="/bong-da-cuoc-song">Bóng đá & Cuộc sống</a></li>
357
- </ul>
358
- </li>
359
- <li>
360
- <ul class="lst">
361
- <li><a class="fst" href="#">Đội tuyển</a></li>
362
- <li><a title="World Cup nữ 2028" href="/world-cup-nu-2023-tags">World Cup nữ 2028</a></li>
363
- <li><a title="World Cup" href="/world-cup">World Cup 2026</a></li>
364
- <li><a title="European Championship 2028" href="/euro-cup-chau-au">EURO 2028</a></li>
365
- <li><a title="Nations League" href="/nations-league">Nations League</a></li>
366
- <li><a title="Copa America" href="/copa-america">Copa America</a></li>
367
- <li><a title="CAN" href="/can">CAN</a></li>
368
- <li><a title="Olympic 2028" href="/olympic">Olympic 2028</a></li>
369
- </ul>
370
- </li>
371
- <li class="exp"></li>
372
- <li>
373
- <ul class="lst">
374
- <li><a class="fst" title="Multimedia" href="/video">Multimedia</a></li>
375
- </ul>
376
- </li>
377
- <li>
378
- <ul class="lst">
379
- <li>&nbsp;</li>
380
- </ul>
381
- </li>
382
- <li class="exp"></li>
383
- <li>
384
- <ul class="lst">
385
- <li><a class="fst" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
386
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Lịch thi đấu V.League">V.League</a></li>
387
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Lịch thi đấu Ngoại hạng Anh">Ngoại hạng Anh</a></li>
388
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Lịch thi đấu Ligue 1 Pháp">Ligue 1</a></li>
389
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Lịch thi đấu La Liga Tây Ban Nha">La Liga</a></li>
390
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Lịch thi đấu Bundesliga Đức">Bundesliga</a></li>
391
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-y" title="Lịch thi đấu Serie A Italia">Serie A</a></li>
392
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Lịch thi đấu Champions League">Champions League</a></li>
393
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?europa-league" title="Lịch thi đấu Europa League">Europa League</a></li>
394
- </ul>
395
- </li>
396
- <li>
397
- <ul class="lst">
398
- <li><a class="fst" href="/bang-xep-hang-bong-da">Bảng xếp hạng</a></li>
399
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-viet-nam" title="Bảng xếp hạng V.League">V.League</a></li>
400
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-anh" title="Bảng xếp hạng Ngoại hạng Anh">Ngoại hạng Anh</a></li>
401
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-phap" title="Bảng xếp hạng Ligue 1 Pháp">Ligue 1</a></li>
402
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-tay-ban-nha" title="Bảng xếp hạng La Liga Tây Ban Nha">La Liga</a></li>
403
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-duc" title="Bảng xếp hạng Bundesliga Đức">Bundesliga</a></li>
404
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-y" title="Bảng xếp hạng Serie A Italia">Serie A</a></li>
405
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?champions-league-cup-c1" title="Bảng xếp hạng Champions League">Champions League</a></li>
406
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?europa-league" title="Bảng xếp hạng Europa League">Europa League</a></li>
407
- </ul>
408
- </li>
409
- <li class="exp"></li>
410
- <li><a class="caption" href="https://phui.bongdaplus.vn" target="_blank">Bóng đá phủi</a></li>
411
- <li><a class="caption" href="/tennis">Tennis</a></li>
412
- <li><a class="caption" href="/esports">Esports</a></li>
413
- <li><a class="caption" href="/the-thao">Thể thao +</a></li>
414
- </ul>
415
- </div>
416
- </div>
417
- <div class="mmnu-end tx-cen">
418
- <p>Theo dõi thông tin</p>
419
- <ul class="bar social-lnk">
420
- <li class="hide-m">Theo dõi thông tin</li>
421
- <li><a class="ico vff-lnk" target="_blank" href="https://vff.org.vn/" style="background-size:28px;opacity:1;" rel="nofollow"></a></li>
422
- <li><a class="ico fb-lnk" target="_blank" href="https://www.facebook.com/BaoBongDa/" style="background-size:28px;opacity:1;"></a></li>
423
- <li><a class="ico yt-lnk" target="_blank" href="https://www.youtube.com/channel/UCF0VFBZu-5SRJXMV_FMysEA" style="background-size:28px;opacity:1;"></a></li>
424
- </ul>
425
- </div>
426
- </div>
427
- <div class="mega-menu">
428
- <div class="cont-wrap">
429
- <div class="menu-row">
430
- <ul class="bar">
431
- <li><a class="caption" title="Tin mới nhất" href="/tin-moi">Mới nhất</a></li>
432
- <li>
433
- <ul class="lst">
434
- <li><a class="fst" title="Bóng đá Việt Nam" href="/bong-da-viet-nam">Bóng đá Việt Nam</a></li>
435
- <li><a title="Đội tuyển Việt Nam" href="/doi-tuyen-quoc-gia-viet-nam">Đội tuyển Việt Nam</a></li>
436
- <li><a title="V.League" href="/v-league">V.League</a></li>
437
- <li><a title="Hạng Nhất" href="/hang-nhat-viet-nam">Hạng Nhất</a></li>
438
- <li><a title="Cúp Quốc gia" href="/cup-quoc-gia-viet-nam">Cúp Quốc gia</a></li>
439
- <li><a title="Cúp châu Á" href="/asian-cup">Cúp châu Á</a></li>
440
- <li><a title="Bóng đá nữ" href="/bong-da-nu-viet-nam">Bóng đá nữ</a></li>
441
- <li><a title="Futsal" href="/futsal">Futsal</a></li>
442
- <li><a title="Phong trào" href="/bong-da-phong-trao">Phong trào</a></li>
443
- <li><a title="Chuyển nhượng V.League" href="/tin-chuyen-nhuong-v-league">Chuyển nhượng V.League</a></li>
444
- <li><a title="Tin khác" href="/tin-khac">Tin khác</a></li>
445
- </ul>
446
- </li>
447
-
448
- <li>
449
- <ul class="lst">
450
- <li><a class="fst" title="Bóng đá Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
451
- <li><a title="Đội tuyển Anh" href="/doi-tuyen-anh">Đội tuyển Anh</a></li>
452
- <li><a title="Ngoại hạng Anh" href="/ngoai-hang-anh">Ngoại hạng Anh</a></li>
453
- <li><a title="FA Cup" href="/fa-cup">FA Cup</a></li>
454
- <li><a title="League Cup" href="/cup-lien-doan-anh">League Cup</a></li>
455
- </ul>
456
- <div class="clx"></div>
457
- <ul class="lst">
458
- <li><a class="fst" href="#">Giải đấu CLB</a></li>
459
- <li><a title="Champions Leage" href="/champions-league-cup-c1">Champions League</a></li>
460
- <li><a title="Europa League" href="/europa-league">Europa League</a></li>
461
- <li><a title="Club World Cup" href="/fifa-club-world-cup-tags">Club World Cup</a></li>
462
- <li><a title="Europa Conference League" href="/europa-conference-league">Europa Conference League</a></li>
463
- </ul>
464
- </li>
465
-
466
- <li>
467
- <ul class="lst">
468
- <li><a class="fst" title="Bóng đá Italia" href="/bong-da-y">Bóng đá Italia</a></li>
469
- <li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
470
- <li><a title="Serie A" href="/serie-a">Serie A</a></li>
471
- <li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
472
- </ul>
473
- <div class="clx"></div>
474
- <ul class="lst">
475
- <li><a class="fst" href="#">Đội tuyển</a></li>
476
- <li><a title="World Cup nữ 2028" href="/world-cup-nu-2023-tags">World Cup nữ 2028</a></li>
477
- <li><a title="World Cup" href="/world-cup">World Cup 2026</a></li>
478
- <li><a title="European Championship 2028" href="/euro-cup-chau-au">EURO 2028</a></li>
479
- <li><a title="Nations League" href="/nations-league">Nations League</a></li>
480
- <li><a title="Copa America" href="/copa-america">Copa America</a></li>
481
- <li><a title="CAN" href="/can">CAN</a></li>
482
- <li><a title="Olympic" href="/olympic">Olympic 2028</a></li>
483
- </ul>
484
- </li>
485
-
486
- <li>
487
- <ul class="lst">
488
- <li><a class="fst" title="Bóng đá Pháp" href="/bong-da-phap">Bóng đá Pháp</a></li>
489
- <li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
490
- <li><a title="Ligue 1" href="/ligue-1">Ligue 1</a></li>
491
- <li><a title="Cúp QG Pháp" href="/cup-quoc-gia-phap">Cúp QG Pháp</a></li>
492
- <li><a title="Cúp Liên đoàn Pháp" href="/cup-lien-doan-phap-tags">Cúp Liên đoàn Pháp</a></li>
493
- </ul>
494
- <div class="clx"></div>
495
- <ul class="lst">
496
- <li><a class="fst" href="#">Khu vực</a></li>
497
- <li><a title="Asian Cup" href="/asian-cup">Asian Cup</a></li>
498
- <li><a title="Asian Games" href="/asian-games">Asian Games</a></li>
499
- <li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
500
- <li><a title="SEA Games" href="/sea-games">SEA Games</a></li>
501
- </ul>
502
- </li>
503
-
504
- <li>
505
- <ul class="lst">
506
- <li><a class="fst" title="Tây Ban nha" href="/bong-da-tay-ban-nha">Tây Ban nha</a></li>
507
- <li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
508
- <li><a title="La Liga" href="/la-liga">La Liga</a></li>
509
- <li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
510
- </ul>
511
- <div class="clx"></div>
512
- <ul class="lst">
513
- <li><a class="fst" href="#">Chuyên đề</a></li>
514
- <li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
515
- <li><a title="Hậu trường bóng đá" href="/hau-truong-bong-da">Hậu trường</a></li>
516
- <li><a title="Điểm tin" href="/diem-tin">Điểm tin</a></li>
517
- <li><a title="Showbiz Ngôi sao" href="/ngoi-sao-showbiz">Showbiz Ngôi sao</a></li>
518
- <li><a title="Góc check var" href="/goc-check-var">Góc check var</a></li>
519
- <li><a title="Công nghệ" href="/cong-nghe">Công nghệ</a></li>
520
- <li><a title="Thông tin Thị trường" href="/thi-truong">Thị trường</a></li>
521
- </ul>
522
- </li>
523
-
524
- <li>
525
- <ul class="lst">
526
- <li><a class="fst" title="Bóng đá Đức" href="/bong-da-duc">Bóng đá Đức</a></li>
527
- <li><a title="Đội tuyển Đức" href="/doi-tuyen-duc">Đội tuyển Đức</a></li>
528
- <li><a title="Bundesliga" href="/bundesliga">Bundesliga</a></li>
529
- <li><a title="Cúp QG Đức" href="/cup-quoc-gia-duc">Cúp QG Đức</a></li>
530
- </ul>
531
- <div class="clx"></div>
532
- <ul class="lst">
533
- <li><a class="fst" href="#">Đặc biệt</a></li>
534
- <li><a title="Big Story" href="/emagazine">Big Story</a></li>
535
- <li><a title="X-File" href="/x-file">X-File</a></li>
536
- <li><a title="Infographic" href="/infographic">Infographic</a></li>
537
- <li><a title="Interactive" href="/interactive">Interactive</a></li>
538
- </ul>
539
- </li>
540
- </ul>
541
- </div>
542
-
543
- <div class="menu-row">
544
- <ul class="bar">
545
- <li>&nbsp;</li>
546
- <li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Tin chuyển nhượng</a></li>
547
- <li><a title="Bóng đá & Cuộc sống" href="/bong-da-cuoc-song">Bóng đá & Cuộc sống</a></li>
548
- <li><a title="Thông tin Thị trường" href="/thi-truong">Thông tin Thị trường</a></li>
549
- <li><a title="Tin tức Công nghệ" href="/cong-nghe">Công nghệ</a></li>
550
- <li><a title="Tin tức Xe auto bike" href="/xe-co">Xe auto</a></li>
551
- </ul>
552
- </div>
553
-
554
- <div class="menu-row">
555
- <ul class="bar">
556
- <li>&nbsp;</li>
557
- <li><a class="caption" title="Nhận định Bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
558
- <li><a class="caption" title="Tư vấn Bóng đá" href="/soi-keo-bong-da">Tư vấn</a></li>
559
- <li><a class="caption" title="Multimedia" href="/video">Multimedia</a></li>
560
- <li><a class="caption" title="Youtube" target="_blank" href="https://www.youtube.com/channel/UCF0VFBZu-5SRJXMV_FMysEA">Youtube</a></li>
561
- <li><a class="caption" title="Facebook fanpage" target="_blank" href="https://www.facebook.com/BaoBongDa">FB Fanpage</a></li>
562
- <li>
563
- <a class="caption" title="Premium" href="/premium">Premium <i class="ico crown"></i></a>
564
- </li>
565
- </ul>
566
- </div>
567
-
568
- <div class="menu-row">
569
- <ul class="bar">
570
- <li><a class="caption" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
571
- <li>
572
- <ul class="lst">
573
- <li><a class="fst" href="/lich-thi-dau-bong-da">Lịch thi đấu hôm nay</a></li>
574
- <li><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Lịch thi đấu V.League">V.League</a></li>
575
- <li><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Lịch thi đấu Ngoại hạng Anh">Ngoại hạng Anh</a></li>
576
- <li><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Lịch thi đấu Ligue 1 Pháp">Ligue 1</a></li>
577
- <li><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Lịch thi đấu La Liga Tây Ban Nha">La Liga</a></li>
578
- <li><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Lịch thi đấu Bundesliga Đức">Bundesliga</a></li>
579
- <li><a href="/lich-thi-dau-bong-da?bong-da-y" title="Lịch thi đấu Serie A Italia">Serie A</a></li>
580
- <li><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Lịch thi đấu Champions League">Champions League</a></li>
581
- <li><a href="/lich-thi-dau-bong-da?europa-league" title="Lịch thi đấu Europa League">Europa League</a></li>
582
- </ul>
583
- </li>
584
-
585
- <li>
586
- <ul class="lst">
587
- <li><a class="fst" href="/ket-qua-bong-da">Kết quả mới nhất</a></li>
588
- <li><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Kết quả V.League">V.League</a></li>
589
- <li><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Kết quả Ngoại hạng Anh">Ngoại hạng Anh</a></li>
590
- <li><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Kết quả Ligue 1 Pháp">Ligue 1</a></li>
591
- <li><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Kết quả La Liga Tây Ban Nha">La Liga</a></li>
592
- <li><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Kết quả Bundesliga Đức">Bundesliga</a></li>
593
- <li><a href="/lich-thi-dau-bong-da?bong-da-y" title="Kết quả Serie A Italia">Serie A</a></li>
594
- <li><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Kết quả Champions League">Champions League</a></li>
595
- <li><a href="/lich-thi-dau-bong-da?europa-league" title="Kết quả Europa League">Europa League</a></li>
596
- </ul>
597
- </li>
598
-
599
- <li>
600
- <ul class="lst">
601
- <li><a class="fst" href="#">Bảng xếp hạng</a></li>
602
- <li><a href="/bang-xep-hang-bong-da?bong-da-viet-nam" title="Bảng xếp hạng V.League">V.League</a></li>
603
- <li><a href="/bang-xep-hang-bong-da?bong-da-anh" title="Bảng xếp hạng Ngoại hạng Anh">Ngoại hạng Anh</a></li>
604
- <li><a href="/bang-xep-hang-bong-da?bong-da-phap" title="Bảng xếp hạng Ligue 1 Pháp">Ligue 1</a></li>
605
- <li><a href="/bang-xep-hang-bong-da?bong-da-tay-ban-nha" title="Bảng xếp hạng La Liga Tây Ban Nha">La Liga</a></li>
606
- <li><a href="/bang-xep-hang-bong-da?bong-da-duc" title="Bảng xếp hạng Bundesliga Đức">Bundesliga</a></li>
607
- <li><a href="/bang-xep-hang-bong-da?bong-da-y" title="Bảng xếp hạng Serie A Italia">Serie A</a></li>
608
- <li><a href="/bang-xep-hang-bong-da?champions-league-cup-c1" title="Bảng xếp hạng Champions League">Champions League</a></li>
609
- <li><a href="/bang-xep-hang-bong-da?europa-league" title="Bảng xếp hạng Europa League">Europa League</a></li>
610
- </ul>
611
- </li>
612
-
613
- <li>
614
- <ul class="lst">
615
- <li><a class="caption" href="https://phui.bongdaplus.vn" target="_blank">Bóng đá phủi</a></li>
616
- <li><a class="caption" href="/tennis">Tennis</a></li>
617
- </ul>
618
- </li>
619
- <li>
620
- <ul class="lst">
621
- <li><a class="caption" href="/esports">Esports</a></li>
622
- <li><a class="caption" href="/the-thao">Thể thao +</a></li>
623
- </ul>
624
- </li>
625
-
626
- <li>
627
- <ul class="lst">
628
- <li>
629
- <div class="copyr">
630
- <div class="splt hide-w"></div>
631
- <p><b class="allcap">Tạp chí Bóng Đá</b></p>
632
- <p>Giấy phép số 48/GP-BTTTT cấp ngày 05/02/2020</p>
633
- <p>&nbsp;</p>
634
- <p>
635
- Tổng biên tập:<br /> <b>Vũ Khắc Sơn</b><br />
636
- Phó tổng biên tập:<br /> <b>Nguyễn Hà Thanh</b>
637
- </p>
638
- <p>&nbsp;</p>
639
- <p>
640
- <a class="contact-showr" href="#">Thông tin tòa soạn</a><br />
641
- <a class="contact-showr" href="#">Liên hệ quảng cáo</a>
642
- </p>
643
- <p>Liên đoàn BĐVN <a href="https://vff.org.vn/" target="_blank" rel="nofollow"><img alt="VFF" src="/img/logo_Vff.png" style="width:28px;" /></a></p>
644
- </div>
645
- </li>
646
- </ul>
647
- </li>
648
- </ul>
649
- </div>
650
- </div>
651
- </div>
652
- <div class="menu-tags hide-w">
653
- <div class="today"></div>
654
- <div class="mtags">
655
- <a title="#HighlightBóngđá" href="/video/highlight">#HighlightBóngđá</a>
656
- <a title="#PremierLeague" href="/ngoai-hang-anh">#PremierLeague</a>
657
- <a title="#V.League" href="/v-league">#V.League</a>
658
- </div>
659
- </div>
660
-
661
- <div id="fxtSlideCover" class="fixt-strm" style="margin-bottom:5px;">
662
- <div class="cont-wrap">
663
- <a class="brand" href="https://admin.giaminhmedia.vn/www/delivery/cl.php?bannerid=1411&zoneid=328&sig=8300d1283663cec85d90499507ffd94240954967132167c0f302d7388b8f9bb5&dest=http%3A%2F%2Fadmin.giaminhmedia.vn%2Fwww%2Funiscore.php" target="_blank" rel="nofollow" style="margin-left:5px;">
664
- <img alt="" src="https://bongdaplus.vn/img/logouniscore-tiny.png">
665
- </a>
666
- <ul id="fxtSlide" class="nav"></ul>
667
- </div>
668
- </div>
669
-
670
- <div class="clz"></div>
671
- <div id="ADTOP" class="banner adweb admob" banner-adpos="top"></div>
672
-
673
- <section class="bread-crums">
674
- <div class="clz"></div>
675
- <div class="cont-wrap">
676
- <div class="chain-navs">
677
- <a href="/video">Multimedia</a>&raquo;
678
- </div>
679
- </div>
680
- </section>
681
-
682
- <section class="media-box">
683
- <div class="clz"></div>
684
- <div class="cont-wrap video-box">
685
- <div class="row">
686
- <div class="col m12 w8 clip-box">
687
- <div class="play-box rbox">
688
- <iframe id="ytbFrBpl" class="play-frame" src="https://www.youtube.com/embed/Qokb_8w6p0s?si=QNKAZIYxNrtn2hSN" cid="UCmn1v6lrIDZghUfbxXaorUw" vnum="0" class="latestVideoEmbed" title="Bongdaplus trên Youtube" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe>
689
- </div>
690
- </div>
691
-
692
- <div class="col m12 w4 clip-cont">
693
- <div class="ovrscrl">
694
- <div class="clip-info">
695
- <h1 class="title">Hull City vs MU: Quỷ Đỏ vào hang bắt hổ</h1>
696
- <p class="desc">
697
- Mùa giải Premier League 2026/27 của MU sẽ mở màn bằng chuyến làm khách đầy thử thách trước tân binh Hull City tại sân MKM. Cuộc đối đầu Hull City vs MU là màn chạm trán đáng chú ý khi “Đàn Hổ” đang tràn đầy khí thế, trong khi Quỷ đỏ không được phép sảy chân nếu muốn sớm tạo đà cho cuộc đua vô địch.
698
- </p>
699
-
700
- <div>08:07 - 22/08/2026</div>
701
- <div class="editor"></div>
702
- <div class="hash-tags">
703
- <b>Tags:</b>
704
- <a href="/video/hull-city-vs-mu-tags">Hull City vs MU</a>
705
- <a href="/video/nhan-dinh-hull-city-vs-mu-tags">nh&#x1EAD;n &#x111;&#x1ECB;nh Hull City vs MU</a>
706
- <a href="/video/premier-league-tags">Premier League</a>
707
- <a href="/video/mu-tags">MU</a>
708
- </div>
709
- <style>
710
- .emobar .emo:not(:empty) {
711
- background-position: left 10px top 50%;
712
- padding: 0 13px 0 36px;
713
- }
714
- .emobar .emo {
715
- border-radius: 16px;
716
- background-color: #d7d3d3;
717
- background-size: auto 18px;
718
- cursor: pointer;
719
- margin: 0 3px;
720
- padding: 0 16px;
721
- line-height: 32px;
722
- height: 32px;
723
- }
724
- .emopicked {
725
- border: solid 1px #767676;
726
- }
727
- </style>
728
- <div class="emobar tx-cen">
729
- <div class="clx"></div>
730
- <a class="emo like" onclick="btnNewsEmotion_click(1)" id="btnEmo1"> <span id="numemo1"></span></a>
731
- <a class="emo heart" onclick="btnNewsEmotion_click(2)" id="btnEmo2"> <span id="numemo2"></span></a>
732
- <a class="emo star" onclick="btnNewsEmotion_click(4)" id="btnEmo4"> <span id="numemo4"></span></a>
733
- <a class="emo comment" id="ncmt" href="#commenting" data-action="comment" data-act-only="true"></a>
734
- </div>
735
- </div>
736
- </div>
737
- </div>
738
- </div>
739
- </div>
740
-
741
- <div class="clx"></div>
742
- <div class="cont-view">
743
-
744
-
745
-
746
-
747
- <style>
748
- .btnlikedislikecmt {
749
- border-radius: 9px;
750
- border: solid 1px #767676;
751
- background-color: #cecece;
752
- }
753
-
754
- sup {
755
- padding: 3px;
756
- position: initial;
757
- vertical-align: initial;
758
- }
759
-
760
- .TopComment {
761
- border: solid 1px #fdd835;
762
- }
763
-
764
- .TopComment .info .member {
765
- color: red;
766
- }
767
-
768
- .TopComment .info .avatar {
769
- border: solid 1px red;
770
- }
771
-
772
- .comment .summ .replyat {
773
- /*color: #1b5e20;*/
774
- display: inline-block;
775
- margin: 0 8px 0 0;
776
- font-weight: bold;
777
- /* text-decoration: underline; */
778
- }
779
- </style>
780
- <div id="postComment" class="comm-zone">
781
- <a class="anchor" name="#comments"></a>
782
- <div class="clz"></div>
783
- <div class="xcap">Bình luận</div>
784
- <div class="clz"></div>
785
- <div class="comm-post ">
786
- <div id="formcomment" class="comm-form">
787
- <label class="txt-fld">
788
- <textarea id="txtcomment_0" name="txtcomment_0" maxlength="1000" placeholder="Hãy chia sẻ suy nghĩ của bạn..." onclick="showcommentloginform();"></textarea>
789
- </label>
790
- <div class="actions">
791
- <button id="btnComment" class="btn btn-send base" onclick="btnComment_click(0,0)">Gửi</button>
792
- </div>
793
- <center><div class="cmterr"></div></center>
794
- </div>
795
- </div>
796
- <div class="clz"></div>
797
- <div class="comm-frame">
798
- <div id="NewsComments"></div>
799
- <div class="clz"></div>
800
- <div class="cat-news">
801
- <div id="btnloadviewmore" class="view-more hide"><a onclick="loadviewmorecomment();">Xem thêm</a></div>
802
- </div>
803
- <div class="clz"></div>
804
- </div>
805
- </div>
806
- </div>
807
- <div class="clz"></div>
808
- </section>
809
-
810
-
811
-
812
-
813
-
814
- <div class="clx"></div>
815
- <center><div id="ADHOT" class="banner adweb admob" banner-adpos="hot"></div></center>
816
- <div class="clx"></div>
817
-
818
-
819
- <section class="cat-news">
820
- <div class="cont-wrap">
821
- <div class="capt-cover">
822
- <div class="caption">Video mới nhất</div>
823
- <button class="more-lnk"></button>
824
- </div>
825
- <div class="row flex">
826
- <div class="col m12 w3">
827
- <div class="news">
828
- <a class="thumb" href="/video/du-doan-ty-so-hull-mu-than-rua-ali-33-chon-doi-nao-982702608.html"><img alt="Dự đoán tỷ số Hull vs MU: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/80/Anh-chu-480-x-270.jpg" /></a>
829
- <a class="title" href="/video/du-doan-ty-so-hull-mu-than-rua-ali-33-chon-doi-nao-982702608.html">Dự đoán tỷ số Hull vs MU: Thần Rùa Ali 33 chọn đội nào?</a>
830
- <span class="info">
831
- 43 ph&#xFA;t tr&#x1B0;&#x1EDB;c
832
- </span>
833
- <span class="info">
834
- </span>
835
- </div>
836
- </div>
837
- <div class="col m12 w3">
838
- <div class="news">
839
- <a class="thumb" href="/video/hull-city-vs-mu-quy-do-vao-hang-bat-ho-982672608.html"><img alt="Hull City vs MU: Quỷ Đỏ vào hang bắt hổ" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbHullvsMU-up.jpg" /></a>
840
- <a class="title" href="/video/hull-city-vs-mu-quy-do-vao-hang-bat-ho-982672608.html">Hull City vs MU: Quỷ Đỏ vào hang bắt hổ</a>
841
- <span class="info">
842
- 44 ph&#xFA;t tr&#x1B0;&#x1EDB;c
843
- </span>
844
- <span class="info">
845
- </span>
846
- </div>
847
- </div>
848
- <div class="col m12 w3">
849
- <div class="news">
850
- <a class="thumb" href="/video/du-doan-ty-so-brentford-vs-tottenham-premier-league-2026-27-than-rua-ali-33-chon-doi-nao-982862608.html"><img alt="Dự đoán tỷ số Brentford vs Tottenham: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/19/80/Brentford-vs-Tottenham-480-x-270-moi.jpg" /></a>
851
- <a class="title" href="/video/du-doan-ty-so-brentford-vs-tottenham-premier-league-2026-27-than-rua-ali-33-chon-doi-nao-982862608.html">Dự đoán tỷ số Brentford vs Tottenham: Thần Rùa Ali 33 chọn đội nào?</a>
852
- <span class="info">
853
- 44 ph&#xFA;t tr&#x1B0;&#x1EDB;c
854
- </span>
855
- <span class="info">
856
- </span>
857
- </div>
858
- </div>
859
- <div class="col m12 w3">
860
- <div class="news">
861
- <a class="thumb" href="/video/brentford-vs-tottenham-de-zerbi-vuot-ai-gtech-982822608.html"><img alt="Brentford vs Tottenham: De Zerbi vượt ải Gtech" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/19/13/Brenfort-vs-Tottenham.jpg" /></a>
862
- <a class="title" href="/video/brentford-vs-tottenham-de-zerbi-vuot-ai-gtech-982822608.html">Brentford vs Tottenham: De Zerbi vượt ải Gtech</a>
863
- <span class="info">
864
- 45 ph&#xFA;t tr&#x1B0;&#x1EDB;c
865
- </span>
866
- <span class="info">
867
- </span>
868
- </div>
869
- </div>
870
- <div class="col m12 w3">
871
- <div class="news">
872
- <a class="thumb" href="/video/du-doan-ty-so-arsenal-vs-coventry-than-rua-ali-33-chon-doi-nao-982612608.html"><img alt="Dự đoán tỷ số Arsenal vs Coventry: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/17/0/thumblai.jpg" /></a>
873
- <a class="title" href="/video/du-doan-ty-so-arsenal-vs-coventry-than-rua-ali-33-chon-doi-nao-982612608.html">Dự đoán tỷ số Arsenal vs Coventry: Thần Rùa Ali 33 chọn đội nào?</a>
874
- <span class="info">
875
- 09 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
876
- </span>
877
- <span class="info">
878
- </span>
879
- </div>
880
- </div>
881
- <div class="col m12 w3">
882
- <div class="news">
883
- <a class="thumb" href="/video/arsenal-vs-coventry-nha-vua-mo-man-bang-mot-chien-thang-982662608.html"><img alt="Arsenal vs Coventry: Nhà vua mở màn bằng một chiến thắng?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbArsenalvsCoventry-up.jpg" /></a>
884
- <a class="title" href="/video/arsenal-vs-coventry-nha-vua-mo-man-bang-mot-chien-thang-982662608.html">Arsenal vs Coventry: Nhà vua mở màn bằng một chiến thắng?</a>
885
- <span class="info">
886
- 09 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
887
- </span>
888
- <span class="info">
889
- </span>
890
- </div>
891
- </div>
892
- <div class="col m12 w3">
893
- <div class="news">
894
- <a class="thumb" href="/video/truc-tuyen-truoc-tran-arsenal-vs-coventry-hull-vs-mu-983222608.html"><img alt="Trực tuyến trước trận Arsenal vs Coventry, Hull vs MU" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/truc-tuyen-truoc-tran-Arsenal-vs-Coventry.jpg" /></a>
895
- <a class="title" href="/video/truc-tuyen-truoc-tran-arsenal-vs-coventry-hull-vs-mu-983222608.html">Trực tuyến trước trận Arsenal vs Coventry, Hull vs MU</a>
896
- <span class="info">
897
- 11 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
898
- </span>
899
- <span class="info">
900
- </span>
901
- </div>
902
- </div>
903
- <div class="col m12 w3">
904
- <div class="news">
905
- <a class="thumb" href="/video/dt-thai-lan-tap-cach-san-rajamangala-40km-hlv-anthony-hudson-chot-luon-doi-hinh-dau-viet-nam-983202608.html"><img alt=" ĐT Thái Lan tập cách sân Rajamangala 40km, HLV Anthony Hudson chốt luôn đội hình đấu Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/HLV-Thai-Lan-chot-doi-hinh.jpg" /></a>
906
- <a class="title" href="/video/dt-thai-lan-tap-cach-san-rajamangala-40km-hlv-anthony-hudson-chot-luon-doi-hinh-dau-viet-nam-983202608.html"> ĐT Thái Lan tập cách sân Rajamangala 40km, HLV Anthony Hudson chốt luôn đội hình đấu Việt Nam</a>
907
- <span class="info">
908
- 13 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
909
- </span>
910
- <span class="info">
911
- </span>
912
- </div>
913
- </div>
914
- <div class="col m12 w3">
915
- <div class="news">
916
- <a class="thumb" href="/video/tro-lai-san-tung-vo-dich-aff-cup-2024-dt-viet-nam-quyet-lam-nen-lich-su-983212608.html"><img alt=" Trở lại sân từng vô địch AFF Cup 2024, ĐT Việt Nam quyết làm nên lịch sử" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/Viet-Nam-tap-2-1-8.jpg" /></a>
917
- <a class="title" href="/video/tro-lai-san-tung-vo-dich-aff-cup-2024-dt-viet-nam-quyet-lam-nen-lich-su-983212608.html"> Trở lại sân từng vô địch AFF Cup 2024, ĐT Việt Nam quyết làm nên lịch sử</a>
918
- <span class="info">
919
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
920
- </span>
921
- <span class="info">
922
- </span>
923
- </div>
924
- </div>
925
- <div class="col m12 w3">
926
- <div class="news">
927
- <a class="thumb" href="/video/vong-1-premier-league-2026-27-lieu-co-cu-soc-983112608.html"><img alt="Nhận định Premier League vòng 1: Liệu có cú sốc?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/thumbvong1-480.jpg" /></a>
928
- <a class="title" href="/video/vong-1-premier-league-2026-27-lieu-co-cu-soc-983112608.html">Nhận định Premier League vòng 1: Liệu có cú sốc?</a>
929
- <span class="info">
930
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
931
- </span>
932
- <span class="info">
933
- </span>
934
- </div>
935
- </div>
936
- <div class="col m12 w3">
937
- <div class="news">
938
- <a class="thumb" href="/video/ronaldinho-gay-soc-46-tuoi-van-tro-lai-san-co-khoac-ao-clb-serie-c-983192608.html"><img alt="Ronaldinho gây sốc: 46 tuổi vẫn trở lại sân cỏ, khoác áo CLB Serie C" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Ronaldinho480.jpg" /></a>
939
- <a class="title" href="/video/ronaldinho-gay-soc-46-tuoi-van-tro-lai-san-co-khoac-ao-clb-serie-c-983192608.html">Ronaldinho gây sốc: 46 tuổi vẫn trở lại sân cỏ, khoác áo CLB Serie C</a>
940
- <span class="info">
941
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
942
- </span>
943
- <span class="info">
944
- </span>
945
- </div>
946
- </div>
947
- <div class="col m12 w3">
948
- <div class="news">
949
- <a class="thumb" href="/video/arsenal-no-bom-tan-51-trieu-bang-de-va-hang-thu-983122608.html"><img alt="Arsenal nổ bom tấn 51 triệu bảng để vá hàng thủ" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Konsa480.jpg" /></a>
950
- <a class="title" href="/video/arsenal-no-bom-tan-51-trieu-bang-de-va-hang-thu-983122608.html">Arsenal nổ bom tấn 51 triệu bảng để vá hàng thủ</a>
951
- <span class="info">
952
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
953
- </span>
954
- <span class="info">
955
- </span>
956
- </div>
957
- </div>
958
- <div class="col m12 w3">
959
- <div class="news">
960
- <a class="thumb" href="/video/truc-tuyen-hop-bao-viet-nam-hlv-kim-sang-sik-noi-gi-khi-pho-chu-tich-thai-lan-mia-mai-dan-nhap-tich-983182608.html"><img alt="Trực tuyến họp báo Việt Nam: HLV Kim Sang Sik nói gì khi Phó Chủ tịch Thái Lan mỉa mai dàn nhập tịch" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhliveVN.jpg" /></a>
961
- <a class="title" href="/video/truc-tuyen-hop-bao-viet-nam-hlv-kim-sang-sik-noi-gi-khi-pho-chu-tich-thai-lan-mia-mai-dan-nhap-tich-983182608.html">Trực tuyến họp báo Việt Nam: HLV Kim Sang Sik nói gì khi Phó Chủ tịch Thái Lan mỉa mai dàn nhập tịch</a>
962
- <span class="info">
963
- 16 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
964
- </span>
965
- <span class="info">
966
- </span>
967
- </div>
968
- </div>
969
- <div class="col m12 w3">
970
- <div class="news">
971
- <a class="thumb" href="/video/truc-tuyen-thai-lan-hop-bao-hlv-thach-dau-dt-viet-nam-983162608.html"><img alt=" Trực tuyến Thái Lan họp báo: HLV Anthony Hudson thách đấu ĐT Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhlivesua-480.jpg" /></a>
972
- <a class="title" href="/video/truc-tuyen-thai-lan-hop-bao-hlv-thach-dau-dt-viet-nam-983162608.html"> Trực tuyến Thái Lan họp báo: HLV Anthony Hudson thách đấu ĐT Việt Nam</a>
973
- <span class="info">
974
- 18 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
975
- </span>
976
- <span class="info">
977
- </span>
978
- </div>
979
- </div>
980
- <div class="col m12 w3">
981
- <div class="news">
982
- <a class="thumb" href="/video/ban-tin-21-8-dt-viet-nam-chiu-thong-ke-cuc-xau-truoc-chung-ket-thai-lan-choi-chieu-nhu-indo-983152608.html"><img alt="Bản tin 21/8: ĐT Việt Nam chịu thống kê cực xấu trước chung kết, Thái Lan chơi chiêu như Indo" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/Thumb-tin-nong-21-8-480-up.jpg" /></a>
983
- <a class="title" href="/video/ban-tin-21-8-dt-viet-nam-chiu-thong-ke-cuc-xau-truoc-chung-ket-thai-lan-choi-chieu-nhu-indo-983152608.html">Bản tin 21/8: ĐT Việt Nam chịu thống kê cực xấu trước chung kết, Thái Lan chơi chiêu như Indo</a>
984
- <span class="info">
985
- 18 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
986
- </span>
987
- <span class="info">
988
- </span>
989
- </div>
990
- </div>
991
- <div class="col m12 w3">
992
- <div class="news">
993
- <a class="thumb" href="/video/thai-lan-khoa-cdv-viet-nam-chi-3-000-ve-cho-tran-chung-ket-tai-rajamangala-983142608.html"><img alt="Thái Lan 'khóa' CĐV Việt Nam: Chỉ 3.000 vé cho trận chung kết tại Rajamangala" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/cdv.jpg" /></a>
994
- <a class="title" href="/video/thai-lan-khoa-cdv-viet-nam-chi-3-000-ve-cho-tran-chung-ket-tai-rajamangala-983142608.html">Thái Lan 'khóa' CĐV Việt Nam: Chỉ 3.000 vé cho trận chung kết tại Rajamangala</a>
995
- <span class="info">
996
- 20 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
997
- </span>
998
- <span class="info">
999
- </span>
1000
- </div>
1001
- </div>
1002
- <div class="col m12 w3">
1003
- <div class="news">
1004
- <a class="thumb" href="/video/truoc-tran-thai-lan-vs-viet-nam-svd-rajamagala-vang-tanh-hon-chua-ba-danh-983132608.html"><img alt="Trước trận Thái Lan vs Việt Nam: SVĐ Rajamagala vắng tanh hơn chùa Bà Đanh" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/livethumbYT-480.jpg" /></a>
1005
- <a class="title" href="/video/truoc-tran-thai-lan-vs-viet-nam-svd-rajamagala-vang-tanh-hon-chua-ba-danh-983132608.html">Trước trận Thái Lan vs Việt Nam: SVĐ Rajamagala vắng tanh hơn chùa Bà Đanh</a>
1006
- <span class="info">
1007
- 22 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
1008
- </span>
1009
- <span class="info">
1010
- </span>
1011
- </div>
1012
- </div>
1013
- <div class="col m12 w3">
1014
- <div class="news">
1015
- <a class="thumb" href="/video/du-doan-ty-so-thai-lan-vs-viet-nam-than-rua-ali-33-chon-doi-nao-983092608.html"><img alt="Dự đoán tỷ số Thái Lan vs Việt Nam: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/80/ThumbRuaThaivsVietnam-480.jpg" /></a>
1016
- <a class="title" href="/video/du-doan-ty-so-thai-lan-vs-viet-nam-than-rua-ali-33-chon-doi-nao-983092608.html">Dự đoán tỷ số Thái Lan vs Việt Nam: Thần Rùa Ali 33 chọn đội nào?</a>
1017
- <span class="info">
1018
- 19:18 ng&#xE0;y 20/08/2026
1019
- </span>
1020
- <span class="info">
1021
- </span>
1022
- </div>
1023
- </div>
1024
- <div class="col m12 w3">
1025
- <div class="news">
1026
- <a class="thumb" href="/video/truyen-thong-anh-dac-biet-chu-y-den-ngoi-no-cua-dt-viet-nam-983072608.html"><img alt="Truyền thông Anh đặc biệt chú ý đến 'ngòi nổ' của ĐT Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/xuan-son.jpg" /></a>
1027
- <a class="title" href="/video/truyen-thong-anh-dac-biet-chu-y-den-ngoi-no-cua-dt-viet-nam-983072608.html">Truyền thông Anh đặc biệt chú ý đến 'ngòi nổ' của ĐT Việt Nam</a>
1028
- <span class="info">
1029
- 19:18 ng&#xE0;y 20/08/2026
1030
- </span>
1031
- <span class="info">
1032
- </span>
1033
- </div>
1034
- </div>
1035
- <div class="col m12 w3">
1036
- <div class="news">
1037
- <a class="thumb" href="/video/dt-viet-nam-co-mat-tai-thai-lan-ao-trang-van-chua-bi-thay-kim-vut-het-983082608.html"><img alt="ĐT Việt Nam có mặt tại Thái Lan, áo trắng vẫn chưa bị thầy Kim bỏ hết" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/Kim-Sang-Sik.jpg" /></a>
1038
- <a class="title" href="/video/dt-viet-nam-co-mat-tai-thai-lan-ao-trang-van-chua-bi-thay-kim-vut-het-983082608.html">ĐT Việt Nam có mặt tại Thái Lan, áo trắng vẫn chưa bị thầy Kim bỏ hết</a>
1039
- <span class="info">
1040
- 17:32 ng&#xE0;y 20/08/2026
1041
- </span>
1042
- <span class="info">
1043
- </span>
1044
- </div>
1045
- </div>
1046
- </div>
1047
- </div>
1048
- <div class="clz"></div>
1049
- </section>
1050
-
1051
-
1052
-
1053
- <div class="clx"></div>
1054
- <center><div id="ADBOT" class="banner adweb admob" banner-adpos="footer"></div></center>
1055
- </main>
1056
-
1057
- <style>
1058
- .iframpanel {
1059
- width: 100%;
1060
- max-width: 500px;
1061
- height: 560px;
1062
- overflow: hidden;
1063
- }
1064
-
1065
- </style>
1066
- <div class="paner-wrap">
1067
- <div class="over-layer hide"></div>
1068
- <div id="loginview" class="login-box paner hide iframpanel">
1069
- <div class="pan-capt">
1070
- <i class="pan-close" data-action="hide"></i>
1071
- </div>
1072
-
1073
-
1074
- <iframe src="https://member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=%2FHome%2FLoginFromBongdaplus" frameborder="0" style="overflow:hidden;height:100%;width:100%"></iframe>
1075
-
1076
- </div>
1077
- <div id="commentloginview" class="login-box paner hide iframpanel">
1078
-
1079
-
1080
- <iframe src="https://member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=%2FHome%2FCommentLoginFromBongdaplus" frameborder="0" style="overflow:hidden;height:100%;width:100%"></iframe>
1081
-
1082
- </div>
1083
- </div>
1084
- <input type="hidden" id="txtFullname" value="" />
1085
- <input type="hidden" id="txtUserid" value="" />
1086
-
1087
-
1088
-
1089
-
1090
-
1091
-
1092
- <center><div id="ADFT" class="banner adweb admob" banner-adpos="footer"></div></center>
1093
- <footer>
1094
- <div class="cont-wrap">
1095
- <div class="row">
1096
- <div class="col m12 w3">
1097
- <div class="caption">Tạp chí điện tử Bóng đá</div>
1098
- </div>
1099
- <div class="col m12 w6">
1100
- <p>Giấy phép số 48/GP-BTTTT cấp ngày 05/02/2020</p>
1101
- <p>Tổng biên tập: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <b>Vũ Khắc Sơn</b></p>
1102
- <p>Phó tổng biên tập: &nbsp; &nbsp;<b>Nguyễn Hà Thanh</b></p>
1103
- <div class="hide-w">
1104
- <p>Địa chỉ: &nbsp;<b>Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội</b></p>
1105
- <p>Văn phòng giao dịch: <b>Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội</b></p>
1106
- <p>Điện thoại: <b>(84.24) 3554 1188 - (84.24) 3554 1199</b></p>
1107
- <p>
1108
- Email: &nbsp; &nbsp; <a href="mailto:toasoan@bongdaplus.vn"><b>toasoan@bongdaplus.vn</b></a> |
1109
- <a href="mailto:vanphong@bongdaplus.vn"><b>vanphong@bongdaplus.vn</b></a>
1110
- </p>
1111
- <div class="spl"></div>
1112
- <p class="caption">Liên hệ quảng cáo</p>
1113
- <p>Hotline: &nbsp;<b>0903 203 412</b></p>
1114
- <p>Email: &nbsp; &nbsp;<a href="mailto:tien.nguyen@giaminhmedia.vn"><b>tien.nguyen@giaminhmedia.vn</b></a></p>
1115
- </div>
1116
- </div>
1117
- <div class="col m12 w3">
1118
- <ul class="bar social-lnk rgt-w">
1119
- <li class="hide-m">Theo dõi thông tin</li>
1120
- <li><a class="ico vff-lnk" href="https://vff.org.vn/" target="_blank" rel="nofollow"></a></li>
1121
- <li><a class="ico fb-lnk" href="https://www.facebook.com/BaoBongDa/"></a></li>
1122
- <li><a class="ico yt-lnk" href="https://www.youtube.com/channel/UCvDYMBE6GipX2VGacqOU2_Q"></a></li>
1123
- </ul>
1124
- </div>
1125
- </div>
1126
- <div class="foot-menu">
1127
- <ul class="bar hide-m">
1128
- <li><a href="#" data-showpaner="#contactview">Thông tin tòa soạn</a></li>
1129
- <li><a href="#" data-showpaner="#advertview">Liên hệ quảng cáo</a></li>
1130
- </ul>
1131
- <div class="copy-rgt">
1132
- Bản quyền &copy;2011 Bongdaplus.vn. Chỉ được phát hành lại thông tin khi
1133
- có sự đồng ý bằng văn bản của Tạp chí điện tử Bóng đá
1134
- </div>
1135
- <div class="clx hide-m"></div>
1136
- </div>
1137
- </div>
1138
- </footer>
1139
-
1140
- <div class="paner-wrap">
1141
- <div class="over-layer hide"></div>
1142
- <div id="contactview" class="contact-box paner hide">
1143
- <div class="pan-capt">
1144
- <div class="pan-title">Thông tin Toà soạn</div>
1145
- <i class="pan-close" data-action="hide"></i>
1146
- </div>
1147
- <div class="pan-cont">
1148
- <div class="xcap lead tx-prime tx-cen">Tạp chí Điện tử Bóng Đá</div>
1149
- <div class="row">
1150
- <div class="col w3 tx-base tx-rgt">Tổng biên tập:</div>
1151
- <div class="col w9"><b>Vũ Khắc Sơn</b></div>
1152
- <div class="clx"></div>
1153
- <div class="col w3 tx-base tx-rgt">Phó Tổng biên tập:</div>
1154
- <div class="col w9">
1155
- <b>Nguyễn Hà Thanh</b>
1156
- </div>
1157
- </div>
1158
- <div class="splt"></div>
1159
- <div class="row">
1160
- <div class="col w3 tx-base tx-rgt">Địa chỉ:</div>
1161
- <div class="col w9"><b>Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội</b></div>
1162
- <div class="col w3 tx-base tx-rgt">Văn phòng giao dịch:</div>
1163
- <div class="col w9"><b>Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội</b></div>
1164
- <div class="col w3 tx-base tx-rgt">Tel:</div>
1165
- <div class="col w9"><b>(84.24) 3554 1188 - (84.24) 3554 1199</b></div>
1166
- <div class="col w3 tx-base tx-rgt">Fax:</div>
1167
- <div class="col w9"><b>(84.24) 3553 9898</b></div>
1168
- <div class="col w3 tx-base tx-rgt">Email:</div>
1169
- <div class="col w9">
1170
- <a href="mailto:toasoan@bongdaplus.vn"><b>toasoan@bongdaplus.vn</b></a>
1171
- <a href="mailto:vanphong@bongdaplus.vn"><b>vanphong@bongdaplus.vn</b></a>
1172
- </div>
1173
- </div>
1174
- </div>
1175
- </div>
1176
-
1177
- <div id="advertview" class="contact-box paner hide">
1178
- <div class="pan-capt">
1179
- <div class="pan-title">Thông tin Liên hệ</div>
1180
- <i class="pan-close" data-action="hide"></i>
1181
- </div>
1182
- <div class="pan-cont">
1183
- <div class="xcap lead tx-prime tx-cen">Tạp chí Điện tử Bóng Đá</div>
1184
- <div class="row">
1185
- <div class="col w4 tx-base tx-rgt">Hotline:</div>
1186
- <div class="col w8"><b>0903 203 412</b></div>
1187
- <div class="col w4 tx-base tx-rgt">Email:</div>
1188
- <div class="col w8"><a href="mailto:tien.nguyen@giaminhmedia.vn"><b>tien.nguyen@giaminhmedia.vn</b></a></div>
1189
- </div>
1190
- <p class="tx-base">Địa chỉ liên hệ:</p>
1191
- <b>Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội</b>
1192
- <p class="tx-base">Văn phòng giao dịch:</p>
1193
- <b>Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội</b>
1194
- </div>
1195
- </div>
1196
-
1197
-
1198
- <div id="loginview" class="login-box paner hide">
1199
- <div class="pan-capt">
1200
- <div class="pan-title">Đăng nhập</div>
1201
- <i class="pan-close" data-action="hide"></i>
1202
- </div>
1203
- <div class="pan-cont">
1204
- <form class="soc-auth" action="#">
1205
- <button><i class="ico gg-color"></i>Google</button>
1206
- <button><i class="ico fb-color"></i>Facebook</button>
1207
- </form>
1208
- <div class="clx"></div>
1209
- <div class="splt"><span>hoặc</span></div>
1210
- <form class="loginer" action="//member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=/" method="post">
1211
- <p>Email:</p>
1212
- <label class="txt-fld" for="email">
1213
- <input id="email" name="Input.Email" type="email" />
1214
- <span>Nhập địa chỉ email của bạn</span>
1215
- </label>
1216
- <label class="err-fld hide" for="email">Địa chỉ email không được để trống</label>
1217
- <p>Mật khẩu:</p>
1218
- <label class="txt-fld" for="passw">
1219
- <input id="passw" name="Input.Password" type="password" data-required="Mật khẩu không được để trống" />
1220
- <span>Nhập mật khẩu của bạn</span>
1221
- </label>
1222
- <label class="err-fld hide" for="passw">Mật khẩu không được để trống</label>
1223
- <p class="ft-sm tx-rgt"><a class="fg-psw" href="#" data-action="hide" data-showpaner="#forgetview">Quên mật khẩu?</a></p>
1224
- <label class="rdo-fld" for="persist">
1225
- <input id="persist" name="Input.RememberMe" type="checkbox" />
1226
- Ghi nhớ thông tin đăng nhập
1227
- </label>
1228
- <br>
1229
- <div class="fld-box">
1230
- <button class="btn major" type="submit">Đăng nhập</button>
1231
- </div>
1232
- <p class="ft-sm tx-cen">
1233
- Bạn chưa có tài khoản?
1234
- <a class="act-now" href="#" data-action="hide" data-showpaner="#registview">Đăng ký ngay</a>
1235
- </p>
1236
- </form>
1237
- </div>
1238
- </div>
1239
- </div>
1240
-
1241
- <script src="/lib/jquery/dist/jquery.min.js"></script>
1242
- <script src="/js/js_cookie.min.js?v=0H3Nuz3aug3afVbUlsu12Puxva3CP4EhJtPExqs54Vg"></script>
1243
- <script src="/js/bongdaplus.js?v=gIIX1pYQ8DB66N40YPyoHGaBgBFoHkPMMCARX4L6NdA"></script>
1244
- <script src="/js/premium.js?v=mbXB8QoXMl1zoEFSeE6z-mwQTb0ot7C8zj_F8JqsvNc"></script>
1245
-
1246
- <script>var _ad_info_code = 'mda/video';</script>
1247
- <script src="/js/advloader.js?v=dvvR0ygTjyYzNcQmQ_dI5bwYeX2Ww0nBjBBmCt9DWjQ"></script>
1248
-
1249
- </body>
1250
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bdp_page.html DELETED
@@ -1,1250 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <title>Hull City vs MU: Quỷ Đỏ vào hang bắt hổ - Bongdaplus.vn</title>
5
-
6
-
7
- <meta name="MobileOptimized" content="100" />
8
- <meta name="viewport" content="width=device-width, initial-scale=1" />
9
- <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
10
- <meta http-equiv="content-language" content="vi" />
11
- <meta http-equiv="refresh" content="300" />
12
- <meta property="fb:pages" content="1738672379699789" />
13
- <meta property="dable:item_id" content="2015111202028" />
14
- <meta property="og:image" itemprop="thumbnailUrl" content="https://bongdaplus.vn/img/logo-daily.png" />
15
- <link href="/favicon.ico" rel="shortcut icon" type="image/x-icon" />
16
- <link href="/favicon.svg" rel="icon" sizes="any" type="image/svg+xml" />
17
- <link href="/apple-touch-icon.png" rel="apple-touch-icon" />
18
- <!--iPhone icons-->
19
- <link href="/logo-57.png" rel="apple-touch-icon-precomposed" sizes="57x57" />
20
- <link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="114x114" />
21
- <!--iPad icons-->
22
- <link href="/logo-72.png" rel="apple-touch-icon-precomposed" sizes="72x72" />
23
- <link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="114x114" />
24
- <!--Android icon-->
25
- <link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="1x1" />
26
- <!--Symbian icon-->
27
- <link href="/logo-57.png" rel="nokia-touch-icon" sizes="57x57" />
28
- <link rel="preconnect" href="https://fonts.googleapis.com">
29
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
30
- <link href="https://fonts.googleapis.com/css2?family=Mulish:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap" rel="stylesheet">
31
- <link href="https://fonts.googleapis.com/css2?family=Noto+Serif:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
32
- <link href="/css/premium.css?id=0000&amp;v=ivL0rSTnUIBx45p3hw7UIAzbXjyQ9rgPVSyjqPglN_o" rel="stylesheet">
33
-
34
- <script async src="https://www.googletagmanager.com/gtag/js?id=UA-20374657-1"></script>
35
- <script>
36
- window.dataLayer = window.dataLayer || [];
37
- function gtag() { dataLayer.push(arguments); }
38
- gtag('js', new Date());
39
-
40
- gtag('config', 'UA-20374657-1');
41
- </script>
42
- <!-- Google tag (gtag.js) -->
43
- <script async src="https://www.googletagmanager.com/gtag/js?id=G-Y1DBNQWMVW"></script>
44
- <script>
45
- window.dataLayer = window.dataLayer || [];
46
- function gtag() { dataLayer.push(arguments); }
47
- gtag('js', new Date());
48
-
49
- gtag('config', 'G-Y1DBNQWMVW');
50
- </script>
51
-
52
- <script async src="https://static.amcdn.vn/tka/cdn.js" type="text/javascript"></script>
53
- <script>var arfAsync = arfAsync || [];</script>
54
- <script id="arf-core-js" onerror="window.arferrorload=true;" src="//media1.admicro.vn/cms/Arf.min.js" async></script>
55
- <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3619133031508264" crossorigin="anonymous"></script>
56
- </head>
57
- <body>
58
- <script type="application/ld+json">
59
- {
60
- "@context": "https://schema.org",
61
- "@type": "Organization",
62
- "name": "TẠP CHÍ ĐIỆN TỬ BÓNG ĐÁ - BONGDAPLUS",
63
- "url": "https://bongdaplus.vn",
64
- "logo": "https://bongdaplus.vn/img/logo.png",
65
- "contactPoint": {
66
- "@type": "ContactPoint",
67
- "telephone": "(84.24)35541188",
68
- "contactType": "customer service",
69
- "areaServed": "VN",
70
- "availableLanguage": "Vietnamese"
71
- },
72
- "sameAs": [
73
- "https://www.facebook.com/baobongda",
74
- "https://www.youtube.com/channel/UCvDYMBE6GipX2VGacqOU2_Q",
75
- "https://bongdaplus.vn",
76
- "https://www.pinterest.com/bongdaplus",
77
- "https://soundcloud.com/baobongda"
78
- ]
79
- }
80
- </script>
81
- <header>
82
- <center><div id="ADMTOP" class="banner admob" banner-adpos="top"></div></center>
83
-
84
- <div class="cont-wrap">
85
- <div class="mmnu-nav">
86
- <button class="ico mnu-sch mmnu-trigger hide-w"></button>
87
- <button class="ico menubar mmnu-trigger hide-m"></button>
88
- <button class="ico close mmnu-closer hide"></button>
89
- </div>
90
- <a class="logo" href="/"></a>
91
- <button class="ico bell hide-w hide"></button>
92
-
93
- <div class="authen-nav dropper">
94
- <a class="member">
95
- <span class="avatar">
96
- </span>
97
- </a>
98
- <div class="drop-down rgt">
99
- <ul class="lst">
100
-
101
- <li><a href="#" data-showpaner="#loginview">Đăng nhập</a></li>
102
- <li><a href="https://member.bongdaplus.vn/Identity/Account/Register?returnUrl=%2F" target="_blank">Tạo tài khoản</a></li>
103
-
104
-
105
- </ul>
106
- </div>
107
- </div>
108
- </div>
109
- </header>
110
- <input type="hidden" id="catrefid" value="video" />
111
- <input type="hidden" id="objectid" value="98267" />
112
- <input type="hidden" id="objecttype" value="1" />
113
- <input type="hidden" id="parentid" value="0" />
114
- <input type="hidden" id="commentnum" value="0" />
115
- <img src="/imgthumbnail/98267/1/1" alt="" height="0" width="0" />
116
-
117
- <main>
118
- <div class="nav-menu">
119
- <div class="menu-bar">
120
- <div class="cont-wrap">
121
- <div class="menu-side">
122
- <ul class="bar-w">
123
- <li><a class="ico home" href="/"></a></li>
124
- <li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
125
- <li class="popover">
126
- <a title="Việt Nam" href="/bong-da-viet-nam">Việt Nam</a>
127
- <div class="drop-down">
128
- <ul class="lst">
129
- <li><a title="V-League" href="/v-league">V-League</a></li>
130
- <li><a title="Hạng Nhất" href="/hang-nhat-quoc-gia">Hạng Nhất</a></li>
131
- <li><a title="Cúp Quốc gia" href="/cup-quoc-gia">Cúp Quốc gia</a></li>
132
- <li><a title="ĐTQG" href="/doi-tuyen-quoc-gia-viet-nam">ĐTQG</a></li>
133
- <li><a title="Bóng đá Nữ" href="/bong-da-nu-viet-nam">Bóng đá Nữ</a></li>
134
- <li><a title="U17 Quốc gia" href="/u17-viet-nam">U17 Quốc gia</a></li>
135
- <li><a title="U19 Quốc Gia" href="/u19-viet-nam">U19 Quốc Gia</a></li>
136
- <li><a title="U21 Quốc Gia" href="/u21-viet-nam">U21 Quốc Gia</a></li>
137
- <li><a title="Các đội tuyển trẻ" href="/cac-doi-tuyen-tre-viet-nam">Các đội tuyển trẻ</a></li>
138
- <li><a title="Futsal" href="/futsal">Futsal</a></li>
139
- <li><a title="Phong trào" href="/bong-da-phong-trao">Phong trào</a></li>
140
- <li><a title="Đơn vị Kết nghĩa" href="/ket-nghia-anh-em">Đơn vị Kết nghĩa</a></li>
141
- </ul>
142
- </div>
143
- </li>
144
- <li class="popover">
145
- <a title="Chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a>
146
- <div class="drop-down">
147
- <ul class="lst">
148
- <li><a title="Tin chuyển nhượng Ngoại Hạng Anh" href="/tin-chuyen-nhuong-ngoai-hang-anh">Tin chuyển nhượng Ngoại Hạng Anh</a></li>
149
- <li><a title="Tin chuyển nhượng V-league" href="/tin-chuyen-nhuong-v-league">Tin chuyển nhượng V-league</a></li>
150
- </ul>
151
- </div>
152
- </li>
153
- <li><a title="Champions League" href="/champions-league-cup-c1">Champions League</a></li>
154
- <li class="popover">
155
- <a title="Tây Ban Nha" href="/bong-da-tay-ban-nha">Tây Ban Nha</a>
156
- <div class="drop-down">
157
- <ul class="lst">
158
- <li><a title="VĐQG Tây Ban Nha" href="/la-liga">VĐQG Tây Ban Nha</a></li>
159
- <li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
160
- <li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
161
- </ul>
162
- </div>
163
- </li>
164
- <li class="popover">
165
- <a title="Pháp" href="/bong-da-phap">Pháp</a>
166
- <div class="drop-down">
167
- <ul class="lst">
168
- <li><a title="VĐQG Pháp" href="/ligue-1">VĐQG Pháp</a></li>
169
- <li><a title="Cúp Quốc gia Pháp" href="/cup-quoc-gia-phap">Cúp Quốc gia Pháp</a></li>
170
- <li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
171
- </ul>
172
- </div>
173
- </li>
174
- <li class="popover">
175
- <a title="Italia" href="/bong-da-y">Italia</a>
176
- <div class="drop-down">
177
- <ul class="lst">
178
- <li><a title="VĐQG Italia" href="/serie-a">VĐQG Italia</a></li>
179
- <li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
180
- <li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
181
- </ul>
182
- </div>
183
- </li>
184
- <li class="popover">
185
- <a title="Thể thao" href="/the-thao">Thể thao</a>
186
- <div class="drop-down">
187
- <ul class="lst">
188
- <li><a title="eSports" href="/esports">eSports</a></li>
189
- <li><a title="Tennis" href="/tennis">Tennis</a></li>
190
- <li><a title="Pickleball" href="/pickleball">Pickleball</a></li>
191
- <li><a title="MMA" href="/mma">MMA</a></li>
192
- <li><a title="Đua xe F1" href="/dua-xe-f1">Đua xe F1</a></li>
193
- <li><a title="Bóng rổ" href="/bong-ro">Bóng rổ</a></li>
194
- <li><a title="Olympic 2028" href="/olympic">Olympic 2028</a></li>
195
- </ul>
196
- </div>
197
- </li>
198
- <li><a title="Nhận định bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
199
- <li><a title="Multimedia" href="/video">Multimedia</a></li>
200
- <li class="hide"><a title="Bongdaplus Premium" href="/premium">Premium <i class="ico crown"></i></a></li>
201
- </ul>
202
- </div>
203
- <div class="menu-nav">
204
- <a class="ico home hide-w" href="/"></a>
205
- <ul class="nav">
206
- <li class="hide-m"><i class="sepr"></i></li>
207
- <li><a href="/tin-moi">Mới nhất</a></li>
208
- <li class="hide-w"><a title="Bongdaplus Premium" href="/premium">Premium <i class="ico crown"></i></a></li>
209
- <li><a title="Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
210
- <li class="hide-w"><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
211
- <li class="hide-w"><a title="Việt Nam" href="/bong-da-viet-nam">Việt Nam</a></li>
212
- <li class="hide-w"><a title="Chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
213
- <li class="hide-w"><a title="Champions League" href="/champions-league-cup-c1">Champions League</a></li>
214
- <li class="hide-w"><a title="Tây Ban Nha" href="/bong-da-tay-ban-nha">Tây Ban Nha</a></li>
215
- <li class="hide-w"><a title="Pháp" href="/bong-da-phap">Pháp</a></li>
216
- <li class="hide-w"><a title="Italia" href="/bong-da-y">Italia</a></li>
217
- <li class="hide-w"><a title="Thể thao" href="/the-thao">Thể thao</a></li>
218
- <li class="hide-w"><a title="Nhận định" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
219
- <li class="hide-w"><a title="Thế giới" href="/the-gioi">Thế giới</a></li>
220
- <li class="hide-w"><a title="Multimedia" href="/video">Multimedia</a></li>
221
- <li class="hide-w"><a title="Bóng đá Cuộc sống" href="/bong-da-cuoc-song">Bóng đá Cuộc sống</a></li>
222
- </ul>
223
- <div class="srch-bar hide-m">
224
- <button class="ico close"></button>
225
- <form class="srch-form" action="/tim-kiem">
226
- <input class="pattern toggler" name="q" type="text" placeholder="Nhập nội dung cần tìm" />
227
- </form>
228
- </div>
229
- </div>
230
- </div>
231
- </div>
232
- <div class="clr"></div>
233
- </div>
234
- <div class="mob-menu">
235
- <div class="mmnu-cap">
236
- <button class="ico close" data-action="hide"></button>
237
- <a class="logo" href="/"></a>
238
- </div>
239
- <form class="mmnu-sch " action="/tim-kiem">
240
- <input type="text" name="q">
241
- <button class="ico search" type="submit"></button>
242
- </form>
243
- <div class="mmnu-lst">
244
- <div class="menu-row">
245
- <ul class="bar">
246
- <li class="exp">
247
- <a class="major" title="Tin mới nhất" href="/tin-moi"><span class="ico blinkr"></span> Mới nhất</a>
248
- </li>
249
- <li>
250
- <ul class="lst">
251
- <li><a class="fst" title="Nh���n định Bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
252
- </ul>
253
- </li>
254
- <li>
255
- <ul class="lst">
256
- <li><a class="fst" title="Lịch thi đấu bóng đá" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
257
- </ul>
258
- </li>
259
- <li class="exp"></li>
260
- <li class="exp"><a class="major" title="Bóng đá Plus Premium" href="/premium">Bóng đá Premium <i class="ico crown"></i></a></li>
261
- <li>
262
- <ul class="lst">
263
- <li><a class="fst" title="Bóng đá Việt Nam" href="/bong-da-viet-nam">Bóng đá Việt Nam</a></li>
264
- <li><a title="Đội tuyển Việt Nam" href="/doi-tuyen-quoc-gia-viet-nam">Đội tuyển Việt Nam</a></li>
265
- <li><a title="V.League" href="/v-league">V.League</a></li>
266
- <li><a title="Hạng Nhất" href="/hang-nhat-viet-nam">Hạng Nhất</a></li>
267
- <li><a title="Cúp Quốc gia" href="/cup-quoc-gia-viet-nam">Cúp Quốc gia</a></li>
268
- <li><a title="Bóng đá nữ" href="/bong-da-nu-viet-nam">Bóng đá nữ</a></li>
269
- <li><a title="Futsal" href="/futsal">Futsal</a></li>
270
- <li><a title="Phong trào" href="/bong-da-phong-trao">Bóng đá Phong trào</a></li>
271
- <li><a title="Chuyển nhượng V.League" href="/tin-chuyen-nhuong-v-league">Chuyển nhượng</a></li>
272
- </ul>
273
- </li>
274
- <li>
275
- <ul class="lst">
276
- <li><a class="fst" title="Bóng đá Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
277
- <li><a title="Đội tuyển Anh" href="/doi-tuyen-anh">Đội tuyển Anh</a></li>
278
- <li><a title="Ngoại hạng Anh" href="/ngoai-hang-anh">Ngoại hạng Anh</a></li>
279
- <li><a title="FA Cup" href="/fa-cup">FA Cup</a></li>
280
- <li><a title="League Cup" href="/cup-lien-doan-anh">League Cup</a></li>
281
- </ul>
282
- <div class="clx"></div>
283
- <ul class="lst">
284
- <li><a class="fst" title="Tây Ban nha" href="/bong-da-tay-ban-nha">Tây Ban nha</a></li>
285
- <li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
286
- <li><a title="La Liga" href="/la-liga">La Liga</a></li>
287
- <li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
288
- </ul>
289
- </li>
290
- <li class="exp"></li>
291
- <li>
292
- <ul class="lst">
293
- <li><a class="fst" title="Bóng đá Pháp" href="/bong-da-phap">Bóng đá Pháp</a></li>
294
- <li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
295
- <li><a title="Ligue 1" href="/ligue-1">Ligue 1</a></li>
296
- <li><a title="Cúp QG Pháp" href="/cup-quoc-gia-phap">Cúp QG Pháp</a></li>
297
- <li><a title="Cúp Liên đoàn Pháp" href="/cup-lien-doan-phap-tags">Cúp Liên đoàn Pháp</a></li>
298
- </ul>
299
- </li>
300
- <li>
301
- <ul class="lst">
302
- <li><a class="fst" href="#">Bóng đá Khu vực</a></li>
303
- <li><a title="Asian Cup" href="/asian-cup">Asian Cup</a></li>
304
- <li><a title="Asian Games" href="/asian-games">Asian Games</a></li>
305
- <li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
306
- <li><a title="SEA Games" href="/sea-games">SEA Games</a></li>
307
- </ul>
308
- </li>
309
- <li class="exp"></li>
310
- <li>
311
- <ul class="lst">
312
- <li><a class="fst" title="Bóng đá Italia" href="/bong-da-y">Bóng đá Italia</a></li>
313
- <li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
314
- <li><a title="Serie A" href="/serie-a">Serie A</a></li>
315
- <li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
316
- </ul>
317
- </li>
318
- <li>
319
- <ul class="lst">
320
- <li><a class="fst" title="Bóng đá Đức" href="/bong-da-duc">Bóng đá Đức</a></li>
321
- <li><a title="Đội tuyển Đức" href="/doi-tuyen-duc">Đội tuyển Đức</a></li>
322
- <li><a title="Bundesliga" href="/bundesliga">Bundesliga</a></li>
323
- <li><a title="Cúp QG Đức" href="/cup-quoc-gia-duc">Cúp QG Đức</a></li>
324
- </ul>
325
- </li>
326
- <li class="exp"></li>
327
- <li>
328
- <ul class="lst">
329
- <li><a class="fst" href="#">Nội dung Đặc biệt</a></li>
330
- <li><a title="Big Story" href="/emagazine">Big Story</a></li>
331
- <li><a title="X-File" href="/x-file">X-File</a></li>
332
- <li><a title="Infographic" href="/infographic">Infographic</a></li>
333
- <li><a title="Interactive" href="/interactive">Interactive</a></li>
334
- </ul>
335
- </li>
336
- <li>
337
- <ul class="lst">
338
- <li><a class="fst" href="#">Giải đấu CLB</a></li>
339
- <li><a title="Champions Leage" href="/champions-league-cup-c1">Champions League</a></li>
340
- <li><a title="Europa League" href="/europa-league">Europa League</a></li>
341
- <li><a title="Club World Cup" href="/fifa-club-world-cup-tags">Club World Cup</a></li>
342
- <li><a title="Europa Conference League" href="/europa-conference-league">Europa Conf League</a></li>
343
- </ul>
344
- </li>
345
- <li class="exp"></li>
346
- <li>
347
- <ul class="lst">
348
- <li><a class="fst" href="#">Chuyên đề</a></li>
349
- <li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
350
- <li><a title="Hậu trường bóng đá" href="/hau-truong-bong-da">Hậu trường</a></li>
351
- <li><a title="Điểm tin" href="/diem-tin">Điểm tin</a></li>
352
- <li><a title="Showbiz Ngôi sao" href="/ngoi-sao-showbiz">Showbiz Ngôi sao</a></li>
353
- <li><a title="Góc check var" href="/goc-check-var">Góc check var</a></li>
354
- <li><a title="Công nghệ" href="/cong-nghe">Công nghệ</a></li>
355
- <li><a title="Thông tin Thị trường" href="/thi-truong">Thị trường</a></li>
356
- <li><a title="Bóng đá & Cuộc sống" href="/bong-da-cuoc-song">Bóng đá & Cuộc sống</a></li>
357
- </ul>
358
- </li>
359
- <li>
360
- <ul class="lst">
361
- <li><a class="fst" href="#">Đội tuyển</a></li>
362
- <li><a title="World Cup nữ 2028" href="/world-cup-nu-2023-tags">World Cup nữ 2028</a></li>
363
- <li><a title="World Cup" href="/world-cup">World Cup 2026</a></li>
364
- <li><a title="European Championship 2028" href="/euro-cup-chau-au">EURO 2028</a></li>
365
- <li><a title="Nations League" href="/nations-league">Nations League</a></li>
366
- <li><a title="Copa America" href="/copa-america">Copa America</a></li>
367
- <li><a title="CAN" href="/can">CAN</a></li>
368
- <li><a title="Olympic 2028" href="/olympic">Olympic 2028</a></li>
369
- </ul>
370
- </li>
371
- <li class="exp"></li>
372
- <li>
373
- <ul class="lst">
374
- <li><a class="fst" title="Multimedia" href="/video">Multimedia</a></li>
375
- </ul>
376
- </li>
377
- <li>
378
- <ul class="lst">
379
- <li>&nbsp;</li>
380
- </ul>
381
- </li>
382
- <li class="exp"></li>
383
- <li>
384
- <ul class="lst">
385
- <li><a class="fst" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
386
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Lịch thi đấu V.League">V.League</a></li>
387
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Lịch thi đấu Ngoại hạng Anh">Ngoại hạng Anh</a></li>
388
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Lịch thi đấu Ligue 1 Pháp">Ligue 1</a></li>
389
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Lịch thi đấu La Liga Tây Ban Nha">La Liga</a></li>
390
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Lịch thi đấu Bundesliga Đức">Bundesliga</a></li>
391
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-y" title="Lịch thi đấu Serie A Italia">Serie A</a></li>
392
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Lịch thi đấu Champions League">Champions League</a></li>
393
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?europa-league" title="Lịch thi đấu Europa League">Europa League</a></li>
394
- </ul>
395
- </li>
396
- <li>
397
- <ul class="lst">
398
- <li><a class="fst" href="/bang-xep-hang-bong-da">Bảng xếp hạng</a></li>
399
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-viet-nam" title="Bảng xếp hạng V.League">V.League</a></li>
400
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-anh" title="Bảng xếp hạng Ngoại hạng Anh">Ngoại hạng Anh</a></li>
401
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-phap" title="Bảng xếp hạng Ligue 1 Pháp">Ligue 1</a></li>
402
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-tay-ban-nha" title="Bảng xếp hạng La Liga Tây Ban Nha">La Liga</a></li>
403
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-duc" title="Bảng xếp hạng Bundesliga Đức">Bundesliga</a></li>
404
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-y" title="Bảng xếp hạng Serie A Italia">Serie A</a></li>
405
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?champions-league-cup-c1" title="Bảng xếp hạng Champions League">Champions League</a></li>
406
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?europa-league" title="Bảng xếp hạng Europa League">Europa League</a></li>
407
- </ul>
408
- </li>
409
- <li class="exp"></li>
410
- <li><a class="caption" href="https://phui.bongdaplus.vn" target="_blank">Bóng đá phủi</a></li>
411
- <li><a class="caption" href="/tennis">Tennis</a></li>
412
- <li><a class="caption" href="/esports">Esports</a></li>
413
- <li><a class="caption" href="/the-thao">Thể thao +</a></li>
414
- </ul>
415
- </div>
416
- </div>
417
- <div class="mmnu-end tx-cen">
418
- <p>Theo dõi thông tin</p>
419
- <ul class="bar social-lnk">
420
- <li class="hide-m">Theo dõi thông tin</li>
421
- <li><a class="ico vff-lnk" target="_blank" href="https://vff.org.vn/" style="background-size:28px;opacity:1;" rel="nofollow"></a></li>
422
- <li><a class="ico fb-lnk" target="_blank" href="https://www.facebook.com/BaoBongDa/" style="background-size:28px;opacity:1;"></a></li>
423
- <li><a class="ico yt-lnk" target="_blank" href="https://www.youtube.com/channel/UCF0VFBZu-5SRJXMV_FMysEA" style="background-size:28px;opacity:1;"></a></li>
424
- </ul>
425
- </div>
426
- </div>
427
- <div class="mega-menu">
428
- <div class="cont-wrap">
429
- <div class="menu-row">
430
- <ul class="bar">
431
- <li><a class="caption" title="Tin mới nhất" href="/tin-moi">Mới nhất</a></li>
432
- <li>
433
- <ul class="lst">
434
- <li><a class="fst" title="Bóng đá Việt Nam" href="/bong-da-viet-nam">Bóng đá Việt Nam</a></li>
435
- <li><a title="Đội tuyển Việt Nam" href="/doi-tuyen-quoc-gia-viet-nam">Đội tuyển Việt Nam</a></li>
436
- <li><a title="V.League" href="/v-league">V.League</a></li>
437
- <li><a title="Hạng Nhất" href="/hang-nhat-viet-nam">Hạng Nhất</a></li>
438
- <li><a title="Cúp Quốc gia" href="/cup-quoc-gia-viet-nam">Cúp Quốc gia</a></li>
439
- <li><a title="Cúp châu Á" href="/asian-cup">Cúp châu Á</a></li>
440
- <li><a title="Bóng đá nữ" href="/bong-da-nu-viet-nam">Bóng đá nữ</a></li>
441
- <li><a title="Futsal" href="/futsal">Futsal</a></li>
442
- <li><a title="Phong trào" href="/bong-da-phong-trao">Phong trào</a></li>
443
- <li><a title="Chuyển nhượng V.League" href="/tin-chuyen-nhuong-v-league">Chuyển nhượng V.League</a></li>
444
- <li><a title="Tin khác" href="/tin-khac">Tin khác</a></li>
445
- </ul>
446
- </li>
447
-
448
- <li>
449
- <ul class="lst">
450
- <li><a class="fst" title="Bóng đá Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
451
- <li><a title="Đội tuyển Anh" href="/doi-tuyen-anh">Đội tuyển Anh</a></li>
452
- <li><a title="Ngoại hạng Anh" href="/ngoai-hang-anh">Ngoại hạng Anh</a></li>
453
- <li><a title="FA Cup" href="/fa-cup">FA Cup</a></li>
454
- <li><a title="League Cup" href="/cup-lien-doan-anh">League Cup</a></li>
455
- </ul>
456
- <div class="clx"></div>
457
- <ul class="lst">
458
- <li><a class="fst" href="#">Giải đấu CLB</a></li>
459
- <li><a title="Champions Leage" href="/champions-league-cup-c1">Champions League</a></li>
460
- <li><a title="Europa League" href="/europa-league">Europa League</a></li>
461
- <li><a title="Club World Cup" href="/fifa-club-world-cup-tags">Club World Cup</a></li>
462
- <li><a title="Europa Conference League" href="/europa-conference-league">Europa Conference League</a></li>
463
- </ul>
464
- </li>
465
-
466
- <li>
467
- <ul class="lst">
468
- <li><a class="fst" title="Bóng đá Italia" href="/bong-da-y">Bóng đá Italia</a></li>
469
- <li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
470
- <li><a title="Serie A" href="/serie-a">Serie A</a></li>
471
- <li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
472
- </ul>
473
- <div class="clx"></div>
474
- <ul class="lst">
475
- <li><a class="fst" href="#">Đội tuyển</a></li>
476
- <li><a title="World Cup nữ 2028" href="/world-cup-nu-2023-tags">World Cup nữ 2028</a></li>
477
- <li><a title="World Cup" href="/world-cup">World Cup 2026</a></li>
478
- <li><a title="European Championship 2028" href="/euro-cup-chau-au">EURO 2028</a></li>
479
- <li><a title="Nations League" href="/nations-league">Nations League</a></li>
480
- <li><a title="Copa America" href="/copa-america">Copa America</a></li>
481
- <li><a title="CAN" href="/can">CAN</a></li>
482
- <li><a title="Olympic" href="/olympic">Olympic 2028</a></li>
483
- </ul>
484
- </li>
485
-
486
- <li>
487
- <ul class="lst">
488
- <li><a class="fst" title="Bóng đá Pháp" href="/bong-da-phap">Bóng đá Pháp</a></li>
489
- <li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
490
- <li><a title="Ligue 1" href="/ligue-1">Ligue 1</a></li>
491
- <li><a title="Cúp QG Pháp" href="/cup-quoc-gia-phap">Cúp QG Pháp</a></li>
492
- <li><a title="Cúp Liên đoàn Pháp" href="/cup-lien-doan-phap-tags">Cúp Liên đoàn Pháp</a></li>
493
- </ul>
494
- <div class="clx"></div>
495
- <ul class="lst">
496
- <li><a class="fst" href="#">Khu vực</a></li>
497
- <li><a title="Asian Cup" href="/asian-cup">Asian Cup</a></li>
498
- <li><a title="Asian Games" href="/asian-games">Asian Games</a></li>
499
- <li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
500
- <li><a title="SEA Games" href="/sea-games">SEA Games</a></li>
501
- </ul>
502
- </li>
503
-
504
- <li>
505
- <ul class="lst">
506
- <li><a class="fst" title="Tây Ban nha" href="/bong-da-tay-ban-nha">Tây Ban nha</a></li>
507
- <li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
508
- <li><a title="La Liga" href="/la-liga">La Liga</a></li>
509
- <li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
510
- </ul>
511
- <div class="clx"></div>
512
- <ul class="lst">
513
- <li><a class="fst" href="#">Chuyên đề</a></li>
514
- <li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
515
- <li><a title="Hậu trường bóng đá" href="/hau-truong-bong-da">Hậu trường</a></li>
516
- <li><a title="Điểm tin" href="/diem-tin">Điểm tin</a></li>
517
- <li><a title="Showbiz Ngôi sao" href="/ngoi-sao-showbiz">Showbiz Ngôi sao</a></li>
518
- <li><a title="Góc check var" href="/goc-check-var">Góc check var</a></li>
519
- <li><a title="Công nghệ" href="/cong-nghe">Công nghệ</a></li>
520
- <li><a title="Thông tin Thị trường" href="/thi-truong">Thị trường</a></li>
521
- </ul>
522
- </li>
523
-
524
- <li>
525
- <ul class="lst">
526
- <li><a class="fst" title="Bóng đá Đức" href="/bong-da-duc">Bóng đá Đức</a></li>
527
- <li><a title="Đội tuyển Đức" href="/doi-tuyen-duc">Đội tuyển Đức</a></li>
528
- <li><a title="Bundesliga" href="/bundesliga">Bundesliga</a></li>
529
- <li><a title="Cúp QG Đức" href="/cup-quoc-gia-duc">Cúp QG Đức</a></li>
530
- </ul>
531
- <div class="clx"></div>
532
- <ul class="lst">
533
- <li><a class="fst" href="#">Đặc biệt</a></li>
534
- <li><a title="Big Story" href="/emagazine">Big Story</a></li>
535
- <li><a title="X-File" href="/x-file">X-File</a></li>
536
- <li><a title="Infographic" href="/infographic">Infographic</a></li>
537
- <li><a title="Interactive" href="/interactive">Interactive</a></li>
538
- </ul>
539
- </li>
540
- </ul>
541
- </div>
542
-
543
- <div class="menu-row">
544
- <ul class="bar">
545
- <li>&nbsp;</li>
546
- <li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Tin chuyển nhượng</a></li>
547
- <li><a title="Bóng đá & Cuộc sống" href="/bong-da-cuoc-song">Bóng đá & Cuộc sống</a></li>
548
- <li><a title="Thông tin Thị trường" href="/thi-truong">Thông tin Thị trường</a></li>
549
- <li><a title="Tin tức Công nghệ" href="/cong-nghe">Công nghệ</a></li>
550
- <li><a title="Tin tức Xe auto bike" href="/xe-co">Xe auto</a></li>
551
- </ul>
552
- </div>
553
-
554
- <div class="menu-row">
555
- <ul class="bar">
556
- <li>&nbsp;</li>
557
- <li><a class="caption" title="Nhận định Bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
558
- <li><a class="caption" title="Tư vấn Bóng đá" href="/soi-keo-bong-da">Tư vấn</a></li>
559
- <li><a class="caption" title="Multimedia" href="/video">Multimedia</a></li>
560
- <li><a class="caption" title="Youtube" target="_blank" href="https://www.youtube.com/channel/UCF0VFBZu-5SRJXMV_FMysEA">Youtube</a></li>
561
- <li><a class="caption" title="Facebook fanpage" target="_blank" href="https://www.facebook.com/BaoBongDa">FB Fanpage</a></li>
562
- <li>
563
- <a class="caption" title="Premium" href="/premium">Premium <i class="ico crown"></i></a>
564
- </li>
565
- </ul>
566
- </div>
567
-
568
- <div class="menu-row">
569
- <ul class="bar">
570
- <li><a class="caption" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
571
- <li>
572
- <ul class="lst">
573
- <li><a class="fst" href="/lich-thi-dau-bong-da">Lịch thi đấu hôm nay</a></li>
574
- <li><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Lịch thi đấu V.League">V.League</a></li>
575
- <li><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Lịch thi đấu Ngoại hạng Anh">Ngoại hạng Anh</a></li>
576
- <li><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Lịch thi đấu Ligue 1 Pháp">Ligue 1</a></li>
577
- <li><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Lịch thi đấu La Liga Tây Ban Nha">La Liga</a></li>
578
- <li><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Lịch thi đấu Bundesliga Đức">Bundesliga</a></li>
579
- <li><a href="/lich-thi-dau-bong-da?bong-da-y" title="Lịch thi đấu Serie A Italia">Serie A</a></li>
580
- <li><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Lịch thi đấu Champions League">Champions League</a></li>
581
- <li><a href="/lich-thi-dau-bong-da?europa-league" title="Lịch thi đấu Europa League">Europa League</a></li>
582
- </ul>
583
- </li>
584
-
585
- <li>
586
- <ul class="lst">
587
- <li><a class="fst" href="/ket-qua-bong-da">Kết quả mới nhất</a></li>
588
- <li><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Kết quả V.League">V.League</a></li>
589
- <li><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Kết quả Ngoại hạng Anh">Ngoại hạng Anh</a></li>
590
- <li><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Kết quả Ligue 1 Pháp">Ligue 1</a></li>
591
- <li><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Kết quả La Liga Tây Ban Nha">La Liga</a></li>
592
- <li><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Kết quả Bundesliga Đức">Bundesliga</a></li>
593
- <li><a href="/lich-thi-dau-bong-da?bong-da-y" title="Kết quả Serie A Italia">Serie A</a></li>
594
- <li><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Kết quả Champions League">Champions League</a></li>
595
- <li><a href="/lich-thi-dau-bong-da?europa-league" title="Kết quả Europa League">Europa League</a></li>
596
- </ul>
597
- </li>
598
-
599
- <li>
600
- <ul class="lst">
601
- <li><a class="fst" href="#">Bảng xếp hạng</a></li>
602
- <li><a href="/bang-xep-hang-bong-da?bong-da-viet-nam" title="Bảng xếp hạng V.League">V.League</a></li>
603
- <li><a href="/bang-xep-hang-bong-da?bong-da-anh" title="Bảng xếp hạng Ngoại hạng Anh">Ngoại hạng Anh</a></li>
604
- <li><a href="/bang-xep-hang-bong-da?bong-da-phap" title="Bảng xếp hạng Ligue 1 Pháp">Ligue 1</a></li>
605
- <li><a href="/bang-xep-hang-bong-da?bong-da-tay-ban-nha" title="Bảng xếp hạng La Liga Tây Ban Nha">La Liga</a></li>
606
- <li><a href="/bang-xep-hang-bong-da?bong-da-duc" title="Bảng xếp hạng Bundesliga Đức">Bundesliga</a></li>
607
- <li><a href="/bang-xep-hang-bong-da?bong-da-y" title="Bảng xếp hạng Serie A Italia">Serie A</a></li>
608
- <li><a href="/bang-xep-hang-bong-da?champions-league-cup-c1" title="Bảng xếp hạng Champions League">Champions League</a></li>
609
- <li><a href="/bang-xep-hang-bong-da?europa-league" title="Bảng xếp hạng Europa League">Europa League</a></li>
610
- </ul>
611
- </li>
612
-
613
- <li>
614
- <ul class="lst">
615
- <li><a class="caption" href="https://phui.bongdaplus.vn" target="_blank">Bóng đá phủi</a></li>
616
- <li><a class="caption" href="/tennis">Tennis</a></li>
617
- </ul>
618
- </li>
619
- <li>
620
- <ul class="lst">
621
- <li><a class="caption" href="/esports">Esports</a></li>
622
- <li><a class="caption" href="/the-thao">Thể thao +</a></li>
623
- </ul>
624
- </li>
625
-
626
- <li>
627
- <ul class="lst">
628
- <li>
629
- <div class="copyr">
630
- <div class="splt hide-w"></div>
631
- <p><b class="allcap">Tạp chí Bóng Đá</b></p>
632
- <p>Giấy phép số 48/GP-BTTTT cấp ngày 05/02/2020</p>
633
- <p>&nbsp;</p>
634
- <p>
635
- Tổng biên tập:<br /> <b>Vũ Khắc Sơn</b><br />
636
- Phó tổng biên tập:<br /> <b>Nguyễn Hà Thanh</b>
637
- </p>
638
- <p>&nbsp;</p>
639
- <p>
640
- <a class="contact-showr" href="#">Thông tin tòa soạn</a><br />
641
- <a class="contact-showr" href="#">Liên hệ quảng cáo</a>
642
- </p>
643
- <p>Liên đoàn BĐVN <a href="https://vff.org.vn/" target="_blank" rel="nofollow"><img alt="VFF" src="/img/logo_Vff.png" style="width:28px;" /></a></p>
644
- </div>
645
- </li>
646
- </ul>
647
- </li>
648
- </ul>
649
- </div>
650
- </div>
651
- </div>
652
- <div class="menu-tags hide-w">
653
- <div class="today"></div>
654
- <div class="mtags">
655
- <a title="#HighlightBóngđá" href="/video/highlight">#HighlightBóngđá</a>
656
- <a title="#PremierLeague" href="/ngoai-hang-anh">#PremierLeague</a>
657
- <a title="#V.League" href="/v-league">#V.League</a>
658
- </div>
659
- </div>
660
-
661
- <div id="fxtSlideCover" class="fixt-strm" style="margin-bottom:5px;">
662
- <div class="cont-wrap">
663
- <a class="brand" href="https://admin.giaminhmedia.vn/www/delivery/cl.php?bannerid=1411&zoneid=328&sig=8300d1283663cec85d90499507ffd94240954967132167c0f302d7388b8f9bb5&dest=http%3A%2F%2Fadmin.giaminhmedia.vn%2Fwww%2Funiscore.php" target="_blank" rel="nofollow" style="margin-left:5px;">
664
- <img alt="" src="https://bongdaplus.vn/img/logouniscore-tiny.png">
665
- </a>
666
- <ul id="fxtSlide" class="nav"></ul>
667
- </div>
668
- </div>
669
-
670
- <div class="clz"></div>
671
- <div id="ADTOP" class="banner adweb admob" banner-adpos="top"></div>
672
-
673
- <section class="bread-crums">
674
- <div class="clz"></div>
675
- <div class="cont-wrap">
676
- <div class="chain-navs">
677
- <a href="/video">Multimedia</a>&raquo;
678
- </div>
679
- </div>
680
- </section>
681
-
682
- <section class="media-box">
683
- <div class="clz"></div>
684
- <div class="cont-wrap video-box">
685
- <div class="row">
686
- <div class="col m12 w8 clip-box">
687
- <div class="play-box rbox">
688
- <iframe id="ytbFrBpl" class="play-frame" src="https://www.youtube.com/embed/Qokb_8w6p0s?si=QNKAZIYxNrtn2hSN" cid="UCmn1v6lrIDZghUfbxXaorUw" vnum="0" class="latestVideoEmbed" title="Bongdaplus trên Youtube" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe>
689
- </div>
690
- </div>
691
-
692
- <div class="col m12 w4 clip-cont">
693
- <div class="ovrscrl">
694
- <div class="clip-info">
695
- <h1 class="title">Hull City vs MU: Quỷ Đỏ vào hang bắt hổ</h1>
696
- <p class="desc">
697
- Mùa giải Premier League 2026/27 của MU sẽ mở màn bằng chuyến làm khách đầy thử thách trước tân binh Hull City tại sân MKM. Cuộc đối đầu Hull City vs MU là màn chạm trán đáng chú ý khi “Đàn Hổ” đang tràn đầy khí thế, trong khi Quỷ đỏ không được phép sảy chân nếu muốn sớm tạo đà cho cuộc đua vô địch.
698
- </p>
699
-
700
- <div>08:07 - 22/08/2026</div>
701
- <div class="editor"></div>
702
- <div class="hash-tags">
703
- <b>Tags:</b>
704
- <a href="/video/hull-city-vs-mu-tags">Hull City vs MU</a>
705
- <a href="/video/nhan-dinh-hull-city-vs-mu-tags">nh&#x1EAD;n &#x111;&#x1ECB;nh Hull City vs MU</a>
706
- <a href="/video/premier-league-tags">Premier League</a>
707
- <a href="/video/mu-tags">MU</a>
708
- </div>
709
- <style>
710
- .emobar .emo:not(:empty) {
711
- background-position: left 10px top 50%;
712
- padding: 0 13px 0 36px;
713
- }
714
- .emobar .emo {
715
- border-radius: 16px;
716
- background-color: #d7d3d3;
717
- background-size: auto 18px;
718
- cursor: pointer;
719
- margin: 0 3px;
720
- padding: 0 16px;
721
- line-height: 32px;
722
- height: 32px;
723
- }
724
- .emopicked {
725
- border: solid 1px #767676;
726
- }
727
- </style>
728
- <div class="emobar tx-cen">
729
- <div class="clx"></div>
730
- <a class="emo like" onclick="btnNewsEmotion_click(1)" id="btnEmo1"> <span id="numemo1"></span></a>
731
- <a class="emo heart" onclick="btnNewsEmotion_click(2)" id="btnEmo2"> <span id="numemo2"></span></a>
732
- <a class="emo star" onclick="btnNewsEmotion_click(4)" id="btnEmo4"> <span id="numemo4"></span></a>
733
- <a class="emo comment" id="ncmt" href="#commenting" data-action="comment" data-act-only="true"></a>
734
- </div>
735
- </div>
736
- </div>
737
- </div>
738
- </div>
739
- </div>
740
-
741
- <div class="clx"></div>
742
- <div class="cont-view">
743
-
744
-
745
-
746
-
747
- <style>
748
- .btnlikedislikecmt {
749
- border-radius: 9px;
750
- border: solid 1px #767676;
751
- background-color: #cecece;
752
- }
753
-
754
- sup {
755
- padding: 3px;
756
- position: initial;
757
- vertical-align: initial;
758
- }
759
-
760
- .TopComment {
761
- border: solid 1px #fdd835;
762
- }
763
-
764
- .TopComment .info .member {
765
- color: red;
766
- }
767
-
768
- .TopComment .info .avatar {
769
- border: solid 1px red;
770
- }
771
-
772
- .comment .summ .replyat {
773
- /*color: #1b5e20;*/
774
- display: inline-block;
775
- margin: 0 8px 0 0;
776
- font-weight: bold;
777
- /* text-decoration: underline; */
778
- }
779
- </style>
780
- <div id="postComment" class="comm-zone">
781
- <a class="anchor" name="#comments"></a>
782
- <div class="clz"></div>
783
- <div class="xcap">Bình luận</div>
784
- <div class="clz"></div>
785
- <div class="comm-post ">
786
- <div id="formcomment" class="comm-form">
787
- <label class="txt-fld">
788
- <textarea id="txtcomment_0" name="txtcomment_0" maxlength="1000" placeholder="Hãy chia sẻ suy nghĩ của bạn..." onclick="showcommentloginform();"></textarea>
789
- </label>
790
- <div class="actions">
791
- <button id="btnComment" class="btn btn-send base" onclick="btnComment_click(0,0)">Gửi</button>
792
- </div>
793
- <center><div class="cmterr"></div></center>
794
- </div>
795
- </div>
796
- <div class="clz"></div>
797
- <div class="comm-frame">
798
- <div id="NewsComments"></div>
799
- <div class="clz"></div>
800
- <div class="cat-news">
801
- <div id="btnloadviewmore" class="view-more hide"><a onclick="loadviewmorecomment();">Xem thêm</a></div>
802
- </div>
803
- <div class="clz"></div>
804
- </div>
805
- </div>
806
- </div>
807
- <div class="clz"></div>
808
- </section>
809
-
810
-
811
-
812
-
813
-
814
- <div class="clx"></div>
815
- <center><div id="ADHOT" class="banner adweb admob" banner-adpos="hot"></div></center>
816
- <div class="clx"></div>
817
-
818
-
819
- <section class="cat-news">
820
- <div class="cont-wrap">
821
- <div class="capt-cover">
822
- <div class="caption">Video mới nhất</div>
823
- <button class="more-lnk"></button>
824
- </div>
825
- <div class="row flex">
826
- <div class="col m12 w3">
827
- <div class="news">
828
- <a class="thumb" href="/video/du-doan-ty-so-hull-mu-than-rua-ali-33-chon-doi-nao-982702608.html"><img alt="Dự đoán tỷ số Hull vs MU: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/80/Anh-chu-480-x-270.jpg" /></a>
829
- <a class="title" href="/video/du-doan-ty-so-hull-mu-than-rua-ali-33-chon-doi-nao-982702608.html">Dự đoán tỷ số Hull vs MU: Thần Rùa Ali 33 chọn đội nào?</a>
830
- <span class="info">
831
- 42 ph&#xFA;t tr&#x1B0;&#x1EDB;c
832
- </span>
833
- <span class="info">
834
- </span>
835
- </div>
836
- </div>
837
- <div class="col m12 w3">
838
- <div class="news">
839
- <a class="thumb" href="/video/hull-city-vs-mu-quy-do-vao-hang-bat-ho-982672608.html"><img alt="Hull City vs MU: Quỷ Đỏ vào hang bắt hổ" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbHullvsMU-up.jpg" /></a>
840
- <a class="title" href="/video/hull-city-vs-mu-quy-do-vao-hang-bat-ho-982672608.html">Hull City vs MU: Quỷ Đỏ vào hang bắt hổ</a>
841
- <span class="info">
842
- 43 ph&#xFA;t tr&#x1B0;&#x1EDB;c
843
- </span>
844
- <span class="info">
845
- </span>
846
- </div>
847
- </div>
848
- <div class="col m12 w3">
849
- <div class="news">
850
- <a class="thumb" href="/video/du-doan-ty-so-brentford-vs-tottenham-premier-league-2026-27-than-rua-ali-33-chon-doi-nao-982862608.html"><img alt="Dự đoán tỷ số Brentford vs Tottenham: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/19/80/Brentford-vs-Tottenham-480-x-270-moi.jpg" /></a>
851
- <a class="title" href="/video/du-doan-ty-so-brentford-vs-tottenham-premier-league-2026-27-than-rua-ali-33-chon-doi-nao-982862608.html">Dự đoán tỷ số Brentford vs Tottenham: Thần Rùa Ali 33 chọn đội nào?</a>
852
- <span class="info">
853
- 43 ph&#xFA;t tr&#x1B0;&#x1EDB;c
854
- </span>
855
- <span class="info">
856
- </span>
857
- </div>
858
- </div>
859
- <div class="col m12 w3">
860
- <div class="news">
861
- <a class="thumb" href="/video/brentford-vs-tottenham-de-zerbi-vuot-ai-gtech-982822608.html"><img alt="Brentford vs Tottenham: De Zerbi vượt ải Gtech" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/19/13/Brenfort-vs-Tottenham.jpg" /></a>
862
- <a class="title" href="/video/brentford-vs-tottenham-de-zerbi-vuot-ai-gtech-982822608.html">Brentford vs Tottenham: De Zerbi vượt ải Gtech</a>
863
- <span class="info">
864
- 43 ph&#xFA;t tr&#x1B0;&#x1EDB;c
865
- </span>
866
- <span class="info">
867
- </span>
868
- </div>
869
- </div>
870
- <div class="col m12 w3">
871
- <div class="news">
872
- <a class="thumb" href="/video/du-doan-ty-so-arsenal-vs-coventry-than-rua-ali-33-chon-doi-nao-982612608.html"><img alt="Dự đoán tỷ số Arsenal vs Coventry: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/17/0/thumblai.jpg" /></a>
873
- <a class="title" href="/video/du-doan-ty-so-arsenal-vs-coventry-than-rua-ali-33-chon-doi-nao-982612608.html">Dự đoán tỷ số Arsenal vs Coventry: Thần Rùa Ali 33 chọn đội nào?</a>
874
- <span class="info">
875
- 09 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
876
- </span>
877
- <span class="info">
878
- </span>
879
- </div>
880
- </div>
881
- <div class="col m12 w3">
882
- <div class="news">
883
- <a class="thumb" href="/video/arsenal-vs-coventry-nha-vua-mo-man-bang-mot-chien-thang-982662608.html"><img alt="Arsenal vs Coventry: Nhà vua mở màn bằng một chiến thắng?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbArsenalvsCoventry-up.jpg" /></a>
884
- <a class="title" href="/video/arsenal-vs-coventry-nha-vua-mo-man-bang-mot-chien-thang-982662608.html">Arsenal vs Coventry: Nhà vua mở màn bằng một chiến thắng?</a>
885
- <span class="info">
886
- 09 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
887
- </span>
888
- <span class="info">
889
- </span>
890
- </div>
891
- </div>
892
- <div class="col m12 w3">
893
- <div class="news">
894
- <a class="thumb" href="/video/truc-tuyen-truoc-tran-arsenal-vs-coventry-hull-vs-mu-983222608.html"><img alt="Trực tuyến trước trận Arsenal vs Coventry, Hull vs MU" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/truc-tuyen-truoc-tran-Arsenal-vs-Coventry.jpg" /></a>
895
- <a class="title" href="/video/truc-tuyen-truoc-tran-arsenal-vs-coventry-hull-vs-mu-983222608.html">Trực tuyến trước trận Arsenal vs Coventry, Hull vs MU</a>
896
- <span class="info">
897
- 11 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
898
- </span>
899
- <span class="info">
900
- </span>
901
- </div>
902
- </div>
903
- <div class="col m12 w3">
904
- <div class="news">
905
- <a class="thumb" href="/video/dt-thai-lan-tap-cach-san-rajamangala-40km-hlv-anthony-hudson-chot-luon-doi-hinh-dau-viet-nam-983202608.html"><img alt=" ĐT Thái Lan tập cách sân Rajamangala 40km, HLV Anthony Hudson chốt luôn đội hình đấu Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/HLV-Thai-Lan-chot-doi-hinh.jpg" /></a>
906
- <a class="title" href="/video/dt-thai-lan-tap-cach-san-rajamangala-40km-hlv-anthony-hudson-chot-luon-doi-hinh-dau-viet-nam-983202608.html"> ĐT Thái Lan tập cách sân Rajamangala 40km, HLV Anthony Hudson chốt luôn đội hình đấu Việt Nam</a>
907
- <span class="info">
908
- 13 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
909
- </span>
910
- <span class="info">
911
- </span>
912
- </div>
913
- </div>
914
- <div class="col m12 w3">
915
- <div class="news">
916
- <a class="thumb" href="/video/tro-lai-san-tung-vo-dich-aff-cup-2024-dt-viet-nam-quyet-lam-nen-lich-su-983212608.html"><img alt=" Trở lại sân từng vô địch AFF Cup 2024, ĐT Việt Nam quyết làm nên lịch sử" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/Viet-Nam-tap-2-1-8.jpg" /></a>
917
- <a class="title" href="/video/tro-lai-san-tung-vo-dich-aff-cup-2024-dt-viet-nam-quyet-lam-nen-lich-su-983212608.html"> Trở lại sân từng vô địch AFF Cup 2024, ĐT Việt Nam quyết làm nên lịch sử</a>
918
- <span class="info">
919
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
920
- </span>
921
- <span class="info">
922
- </span>
923
- </div>
924
- </div>
925
- <div class="col m12 w3">
926
- <div class="news">
927
- <a class="thumb" href="/video/vong-1-premier-league-2026-27-lieu-co-cu-soc-983112608.html"><img alt="Nhận định Premier League vòng 1: Liệu có cú sốc?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/thumbvong1-480.jpg" /></a>
928
- <a class="title" href="/video/vong-1-premier-league-2026-27-lieu-co-cu-soc-983112608.html">Nhận định Premier League vòng 1: Liệu có cú sốc?</a>
929
- <span class="info">
930
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
931
- </span>
932
- <span class="info">
933
- </span>
934
- </div>
935
- </div>
936
- <div class="col m12 w3">
937
- <div class="news">
938
- <a class="thumb" href="/video/ronaldinho-gay-soc-46-tuoi-van-tro-lai-san-co-khoac-ao-clb-serie-c-983192608.html"><img alt="Ronaldinho gây sốc: 46 tuổi vẫn trở lại sân cỏ, khoác áo CLB Serie C" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Ronaldinho480.jpg" /></a>
939
- <a class="title" href="/video/ronaldinho-gay-soc-46-tuoi-van-tro-lai-san-co-khoac-ao-clb-serie-c-983192608.html">Ronaldinho gây sốc: 46 tuổi vẫn trở lại sân cỏ, khoác áo CLB Serie C</a>
940
- <span class="info">
941
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
942
- </span>
943
- <span class="info">
944
- </span>
945
- </div>
946
- </div>
947
- <div class="col m12 w3">
948
- <div class="news">
949
- <a class="thumb" href="/video/arsenal-no-bom-tan-51-trieu-bang-de-va-hang-thu-983122608.html"><img alt="Arsenal nổ bom tấn 51 triệu bảng để vá hàng thủ" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Konsa480.jpg" /></a>
950
- <a class="title" href="/video/arsenal-no-bom-tan-51-trieu-bang-de-va-hang-thu-983122608.html">Arsenal nổ bom tấn 51 triệu bảng để vá hàng thủ</a>
951
- <span class="info">
952
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
953
- </span>
954
- <span class="info">
955
- </span>
956
- </div>
957
- </div>
958
- <div class="col m12 w3">
959
- <div class="news">
960
- <a class="thumb" href="/video/truc-tuyen-hop-bao-viet-nam-hlv-kim-sang-sik-noi-gi-khi-pho-chu-tich-thai-lan-mia-mai-dan-nhap-tich-983182608.html"><img alt="Trực tuyến họp báo Việt Nam: HLV Kim Sang Sik nói gì khi Phó Chủ tịch Thái Lan mỉa mai dàn nhập tịch" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhliveVN.jpg" /></a>
961
- <a class="title" href="/video/truc-tuyen-hop-bao-viet-nam-hlv-kim-sang-sik-noi-gi-khi-pho-chu-tich-thai-lan-mia-mai-dan-nhap-tich-983182608.html">Trực tuyến họp báo Việt Nam: HLV Kim Sang Sik nói gì khi Phó Chủ tịch Thái Lan mỉa mai dàn nhập tịch</a>
962
- <span class="info">
963
- 16 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
964
- </span>
965
- <span class="info">
966
- </span>
967
- </div>
968
- </div>
969
- <div class="col m12 w3">
970
- <div class="news">
971
- <a class="thumb" href="/video/truc-tuyen-thai-lan-hop-bao-hlv-thach-dau-dt-viet-nam-983162608.html"><img alt=" Trực tuyến Thái Lan họp báo: HLV Anthony Hudson thách đấu ĐT Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhlivesua-480.jpg" /></a>
972
- <a class="title" href="/video/truc-tuyen-thai-lan-hop-bao-hlv-thach-dau-dt-viet-nam-983162608.html"> Trực tuyến Thái Lan họp báo: HLV Anthony Hudson thách đấu ĐT Việt Nam</a>
973
- <span class="info">
974
- 18 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
975
- </span>
976
- <span class="info">
977
- </span>
978
- </div>
979
- </div>
980
- <div class="col m12 w3">
981
- <div class="news">
982
- <a class="thumb" href="/video/ban-tin-21-8-dt-viet-nam-chiu-thong-ke-cuc-xau-truoc-chung-ket-thai-lan-choi-chieu-nhu-indo-983152608.html"><img alt="Bản tin 21/8: ĐT Việt Nam chịu thống kê cực xấu trước chung kết, Thái Lan chơi chiêu như Indo" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/Thumb-tin-nong-21-8-480-up.jpg" /></a>
983
- <a class="title" href="/video/ban-tin-21-8-dt-viet-nam-chiu-thong-ke-cuc-xau-truoc-chung-ket-thai-lan-choi-chieu-nhu-indo-983152608.html">Bản tin 21/8: ĐT Việt Nam chịu thống kê cực xấu trước chung kết, Thái Lan chơi chiêu như Indo</a>
984
- <span class="info">
985
- 18 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
986
- </span>
987
- <span class="info">
988
- </span>
989
- </div>
990
- </div>
991
- <div class="col m12 w3">
992
- <div class="news">
993
- <a class="thumb" href="/video/thai-lan-khoa-cdv-viet-nam-chi-3-000-ve-cho-tran-chung-ket-tai-rajamangala-983142608.html"><img alt="Thái Lan 'khóa' CĐV Việt Nam: Chỉ 3.000 vé cho trận chung kết tại Rajamangala" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/cdv.jpg" /></a>
994
- <a class="title" href="/video/thai-lan-khoa-cdv-viet-nam-chi-3-000-ve-cho-tran-chung-ket-tai-rajamangala-983142608.html">Thái Lan 'khóa' CĐV Việt Nam: Chỉ 3.000 vé cho trận chung kết tại Rajamangala</a>
995
- <span class="info">
996
- 20 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
997
- </span>
998
- <span class="info">
999
- </span>
1000
- </div>
1001
- </div>
1002
- <div class="col m12 w3">
1003
- <div class="news">
1004
- <a class="thumb" href="/video/truoc-tran-thai-lan-vs-viet-nam-svd-rajamagala-vang-tanh-hon-chua-ba-danh-983132608.html"><img alt="Trước trận Thái Lan vs Việt Nam: SVĐ Rajamagala vắng tanh hơn chùa Bà Đanh" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/livethumbYT-480.jpg" /></a>
1005
- <a class="title" href="/video/truoc-tran-thai-lan-vs-viet-nam-svd-rajamagala-vang-tanh-hon-chua-ba-danh-983132608.html">Trước trận Thái Lan vs Việt Nam: SVĐ Rajamagala vắng tanh hơn chùa Bà Đanh</a>
1006
- <span class="info">
1007
- 21 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
1008
- </span>
1009
- <span class="info">
1010
- </span>
1011
- </div>
1012
- </div>
1013
- <div class="col m12 w3">
1014
- <div class="news">
1015
- <a class="thumb" href="/video/du-doan-ty-so-thai-lan-vs-viet-nam-than-rua-ali-33-chon-doi-nao-983092608.html"><img alt="Dự đoán tỷ số Thái Lan vs Việt Nam: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/80/ThumbRuaThaivsVietnam-480.jpg" /></a>
1016
- <a class="title" href="/video/du-doan-ty-so-thai-lan-vs-viet-nam-than-rua-ali-33-chon-doi-nao-983092608.html">Dự đoán tỷ số Thái Lan vs Việt Nam: Thần Rùa Ali 33 chọn đội nào?</a>
1017
- <span class="info">
1018
- 19:18 ng&#xE0;y 20/08/2026
1019
- </span>
1020
- <span class="info">
1021
- </span>
1022
- </div>
1023
- </div>
1024
- <div class="col m12 w3">
1025
- <div class="news">
1026
- <a class="thumb" href="/video/truyen-thong-anh-dac-biet-chu-y-den-ngoi-no-cua-dt-viet-nam-983072608.html"><img alt="Truyền thông Anh đặc biệt chú ý đến 'ngòi nổ' của ĐT Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/xuan-son.jpg" /></a>
1027
- <a class="title" href="/video/truyen-thong-anh-dac-biet-chu-y-den-ngoi-no-cua-dt-viet-nam-983072608.html">Truyền thông Anh đặc biệt chú ý đến 'ngòi nổ' của ĐT Việt Nam</a>
1028
- <span class="info">
1029
- 19:18 ng&#xE0;y 20/08/2026
1030
- </span>
1031
- <span class="info">
1032
- </span>
1033
- </div>
1034
- </div>
1035
- <div class="col m12 w3">
1036
- <div class="news">
1037
- <a class="thumb" href="/video/dt-viet-nam-co-mat-tai-thai-lan-ao-trang-van-chua-bi-thay-kim-vut-het-983082608.html"><img alt="ĐT Việt Nam có mặt tại Thái Lan, áo trắng vẫn chưa bị thầy Kim bỏ hết" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/Kim-Sang-Sik.jpg" /></a>
1038
- <a class="title" href="/video/dt-viet-nam-co-mat-tai-thai-lan-ao-trang-van-chua-bi-thay-kim-vut-het-983082608.html">ĐT Việt Nam có mặt tại Thái Lan, áo trắng vẫn chưa bị thầy Kim bỏ hết</a>
1039
- <span class="info">
1040
- 17:32 ng&#xE0;y 20/08/2026
1041
- </span>
1042
- <span class="info">
1043
- </span>
1044
- </div>
1045
- </div>
1046
- </div>
1047
- </div>
1048
- <div class="clz"></div>
1049
- </section>
1050
-
1051
-
1052
-
1053
- <div class="clx"></div>
1054
- <center><div id="ADBOT" class="banner adweb admob" banner-adpos="footer"></div></center>
1055
- </main>
1056
-
1057
- <style>
1058
- .iframpanel {
1059
- width: 100%;
1060
- max-width: 500px;
1061
- height: 560px;
1062
- overflow: hidden;
1063
- }
1064
-
1065
- </style>
1066
- <div class="paner-wrap">
1067
- <div class="over-layer hide"></div>
1068
- <div id="loginview" class="login-box paner hide iframpanel">
1069
- <div class="pan-capt">
1070
- <i class="pan-close" data-action="hide"></i>
1071
- </div>
1072
-
1073
-
1074
- <iframe src="https://member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=%2FHome%2FLoginFromBongdaplus" frameborder="0" style="overflow:hidden;height:100%;width:100%"></iframe>
1075
-
1076
- </div>
1077
- <div id="commentloginview" class="login-box paner hide iframpanel">
1078
-
1079
-
1080
- <iframe src="https://member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=%2FHome%2FCommentLoginFromBongdaplus" frameborder="0" style="overflow:hidden;height:100%;width:100%"></iframe>
1081
-
1082
- </div>
1083
- </div>
1084
- <input type="hidden" id="txtFullname" value="" />
1085
- <input type="hidden" id="txtUserid" value="" />
1086
-
1087
-
1088
-
1089
-
1090
-
1091
-
1092
- <center><div id="ADFT" class="banner adweb admob" banner-adpos="footer"></div></center>
1093
- <footer>
1094
- <div class="cont-wrap">
1095
- <div class="row">
1096
- <div class="col m12 w3">
1097
- <div class="caption">Tạp chí điện tử Bóng đá</div>
1098
- </div>
1099
- <div class="col m12 w6">
1100
- <p>Giấy phép số 48/GP-BTTTT cấp ngày 05/02/2020</p>
1101
- <p>Tổng biên tập: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <b>Vũ Khắc Sơn</b></p>
1102
- <p>Phó tổng biên tập: &nbsp; &nbsp;<b>Nguyễn Hà Thanh</b></p>
1103
- <div class="hide-w">
1104
- <p>Địa chỉ: &nbsp;<b>Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội</b></p>
1105
- <p>Văn phòng giao dịch: <b>Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội</b></p>
1106
- <p>Điện thoại: <b>(84.24) 3554 1188 - (84.24) 3554 1199</b></p>
1107
- <p>
1108
- Email: &nbsp; &nbsp; <a href="mailto:toasoan@bongdaplus.vn"><b>toasoan@bongdaplus.vn</b></a> |
1109
- <a href="mailto:vanphong@bongdaplus.vn"><b>vanphong@bongdaplus.vn</b></a>
1110
- </p>
1111
- <div class="spl"></div>
1112
- <p class="caption">Liên hệ quảng cáo</p>
1113
- <p>Hotline: &nbsp;<b>0903 203 412</b></p>
1114
- <p>Email: &nbsp; &nbsp;<a href="mailto:tien.nguyen@giaminhmedia.vn"><b>tien.nguyen@giaminhmedia.vn</b></a></p>
1115
- </div>
1116
- </div>
1117
- <div class="col m12 w3">
1118
- <ul class="bar social-lnk rgt-w">
1119
- <li class="hide-m">Theo dõi thông tin</li>
1120
- <li><a class="ico vff-lnk" href="https://vff.org.vn/" target="_blank" rel="nofollow"></a></li>
1121
- <li><a class="ico fb-lnk" href="https://www.facebook.com/BaoBongDa/"></a></li>
1122
- <li><a class="ico yt-lnk" href="https://www.youtube.com/channel/UCvDYMBE6GipX2VGacqOU2_Q"></a></li>
1123
- </ul>
1124
- </div>
1125
- </div>
1126
- <div class="foot-menu">
1127
- <ul class="bar hide-m">
1128
- <li><a href="#" data-showpaner="#contactview">Thông tin tòa soạn</a></li>
1129
- <li><a href="#" data-showpaner="#advertview">Liên hệ quảng cáo</a></li>
1130
- </ul>
1131
- <div class="copy-rgt">
1132
- Bản quyền &copy;2011 Bongdaplus.vn. Chỉ được phát hành lại thông tin khi
1133
- có sự đồng ý bằng văn bản của Tạp chí điện tử Bóng đá
1134
- </div>
1135
- <div class="clx hide-m"></div>
1136
- </div>
1137
- </div>
1138
- </footer>
1139
-
1140
- <div class="paner-wrap">
1141
- <div class="over-layer hide"></div>
1142
- <div id="contactview" class="contact-box paner hide">
1143
- <div class="pan-capt">
1144
- <div class="pan-title">Thông tin Toà soạn</div>
1145
- <i class="pan-close" data-action="hide"></i>
1146
- </div>
1147
- <div class="pan-cont">
1148
- <div class="xcap lead tx-prime tx-cen">Tạp chí Điện tử Bóng Đá</div>
1149
- <div class="row">
1150
- <div class="col w3 tx-base tx-rgt">Tổng biên tập:</div>
1151
- <div class="col w9"><b>Vũ Khắc Sơn</b></div>
1152
- <div class="clx"></div>
1153
- <div class="col w3 tx-base tx-rgt">Phó Tổng biên tập:</div>
1154
- <div class="col w9">
1155
- <b>Nguyễn Hà Thanh</b>
1156
- </div>
1157
- </div>
1158
- <div class="splt"></div>
1159
- <div class="row">
1160
- <div class="col w3 tx-base tx-rgt">Địa chỉ:</div>
1161
- <div class="col w9"><b>Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội</b></div>
1162
- <div class="col w3 tx-base tx-rgt">Văn phòng giao dịch:</div>
1163
- <div class="col w9"><b>Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội</b></div>
1164
- <div class="col w3 tx-base tx-rgt">Tel:</div>
1165
- <div class="col w9"><b>(84.24) 3554 1188 - (84.24) 3554 1199</b></div>
1166
- <div class="col w3 tx-base tx-rgt">Fax:</div>
1167
- <div class="col w9"><b>(84.24) 3553 9898</b></div>
1168
- <div class="col w3 tx-base tx-rgt">Email:</div>
1169
- <div class="col w9">
1170
- <a href="mailto:toasoan@bongdaplus.vn"><b>toasoan@bongdaplus.vn</b></a>
1171
- <a href="mailto:vanphong@bongdaplus.vn"><b>vanphong@bongdaplus.vn</b></a>
1172
- </div>
1173
- </div>
1174
- </div>
1175
- </div>
1176
-
1177
- <div id="advertview" class="contact-box paner hide">
1178
- <div class="pan-capt">
1179
- <div class="pan-title">Thông tin Liên hệ</div>
1180
- <i class="pan-close" data-action="hide"></i>
1181
- </div>
1182
- <div class="pan-cont">
1183
- <div class="xcap lead tx-prime tx-cen">Tạp chí Điện tử Bóng Đá</div>
1184
- <div class="row">
1185
- <div class="col w4 tx-base tx-rgt">Hotline:</div>
1186
- <div class="col w8"><b>0903 203 412</b></div>
1187
- <div class="col w4 tx-base tx-rgt">Email:</div>
1188
- <div class="col w8"><a href="mailto:tien.nguyen@giaminhmedia.vn"><b>tien.nguyen@giaminhmedia.vn</b></a></div>
1189
- </div>
1190
- <p class="tx-base">Địa chỉ liên hệ:</p>
1191
- <b>Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội</b>
1192
- <p class="tx-base">Văn phòng giao dịch:</p>
1193
- <b>Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội</b>
1194
- </div>
1195
- </div>
1196
-
1197
-
1198
- <div id="loginview" class="login-box paner hide">
1199
- <div class="pan-capt">
1200
- <div class="pan-title">Đăng nhập</div>
1201
- <i class="pan-close" data-action="hide"></i>
1202
- </div>
1203
- <div class="pan-cont">
1204
- <form class="soc-auth" action="#">
1205
- <button><i class="ico gg-color"></i>Google</button>
1206
- <button><i class="ico fb-color"></i>Facebook</button>
1207
- </form>
1208
- <div class="clx"></div>
1209
- <div class="splt"><span>hoặc</span></div>
1210
- <form class="loginer" action="//member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=/" method="post">
1211
- <p>Email:</p>
1212
- <label class="txt-fld" for="email">
1213
- <input id="email" name="Input.Email" type="email" />
1214
- <span>Nhập địa chỉ email của bạn</span>
1215
- </label>
1216
- <label class="err-fld hide" for="email">Địa chỉ email không được để trống</label>
1217
- <p>Mật khẩu:</p>
1218
- <label class="txt-fld" for="passw">
1219
- <input id="passw" name="Input.Password" type="password" data-required="Mật khẩu không được để trống" />
1220
- <span>Nhập mật khẩu của bạn</span>
1221
- </label>
1222
- <label class="err-fld hide" for="passw">Mật khẩu không được để trống</label>
1223
- <p class="ft-sm tx-rgt"><a class="fg-psw" href="#" data-action="hide" data-showpaner="#forgetview">Quên mật khẩu?</a></p>
1224
- <label class="rdo-fld" for="persist">
1225
- <input id="persist" name="Input.RememberMe" type="checkbox" />
1226
- Ghi nhớ thông tin đăng nhập
1227
- </label>
1228
- <br>
1229
- <div class="fld-box">
1230
- <button class="btn major" type="submit">Đăng nhập</button>
1231
- </div>
1232
- <p class="ft-sm tx-cen">
1233
- Bạn chưa có tài khoản?
1234
- <a class="act-now" href="#" data-action="hide" data-showpaner="#registview">Đăng ký ngay</a>
1235
- </p>
1236
- </form>
1237
- </div>
1238
- </div>
1239
- </div>
1240
-
1241
- <script src="/lib/jquery/dist/jquery.min.js"></script>
1242
- <script src="/js/js_cookie.min.js?v=0H3Nuz3aug3afVbUlsu12Puxva3CP4EhJtPExqs54Vg"></script>
1243
- <script src="/js/bongdaplus.js?v=gIIX1pYQ8DB66N40YPyoHGaBgBFoHkPMMCARX4L6NdA"></script>
1244
- <script src="/js/premium.js?v=mbXB8QoXMl1zoEFSeE6z-mwQTb0ot7C8zj_F8JqsvNc"></script>
1245
-
1246
- <script>var _ad_info_code = 'mda/video';</script>
1247
- <script src="/js/advloader.js?v=dvvR0ygTjyYzNcQmQ_dI5bwYeX2Ww0nBjBBmCt9DWjQ"></script>
1248
-
1249
- </body>
1250
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
embed.html DELETED
@@ -1,56 +0,0 @@
1
- <!DOCTYPE html>
2
- <html>
3
- <head>
4
- <meta name="viewport" content="width=device-width" />
5
- <title>EmbedVideo - Bongdaplus.vn </title>
6
-
7
- <link href="/css/video-js.css" rel="stylesheet">
8
- <link href="/css/style.css" rel="stylesheet">
9
- <link href="/css/player.css?new=43666246" rel="stylesheet">
10
- <script src="https://imasdk.googleapis.com/js/sdkloader/ima3.js"></script>
11
- <script src="/js/video.min.js"></script>
12
- <!-- Google tag (gtag.js) -->
13
- <script async src="https://www.googletagmanager.com/gtag/js?id=G-EDHD2H8E9T"></script>
14
- <script>window.dataLayer = window.dataLayer || []; function gtag() { dataLayer.push(arguments); } gtag('js', new Date()); gtag('config', 'G-EDHD2H8E9T');</script>
15
- </head>
16
- <body class="vjs-fullscreen">
17
-
18
- <div class="">
19
- <video id="videoPlayer" class="video-js" preload="auto" width="100%" height="100%" poster="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbHullvsMU-up.jpg" controls disablePictureInPicture>
20
- <source src="https://cdn.bongdaplus.vn/assets//Media/2026/08/18/13/thumbhullvsmu-up.jpg" type="video/mp4">
21
- </video>
22
- <script>
23
- var adpath = 'mob/'; var cat = 127, evt = 0;
24
- if (window.matchMedia('(min-width: 1023px)').matches || (window.parent.Detect && !window.parent.Detect.isMob())) {
25
- adpath = 'web/';
26
- }
27
- adpath += 'mda';
28
- document.write('<script src="/adscripts/' + adpath + '/vast.js?new=' + (new Date()).getTime() + '"></' + 'script>');
29
- </script>
30
-
31
- <script>
32
- var vdo = videojs('videoPlayer', { autoplay: true });
33
- vdo.relatedCarousel([{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/18/80/Anh-chu-480-x-270.jpg', vdoref: '/video/du-doan-ty-so-hull-mu-than-rua-ali-33-chon-doi-nao-982702608.html', title: 'Dự đoán tỷ số Hull vs MU: Thần Rùa Ali 33 chọn đội nào?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbHullvsMU-up.jpg', vdoref: '/video/hull-city-vs-mu-quy-do-vao-hang-bat-ho-982672608.html', title: 'Hull City vs MU: Quỷ Đỏ vào hang bắt hổ', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/19/80/Brentford-vs-Tottenham-480-x-270-moi.jpg', vdoref: '/video/du-doan-ty-so-brentford-vs-tottenham-premier-league-2026-27-than-rua-ali-33-chon-doi-nao-982862608.html', title: 'Dự đoán tỷ số Brentford vs Tottenham: Thần Rùa Ali 33 chọn đội nào?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/19/13/Brenfort-vs-Tottenham.jpg', vdoref: '/video/brentford-vs-tottenham-de-zerbi-vuot-ai-gtech-982822608.html', title: 'Brentford vs Tottenham: De Zerbi vượt ải Gtech', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/17/0/thumblai.jpg', vdoref: '/video/du-doan-ty-so-arsenal-vs-coventry-than-rua-ali-33-chon-doi-nao-982612608.html', title: 'Dự đoán tỷ số Arsenal vs Coventry: Thần Rùa Ali 33 chọn đội nào?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbArsenalvsCoventry-up.jpg', vdoref: '/video/arsenal-vs-coventry-nha-vua-mo-man-bang-mot-chien-thang-982662608.html', title: 'Arsenal vs Coventry: Nhà vua mở màn bằng một chiến thắng?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/truc-tuyen-truoc-tran-Arsenal-vs-Coventry.jpg', vdoref: '/video/truc-tuyen-truoc-tran-arsenal-vs-coventry-hull-vs-mu-983222608.html', title: 'Trực tuyến trước trận Arsenal vs Coventry, Hull vs MU', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/HLV-Thai-Lan-chot-doi-hinh.jpg', vdoref: '/video/dt-thai-lan-tap-cach-san-rajamangala-40km-hlv-anthony-hudson-chot-luon-doi-hinh-dau-viet-nam-983202608.html', title: ' ĐT Thái Lan tập cách sân Rajamangala 40km, HLV Anthony Hudson chốt luôn đội hình đấu Việt Nam', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/Viet-Nam-tap-2-1-8.jpg', vdoref: '/video/tro-lai-san-tung-vo-dich-aff-cup-2024-dt-viet-nam-quyet-lam-nen-lich-su-983212608.html', title: ' Trở lại sân từng vô địch AFF Cup 2024, ĐT Việt Nam quyết làm nên lịch sử', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/thumbvong1-480.jpg', vdoref: '/video/vong-1-premier-league-2026-27-lieu-co-cu-soc-983112608.html', title: 'Nhận định Premier League vòng 1: Liệu có cú sốc?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Ronaldinho480.jpg', vdoref: '/video/ronaldinho-gay-soc-46-tuoi-van-tro-lai-san-co-khoac-ao-clb-serie-c-983192608.html', title: 'Ronaldinho gây sốc: 46 tuổi vẫn trở lại sân cỏ, khoác áo CLB Serie C', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Konsa480.jpg', vdoref: '/video/arsenal-no-bom-tan-51-trieu-bang-de-va-hang-thu-983122608.html', title: 'Arsenal nổ bom tấn 51 triệu bảng để vá hàng thủ', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhliveVN.jpg', vdoref: '/video/truc-tuyen-hop-bao-viet-nam-hlv-kim-sang-sik-noi-gi-khi-pho-chu-tich-thai-lan-mia-mai-dan-nhap-tich-983182608.html', title: 'Trực tuyến họp báo Việt Nam: HLV Kim Sang Sik nói gì khi Phó Chủ tịch Thái Lan mỉa mai dàn nhập tịch', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhlivesua-480.jpg', vdoref: '/video/truc-tuyen-thai-lan-hop-bao-hlv-thach-dau-dt-viet-nam-983162608.html', title: ' Trực tuyến Thái Lan họp báo: HLV Anthony Hudson thách đấu ĐT Việt Nam', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/Thumb-tin-nong-21-8-480-up.jpg', vdoref: '/video/ban-tin-21-8-dt-viet-nam-chiu-thong-ke-cuc-xau-truoc-chung-ket-thai-lan-choi-chieu-nhu-indo-983152608.html', title: 'Bản tin 218: ĐT Việt Nam chịu thống kê cực xấu trước chung kết, Thái Lan chơi chiêu như Indo', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/cdv.jpg', vdoref: '/video/thai-lan-khoa-cdv-viet-nam-chi-3-000-ve-cho-tran-chung-ket-tai-rajamangala-983142608.html', title: 'Thái Lan khóa CĐV Việt Nam: Chỉ 3.000 vé cho trận chung kết tại Rajamangala', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/livethumbYT-480.jpg', vdoref: '/video/truoc-tran-thai-lan-vs-viet-nam-svd-rajamagala-vang-tanh-hon-chua-ba-danh-983132608.html', title: 'Trước trận Thái Lan vs Việt Nam: SVĐ Rajamagala vắng tanh hơn chùa Bà Đanh', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/20/80/ThumbRuaThaivsVietnam-480.jpg', vdoref: '/video/du-doan-ty-so-thai-lan-vs-viet-nam-than-rua-ali-33-chon-doi-nao-983092608.html', title: 'Dự đoán tỷ số Thái Lan vs Việt Nam: Thần Rùa Ali 33 chọn đội nào?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/xuan-son.jpg', vdoref: '/video/truyen-thong-anh-dac-biet-chu-y-den-ngoi-no-cua-dt-viet-nam-983072608.html', title: 'Truyền thông Anh đặc biệt chú ý đến ngòi nổ của ĐT Việt Nam', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/Kim-Sang-Sik.jpg', vdoref: '/video/dt-viet-nam-co-mat-tai-thai-lan-ao-trang-van-chua-bi-thay-kim-vut-het-983082608.html', title: 'ĐT Việt Nam có mặt tại Thái Lan, áo trắng vẫn chưa bị thầy Kim bỏ hết', target: '_parent' }]);
34
-
35
- var vst = window['vast'] || '';
36
- if (vst) {
37
- vst = vst.replace('[yo_page_url]', window.parent.location.href);
38
- vdo.adsVastClient(vst);
39
- }
40
- vdo.ready(function () { vdo.play(); }); var counter = 0;
41
- if (window.parent.location.href.indexOf('.vn/video/') < 0) {
42
- window.addEventListener("click", function () {
43
- if (!counter) {
44
- var img = document.createElement("img");
45
- img.src = "/imgthumbnail/98267/1/1";
46
- img.width = 0;
47
- img.height = 0;
48
- document.body.appendChild(img);
49
- counter++;
50
- }
51
- });
52
- }
53
- </script>
54
- </div>
55
- </body>
56
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
headers.txt DELETED
@@ -1,9 +0,0 @@
1
- HTTP/2 200
2
- strict-transport-security: max-age=31536000; includeSubDomains; preload
3
- set-cookie: BongdaplusView_98267_1=1; expires=Sat, 22 Aug 2026 02:21:27 GMT; path=/
4
- x-frame-options: SAMEORIGIN
5
- x-xss-protection: 1; mode=block
6
- x-content-type-options: nosniff
7
- x-powered-by: ASP.NET
8
- date: Sat, 22 Aug 2026 01:51:27 GMT
9
-
 
 
 
 
 
 
 
 
 
 
index_v2.html DELETED
@@ -1,82 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
6
- <title>VNEWS - Tin Tức Việt Nam</title>
7
- <meta name="description" content="Tin tức tổng hợp, bóng đá trực tiếp, video highlight, AI tóm tắt.">
8
- <meta property="og:title" content="VNEWS - Tin Tức Việt Nam">
9
- <meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
10
- <link rel="canonical" href="https://bep40-vnews.hf.space">
11
- <link rel="stylesheet" href="/static/wc2026.css">
12
- <script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
13
- <style>
14
- *{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px;color:#fff}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cats::-webkit-scrollbar{display:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer;flex-shrink:0}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}.slider-track::-webkit-scrollbar{display:none}.slider-item{flex:0 0 160px;cursor:pointer}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-img img{width:100%;height:100%;object-fit:cover}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-genk{background:#6a1b9a}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60;cursor:pointer}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px;color:#eee}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px;cursor:pointer}.article-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;font-size:12px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px;font-size:11px;cursor:pointer}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;scrollbar-width:none}.tiktok-feed::-webkit-scrollbar{display:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center}.tiktok-slide video,.tiktok-slide iframe{width:100%;height:100%;object-fit:cover;border:none}.tiktok-slide.ratio-wide video,.tiktok-slide.ratio-wide iframe{object-fit:contain}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}.tiktok-title{font-size:12px;color:#fff}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height:60%;min-height:140px;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:clip;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700;flex-shrink:0}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1 1 auto;overflow-y:auto;padding:6px 10px;max-height:140px;min-height:40px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333;flex-shrink:0;position:sticky;bottom:0;background:rgba(18,18,18,.98)}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px;min-height:32px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer;min-height:32px}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4d8;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}.hot-topic-row::-webkit-scrollbar{display:none}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap}.hot-chip:active{transform:scale(.96)}.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}.hashtag-load-more{width:100%;margin-top:8px;background:#222;border:1px solid #333;color:#ccc;padding:9px;border-radius:10px;font-size:12px;cursor:pointer}.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}@keyframes ht-spin{to{transform:rotate(360deg)}}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-item-new{animation:wall-flash 1.8s ease-out}@keyframes wall-flash{0%{border-color:#f0c040;box-shadow:0 0 18px rgba(240,192,64,.35)}30%{border-color:#f0c040;box-shadow:0 0 12px rgba(240,192,64,.2)}100%{border-color:#2b2b2b;box-shadow:none}}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px;position:relative}.wall-thumb img{width:100%;height:100%;object-fit:cover}.wall-video-badge{position:absolute;top:4px;right:4px;background:rgba(45,134,89,.9);color:#fff;font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;cursor:pointer}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.wall-actions button.wall-btn-design{background:#1a3a5a;border-color:#3a5a7a;color:#fff;font-weight:700}#progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
15
- </style>
16
- </head>
17
- <body>
18
- <div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Highlight · AI · World Cup 2026</p></div>
19
- <div class="cats" id="cat-bar"></div>
20
- <div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
21
- <div id="view-cat" class="view"></div>
22
- <div id="view-video" class="view"></div>
23
- <div id="view-tiktok" class="view"></div>
24
- <div id="view-article" class="view"></div>
25
- <div class="match-overlay" id="match-overlay">
26
- <div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div>
27
- <div class="mo-tabs"><span class="mo-tab active" onclick="loadMatchTab('detail')">📋 Chi tiết</span><span class="mo-tab" onclick="loadMatchTab('comm')">Diễn biến</span><span class="mo-tab" onclick="loadMatchTab('stats')">Thống kê</span></div>
28
- <div class="mo-body" id="mo-body"><div class="loading">Đang tải...</div></div>
29
- </div>
30
- <div id="progress-toast"></div>
31
- <script>
32
- var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
33
- function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
34
- function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
35
- function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector('[data-cat="'+id+'"]')?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
36
- function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
37
- function doShare(title,url,img,postId){
38
- var shareUrl;
39
- if(postId){
40
- shareUrl = SPACE+'/s?post_id='+encodeURIComponent(postId)+'&title='+encodeURIComponent(title);
41
- } else {
42
- shareUrl = SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');
43
- }
44
- // Use clipboard API first (modern, works on Chrome mobile 85+)
45
- if(navigator.clipboard && navigator.clipboard.writeText){
46
- navigator.clipboard.writeText(shareUrl).then(function(){
47
- toast('📋 Đã sao chép link!');
48
- try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
49
- }).catch(function(){
50
- try{
51
- var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
52
- document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
53
- if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
54
- document.body.removeChild(ta);
55
- }catch(e){prompt('📋 Sao chép link:', shareUrl);}
56
- try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
57
- });
58
- } else {
59
- try{
60
- var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
61
- document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
62
- if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
63
- document.body.removeChild(ta);
64
- }catch(e){prompt('📋 Sao chép link:', shareUrl);}
65
- try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
66
- }
67
- }
68
- 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)});}
69
- var SPACE=location.origin;
70
- </script>
71
- <script src="/static/app_v2.js?v=20260808b"></script>
72
- <script src="/static/designer_v2.js?v=20260808c"></script>
73
- <script src="/static/yt_live.js"></script>
74
- <script src="/static/vtv_init.js"></script>
75
- <script src="/static/hot_multi.js?v=1"></script>
76
- <script src="/static/wc2026_v2.js"></script>
77
- <script src="/static/live_mode.js"></script>
78
- <script src="/static/match_detail_v6.js"></script>
79
- <script src="/static/modal_quote_design.js"></script>
80
- <script>init();loadHome();</script>
81
- </body>
82
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
main.py CHANGED
@@ -8,7 +8,7 @@ VN_TZ = timezone(timedelta(hours=7))
8
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
  from fastapi import FastAPI, Query, Request
10
  from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
11
- from urllib.parse import quote, urlencode, urljoin
12
  import requests
13
  from bs4 import BeautifulSoup
14
 
@@ -51,7 +51,7 @@ _cache_ttl_live = 60
51
  _cache_ttl_yt = 1800
52
 
53
  PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
54
- LEAGUE_IDS = {"nha":36781,"laliga":38843,"seriea":36072,"bundesliga":40040,"ligue1":37298}
55
  HL_LEAGUES = {
56
  "premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿"},
57
  "fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},
@@ -69,17 +69,8 @@ def _cached(key, fn, ttl=None):
69
  except: data=_cache.get(key,{}).get("d",[])
70
  _cache[key]={"d":data,"t":now}; return data
71
  def _get(url, headers=None):
72
- h=headers or HEADERS
73
- for attempt in range(3):
74
- try:
75
- r=requests.get(url, headers=h, timeout=15)
76
- r.encoding="utf-8"
77
- return BeautifulSoup(r.text,"lxml")
78
- except Exception:
79
- if attempt < 2:
80
- time.sleep(0.5 * (attempt + 1))
81
- else:
82
- raise
83
  def fetch_bongda_api(endpoint):
84
  try:
85
  r=requests.get(f"https://bongda.com.vn{endpoint}", headers=BONGDA_HEADERS, timeout=10)
@@ -120,275 +111,15 @@ def proxy_m3u8(url: str = Query(...)):
120
  return Response(content='\n'.join(rewritten).encode('utf-8'), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=300"})
121
  except: return Response(status_code=502, content="proxy error")
122
 
123
- @app.get("/api/proxy/m3u_hls")
124
- def proxy_m3u_hls(url: str = Query(...), ua: str = Query(default=None), cookie: str = Query(default=None), auth: str = Query(default=None), fallbacks: str = Query(default=None)):
125
- """Wrap a raw stream URL into an HLS playlist that Hls.js can play.
126
-
127
- Handles two cases that previously broke on the browser (the Space player)
128
- while working fine on native apps like Televizo (which play the raw URL
129
- directly with their own headers):
130
-
131
- 1) The upstream is already an .m3u8 playlist -> re-proxy it through
132
- /api/proxy/seg so the browser (CORS-blocked) can fetch every segment
133
- via same-origin, forwarding stalker headers (ua/cookie/auth) to CDNs
134
- that require a Referer/token.
135
- 2) The upstream is raw MPEG-TS / a Stalker .php stream (not HLS) -> the
136
- browser's Hls.js can't ingest a bare .ts URL, so we emit a live,
137
- open-ended #EXTM3U playlist whose single segment proxies the raw
138
- stream through /api/proxy/seg (media_type=video/mp2t).
139
- """
140
- if not url or not url.startswith("http"):
141
- return Response(status_code=400, content="Invalid URL")
142
- seg_params = {"url": url}
143
- if ua: seg_params["ua"] = ua
144
- if cookie: seg_params["cookie"] = cookie
145
- if auth: seg_params["auth"] = auth
146
- if fallbacks: seg_params["fallbacks"] = fallbacks
147
- seg_proxy_base = "/api/proxy/seg?" + urlencode(seg_params)
148
- headers = dict(HEADERS)
149
- if ua: headers["User-Agent"] = ua
150
- if cookie: headers["Cookie"] = cookie
151
- if auth: headers["Authorization"] = auth
152
- # Determine if the upstream is already HLS.
153
- looks_hls = ".m3u8" in url.lower() or "/m3u8" in url.lower()
154
- if looks_hls:
155
- try:
156
- r = requests.get(url, headers=headers, timeout=(10, 60), stream=True)
157
- if r.status_code != 200:
158
- return Response(status_code=502, content="upstream error")
159
-
160
- # Read the playlist body to decide master vs media.
161
- body_parts = []
162
- for ln in r.iter_lines(chunk_size=1024):
163
- if not ln:
164
- continue
165
- body_parts.append(ln.decode("utf-8", "replace") if isinstance(ln, bytes) else str(ln))
166
- body = "\n".join(body_parts)
167
- # MASTER playlist (multi-bitrate): follow the FIRST variant so the
168
- # browser gets a playable media playlist immediately. The variant
169
- # is itself fetched and rewritten so its segments go through
170
- # /api/proxy/seg (which sniff-detects playlist vs TS and rewrites
171
- # relative .ts URIs against the variant URL).
172
- if "#EXT-X-STREAM-INF" in body:
173
- var = None
174
- lines = body.strip().split("\n")
175
- for i, ln in enumerate(lines):
176
- if ln.startswith("#EXT-X-STREAM-INF") and i + 1 < len(lines):
177
- cand = lines[i + 1].strip()
178
- if cand and not cand.startswith("#"):
179
- var = cand if cand.startswith("http") else urljoin(url, cand)
180
- break
181
- if var:
182
- try:
183
- vr = requests.get(var, headers=headers, timeout=(10, 30))
184
- if vr.status_code == 200:
185
- vtext = vr.text
186
- if "#EXT-X-STREAM-INF" in vtext:
187
- # Nested master (rare) — pick ITS first variant.
188
- vlines = vtext.strip().split("\n")
189
- for i, ln in enumerate(vlines):
190
- if ln.startswith("#EXT-X-STREAM-INF") and i + 1 < len(vlines):
191
- vcand = vlines[i + 1].strip()
192
- if vcand and not vcand.startswith("#"):
193
- var2 = vcand if vcand.startswith("http") else urljoin(var, vcand)
194
- vr = requests.get(var2, headers=headers, timeout=(10, 30))
195
- if vr.status_code == 200:
196
- vtext = vr.text
197
- break
198
- if vtext.lstrip().startswith("#EXTM3U"):
199
- rewritten, _ = rewrite_hls_playlist(vtext, var, ua, cookie, auth, fallbacks)
200
- return Response(
201
- content=rewritten.encode("utf-8"),
202
- media_type="application/vnd.apple.mpegurl",
203
- headers={"Access-Control-Allow-Origin":"*","Cache-Control":"no-cache, no-store, must-revalidate"},
204
- )
205
- except Exception:
206
- pass
207
- # Fallback: rewrite the master itself (seg proxy will handle
208
- # any variant playlists returned).
209
- rewritten, _ = rewrite_hls_playlist(body, url, ua, cookie, auth, fallbacks)
210
- return Response(
211
- content=rewritten.encode("utf-8"),
212
- media_type="application/vnd.apple.mpegurl",
213
- headers={"Access-Control-Allow-Origin":"*","Cache-Control":"no-cache, no-store, must-revalidate"},
214
- )
215
- except Exception:
216
- return Response(status_code=502, content="proxy error")
217
- # Raw MPEG-TS / Stalker stream: emit a live HLS wrapper playlist.
218
- # FIX: Emit a LIVE sliding window with multiple #EXTINF segments (all
219
- # pointing at the same /api/proxy/seg URL). The old single-segment manifest
220
- # caused Hls.js to play ~8s of data and then stall — the playlist had no
221
- # new segments to discover. With 3 segments Hls.js keeps the playback
222
- # buffer alive and polls for the next window, giving continuous live
223
- # playback for MPEG-TS / Stalker streams.
224
- _seg = seg_proxy_base
225
- manifest = (
226
- "#EXTM3U\n"
227
- "#EXT-X-VERSION:6\n"
228
- "#EXT-X-TARGETDURATION:8\n"
229
- "#EXT-X-MEDIA-SEQUENCE:0\n"
230
- "#EXT-X-DISCONTINUITY-SEQUENCE:0\n"
231
- "#EXT-X-START:TIME-OFFSET=0,PRECISE=YES\n"
232
- "#EXTINF:8.0,\n"
233
- + _seg + "\n"
234
- "#EXTINF:8.0,\n"
235
- + _seg + "\n"
236
- "#EXTINF:8.0,\n"
237
- + _seg + "\n"
238
- "#EXTINF:8.0,\n"
239
- + _seg + "\n"
240
- "#EXTINF:8.0,\n"
241
- + _seg + "\n"
242
- "#EXTINF:8.0,\n"
243
- + _seg + "\n"
244
- "#EXTINF:8.0,\n"
245
- + _seg + "\n"
246
- "#EXTINF:8.0,\n"
247
- + _seg + "\n"
248
- )
249
- return Response(content=manifest.encode("utf-8"),
250
- media_type="application/vnd.apple.mpegurl",
251
- headers={"Access-Control-Allow-Origin":"*","Cache-Control":"no-cache, no-store, must-revalidate"})
252
-
253
- def build_seg_qs(ua, cookie, auth, fallbacks):
254
- qs = ""
255
- if ua: qs += "&ua=" + quote(ua, safe="")
256
- if cookie: qs += "&cookie=" + quote(cookie, safe="")
257
- if auth: qs += "&auth=" + quote(auth, safe="")
258
- if fallbacks: qs += "&fallbacks=" + quote(fallbacks, safe="")
259
- return qs
260
-
261
-
262
- def rewrite_hls_playlist(text, base_url, ua=None, cookie=None, auth=None, fallbacks=None):
263
- """Rewrite an HLS playlist's segment/variant URIs so every data line goes
264
- through /api/proxy/seg, resolving relative URIs against base_url and
265
- forwarding stalker headers (ua/cookie/auth). Returns
266
- (rewritten_text, is_master_playlist)."""
267
- out = []
268
- seg_qs = build_seg_qs(ua, cookie, auth, fallbacks)
269
- is_master = '#EXT-X-STREAM-INF' in text
270
- for line in text.splitlines():
271
- s = line.strip()
272
- if not s or s.startswith('#'):
273
- out.append(line)
274
- continue
275
- seg = s if s.startswith('http') else urljoin(base_url, s)
276
- out.append('/api/proxy/seg?url=' + quote(seg, safe='') + seg_qs)
277
- return '\n'.join(out), is_master
278
-
279
-
280
- def _looks_like_playlist(lead):
281
- """True if the first bytes look like an HLS playlist (master or media)."""
282
- if not lead:
283
- return False
284
- head = lead[:512].lstrip()
285
- return head.startswith(b'#EXTM3U') or b'#EXT-X-' in head[:256]
286
-
287
-
288
- @app.get("/api/proxy/stream")
289
- def proxy_stream(url: str = Query(...), ua: str = Query(default=None), cookie: str = Query(default=None)):
290
- """Proxy a raw .ts segment through the Space backend so the browser
291
- (CORS-blocked from fetching the Stalker/xemtv origin directly) can load
292
- it same-origin. Forwards stalker headers (ua/cookie) and a Referer that
293
- these portals require, and sets Access-Control-Allow-Origin: *.
294
- """
295
- if not url or not url.startswith("http"):
296
- return Response(status_code=400, content="Invalid URL")
297
- try:
298
- h = {"User-Agent": ua or HEADERS["User-Agent"], "Referer": "http://a47.streamtrx.xyz/" if "streamtrx" in url else HEADERS.get("Referer","https://bep40-vnews.hf.space/"), "Accept": "*/*"}
299
- if cookie: h["Cookie"] = cookie
300
- r = requests.get(url, headers=h, timeout=(8, 60), stream=True)
301
- if r.status_code != 200:
302
- return Response(status_code=502, content="upstream %s" % r.status_code)
303
- def _stream(src=r):
304
- for chunk in src.iter_content(chunk_size=65536):
305
- yield chunk
306
- return StreamingResponse(
307
- _stream(),
308
- media_type="video/mp2t",
309
- headers={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Credentials":"false","Cache-Control":"no-cache"},
310
- )
311
- except Exception:
312
- return Response(status_code=502, content="proxy error")
313
-
314
  @app.get("/api/proxy/seg")
315
- def proxy_segment(url: str = Query(...), ua: str = Query(default=None), cookie: str = Query(default=None), auth: str = Query(default=None), fallbacks: str = Query(default=None), max_sec: float = Query(default=0)):
316
- """Proxy a stream segment. For live MPEG-TS streams, streams the raw data
317
- continuously so Hls.js can buffer and play it. If the fetched URL turns
318
- out to be an HLS *playlist* (master or media), it is rewritten so the
319
- downstream segment(s) also go through this proxy. The max_sec parameter
320
- is accepted for backwards compatibility but the stream is left open-ended
321
- (the client / CDN closes the read when sufficient data is buffered or the
322
- user navigates away)."""
323
  try:
324
- seg_headers = dict(HEADERS)
325
- if ua: seg_headers["User-Agent"] = ua
326
- if cookie: seg_headers["Cookie"] = cookie
327
- if auth: seg_headers["Authorization"] = auth
328
- # FIX: try the primary URL first; if it returns non-200 (e.g. an
329
- # expired stalker play_token -> 502/403), fall through the "fallbacks"
330
- # list (same stream ID on alternate servers) and use the first that
331
- # returns 200. This fixes "black screen" on Stalker channels whose
332
- # cached token on one server expired but works on another.
333
- import json as _json
334
- fb_urls = []
335
- if fallbacks:
336
- try:
337
- fb_urls = [u for u in _json.loads(fallbacks) if u and u.startswith("http")]
338
- except Exception:
339
- fb_urls = []
340
- attempt_urls = [url] + [u for u in fb_urls if u != url]
341
- last_err = "upstream error"
342
- for attempt_url in attempt_urls:
343
- try:
344
- r = requests.get(attempt_url, headers=seg_headers, timeout=(8, 300), stream=True)
345
- if r.status_code == 200:
346
- # Sniff the first bytes: if it's an HLS playlist (e.g. a
347
- # master playlist from "/api/proxy/m3u_hls" for an .m3u8
348
- # URL, or a media playlist variant), rewrite it so every
349
- # segment goes through this proxy again. MPEG-TS (0x47
350
- # sync bytes) or other media streams pass through raw.
351
- lead = b""
352
- try:
353
- lead = next(r.iter_content(4096))
354
- except Exception:
355
- pass
356
- if _looks_like_playlist(lead):
357
- r.close()
358
- body = ""
359
- try:
360
- rr = requests.get(attempt_url, headers=seg_headers, timeout=(8, 60))
361
- if rr.status_code == 200:
362
- body = rr.text
363
- except Exception:
364
- pass
365
- # Resolve against the playlist location (some variants
366
- # use relative URIs) — use the attempt URL as base.
367
- rewritten, _ = rewrite_hls_playlist(body, attempt_url, ua, cookie, auth, None)
368
- return Response(
369
- content=rewritten.encode("utf-8"),
370
- media_type="application/vnd.apple.mpegurl",
371
- headers={"Access-Control-Allow-Origin":"*", "Cache-Control":"no-cache, no-store, must-revalidate"},
372
- )
373
- # Real media segment — stream through.
374
- def _stream(src=r):
375
- if lead:
376
- yield lead
377
- for chunk in src.iter_content(chunk_size=65536):
378
- yield chunk
379
- return StreamingResponse(
380
- _stream(),
381
- media_type="video/mp2t",
382
- headers={"Access-Control-Allow-Origin":"*","Cache-Control":"no-cache"}
383
- )
384
- last_err = "upstream %s" % r.status_code
385
- r.close()
386
- except Exception as e:
387
- last_err = str(e)[:120]
388
- continue
389
- return Response(status_code=502, content=last_err)
390
- except Exception:
391
- return Response(status_code=502, content="proxy error")
392
 
393
  @app.get("/api/proxy/video")
394
  def proxy_video(url: str = Query(...), request: Request = None):
@@ -420,7 +151,7 @@ def proxy_img(url: str = Query(...)):
420
  def _scrape_xemlaibongda_page(page_path, limit=20):
421
  try:
422
  url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
423
- r = requests.get(url, headers=HEADERS, timeout=8)
424
  if r.status_code != 200: return []
425
  r.encoding = "utf-8"
426
  soup = BeautifulSoup(r.text, "lxml")
@@ -478,7 +209,7 @@ def _scrape_xemlaibongda_page(page_path, limit=20):
478
  if not img_src:
479
  slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
480
  img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg"
481
- videos.append({"title": title[:100], "link": clean_href, "img": img_src, "source": "xemlaibongda", "published": _video_published(img_src, clean_href)})
482
  if len(videos) >= limit: break
483
  return videos
484
  except Exception as e:
@@ -487,240 +218,18 @@ def _scrape_xemlaibongda_page(page_path, limit=20):
487
  def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
488
  def scrape_highlights_by_league(league_key):
489
  if league_key not in HL_LEAGUES: return []
490
- # Try bongdaplus.vn first (reliable from HF Spaces), fallback from there
491
- bp_vids = scrape_bongdaplus_by_league(league_key, 20)
492
- if bp_vids: return bp_vids
493
- # Fallback: xemlaibongda.top (may be slow/unreachable)
494
- vids = _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"], 20)
495
- if vids: return vids
496
- # Ultimate fallback: bongdaplus.vn general video feed, only for primary home league
497
- if league_key == "premier-league":
498
- return scrape_bongdaplus_videos(20)
499
- return []
500
  def scrape_all_league_highlights():
501
  results = {}
502
  def _fetch(key): return key, scrape_highlights_by_league(key)
503
  with ThreadPoolExecutor(8) as ex:
504
  futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
505
- for f in as_completed(futs, timeout=20):
506
  try: key, vids = f.result()
507
  except: continue
508
  if vids: results[key] = vids
509
- if not results:
510
- fb = scrape_bongdaplus_videos(20)
511
- if fb:
512
- results["premier-league"] = fb
513
- # The league sub-pages resolve to the SAME content from this server (the
514
- # football sites serve one generic list regardless of league path). Return
515
- # the unique videos once per league (deduped against the global set) and
516
- # enrich with the general bongdaplus feed so the homepage slider gets a
517
- # genuinely bigger, duplicate-free list instead of 8x the same 10 clips.
518
- global_seen = set()
519
- seen_per_league = {}
520
- enriched = {}
521
- try:
522
- extra = scrape_bongdaplus_videos(40)
523
- except Exception:
524
- extra = []
525
- for key, vids in list(results.items()):
526
- uniq = []
527
- fresh = []
528
- for v in vids:
529
- link = (v or {}).get("link", "")
530
- if not link or link in seen_per_league.get(key, set()): continue
531
- seen_per_league.setdefault(key, set()).add(link)
532
- if link in global_seen:
533
- fresh.append(v) # duplicate across leagues — will dedupe
534
- continue
535
- global_seen.add(link)
536
- uniq.append(v)
537
- # league-local unique list
538
- if uniq:
539
- results[key] = uniq
540
- # still add across-league dupes to each league so per-league feeds
541
- # are non-empty, but ONLY if the league has < 10 unique videos
542
- if len(uniq) < 10:
543
- for v in fresh:
544
- link = (v or {}).get("link", "")
545
- if link in seen_per_league.get(key, set()): continue
546
- seen_per_league[key].add(link)
547
- uniq.append(v)
548
- results[key] = uniq
549
- # enrich league with global feed leftovers when it's still thin
550
- if len(uniq) < 10:
551
- for v in extra:
552
- link = (v or {}).get("link", "")
553
- if not link or link in seen_per_league.get(key, set()): continue
554
- seen_per_league[key].add(link)
555
- uniq.append(v)
556
- results[key] = uniq
557
  return results
558
 
559
- BDP_HL = "https://bongdaplus.vn/video"
560
- def _video_published(img_src, link=""):
561
- """Best-effort publish date from image URL (bongdaplus stores dates as
562
- /Media/YYYY/MM/DD/) or from a dated slug. Returns 'YYYY-MM-DD' or ''."""
563
- try:
564
- if img_src:
565
- m = re.search(r"/Media/(\d{4})/(\d{2})/(\d{2})/", img_src)
566
- if m:
567
- return "%s-%s-%s" % (m.group(1), m.group(2), m.group(3))
568
- if link:
569
- m2 = re.search(r"/(\d{4})/(\d{2})/(\d{2})/", link)
570
- if m2:
571
- return "%s-%s-%s" % (m2.group(1), m2.group(2), m2.group(3))
572
- except Exception:
573
- pass
574
- return ""
575
-
576
- def scrape_bongdaplus_videos(limit=20):
577
- """Scrape football highlight/clip videos from bongdaplus.vn/video (reachable from HF Spaces)."""
578
- try:
579
- r = requests.get(BDP_HL, headers=HEADERS, timeout=15)
580
- if r.status_code != 200: return []
581
- r.encoding = "utf-8"
582
- soup = BeautifulSoup(r.text, "lxml")
583
- videos = []; seen = set()
584
- for a in soup.find_all("a", href=True):
585
- href = a.get("href","")
586
- if "/video/" not in href or not href.endswith(".html"): continue
587
- if not href.startswith("http"): href = "https://bongdaplus.vn" + href
588
- link = href.split("?")[0].split("#")[0]
589
- if link in seen: continue
590
- seen.add(link)
591
- img_src = ""
592
- img = a.find("img")
593
- if img:
594
- img_src = img.get("data-src","") or img.get("src","") or ""
595
- title = ""
596
- t = a.get("title","") or ""
597
- if t and len(t)>=5: title = t
598
- if not title:
599
- img_alt = img.get("alt","") if img else ""
600
- if img_alt and len(img_alt)>=5: title = img_alt
601
- if not title:
602
- txt = a.get_text(strip=True)
603
- if txt and len(txt)>=5: title = txt
604
- title = re.sub(r'^(VIDEO\s*:\s*|VIDEO\s+)', '', title, flags=re.I).strip()
605
- if not title or len(title)<5: continue
606
- if img_src and img_src.startswith("//"): img_src = "https:" + img_src
607
- videos.append({"title": title[:100], "link": link, "img": img_src, "source": "bongdaplus", "published": _video_published(img_src, link)})
608
- if len(videos) >= limit: break
609
- return videos
610
- except Exception as e:
611
- print(f"[bongdaplus] Error: {e}"); return []
612
-
613
- # Bongdaplus.vn league-specific video URLs (used as fallback when xemlaibongda.top fails)
614
- BDP_LEAGUE_URLS = {
615
- "premier-league": "https://bongdaplus.vn/video/premier-league",
616
- "la-liga": "https://bongdaplus.vn/video/la-liga",
617
- "serie-a": "https://bongdaplus.vn/video/serie-a",
618
- "bundesliga": "https://bongdaplus.vn/video/bundesliga",
619
- "champions-league": "https://bongdaplus.vn/video/champions-league",
620
- "fa-cup": "https://bongdaplus.vn/video/fa-cup",
621
- "europa-league": "https://bongdaplus.vn/video/europa-league",
622
- "world-cup": "https://bongdaplus.vn/video/world-cup",
623
- }
624
- def scrape_bongdaplus_by_league(league_key, limit=20):
625
- """Scrape bongdaplus.vn video page for a specific league."""
626
- url = BDP_LEAGUE_URLS.get(league_key)
627
- if not url: return []
628
- try:
629
- r = requests.get(url, headers=HEADERS, timeout=15)
630
- if r.status_code != 200: return []
631
- r.encoding = "utf-8"
632
- soup = BeautifulSoup(r.text, "lxml")
633
- videos = []; seen = set()
634
- for a in soup.find_all("a", href=True):
635
- href = a.get("href","")
636
- if "/video/" not in href or not href.endswith(".html"): continue
637
- if not href.startswith("http"): href = "https://bongdaplus.vn" + href
638
- link = href.split("?")[0].split("#")[0]
639
- if link in seen: continue
640
- seen.add(link)
641
- img_src = ""
642
- img = a.find("img")
643
- if img:
644
- img_src = img.get("data-src","") or img.get("src","") or ""
645
- title = ""
646
- t = a.get("title","") or ""
647
- if t and len(t)>=5: title = t
648
- if not title:
649
- img_alt = img.get("alt","") if img else ""
650
- if img_alt and len(img_alt)>=5: title = img_alt
651
- if not title:
652
- txt = a.get_text(strip=True)
653
- if txt and len(txt)>=5: title = txt
654
- title = re.sub(r'^(VIDEO\s*:\s*|VIDEO\s+)', '', title, flags=re.I).strip()
655
- if not title or len(title)<5: continue
656
- if img_src and img_src.startswith("//"): img_src = "https:" + img_src
657
- videos.append({"title": title[:100], "link": link, "img": img_src, "source": "bongdaplus", "published": _video_published(img_src, link)})
658
- if len(videos) >= limit: break
659
- return videos
660
- except Exception as e:
661
- print(f"[bongdaplus:{league_key}] Error: {e}"); return []
662
-
663
- def extract_bongdaplus_video(url):
664
- """Extract direct MP4 from bongdaplus.vn video detail via its embed page.
665
-
666
- The embed page (/video-embed/{id}.html) sometimes only has a JPG placeholder
667
- as <source>. In that case, fall back to the main video page
668
- (/video/{slug}.html) which contains a YouTube iframe with the actual
669
- highlight.
670
- """
671
- try:
672
- m = re.search(r'/video/(?:[^/]+-)?(\d+)\.html', url)
673
- if not m:
674
- return None
675
- vid = m.group(1)
676
- embed = f"https://bongdaplus.vn/video-embed/{vid}.html"
677
- r = requests.get(embed, headers=HEADERS, timeout=15)
678
- if r.status_code != 200: return None
679
- r.encoding = "utf-8"
680
- soup = BeautifulSoup(r.text, "lxml")
681
- video = soup.find("video")
682
- src = ""
683
- poster = ""
684
- if video:
685
- src = video.get("src","")
686
- poster = video.get("poster","")
687
- if not src:
688
- src_el = soup.find("source")
689
- if src_el: src = src_el.get("src","")
690
- if not src: return None
691
- src = src.split("?")[0].split("#")[0]
692
- # Only accept real media; placeholder .jpg/jpeg/png means video not available
693
- if not re.search(r'\.(mp4|m3u8|webm)$', src, re.I):
694
- # The embed source is a JPG placeholder — fall back to the main video
695
- # page and look for a YouTube iframe (actual highlight video).
696
- try:
697
- r2 = requests.get(url, headers=HEADERS, timeout=15)
698
- if r2.status_code == 200:
699
- soup2 = BeautifulSoup(r2.text, "lxml")
700
- yt_iframe = soup2.find("iframe", src=re.compile(r"youtube\.com/embed|youtube-nocookie\.com/embed"))
701
- if yt_iframe:
702
- yt_src = yt_iframe.get("src","").strip()
703
- if yt_src.startswith("//"): yt_src = "https:" + yt_src
704
- yt_id = re.search(r'/(?:embed|v)/([a-zA-Z0-9_-]{11})', yt_src)
705
- if yt_id:
706
- poster = poster or ""
707
- if not poster:
708
- og = soup2.find("meta", property="og:image")
709
- if og: poster = og.get("content","")
710
- if not poster:
711
- og = soup.find("meta", property="og:image")
712
- if og: poster = og.get("content","")
713
- return {"src": f"https://www.youtube.com/embed/{yt_id.group(1)}?autoplay=1&rel=0&enablejsapi=1", "poster": poster or yt_src, "type": "youtube"}
714
- except Exception:
715
- pass
716
- return None
717
- if not poster:
718
- og = soup.find("meta",property="og:image")
719
- if og: poster = og.get("content","")
720
- return {"src": src, "poster": poster, "type": "video"}
721
- except Exception:
722
- return None
723
-
724
  def extract_xemlaibongda_video(url):
725
  try:
726
  r=requests.get(url, headers=HEADERS, timeout=15)
@@ -757,206 +266,44 @@ def api_livescore_results():
757
  today=datetime.now(VN_TZ).strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_results",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}&status=finished"),ttl=_cache_ttl)})
758
  @app.get("/api/livescore/standings/{league}")
759
  def api_livescore_standings(league:str):
760
- tid=LEAGUE_IDS.get(league,36781);return JSONResponse({"html":_cached(f"ls_bxh_{league}",lambda:fetch_bongda_api(f"/api/league-table/home?tournament_id={tid}&is_detail=True"),ttl=_cache_ttl)})
761
  @app.get("/api/livescore/date/{date}")
762
  def api_livescore_date(date:str):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/get-by-date?date={date}")})
763
 
764
- def _compute_updates7d():
765
- from datetime import date as _date
766
- today = _date.today()
767
- all_html = []
768
- # Past 7 days (results)
769
- for i in range(7, 0, -1):
770
- d = (today - timedelta(days=i)).strftime("%Y-%m-%d")
771
- html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished")
772
- if html and len(html) > 50:
773
- soup = BeautifulSoup(html, "lxml")
774
- day_label = (today - timedelta(days=i)).strftime("%d/%m")
775
- for match in soup.select(".match-detail"):
776
- dt = soup.new_tag("div", **{"class": "datetime"})
777
- dt.string = f"📅 {day_label}"
778
- match.insert(0, dt)
779
- all_html.append(str(soup))
780
- # Next 7 days (upcoming)
781
- for i in range(7):
782
- d = (today + timedelta(days=i)).strftime("%Y-%m-%d")
783
- html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}")
784
- if html and len(html) > 50:
785
- soup = BeautifulSoup(html, "lxml")
786
- day_label = (today + timedelta(days=i)).strftime("%d/%m")
787
- for match in soup.select(".match-detail"):
788
- dt = soup.new_tag("div", **{"class": "datetime"})
789
- dt.string = f"📅 {day_label}"
790
- match.insert(0, dt)
791
- all_html.append(str(soup))
792
- combined = "<div class='updates7d'>" + "".join(all_html) + "</div>"
793
- return combined if all_html else ""
794
-
795
- _updates7d_cache = {"t": 0, "d": "", "busy": False}
796
- _updates7d_lock = threading.Lock()
797
- def _start_updates7d_refresh():
798
- def _run():
799
- try:
800
- with _updates7d_lock:
801
- if _updates7d_cache["busy"]: return
802
- _updates7d_cache["busy"] = True
803
- data = _compute_updates7d()
804
- with _updates7d_lock:
805
- if data:
806
- _updates7d_cache["t"] = time.time()
807
- _updates7d_cache["d"] = data
808
- _updates7d_cache["busy"] = False
809
- except Exception:
810
- with _updates7d_lock:
811
- _updates7d_cache["busy"] = False
812
- th = threading.Thread(target=_run, daemon=True)
813
- th.start()
814
-
815
  @app.get("/api/livescore/updates7d")
816
  def api_livescore_updates7d():
817
- """Aggregate matches. Never blocks synchronously — returns cached value instantly
818
- and refreshes in a background thread. Pre-warmed at startup."""
819
- now = time.time()
820
- with _updates7d_lock:
821
- fresh = _updates7d_cache["d"] and (now - _updates7d_cache["t"] < _cache_ttl)
822
- stale = _updates7d_cache["d"]
823
- if not fresh:
824
- _start_updates7d_refresh()
825
- return JSONResponse({"html": stale or "", "cached": bool(stale)})
826
-
827
- # ===== LIVESCORE RECENT (prioritized: upcoming → just-finished today → yesterday → older) =====
828
- def _compute_recent():
829
- """Compute recent livescore with priority: upcoming → just-finished today → yesterday → older."""
830
- from datetime import date as _date
831
- today = _date.today()
832
- all_html = []
833
-
834
- # Helper to extract match details with date label
835
- def _extract_matches(date_str, day_label, status_param=""):
836
- url_path = f"/api/fixtures/get-by-date?date={date_str}"
837
- if status_param:
838
- url_path += f"&status={status_param}"
839
- html = fetch_bongda_api(url_path)
840
- if not html or len(html) <= 50:
841
- return
842
- soup = BeautifulSoup(html, "lxml")
843
- for match in soup.select(".match-detail"):
844
- dt = soup.new_tag("div", **{"class": "datetime section-date"})
845
- dt.string = f"📅 {day_label}"
846
- match.insert(0, dt)
847
- all_html.append(str(soup))
848
-
849
- # 1) Trận sắp tới (hôm nay + ngày mai)
850
- upcoming_html = []
851
- for offset in [0, 1]:
852
- d = (today + timedelta(days=offset)).strftime("%Y-%m-%d")
853
- day_label = (today + timedelta(days=offset)).strftime("%d/%m")
854
- html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}")
855
- if html and len(html) > 50:
856
- soup = BeautifulSoup(html, "lxml")
857
- for match in soup.select(".match-detail"):
858
- dt = soup.new_tag("div", **{"class": "datetime section-date"})
859
- dt.string = f"📅 {day_label}"
860
- match.insert(0, dt)
861
- upcoming_html.append(str(soup))
862
- if upcoming_html:
863
- all_html.append("<div class='ls-section-header upcoming'><span>⏰ Trận sắp tới</span></div><div class='ls-section-body upcoming'>" + "".join(upcoming_html) + "</div>")
864
-
865
- # 2) Vừa kết thúc hôm nay
866
- today_str = today.strftime("%Y-%m-%d")
867
- finished_today = fetch_bongda_api(f"/api/fixtures/get-by-date?date={today_str}&status=finished")
868
- if finished_today and len(finished_today) > 50:
869
- soup = BeautifulSoup(finished_today, "lxml")
870
- for match in soup.select(".match-detail"):
871
- dt = soup.new_tag("div", **{"class": "datetime section-date"})
872
- dt.string = f"📅 {today.strftime('%d/%m')}"
873
- match.insert(0, dt)
874
- all_html.append("<div class='ls-section-header finished'><span>✅ Vừa kết thúc</span></div><div class='ls-section-body finished'>" + str(soup) + "</div>")
875
-
876
- # 3) Hôm qua
877
- yesterday = today - timedelta(days=1)
878
- d = yesterday.strftime("%Y-%m-%d")
879
- html_yesterday = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished")
880
- if html_yesterday and len(html_yesterday) > 50:
881
- soup = BeautifulSoup(html_yesterday, "lxml")
882
- for match in soup.select(".match-detail"):
883
- dt = soup.new_tag("div", **{"class": "datetime section-date"})
884
- dt.string = f"📅 {yesterday.strftime('%d/%m')}"
885
- match.insert(0, dt)
886
- all_html.append("<div class='ls-section-header yesterday'><span>📅 Hôm qua</span></div><div class='ls-section-body yesterday'>" + str(soup) + "</div>")
887
-
888
- # 4) Các ngày cũ hơn (2 ngày trước → 6 ngày trước)
889
- older_html = []
890
- for i in range(2, 7):
891
- d = (today - timedelta(days=i)).strftime("%Y-%m-%d")
892
- day_label = (today - timedelta(days=i)).strftime("%d/%m")
893
- html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished")
894
- if html and len(html) > 50:
895
- soup = BeautifulSoup(html, "lxml")
896
- for match in soup.select(".match-detail"):
897
- dt = soup.new_tag("div", **{"class": "datetime section-date"})
898
- dt.string = f"📅 {day_label}"
899
- match.insert(0, dt)
900
- older_html.append(str(soup))
901
- if older_html:
902
- all_html.append("<div class='ls-section-header older'><span>🗓️ Kết quả trước</span></div><div class='ls-section-body older'>" + "".join(older_html) + "</div>")
903
-
904
- combined = "<div class='updates7d'>" + "".join(all_html) + "</div>"
905
- return combined if all_html else ""
906
-
907
- _recent_cache = {"t": 0, "d": "", "busy": False}
908
- _recent_lock = threading.Lock()
909
- def _start_recent_refresh():
910
- def _run():
911
- try:
912
- with _recent_lock:
913
- if _recent_cache["busy"]: return
914
- _recent_cache["busy"] = True
915
- data = _compute_recent()
916
- with _recent_lock:
917
- if data:
918
- _recent_cache["t"] = time.time()
919
- _recent_cache["d"] = data
920
- _recent_cache["busy"] = False
921
- except Exception:
922
- with _recent_lock:
923
- _recent_cache["busy"] = False
924
- th = threading.Thread(target=_run, daemon=True)
925
- th.start()
926
-
927
- @app.get("/api/livescore/recent")
928
- def api_livescore_recent():
929
- """Recent matches with new priority: upcoming today/tomorrow → finished today → yesterday → older."""
930
- now = time.time()
931
- with _recent_lock:
932
- fresh = _recent_cache["d"] and (now - _recent_cache["t"] < _cache_ttl)
933
- stale = _recent_cache["d"]
934
- if not fresh:
935
- _start_recent_refresh()
936
- return JSONResponse({"html": stale or "", "cached": bool(stale)})
937
-
938
- def _warm_recent():
939
- def _warm():
940
- try:
941
- time.sleep(3)
942
- _start_recent_refresh()
943
- except Exception:
944
- pass
945
- th = threading.Thread(target=_warm, daemon=True)
946
- th.start()
947
- _warm_recent()
948
-
949
- def _warm_updates7d():
950
- # Lightweight startup pre-warm so the first user request is instant.
951
- def _warm():
952
- try:
953
- time.sleep(3)
954
- _start_updates7d_refresh()
955
- except Exception:
956
- pass
957
- th = threading.Thread(target=_warm, daemon=True)
958
- th.start()
959
- _warm_updates7d()
960
 
961
  @app.get("/api/match/{event_id}/commentaries")
962
  def api_match_commentaries(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/commentaries?event_id={event_id}")})
@@ -995,7 +342,7 @@ def api_livescore_featured():
995
  return JSONResponse(_cached("ls_featured",_f,ttl=30))
996
 
997
  @app.get("/api/highlights")
998
- def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",lambda: scrape_bongdaplus_videos(20) or scrape_xemlaibongda(),ttl=_cache_ttl))
999
  @app.get("/api/highlights/leagues")
1000
  def api_highlights_leagues(): return JSONResponse(_cached("hl_leagues",scrape_all_league_highlights,ttl=_cache_ttl))
1001
  @app.get("/api/highlights/{league}")
@@ -1003,47 +350,6 @@ def api_highlights_league(league:str):
1003
  if league not in HL_LEAGUES: return JSONResponse({"error":"league not found"})
1004
  return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
1005
 
1006
- @app.get("/api/highlights/{league}/page")
1007
- def api_highlights_league_page(league:str, page:int=Query(default=0, ge=0), limit:int=Query(default=15, ge=5, le=40)):
1008
- """Paginated league highlights for 'Xem thêm' load-more. Supports 'all' to fetch every league aggregated."""
1009
- if league == "all":
1010
- all_vids = []
1011
- with ThreadPoolExecutor(8) as ex:
1012
- futs = {ex.submit(scrape_highlights_by_league, k): k for k in HL_LEAGUES}
1013
- for f in as_completed(futs, timeout=20):
1014
- try:
1015
- all_vids.extend(f.result())
1016
- except: pass
1017
- # The league sub-pages resolve to the same content from this server,
1018
- # so dedupe by link — the slider/feed must not show 8x the same 10
1019
- # videos. If dedup leaves few unique items, enrich from the general
1020
- # bongdaplus video feed to add genuinely different videos.
1021
- seen = set(); uniq = []
1022
- for v in all_vids:
1023
- link = (v or {}).get("link", "")
1024
- if not link or link in seen: continue
1025
- seen.add(link); uniq.append(v)
1026
- if len(uniq) < 20:
1027
- try:
1028
- extra = scrape_bongdaplus_videos(40)
1029
- for v in extra:
1030
- link = (v or {}).get("link", "")
1031
- if not link or link in seen: continue
1032
- seen.add(link); uniq.append(v)
1033
- except Exception:
1034
- pass
1035
- all_vids = uniq
1036
- start = page * limit
1037
- end = start + limit
1038
- paged = all_vids[start:end]
1039
- return JSONResponse({"videos": paged, "league": "all", "page": page, "has_more": end < len(all_vids), "total": len(all_vids)})
1040
- if league not in HL_LEAGUES: return JSONResponse({"error":"league not found"})
1041
- all_vids = scrape_highlights_by_league(league)
1042
- start = page * limit
1043
- end = start + limit
1044
- paged = all_vids[start:end]
1045
- return JSONResponse({"videos": paged, "league": league, "page": page, "has_more": end < len(all_vids), "total": len(all_vids)})
1046
-
1047
  @app.get("/api/video_url")
1048
  def api_video_url(url:str=Query(...), img:str=Query(default="")):
1049
  if "youtube.com" in url or "youtu.be" in url:
@@ -1055,116 +361,8 @@ def api_video_url(url:str=Query(...), img:str=Query(default="")):
1055
  if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
1056
  if not v.get("poster") and img: v["poster"] = img
1057
  return JSONResponse(v)
1058
- if "bongdaplus.vn" in url and "/video/" in url:
1059
- v=extract_bongdaplus_video(url)
1060
- if v:
1061
- if not v.get("poster") and img: v["poster"] = img
1062
- return JSONResponse(v)
1063
  return JSONResponse({"error":"not found"})
1064
 
1065
- # ===== FPT PLAY (YouTube channel) API =====
1066
- # The datacenter cannot reach youtube.com directly, so this endpoint keeps a
1067
- # persisted list of the channel's latest videos. The frontend (running in the
1068
- # user's browser, which CAN reach YouTube) periodically fetches the channel via
1069
- # r.jina.ai, then POSTs the result here so the slide auto-updates when the
1070
- # channel uploads — exactly like "Thêm Short HOT" but fully automatic.
1071
- FPT_FILE = "/data/fpt_play_videos.json"
1072
- FPT_CHANNEL_URL = "https://www.youtube.com/@fptbongdaofficial"
1073
- FPT_CACHE_TTL = 600 # 10 min
1074
-
1075
- def _fpt_load():
1076
- try:
1077
- if os.path.exists(FPT_FILE):
1078
- with open(FPT_FILE, encoding="utf-8") as f:
1079
- j = json.load(f)
1080
- if isinstance(j, list):
1081
- return j
1082
- except Exception:
1083
- pass
1084
- return []
1085
-
1086
- def _fpt_save(videos):
1087
- try:
1088
- with open(FPT_FILE, "w", encoding="utf-8") as f:
1089
- json.dump(videos, f, ensure_ascii=False)
1090
- except Exception:
1091
- pass
1092
-
1093
- def _fpt_try_server_fetch():
1094
- """Server-side attempt (jina proxy with our Origin). Usually blocked from
1095
- the datacenter, but harmless to try once so the slide works even without
1096
- a client push."""
1097
- try:
1098
- _hdrs = dict(HEADERS)
1099
- _hdrs["Origin"] = "https://bep40-vnews.hf.space"
1100
- r = requests.get("https://r.jina.ai/" + FPT_CHANNEL_URL + "/videos",
1101
- headers=_hdrs, timeout=40)
1102
- if r.status_code != 200:
1103
- return []
1104
- txt = r.text
1105
- out = []
1106
- seen = set()
1107
- # jina markdown lists entries as "1. Title - url" or link lines with yt ids
1108
- for m in re.finditer(r"https?://(?:www\.)?youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})", txt):
1109
- vid = m.group(1)
1110
- if vid in seen: continue
1111
- seen.add(vid)
1112
- out.append({
1113
- "id": vid,
1114
- "title": "",
1115
- "link": "https://youtu.be/" + vid,
1116
- "img": "https://i.ytimg.com/vi/%s/hqdefault.jpg" % vid,
1117
- "source": "fptplay",
1118
- "published": "",
1119
- })
1120
- return out
1121
- except Exception:
1122
- return []
1123
-
1124
- @app.get("/api/fptplay/videos")
1125
- def api_fptplay_videos():
1126
- vids = _fpt_load()
1127
- if not vids:
1128
- vids = _fpt_try_server_fetch()
1129
- if vids: _fpt_save(vids)
1130
- return JSONResponse({"channel": FPT_CHANNEL_URL, "videos": vids, "updated": int(time.time())})
1131
-
1132
- @app.post("/api/fptplay/update")
1133
- async def api_fptplay_update(request: Request):
1134
- try:
1135
- body = await request.json()
1136
- except Exception:
1137
- body = {}
1138
- incoming = body.get("videos") or []
1139
- if not isinstance(incoming, list) or not incoming:
1140
- return JSONResponse({"ok": True, "videos": _fpt_load()})
1141
- seen = set()
1142
- merged = []
1143
- for v in _fpt_load():
1144
- vid = (v or {}).get("id", "")
1145
- if vid and vid in seen: continue
1146
- if vid: seen.add(vid)
1147
- merged.append(v)
1148
- added = 0
1149
- for v in incoming:
1150
- vid = (v or {}).get("id", "") or ""
1151
- if not vid or vid in seen: continue
1152
- seen.add(vid)
1153
- # keep user-friendly fields, always normalize the watch link
1154
- merged.append({
1155
- "id": vid,
1156
- "title": (v.get("title") or "").strip()[:200],
1157
- "link": (v.get("link") or "https://youtu.be/" + vid),
1158
- "img": (v.get("img") or "https://i.ytimg.com/vi/%s/hqdefault.jpg" % vid),
1159
- "source": "fptplay",
1160
- "published": (v.get("published") or "")[:10],
1161
- })
1162
- added += 1
1163
- # newest first
1164
- merged.sort(key=lambda x: (x.get("published") or ""), reverse=True)
1165
- _fpt_save(merged[:50])
1166
- return JSONResponse({"ok": True, "added": added, "videos": merged[:50]})
1167
-
1168
  # ===== WORLD CUP 2026 API =====
1169
  _wc_request_times = []; _wc_rate_limit_lock = threading.Lock()
1170
  _WC_RATE_LIMIT = 10
@@ -1230,8 +428,7 @@ def scrape_vne(cat_url):
1230
  if src: img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
1231
  arts.append({"title":t,"link":lk,"img":img,"source":"vne"})
1232
  return arts
1233
- except Exception:
1234
- return []
1235
 
1236
  def scrape_genk_ai():
1237
  try:
@@ -1296,19 +493,10 @@ def api_categories():
1296
 
1297
  @app.get("/api/proxy/xlb")
1298
  def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
1299
- # Try xemlaibongda.top first (fast timeout), fallback to bongdaplus.vn
1300
- videos = _do_xlb_scrape_xlb(path, limit)
1301
- if not videos:
1302
- videos = _do_xlb_scrape_bdp(limit)
1303
- if not videos:
1304
- return JSONResponse({"videos": [], "error": "No videos available"})
1305
- return JSONResponse({"videos": videos})
1306
-
1307
- def _do_xlb_scrape_xlb(path, limit):
1308
  try:
1309
  url = f"https://xemlaibongda.top/{path}" if path else "https://xemlaibongda.top/"
1310
- r = requests.get(url, headers=HEADERS, timeout=8)
1311
- if r.status_code != 200: return []
1312
  r.encoding = "utf-8"
1313
  soup = BeautifulSoup(r.text, "lxml")
1314
  videos, seen = [], set()
@@ -1337,18 +525,11 @@ def _do_xlb_scrape_xlb(path, limit):
1337
  if not t:
1338
  slug = clean.split("/video/")[-1].rstrip("/")
1339
  t = slug.replace("-", " ").title()
1340
- videos.append({"title": t[:100], "link": clean, "img": img_src, "source": "xemlaibongda", "published": _video_published(img_src, clean)})
1341
  if len(videos) >= limit: break
1342
- return videos
1343
- except Exception:
1344
- return []
1345
-
1346
- def _do_xlb_scrape_bdp(limit):
1347
- """Fallback: scrape bongdaplus.vn videos for the xlb endpoint."""
1348
- try:
1349
- return scrape_bongdaplus_videos(limit)
1350
- except Exception:
1351
- return []
1352
 
1353
  @app.get("/api/article")
1354
  def api_article(url:str=Query(...)):
 
8
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
  from fastapi import FastAPI, Query, Request
10
  from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
11
+ from urllib.parse import quote
12
  import requests
13
  from bs4 import BeautifulSoup
14
 
 
51
  _cache_ttl_yt = 1800
52
 
53
  PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
54
+ LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
55
  HL_LEAGUES = {
56
  "premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿"},
57
  "fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},
 
69
  except: data=_cache.get(key,{}).get("d",[])
70
  _cache[key]={"d":data,"t":now}; return data
71
  def _get(url, headers=None):
72
+ h=headers or HEADERS; r=requests.get(url, headers=h, timeout=15); r.encoding="utf-8"
73
+ return BeautifulSoup(r.text,"lxml")
 
 
 
 
 
 
 
 
 
74
  def fetch_bongda_api(endpoint):
75
  try:
76
  r=requests.get(f"https://bongda.com.vn{endpoint}", headers=BONGDA_HEADERS, timeout=10)
 
111
  return Response(content='\n'.join(rewritten).encode('utf-8'), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=300"})
112
  except: return Response(status_code=502, content="proxy error")
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  @app.get("/api/proxy/seg")
115
+ def proxy_segment(url: str = Query(...)):
 
 
 
 
 
 
 
116
  try:
117
+ r = requests.get(url, headers=HEADERS, timeout=30)
118
+ if r.status_code != 200: return Response(status_code=502, content="upstream error")
119
+ data = r.content
120
+ if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47: data = data[188:]
121
+ return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=3600"})
122
+ except: return Response(status_code=502, content="proxy error")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
  @app.get("/api/proxy/video")
125
  def proxy_video(url: str = Query(...), request: Request = None):
 
151
  def _scrape_xemlaibongda_page(page_path, limit=20):
152
  try:
153
  url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
154
+ r = requests.get(url, headers=HEADERS, timeout=15)
155
  if r.status_code != 200: return []
156
  r.encoding = "utf-8"
157
  soup = BeautifulSoup(r.text, "lxml")
 
209
  if not img_src:
210
  slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
211
  img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg"
212
+ videos.append({"title": title[:100], "link": clean_href, "img": img_src, "source": "xemlaibongda"})
213
  if len(videos) >= limit: break
214
  return videos
215
  except Exception as e:
 
218
  def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
219
  def scrape_highlights_by_league(league_key):
220
  if league_key not in HL_LEAGUES: return []
221
+ return _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"], 20)
 
 
 
 
 
 
 
 
 
222
  def scrape_all_league_highlights():
223
  results = {}
224
  def _fetch(key): return key, scrape_highlights_by_league(key)
225
  with ThreadPoolExecutor(8) as ex:
226
  futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
227
+ for f in as_completed(futs, timeout=25):
228
  try: key, vids = f.result()
229
  except: continue
230
  if vids: results[key] = vids
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  return results
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  def extract_xemlaibongda_video(url):
234
  try:
235
  r=requests.get(url, headers=HEADERS, timeout=15)
 
266
  today=datetime.now(VN_TZ).strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_results",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}&status=finished"),ttl=_cache_ttl)})
267
  @app.get("/api/livescore/standings/{league}")
268
  def api_livescore_standings(league:str):
269
+ tid=LEAGUE_IDS.get(league,27110);return JSONResponse({"html":_cached(f"ls_bxh_{league}",lambda:fetch_bongda_api(f"/api/league-table/home?tournament_id={tid}&is_detail=True"),ttl=_cache_ttl)})
270
  @app.get("/api/livescore/date/{date}")
271
  def api_livescore_date(date:str):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/get-by-date?date={date}")})
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  @app.get("/api/livescore/updates7d")
274
  def api_livescore_updates7d():
275
+ """Aggregate results + incoming matches from past 7 days and next 7 days."""
276
+ def _f():
277
+ from datetime import date as _date
278
+ today = _date.today()
279
+ all_html = []
280
+ # Past 7 days (results)
281
+ for i in range(7, 0, -1):
282
+ d = (today - timedelta(days=i)).strftime("%Y-%m-%d")
283
+ html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished")
284
+ if html and len(html) > 50:
285
+ soup = BeautifulSoup(html, "lxml")
286
+ day_label = (today - timedelta(days=i)).strftime("%d/%m")
287
+ for match in soup.select(".match-detail"):
288
+ dt = soup.new_tag("div", **{"class": "datetime"})
289
+ dt.string = f"📅 {day_label}"
290
+ match.insert(0, dt)
291
+ all_html.append(str(soup))
292
+ # Next 7 days (upcoming)
293
+ for i in range(7):
294
+ d = (today + timedelta(days=i)).strftime("%Y-%m-%d")
295
+ html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}")
296
+ if html and len(html) > 50:
297
+ soup = BeautifulSoup(html, "lxml")
298
+ day_label = (today + timedelta(days=i)).strftime("%d/%m")
299
+ for match in soup.select(".match-detail"):
300
+ dt = soup.new_tag("div", **{"class": "datetime"})
301
+ dt.string = f"📅 {day_label}"
302
+ match.insert(0, dt)
303
+ all_html.append(str(soup))
304
+ combined = "<div class='updates7d'>" + "".join(all_html) + "</div>"
305
+ return combined if all_html else ""
306
+ return JSONResponse({"html": _cached("ls_updates7d", _f, ttl=_cache_ttl)})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
 
308
  @app.get("/api/match/{event_id}/commentaries")
309
  def api_match_commentaries(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/commentaries?event_id={event_id}")})
 
342
  return JSONResponse(_cached("ls_featured",_f,ttl=30))
343
 
344
  @app.get("/api/highlights")
345
+ def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
346
  @app.get("/api/highlights/leagues")
347
  def api_highlights_leagues(): return JSONResponse(_cached("hl_leagues",scrape_all_league_highlights,ttl=_cache_ttl))
348
  @app.get("/api/highlights/{league}")
 
350
  if league not in HL_LEAGUES: return JSONResponse({"error":"league not found"})
351
  return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
352
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  @app.get("/api/video_url")
354
  def api_video_url(url:str=Query(...), img:str=Query(default="")):
355
  if "youtube.com" in url or "youtu.be" in url:
 
361
  if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
362
  if not v.get("poster") and img: v["poster"] = img
363
  return JSONResponse(v)
 
 
 
 
 
364
  return JSONResponse({"error":"not found"})
365
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  # ===== WORLD CUP 2026 API =====
367
  _wc_request_times = []; _wc_rate_limit_lock = threading.Lock()
368
  _WC_RATE_LIMIT = 10
 
428
  if src: img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
429
  arts.append({"title":t,"link":lk,"img":img,"source":"vne"})
430
  return arts
431
+ except: return []
 
432
 
433
  def scrape_genk_ai():
434
  try:
 
493
 
494
  @app.get("/api/proxy/xlb")
495
  def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
 
 
 
 
 
 
 
 
 
496
  try:
497
  url = f"https://xemlaibongda.top/{path}" if path else "https://xemlaibongda.top/"
498
+ r = requests.get(url, headers=HEADERS, timeout=15)
499
+ if r.status_code != 200: return JSONResponse({"videos": []})
500
  r.encoding = "utf-8"
501
  soup = BeautifulSoup(r.text, "lxml")
502
  videos, seen = [], set()
 
525
  if not t:
526
  slug = clean.split("/video/")[-1].rstrip("/")
527
  t = slug.replace("-", " ").title()
528
+ videos.append({"title": t[:100], "link": clean, "img": img_src, "source": "xemlaibongda"})
529
  if len(videos) >= limit: break
530
+ return JSONResponse({"videos": videos})
531
+ except Exception as e:
532
+ return JSONResponse({"videos": [], "error": str(e)})
 
 
 
 
 
 
 
533
 
534
  @app.get("/api/article")
535
  def api_article(url:str=Query(...)):
pid_gen.py DELETED
@@ -1,379 +0,0 @@
1
- """pid_gen.py — AI background-image + best-text generation for Tạo Ảnh Bài Viết (VNEWS).
2
-
3
- Backend: ZeroGPU Space bep40/vnews-image-zerogpu (SenseNova-U1.5-8B-MoT, 100% GPU Zero)
4
- - /api/pid/gen_bg -> SenseNova-U1.5-8B-MoT on ZeroGPU (Gradio API)
5
- - /api/pid/gen_text -> synthesize fresh Vietnamese article lines from latest news
6
- (primary: ZeroGPU SenseNova generate_text;
7
- fallback: local news RSS + KNOWLEDGE_BANK synthesis)
8
- Imported by app_v2_entry.py at startup (decorates shared `app`)."""
9
- import os, re, time, requests
10
- import xml.etree.ElementTree as ET
11
- from fastapi import FastAPI, Request
12
- from fastapi.responses import JSONResponse, Response
13
- from pydantic import BaseModel
14
-
15
- ZEROGPU_BASE = 'https://bep40-vnews-image-zerogpu.hf.space'
16
- _HF_TOKEN = os.environ.get('HF_TOKEN', '')
17
- _HDR = {'Authorization': 'Bearer ' + _HF_TOKEN, 'Content-Type': 'application/json'}
18
-
19
- TEXT_MODELS = [
20
- 'Qwen/Qwen3-8B',
21
- 'Qwen/Qwen2.5-7B-Instruct',
22
- 'Qwen/Qwen2.5-3B-Instruct',
23
- 'Qwen/Qwen2.5-1.5B-Instruct',
24
- ]
25
- IMG_OPTIONS = [
26
- {'id': 'sensenova/SenseNova-U1.5-8B-MoT', 'label': '✨ SenseNova-U1.5-8B-MoT (AI ảnh nền)', 'poll': 'flux'},
27
- ]
28
- DEFAULT_IMG = 'sensenova/SenseNova-U1.5-8B-MoT'
29
-
30
-
31
- def _split_vn_lines(txt, n):
32
- lines, seen = [], set()
33
- for ln in (txt or '').split('\n'):
34
- l = re.sub(r'^[\s\-•*·▪◦‣0-9.]+', '', ln).strip()
35
- if l and l.lower() not in seen and len(l) >= 3:
36
- if lines and not re.search(r'[.!?…]["\')\]]?$', lines[-1]) and re.match(r'^[a-z\u00e0-\u024f]', l):
37
- lines[-1] = (lines[-1] + ' ' + l).strip()
38
- else:
39
- seen.add(l.lower())
40
- lines.append(l)
41
- return lines[:n]
42
-
43
-
44
- # ---------- ZeroGPU Gradio API helpers ----------
45
- _ZG_TOKEN = os.environ.get('ZEROGPU_TOKEN', '') or _HF_TOKEN
46
- _ZG_HDRS = {'User-Agent': 'Mozilla/5.0', 'Content-Type': 'application/json'}
47
- if _ZG_TOKEN:
48
- _ZG_HDRS['Authorization'] = 'Bearer ' + _ZG_TOKEN
49
-
50
- def _zg_submit(fn, data, timeout=60, _attempt=0):
51
- """Submit to ZeroGPU Space with retry/backoff. 429 == GPU pool busy (concurrency/reservation
52
- window), NOT quota exhaustion - retry with increasing waits before giving up."""
53
- import time as _t
54
- if _attempt > 0:
55
- _t.sleep([5, 15][min(_attempt - 1, 3)])
56
- try:
57
- r = requests.post(f'{ZEROGPU_BASE}/gradio_api/call/{fn}', json={'data': data}, timeout=timeout,
58
- headers=_ZG_HDRS)
59
- except Exception as _e:
60
- if _attempt < 2:
61
- return _zg_submit(fn, data, timeout=timeout, _attempt=_attempt + 1)
62
- raise RuntimeError(f'ZeroGPU submit {fn}: network error {type(_e).__name__}')
63
- if r.status_code != 200:
64
- body = re.sub(r'<[^>]+>', ' ', r.text or '')[:150]
65
- status = '429 (ZeroGPU GPU pool busy)' if r.status_code == 429 else f'HTTP {r.status_code}'
66
- if r.status_code in (429, 500, 502, 503, 504) and _attempt < 4:
67
- return _zg_submit(fn, data, timeout=timeout, _attempt=_attempt + 1)
68
- raise RuntimeError(f'ZeroGPU submit {fn}: {status} {body}')
69
- return r.json().get('event_id')
70
-
71
-
72
- def _zg_poll_result(fn, event_id, wait_max=240, interval=3):
73
- import time as _t
74
- t0 = _t.time()
75
- while _t.time() - t0 < wait_max:
76
- _t.sleep(interval)
77
- try:
78
- rr = requests.get(f'{ZEROGPU_BASE}/gradio_api/call/{fn}/{event_id}', timeout=30,
79
- headers=_ZG_HDRS)
80
- if rr.status_code != 200:
81
- continue
82
- data = rr.json()
83
- if isinstance(data, dict) and data.get('status') == 'SUCCESS':
84
- return data.get('result', [None, None])[0]
85
- if isinstance(data, dict) and data.get('status') == 'FAILED':
86
- return None
87
- except Exception:
88
- continue
89
- return None
90
-
91
-
92
- def _zg_generate_bg(prompt, w, h, steps=28, timeout=600):
93
- """Generate an article-background image from text prompt."""
94
- import time as _t
95
- t0 = _t.time()
96
- event_id = _zg_submit('predict', [{'prompt': prompt, 'width': w, 'height': h, 'guidance': 7.0, 'steps': steps}, 0.8, 30], timeout=timeout)
97
- if not event_id:
98
- raise RuntimeError('ZeroGPU submit failed')
99
- result = _zg_poll_result('predict', event_id, wait_max=timeout, interval=2)
100
- if not result or not isinstance(result, (bytes, str)):
101
- raise RuntimeError('ZeroGPU gen_bg: no image in result')
102
- if isinstance(result, str):
103
- raise RuntimeError('ZeroGPU gen_bg: text result')
104
- return result, 'image/png'
105
-
106
-
107
- def _zg_generate_text(prompt, n, timeout=120):
108
- """Generate article text (Vietnamese) from a topic prompt."""
109
- import time as _t
110
- t0 = _t.time()
111
- event_id = _zg_submit('generate_text', [{'prompt': prompt, 'num_lines': n}], timeout=timeout)
112
- if not event_id:
113
- raise RuntimeError('ZeroGPU submit failed')
114
- result = _zg_poll_result('generate_text', event_id, wait_max=timeout, interval=2)
115
- if not result or not isinstance(result, str):
116
- raise RuntimeError('ZeroGPU gen_text: no text result')
117
- return result
118
-
119
-
120
- def _fetch_news(topic, limit=10):
121
- """Fetch latest news items from Vietnamese RSS feeds for the given topic."""
122
- feeds = [
123
- ('https://vnexpress.net/rss/the-thao.rss', topic),
124
- ('https://dantri.com.vn/rss/tintuc.rss', topic),
125
- ('https://tuoitre.vn/rss/latest.xml', topic),
126
- ]
127
- items = []
128
- for feed, _ in feeds:
129
- try:
130
- resp = requests.get(feed, timeout=25, headers={'User-Agent': 'Mozilla/5.0'})
131
- if resp.status_code != 200:
132
- continue
133
- root = ET.fromstring(resp.content)
134
- for it in root.iter('item'):
135
- t = (it.findtext('title') or '').strip()
136
- s = (it.findtext('description') or '').strip()
137
- if t and len(t) >= 12:
138
- items.append({'title': t, 'snippet': s})
139
- except Exception:
140
- continue
141
- if len(items) >= limit:
142
- break
143
- return items[:limit]
144
-
145
-
146
-
147
- def _split_vn_sentences(txt):
148
- """Split Vietnamese text into complete sentences.
149
-
150
- Only breaks at real sentence terminators (. ! ? …) followed by whitespace
151
- (or end of text). Commas, semicolons and colons NOT followed by a terminator
152
- keep the sentence together so we never cut mid-sentence. Quoted/abbreviated
153
- periods ("3,5.", "12.5") stay intact via the ``(?<!\\d)`` lookbehind.
154
- """
155
- txt = re.sub(r'<[^>]+>', '', txt or '')
156
- txt = re.sub(r'\s+', ' ', txt).strip()
157
- if not txt:
158
- return []
159
- # Sentence terminators: period / ! / ? / ellipsis / Vietnamese full stop,
160
- # optionally followed by a closing quote/paren, then whitespace or EOS.
161
- parts = re.split(r'(?<=[.!?…])(?<!\.\d)(?<![A-Za-z]\.[A-Za-z])\s+', txt)
162
- # also handle ellipsis without space and trailing safety
163
- out = []
164
- for part in parts:
165
- part = part.strip()
166
- if not part:
167
- continue
168
- if out and not re.search(r'[.!?…]$', out[-1]):
169
- out[-1] = (out[-1] + ' ' + part).strip()
170
- else:
171
- out.append(part)
172
- return out
173
-
174
-
175
- def _assemble_lines(sentence_groups, cap=110, n=None):
176
- """Assemble complete-sentence groups into readable article lines (Vietnamese).
177
-
178
- Keeps each line a grammatically complete sentence; splits long sentences
179
- only when the remaining text is still a complete clause.
180
- """
181
- if not sentence_groups:
182
- return []
183
- lines = []
184
- cur = ''
185
- for grp in sentence_groups:
186
- cap = cap or 110
187
- candidate = (cur + ' ' + grp).strip() if cur else grp
188
- if not cur:
189
- cur = grp
190
- elif len(candidate) <= cap:
191
- cur = candidate
192
- else:
193
- # cur is a complete sentence (or joined set) -> flush
194
- lines.append(cur)
195
- cur = grp
196
- if len(lines) >= (n or 8):
197
- break
198
- if cur and len(lines) < (n or 8):
199
- lines.append(cur)
200
- lines = [l for l in lines if l]
201
- return lines[:max(n or 8, 3)]
202
-
203
-
204
- def _article_to_lines(article, n):
205
- """Turn an article string into `n` complete-sentence lines (Vietnamese)."""
206
- sentences = _split_vn_sentences(article)
207
- if not sentences:
208
- return [article or ''][:n]
209
- # Group consecutive sentences into article lines, keeping every line complete.
210
- lines = _assemble_lines(sentences, cap=130, n=n)
211
- if not lines:
212
- # last resort: relax cap to fit every sentence completely
213
- lines = [s for s in sentences if s] or [t2 for t2 in [re.sub(r'[\s\-:]+', ' ', article or '')[:80]]]
214
- return lines[:n]
215
-
216
-
217
- KNOWLEDGE_BANK = {
218
- 'trung thu': 'Tết Trung thu (rằm tháng 8 âm lịch) bắt nguồn từ văn hóa Trung Hoa cổ đại gắn với tích Hằng Nga - hậu Nghệ, chú Cuội cây đa; tại Việt Nam từ thời Lý - Trần đã thành lễ hội tết trông trăng: trẻ em rước đèn, phá cỗ, múa lân; tên "Trung thu" nghĩa là giữa mùa thu, đêm trăng rằm sáng tròn nhất năm, tượng trưng cho gia đình đoàn viên.',
219
- 'tết': 'Tết Nguyên đán bắt nguồn từ truyền thống nông nghiệp lúa nước, "Tết" gốc từ chữ "Tiết" (tiết khí), "Nguyên đán" nghĩa là buổi sáng đầu năm mới; dịp đoàn viên, cúng ông Công ông Táo, gói bánh chưng, chúc Tết và lì xì.',
220
- 'bóng đá': 'Bóng đá là môn thể thao vua, đội tuyển Việt Nam lập nhiều cột mốc: vô địch AFF Cup 2008, 2018, 2024; vào tứ kết Asian Cup 2019 và vòng loại cuối World Cup 2026.',
221
- 'kinh tế': 'Kinh tế Việt Nam từ Đổi mới 1986 chuyển sang kinh tế thị trường định hướng xã hội chủ nghĩa, nhiều năm tăng trưởng 6-7%/năm, thu hút FDI và xuất khẩu mạnh.',
222
- 'giáo dục': 'Giáo dục Việt Nam có truyền thống khoa bảng nghìn năm từ Văn Miếu - Quốc Tử Giám (1070), hiện đổi mới ch��ơng trình phổ thông 2018 và chuyển đổi số.',
223
- 'chứng khoán': 'Thị trường chứng khoán Việt Nam thành lập năm 2000 với các sàn HOSE, HNX, là kênh huy động vốn trung dài hạn cho doanh nghiệp.',
224
- 'thời tiết': 'Thời tiết Việt Nam chịu ảnh hưởng gió mùa Đông Bắc - Tây Nam, mùa mưa bão tập trung tháng 7-10, cần theo dõi dự báo để phòng tránh.',
225
- 'công nghệ': 'Công nghệ Việt Nam phát triển mạnh từ 2000 với phần mềm, bán dẫn, AI; nhiều startup gây quỹ hàng trăm triệu USD tại Đông Nam Á.',
226
- 'du lịch': 'Du lịch Việt Nam nổi tiếng với di sản UNESCO như Vịnh Hạ Long, Hội An, Huế, Phong Nha; đóng góp lớn vào GDP và thu hút hàng chục triệu khách quốc tế mỗi năm.',
227
- 'trường học': 'Năm học mới ở Việt Nam khai giảng 5/9, học sinh háo hức tựu trường sau kỳ hè; lễ khai giảng gọn nhẹ, ý nghĩa.',
228
- }
229
- def _kb_line(topic):
230
- tl = (topic or '').strip().lower()
231
- for k, v in KNOWLEDGE_BANK.items():
232
- if k in tl or tl in k:
233
- return v
234
- return None
235
-
236
-
237
- def _local_gen_text(topic, n):
238
- """Full Vietnamese news-article prose (9-11 sentences) — richer output so
239
- the "Tạo nội đúng AI" feature never returns a 1-3 line stub. Always yields
240
- at least `n` usable lines."""
241
- news = _fetch_news(topic, limit=10)
242
- frags = []
243
- for x in news:
244
- t = re.sub(r"<[^>]+>", "", x.get("title") or "")
245
- t = re.sub(r"\s+", " ", t).strip()
246
- t = re.sub(r"\s*-\s*[^\n\r]{0,30}$", "", t)
247
- t = t[:120]
248
- if t and len(t) >= 15 and t not in frags:
249
- frags.append(t)
250
- tl = (topic or "").strip().lower()
251
- kb = _kb_line(tl)
252
- t2 = re.sub(r"[\s\-:]+", " ", topic)[:45] or "vấn đề này"
253
- t2c = t2[0].upper() + t2[1:] if t2 else "Chủ đề này"
254
- parts = []
255
- if kb:
256
- parts.append(kb)
257
- parts.append(f"{t2c} đang thu hút sự quan tâm rất lớn của dư luận trong thời gian gần đây, khi những diễn biến xoay quanh nó liên tục được cập nhật trên các mặt báo lớn.")
258
- if frags:
259
- parts.append(f"Theo ghi nhận, thông tin mới nhất cho thấy {t2} đang được nhiều chuyên gia đánh giá là có tác động sâu rộng đến đời sống kinh tế - xã hội.")
260
- else:
261
- parts.append(f"Theo ghi nhận, {t2} đang được nhiều chuyên gia đánh giá là vấn đề có tác động sâu rộng và cần được nghiên cứu kỹ lưỡng.")
262
- for f in frags[:5]:
263
- if not f:
264
- continue
265
- # keep acronyms/initials intact: only lower-case the first letter when
266
- # the word starts with a capital letter AND is followed by lowercase
267
- # letters (e.g. 'Thắng' -> 'thắng'); leave 'MU', 'HLV', 'FPT' as-is
268
- fc = f
269
- if f and f[0].isupper() and (len(f) < 2 or not f[1].isupper()):
270
- fc = f[0].lower() + f[1:]
271
- parts.append(f"{fc} — một diễn biến mới được báo cáo gần đây, tiếp tục hé lộ nhiều thông tin quan trọng liên quan đến chủ đề này.")
272
- parts.append(f"Không chỉ dừng lại ở phạm vi một nhóm đối tượng, sự việc còn kéo theo những hệ lụy lan tỏa, buộc các bên liên quan phải có sự chuẩn bị kỹ lưỡng.")
273
- parts.append(f"Các chuyên gia cũng khuyến nghị người dân nên tham khảo nhiều nguồn tin khác nhau, tránh bị chi phối bởi những thông tin chưa được kiểm chứng.")
274
- parts.append(f"Trong bối cảnh đó, việc theo dõi sát sao và cập nhật thông tin thường xuyên là điều cần thiết để có cái nhìn toàn diện và chính xác nhất.")
275
- parts.append(f"Nhìn chung, những diễn biến xoay quanh {t2} vẫn đang tiếp tục phát triển, hứa hẹn sẽ mang đến nhiều điều bất ngờ trong thời gian tới.")
276
- parts.append(f"Với tầm quan trọng và sức ảnh hưởng của mình, {t2} chắc chắn sẽ còn được dư luận quan tâm theo dõi trong nhiều ngày nữa.")
277
- article = " ".join(p for p in parts if p)
278
- return [article]
279
-
280
-
281
- # ---------- SenseNova text generation via ZeroGPU ----------
282
- def _zg_generate_text(topic, n=8, timeout=120):
283
- """Generate a fresh Vietnamese article via SenseNova-U1.5 on ZeroGPU.
284
- Returns text or None on failure."""
285
- try:
286
- r = requests.post(
287
- f'{ZEROGPU_BASE}/api/generate_text',
288
- headers=_HDR,
289
- json={'topic': topic, 'num_lines': n},
290
- timeout=timeout
291
- )
292
- if r.status_code != 200:
293
- print(f'[pid_gen] ZeroGPU gen_text error: {r.status_code} {r.text[:120]}')
294
- return None
295
- body = r.json()
296
- txt = body.get('text') or body.get('article') or ''
297
- txt = re.sub(r'<[^>]+>', '', txt).strip()
298
- if len(txt) >= 20:
299
- return txt
300
- return None
301
- except Exception as e:
302
- print(f'[pid_gen] ZeroGPU gen_text exception: {type(e).__name__}: {str(e)[:150]}')
303
- return None
304
-
305
-
306
- _BG_CACHE = {} # prompt -> (img_bytes, content_type, ts); ttl 60s
307
- _CACHE_TTL = 60
308
-
309
- def _zg_generate_bg_cached(prompt, w, h, steps=28, timeout=600):
310
- key = (prompt, w, h, steps)
311
- now = time.time()
312
- hit = _BG_CACHE.get(key)
313
- if hit and now - hit[2] < _CACHE_TTL:
314
- return hit[0], hit[1]
315
- img, ct = _zg_generate_bg(prompt, w, h, steps=steps, timeout=timeout)
316
- _BG_CACHE[key] = (img, ct, now)
317
- # prune
318
- for k in list(_BG_CACHE.keys()):
319
- if now - _BG_CACHE[k][2] > _CACHE_TTL:
320
- del _BG_CACHE[k]
321
- return img, ct
322
-
323
- def _register(app):
324
- @app.post('/api/pid/gen_bg')
325
- def gen_bg(req: GenBGReq):
326
- t0 = time.time()
327
- w, h = 1024, 1536
328
- msz = re.match(r'\s*(\d+)[xX,]\s*(\d+)', (req.size or '').strip())
329
- if msz:
330
- w, h = int(msz.group(1)), int(msz.group(2))
331
- # SenseNova-U1.5-8B-MoT via ZeroGPU Space ONLY — NO pollinations fallback
332
- try:
333
- img, ct = _zg_generate_bg_cached((req.prompt or '').strip(), w, h, steps=28, timeout=600)
334
- except Exception as e:
335
- err0 = re.sub(r'<[^>]+>', ' ', f'{type(e).__name__}: {str(e)[:300]}')
336
- err0 = re.sub(r'\s+', ' ', err0).strip()[:180]
337
- print(f'[pid_gen] SenseNova gen_bg FAILED (no fallback): {err0}', flush=True)
338
- return JSONResponse(
339
- {'error': 'SenseNova-U1.5 chưa sẵn sàng (GPU pool đang bận). Vui lòng thử lại sau vài giây.'},
340
- status_code=503,
341
- headers={'X-Gen-Fail': 'sensenova-busy', 'X-Retry-After': '30'},
342
- )
343
- resp = Response(content=img, media_type=ct)
344
- resp.headers['X-Gen-Ms'] = str(int((time.time() - t0) * 1000))
345
- resp.headers['X-Engine'] = 'sensenova-zerogpu'
346
- return resp
347
-
348
- @app.post('/api/pid/gen_text')
349
- def gen_text(req: GenTextReq):
350
- try:
351
- n = max(8, min(int(req.num_lines or 8), 12))
352
- topic = (req.topic or '').strip()
353
- if not topic:
354
- return JSONResponse({'error': 'missing topic'}, status_code=400)
355
- article = None
356
- engine_name = None
357
- # ZeroGPU SenseNova text generation first
358
- try:
359
- txt_zg = _zg_generate_text(topic, n, timeout=120)
360
- if txt_zg and len(txt_zg) >= 20:
361
- article = txt_zg
362
- engine_name = 'zerogpu-sensenova'
363
- except Exception as e_zg:
364
- print(f'[pid_gen] ZeroGPU gen_text failed: {type(e_zg).__name__}', flush=True)
365
- # fallback: local news RSS + KNOWLEDGE_BANK synthesis (instant, always works)
366
- if not article:
367
- article = '\n'.join(_local_gen_text(topic, n))
368
- engine_name = engine_name or 'local-news-synthesis'
369
- lines = _article_to_lines(article, n)
370
- return JSONResponse({'lines': lines, 'text': article, 'article': article,
371
- 'model': 'sensenova/SenseNova-U1.5-8B-MoT' if 'zerogpu' in engine_name else 'Qwen/Qwen2.5-7B-Instruct',
372
- 'engine': engine_name})
373
- except Exception as e:
374
- return JSONResponse({'error': f"[pid_gen] gen_text {type(e).__name__}: {str(e)[:300]}"}, status_code=500)
375
-
376
- def patch_app(app):
377
- _register(app)
378
-
379
- print('[pid_gen] endpoints registered (ZeroGPU backend)') if __name__ != '__main__' else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
rebuild_trigger.txt CHANGED
@@ -1 +1 @@
1
- M3U perf fix: disk cache + background pre-warm Sat Aug 29 00:43:20 UTC 2026
 
1
+ TRIGGER_REBUILD=20260719
requirements.txt CHANGED
@@ -11,6 +11,4 @@ pillow
11
  edge-tts
12
  python-dateutil
13
  httpx
14
- python-multipart
15
- pycryptodome
16
- # trigger rebuild 1784359130
 
11
  edge-tts
12
  python-dateutil
13
  httpx
14
+ # trigger rebuild 1784359129
 
 
restart.txt CHANGED
@@ -1 +1 @@
1
- rebuild trigger 1789270802.2049925
 
1
+ restart
restart_trigger.md DELETED
@@ -1,14 +0,0 @@
1
- # VNEWS Space Restart Trigger
2
-
3
- ## Version: v20260822d
4
-
5
- ### All fixes applied:
6
- 1. **Scrolling fix**: `-webkit-overflow-scrolling: touch` + `scroll-behavior: smooth` on `.tiktok-feed`
7
- 2. **Thumbnail fix**: `.yt-thumb-wrap` with background image for YouTube iframes, YouTube hqdefault fallback
8
- 3. **Lazy loading**: IntersectionObserver to unload slides far from viewport
9
- 4. **SHORT HOT video looping**: Video plays at full duration instead of being trimmed to narration length
10
- 5. **CSS consistency**: Same poster/thumbnail styling for all TikTok slides
11
- 6. **Homepage loading**: Retry logic for `scrape_vne`, bongdaplus.vn as primary source, faster xemlaibongda timeout
12
- 7. **YouTube SSL error**: Increased yt-dlp retries (5), socket_timeout 30s, YouTube embed fallback
13
-
14
- Last restart: 20260822d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/app_v2.js CHANGED
The diff for this file is too large to render. See raw diff
 
static/designer_v2.js DELETED
@@ -1,1182 +0,0 @@
1
- /* =========================================================================
2
- * designer_v2.js — Slide Designer nâng cấp cho VNEWS
3
- * - Nhiều hiệu ứng độc đáo (gradient, blur, neon, top-banner, lower-third,
4
- * card, corner-frame, stars, vignette, split, spotlight, diagonal, solid)
5
- * - Bộ lọc màu (grayscale, sepia, saturate, warm, cool, invert)
6
- * - Chữ phát sáng (neon/glow)
7
- * - Vị trí chữ (trên/giữa/dưới)
8
- * - Chọn 1 / nhiều / TẤT CẢ slide trong modal sửa ảnh
9
- * - Đăng lên Tường AI là bài dạng slide gồm CÁC SLIDE ĐÃ CHỌN
10
- *
11
- * 2026-08-12 FIX (2 lỗi báo cáo):
12
- * - Bug 1: upload ảnh preview + nút "Áp dụng ảnh". _dsHandleBgFile được gắn
13
- * trực tiếp làm listener 'change' nên nhận Event object, `input.files`
14
- * undefined -> upload im lặng không làm gì. Giờ đọc evt.target.files.
15
- * Nút "Áp dụng ảnh này cho TẤT CẢ slide đã chọn" luôn hiển thị.
16
- * - Bug 2: chọn 1 ảnh rewrite áp dụng cho nhiều slide. Danh sách ảnh dùng
17
- * data-index + delegated click (hết fragile inline onclick với URL thô).
18
- *
19
- * 2026-08-13 FIX (lỗi báo cáo tiếp):
20
- * - Bug 3: "dùng 1 ảnh cho nhiều slide" vẫn chỉ áp dụng cho slide đầu.
21
- * Giờ có _dsAppliedImg (ảnh đã áp dụng ở cấp thiết kế): khi chọn ảnh bất kỳ
22
- * (rewrite / upload / URL) sẽ áp dụng cho TẤT CẢ slide đã chọn NGAY LẬP TỨC;
23
- * khi đổi lựa chọn slide (chọn thêm / bỏ chọn / chọn tất cả) ảnh đó vẫn được
24
- * áp dụng tự động cho các slide mới được chọn; khi lưu, mọi slide được chọn
25
- * đều dùng ảnh đó.
26
- * - Bug 4: ảnh preview hiển thị đúng tỉ lệ nhưng sau khi tạo ảnh xong hiển thị
27
- * sai tỉ lệ + ảnh nền không định vị chính xác như preview. Preview và khi
28
- * lưu giờ render Ở CÙNG một hàm `_dsRenderSlide(canvas, opts, cb)` (cùng
29
- * resolution, cùng cover-fit, cùng filter, cùng text) nên file tạo ra khớp
30
- * TUYỆT ĐỐI với preview. Ảnh trên Tường/slide viewer giữ đúng tỉ lệ khung
31
- * (ko cắt xén bằng object-fit:cover nữa).
32
- * - Thêm MÀU NỀN + nhiều ẢNH NỀN MẪU khi chọn "Nền đều (phủ màu tối)" —
33
- * dải màu nền (solid color swatches) và bộ ảnh nền mẫu (gradient mờ) để
34
- * người dùng tạo ảnh nền trơn thay vì chỉ phủ màu tối.
35
- * ========================================================================= */
36
-
37
- /* ---------------- state ---------------- */
38
- let _dsState = {
39
- postIdx: -1, // index into _wallPosts
40
- selected: [], // array of selected slide indices (into p.slides)
41
- };
42
-
43
- /* per-slide image override map: {si: imageUrl} set by the rewrite image list */
44
- let _dsImgMap = {};
45
-
46
- /* srcs backing the rewrite image list — REMOVED (tùy chọn ảnh từ rewrite đã bỏ) */
47
-
48
- /* designer-level "applied image": the image the user picked (rewrite / upload /
49
- URL). Applied to ALL selected slides, including slides selected LATER.
50
- Bug 3 fix: keeps "1 ảnh cho nhiều slide" working when selection changes. */
51
- let _dsAppliedImg = '';
52
-
53
- /* solid-color background for the 'solid' layout (map because _dsOptions reads it) */
54
- let _dsBgColor = '';
55
- /* sample background image (proxied URL) for the 'solid' layout */
56
- let _dsBgSample = '';
57
-
58
- /* Quote design state (Style 1: Trích dẫn) */
59
- let _dsQuoteType = 'about'; // 'about' or 'speaker'
60
- let _dsQuoteText = ''; // quote text
61
- let _dsQuoteColor = '#ffd700'; // quote text color
62
- let _dsQuoteSpeaker = ''; // speaker name
63
- let _dsQuoteRole = ''; // speaker role
64
- let _dsQuotePos = 'bottom'; // text position
65
-
66
- /* Grid design state (Style 2: Lưới) */
67
- let _dsGridQuotes = {}; // {imageIndex: quoteText}
68
- let _dsGridPos = 'bottom'; // text position on grid
69
-
70
- /* ---------------- ratio / layout helpers ---------------- */
71
- function designerGetRatioV2(){
72
- const v = document.getElementById('ds-ratio')?.value || '3:4';
73
- const m = v.split(':');
74
- return m.length === 2 ? {w: parseInt(m[0]), h: parseInt(m[1])} : {w:3,h:4};
75
- }
76
- function designerGetLayoutV2(){ return document.getElementById('ds-layout')?.value || 'solid'; }
77
- function designerGetFilterV2(){ return document.getElementById('ds-filter')?.value || 'none'; }
78
- function designerGetGlowV2(){ return document.getElementById('ds-glow')?.value || 'none'; }
79
- function designerGetPosV2(){ return document.getElementById('ds-pos')?.value || 'middle'; }
80
-
81
- /* ---------------- open modal (override) ---------------- */
82
- function openSlideDesigner(idx){
83
- const p = _wallPosts[idx]; if(!p || !p.slides || !p.slides.length) return;
84
- _dsState.postIdx = idx;
85
- // default: chỉ slide đầu tiên (slide 1 = ảnh đại diện bài đăng tường).
86
- // Người dùng có thể chọn thêm bằng checkbox hoặc "Chọn tất cả".
87
- _dsState.selected = p.slides.length ? [0] : [];
88
- _dsBgDataUrl = null; // reset uploaded bg between opens
89
- _dsTextColor = '#ffffff';
90
- _dsImgMode = 'custom'; // default: no automatic rewrite image; user picks
91
- _dsImgMap = {}; // reset per-slide image overrides
92
- _dsAppliedImg = ''; // reset designer-level applied image
93
- // Reset quote/grid state
94
- _dsQuoteType = 'about';
95
- _dsQuoteText = '';
96
- _dsQuoteColor = '#ffd700';
97
- _dsQuoteSpeaker = '';
98
- _dsQuoteRole = '';
99
- _dsQuotePos = 'bottom';
100
- _dsGridQuotes = {};
101
- _dsGridPos = 'bottom';
102
- _buildDesignerModal(p);
103
- }
104
-
105
- function _buildDesignerModal(p){
106
- const overlay = document.createElement('div');
107
- overlay.id = 'slide-designer-overlay';
108
- overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.94);z-index:99999;display:flex;align-items:center;justify-content:center;padding:12px;overflow-y:auto';
109
- let h='<div style="background:#16161a;border:2px solid #5cb87a;border-radius:16px;padding:18px;max-width:780px;width:100%;max-height:94vh;overflow-y:auto">';
110
- h+='<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">\
111
- <h3 style="color:#5cb87a;margin:0;font-size:16px">🎨 Thiết kế ảnh slide</h3>\
112
- <div class="ds-tabs" style="display:flex;gap:4px;margin-left:auto;margin-right:60px">\
113
- <button id="ds-tab-design" class="ds-tab-btn active" onclick="_dsSwitchTab(\'design\')" style="background:#2d8659;border:0;color:#fff;padding:5px 12px;border-radius:8px;font-size:11px;cursor:pointer">🎨 Thiết kế</button>\
114
- <button id="ds-tab-quote" class="ds-tab-btn" onclick="_dsSwitchTab(\'quote\')" style="background:#333;border:0;color:#ccc;padding:5px 12px;border-radius:8px;font-size:11px;cursor:pointer">✏️ Trích dẫn</button>\
115
- <button id="ds-tab-grid" class="ds-tab-btn" onclick="_dsSwitchTab(\'grid\')" style="background:#333;border:0;color:#ccc;padding:5px 12px;border-radius:8px;font-size:11px;cursor:pointer">📊 Lưới</button>\
116
- </div>\
117
- <button onclick="document.getElementById(\'slide-designer-overlay\').remove()" style="background:none;border:0;color:#fff;font-size:24px;cursor:pointer">✕</button></div>';
118
-
119
- /* ---- 1. Chọn slide (1 / nhiều / tất cả) ---- */
120
- h+='<div style="margin-bottom:10px"><label style="color:#888;font-size:11px;display:block;margin-bottom:6px">📌 Chọn slide để chỉnh sửa & đăng lên Tường AI:</label>';
121
- h+='<div style="margin-bottom:8px;display:flex;gap:8px;flex-wrap:wrap">';
122
- h+='<button id="ds-sel-all" style="background:#2d8659;border:0;color:#fff;padding:6px 12px;border-radius:8px;font-size:11px;cursor:pointer">✅ Chọn tất cả</button>';
123
- h+='<button id="ds-sel-none" style="background:#333;border:0;color:#ddd;padding:6px 12px;border-radius:8px;font-size:11px;cursor:pointer">🚫 Bỏ chọn</button>';
124
- h+='</div><div id="ds-slide-list" style="display:flex;flex-wrap:wrap;gap:8px">';
125
- p.slides.forEach((s,i)=>{
126
- const preview = s.image ? `<img src="${_proxyImg(s.image)}" style="width:100%;height:54px;object-fit:cover;border-radius:6px;margin-bottom:4px" onerror="this.style.display='none'">` : `<div style="width:100%;height:54px;background:#222;border-radius:6px;margin-bottom:4px;display:flex;align-items:center;justify-content:center;color:#555;font-size:20px">🖼️</div>`;
127
- const isSel = _dsState.selected.includes(i);
128
- h+=`<label data-idx="${i}" class="ds-slide-chip" style="width:110px;border:2px solid #2d8659;background:#1e2621;border-radius:10px;padding:6px;cursor:pointer;text-align:center">
129
- <input type="checkbox" class="ds-slide-chk" data-idx="${i}"${isSel?' checked':''} style="accent-color:#5cb87a;width:14px;height:14px">
130
- ${preview}
131
- <div style="color:#eee;font-size:10px;line-height:1.3;height:32px;overflow:hidden">Slide ${s.index||i+1}: ${(s.text||'').slice(0,34)}</div>
132
- </label>`;
133
- });
134
- h+='</div><div id="ds-tab-content-design">';
135
-
136
- /* ---- 2. Nội dung từng slide đã chọn ---- */
137
- h+='<div style="margin-bottom:10px"><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">✏️ Nội dung từng slide:</label><div id="ds-texts"></div></div>';
138
-
139
- /* ---- 3. Cấu hình thiết kế ---- */
140
- h+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px">';
141
- h+='<div><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">Tỉ lệ cắt ảnh:</label><select id="ds-ratio" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px"><option value="3:4">📱 3:4 (dọc)</option><option value="1:1">📐 1:1 (vuông)</option><option value="4:3">🖥️ 4:3 (ngang)</option><option value="16:9">🎞️ 16:9 (rộng)</option></select></div>';
142
- h+='<div><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">Vị trí chữ:</label><select id="ds-pos" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px"><option value="middle">⬅️ Giữa</option><option value="top">⬆️ Trên</option><option value="bottom">⬇️ Dưới</option></select></div>';
143
- h+='<div style="grid-column:1/3"><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">✨ Hiệu ứng nền:</label><select id="ds-layout" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px">';
144
- h+='<option value="solid">🎨 Nền đều (phủ màu tối)</option>';
145
- h+='<option value="gradient">🌈 Gradient (đổ màu)</option>';
146
- h+='<option value="vignette">🌙 Vignette (viền tối dần)</option>';
147
- h+='<option value="blur">🌀 Làm mờ hậu cảnh</option>';
148
- h+='<option value="split">✂️ Chia đôi (trên mờ, dưới rõ)</option>';
149
- h+='<option value="spotlight">🎯 Spotlight (ánh sáng giữa)</option>';
150
- h+='<option value="diagonal">↘️ Đường chéo</option>';
151
- h+='<option value="neon">💡 Neon (nền tối + chữ phát sáng)</option>';
152
- h+='<option value="top-banner">🏷️ Banner trên cùng</option>';
153
- h+='<option value="lower-third">📺 Lower-third (chữ dưới)</option>';
154
- h+='<option value="card">🃏 Card bo góc</option>';
155
- h+='<option value="corner">🖼️ Khung viền góc</option>';
156
- h+='<option value="stars">🌠 Nền sao lấp lánh</option>';
157
- h+='</select></div>';
158
- h+='<div><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">🎚️ Bộ lọc màu:</label><select id="ds-filter" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px"><option value="none">Nguyên bản</option><option value="grayscale">⚪ Đen trắng</option><option value="sepia">🟤 Sepia</option><option value="saturate">🎨 Bão hòa mạnh</option><option value="warm">🔥 Ấm áp</option><option value="cool">❄️ Mát lạnh</option><option value="invert">🔁 Đảo màu</option><option value="noir">🖤 Noir tương phản</option></select></div>';
159
- h+='<div><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">💡 Phát sáng chữ:</label><select id="ds-glow" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px"><option value="none">Tắt</option><option value="white">✨ Tỏa trắng</option><option value="color">🌈 Tỏa màu chữ</option><option value="neon">💡 Neon mạnh</option></select></div>';
160
- h+='</div>';
161
-
162
- /* ---- 3b. Màu nền + ảnh nền mẫu (chỉ hiển thị khi chọn "Nền đều/solid") ---- */
163
- h+='<div id="ds-bg-solid-wrap" style="display:none;margin-bottom:10px;padding:10px;background:#1e2621;border:1px solid #2d8659;border-radius:10px">';
164
- h+='<div style="color:#888;font-size:11px;display:block;margin-bottom:6px">🎨 Màu nền (nền trơn):</div><div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px" id="ds-bg-colors">';
165
- ['#0f172a','#1e293b','#334155','#1a1a2e','#16213e','#0f3460','#202040','#2d3436','#2c2c54','#3d1e1e','#4a1e3d','#1e3d2f','#3a2e1e','#111827','#0b0b1a','#233329'].forEach(c=>{h+=`<button class="ds-bgcolor-btn" data-color="${c}" style="width:30px;height:30px;border-radius:8px;border:2px solid #333;background:${c};cursor:pointer;padding:0" title="${c}"></button>`;});
166
- h+='</div>';
167
- h+='<div style="color:#888;font-size:11px;display:block;margin-bottom:6px">🖼️ Hoặc chọn ẢNH NỀN MẪU:</div><div style="display:flex;flex-wrap:wrap;gap:6px" id="ds-bg-samples">';
168
- _DS_BG_SAMPLES.forEach((b,bi)=>{h+=`<button type="button" class="ds-bgsample-btn" data-bi="${bi}" style="width:52px;height:68px;border-radius:8px;border:2px solid #333;background:#222;background-image:url('${_dsImageSrc(b)}');background-size:cover;background-position:center;cursor:pointer;padding:0;position:relative"><span style="position:absolute;right:3px;top:3px;font-size:9px;line-height:1;color:#fff;text-shadow:0 0 3px #000">🖼️</span></button>`;});
169
- h+='</div>';
170
- h+='<div style="color:#666;font-size:9px;margin-top:6px">Chọn màu nền hoặc ảnh nền mẫu — ảnh nền mẫu sẽ được phủ mờ để chữ dễ đọc. Nếu không chọn, dùng màu tối mặc định.</div>';
171
- h+='</div>';
172
-
173
- /* ---- 4. Màu chữ + nền ---- */
174
- h+='<div style="margin-bottom:10px"><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">🎨 Màu chữ:</label><div style="display:flex;gap:6px;flex-wrap:wrap" id="ds-colors">';
175
- ['#ffffff','#000000','#ff4444','#44ff44','#4444ff','#ffff00','#ff00ff','#00ffff','#ff8800','#88ff00','#ffd700','#ff69b4'].forEach(c=>{h+=`<button class="ds-color-btn" data-color="${c}" style="width:26px;height:26px;border-radius:50%;border:2px solid ${c==='#ffffff'?'#888':'#333'};background:${c};cursor:pointer;padding:0"></button>`;});
176
- h+='</div></div>';
177
-
178
- /* ---- 5. Ảnh nền (source buttons + URL/upload) ---- */
179
- h+='<div style="margin-bottom:6px"><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">🖼️ Ảnh nền:</label>';
180
- h+='<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px">';
181
- h+='<button id="ds-img-src-custom" class="ds-img-src-btn" style="background:#333;border:0;color:#ccc;padding:6px 10px;border-radius:8px;font-size:10px;cursor:pointer">🖼️ Ảnh riêng (URL/upload)</button>';
182
- h+='<button id="ds-img-src-none" class="ds-img-src-btn" style="background:#333;border:0;color:#ccc;padding:6px 10px;border-radius:8px;font-size:10px;cursor:pointer">🚫 Không ảnh (nền trơn)</button>';
183
- h+='</div>';
184
- /* URL + upload panel (only the URL/upload inputs collapse; apply button stays visible) */
185
- h+='<div id="ds-custom-img-url-wrap" style="display:none">';
186
- h+='<input id="ds-bg-url" type="text" placeholder="https://... (URL ảnh)" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px;margin-bottom:6px">';
187
- h+='<input type="file" id="ds-bg-file" accept="image/*" style="font-size:11px;color:#888">';
188
- h+='</div>';
189
- /* "Áp dụng ảnh này cho TẤT CẢ slide đã chọn" — ALWAYS visible (Bug 1 fix) */
190
- h+='<div style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;align-items:center"><button id="ds-apply-img-btn" style="background:#2d8659;border:0;color:#fff;padding:6px 12px;border-radius:8px;font-size:10px;cursor:pointer">📌 Áp dụng ảnh này cho TẤT CẢ slide đã chọn</button><span id="ds-apply-img-hint" style="color:#5cb87a;font-size:9px;line-height:1.6">✓ Áp dụng để dùng 1 ảnh cho nhiều slide</span></div>';
191
- h+='<div style="color:#888;font-size:10px">Ảnh tải lên/URL nhập từ ngoài sẽ áp dụng cho TẤT CẢ slide đã chọn. Nếu không chọn ảnh, dùng nền mẫu/màu nền.</div>';
192
- h+='</div>';
193
-
194
- h+='</div>'; /* /ds-tab-content-design */
195
- h+='<div id="ds-tab-content-quote" style="display:none">';
196
-
197
- h+=' <div style="margin-bottom:10px"><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">📋 Phong cách trích dẫn:</label>';
198
- h+=' <div style="display:flex;gap:6px">';
199
- h+=' <button id="ds-quote-type-about" class="ds-type-btn active" onclick="_dsSetQuoteType(\'about\')" style="flex:1;text-align:center;background:#2d8659;border:0;color:#fff;padding:7px;border-radius:8px;font-size:11px;cursor:pointer">🗣️ Về người/sự kiện</button>';
200
- h+=' <button id="ds-quote-type-speaker" class="ds-type-btn" onclick="_dsSetQuoteType(\'speaker\')" style="flex:1;text-align:center;background:#333;border:0;color:#ccc;padding:7px;border-radius:8px;font-size:11px;cursor:pointer">👤 Nói nội dung trích dẫn</button>';
201
- h+=' </div>';
202
- h+=' </div>';
203
- h+=' <div style="margin-bottom:10px">';
204
- h+=' <label style="color:#888;font-size:11px;display:block;margin-bottom:4px">✏️ Chủ đề trích dẫn (áp dụng cho slide đang chọn):</label>';
205
- h+=' <textarea id="ds-quote-text" placeholder="Nhập nội dung trích dẫn..." style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:13px;resize:vertical;min-height:44px"></textarea>';
206
- h+=' </div>';
207
- h+=' <div style="margin-bottom:10px">';
208
- h+=' <label style="color:#888;font-size:11px;display:block;margin-bottom:4px">👤 Người dẫn lại:</label>';
209
- h+=' <input type="text" id="ds-quote-speaker" placeholder="Tên người/ký kết..." style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px">';
210
- h+=' </div>';
211
- h+=' <div style="margin-bottom:10px">';
212
- h+=' <label style="color:#888;font-size:11px;display-block;margin-bottom:4px">🏷️ Vai trò/chức danh:</label>';
213
- h+=' <input type="text" id="ds-quote-role" placeholder="Ví dụ: HLV, Cầu thủ, Chuyên gia..." style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px">';
214
- h+=' </div>';
215
- h+=' <div style="margin-bottom:8px">';
216
- h+=' <label style="color:#888;font-size:11px;display-block;margin-bottom:4px">🎨 Màu chữ trích dẫn:</label>';
217
- h+=' <div style="display:flex;gap:6px;flex-wrap:wrap" id="ds-quote-colors">';
218
- h+=' <button data-color="#ffd700" style="width:28px;height:28px;border-radius:6px;border:2px solid #5cb87a;background:#ffd700;cursor:pointer"></button>';
219
- h+=' <button data-color="#ffffff" style="width:28px;height:28px;border-radius:6px;border:2px solid #333;background:#ffffff;cursor:pointer"></button>';
220
- h+=' <button data-color="#5cb87a" style="width:28px;height:28px;border-radius:6px;border:2px solid #333;background:#5cb87a;cursor:pointer"></button>';
221
- h+=' <button data-color="#ff6b6b" style="width:28px;height:28px;border-radius:6px;border:2px solid #333;background:#ff6b6b;cursor:pointer"></button>';
222
- h+=' <button data-color="#4dabf7" style="width:28px;height:28px;border-radius:6px;border:2px solid #333;background:#4dabf7;cursor:pointer"></button>';
223
- h+=' </div>';
224
- h+=' </div>';
225
- h+=' <div style="margin-bottom:10px">';
226
- h+=' <label style="color:#888;font-size:11px;display-block;margin-bottom:4px">📍 Vị trí trích dẫn:</label>';
227
- h+=' <select id="ds-quote-pos" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px">';
228
- h+=' <option value="bottom">⬇️ Dưới (mặc định)</option>';
229
- h+=' <option value="middle">⬅️ Giữa</option>';
230
- h+=' <option value="top">⬆️ Trên</option>';
231
- h+=' </select>';
232
- h+=' </div>';
233
- h+=' </div> <!-- /ds-tab-content-quote -->';
234
- h+=' <div id="ds-tab-content-grid" style="display:none">';
235
- h+=' <div style="margin-bottom:10px">';
236
- h+=' <label style="color:#888;font-size:11px;display-block;margin-bottom:6px">📊 Lưới ảnh (tối đa 4 ảnh từ bài):</label>';
237
- h+=' <div id="ds-grid-images" style="display:grid;grid-template-columns:repeat(2,1fr);gap:8px">';
238
- h+=' <!-- Populated dynamically -->';
239
- h+=' </div>';
240
- h+=' </div>';
241
- h+=' <div style="margin-bottom:8px">';
242
- h+=' <label style="color:#888;font-size:11px;display-block;margin-bottom:4px">📍 Vị trí trích dẫn trên lưới:</label>';
243
- h+=' <select id="ds-grid-pos" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px">';
244
- h+=' <option value="bottom">⬇️ Dưới</option>';
245
- h+=' <option value="top">⬆️ Trên</option>';
246
- h+=' </select>';
247
- h+=' </div>';
248
- h+='<div style="margin-top:14px;display:flex;gap:8px;justify-content:center;flex-wrap:wrap"><button id="ds-preview-btn" style="background:#2d8659;border:0;color:#fff;padding:10px 18px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer">👁 Xem trước</button><button id="ds-save-btn" style="background:#1a3a5a;border:1px solid #3a5a7b;color:#fff;padding:10px 18px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer">📤 Lưu & Đăng lên Tường AI ('+p.slides.length+' slide)</button></div>';
249
- h+='<div id="ds-preview-area" style="margin-top:12px;text-align:center;display:none"></div>';
250
- h+='<div id="ds-status" style="color:#888;font-size:11px;margin-top:8px;text-align:center"></div>';
251
- overlay.innerHTML=h; document.body.appendChild(overlay);
252
-
253
- /* button label update fn */
254
- window._dsUpdateSaveLabel = function(){
255
- const n = _dsState.selected.length;
256
- const b = document.getElementById('ds-save-btn');
257
- if(b) b.textContent = '📤 Lưu & Đăng lên Tường AI ('+n+' slide)';
258
- };
259
-
260
- /* ---- tab switching ---- */
261
- window._dsSwitchTab = function(tab){
262
- ['design','quote','grid'].forEach(t=>{
263
- document.getElementById('ds-tab-content-'+t)?.style.setProperty('display', t===tab?'block':'none');
264
- document.getElementById('ds-tab-'+t)?.style.setProperty('background', t===tab?'#2d8659':'#333');
265
- const btn = document.getElementById('ds-tab-'+t);
266
- if(btn) btn.style.setProperty('color', t===tab?'#fff':'#ccc');
267
- });
268
- // Initialize quote/grid content when tab is opened
269
- if(tab==='quote') _dsInitQuoteTab(p);
270
- if(tab==='grid') _dsInitGridTab(p);
271
- };
272
-
273
- /* ---- quote tab init ---- */
274
- window._dsInitQuoteTab = function(p){
275
- // Populate quote text with current slide text
276
- const sel = _dsState.selected;
277
- if(sel.length){
278
- const si = sel[0];
279
- const s = p.slides[si];
280
- const ta = document.querySelector('#ds-texts textarea[data-sidx="'+si+'"]');
281
- const quoteText = document.getElementById('ds-quote-text');
282
- if(quoteText && s.text && !quoteText.value) quoteText.value = s.text;
283
- }
284
- // Wire quote color buttons
285
- const colorBox = document.getElementById('ds-quote-colors');
286
- if(colorBox){
287
- colorBox.addEventListener('click', function(e){
288
- const b = e.target.closest('[data-color]');
289
- if(!b) return;
290
- document.querySelectorAll('#ds-quote-colors [data-color]').forEach(x=>x.style.borderColor='#333');
291
- b.style.borderColor='#fff';
292
- _dsQuoteColor = b.dataset.color;
293
- _dsDebounce(_dsPreview, 150);
294
- });
295
- }
296
- // Wire quote type buttons
297
- ['about','speaker'].forEach(t=>{
298
- const b = document.getElementById('ds-quote-type-'+t);
299
- if(b) b.addEventListener('click', function(){
300
- document.querySelectorAll('#ds-quote-type-about,#ds-quote-type-speaker').forEach(x=>x.style.setProperty('background', '#333'));
301
- b.style.setProperty('background', '#2d8659');
302
- _dsQuoteType = t;
303
- _dsDebounce(_dsPreview, 150);
304
- });
305
- });
306
- // Wire quote text input
307
- const qt = document.getElementById('ds-quote-text');
308
- if(qt && !qt._wired){
309
- qt._wired = true;
310
- qt.addEventListener('input', function(){
311
- _dsQuoteText = this.value;
312
- // Also update the slide text areas
313
- var ta = document.querySelector('#ds-texts textarea[data-sidx="'+_dsState.selected[0]+'"]');
314
- if(ta) ta.value = this.value;
315
- _dsDebounce(_dsPreview, 200);
316
- });
317
- }
318
- // Wire speaker input
319
- const sp = document.getElementById('ds-quote-speaker');
320
- if(sp && !sp._wired){
321
- sp._wired = true;
322
- sp.addEventListener('input', function(){ _dsQuoteSpeaker = this.value; });
323
- }
324
- // Wire role input
325
- const rl = document.getElementById('ds-quote-role');
326
- if(rl && !rl._wired){
327
- rl._wired = true;
328
- rl.addEventListener('input', function(){ _dsQuoteRole = this.value; });
329
- }
330
- // Wire position
331
- const qp = document.getElementById('ds-quote-pos');
332
- if(qp && !qp._wired){
333
- qp._wired = true;
334
- qp.addEventListener('change', function(){ _dsQuotePos = this.value; _dsDebounce(_dsPreview, 150); });
335
- }
336
- };
337
-
338
- /* ---- grid tab init ---- */
339
- window._dsInitGridTab = function(p){
340
- var gridEl = document.getElementById('ds-grid-images');
341
- if(!gridEl) return;
342
- // Show up to 4 images from the post
343
- var imgs = [];
344
- if(p.img) imgs.push(p.img);
345
- if(p.images && p.images.length) imgs.push.apply(imgs, p.images);
346
- if(p.slides && p.slides.length){
347
- p.slides.forEach(function(s){ if(s.image && imgs.indexOf(s.image) === -1) imgs.push(s.image); });
348
- }
349
- imgs = imgs.slice(0, 4);
350
- if(!imgs.length){
351
- gridEl.innerHTML = '<div style="color:#888;font-size:11px;text-align:center;padding:20px">Chưa có ảnh để tạo lưới</div>';
352
- return;
353
- }
354
- var h = '';
355
- imgs.forEach(function(img, i){
356
- h += '<div style="background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;padding:6px;margin-bottom:8px">';
357
- h += '<img src="'+_dsImageSrc(img)+'" style="width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:6px;display:block;margin-bottom:4px" onerror="this.style.display=\'none\'">';
358
- h += '<textarea data-gi="'+i+'" placeholder="Trích dẫn cho ảnh '+(i+1)+'" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:6px;padding:6px;font-size:11px;min-height:44px;resize:vertical;_dsGridQuotes['+i+']=this.value" onchange="_dsGridQuotes['+i+']=this.value" oninput="_dsGridQuotes['+i+']=this.value"></textarea>';
359
- h += '</div>';
360
- });
361
- gridEl.innerHTML = h;
362
- };
363
-
364
- /* ---- slide selection wiring ---- */
365
- overlay.querySelectorAll('.ds-slide-chk').forEach(chk=>{
366
- chk.addEventListener('change', _dsSyncSelection);
367
- });
368
- document.getElementById('ds-sel-all').addEventListener('click', ()=>{ _setAllSelected(true); });
369
- document.getElementById('ds-sel-none').addEventListener('click', ()=>{ _setAllSelected(false); });
370
- document.getElementById('ds-slide-list').addEventListener('click', e=>{
371
- const lbl = e.target.closest('.ds-slide-chip');
372
- if(!lbl) return;
373
- const chk = lbl.querySelector('.ds-slide-chk');
374
- if(e.target === chk) return;
375
- chk.checked = !chk.checked; _dsSyncSelection();
376
- });
377
-
378
- /* ---- inputs wiring ---- */
379
- document.getElementById('ds-preview-btn').addEventListener('click', _dsPreview);
380
- document.getElementById('ds-save-btn').addEventListener('click', ()=>_dsSave(p));
381
- ['ds-texts','ds-ratio','ds-layout','ds-filter','ds-glow','ds-pos'].forEach(id=>{
382
- ['change','input'].forEach(evt=>{
383
- const el=document.getElementById(id); if(el) el.addEventListener(evt, ()=>_dsDebounce(_dsPreview, 150));
384
- });
385
- });
386
- // URL input: as user types a custom image URL, live-preview + apply to selected
387
- const bgUrlInput=document.getElementById('ds-bg-url');
388
- if(bgUrlInput){
389
- bgUrlInput.addEventListener('input', ()=>{
390
- const v=bgUrlInput.value.trim();
391
- if(v && v.length>8){
392
- _dsSetImgMode('custom');
393
- const proxied=_dsImageSrc(v);
394
- _dsAppliedImg = proxied; // Bug 3: applies to current + future selections
395
- const sel=_dsState.selected;
396
- if(sel.length){ sel.forEach(si=>{ _dsImgMap[si]=proxied; }); }
397
- else { _dsImgMap[0]=proxied; }
398
- }
399
- _dsDebounce(_dsPreview,150);
400
- });
401
- }
402
- // Bug 1 fix: pass through the event; _dsHandleBgFile reads evt.target.files
403
- document.getElementById('ds-bg-file').addEventListener('change', _dsHandleBgFile);
404
- ['ds-img-src-custom','ds-img-src-none'].forEach(id=>{
405
- const b=document.getElementById(id);
406
- if(b) b.addEventListener('click', ()=>{
407
- _dsSetImgMode(id==='ds-img-src-custom'?'custom':'none');
408
- });
409
- });
410
- /* "Áp dụng ảnh này cho TẤT CẢ slide đã chọn" — always-visible apply button */
411
- const applyBtn=document.getElementById('ds-apply-img-btn');
412
- if(applyBtn){
413
- applyBtn.addEventListener('click', function(){
414
- let chosen = _dsBgDataUrl || (document.getElementById('ds-bg-url')?.value||'');
415
- if(!chosen){ toast('⚠️ Chọn ảnh (tải lên hoặc nhập URL) trước khi áp dụng'); return; }
416
- _dsSetImgMode('custom');
417
- let proxied = _dsImageSrc(chosen);
418
- _dsAppliedImg = proxied; // Bug 3 fix
419
- dsApplyImgToSelected(proxied);
420
- });
421
- }
422
- // Toggle the "nền trơn" (solid) color + sample-image panel when layout changes
423
- const layoutSel=document.getElementById('ds-layout');
424
- if(layoutSel){
425
- const toggleSolidPanel=()=>{
426
- const wrap=document.getElementById('ds-bg-solid-wrap');
427
- if(wrap) wrap.style.display = layoutSel.value==='solid' ? 'block' : 'none';
428
- };
429
- layoutSel.addEventListener('change', toggleSolidPanel);
430
- layoutSel.addEventListener('input', toggleSolidPanel);
431
- toggleSolidPanel();
432
- }
433
- // Solid background color swatches
434
- const bgColorBox=document.getElementById('ds-bg-colors');
435
- if(bgColorBox){
436
- bgColorBox.addEventListener('click', e=>{
437
- const b=e.target.closest('.ds-bgcolor-btn'); if(!b) return;
438
- document.querySelectorAll('#ds-bg-colors .ds-bgcolor-btn').forEach(x=>x.style.borderColor='#333');
439
- b.style.borderColor='#fff';
440
- _dsBgColor = b.dataset.color;
441
- _dsBgSample = ''; // color takes precedence over sample image
442
- document.querySelectorAll('#ds-bg-samples .ds-bgsample-btn').forEach(x=>x.style.borderColor='#333');
443
- _dsStatus('🎨 Đã chọn màu nền: '+_dsBgColor);
444
- _dsDebounce(_dsPreview, 150);
445
- });
446
- }
447
- // Sample background images (only for solid layout)
448
- const bgSampleBox=document.getElementById('ds-bg-samples');
449
- if(bgSampleBox){
450
- bgSampleBox.addEventListener('click', e=>{
451
- const b=e.target.closest('.ds-bgsample-btn'); if(!b) return;
452
- const bi=parseInt(b.dataset.bi,10);
453
- const src=_DS_BG_SAMPLES[bi];
454
- if(!src) return;
455
- document.querySelectorAll('#ds-bg-samples .ds-bgsample-btn').forEach(x=>x.style.borderColor='#333');
456
- b.style.borderColor='#ffd700';
457
- _dsBgSample = _dsImageSrc(src);
458
- _dsBgColor = ''; // sample image takes precedence over color
459
- document.querySelectorAll('#ds-bg-colors .ds-bgcolor-btn').forEach(x=>x.style.borderColor='#333');
460
- _dsStatus('🖼️ Đã chọn ảnh nền mẫu');
461
- _dsDebounce(_dsPreview, 150);
462
- });
463
- }
464
- _syncImgModeBtns();
465
- document.getElementById('ds-colors').addEventListener('click', e=>{
466
- const b = e.target.closest('.ds-color-btn'); if(!b) return;
467
- overlay.querySelectorAll('.ds-color-btn').forEach(x=>x.style.borderColor='#333');
468
- b.style.borderColor='#fff';
469
- _dsTextColor = b.dataset.color;
470
- _dsDebounce(_dsPreview, 150);
471
- });
472
-
473
- _dsRefreshTexts(p);
474
- _dsUpdateSaveLabel();
475
- _dsPreview();
476
- }
477
-
478
- /* ---------------- selection sync ---------------- */
479
- function _setAllSelected(on){
480
- _dsState.selected = [];
481
- document.querySelectorAll('#ds-slide-list .ds-slide-chk').forEach(chk=>{ chk.checked = on; if(on) _dsState.selected.push(parseInt(chk.dataset.idx)); });
482
- // Bug 3 fix: if an image was already applied at designer level, push it to
483
- // every slide that is (now) selected so "1 ảnh cho nhiều slide" also covers
484
- // slides selected AFTER the image was picked.
485
- if(on && _dsAppliedImg){
486
- _dsState.selected.forEach(si=>{ _dsImgMap[si] = _dsAppliedImg; });
487
- }
488
- const p = _wallPosts[_dsState.postIdx];
489
- if(p) _dsRefreshTexts(p);
490
- _dsUpdateSaveLabel();
491
- _dsDebounce(_dsPreview, 150);
492
- }
493
- function _dsSyncSelection(){
494
- _dsState.selected = [];
495
- document.querySelectorAll('#ds-slide-list .ds-slide-chk:checked').forEach(chk=>{ _dsState.selected.push(parseInt(chk.dataset.idx)); });
496
- // Bug 3 fix: same propagation for checkbox changes.
497
- if(_dsAppliedImg){
498
- _dsState.selected.forEach(si=>{ if(!_dsImgMap[si]) _dsImgMap[si] = _dsAppliedImg; });
499
- }
500
- const p = _wallPosts[_dsState.postIdx];
501
- if(p) _dsRefreshTexts(p);
502
- _dsUpdateSaveLabel();
503
- _dsDebounce(_dsPreview, 150);
504
- }
505
-
506
- /* ---------------- per-slide text areas ---------------- */
507
- function _dsRefreshTexts(p){
508
- const box = document.getElementById('ds-texts'); if(!box) return;
509
- if(!_dsState.selected.length){ box.innerHTML='<div style="color:#888;font-size:11px;padding:6px">Chưa chọn slide nào.</div>'; return; }
510
- let h='<div style="display:flex;flex-direction:column;gap:6px">';
511
- _dsState.selected.forEach((si, k)=>{
512
- const s = p.slides[si];
513
- h+=`<textarea data-sivec="${k}" data-sidx="${si}" style="width:100%;min-height:44px;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:13px;resize:vertical">${esc(s.text||'')}</textarea>`;
514
- });
515
- h+='</div>';
516
- box.innerHTML=h;
517
- box.querySelectorAll('textarea').forEach(ta=>{
518
- ta.addEventListener('input', ()=>{ _dsDebounce(_dsPreview, 200); });
519
- });
520
- }
521
-
522
- /* ---------------- bg file ---------------- */
523
- let _dsBgDataUrl = null;
524
- let _dsTextColor = '#ffffff';
525
- let _dsImgMode = 'custom'; // 'custom' | 'none' ('rewrite' removed)
526
- /* Bug 1 fix: this is bound as a 'change' listener, so the first arg is the
527
- Event. Read the <input> from evt.target.files (previously input.files was
528
- undefined, so the upload silently did nothing and the preview/apply button
529
- never saw the image). */
530
- function _dsHandleBgFile(evt){
531
- const input = evt && evt.target ? evt.target : evt;
532
- const file = (input && input.files && input.files[0]) ? input.files[0] : null;
533
- if(!file) return;
534
- _dsSetImgMode('custom'); // show the custom-img URL/upload panel
535
- const reader=new FileReader();
536
- reader.onload=function(e){
537
- _dsBgDataUrl=e.target.result;
538
- _dsImgMode='custom';
539
- _syncImgModeBtns();
540
- _dsStatus('✅ Đã chọn ảnh: '+(file.name||'').slice(0,30) + (file.size? ' ('+Math.round(file.size/1024)+'KB)':''));
541
- // Auto-apply uploaded image to ALL currently selected slides so the upload
542
- // has an immediate, visible effect (preview + save will use it for every slide).
543
- _dsAppliedImg = _dsBgDataUrl; // Bug 3 fix: applies to future selections too
544
- const sel=_dsState.selected;
545
- if(sel.length){ sel.forEach(si=>{ _dsImgMap[si]=_dsBgDataUrl; }); }
546
- else { _dsImgMap[0]=_dsBgDataUrl; }
547
- _dsDebounce(_dsPreview,150);
548
- };
549
- reader.readAsDataURL(file);
550
- }
551
- /* Show a transient status line in the designer footer. */
552
- function _dsStatus(msg){
553
- const s=document.getElementById('ds-status'); if(s) s.textContent=msg;
554
- }
555
- function _syncImgModeBtns(){
556
- ['ds-img-src-custom','ds-img-src-none'].forEach(id=>{
557
- const b=document.getElementById(id);
558
- if(!b) return;
559
- const on = (id==='ds-img-src-custom' && _dsImgMode==='custom') ||
560
- (id==='ds-img-src-none' && _dsImgMode==='none');
561
- b.style.background = on ? '#2d8659' : '#333';
562
- b.style.color = on ? '#fff' : '#ccc';
563
- });
564
- const wrap=document.getElementById('ds-custom-img-url-wrap');
565
- if(wrap) wrap.style.display = _dsImgMode==='custom' ? 'block' : 'none';
566
- }
567
- function _dsSetImgMode(m){
568
- _dsImgMode = m;
569
- _syncImgModeBtns();
570
- _dsDebounce(_dsPreview, 150);
571
- }
572
-
573
- /* Apply a chosen image src to ALL selected slides (or the current preview slide). */
574
- function dsApplyImgToSelected(imgSrc){
575
- _dsAppliedImg = imgSrc; // Bug 3 fix: remember at designer level (future selections too)
576
- const sel = _dsState.selected;
577
- if(sel.length){
578
- sel.forEach(si=>{ _dsImgMap[si] = imgSrc; });
579
- }else{
580
- _dsImgMap[0] = imgSrc;
581
- }
582
- _dsImgMode = 'custom'; // visually marks we are using a chosen image
583
- _syncImgModeBtns();
584
- _dsStatus('✅ Đã áp dụng ảnh cho '+(sel.length? sel.length+' slide đã chọn':'slide hiện tại'));
585
- // refresh preview immediately (selected slide 0 is what we preview)
586
- const p=_wallPosts[_dsState.postIdx];
587
- if(p && sel.length){ _dsOptions(p, sel[0]); }
588
- _dsPreview();
589
- }
590
-
591
- function _dsDebounce(fn,d){ clearTimeout(fn._t); fn._t=setTimeout(fn,d); }
592
-
593
- /* ---------------- get design options ---------------- */
594
- function _dsOptions(p, si){
595
- const s = p.slides[si];
596
- const ta = document.querySelector(`#ds-texts textarea[data-sidx="${si}"]`);
597
- const text = ta ? ta.value : (s.text||'');
598
- const bgInput = document.getElementById('ds-bg-url')?.value||'';
599
- // Read quote text from textarea if available (Style 1: Trích dẫn)
600
- var quoteTextVal = _dsQuoteText;
601
- var quoteEl = document.getElementById('ds-quote-text');
602
- if(quoteEl && quoteEl.value) quoteTextVal = quoteEl.value;
603
- var speakerEl = document.getElementById('ds-quote-speaker');
604
- if(speakerEl) _dsQuoteSpeaker = speakerEl.value || _dsQuoteSpeaker;
605
- var roleEl = document.getElementById('ds-quote-role');
606
- if(roleEl) _dsQuoteRole = roleEl.value || _dsQuoteRole;
607
- var posEl = document.getElementById('ds-quote-pos');
608
- if(posEl) _dsQuotePos = posEl.value || _dsQuotePos;
609
- // Per-slide image override: when user picks an image from the rewrite list,
610
- // it is stored in _dsImgMap[si] and applied to that specific slide.
611
- let bgUrl = '';
612
- // Solid layout + user picked a SAMPLE background image -> use it as the
613
- // background photo (dark overlay for readability) instead of a flat color.
614
- const isSolid = designerGetLayoutV2()==='solid';
615
- const useSample = isSolid && _dsBgSample && !_dsAppliedImg;
616
- if(useSample) bgUrl = _dsBgSample;
617
- // Bug 3 fix: designer-level applied image wins for every selected slide.
618
- // Applied when the user picked an image (rewrite picker / upload / URL /
619
- // "Áp dụng" button) — even for slides selected AFTER the image was picked.
620
- if(!bgUrl && _dsAppliedImg) bgUrl = _dsAppliedImg;
621
- if(!bgUrl && _dsImgMap[si]) bgUrl = _dsImgMap[si];
622
- if(!bgUrl){
623
- if(_dsImgMode === 'custom'){
624
- bgUrl = _dsBgDataUrl || bgInput || '';
625
- } // 'none' -> '' ('rewrite' option removed: no auto fallback to wire image)
626
- }
627
- return {
628
- si, s, text, bgUrl,
629
- ratio: designerGetRatioV2(),
630
- layout: isSolid ? 'solid' : designerGetLayoutV2(),
631
- filter: designerGetFilterV2(),
632
- glow: designerGetGlowV2(),
633
- pos: designerGetPosV2(),
634
- color: _dsTextColor,
635
- bgColor: _dsBgColor, // solid-layout background color ('' = default dark)
636
- bgSample: useSample ? _dsBgSample : '', // 'sample' mode: img passed to draw
637
- // Quote design mode (Style 1: Trích dẫn)
638
- quoteMode: _dsQuoteType === 'about' ? 'quote' : 'speaker_quote',
639
- quoteText: quoteTextVal || '',
640
- quoteColor: _dsQuoteColor || '#ffd700',
641
- quoteSpeaker: _dsQuoteSpeaker || '',
642
- quoteRole: _dsQuoteRole || '',
643
- quotePos: _dsQuotePos || 'bottom',
644
- };
645
- }
646
-
647
- /* ---------------- drawing core ---------------- */
648
- function _dsImageSrc(url){
649
- if(!url) return '';
650
- if(url.startsWith('/api/') || url.startsWith('data:')) return url;
651
- return '/api/proxy/img?url='+encodeURIComponent(url);
652
- }
653
- /* Only tag crossOrigin=anonymous for proxied EXTERNAL images.
654
- Setting it on data: URLs or local /api/ paths can cause silent onload
655
- failures (black preview) in some browser/CORS configurations. */
656
- function _dsMakeImg(url){
657
- const img=new Image();
658
- if(url && !url.startsWith('data:') && !url.startsWith('/api/')) img.crossOrigin='anonymous';
659
- return img;
660
- }
661
-
662
- /* Sample background images for the "Nền đều (solid)" layout — abstract
663
- gradients that look good with white text. Served through the /api/proxy/img
664
- endpoint (same-origin, no crossOrigin needed — _dsMakeImg handles that).
665
- Keyed for the modal background swatches and for _dsDrawLayout. */
666
- const _DS_BG_SAMPLES = [
667
- 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=900&q=60', // sơn hùng vĩ (núi)
668
- 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=900&q=60', // sương mù rừng
669
- 'https://images.unsplash.com/photo-1462331940025-496dfbfc7564?w=900&q=60', // vũ trụ
670
- 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=900&q=60', // núi rừng
671
- 'https://images.unsplash.com/photo-1499002238440-d264edd596ec?w=900&q=60', // hoàng hôn
672
- 'https://images.unsplash.com/photo-1448375240586-882707db888b?w=900&q=60', // rừng tối
673
- 'https://images.unsplash.com/photo-1519681393784-d120267933ba?w=900&q=60', // trời sao
674
- 'https://images.unsplash.com/photo-1518173946687-a4c8892bbd9f?w=900&q=60', // biển đêm
675
- 'https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?w=900&q=60', // đồng cỏ
676
- ];
677
-
678
- /* draw bg image with cover-fit + color filter */
679
- function _dsDrawBg(ctx, W, H, bgUrl, filter, blur){
680
- ctx.save();
681
- ctx.fillStyle='#111'; ctx.fillRect(0,0,W,H);
682
- // filter string
683
- let f='';
684
- if(filter==='grayscale') f='grayscale(1)';
685
- else if(filter==='sepia') f='sepia(0.85)';
686
- else if(filter==='saturate') f='saturate(2.4)';
687
- else if(filter==='warm') f='sepia(0.45) saturate(1.5) hue-rotate(-15deg)';
688
- else if(filter==='cool') f='saturate(1.2) hue-rotate(15deg) brightness(1.05)';
689
- else if(filter==='invert') f='invert(1)';
690
- else if(filter==='noir') f='grayscale(1) contrast(1.6) brightness(0.9)';
691
- if(blur) f=(f?f+' ':'')+'blur('+blur+'px)';
692
- ctx.filter = f;
693
- if(bgUrl){
694
- const img = new Image(); img.crossOrigin='anonymous';
695
- // Note: async path handled by caller via onload; this fn is synchronous draw of already-loaded img
696
- }
697
- ctx.restore();
698
- }
699
-
700
- /* ---------------- layout effects (with loaded bg image) ---------------- */
701
- function _dsDrawLayout(ctx, W, H, opts, img){
702
- const { layout, filter, bgUrl, bgColor } = opts;
703
- // base: solid layout can use the chosen background color (no image needed)
704
- let baseColor = '#141414';
705
- if(layout==='solid' && !img && bgColor) baseColor = bgColor;
706
- ctx.fillStyle = baseColor; ctx.fillRect(0,0,W,H);
707
- // draw bg image cover-fit if present & loaded
708
- if(img){
709
- const blur = layout==='blur' ? Math.max(6, Math.round(W/90)) : 0;
710
- ctx.save();
711
- let f='';
712
- if(filter==='grayscale') f='grayscale(1)';
713
- else if(filter==='sepia') f='sepia(0.85)';
714
- else if(filter==='saturate') f='saturate(2.4)';
715
- else if(filter==='warm') f='sepia(0.45) saturate(1.5) hue-rotate(-15deg)';
716
- else if(filter==='cool') f='saturate(1.2) hue-rotate(15deg) brightness(1.05)';
717
- else if(filter==='invert') f='invert(1)';
718
- else if(filter==='noir') f='grayscale(1) contrast(1.6) brightness(0.9)';
719
- if(blur) f=(f?f+' ':'')+'blur('+blur+'px)';
720
- ctx.filter = f;
721
- const iw=img.naturalWidth||1, ih=img.naturalHeight||1;
722
- const scale=Math.max(W/iw, H/ih);
723
- const dw=iw*scale, dh=ih*scale;
724
- const dx=(W-dw)/2, dy=(H-dh)/2;
725
- ctx.drawImage(img,0,0,iw,ih,dx,dy,dw,dh);
726
- ctx.filter='none';
727
- ctx.restore();
728
- }
729
- // overlay per layout
730
- if(layout==='gradient'){
731
- const g=ctx.createLinearGradient(0,0,W,H);
732
- g.addColorStop(0,'rgba(50,20,90,0.75)'); g.addColorStop(0.5,'rgba(20,60,120,0.6)'); g.addColorStop(1,'rgba(120,30,60,0.75)');
733
- ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
734
- }
735
- else if(layout==='vignette'){
736
- const g=ctx.createRadialGradient(W/2,H/2,0,W/2,H/2,Math.max(W,H)*0.62);
737
- g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.78)');
738
- ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
739
- }
740
- else if(layout==='split'){
741
- const g=ctx.createLinearGradient(0,0,0,H);
742
- g.addColorStop(0,'rgba(0,0,0,0.85)'); g.addColorStop(0.5,'rgba(0,0,0,0.15)'); g.addColorStop(1,'rgba(0,0,0,0.85)');
743
- ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
744
- }
745
- else if(layout==='spotlight'){
746
- ctx.fillStyle='rgba(0,0,0,0.72)'; ctx.fillRect(0,0,W,H);
747
- const cx=W/2, cy=H*0.45, r=Math.max(W,H)*0.4;
748
- const g=ctx.createRadialGradient(cx,cy,0,cx,cy,r);
749
- g.addColorStop(0,'rgba(255,255,255,0.45)'); g.addColorStop(0.6,'rgba(255,255,255,0.12)'); g.addColorStop(1,'rgba(0,0,0,0.9)');
750
- ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
751
- }
752
- else if(layout==='diagonal'){
753
- ctx.fillStyle='rgba(0,0,0,0.55)'; ctx.fillRect(0,0,W,H);
754
- ctx.fillStyle='rgba(0,0,0,0.8)';
755
- ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(W*0.9,0); ctx.lineTo(0,H*0.9); ctx.closePath(); ctx.fill();
756
- }
757
- else if(layout==='neon'){
758
- ctx.fillStyle='rgba(10,10,30,0.85)'; ctx.fillRect(0,0,W,H);
759
- ctx.strokeStyle='rgba(80,220,255,0.5)'; ctx.lineWidth=Math.max(2,W/300);
760
- ctx.strokeRect(W*0.03,H*0.03,W*0.94,H*0.94);
761
- }
762
- else if(layout==='top-banner'){
763
- ctx.fillStyle='rgba(0,0,0,0)';
764
- const g=ctx.createLinearGradient(0,0,0,H*0.5);
765
- g.addColorStop(0,'rgba(0,0,0,0.8)'); g.addColorStop(1,'rgba(0,0,0,0)');
766
- ctx.fillStyle=g; ctx.fillRect(0,0,W,H*0.5);
767
- }
768
- else if(layout==='lower-third'){
769
- const g=ctx.createLinearGradient(0,H*0.55,0,H);
770
- g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.82)');
771
- ctx.fillStyle=g; ctx.fillRect(0,H*0.55,W,H*0.45);
772
- }
773
- else if(layout==='card'){
774
- ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.fillRect(0,0,W,H);
775
- // rounded card
776
- const pad=Math.round(W*0.04);
777
- ctx.fillStyle='rgba(0,0,0,0.65)';
778
- _dsRoundRect(ctx, pad, H*0.16, W-2*pad, H*0.68, Math.round(W*0.05));
779
- ctx.fill();
780
- ctx.strokeStyle='rgba(255,255,255,0.25)'; ctx.lineWidth=Math.max(2,W/400); _dsRoundRect(ctx,pad,H*0.16,W-2*pad,H*0.68,Math.round(W*0.05)); ctx.stroke();
781
- }
782
- else if(layout==='corner'){
783
- ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.fillRect(0,0,W,H);
784
- ctx.strokeStyle='rgba(255,215,0,0.9)'; ctx.lineWidth=Math.max(4,W/160);
785
- const L=Math.round(W*0.18), c=Math.round(W*0.04);
786
- [[0,c],[c,0],[W,c],[W-c,0],[0,H-c],[c,H],[W,H-c],[W-c,H]].forEach(([x,y],i)=>{
787
- const top = y===0 || y===c; const left = x===0 || x===c;
788
- ctx.beginPath();
789
- if(i<4){ // top corners
790
- if(top && left){ ctx.moveTo(x,y+L); ctx.lineTo(x,y); ctx.lineTo(x+L,y); }
791
- else if(top){ ctx.moveTo(x,y+L); ctx.lineTo(x,y); ctx.lineTo(x-L,y); }
792
- else { ctx.moveTo(x-L,y); ctx.lineTo(x,y); ctx.lineTo(x,y+L); }
793
- } else { // bottom corners
794
- if(left){ ctx.moveTo(x,y-L); ctx.lineTo(x,y); ctx.lineTo(x+L,y); }
795
- else { ctx.moveTo(x-L,y); ctx.lineTo(x,y); ctx.lineTo(x,y-L); }
796
- }
797
- ctx.stroke();
798
- });
799
- }
800
- else if(layout==='stars'){
801
- ctx.fillStyle='rgba(8,8,30,0.82)'; ctx.fillRect(0,0,W,H);
802
- const n=Math.round(W*H/18000);
803
- for(let i=0;i<n;i++){
804
- const x=Math.random()*W, y=Math.random()*H, r=Math.random()*Math.max(1,W/300)+0.5;
805
- ctx.fillStyle=`rgba(255,255,255,${0.4+Math.random()*0.6})`;
806
- ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill();
807
- }
808
- }
809
- else { // solid
810
- if(img && bgUrl){
811
- // sample background photo (or user image) -> dark overlay so text is readable
812
- ctx.fillStyle='rgba(0,0,0,0.45)'; ctx.fillRect(0,0,W,H);
813
- } else if(bgColor){
814
- // chosen solid color with a subtle gradient + soft vignette for depth
815
- const g=ctx.createLinearGradient(0,0,W,H);
816
- g.addColorStop(0, bgColor);
817
- g.addColorStop(1, _dsShadeColor(bgColor, -0.25));
818
- ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
819
- const vg=ctx.createRadialGradient(W/2,H/2,0,W/2,H/2,Math.max(W,H)*0.66);
820
- vg.addColorStop(0,'rgba(0,0,0,0)'); vg.addColorStop(1,'rgba(0,0,0,0.42)');
821
- ctx.fillStyle=vg; ctx.fillRect(0,0,W,H);
822
- } else {
823
- ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(0,0,W,H);
824
- }
825
- }
826
- }
827
-
828
- /* Darken (n<0) or lighten (n>0) a hex color by n*100% (for solid bg gradient). */
829
- function _dsShadeColor(hex, n){
830
- try{
831
- hex=String(hex||'').replace('#','');
832
- if(hex.length===3) hex=hex.split('').map(c=>c+c).join('');
833
- if(hex.length!==6) return hex;
834
- const num=parseInt(hex,16);
835
- let r=(num>>16)&255, g=(num>>8)&255, b=num&255;
836
- const t = n<0 ? 0 : 255;
837
- const p = Math.abs(n);
838
- r=Math.round((t-r)*p)+r; g=Math.round((t-g)*p)+g; b=Math.round((t-b)*p)+b;
839
- return '#'+((1<<24)+(r<<16)+(g<<8)+b).toString(16).slice(1);
840
- }catch(e){ return hex; }
841
- }
842
-
843
- function _dsRoundRect(ctx,x,y,w,h,r){
844
- ctx.beginPath();
845
- ctx.moveTo(x+r,y);
846
- ctx.arcTo(x+w,y,x+w,y+h,r);
847
- ctx.arcTo(x+w,y+h,x,y+h,r);
848
- ctx.arcTo(x,y+h,x,y,r);
849
- ctx.arcTo(x,y,x+w,y,r);
850
- ctx.closePath();
851
- }
852
-
853
- /* ---------------- text drawing ---------------- */
854
- function _dsDrawText(ctx, W, H, opts, baseFontScale){
855
- const { text, pos, glow, color, bgColor: bgCol, layout } = opts;
856
- if(!text || !text.trim()) return;
857
- const base = Math.max(28, Math.round(W*baseFontScale));
858
- const fs = Math.min(base, Math.round(W*0.09));
859
-
860
- // FIX: Use Noto Sans CJK for Vietnamese support, ensure proper font stack
861
- ctx.font = 'bold '+fs+'px "Noto Sans","Noto Sans CJK","Segoe UI","Arial",sans-serif';
862
- ctx.textAlign='center';
863
-
864
- // split lines - handle Vietnamese text properly
865
- const lines=[]; let line='';
866
- const maxW=Math.round(W*0.82);
867
- text.split(' ').forEach(w=>{
868
- const test = line? line+' '+w : w;
869
- if(ctx.measureText(test).width>maxW && line){ lines.push(line.trim()); line=w; } else { line=test; }
870
- });
871
- if(line) lines.push(line.trim());
872
- const lh=Math.round(fs*1.25);
873
- const blockH=lines.length*lh;
874
- let y;
875
- if(pos==='top') y=Math.round(H*0.14)+lh/2;
876
- else if(pos==='bottom') y=Math.round(H*0.88)-blockH+lh/2;
877
- else y=Math.round(H/2)-blockH/2+lh/2;
878
-
879
- // FIX: Set fillStyle BEFORE shadow effects for proper text rendering
880
- ctx.fillStyle = color;
881
-
882
- // FIX: Determine stroke color based on text color for legibility
883
- // If text is dark, use light stroke; if text is light, use dark stroke
884
- const textColorBrightness = _dsGetBrightness(color);
885
- const strokeColor = textColorBrightness > 128 ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.8)';
886
-
887
- // glow effects - apply AFTER fillStyle is set
888
- const glowEnabled = glow === 'white' || glow === 'color' || glow === 'neon';
889
- if(glowEnabled){
890
- if(glow==='white'){ ctx.shadowColor='rgba(255,255,255,0.95)'; ctx.shadowBlur=Math.max(8,fs*0.5); }
891
- else if(glow==='color'){ ctx.shadowColor=color; ctx.shadowBlur=Math.max(10,fs*0.6); }
892
- else if(glow==='neon'){ ctx.shadowColor='#00ffff'; ctx.shadowBlur=Math.max(16,fs*0.9); }
893
- }
894
-
895
- // FIX: Always apply stroke for each line individually (not just top/bottom)
896
- // This ensures text is legible on any background
897
- const shouldStroke = glowEnabled ? true : (pos==='top' || pos==='bottom' || !textColorBrightness);
898
- if(shouldStroke){
899
- ctx.strokeStyle = strokeColor;
900
- ctx.lineWidth = Math.max(3, fs*0.15); // FIX: thicker stroke for better visibility
901
- }
902
-
903
- lines.forEach((l,li)=>{
904
- // FIX: Stroke first, then fill for each line individually
905
- if(shouldStroke){ ctx.strokeText(l, W/2, y+li*lh); }
906
- ctx.fillText(l, W/2, y+li*lh);
907
- });
908
-
909
- // Reset shadow after drawing
910
- if(glowEnabled){ ctx.shadowBlur=0; }
911
- }
912
-
913
- /* Draw quote text with speaker attribution (Style 1: Trích dẫn) */
914
- function _dsDrawQuoteText(ctx, W, H, opts){
915
- const { quoteText, quoteColor, quoteSpeaker, quoteRole, quotePos } = opts;
916
- if(!quoteText || !quoteText.trim()) return;
917
-
918
- const qfs = Math.min(Math.round(W*0.085), Math.max(32, Math.round(W*0.045)));
919
- ctx.font = 'bold '+qfs+'px "Noto Sans","Noto Sans CJK","Segoe UI","Arial",sans-serif';
920
- ctx.textAlign='center';
921
-
922
- // Quote text with stroke for legibility
923
- const qBrightness = _dsGetBrightness(quoteColor);
924
- const qStrokeColor = qBrightness > 128 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.7)';
925
-
926
- // Split quote text into lines
927
- const lines=[]; let line='';
928
- const maxW=Math.round(W*0.80);
929
- quoteText.split(' ').forEach(w=>{
930
- const test = line? line+' '+w : w;
931
- if(ctx.measureText(test).width>maxW && line){ lines.push(line.trim()); line=w; } else { line=test; }
932
- });
933
- if(line) lines.push(line.trim());
934
-
935
- const lh=Math.round(qfs*1.3);
936
- const blockH=lines.length*lh;
937
- let qy;
938
- if(quotePos==='top') qy=Math.round(H*0.14)+lh/2;
939
- else if(quotePos==='bottom') qy=Math.round(H*0.92)-blockH+lh/2;
940
- else qy=Math.round(H/2)-blockH/2+lh/2;
941
-
942
- // Draw quote text
943
- ctx.fillStyle = quoteColor;
944
- ctx.strokeStyle = qStrokeColor;
945
- ctx.lineWidth = Math.max(4, qfs*0.18);
946
- ctx.shadowColor = 'rgba(0,0,0,0.6)';
947
- ctx.shadowBlur = Math.max(6, qfs*0.4);
948
-
949
- lines.forEach((l,li)=>{
950
- ctx.strokeText(l, W/2, qy+li*lh);
951
- ctx.fillText(l, W/2, qy+li*lh);
952
- });
953
- ctx.shadowBlur = 0;
954
-
955
- // Draw speaker and role (if provided)
956
- if(quoteSpeaker){
957
- const sfs = Math.round(qfs*0.55);
958
- ctx.font = 'bold '+sfs+'px "Noto Sans","Noto Sans CJK","Segoe UI","Arial",sans-serif';
959
- ctx.textAlign='right';
960
- ctx.shadowColor = 'rgba(0,0,0,0.7)';
961
- ctx.shadowBlur = 4;
962
-
963
- // Position speaker below quote text
964
- let sy = qy + blockH + Math.round(qfs*0.6);
965
-
966
- // Trống (em dash) prefix for speaker
967
- const speakerText = '— ' + quoteSpeaker;
968
- // Measure and cap width
969
- let speakerDisplay = speakerText;
970
- if(ctx.measureText(speakerText).width > maxW){
971
- // Truncate
972
- while(speakerDisplay.length > 5 && ctx.measureText(speakerDisplay + '...').width > maxW*0.9){
973
- speakerDisplay = speakerDisplay.slice(0, -1);
974
- }
975
- speakerDisplay += '...';
976
- }
977
-
978
- ctx.fillStyle = '#fff';
979
- ctx.strokeStyle = 'rgba(0,0,0,0.85)';
980
- ctx.lineWidth = 2;
981
- ctx.strokeText(speakerDisplay, W - Math.round(W*0.04), sy);
982
- ctx.fillText(speakerDisplay, W - Math.round(W*0.04), sy);
983
-
984
- // Role text below speaker
985
- if(quoteRole){
986
- const rfs = Math.round(sfs*0.85);
987
- ctx.font = 'normal '+rfs+'px "Noto Sans","Noto Sans CJK","Segoe UI","Arial",sans-serif';
988
- const roleText = quoteRole;
989
- let roleDisplay = roleText;
990
- if(ctx.measureText(roleText).width > maxW*0.9){
991
- while(roleDisplay.length > 5 && ctx.measureText(roleDisplay + '...').width > maxW*0.8){
992
- roleDisplay = roleDisplay.slice(0, -1);
993
- }
994
- roleDisplay += '...';
995
- }
996
- ctx.fillStyle = '#aaa';
997
- ctx.fillText(roleDisplay, W - Math.round(W*0.04), sy + Math.round(rfs*1.4));
998
- }
999
-
1000
- ctx.shadowBlur = 0;
1001
- }
1002
- }
1003
-
1004
- /* Calculate perceived brightness of a hex color (0-255) */
1005
- function _dsGetBrightness(hex){
1006
- try{
1007
- hex=String(hex||'').replace('#','');
1008
- if(hex.length===3) hex=hex.split('').map(c=>c+c).join('');
1009
- if(hex.length!==6) return 128;
1010
- const r=parseInt(hex.substr(0,2),16), g=parseInt(hex.substr(2,2),16), b=parseInt(hex.substr(4,2),16);
1011
- return (r*299 + g*587 + b*114) / 1000;
1012
- }catch(e){ return 128; }
1013
- }
1014
-
1015
- function textLine(t){ return t; }
1016
-
1017
- /* ---------------- compute final canvas size for a design ---------------- */
1018
- function _dsFinalSize(opts){
1019
- const r=opts.ratio;
1020
- const w = (r.w>=r.h) ? 1800 : 1350;
1021
- const h = Math.round(w*r.h/r.w);
1022
- return { w, h };
1023
- }
1024
-
1025
- /* Shared renderer (Bug 4 fix): renders a slide onto `canvas` using the SAME
1026
- code path and SAME resolution for BOTH preview and final save, so the
1027
- published image is pixel-identical to what the user saw in the preview —
1028
- same cover-fit, same filter, same text placement, same background. */
1029
- function _dsRenderSlide(canvas, opts, done){
1030
- const ctx = canvas.getContext('2d');
1031
- const bgUrl = _dsImageSrc(opts.bgUrl);
1032
- function draw(img){
1033
- _dsDrawLayout(ctx, canvas.width, canvas.height, opts, img||null);
1034
- // FIX: Only draw quote text when in quote mode
1035
- if(opts.quoteText && opts.quoteText.trim()){
1036
- _dsDrawQuoteText(ctx, canvas.width, canvas.height, opts);
1037
- } else {
1038
- // Normal text drawing
1039
- _dsDrawText(ctx, canvas.width, canvas.height, opts, 0.052);
1040
- }
1041
- if(done) done(img||null);
1042
- }
1043
- if(bgUrl){
1044
- const img=_dsMakeImg(bgUrl);
1045
- img.onload=function(){ draw(img); };
1046
- img.onerror=function(){ console.warn('[designer] bg image failed to load:', bgUrl); draw(null); };
1047
- img.src=bgUrl;
1048
- } else {
1049
- draw(null);
1050
- }
1051
- }
1052
-
1053
- /* ---------------- preview ---------------- */
1054
- function _dsPreview(){
1055
- const p=_wallPosts[_dsState.postIdx]; if(!p) return;
1056
- const area=document.getElementById('ds-preview-area'); if(!area) return;
1057
- const sel=_dsState.selected;
1058
- if(!sel.length){ area.style.display='block'; area.innerHTML='<div style="color:#888;font-size:12px">Chưa chọn slide nào để xem trước.</div>'; return; }
1059
- area.style.display='block';
1060
- area.innerHTML='<div style="color:#888;font-size:12px">⏳ Đang tạo xem trước...</div>';
1061
- // display-scale: cap visible width at 300px, keep aspect ratio per slide
1062
- const dispW=Math.min(300, Math.floor((Math.max(document.documentElement.clientWidth||540, document.body.clientWidth||540))*0.5));
1063
- const cans=[];
1064
- sel.forEach((si, k)=>{
1065
- const opts=_dsOptions(p, si);
1066
- const { w, h }=_dsFinalSize(opts);
1067
- const canvas=document.createElement('canvas');
1068
- canvas.width=w; canvas.height=h;
1069
- canvas.style.width=dispW+'px'; canvas.style.height='auto';
1070
- canvas.style.borderRadius='12px'; canvas.style.boxShadow='0 8px 24px rgba(0,0,0,.5)'; canvas.style.display='block'; canvas.style.margin='0 auto 14px auto';
1071
- cans.push({opts, canvas});
1072
- });
1073
- let remaining=cans.length;
1074
- function allDone(){
1075
- if(--remaining>0) return;
1076
- let out='';
1077
- if(cans.length>1) out+='<div style="color:#888;font-size:10px;margin-bottom:8px">Xem trước '+(cans.length)+' slide đã chọn (cuộn xuống để xem hết):</div>';
1078
- area.innerHTML=out;
1079
- cans.forEach((c,k)=>{
1080
- if(k>0) area.appendChild(document.createElement('br'));
1081
- area.appendChild(c.canvas);
1082
- });
1083
- }
1084
- cans.forEach(c=>{ _dsRenderSlide(c.canvas, c.opts, allDone); });
1085
- }
1086
-
1087
- /* ---------------- save: render + upload each selected slide, then post ---------------- */
1088
- async function _dsSave(p){
1089
- const statusEl=document.getElementById('ds-status'); if(statusEl) statusEl.textContent='⏳ Đang tạo ảnh các slide...';
1090
- const sel=_dsState.selected;
1091
- if(!sel.length){ if(statusEl) statusEl.textContent='⚠️ Chưa chọn slide nào để đăng.'; return; }
1092
- const saveBtn=document.getElementById('ds-save-btn'); if(saveBtn){ saveBtn.disabled=true; saveBtn.textContent='⏳ Đang xử lý...'; }
1093
-
1094
- const uploaded = []; // {si, url}
1095
- try{
1096
- // render each selected slide at final resolution
1097
- for(let k=0;k<sel.length;k++){
1098
- const si=sel[k];
1099
- if(statusEl) statusEl.textContent=`⏳ Đang tạo ảnh slide ${k+1}/${sel.length}... (${p.slides.length} slide trong bài)`;
1100
- const opts=_dsOptions(p, si);
1101
- const { w: FINAL_W, h: FINAL_H }=_dsFinalSize(opts);
1102
- const canvas=document.createElement('canvas'); canvas.width=FINAL_W; canvas.height=FINAL_H;
1103
- // Shared render path (Bug 4 fix) — identical to preview.
1104
- const url = await new Promise((resolve)=>{
1105
- _dsRenderSlide(canvas, opts, function(){
1106
- // Nén JPEG thay PNG: dung lượng rất nhỏ (thường 5-10x nhỏ hơn),
1107
- // ảnh tải nhanh hơn nhiều trên Tường AI. Chất lượng 0.82 giữ chữ sắc nét.
1108
- canvas.toBlob(async function(blob){
1109
- const fd=new FormData();
1110
- fd.append('file', blob, 'slide_'+(si+1)+'.jpg');
1111
- fd.append('post_id', p.id||'');
1112
- try{
1113
- const r=await fetch('/api/wall/img',{method:'POST',body:fd});
1114
- const j=await r.json();
1115
- if(!r.ok || j.error) throw new Error(j.error||'Lỗi upload');
1116
- resolve(j.url);
1117
- }catch(e){ resolve({error:e.message}); }
1118
- },'image/jpeg', 0.82);
1119
- });
1120
- });
1121
- if(url && url.error) throw new Error(url.error);
1122
- uploaded.push({si, url});
1123
- // update slide image live
1124
- if(p.slides[si]) p.slides[si].image = url;
1125
- }
1126
-
1127
- if(statusEl) statusEl.textContent='⏳ Đang đăng lên Tường AI...';
1128
-
1129
- // build updated slides array (keep unselected unchanged)
1130
- const newSlides = p.slides.map((s,i)=>{
1131
- const up = uploaded.find(u=>u.si===i);
1132
- // ensure text edits are applied too
1133
- const ta = document.querySelector(`#ds-texts textarea[data-sidx="${i}"]`);
1134
- return {
1135
- text: ta ? ta.value : (s.text||''),
1136
- image: up ? up.url : (s.image||''),
1137
- index: s.index || (i+1),
1138
- };
1139
- });
1140
-
1141
- // pick selected slides only -> the wall post is a SLIDE-IMAGE post of selected slides
1142
- const selSlides = newSlides.filter((_,i)=> sel.includes(i));
1143
-
1144
- const post = {
1145
- id: p.id,
1146
- title: p.title||'',
1147
- text: selSlides.map(s=>'• '+s.text).join('\n\n'),
1148
- img: selSlides[0] ? selSlides[0].image : p.img||'',
1149
- url: p.url||'',
1150
- slides: selSlides, // <-- bài đăng bao gồm chỉ các slide đã chọn
1151
- images: selSlides.map(s=>s.image),
1152
- kind: p.kind||'slide_summary',
1153
- video: p.video||'',
1154
- voice: p.voice||'',
1155
- emotion: p.emotion||'',
1156
- language: p.language||'',
1157
- ts: p.ts||Math.floor(Date.now()/1000),
1158
- };
1159
-
1160
- const wr=await fetch('/api/wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(post)});
1161
- const wj=await wr.json();
1162
- if(wr.ok && wj.post){
1163
- prependWallPost(wj.post);
1164
- toast('✅ Đã đăng bài slide lên Tường AI ('+selSlides.length+' slide)!');
1165
- } else {
1166
- toast('✅ Đã lưu ảnh slide!');
1167
- }
1168
- document.getElementById('slide-designer-overlay').remove();
1169
- }catch(e){
1170
- if(statusEl) statusEl.textContent='❌ '+e.message;
1171
- toast('❌ '+e.message);
1172
- if(saveBtn){ saveBtn.disabled=false; saveBtn.textContent='📤 Lưu & Đăng lên Tường AI ('+sel.length+' slide)'; }
1173
- }
1174
- }
1175
-
1176
- /* keep old helper names harmless for any other caller */
1177
- function updateDesignerText(){}
1178
- function designerGetRatio(){ return designerGetRatioV2(); }
1179
- function designerGetLayout(){ return designerGetLayoutV2(); }
1180
- function designerDebounce(fn,d){ _dsDebounce(fn,d); }
1181
- function handleDesignerBgFile(){}
1182
- function previewDesignerSlide(){ _dsPreview(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/fm_fix.css CHANGED
@@ -168,282 +168,3 @@
168
  min-width:100%!important;max-width:none!important;max-height:none!important;
169
  object-fit:cover!important;
170
  }
171
-
172
- /* ============================================================
173
- BXH / BÅNG XẼP HẠNG (standing league table) FIX
174
- Bongda returns div-based Tailwind rows (.ranking-table .leaderboard
175
- .leaderboard-item / .head / .team / .copy). The old CSS only styled
176
- <table> so the standings rendered broken. Style the divs directly.
177
- ============================================================ */
178
- #ls-content .ranking-table,
179
- #ls-content .leaderboard{display:block!important;width:100%!important}
180
- #ls-content .ranking-table .head,
181
- #ls-content .leaderboard .head{display:flex!important;width:100%!important;background:#222!important;border-bottom:1px solid #333!important;padding:5px 0!important;font-size:10px!important;color:#999!important}
182
- #ls-content .ranking-table .head .team,
183
- #ls-content .leaderboard .head .team{flex:1 1 auto!important;min-width:0!important}
184
- #ls-content .ranking-table .head .team span,
185
- #ls-content .leaderboard .head .team span{display:inline-block!important;width:18px!important;text-align:center!important;color:#888!important}
186
- #ls-content .ranking-table .head .team .link,
187
- #ls-content .leaderboard .head .team .link{font-size:10px!important;color:#999!important;font-weight:700!important}
188
- #ls-content .ranking-table .head .copy,
189
- #ls-content .leaderboard .head .copy{display:flex!important;gap:2px!important;align-items:center!important;justify-content:flex-end!important;min-width:0!important}
190
- #ls-content .ranking-table .head .copy p,
191
- #ls-content .leaderboard .head .copy p{font-size:9px!important;color:#888!important;min-width:16px!important;text-align:center!important;margin:0!important}
192
- #ls-content .ranking-table .head .copy p.form,
193
- #ls-content .leaderboard .head .copy p.form{display:none!important}
194
- #ls-content .ranking-table .leaderboard-item,
195
- #ls-content .ranking-table #team_,
196
- #ls-content .leaderboard .leaderboard-item{
197
- display:table!important;width:100%!important;table-layout:fixed!important;
198
- border-collapse:collapse!important;border-bottom:1px solid #1a1a1a!important;
199
- font-size:11px!important;color:#ccc!important;background:transparent!important
200
- }
201
- #ls-content .ranking-table .leaderboard-item>div.team,
202
- #ls-content .ranking-table .leaderboard-item>.team,
203
- #ls-content .leaderboard .leaderboard-item .team{
204
- display:table-cell!important;vertical-align:middle!important;
205
- padding:5px 4px!important;text-align:left!important;min-width:0!important
206
- }
207
- #ls-content .ranking-table .leaderboard-item>div.team span,
208
- #ls-content .ranking-table .leaderboard-item>.team span{
209
- display:inline-block!important;width:18px!important;min-width:18px!important;
210
- text-align:center!important;font-size:10px!important;color:#888!important;font-weight:600!important
211
- }
212
- #ls-content .ranking-table .leaderboard-item>div.team a.link,
213
- #ls-content .ranking-table .leaderboard-item>.team a.link{
214
- display:inline-flex!important;align-items:center!important;gap:4px!important;
215
- min-width:0!important;font-size:11px!important;color:#ccc!important;text-decoration:none!important
216
- }
217
- #ls-content .ranking-table .leaderboard-item>div.team a.link img,
218
- #ls-content .ranking-table .leaderboard-item>.team a.link img{width:16px!important;height:16px!important;object-fit:contain!important;flex:0 0 auto!important}
219
- #ls-content .ranking-table .leaderboard-item>div.team a.link p,
220
- #ls-content .ranking-table .leaderboard-item>.team a.link p{
221
- font-size:11px!important;color:#ccc!important;margin:0!important;
222
- white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important
223
- }
224
- #ls-content .ranking-table .leaderboard-item>div.copy,
225
- #ls-content .ranking-table .leaderboard-item>.copy,
226
- #ls-content .leaderboard .leaderboard-item .copy{
227
- display:table-cell!important;vertical-align:middle!important;
228
- padding:5px 2px!important;text-align:center!important;white-space:nowrap!important
229
- }
230
- #ls-content .ranking-table .leaderboard-item>div.copy p,
231
- #ls-content .ranking-table .leaderboard-item>.copy p{
232
- display:inline-block!important;min-width:22px!important;text-align:center!important;
233
- font-size:11px!important;color:#ccc!important;margin:0 1px!important
234
- }
235
- #ls-content .ranking-table .leaderboard-item>div.copy p strong,
236
- #ls-content .ranking-table .leaderboard-item>.copy p strong{
237
- display:inline-block!important;min-width:22px!important;text-align:center!important;
238
- font-weight:800!important;color:#f0c040!important;font-size:12px!important
239
- }
240
- #ls-content .ranking-table .leaderboard-item>div.copy p.form,
241
- #ls-content .ranking-table .leaderboard-item>.copy p.form{display:inline-flex!important;flex-wrap:nowrap!important}
242
- #ls-content .ranking-table .leaderboard-item>div.copy p.form span,
243
- #ls-content .ranking-table .leaderboard-item>.copy p.form span{
244
- display:inline-block!important;min-width:14px!important;height:14px!important;
245
- line-height:14px!important;text-align:center!important;font-size:9px!important;
246
- border-radius:2px!important;margin:0 1px!important;color:#fff!important
247
- }
248
- #ls-content .ranking-table .leaderboard-item>div.copy p.form span.bg-green,
249
- #ls-content .ranking-table .leaderboard-item>.copy p.form span.bg-green{background:#2d8659!important}
250
- #ls-content .ranking-table .leaderboard-item>div.copy p.form span.bg-gray-5,
251
- #ls-content .ranking-table .leaderboard-item>.copy p.form span.bg-gray-5,
252
- #ls-content .leaderboard .leaderboard-item .copy p.form span.bg-gray-5,
253
- #ls-content .ranking-table .leaderboard-item .copy p.form span.bg-gray-5{background:#555!important;color:#ccc!important}
254
- #ls-content .ranking-table .leaderboard-item>div.copy p.form span.bg-red,
255
- #ls-content .ranking-table .leaderboard-item>.copy p.form span.bg-red{background:#c0392b!important}
256
- /* hide the bongda left accent bar that floats absolutely */
257
- #ls-content .ranking-table .leaderboard-item>div[class*="w-["],
258
- #ls-content .ranking-table .leaderboard-item div.relative{display:none!important}
259
- /* top row = champion accent can stay, but clean layout */
260
- #ls-content .leaderboard-item{margin:0!important}
261
- #ls-content .rank-table{width:100%!important;overflow-x:auto!important}
262
- /* fallback generic so ANY div-based standings table gets readable */
263
- #ls-content .team .name,#ls-content .team .logo{font-size:11px!important;color:#ccc!important}
264
- #ls-content .copy p{font-size:11px!important;color:#ccc!important}
265
- /* injected BXH header + row layout (see _decorateStandings in app_v2.js) */
266
- #ls-content .ls-bxh-head{
267
- display:flex!important;width:100%!important;align-items:center!important;gap:4px!important;
268
- background:#222!important;border-bottom:1px solid #333!important;
269
- padding:5px 2px!important;position:sticky!important;top:0!important;z-index:2!important
270
- }
271
- #ls-content .ls-bxh-head .team{flex:1!important;min-width:0!important;display:flex!important;align-items:center!important;gap:4px!important}
272
- #ls-content .ls-bxh-head .team span{display:inline-block!important;width:18px!important;min-width:18px!important;text-align:center!important;color:#888!important;font-size:10px!important;font-weight:700!important}
273
- #ls-content .ls-bxh-head .team p{font-size:10px!important;color:#999!important;font-weight:700!important;margin:0!important;text-transform:uppercase!important}
274
- #ls-content .ls-bxh-head .copy{display:flex!important;align-items:center!important;gap:2px!important;white-space:nowrap!important}
275
- #ls-content .ls-bxh-head .copy p{display:inline-block!important;min-width:20px!important;text-align:center!important;font-size:9px!important;color:#888!important;font-weight:700!important;margin:0!important}
276
- #ls-content .leaderboard-item{display:flex!important;width:100%!important;align-items:center!important;gap:4px!important;border-bottom:1px solid #1a1a1a!important;padding:4px 2px!important;background:transparent!important}
277
- #ls-content .leaderboard-item .team{flex:1!important;min-width:0!important;display:flex!important;align-items:center!important;gap:4px!important}
278
- #ls-content .leaderboard-item .team span{display:inline-block!important;width:18px!important;min-width:18px!important;text-align:center!important;color:#888!important;font-size:10px!important;font-weight:600!important}
279
- #ls-content .leaderboard-item .team a{display:inline-flex!important;align-items:center!important;gap:4px!important;font-size:11px!important;color:#ccc!important;text-decoration:none!important;overflow:hidden!important;min-width:0!important}
280
- #ls-content .leaderboard-item .team a img{width:16px!important;height:16px!important;object-fit:contain!important;flex:0 0 auto!important}
281
- #ls-content .leaderboard-item .team a p{font-size:11px!important;color:#ccc!important;margin:0!important;white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important}
282
- #ls-content .leaderboard-item .copy{display:flex!important;align-items:center!important;gap:2px!important;white-space:nowrap!important}
283
- #ls-content .leaderboard-item .copy p{display:inline-block!important;min-width:20px!important;text-align:center!important;font-size:11px!important;color:#ccc!important;margin:0!important}
284
- #ls-content .leaderboard-item .copy p strong{font-weight:800!important;color:#f0c040!important;min-width:20px!important;text-align:center!important}
285
- #ls-content .leaderboard-item .copy p.form{display:inline-flex!important;flex-wrap:nowrap!important}
286
- #ls-content .leaderboard-item .copy p.form span{width:14px!important;height:14px!important;line-height:14px!important;font-size:9px!important;border-radius:2px!important;color:#fff!important;margin:0 1px!important;text-align:center!important}
287
- #ls-content .leaderboard-item .copy p.form span.bg-green{background:#2d8659!important;display:inline-block!important}
288
- #ls-content .leaderboard-item .copy p.form span.bg-gray-5{background:#555!important;color:#ccc!important;display:inline-block!important}
289
- #ls-content .leaderboard-item .copy p.form span.bg-red{background:#c0392b!important;display:inline-block!important}
290
- #ls-content .leaderboard-item>div[class*="w-["]{display:none!important}#ls-content .leaderboard-item>div[class*="w-["]{display:none!important}
291
-
292
- /* ============================================================
293
- FPT PLAY / HIGHLIGHT VIDEO OVERFLOW FIX v2
294
- fptplay videos open in the TikTok-style feed inside .yt-thumb-wrap >
295
- iframe (YouTube) or <video>. <iframe> ignores object-fit, so a 9:16
296
- Shorts (or any non-16:9) source stretched the slide and overflowed
297
- width/height — the iframe's intrinsic aspect ratio pushed it beyond the
298
- slide bounds.
299
-
300
- BUG FIX v2 (two issues):
301
- 1) "FPT Play videos displayed differently from Short AI videos — too tall
302
- and wide": The old rule sized iframes to 177.78vh (16:9 of viewport
303
- height, ~1.78x screen wide). Short AI <video> tags used width:100%;
304
- height:100%; object-fit:contain. The iframe was much wider/taller than
305
- the video. Fix: make iframe use width:100%; height:100% — identical to
306
- <video> — centered and clipped by the slide's overflow:hidden.
307
-
308
- 2) "9:16 ↔ 16:9 ratio toggle button (🖥️/📺) doesn't work on football
309
- player slides and wall videos": The inline CSS .ratio-wide rule
310
- (specificity 0,3,1) was overridden by fm_fix.css selectors using the
311
- #tiktok-feed ID (specificity 1,3,1). The .ratio-wide rules never applied
312
- to iframes inside #tiktok-feed. Fix: add .ratio-wide override rules
313
- with #tiktok-feed ID prefix (matching or higher specificity) AFTER the
314
- default iframe rules, so the !important cascade honors the toggle.
315
-
316
- Sizing model: iframes don't support object-fit, so we use
317
- position:absolute + transform centering inside .yt-thumb-wrap (which is
318
- position:relative, width:100%, height:100%). The slide's
319
- overflow:hidden clips any letterbox/pillarbox bars — same visual result
320
- as object-fit:contain on <video>.
321
- ============================================================ */
322
- .tiktok-slide{overflow:hidden!important;position:relative!important}
323
- .tiktok-slide video{max-width:100%!important;max-height:100%!important;object-fit:contain!important;width:100%!important;height:100%!important;border:none!important}
324
- /* FPT play / YouTube iframe in the TikTok viewer: SAME sizing as <video>.
325
- Width and height 100% of .yt-thumb-wrap (which fills .tiktok-slide),
326
- centered via transform, clipped by overflow:hidden on the slide.
327
- This makes iframes appear IDENTICAL to Short AI <video> tags. */
328
- #tiktok-feed .tiktok-slide .yt-thumb-wrap iframe,
329
- #wc-shorts-feed .tiktok-slide .yt-thumb-wrap iframe,
330
- #tt-feed .tiktok-slide .yt-thumb-wrap iframe,
331
- .tiktok-slide .yt-thumb-wrap iframe{
332
- position:absolute!important;
333
- top:50%!important;
334
- left:50%!important;
335
- transform:translate(-50%,-50%)!important;
336
- width:100%!important; /* match <video> — 100% of slide */
337
- height:100%!important; /* match <video> — 100% of slide */
338
- min-width:100%!important;
339
- min-height:100%!important;
340
- max-height:100%!important;
341
- max-width:100vw!important; /* never overflow screen width */
342
- border:none!important;
343
- }
344
- /* WC Shorts path: iframe is a DIRECT child of .tiktok-slide (no .yt-thumb-wrap) */
345
- #tiktok-feed .tiktok-slide > iframe,
346
- #wc-shorts-feed .tiktok-slide > iframe,
347
- .tiktok-slide > iframe{
348
- position:absolute!important;
349
- top:50%!important;
350
- left:50%!important;
351
- transform:translate(-50%,-50%)!important;
352
- width:100%!important; /* match <video> — 100% of slide */
353
- height:100%!important; /* match <video> — 100% of slide */
354
- min-width:100%!important;
355
- min-height:100%!important;
356
- max-height:100%!important;
357
- max-width:100vw!important; /* never overflow screen width */
358
- border:none!important;
359
- }
360
- /* RATIO TOGGLE FIX: when .ratio-wide is on a .tiktok-slide inside
361
- #tiktok-feed, switch from 9:16 portrait to 16:9 landscape.
362
- These rules use #tiktok-feed ID prefix (same specificity as the default
363
- iframe rules above) AND .ratio-wide class, so they win the CSS cascade
364
- only when the toggle button is active. The default 9:16 sizing (width:100%,
365
- height:100%) is overridden to 16:9 landscape (width:100vw, height:56.25vw). */
366
- #tiktok-feed .tiktok-slide.ratio-wide .yt-thumb-wrap iframe,
367
- #wc-shorts-feed .tiktok-slide.ratio-wide .yt-thumb-wrap iframe,
368
- #tt-feed .tiktok-slide.ratio-wide .yt-thumb-wrap iframe,
369
- .tiktok-slide.ratio-wide .yt-thumb-wrap iframe{
370
- position:absolute!important;
371
- top:50%!important;
372
- left:50%!important;
373
- transform:translate(-50%,-50%)!important;
374
- width:calc(100vw - 1px)!important;
375
- height:56.25vw!important;
376
- max-width:100vw!important;
377
- max-height:100dvh!important;
378
- min-width:100%!important;
379
- min-height:100%!important;
380
- border:none!important;
381
- pointer-events:auto!important;
382
- }
383
- #tiktok-feed .tiktok-slide.ratio-wide > iframe,
384
- #wc-shorts-feed .tiktok-slide.ratio-wide > iframe,
385
- .tiktok-slide.ratio-wide > iframe{
386
- position:absolute!important;
387
- top:50%!important;
388
- left:50%!important;
389
- transform:translate(-50%,-50%)!important;
390
- width:calc(100vw - 1px)!important;
391
- height:56.25vw!important;
392
- max-width:100vw!important;
393
- max-height:100dvh!important;
394
- min-width:100%!important;
395
- min-height:100%!important;
396
- border:none!important;
397
- pointer-events:auto!important;
398
- }
399
- /* Toggle back to 9:16 (default) — reset to 100% sizing */
400
- #tt-feed .tiktok-slide:not(.ratio-wide) .yt-thumb-wrap iframe,
401
- #tt-feed .tiktok-slide:not(.ratio-wide) > iframe{
402
- width:100%!important;
403
- height:100%!important;
404
- max-width:100vw!important;
405
- min-width:100%!important;
406
- }
407
- /* .yt-thumb-wrap is the positioning context for the centered iframe */
408
- .yt-thumb-wrap{position:relative!important;overflow:hidden!important;width:100%!important;height:100%!important;background:#000!important}
409
- .yt-thumb-wrap img,.yt-thumb-wrap video{width:100%!important;height:100%!important;object-fit:cover!important}
410
- .wall-item{overflow:hidden!important}
411
- .wall-thumb img,.wall-thumb video{max-width:100%!important;max-height:100%!important;object-fit:cover!important}
412
- /* FIX: FPT Play cards on the AI wall (Tường AI) are wrapped in
413
- .wall-yt-feed-item which had NO CSS — the inner .wall-item's inline
414
- flex:0 0 260px is ignored inside a display:block wrapper, so the card
415
- sized to its content (1280px hq720 thumbnail) → ~2x the Short AI card.
416
- Give the wrapper the same flex sizing as a wall card so the FPT card is
417
- 100% identical in width/height to a Short AI card. */
418
- .ai-wall-track .wall-yt-feed-item,
419
- #ai-wall-track .wall-yt-feed-item,
420
- .wall-yt-feed-item{
421
- flex:0 0 260px!important;
422
- max-width:260px!important;
423
- min-width:260px!important;
424
- }
425
- .wall-yt-feed-item .wall-item{
426
- width:100%!important;
427
- flex:0 0 auto!important; /* inner card fills the wrapper, not double-sizes */
428
- max-width:100%!important;
429
- }
430
- #fpt-main-track .wall-item{flex:0 0 260px!important;max-width:260px!important}
431
- .slider-thumb{overflow:hidden!important}
432
- .slider-thumb img,.slider-thumb video{max-width:100%!important;max-height:100%!important;object-fit:cover!important}
433
- .fpt-slider-thumb img,.fpt-slider-thumb video{max-width:100%!important;max-height:100%!important;object-fit:cover!important}
434
-
435
- /* FPT Play slider moved into Livescore section - ensure it fits within the ls-section */
436
- .ls-section #home-fpt-section{margin-top:4px;padding:0 4px}
437
- /* M3U IPTV grid */
438
- .m3u-grid{padding:12px}
439
- .m3u-group-label{color:#5cb87a;font-size:14px;font-weight:700;margin:10px 0 6px 4px}
440
- .m3u-group-track{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}
441
- .m3u-channel-card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:8px;cursor:pointer;transition:0.2s;position:relative}
442
- .m3u-channel-card:hover{border-color:#5cb87a;transform:translateY(-1px)}
443
- .m3u-channel-thumb{width:100%;max-width:80px;height:auto;aspect-ratio:1/1;background:#222;border-radius:6px;overflow:hidden;display:flex;align-items:center;justify-content:center;margin:0 auto}
444
- .m3u-channel-thumb img{width:100%;height:100%;object-fit:contain}
445
- .m3u-no-logo{font-size:24px;color:#5cb87a}
446
- .m3u-channel-title{font-size:11px;color:#ccc;margin-top:4px;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
447
- .m3u-stream-count{font-size:9px;color:#5cb87a;margin-top:3px;text-align:center;background:rgba(45,134,89,.12);border-radius:8px;padding:1px 8px;display:inline-block}
448
- .badge-live{background:#ff4444 !important;color:#fff !important;animation:pulse 1.5s infinite}
449
- @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}}
 
168
  min-width:100%!important;max-width:none!important;max-height:none!important;
169
  object-fit:cover!important;
170
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/index_v2.html CHANGED
@@ -8,40 +8,20 @@
8
  <meta property="og:title" content="VNEWS - Tin Tức Việt Nam">
9
  <meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
10
  <link rel="canonical" href="https://bep40-vnews.hf.space">
11
- <link rel="stylesheet" href="/static/wc2026.css?v=20260902sharen"><link rel="stylesheet" href="/static/fm_fix.css?v=20260902sharen">
12
  <script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
13
  <style>
14
- *{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px;color:#fff}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cats::-webkit-scrollbar{display:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer;flex-shrink:0}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;overflow-y:hidden;gap:8px;padding:4px 10px 10px;scrollbar-width:none;-webkit-overflow-scrolling:touch;touch-action:pan-x pan-y;overscroll-behavior-x:contain;scroll-snap-type:x proximity}.slider-track>*{flex:0 0 auto;scroll-snap-align:start;-webkit-tap-highlight-color:transparent}.slider-track::-webkit-scrollbar{display:none}.slider-item{flex:0 0 160px;cursor:pointer;box-sizing:border-box}.hl-league-block{display:inline-flex;align-items:flex-start;flex:0 0 auto;min-width:0}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-img img{width:100%;height:100%;object-fit:cover}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-genk{background:#6a1b9a}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60;cursor:pointer}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px;color:#eee}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px;cursor:pointer}.article-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:8px;padding:9px;font-size:12px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px;font-size:11px;cursor:pointer}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-container.feed-fullscreen{position:fixed;inset:0;width:100vw;height:100dvh;max-height:none;min-height:0;z-index:99990;background:#000}.tiktok-container.feed-fullscreen .tiktok-feed,.tiktok-container.feed-fullscreen .tiktok-slide{height:100dvh;max-height:100dvh;min-height:0}.tiktok-feed{height:100%;overflow-y:scroll;-webkit-overflow-scrolling:touch;scroll-behavior:smooth;scroll-snap-type:y mandatory;scrollbar-width:none;touch-action:pan-y;-webkit-user-select:none;user-select:none;overscroll-behavior:contain}.tiktok-feed::-webkit-scrollbar{display:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center;touch-action:pan-y;cursor:pointer}.tiktok-slide video{width:100%;height:100%;object-fit:cover;border:none;touch-action:manipulation}.tiktok-slide iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:100%;height:100%;border:none;pointer-events:none}.tiktok-slide .slide-gesture{position:absolute;inset:0;z-index:2;touch-action:pan-y;pointer-events:auto;cursor:pointer;background:transparent}.tiktok-slide .tiktok-bottom,.tiktok-slide .tiktok-right,.tiktok-slide .tiktok-counter{pointer-events:auto}.tiktok-slide .inline-comments{pointer-events:auto}.yt-thumb-wrap{position:relative;width:100%;height:100%;background-size:cover;background-position:center;background-image:url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="1" height="1"%3E%3C/svg%3E')}.yt-thumb-wrap iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:100%;height:100%;border:none;pointer-events:auto;cursor:pointer}.tiktok-slide.ratio-wide video{width:100%!important;height:100%!important;object-fit:contain!important}.tiktok-slide.ratio-wide iframe{position:absolute!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:calc(100vw - 1px)!important;height:56.25vw!important;max-width:100vw!important;max-height:100%!important;border:none!important;pointer-events:none!important}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}.tiktok-title{font-size:12px;color:#fff;max-height:34px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tiktok-desc{font-size:10px;color:#bbb;margin-top:3px;max-height:40px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height:60%;min-height:140px;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:clip;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700;flex-shrink:0}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1 1 auto;overflow-y:auto;padding:6px 10px;max-height:140px;min-height:40px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333;flex-shrink:0;position:sticky;bottom:0;background:rgba(18,18,18,.98)}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px;min-height:32px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer;min-height:32px}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4f0;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.ai-compose button.hot-short-btn{background:linear-gradient(135deg,#8b1a5a,#c0392b);border:1px solid #e07050;color:#fff;box-shadow:0 0 10px rgba(192,57,43,.4);animation:hot-pulse 2s infinite}@keyframes hot-pulse{0%,100%{box-shadow:0 0 6px rgba(192,57,43,.35)}50%{box-shadow:0 0 16px rgba(192,57,43,.75)}}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}.hot-topic-row::-webkit-scrollbar{display:none}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap}.hot-chip:active{transform:scale(.96)}.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}.hashtag-load-more{width:100%;margin-top:8px;background:#222;border:1px solid #333;color:#ccc;padding:9px;border-radius:10px;font-size:12px;cursor:pointer}.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}@keyframes ht-spin{to{transform:rotate(360deg)}}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-item-new{animation:wall-flash 1.8s ease-out}@keyframes wall-flash{0%{border-color:#f0c040;box-shadow:0 0 18px rgba(240,192,64,.35)}30%{border-color:#f0c040;box-shadow:0 0 12px rgba(240,192,64,.2)}100%{border-color:#2b2b2b;box-shadow:none}}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px;position:relative}.wall-thumb img,.wall-thumb video{width:100%;height:100%;object-fit:cover}.wall-video-badge{position:absolute;top:4px;right:4px;background:rgba(45,134,89,.9);color:#fff;font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;cursor:pointer}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.wall-actions button.wall-btn-design{background:#1a3a5a;border-color:#3a5a7a;color:#fff;font-weight:700}.wall-actions button.wall-btn-quote{background:#5d4037;border-color:#795548;color:#ffcc80;font-weight:700}.wall-actions button.wall-btn-grid{background:#1a237e;border-color:#3949ab;color:#c5cae6;font-weight:700}#progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
15
- .hl-load-more{margin:4px 8px 8px;background:#222;border:1px solid #333;color:#ccc;padding:6px 12px;border-radius:8px;font-size:11px;cursor:pointer;width:calc(100% - 16px);display:block}
16
- .hl-load-more:hover{background:#2a2a2a}
17
- .ls-section-header{padding:8px 10px;font-size:11px;font-weight:700;color:#5cb87a;border-bottom:1px solid #2a2a2a}
18
- .ls-section-header.upcoming{color:#f0c040}
19
- .ls-section-header.finished{color:#e74c3c}
20
- .ls-section-header.yesterday{color:#8ab4f0}
21
- .ls-section-header.older{color:#888}.ls-section-header.older{color:#888}
22
- /* FPT PLAY / HIGHLIGHT video overflow fix v2 — same containment as short-AI wall
23
- FIX: iframes now use width:100%/height:100% (matching <video>) instead of
24
- width:177.78vh. FPT/YouTube iframes now appear IDENTICAL to Short AI <video> tags.
25
- The .ratio-wide toggle (16:9) is handled by fm_fix.css with matching ID specificity. */
26
- .tiktok-slide{overflow:hidden!important}
27
- .tiktok-slide video{position:absolute!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:100%!important;height:100%!important;object-fit:contain!important;border:none!important;pointer-events:auto!important}
28
- .tiktok-slide iframe{position:absolute!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:100%!important;height:100%!important;border:none!important;pointer-events:none!important}
29
- .tiktok-slide.ratio-wide iframe{position:absolute!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:calc(100vw - 1px)!important;height:56.25vw!important;max-width:100vw!important;max-height:100dvh!important;border:none!important;pointer-events:auto!important}
30
- .yt-thumb-wrap{overflow:hidden!important}
31
- .yt-thumb-wrap iframe{position:absolute!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:100%!important;height:100%!important;border:none!important;pointer-events:auto!important;cursor:pointer!important}
32
- .wall-thumb img,.wall-thumb video{max-width:100%!important;max-height:100%!important;object-fit:cover!important}
33
  </style>
34
  </head>
35
  <body>
36
  <div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Highlight · AI · World Cup 2026</p></div>
37
  <div class="cats" id="cat-bar"></div>
38
- <div id="home-hl-section"></div>
39
  <div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
40
  <div id="view-cat" class="view"></div>
41
  <div id="view-video" class="view"></div>
42
  <div id="view-tiktok" class="view"></div>
43
  <div id="view-article" class="view"></div>
44
- <div id="view-m3u" class="view"></div>
45
  <div class="match-overlay" id="match-overlay">
46
  <div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div>
47
  <div class="mo-tabs"><span class="mo-tab active" onclick="loadMatchTab('detail')">📋 Chi tiết</span><span class="mo-tab" onclick="loadMatchTab('comm')">Diễn biến</span><span class="mo-tab" onclick="loadMatchTab('stats')">Thống kê</span></div>
@@ -52,7 +32,7 @@
52
  var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
53
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
54
  function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
55
- 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 if(id==='m3u'){document.getElementById('view-m3u').classList.add('active');if(typeof loadM3UChannels==='function')loadM3UChannels()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
56
  function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
57
  function doShare(title,url,img,postId){
58
  var shareUrl;
@@ -61,6 +41,7 @@ function doShare(title,url,img,postId){
61
  } else {
62
  shareUrl = SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');
63
  }
 
64
  if(navigator.clipboard && navigator.clipboard.writeText){
65
  navigator.clipboard.writeText(shareUrl).then(function(){
66
  toast('📋 Đã sao chép link!');
@@ -79,18 +60,15 @@ function doShare(title,url,img,postId){
79
  var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
80
  document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
81
  if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
82
- document.body.removeChild(ta);
83
  }catch(e){prompt('📋 Sao chép link:', shareUrl);}
84
  try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
85
  }
86
  }
87
- 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><div class="cat" data-cat="m3u">📺 Trực tiếp</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)});}
88
  var SPACE=location.origin;
89
  </script>
90
- <script src="/static/app_v2.js?v=20260903livefootball"></script>
91
- <script src="/static/designer_v2.js?v=20260907fix2"></script>
92
- <script src="/static/modal_quote_design.js"></script>
93
- <script src="/static/post_image_designer.js?v=20260917pidgrid2"></script>
94
  <script src="/static/yt_live.js"></script>
95
  <script src="/static/vtv_init.js"></script>
96
  <script src="/static/hot_multi.js?v=1"></script>
 
8
  <meta property="og:title" content="VNEWS - Tin Tức Việt Nam">
9
  <meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
10
  <link rel="canonical" href="https://bep40-vnews.hf.space">
11
+ <link rel="stylesheet" href="/static/wc2026.css">
12
  <script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
13
  <style>
14
+ *{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px;color:#fff}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cats::-webkit-scrollbar{display:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer;flex-shrink:0}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}.slider-track::-webkit-scrollbar{display:none}.slider-item{flex:0 0 160px;cursor:pointer}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-img img{width:100%;height:100%;object-fit:cover}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-genk{background:#6a1b9a}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60;cursor:pointer}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px;color:#eee}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px;cursor:pointer}.article-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;font-size:12px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px;font-size:11px;cursor:pointer}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;scrollbar-width:none}.tiktok-feed::-webkit-scrollbar{display:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center}.tiktok-slide video,.tiktok-slide iframe{width:100%;height:100%;object-fit:cover;border:none}.tiktok-slide.ratio-wide video,.tiktok-slide.ratio-wide iframe{object-fit:contain}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}.tiktok-title{font-size:12px;color:#fff}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height:60%;min-height:140px;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:clip;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700;flex-shrink:0}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1 1 auto;overflow-y:auto;padding:6px 10px;max-height:140px;min-height:40px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333;flex-shrink:0;position:sticky;bottom:0;background:rgba(18,18,18,.98)}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px;min-height:32px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer;min-height:32px}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4d8;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}.hot-topic-row::-webkit-scrollbar{display:none}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap}.hot-chip:active{transform:scale(.96)}.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}.hashtag-load-more{width:100%;margin-top:8px;background:#222;border:1px solid #333;color:#ccc;padding:9px;border-radius:10px;font-size:12px;cursor:pointer}.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}@keyframes ht-spin{to{transform:rotate(360deg)}}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-item-new{animation:wall-flash 1.8s ease-out}@keyframes wall-flash{0%{border-color:#f0c040;box-shadow:0 0 18px rgba(240,192,64,.35)}30%{border-color:#f0c040;box-shadow:0 0 12px rgba(240,192,64,.2)}100%{border-color:#2b2b2b;box-shadow:none}}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px;position:relative}.wall-thumb img{width:100%;height:100%;object-fit:cover}.wall-video-badge{position:absolute;top:4px;right:4px;background:rgba(45,134,89,.9);color:#fff;font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;cursor:pointer}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}#progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  </style>
16
  </head>
17
  <body>
18
  <div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Highlight · AI · World Cup 2026</p></div>
19
  <div class="cats" id="cat-bar"></div>
 
20
  <div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
21
  <div id="view-cat" class="view"></div>
22
  <div id="view-video" class="view"></div>
23
  <div id="view-tiktok" class="view"></div>
24
  <div id="view-article" class="view"></div>
 
25
  <div class="match-overlay" id="match-overlay">
26
  <div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div>
27
  <div class="mo-tabs"><span class="mo-tab active" onclick="loadMatchTab('detail')">📋 Chi tiết</span><span class="mo-tab" onclick="loadMatchTab('comm')">Diễn biến</span><span class="mo-tab" onclick="loadMatchTab('stats')">Thống kê</span></div>
 
32
  var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
33
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
34
  function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
35
+ function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector('[data-cat="'+id+'"]')?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
36
  function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
37
  function doShare(title,url,img,postId){
38
  var shareUrl;
 
41
  } else {
42
  shareUrl = SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');
43
  }
44
+ // Use clipboard API first (modern, works on Chrome mobile 85+)
45
  if(navigator.clipboard && navigator.clipboard.writeText){
46
  navigator.clipboard.writeText(shareUrl).then(function(){
47
  toast('📋 Đã sao chép link!');
 
60
  var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
61
  document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
62
  if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
63
+ document.body.removeChild(ta);
64
  }catch(e){prompt('📋 Sao chép link:', shareUrl);}
65
  try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
66
  }
67
  }
68
+ 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)});}
69
  var SPACE=location.origin;
70
  </script>
71
+ <script src="/static/app_v2.js?v=20260722"></script>
 
 
 
72
  <script src="/static/yt_live.js"></script>
73
  <script src="/static/vtv_init.js"></script>
74
  <script src="/static/hot_multi.js?v=1"></script>
static/live_mode.js CHANGED
@@ -23,34 +23,7 @@
23
  .hashtag-src-img img{width:100%!important;height:100%!important;object-fit:cover!important}
24
  .hashtag-src-text{flex:1!important;min-width:0!important}
25
  .hashtag-src-title{font-size:12px!important;font-weight:700!important;color:#eee!important;display:-webkit-box!important;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden!important}
26
- .hashtag-src-via{font-size:10px!important;color:#888!important;margin-top:2px!important}
27
- #ls-content .ranking-table .head,#ls-content .leaderboard .head{width:100%!important}
28
- #ls-content .ranking-table .leaderboard-item,#ls-content .leaderboard .leaderboard-item{display:flex!important;width:100%!important;align-items:center!important;gap:4px!important;border-bottom:1px solid #1a1a1a!important;padding:4px 2px!important;background:transparent!important}
29
- #ls-content .ranking-table .leaderboard-item>div.team,#ls-content .ranking-table .leaderboard-item>.team,#ls-content .leaderboard .leaderboard-item .team{flex:1!important;min-width:0!important;display:flex!important;align-items:center!important;gap:4px!important}
30
- #ls-content .leaderboard-item .team a{display:inline-flex!important;align-items:center!important;gap:4px!important;font-size:11px!important;color:#ccc!important;text-decoration:none!important;overflow:hidden!important}
31
- #ls-content .leaderboard-item .team a img{width:16px!important;height:16px!important;object-fit:contain!important}
32
- #ls-content .leaderboard-item .team a p{font-size:11px!important;color:#ccc!important;margin:0!important;white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important}
33
- #ls-content .leaderboard-item .copy{display:flex!important;align-items:center!important;gap:2px!important;white-space:nowrap!important}
34
- #ls-content .leaderboard-item .copy p{display:inline-block!important;min-width:20px!important;text-align:center!important;font-size:11px!important;color:#ccc!important;margin:0!important}
35
- #ls-content .leaderboard-item .copy p strong{font-weight:800!important;color:#f0c040!important;min-width:20px!important;text-align:center!important}
36
- #ls-content .leaderboard-item .copy p.form span{width:14px!important;height:14px!important;line-height:14px!important;font-size:9px!important;border-radius:2px!important;color:#fff!important;margin:0 1px!important}
37
- #ls-content .leaderboard-item .copy p.form span.bg-green{background:#2d8659!important}
38
- #ls-content .leaderboard-item .copy p.form span.bg-gray-5{background:#555!important;color:#ccc!important}
39
- #ls-content .leaderboard-item .copy p.form span.bg-red{background:#c0392b!important}
40
- #ls-content .leaderboard-item>div[class*="w-["]{display:none!important}
41
- #ls-content .leaderboard-item .w-\[2px\]{display:none!important}
42
- /* FPT PLAY / HIGHLIGHT video overflow fix - same containment as short-AI wall */
43
- .tiktok-slide{overflow:hidden!important}
44
- .tiktok-slide video,.tiktok-slide iframe{max-width:100%!important;max-height:100%!important;object-fit:contain!important}
45
- .yt-thumb-wrap{overflow:hidden!important}
46
- .yt-thumb-wrap iframe{max-width:100%!important;max-height:100%!important}
47
- .wall-item{overflow:hidden!important}
48
- .wall-thumb img,.wall-thumb video{max-width:100%!important;max-height:100%!important;object-fit:cover!important}
49
- /* FPT wall cards on Tường AI: the .wall-yt-feed-item wrapper must match the
50
- Short AI card size (was 2x because the wrapper had no flex sizing) */
51
- .wall-yt-feed-item{flex:0 0 260px!important;max-width:260px!important;min-width:260px!important}
52
- .wall-yt-feed-item .wall-item{width:100%!important;flex:0 0 auto!important;max-width:100%!important}
53
- #fpt-main-track .wall-item{flex:0 0 260px!important;max-width:260px!important}`;
54
  document.head.appendChild(style);
55
 
56
  // Load rewrite fix
 
23
  .hashtag-src-img img{width:100%!important;height:100%!important;object-fit:cover!important}
24
  .hashtag-src-text{flex:1!important;min-width:0!important}
25
  .hashtag-src-title{font-size:12px!important;font-weight:700!important;color:#eee!important;display:-webkit-box!important;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden!important}
26
+ .hashtag-src-via{font-size:10px!important;color:#888!important;margin-top:2px!important}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  document.head.appendChild(style);
28
 
29
  // Load rewrite fix
static/modal_quote_design.js DELETED
@@ -1,482 +0,0 @@
1
- const MODAL_QUOTE_CSS = `
2
- .mq-overlay {
3
- position:fixed; inset:0; background:rgba(0,0,0,.94); z-index:10000;
4
- display:flex; align-items:center; justify-content:center; padding:12px;
5
- overflow-y:auto;
6
- }
7
- .mq-box {
8
- background:#16161a; border:2px solid #5cb87a; border-radius:16px;
9
- padding:18px; max-width:700px; width:100%; max-height:94vh;
10
- overflow-y:auto; position:relative;
11
- }
12
- .mq-img-preview {
13
- width:100%; max-height:320px; object-fit:cover; border-radius:12px;
14
- margin-bottom:12px; cursor:pointer; border:1px solid #2a2a2a;
15
- }
16
- .mq-textarea {
17
- width:100%; min-height:120px; background:#1a1a1a; border:1px solid #333;
18
- color:#eee; border-radius:10px; padding:10px; font-size:13px; line-height:1.5;
19
- resize:vertical;
20
- }
21
- .mq-types {
22
- display:flex; gap:6px; margin-bottom:10px; flex-wrap:wrap;
23
- }
24
- .mq-type-btn {
25
- background:#333; border:0; color:#ccc; padding:5px 12px; border-radius:8px;
26
- cursor:pointer; font-size:11px; font-weight:700;
27
- }
28
- .mq-type-btn.active {
29
- background:#2d8659; color:#fff;
30
- }
31
- .mq-colors {
32
- display:flex; gap:6px; flex-wrap:wrap; margin:10px 0;
33
- }
34
- .mq-color-btn {
35
- width:24px; height:24px; border-radius:50%; border:2px solid #444;
36
- cursor:pointer; flex-shrink:0;
37
- }
38
- .mq-color-btn.active { border-color:#fff; box-shadow:0 0 0 2px #2d8659; }
39
- .mq-inputs { display:flex; flex-direction:column; gap:8px; }
40
- .mq-inputs input {
41
- background:#1a1a1a; border:1px solid #333; color:#eee;
42
- border-radius:8px; padding:8px; font-size:12px;
43
- }
44
- .mq-actions {
45
- display:flex; gap:8px; margin-top:14px; justify-content:flex-end;
46
- }
47
- .mq-actions button {
48
- padding:8px 14px; border-radius:10px; font-size:12px; font-weight:700;
49
- cursor:pointer; border:0;
50
- }
51
- .mq-actions .secondary { background:#333; color:#eee; }
52
- .mq-actions .primary { background:#2d8659; color:#fff; }
53
- `;
54
-
55
- // ===== Grid modal CSS =====
56
- const MODAL_GRID_CSS = `
57
- .modal-grid-overlay {
58
- position:fixed; inset:0; background:rgba(0,0,0,.94); z-index:10000;
59
- display:flex; align-items:center; justify-content:center; padding:12px;
60
- overflow-y:auto;
61
- }
62
- .modal-grid-box {
63
- background:#16161a; border:2px solid #5cb87a; border-radius:16px;
64
- padding:18px; max-width:800px; width:100%; max-height:94vh;
65
- overflow-y:auto; position:relative;
66
- }
67
- .mg-image-item {
68
- background:#1a1a1a; border:1px solid #2a2a2a; border-radius:10px;
69
- padding:10px; margin-bottom:10px;
70
- }
71
- .mg-image-preview {
72
- width:100%; aspect-ratio:4/3; object-fit:cover; border-radius:8px;
73
- margin-bottom:8px; border:1px solid #333;
74
- }
75
- .mg-quote-input {
76
- width:100%; min-height:50px; background:#111; border:1px solid #333;
77
- color:#eee; border-radius:8px; padding:8px; font-size:11px;
78
- resize:vertical;
79
- }
80
- `;
81
-
82
- // Module-scoped state (kept at top so modal builders can use it).
83
- var _quoteState = {
84
- images: [],
85
- currentImgIdx: -1,
86
- quotes: [],
87
- quoteType: 'about',
88
- };
89
-
90
- // Module-scoped: per-quote inputs for the grid modal.
91
- // Previously declared at the BOTTOM of the file — modal builders referenced it
92
- // at the TOP before it was defined, which killed the whole modal render.
93
- // Moved to module-scope; reset once inside openModalGridDesigner.
94
- var _gridQuotes = {};
95
-
96
- // Current quote style values (used by preview).
97
- var _currentQuoteType = 'about';
98
- var _currentQuoteColor = '#ffd700';
99
-
100
- const _quoteTypeIcons = {
101
- about: '🗣️ Người nói về người/sự kiện',
102
- speaker: '👤 Người nói nội dung trích dẫn',
103
- };
104
-
105
- function detectEmotion(text) {
106
- const t = (text || '').toLowerCase();
107
- if (/vui|happy|tươi cười|cười|tuyệt|hoàn hảo|đỉnh|tuyệt vời|cảm ơn|tuyệt cú mước/i.test(t)) return 'happy';
108
- if (/buồn|sad|đau|khóc|nước mắt|tuyệt vọng|mất|chết|thất bại|tiếc/i.test(t)) return 'sad';
109
- if (/giận|angry|tức|đe dọa|thù|trừng trị|phẫn|nổi giận|bạo lực|chiến tranh/i.test(t)) return 'angry';
110
- if (/thực cảm|cảm động|xúc động|ngưỡng mộ|kính trọng|tôn vinh/i.test(t)) return 'proud';
111
- return 'neutral';
112
- }
113
-
114
- // ===== Open Quote Modal Designer =====
115
- function openModalQuoteDesigner(postIdx) {
116
- const p = _wallPosts[postIdx];
117
- if(!p) return;
118
-
119
- // Inject CSS
120
- if(!document.getElementById('modal-quote-css')) {
121
- document.head.insertAdjacentHTML('beforeend', MODAL_QUOTE_CSS);
122
- }
123
-
124
- // Collect images (SP onwards = current image + any additional images)
125
- const images = [];
126
- if(p.img) images.push(p.img);
127
- if(p.images && p.images.length) images.push(...p.images);
128
-
129
- let h = `<div class="mq-overlay" id="modal-quote-overlay">
130
- <div class="mq-box">
131
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
132
- <h3 style="color:#5cb87a;font-size:16px;font-weight:700;margin:0">✏️ Trích dẫn người nổi tiếng</h3>
133
- <button onclick="closeModalQuote()" style="background:none;border:0;color:#fff;font-size:24px;cursor:pointer">&times;</button>
134
- </div>`;
135
-
136
- // 1. Chọn ảnh / upload
137
- h += `<div style="margin-bottom:10px">
138
- <div style="display:flex;gap:6px;flex-wrap:wrap">
139
- <button onclick="openFileSelector()" style="background:#2d8659;border:0;color:#fff;padding:6px 12px;border-radius:8px;cursor:pointer;font-size:11px;font-weight:700">📁 Tải ảnh lên</button>
140
- <input type="text" id="mq-img-url" placeholder="Dán URL ảnh (hoặc dùng ảnh mặc định)" style="flex:1;background:#1a1a1a;border:1px solid #333;color:#eee;border-radius:8px;padding:6px 10px;font-size:11px">
141
- <button onclick="mqUseDefaultImage()" style="background:#333;border:0;color:#ccc;padding:6px 12px;border-radius:8px;cursor:pointer;font-size:11px">🖼️ Ảnh mặc định</button>
142
- </div>
143
- <input type="file" id="mq-file-input" accept="image/*" style="display:none" onchange="mqHandleFileSelect(this)">
144
- </div>`;
145
-
146
- // 2. Preview
147
- h += `<div style="position:relative;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;overflow:hidden;margin-bottom:10px" id="mq-preview-wrap">
148
- <img id="mq-preview-img" src="${images[0] ? _proxyImg(images[0]) : 'data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22400%22 height=%22500%22><rect fill=%22%23222%22 width=%22400%22 height=%22500%22/><text fill=%22%23666%22 font-size=%2214%22 x=%2250%25%25%22 y=%2250%25%25%22 text-anchor=%22middle%22>Chọn ảnh để xem trước</text></svg>'}" class="mq-img-preview" onclick="openImgPreview(${images[0] ? _proxyImg(images[0]) : ''})">
149
- </div>`;
150
-
151
- // 3. Chọn style trích dẫn
152
- h += `<div style="color:#888;font-size:11px;margin-bottom:6px">1. Chọn kiểu trích dẫn</div>
153
- <div class="mq-types" id="mq-types">
154
- <button class="mq-type-btn active" id="mq-type-about" onclick="setQuoteType('about')">Người nói về người/sự kiện</button>
155
- <button class="mq-type-btn" id="mq-type-speaker" onclick="setQuoteType('speaker')">Người nói nội dung</button>
156
- </div>`;
157
-
158
- // 4. Nhập nội dung
159
- h += `<div style="color:#888;font-size:11px;margin-bottom:6px">2. Nhập nội dung trích dẫn</div>
160
- <textarea id="mq-quote-text" class="mq-textarea" placeholder="Nhập nội dung trích dẫn (sẽ được AI tối ưu hơn nếu cần)..."></textarea>`;
161
-
162
- // 5. Người nói + chức vụ
163
- h += `<div style="color:#888;font-size:11px;margin-bottom:6px">3. Người nói & chức vụ</div>
164
- <div class="mq-inputs">
165
- <input type="text" id="mq-speaker" placeholder="Tên người nói (ví dụ: Lionel Messi)">
166
- <input type="text" id="mq-speaker-role" placeholder="Chức vụ / vai trò (ví dụ: Cầu thủ bóng đá)">
167
- </div>`;
168
-
169
- // 6. Màu chữ
170
- h += `<div style="color:#888;font-size:11px;margin-bottom:6px">4. Màu chữ</div>
171
- <div class="mq-colors" id="mq-colors">
172
- ${['#ffd700','#ffffff','#ff6b6b','#4ecdc4','#45b7d1','#96ceb4','#ffeaa7','#dfe6e9','#fd79a8'].map((c, i) => `<button class="mq-color-btn${i===0?' active':''}" style="background:${c}" onclick="selectQuoteColor('${c}')"></button>`).join('')}
173
- </div>`;
174
-
175
- // Actions
176
- h += `<div class="mq-actions">
177
- <button class="secondary" onclick="closeModalQuote()">Hủy</button>
178
- <button class="primary" onclick="generateQuoteModal()">✏️ Tạo ảnh trích dẫn</button>
179
- </div></div></div>`;
180
-
181
- document.body.insertAdjacentHTML('beforeend', h);
182
-
183
- // Attach CSS
184
- const css = document.createElement('style');
185
- css.id = 'modal-quote-css';
186
- css.textContent = MODAL_QUOTE_CSS;
187
- document.head.appendChild(css);
188
-
189
- // Update preview on input
190
- const ta = document.getElementById('mq-quote-text');
191
- if(ta) ta.addEventListener('input', updateQuotePreview);
192
- const sp = document.getElementById('mq-speaker');
193
- if(sp) sp.addEventListener('input', updateQuotePreview);
194
- const role = document.getElementById('mq-speaker-role');
195
- if(role) role.addEventListener('input', updateQuotePreview);
196
- }
197
-
198
- function openFileSelector() {
199
- const input = document.getElementById('mq-file-input');
200
- if(input) input.click();
201
- }
202
-
203
- function mqHandleFileSelect(input) {
204
- const file = input.files[0];
205
- if(!file) return;
206
- const url = URL.createObjectURL(file);
207
- const img = document.getElementById('mq-preview-img');
208
- if(img){ img.src = url; _quoteState.currentImgIdx = 0; _quoteState.images = [url]; }
209
- updateQuotePreview();
210
- }
211
-
212
- function mqUseDefaultImage() {
213
- const post = _wallPosts[0];
214
- const imgUrl = (post && post.img) || 'https://images.unsplash.com/photo-1508098682722-e99c43a406b2?w=900&q=60';
215
- const img = document.getElementById('mq-preview-img');
216
- if(img){ img.src = _proxyImg(imgUrl); _quoteState.currentImgIdx = 0; _quoteState.images = [imgUrl]; }
217
- updateQuotePreview();
218
- }
219
-
220
- function setQuoteType(type) {
221
- _currentQuoteType = type;
222
- document.getElementById('mq-type-about').classList.toggle('active', type === 'about');
223
- document.getElementById('mq-type-speaker').classList.toggle('active', type !== 'about');
224
- updateQuotePreview();
225
- }
226
-
227
- function selectQuoteColor(hex) {
228
- _currentQuoteColor = hex;
229
- document.querySelectorAll('.mq-color-btn').forEach(btn => {
230
- const rgb = window.getComputedStyle(btn).color;
231
- btn.classList.toggle('active', rgb === hex);
232
- });
233
- updateQuotePreview();
234
- }
235
-
236
- function rgbToHex(rgb) {
237
- if (!rgb || rgb === 'transparent') return '#000000';
238
- rgb = rgb.match(/^rgb[^()]+$/);
239
- if (rgb) {
240
- const nums = rgb[0].match(/\d+/g);
241
- if (!nums) return '#000000';
242
- const [r, g, b] = nums.map(x => parseInt(x, 10));
243
- const toHex = (x) => {
244
- const h = x.toString(16);
245
- return h.length === 1 ? '0' + h : h;
246
- };
247
- return '#' + [toHex(r), toHex(g), toHex(b)].join('');
248
- }
249
- return rgb.replace('#', '');
250
- }
251
-
252
- // ===== Update preview =====
253
- function updateQuotePreview() {
254
- const idx = _quoteState.currentImgIdx;
255
- if(idx < 0 || !_quoteState.images[idx]) return;
256
-
257
- const text = document.getElementById('mq-quote-text')?.value || '';
258
- const speaker = document.getElementById('mq-speaker')?.value || 'Ẩn danh';
259
- const role = document.getElementById('mq-speaker-role')?.value || '';
260
-
261
- const typeLabel = _currentQuoteType === 'about'
262
- ? '🗣️ Người nói về người/sự kiện trong ảnh'
263
- : '👤 Người nói nội dung trích dẫn';
264
-
265
- const previewEl = document.getElementById('mq-preview');
266
- if(previewEl) {
267
- previewEl.innerHTML = `
268
- <img src="${_proxyImg(_quoteState.images[idx])}" style="height:200px">
269
- <div style="padding:10px;background:#1a1a1a;border-top:1px solid #2a2a2a">
270
- <div style="color:#5cb87a;font-size:10px;margin-bottom:4px">${typeLabel}</div>
271
- <div style="color:${_currentQuoteColor};font-size:12px;line-height:1.4">${text || 'Chưa nhập trích dẫn...'}</div>
272
- <div style="color:#ccc;font-size:12px;margin-top:4px;text-align:right">— ${speaker}</div>
273
- ${role ? `<div style="color:#888;font-size:10px;text-align:right">${role}</div>` : ''}
274
- </div>
275
- `;
276
- }
277
- }
278
-
279
- // ===== Generate Quote Modal =====
280
- async function generateQuoteModal() {
281
- const idx = _quoteState.currentImgIdx;
282
- if(idx < 0 || !_quoteState.images[idx]) {
283
- alert('Vui lòng chọn một ảnh!');
284
- return;
285
- }
286
-
287
- const text = document.getElementById('mq-quote-text')?.value || '';
288
- if(!text.trim()) {
289
- alert('Vui lòng nhập nội dung trích dẫn!');
290
- return;
291
- }
292
-
293
- const speaker = document.getElementById('mq-speaker')?.value || 'Ẩn danh';
294
- const role = document.getElementById('mq-speaker-role')?.value || '';
295
- const imgUrl = _quoteState.images[idx];
296
-
297
- // Upload to wall as a quote post
298
- const post = {
299
- id: 'quote-' + Date.now(),
300
- title: `"${text}"`,
301
- text: text,
302
- img: imgUrl,
303
- url: '',
304
- slides: [{
305
- text: text,
306
- image: imgUrl,
307
- index: 1,
308
- }],
309
- kind: 'quote_modal',
310
- voice: 'vi-VN-HoaiMyNeural',
311
- emotion: detectEmotion(text),
312
- language: 'vi',
313
- quote_type: _currentQuoteType,
314
- speaker: speaker,
315
- speaker_role: role,
316
- color: _currentQuoteColor,
317
- ts: Math.floor(Date.now() / 1000),
318
- };
319
-
320
- if(typeof prependWallPost === 'function') {
321
- prependWallPost(post);
322
- } else if(Array.isArray(_wallPosts)) {
323
- _wallPosts.unshift(post);
324
- }
325
-
326
- toast('✅ Đã tạo ảnh trích dẫn và đăng lên Tường AI!');
327
- closeModalQuote();
328
- }
329
-
330
- // ===== Preview full size =====
331
- function previewQuoteModal() {
332
- const idx = _quoteState.currentImgIdx;
333
- if(idx < 0 || !_quoteState.images[idx]) return;
334
-
335
- const text = document.getElementById('mq-quote-text')?.value || '';
336
- const speaker = document.getElementById('mq-speaker')?.value || 'Ẩn danh';
337
- const role = document.getElementById('mq-speaker-role')?.value || '';
338
-
339
- // Show full preview in overlay
340
- const previewOverlay = document.createElement('div');
341
- previewOverlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.9);z-index:99998;display:flex;align-items:center;justify-content:center;padding:12px';
342
- previewOverlay.innerHTML = `
343
- <div style="max-width:90%;max-height:80vh;text-align:center">
344
- <img src="${_proxyImg(_quoteState.images[idx])}" style="max-width:100%;max-height:60vh;border-radius:12px;border:2px solid #5cb87a">
345
- <div style="margin-top:12px;padding:12px;background:#1a1a1a;border-radius:10px;border:1px solid #2a2a2a">
346
- <div style="color:#5cb87a;font-size:13px;font-weight:700;margin-bottom:8px">
347
- ${_currentQuoteType === 'about' ? '🗣️ Người nói về người/sự kiện' : '👤 Người nói nội dung trích dẫn'}
348
- </div>
349
- <div style="color:#fff;font-size:15px;line-height:1.5;margin-bottom:8px">${text}</div>
350
- <div style="color:${_currentQuoteColor};font-size:14px;font-weight:700">— ${speaker}</div>
351
- ${role ? `<div style="color:#888;font-size:12px">${role}</div>` : ''}
352
- </div>
353
- <button onclick="this.closest('div').parentElement.remove()"
354
- style="position:absolute;top:8px;right:8px;background:none;border:0;color:#fff;font-size:24px;cursor:pointer">&times;</button>
355
- </div>
356
- `;
357
- document.body.appendChild(previewOverlay);
358
- }
359
-
360
- // ===== Open Grid Modal Designer =====
361
- function openModalGridDesigner(postIdx) {
362
- const p = _wallPosts[postIdx];
363
- if(!p) return;
364
-
365
- // Inject CSS
366
- if(!document.getElementById('modal-grid-css')) {
367
- document.head.insertAdjacentHTML('beforeend', MODAL_GRID_CSS);
368
- }
369
-
370
- // Reset grid quotes at module scope BEFORE rebuilding the modal
371
- _gridQuotes = {};
372
-
373
- // Get images (SP onwards = current image + any additional images)
374
- const images = [];
375
- if(p.img && p.img) images.push(p.img);
376
- if(p.images && p.images.length) images.push(...p.images);
377
-
378
- // Take up to 4 images (SP onwards)
379
- const gridImages = images.slice(0, 4);
380
-
381
- let h = `<div class="modal-grid-overlay" id="modal-grid-overlay">
382
- <div class="modal-grid-box">
383
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
384
- <h3 style="color:#5cb87a;font-size:16px;font-weight:700;margin:0">📊 Thiết kế ảnh lưới (tối đa 4 ảnh)</h3>
385
- <button onclick="closeModalGrid()" style="background:none;border:0;color:#fff;font-size:24px;cursor:pointer">&times;</button>
386
- </div>`;
387
-
388
- // Image list
389
- gridImages.forEach((img, i) => {
390
- const quote = _gridQuotes[i] || '';
391
- h += `<div class="mg-image-item">
392
- <img src="${_proxyImg(img)}" class="mg-image-preview">
393
- <input type="hidden" class="mg-img-url" value="${img}">
394
- <textarea class="mg-quote-input" placeholder="Nhập trích dẫn cho ảnh ${i+1}..."
395
- oninput="_gridQuotes[${i}]=this.value">${_esc(quote)}</textarea>
396
- </div>`;
397
- });
398
-
399
- // Status
400
- h += `<div style="color:#888;font-size:11px;text-align:center;margin:10px 0" id="mg-status"></div>`;
401
-
402
- // Actions
403
- h += `<div class="mq-actions">
404
- <button class="secondary" onclick="closeModalGrid()">Hủy</button>
405
- <button class="primary" onclick="generateGridModal()">🎨 Tạo ảnh lưới</button>
406
- </div></div></div>`;
407
-
408
- document.body.insertAdjacentHTML('beforeend', h);
409
-
410
- // (moved here for safety — grid is built before this line; module-scope _gridQuotes already reset above)
411
- gridImages.forEach((_, i) => {
412
- _gridQuotes[i] = '';
413
- });
414
- }
415
-
416
- // ===== Close Grid Modal =====
417
- function closeModalGrid() {
418
- document.getElementById('modal-grid-overlay')?.remove();
419
- }
420
-
421
- // ===== Generate Grid Modal =====
422
- async function generateGridModal() {
423
- const items = document.querySelectorAll('.mg-image-item');
424
- if(!items.length) return;
425
-
426
- const gridData = [];
427
- items.forEach((item, i) => {
428
- const imgUrl = item.querySelector('.mg-img-url').value;
429
- const quote = _gridQuotes[i] || item.querySelector('.mg-quote-input').value;
430
- if(imgUrl) {
431
- gridData.push({ img: imgUrl, quote: quote || `Nội dung ảnh ${i+1}`, index: i + 1 });
432
- }
433
- });
434
-
435
- if(!gridData.length) return;
436
-
437
- // Create a wall post with all grid items
438
- const post = {
439
- id: 'grid-' + Date.now(),
440
- title: '📊 Tóm tắt ảnh nhanh',
441
- text: gridData.map(g => `• ${g.quote}`).join('\n'),
442
- img: gridData[0].img,
443
- url: '',
444
- slides: gridData.map(g => ({
445
- text: g.quote,
446
- image: g.img,
447
- index: g.index,
448
- })),
449
- kind: 'grid_modal',
450
- grid_items: gridData,
451
- ts: Math.floor(Date.now() / 1000),
452
- };
453
-
454
- if(typeof prependWallPost === 'function') {
455
- prependWallPost(post);
456
- } else if(Array.isArray(_wallPosts)) {
457
- _wallPosts.unshift(post);
458
- }
459
-
460
- toast('✅ Đã tạo ảnh lưới và đăng lên Tường AI!');
461
- closeModalGrid();
462
- }
463
-
464
- // ===== Escape helper (renamed from 'escape' — global escape only escapes ') =====
465
- function _esc(s) {
466
- return String(s || '').replace(/[&<>"']/g, function(m) {
467
- return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m];
468
- });
469
- }
470
-
471
- // ===== Init =====
472
- console.log('VNEWS Modal Quote Design v1.0 loaded - Style 1: Trích dẫn, Style 2: Lưới ảnh');
473
-
474
- // ===== Expose to global scope (called from app_v2.js / designer_v2.js) =====
475
- if (typeof window !== 'undefined') {
476
- window.openModalQuoteDesigner = openModalQuoteDesigner;
477
- window.closeModalQuote = closeModalQuote;
478
- window.openModalGridDesigner = openModalGridDesigner;
479
- window.closeModalGrid = closeModalGrid;
480
- window.setQuoteType = setQuoteType;
481
- window.selectQuoteColor = selectQuoteColor;
482
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/playlist.m3u DELETED
The diff for this file is too large to render. See raw diff
 
static/playlist_raw.m3u DELETED
The diff for this file is too large to render. See raw diff
 
static/post_image_designer.js DELETED
The diff for this file is too large to render. See raw diff
 
static/tv_default.png DELETED
Binary file (3.63 kB)
 
static/vtv_init.js CHANGED
@@ -1,4 +1,4 @@
1
- // ===== VNEWS VTV Player v11 + FPT Play — Simple toggle like VTV6 =====
2
  (function() {
3
  if (window._vtvInitLoaded) return;
4
  window._vtvInitLoaded = true;
@@ -10,19 +10,14 @@
10
  {id:'vtv7',name:'VTV7',badge:'Giáo Dục'},{id:'vtv8',name:'VTV8',badge:'Miền Trung'},
11
  {id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
12
  {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
13
- {id:'fpt-the-thao',name:'FPT',badge:'Thể Thao'},
14
  ];
15
 
16
- // FPT Play iframe source — lấy từ iframe #1 của https://hd1.xemtv.net/kenh-fptplay-truc-tuyen-the-thao.html
17
- // Trang đó có 3 iframe: ads/below1.php (quảng cáo), ads/below.php (quảng cáo), kenh/fptplay.php (stream chính)
18
- var _fptIfUrl = 'https://hd1.xemtv.net/kenh/fptplay.php';
19
-
20
  var _currentCh = null;
21
  var _epgTimer = null;
22
  var _epgData = [];
23
  var _timeTimer = null;
24
  var _serverDate = '';
25
- var _nativeMode = false;
26
  var _hls = null;
27
 
28
  // ===== CSS =====
@@ -43,8 +38,6 @@
43
  '#vtv-player-section .vtv-pro-tab{padding:5px 10px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);border-radius:8px;color:#9aa0a6;font-size:10px;font-weight:500;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s ease}',
44
  '#vtv-player-section .vtv-pro-tab:hover{background:rgba(0,102,204,.1);border-color:rgba(0,102,204,.3);color:#e8eaed}',
45
  '#vtv-player-section .vtv-pro-tab.on{background:rgba(0,102,204,.2);border-color:#0066cc;color:#fff;font-weight:600}',
46
- '#vtv-player-section .vtv-pro-tab.fpt{background:rgba(255,100,0,.04);border-color:rgba(255,120,0,.08);color:#ffa866}',
47
- '#vtv-player-section .vtv-pro-tab.fpt.on{background:rgba(255,100,0,.2);border-color:#ff6400;color:#fff}',
48
  '#vtv-player-section .vtv-pro-tab .b{font-size:7px;opacity:.5;display:block;margin-top:1px}',
49
  '#vtv-player-section .vtv-pro-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:200px;border-top:1px solid rgba(255,255,255,.04)}',
50
  '#vtv-player-section .vtv-pro-frame iframe{position:absolute;inset:0;width:100%!important;height:100%!important;border:none;background:#000}',
@@ -116,7 +109,6 @@
116
 
117
  // ===== EPG =====
118
  function loadEPG(chId) {
119
- if (chId.indexOf('fpt') >= 0) return; // FPT ko có EPG
120
  var epgEl = document.getElementById('vtv-pro-epg-body');
121
  if (!epgEl) return;
122
  epgEl.innerHTML = '<div class="vtv-pro-epg-load"><div class="vtv-pro-spinner" style="width:12px;height:12px;border-width:1.5px;flex-shrink:0"></div>Đang tải lịch...</div>';
@@ -157,7 +149,7 @@
157
  var cls = 'vtv-pro-epg-row';
158
  if (p.now) cls += ' now';
159
  else if (p.time < nowStr) cls += ' passed';
160
- var xt = p.end_time ? ' -> ' + p.end_time : (i < _epgData.length - 1 ? ' -> ' + _epgData[i+1].time : '');
161
  var db = '';
162
  if (p.date && p.date !== prevDate) { db = '<span class="d">' + p.date + '</span>'; prevDate = p.date; }
163
  html += '<div class="' + cls + '"><span class="t">' + p.time + xt + '</span><span class="bar"></span><span class="n">' + p.title + '</span>' + db + '</div>';
@@ -169,7 +161,7 @@
169
 
170
  function startEpgRefresh() {
171
  if (_epgTimer) clearInterval(_epgTimer);
172
- _epgTimer = setInterval(function() { if (_currentCh && _currentCh.indexOf('fpt') < 0) loadEPG(_currentCh); }, 60000);
173
  }
174
 
175
  function updateClock() {
@@ -189,176 +181,139 @@
189
  }
190
 
191
  // ===== Play native HLS via proxy =====
192
- function playNativeHLS(m3u8Url) {
193
  destroyHLS();
194
  var frame = document.getElementById('vtv-pro-frame');
195
  if (!frame) return;
196
  frame.innerHTML = '<video id="vtv-native-video" controls autoplay playsinline webkit-playsinline style="position:absolute;inset:0;width:100%;height:100%;background:#000"></video>';
 
197
  var video = document.getElementById('vtv-native-video');
198
  if (!video) return;
 
 
199
  if (typeof Hls !== 'undefined' && Hls.isSupported()) {
200
- _hls = new Hls({ enableWorker: false, liveDurationInfinity: true, liveSyncDurationCount: 5, maxBufferLength: 60, maxMaxBufferLength: 240 });
201
- _hls.loadSource(m3u8Url);
202
  _hls.attachMedia(video);
203
  _hls.on(Hls.Events.MANIFEST_PARSED, function() {
204
  video.play().catch(function(){});
205
  });
206
  _hls.on(Hls.Events.ERROR, function(evt, data) {
207
  if (data.fatal) {
 
208
  destroyHLS();
209
- video.src = m3u8Url;
210
  video.play().catch(function(){});
211
  }
212
  });
213
  } else {
214
- video.src = m3u8Url;
 
215
  }
216
  }
217
 
218
- // ===== Load iframe into frame =====
219
- function loadIframe(iframeUrl) {
220
- destroyHLS();
 
 
 
 
221
  var frame = document.getElementById('vtv-pro-frame');
222
  if (!frame) return;
223
- var iframe = document.createElement('iframe');
224
- iframe.src = iframeUrl;
225
- iframe.setAttribute('allowfullscreen', 'true');
226
- iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
227
- iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none;background:#000';
228
- frame.innerHTML = '';
229
- frame.appendChild(iframe);
230
- }
231
 
232
- // ===== Fetch stream data for any channel including FPT =====
233
- function fetchStreamData(chId, callback) {
234
- if (chId.indexOf('fpt') >= 0) {
235
- // FPT Play: use iframe #1 from hd1.xemtv.net page (kenh/fptplay.php)
236
- callback({ iframe_url: _fptIfUrl });
237
- return;
238
- }
239
- // All other channels: use server API
240
  var xhr = new XMLHttpRequest();
241
- xhr.open('GET', '/api/vtv/stream/' + chId, true);
242
  xhr.timeout = 15000;
243
  xhr.onload = function() {
244
  try {
245
- callback(JSON.parse(xhr.responseText));
246
- } catch(e) { callback(null); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  };
248
- xhr.onerror = xhr.ontimeout = function() { callback(null); };
249
  xhr.send();
250
- }
251
 
252
  // ===== Switch channel =====
253
  window._vtvProSwitch = function(chId) {
254
  _currentCh = chId;
255
  _nativeMode = false;
256
 
257
- // Update tabs
258
  var tabs = document.querySelectorAll('#vtv-player-section .vtv-pro-tab');
259
- for (var i = 0; i < tabs.length; i++) {
260
- var t = tabs[i];
261
- var isActive = (t.getAttribute('data-ch') === chId);
262
- t.className = 'vtv-pro-tab' + (isActive ? ' on' : '') + (isActive && chId.indexOf('fpt') >= 0 ? ' fpt on' : '');
263
- }
264
 
 
265
  var toggleBtn = document.getElementById('vtv-pro-toggle-btn');
 
266
 
267
- // Show/hide toggle button (same for all channels including FPT)
268
- if (toggleBtn) {
269
- toggleBtn.style.display = '';
270
- toggleBtn.classList.remove('active');
271
- toggleBtn.innerHTML = '\ud83d\udd0c JWPlayer';
272
- }
273
-
274
- // Loading state
275
- var frame = document.getElementById('vtv-pro-frame');
276
- if (frame) frame.innerHTML = '<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Đang kết nối ' + chId.toUpperCase() + '...</span></div>';
277
- destroyHLS();
278
  updateClock();
279
- loadEPG(chId);
280
- startEpgRefresh();
281
 
282
- // Fetch stream and load
283
- fetchStreamData(chId, function(data) {
284
- if (!data) {
285
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">\ud83d\udce1</div><div class="msg">Kênh không khả dụng</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
286
- return;
287
- }
288
- // VTV channels: prefer iframe (JWPlayer), but fallback to native HLS
289
- // if the iframe fails to load (e.g. geo-blocked or down).
290
- if (data.iframe_url) {
291
- loadIframe(data.iframe_url);
292
- // Set a timeout: if the iframe hasn't produced a video element or
293
- // remains empty after a few seconds, fall back to native HLS.
294
- setTimeout(function() {
295
- var iframeEl = frame.querySelector('iframe');
296
- if (!iframeEl && (data.m3u_hls_proxy_url || data.m3u8_proxy_url || data.stream_url)) {
297
- _nativeMode = true;
298
- var btn = document.getElementById('vtv-pro-toggle-btn');
299
- if (btn) { btn.classList.add('active'); btn.innerHTML = '\ud83d\udce1 HLS'; }
300
- playNativeHLS(data.m3u_hls_proxy_url || data.m3u8_proxy_url || data.proxy_url_hls || data.stream_url);
301
- }
302
- }, 6000);
303
- } else if (data.m3u_hls_proxy_url || data.m3u8_proxy_url || data.stream_url) {
304
- // No iframe: go straight to native HLS (via m3u_hls for TS streams)
305
- var hlsUrl = data.m3u_hls_proxy_url || data.m3u8_proxy_url || data.proxy_url_hls || data.stream_url;
306
- _nativeMode = true;
307
- playNativeHLS(hlsUrl);
308
- var toggleBtn = document.getElementById('vtv-pro-toggle-btn');
309
- if (toggleBtn) { toggleBtn.classList.add('active'); toggleBtn.innerHTML = '\ud83d\udce1 HLS'; }
310
- } else {
311
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">\ud83d\udce1</div><div class="msg">Kênh không khả dụng</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
312
- }
313
- });
314
- };
315
-
316
- // ===== Toggle iframe / native HLS (for all channels including FPT) =====
317
- window._vtvToggleNative = function(btn) {
318
- if (!_currentCh) return;
319
- _nativeMode = !_nativeMode;
320
- btn.classList.toggle('active', _nativeMode);
321
- btn.innerHTML = _nativeMode ? '\ud83d\udce1 HLS' : '\ud83d\udd0c JWPlayer';
322
 
323
  var frame = document.getElementById('vtv-pro-frame');
324
  if (!frame) return;
 
325
 
326
- if (_currentCh.indexOf('fpt') >= 0) {
327
- // FPT mode: toggle between iframe and HLS
328
- if (_nativeMode) {
329
- // Switch to HLS
330
- destroyHLS();
331
- playNativeHLS('/api/proxy/m3u8?url=https://live.fptplay53.net/epzhd1/htvcthethao_vhls.smil/chunklist.m3u8');
332
- } else {
333
- // Switch back to iframe
334
- loadIframe(_fptIfUrl);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  }
336
- } else {
337
- // VTV mode: toggle via server
338
- var xhr = new XMLHttpRequest();
339
- xhr.open('GET', '/api/vtv/stream/' + _currentCh, true);
340
- xhr.timeout = 15000;
341
- xhr.onload = function() {
342
- try {
343
- var data = JSON.parse(xhr.responseText);
344
- if (_nativeMode) {
345
- var m3u8Url = data.m3u_hls_proxy_url || data.m3u8_proxy_url || data.proxy_url_hls || data.stream_url;
346
- if (m3u8Url) {
347
- playNativeHLS(m3u8Url);
348
- } else {
349
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">\ud83d\udce1</div><div class="msg">Kênh không hỗ trợ HLS</div></div>';
350
- _nativeMode = false;
351
- btn.classList.remove('active');
352
- btn.innerHTML = '\ud83d\udd0c JWPlayer';
353
- }
354
- } else {
355
- if (data.iframe_url) loadIframe(data.iframe_url);
356
- }
357
- } catch(e) { frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">\u26a0\ufe0f</div></div>'; }
358
- };
359
- xhr.onerror = function() { frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">\ud83d\udd0c</div><div class="msg">Lỗi kết nối</div></div>'; };
360
- xhr.send();
361
- }
362
  };
363
 
364
  // ===== HTML builder =====
@@ -366,8 +321,7 @@
366
  var tabs = '';
367
  for (var i = 0; i < CHANNELS.length; i++) {
368
  var ch = CHANNELS[i];
369
- var isFpt = ch.id.indexOf('fpt') >= 0;
370
- tabs += '<span class="vtv-pro-tab' + (isFpt ? ' fpt' : '') + '" data-ch="'+ch.id+'" onclick="_vtvProSwitch(\''+ch.id+'\')" ' + (isFpt ? 'data-fpt="true"' : '') + '>' +
371
  ch.name + '<span class="b">'+ch.badge+'</span></span>';
372
  }
373
  return '<div id="vtv-player-section">' +
@@ -382,10 +336,10 @@
382
  '</div>' +
383
  '<div class="vtv-pro-controls">' +
384
  '<span id="vtv-pro-time" class="vtv-pro-btn" style="background:none;border:none;font-size:10px;color:#5f6368;margin-right:auto"></span>' +
385
- '<span class="vtv-pro-switch" id="vtv-pro-toggle-btn" onclick="_vtvToggleNative(this)">\ud83d\udd0c JWPlayer</span>' +
386
  '</div>' +
387
  '<div class="vtv-pro-epg">' +
388
- '<div class="vtv-pro-epg-hdr"><span class="vtv-pro-epg-title">Lịch phát sóng \u2014 nguồn VTV.vn</span></div>' +
389
  '<div class="vtv-pro-epg-list" id="vtv-pro-epg-body"><div class="vtv-pro-epg-empty">Chọn kênh để xem lịch phát sóng</div></div>' +
390
  '</div>' +
391
  '</div>';
@@ -413,4 +367,4 @@
413
  else if (!document.getElementById('vtv-player-section')) setTimeout(waitAndInject, 1000);
414
  })();
415
  }
416
- })();
 
1
+ // ===== VNEWS VTV Player v11 — SV2 iframe + HLS.js native player toggle =====
2
  (function() {
3
  if (window._vtvInitLoaded) return;
4
  window._vtvInitLoaded = true;
 
10
  {id:'vtv7',name:'VTV7',badge:'Giáo Dục'},{id:'vtv8',name:'VTV8',badge:'Miền Trung'},
11
  {id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
12
  {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
 
13
  ];
14
 
 
 
 
 
15
  var _currentCh = null;
16
  var _epgTimer = null;
17
  var _epgData = [];
18
  var _timeTimer = null;
19
  var _serverDate = '';
20
+ var _nativeMode = false; // false=iframe, true=HLSjs
21
  var _hls = null;
22
 
23
  // ===== CSS =====
 
38
  '#vtv-player-section .vtv-pro-tab{padding:5px 10px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);border-radius:8px;color:#9aa0a6;font-size:10px;font-weight:500;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s ease}',
39
  '#vtv-player-section .vtv-pro-tab:hover{background:rgba(0,102,204,.1);border-color:rgba(0,102,204,.3);color:#e8eaed}',
40
  '#vtv-player-section .vtv-pro-tab.on{background:rgba(0,102,204,.2);border-color:#0066cc;color:#fff;font-weight:600}',
 
 
41
  '#vtv-player-section .vtv-pro-tab .b{font-size:7px;opacity:.5;display:block;margin-top:1px}',
42
  '#vtv-player-section .vtv-pro-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:200px;border-top:1px solid rgba(255,255,255,.04)}',
43
  '#vtv-player-section .vtv-pro-frame iframe{position:absolute;inset:0;width:100%!important;height:100%!important;border:none;background:#000}',
 
109
 
110
  // ===== EPG =====
111
  function loadEPG(chId) {
 
112
  var epgEl = document.getElementById('vtv-pro-epg-body');
113
  if (!epgEl) return;
114
  epgEl.innerHTML = '<div class="vtv-pro-epg-load"><div class="vtv-pro-spinner" style="width:12px;height:12px;border-width:1.5px;flex-shrink:0"></div>Đang tải lịch...</div>';
 
149
  var cls = 'vtv-pro-epg-row';
150
  if (p.now) cls += ' now';
151
  else if (p.time < nowStr) cls += ' passed';
152
+ var xt = p.end_time ? ' → ' + p.end_time : (i < _epgData.length - 1 ? ' → ' + _epgData[i+1].time : '');
153
  var db = '';
154
  if (p.date && p.date !== prevDate) { db = '<span class="d">' + p.date + '</span>'; prevDate = p.date; }
155
  html += '<div class="' + cls + '"><span class="t">' + p.time + xt + '</span><span class="bar"></span><span class="n">' + p.title + '</span>' + db + '</div>';
 
161
 
162
  function startEpgRefresh() {
163
  if (_epgTimer) clearInterval(_epgTimer);
164
+ _epgTimer = setInterval(function() { if (_currentCh) loadEPG(_currentCh); }, 60000);
165
  }
166
 
167
  function updateClock() {
 
181
  }
182
 
183
  // ===== Play native HLS via proxy =====
184
+ function playNativeHLS(chId, m3u8ProxyUrl) {
185
  destroyHLS();
186
  var frame = document.getElementById('vtv-pro-frame');
187
  if (!frame) return;
188
  frame.innerHTML = '<video id="vtv-native-video" controls autoplay playsinline webkit-playsinline style="position:absolute;inset:0;width:100%;height:100%;background:#000"></video>';
189
+
190
  var video = document.getElementById('vtv-native-video');
191
  if (!video) return;
192
+
193
+ // Try HLS.js if available (injected via index_v2.html)
194
  if (typeof Hls !== 'undefined' && Hls.isSupported()) {
195
+ _hls = new Hls({ enableWorker: false });
196
+ _hls.loadSource(m3u8ProxyUrl);
197
  _hls.attachMedia(video);
198
  _hls.on(Hls.Events.MANIFEST_PARSED, function() {
199
  video.play().catch(function(){});
200
  });
201
  _hls.on(Hls.Events.ERROR, function(evt, data) {
202
  if (data.fatal) {
203
+ // Fallback: direct video src
204
  destroyHLS();
205
+ video.src = m3u8ProxyUrl;
206
  video.play().catch(function(){});
207
  }
208
  });
209
  } else {
210
+ // Native HLS fallback (Safari/iOS)
211
+ video.src = m3u8ProxyUrl;
212
  }
213
  }
214
 
215
+ // ===== Toggle iframe / native HLS =====
216
+ window._vtvToggleNative = function(btn) {
217
+ if (!_currentCh) return;
218
+ _nativeMode = !_nativeMode;
219
+ btn.classList.toggle('active', _nativeMode);
220
+ btn.innerHTML = _nativeMode ? '📡 HLS' : '🔌 JWPlayer';
221
+
222
  var frame = document.getElementById('vtv-pro-frame');
223
  if (!frame) return;
 
 
 
 
 
 
 
 
224
 
225
+ // Re-fetch stream data then switch
 
 
 
 
 
 
 
226
  var xhr = new XMLHttpRequest();
227
+ xhr.open('GET', '/api/vtv/stream/' + _currentCh, true);
228
  xhr.timeout = 15000;
229
  xhr.onload = function() {
230
  try {
231
+ var data = JSON.parse(xhr.responseText);
232
+ if (_nativeMode) {
233
+ var m3u8Url = data.m3u8_proxy_url || data.proxy_url_hls || data.stream_url;
234
+ if (m3u8Url) {
235
+ playNativeHLS(_currentCh, m3u8Url);
236
+ } else {
237
+ frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">📡</div><div class="msg">Kênh không hỗ trợ HLS</div></div>';
238
+ _nativeMode = false;
239
+ btn.classList.remove('active');
240
+ btn.innerHTML = '🔌 JWPlayer';
241
+ }
242
+ } else {
243
+ var iframeUrl = data.iframe_url;
244
+ if (iframeUrl) {
245
+ destroyHLS();
246
+ var iframe = document.createElement('iframe');
247
+ iframe.src = iframeUrl;
248
+ iframe.setAttribute('allowfullscreen', 'true');
249
+ iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
250
+ iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none;background:#000';
251
+ frame.innerHTML = '';
252
+ frame.appendChild(iframe);
253
+ }
254
+ }
255
+ } catch(e) {
256
+ frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">⚠️</div></div>';
257
+ }
258
+ };
259
+ xhr.onerror = function() {
260
+ frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">🔌</div><div class="msg">Lỗi kết nối</div></div>';
261
  };
 
262
  xhr.send();
263
+ };
264
 
265
  // ===== Switch channel =====
266
  window._vtvProSwitch = function(chId) {
267
  _currentCh = chId;
268
  _nativeMode = false;
269
 
 
270
  var tabs = document.querySelectorAll('#vtv-player-section .vtv-pro-tab');
271
+ for (var i = 0; i < tabs.length; i++)
272
+ tabs[i].className = 'vtv-pro-tab' + (tabs[i].getAttribute('data-ch') === chId ? ' on' : '');
 
 
 
273
 
274
+ // Reset toggle button
275
  var toggleBtn = document.getElementById('vtv-pro-toggle-btn');
276
+ if (toggleBtn) { toggleBtn.classList.remove('active'); toggleBtn.innerHTML = '🔌 JWPlayer'; }
277
 
 
 
 
 
 
 
 
 
 
 
 
278
  updateClock();
279
+ if (_timeTimer) clearInterval(_timeTimer);
280
+ _timeTimer = setInterval(updateClock, 60000);
281
 
282
+ destroyHLS();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
 
284
  var frame = document.getElementById('vtv-pro-frame');
285
  if (!frame) return;
286
+ frame.innerHTML = '<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Đang kết nối ' + chId.toUpperCase() + '...</span></div>';
287
 
288
+ loadEPG(chId);
289
+ startEpgRefresh();
290
+
291
+ var xhr = new XMLHttpRequest();
292
+ xhr.open('GET', '/api/vtv/stream/' + chId, true);
293
+ xhr.timeout = 15000;
294
+ xhr.onload = function() {
295
+ try {
296
+ var data = JSON.parse(xhr.responseText);
297
+ var iframeUrl = data.iframe_url;
298
+ if (!iframeUrl) {
299
+ frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">📡</div><div class="msg">Kênh không khả dụng</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
300
+ return;
301
+ }
302
+ var iframe = document.createElement('iframe');
303
+ iframe.src = iframeUrl;
304
+ iframe.setAttribute('allowfullscreen', 'true');
305
+ iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
306
+ iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none;background:#000';
307
+ frame.innerHTML = '';
308
+ frame.appendChild(iframe);
309
+ } catch(e) {
310
+ frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">⚠️</div><div class="msg">Lỗi tải dữ liệu</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
311
  }
312
+ };
313
+ xhr.onerror = xhr.ontimeout = function() {
314
+ frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">🔌</div><div class="msg">Mất kết nối máy chủ</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
315
+ };
316
+ xhr.send();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  };
318
 
319
  // ===== HTML builder =====
 
321
  var tabs = '';
322
  for (var i = 0; i < CHANNELS.length; i++) {
323
  var ch = CHANNELS[i];
324
+ tabs += '<span class="vtv-pro-tab" data-ch="'+ch.id+'" onclick="_vtvProSwitch(\''+ch.id+'\')">' +
 
325
  ch.name + '<span class="b">'+ch.badge+'</span></span>';
326
  }
327
  return '<div id="vtv-player-section">' +
 
336
  '</div>' +
337
  '<div class="vtv-pro-controls">' +
338
  '<span id="vtv-pro-time" class="vtv-pro-btn" style="background:none;border:none;font-size:10px;color:#5f6368;margin-right:auto"></span>' +
339
+ '<span class="vtv-pro-switch" id="vtv-pro-toggle-btn" onclick="_vtvToggleNative(this)">🔌 JWPlayer</span>' +
340
  '</div>' +
341
  '<div class="vtv-pro-epg">' +
342
+ '<div class="vtv-pro-epg-hdr"><span class="vtv-pro-epg-title">Lịch phát sóng — nguồn VTV.vn</span></div>' +
343
  '<div class="vtv-pro-epg-list" id="vtv-pro-epg-body"><div class="vtv-pro-epg-empty">Chọn kênh để xem lịch phát sóng</div></div>' +
344
  '</div>' +
345
  '</div>';
 
367
  else if (!document.getElementById('vtv-player-section')) setTimeout(waitAndInject, 1000);
368
  })();
369
  }
370
+ })();
static/yt_live.js CHANGED
@@ -1,13 +1,175 @@
1
- // ===== VNEWS — VTV LIVE + Inline Recorder (v9) =====
2
- // NOTE: This file was truncated during a previous edit and broke page load.
3
- // All VTV player functionality now lives in /static/vtv_init.js (v11).
4
- // This stub keeps any legacy references working without errors.
5
- (function() {
6
- 'use strict';
7
- if (window._vtvLiveStubLoaded) return;
8
- window._vtvLiveStubLoaded = true;
9
- // Legacy function names that other modules may check via typeof guards.
10
- window.initYTLive = window.initYTLive || function() {};
11
- window.openYTLive = window.openYTLive || function() {};
12
- window.toggleYTLive = window.toggleYTLive || function() {};
13
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // === VNEWS — VTV LIVE + Inline Recorder v9 ===
2
+ // Features: PiP, mini-player, INLINE RECORDER, VTVGO POPUP players for each channel
3
+ // FIX v10: Correct VTVGO popup URLs for all channels + World Cup 2026 TS stream links
4
+ // FIX v11: Allow clicking off tabs (always clickable), better stream status handling
5
+
6
+ (function(){
7
+ if(window._ytLiveLoaded) return;
8
+ window._ytLiveLoaded = true;
9
+
10
+ const CHANNELS = [
11
+ {id:'vtv1',name:'VTV1',badge:'Tin tức'},{id:'vtv2',name:'VTV2',badge:'Khoa học'},
12
+ {id:'vtv3',name:'VTV3',badge:'Giải trí'},{id:'vtv4',name:'VTV4',badge:'Quốc tế'},
13
+ {id:'vtv5',name:'VTV5',badge:'Miền Nam'},{id:'vtv6',name:'VTV6',badge:'Thanh niên'},
14
+ {id:'vtv7',name:'VTV7',badge:'Giáo dục'},{id:'vtv8',name:'VTV8',badge:'Miền Trung'},
15
+ {id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
16
+ {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
17
+ ];
18
+
19
+ // VTVGO popup URLs for each channel (FIXED v10 - verified correct URLs)
20
+ const VTV_POPUP_URLS = {
21
+ vtv1: 'https://package.vtvgo.vn/channel/vtv1-1,1.html',
22
+ vtv2: 'https://package.vtvgo.vn/channel/vtv2-1,2.html',
23
+ vtv3: 'https://package.vtvgo.vn/channel/vtv3-1,3.html',
24
+ vtv4: 'https://package.vtvgo.vn/channel/vtv4-1,4.html',
25
+ vtv5: 'https://package.vtvgo.vn/channel/vtv5-1,5.html',
26
+ vtv6: 'https://package.vtvgo.vn/channel/vtv6-1,13.html',
27
+ vtv7: 'https://package.vtvgo.vn/channel/vtv7-1,27.html',
28
+ vtv8: 'https://package.vtvgo.vn/channel/vtv8-1,36.html',
29
+ vtv9: 'https://package.vtvgo.vn/channel/vtv9-1,39.html',
30
+ vtv10: 'https://package.vtvgo.vn/channel/vtv10-1,6.html',
31
+ };
32
+
33
+ const DEFAULT_CHANNEL = 'vtv3';
34
+ const NEEDS_PROXY = /fptplay\.net|vtvgo\.vn/;
35
+ const STREAMS = {};
36
+ let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
37
+ let _streamsLoaded = false, _pipActive = false, _miniActive = false, _vtvPinned = false, _vtvPopupActive = false;
38
+
39
+ // ===== RECORDER STATE =====
40
+ const _rec = {
41
+ active: false, startTime: null, endTime: null,
42
+ isRecording: false, recorder: null, chunks: [], blob: null,
43
+ ratio: 'original', _dragMarker: null, _recTimer: null,
44
+ };
45
+
46
+ // ===== STYLES =====
47
+ const style = document.createElement('style');
48
+ style.textContent = `
49
+ .vtv-wrap{position:relative;margin:6px 4px;background:#111;border:1px solid #0066cc;border-radius:10px;overflow:hidden}
50
+ .vtv-head{display:flex;align-items:center;gap:8px;padding:8px 10px;background:linear-gradient(90deg,#003366,#1a1a1a)}
51
+ .vtv-title{font-size:13px;font-weight:800;color:#00ccff}
52
+ .vtv-badge{font-size:10px;font-weight:800;color:#00ccff;animation:vtvp 1.3s infinite}
53
+ @keyframes vtvp{0%,100%{opacity:1}50%{opacity:.3}}
54
+ .vtv-tabs{display:flex;flex-wrap:wrap;gap:3px;padding:6px 8px;overflow-x:auto;scrollbar-width:none;background:#0d1a2a}
55
+ .vtv-tabs::-webkit-scrollbar{display:none}
56
+ .vtv-tab{padding:4px 8px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:10px;color:#8ab4d8;font-size:9px;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s}
57
+ .vtv-tab:hover{background:#0b4a7a;color:#fff}
58
+ .vtv-tab.on{background:#0066cc;border-color:#00ccff;color:#fff;font-weight:700}
59
+ .vtv-tab.off{opacity:.35}
60
+ .vtv-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:180px}
61
+ .vtv-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
62
+ .vtv-err{display:flex;align-items:center;justify-content:center;height:180px;color:#888;font-size:12px;text-align:center;padding:20px;flex-direction:column;gap:8px}
63
+ .vtv-err button{background:#0066cc;border:none;color:#fff;padding:6px 14px;border-radius:8px;font-size:11px;cursor:pointer}
64
+ .vtv-load{display:flex;align-items:center;justify-content:center;height:180px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
65
+ .vtv-spinner{width:24px;height:24px;border:2px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
66
+ @keyframes vtvspin{to{transform:rotate(360deg)}}
67
+ .vtv-controls{display:flex;align-items:center;gap:4px;padding:4px 8px;background:#0d1a2a;border-top:1px solid #1a2a3a}
68
+ .vtv-pip-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px}
69
+ .vtv-pip-btn:hover{background:#0b4a7a;color:#fff}
70
+ .vtv-pip-btn.on{background:#0066cc;border-color:#00ccff;color:#fff}
71
+ .vtv-pip-btn svg{width:12px;height:12px;fill:currentColor}
72
+ .vtv-epg{margin:0;padding:6px 10px;background:#0a1628;border-top:1px solid #1a2a3a}
73
+ .vtv-epg-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
74
+ .vtv-epg-title{font-size:10px;font-weight:700;color:#00ccff}
75
+ .vtv-epg-toggle{background:none;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 8px;border-radius:6px;cursor:pointer}
76
+ .vtv-epg-list{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none;padding-bottom:4px}
77
+ .vtv-epg-list::-webkit-scrollbar{display:none}
78
+ .vtv-epg-item{flex:0 0 auto;padding:3px 6px;background:#1a2a3a;border-radius:4px;font-size:8px;color:#8ab4d8;white-space:nowrap;cursor:pointer}
79
+ .vtv-epg-item:hover{background:#2a4a6a}
80
+ .vtv-epg-item.now{background:#0066cc;color:#fff;font-weight:700}
81
+ .vtv-epg-item .t{font-size:7px;color:#6a8aaa}
82
+ .vtv-epg-item.now .t{color:#aaccee}
83
+ .vtv-epg-item .n{color:#ccc;font-size:8px}
84
+ .vtv-epg-item.now .n{color:#fff}
85
+ .vtv-epg-empty{color:#666;font-size:9px;padding:4px}
86
+ .vtv-epg-loading{color:#00ccff;font-size:9px;padding:4px;display:flex;align-items:center;gap:6px}
87
+ .vtv-epg-sp{width:10px;height:10px;border:1px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
88
+ .vtv-pin-btn{position:absolute;top:6px;right:8px;z-index:5;background:rgba(0,0,0,.5);border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 6px;border-radius:4px;cursor:pointer;font-weight:700;display:flex;align-items:center;gap:3px}
89
+ .vtv-pin-btn:hover{background:#0b4a7a;color:#fff}
90
+ .vtv-pin-btn.pinned{background:#0066cc;border-color:#00ccff;color:#fff}
91
+ .vtv-wrap.vtv-sticky{position:sticky;top:0;z-index:48;transition:all .25s ease;box-shadow:0 4px 24px rgba(0,102,204,.35)}
92
+ .vtv-wrap.vtv-sticky .vtv-epg{display:none}
93
+ .vtv-wrap.vtv-sticky .vtv-controls{display:none}
94
+ .vtv-wrap.vtv-sticky .vtv-tabs{padding:3px 8px}
95
+ .vtv-wrap.vtv-sticky .vtv-tab{padding:3px 7px;font-size:8px}
96
+ .vtv-wrap.vtv-sticky .vtv-frame{max-height:140px;min-height:100px}
97
+ .vtv-wrap.vtv-sticky .vtv-frame video{max-height:140px}
98
+ .vtv-wrap.vtv-sticky .vtv-load{height:100px}
99
+ .vtv-wrap.vtv-sticky .vtv-err{height:100px}
100
+ .vtv-wrap.vtv-sticky .vtv-head{padding:5px 10px}
101
+ .vtv-wrap.vtv-sticky .vtv-title{font-size:11px}
102
+ .vtv-wrap.vtv-sticky .vtv-badge{font-size:8px}
103
+ .vtv-wrap.vtv-sticky .vtv-pin-btn{top:4px;right:6px}
104
+ .vtv-rec-btn{background:#660000;border:1px solid #990000;color:#ff6666;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px;font-weight:700}
105
+ .vtv-rec-btn:hover{background:#990000;color:#fff}
106
+ .vtv-rec-btn.recording{background:#cc0000;border-color:#ff0000;color:#fff;animation:vtv-rec-pulse 1s infinite}
107
+ @keyframes vtv-rec-pulse{0%,100%{opacity:1}50%{opacity:.5}}
108
+ .vtv-rec-btn svg{width:12px;height:12px;fill:currentColor}
109
+ .vtv-inline-rec{display:none;padding:8px 10px;background:#0a0a1a;border-top:1px solid #1a1a3a}
110
+ .vtv-inline-rec.show{display:block}
111
+ .vtv-inline-rec .rec-bar{position:relative;height:32px;background:#000;border-radius:4px;overflow:hidden;cursor:pointer;border:1px solid #2a2a4a;margin-bottom:6px;user-select:none}
112
+ .vtv-inline-rec .rec-bar .rec-progress{position:absolute;top:0;bottom:0;background:rgba(155,89,182,.3);left:0;width:0%;pointer-events:none}
113
+ .vtv-inline-rec .rec-bar .rec-marker{position:absolute;top:0;bottom:0;width:4px;z-index:2;border-radius:2px}
114
+ .vtv-inline-rec .rec-bar .rec-marker.s{background:#2ecc71}
115
+ .vtv-inline-rec .rec-bar .rec-marker.e{background:#e74c3c}
116
+ .vtv-inline-rec .rec-time{display:flex;justify-content:space-between;font-size:9px;color:#888;margin-bottom:6px}
117
+ .vtv-inline-rec .rec-controls{display:flex;gap:4px}
118
+ .vtv-inline-rec .rec-controls button{flex:1;padding:6px;border:none;border-radius:6px;font-size:10px;font-weight:700;cursor:pointer}
119
+ .vtv-inline-rec .rec-controls .rec-set-start{background:#1a3a1a;border:1px solid #2d6a2d;color:#5cb87a}
120
+ .vtv-inline-rec .rec-controls .rec-set-end{background:#3a1a1a;border:1px solid #6a2d2d;color:#e74c3c}
121
+ .vtv-inline-rec .rec-controls .rec-go{background:#1a0a3a;border:1px solid #3a2a6a;color:#9b59b6}
122
+ .vtv-inline-rec .rec-controls .rec-reset{background:#222;border:1px solid #333;color:#888}
123
+ .vtv-inline-rec .rec-hint{font-size:9px;color:#666;text-align:center;margin-top:4px}
124
+ .vtv-inline-rec .rec-status{font-size:10px;color:#888;text-align:center;padding:4px;margin-top:4px;background:#111;border-radius:4px}
125
+ .vtv-inline-rec .rec-status.ok{color:#2ecc71}
126
+ .vtv-inline-rec .rec-status.err{color:#e74c3c}
127
+ .vtv-inline-rec .rec-status.recording{color:#e74c3c;animation:vtv-rec-pulse 1s infinite}
128
+ .vtv-rec-panel{display:none;padding:10px;background:#0d0d20;border-top:1px solid #2a1a4a}
129
+ .vtv-rec-panel.show{display:block}
130
+ .vtv-rec-panel .rec-panel-title{font-size:11px;font-weight:700;color:#9b59b6;margin-bottom:8px}
131
+ .vtv-rec-panel .rec-preview-wrap{margin-bottom:8px;text-align:center}
132
+ .vtv-rec-panel .rec-preview-wrap video{max-width:100%;max-height:180px;border-radius:6px;background:#000}
133
+ .vtv-rec-panel .rec-ratio-row{display:flex;gap:4px;margin-bottom:8px}
134
+ .vtv-rec-panel .rec-ratio-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
135
+ .vtv-rec-panel .rec-ratio-row button.active{border-color:#9b59b6;color:#9b59b6;background:#2a1a4a}
136
+ .vtv-rec-panel .rec-actions{display:flex;gap:4px}
137
+ .vtv-rec-panel .rec-actions button{flex:1;padding:8px;border:none;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer}
138
+ .vtv-rec-panel .rec-actions .rec-download{background:#2d8659;color:#fff}
139
+ .vtv-rec-panel .rec-actions .rec-share{background:#9b59b6;color:#fff}
140
+ .vtv-rec-panel .rec-proc{text-align:center;padding:12px;color:#9b59b6;font-size:12px;display:none}
141
+ .vtv-rec-panel .rec-title-input{width:100%;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;padding:8px;color:#ccc;font-size:11px;margin-bottom:8px;box-sizing:border-box}
142
+ .vtv-rec-panel .rec-title-input:focus{border-color:#9b59b6;outline:none}
143
+ .vtv-rec-panel .rec-ai-title-row{display:flex;gap:4px;margin-bottom:8px}
144
+ .vtv-rec-panel .rec-ai-title-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
145
+ .vtv-rec-panel .rec-ai-title-row button:hover{border-color:#9b59b6;color:#9b59b6}
146
+ .vtv-rec-panel .rec-ai-title-row button:disabled{opacity:.4;cursor:not-allowed}
147
+ .vtv-mini{position:fixed;top:0;left:0;right:0;z-index:99990;background:#000;border-bottom:2px solid #0066cc;box-shadow:0 4px 20px rgba(0,102,204,.4);display:none;transition:transform .3s ease}
148
+ .vtv-mini.show{display:block}
149
+ .vtv-mini.hidden{transform:translateY(-88%)}
150
+ .vtv-mini-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;max-height:180px}
151
+ .vtv-mini-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
152
+ .vtv-mini-bar{display:flex;align-items:center;justify-content:space-between;padding:3px 8px;background:linear-gradient(90deg,#003366,#1a1a1a)}
153
+ .vtv-mini-ch{font-size:10px;font-weight:700;color:#00ccff}
154
+ .vtv-mini-epg{font-size:9px;color:#8ab4d8;max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-left:8px}
155
+ .vtv-mini-btns{display:flex;gap:3px}
156
+ .vtv-mini-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 6px;border-radius:4px;cursor:pointer}
157
+ .vtv-mini-btn:hover{background:#0b4a7a;color:#fff}
158
+ .vtv-mini-btn.x{background:#600;border-color:#900;color:#f66}
159
+ .vtv-mini-btn.x:hover{background:#900;color:#fff}
160
+ .vtv-mini-peek{position:absolute;bottom:-18px;right:10px;background:#0066cc;color:#fff;font-size:9px;padding:2px 8px;border-radius:0 0 6px 6px;cursor:pointer;display:none}
161
+ .vtv-mini.hidden .vtv-mini-peek{display:block}
162
+ .vtv-popup{position:fixed;top:0;left:0;right:0;margin:8px auto 0;background:rgba(0,0,0,.95);z-index:99999;display:none;flex-direction:column;max-height:calc(100vh - 16px);max-width:800px;border-radius:12px;overflow:hidden}
163
+ .vtv-popup.show{display:flex}
164
+ .vtv-popup:has(#vtv-popup-hdr[style*="none"]) .vtv-popup-frame{height:100%}
165
+ .vtv-popup-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#111;border-bottom:1px solid #333}
166
+ .vtv-popup-title{font-size:14px;font-weight:700;color:#00ccff}
167
+ .vtv-popup-close{background:#333;border:none;color:#fff;font-size:18px;width:32px;height:32px;border-radius:6px;cursor:pointer}
168
+ .vtv-popup-frame{flex:1;position:relative;background:#000;min-height:300px}
169
+ .vtv-popup-frame iframe{position:absolute;inset:0;width:100%;height:100%;border:none}
170
+ .vtv-popup-frame iframe{position:absolute;inset:0;width:100%;height:100%;border:none}
171
+ .vtv-popup-loader{display:flex;align-items:center;justify-content:center;height:280px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
172
+ .vtv-popup-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;margin-left:4px}
173
+ .vtv-popup-btn:hover{background:#0b4a7a;color:#fff}
174
+ `;
175
+ document.head.appendChild(style);
vtv_api.py CHANGED
@@ -1,4 +1,4 @@
1
- # VTV Stream — SV2/sv.xemtivitop iframe URLs + M3U fallback + EPG + FPT Play
2
  import re, time, threading, json, requests
3
  from fastapi import APIRouter, Query
4
  from fastapi.responses import JSONResponse, Response, StreamingResponse
@@ -21,6 +21,8 @@ HARDCODED_URLS = {
21
  CHANNEL_URLS = {k: (HARDCODED_URLS[k] if k in HARDCODED_URLS else None) for k in ["vtv1","vtv2","vtv3","vtv4","vtv5","vtv6","vtv7","vtv8","vtv9","vtv10"]}
22
  CHANNEL_NAMES = {"vtv1":"VTV1","vtv2":"VTV2","vtv3":"VTV3","vtv4":"VTV4","vtv5":"VTV5","vtv6":"VTV6","vtv7":"VTV7","vtv8":"VTV8","vtv9":"VTV9","vtv10":"VTV10","vtvprime":"VTVPrime"}
23
 
 
 
24
  IFRAME_URLS = {
25
  "vtv1": "https://sv2.xemtivitop.com/live/hot/vtv1.php",
26
  "vtv2": "https://sv2.xemtivitop.com/live/hot/vtv2.php",
@@ -28,34 +30,20 @@ IFRAME_URLS = {
28
  "vtv4": "https://sv2.xemtivitop.com/live/hot/vtv4.php",
29
  "vtv5": "https://sv2.xemtivitop.com/live/hot/vtv5.php",
30
  "vtv6": "https://sv2.xemtivitop.com/live/hot/vtv6.php",
 
31
  "vtv7": "https://sv.xemtivitop.com/live/vtv/vtv7.php",
32
  "vtv8": "https://sv2.xemtivitop.com/live/hot/vtv8.php",
33
  "vtv9": "https://sv2.xemtivitop.com/live/hot/vtv9.php",
34
  "vtv10": "https://sv2.xemtivitop.com/live/hot/vtv10.php",
35
  }
36
 
37
- # ===== FPT PLAY =====
38
- FPT_PLAY_IFRAMES = {
39
- "fpt-the-thao": "https://hd1.xemtv.net/kenh-fptplay-truc-tuyen-the-thao.html",
40
- }
41
-
42
- FPT_PLAY_STREAM_SELECTORS = {
43
- "fpt-the-thao": {
44
- "label": "FPT Play Thể Thao",
45
- "streams": [
46
- {"label":"📡 IPTV Primary","url":"https://live.fptplay53.net/epzhd1/htvcthethao_mhls.smil/master.m3u8","type":"m3u8"},
47
- {"label":"📡 IPTV Fallback","url":"https://live.fptplay53.net/epzhd1/htvcthethao_vhls.smil/chunklist.m3u8","type":"m3u8"},
48
- {"label":"🔗 hd1.xemtv.net","url":"https://hd1.xemtv.net/kenh-fptplay-truc-tuyen-the-thao.html","type":"iframe","allow":"autoplay; encrypted-media; fullscreen"},
49
- ],
50
- },
51
- }
52
-
53
  M3U_URLS = [
54
- "https://huggingface.co/spaces/bep40/vnews/resolve/main/static/playlist.m3u",
55
  "https://raw.githubusercontent.com/Love4vn/love4vn/main/Out_Iptv_CXT.m3u",
56
  "https://raw.githubusercontent.com/konanda-sg/Test_Love4vn/main/IPTV.m3u",
57
- "https://raw.githubusercontent.com/iptv-org/iptv/master/streams/vn.m3u"
58
  ]
 
59
  _channel_urls_lock = threading.Lock()
60
  _last_m3u_fetch = 0
61
  _M3U_TTL = 900
@@ -105,6 +93,8 @@ def get_channel_url(channel_id):
105
  _fetch_m3u()
106
  with _channel_urls_lock: return CHANNEL_URLS.get(channel_id)
107
 
 
 
108
  @router.get("/api/vtv/streams")
109
  def api_vtv_streams():
110
  urls = dict(CHANNEL_URLS)
@@ -113,34 +103,37 @@ def api_vtv_streams():
113
  stream_url = urls.get(ch_id)
114
  iframe_url = IFRAME_URLS.get(ch_id)
115
  is_flv = stream_url and ".flv" in stream_url
116
- result[ch_id] = {"name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "", "proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "", "is_flv": is_flv, "iframe_url": iframe_url, "status": "ok"}
117
- # Add FPT Play channels
118
- for ch_id in FPT_PLAY_STREAM_SELECTORS:
119
- streams = FPT_PLAY_STREAM_SELECTORS[ch_id]["streams"]
120
- primary = streams[0]["url"] if streams else ""
121
- result[ch_id] = {"name": FPT_PLAY_STREAM_SELECTORS[ch_id]["label"], "stream_url": primary, "m3u8_proxy_url": f"/api/proxy/m3u8?url={quote(primary, safe='')}" if primary else "", "iframe_url": FPT_PLAY_IFRAMES.get(ch_id), "stream_selectors": FPT_PLAY_STREAM_SELECTORS[ch_id], "is_fpt_play": True, "status": "ok"}
 
 
 
122
  return JSONResponse(result)
123
 
124
  @router.get("/api/vtv/stream/{channel_id}")
125
  def api_vtv_stream(channel_id: str):
126
  channel_id = channel_id.lower().strip()
127
- # Check FPT Play FIRST
128
- if channel_id in FPT_PLAY_STREAM_SELECTORS:
129
- streams = FPT_PLAY_STREAM_SELECTORS[channel_id]["streams"]
130
- primary = streams[0]["url"] if streams else ""
131
- return JSONResponse({"name": FPT_PLAY_STREAM_SELECTORS[channel_id]["label"], "stream_url": primary, "m3u8_proxy_url": f"/api/proxy/m3u8?url={quote(primary, safe='')}" if primary else "", "iframe_url": FPT_PLAY_IFRAMES.get(channel_id), "stream_selectors": FPT_PLAY_STREAM_SELECTORS[channel_id], "is_fpt_play": True, "status": "ok"})
132
  if channel_id not in CHANNEL_NAMES: return JSONResponse({"error":"not found"}, status_code=404)
133
  stream_url = get_channel_url(channel_id)
134
  iframe_url = IFRAME_URLS.get(channel_id)
135
  is_flv = stream_url and ".flv" in stream_url
136
  m3u8_proxy = ""
137
- m3u_hls_proxy = ""
138
  if stream_url and not is_flv and stream_url.startswith("http"):
139
  m3u8_proxy = f"/api/proxy/m3u8?url={quote(stream_url, safe='')}"
140
- # For MPEG-TS (.ts) streams, wrap via m3u_hls to generate a proper HLS playlist
141
- if '.m3u8' not in stream_url.lower():
142
- m3u_hls_proxy = f"/api/proxy/m3u_hls?url={quote(stream_url, safe='')}"
143
- return JSONResponse({"name": CHANNEL_NAMES[channel_id], "stream_url": stream_url, "m3u8_proxy_url": m3u8_proxy, "m3u_hls_proxy_url": m3u_hls_proxy, "proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "", "proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "", "is_flv": is_flv, "iframe_url": iframe_url, "status": "ok"})
 
 
 
 
 
 
144
 
145
  @router.get("/api/vtv/m3u/refresh")
146
  def api_vtv_m3u_refresh():
@@ -151,6 +144,7 @@ def api_vtv_m3u_refresh():
151
  found = sum(1 for v in urls.values() if v)
152
  return JSONResponse({"status":"refreshed","channels_found":found,"channels":{k:v for k,v in urls.items() if v}})
153
 
 
154
  _epg_cache = {}; _epg_cache_time = 0; _EPG_CACHE_TTL = 600
155
  VTV_CHANNEL_MAP = {"vtv1":"vtv1","vtv2":"vtv2","vtv3":"vtv3","vtv4":"vtv4","vtv5":"vtv5","vtv5-tay-nam-bo":"vtv5","vtv5-tay-nguyen":"vtv5","vtv6":"vtv6","vtv7":"vtv7","vtv8":"vtv8","vtv9":"vtv9","vtv-can-tho":"vtv10"}
156
 
@@ -230,4 +224,4 @@ def api_vtv_epg_refresh():
230
  global _epg_cache, _epg_cache_time
231
  _epg_cache = {}; _epg_cache_time = 0
232
  epg_data = _fetch_epg_from_vtv()
233
- return JSONResponse({"status":"refreshed","channels":len(epg_data),"total":sum(len(v) for v in epg_data.values())})
 
1
+ # VTV Stream — SV2/sv.xemtivitop iframe URLs + M3U fallback + EPG
2
  import re, time, threading, json, requests
3
  from fastapi import APIRouter, Query
4
  from fastapi.responses import JSONResponse, Response, StreamingResponse
 
21
  CHANNEL_URLS = {k: (HARDCODED_URLS[k] if k in HARDCODED_URLS else None) for k in ["vtv1","vtv2","vtv3","vtv4","vtv5","vtv6","vtv7","vtv8","vtv9","vtv10"]}
22
  CHANNEL_NAMES = {"vtv1":"VTV1","vtv2":"VTV2","vtv3":"VTV3","vtv4":"VTV4","vtv5":"VTV5","vtv6":"VTV6","vtv7":"VTV7","vtv8":"VTV8","vtv9":"VTV9","vtv10":"VTV10","vtvprime":"VTVPrime"}
23
 
24
+ # Iframe URLs cho từng kênh — lấy từ xemtivitop.com
25
+ # Với VTV7: LINK 1 = blogspot (lỗi), LINK 2 = sv.xemtivitop.com (JWPlayer, hoạt động)
26
  IFRAME_URLS = {
27
  "vtv1": "https://sv2.xemtivitop.com/live/hot/vtv1.php",
28
  "vtv2": "https://sv2.xemtivitop.com/live/hot/vtv2.php",
 
30
  "vtv4": "https://sv2.xemtivitop.com/live/hot/vtv4.php",
31
  "vtv5": "https://sv2.xemtivitop.com/live/hot/vtv5.php",
32
  "vtv6": "https://sv2.xemtivitop.com/live/hot/vtv6.php",
33
+ # VTV7: LINK 2 trên xemtivitop.com — sv.xemtivitop.com (ko phải sv2), JWPlayer, cho phép iframe
34
  "vtv7": "https://sv.xemtivitop.com/live/vtv/vtv7.php",
35
  "vtv8": "https://sv2.xemtivitop.com/live/hot/vtv8.php",
36
  "vtv9": "https://sv2.xemtivitop.com/live/hot/vtv9.php",
37
  "vtv10": "https://sv2.xemtivitop.com/live/hot/vtv10.php",
38
  }
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  M3U_URLS = [
41
+ "https://raw.githubusercontent.com/Love4vn/Test/refs/heads/main/IPTV.m3u",
42
  "https://raw.githubusercontent.com/Love4vn/love4vn/main/Out_Iptv_CXT.m3u",
43
  "https://raw.githubusercontent.com/konanda-sg/Test_Love4vn/main/IPTV.m3u",
44
+ "https://raw.githubusercontent.com/iptv-org/iptv/master/streams/vn.m3u",
45
  ]
46
+
47
  _channel_urls_lock = threading.Lock()
48
  _last_m3u_fetch = 0
49
  _M3U_TTL = 900
 
93
  _fetch_m3u()
94
  with _channel_urls_lock: return CHANNEL_URLS.get(channel_id)
95
 
96
+ # ===================== API ENDPOINTS =====================
97
+
98
  @router.get("/api/vtv/streams")
99
  def api_vtv_streams():
100
  urls = dict(CHANNEL_URLS)
 
103
  stream_url = urls.get(ch_id)
104
  iframe_url = IFRAME_URLS.get(ch_id)
105
  is_flv = stream_url and ".flv" in stream_url
106
+ result[ch_id] = {
107
+ "name": CHANNEL_NAMES[ch_id],
108
+ "stream_url": stream_url,
109
+ "proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "",
110
+ "proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "",
111
+ "is_flv": is_flv,
112
+ "iframe_url": iframe_url,
113
+ "status": "ok"
114
+ }
115
  return JSONResponse(result)
116
 
117
  @router.get("/api/vtv/stream/{channel_id}")
118
  def api_vtv_stream(channel_id: str):
119
  channel_id = channel_id.lower().strip()
 
 
 
 
 
120
  if channel_id not in CHANNEL_NAMES: return JSONResponse({"error":"not found"}, status_code=404)
121
  stream_url = get_channel_url(channel_id)
122
  iframe_url = IFRAME_URLS.get(channel_id)
123
  is_flv = stream_url and ".flv" in stream_url
124
  m3u8_proxy = ""
 
125
  if stream_url and not is_flv and stream_url.startswith("http"):
126
  m3u8_proxy = f"/api/proxy/m3u8?url={quote(stream_url, safe='')}"
127
+ return JSONResponse({
128
+ "name": CHANNEL_NAMES[channel_id],
129
+ "stream_url": stream_url,
130
+ "m3u8_proxy_url": m3u8_proxy,
131
+ "proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "",
132
+ "proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "",
133
+ "is_flv": is_flv,
134
+ "iframe_url": iframe_url,
135
+ "status": "ok"
136
+ })
137
 
138
  @router.get("/api/vtv/m3u/refresh")
139
  def api_vtv_m3u_refresh():
 
144
  found = sum(1 for v in urls.values() if v)
145
  return JSONResponse({"status":"refreshed","channels_found":found,"channels":{k:v for k,v in urls.items() if v}})
146
 
147
+ # ===================== EPG =====================
148
  _epg_cache = {}; _epg_cache_time = 0; _EPG_CACHE_TTL = 600
149
  VTV_CHANNEL_MAP = {"vtv1":"vtv1","vtv2":"vtv2","vtv3":"vtv3","vtv4":"vtv4","vtv5":"vtv5","vtv5-tay-nam-bo":"vtv5","vtv5-tay-nguyen":"vtv5","vtv6":"vtv6","vtv7":"vtv7","vtv8":"vtv8","vtv9":"vtv9","vtv-can-tho":"vtv10"}
150
 
 
224
  global _epg_cache, _epg_cache_time
225
  _epg_cache = {}; _epg_cache_time = 0
226
  epg_data = _fetch_epg_from_vtv()
227
+ return JSONResponse({"status":"refreshed","channels":len(epg_data),"total":sum(len(v) for v in epg_data.values())})