update tiktok download
Browse files- HANDOFF_SUBTITLE_PREVIEW.md +119 -0
- backend/app/services/downloader/ydl.py +45 -15
HANDOFF_SUBTITLE_PREVIEW.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# BÀN GIAO: Khớp "xem trước" phụ đề với video burn (hard subtitle)
|
| 2 |
+
|
| 3 |
+
> Tài liệu context để 1 agent khác tiếp tục. Đọc hết trước khi sửa. Repo:
|
| 4 |
+
> `website_content` (HF Space Docker, base `python:3.11-slim`). FE React+Vite,
|
| 5 |
+
> BE FastAPI. Team 2 người, đồng nghiệp `dangdev` hay **force-push main** → cẩn thận.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## 1. MỤC TIÊU
|
| 10 |
+
Tool "Dịch/đổi giọng video" → "Chèn phụ đề (hard sub)": trình sửa phụ đề kiểu CapCut.
|
| 11 |
+
Yêu cầu của user: **bản XEM TRƯỚC trong editor phải giống HỆT video BURN ra** (font,
|
| 12 |
+
cỡ chữ, vị trí, ngắt dòng, viền/nền). User rất khó tính về điểm này, đã phàn nàn
|
| 13 |
+
nhiều lần "preview khác burn".
|
| 14 |
+
|
| 15 |
+
## 2. FILE LIÊN QUAN
|
| 16 |
+
- `frontend/src/components/SubtitleEditor.jsx` — toàn bộ editor (preview CSS + overlay
|
| 17 |
+
ảnh chuẩn + kéo/thả/sửa text + timeline). **Trọng tâm.**
|
| 18 |
+
- `backend/app/services/dubber/subtitle_render.py` — dựng ASS + burn ffmpeg/libass.
|
| 19 |
+
Hàm chính: `build_ass_str` (1 NGUỒN ASS), `build_ass` (ghi file), `burn`,
|
| 20 |
+
`render_frame` (render 1 khung = xem trước chuẩn), `_style_line`, `_bg_event`,
|
| 21 |
+
`_fontsdir_arg`, `probe_size`.
|
| 22 |
+
- `backend/app/api/dub.py` — endpoint: `POST /api/dub/subtitle` (burn job),
|
| 23 |
+
`/subtitle/preview-frame` (render 1 khung → PNG), `/subtitle/plan`, `/subtitle/import`.
|
| 24 |
+
- `frontend/src/api.js` — `subtitleBurn`, `subtitlePreviewFrame`.
|
| 25 |
+
- `frontend/src/theme.css` — `@font-face` cho 6 font nhúng (dùng cho CSS preview).
|
| 26 |
+
- `backend/app/assets/fonts/` — 6 file .ttf nhúng (Git LFS).
|
| 27 |
+
- `Dockerfile` — cài `fontconfig` + copy font vào `/usr/share/fonts` + `fc-cache`.
|
| 28 |
+
- `.github/workflows/deploy-hf-spaces.yml` — push toàn bộ history sang HF Space.
|
| 29 |
+
|
| 30 |
+
## 3. KIẾN TRÚC HIỆN TẠI (sau nhiều vòng sửa)
|
| 31 |
+
**Nguyên tắc vàng đã chốt:** KHÔNG dùng bộ vẽ thứ 2 để "bắt chước" libass. Muốn
|
| 32 |
+
preview = burn thì phải render preview bằng CHÍNH ffmpeg/libass của bản burn.
|
| 33 |
+
|
| 34 |
+
- `build_ass_str(segments, mode, res)` = NGUỒN ASS DUY NHẤT. Cả `burn` (video cuối)
|
| 35 |
+
lẫn `render_frame` (ảnh xem trước) đều gọi nó → cùng ASS.
|
| 36 |
+
- FE gửi `segPayload()` (mỗi đoạn: start,end,text,style,breaks) — **breaks** đo bằng
|
| 37 |
+
canvas ở FE (`measureBreaks`) để ngắt dòng khớp. CÙNG payload cho burn và preview.
|
| 38 |
+
- **Xem trước khớp 100%** (toggle `exact`, mặc định BẬT): khi video **TẠM DỪNG** +
|
| 39 |
+
không gõ/kéo (`idle`), FE gọi `/subtitle/preview-frame` → server ffmpeg render đúng
|
| 40 |
+
1 khung tại thời điểm `t` → trả PNG → **đè lên video**. Ảnh đó = ĐÚNG bản burn.
|
| 41 |
+
- Lúc **đang phát / đang chờ ảnh / lỗi / gõ / kéo**: hiện lớp **CSS gần đúng** làm nền
|
| 42 |
+
(không bao giờ để trống chữ). Ảnh chuẩn về thì đè lên (ẩn CSS qua `showExact`).
|
| 43 |
+
- `render_frame`: `-ss {t} -copyts -i video` (seek đầu vào, nhanh ~0.2s) + subtitles
|
| 44 |
+
filter + `-update 1` ra PNG. Đã verify pixel trùng KHÍT cách seek đầu ra, nhanh gấp 3.
|
| 45 |
+
|
| 46 |
+
## 4. LỊCH SỬ ĐÃ LÀM (commit mới → cũ)
|
| 47 |
+
- `1b53507` fix: **không bao giờ trống chữ** — chỉ ẩn CSS khi ảnh chuẩn ĐÃ hiện
|
| 48 |
+
(`showExact = idle && previewImg`); còn lại luôn hiện CSS nền. `<img onError>` lùi CSS.
|
| 49 |
+
- `6547b89` fix: lúc phát ẩn phụ đề gần-đúng (sau đó `1b53507` chỉnh lại thành luôn
|
| 50 |
+
hiện CSS nền); `render_frame` seek đầu vào +copyts nhanh 3x; debounce 200ms.
|
| 51 |
+
- `4f9859d` fix: **BỎ libass-wasm**, chuyển sang render khung ở server (xem mục 5).
|
| 52 |
+
- `d908e31` feat: (ĐÃ HỦY) thử libass-wasm/SubtitlesOctopus real-time — thất bại.
|
| 53 |
+
- `d4a04dd` fix: **nhúng 6 font mã nguồn mở** (Be Vietnam Pro, Roboto, Open Sans,
|
| 54 |
+
Montserrat, Arimo≈Arial, Tinos≈Times) vào `assets/fonts`, đặt **Git LFS**; ASS
|
| 55 |
+
`Bold=0` (font đã đậm sẵn); Dockerfile cài fontconfig + font hệ thống. `@font-face`
|
| 56 |
+
cho CSS preview. Sửa lỗi gốc: server Linux thiếu font Microsoft → libass fallback
|
| 57 |
+
DejaVu → lệch. (Đã verify trên Debian WSL: libass chọn đúng font nhúng, 0 fallback.)
|
| 58 |
+
|
| 59 |
+
## 5. NGHIÊN CỨU QUAN TRỌNG (đừng lặp lại sai lầm)
|
| 60 |
+
- **CSS ≠ libass:** trình duyệt vẽ chữ theo luật riêng (wrap, viền, cỡ, nền) khác
|
| 61 |
+
libass → CSS preview KHÔNG BAO GIỜ giống hệt burn. Đừng cố tinh chỉnh CSS cho khớp.
|
| 62 |
+
- **libass-wasm ≠ ffmpeg libass:** cả 2 đều libass NHƯNG **khác phiên bản build** →
|
| 63 |
+
render khác nhau. libass còn có bug [#460](https://github.com/libass/libass/issues/460):
|
| 64 |
+
logic đổi/fallback font **làm RỚT vài chữ/glyph** (phụ thuộc phiên bản). Đây là lý do
|
| 65 |
+
đã BỎ libass-wasm. → Kết luận: chỉ có server ffmpeg mới đảm bảo giống hệt.
|
| 66 |
+
- **Lỗi hình học letterbox (đã sửa):** trước đây overlay tính theo khung thẻ `<video>`
|
| 67 |
+
(width 100% + maxHeight 460 + objectFit contain) → có viền đen 2 bên → overlay lệch
|
| 68 |
+
ngang + hộp sai + wrap khác. ĐÃ SỬA: `fit` = tính cỡ hiển thị = nội dung video THẬT
|
| 69 |
+
(bỏ letterbox), `wrapRef` đúng cỡ đó, overlay ánh xạ đúng toạ độ. (Xác nhận trong
|
| 70 |
+
trình duyệt: video clientW/H = đúng nội dung.)
|
| 71 |
+
- **Deploy HF:** workflow push TOÀN BỘ history sang HF. HF `pre-receive hook` TỪ CHỐI
|
| 72 |
+
binary thô (báo "contains binary files / dùng xet") nhưng CHẤP NHẬN binary dạng LFS.
|
| 73 |
+
→ Mọi file nhị phân (.ttf, .wasm...) PHẢI Git LFS. `.gitattributes` đã có `*.ttf *.otf`.
|
| 74 |
+
- **Windows dev bẫy:** render thử trên Windows KHỚP vì có sẵn font MS → phải test môi
|
| 75 |
+
trường GIỐNG SERVER. Cách test Linux libass thật (máy này không có Docker):
|
| 76 |
+
`wsl --install -d Debian` rồi `apt install ffmpeg fontconfig`, render bằng ffmpeg
|
| 77 |
+
`-loglevel verbose`, tìm dòng `fontselect: (Family,...) -> /path.ttf` để biết libass
|
| 78 |
+
nạp đúng font (không fallback DejaVu / không "Glyph not found").
|
| 79 |
+
- **render_frame nhanh:** `-ss {t}` TRƯỚC `-i` (seek keyframe, nhanh) + `-copyts`
|
| 80 |
+
(giữ PTS đúng để phụ đề hiện đúng thời điểm). Đã đo: 0.22s vs 0.62s (seek đầu ra),
|
| 81 |
+
ảnh y hệt.
|
| 82 |
+
|
| 83 |
+
## 6. BUG ĐANG MỞ (cần agent tiếp theo xử lý)
|
| 84 |
+
### 6.1. Cỡ chữ preview (CSS) TO hơn burn — user vừa báo
|
| 85 |
+
- Triệu chứng: bản preview lúc toggle TẮT (CSS) chữ to; video burn chữ nhỏ hơn.
|
| 86 |
+
- **Cần verify trước:** bật toggle "Xem trước khớp 100%" + DỪNG → ảnh chuẩn (server)
|
| 87 |
+
có cỡ chữ ĐÚNG bằng burn không? Nếu ĐÚNG → đây chỉ là CSS gần đúng vẽ to hơn libass,
|
| 88 |
+
không phải bug burn; chỉ cần user dùng bản chuẩn. Nếu ảnh chuẩn CŨNG khác burn → bug thật.
|
| 89 |
+
- Preview CSS: `px = st.fontSize * scale()`, `scale = video.clientHeight / videoHeight`
|
| 90 |
+
(SubtitleEditor.jsx ~L120, L276). Burn: ASS `Fontsize = st.fontSize` tại
|
| 91 |
+
`PlayResY = probe_size(video)[1]` (subtitle_render.py `_style_line`, `build_ass_str`).
|
| 92 |
+
Về lý thuyết cùng tỉ lệ `fontSize/videoHeight`. Giả thuyết chênh: (a) CSS font-size
|
| 93 |
+
(em box) vs libass Fontsize khác metric ~10-20%; (b) `scale()` đọc clientHeight sai
|
| 94 |
+
thời điểm; (c) 2 ảnh user gửi là 2 ĐOẠN khác nhau nên không so trực tiếp được.
|
| 95 |
+
- **Đề xuất hướng:** nếu muốn CSS preview (lúc phát/kéo) gần cỡ burn hơn, đo hệ số hiệu
|
| 96 |
+
chỉnh CSS↔libass 1 lần rồi nhân vào `px`. Nhưng ưu tiên: để user xem bằng bản CHUẨN
|
| 97 |
+
(ảnh server) — đó mới là chân lý.
|
| 98 |
+
|
| 99 |
+
### 6.2. "Mất chữ" (1 từ không hiện) — cần xác nhận lại
|
| 100 |
+
- Trước đây user thấy mất 1 từ. Rất có thể do libass-wasm cũ (bug #460) → **đã bỏ
|
| 101 |
+
libass-wasm** nên có thể HẾT. Cần user xác nhận trên ảnh chuẩn (server ffmpeg).
|
| 102 |
+
- Nếu ảnh chuẩn (server) VẪN mất chữ → là bug dựng ASS thật (ảnh hưởng cả burn). Khi
|
| 103 |
+
đó cần **text chính xác của đoạn** để tái hiện; soi `build_ass_str`/`_esc`/`joinw`/
|
| 104 |
+
`words_for`/`breaks` xem chữ bị nuốt ở đâu.
|
| 105 |
+
|
| 106 |
+
## 7. CÁCH TEST / CHỖ DỄ SAI
|
| 107 |
+
- Chạy BE local: env conda `tnt` (`D:\miniconda3\envs\tnt\python.exe`); ffmpeg ở
|
| 108 |
+
`D:\Tuyen\outsource\video_cook\ffmpeg-8.1.1-essentials_build\bin`. Build FE: `npx vite build`.
|
| 109 |
+
- Editor cần 1 session dub thật (video + srt) → khó e2e ngoài app. Nhưng có thể test
|
| 110 |
+
riêng `build_ass_str`/`render_frame` bằng python + ffmpeg (xem cách trong git history
|
| 111 |
+
của các commit trên, hoặc tự dựng video lavfi).
|
| 112 |
+
- **Dòng trạng thái dưới nút play** (mới thêm) cho biết: ✅ đang hiện ảnh chuẩn /
|
| 113 |
+
⏳ đang dựng / ⚠ lỗi server / ▶ đang phát. Dùng để chẩn đoán nhanh khi user báo lỗi.
|
| 114 |
+
- KHÔNG test kết luận trên Windows (có sẵn font MS che lỗi). Dùng WSL Debian.
|
| 115 |
+
|
| 116 |
+
## 8. TRẠNG THÁI
|
| 117 |
+
- Đã push tới `1b53507` trên `origin/main`. Deploy HF tự chạy khi push.
|
| 118 |
+
- Preview khớp burn (khi bật toggle + dừng) về mặt kiến trúc là ĐÚNG (cùng ffmpeg/
|
| 119 |
+
ASS/font). Việc còn lại chủ yếu là XÁC NHẬN với user trên bản chuẩn + xử 2 bug mục 6.
|
backend/app/services/downloader/ydl.py
CHANGED
|
@@ -38,15 +38,35 @@ def clean_error(s) -> str:
|
|
| 38 |
s = s.replace("ERROR:", "").replace("\n", " ").strip()
|
| 39 |
return (s[:240] + "…") if len(s) > 240 else s
|
| 40 |
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
- Douyin 'jingxuan?modal_id=ID' / '?modal_id=ID' -> '/video/ID'
|
| 46 |
-
- Bỏ các tham số rác phía sau cho douyin video.
|
| 47 |
"""
|
| 48 |
u = (url or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
m = re.search(r"douyin\.com/.*?[?&]modal_id=(\d+)", u)
|
|
|
|
| 50 |
if m:
|
| 51 |
return f"https://www.douyin.com/video/{m.group(1)}"
|
| 52 |
m = re.search(r"douyin\.com/video/(\d+)", u)
|
|
@@ -179,7 +199,7 @@ def _build_opts(out_dir: str, cookies_file: str | None = None,
|
|
| 179 |
# TikTok/Douyin nay BẮT BUỘC giả lập trình duyệt (impersonation) mới qua được
|
| 180 |
# JS challenge + lấy luồng video; thiếu -> chỉ bóc được metadata/audio, coi như
|
| 181 |
# "không phải video". Cần curl_cffi (trong requirements); không có -> bỏ qua êm.
|
| 182 |
-
if source in ("tiktok", "douyin"):
|
| 183 |
try:
|
| 184 |
from yt_dlp.networking.impersonate import ImpersonateTarget
|
| 185 |
opts["impersonate"] = ImpersonateTarget.from_str("chrome")
|
|
@@ -256,9 +276,9 @@ def _attempt(url: str, out_dir: str, opts: dict) -> DownloadResult:
|
|
| 256 |
)
|
| 257 |
|
| 258 |
|
| 259 |
-
_UA_MOBILE = ("Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) "
|
| 260 |
-
|
| 261 |
-
|
| 262 |
|
| 263 |
|
| 264 |
def _douyin_direct(url: str, out_dir: str) -> DownloadResult:
|
|
@@ -452,7 +472,9 @@ def _tiktok_via_tikwm(url: str, out_dir: str) -> DownloadResult:
|
|
| 452 |
raise RuntimeError(f"tikwm báo lỗi: {j.get('msg')}")
|
| 453 |
d = j.get("data") or {}
|
| 454 |
title = (d.get("title") or "").strip()
|
| 455 |
-
m = re.search(r"/(?:video|photo)/(\d
|
|
|
|
|
|
|
| 456 |
vid_id = str(d.get("id") or (m.group(1) if m else "tiktok"))
|
| 457 |
safe = re.sub(r'[\\/:*?"<>|\n\r\t]+', "_", title)[:60].strip() or vid_id
|
| 458 |
|
|
@@ -556,14 +578,22 @@ def download_one(url: str, out_dir: str, cookies_file: str | None = None) -> Dow
|
|
| 556 |
except Exception as e2:
|
| 557 |
return DownloadResult(url=url, ok=False,
|
| 558 |
error=clean_error(f"{source}: {e2}"))
|
| 559 |
-
# TikTok: yt-dlp hay chỉ bóc audio
|
| 560 |
-
#
|
| 561 |
-
if source
|
| 562 |
try:
|
| 563 |
return _tiktok_via_tikwm(url, out_dir)
|
| 564 |
except Exception as e_tk:
|
| 565 |
-
|
| 566 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
# YouTube bị chặn (IP datacenter) -> lưới an toàn: thử Invidious/Piped.
|
| 568 |
if source == "youtube":
|
| 569 |
try:
|
|
|
|
| 38 |
s = s.replace("ERROR:", "").replace("\n", " ").strip()
|
| 39 |
return (s[:240] + "…") if len(s) > 240 else s
|
| 40 |
|
| 41 |
+
def _resolve_tiktok_url(url: str) -> str:
|
| 42 |
+
"""Follow redirect cho link rút gọn TikTok (vt.tiktok.com / vm.tiktok.com / share ads link).
|
| 43 |
|
| 44 |
+
Link quảng cáo (Spark Ads) hầu hết ở dạng rút gọn -> không có sẵn '/video/<id>/'
|
| 45 |
+
nên các bước tách ID phía sau sẽ fail nếu không resolve ra link thật trước.
|
|
|
|
|
|
|
|
|
|
| 46 |
"""
|
| 47 |
u = (url or "").strip()
|
| 48 |
+
if not re.search(r"(vt|vm)\.tiktok\.com/", u):
|
| 49 |
+
return u
|
| 50 |
+
try:
|
| 51 |
+
import requests # type: ignore
|
| 52 |
+
r = requests.head(u, allow_redirects=True, timeout=15,
|
| 53 |
+
headers={"User-Agent": _UA_MOBILE})
|
| 54 |
+
final = r.url or u
|
| 55 |
+
# Một số server chặn HEAD -> thử GET nếu HEAD không redirect được.
|
| 56 |
+
if final == u:
|
| 57 |
+
r = requests.get(u, allow_redirects=True, timeout=15,
|
| 58 |
+
headers={"User-Agent": _UA_MOBILE}, stream=True)
|
| 59 |
+
final = r.url or u
|
| 60 |
+
r.close()
|
| 61 |
+
return final
|
| 62 |
+
except Exception:
|
| 63 |
+
return u # resolve fail -> trả lại url gốc, để bước sau tự lo
|
| 64 |
+
|
| 65 |
+
def normalize_url(url: str) -> str:
|
| 66 |
+
u = (url or "").strip()
|
| 67 |
+
u = _resolve_tiktok_url(u) # <-- MỚI: giải link rút gọn tiktok ads trước
|
| 68 |
m = re.search(r"douyin\.com/.*?[?&]modal_id=(\d+)", u)
|
| 69 |
+
|
| 70 |
if m:
|
| 71 |
return f"https://www.douyin.com/video/{m.group(1)}"
|
| 72 |
m = re.search(r"douyin\.com/video/(\d+)", u)
|
|
|
|
| 199 |
# TikTok/Douyin nay BẮT BUỘC giả lập trình duyệt (impersonation) mới qua được
|
| 200 |
# JS challenge + lấy luồng video; thiếu -> chỉ bóc được metadata/audio, coi như
|
| 201 |
# "không phải video". Cần curl_cffi (trong requirements); không có -> bỏ qua êm.
|
| 202 |
+
if source in ("tiktok", "tiktok_photo", "douyin"):
|
| 203 |
try:
|
| 204 |
from yt_dlp.networking.impersonate import ImpersonateTarget
|
| 205 |
opts["impersonate"] = ImpersonateTarget.from_str("chrome")
|
|
|
|
| 276 |
)
|
| 277 |
|
| 278 |
|
| 279 |
+
# _UA_MOBILE = ("Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) "
|
| 280 |
+
# "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 "
|
| 281 |
+
# "Mobile/15E148 Safari/604.1")
|
| 282 |
|
| 283 |
|
| 284 |
def _douyin_direct(url: str, out_dir: str) -> DownloadResult:
|
|
|
|
| 472 |
raise RuntimeError(f"tikwm báo lỗi: {j.get('msg')}")
|
| 473 |
d = j.get("data") or {}
|
| 474 |
title = (d.get("title") or "").strip()
|
| 475 |
+
m = (re.search(r"/(?:video|photo)/(\d{6,})", url)
|
| 476 |
+
or re.search(r"item_id=(\d{6,})", url)
|
| 477 |
+
or re.search(r"(\d{15,})", url)) # aweme id thường rất dài, ưu tiên số dài nhất
|
| 478 |
vid_id = str(d.get("id") or (m.group(1) if m else "tiktok"))
|
| 479 |
safe = re.sub(r'[\\/:*?"<>|\n\r\t]+', "_", title)[:60].strip() or vid_id
|
| 480 |
|
|
|
|
| 578 |
except Exception as e2:
|
| 579 |
return DownloadResult(url=url, ok=False,
|
| 580 |
error=clean_error(f"{source}: {e2}"))
|
| 581 |
+
# TikTok (video thường lẫn ảnh/slideshow lẫn ads): yt-dlp hay chỉ bóc audio
|
| 582 |
+
# hoặc fail thẳng với nội dung ads -> dự phòng qua tikwm (giống SnapTik).
|
| 583 |
+
if source in ("tiktok", "tiktok_photo"):
|
| 584 |
try:
|
| 585 |
return _tiktok_via_tikwm(url, out_dir)
|
| 586 |
except Exception as e_tk:
|
| 587 |
+
msg = f"{first_err} | tikwm: {e_tk}"
|
| 588 |
+
# Ads nhắm mục tiêu (targeted ads) đôi khi chỉ hiện trong app cho
|
| 589 |
+
# đúng tài khoản được target -> KHÔNG server nào (kể cả tikwm) có
|
| 590 |
+
# quyền truy cập nội dung này qua API công khai. Báo rõ để khỏi
|
| 591 |
+
# nhầm tưởng là bug.
|
| 592 |
+
if "báo lỗi" in str(e_tk) or "không trả link" in str(e_tk):
|
| 593 |
+
msg += (" | Có thể đây là video quảng cáo (Spark Ads) chỉ hiển "
|
| 594 |
+
"thị trong app cho tài khoản được nhắm mục tiêu -> "
|
| 595 |
+
"không tải được qua API công khai.")
|
| 596 |
+
return DownloadResult(url=url, ok=False, error=clean_error(msg))
|
| 597 |
# YouTube bị chặn (IP datacenter) -> lưới an toàn: thử Invidious/Piped.
|
| 598 |
if source == "youtube":
|
| 599 |
try:
|