diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 40f60e1de89e0f904c11d302058de8c039f587a9..0000000000000000000000000000000000000000 --- a/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -__pycache__/ -*.pyc -data/ -.data -.huggingface/ -.restart_trigger diff --git a/.huggingface/rebuild b/.huggingface/rebuild new file mode 100644 index 0000000000000000000000000000000000000000..224b6f5758c6f946c68971996fff91c71b1561f5 --- /dev/null +++ b/.huggingface/rebuild @@ -0,0 +1 @@ +rebuild vtv_api.py \ No newline at end of file diff --git a/.rebuild b/.rebuild new file mode 100644 index 0000000000000000000000000000000000000000..5633ed4f096f3875c88c6bdeb43c2fac74c06a47 --- /dev/null +++ b/.rebuild @@ -0,0 +1 @@ +rebuild_vtv_fix_20260706_v3 \ No newline at end of file diff --git a/.restart_trigger b/.restart_trigger new file mode 100644 index 0000000000000000000000000000000000000000..048f645894a0ea68f3604d333a2d43801eaf8be1 --- /dev/null +++ b/.restart_trigger @@ -0,0 +1 @@ +rebuild 1782693789.5665495 - force rebuild after voice+thumbnail fixes \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bed2a391f57aae4c106228b98824a5a07c71913b..065430a415d73f8c8cf5cdae84396bd20b5e22d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,71 @@ -# FPT Play Stream Selector Update +# VNEWS v6.5 - Resilient Shorts Auto-Updater -Added FPT Play channel with stream selector UI similar to VTV6: +## Changes + +### Critical Fix: Shorts timeout and homepage load stability +**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: +- Homepage `/api/shorts` endpoint to time out (30s limit) +- Space to appear unresponsive on first load +- No fallback when sources fail + +**Fix applied**: +1. **shorts_updater.py** (NEW) — Resilient background updater: + - Hard timeout (25s) per channel using subprocess isolation + - Stale-while-revalidate pattern: returns cached data immediately, updates in background + - Automatic fallback to hardcoded short URLs when all sources fail + - Persistent storage in `/data/shorts_cache.json` for cache across restarts + - Background scheduler runs every 10 minutes automatically + - No blocking on first homepage load + +2. **_run.py** — Integrated resilient shorts endpoint: + - Overrides `/api/shorts` with non-blocking version + - Returns cached/fallback data in <100ms guaranteed + - Triggers background update if cache is stale or empty + - Never hangs - always returns valid JSON response + +3. **FALLBACK_SHORTS** — 6 hardcoded viral shorts as emergency fallback: + - baodantri7941 (Dân trí) headlines + - baosuckhoedoisongboyte (Sức khỏe & đời sống) stories + - vtvnambo (VTV Nam Bộ) news + +### Benefits +- Homepage loads in <2 seconds always +- Shorts data auto-updates every 10 minutes +- Never times out - graceful degradation to fallback +- Persistent cache survives Space restarts +- Uses bucket `bep40/VNEWS-storage` for cache storage + +### Channels monitored +- baodantri7941 (Dân trí) +- baosuckhoedoisongboyte (Sức khỏe & đời sống) +- vtvnambo (VTV Nam Bộ) -- New tab "FPT" (orange themed) in the channel tabs -- Stream selector with 4 sources: - 1. 🌐 Web FPT Play (iframe) - 2. 📡 HLS Proxy (via /api/proxy/m3u8) - 3. 🔗 HLS Direct - 4. 📺 HD1.xemtv.net (iframe from LINK 1) -- Backend vtv_api.py now returns stream_selectors for fpt-the-thao channel -- Frontend handles stream switching automatically when FPT tab is active +--- + +# VNEWS v5.1 - Rewrite Fix ## Changes -- `static/vtv_init.js`: Added FPT tab + stream selector UI logic -- `vtv_api.py`: Added FPT Play endpoint responses with stream_selectors data + +### Critical Fix: Rewrite button not creating posts on Tường AI +**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. + +**Fix applied**: +1. **app_v2_entry.py** — Added 3 new endpoints: + - `POST /api/rewrite_slide` — Fast extractive summary (no AI needed), creates slides from article key points + images, saves to wall + - `POST /api/rewrite_share` — AI-powered rewrite with extractive fallback, saves to wall + - `POST /api/url_wall` — URL submission endpoint (alias for rewrite_share) + - All endpoints use the same `_load_wall_posts()` / `_save_wall_posts()` and `WALL_FILE` path as the existing `/api/wall` endpoint + +2. **static/index_v2.html** — Added `` to load the rewrite fix + +3. **static/rewrite_fix_v2.js** — New file that overrides `rewriteArticle()` to: + - Call `/api/rewrite_slide` first (fast, no AI needed) + - Fallback to `/api/rewrite_share` if slide fails + - Show slide preview overlay after successful post + - Use `prependWallPost()` to add the new post to Tường AI + +### Previous changes (v5) +- Rewrote match_detail_v2.py with correct event parsing +- 2-tab layout for match detail (stats + timeline) +- Fixed _run.py import +- Dockerfile cache busting diff --git a/Dockerfile b/Dockerfile index 5e4cdaa1a67afb1e0ab5faa307e619fbb285f0c2..5cf7f085c1b6677fd6b8513d16b4eb8dd0404f76 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,48 +2,19 @@ FROM python:3.12-slim WORKDIR /app -RUN echo "[BUILD] step1: apt-get update+install ffmpeg + Vietnamese fonts" && \ - apt-get update && apt-get install -y --no-install-recommends \ - ffmpeg \ - fonts-dejavu-core \ - fonts-noto \ - fonts-noto-cjk \ - fonts-noto-color-emoji \ - fonts-liberation \ - fonts-freefont-ttf \ - libfreetype6 \ - && rm -rf /var/lib/apt/lists/* && \ - echo "[BUILD] step1 done" - -RUN echo "[BUILD] step2: pip base pkgs (bs4/lxml)" && \ - pip install --no-cache-dir "beautifulsoup4>=4.12" lxml && \ - echo "[BUILD] step2 done" - -RUN echo "[BUILD] step3: pip main pkgs" && \ - pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx pycryptodome && \ - echo "[BUILD] step3 done" +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-dejavu-core && rm -rf /var/lib/apt/lists/* +RUN pip install --no-cache-dir "beautifulsoup4>=4.12" lxml +RUN pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx COPY requirements.txt . -RUN echo "[BUILD] step4: pip requirements.txt" && \ - pip install --no-cache-dir -r requirements.txt || true && \ - echo "[BUILD] step4 done" +RUN pip install --no-cache-dir -r requirements.txt || true COPY . . EXPOSE 7860 -RUN echo "[BUILD] step5: setup Vietnamese font symlink" && \ - mkdir -p /usr/share/fonts/truetype/vn && \ - # Prefer Noto Sans for Vietnamese - it has full diacritic support - if [ -f /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf ]; then \ - ln -sf /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf /usr/share/fonts/truetype/vn/VNFont.ttf; \ - elif [ -f /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf ]; then \ - ln -sf /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf /usr/share/fonts/truetype/vn/VNFont.ttf; \ - ln -sf /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf /usr/share/fonts/truetype/vn/VNFont-Bold.ttf; \ - fi; \ - fc-cache -f -v || true; \ - date > /app/.build_done && \ - echo "[BUILD] step5 done" - -CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860"] -# v3.0-vn-font-fix-short-video-2026-07-19 -# rebuild-trigger: m3u-perf-fix-20260829 +# v7 - 2026-07-06 - FIX VTV2/VTV3/VTV6/VTV9: new aggressive extraction patterns + backup CDN mediacdn +CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"] +# build 1782698397 +# build 1782698798 +# build 1782699174 +# build 1790000001 diff --git a/README.md b/README.md index f65ec063b0184f66ec5c41d94307c4f38f4c0a38..8f9526538a80ace404f12e119c3bd2d0e940b957 100644 --- a/README.md +++ b/README.md @@ -30,29 +30,4 @@ tags: - 🏆 World Cup 2026 (news, fixtures, standings, stats, highlights) - 🤖 AI article writing + TTS (multilingual, emotion-aware) - 🔍 Topic search (8 news sources) -- 🎤 TTS: voice selector + emotion selector + speed control - -## 🎬 Short AI — Video từ link (scrap YouTube / TikTok / tin tức) - -Short creator có chế độ **"🔗 Video từ link"**: dán link video YouTube / TikTok / -VnExpress / Dân trí / Znews / 24h... → bấm "Lấy video" để xem trước → tạo short -chạy video + ảnh đã chọn bù phần còn thiếu nếu video ngắn hơn giọng đọc. - -### 🔑 Cài cookies cho YouTube (bỏ chặn "Sign in to confirm you're not a bot") -YouTube đôi khi chặn IP datacenter. Cách khắc phục bằng cookies: - -1. Cài extension trình duyệt **"Get cookies.txt LOCALLY"** (Chrome/Edge) hoặc - **"cookies.txt"** (Firefox). -2. Mở `https://www.youtube.com` (đã đăng nhập) → bấm extension → **Export** → ra file `cookies.txt` (định dạng Netscape). -3. Đưa cookies vào Space bằng **một trong hai cách**: - - **Cách A (khuyến nghị):** Vào Settings của Space - `huggingface.co/spaces/bep40/VNEWS/settings` → **Variables and secrets** → - tạo secret tên **`YT_COOKIES`**, giá trị = toàn bộ nội dung file `cookies.txt`. - - **Cách B:** đặt file `cookies.txt` vào thư mục gốc repo `VNEWS/` và commit - (chú ý: cookies sẽ công khai nếu repo public — ưu tiên Cách A). -4. Rebuild Space (mỗi lần đổi secret phải **Restart** Space). - -Backend tự đọc `YT_COOKIES` (secret) hoặc `/app/cookies.txt`, ghi thành file tạm -và truyền cho yt-dlp qua `cookiefile`. Không cần sửa code. - -> 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. \ No newline at end of file +- 🎤 TTS: voice selector + emotion selector + speed control \ No newline at end of file diff --git a/RESTART_TRIGGER.md b/RESTART_TRIGGER.md deleted file mode 100644 index e6c79c75d2068b3dc20f52912afac9b21944b2d0..0000000000000000000000000000000000000000 --- a/RESTART_TRIGGER.md +++ /dev/null @@ -1,7 +0,0 @@ -trigger rebuild v20260827fptwall - -- FPT wall cards now use .wall-thumb 100% width/height (16:9), identical to Short AI cards -- FPT player opens a scrollable vertical feed interleaving FPT Play + Short AI videos, newest-first -- 16:9 <-> 9:16 ratio toggle (🖥️/📺) works on football highlight slides and Tường AI slide player -- Cache-busting: app_v2.js?v=20260827fptwall -- FORCE REBUILD: this commit retriggers the Space container with the latest static/app_v2.js \ No newline at end of file diff --git a/ai_ext.py b/ai_ext.py index ab7540db0765711cf34947d481888084e0f87c65..cf4b1d0ff3215baa8ed3f05887a252c47d56bc90 100644 --- a/ai_ext.py +++ b/ai_ext.py @@ -96,20 +96,27 @@ def _domain(url: str) -> str: async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str: - """Generate text using Llama/Qwen models via Hugging Face Inference API. + """Generate text using Qwen models via Hugging Face Inference API. - Prioritizes Llama-3.3-70B for better creative/opinion writing. + This function provides a resilient implementation that: + 1. First tries the SDK-based inference client if available + 2. Falls back to REST API calls to HF router endpoint + 3. Returns a fallback summary if all else fails """ token = _hf_token() errors = [] - # Try HF router API with multiple models - Llama FIRST for opinion writing + # Try HF router API with multiple models if token: models = [ os.getenv("QWEN_VL_MODEL", ""), - "meta-llama/Llama-3.3-70B-Instruct", # FIRST - best for opinion/analysis "Qwen/Qwen2.5-VL-7B-Instruct", + "Qwen/Qwen2.5-VL-3B-Instruct", + "Qwen/Qwen2.5-7B-Instruct", + "Qwen/Qwen2.5-3B-Instruct", + "Qwen/Qwen2.5-1.5B-Instruct", "Qwen/Qwen2.5-72B-Instruct", + "meta-llama/Llama-3.3-70B-Instruct", ] # Deduplicate while preserving order seen = set() @@ -131,12 +138,12 @@ async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 12 payload = { "model": model, "messages": [ - {"role": "system", "content": "Bạn là nhà báo phản biện chuyên nghiệp. Luôn viết theo quan điểm cá nhân, phân tích sâu, không sao chép nguyên văn nguồn tin."}, + {"role": "system", "content": "Bạn là trợ lý AI tiếng Việt. Trả lời tự nhiên, ngắn gọn, chính xác."}, {"role": "user", "content": user_content}, ], - "max_tokens": min(int(max_tokens or 2000), 2500), - "temperature": 0.75, - "top_p": 0.9, + "max_tokens": min(int(max_tokens or 900), 1400), + "temperature": 0.35, + "top_p": 0.85, } r = requests.post( @@ -176,18 +183,20 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str: text = re.sub(r"https?://\S+", "", text) text = re.sub(r"\s+", " ", text).strip() - # Split into sentences + # Split into sentences - extract ALL valid sentences, not just first few sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text) units = [] for s in sentences: s = _clean_text(s) - if len(s) >= 30: + if len(s) >= 30: # Lower threshold to capture more content units.append(s) if units: + # Take up to max_units valid sentences result_units = units[:max_units] return "\n".join("• " + u for u in result_units) if text: + # Fallback: take chunks if no sentence boundaries found chunks = [] for i in range(0, min(len(text), max_units * 300), 280): chunk = _clean_text(text[i:i+300]) @@ -199,134 +208,367 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str: return "\n".join("• " + c for c in chunks) return "• Không có đủ nội dung để tóm tắt." -# ===== URL scraping & article processing ===== -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"} -try: - _shorts_base = "/data" if os.path.isdir("/data") else os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") -except Exception: - _shorts_base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") -SHORTS_DIR = os.path.join(_shorts_base, "ai_shorts") -os.makedirs(SHORTS_DIR, exist_ok=True) +HF_TOKEN = _hf_token() +QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct") +QWEN_TEXT_MODELS = [m.strip() for m in os.getenv( + "QWEN_TEXT_MODELS", + "Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct" +).split(",") if m.strip()] +_WORKING_MODEL_TEXT = None +_WORKING_MODEL_VL = None +DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data" +SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts") +HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8" +} +LAST_QWEN_ERROR = "" + + +# ===== MULTILINGUAL VOICES FOR TTS ===== +# Maps voice IDs to edge-tts voice names (only MultilingualNeural voices) +MULTILINGUAL_VOICES = { + # Vietnamese - Native voices + "vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural", + "vi-vn-namminhneural": "vi-VN-NamMinhNeural", + "hoaimy": "vi-VN-HoaiMyNeural", + "namminh": "vi-VN-NamMinhNeural", + "vi_female": "vi-VN-HoaiMyNeural", + "vi_male": "vi-VN-NamMinhNeural", + "nu": "vi-VN-HoaiMyNeural", + "male": "vi-VN-NamMinhNeural", + "female": "vi-VN-HoaiMyNeural", + "mien-nam": "vi-VN-HoaiMyNeural", + # English - Multilingual + "en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural", + "en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural", + "en_andrew": "en-US-AndrewMultilingualNeural", + "andrew": "en-US-AndrewMultilingualNeural", + "en_jenny": "en-US-AndrewMultilingualNeural", + "jenny": "en-US-AndrewMultilingualNeural", + # Portuguese - Thalita Multilingual ONLY + "pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural", + "pt_thalita": "pt-BR-ThalitaMultilingualNeural", + "thalita": "pt-BR-ThalitaMultilingualNeural", + "pt_francisco": "pt-BR-ThalitaMultilingualNeural", + "pt": "pt-BR-ThalitaMultilingualNeural", + # French - Multilingual + "fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural", + "fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural", + "fr_denise": "fr-FR-VivienneMultilingualNeural", + "denise": "fr-FR-VivienneMultilingualNeural", + "fr": "fr-FR-VivienneMultilingualNeural", + # German - Multilingual + "de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural", + "de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural", + "de_katja": "de-DE-SeraphinaMultilingualNeural", + "katja": "de-DE-SeraphinaMultilingualNeural", + "de": "de-DE-SeraphinaMultilingualNeural", + # Korean - Hyunsu Multilingual (NOT SunHee) + "ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural", + "ko_sunhee": "ko-KR-HyunsuMultilingualNeural", + "sunhee": "ko-KR-HyunsuMultilingualNeural", + "ko": "ko-KR-HyunsuMultilingualNeural", + # Italian - Multilingual + "it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural", + # Spanish (fallback to English multilingual) + "es_ela": "en-US-AndrewMultilingualNeural", + "ela": "en-US-AndrewMultilingualNeural", + "es_carlos": "en-US-AndrewMultilingualNeural", + "es": "en-US-AndrewMultilingualNeural", + # Japanese (fallback to English multilingual) + "ja_nanami": "en-US-AndrewMultilingualNeural", + "nanami": "en-US-AndrewMultilingualNeural", + "ja": "en-US-AndrewMultilingualNeural", + # Chinese (fallback to English multilingual) + "zh_xiaochen": "en-US-AndrewMultilingualNeural", + "xiaochen": "en-US-AndrewMultilingualNeural", + "zh": "en-US-AndrewMultilingualNeural", +} + -import random as _random2 -from datetime import datetime, timezone, timedelta -_VN_TZ = timezone(timedelta(hours=7)) +def _detect_voice_emotion(title, text): + """Detect appropriate voice and emotion based on content for multilingual TTS.""" + content = ((title or "") + " " + (text or "")).lower() + + # World Cup / Football content - use Andrew multilingual + if any(kw in content for kw in ["world cup", "wc 2026", "fifa", "bóng đá", "trận đấu", "bóng bóng", "đội tuyển", "cầu thủ"]): + return ("andrew", "excited") + + # News categories - choose appropriate voice + if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]): + return ("jenny", "calm") + if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]): + return ("thalita", "serious") + if any(kw in content for kw in ["giải trí", "showbiz", "entertainment", "hài hước"]): + return ("ela", "happy") + if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]): + return ("katja", "excited") + + # Default Vietnamese + return ("hoaimy", "trung_tinh") -def _safe_name(filename: str) -> str: - """Sanitize filename.""" - return re.sub(r"[^a-zA-Z0-9_.-]", "_", filename)[:120] +def _safe_name(s: str) -> str: + """Create safe filename from string.""" + s = re.sub(r"[^\w\-.]", "_", s) + return s[:100] if len(s) > 100 else s -def pollinations_image_url(topic: str) -> str: - """Generate a placeholder image URL via Pollinations.""" +def _download_image(url: str, fallback_title: str, out_path: str) -> bool: + """Download image from URL to path.""" + if not url: + return False try: - return "https://image.pollinations.ai/prompt/" + quote("Vietnamese editorial illustration, " + topic, safe="") + "?width=1024&height=576&nologo=true" + r = requests.get(url, headers=HEADERS, timeout=15) + if r.status_code == 200: + os.makedirs(os.path.dirname(out_path), exist_ok=True) + with open(out_path, "wb") as f: + f.write(r.content) + return True except Exception: - return "" + pass + return False -def _download_image(url: str, fallback_title: str, out_path: str) -> str: - """Download an image from URL or create a placeholder.""" - if url: - try: - r = requests.get(url, headers=HEADERS, timeout=15) - if r.status_code == 200 and len(r.content) > 1200: - os.makedirs(os.path.dirname(out_path), exist_ok=True) - with open(out_path, "wb") as f: - f.write(r.content) - return out_path - except Exception: - pass - # Fallback: create a placeholder image +def pollination_image_url(topic: str) -> str: + """Generate image URL from Pollinations.ai.""" + return f"https://image.pollinations.ai/prompt/{quote(topic)}?width=1024&height=768&nologo=true&model=flux" + + +# Use the same wall file as app_v2_entry.py for consistency +WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json") + +def _load_ai_wall(): + """Load AI wall posts from JSON file (uses wall_posts.json for consistency with app_v2_entry).""" try: - from PIL import Image, ImageDraw, ImageFont - img = Image.new("RGB", (1080, 760), (24, 24, 24)) - draw = ImageDraw.Draw(img) - try: - font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48) - except Exception: - font = None - text = (fallback_title or "VNEWS")[:40] - try: - bbox = draw.textbbox((0, 0), text, font=font) - tw = bbox[2] - bbox[0] - except Exception: - tw = len(text) * 24 - draw.text(((1080 - tw) // 2, 330), text, fill=(255, 255, 255), font=font) - os.makedirs(os.path.dirname(out_path), exist_ok=True) - img.save(out_path, quality=90) - return out_path + if os.path.exists(WALL_FILE): + with open(WALL_FILE, "r", encoding="utf-8") as f: + return json.load(f) except Exception: - return out_path + pass + return [] +def _save_ai_wall(posts): + """Save AI wall posts to JSON file (uses wall_posts.json for consistency with app_v2_entry).""" + try: + os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True) + tmp = WALL_FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(posts[:100], f, ensure_ascii=False) + os.replace(tmp, WALL_FILE) + except Exception: + pass + + +# Helper functions for wall operations +def _load_wall_posts(): + """Alias for _load_ai_wall for consistency with app_v2_entry.py.""" + return _load_ai_wall() + + +def _save_wall_posts(posts): + """Alias for _save_ai_wall for consistency with app_v2_entry.py.""" + return _save_ai_wall(posts) + + +def make_post(title: str, text: str, img: str, url: str, kind: str, sources=None): + """Create a post dict with standard fields.""" + return { + "id": str(int(time.time() * 1000)), + "title": title, + "text": text, + "img": img, + "url": url, + "kind": kind, + "sources": sources or [], + "ts": int(time.time()) + } + + +def _short_script(post) -> str: + """Extract clean text for TTS from post.""" + text = post.get("text", "") or post.get("title", "") + text = re.sub(r"^[•\-\*]\s*", "", text, flags=re.M) + text = re.sub(r"\s*\n\s*", ". ", text) + return _clean_text(text)[:2000] # Increased from 1000 to 2000 for full content + + +# ===== SCRAPER FUNCTIONS (required by ai_patch.py) ===== def scrape_any_url(url: str) -> dict: - """Scrape article content from any URL.""" - if not url or not url.startswith("http"): - return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": ""} + """Scrape any URL and extract article content. + + Returns dict with: title, summary, text, image, og_image, via (domain) + """ try: r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True) - if r.status_code != 200 or not r.text: - return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)} - r.encoding = "utf-8" - soup = BeautifulSoup(r.text, "lxml") - 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"]): + r.encoding = 'utf-8' + soup = BeautifulSoup(r.text, 'lxml') + + # Remove scripts, styles, nav, footer + for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']): tag.decompose() - title = "" - ogt = soup.find("meta", property="og:title") - if ogt: - title = ogt.get("content", "") - h1 = soup.find("h1") - if not title and h1: - title = h1.get_text(strip=True) - if not title: - t = soup.find("title") - if t: - title = t.get_text(strip=True) - og_image = "" - ogi = soup.find("meta", property="og:image") - if ogi: - og_image = ogi.get("content", "") - if og_image.startswith("//"): - og_image = "https:" + og_image - summary = "" - ogd = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"}) - if ogd: - summary = ogd.get("content", "")[:500] - body_text = [] - for sel in ["article", ".singular-content", ".detail-content", ".fck_detail", ".content-detail", ".knc-content", "main", ".cms-body", ".article__body", ".post-content", ".entry-content"]: + + # Extract title + h1 = soup.find('h1') + ogt = soup.find('meta', property='og:title') + title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else url) + + # Extract OG image + ogi = soup.find('meta', property='og:image') + og_image = ogi.get('content', '') if ogi else '' + + # Extract article body + block = None + for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']: el = soup.select_one(sel) - if el and len(el.find_all("p")) >= 2: - for p in el.find_all("p"): - t = _clean_text(p.get_text(strip=True)) - if t and len(t) > 30: - body_text.append(t) + if el and len(el.find_all('p')) >= 2: + block = el break - if not body_text and soup.body: - for p in soup.body.find_all("p"): - t = _clean_text(p.get_text(strip=True)) - if t and len(t) > 30: - body_text.append(t) - text = "\n".join(body_text) - return {"title": _clean_text(title), "text": text, "summary": _clean_text(summary), "image": og_image, "og_image": og_image, "via": _domain(url), "url": url} + if not block: + block = soup.body or soup + + # Extract text from paragraphs + paragraphs = [] + for el in block.find_all(['p', 'h2', 'h3'], recursive=True): + t = _clean_text(el.get_text(strip=True)) + if t and len(t) > 40: + paragraphs.append(t) + + # Extract images + images = [] + for el in block.find_all(['figure', 'img'], recursive=True): + im = el if el.name == 'img' else el.find('img') + if im: + src = im.get('data-src') or im.get('src') or im.get('data-original') or '' + if src and 'base64' not in src: + if src.startswith('//'): + src = 'https:' + src + images.append(src) + + # Prefer OG image as main image + image = og_image or (images[0] if images else '') + + return { + 'title': title, + 'summary': paragraphs[0] if paragraphs else '', + 'text': '\n'.join(paragraphs), + 'image': image, + 'og_image': og_image, + 'via': _domain(url), + 'images': images + } except Exception as e: - return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)} + return {'title': url, 'summary': '', 'text': '', 'image': '', 'og_image': '', 'via': _domain(url), 'error': str(e)} -def make_post(title: str, text: str, img: str, url: str, kind: str = "auto", sources: list = None) -> dict: - """Create a wall post dict.""" - import random as _r2 - now = int(time.time() * 1000) - return { - "id": str(now) + str(_r2.randint(100, 999)), - "title": (title or "Bài viết")[:200], - "text": (text or "")[:5000], - "img": img or "", - "url": url or "", - "kind": kind or "auto", - "sources": sources or [], - "created": now, - "created_str": datetime.now(_VN_TZ).strftime("%H:%M %d/%m/%Y"), - } +def web_context(topic: str, limit: int = 5) -> tuple: + """Get web context for a topic. Returns (context_text, sources_list).""" + sources = [] + try: + # Try Google News RSS + rss_url = f"https://news.google.com/rss/search?q={quote_plus(topic)}&hl=vi&gl=VN&ceid=VN:vi" + r = requests.get(rss_url, headers=HEADERS, timeout=15) + r.encoding = 'utf-8' + soup = BeautifulSoup(r.text, 'xml') + for it in soup.find_all('item')[:limit]: + title = it.find('title').get_text(' ', strip=True) if it.find('title') else '' + link = it.find('link').get_text(strip=True) if it.find('link') else '' + if title and link: + sources.append({'title': title, 'url': link, 'via': _domain(link)}) + except Exception: + pass + + context = f'Trên mạng có nhiều bài viết về "{topic}". Một số nguồn: ' + ', '.join([s.get('title', '') for s in sources[:3]]) + return context, sources + + +# ===== SHORT FRAME FUNCTION (required by ai_patch.py) ===== +def _make_short_frame(post, img_path, out_path): + """Create a short video frame from post and image. + + Called by ai_patch.py _make_short_frame_full when Image is available. + """ + if Image is None: + # Create a minimal frame without PIL - just return success + # The caller should handle this case + return False + + W, H = 1080, 1920 + bg = Image.new("RGB", (W, H), (14, 14, 14)) + + try: + im = Image.open(img_path).convert("RGB") + target = (1080, 760) + im_ratio = im.width / max(1, im.height) + target_ratio = target[0] / target[1] + + if im_ratio > target_ratio: + new_h = target[1] + new_w = int(new_h * im_ratio) + else: + new_w = target[0] + new_h = int(new_w / im_ratio) + + im = im.resize((new_w, new_h)) + left = (new_w - target[0]) // 2 + top = (new_h - target[1]) // 2 + im = im.crop((left, top, left + target[0], top + target[1])) + bg.paste(im, (0, 0)) + except Exception: + pass + + draw = ImageDraw.Draw(bg) + + try: + font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54) + font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38) + except Exception: + font_title = font_body = None + + draw.rectangle((0, 720, W, H), fill=(14, 14, 14)) + margin = 48 + maxw = W - margin * 2 + + y = 830 + for ln in _wrap_text(draw, post.get("title", ""), font_title, maxw, 4): + draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title) + y += 66 + + y += 18 + text = post.get("text", "") + text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip() + body_lines = _wrap_text(draw, text, font_body, maxw, 14) + for ln in body_lines: + draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body) + y += 50 + if y > 1640: + break + + bg.save(out_path, quality=92) + return True + + +def _wrap_text(draw, text, font, max_width, max_lines): + """Helper for wrapping text in frames.""" + words = _clean_text(text).split() + lines, cur = [], "" + for w in words: + test = (cur + " " + w).strip() + try: + width = draw.textbbox((0, 0), test, font=font)[2] + except Exception: + width = len(test) * 20 + if width <= max_width: + cur = test + else: + if cur: + lines.append(cur) + cur = w + if len(lines) >= max_lines: + break + if cur and len(lines) < max_lines: + lines.append(cur) + return lines \ No newline at end of file diff --git a/ai_patch.py b/ai_patch.py index 41aeba3d810744429c14e473c32c3a9fb8e3a605..a1ef29df2d0a94076dabec10f6cfbb90445cf76d 100644 --- a/ai_patch.py +++ b/ai_patch.py @@ -327,24 +327,6 @@ Nội dung bài: if 'Nguồn tham khảo:' not in text: text += "\n\n" + _source_line(src) post = base.make_post(art['title'], text, art.get('image') or base.pollinations_image_url(art['title']), art.get('url') or '', 'topic_article', sources=src) - - # Generate slides for this post so they persist after page reload - try: - page_data = _scrape_article_images(art.get('url', '')) - if page_data and page_data.get('paragraphs'): - key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12) - if key_points: - relevant_imgs = page_data.get('images', []) - if not relevant_imgs and page_data.get('og_img'): - relevant_imgs = [page_data['og_img']] - slides = [] - for i, point in enumerate(key_points): - img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '') - slides.append({'text': point, 'image': img, 'index': i + 1}) - post['slides'] = slides - except Exception: - pass - new_posts.append(post) posts = new_posts + posts base._save_ai_wall(posts) @@ -371,26 +353,8 @@ async def compat_url_wall(request: Request): if 'Nguồn tham khảo:' not in text: text += "\n\n" + _source_line(src) post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src) - - # Generate slides so they persist after page reload - slides = [] - try: - page_data = _scrape_article_images(url) - if page_data and page_data.get('paragraphs'): - key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12) - if key_points: - relevant_imgs = page_data.get('images', []) - if not relevant_imgs and page_data.get('og_img'): - relevant_imgs = [page_data['og_img']] - for i, point in enumerate(key_points): - img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '') - slides.append({'text': point, 'image': img, 'index': i + 1}) - except Exception: - pass - post['slides'] = slides - posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts) - return JSONResponse({'post': post, 'slides': slides}) + return JSONResponse({'post': post}) def _is_relevant_image(img_url, title, text): @@ -523,6 +487,7 @@ async def compat_rewrite_share(request: Request): if 'Nguồn tham khảo:' not in text: text += "\n\n" + _source_line(src) post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src) + posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts) # Generate slides with relevant images only slides = [] @@ -537,10 +502,6 @@ async def compat_rewrite_share(request: Request): img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '') slides.append({'text': point, 'image': img, 'index': i + 1}) - # FIX: Save slides into post so they persist after page reload - post['slides'] = slides - posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts) - return JSONResponse({'post': post, 'slides': slides}) diff --git a/ai_runtime_fix.py b/ai_runtime_fix.py deleted file mode 100644 index 0ec96f7c3c57d95b682765689d6ed0964058cd85..0000000000000000000000000000000000000000 --- a/ai_runtime_fix.py +++ /dev/null @@ -1,394 +0,0 @@ -"""VNEWS Short Video Fix - standalone module with clean registration. -This module MUST be imported LAST to register /api/ai/short endpoints. -FIX v1: No route filtering issues - registers endpoints unconditionally. -FIX v2: SSE inline endpoint for auto homepage updates -""" -import os -import re -import time -import json -import sys -import logging -import asyncio -import hashlib -import subprocess -import requests -from datetime import datetime, timezone, timedelta -from urllib.parse import urlparse -from fastapi import Request, Query -from fastapi.responses import JSONResponse, FileResponse - -# Import dependencies -try: - import ai_ext as base -except ImportError: - import ai_runtime_final6 as base - -# Try to import app from various sources -try: - from app_v2_entry import app -except ImportError: - try: - from main import app - except ImportError: - from ai_runtime_final6 import app - -_log = logging.getLogger("short_fix") -_log.setLevel(logging.INFO) -if not _log.handlers: - _log.addHandler(logging.StreamHandler(sys.stderr)) - -DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data" -os.makedirs(DATA_DIR, exist_ok=True) -SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts") -os.makedirs(SHORTS_DIR, exist_ok=True) - -# ===== VIETNAMESE FONT DETECTION ===== -_VN_FONT_REG = None -_VN_FONT_BOLD = None - -def _get_vn_fonts(): - """Find Vietnamese-supporting fonts.""" - global _VN_FONT_REG, _VN_FONT_BOLD - if _VN_FONT_REG is not None: - return _VN_FONT_REG, _VN_FONT_BOLD - - try: - from PIL import ImageFont - except Exception: - _log.error("PIL not available!") - return None, None - - # Priority: Noto > DejaVu > Liberation - reg_paths = [ - "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - "/usr/share/fonts/truetype/freefont/FreeSans.ttf", - ] - bold_paths = [ - "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf", - "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", - "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", - "/usr/share/fonts/truetype/freefont/FreeSans.ttf", - ] - - for path in reg_paths: - if os.path.exists(path): - try: - _VN_FONT_REG = ImageFont.truetype(path, 40) - _log.info(f"Found regular font: {path}") - break - except: - continue - - for path in bold_paths: - if os.path.exists(path): - try: - _VN_FONT_BOLD = ImageFont.truetype(path, 52) - _log.info(f"Found bold font: {path}") - break - except: - continue - - if _VN_FONT_REG is None: - _VN_FONT_REG = ImageFont.load_default() - if _VN_FONT_BOLD is None: - _VN_FONT_BOLD = _VN_FONT_REG - - return _VN_FONT_REG, _VN_FONT_BOLD - - -def _clean(s): - import html as html_lib - return re.sub(r"\s+", " ", html_lib.unescape(str(s or ""))).strip() - - -# ===== ROBUST TEXT SEGMENTATION ===== -def _split_into_segments(text, max_segments=10, min_len=30): - """Split text into segments - multi strategy.""" - text = _clean(text) - if not text: - return [] - - # Strategy 1: bullet points - lines = text.split('\n') - segmented = [] - for line in lines: - line = _clean(line) - line_bare = re.sub(r'^[•\-\*\d\.\)\s]+', '', line).strip() - if len(line_bare) > min_len: - segmented.append(line_bare) - elif len(line) > min_len: - segmented.append(line) - - # Strategy 2: sentences (Vietnamese) - if len(segmented) < 2: - sents = re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-ỸĐ])', text) - segmented = [s for s in sents if len(_clean(s)) > min_len] - - # Strategy 3: character chunks - if not segmented: - words = text.split() - for i in range(0, min(len(words), max_segments * 20), 20): - chunk = ' '.join(words[i:i+20]) - if len(chunk) > min_len: - segmented.append(chunk) - - # Strategy 4: fallback - if not segmented: - segmented = [text[:300]] - - return segmented[:max_segments] - - -# ===== SHORT VIDEO GENERATOR ===== -def _gen_short_core(post, work_dir): - """Core short generation - returns video path or None.""" - post_id = post.get('id', '') - text = post.get('text', '') or post.get('title', '') - - if not post_id or len(text) < 100: - _log.error(f"Invalid post: id={post_id}, text_len={len(text)}") - return None - - segments = _split_into_segments(text, max_segments=10, min_len=30) - if not segments: - _log.error("No segments generated") - return None - - _log.info(f"Generating short: {len(segments)} segments") - - seg_hash = hashlib.md5(('|'.join(segments) + 'nu').encode()).hexdigest()[:8] - suffix = f"_nu_{seg_hash}" - out_mp4 = os.path.join(work_dir, f"{post_id}{suffix}.mp4") - - if os.path.exists(out_mp4): - _log.info(f"Already exists: {out_mp4}") - return out_mp4 - - # Check dependencies - try: - subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5) - except Exception as e: - _log.error(f"ffmpeg missing: {e}") - return None - - # Download image - img_path = os.path.join(work_dir, 'bg.jpg') - downloaded = False - try: - img_url = post.get('img', '') - if img_url and img_url.startswith('http'): - r = requests.get(img_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=12) - if r.status_code == 200: - with open(img_path, 'wb') as f: - f.write(r.content) - downloaded = True - except Exception as e: - _log.warning(f"Image download: {e}") - - try: - from PIL import Image, ImageDraw - has_pil = True - except: - has_pil = False - _log.warning("PIL not available") - - try: - from gtts import gTTS - has_tts = True - except: - has_tts = False - _log.warning("gTTS not available") - - parts = [] - - for i, seg in enumerate(segments[:10]): - frame = os.path.join(work_dir, f'frame_{i}.jpg') - audio = os.path.join(work_dir, f'audio_{i}.mp3') - part = os.path.join(work_dir, f'part_{i}.mp4') - - # Create frame - try: - if has_pil: - _make_frame(post, seg, img_path, downloaded, frame) - else: - subprocess.run(['ffmpeg', '-y', '-f', 'lavfi', '-i', - 'color=c=black:s=1080x1920:d=1', '-frames:v', '1', frame], - capture_output=True, timeout=20) - except Exception as e: - _log.error(f"Frame error: {e}") - continue - - # Create audio - if has_tts: - try: - tts = _clean(seg)[:300] - gTTS(tts, lang='vi', slow=False).save(audio) - except Exception as e: - _log.warning(f"TTS error: {e}") - audio = None - - # Combine - dur = 10 - try: - cmd = ['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame] - if has_tts and os.path.exists(audio): - cmd += ['-i', audio, '-shortest'] - else: - cmd += ['-f', 'lavfi', '-i', 'anullsrc', '-shortest'] - cmd += ['-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p', - '-c:a', 'aac', '-b:a', '128k', part] - subprocess.run(cmd, capture_output=True, timeout=120) - if os.path.exists(part) and os.path.getsize(part) > 5000: - parts.append(part) - except Exception as e: - _log.error(f"Part combine error: {e}") - - if not parts: - _log.error("No video parts created!") - return None - - # Concatenate - try: - concat = os.path.join(work_dir, 'list.txt') - with open(concat, 'w') as f: - for p in parts: - f.write(f"file '{p}'\n") - subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat, '-c', 'copy', out_mp4], - capture_output=True, timeout=180) - _log.info(f"Short created: {out_mp4}") - return out_mp4 - except Exception as e: - _log.error(f"Concat error: {e}") - return None - - -def _make_frame(post, text, img_path, downloaded, out_path): - """Create video frame with Vietnamese font.""" - from PIL import Image, ImageDraw - _get_vn_fonts() - - W, H = 1080, 1920 - bg = Image.new('RGB', (W, H), (15, 23, 38)) - d = ImageDraw.Draw(bg) - - # Background image - if downloaded and os.path.exists(img_path): - try: - im = Image.open(img_path).convert('RGB') - im = im.resize((W, 760)) - bg.paste(im, (0, 0)) - except: - pass - - # Title - d.rectangle([0, 0, W, 100], fill=(25, 118, 210)) - ttl = post.get('title', '')[:50] - if _VN_FONT_BOLD: - d.text((W//2, 50), ttl, fill='white', font=_VN_FONT_BOLD, anchor='mm') - - # Content - y = 150 - for ln in _wrap_text(d, text[:200], _VN_FONT_REG, 80, 920, 10): - d.text((80, y), ln, fill='white', font=_VN_FONT_REG) - y += 55 - - bg.save(out_path, quality=85) - - -def _wrap_text(draw, text, font, x, max_w, max_lines): - """Word wrap text.""" - words = text.split() - lines = [] - cur = [] - for w in words: - test = ' '.join(cur + [w]) - try: - w_px = draw.textbbox((0, 0), test, font=font)[2] - except: - w_px = len(test) * 22 - if w_px <= max_w: - cur.append(w) - else: - if cur: - lines.append(' '.join(cur)) - cur = [w] - if len(lines) >= max_lines: - break - if cur and len(lines) < max_lines: - lines.append(' '.join(cur)) - return lines - - -def _gen_short_sync(post) -> str: - """Sync wrapper - returns video URL.""" - work = os.path.join(SHORTS_DIR, f"work_{post.get('id', int(time.time()))}") - os.makedirs(work, exist_ok=True) - result = _gen_short_core(post, work) - if result: - # Update wall - try: - wall = base._load_ai_wall() - for i, p in enumerate(wall): - if str(p.get('id')) == str(post.get('id')): - p['video'] = f'/api/ai/short-file/{post.get("id")}_nu_{hashlib.md5(str(post).encode()).hexdigest()[:8]}' - wall[i] = p - break - base._save_ai_wall(wall) - # Notify SSE for auto-update - try: - from auto_update_sse import notify_new_short - notify_new_short(post) - except: - pass - except Exception as e: - _log.warning(f"Wall update: {e}") - return result - return '' - - -# ===== REGISTER ENDPOINTS - MUST BE AT MODULE LEVEL ===== -@app.post('/api/ai/short/{post_id}') -async def api_short_generate(post_id: str, request: Request): - _log.info(f"POST /api/ai/short/{post_id}") - wall = base._load_ai_wall() - post = next((p for p in wall if str(p.get('id')) == str(post_id)), None) - if not post: - return JSONResponse({'error': 'Post not found in wall'}, status_code=404) - - if post.get('video'): - return JSONResponse({'post': post, 'video': post['video'], 'status': 'done'}) - - loop = asyncio.get_event_loop() - result = await loop.run_in_executor(None, _gen_short_sync, post) - - if result: - # Get the video URL from wall (updated in _gen_short_sync) - wall = base._load_ai_wall() - post = next((p for p in wall if str(p.get('id')) == str(post_id)), post) - return JSONResponse({'post': post, 'video': post.get('video'), 'status': 'done'}) - return JSONResponse({'error': 'Video generation failed'}, status_code=500) - - -@app.get('/api/ai/short-file/{file_id:path}') -async def api_short_file(file_id: str): - safe = re.sub(r'[^\w\-.]', '_', file_id)[:100] - for fname in os.listdir(SHORTS_DIR) if os.path.isdir(SHORTS_DIR) else []: - if fname.endswith('.mp4') and safe in fname: - return FileResponse(os.path.join(SHORTS_DIR, fname), media_type='video/mp4') - return JSONResponse({'error': 'Not found'}, status_code=404) - - -# ===== SSE ENDPOINT FOR AUTO-UPDATE ===== -try: - from auto_update_sse import sse_events as _sse_handler - app.add_api_route('/api/events', _sse_handler, methods=['GET']) - _log.info("SSE endpoint registered at /api/events") -except Exception as e: - _log.warning(f"SSE route not loaded: {e}") - - -# Log startup -_log.info("Short video endpoints registered") \ No newline at end of file diff --git a/ai_runtime_patch_fast.py b/ai_runtime_patch_fast.py index e08d425c45bba58bd73cb1ed98f7a4dbb7828b43..126cc9a14012fabb8b4f72064810a56a1c663802 100644 --- a/ai_runtime_patch_fast.py +++ b/ai_runtime_patch_fast.py @@ -1,5 +1,5 @@ """Final patch v2: fix topic rewrite, remove duplicate short slide, full short interaction buttons.""" -import re, threading, time, json, os, asyncio, requests +import re, threading, time, json, os, asyncio import ai_runtime_final6 as f6 from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query import html as html_lib @@ -121,69 +121,6 @@ async def _tp(request:Request): 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 ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps);return JSONResponse({'post':post}) -# ===== M3U IPTV Channels (FPT Play Bóng Đá live streams) ===== -_M3U_URL = "https://raw.githubusercontent.com/Love4vn/Stalker2M3U/refs/heads/1/live_schedule_Optimize.m3u" -_m3u_cache = {"t": 0, "d": []} - -def _parse_m3u(): - """Fetch & parse the M3U playlist. Returns list of channel dicts.""" - r = requests.get(_M3U_URL, headers={"User-Agent": "Mozilla/5.0"}, timeout=20) - if r.status_code != 200: - return [] - lines = r.text.splitlines() - out = [] - cur = {} - for line in lines: - line = line.strip() - if line.startswith("#EXTINF"): - t_match = re.search(r',(.+)$', line) - cur = { - "title": t_match.group(1).strip() if t_match else "Channel", - "id": "", - "logo": "", - "group": "", - "url": "", - "vlcopts": {}, - } - for attr_match in re.finditer(r'(\w+)="([^"]*)"', line): - k, v = attr_match.group(1), attr_match.group(2) - if k == "tvg-id": - cur["id"] = v - elif k == "tvg-logo": - cur["logo"] = v - elif k == "group-title": - cur["group"] = v - elif line.startswith("#EXTVLCOPT:"): - key_part = line[len("#EXTVLCOPT:"):] - if "=" in key_part: - k, v = key_part.split("=", 1) - cur.setdefault("vlcopts", {})[k.strip()] = v.strip() - elif line and not line.startswith("#") and cur: - cur["url"] = line - out.append(cur) - cur = {} - # Deduplicate by URL (same channel may have multiple stalker entries) - seen = set() - deduped = [] - for c in out: - if c["url"] not in seen: - seen.add(c["url"]) - deduped.append(c) - return deduped - -@app.get('/api/m3u/channels') -def api_m3u_channels(refresh: int = Query(default=0)): - n = int(time.time()) - if not refresh and _m3u_cache["d"] and n - _m3u_cache["t"] < 300: - return JSONResponse({"channels": _m3u_cache["d"], "updated": _m3u_cache["t"]}) - try: - channels = _parse_m3u() - _m3u_cache["t"] = n - _m3u_cache["d"] = channels - return JSONResponse({"channels": channels, "updated": n}) - except Exception as e: - return JSONResponse({"channels": _m3u_cache["d"], "updated": _m3u_cache["t"], "error": str(e)}) - PATCH_INJECT=r'''
''' diff --git a/ai_runtime_patch_final.py b/ai_runtime_patch_final.py deleted file mode 100644 index 0512ad0ef58446a2f791f8f85e0457950cdd54d3..0000000000000000000000000000000000000000 --- a/ai_runtime_patch_final.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Final patch: homepage fix + AI topics at top + SSE auto-update""" -import re, json, time -from fastapi.responses import HTMLResponse, JSONResponse -from fastapi import Query - -# Import chain - must be after ai_runtime_final6 -try: - import ai_runtime_final6 as f6 - from ai_runtime_final6 import app, f5 - from main import rt -except Exception as e: - print(f"[ERROR] f6 import: {e}") - f6 = None - f5 = None - rt = None - -PATCH_CSS_JS = r''' - - - -''' - -# Register route if possible -if f6 and app: - try: - # Remove duplicate / route to avoid conflict - original_routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))] - app.router.routes = original_routes - - @app.get('/') - async def patch_homepage(): - html = f5.f4.f3.f2.f1._load_index_html() if f5 else "" - body = "" - if hasattr(rt,'old') and hasattr(rt.old,'PATCH_INJECT'): - body += getattr(rt.old,'PATCH_INJECT','') - if f5: - body += getattr(f5.f4.f3.f2.f1,'FINAL_INJECT','') if hasattr(f5,'f4') else '' - body += getattr(f5.f4.f3,'FINAL3_INJECT','') if hasattr(f5,'f4') else '' - body += getattr(f5.f4,'FINAL4_INJECT','') if hasattr(f5,'f4') else '' - body += getattr(f5,'FINAL5_INJECT','') if hasattr(f5,'f4') else '' - body += getattr(f6,'FINAL6_INJECT','') if f6 else '' - body += getattr(f6,'FINAL6_FAST_HOME_INJECT','') if f6 else '' - body += getattr(f6,'FINAL6E_INJECT','') if f6 else '' - body += PATCH_CSS_JS - if '