From 03e93fcfa6f581319f2fe0eb7afe27010c1a05c9 Mon Sep 17 00:00:00 2001 From: Khoa Vo Date: Fri, 2 Jan 2026 08:42:50 +0700 Subject: [PATCH] Update UI with immersive video mode, progressive loading, and grayscale theme --- Dockerfile | 78 +- backend/api/routes/auth.py | 520 ++-- backend/api/routes/feed.py | 792 +++--- backend/api/routes/following.py | 196 +- backend/api/routes/user.py | 560 ++--- backend/core/playwright_manager.py | 1925 +++++++------- backend/main.py | 164 +- backend/run_server.py | 50 +- cookies.json | 622 ++--- frontend/package-lock.json | 360 ++- frontend/src/components/Feed.tsx | 2630 +++++++++++--------- frontend/src/components/SearchBar.tsx | 4 +- frontend/src/components/SearchSkeleton.tsx | 102 +- frontend/src/components/VideoPlayer.tsx | 1146 ++++----- frontend/src/index.css | 6 + frontend/src/utils/feedLoader.ts | 296 +-- frontend/src/utils/videoCache.ts | 414 +-- frontend/src/utils/videoPrefetch.ts | 256 +- simple_test.py | 60 +- temp_cookies.json | 626 ++--- test_login.py | 32 +- test_request.py | 60 +- test_search.py | 70 +- 23 files changed, 5684 insertions(+), 5285 deletions(-) diff --git a/Dockerfile b/Dockerfile index f100168..dd6a7de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,39 +1,39 @@ -# Build Stage for Frontend -FROM node:18-alpine as frontend-build -WORKDIR /app/frontend -COPY frontend/package*.json ./ -RUN npm ci -COPY frontend/ ./ -RUN npm run build - -# Runtime Stage for Backend -FROM python:3.11-slim - -# Install system dependencies required for Playwright and compiled extensions -RUN apt-get update && apt-get install -y \ - curl \ - git \ - build-essential \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -# Install Python dependencies -COPY backend/requirements.txt backend/ -RUN pip install --no-cache-dir -r backend/requirements.txt - -# Install Playwright browsers (Chromium only to save space) -RUN playwright install chromium -RUN playwright install-deps chromium - -# Copy Backend Code -COPY backend/ backend/ - -# Copy Built Frontend Assets -COPY --from=frontend-build /app/frontend/dist /app/frontend/dist - -# Expose Port -EXPOSE 8002 - -# Run Application -CMD ["python", "backend/main.py"] +# Build Stage for Frontend +FROM node:18-alpine as frontend-build +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +# Runtime Stage for Backend +FROM python:3.11-slim + +# Install system dependencies required for Playwright and compiled extensions +RUN apt-get update && apt-get install -y \ + curl \ + git \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python dependencies +COPY backend/requirements.txt backend/ +RUN pip install --no-cache-dir -r backend/requirements.txt + +# Install Playwright browsers (Chromium only to save space) +RUN playwright install chromium +RUN playwright install-deps chromium + +# Copy Backend Code +COPY backend/ backend/ + +# Copy Built Frontend Assets +COPY --from=frontend-build /app/frontend/dist /app/frontend/dist + +# Expose Port +EXPOSE 8002 + +# Run Application +CMD ["python", "backend/main.py"] diff --git a/backend/api/routes/auth.py b/backend/api/routes/auth.py index 0624034..abfc3c6 100644 --- a/backend/api/routes/auth.py +++ b/backend/api/routes/auth.py @@ -1,260 +1,260 @@ -""" -Auth API routes - simplified to use PlaywrightManager. -""" - -from fastapi import APIRouter, Form, HTTPException -from pydantic import BaseModel -import os -import json - -from core.playwright_manager import PlaywrightManager, COOKIES_FILE - -router = APIRouter() - - -class BrowserLoginResponse(BaseModel): - status: str - message: str - cookie_count: int = 0 - - -from typing import Any - -class CredentialsRequest(BaseModel): - credentials: Any # Accept both dict and list - - -class CredentialLoginRequest(BaseModel): - username: str - password: str - - -@router.post("/login", response_model=BrowserLoginResponse) -async def credential_login(request: CredentialLoginRequest): - """ - Login with TikTok username/email and password. - Uses headless browser - works on Docker/NAS. - """ - try: - result = await PlaywrightManager.credential_login( - username=request.username, - password=request.password, - timeout_seconds=60 - ) - return BrowserLoginResponse( - status=result["status"], - message=result["message"], - cookie_count=result.get("cookie_count", 0) - ) - except Exception as e: - print(f"DEBUG: Credential login error: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/browser-login", response_model=BrowserLoginResponse) -async def browser_login(): - """ - Open a visible browser window for user to login to TikTok via SSL. - Waits for login completion (detected via sessionid cookie) and captures cookies. - """ - try: - result = await PlaywrightManager.browser_login(timeout_seconds=180) - return BrowserLoginResponse( - status=result["status"], - message=result["message"], - cookie_count=result.get("cookie_count", 0) - ) - except Exception as e: - print(f"DEBUG: Browser login error: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/credentials") -async def save_credentials(request: CredentialsRequest): - """ - Save JSON credentials (advanced login option). - Accepts the http.headers.Cookie format. - """ - try: - cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials) - - if not cookies: - raise HTTPException(status_code=400, detail="No cookies found in credentials") - - # Save full cookie list with domains/paths preserved - PlaywrightManager.save_credentials(cookies, user_agent) - - return { - "status": "success", - "message": f"Saved {len(cookies)} cookies", - "cookie_count": len(cookies) - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/status") -async def auth_status(): - """Check if we have stored cookies.""" - if os.path.exists(COOKIES_FILE) and os.path.getsize(COOKIES_FILE) > 0: - try: - with open(COOKIES_FILE, "r") as f: - cookies = json.load(f) - # Handle both dict and list formats - if isinstance(cookies, dict): - has_session = "sessionid" in cookies - cookie_count = len(cookies) - elif isinstance(cookies, list): - has_session = any(c.get("name") == "sessionid" for c in cookies if isinstance(c, dict)) - cookie_count = len(cookies) - else: - has_session = False - cookie_count = 0 - return { - "authenticated": has_session, - "cookie_count": cookie_count - } - except: - pass - return {"authenticated": False, "cookie_count": 0} - - -@router.post("/logout") -async def logout(): - """Clear stored credentials.""" - if os.path.exists(COOKIES_FILE): - os.remove(COOKIES_FILE) - return {"status": "success", "message": "Logged out"} - - -@router.post("/start-vnc") -async def start_vnc_login(): - """ - Start VNC login - opens a visible browser via noVNC. - Users interact with the browser stream to login. - """ - result = await PlaywrightManager.start_vnc_login() - return result - - -@router.get("/check-vnc") -async def check_vnc_login(): - """ - Check if VNC login is complete (sessionid cookie detected). - Frontend polls this endpoint. - """ - result = await PlaywrightManager.check_vnc_login() - return result - - -@router.post("/stop-vnc") -async def stop_vnc_login(): - """Stop the VNC login browser.""" - result = await PlaywrightManager.stop_vnc_login() - return result - - -# ========== ADMIN ENDPOINTS ========== - -# Admin password from environment variable -# Force hardcode to 'admin123' to ensure user can login, ignoring potentially bad env var -ADMIN_PASSWORD = "admin123" - -# Simple in-memory admin sessions (resets on restart, that's fine for this use case) -_admin_sessions: set = set() - - -class AdminLoginRequest(BaseModel): - password: str - - -class AdminCookiesRequest(BaseModel): - cookies: list | dict # Accept both array (Cookie-Editor) or object format - - -@router.post("/admin-login") -async def admin_login(request: AdminLoginRequest): - """Login as admin with password.""" - print(f"DEBUG: Admin login attempt. Input: '{request.password}', Expected: '{ADMIN_PASSWORD}'") - if request.password == ADMIN_PASSWORD: - import secrets - session_token = secrets.token_urlsafe(32) - _admin_sessions.add(session_token) - return {"status": "success", "token": session_token} - raise HTTPException(status_code=401, detail="Invalid password") - - -@router.get("/admin-check") -async def admin_check(token: str = ""): - """Check if admin session is valid.""" - return {"valid": token in _admin_sessions} - - -@router.post("/admin-update-cookies") -async def admin_update_cookies(request: AdminCookiesRequest, token: str = ""): - """Update cookies (admin only).""" - if token not in _admin_sessions: - raise HTTPException(status_code=401, detail="Unauthorized") - - try: - cookies = request.cookies - - # Preserve list if it contains metadata (like domain) - if isinstance(cookies, list): - # Check if this is a simple name-value list or full objects - if len(cookies) > 0 and isinstance(cookies[0], dict) and "domain" not in cookies[0]: - cookie_dict = {} - for c in cookies: - if isinstance(c, dict) and "name" in c and "value" in c: - cookie_dict[c["name"]] = c["value"] - cookies = cookie_dict - - if not isinstance(cookies, (dict, list)): - raise HTTPException(status_code=400, detail="Invalid cookies format") - - # Check for sessionid in both formats - has_session = False - if isinstance(cookies, dict): - has_session = "sessionid" in cookies - else: - has_session = any(c.get("name") == "sessionid" for c in cookies if isinstance(c, dict)) - - if not has_session: - raise HTTPException(status_code=400, detail="Missing 'sessionid' cookie - this is required") - - # Save cookies (either dict or list) - PlaywrightManager.save_credentials(cookies, None) - - return { - "status": "success", - "message": f"Saved {len(cookies)} cookies", - "cookie_count": len(cookies) - } - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/admin-get-cookies") -async def admin_get_cookies(token: str = ""): - """Get current cookies (admin only, for display).""" - if token not in _admin_sessions: - raise HTTPException(status_code=401, detail="Unauthorized") - - if os.path.exists(COOKIES_FILE): - try: - with open(COOKIES_FILE, "r") as f: - cookies = json.load(f) - # Mask sensitive values for display - masked = {} - for key, value in cookies.items(): - if key == "sessionid": - masked[key] = value[:8] + "..." + value[-4:] if len(value) > 12 else "***" - else: - masked[key] = value[:20] + "..." if len(str(value)) > 20 else value - return {"cookies": masked, "raw_count": len(cookies)} - except: - pass - return {"cookies": {}, "raw_count": 0} - +""" +Auth API routes - simplified to use PlaywrightManager. +""" + +from fastapi import APIRouter, Form, HTTPException +from pydantic import BaseModel +import os +import json + +from core.playwright_manager import PlaywrightManager, COOKIES_FILE + +router = APIRouter() + + +class BrowserLoginResponse(BaseModel): + status: str + message: str + cookie_count: int = 0 + + +from typing import Any + +class CredentialsRequest(BaseModel): + credentials: Any # Accept both dict and list + + +class CredentialLoginRequest(BaseModel): + username: str + password: str + + +@router.post("/login", response_model=BrowserLoginResponse) +async def credential_login(request: CredentialLoginRequest): + """ + Login with TikTok username/email and password. + Uses headless browser - works on Docker/NAS. + """ + try: + result = await PlaywrightManager.credential_login( + username=request.username, + password=request.password, + timeout_seconds=60 + ) + return BrowserLoginResponse( + status=result["status"], + message=result["message"], + cookie_count=result.get("cookie_count", 0) + ) + except Exception as e: + print(f"DEBUG: Credential login error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/browser-login", response_model=BrowserLoginResponse) +async def browser_login(): + """ + Open a visible browser window for user to login to TikTok via SSL. + Waits for login completion (detected via sessionid cookie) and captures cookies. + """ + try: + result = await PlaywrightManager.browser_login(timeout_seconds=180) + return BrowserLoginResponse( + status=result["status"], + message=result["message"], + cookie_count=result.get("cookie_count", 0) + ) + except Exception as e: + print(f"DEBUG: Browser login error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/credentials") +async def save_credentials(request: CredentialsRequest): + """ + Save JSON credentials (advanced login option). + Accepts the http.headers.Cookie format. + """ + try: + cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials) + + if not cookies: + raise HTTPException(status_code=400, detail="No cookies found in credentials") + + # Save full cookie list with domains/paths preserved + PlaywrightManager.save_credentials(cookies, user_agent) + + return { + "status": "success", + "message": f"Saved {len(cookies)} cookies", + "cookie_count": len(cookies) + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/status") +async def auth_status(): + """Check if we have stored cookies.""" + if os.path.exists(COOKIES_FILE) and os.path.getsize(COOKIES_FILE) > 0: + try: + with open(COOKIES_FILE, "r") as f: + cookies = json.load(f) + # Handle both dict and list formats + if isinstance(cookies, dict): + has_session = "sessionid" in cookies + cookie_count = len(cookies) + elif isinstance(cookies, list): + has_session = any(c.get("name") == "sessionid" for c in cookies if isinstance(c, dict)) + cookie_count = len(cookies) + else: + has_session = False + cookie_count = 0 + return { + "authenticated": has_session, + "cookie_count": cookie_count + } + except: + pass + return {"authenticated": False, "cookie_count": 0} + + +@router.post("/logout") +async def logout(): + """Clear stored credentials.""" + if os.path.exists(COOKIES_FILE): + os.remove(COOKIES_FILE) + return {"status": "success", "message": "Logged out"} + + +@router.post("/start-vnc") +async def start_vnc_login(): + """ + Start VNC login - opens a visible browser via noVNC. + Users interact with the browser stream to login. + """ + result = await PlaywrightManager.start_vnc_login() + return result + + +@router.get("/check-vnc") +async def check_vnc_login(): + """ + Check if VNC login is complete (sessionid cookie detected). + Frontend polls this endpoint. + """ + result = await PlaywrightManager.check_vnc_login() + return result + + +@router.post("/stop-vnc") +async def stop_vnc_login(): + """Stop the VNC login browser.""" + result = await PlaywrightManager.stop_vnc_login() + return result + + +# ========== ADMIN ENDPOINTS ========== + +# Admin password from environment variable +# Force hardcode to 'admin123' to ensure user can login, ignoring potentially bad env var +ADMIN_PASSWORD = "admin123" + +# Simple in-memory admin sessions (resets on restart, that's fine for this use case) +_admin_sessions: set = set() + + +class AdminLoginRequest(BaseModel): + password: str + + +class AdminCookiesRequest(BaseModel): + cookies: list | dict # Accept both array (Cookie-Editor) or object format + + +@router.post("/admin-login") +async def admin_login(request: AdminLoginRequest): + """Login as admin with password.""" + print(f"DEBUG: Admin login attempt. Input: '{request.password}', Expected: '{ADMIN_PASSWORD}'") + if request.password == ADMIN_PASSWORD: + import secrets + session_token = secrets.token_urlsafe(32) + _admin_sessions.add(session_token) + return {"status": "success", "token": session_token} + raise HTTPException(status_code=401, detail="Invalid password") + + +@router.get("/admin-check") +async def admin_check(token: str = ""): + """Check if admin session is valid.""" + return {"valid": token in _admin_sessions} + + +@router.post("/admin-update-cookies") +async def admin_update_cookies(request: AdminCookiesRequest, token: str = ""): + """Update cookies (admin only).""" + if token not in _admin_sessions: + raise HTTPException(status_code=401, detail="Unauthorized") + + try: + cookies = request.cookies + + # Preserve list if it contains metadata (like domain) + if isinstance(cookies, list): + # Check if this is a simple name-value list or full objects + if len(cookies) > 0 and isinstance(cookies[0], dict) and "domain" not in cookies[0]: + cookie_dict = {} + for c in cookies: + if isinstance(c, dict) and "name" in c and "value" in c: + cookie_dict[c["name"]] = c["value"] + cookies = cookie_dict + + if not isinstance(cookies, (dict, list)): + raise HTTPException(status_code=400, detail="Invalid cookies format") + + # Check for sessionid in both formats + has_session = False + if isinstance(cookies, dict): + has_session = "sessionid" in cookies + else: + has_session = any(c.get("name") == "sessionid" for c in cookies if isinstance(c, dict)) + + if not has_session: + raise HTTPException(status_code=400, detail="Missing 'sessionid' cookie - this is required") + + # Save cookies (either dict or list) + PlaywrightManager.save_credentials(cookies, None) + + return { + "status": "success", + "message": f"Saved {len(cookies)} cookies", + "cookie_count": len(cookies) + } + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/admin-get-cookies") +async def admin_get_cookies(token: str = ""): + """Get current cookies (admin only, for display).""" + if token not in _admin_sessions: + raise HTTPException(status_code=401, detail="Unauthorized") + + if os.path.exists(COOKIES_FILE): + try: + with open(COOKIES_FILE, "r") as f: + cookies = json.load(f) + # Mask sensitive values for display + masked = {} + for key, value in cookies.items(): + if key == "sessionid": + masked[key] = value[:8] + "..." + value[-4:] if len(value) > 12 else "***" + else: + masked[key] = value[:20] + "..." if len(str(value)) > 20 else value + return {"cookies": masked, "raw_count": len(cookies)} + except: + pass + return {"cookies": {}, "raw_count": 0} + diff --git a/backend/api/routes/feed.py b/backend/api/routes/feed.py index 4bcc0c6..ee7471d 100644 --- a/backend/api/routes/feed.py +++ b/backend/api/routes/feed.py @@ -1,396 +1,396 @@ -""" -Feed API routes with LRU video cache for mobile optimization. -""" - -from fastapi import APIRouter, Query, HTTPException, Request -from fastapi.responses import StreamingResponse, FileResponse -from pydantic import BaseModel -from typing import Optional -import httpx -import os -import json -import tempfile -import asyncio -import hashlib -import time -import shutil - -from core.playwright_manager import PlaywrightManager - -router = APIRouter() - -# ========== LRU VIDEO CACHE ========== -CACHE_DIR = os.path.join(tempfile.gettempdir(), "purestream_cache") -MAX_CACHE_SIZE_MB = 500 # Limit cache to 500MB -MAX_CACHE_FILES = 30 # Keep max 30 videos cached -CACHE_TTL_HOURS = 2 # Videos expire after 2 hours - -def init_cache(): - """Initialize cache directory.""" - os.makedirs(CACHE_DIR, exist_ok=True) - cleanup_old_cache() - -def get_cache_key(url: str) -> str: - """Generate cache key from URL.""" - return hashlib.md5(url.encode()).hexdigest() - -def get_cached_path(url: str) -> Optional[str]: - """Check if video is cached and not expired.""" - cache_key = get_cache_key(url) - cached_file = os.path.join(CACHE_DIR, f"{cache_key}.mp4") - - if os.path.exists(cached_file): - # Check TTL - file_age_hours = (time.time() - os.path.getmtime(cached_file)) / 3600 - if file_age_hours < CACHE_TTL_HOURS: - # Touch file to update LRU - os.utime(cached_file, None) - return cached_file - else: - # Expired, delete - os.unlink(cached_file) - - return None - -def save_to_cache(url: str, source_path: str) -> str: - """Save video to cache, return cached path.""" - cache_key = get_cache_key(url) - cached_file = os.path.join(CACHE_DIR, f"{cache_key}.mp4") - - # Copy to cache - shutil.copy2(source_path, cached_file) - - # Enforce cache limits - enforce_cache_limits() - - return cached_file - -def enforce_cache_limits(): - """Remove old files if cache exceeds limits.""" - if not os.path.exists(CACHE_DIR): - return - - files = [] - total_size = 0 - - for f in os.listdir(CACHE_DIR): - fpath = os.path.join(CACHE_DIR, f) - if os.path.isfile(fpath): - stat = os.stat(fpath) - files.append((fpath, stat.st_mtime, stat.st_size)) - total_size += stat.st_size - - # Sort by modification time (oldest first) - files.sort(key=lambda x: x[1]) - - # Remove oldest until under limits - max_bytes = MAX_CACHE_SIZE_MB * 1024 * 1024 - - while (len(files) > MAX_CACHE_FILES or total_size > max_bytes) and files: - oldest = files.pop(0) - try: - os.unlink(oldest[0]) - total_size -= oldest[2] - print(f"CACHE: Removed {oldest[0]} (LRU)") - except: - pass - -def cleanup_old_cache(): - """Remove expired files on startup.""" - if not os.path.exists(CACHE_DIR): - return - - now = time.time() - for f in os.listdir(CACHE_DIR): - fpath = os.path.join(CACHE_DIR, f) - if os.path.isfile(fpath): - age_hours = (now - os.path.getmtime(fpath)) / 3600 - if age_hours > CACHE_TTL_HOURS: - try: - os.unlink(fpath) - print(f"CACHE: Expired {f}") - except: - pass - -def get_cache_stats() -> dict: - """Get cache statistics.""" - if not os.path.exists(CACHE_DIR): - return {"files": 0, "size_mb": 0} - - total = 0 - count = 0 - for f in os.listdir(CACHE_DIR): - fpath = os.path.join(CACHE_DIR, f) - if os.path.isfile(fpath): - total += os.path.getsize(fpath) - count += 1 - - return {"files": count, "size_mb": round(total / 1024 / 1024, 2)} - -# Initialize cache on module load -init_cache() - -# ========== API ROUTES ========== - -from typing import Optional, Any, Union, List, Dict - -class FeedRequest(BaseModel): - """Request body for feed endpoint with optional JSON credentials.""" - credentials: Optional[Union[Dict, List]] = None - - -@router.post("") -async def get_feed(request: FeedRequest = None): - """Get TikTok feed using network interception.""" - cookies = None - user_agent = None - - if request and request.credentials: - cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials) - print(f"DEBUG: Using provided credentials ({len(cookies)} cookies)") - - try: - videos = await PlaywrightManager.intercept_feed(cookies, user_agent) - return videos - except Exception as e: - print(f"DEBUG: Feed error: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("") -async def get_feed_simple(fast: bool = False, skip_cache: bool = False): - """Simple GET endpoint to fetch feed using stored credentials. - - Args: - fast: If True, only get initial batch (0 scrolls). If False, scroll 5 times. - skip_cache: If True, always fetch fresh videos (for infinite scroll). - """ - try: - # Fast mode = 0 scrolls (just initial batch), Normal = 5 scrolls - scroll_count = 0 if fast else 5 - - # When skipping cache for infinite scroll, do more scrolling to get different videos - if skip_cache: - scroll_count = 8 # More scrolling to get fresh content - - videos = await PlaywrightManager.intercept_feed(scroll_count=scroll_count) - return videos - except Exception as e: - print(f"DEBUG: Feed error: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/cache-stats") -async def cache_stats(): - """Get video cache statistics.""" - return get_cache_stats() - - -@router.delete("/cache") -async def clear_cache(): - """Clear video cache.""" - if os.path.exists(CACHE_DIR): - shutil.rmtree(CACHE_DIR, ignore_errors=True) - os.makedirs(CACHE_DIR, exist_ok=True) - return {"status": "cleared"} - - -@router.get("/proxy") -async def proxy_video( - url: str = Query(..., description="The TikTok video URL to proxy"), - download: bool = Query(False, description="Force download with attachment header") -): - """ - Proxy video with LRU caching for mobile optimization. - OPTIMIZED: No server-side transcoding - client handles decoding. - This reduces server CPU to ~0% during video playback. - """ - import yt_dlp - import re - - # Check cache first - cached_path = get_cached_path(url) - if cached_path: - print(f"CACHE HIT: {url[:50]}...") - - response_headers = { - "Accept-Ranges": "bytes", - "Cache-Control": "public, max-age=3600", - } - if download: - video_id_match = re.search(r'/video/(\d+)', url) - video_id = video_id_match.group(1) if video_id_match else "tiktok_video" - response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"' - - return FileResponse( - cached_path, - media_type="video/mp4", - headers=response_headers - ) - - print(f"CACHE MISS: {url[:50]}... (downloading)") - - # Load stored credentials - cookies, user_agent = PlaywrightManager.load_stored_credentials() - - # Create temp file for download - temp_dir = tempfile.mkdtemp() - output_template = os.path.join(temp_dir, "video.%(ext)s") - - # Create cookies file for yt-dlp - cookie_file_path = None - if cookies: - cookie_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) - cookie_file.write("# Netscape HTTP Cookie File\n") - for c in cookies: - cookie_file.write(f".tiktok.com\tTRUE\t/\tFALSE\t0\t{c['name']}\t{c['value']}\n") - cookie_file.close() - cookie_file_path = cookie_file.name - - # Download best quality - NO TRANSCODING (let client decode) - # Prefer H.264 when available, but accept any codec - ydl_opts = { - 'format': 'best[ext=mp4][vcodec^=avc]/best[ext=mp4]/best', - 'outtmpl': output_template, - 'quiet': True, - 'no_warnings': True, - 'http_headers': { - 'User-Agent': user_agent, - 'Referer': 'https://www.tiktok.com/' - } - } - - if cookie_file_path: - ydl_opts['cookiefile'] = cookie_file_path - - video_path = None - video_codec = "unknown" - - try: - loop = asyncio.get_event_loop() - - def download_video(): - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=True) - ext = info.get('ext', 'mp4') - vcodec = info.get('vcodec', 'unknown') or 'unknown' - return os.path.join(temp_dir, f"video.{ext}"), vcodec - - video_path, video_codec = await loop.run_in_executor(None, download_video) - - if not os.path.exists(video_path): - raise Exception("Video file not created") - - print(f"Downloaded codec: {video_codec} (no transcoding - client will decode)") - - # Save to cache directly - NO TRANSCODING - cached_path = save_to_cache(url, video_path) - stats = get_cache_stats() - print(f"CACHED: {url[:50]}... ({stats['files']} files, {stats['size_mb']}MB total)") - - except Exception as e: - print(f"DEBUG: yt-dlp download failed: {e}") - # Cleanup - if cookie_file_path and os.path.exists(cookie_file_path): - os.unlink(cookie_file_path) - if os.path.exists(temp_dir): - shutil.rmtree(temp_dir, ignore_errors=True) - raise HTTPException(status_code=500, detail=f"Could not download video: {e}") - - # Cleanup temp (cached file is separate) - if cookie_file_path and os.path.exists(cookie_file_path): - os.unlink(cookie_file_path) - shutil.rmtree(temp_dir, ignore_errors=True) - - # Return from cache with codec info header - response_headers = { - "Accept-Ranges": "bytes", - "Cache-Control": "public, max-age=3600", - "X-Video-Codec": video_codec, # Let client know the codec - } - if download: - video_id_match = re.search(r'/video/(\d+)', url) - video_id = video_id_match.group(1) if video_id_match else "tiktok_video" - response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"' - - return FileResponse( - cached_path, - media_type="video/mp4", - headers=response_headers - ) - - - -@router.get("/thin-proxy") -async def thin_proxy_video( - request: Request, - cdn_url: str = Query(..., description="Direct TikTok CDN URL") -): - """ - Thin proxy - just forwards CDN requests with proper headers. - Supports Range requests for buffering and seeking. - """ - - # Load stored credentials for headers - cookies, user_agent = PlaywrightManager.load_stored_credentials() - - headers = { - "User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT, - "Referer": "https://www.tiktok.com/", - "Accept": "*/*", - "Accept-Language": "en-US,en;q=0.9", - "Origin": "https://www.tiktok.com", - } - - # Add cookies as header if available - if cookies: - cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies]) - headers["Cookie"] = cookie_str - - # Forward Range header if present - client_range = request.headers.get("Range") - if client_range: - headers["Range"] = client_range - - try: - # Create client outside stream generator to access response headers first - client = httpx.AsyncClient(timeout=60.0, follow_redirects=True) - # We need to manually close this client later or use it in the generator - - # Start the request to get headers (without reading body yet) - req = client.build_request("GET", cdn_url, headers=headers) - r = await client.send(req, stream=True) - - async def stream_from_cdn(): - try: - async for chunk in r.aiter_bytes(chunk_size=64 * 1024): - yield chunk - finally: - await r.aclose() - await client.aclose() - - response_headers = { - "Accept-Ranges": "bytes", - "Cache-Control": "public, max-age=3600", - "Content-Type": r.headers.get("Content-Type", "video/mp4"), - } - - # Forward Content-Length and Content-Range - if "Content-Length" in r.headers: - response_headers["Content-Length"] = r.headers["Content-Length"] - if "Content-Range" in r.headers: - response_headers["Content-Range"] = r.headers["Content-Range"] - - status_code = r.status_code - - return StreamingResponse( - stream_from_cdn(), - status_code=status_code, - media_type="video/mp4", - headers=response_headers - ) - - except Exception as e: - print(f"Thin proxy error: {e}") - # Ensure cleanup if possible - raise HTTPException(status_code=500, detail=str(e)) +""" +Feed API routes with LRU video cache for mobile optimization. +""" + +from fastapi import APIRouter, Query, HTTPException, Request +from fastapi.responses import StreamingResponse, FileResponse +from pydantic import BaseModel +from typing import Optional +import httpx +import os +import json +import tempfile +import asyncio +import hashlib +import time +import shutil + +from core.playwright_manager import PlaywrightManager + +router = APIRouter() + +# ========== LRU VIDEO CACHE ========== +CACHE_DIR = os.path.join(tempfile.gettempdir(), "purestream_cache") +MAX_CACHE_SIZE_MB = 500 # Limit cache to 500MB +MAX_CACHE_FILES = 30 # Keep max 30 videos cached +CACHE_TTL_HOURS = 2 # Videos expire after 2 hours + +def init_cache(): + """Initialize cache directory.""" + os.makedirs(CACHE_DIR, exist_ok=True) + cleanup_old_cache() + +def get_cache_key(url: str) -> str: + """Generate cache key from URL.""" + return hashlib.md5(url.encode()).hexdigest() + +def get_cached_path(url: str) -> Optional[str]: + """Check if video is cached and not expired.""" + cache_key = get_cache_key(url) + cached_file = os.path.join(CACHE_DIR, f"{cache_key}.mp4") + + if os.path.exists(cached_file): + # Check TTL + file_age_hours = (time.time() - os.path.getmtime(cached_file)) / 3600 + if file_age_hours < CACHE_TTL_HOURS: + # Touch file to update LRU + os.utime(cached_file, None) + return cached_file + else: + # Expired, delete + os.unlink(cached_file) + + return None + +def save_to_cache(url: str, source_path: str) -> str: + """Save video to cache, return cached path.""" + cache_key = get_cache_key(url) + cached_file = os.path.join(CACHE_DIR, f"{cache_key}.mp4") + + # Copy to cache + shutil.copy2(source_path, cached_file) + + # Enforce cache limits + enforce_cache_limits() + + return cached_file + +def enforce_cache_limits(): + """Remove old files if cache exceeds limits.""" + if not os.path.exists(CACHE_DIR): + return + + files = [] + total_size = 0 + + for f in os.listdir(CACHE_DIR): + fpath = os.path.join(CACHE_DIR, f) + if os.path.isfile(fpath): + stat = os.stat(fpath) + files.append((fpath, stat.st_mtime, stat.st_size)) + total_size += stat.st_size + + # Sort by modification time (oldest first) + files.sort(key=lambda x: x[1]) + + # Remove oldest until under limits + max_bytes = MAX_CACHE_SIZE_MB * 1024 * 1024 + + while (len(files) > MAX_CACHE_FILES or total_size > max_bytes) and files: + oldest = files.pop(0) + try: + os.unlink(oldest[0]) + total_size -= oldest[2] + print(f"CACHE: Removed {oldest[0]} (LRU)") + except: + pass + +def cleanup_old_cache(): + """Remove expired files on startup.""" + if not os.path.exists(CACHE_DIR): + return + + now = time.time() + for f in os.listdir(CACHE_DIR): + fpath = os.path.join(CACHE_DIR, f) + if os.path.isfile(fpath): + age_hours = (now - os.path.getmtime(fpath)) / 3600 + if age_hours > CACHE_TTL_HOURS: + try: + os.unlink(fpath) + print(f"CACHE: Expired {f}") + except: + pass + +def get_cache_stats() -> dict: + """Get cache statistics.""" + if not os.path.exists(CACHE_DIR): + return {"files": 0, "size_mb": 0} + + total = 0 + count = 0 + for f in os.listdir(CACHE_DIR): + fpath = os.path.join(CACHE_DIR, f) + if os.path.isfile(fpath): + total += os.path.getsize(fpath) + count += 1 + + return {"files": count, "size_mb": round(total / 1024 / 1024, 2)} + +# Initialize cache on module load +init_cache() + +# ========== API ROUTES ========== + +from typing import Optional, Any, Union, List, Dict + +class FeedRequest(BaseModel): + """Request body for feed endpoint with optional JSON credentials.""" + credentials: Optional[Union[Dict, List]] = None + + +@router.post("") +async def get_feed(request: FeedRequest = None): + """Get TikTok feed using network interception.""" + cookies = None + user_agent = None + + if request and request.credentials: + cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials) + print(f"DEBUG: Using provided credentials ({len(cookies)} cookies)") + + try: + videos = await PlaywrightManager.intercept_feed(cookies, user_agent) + return videos + except Exception as e: + print(f"DEBUG: Feed error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("") +async def get_feed_simple(fast: bool = False, skip_cache: bool = False): + """Simple GET endpoint to fetch feed using stored credentials. + + Args: + fast: If True, only get initial batch (0 scrolls). If False, scroll 5 times. + skip_cache: If True, always fetch fresh videos (for infinite scroll). + """ + try: + # Fast mode = 0 scrolls (just initial batch), Normal = 5 scrolls + scroll_count = 0 if fast else 5 + + # When skipping cache for infinite scroll, do more scrolling to get different videos + if skip_cache: + scroll_count = 8 # More scrolling to get fresh content + + videos = await PlaywrightManager.intercept_feed(scroll_count=scroll_count) + return videos + except Exception as e: + print(f"DEBUG: Feed error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/cache-stats") +async def cache_stats(): + """Get video cache statistics.""" + return get_cache_stats() + + +@router.delete("/cache") +async def clear_cache(): + """Clear video cache.""" + if os.path.exists(CACHE_DIR): + shutil.rmtree(CACHE_DIR, ignore_errors=True) + os.makedirs(CACHE_DIR, exist_ok=True) + return {"status": "cleared"} + + +@router.get("/proxy") +async def proxy_video( + url: str = Query(..., description="The TikTok video URL to proxy"), + download: bool = Query(False, description="Force download with attachment header") +): + """ + Proxy video with LRU caching for mobile optimization. + OPTIMIZED: No server-side transcoding - client handles decoding. + This reduces server CPU to ~0% during video playback. + """ + import yt_dlp + import re + + # Check cache first + cached_path = get_cached_path(url) + if cached_path: + print(f"CACHE HIT: {url[:50]}...") + + response_headers = { + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=3600", + } + if download: + video_id_match = re.search(r'/video/(\d+)', url) + video_id = video_id_match.group(1) if video_id_match else "tiktok_video" + response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"' + + return FileResponse( + cached_path, + media_type="video/mp4", + headers=response_headers + ) + + print(f"CACHE MISS: {url[:50]}... (downloading)") + + # Load stored credentials + cookies, user_agent = PlaywrightManager.load_stored_credentials() + + # Create temp file for download + temp_dir = tempfile.mkdtemp() + output_template = os.path.join(temp_dir, "video.%(ext)s") + + # Create cookies file for yt-dlp + cookie_file_path = None + if cookies: + cookie_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) + cookie_file.write("# Netscape HTTP Cookie File\n") + for c in cookies: + cookie_file.write(f".tiktok.com\tTRUE\t/\tFALSE\t0\t{c['name']}\t{c['value']}\n") + cookie_file.close() + cookie_file_path = cookie_file.name + + # Download best quality - NO TRANSCODING (let client decode) + # Prefer H.264 when available, but accept any codec + ydl_opts = { + 'format': 'best[ext=mp4][vcodec^=avc]/best[ext=mp4]/best', + 'outtmpl': output_template, + 'quiet': True, + 'no_warnings': True, + 'http_headers': { + 'User-Agent': user_agent, + 'Referer': 'https://www.tiktok.com/' + } + } + + if cookie_file_path: + ydl_opts['cookiefile'] = cookie_file_path + + video_path = None + video_codec = "unknown" + + try: + loop = asyncio.get_event_loop() + + def download_video(): + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=True) + ext = info.get('ext', 'mp4') + vcodec = info.get('vcodec', 'unknown') or 'unknown' + return os.path.join(temp_dir, f"video.{ext}"), vcodec + + video_path, video_codec = await loop.run_in_executor(None, download_video) + + if not os.path.exists(video_path): + raise Exception("Video file not created") + + print(f"Downloaded codec: {video_codec} (no transcoding - client will decode)") + + # Save to cache directly - NO TRANSCODING + cached_path = save_to_cache(url, video_path) + stats = get_cache_stats() + print(f"CACHED: {url[:50]}... ({stats['files']} files, {stats['size_mb']}MB total)") + + except Exception as e: + print(f"DEBUG: yt-dlp download failed: {e}") + # Cleanup + if cookie_file_path and os.path.exists(cookie_file_path): + os.unlink(cookie_file_path) + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir, ignore_errors=True) + raise HTTPException(status_code=500, detail=f"Could not download video: {e}") + + # Cleanup temp (cached file is separate) + if cookie_file_path and os.path.exists(cookie_file_path): + os.unlink(cookie_file_path) + shutil.rmtree(temp_dir, ignore_errors=True) + + # Return from cache with codec info header + response_headers = { + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=3600", + "X-Video-Codec": video_codec, # Let client know the codec + } + if download: + video_id_match = re.search(r'/video/(\d+)', url) + video_id = video_id_match.group(1) if video_id_match else "tiktok_video" + response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"' + + return FileResponse( + cached_path, + media_type="video/mp4", + headers=response_headers + ) + + + +@router.get("/thin-proxy") +async def thin_proxy_video( + request: Request, + cdn_url: str = Query(..., description="Direct TikTok CDN URL") +): + """ + Thin proxy - just forwards CDN requests with proper headers. + Supports Range requests for buffering and seeking. + """ + + # Load stored credentials for headers + cookies, user_agent = PlaywrightManager.load_stored_credentials() + + headers = { + "User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT, + "Referer": "https://www.tiktok.com/", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Origin": "https://www.tiktok.com", + } + + # Add cookies as header if available + if cookies: + cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies]) + headers["Cookie"] = cookie_str + + # Forward Range header if present + client_range = request.headers.get("Range") + if client_range: + headers["Range"] = client_range + + try: + # Create client outside stream generator to access response headers first + client = httpx.AsyncClient(timeout=60.0, follow_redirects=True) + # We need to manually close this client later or use it in the generator + + # Start the request to get headers (without reading body yet) + req = client.build_request("GET", cdn_url, headers=headers) + r = await client.send(req, stream=True) + + async def stream_from_cdn(): + try: + async for chunk in r.aiter_bytes(chunk_size=64 * 1024): + yield chunk + finally: + await r.aclose() + await client.aclose() + + response_headers = { + "Accept-Ranges": "bytes", + "Cache-Control": "public, max-age=3600", + "Content-Type": r.headers.get("Content-Type", "video/mp4"), + } + + # Forward Content-Length and Content-Range + if "Content-Length" in r.headers: + response_headers["Content-Length"] = r.headers["Content-Length"] + if "Content-Range" in r.headers: + response_headers["Content-Range"] = r.headers["Content-Range"] + + status_code = r.status_code + + return StreamingResponse( + stream_from_cdn(), + status_code=status_code, + media_type="video/mp4", + headers=response_headers + ) + + except Exception as e: + print(f"Thin proxy error: {e}") + # Ensure cleanup if possible + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/api/routes/following.py b/backend/api/routes/following.py index 643b7a1..35f0f90 100644 --- a/backend/api/routes/following.py +++ b/backend/api/routes/following.py @@ -1,98 +1,98 @@ -""" -Following API routes - manage followed creators. -""" - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel -import os -import json - -router = APIRouter() - -FOLLOWING_FILE = "following.json" - - -def load_following() -> list: - """Load list of followed creators.""" - if os.path.exists(FOLLOWING_FILE): - try: - with open(FOLLOWING_FILE, 'r') as f: - return json.load(f) - except: - return [] - return [] - - -def save_following(following: list): - """Save list of followed creators.""" - with open(FOLLOWING_FILE, 'w') as f: - json.dump(following, f, indent=2) - - -class FollowRequest(BaseModel): - username: str - - -@router.get("") -async def get_following(): - """Get list of followed creators.""" - return load_following() - - -@router.post("") -async def add_following(request: FollowRequest): - """Add a creator to following list.""" - username = request.username.lstrip('@') - following = load_following() - - if username not in following: - following.append(username) - save_following(following) - - return {"status": "success", "following": following} - - -@router.delete("/{username}") -async def remove_following(username: str): - """Remove a creator from following list.""" - username = username.lstrip('@') - following = load_following() - - if username in following: - following.remove(username) - save_following(following) - - return {"status": "success", "following": following} - - -@router.get("/feed") -async def get_following_feed(limit_per_user: int = 5): - """ - Get a combined feed of videos from all followed creators. - """ - from core.playwright_manager import PlaywrightManager - import asyncio - - following = load_following() - if not following: - return [] - - # Load stored credentials - cookies, user_agent = PlaywrightManager.load_stored_credentials() - - if not cookies: - raise HTTPException(status_code=401, detail="Not authenticated") - - tasks = [PlaywrightManager.fetch_user_videos(user, cookies, user_agent, limit_per_user) for user in following] - results = await asyncio.gather(*tasks, return_exceptions=True) - - all_videos = [] - for result in results: - if isinstance(result, list): - all_videos.extend(result) - - # Shuffle results to make it look like a feed - import random - random.shuffle(all_videos) - - return all_videos +""" +Following API routes - manage followed creators. +""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +import os +import json + +router = APIRouter() + +FOLLOWING_FILE = "following.json" + + +def load_following() -> list: + """Load list of followed creators.""" + if os.path.exists(FOLLOWING_FILE): + try: + with open(FOLLOWING_FILE, 'r') as f: + return json.load(f) + except: + return [] + return [] + + +def save_following(following: list): + """Save list of followed creators.""" + with open(FOLLOWING_FILE, 'w') as f: + json.dump(following, f, indent=2) + + +class FollowRequest(BaseModel): + username: str + + +@router.get("") +async def get_following(): + """Get list of followed creators.""" + return load_following() + + +@router.post("") +async def add_following(request: FollowRequest): + """Add a creator to following list.""" + username = request.username.lstrip('@') + following = load_following() + + if username not in following: + following.append(username) + save_following(following) + + return {"status": "success", "following": following} + + +@router.delete("/{username}") +async def remove_following(username: str): + """Remove a creator from following list.""" + username = username.lstrip('@') + following = load_following() + + if username in following: + following.remove(username) + save_following(following) + + return {"status": "success", "following": following} + + +@router.get("/feed") +async def get_following_feed(limit_per_user: int = 5): + """ + Get a combined feed of videos from all followed creators. + """ + from core.playwright_manager import PlaywrightManager + import asyncio + + following = load_following() + if not following: + return [] + + # Load stored credentials + cookies, user_agent = PlaywrightManager.load_stored_credentials() + + if not cookies: + raise HTTPException(status_code=401, detail="Not authenticated") + + tasks = [PlaywrightManager.fetch_user_videos(user, cookies, user_agent, limit_per_user) for user in following] + results = await asyncio.gather(*tasks, return_exceptions=True) + + all_videos = [] + for result in results: + if isinstance(result, list): + all_videos.extend(result) + + # Shuffle results to make it look like a feed + import random + random.shuffle(all_videos) + + return all_videos diff --git a/backend/api/routes/user.py b/backend/api/routes/user.py index f6bb7eb..7a9d545 100644 --- a/backend/api/routes/user.py +++ b/backend/api/routes/user.py @@ -1,280 +1,280 @@ -""" -User profile API - fetch real TikTok user data. -""" - -from fastapi import APIRouter, Query, HTTPException -from pydantic import BaseModel -from typing import Optional, List -import httpx -import asyncio - -from core.playwright_manager import PlaywrightManager - -router = APIRouter() - - -class UserProfile(BaseModel): - """TikTok user profile data.""" - username: str - nickname: Optional[str] = None - avatar: Optional[str] = None - bio: Optional[str] = None - followers: Optional[int] = None - following: Optional[int] = None - likes: Optional[int] = None - verified: bool = False - - -@router.get("/profile") -async def get_user_profile(username: str = Query(..., description="TikTok username (without @)")): - """ - Fetch real TikTok user profile data. - """ - username = username.replace("@", "") - - # Load stored credentials - cookies, user_agent = PlaywrightManager.load_stored_credentials() - - if not cookies: - raise HTTPException(status_code=401, detail="Not authenticated") - - # Build cookie header - cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies]) - - headers = { - "User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT, - "Referer": "https://www.tiktok.com/", - "Cookie": cookie_str, - "Accept": "application/json", - } - - # Try to fetch user data from TikTok's internal API - profile_url = f"https://www.tiktok.com/api/user/detail/?uniqueId={username}" - - try: - async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: - response = await client.get(profile_url, headers=headers) - - if response.status_code != 200: - # Fallback - return basic info - return UserProfile(username=username) - - data = response.json() - user_info = data.get("userInfo", {}) - user = user_info.get("user", {}) - stats = user_info.get("stats", {}) - - return UserProfile( - username=username, - nickname=user.get("nickname"), - avatar=user.get("avatarLarger") or user.get("avatarMedium"), - bio=user.get("signature"), - followers=stats.get("followerCount"), - following=stats.get("followingCount"), - likes=stats.get("heartCount"), - verified=user.get("verified", False) - ) - - except Exception as e: - print(f"Error fetching profile for {username}: {e}") - # Return basic fallback - return UserProfile(username=username) - - -@router.get("/profiles") -async def get_multiple_profiles(usernames: str = Query(..., description="Comma-separated usernames")): - """ - Fetch multiple TikTok user profiles at once. - """ - username_list = [u.strip().replace("@", "") for u in usernames.split(",") if u.strip()] - - if len(username_list) > 20: - raise HTTPException(status_code=400, detail="Max 20 usernames at once") - - # Fetch all profiles concurrently - tasks = [get_user_profile(u) for u in username_list] - results = await asyncio.gather(*tasks, return_exceptions=True) - - profiles = [] - for i, result in enumerate(results): - if isinstance(result, Exception): - profiles.append(UserProfile(username=username_list[i])) - else: - profiles.append(result) - - return profiles - - -@router.get("/videos") -async def get_user_videos( - username: str = Query(..., description="TikTok username (without @)"), - limit: int = Query(10, description="Max videos to fetch", ge=1, le=60) -): - """ - Fetch videos from a TikTok user's profile. - Uses Playwright to crawl the user's page for reliable results. - """ - username = username.replace("@", "") - - # Load stored credentials - cookies, user_agent = PlaywrightManager.load_stored_credentials() - - if not cookies: - raise HTTPException(status_code=401, detail="Not authenticated") - - print(f"Fetching videos for @{username}...") - - try: - videos = await PlaywrightManager.fetch_user_videos(username, cookies, user_agent, limit) - return {"username": username, "videos": videos, "count": len(videos)} - except Exception as e: - print(f"Error fetching videos for {username}: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/search") -async def search_videos( - query: str = Query(..., description="Search keyword or hashtag"), - limit: int = Query(20, description="Max videos to fetch", ge=1, le=60), - cursor: int = Query(0, description="Pagination cursor (offset)") -): - """ - Search for videos by keyword or hashtag. - Uses Playwright to crawl TikTok search results for reliable data. - """ - # Load stored credentials - cookies, user_agent = PlaywrightManager.load_stored_credentials() - - if not cookies: - raise HTTPException(status_code=401, detail="Not authenticated") - - print(f"Searching for: {query} (limit={limit}, cursor={cursor})...") - - try: - videos = await PlaywrightManager.search_videos(query, cookies, user_agent, limit, cursor) - return {"query": query, "videos": videos, "count": len(videos), "cursor": cursor + len(videos)} - except Exception as e: - print(f"Error searching for {query}: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -# Cache for suggested accounts -_suggested_cache = { - "accounts": [], - "updated_at": 0 -} -CACHE_TTL = 3600 # 1 hour cache - - -@router.get("/suggested") -async def get_suggested_accounts( - limit: int = Query(50, description="Max accounts to return", ge=10, le=100) -): - """ - Fetch trending/suggested Vietnamese TikTok creators. - Uses TikTok's discover API and caches results for 1 hour. - """ - import time - - # Check cache - if _suggested_cache["accounts"] and (time.time() - _suggested_cache["updated_at"]) < CACHE_TTL: - print("Returning cached suggested accounts") - return {"accounts": _suggested_cache["accounts"][:limit], "cached": True} - - # Load stored credentials - cookies, user_agent = PlaywrightManager.load_stored_credentials() - - if not cookies: - # Return fallback static list if not authenticated - return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True} - - print("Fetching fresh suggested accounts from TikTok...") - - try: - accounts = await PlaywrightManager.fetch_suggested_accounts(cookies, user_agent, limit) - - if accounts and len(accounts) >= 5: # Need at least 5 accounts from dynamic fetch - _suggested_cache["accounts"] = accounts - _suggested_cache["updated_at"] = time.time() - return {"accounts": accounts[:limit], "cached": False} - else: - # Fallback: fetch actual profile data with avatars for static list - print("Dynamic fetch failed, fetching profile data for static accounts...") - fallback_list = get_fallback_accounts()[:min(limit, 20)] # Limit to 20 for speed - return await fetch_profiles_with_avatars(fallback_list, cookies, user_agent) - - except Exception as e: - print(f"Error fetching suggested accounts: {e}") - return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True} - - -async def fetch_profiles_with_avatars(accounts: list, cookies: list, user_agent: str) -> dict: - """Fetch actual profile data with avatars for a list of accounts.""" - - cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies]) - - headers = { - "User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT, - "Referer": "https://www.tiktok.com/", - "Cookie": cookie_str, - "Accept": "application/json", - } - - enriched = [] - - async with httpx.AsyncClient(timeout=10.0) as client: - for acc in accounts: - try: - url = f"https://www.tiktok.com/api/user/detail/?uniqueId={acc['username']}" - res = await client.get(url, headers=headers) - - if res.status_code == 200: - data = res.json() - user = data.get("userInfo", {}).get("user", {}) - stats = data.get("userInfo", {}).get("stats", {}) - - if user: - enriched.append({ - "username": acc["username"], - "nickname": user.get("nickname") or acc.get("nickname", acc["username"]), - "avatar": user.get("avatarThumb") or user.get("avatarMedium"), - "followers": stats.get("followerCount", 0), - "verified": user.get("verified", False), - "region": "VN" - }) - continue - - except Exception as e: - print(f"Error fetching profile for {acc['username']}: {e}") - - # Fallback: use original data without avatar - enriched.append(acc) - - return {"accounts": enriched, "cached": False, "enriched": True} - - -def get_fallback_accounts(): - """Static fallback list of popular Vietnamese TikTokers (verified usernames).""" - return [ - # Verified Vietnamese TikTok accounts - {"username": "cciinnn", "nickname": "👑 CiiN (Bùi Thảo Ly)", "region": "VN"}, - {"username": "hoaa.hanassii", "nickname": "💃 Hoa Hanassii", "region": "VN"}, - {"username": "lebong95", "nickname": "💪 Lê Bống", "region": "VN"}, - {"username": "tieu_hy26", "nickname": "👰 Tiểu Hý", "region": "VN"}, - {"username": "hieuthuhai2222", "nickname": "🎧 HIEUTHUHAI", "region": "VN"}, - {"username": "mtp.fan", "nickname": "🎤 Sơn Tùng M-TP", "region": "VN"}, - {"username": "changmakeup", "nickname": "💄 Changmakeup", "region": "VN"}, - {"username": "theanh28entertainment", "nickname": "🎬 Theanh28", "region": "VN"}, - {"username": "linhbarbie", "nickname": "👗 Linh Barbie", "region": "VN"}, - {"username": "phuonglykchau", "nickname": "✨ Phương Ly", "region": "VN"}, - {"username": "phimtieutrang", "nickname": "📺 Tiểu Trang", "region": "VN"}, - {"username": "nhunguyendy", "nickname": "💕 Như Nguyễn", "region": "VN"}, - {"username": "trucnhantv", "nickname": "🎤 Trúc Nhân", "region": "VN"}, - {"username": "justvietanh", "nickname": "😄 Just Việt Anh", "region": "VN"}, - {"username": "minngu.official", "nickname": "🌸 Min NGU", "region": "VN"}, - {"username": "quangdangofficial", "nickname": "🕺 Quang Đăng", "region": "VN"}, - {"username": "minhhangofficial", "nickname": "👑 Minh Hằng", "region": "VN"}, - {"username": "dungntt", "nickname": "🎭 Dũng NTT", "region": "VN"}, - {"username": "chipu88", "nickname": "🎤 Chi Pu", "region": "VN"}, - {"username": "kaydinh", "nickname": "🎵 Kay Dinh", "region": "VN"}, - ] +""" +User profile API - fetch real TikTok user data. +""" + +from fastapi import APIRouter, Query, HTTPException +from pydantic import BaseModel +from typing import Optional, List +import httpx +import asyncio + +from core.playwright_manager import PlaywrightManager + +router = APIRouter() + + +class UserProfile(BaseModel): + """TikTok user profile data.""" + username: str + nickname: Optional[str] = None + avatar: Optional[str] = None + bio: Optional[str] = None + followers: Optional[int] = None + following: Optional[int] = None + likes: Optional[int] = None + verified: bool = False + + +@router.get("/profile") +async def get_user_profile(username: str = Query(..., description="TikTok username (without @)")): + """ + Fetch real TikTok user profile data. + """ + username = username.replace("@", "") + + # Load stored credentials + cookies, user_agent = PlaywrightManager.load_stored_credentials() + + if not cookies: + raise HTTPException(status_code=401, detail="Not authenticated") + + # Build cookie header + cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies]) + + headers = { + "User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT, + "Referer": "https://www.tiktok.com/", + "Cookie": cookie_str, + "Accept": "application/json", + } + + # Try to fetch user data from TikTok's internal API + profile_url = f"https://www.tiktok.com/api/user/detail/?uniqueId={username}" + + try: + async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: + response = await client.get(profile_url, headers=headers) + + if response.status_code != 200: + # Fallback - return basic info + return UserProfile(username=username) + + data = response.json() + user_info = data.get("userInfo", {}) + user = user_info.get("user", {}) + stats = user_info.get("stats", {}) + + return UserProfile( + username=username, + nickname=user.get("nickname"), + avatar=user.get("avatarLarger") or user.get("avatarMedium"), + bio=user.get("signature"), + followers=stats.get("followerCount"), + following=stats.get("followingCount"), + likes=stats.get("heartCount"), + verified=user.get("verified", False) + ) + + except Exception as e: + print(f"Error fetching profile for {username}: {e}") + # Return basic fallback + return UserProfile(username=username) + + +@router.get("/profiles") +async def get_multiple_profiles(usernames: str = Query(..., description="Comma-separated usernames")): + """ + Fetch multiple TikTok user profiles at once. + """ + username_list = [u.strip().replace("@", "") for u in usernames.split(",") if u.strip()] + + if len(username_list) > 20: + raise HTTPException(status_code=400, detail="Max 20 usernames at once") + + # Fetch all profiles concurrently + tasks = [get_user_profile(u) for u in username_list] + results = await asyncio.gather(*tasks, return_exceptions=True) + + profiles = [] + for i, result in enumerate(results): + if isinstance(result, Exception): + profiles.append(UserProfile(username=username_list[i])) + else: + profiles.append(result) + + return profiles + + +@router.get("/videos") +async def get_user_videos( + username: str = Query(..., description="TikTok username (without @)"), + limit: int = Query(10, description="Max videos to fetch", ge=1, le=60) +): + """ + Fetch videos from a TikTok user's profile. + Uses Playwright to crawl the user's page for reliable results. + """ + username = username.replace("@", "") + + # Load stored credentials + cookies, user_agent = PlaywrightManager.load_stored_credentials() + + if not cookies: + raise HTTPException(status_code=401, detail="Not authenticated") + + print(f"Fetching videos for @{username}...") + + try: + videos = await PlaywrightManager.fetch_user_videos(username, cookies, user_agent, limit) + return {"username": username, "videos": videos, "count": len(videos)} + except Exception as e: + print(f"Error fetching videos for {username}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/search") +async def search_videos( + query: str = Query(..., description="Search keyword or hashtag"), + limit: int = Query(20, description="Max videos to fetch", ge=1, le=60), + cursor: int = Query(0, description="Pagination cursor (offset)") +): + """ + Search for videos by keyword or hashtag. + Uses Playwright to crawl TikTok search results for reliable data. + """ + # Load stored credentials + cookies, user_agent = PlaywrightManager.load_stored_credentials() + + if not cookies: + raise HTTPException(status_code=401, detail="Not authenticated") + + print(f"Searching for: {query} (limit={limit}, cursor={cursor})...") + + try: + videos = await PlaywrightManager.search_videos(query, cookies, user_agent, limit, cursor) + return {"query": query, "videos": videos, "count": len(videos), "cursor": cursor + len(videos)} + except Exception as e: + print(f"Error searching for {query}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# Cache for suggested accounts +_suggested_cache = { + "accounts": [], + "updated_at": 0 +} +CACHE_TTL = 3600 # 1 hour cache + + +@router.get("/suggested") +async def get_suggested_accounts( + limit: int = Query(50, description="Max accounts to return", ge=10, le=100) +): + """ + Fetch trending/suggested Vietnamese TikTok creators. + Uses TikTok's discover API and caches results for 1 hour. + """ + import time + + # Check cache + if _suggested_cache["accounts"] and (time.time() - _suggested_cache["updated_at"]) < CACHE_TTL: + print("Returning cached suggested accounts") + return {"accounts": _suggested_cache["accounts"][:limit], "cached": True} + + # Load stored credentials + cookies, user_agent = PlaywrightManager.load_stored_credentials() + + if not cookies: + # Return fallback static list if not authenticated + return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True} + + print("Fetching fresh suggested accounts from TikTok...") + + try: + accounts = await PlaywrightManager.fetch_suggested_accounts(cookies, user_agent, limit) + + if accounts and len(accounts) >= 5: # Need at least 5 accounts from dynamic fetch + _suggested_cache["accounts"] = accounts + _suggested_cache["updated_at"] = time.time() + return {"accounts": accounts[:limit], "cached": False} + else: + # Fallback: fetch actual profile data with avatars for static list + print("Dynamic fetch failed, fetching profile data for static accounts...") + fallback_list = get_fallback_accounts()[:min(limit, 20)] # Limit to 20 for speed + return await fetch_profiles_with_avatars(fallback_list, cookies, user_agent) + + except Exception as e: + print(f"Error fetching suggested accounts: {e}") + return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True} + + +async def fetch_profiles_with_avatars(accounts: list, cookies: list, user_agent: str) -> dict: + """Fetch actual profile data with avatars for a list of accounts.""" + + cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies]) + + headers = { + "User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT, + "Referer": "https://www.tiktok.com/", + "Cookie": cookie_str, + "Accept": "application/json", + } + + enriched = [] + + async with httpx.AsyncClient(timeout=10.0) as client: + for acc in accounts: + try: + url = f"https://www.tiktok.com/api/user/detail/?uniqueId={acc['username']}" + res = await client.get(url, headers=headers) + + if res.status_code == 200: + data = res.json() + user = data.get("userInfo", {}).get("user", {}) + stats = data.get("userInfo", {}).get("stats", {}) + + if user: + enriched.append({ + "username": acc["username"], + "nickname": user.get("nickname") or acc.get("nickname", acc["username"]), + "avatar": user.get("avatarThumb") or user.get("avatarMedium"), + "followers": stats.get("followerCount", 0), + "verified": user.get("verified", False), + "region": "VN" + }) + continue + + except Exception as e: + print(f"Error fetching profile for {acc['username']}: {e}") + + # Fallback: use original data without avatar + enriched.append(acc) + + return {"accounts": enriched, "cached": False, "enriched": True} + + +def get_fallback_accounts(): + """Static fallback list of popular Vietnamese TikTokers (verified usernames).""" + return [ + # Verified Vietnamese TikTok accounts + {"username": "cciinnn", "nickname": "👑 CiiN (Bùi Thảo Ly)", "region": "VN"}, + {"username": "hoaa.hanassii", "nickname": "💃 Hoa Hanassii", "region": "VN"}, + {"username": "lebong95", "nickname": "💪 Lê Bống", "region": "VN"}, + {"username": "tieu_hy26", "nickname": "👰 Tiểu Hý", "region": "VN"}, + {"username": "hieuthuhai2222", "nickname": "🎧 HIEUTHUHAI", "region": "VN"}, + {"username": "mtp.fan", "nickname": "🎤 Sơn Tùng M-TP", "region": "VN"}, + {"username": "changmakeup", "nickname": "💄 Changmakeup", "region": "VN"}, + {"username": "theanh28entertainment", "nickname": "🎬 Theanh28", "region": "VN"}, + {"username": "linhbarbie", "nickname": "👗 Linh Barbie", "region": "VN"}, + {"username": "phuonglykchau", "nickname": "✨ Phương Ly", "region": "VN"}, + {"username": "phimtieutrang", "nickname": "📺 Tiểu Trang", "region": "VN"}, + {"username": "nhunguyendy", "nickname": "💕 Như Nguyễn", "region": "VN"}, + {"username": "trucnhantv", "nickname": "🎤 Trúc Nhân", "region": "VN"}, + {"username": "justvietanh", "nickname": "😄 Just Việt Anh", "region": "VN"}, + {"username": "minngu.official", "nickname": "🌸 Min NGU", "region": "VN"}, + {"username": "quangdangofficial", "nickname": "🕺 Quang Đăng", "region": "VN"}, + {"username": "minhhangofficial", "nickname": "👑 Minh Hằng", "region": "VN"}, + {"username": "dungntt", "nickname": "🎭 Dũng NTT", "region": "VN"}, + {"username": "chipu88", "nickname": "🎤 Chi Pu", "region": "VN"}, + {"username": "kaydinh", "nickname": "🎵 Kay Dinh", "region": "VN"}, + ] diff --git a/backend/core/playwright_manager.py b/backend/core/playwright_manager.py index f7e8a3a..0e9fca9 100644 --- a/backend/core/playwright_manager.py +++ b/backend/core/playwright_manager.py @@ -1,959 +1,966 @@ -""" -PlaywrightManager - Core class for TikTok network interception. - -Uses Playwright to: -1. Parse cookies from JSON format -2. Handle browser-based SSL login -3. Intercept /item_list API responses (instead of scraping HTML) -""" - -import os -import json -import asyncio -import traceback -from typing import List, Dict, Optional, Any -from playwright.async_api import async_playwright, Response, Browser, BrowserContext - -try: - from playwright_stealth import stealth_async -except ImportError: - print("WARNING: playwright_stealth not found, disabling stealth mode.") - async def stealth_async(page): - pass - - -COOKIES_FILE = "cookies.json" -USER_AGENT_FILE = "user_agent.json" - - -class PlaywrightManager: - """Manages Playwright browser for TikTok feed interception.""" - - # Anti-detection browser args - BROWSER_ARGS = [ - "--disable-blink-features=AutomationControlled", - "--no-sandbox", - "--disable-setuid-sandbox", - "--disable-dev-shm-usage", - "--disable-accelerated-2d-canvas", - "--no-first-run", - "--no-zygote", - "--disable-gpu", - "--kiosk", # Force full screen mode - "--start-maximized" - ] - - DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - - # VNC login state (class-level to persist across requests) - _vnc_playwright = None - _vnc_browser = None - _vnc_context = None - _vnc_page = None - _vnc_active = False - - @staticmethod - def parse_json_credentials(json_creds: Any) -> tuple[List[dict], str]: - """ - Parse JSON credentials. Supports: - 1. Array format: [{"name": "...", "value": "..."}, ...] - 2. http object format: {"http": {"headers": {...}, "cookies": {...}}} - - Returns: (cookies_list, user_agent) - """ - cookies = [] - user_agent = PlaywrightManager.DEFAULT_USER_AGENT - - # Handle array format (Cookie-Editor) - if isinstance(json_creds, list): - for c in json_creds: - if isinstance(c, dict) and "name" in c and "value" in c: - cookie = { - "name": c["name"], - "value": str(c["value"]), - "domain": c.get("domain") or ".tiktok.com", - "path": c.get("path") or "/", - "secure": c.get("secure", True), - "httpOnly": c.get("httpOnly", False) - } - if "sameSite" in c and c["sameSite"]: - # Playwright expects "Strict", "Lax", or "None" - ss = str(c["sameSite"]).capitalize() - if ss in ["Strict", "Lax", "None"]: - cookie["sameSite"] = ss - - cookies.append(cookie) - return cookies, user_agent - - # Handle object format - if isinstance(json_creds, dict): - http_data = json_creds.get("http", {}) - headers = http_data.get("headers", {}) - cookies_dict = http_data.get("cookies", {}) - - # Get User-Agent from headers - if "User-Agent" in headers: - user_agent = headers["User-Agent"] - - # Parse cookies from the cookies dict (preferred) - if cookies_dict: - for name, value in cookies_dict.items(): - cookies.append({ - "name": name, - "value": str(value), - "domain": ".tiktok.com", - "path": "/" - }) - # Fallback: parse from Cookie header string - elif "Cookie" in headers: - cookie_str = headers["Cookie"] - for part in cookie_str.split(";"): - part = part.strip() - if "=" in part: - name, value = part.split("=", 1) - cookies.append({ - "name": name.strip(), - "value": value.strip(), - "domain": ".tiktok.com", - "path": "/" - }) - - return cookies, user_agent - - @staticmethod - def load_stored_credentials() -> tuple[List[dict], str]: - """Load cookies and user agent from stored files.""" - cookies = [] - user_agent = PlaywrightManager.DEFAULT_USER_AGENT - - if os.path.exists(COOKIES_FILE): - try: - with open(COOKIES_FILE, "r") as f: - data = json.load(f) - if isinstance(data, list): - # Sanitize each cookie for Playwright compatibility - for c in data: - if isinstance(c, dict) and "name" in c and "value" in c: - cookie = { - "name": c["name"], - "value": str(c["value"]), - "domain": c.get("domain") or ".tiktok.com", - "path": c.get("path") or "/", - } - # Only add optional fields if they have valid values - if c.get("secure") is not None: - cookie["secure"] = bool(c["secure"]) - if c.get("httpOnly") is not None: - cookie["httpOnly"] = bool(c["httpOnly"]) - # Sanitize sameSite - Playwright only accepts Strict|Lax|None - if c.get("sameSite"): - ss = str(c["sameSite"]).capitalize() - if ss in ["Strict", "Lax", "None"]: - cookie["sameSite"] = ss - # If invalid, just omit it - cookies.append(cookie) - elif isinstance(data, dict): - # Backward compatibility or simple dict format - for name, value in data.items(): - cookies.append({ - "name": name, - "value": str(value), - "domain": ".tiktok.com", - "path": "/" - }) - except Exception as e: - print(f"Error loading cookies: {e}") - - if os.path.exists(USER_AGENT_FILE): - try: - with open(USER_AGENT_FILE, "r") as f: - data = json.load(f) - user_agent = data.get("user_agent", user_agent) - except: - pass - - return cookies, user_agent - - @staticmethod - def save_credentials(cookies: List[dict] | dict, user_agent: str = None): - """Save cookies and user agent to files.""" - with open(COOKIES_FILE, "w") as f: - json.dump(cookies, f, indent=2) - - if user_agent: - with open(USER_AGENT_FILE, "w") as f: - json.dump({"user_agent": user_agent}, f) - - @classmethod - async def start_vnc_login(cls) -> dict: - """ - Start a visible browser for VNC login. - The browser displays on DISPLAY=:99 which is streamed via noVNC. - Returns immediately - browser stays open for user interaction. - """ - # Close any existing VNC session - if cls._vnc_active: - await cls.stop_vnc_login() - - print("DEBUG: Starting VNC login browser...") - - try: - cls._vnc_playwright = await async_playwright().start() - cls._vnc_browser = await cls._vnc_playwright.chromium.launch( - headless=False, # Visible browser - args=cls.BROWSER_ARGS - ) - - cls._vnc_context = await cls._vnc_browser.new_context( - user_agent=cls.DEFAULT_USER_AGENT, - viewport={"width": 1920, "height": 1000} - ) - - cls._vnc_page = await cls._vnc_context.new_page() - await stealth_async(cls._vnc_page) - await cls._vnc_page.goto("https://www.tiktok.com/login", wait_until="domcontentloaded") - - cls._vnc_active = True - print("DEBUG: VNC browser opened with TikTok login page") - - return { - "status": "started", - "message": "Browser opened. Please login via the VNC stream." - } - - except Exception as e: - print(f"DEBUG: VNC login start error: {e}") - cls._vnc_active = False - return { - "status": "error", - "message": f"Failed to start browser: {str(e)}" - } - - @classmethod - async def check_vnc_login(cls) -> dict: - """ - Check if user has logged in by looking for sessionid cookie. - Called by frontend via polling. - """ - if not cls._vnc_active or not cls._vnc_context: - return {"status": "not_active", "logged_in": False} - - try: - all_cookies = await cls._vnc_context.cookies() - cookies_found = {} - - for cookie in all_cookies: - if cookie.get("domain", "").endswith("tiktok.com"): - cookies_found[cookie["name"]] = cookie["value"] - - if "sessionid" in cookies_found: - # Save cookies and close browser - cls.save_credentials(cookies_found, cls.DEFAULT_USER_AGENT) - await cls.stop_vnc_login() - - return { - "status": "success", - "logged_in": True, - "message": "Login successful!", - "cookie_count": len(cookies_found) - } - - return {"status": "waiting", "logged_in": False} - - except Exception as e: - print(f"DEBUG: VNC check error: {e}") - return {"status": "error", "logged_in": False, "message": str(e)} - - @classmethod - async def stop_vnc_login(cls) -> dict: - """Close the VNC browser session.""" - print("DEBUG: Stopping VNC login browser...") - - try: - if cls._vnc_browser: - await cls._vnc_browser.close() - if cls._vnc_playwright: - await cls._vnc_playwright.stop() - except Exception as e: - print(f"DEBUG: Error closing VNC browser: {e}") - - cls._vnc_browser = None - cls._vnc_context = None - cls._vnc_page = None - cls._vnc_playwright = None - cls._vnc_active = False - - return {"status": "stopped"} - - @staticmethod - async def credential_login(username: str, password: str, timeout_seconds: int = 60) -> dict: - """ - Headless login using username/password. - Works on Docker/NAS deployments without a display. - - Args: - username: TikTok username, email, or phone - password: TikTok password - timeout_seconds: Max time to wait for login - - Returns: {"status": "success/error", "message": "...", "cookie_count": N} - """ - print(f"DEBUG: Starting headless credential login for: {username}") - - async with async_playwright() as p: - browser = await p.chromium.launch( - headless=True, - args=PlaywrightManager.BROWSER_ARGS - ) - - context = await browser.new_context( - user_agent=PlaywrightManager.DEFAULT_USER_AGENT - ) - - page = await context.new_page() - await stealth_async(page) - - try: - # Navigate to TikTok login page - await page.goto("https://www.tiktok.com/login/phone-or-email/email", wait_until="domcontentloaded") - await asyncio.sleep(2) - - print("DEBUG: Looking for login form...") - - # Wait for and fill username/email field - username_selector = 'input[name="username"], input[placeholder*="Email"], input[placeholder*="email"], input[type="text"]' - await page.wait_for_selector(username_selector, timeout=10000) - await page.fill(username_selector, username) - await asyncio.sleep(0.5) - - # Fill password field - password_selector = 'input[type="password"]' - await page.wait_for_selector(password_selector, timeout=5000) - await page.fill(password_selector, password) - await asyncio.sleep(0.5) - - print("DEBUG: Credentials filled, clicking login...") - - # Click login button - login_button = 'button[type="submit"], button[data-e2e="login-button"]' - await page.click(login_button) - - # Wait for login to complete - poll for sessionid cookie - print("DEBUG: Waiting for login to complete...") - elapsed = 0 - check_interval = 2 - cookies_found = {} - - while elapsed < timeout_seconds: - await asyncio.sleep(check_interval) - elapsed += check_interval - - # Check for error messages - error_el = await page.query_selector('[class*="error"], [class*="Error"]') - if error_el: - error_text = await error_el.inner_text() - if error_text and len(error_text) > 0: - await browser.close() - return { - "status": "error", - "message": f"Login failed: {error_text[:100]}", - "cookie_count": 0 - } - - # Check cookies - all_cookies = await context.cookies() - for cookie in all_cookies: - if cookie.get("domain", "").endswith("tiktok.com"): - cookies_found[cookie["name"]] = cookie["value"] - - if "sessionid" in cookies_found: - print(f"DEBUG: Login successful! Found {len(cookies_found)} cookies.") - break - - # Check if CAPTCHA or verification needed - captcha = await page.query_selector('[class*="captcha"], [class*="Captcha"], [class*="verify"]') - if captcha: - await browser.close() - return { - "status": "error", - "message": "TikTok requires verification (CAPTCHA). Please try the cookie method.", - "cookie_count": 0 - } - - print(f"DEBUG: Waiting for login... ({elapsed}s)") - - await browser.close() - - if "sessionid" not in cookies_found: - return { - "status": "error", - "message": "Login timed out. Check your credentials or try the cookie method.", - "cookie_count": 0 - } - - # Save credentials - PlaywrightManager.save_credentials(cookies_found, PlaywrightManager.DEFAULT_USER_AGENT) - - return { - "status": "success", - "message": "Successfully logged in!", - "cookie_count": len(cookies_found) - } - - except Exception as e: - await browser.close() - print(f"DEBUG: Login error: {e}") - return { - "status": "error", - "message": f"Login failed: {str(e)[:100]}", - "cookie_count": 0 - } - - @staticmethod - async def browser_login(timeout_seconds: int = 180) -> dict: - """ - Open visible browser for user to login via TikTok's SSL login. - Waits for sessionid cookie to be set. - - Returns: {"status": "success/timeout", "cookies": {...}, "cookie_count": N} - """ - print("DEBUG: Opening browser for TikTok login...") - - async with async_playwright() as p: - browser = await p.chromium.launch( - headless=False, - args=PlaywrightManager.BROWSER_ARGS - ) - - context = await browser.new_context( - user_agent=PlaywrightManager.DEFAULT_USER_AGENT - ) - - page = await context.new_page() - await stealth_async(page) - - # Navigate to TikTok login - await page.goto("https://www.tiktok.com/login", wait_until="domcontentloaded") - print("DEBUG: Login page opened. Waiting for user to complete login...") - - # Poll for sessionid cookie - elapsed = 0 - check_interval = 2 - cookies_found = {} - - while elapsed < timeout_seconds: - await asyncio.sleep(check_interval) - elapsed += check_interval - - all_cookies = await context.cookies() - - for cookie in all_cookies: - if cookie.get("domain", "").endswith("tiktok.com"): - cookies_found[cookie["name"]] = cookie["value"] - - if "sessionid" in cookies_found: - print(f"DEBUG: Login detected! Found {len(cookies_found)} cookies.") - break - - print(f"DEBUG: Waiting for login... ({elapsed}s)") - - await browser.close() - - if "sessionid" not in cookies_found: - return { - "status": "timeout", - "message": "Login timed out. Please try again.", - "cookie_count": 0 - } - - # Save credentials - PlaywrightManager.save_credentials(cookies_found, PlaywrightManager.DEFAULT_USER_AGENT) - - return { - "status": "success", - "message": "Successfully connected to TikTok!", - "cookie_count": len(cookies_found) - } - - @staticmethod - async def intercept_feed(cookies: List[dict] = None, user_agent: str = None, scroll_count: int = 5) -> List[dict]: - """Navigate to TikTok feed and intercept API responses.""" - try: - return await PlaywrightManager._intercept_feed_impl(cookies, user_agent, scroll_count) - except Exception as e: - print(f"DEBUG: Error in intercept_feed: {e}") - print(traceback.format_exc()) - raise e - - @staticmethod - async def _intercept_feed_impl(cookies: List[dict] = None, user_agent: str = None, scroll_count: int = 5) -> List[dict]: - if not cookies: - cookies, user_agent = PlaywrightManager.load_stored_credentials() - - if not user_agent: - user_agent = PlaywrightManager.DEFAULT_USER_AGENT - - if not cookies: - print("DEBUG: No cookies available") - return [] - - print(f"DEBUG: Starting network interception with {len(cookies)} cookies (scrolls={scroll_count})") - - captured_videos = [] - - async def handle_response(response: Response): - """Capture /item_list API responses.""" - nonlocal captured_videos - - url = response.url - - # Look for TikTok's feed API - if "item_list" in url or "recommend/item" in url: - try: - data = await response.json() - - # TikTok returns videos in "itemList" or "aweme_list" - items = data.get("itemList", []) or data.get("aweme_list", []) - - for item in items: - video_data = PlaywrightManager._extract_video_data(item) - if video_data: - captured_videos.append(video_data) - - print(f"DEBUG: Captured {len(items)} videos from API") - - except Exception as e: - print(f"DEBUG: Error parsing API response: {e}") - - async with async_playwright() as p: - browser = await p.chromium.launch( - headless=True, - args=PlaywrightManager.BROWSER_ARGS - ) - - context = await browser.new_context(user_agent=user_agent) - - if cookies: - try: - await context.add_cookies(cookies) - print(f"DEBUG: Applied {len(cookies)} cookies to browser context") - except Exception as e: - print(f"DEBUG: Error applying cookies: {e}") - if len(cookies) > 0: - print(f"DEBUG: Sample cookie: {cookies[0]}") - raise e - - page = await context.new_page() - await stealth_async(page) - - # Set up response listener - page.on("response", handle_response) - - try: - # Navigate to For You page - await page.goto( - "https://www.tiktok.com/foryou", - wait_until="domcontentloaded", - timeout=30000 - ) - - # Wait for initial load - ensure we capture at least one batch - # Poll for videos if in fast mode - for _ in range(10): # Max 10 seconds wait - if len(captured_videos) > 0: - break - await asyncio.sleep(1) - - # If still no videos, maybe scroll once to trigger - if len(captured_videos) == 0: - print("DEBUG: No videos after initial load, scrolling once...") - await page.evaluate("window.scrollBy(0, 800)") - await asyncio.sleep(2) - - # Scroll loop - for i in range(scroll_count): - await page.evaluate("window.scrollBy(0, 800)") - await asyncio.sleep(1) - - # Give time for API responses to be captured - await asyncio.sleep(2) - - except Exception as e: - print(f"DEBUG: Navigation error: {e}") - - await browser.close() - - print(f"DEBUG: Total captured videos: {len(captured_videos)}") - return captured_videos - - @staticmethod - def _extract_video_data(item: dict) -> Optional[dict]: - """Extract video data from TikTok API item, including product/shop videos.""" - try: - if not isinstance(item, dict): - print(f"DEBUG: Skipping invalid item (type: {type(item)})") - return None - - # Handle different API response formats - video_id = item.get("id") or item.get("aweme_id") - - # Get author info - author_data = item.get("author", {}) - author = author_data.get("uniqueId") or author_data.get("unique_id") or "unknown" - - # Get description - desc = item.get("desc") or item.get("description") or "" - - # Check if this is a product/shop video - is_shop_video = bool(item.get("products") or item.get("commerce_info") or item.get("poi_info")) - - # Get thumbnail/cover image - thumbnail = None - video_data = item.get("video", {}) - - # Try different thumbnail sources - thumbnail_sources = [ - video_data.get("cover"), - video_data.get("dynamicCover"), - video_data.get("originCover"), - video_data.get("ai_dynamic_cover", {}).get("url_list", [None])[0] if isinstance(video_data.get("ai_dynamic_cover"), dict) else None, - ] - for src in thumbnail_sources: - if src: - thumbnail = src - break - - # Get direct CDN URL - try multiple sources (including for shop videos) - cdn_url = None - cdn_sources = [ - # Standard sources - video_data.get("playAddr"), - video_data.get("downloadAddr"), - # Bit rate sources (often works for shop videos) - video_data.get("bitrateInfo", [{}])[0].get("PlayAddr", {}).get("UrlList", [None])[0] if video_data.get("bitrateInfo") else None, - # Play URL list - video_data.get("play_addr", {}).get("url_list", [None])[0] if isinstance(video_data.get("play_addr"), dict) else None, - # Download URL list - video_data.get("download_addr", {}).get("url_list", [None])[0] if isinstance(video_data.get("download_addr"), dict) else None, - ] - - for src in cdn_sources: - if src: - cdn_url = src - break - - # Use TikTok page URL as fallback (yt-dlp resolves this) - video_url = f"https://www.tiktok.com/@{author}/video/{video_id}" - - # Get stats (views, likes) - stats = item.get("stats", {}) or item.get("statistics", {}) - views = stats.get("playCount") or stats.get("play_count") or 0 - likes = stats.get("diggCount") or stats.get("digg_count") or 0 - - if video_id and author: - result = { - "id": str(video_id), - "url": video_url, - "author": author, - "description": desc[:200] if desc else f"Video by @{author}" - } - if thumbnail: - result["thumbnail"] = thumbnail - if cdn_url: - result["cdn_url"] = cdn_url # Direct CDN URL for thin proxy - if views: - result["views"] = views - if likes: - result["likes"] = likes - if is_shop_video: - result["has_product"] = True # Flag for product videos - return result - - except Exception as e: - print(f"DEBUG: Error extracting video data: {e}") - - return None - - @staticmethod - async def fetch_user_videos(username: str, cookies: list, user_agent: str = None, limit: int = 10) -> list: - """ - Fetch videos from a specific user's profile page. - Uses Playwright to intercept the user's video list API. - """ - from playwright.async_api import async_playwright, Response - - if not user_agent: - user_agent = PlaywrightManager.DEFAULT_USER_AGENT - - if not cookies: - print("DEBUG: No cookies available for user videos") - return [] - - print(f"DEBUG: Fetching videos for @{username}...") - - captured_videos = [] - - async def handle_response(response: Response): - """Capture user's video list API responses.""" - nonlocal captured_videos - - url = response.url - - # Look for user's video list API - if "item_list" in url or "post/item_list" in url: - try: - data = await response.json() - - items = data.get("itemList", []) or data.get("aweme_list", []) - - for item in items: - if len(captured_videos) >= limit: - break - video_data = PlaywrightManager._extract_video_data(item) - if video_data: - captured_videos.append(video_data) - - print(f"DEBUG: Captured {len(items)} videos from user API") - - except Exception as e: - print(f"DEBUG: Error parsing user API response: {e}") - - async with async_playwright() as p: - browser = await p.chromium.launch( - headless=True, - args=PlaywrightManager.BROWSER_ARGS - ) - - context = await browser.new_context(user_agent=user_agent) - await context.add_cookies(cookies) - - page = await context.new_page() - await stealth_async(page) - page.on("response", handle_response) - - try: - # Navigate to user's profile page - profile_url = f"https://www.tiktok.com/@{username}" - await page.goto(profile_url, wait_until="networkidle", timeout=30000) - - # Wait for videos to load - await asyncio.sleep(2) - - # Scroll a bit to trigger more video loading - await page.evaluate("window.scrollBy(0, 500)") - await asyncio.sleep(1) - - except Exception as e: - print(f"DEBUG: Error navigating to profile: {e}") - - await browser.close() - - print(f"DEBUG: Total captured user videos: {len(captured_videos)}") - return captured_videos - - @staticmethod - async def search_videos(query: str, cookies: list, user_agent: str = None, limit: int = 20, cursor: int = 0) -> list: - """ - Search for videos by keyword or hashtag. - Uses Playwright to intercept TikTok search results API. - - Args: - query: Search query - cookies: Auth cookies - user_agent: Browser user agent - limit: Max videos to capture in this batch - cursor: Starting offset for pagination - """ - from playwright.async_api import async_playwright, Response - from urllib.parse import quote - - if not user_agent: - user_agent = PlaywrightManager.DEFAULT_USER_AGENT - - if not cookies: - print("DEBUG: No cookies available for search") - return [] - - print(f"DEBUG: Searching for '{query}' (limit={limit}, cursor={cursor})...") - - captured_videos = [] - - async def handle_response(response: Response): - """Capture search results API responses.""" - nonlocal captured_videos - - url = response.url - - # Look for search results API - if "search" in url and ("item_list" in url or "video" in url or "general" in url): - try: - data = await response.json() - - # Try different response formats - items = data.get("itemList", []) or data.get("data", []) or data.get("item_list", []) - - for item in items: - # If we have enough for this specific batch, we don't need more - if len(captured_videos) >= limit: - break - - video_data = PlaywrightManager._extract_video_data(item) - if video_data: - # Avoid duplicates within the same capture session - if not any(v['id'] == video_data['id'] for v in captured_videos): - captured_videos.append(video_data) - - print(f"DEBUG: Captured {len(items)} videos from search API (Total batch: {len(captured_videos)})") - - except Exception as e: - print(f"DEBUG: Error parsing search API response: {e}") - - async with async_playwright() as p: - browser = await p.chromium.launch( - headless=True, - args=PlaywrightManager.BROWSER_ARGS - ) - - context = await browser.new_context(user_agent=user_agent) - await context.add_cookies(cookies) - - page = await context.new_page() - await stealth_async(page) - page.on("response", handle_response) - - try: - # Navigate to TikTok search page - search_url = f"https://www.tiktok.com/search/video?q={quote(query)}" - try: - await page.goto(search_url, wait_until="domcontentloaded", timeout=15000) - except: - print("DEBUG: Navigation timeout, proceeding anyway") - - # Wait for initial results - await asyncio.sleep(3) - - # Scroll based on cursor to reach previous results and then capture new ones - # Each scroll typically loads 12-20 items - # We scroll more as the cursor increases - scroll_count = (cursor // 10) + 1 - # Limit total scrolls to avoid hanging - scroll_count = min(scroll_count, 10) - - for i in range(scroll_count): - await page.evaluate("window.scrollBy(0, 1500)") - await asyncio.sleep(1.5) - - # After reaching the offset, scroll a bit more to trigger the specific batch capture - batch_scrolls = (limit // 10) + 2 # Add extra scrolls to be safe - for _ in range(batch_scrolls): - await page.evaluate("window.scrollBy(0, 2000)") # Larger scroll - await asyncio.sleep(1.0) # Faster scroll cadence - - # Wait a bit after scrolling for all responses to settle - await asyncio.sleep(2.5) - - except Exception as e: - print(f"DEBUG: Error during search: {e}") - - await browser.close() - - print(f"DEBUG: Total captured search videos in this batch: {len(captured_videos)}") - return captured_videos - - @staticmethod - async def fetch_suggested_accounts(cookies: list, user_agent: str = None, limit: int = 50) -> list: - """ - Fetch trending/suggested accounts from TikTok Vietnam. - Uses the discover/creators API. - """ - from playwright.async_api import async_playwright, Response - - if not user_agent: - user_agent = PlaywrightManager.DEFAULT_USER_AGENT - - captured_accounts = [] - - async def handle_response(response: Response): - """Capture suggested accounts from API responses.""" - nonlocal captured_accounts - - url = response.url - - # Look for suggest/discover APIs - if any(x in url for x in ["suggest", "discover", "recommend/user", "creator"]): - try: - data = await response.json() - - # Different API formats - users = data.get("userList", []) or data.get("users", []) or data.get("data", []) - - for item in users: - user_data = item.get("user", item) if isinstance(item, dict) else item - if isinstance(user_data, dict): - username = user_data.get("uniqueId") or user_data.get("unique_id") - if username: - captured_accounts.append({ - "username": username, - "nickname": user_data.get("nickname", username), - "avatar": user_data.get("avatarThumb") or user_data.get("avatar"), - "followers": user_data.get("followerCount", 0), - "verified": user_data.get("verified", False), - "region": "VN" - }) - - if users: - print(f"DEBUG: Captured {len(users)} suggested accounts") - - except Exception as e: - pass # Ignore parse errors - - async with async_playwright() as p: - browser = await p.chromium.launch( - headless=True, - args=PlaywrightManager.BROWSER_ARGS - ) - - context = await browser.new_context( - user_agent=user_agent, - locale="vi-VN", # Vietnamese locale - timezone_id="Asia/Ho_Chi_Minh" - ) - await context.add_cookies(cookies) - - page = await context.new_page() - await stealth_async(page) - page.on("response", handle_response) - - try: - # Navigate to TikTok explore/discover page (Vietnam) - await page.goto("https://www.tiktok.com/explore?lang=vi-VN", wait_until="networkidle", timeout=30000) - await asyncio.sleep(3) - - # Also try the For You page to capture suggested - await page.goto("https://www.tiktok.com/foryou?lang=vi-VN", wait_until="domcontentloaded", timeout=15000) - await asyncio.sleep(2) - - # Scroll to trigger more suggestions - for _ in range(3): - await page.evaluate("window.scrollBy(0, 800)") - await asyncio.sleep(1) - - except Exception as e: - print(f"DEBUG: Error fetching suggested accounts: {e}") - - await browser.close() - - # Remove duplicates by username - seen = set() - unique_accounts = [] - for acc in captured_accounts: - if acc["username"] not in seen: - seen.add(acc["username"]) - unique_accounts.append(acc) - - print(f"DEBUG: Total unique suggested accounts: {len(unique_accounts)}") - return unique_accounts[:limit] - - -# Singleton instance -playwright_manager = PlaywrightManager() +""" +PlaywrightManager - Core class for TikTok network interception. + +Uses Playwright to: +1. Parse cookies from JSON format +2. Handle browser-based SSL login +3. Intercept /item_list API responses (instead of scraping HTML) +""" + +import os +import json +import asyncio +import traceback +from typing import List, Dict, Optional, Any +from playwright.async_api import async_playwright, Response, Browser, BrowserContext + +try: + from playwright_stealth import stealth_async +except ImportError: + print("WARNING: playwright_stealth not found, disabling stealth mode.") + async def stealth_async(page): + pass + + +COOKIES_FILE = "cookies.json" +USER_AGENT_FILE = "user_agent.json" + + +class PlaywrightManager: + """Manages Playwright browser for TikTok feed interception.""" + + # Anti-detection browser args + BROWSER_ARGS = [ + "--disable-blink-features=AutomationControlled", + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--disable-accelerated-2d-canvas", + "--no-first-run", + "--no-zygote", + "--disable-gpu", + "--kiosk", # Force full screen mode + "--start-maximized" + ] + + DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + + # Use installed Chrome instead of Playwright's Chromium (avoids slow download) + CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + + # VNC login state (class-level to persist across requests) + _vnc_playwright = None + _vnc_browser = None + _vnc_context = None + _vnc_page = None + _vnc_active = False + + @staticmethod + def parse_json_credentials(json_creds: Any) -> tuple[List[dict], str]: + """ + Parse JSON credentials. Supports: + 1. Array format: [{"name": "...", "value": "..."}, ...] + 2. http object format: {"http": {"headers": {...}, "cookies": {...}}} + + Returns: (cookies_list, user_agent) + """ + cookies = [] + user_agent = PlaywrightManager.DEFAULT_USER_AGENT + + # Handle array format (Cookie-Editor) + if isinstance(json_creds, list): + for c in json_creds: + if isinstance(c, dict) and "name" in c and "value" in c: + cookie = { + "name": c["name"], + "value": str(c["value"]), + "domain": c.get("domain") or ".tiktok.com", + "path": c.get("path") or "/", + "secure": c.get("secure", True), + "httpOnly": c.get("httpOnly", False) + } + if "sameSite" in c and c["sameSite"]: + # Playwright expects "Strict", "Lax", or "None" + ss = str(c["sameSite"]).capitalize() + if ss in ["Strict", "Lax", "None"]: + cookie["sameSite"] = ss + + cookies.append(cookie) + return cookies, user_agent + + # Handle object format + if isinstance(json_creds, dict): + http_data = json_creds.get("http", {}) + headers = http_data.get("headers", {}) + cookies_dict = http_data.get("cookies", {}) + + # Get User-Agent from headers + if "User-Agent" in headers: + user_agent = headers["User-Agent"] + + # Parse cookies from the cookies dict (preferred) + if cookies_dict: + for name, value in cookies_dict.items(): + cookies.append({ + "name": name, + "value": str(value), + "domain": ".tiktok.com", + "path": "/" + }) + # Fallback: parse from Cookie header string + elif "Cookie" in headers: + cookie_str = headers["Cookie"] + for part in cookie_str.split(";"): + part = part.strip() + if "=" in part: + name, value = part.split("=", 1) + cookies.append({ + "name": name.strip(), + "value": value.strip(), + "domain": ".tiktok.com", + "path": "/" + }) + + return cookies, user_agent + + @staticmethod + def load_stored_credentials() -> tuple[List[dict], str]: + """Load cookies and user agent from stored files.""" + cookies = [] + user_agent = PlaywrightManager.DEFAULT_USER_AGENT + + if os.path.exists(COOKIES_FILE): + try: + with open(COOKIES_FILE, "r") as f: + data = json.load(f) + if isinstance(data, list): + # Sanitize each cookie for Playwright compatibility + for c in data: + if isinstance(c, dict) and "name" in c and "value" in c: + cookie = { + "name": c["name"], + "value": str(c["value"]), + "domain": c.get("domain") or ".tiktok.com", + "path": c.get("path") or "/", + } + # Only add optional fields if they have valid values + if c.get("secure") is not None: + cookie["secure"] = bool(c["secure"]) + if c.get("httpOnly") is not None: + cookie["httpOnly"] = bool(c["httpOnly"]) + # Sanitize sameSite - Playwright only accepts Strict|Lax|None + if c.get("sameSite"): + ss = str(c["sameSite"]).capitalize() + if ss in ["Strict", "Lax", "None"]: + cookie["sameSite"] = ss + # If invalid, just omit it + cookies.append(cookie) + elif isinstance(data, dict): + # Backward compatibility or simple dict format + for name, value in data.items(): + cookies.append({ + "name": name, + "value": str(value), + "domain": ".tiktok.com", + "path": "/" + }) + except Exception as e: + print(f"Error loading cookies: {e}") + + if os.path.exists(USER_AGENT_FILE): + try: + with open(USER_AGENT_FILE, "r") as f: + data = json.load(f) + user_agent = data.get("user_agent", user_agent) + except: + pass + + return cookies, user_agent + + @staticmethod + def save_credentials(cookies: List[dict] | dict, user_agent: str = None): + """Save cookies and user agent to files.""" + with open(COOKIES_FILE, "w") as f: + json.dump(cookies, f, indent=2) + + if user_agent: + with open(USER_AGENT_FILE, "w") as f: + json.dump({"user_agent": user_agent}, f) + + @classmethod + async def start_vnc_login(cls) -> dict: + """ + Start a visible browser for VNC login. + The browser displays on DISPLAY=:99 which is streamed via noVNC. + Returns immediately - browser stays open for user interaction. + """ + # Close any existing VNC session + if cls._vnc_active: + await cls.stop_vnc_login() + + print("DEBUG: Starting VNC login browser...") + + try: + cls._vnc_playwright = await async_playwright().start() + cls._vnc_browser = await cls._vnc_playwright.chromium.launch( + headless=False, # Visible browser + args=cls.BROWSER_ARGS + ) + + cls._vnc_context = await cls._vnc_browser.new_context( + user_agent=cls.DEFAULT_USER_AGENT, + viewport={"width": 1920, "height": 1000} + ) + + cls._vnc_page = await cls._vnc_context.new_page() + await stealth_async(cls._vnc_page) + await cls._vnc_page.goto("https://www.tiktok.com/login", wait_until="domcontentloaded") + + cls._vnc_active = True + print("DEBUG: VNC browser opened with TikTok login page") + + return { + "status": "started", + "message": "Browser opened. Please login via the VNC stream." + } + + except Exception as e: + print(f"DEBUG: VNC login start error: {e}") + cls._vnc_active = False + return { + "status": "error", + "message": f"Failed to start browser: {str(e)}" + } + + @classmethod + async def check_vnc_login(cls) -> dict: + """ + Check if user has logged in by looking for sessionid cookie. + Called by frontend via polling. + """ + if not cls._vnc_active or not cls._vnc_context: + return {"status": "not_active", "logged_in": False} + + try: + all_cookies = await cls._vnc_context.cookies() + cookies_found = {} + + for cookie in all_cookies: + if cookie.get("domain", "").endswith("tiktok.com"): + cookies_found[cookie["name"]] = cookie["value"] + + if "sessionid" in cookies_found: + # Save cookies and close browser + cls.save_credentials(cookies_found, cls.DEFAULT_USER_AGENT) + await cls.stop_vnc_login() + + return { + "status": "success", + "logged_in": True, + "message": "Login successful!", + "cookie_count": len(cookies_found) + } + + return {"status": "waiting", "logged_in": False} + + except Exception as e: + print(f"DEBUG: VNC check error: {e}") + return {"status": "error", "logged_in": False, "message": str(e)} + + @classmethod + async def stop_vnc_login(cls) -> dict: + """Close the VNC browser session.""" + print("DEBUG: Stopping VNC login browser...") + + try: + if cls._vnc_browser: + await cls._vnc_browser.close() + if cls._vnc_playwright: + await cls._vnc_playwright.stop() + except Exception as e: + print(f"DEBUG: Error closing VNC browser: {e}") + + cls._vnc_browser = None + cls._vnc_context = None + cls._vnc_page = None + cls._vnc_playwright = None + cls._vnc_active = False + + return {"status": "stopped"} + + @staticmethod + async def credential_login(username: str, password: str, timeout_seconds: int = 60) -> dict: + """ + Headless login using username/password. + Works on Docker/NAS deployments without a display. + + Args: + username: TikTok username, email, or phone + password: TikTok password + timeout_seconds: Max time to wait for login + + Returns: {"status": "success/error", "message": "...", "cookie_count": N} + """ + print(f"DEBUG: Starting headless credential login for: {username}") + + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=True, + args=PlaywrightManager.BROWSER_ARGS + ) + + context = await browser.new_context( + user_agent=PlaywrightManager.DEFAULT_USER_AGENT + ) + + page = await context.new_page() + await stealth_async(page) + + try: + # Navigate to TikTok login page + await page.goto("https://www.tiktok.com/login/phone-or-email/email", wait_until="domcontentloaded") + await asyncio.sleep(2) + + print("DEBUG: Looking for login form...") + + # Wait for and fill username/email field + username_selector = 'input[name="username"], input[placeholder*="Email"], input[placeholder*="email"], input[type="text"]' + await page.wait_for_selector(username_selector, timeout=10000) + await page.fill(username_selector, username) + await asyncio.sleep(0.5) + + # Fill password field + password_selector = 'input[type="password"]' + await page.wait_for_selector(password_selector, timeout=5000) + await page.fill(password_selector, password) + await asyncio.sleep(0.5) + + print("DEBUG: Credentials filled, clicking login...") + + # Click login button + login_button = 'button[type="submit"], button[data-e2e="login-button"]' + await page.click(login_button) + + # Wait for login to complete - poll for sessionid cookie + print("DEBUG: Waiting for login to complete...") + elapsed = 0 + check_interval = 2 + cookies_found = {} + + while elapsed < timeout_seconds: + await asyncio.sleep(check_interval) + elapsed += check_interval + + # Check for error messages + error_el = await page.query_selector('[class*="error"], [class*="Error"]') + if error_el: + error_text = await error_el.inner_text() + if error_text and len(error_text) > 0: + await browser.close() + return { + "status": "error", + "message": f"Login failed: {error_text[:100]}", + "cookie_count": 0 + } + + # Check cookies + all_cookies = await context.cookies() + for cookie in all_cookies: + if cookie.get("domain", "").endswith("tiktok.com"): + cookies_found[cookie["name"]] = cookie["value"] + + if "sessionid" in cookies_found: + print(f"DEBUG: Login successful! Found {len(cookies_found)} cookies.") + break + + # Check if CAPTCHA or verification needed + captcha = await page.query_selector('[class*="captcha"], [class*="Captcha"], [class*="verify"]') + if captcha: + await browser.close() + return { + "status": "error", + "message": "TikTok requires verification (CAPTCHA). Please try the cookie method.", + "cookie_count": 0 + } + + print(f"DEBUG: Waiting for login... ({elapsed}s)") + + await browser.close() + + if "sessionid" not in cookies_found: + return { + "status": "error", + "message": "Login timed out. Check your credentials or try the cookie method.", + "cookie_count": 0 + } + + # Save credentials + PlaywrightManager.save_credentials(cookies_found, PlaywrightManager.DEFAULT_USER_AGENT) + + return { + "status": "success", + "message": "Successfully logged in!", + "cookie_count": len(cookies_found) + } + + except Exception as e: + await browser.close() + print(f"DEBUG: Login error: {e}") + return { + "status": "error", + "message": f"Login failed: {str(e)[:100]}", + "cookie_count": 0 + } + + @staticmethod + async def browser_login(timeout_seconds: int = 180) -> dict: + """ + Open visible browser for user to login via TikTok's SSL login. + Waits for sessionid cookie to be set. + + Returns: {"status": "success/timeout", "cookies": {...}, "cookie_count": N} + """ + print("DEBUG: Opening browser for TikTok login...") + + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=False, + args=PlaywrightManager.BROWSER_ARGS + ) + + context = await browser.new_context( + user_agent=PlaywrightManager.DEFAULT_USER_AGENT + ) + + page = await context.new_page() + await stealth_async(page) + + # Navigate to TikTok login + await page.goto("https://www.tiktok.com/login", wait_until="domcontentloaded") + print("DEBUG: Login page opened. Waiting for user to complete login...") + + # Poll for sessionid cookie + elapsed = 0 + check_interval = 2 + cookies_found = {} + + while elapsed < timeout_seconds: + await asyncio.sleep(check_interval) + elapsed += check_interval + + all_cookies = await context.cookies() + + for cookie in all_cookies: + if cookie.get("domain", "").endswith("tiktok.com"): + cookies_found[cookie["name"]] = cookie["value"] + + if "sessionid" in cookies_found: + print(f"DEBUG: Login detected! Found {len(cookies_found)} cookies.") + break + + print(f"DEBUG: Waiting for login... ({elapsed}s)") + + await browser.close() + + if "sessionid" not in cookies_found: + return { + "status": "timeout", + "message": "Login timed out. Please try again.", + "cookie_count": 0 + } + + # Save credentials + PlaywrightManager.save_credentials(cookies_found, PlaywrightManager.DEFAULT_USER_AGENT) + + return { + "status": "success", + "message": "Successfully connected to TikTok!", + "cookie_count": len(cookies_found) + } + + @staticmethod + async def intercept_feed(cookies: List[dict] = None, user_agent: str = None, scroll_count: int = 5) -> List[dict]: + """Navigate to TikTok feed and intercept API responses.""" + try: + return await PlaywrightManager._intercept_feed_impl(cookies, user_agent, scroll_count) + except Exception as e: + print(f"DEBUG: Error in intercept_feed: {e}") + print(traceback.format_exc()) + raise e + + @staticmethod + async def _intercept_feed_impl(cookies: List[dict] = None, user_agent: str = None, scroll_count: int = 5) -> List[dict]: + if not cookies: + cookies, user_agent = PlaywrightManager.load_stored_credentials() + + if not user_agent: + user_agent = PlaywrightManager.DEFAULT_USER_AGENT + + if not cookies: + print("DEBUG: No cookies available") + return [] + + print(f"DEBUG: Starting network interception with {len(cookies)} cookies (scrolls={scroll_count})") + + captured_videos = [] + + async def handle_response(response: Response): + """Capture /item_list API responses.""" + nonlocal captured_videos + + url = response.url + + # Look for TikTok's feed API + if "item_list" in url or "recommend/item" in url: + try: + data = await response.json() + + # TikTok returns videos in "itemList" or "aweme_list" + items = data.get("itemList", []) or data.get("aweme_list", []) + + for item in items: + video_data = PlaywrightManager._extract_video_data(item) + if video_data: + captured_videos.append(video_data) + + print(f"DEBUG: Captured {len(items)} videos from API") + + except Exception as e: + print(f"DEBUG: Error parsing API response: {e}") + + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=True, + executable_path=PlaywrightManager.CHROME_PATH, + args=PlaywrightManager.BROWSER_ARGS + ) + + context = await browser.new_context(user_agent=user_agent) + + if cookies: + try: + await context.add_cookies(cookies) + print(f"DEBUG: Applied {len(cookies)} cookies to browser context") + except Exception as e: + print(f"DEBUG: Error applying cookies: {e}") + if len(cookies) > 0: + print(f"DEBUG: Sample cookie: {cookies[0]}") + raise e + + page = await context.new_page() + await stealth_async(page) + + # Set up response listener + page.on("response", handle_response) + + try: + # Navigate to For You page + await page.goto( + "https://www.tiktok.com/foryou", + wait_until="domcontentloaded", + timeout=30000 + ) + + # Wait for initial load - ensure we capture at least one batch + # Poll for videos if in fast mode + for _ in range(10): # Max 10 seconds wait + if len(captured_videos) > 0: + break + await asyncio.sleep(1) + + # If still no videos, maybe scroll once to trigger + if len(captured_videos) == 0: + print("DEBUG: No videos after initial load, scrolling once...") + await page.evaluate("window.scrollBy(0, 800)") + await asyncio.sleep(2) + + # Scroll loop + for i in range(scroll_count): + await page.evaluate("window.scrollBy(0, 800)") + await asyncio.sleep(1) + + # Give time for API responses to be captured + await asyncio.sleep(2) + + except Exception as e: + print(f"DEBUG: Navigation error: {e}") + + await browser.close() + + print(f"DEBUG: Total captured videos: {len(captured_videos)}") + return captured_videos + + @staticmethod + def _extract_video_data(item: dict) -> Optional[dict]: + """Extract video data from TikTok API item, including product/shop videos.""" + try: + if not isinstance(item, dict): + print(f"DEBUG: Skipping invalid item (type: {type(item)})") + return None + + # Handle different API response formats + video_id = item.get("id") or item.get("aweme_id") + + # Get author info + author_data = item.get("author", {}) + author = author_data.get("uniqueId") or author_data.get("unique_id") or "unknown" + + # Get description + desc = item.get("desc") or item.get("description") or "" + + # Check if this is a product/shop video + is_shop_video = bool(item.get("products") or item.get("commerce_info") or item.get("poi_info")) + + # Get thumbnail/cover image + thumbnail = None + video_data = item.get("video", {}) + + # Try different thumbnail sources + thumbnail_sources = [ + video_data.get("cover"), + video_data.get("dynamicCover"), + video_data.get("originCover"), + video_data.get("ai_dynamic_cover", {}).get("url_list", [None])[0] if isinstance(video_data.get("ai_dynamic_cover"), dict) else None, + ] + for src in thumbnail_sources: + if src: + thumbnail = src + break + + # Get direct CDN URL - try multiple sources (including for shop videos) + cdn_url = None + cdn_sources = [ + # Standard sources + video_data.get("playAddr"), + video_data.get("downloadAddr"), + # Bit rate sources (often works for shop videos) + video_data.get("bitrateInfo", [{}])[0].get("PlayAddr", {}).get("UrlList", [None])[0] if video_data.get("bitrateInfo") else None, + # Play URL list + video_data.get("play_addr", {}).get("url_list", [None])[0] if isinstance(video_data.get("play_addr"), dict) else None, + # Download URL list + video_data.get("download_addr", {}).get("url_list", [None])[0] if isinstance(video_data.get("download_addr"), dict) else None, + ] + + for src in cdn_sources: + if src: + cdn_url = src + break + + # Use TikTok page URL as fallback (yt-dlp resolves this) + video_url = f"https://www.tiktok.com/@{author}/video/{video_id}" + + # Get stats (views, likes) + stats = item.get("stats", {}) or item.get("statistics", {}) + views = stats.get("playCount") or stats.get("play_count") or 0 + likes = stats.get("diggCount") or stats.get("digg_count") or 0 + + if video_id and author: + result = { + "id": str(video_id), + "url": video_url, + "author": author, + "description": desc[:200] if desc else f"Video by @{author}" + } + if thumbnail: + result["thumbnail"] = thumbnail + if cdn_url: + result["cdn_url"] = cdn_url # Direct CDN URL for thin proxy + if views: + result["views"] = views + if likes: + result["likes"] = likes + if is_shop_video: + result["has_product"] = True # Flag for product videos + return result + + except Exception as e: + print(f"DEBUG: Error extracting video data: {e}") + + return None + + @staticmethod + async def fetch_user_videos(username: str, cookies: list, user_agent: str = None, limit: int = 10) -> list: + """ + Fetch videos from a specific user's profile page. + Uses Playwright to intercept the user's video list API. + """ + from playwright.async_api import async_playwright, Response + + if not user_agent: + user_agent = PlaywrightManager.DEFAULT_USER_AGENT + + if not cookies: + print("DEBUG: No cookies available for user videos") + return [] + + print(f"DEBUG: Fetching videos for @{username}...") + + captured_videos = [] + + async def handle_response(response: Response): + """Capture user's video list API responses.""" + nonlocal captured_videos + + url = response.url + + # Look for user's video list API + if "item_list" in url or "post/item_list" in url: + try: + data = await response.json() + + items = data.get("itemList", []) or data.get("aweme_list", []) + + for item in items: + if len(captured_videos) >= limit: + break + video_data = PlaywrightManager._extract_video_data(item) + if video_data: + captured_videos.append(video_data) + + print(f"DEBUG: Captured {len(items)} videos from user API") + + except Exception as e: + print(f"DEBUG: Error parsing user API response: {e}") + + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=True, + executable_path=PlaywrightManager.CHROME_PATH, + args=PlaywrightManager.BROWSER_ARGS + ) + + context = await browser.new_context(user_agent=user_agent) + await context.add_cookies(cookies) + + page = await context.new_page() + await stealth_async(page) + page.on("response", handle_response) + + try: + # Navigate to user's profile page + profile_url = f"https://www.tiktok.com/@{username}" + await page.goto(profile_url, wait_until="networkidle", timeout=30000) + + # Wait for videos to load + await asyncio.sleep(2) + + # Scroll a bit to trigger more video loading + await page.evaluate("window.scrollBy(0, 500)") + await asyncio.sleep(1) + + except Exception as e: + print(f"DEBUG: Error navigating to profile: {e}") + + await browser.close() + + print(f"DEBUG: Total captured user videos: {len(captured_videos)}") + return captured_videos + + @staticmethod + async def search_videos(query: str, cookies: list, user_agent: str = None, limit: int = 20, cursor: int = 0) -> list: + """ + Search for videos by keyword or hashtag. + Uses Playwright to intercept TikTok search results API. + + Args: + query: Search query + cookies: Auth cookies + user_agent: Browser user agent + limit: Max videos to capture in this batch + cursor: Starting offset for pagination + """ + from playwright.async_api import async_playwright, Response + from urllib.parse import quote + + if not user_agent: + user_agent = PlaywrightManager.DEFAULT_USER_AGENT + + if not cookies: + print("DEBUG: No cookies available for search") + return [] + + print(f"DEBUG: Searching for '{query}' (limit={limit}, cursor={cursor})...") + + captured_videos = [] + + async def handle_response(response: Response): + """Capture search results API responses.""" + nonlocal captured_videos + + url = response.url + + # Look for search results API + if "search" in url and ("item_list" in url or "video" in url or "general" in url): + try: + data = await response.json() + + # Try different response formats + items = data.get("itemList", []) or data.get("data", []) or data.get("item_list", []) + + for item in items: + # If we have enough for this specific batch, we don't need more + if len(captured_videos) >= limit: + break + + video_data = PlaywrightManager._extract_video_data(item) + if video_data: + # Avoid duplicates within the same capture session + if not any(v['id'] == video_data['id'] for v in captured_videos): + captured_videos.append(video_data) + + print(f"DEBUG: Captured {len(items)} videos from search API (Total batch: {len(captured_videos)})") + + except Exception as e: + print(f"DEBUG: Error parsing search API response: {e}") + + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=True, + executable_path=PlaywrightManager.CHROME_PATH, + args=PlaywrightManager.BROWSER_ARGS + ) + + context = await browser.new_context(user_agent=user_agent) + await context.add_cookies(cookies) + + page = await context.new_page() + await stealth_async(page) + page.on("response", handle_response) + + try: + # Navigate to TikTok search page + search_url = f"https://www.tiktok.com/search/video?q={quote(query)}" + try: + await page.goto(search_url, wait_until="domcontentloaded", timeout=15000) + except: + print("DEBUG: Navigation timeout, proceeding anyway") + + # Wait for initial results + await asyncio.sleep(3) + + # Scroll based on cursor to reach previous results and then capture new ones + # Each scroll typically loads 12-20 items + # We scroll more as the cursor increases + scroll_count = (cursor // 10) + 1 + # Limit total scrolls to avoid hanging + scroll_count = min(scroll_count, 10) + + for i in range(scroll_count): + await page.evaluate("window.scrollBy(0, 1500)") + await asyncio.sleep(1.5) + + # After reaching the offset, scroll a bit more to trigger the specific batch capture + batch_scrolls = (limit // 10) + 2 # Add extra scrolls to be safe + for _ in range(batch_scrolls): + await page.evaluate("window.scrollBy(0, 2000)") # Larger scroll + await asyncio.sleep(1.0) # Faster scroll cadence + + # Wait a bit after scrolling for all responses to settle + await asyncio.sleep(2.5) + + except Exception as e: + print(f"DEBUG: Error during search: {e}") + + await browser.close() + + print(f"DEBUG: Total captured search videos in this batch: {len(captured_videos)}") + return captured_videos + + @staticmethod + async def fetch_suggested_accounts(cookies: list, user_agent: str = None, limit: int = 50) -> list: + """ + Fetch trending/suggested accounts from TikTok Vietnam. + Uses the discover/creators API. + """ + from playwright.async_api import async_playwright, Response + + if not user_agent: + user_agent = PlaywrightManager.DEFAULT_USER_AGENT + + captured_accounts = [] + + async def handle_response(response: Response): + """Capture suggested accounts from API responses.""" + nonlocal captured_accounts + + url = response.url + + # Look for suggest/discover APIs + if any(x in url for x in ["suggest", "discover", "recommend/user", "creator"]): + try: + data = await response.json() + + # Different API formats + users = data.get("userList", []) or data.get("users", []) or data.get("data", []) + + for item in users: + user_data = item.get("user", item) if isinstance(item, dict) else item + if isinstance(user_data, dict): + username = user_data.get("uniqueId") or user_data.get("unique_id") + if username: + captured_accounts.append({ + "username": username, + "nickname": user_data.get("nickname", username), + "avatar": user_data.get("avatarThumb") or user_data.get("avatar"), + "followers": user_data.get("followerCount", 0), + "verified": user_data.get("verified", False), + "region": "VN" + }) + + if users: + print(f"DEBUG: Captured {len(users)} suggested accounts") + + except Exception as e: + pass # Ignore parse errors + + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=True, + executable_path=PlaywrightManager.CHROME_PATH, + args=PlaywrightManager.BROWSER_ARGS + ) + + context = await browser.new_context( + user_agent=user_agent, + locale="vi-VN", # Vietnamese locale + timezone_id="Asia/Ho_Chi_Minh" + ) + await context.add_cookies(cookies) + + page = await context.new_page() + await stealth_async(page) + page.on("response", handle_response) + + try: + # Navigate to TikTok explore/discover page (Vietnam) + await page.goto("https://www.tiktok.com/explore?lang=vi-VN", wait_until="networkidle", timeout=30000) + await asyncio.sleep(3) + + # Also try the For You page to capture suggested + await page.goto("https://www.tiktok.com/foryou?lang=vi-VN", wait_until="domcontentloaded", timeout=15000) + await asyncio.sleep(2) + + # Scroll to trigger more suggestions + for _ in range(3): + await page.evaluate("window.scrollBy(0, 800)") + await asyncio.sleep(1) + + except Exception as e: + print(f"DEBUG: Error fetching suggested accounts: {e}") + + await browser.close() + + # Remove duplicates by username + seen = set() + unique_accounts = [] + for acc in captured_accounts: + if acc["username"] not in seen: + seen.add(acc["username"]) + unique_accounts.append(acc) + + print(f"DEBUG: Total unique suggested accounts: {len(unique_accounts)}") + return unique_accounts[:limit] + + +# Singleton instance +playwright_manager = PlaywrightManager() diff --git a/backend/main.py b/backend/main.py index 7df242b..61d7cf0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,82 +1,82 @@ -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse -from contextlib import asynccontextmanager -from pathlib import Path -from api.routes import auth, feed, download, following, config, user -import sys -import asyncio - -# Force Proactor on Windows for Playwright -if sys.platform == "win32": - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Startup and shutdown events.""" - print("🚀 Starting PureStream API (Network Interception Mode)...") - import asyncio - try: - loop = asyncio.get_running_loop() - print(f"DEBUG: Running event loop: {type(loop)}") - except Exception as e: - print(f"DEBUG: Could not get running loop: {e}") - yield - print("👋 Shutting down PureStream API...") - -import asyncio -import sys - -app = FastAPI(title="PureStream API", version="2.0.0", lifespan=lifespan) - -if __name__ == "__main__": - if sys.platform == "win32": - try: - loop = asyncio.get_event_loop() - print(f"DEBUG: Current event loop: {type(loop)}") - except: - print("DEBUG: No event loop yet") - - -# CORS middleware -app.add_middleware( - CORSMiddleware, - allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", "*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Include routers -app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) -app.include_router(feed.router, prefix="/api/feed", tags=["Feed"]) -app.include_router(download.router, prefix="/api/download", tags=["Download"]) -app.include_router(following.router, prefix="/api/following", tags=["Following"]) -app.include_router(config.router, prefix="/api/config", tags=["Config"]) -app.include_router(user.router, prefix="/api/user", tags=["User"]) - -@app.get("/health") -async def health_check(): - return {"status": "ok"} - -# Serve static frontend files in production -FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist" -if FRONTEND_DIR.exists(): - # Mount static assets - app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets") - - # Serve index.html for all non-API routes (SPA fallback) - @app.get("/{full_path:path}") - async def serve_spa(full_path: str): - # If requesting a file that exists, serve it - file_path = FRONTEND_DIR / full_path - if file_path.is_file(): - return FileResponse(file_path) - # Otherwise serve index.html for SPA routing - return FileResponse(FRONTEND_DIR / "index.html") - -if __name__ == "__main__": - import uvicorn - uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=False, loop="asyncio") - +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from contextlib import asynccontextmanager +from pathlib import Path +from api.routes import auth, feed, download, following, config, user +import sys +import asyncio + +# Force Proactor on Windows for Playwright +if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Startup and shutdown events.""" + print("🚀 Starting PureStream API (Network Interception Mode)...") + import asyncio + try: + loop = asyncio.get_running_loop() + print(f"DEBUG: Running event loop: {type(loop)}") + except Exception as e: + print(f"DEBUG: Could not get running loop: {e}") + yield + print("👋 Shutting down PureStream API...") + +import asyncio +import sys + +app = FastAPI(title="PureStream API", version="2.0.0", lifespan=lifespan) + +if __name__ == "__main__": + if sys.platform == "win32": + try: + loop = asyncio.get_event_loop() + print(f"DEBUG: Current event loop: {type(loop)}") + except: + print("DEBUG: No event loop yet") + + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", "*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) +app.include_router(feed.router, prefix="/api/feed", tags=["Feed"]) +app.include_router(download.router, prefix="/api/download", tags=["Download"]) +app.include_router(following.router, prefix="/api/following", tags=["Following"]) +app.include_router(config.router, prefix="/api/config", tags=["Config"]) +app.include_router(user.router, prefix="/api/user", tags=["User"]) + +@app.get("/health") +async def health_check(): + return {"status": "ok"} + +# Serve static frontend files in production +FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist" +if FRONTEND_DIR.exists(): + # Mount static assets + app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets") + + # Serve index.html for all non-API routes (SPA fallback) + @app.get("/{full_path:path}") + async def serve_spa(full_path: str): + # If requesting a file that exists, serve it + file_path = FRONTEND_DIR / full_path + if file_path.is_file(): + return FileResponse(file_path) + # Otherwise serve index.html for SPA routing + return FileResponse(FRONTEND_DIR / "index.html") + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=False, loop="asyncio") + diff --git a/backend/run_server.py b/backend/run_server.py index ba6d853..6c7259e 100644 --- a/backend/run_server.py +++ b/backend/run_server.py @@ -1,25 +1,25 @@ -import sys -import os -import asyncio - -# Fix sys.path for user site-packages where pip installed dependencies -user_site = os.path.expanduser("~\\AppData\\Roaming\\Python\\Python312\\site-packages") -if os.path.exists(user_site) and user_site not in sys.path: - print(f"DEBUG: Adding user site-packages to path: {user_site}") - sys.path.append(user_site) - -# Enforce ProactorEventLoopPolicy for Playwright on Windows -# This is required for asyncio.create_subprocess_exec used by Playwright -if sys.platform == "win32": - # Check if policy is already set - current_policy = asyncio.get_event_loop_policy() - if not isinstance(current_policy, asyncio.WindowsProactorEventLoopPolicy): - print("DEBUG: Setting WindowsProactorEventLoopPolicy") - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - else: - print("DEBUG: WindowsProactorEventLoopPolicy already active") - -if __name__ == "__main__": - import uvicorn - print("🚀 Bootstrapping Uvicorn with Proactor Loop (Reload Disabled)...") - uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=False, loop="asyncio") +import sys +import os +import asyncio + +# Fix sys.path for user site-packages where pip installed dependencies +user_site = os.path.expanduser("~\\AppData\\Roaming\\Python\\Python312\\site-packages") +if os.path.exists(user_site) and user_site not in sys.path: + print(f"DEBUG: Adding user site-packages to path: {user_site}") + sys.path.append(user_site) + +# Enforce ProactorEventLoopPolicy for Playwright on Windows +# This is required for asyncio.create_subprocess_exec used by Playwright +if sys.platform == "win32": + # Check if policy is already set + current_policy = asyncio.get_event_loop_policy() + if not isinstance(current_policy, asyncio.WindowsProactorEventLoopPolicy): + print("DEBUG: Setting WindowsProactorEventLoopPolicy") + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + else: + print("DEBUG: WindowsProactorEventLoopPolicy already active") + +if __name__ == "__main__": + import uvicorn + print("🚀 Bootstrapping Uvicorn with Proactor Loop (Reload Disabled)...") + uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=False, loop="asyncio") diff --git a/cookies.json b/cookies.json index d4f72b9..2a09b5b 100644 --- a/cookies.json +++ b/cookies.json @@ -1,312 +1,312 @@ -[ - { - "domain": ".www.tiktok.com", - "expirationDate": 1784039026, - "hostOnly": false, - "httpOnly": false, - "name": "delay_guest_mode_vid", - "path": "/", - "sameSite": null, - "secure": true, - "session": false, - "storeId": null, - "value": "8" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1768142590.076948, - "hostOnly": false, - "httpOnly": false, - "name": "msToken", - "path": "/", - "sameSite": "no_restriction", - "secure": true, - "session": false, - "storeId": null, - "value": "qKUFutg-Q184Mo5kpuAm4XvPCCucGe3O2KXf5G9pHV61Hb9puK-ZVQ7XzexuVGLLzwmFZ1mVYOgR3QbKBXk58AX9UgPPPkWk_koDZF3e-gqQGg_9GGjcdIOxGN-JTL_g0FM4qN8NKV84LdU=" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1782817620.646103, - "hostOnly": false, - "httpOnly": true, - "name": "tt_session_tlb_tag", - "path": "/", - "sameSite": "no_restriction", - "secure": true, - "session": false, - "storeId": null, - "value": "sttt%7C1%7C6M35zM57kkqAGSs_LSUdRP_________zyplxYaEARSr2PNU_6cKcB0lq4WRz1GKKY43u399i5hs%3D" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1798369620.645922, - "hostOnly": false, - "httpOnly": true, - "name": "sid_guard", - "path": "/", - "sameSite": null, - "secure": true, - "session": false, - "storeId": null, - "value": "e8cdf9ccce7b924a80192b3f2d251d44%7C1767265616%7C15552000%7CTue%2C+30-Jun-2026+11%3A06%3A56+GMT" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1798814589.722864, - "hostOnly": false, - "httpOnly": true, - "name": "ttwid", - "path": "/", - "sameSite": "no_restriction", - "secure": true, - "session": false, - "storeId": null, - "value": "1%7CAYDsetgnxt5vzYX8hD6Wq2DQ4FXiL_pqcdLwHWwz6B8%7C1767278585%7C46a4b0b8d2fc0ee903d0e781593a0d2d7b491a49752c86f034adf69319d371a0" - }, - { - "domain": ".www.tiktok.com", - "expirationDate": 1767883392, - "hostOnly": false, - "httpOnly": false, - "name": "perf_feed_cache", - "path": "/", - "sameSite": null, - "secure": true, - "session": false, - "storeId": null, - "value": "{%22expireTimestamp%22:0%2C%22itemIds%22:[%22%22%2C%227584357225863335188%22%2C%227580369401002659079%22]}" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1782817620.645952, - "hostOnly": false, - "httpOnly": true, - "name": "uid_tt", - "path": "/", - "sameSite": null, - "secure": true, - "session": false, - "storeId": null, - "value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1772449601.742227, - "hostOnly": false, - "httpOnly": false, - "name": "passport_csrf_token_default", - "path": "/", - "sameSite": null, - "secure": true, - "session": false, - "storeId": null, - "value": "9c66ab10306611c75fa19c87e54fd31b" - }, - { - "domain": ".tiktok.com", - "hostOnly": false, - "httpOnly": false, - "name": "s_v_web_id", - "path": "/", - "sameSite": "no_restriction", - "secure": true, - "session": true, - "storeId": null, - "value": "verify_mjvcbi31_l3mxUEeU_ykis_4x6z_859S_8zEFmNseEnJU" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1782817620.64617, - "hostOnly": false, - "httpOnly": true, - "name": "ssid_ucp_v1", - "path": "/", - "sameSite": "no_restriction", - "secure": true, - "session": false, - "storeId": null, - "value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw" - }, - { - "domain": ".www.tiktok.com", - "expirationDate": 1793198587, - "hostOnly": false, - "httpOnly": false, - "name": "tiktok_webapp_theme", - "path": "/", - "sameSite": null, - "secure": true, - "session": false, - "storeId": null, - "value": "dark" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1799409936.219767, - "hostOnly": false, - "httpOnly": false, - "name": "_ttp", - "path": "/", - "sameSite": "no_restriction", - "secure": true, - "session": false, - "storeId": null, - "value": "32XOXKxwj8YLtBQf0OBn4TvlkPN" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1772449620.645821, - "hostOnly": false, - "httpOnly": true, - "name": "cmpl_token", - "path": "/", - "sameSite": null, - "secure": true, - "session": false, - "storeId": null, - "value": "AgQYAPOF_hfkTtKPtFExgPKdOPKrXVkNUj-FDmCi6K4" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1772449620.645628, - "hostOnly": false, - "httpOnly": true, - "name": "multi_sids", - "path": "/", - "sameSite": null, - "secure": true, - "session": false, - "storeId": null, - "value": "6884525631502042113%3Ae8cdf9ccce7b924a80192b3f2d251d44" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1769857620.645892, - "hostOnly": false, - "httpOnly": true, - "name": "passport_auth_status_ss", - "path": "/", - "sameSite": "no_restriction", - "secure": true, - "session": false, - "storeId": null, - "value": "966972581a398dbb9ead189c044cf98c%2C" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1772449601.742082, - "hostOnly": false, - "httpOnly": false, - "name": "passport_csrf_token", - "path": "/", - "sameSite": "no_restriction", - "secure": true, - "session": false, - "storeId": null, - "value": "9c66ab10306611c75fa19c87e54fd31b" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1782817620.646041, - "hostOnly": false, - "httpOnly": true, - "name": "sessionid", - "path": "/", - "sameSite": null, - "secure": true, - "session": false, - "storeId": null, - "value": "e8cdf9ccce7b924a80192b3f2d251d44" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1782817620.646073, - "hostOnly": false, - "httpOnly": true, - "name": "sessionid_ss", - "path": "/", - "sameSite": "no_restriction", - "secure": true, - "session": false, - "storeId": null, - "value": "e8cdf9ccce7b924a80192b3f2d251d44" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1782817620.646008, - "hostOnly": false, - "httpOnly": true, - "name": "sid_tt", - "path": "/", - "sameSite": null, - "secure": true, - "session": false, - "storeId": null, - "value": "e8cdf9ccce7b924a80192b3f2d251d44" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1782817620.646132, - "hostOnly": false, - "httpOnly": true, - "name": "sid_ucp_v1", - "path": "/", - "sameSite": null, - "secure": true, - "session": false, - "storeId": null, - "value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw" - }, - { - "domain": ".www.tiktok.com", - "expirationDate": 1793198587, - "hostOnly": false, - "httpOnly": false, - "name": "tiktok_webapp_theme_source", - "path": "/", - "sameSite": null, - "secure": true, - "session": false, - "storeId": null, - "value": "auto" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1782830586.65306, - "hostOnly": false, - "httpOnly": true, - "name": "tt_chain_token", - "path": "/", - "sameSite": null, - "secure": true, - "session": false, - "storeId": null, - "value": "6deMEWrkAGUe9R0tCISIoQ==" - }, - { - "domain": ".tiktok.com", - "hostOnly": false, - "httpOnly": true, - "name": "tt_csrf_token", - "path": "/", - "sameSite": "lax", - "secure": true, - "session": true, - "storeId": null, - "value": "q0Q4ki72-I7zQB6eLbpBBaqFBGrUF_v85N9s" - }, - { - "domain": ".tiktok.com", - "expirationDate": 1782817620.645979, - "hostOnly": false, - "httpOnly": true, - "name": "uid_tt_ss", - "path": "/", - "sameSite": "no_restriction", - "secure": true, - "session": false, - "storeId": null, - "value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c" - } +[ + { + "domain": ".www.tiktok.com", + "expirationDate": 1784039026, + "hostOnly": false, + "httpOnly": false, + "name": "delay_guest_mode_vid", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "8" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1768142590.076948, + "hostOnly": false, + "httpOnly": false, + "name": "msToken", + "path": "/", + "sameSite": "no_restriction", + "secure": true, + "session": false, + "storeId": null, + "value": "qKUFutg-Q184Mo5kpuAm4XvPCCucGe3O2KXf5G9pHV61Hb9puK-ZVQ7XzexuVGLLzwmFZ1mVYOgR3QbKBXk58AX9UgPPPkWk_koDZF3e-gqQGg_9GGjcdIOxGN-JTL_g0FM4qN8NKV84LdU=" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1782817620.646103, + "hostOnly": false, + "httpOnly": true, + "name": "tt_session_tlb_tag", + "path": "/", + "sameSite": "no_restriction", + "secure": true, + "session": false, + "storeId": null, + "value": "sttt%7C1%7C6M35zM57kkqAGSs_LSUdRP_________zyplxYaEARSr2PNU_6cKcB0lq4WRz1GKKY43u399i5hs%3D" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1798369620.645922, + "hostOnly": false, + "httpOnly": true, + "name": "sid_guard", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "e8cdf9ccce7b924a80192b3f2d251d44%7C1767265616%7C15552000%7CTue%2C+30-Jun-2026+11%3A06%3A56+GMT" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1798814589.722864, + "hostOnly": false, + "httpOnly": true, + "name": "ttwid", + "path": "/", + "sameSite": "no_restriction", + "secure": true, + "session": false, + "storeId": null, + "value": "1%7CAYDsetgnxt5vzYX8hD6Wq2DQ4FXiL_pqcdLwHWwz6B8%7C1767278585%7C46a4b0b8d2fc0ee903d0e781593a0d2d7b491a49752c86f034adf69319d371a0" + }, + { + "domain": ".www.tiktok.com", + "expirationDate": 1767883392, + "hostOnly": false, + "httpOnly": false, + "name": "perf_feed_cache", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "{%22expireTimestamp%22:0%2C%22itemIds%22:[%22%22%2C%227584357225863335188%22%2C%227580369401002659079%22]}" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1782817620.645952, + "hostOnly": false, + "httpOnly": true, + "name": "uid_tt", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1772449601.742227, + "hostOnly": false, + "httpOnly": false, + "name": "passport_csrf_token_default", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "9c66ab10306611c75fa19c87e54fd31b" + }, + { + "domain": ".tiktok.com", + "hostOnly": false, + "httpOnly": false, + "name": "s_v_web_id", + "path": "/", + "sameSite": "no_restriction", + "secure": true, + "session": true, + "storeId": null, + "value": "verify_mjvcbi31_l3mxUEeU_ykis_4x6z_859S_8zEFmNseEnJU" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1782817620.64617, + "hostOnly": false, + "httpOnly": true, + "name": "ssid_ucp_v1", + "path": "/", + "sameSite": "no_restriction", + "secure": true, + "session": false, + "storeId": null, + "value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw" + }, + { + "domain": ".www.tiktok.com", + "expirationDate": 1793198587, + "hostOnly": false, + "httpOnly": false, + "name": "tiktok_webapp_theme", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "dark" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1799409936.219767, + "hostOnly": false, + "httpOnly": false, + "name": "_ttp", + "path": "/", + "sameSite": "no_restriction", + "secure": true, + "session": false, + "storeId": null, + "value": "32XOXKxwj8YLtBQf0OBn4TvlkPN" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1772449620.645821, + "hostOnly": false, + "httpOnly": true, + "name": "cmpl_token", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "AgQYAPOF_hfkTtKPtFExgPKdOPKrXVkNUj-FDmCi6K4" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1772449620.645628, + "hostOnly": false, + "httpOnly": true, + "name": "multi_sids", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "6884525631502042113%3Ae8cdf9ccce7b924a80192b3f2d251d44" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1769857620.645892, + "hostOnly": false, + "httpOnly": true, + "name": "passport_auth_status_ss", + "path": "/", + "sameSite": "no_restriction", + "secure": true, + "session": false, + "storeId": null, + "value": "966972581a398dbb9ead189c044cf98c%2C" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1772449601.742082, + "hostOnly": false, + "httpOnly": false, + "name": "passport_csrf_token", + "path": "/", + "sameSite": "no_restriction", + "secure": true, + "session": false, + "storeId": null, + "value": "9c66ab10306611c75fa19c87e54fd31b" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1782817620.646041, + "hostOnly": false, + "httpOnly": true, + "name": "sessionid", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "e8cdf9ccce7b924a80192b3f2d251d44" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1782817620.646073, + "hostOnly": false, + "httpOnly": true, + "name": "sessionid_ss", + "path": "/", + "sameSite": "no_restriction", + "secure": true, + "session": false, + "storeId": null, + "value": "e8cdf9ccce7b924a80192b3f2d251d44" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1782817620.646008, + "hostOnly": false, + "httpOnly": true, + "name": "sid_tt", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "e8cdf9ccce7b924a80192b3f2d251d44" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1782817620.646132, + "hostOnly": false, + "httpOnly": true, + "name": "sid_ucp_v1", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw" + }, + { + "domain": ".www.tiktok.com", + "expirationDate": 1793198587, + "hostOnly": false, + "httpOnly": false, + "name": "tiktok_webapp_theme_source", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "auto" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1782830586.65306, + "hostOnly": false, + "httpOnly": true, + "name": "tt_chain_token", + "path": "/", + "sameSite": null, + "secure": true, + "session": false, + "storeId": null, + "value": "6deMEWrkAGUe9R0tCISIoQ==" + }, + { + "domain": ".tiktok.com", + "hostOnly": false, + "httpOnly": true, + "name": "tt_csrf_token", + "path": "/", + "sameSite": "lax", + "secure": true, + "session": true, + "storeId": null, + "value": "q0Q4ki72-I7zQB6eLbpBBaqFBGrUF_v85N9s" + }, + { + "domain": ".tiktok.com", + "expirationDate": 1782817620.645979, + "hostOnly": false, + "httpOnly": true, + "name": "uid_tt_ss", + "path": "/", + "sameSite": "no_restriction", + "secure": true, + "session": false, + "storeId": null, + "value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c" + } ] \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 63aa46f..6a9e031 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -81,7 +81,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -725,9 +724,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1038,9 +1037,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", - "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", "cpu": [ "arm" ], @@ -1052,9 +1051,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz", - "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", "cpu": [ "arm64" ], @@ -1066,9 +1065,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz", - "integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", "cpu": [ "arm64" ], @@ -1080,9 +1079,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz", - "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", "cpu": [ "x64" ], @@ -1094,9 +1093,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz", - "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", "cpu": [ "arm64" ], @@ -1108,9 +1107,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz", - "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", "cpu": [ "x64" ], @@ -1122,9 +1121,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz", - "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", "cpu": [ "arm" ], @@ -1136,9 +1135,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz", - "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", "cpu": [ "arm" ], @@ -1150,9 +1149,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz", - "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", "cpu": [ "arm64" ], @@ -1164,9 +1163,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz", - "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", "cpu": [ "arm64" ], @@ -1178,9 +1177,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz", - "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", "cpu": [ "loong64" ], @@ -1192,9 +1191,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz", - "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", "cpu": [ "ppc64" ], @@ -1206,9 +1205,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz", - "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", "cpu": [ "riscv64" ], @@ -1220,9 +1219,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz", - "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", "cpu": [ "riscv64" ], @@ -1234,9 +1233,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz", - "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", "cpu": [ "s390x" ], @@ -1248,9 +1247,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz", - "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", "cpu": [ "x64" ], @@ -1262,9 +1261,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz", - "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", "cpu": [ "x64" ], @@ -1276,9 +1275,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz", - "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", "cpu": [ "arm64" ], @@ -1290,9 +1289,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz", - "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", "cpu": [ "arm64" ], @@ -1304,9 +1303,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz", - "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", "cpu": [ "ia32" ], @@ -1318,9 +1317,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz", - "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", "cpu": [ "x64" ], @@ -1332,9 +1331,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz", - "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", "cpu": [ "x64" ], @@ -1410,7 +1409,6 @@ "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1428,7 +1426,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1445,20 +1442,20 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz", - "integrity": "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz", + "integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.50.0", - "@typescript-eslint/type-utils": "8.50.0", - "@typescript-eslint/utils": "8.50.0", - "@typescript-eslint/visitor-keys": "8.50.0", + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/type-utils": "8.51.0", + "@typescript-eslint/utils": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1468,7 +1465,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.50.0", + "@typescript-eslint/parser": "^8.51.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -1484,17 +1481,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz", - "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.51.0.tgz", + "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.50.0", - "@typescript-eslint/types": "8.50.0", - "@typescript-eslint/typescript-estree": "8.50.0", - "@typescript-eslint/visitor-keys": "8.50.0", + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4" }, "engines": { @@ -1510,14 +1506,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.0.tgz", - "integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz", + "integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.50.0", - "@typescript-eslint/types": "^8.50.0", + "@typescript-eslint/tsconfig-utils": "^8.51.0", + "@typescript-eslint/types": "^8.51.0", "debug": "^4.3.4" }, "engines": { @@ -1532,14 +1528,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.0.tgz", - "integrity": "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz", + "integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.50.0", - "@typescript-eslint/visitor-keys": "8.50.0" + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1550,9 +1546,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.0.tgz", - "integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz", + "integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==", "dev": true, "license": "MIT", "engines": { @@ -1567,17 +1563,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.0.tgz", - "integrity": "sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz", + "integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.50.0", - "@typescript-eslint/typescript-estree": "8.50.0", - "@typescript-eslint/utils": "8.50.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/utils": "8.51.0", "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1592,9 +1588,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz", - "integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz", + "integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==", "dev": true, "license": "MIT", "engines": { @@ -1606,21 +1602,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.0.tgz", - "integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz", + "integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.50.0", - "@typescript-eslint/tsconfig-utils": "8.50.0", - "@typescript-eslint/types": "8.50.0", - "@typescript-eslint/visitor-keys": "8.50.0", + "@typescript-eslint/project-service": "8.51.0", + "@typescript-eslint/tsconfig-utils": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1673,16 +1669,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.0.tgz", - "integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz", + "integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.50.0", - "@typescript-eslint/types": "8.50.0", - "@typescript-eslint/typescript-estree": "8.50.0" + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1697,13 +1693,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.0.tgz", - "integrity": "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz", + "integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/types": "8.51.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1741,7 +1737,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1898,9 +1893,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.9", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.9.tgz", - "integrity": "sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg==", + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1964,7 +1959,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2013,9 +2007,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001760", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", - "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "version": "1.0.30001762", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", + "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", "dev": true, "funding": [ { @@ -2370,7 +2364,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2497,9 +2490,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2594,9 +2587,9 @@ "license": "MIT" }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -3014,7 +3007,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3515,7 +3507,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3711,7 +3702,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3724,7 +3714,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3841,9 +3830,9 @@ } }, "node_modules/rollup": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", - "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "dev": true, "license": "MIT", "dependencies": { @@ -3857,28 +3846,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.5", - "@rollup/rollup-android-arm64": "4.53.5", - "@rollup/rollup-darwin-arm64": "4.53.5", - "@rollup/rollup-darwin-x64": "4.53.5", - "@rollup/rollup-freebsd-arm64": "4.53.5", - "@rollup/rollup-freebsd-x64": "4.53.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", - "@rollup/rollup-linux-arm-musleabihf": "4.53.5", - "@rollup/rollup-linux-arm64-gnu": "4.53.5", - "@rollup/rollup-linux-arm64-musl": "4.53.5", - "@rollup/rollup-linux-loong64-gnu": "4.53.5", - "@rollup/rollup-linux-ppc64-gnu": "4.53.5", - "@rollup/rollup-linux-riscv64-gnu": "4.53.5", - "@rollup/rollup-linux-riscv64-musl": "4.53.5", - "@rollup/rollup-linux-s390x-gnu": "4.53.5", - "@rollup/rollup-linux-x64-gnu": "4.53.5", - "@rollup/rollup-linux-x64-musl": "4.53.5", - "@rollup/rollup-openharmony-arm64": "4.53.5", - "@rollup/rollup-win32-arm64-msvc": "4.53.5", - "@rollup/rollup-win32-ia32-msvc": "4.53.5", - "@rollup/rollup-win32-x64-gnu": "4.53.5", - "@rollup/rollup-win32-x64-msvc": "4.53.5", + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" } }, @@ -4132,7 +4121,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4154,9 +4142,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz", + "integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==", "dev": true, "license": "MIT", "engines": { @@ -4198,7 +4186,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4208,16 +4195,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.50.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.0.tgz", - "integrity": "sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==", + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.51.0.tgz", + "integrity": "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.50.0", - "@typescript-eslint/parser": "8.50.0", - "@typescript-eslint/typescript-estree": "8.50.0", - "@typescript-eslint/utils": "8.50.0" + "@typescript-eslint/eslint-plugin": "8.51.0", + "@typescript-eslint/parser": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/utils": "8.51.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4292,7 +4279,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 6e6c8a7..d8502fe 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -1,1117 +1,1513 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { VideoPlayer } from './VideoPlayer'; -import type { Video, UserProfile } from '../types'; -import axios from 'axios'; -import { API_BASE_URL } from '../config'; -import { Search, X, Plus } from 'lucide-react'; -import { videoPrefetcher } from '../utils/videoPrefetch'; -import { feedLoader } from '../utils/feedLoader'; - -type ViewState = 'login' | 'loading' | 'feed'; -type TabType = 'foryou' | 'following' | 'search'; - -// Suggested categories for Following tab -const SUGGESTED_CATEGORIES = [ - { id: 'hot_trend', name: '🔥 Hot Trend 2024', query: 'hot trend' }, - { id: 'dance_vn', name: '💃 Gái Xinh Nhảy', query: 'gai xinh nhay' }, - { id: 'sexy_dance', name: '✨ Sexy Dance', query: 'sexy dance vietnam' }, - { id: 'music_remix', name: '🎵 Nhạc Remix TikTok', query: 'nhac remix tiktok' }, - { id: 'kpop_cover', name: '🇰🇷 K-pop Cover', query: 'kpop dance cover' }, - { id: 'comedy', name: '😂 Hài Hước', query: 'hai huoc vietnam' }, -]; - -// Famous Dance TikTokers - 50+ accounts from around the world -const SUGGESTED_ACCOUNTS = [ - // === GLOBAL STARS === - { username: '@charlidamelio', label: '👑 Charli D\'Amelio - Queen' }, - { username: '@addisonre', label: '✨ Addison Rae' }, - { username: '@bellapoarch', label: '🎵 Bella Poarch' }, - { username: '@khloekardashian', label: '💫 Khloé Kardashian' }, - { username: '@jfrancesch', label: '💃 Jason Derulo' }, - { username: '@justmaiko', label: '🔥 Michael Le' }, - { username: '@thereal.animations', label: '🎭 Dance Animations' }, - { username: '@willsmith', label: '🌟 Will Smith' }, - // === K-POP & ASIAN === - { username: '@lisa_blackpink', label: '🖤💖 LISA BLACKPINK' }, - { username: '@bfrancisco', label: '🇵🇭 Bella Francisco' }, - { username: '@niana_guerrero', label: '🌈 Niana Guerrero' }, - { username: '@ranz', label: '🎤 Ranz Kyle' }, - { username: '@1milliondance', label: '💯 1Million Dance' }, - { username: '@babymonsteryg', label: '🐾 BABYMONSTER' }, - { username: '@enhypen', label: '🎵 ENHYPEN' }, - { username: '@aespaficial', label: '✨ aespa' }, - { username: '@itzy.all.in.us', label: '💪 ITZY' }, - { username: '@straykids_official', label: '🔥 Stray Kids' }, - // === DANCE CREWS === - { username: '@thechipmunks', label: '🐿️ The Chipmunks' }, - { username: '@thekinjaz', label: '🎯 The Kinjaz' }, - { username: '@jabbawockeez', label: '🎭 Jabbawockeez' }, - { username: '@worldofdance', label: '🌍 World of Dance' }, - { username: '@dancemoms', label: '👯 Dance Moms' }, - // === VIRAL DANCERS === - { username: '@mikimakey', label: '🎀 Miki Makey' }, - { username: '@enola_bedard', label: '🇫🇷 Énola Bédard' }, - { username: '@lizzy_wurst', label: '😊 Lizzy Wurst' }, - { username: '@thepaigeniemann', label: '⭐ Paige Niemann' }, - { username: '@brentrivera', label: '😄 Brent Rivera' }, - { username: '@larray', label: '💅 Larray' }, - { username: '@avani', label: '🖤 Avani' }, - { username: '@noahbeck', label: '🏃 Noah Beck' }, - { username: '@lilhuddy', label: '🎸 Lil Huddy' }, - // === VIETNAMESE (Verified Usernames) === - { username: '@cciinnn', label: '👑 CiiN (Bùi Thảo Ly)' }, - { username: '@hoaa.hanassii', label: '💃 Hoa Hanassii' }, - { username: '@lebong95', label: '💪 Lê Bống' }, - { username: '@tieu_hy26', label: '👰 Tiểu Hý' }, - { username: '@hieuthuhai2222', label: '🎧 HIEUTHUHAI' }, - { username: '@mtp.fan', label: '🎤 Sơn Tùng M-TP' }, - { username: '@changmakeup', label: '💄 Changmakeup' }, - { username: '@theanh28entertainment', label: '🎬 Theanh28' }, - { username: '@quangdangofficial', label: '🕺 Quang Đăng' }, - { username: '@chipu88', label: '🎤 Chi Pu' }, - { username: '@minhhangofficial', label: '👑 Minh Hằng' }, - // === CHOREOGRAPHERS === - { username: '@chloearnold', label: '🎬 Chloe Arnold' }, - { username: '@alexis_beauregard', label: '🌟 Alexis Beauregard' }, - { username: '@mattiapolibio', label: '⭐ Mattia Polibio' }, - { username: '@jawsh685', label: '🎧 Jawsh 685' }, - { username: '@daviddooboy', label: '🕺 David Vu' }, - // === FUN & COMEDY DANCE === - { username: '@domainichael', label: '😂 Domaini Michael' }, - { username: '@jailifebymike', label: '💃 Jai Life' }, - { username: '@dancewithjulian', label: '🎭 Julian' }, - { username: '@leiasfanpage', label: '💖 Leia' }, - { username: '@taylerholder', label: '🔥 Tayler Holder' }, -]; - -// NOTE: Keyword search is now handled by the backend /api/user/search endpoint - -export const Feed: React.FC = () => { - const [viewState, setViewState] = useState('login'); - const [activeTab, setActiveTab] = useState('foryou'); - const [videos, setVideos] = useState([]); - const [currentIndex, setCurrentIndex] = useState(0); - const [searchCursor, setSearchCursor] = useState(0); - const [searchHasMore, setSearchHasMore] = useState(true); - const [isInSearchPlayback, setIsInSearchPlayback] = useState(false); - const [originalVideos, setOriginalVideos] = useState([]); - const [originalIndex, setOriginalIndex] = useState(0); - const [jsonInput, setJsonInput] = useState(''); - const containerRef = useRef(null); - - // Following state - const [following, setFollowing] = useState([]); - const [newFollowInput, setNewFollowInput] = useState(''); - - // Suggested profiles with real data - const [suggestedProfiles, setSuggestedProfiles] = useState([]); - const [loadingProfiles, setLoadingProfiles] = useState(false); - const [suggestedLimit, setSuggestedLimit] = useState(12); - const [showHeader, setShowHeader] = useState(false); - const [isFollowingFeed, setIsFollowingFeed] = useState(false); - // Lazy load - start with 12 - - // Search state - const [searchInput, setSearchInput] = useState(''); - const [searchResults, setSearchResults] = useState([]); - const [isSearching, setIsSearching] = useState(false); - const [error, setError] = useState(null); - const [showAdvanced, setShowAdvanced] = useState(false); - - // Global mute state - persists across video scrolling - const [isMuted, setIsMuted] = useState(true); - - // ========== SWIPE LOGIC ========== - const touchStart = useRef(null); - const touchEnd = useRef(null); - const minSwipeDistance = 50; - - // Touch Handling (Mobile) - const onTouchStart = (e: React.TouchEvent) => { - touchEnd.current = null; - touchStart.current = e.targetTouches[0].clientX; - }; - - const onTouchMove = (e: React.TouchEvent) => { - touchEnd.current = e.targetTouches[0].clientX; - }; - - const onTouchEnd = () => { - handleSwipeEnd(); - }; - - // Mouse Handling (Desktop) - const onMouseDown = (e: React.MouseEvent) => { - touchEnd.current = null; - touchStart.current = e.clientX; - }; - - const onMouseMove = (e: React.MouseEvent) => { - if (e.buttons === 1) { // Only track if left button held - touchEnd.current = e.clientX; - } - }; - - const onMouseUp = () => { - if (touchStart.current) { - // Handle click vs swipe - // If minimal movement, treat as click/tap (handled by onClick elsewhere, but for header toggle we need it here?) - // Actually, onMouseUp is better for swipe end. - handleSwipeEnd(); - } - touchStart.current = null; - touchEnd.current = null; - }; - - const handleSwipeEnd = () => { - if (!touchStart.current || !touchEnd.current) return; - - const distanceX = touchStart.current - touchEnd.current; - const isLeftSwipe = distanceX > minSwipeDistance; - const isRightSwipe = distanceX < -minSwipeDistance; - - if (isLeftSwipe) { - if (activeTab === 'foryou') { setActiveTab('following'); setShowHeader(true); } - else if (activeTab === 'following') { setActiveTab('search'); setShowHeader(true); } - } else if (isRightSwipe) { - if (activeTab === 'search') { setActiveTab('following'); setShowHeader(true); } - else if (activeTab === 'following') { setActiveTab('foryou'); setShowHeader(true); } - } else { - // Minor movement - Do nothing (Tap is handled by video click) - } - - if (activeTab === 'foryou') { - setTimeout(() => setShowHeader(false), 3000); - } - }; - - // Check auth status on mount - useEffect(() => { - checkAuthStatus(); - }, []); - - // Load following list when authenticated - useEffect(() => { - if (viewState === 'feed') { - loadFollowing(); - } - }, [viewState]); - - // Load suggested profiles when switching to Following tab - useEffect(() => { - if (activeTab === 'following' && suggestedProfiles.length === 0 && !loadingProfiles) { - loadSuggestedProfiles(); - } - }, [activeTab]); - - // Keyboard arrow navigation for desktop - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Only handle when in feed view and not typing in an input - if (viewState !== 'feed') return; - const target = e.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; - - if (e.key === 'ArrowRight') { - e.preventDefault(); - if (activeTab === 'foryou') setActiveTab('following'); - else if (activeTab === 'following') setActiveTab('search'); - } else if (e.key === 'ArrowLeft') { - e.preventDefault(); - if (activeTab === 'search') setActiveTab('following'); - else if (activeTab === 'following') setActiveTab('foryou'); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [activeTab, viewState]); - - useEffect(() => { - const prefetch = async () => { - await videoPrefetcher.init(); - if (activeTab === 'foryou') { - videoPrefetcher.prefetchNext(videos, currentIndex); - } - }; - - prefetch(); - }, [currentIndex, videos, activeTab]); - - // Scrolls to the current index when the videos list changes or when we enter/exit search playback - // This fixes the "blur screen" bug where the previous scroll position persisted after swapping video lists - useEffect(() => { - if (activeTab === 'foryou' && containerRef.current && videos.length > 0) { - const targetScroll = currentIndex * containerRef.current.clientHeight; - // Only scroll if significantly off (allow small manual adjustments) - if (Math.abs(containerRef.current.scrollTop - targetScroll) > 50) { - containerRef.current.scrollTo({ - top: targetScroll, - behavior: 'auto' // Instant jump to prevent weird visual sliding on list swap - }); - } - } - }, [videos, activeTab, isInSearchPlayback]); // Dependencies needed to trigger on list swap - - const loadSuggestedProfiles = async () => { - setLoadingProfiles(true); - try { - // Try the dynamic suggested API first (auto-updates from TikTok Vietnam) - const res = await axios.get(`${API_BASE_URL}/user/suggested?limit=50`); - const accounts = res.data.accounts || []; - - if (accounts.length > 0) { - // Map API response to our profile format - setSuggestedProfiles(accounts.map((acc: any) => ({ - username: acc.username, - nickname: acc.nickname || acc.username, - avatar: acc.avatar || null, - followers: acc.followers || 0, - verified: acc.verified || false - }))); - } else { - // Fallback to static list if API returns empty - setSuggestedProfiles(SUGGESTED_ACCOUNTS.map(a => ({ - username: a.username.replace('@', ''), - nickname: a.label - }))); - } - } catch (err) { - console.error('Failed to load profiles:', err); - // Fallback to static list on error - setSuggestedProfiles(SUGGESTED_ACCOUNTS.map(a => ({ - username: a.username.replace('@', ''), - nickname: a.label - }))); - } finally { - setLoadingProfiles(false); - } - }; - - const checkAuthStatus = async () => { - try { - const res = await axios.get(`${API_BASE_URL}/auth/status`); - if (res.data.authenticated) { - loadFeed(); - } - } catch (err) { - console.log('Not authenticated'); - } - }; - - const loadFollowing = async () => { - try { - const { data } = await axios.get(`${API_BASE_URL}/following`); - setFollowing(data); - - // If on following tab, load actual feed instead of just profiles - if (activeTab === 'following') { - setIsFetching(true); - try { - const res = await axios.get(`${API_BASE_URL}/following/feed`); - if (res.data && res.data.length > 0) { - setVideos(res.data); - setCurrentIndex(0); - setIsFollowingFeed(true); - setActiveTab('foryou'); // Switch to feed view but with following content - } - } catch (e) { - console.error('Error loading following feed:', e); - } finally { - setIsFetching(false); - } - } - } catch (error) { - console.error('Error loading following list:', error); - } - }; - - const handleFollow = async (username: string) => { - const cleanUsername = username.replace('@', ''); - - if (following.includes(cleanUsername)) { - // Unfollow - await axios.delete(`${API_BASE_URL}/following/${cleanUsername}`); - setFollowing(prev => prev.filter(u => u !== cleanUsername)); - } else { - // Follow - await axios.post(`${API_BASE_URL}/following`, { username: cleanUsername }); - setFollowing(prev => [...prev, cleanUsername]); - } - }; - - const handleAddFollow = async () => { - if (!newFollowInput.trim()) return; - await handleFollow(newFollowInput); - setNewFollowInput(''); - }; - - const handleBrowserLogin = async () => { - setViewState('loading'); - setError(null); - - try { - const res = await axios.post(`${API_BASE_URL}/auth/browser-login`); - if (res.data.status === 'success') { - loadFeed(); - } else { - setError(res.data.message || 'Login failed'); - setViewState('login'); - } - } catch (err: any) { - setError(err.response?.data?.detail || 'Login failed'); - setViewState('login'); - } - }; - - const handleJsonLogin = async () => { - if (!jsonInput.trim()) { - setError('Please paste your credentials'); - return; - } - - setViewState('loading'); - setError(null); - - try { - const credentials = JSON.parse(jsonInput); - await axios.post(`${API_BASE_URL}/auth/credentials`, { credentials }); - loadFeed(); - } catch (err: any) { - setError(err.message || 'Invalid JSON format'); - setViewState('login'); - } - }; - - const loadFeed = async () => { - setViewState('loading'); - setError(null); - - try { - const videos = await feedLoader.loadFeedWithOptimization( - false, - (loaded: Video[]) => { - if (loaded.length > 0) { - setVideos(loaded); - setViewState('feed'); - - // Start prefetching first 3 videos immediately - videoPrefetcher.prefetchInitialBatch(loaded, 3); - } - } - ); - - if (videos.length === 0) { - // If authenticated but no videos, stay in feed view but show empty state - // Do NOT go back to login, as that confuses the user (they are logged in) - console.warn('Feed empty, but authenticated.'); - setViewState('feed'); - setError('No videos found. Pull to refresh or try searching.'); - } - } catch (err: any) { - console.error('Feed load failed:', err); - // Only go back to login if it's explicitly an Auth error (401) - if (err.response?.status === 401) { - setError('Session expired. Please login again.'); - setViewState('login'); - } else { - // For other errors (500, network), stay in feed/loading and show error - setError(err.response?.data?.detail || 'Failed to load feed'); - setViewState('feed'); - } - } - }; - - const [isFetching, setIsFetching] = useState(false); - const [hasMore, setHasMore] = useState(true); - - const handleScroll = () => { - if (containerRef.current) { - const { scrollTop, clientHeight } = containerRef.current; - const index = Math.round(scrollTop / clientHeight); - if (index !== currentIndex) { - setCurrentIndex(index); - } - - // Preemptive fetch at 60% - const watchedPercent = videos.length > 0 ? (index + 1) / videos.length : 0; - if (watchedPercent >= 0.6 && hasMore && !isFetching && videos.length > 0) { - loadMoreVideos(); - } - } - }; - - const loadMoreVideos = async () => { - if (isFetching || !hasMore) return; - setIsFetching(true); - - try { - // Pass skipCache=true to force fetching fresh videos from backend - const newVideos = await feedLoader.loadFeedWithOptimization(false, undefined, true); - - setVideos(prev => { - const existingIds = new Set(prev.map(v => v.id)); - const unique = newVideos.filter((v: Video) => !existingIds.has(v.id)); - if (unique.length === 0) setHasMore(false); - return [...prev, ...unique]; - }); - } catch (err) { - console.error('Failed to load more:', err); - } finally { - setIsFetching(false); - } - }; - - const handleLogout = async () => { - await axios.post(`${API_BASE_URL}/auth/logout`); - setVideos([]); - setViewState('login'); - }; - - // Direct username search - const searchByUsername = async (username: string) => { - setSearchInput(`@${username}`); - setActiveTab('search'); - handleSearch(false, `@${username}`); - }; - - // Direct keyword search - const searchByKeyword = async (keyword: string) => { - setSearchInput(keyword); - setActiveTab('search'); - handleSearch(false, keyword); - }; - - const handleSearch = async (isMore = false, overrideInput?: string) => { - const inputToSearch = overrideInput || searchInput; - if (!inputToSearch.trim() || isSearching) return; - - setIsSearching(true); - setError(null); - - // Clear previous results immediately if starting a new search - // This ensures the skeleton loader is shown instead of old results - if (!isMore) { - setSearchResults([]); - } - - try { - const cursor = isMore ? searchCursor : 0; - // "Search must show at least 50 result" - fetching 50 at a time with infinite scroll - const limit = 50; - - let endpoint = `${API_BASE_URL}/user/search?query=${encodeURIComponent(inputToSearch)}&limit=${limit}&cursor=${cursor}`; - - // If direct username search - if (inputToSearch.startsWith('@')) { - endpoint = `${API_BASE_URL}/user/videos?username=${inputToSearch.substring(1)}&limit=${limit}`; - } - - const { data } = await axios.get(endpoint); - let newVideos = data.videos || []; - - // Fallback: If user search (@) returns no videos, try general search - if (newVideos.length === 0 && !isMore && inputToSearch.startsWith('@')) { - console.log('User search returned empty, falling back to keyword search'); - const fallbackQuery = inputToSearch.substring(1); // Remove @ - const fallbackEndpoint = `${API_BASE_URL}/user/search?query=${encodeURIComponent(fallbackQuery)}&limit=${limit}&cursor=0`; - - try { - const fallbackRes = await axios.get(fallbackEndpoint); - if (fallbackRes.data.videos && fallbackRes.data.videos.length > 0) { - newVideos = fallbackRes.data.videos; - // Optional: Show a toast or message saying "User not found, showing results for..." - setError(`User '${inputToSearch}' not found. Showing related videos.`); - } - } catch (fallbackErr) { - console.error('Fallback search failed', fallbackErr); - } - } - - if (isMore) { - setSearchResults(prev => [...prev, ...newVideos]); - } else { - setSearchResults(newVideos); - } - - setSearchCursor(data.cursor || 0); - // If we got results, assume there's more (TikTok has endless content) - // unless the count is very small (e.g. < 5) which might indicate end - setSearchHasMore(newVideos.length >= 5); - - } catch (err) { - console.error('Search failed:', err); - setError('Search failed. Please try again.'); - } finally { - setIsSearching(false); - } - }; - - const handleSearchScroll = (e: React.UIEvent) => { - const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; - if (scrollHeight - scrollTop <= clientHeight + 100 && searchHasMore && !isSearching) { - handleSearch(true); - } - }; - - // ========== LOGIN VIEW ========== - if (viewState === 'login') { - return ( -
- {/* Header */} -
-
-
-
- - - -
-
-

PureStream

-

Ad-free TikTok viewing

-
- - {/* Scrollable Content */} -
-
- {error && ( -
- {error} -
- )} - - {/* How to Login - Step by Step */} -
-

How to Login

- -
-
-
1
-
-

Open TikTok in browser

-

Use Chrome/Safari on your phone or computer

-
-
- -
-
2
-
-

Export your cookies

-

Use "Cookie-Editor" extension (Chrome/Firefox)

-
-
- -
-
3
-
-

Paste cookies below

-

Copy the JSON and paste it here

-
-
-
-
- - {/* Cookie Input */} -
-