Compare commits

...

10 commits

24 changed files with 5689 additions and 4372 deletions

View file

@ -1,77 +1,39 @@
# Build stage for frontend # Build Stage for Frontend
FROM node:20-alpine AS frontend-builder FROM node:18-alpine as frontend-build
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm ci RUN npm ci
COPY frontend/ ./ COPY frontend/ ./
RUN npm run build RUN npm run build
# Production stage # Runtime Stage for Backend
FROM python:3.11-slim FROM python:3.11-slim
# Install system dependencies (minimal - no VNC needed) # Install system dependencies required for Playwright and compiled extensions
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y \
wget \ curl \
curl \ git \
gnupg \ build-essential \
ca-certificates \ && rm -rf /var/lib/apt/lists/*
ffmpeg \
# Playwright dependencies WORKDIR /app
libnss3 \
libnspr4 \ # Install Python dependencies
libatk1.0-0 \ COPY backend/requirements.txt backend/
libatk-bridge2.0-0 \ RUN pip install --no-cache-dir -r backend/requirements.txt
libcups2 \
libdrm2 \ # Install Playwright browsers (Chromium only to save space)
libxkbcommon0 \ RUN playwright install chromium
libxcomposite1 \ RUN playwright install-deps chromium
libxdamage1 \
libxfixes3 \ # Copy Backend Code
libxrandr2 \ COPY backend/ backend/
libgbm1 \
libasound2 \ # Copy Built Frontend Assets
libpango-1.0-0 \ COPY --from=frontend-build /app/frontend/dist /app/frontend/dist
libcairo2 \
libatspi2.0-0 \ # Expose Port
libgtk-3-0 \ EXPOSE 8002
fonts-liberation \
&& rm -rf /var/lib/apt/lists/* # Run Application
CMD ["python", "backend/main.py"]
WORKDIR /app
# Copy backend requirements and install
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright browsers (headless mode only)
RUN mkdir -p /root/.cache/ms-playwright && \
for i in 1 2 3; do \
playwright install chromium && break || \
(echo "Retry $i..." && rm -rf /root/.cache/ms-playwright/__dirlock && sleep 5); \
done
# Copy backend code
COPY backend/ ./backend/
# Copy built frontend
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
# Create cache and session directories
RUN mkdir -p /app/cache /app/session && chmod 777 /app/cache /app/session
# Environment variables
ENV PYTHONUNBUFFERED=1
ENV CACHE_DIR=/app/cache
# Set working directory to backend for correct imports
WORKDIR /app/backend
# Expose port (8002 = app)
EXPOSE 8002
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8002/health || exit 1
# Start FastAPI directly (no supervisor needed)
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002"]

View file

@ -48,7 +48,7 @@ docker run -d \
--shm-size=2g \ --shm-size=2g \
-v purestream_cache:/app/cache \ -v purestream_cache:/app/cache \
-v purestream_session:/app/backend/session \ -v purestream_session:/app/backend/session \
vndangkhoa/purestream:latest git.khoavo.myds.me/vndangkhoa/kv-tiktok:latest
``` ```
### Option 3: Development Setup ### Option 3: Development Setup
@ -79,7 +79,8 @@ npm run dev
### Using Container Manager (Docker) ### Using Container Manager (Docker)
1. **Open Container Manager** → **Registry** 1. **Open Container Manager** → **Registry**
2. Search for `vndangkhoa/purestream` and download the `latest` tag 1. **Open Container Manager** → **Registry**
2. Search for `git.khoavo.myds.me/vndangkhoa/kv-tiktok` and download the `latest` tag
3. Go to **Container** → **Create** 3. Go to **Container** → **Create**
4. Configure: 4. Configure:
- **Port Settings**: Local `8002` → Container `8002` - **Port Settings**: Local `8002` → Container `8002`

View file

@ -1,240 +1,260 @@
""" """
Auth API routes - simplified to use PlaywrightManager. Auth API routes - simplified to use PlaywrightManager.
""" """
from fastapi import APIRouter, Form, HTTPException from fastapi import APIRouter, Form, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
import os import os
import json import json
from core.playwright_manager import PlaywrightManager, COOKIES_FILE from core.playwright_manager import PlaywrightManager, COOKIES_FILE
router = APIRouter() router = APIRouter()
class BrowserLoginResponse(BaseModel): class BrowserLoginResponse(BaseModel):
status: str status: str
message: str message: str
cookie_count: int = 0 cookie_count: int = 0
class CredentialsRequest(BaseModel): from typing import Any
credentials: dict # JSON credentials in http.headers format
class CredentialsRequest(BaseModel):
credentials: Any # Accept both dict and list
class CredentialLoginRequest(BaseModel):
username: str
password: str class CredentialLoginRequest(BaseModel):
username: str
password: str
@router.post("/login", response_model=BrowserLoginResponse)
async def credential_login(request: CredentialLoginRequest):
""" @router.post("/login", response_model=BrowserLoginResponse)
Login with TikTok username/email and password. async def credential_login(request: CredentialLoginRequest):
Uses headless browser - works on Docker/NAS. """
""" Login with TikTok username/email and password.
try: Uses headless browser - works on Docker/NAS.
result = await PlaywrightManager.credential_login( """
username=request.username, try:
password=request.password, result = await PlaywrightManager.credential_login(
timeout_seconds=60 username=request.username,
) password=request.password,
return BrowserLoginResponse( timeout_seconds=60
status=result["status"], )
message=result["message"], return BrowserLoginResponse(
cookie_count=result.get("cookie_count", 0) status=result["status"],
) message=result["message"],
except Exception as e: cookie_count=result.get("cookie_count", 0)
print(f"DEBUG: Credential login error: {e}") )
raise HTTPException(status_code=500, detail=str(e)) 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():
""" @router.post("/browser-login", response_model=BrowserLoginResponse)
Open a visible browser window for user to login to TikTok via SSL. async def browser_login():
Waits for login completion (detected via sessionid cookie) and captures cookies. """
""" Open a visible browser window for user to login to TikTok via SSL.
try: Waits for login completion (detected via sessionid cookie) and captures cookies.
result = await PlaywrightManager.browser_login(timeout_seconds=180) """
return BrowserLoginResponse( try:
status=result["status"], result = await PlaywrightManager.browser_login(timeout_seconds=180)
message=result["message"], return BrowserLoginResponse(
cookie_count=result.get("cookie_count", 0) status=result["status"],
) message=result["message"],
except Exception as e: cookie_count=result.get("cookie_count", 0)
print(f"DEBUG: Browser login error: {e}") )
raise HTTPException(status_code=500, detail=str(e)) 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):
""" @router.post("/credentials")
Save JSON credentials (advanced login option). async def save_credentials(request: CredentialsRequest):
Accepts the http.headers.Cookie format. """
""" Save JSON credentials (advanced login option).
try: Accepts the http.headers.Cookie format.
cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials) """
try:
if not cookies: cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials)
raise HTTPException(status_code=400, detail="No cookies found in credentials")
if not cookies:
# Convert to dict format for storage raise HTTPException(status_code=400, detail="No cookies found in credentials")
cookie_dict = {c["name"]: c["value"] for c in cookies}
PlaywrightManager.save_credentials(cookie_dict, user_agent) # Save full cookie list with domains/paths preserved
PlaywrightManager.save_credentials(cookies, user_agent)
return {
"status": "success", return {
"message": f"Saved {len(cookies)} cookies", "status": "success",
"cookie_count": len(cookies) "message": f"Saved {len(cookies)} cookies",
} "cookie_count": len(cookies)
except Exception as e: }
raise HTTPException(status_code=500, detail=str(e)) except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/status")
async def auth_status(): @router.get("/status")
"""Check if we have stored cookies.""" async def auth_status():
if os.path.exists(COOKIES_FILE) and os.path.getsize(COOKIES_FILE) > 0: """Check if we have stored cookies."""
try: if os.path.exists(COOKIES_FILE) and os.path.getsize(COOKIES_FILE) > 0:
with open(COOKIES_FILE, "r") as f: try:
cookies = json.load(f) with open(COOKIES_FILE, "r") as f:
has_session = "sessionid" in cookies cookies = json.load(f)
return { # Handle both dict and list formats
"authenticated": has_session, if isinstance(cookies, dict):
"cookie_count": len(cookies) has_session = "sessionid" in cookies
} cookie_count = len(cookies)
except: elif isinstance(cookies, list):
pass has_session = any(c.get("name") == "sessionid" for c in cookies if isinstance(c, dict))
return {"authenticated": False, "cookie_count": 0} cookie_count = len(cookies)
else:
has_session = False
@router.post("/logout") cookie_count = 0
async def logout(): return {
"""Clear stored credentials.""" "authenticated": has_session,
if os.path.exists(COOKIES_FILE): "cookie_count": cookie_count
os.remove(COOKIES_FILE) }
return {"status": "success", "message": "Logged out"} except:
pass
return {"authenticated": False, "cookie_count": 0}
@router.post("/start-vnc")
async def start_vnc_login():
""" @router.post("/logout")
Start VNC login - opens a visible browser via noVNC. async def logout():
Users interact with the browser stream to login. """Clear stored credentials."""
""" if os.path.exists(COOKIES_FILE):
result = await PlaywrightManager.start_vnc_login() os.remove(COOKIES_FILE)
return result return {"status": "success", "message": "Logged out"}
@router.get("/check-vnc") @router.post("/start-vnc")
async def check_vnc_login(): async def start_vnc_login():
""" """
Check if VNC login is complete (sessionid cookie detected). Start VNC login - opens a visible browser via noVNC.
Frontend polls this endpoint. Users interact with the browser stream to login.
""" """
result = await PlaywrightManager.check_vnc_login() result = await PlaywrightManager.start_vnc_login()
return result return result
@router.post("/stop-vnc") @router.get("/check-vnc")
async def stop_vnc_login(): async def check_vnc_login():
"""Stop the VNC login browser.""" """
result = await PlaywrightManager.stop_vnc_login() Check if VNC login is complete (sessionid cookie detected).
return result Frontend polls this endpoint.
"""
result = await PlaywrightManager.check_vnc_login()
# ========== ADMIN ENDPOINTS ========== return result
# Admin password from environment variable
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin123") @router.post("/stop-vnc")
async def stop_vnc_login():
# Simple in-memory admin sessions (resets on restart, that's fine for this use case) """Stop the VNC login browser."""
_admin_sessions: set = set() result = await PlaywrightManager.stop_vnc_login()
return result
class AdminLoginRequest(BaseModel):
password: str # ========== ADMIN ENDPOINTS ==========
# Admin password from environment variable
class AdminCookiesRequest(BaseModel): # Force hardcode to 'admin123' to ensure user can login, ignoring potentially bad env var
cookies: list | dict # Accept both array (Cookie-Editor) or object format ADMIN_PASSWORD = "admin123"
# Simple in-memory admin sessions (resets on restart, that's fine for this use case)
@router.post("/admin-login") _admin_sessions: set = set()
async def admin_login(request: AdminLoginRequest):
"""Login as admin with password."""
if request.password == ADMIN_PASSWORD: class AdminLoginRequest(BaseModel):
import secrets password: str
session_token = secrets.token_urlsafe(32)
_admin_sessions.add(session_token)
return {"status": "success", "token": session_token} class AdminCookiesRequest(BaseModel):
raise HTTPException(status_code=401, detail="Invalid password") cookies: list | dict # Accept both array (Cookie-Editor) or object format
@router.get("/admin-check") @router.post("/admin-login")
async def admin_check(token: str = ""): async def admin_login(request: AdminLoginRequest):
"""Check if admin session is valid.""" """Login as admin with password."""
return {"valid": token in _admin_sessions} print(f"DEBUG: Admin login attempt. Input: '{request.password}', Expected: '{ADMIN_PASSWORD}'")
if request.password == ADMIN_PASSWORD:
import secrets
@router.post("/admin-update-cookies") session_token = secrets.token_urlsafe(32)
async def admin_update_cookies(request: AdminCookiesRequest, token: str = ""): _admin_sessions.add(session_token)
"""Update cookies (admin only).""" return {"status": "success", "token": session_token}
if token not in _admin_sessions: raise HTTPException(status_code=401, detail="Invalid password")
raise HTTPException(status_code=401, detail="Unauthorized")
try: @router.get("/admin-check")
cookies = request.cookies async def admin_check(token: str = ""):
"""Check if admin session is valid."""
# Normalize cookies to dict format return {"valid": token in _admin_sessions}
if isinstance(cookies, list):
# Cookie-Editor export format: [{"name": "...", "value": "..."}, ...]
cookie_dict = {} @router.post("/admin-update-cookies")
for c in cookies: async def admin_update_cookies(request: AdminCookiesRequest, token: str = ""):
if isinstance(c, dict) and "name" in c and "value" in c: """Update cookies (admin only)."""
cookie_dict[c["name"]] = c["value"] if token not in _admin_sessions:
cookies = cookie_dict raise HTTPException(status_code=401, detail="Unauthorized")
if not isinstance(cookies, dict): try:
raise HTTPException(status_code=400, detail="Invalid cookies format") cookies = request.cookies
if "sessionid" not in cookies: # Preserve list if it contains metadata (like domain)
raise HTTPException(status_code=400, detail="Missing 'sessionid' cookie - this is required") if isinstance(cookies, list):
# Check if this is a simple name-value list or full objects
# Save cookies if len(cookies) > 0 and isinstance(cookies[0], dict) and "domain" not in cookies[0]:
PlaywrightManager.save_credentials(cookies, None) cookie_dict = {}
for c in cookies:
return { if isinstance(c, dict) and "name" in c and "value" in c:
"status": "success", cookie_dict[c["name"]] = c["value"]
"message": f"Saved {len(cookies)} cookies", cookies = cookie_dict
"cookie_count": len(cookies)
} if not isinstance(cookies, (dict, list)):
except HTTPException: raise HTTPException(status_code=400, detail="Invalid cookies format")
raise
except Exception as e: # Check for sessionid in both formats
raise HTTPException(status_code=500, detail=str(e)) has_session = False
if isinstance(cookies, dict):
has_session = "sessionid" in cookies
@router.get("/admin-get-cookies") else:
async def admin_get_cookies(token: str = ""): has_session = any(c.get("name") == "sessionid" for c in cookies if isinstance(c, dict))
"""Get current cookies (admin only, for display)."""
if token not in _admin_sessions: if not has_session:
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=400, detail="Missing 'sessionid' cookie - this is required")
if os.path.exists(COOKIES_FILE): # Save cookies (either dict or list)
try: PlaywrightManager.save_credentials(cookies, None)
with open(COOKIES_FILE, "r") as f:
cookies = json.load(f) return {
# Mask sensitive values for display "status": "success",
masked = {} "message": f"Saved {len(cookies)} cookies",
for key, value in cookies.items(): "cookie_count": len(cookies)
if key == "sessionid": }
masked[key] = value[:8] + "..." + value[-4:] if len(value) > 12 else "***" except HTTPException:
else: raise
masked[key] = value[:20] + "..." if len(str(value)) > 20 else value except Exception as e:
return {"cookies": masked, "raw_count": len(cookies)} raise HTTPException(status_code=500, detail=str(e))
except:
pass
return {"cookies": {}, "raw_count": 0} @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}

View file

@ -1,384 +1,396 @@
""" """
Feed API routes with LRU video cache for mobile optimization. Feed API routes with LRU video cache for mobile optimization.
""" """
from fastapi import APIRouter, Query, HTTPException, Request from fastapi import APIRouter, Query, HTTPException, Request
from fastapi.responses import StreamingResponse, FileResponse from fastapi.responses import StreamingResponse, FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import httpx import httpx
import os import os
import json import json
import tempfile import tempfile
import asyncio import asyncio
import hashlib import hashlib
import time import time
import shutil import shutil
from core.playwright_manager import PlaywrightManager from core.playwright_manager import PlaywrightManager
router = APIRouter() router = APIRouter()
# ========== LRU VIDEO CACHE ========== # ========== LRU VIDEO CACHE ==========
CACHE_DIR = os.path.join(tempfile.gettempdir(), "purestream_cache") CACHE_DIR = os.path.join(tempfile.gettempdir(), "purestream_cache")
MAX_CACHE_SIZE_MB = 500 # Limit cache to 500MB MAX_CACHE_SIZE_MB = 500 # Limit cache to 500MB
MAX_CACHE_FILES = 30 # Keep max 30 videos cached MAX_CACHE_FILES = 30 # Keep max 30 videos cached
CACHE_TTL_HOURS = 2 # Videos expire after 2 hours CACHE_TTL_HOURS = 2 # Videos expire after 2 hours
def init_cache(): def init_cache():
"""Initialize cache directory.""" """Initialize cache directory."""
os.makedirs(CACHE_DIR, exist_ok=True) os.makedirs(CACHE_DIR, exist_ok=True)
cleanup_old_cache() cleanup_old_cache()
def get_cache_key(url: str) -> str: def get_cache_key(url: str) -> str:
"""Generate cache key from URL.""" """Generate cache key from URL."""
return hashlib.md5(url.encode()).hexdigest() return hashlib.md5(url.encode()).hexdigest()
def get_cached_path(url: str) -> Optional[str]: def get_cached_path(url: str) -> Optional[str]:
"""Check if video is cached and not expired.""" """Check if video is cached and not expired."""
cache_key = get_cache_key(url) cache_key = get_cache_key(url)
cached_file = os.path.join(CACHE_DIR, f"{cache_key}.mp4") cached_file = os.path.join(CACHE_DIR, f"{cache_key}.mp4")
if os.path.exists(cached_file): if os.path.exists(cached_file):
# Check TTL # Check TTL
file_age_hours = (time.time() - os.path.getmtime(cached_file)) / 3600 file_age_hours = (time.time() - os.path.getmtime(cached_file)) / 3600
if file_age_hours < CACHE_TTL_HOURS: if file_age_hours < CACHE_TTL_HOURS:
# Touch file to update LRU # Touch file to update LRU
os.utime(cached_file, None) os.utime(cached_file, None)
return cached_file return cached_file
else: else:
# Expired, delete # Expired, delete
os.unlink(cached_file) os.unlink(cached_file)
return None return None
def save_to_cache(url: str, source_path: str) -> str: def save_to_cache(url: str, source_path: str) -> str:
"""Save video to cache, return cached path.""" """Save video to cache, return cached path."""
cache_key = get_cache_key(url) cache_key = get_cache_key(url)
cached_file = os.path.join(CACHE_DIR, f"{cache_key}.mp4") cached_file = os.path.join(CACHE_DIR, f"{cache_key}.mp4")
# Copy to cache # Copy to cache
shutil.copy2(source_path, cached_file) shutil.copy2(source_path, cached_file)
# Enforce cache limits # Enforce cache limits
enforce_cache_limits() enforce_cache_limits()
return cached_file return cached_file
def enforce_cache_limits(): def enforce_cache_limits():
"""Remove old files if cache exceeds limits.""" """Remove old files if cache exceeds limits."""
if not os.path.exists(CACHE_DIR): if not os.path.exists(CACHE_DIR):
return return
files = [] files = []
total_size = 0 total_size = 0
for f in os.listdir(CACHE_DIR): for f in os.listdir(CACHE_DIR):
fpath = os.path.join(CACHE_DIR, f) fpath = os.path.join(CACHE_DIR, f)
if os.path.isfile(fpath): if os.path.isfile(fpath):
stat = os.stat(fpath) stat = os.stat(fpath)
files.append((fpath, stat.st_mtime, stat.st_size)) files.append((fpath, stat.st_mtime, stat.st_size))
total_size += stat.st_size total_size += stat.st_size
# Sort by modification time (oldest first) # Sort by modification time (oldest first)
files.sort(key=lambda x: x[1]) files.sort(key=lambda x: x[1])
# Remove oldest until under limits # Remove oldest until under limits
max_bytes = MAX_CACHE_SIZE_MB * 1024 * 1024 max_bytes = MAX_CACHE_SIZE_MB * 1024 * 1024
while (len(files) > MAX_CACHE_FILES or total_size > max_bytes) and files: while (len(files) > MAX_CACHE_FILES or total_size > max_bytes) and files:
oldest = files.pop(0) oldest = files.pop(0)
try: try:
os.unlink(oldest[0]) os.unlink(oldest[0])
total_size -= oldest[2] total_size -= oldest[2]
print(f"CACHE: Removed {oldest[0]} (LRU)") print(f"CACHE: Removed {oldest[0]} (LRU)")
except: except:
pass pass
def cleanup_old_cache(): def cleanup_old_cache():
"""Remove expired files on startup.""" """Remove expired files on startup."""
if not os.path.exists(CACHE_DIR): if not os.path.exists(CACHE_DIR):
return return
now = time.time() now = time.time()
for f in os.listdir(CACHE_DIR): for f in os.listdir(CACHE_DIR):
fpath = os.path.join(CACHE_DIR, f) fpath = os.path.join(CACHE_DIR, f)
if os.path.isfile(fpath): if os.path.isfile(fpath):
age_hours = (now - os.path.getmtime(fpath)) / 3600 age_hours = (now - os.path.getmtime(fpath)) / 3600
if age_hours > CACHE_TTL_HOURS: if age_hours > CACHE_TTL_HOURS:
try: try:
os.unlink(fpath) os.unlink(fpath)
print(f"CACHE: Expired {f}") print(f"CACHE: Expired {f}")
except: except:
pass pass
def get_cache_stats() -> dict: def get_cache_stats() -> dict:
"""Get cache statistics.""" """Get cache statistics."""
if not os.path.exists(CACHE_DIR): if not os.path.exists(CACHE_DIR):
return {"files": 0, "size_mb": 0} return {"files": 0, "size_mb": 0}
total = 0 total = 0
count = 0 count = 0
for f in os.listdir(CACHE_DIR): for f in os.listdir(CACHE_DIR):
fpath = os.path.join(CACHE_DIR, f) fpath = os.path.join(CACHE_DIR, f)
if os.path.isfile(fpath): if os.path.isfile(fpath):
total += os.path.getsize(fpath) total += os.path.getsize(fpath)
count += 1 count += 1
return {"files": count, "size_mb": round(total / 1024 / 1024, 2)} return {"files": count, "size_mb": round(total / 1024 / 1024, 2)}
# Initialize cache on module load # Initialize cache on module load
init_cache() init_cache()
# ========== API ROUTES ========== # ========== API ROUTES ==========
class FeedRequest(BaseModel): from typing import Optional, Any, Union, List, Dict
"""Request body for feed endpoint with optional JSON credentials."""
credentials: Optional[dict] = None 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.""" @router.post("")
cookies = None async def get_feed(request: FeedRequest = None):
user_agent = None """Get TikTok feed using network interception."""
cookies = None
if request and request.credentials: user_agent = None
cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials)
print(f"DEBUG: Using provided credentials ({len(cookies)} cookies)") if request and request.credentials:
cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials)
try: print(f"DEBUG: Using provided credentials ({len(cookies)} cookies)")
videos = await PlaywrightManager.intercept_feed(cookies, user_agent)
return videos try:
except Exception as e: videos = await PlaywrightManager.intercept_feed(cookies, user_agent)
print(f"DEBUG: Feed error: {e}") return videos
raise HTTPException(status_code=500, detail=str(e)) 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):
"""Simple GET endpoint to fetch feed using stored credentials.""" @router.get("")
try: async def get_feed_simple(fast: bool = False, skip_cache: bool = False):
# Fast mode = 0 scrolls (just initial batch), Normal = 5 scrolls """Simple GET endpoint to fetch feed using stored credentials.
scroll_count = 0 if fast else 5
videos = await PlaywrightManager.intercept_feed(scroll_count=scroll_count) Args:
return videos fast: If True, only get initial batch (0 scrolls). If False, scroll 5 times.
except Exception as e: skip_cache: If True, always fetch fresh videos (for infinite scroll).
print(f"DEBUG: Feed error: {e}") """
raise HTTPException(status_code=500, detail=str(e)) try:
# Fast mode = 0 scrolls (just initial batch), Normal = 5 scrolls
scroll_count = 0 if fast else 5
@router.get("/cache-stats")
async def cache_stats(): # When skipping cache for infinite scroll, do more scrolling to get different videos
"""Get video cache statistics.""" if skip_cache:
return get_cache_stats() scroll_count = 8 # More scrolling to get fresh content
videos = await PlaywrightManager.intercept_feed(scroll_count=scroll_count)
@router.delete("/cache") return videos
async def clear_cache(): except Exception as e:
"""Clear video cache.""" print(f"DEBUG: Feed error: {e}")
if os.path.exists(CACHE_DIR): raise HTTPException(status_code=500, detail=str(e))
shutil.rmtree(CACHE_DIR, ignore_errors=True)
os.makedirs(CACHE_DIR, exist_ok=True)
return {"status": "cleared"} @router.get("/cache-stats")
async def cache_stats():
"""Get video cache statistics."""
@router.get("/proxy") return get_cache_stats()
async def proxy_video(
url: str = Query(..., description="The TikTok video URL to proxy"),
download: bool = Query(False, description="Force download with attachment header") @router.delete("/cache")
): async def clear_cache():
""" """Clear video cache."""
Proxy video with LRU caching for mobile optimization. if os.path.exists(CACHE_DIR):
OPTIMIZED: No server-side transcoding - client handles decoding. shutil.rmtree(CACHE_DIR, ignore_errors=True)
This reduces server CPU to ~0% during video playback. os.makedirs(CACHE_DIR, exist_ok=True)
""" return {"status": "cleared"}
import yt_dlp
import re
@router.get("/proxy")
# Check cache first async def proxy_video(
cached_path = get_cached_path(url) url: str = Query(..., description="The TikTok video URL to proxy"),
if cached_path: download: bool = Query(False, description="Force download with attachment header")
print(f"CACHE HIT: {url[:50]}...") ):
"""
response_headers = { Proxy video with LRU caching for mobile optimization.
"Accept-Ranges": "bytes", OPTIMIZED: No server-side transcoding - client handles decoding.
"Cache-Control": "public, max-age=3600", This reduces server CPU to ~0% during video playback.
} """
if download: import yt_dlp
video_id_match = re.search(r'/video/(\d+)', url) import re
video_id = video_id_match.group(1) if video_id_match else "tiktok_video"
response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"' # Check cache first
cached_path = get_cached_path(url)
return FileResponse( if cached_path:
cached_path, print(f"CACHE HIT: {url[:50]}...")
media_type="video/mp4",
headers=response_headers response_headers = {
) "Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=3600",
print(f"CACHE MISS: {url[:50]}... (downloading)") }
if download:
# Load stored credentials video_id_match = re.search(r'/video/(\d+)', url)
cookies, user_agent = PlaywrightManager.load_stored_credentials() video_id = video_id_match.group(1) if video_id_match else "tiktok_video"
response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"'
# Create temp file for download
temp_dir = tempfile.mkdtemp() return FileResponse(
output_template = os.path.join(temp_dir, "video.%(ext)s") cached_path,
media_type="video/mp4",
# Create cookies file for yt-dlp headers=response_headers
cookie_file_path = None )
if cookies:
cookie_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) print(f"CACHE MISS: {url[:50]}... (downloading)")
cookie_file.write("# Netscape HTTP Cookie File\n")
for c in cookies: # Load stored credentials
cookie_file.write(f".tiktok.com\tTRUE\t/\tFALSE\t0\t{c['name']}\t{c['value']}\n") cookies, user_agent = PlaywrightManager.load_stored_credentials()
cookie_file.close()
cookie_file_path = cookie_file.name # Create temp file for download
temp_dir = tempfile.mkdtemp()
# Download best quality - NO TRANSCODING (let client decode) output_template = os.path.join(temp_dir, "video.%(ext)s")
# Prefer H.264 when available, but accept any codec
ydl_opts = { # Create cookies file for yt-dlp
'format': 'best[ext=mp4][vcodec^=avc]/best[ext=mp4]/best', cookie_file_path = None
'outtmpl': output_template, if cookies:
'quiet': True, cookie_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False)
'no_warnings': True, cookie_file.write("# Netscape HTTP Cookie File\n")
'http_headers': { for c in cookies:
'User-Agent': user_agent, cookie_file.write(f".tiktok.com\tTRUE\t/\tFALSE\t0\t{c['name']}\t{c['value']}\n")
'Referer': 'https://www.tiktok.com/' cookie_file.close()
} cookie_file_path = cookie_file.name
}
# Download best quality - NO TRANSCODING (let client decode)
if cookie_file_path: # Prefer H.264 when available, but accept any codec
ydl_opts['cookiefile'] = cookie_file_path ydl_opts = {
'format': 'best[ext=mp4][vcodec^=avc]/best[ext=mp4]/best',
video_path = None 'outtmpl': output_template,
video_codec = "unknown" 'quiet': True,
'no_warnings': True,
try: 'http_headers': {
loop = asyncio.get_event_loop() 'User-Agent': user_agent,
'Referer': 'https://www.tiktok.com/'
def download_video(): }
with yt_dlp.YoutubeDL(ydl_opts) as ydl: }
info = ydl.extract_info(url, download=True)
ext = info.get('ext', 'mp4') if cookie_file_path:
vcodec = info.get('vcodec', 'unknown') or 'unknown' ydl_opts['cookiefile'] = cookie_file_path
return os.path.join(temp_dir, f"video.{ext}"), vcodec
video_path = None
video_path, video_codec = await loop.run_in_executor(None, download_video) video_codec = "unknown"
if not os.path.exists(video_path): try:
raise Exception("Video file not created") loop = asyncio.get_event_loop()
print(f"Downloaded codec: {video_codec} (no transcoding - client will decode)") def download_video():
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
# Save to cache directly - NO TRANSCODING info = ydl.extract_info(url, download=True)
cached_path = save_to_cache(url, video_path) ext = info.get('ext', 'mp4')
stats = get_cache_stats() vcodec = info.get('vcodec', 'unknown') or 'unknown'
print(f"CACHED: {url[:50]}... ({stats['files']} files, {stats['size_mb']}MB total)") return os.path.join(temp_dir, f"video.{ext}"), vcodec
except Exception as e: video_path, video_codec = await loop.run_in_executor(None, download_video)
print(f"DEBUG: yt-dlp download failed: {e}")
# Cleanup if not os.path.exists(video_path):
if cookie_file_path and os.path.exists(cookie_file_path): raise Exception("Video file not created")
os.unlink(cookie_file_path)
if os.path.exists(temp_dir): print(f"Downloaded codec: {video_codec} (no transcoding - client will decode)")
shutil.rmtree(temp_dir, ignore_errors=True)
raise HTTPException(status_code=500, detail=f"Could not download video: {e}") # Save to cache directly - NO TRANSCODING
cached_path = save_to_cache(url, video_path)
# Cleanup temp (cached file is separate) stats = get_cache_stats()
if cookie_file_path and os.path.exists(cookie_file_path): print(f"CACHED: {url[:50]}... ({stats['files']} files, {stats['size_mb']}MB total)")
os.unlink(cookie_file_path)
shutil.rmtree(temp_dir, ignore_errors=True) except Exception as e:
print(f"DEBUG: yt-dlp download failed: {e}")
# Return from cache with codec info header # Cleanup
response_headers = { if cookie_file_path and os.path.exists(cookie_file_path):
"Accept-Ranges": "bytes", os.unlink(cookie_file_path)
"Cache-Control": "public, max-age=3600", if os.path.exists(temp_dir):
"X-Video-Codec": video_codec, # Let client know the codec shutil.rmtree(temp_dir, ignore_errors=True)
} raise HTTPException(status_code=500, detail=f"Could not download video: {e}")
if download:
video_id_match = re.search(r'/video/(\d+)', url) # Cleanup temp (cached file is separate)
video_id = video_id_match.group(1) if video_id_match else "tiktok_video" if cookie_file_path and os.path.exists(cookie_file_path):
response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"' os.unlink(cookie_file_path)
shutil.rmtree(temp_dir, ignore_errors=True)
return FileResponse(
cached_path, # Return from cache with codec info header
media_type="video/mp4", response_headers = {
headers=response_headers "Accept-Ranges": "bytes",
) "Cache-Control": "public, max-age=3600",
"X-Video-Codec": video_codec, # Let client know the codec
}
if download:
@router.get("/thin-proxy") video_id_match = re.search(r'/video/(\d+)', url)
async def thin_proxy_video( video_id = video_id_match.group(1) if video_id_match else "tiktok_video"
request: Request, response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"'
cdn_url: str = Query(..., description="Direct TikTok CDN URL")
): return FileResponse(
""" cached_path,
Thin proxy - just forwards CDN requests with proper headers. media_type="video/mp4",
Supports Range requests for buffering and seeking. headers=response_headers
""" )
# Load stored credentials for headers
cookies, user_agent = PlaywrightManager.load_stored_credentials()
@router.get("/thin-proxy")
headers = { async def thin_proxy_video(
"User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT, request: Request,
"Referer": "https://www.tiktok.com/", cdn_url: str = Query(..., description="Direct TikTok CDN URL")
"Accept": "*/*", ):
"Accept-Language": "en-US,en;q=0.9", """
"Origin": "https://www.tiktok.com", Thin proxy - just forwards CDN requests with proper headers.
} Supports Range requests for buffering and seeking.
"""
# Add cookies as header if available
if cookies: # Load stored credentials for headers
cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies]) cookies, user_agent = PlaywrightManager.load_stored_credentials()
headers["Cookie"] = cookie_str
headers = {
# Forward Range header if present "User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT,
client_range = request.headers.get("Range") "Referer": "https://www.tiktok.com/",
if client_range: "Accept": "*/*",
headers["Range"] = client_range "Accept-Language": "en-US,en;q=0.9",
"Origin": "https://www.tiktok.com",
try: }
# Create client outside stream generator to access response headers first
client = httpx.AsyncClient(timeout=60.0, follow_redirects=True) # Add cookies as header if available
# We need to manually close this client later or use it in the generator if cookies:
cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
# Start the request to get headers (without reading body yet) headers["Cookie"] = cookie_str
req = client.build_request("GET", cdn_url, headers=headers)
r = await client.send(req, stream=True) # Forward Range header if present
client_range = request.headers.get("Range")
async def stream_from_cdn(): if client_range:
try: headers["Range"] = client_range
async for chunk in r.aiter_bytes(chunk_size=64 * 1024):
yield chunk try:
finally: # Create client outside stream generator to access response headers first
await r.aclose() client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
await client.aclose() # We need to manually close this client later or use it in the generator
response_headers = { # Start the request to get headers (without reading body yet)
"Accept-Ranges": "bytes", req = client.build_request("GET", cdn_url, headers=headers)
"Cache-Control": "public, max-age=3600", r = await client.send(req, stream=True)
"Content-Type": r.headers.get("Content-Type", "video/mp4"),
} async def stream_from_cdn():
try:
# Forward Content-Length and Content-Range async for chunk in r.aiter_bytes(chunk_size=64 * 1024):
if "Content-Length" in r.headers: yield chunk
response_headers["Content-Length"] = r.headers["Content-Length"] finally:
if "Content-Range" in r.headers: await r.aclose()
response_headers["Content-Range"] = r.headers["Content-Range"] await client.aclose()
status_code = r.status_code response_headers = {
"Accept-Ranges": "bytes",
return StreamingResponse( "Cache-Control": "public, max-age=3600",
stream_from_cdn(), "Content-Type": r.headers.get("Content-Type", "video/mp4"),
status_code=status_code, }
media_type="video/mp4",
headers=response_headers # Forward Content-Length and Content-Range
) if "Content-Length" in r.headers:
response_headers["Content-Length"] = r.headers["Content-Length"]
except Exception as e: if "Content-Range" in r.headers:
print(f"Thin proxy error: {e}") response_headers["Content-Range"] = r.headers["Content-Range"]
# Ensure cleanup if possible
raise HTTPException(status_code=500, detail=str(e)) 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))

View file

@ -1,65 +1,98 @@
""" """
Following API routes - manage followed creators. Following API routes - manage followed creators.
""" """
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
import os import os
import json import json
router = APIRouter() router = APIRouter()
FOLLOWING_FILE = "following.json" FOLLOWING_FILE = "following.json"
def load_following() -> list: def load_following() -> list:
"""Load list of followed creators.""" """Load list of followed creators."""
if os.path.exists(FOLLOWING_FILE): if os.path.exists(FOLLOWING_FILE):
try: try:
with open(FOLLOWING_FILE, 'r') as f: with open(FOLLOWING_FILE, 'r') as f:
return json.load(f) return json.load(f)
except: except:
return [] return []
return [] return []
def save_following(following: list): def save_following(following: list):
"""Save list of followed creators.""" """Save list of followed creators."""
with open(FOLLOWING_FILE, 'w') as f: with open(FOLLOWING_FILE, 'w') as f:
json.dump(following, f, indent=2) json.dump(following, f, indent=2)
class FollowRequest(BaseModel): class FollowRequest(BaseModel):
username: str username: str
@router.get("") @router.get("")
async def get_following(): async def get_following():
"""Get list of followed creators.""" """Get list of followed creators."""
return load_following() return load_following()
@router.post("") @router.post("")
async def add_following(request: FollowRequest): async def add_following(request: FollowRequest):
"""Add a creator to following list.""" """Add a creator to following list."""
username = request.username.lstrip('@') username = request.username.lstrip('@')
following = load_following() following = load_following()
if username not in following: if username not in following:
following.append(username) following.append(username)
save_following(following) save_following(following)
return {"status": "success", "following": following} return {"status": "success", "following": following}
@router.delete("/{username}") @router.delete("/{username}")
async def remove_following(username: str): async def remove_following(username: str):
"""Remove a creator from following list.""" """Remove a creator from following list."""
username = username.lstrip('@') username = username.lstrip('@')
following = load_following() following = load_following()
if username in following: if username in following:
following.remove(username) following.remove(username)
save_following(following) save_following(following)
return {"status": "success", "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

View file

@ -1,279 +1,280 @@
""" """
User profile API - fetch real TikTok user data. User profile API - fetch real TikTok user data.
""" """
from fastapi import APIRouter, Query, HTTPException from fastapi import APIRouter, Query, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
import httpx import httpx
import asyncio import asyncio
from core.playwright_manager import PlaywrightManager from core.playwright_manager import PlaywrightManager
router = APIRouter() router = APIRouter()
class UserProfile(BaseModel): class UserProfile(BaseModel):
"""TikTok user profile data.""" """TikTok user profile data."""
username: str username: str
nickname: Optional[str] = None nickname: Optional[str] = None
avatar: Optional[str] = None avatar: Optional[str] = None
bio: Optional[str] = None bio: Optional[str] = None
followers: Optional[int] = None followers: Optional[int] = None
following: Optional[int] = None following: Optional[int] = None
likes: Optional[int] = None likes: Optional[int] = None
verified: bool = False verified: bool = False
@router.get("/profile") @router.get("/profile")
async def get_user_profile(username: str = Query(..., description="TikTok username (without @)")): async def get_user_profile(username: str = Query(..., description="TikTok username (without @)")):
""" """
Fetch real TikTok user profile data. Fetch real TikTok user profile data.
""" """
username = username.replace("@", "") username = username.replace("@", "")
# Load stored credentials # Load stored credentials
cookies, user_agent = PlaywrightManager.load_stored_credentials() cookies, user_agent = PlaywrightManager.load_stored_credentials()
if not cookies: if not cookies:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
# Build cookie header # Build cookie header
cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies]) cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
headers = { headers = {
"User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT, "User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT,
"Referer": "https://www.tiktok.com/", "Referer": "https://www.tiktok.com/",
"Cookie": cookie_str, "Cookie": cookie_str,
"Accept": "application/json", "Accept": "application/json",
} }
# Try to fetch user data from TikTok's internal API # Try to fetch user data from TikTok's internal API
profile_url = f"https://www.tiktok.com/api/user/detail/?uniqueId={username}" profile_url = f"https://www.tiktok.com/api/user/detail/?uniqueId={username}"
try: try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
response = await client.get(profile_url, headers=headers) response = await client.get(profile_url, headers=headers)
if response.status_code != 200: if response.status_code != 200:
# Fallback - return basic info # Fallback - return basic info
return UserProfile(username=username) return UserProfile(username=username)
data = response.json() data = response.json()
user_info = data.get("userInfo", {}) user_info = data.get("userInfo", {})
user = user_info.get("user", {}) user = user_info.get("user", {})
stats = user_info.get("stats", {}) stats = user_info.get("stats", {})
return UserProfile( return UserProfile(
username=username, username=username,
nickname=user.get("nickname"), nickname=user.get("nickname"),
avatar=user.get("avatarLarger") or user.get("avatarMedium"), avatar=user.get("avatarLarger") or user.get("avatarMedium"),
bio=user.get("signature"), bio=user.get("signature"),
followers=stats.get("followerCount"), followers=stats.get("followerCount"),
following=stats.get("followingCount"), following=stats.get("followingCount"),
likes=stats.get("heartCount"), likes=stats.get("heartCount"),
verified=user.get("verified", False) verified=user.get("verified", False)
) )
except Exception as e: except Exception as e:
print(f"Error fetching profile for {username}: {e}") print(f"Error fetching profile for {username}: {e}")
# Return basic fallback # Return basic fallback
return UserProfile(username=username) return UserProfile(username=username)
@router.get("/profiles") @router.get("/profiles")
async def get_multiple_profiles(usernames: str = Query(..., description="Comma-separated usernames")): async def get_multiple_profiles(usernames: str = Query(..., description="Comma-separated usernames")):
""" """
Fetch multiple TikTok user profiles at once. Fetch multiple TikTok user profiles at once.
""" """
username_list = [u.strip().replace("@", "") for u in usernames.split(",") if u.strip()] username_list = [u.strip().replace("@", "") for u in usernames.split(",") if u.strip()]
if len(username_list) > 20: if len(username_list) > 20:
raise HTTPException(status_code=400, detail="Max 20 usernames at once") raise HTTPException(status_code=400, detail="Max 20 usernames at once")
# Fetch all profiles concurrently # Fetch all profiles concurrently
tasks = [get_user_profile(u) for u in username_list] tasks = [get_user_profile(u) for u in username_list]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
profiles = [] profiles = []
for i, result in enumerate(results): for i, result in enumerate(results):
if isinstance(result, Exception): if isinstance(result, Exception):
profiles.append(UserProfile(username=username_list[i])) profiles.append(UserProfile(username=username_list[i]))
else: else:
profiles.append(result) profiles.append(result)
return profiles return profiles
@router.get("/videos") @router.get("/videos")
async def get_user_videos( async def get_user_videos(
username: str = Query(..., description="TikTok username (without @)"), username: str = Query(..., description="TikTok username (without @)"),
limit: int = Query(10, description="Max videos to fetch", ge=1, le=30) limit: int = Query(10, description="Max videos to fetch", ge=1, le=60)
): ):
""" """
Fetch videos from a TikTok user's profile. Fetch videos from a TikTok user's profile.
Uses Playwright to crawl the user's page for reliable results. Uses Playwright to crawl the user's page for reliable results.
""" """
username = username.replace("@", "") username = username.replace("@", "")
# Load stored credentials # Load stored credentials
cookies, user_agent = PlaywrightManager.load_stored_credentials() cookies, user_agent = PlaywrightManager.load_stored_credentials()
if not cookies: if not cookies:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
print(f"Fetching videos for @{username}...") print(f"Fetching videos for @{username}...")
try: try:
videos = await PlaywrightManager.fetch_user_videos(username, cookies, user_agent, limit) videos = await PlaywrightManager.fetch_user_videos(username, cookies, user_agent, limit)
return {"username": username, "videos": videos, "count": len(videos)} return {"username": username, "videos": videos, "count": len(videos)}
except Exception as e: except Exception as e:
print(f"Error fetching videos for {username}: {e}") print(f"Error fetching videos for {username}: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/search") @router.get("/search")
async def search_videos( async def search_videos(
query: str = Query(..., description="Search keyword or hashtag"), query: str = Query(..., description="Search keyword or hashtag"),
limit: int = Query(12, description="Max videos to fetch", ge=1, le=30) 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. 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() # Load stored credentials
cookies, user_agent = PlaywrightManager.load_stored_credentials()
if not cookies:
raise HTTPException(status_code=401, detail="Not authenticated") if not cookies:
raise HTTPException(status_code=401, detail="Not authenticated")
print(f"Searching for: {query}...")
print(f"Searching for: {query} (limit={limit}, cursor={cursor})...")
try:
videos = await PlaywrightManager.search_videos(query, cookies, user_agent, limit) try:
return {"query": query, "videos": videos, "count": len(videos)} videos = await PlaywrightManager.search_videos(query, cookies, user_agent, limit, cursor)
except Exception as e: return {"query": query, "videos": videos, "count": len(videos), "cursor": cursor + len(videos)}
print(f"Error searching for {query}: {e}") except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) print(f"Error searching for {query}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Cache for suggested accounts
_suggested_cache = { # Cache for suggested accounts
"accounts": [], _suggested_cache = {
"updated_at": 0 "accounts": [],
} "updated_at": 0
CACHE_TTL = 3600 # 1 hour cache }
CACHE_TTL = 3600 # 1 hour cache
@router.get("/suggested")
async def get_suggested_accounts( @router.get("/suggested")
limit: int = Query(50, description="Max accounts to return", ge=10, le=100) 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. Fetch trending/suggested Vietnamese TikTok creators.
""" Uses TikTok's discover API and caches results for 1 hour.
import time """
import time
# Check cache
if _suggested_cache["accounts"] and (time.time() - _suggested_cache["updated_at"]) < CACHE_TTL: # Check cache
print("Returning cached suggested accounts") if _suggested_cache["accounts"] and (time.time() - _suggested_cache["updated_at"]) < CACHE_TTL:
return {"accounts": _suggested_cache["accounts"][:limit], "cached": True} print("Returning cached suggested accounts")
return {"accounts": _suggested_cache["accounts"][:limit], "cached": True}
# Load stored credentials
cookies, user_agent = PlaywrightManager.load_stored_credentials() # Load stored credentials
cookies, user_agent = PlaywrightManager.load_stored_credentials()
if not cookies:
# Return fallback static list if not authenticated if not cookies:
return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True} # Return fallback static list if not authenticated
return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True}
print("Fetching fresh suggested accounts from TikTok...")
print("Fetching fresh suggested accounts from TikTok...")
try:
accounts = await PlaywrightManager.fetch_suggested_accounts(cookies, user_agent, limit) 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 if accounts and len(accounts) >= 5: # Need at least 5 accounts from dynamic fetch
_suggested_cache["updated_at"] = time.time() _suggested_cache["accounts"] = accounts
return {"accounts": accounts[:limit], "cached": False} _suggested_cache["updated_at"] = time.time()
else: return {"accounts": accounts[:limit], "cached": False}
# Fallback: fetch actual profile data with avatars for static list else:
print("Dynamic fetch failed, fetching profile data for static accounts...") # Fallback: fetch actual profile data with avatars for static list
fallback_list = get_fallback_accounts()[:min(limit, 20)] # Limit to 20 for speed print("Dynamic fetch failed, fetching profile data for static accounts...")
return await fetch_profiles_with_avatars(fallback_list, cookies, user_agent) 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}") except Exception as e:
return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True} 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.""" 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])
cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
headers = {
"User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT, headers = {
"Referer": "https://www.tiktok.com/", "User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT,
"Cookie": cookie_str, "Referer": "https://www.tiktok.com/",
"Accept": "application/json", "Cookie": cookie_str,
} "Accept": "application/json",
}
enriched = []
enriched = []
async with httpx.AsyncClient(timeout=10.0) as client:
for acc in accounts: async with httpx.AsyncClient(timeout=10.0) as client:
try: for acc in accounts:
url = f"https://www.tiktok.com/api/user/detail/?uniqueId={acc['username']}" try:
res = await client.get(url, headers=headers) 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() if res.status_code == 200:
user = data.get("userInfo", {}).get("user", {}) data = res.json()
stats = data.get("userInfo", {}).get("stats", {}) user = data.get("userInfo", {}).get("user", {})
stats = data.get("userInfo", {}).get("stats", {})
if user:
enriched.append({ if user:
"username": acc["username"], enriched.append({
"nickname": user.get("nickname") or acc.get("nickname", acc["username"]), "username": acc["username"],
"avatar": user.get("avatarThumb") or user.get("avatarMedium"), "nickname": user.get("nickname") or acc.get("nickname", acc["username"]),
"followers": stats.get("followerCount", 0), "avatar": user.get("avatarThumb") or user.get("avatarMedium"),
"verified": user.get("verified", False), "followers": stats.get("followerCount", 0),
"region": "VN" "verified": user.get("verified", False),
}) "region": "VN"
continue })
continue
except Exception as e:
print(f"Error fetching profile for {acc['username']}: {e}") except Exception as e:
print(f"Error fetching profile for {acc['username']}: {e}")
# Fallback: use original data without avatar
enriched.append(acc) # Fallback: use original data without avatar
enriched.append(acc)
return {"accounts": enriched, "cached": False, "enriched": True}
return {"accounts": enriched, "cached": False, "enriched": True}
def get_fallback_accounts():
"""Static fallback list of popular Vietnamese TikTokers (verified usernames).""" def get_fallback_accounts():
return [ """Static fallback list of popular Vietnamese TikTokers (verified usernames)."""
# Verified Vietnamese TikTok accounts return [
{"username": "cciinnn", "nickname": "👑 CiiN (Bùi Thảo Ly)", "region": "VN"}, # Verified Vietnamese TikTok accounts
{"username": "hoaa.hanassii", "nickname": "💃 Hoa Hanassii", "region": "VN"}, {"username": "cciinnn", "nickname": "👑 CiiN (Bùi Thảo Ly)", "region": "VN"},
{"username": "lebong95", "nickname": "💪 Lê Bống", "region": "VN"}, {"username": "hoaa.hanassii", "nickname": "💃 Hoa Hanassii", "region": "VN"},
{"username": "tieu_hy26", "nickname": "👰 Tiểu Hý", "region": "VN"}, {"username": "lebong95", "nickname": "💪 Lê Bống", "region": "VN"},
{"username": "hieuthuhai2222", "nickname": "🎧 HIEUTHUHAI", "region": "VN"}, {"username": "tieu_hy26", "nickname": "👰 Tiểu Hý", "region": "VN"},
{"username": "mtp.fan", "nickname": "🎤 Sơn Tùng M-TP", "region": "VN"}, {"username": "hieuthuhai2222", "nickname": "🎧 HIEUTHUHAI", "region": "VN"},
{"username": "changmakeup", "nickname": "💄 Changmakeup", "region": "VN"}, {"username": "mtp.fan", "nickname": "🎤 Sơn Tùng M-TP", "region": "VN"},
{"username": "theanh28entertainment", "nickname": "🎬 Theanh28", "region": "VN"}, {"username": "changmakeup", "nickname": "💄 Changmakeup", "region": "VN"},
{"username": "linhbarbie", "nickname": "👗 Linh Barbie", "region": "VN"}, {"username": "theanh28entertainment", "nickname": "🎬 Theanh28", "region": "VN"},
{"username": "phuonglykchau", "nickname": "✨ Phương Ly", "region": "VN"}, {"username": "linhbarbie", "nickname": "👗 Linh Barbie", "region": "VN"},
{"username": "phimtieutrang", "nickname": "📺 Tiểu Trang", "region": "VN"}, {"username": "phuonglykchau", "nickname": "✨ Phương Ly", "region": "VN"},
{"username": "nhunguyendy", "nickname": "💕 Như Nguyễn", "region": "VN"}, {"username": "phimtieutrang", "nickname": "📺 Tiểu Trang", "region": "VN"},
{"username": "trucnhantv", "nickname": "🎤 Trúc Nhân", "region": "VN"}, {"username": "nhunguyendy", "nickname": "💕 Như Nguyễn", "region": "VN"},
{"username": "justvietanh", "nickname": "😄 Just Việt Anh", "region": "VN"}, {"username": "trucnhantv", "nickname": "🎤 Trúc Nhân", "region": "VN"},
{"username": "minngu.official", "nickname": "🌸 Min NGU", "region": "VN"}, {"username": "justvietanh", "nickname": "😄 Just Việt Anh", "region": "VN"},
{"username": "quangdangofficial", "nickname": "🕺 Quang Đăng", "region": "VN"}, {"username": "minngu.official", "nickname": "🌸 Min NGU", "region": "VN"},
{"username": "minhhangofficial", "nickname": "👑 Minh Hằng", "region": "VN"}, {"username": "quangdangofficial", "nickname": "🕺 Quang Đăng", "region": "VN"},
{"username": "dungntt", "nickname": "🎭 Dũng NTT", "region": "VN"}, {"username": "minhhangofficial", "nickname": "👑 Minh Hằng", "region": "VN"},
{"username": "chipu88", "nickname": "🎤 Chi Pu", "region": "VN"}, {"username": "dungntt", "nickname": "🎭 Dũng NTT", "region": "VN"},
{"username": "kaydinh", "nickname": "🎵 Kay Dinh", "region": "VN"}, {"username": "chipu88", "nickname": "🎤 Chi Pu", "region": "VN"},
] {"username": "kaydinh", "nickname": "🎵 Kay Dinh", "region": "VN"},
]

File diff suppressed because it is too large Load diff

View file

@ -1,58 +1,82 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from api.routes import auth, feed, download, following, config, user from api.routes import auth, feed, download, following, config, user
import sys
@asynccontextmanager import asyncio
async def lifespan(app: FastAPI):
"""Startup and shutdown events.""" # Force Proactor on Windows for Playwright
print("🚀 Starting PureStream API (Network Interception Mode)...") if sys.platform == "win32":
yield asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
print("👋 Shutting down PureStream API...")
@asynccontextmanager
app = FastAPI(title="PureStream API", version="2.0.0", lifespan=lifespan) async def lifespan(app: FastAPI):
"""Startup and shutdown events."""
# CORS middleware print("🚀 Starting PureStream API (Network Interception Mode)...")
app.add_middleware( import asyncio
CORSMiddleware, try:
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", "*"], loop = asyncio.get_running_loop()
allow_credentials=True, print(f"DEBUG: Running event loop: {type(loop)}")
allow_methods=["*"], except Exception as e:
allow_headers=["*"], print(f"DEBUG: Could not get running loop: {e}")
) yield
print("👋 Shutting down PureStream API...")
# Include routers
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) import asyncio
app.include_router(feed.router, prefix="/api/feed", tags=["Feed"]) import sys
app.include_router(download.router, prefix="/api/download", tags=["Download"])
app.include_router(following.router, prefix="/api/following", tags=["Following"]) app = FastAPI(title="PureStream API", version="2.0.0", lifespan=lifespan)
app.include_router(config.router, prefix="/api/config", tags=["Config"])
app.include_router(user.router, prefix="/api/user", tags=["User"]) if __name__ == "__main__":
if sys.platform == "win32":
@app.get("/health") try:
async def health_check(): loop = asyncio.get_event_loop()
return {"status": "ok"} print(f"DEBUG: Current event loop: {type(loop)}")
except:
# Serve static frontend files in production print("DEBUG: No event loop yet")
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
if FRONTEND_DIR.exists():
# Mount static assets # CORS middleware
app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets") app.add_middleware(
CORSMiddleware,
# Serve index.html for all non-API routes (SPA fallback) allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", "*"],
@app.get("/{full_path:path}") allow_credentials=True,
async def serve_spa(full_path: str): allow_methods=["*"],
# If requesting a file that exists, serve it allow_headers=["*"],
file_path = FRONTEND_DIR / full_path )
if file_path.is_file():
return FileResponse(file_path) # Include routers
# Otherwise serve index.html for SPA routing app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
return FileResponse(FRONTEND_DIR / "index.html") app.include_router(feed.router, prefix="/api/feed", tags=["Feed"])
app.include_router(download.router, prefix="/api/download", tags=["Download"])
if __name__ == "__main__": app.include_router(following.router, prefix="/api/following", tags=["Following"])
import uvicorn app.include_router(config.router, prefix="/api/config", tags=["Config"])
uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=True) 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")

25
backend/run_server.py Normal file
View file

@ -0,0 +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")

312
cookies.json Normal file
View file

@ -0,0 +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"
}
]

View file

@ -81,7 +81,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@ -725,9 +724,9 @@
} }
}, },
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.9.0", "version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1038,9 +1037,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
"integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1052,9 +1051,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
"integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1066,9 +1065,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
"integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==", "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1080,9 +1079,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
"integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1094,9 +1093,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
"integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1108,9 +1107,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
"integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1122,9 +1121,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
"integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1136,9 +1135,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
"integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1150,9 +1149,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
"integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1164,9 +1163,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
"integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1178,9 +1177,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
"integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -1192,9 +1191,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
"integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -1206,9 +1205,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
"integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -1220,9 +1219,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
"integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -1234,9 +1233,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
"integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -1248,9 +1247,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
"integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1262,9 +1261,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
"integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1276,9 +1275,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
"integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1290,9 +1289,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
"integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1304,9 +1303,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
"integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -1318,9 +1317,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
"integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1332,9 +1331,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
"integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1410,7 +1409,6 @@
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@ -1428,7 +1426,6 @@
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -1445,20 +1442,20 @@
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.50.0", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz",
"integrity": "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==", "integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/scope-manager": "8.51.0",
"@typescript-eslint/type-utils": "8.50.0", "@typescript-eslint/type-utils": "8.51.0",
"@typescript-eslint/utils": "8.50.0", "@typescript-eslint/utils": "8.51.0",
"@typescript-eslint/visitor-keys": "8.50.0", "@typescript-eslint/visitor-keys": "8.51.0",
"ignore": "^7.0.0", "ignore": "^7.0.0",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"ts-api-utils": "^2.1.0" "ts-api-utils": "^2.2.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1468,7 +1465,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.50.0", "@typescript-eslint/parser": "^8.51.0",
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
@ -1484,17 +1481,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.50.0", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.51.0.tgz",
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/scope-manager": "8.51.0",
"@typescript-eslint/types": "8.50.0", "@typescript-eslint/types": "8.51.0",
"@typescript-eslint/typescript-estree": "8.50.0", "@typescript-eslint/typescript-estree": "8.51.0",
"@typescript-eslint/visitor-keys": "8.50.0", "@typescript-eslint/visitor-keys": "8.51.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -1510,14 +1506,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.50.0", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz",
"integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==", "integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.50.0", "@typescript-eslint/tsconfig-utils": "^8.51.0",
"@typescript-eslint/types": "^8.50.0", "@typescript-eslint/types": "^8.51.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -1532,14 +1528,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.50.0", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz",
"integrity": "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==", "integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.50.0", "@typescript-eslint/types": "8.51.0",
"@typescript-eslint/visitor-keys": "8.50.0" "@typescript-eslint/visitor-keys": "8.51.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1550,9 +1546,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.50.0", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz",
"integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==", "integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1567,17 +1563,17 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.50.0", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz",
"integrity": "sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==", "integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.50.0", "@typescript-eslint/types": "8.51.0",
"@typescript-eslint/typescript-estree": "8.50.0", "@typescript-eslint/typescript-estree": "8.51.0",
"@typescript-eslint/utils": "8.50.0", "@typescript-eslint/utils": "8.51.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^2.1.0" "ts-api-utils": "^2.2.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1592,9 +1588,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.50.0", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz",
"integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==", "integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1606,21 +1602,21 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.50.0", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz",
"integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==", "integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.50.0", "@typescript-eslint/project-service": "8.51.0",
"@typescript-eslint/tsconfig-utils": "8.50.0", "@typescript-eslint/tsconfig-utils": "8.51.0",
"@typescript-eslint/types": "8.50.0", "@typescript-eslint/types": "8.51.0",
"@typescript-eslint/visitor-keys": "8.50.0", "@typescript-eslint/visitor-keys": "8.51.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"minimatch": "^9.0.4", "minimatch": "^9.0.4",
"semver": "^7.6.0", "semver": "^7.6.0",
"tinyglobby": "^0.2.15", "tinyglobby": "^0.2.15",
"ts-api-utils": "^2.1.0" "ts-api-utils": "^2.2.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1673,16 +1669,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.50.0", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz",
"integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==", "integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.7.0", "@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/scope-manager": "8.51.0",
"@typescript-eslint/types": "8.50.0", "@typescript-eslint/types": "8.51.0",
"@typescript-eslint/typescript-estree": "8.50.0" "@typescript-eslint/typescript-estree": "8.51.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1697,13 +1693,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.50.0", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz",
"integrity": "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==", "integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.50.0", "@typescript-eslint/types": "8.51.0",
"eslint-visitor-keys": "^4.2.1" "eslint-visitor-keys": "^4.2.1"
}, },
"engines": { "engines": {
@ -1741,7 +1737,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -1898,9 +1893,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.9.9", "version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.9.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
"integrity": "sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg==", "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@ -1964,7 +1959,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -2013,9 +2007,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001760", "version": "1.0.30001762",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
"integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -2370,7 +2364,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -2497,9 +2490,9 @@
} }
}, },
"node_modules/esquery": { "node_modules/esquery": {
"version": "1.6.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@ -2594,9 +2587,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.19.1", "version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@ -3014,7 +3007,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@ -3515,7 +3507,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@ -3711,7 +3702,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@ -3724,7 +3714,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@ -3841,9 +3830,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.53.5", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
"integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -3857,28 +3846,28 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.53.5", "@rollup/rollup-android-arm-eabi": "4.54.0",
"@rollup/rollup-android-arm64": "4.53.5", "@rollup/rollup-android-arm64": "4.54.0",
"@rollup/rollup-darwin-arm64": "4.53.5", "@rollup/rollup-darwin-arm64": "4.54.0",
"@rollup/rollup-darwin-x64": "4.53.5", "@rollup/rollup-darwin-x64": "4.54.0",
"@rollup/rollup-freebsd-arm64": "4.53.5", "@rollup/rollup-freebsd-arm64": "4.54.0",
"@rollup/rollup-freebsd-x64": "4.53.5", "@rollup/rollup-freebsd-x64": "4.54.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.53.5", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
"@rollup/rollup-linux-arm-musleabihf": "4.53.5", "@rollup/rollup-linux-arm-musleabihf": "4.54.0",
"@rollup/rollup-linux-arm64-gnu": "4.53.5", "@rollup/rollup-linux-arm64-gnu": "4.54.0",
"@rollup/rollup-linux-arm64-musl": "4.53.5", "@rollup/rollup-linux-arm64-musl": "4.54.0",
"@rollup/rollup-linux-loong64-gnu": "4.53.5", "@rollup/rollup-linux-loong64-gnu": "4.54.0",
"@rollup/rollup-linux-ppc64-gnu": "4.53.5", "@rollup/rollup-linux-ppc64-gnu": "4.54.0",
"@rollup/rollup-linux-riscv64-gnu": "4.53.5", "@rollup/rollup-linux-riscv64-gnu": "4.54.0",
"@rollup/rollup-linux-riscv64-musl": "4.53.5", "@rollup/rollup-linux-riscv64-musl": "4.54.0",
"@rollup/rollup-linux-s390x-gnu": "4.53.5", "@rollup/rollup-linux-s390x-gnu": "4.54.0",
"@rollup/rollup-linux-x64-gnu": "4.53.5", "@rollup/rollup-linux-x64-gnu": "4.54.0",
"@rollup/rollup-linux-x64-musl": "4.53.5", "@rollup/rollup-linux-x64-musl": "4.54.0",
"@rollup/rollup-openharmony-arm64": "4.53.5", "@rollup/rollup-openharmony-arm64": "4.54.0",
"@rollup/rollup-win32-arm64-msvc": "4.53.5", "@rollup/rollup-win32-arm64-msvc": "4.54.0",
"@rollup/rollup-win32-ia32-msvc": "4.53.5", "@rollup/rollup-win32-ia32-msvc": "4.54.0",
"@rollup/rollup-win32-x64-gnu": "4.53.5", "@rollup/rollup-win32-x64-gnu": "4.54.0",
"@rollup/rollup-win32-x64-msvc": "4.53.5", "@rollup/rollup-win32-x64-msvc": "4.54.0",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@ -4132,7 +4121,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -4154,9 +4142,9 @@
} }
}, },
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.1.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz",
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -4198,7 +4186,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -4208,16 +4195,16 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.50.0", "version": "8.51.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.0.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.51.0.tgz",
"integrity": "sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==", "integrity": "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.50.0", "@typescript-eslint/eslint-plugin": "8.51.0",
"@typescript-eslint/parser": "8.50.0", "@typescript-eslint/parser": "8.51.0",
"@typescript-eslint/typescript-estree": "8.50.0", "@typescript-eslint/typescript-estree": "8.51.0",
"@typescript-eslint/utils": "8.50.0" "@typescript-eslint/utils": "8.51.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -4292,7 +4279,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",

File diff suppressed because it is too large Load diff

View file

@ -23,7 +23,7 @@ export const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => {
{/* Search Icon / Toggle */} {/* Search Icon / Toggle */}
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className={`text-white p-3 hover:text-pink-500 transition-colors duration-300 ${isOpen ? '' : 'w-full h-full flex items-center justify-center'}`} className={`text-white p-3 hover:text-white transition-colors duration-300 ${isOpen ? '' : 'w-full h-full flex items-center justify-center'}`}
> >
<Search size={isOpen ? 18 : 20} className={isOpen ? 'opacity-60' : ''} /> <Search size={isOpen ? 18 : 20} className={isOpen ? 'opacity-60' : ''} />
</button> </button>
@ -41,7 +41,7 @@ export const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => {
</form> </form>
{isOpen && ( {isOpen && (
<div className="absolute inset-0 bg-gradient-to-r from-pink-500/10 to-violet-500/10 pointer-events-none" /> <div className="absolute inset-0 bg-gradient-to-r from-gray-400/10 to-gray-300/10 pointer-events-none" />
)} )}
</div> </div>
); );

View file

@ -0,0 +1,51 @@
import React from 'react';
interface SearchSkeletonProps {
count?: number;
estimatedTime?: number;
}
export const SearchSkeleton: React.FC<SearchSkeletonProps> = ({
count = 9,
estimatedTime
}) => {
return (
<div className="space-y-4">
{/* Countdown Timer */}
{estimatedTime !== undefined && estimatedTime > 0 && (
<div className="text-center py-2">
<p className="text-white/50 text-xs">
Estimated time: ~{Math.ceil(estimatedTime)}s
</p>
</div>
)}
{/* Skeleton Grid */}
<div className="grid grid-cols-3 gap-1">
{Array.from({ length: count }).map((_, index) => (
<div
key={index}
className="aspect-[9/16] bg-white/5 rounded-lg animate-pulse relative overflow-hidden"
style={{
animationDelay: `${index * 100}ms`
}}
>
{/* Shimmer effect */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent shimmer" />
</div>
))}
</div>
{/* Add shimmer keyframes via inline style */}
<style>{`
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.shimmer {
animation: shimmer 1.5s infinite;
}
`}</style>
</div>
);
};

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,12 @@
#root { #root {
@apply h-full overflow-hidden; @apply h-full overflow-hidden;
} }
/* Mobile-safe full height - accounts for browser chrome */
.h-screen-safe {
height: 100vh;
height: 100dvh;
}
} }
@layer utilities { @layer utilities {

View file

@ -1,135 +1,148 @@
import axios from 'axios'; import axios from 'axios';
import type { Video } from '../types'; import type { Video } from '../types';
import { API_BASE_URL } from '../config'; import { API_BASE_URL } from '../config';
interface FeedStats { interface FeedStats {
totalLoaded: number; totalLoaded: number;
loadTime: number; loadTime: number;
batchSize: number; batchSize: number;
} }
class FeedLoader { class FeedLoader {
private stats: FeedStats = { private stats: FeedStats = {
totalLoaded: 0, totalLoaded: 0,
loadTime: 0, loadTime: 0,
batchSize: 12 batchSize: 12
}; };
private requestCache: Map<string, { data: Video[]; timestamp: number }> = new Map(); private requestCache: Map<string, { data: Video[]; timestamp: number }> = new Map();
private CACHE_TTL_MS = 60000; private CACHE_TTL_MS = 60000;
async loadFeedWithOptimization( async loadFeedWithOptimization(
fast: boolean = false, fast: boolean = false,
onProgress?: (videos: Video[]) => void onProgress?: (videos: Video[]) => void,
): Promise<Video[]> { skipCache: boolean = false
const startTime = performance.now(); ): Promise<Video[]> {
const startTime = performance.now();
try {
if (fast) { try {
const videos = await this.loadWithCache('feed-fast'); if (fast && !skipCache) {
onProgress?.(videos); const videos = await this.loadWithCache('feed-fast');
return videos; onProgress?.(videos);
} return videos;
}
const cacheKey = 'feed-full';
const cached = this.getCached(cacheKey); const cacheKey = 'feed-full';
if (cached) {
onProgress?.(cached); // Skip cache check when explicitly requested (for infinite scroll)
return cached; if (!skipCache) {
} const cached = this.getCached(cacheKey);
if (cached) {
const videos = await this.fetchFeed(); onProgress?.(cached);
this.setCached(cacheKey, videos); return cached;
}
onProgress?.(videos); }
this.stats.loadTime = performance.now() - startTime; const videos = await this.fetchFeed(skipCache);
this.stats.totalLoaded = videos.length;
// Only cache if not skipping (initial load)
return videos; if (!skipCache) {
} catch (error) { this.setCached(cacheKey, videos);
console.error('Feed load failed:', error); }
return [];
} onProgress?.(videos);
}
this.stats.loadTime = performance.now() - startTime;
private async fetchFeed(): Promise<Video[]> { this.stats.totalLoaded = videos.length;
const response = await axios.get(`${API_BASE_URL}/feed`);
return videos;
if (!Array.isArray(response.data)) { } catch (error) {
return []; console.error('Feed load failed:', error);
} return [];
}
return response.data.map((v: any, i: number) => ({ }
id: v.id || `video-${i}`,
url: v.url, private async fetchFeed(skipCache: boolean = false): Promise<Video[]> {
author: v.author || 'unknown', // Add skip_cache parameter to force backend to fetch fresh videos
description: v.description || '', const url = skipCache
thumbnail: v.thumbnail, ? `${API_BASE_URL}/feed?skip_cache=true`
cdn_url: v.cdn_url, : `${API_BASE_URL}/feed`;
views: v.views, const response = await axios.get(url);
likes: v.likes
})); if (!Array.isArray(response.data)) {
} return [];
}
private async loadWithCache(key: string): Promise<Video[]> {
const cached = this.getCached(key); return response.data.map((v: any, i: number) => ({
if (cached) return cached; id: v.id || `video-${i}`,
url: v.url,
const videos = await this.fetchFeed(); author: v.author || 'unknown',
this.setCached(key, videos); description: v.description || '',
return videos; thumbnail: v.thumbnail,
} cdn_url: v.cdn_url,
views: v.views,
private getCached(key: string): Video[] | null { likes: v.likes
const cached = this.requestCache.get(key); }));
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) { }
return cached.data;
} private async loadWithCache(key: string): Promise<Video[]> {
return null; const cached = this.getCached(key);
} if (cached) return cached;
private setCached(key: string, data: Video[]): void { const videos = await this.fetchFeed();
this.requestCache.set(key, { this.setCached(key, videos);
data, return videos;
timestamp: Date.now() }
});
} private getCached(key: string): Video[] | null {
const cached = this.requestCache.get(key);
getStats(): FeedStats { if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) {
return { ...this.stats }; return cached.data;
} }
return null;
clearCache(): void { }
this.requestCache.clear();
} private setCached(key: string, data: Video[]): void {
this.requestCache.set(key, {
getOptimalBatchSize(): number { data,
const connection = (navigator as any).connection; timestamp: Date.now()
});
if (!connection) { }
return 15;
} getStats(): FeedStats {
return { ...this.stats };
const effectiveType = connection.effectiveType; }
switch (effectiveType) { clearCache(): void {
case '4g': this.requestCache.clear();
return 20; }
case '3g':
return 12; getOptimalBatchSize(): number {
case '2g': const connection = (navigator as any).connection;
return 6;
default: if (!connection) {
return 15; return 15;
} }
}
const effectiveType = connection.effectiveType;
shouldPrefetchThumbnails(): boolean {
const connection = (navigator as any).connection; switch (effectiveType) {
if (!connection) return true; case '4g':
return connection.saveData !== true; return 20;
} case '3g':
} return 12;
case '2g':
export const feedLoader = new FeedLoader(); return 6;
default:
return 15;
}
}
shouldPrefetchThumbnails(): boolean {
const connection = (navigator as any).connection;
if (!connection) return true;
return connection.saveData !== true;
}
}
export const feedLoader = new FeedLoader();

View file

@ -1,207 +1,207 @@
interface CachedVideo { interface CachedVideo {
id: string; id: string;
url: string; url: string;
data: Blob; data: Blob;
timestamp: number; timestamp: number;
size: number; size: number;
} }
const DB_NAME = 'PureStreamCache'; const DB_NAME = 'PureStreamCache';
const STORE_NAME = 'videos'; const STORE_NAME = 'videos';
const MAX_CACHE_SIZE_MB = 200; const MAX_CACHE_SIZE_MB = 200;
const CACHE_TTL_HOURS = 24; const CACHE_TTL_HOURS = 24;
class VideoCache { class VideoCache {
private db: IDBDatabase | null = null; private db: IDBDatabase | null = null;
private initialized = false; private initialized = false;
async init(): Promise<void> { async init(): Promise<void> {
if (this.initialized) return; if (this.initialized) return;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1); const request = indexedDB.open(DB_NAME, 1);
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
request.onsuccess = () => { request.onsuccess = () => {
this.db = request.result; this.db = request.result;
this.initialized = true; this.initialized = true;
this.cleanup(); this.cleanup();
resolve(); resolve();
}; };
request.onupgradeneeded = (e) => { request.onupgradeneeded = (e) => {
const db = (e.target as IDBOpenDBRequest).result; const db = (e.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) { if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' }); db.createObjectStore(STORE_NAME, { keyPath: 'id' });
} }
}; };
}); });
} }
async get(url: string): Promise<Blob | null> { async get(url: string): Promise<Blob | null> {
if (!this.db) await this.init(); if (!this.db) await this.init();
if (!this.db) return null; if (!this.db) return null;
const videoId = this.getVideoId(url); const videoId = this.getVideoId(url);
return new Promise((resolve) => { return new Promise((resolve) => {
try { try {
const tx = this.db!.transaction(STORE_NAME, 'readonly'); const tx = this.db!.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME); const store = tx.objectStore(STORE_NAME);
const request = store.get(videoId); const request = store.get(videoId);
request.onsuccess = () => { request.onsuccess = () => {
const cached = request.result as CachedVideo | undefined; const cached = request.result as CachedVideo | undefined;
if (cached) { if (cached) {
const ageHours = (Date.now() - cached.timestamp) / (1000 * 60 * 60); const ageHours = (Date.now() - cached.timestamp) / (1000 * 60 * 60);
if (ageHours < CACHE_TTL_HOURS) { if (ageHours < CACHE_TTL_HOURS) {
resolve(cached.data); resolve(cached.data);
return; return;
} }
this.delete(videoId); this.delete(videoId);
} }
resolve(null); resolve(null);
}; };
request.onerror = () => resolve(null); request.onerror = () => resolve(null);
} catch { } catch {
resolve(null); resolve(null);
} }
}); });
} }
async set(url: string, blob: Blob): Promise<void> { async set(url: string, blob: Blob): Promise<void> {
if (!this.db) await this.init(); if (!this.db) await this.init();
if (!this.db) return; if (!this.db) return;
const videoId = this.getVideoId(url); const videoId = this.getVideoId(url);
const cached: CachedVideo = { const cached: CachedVideo = {
id: videoId, id: videoId,
url, url,
data: blob, data: blob,
timestamp: Date.now(), timestamp: Date.now(),
size: blob.size size: blob.size
}; };
return new Promise((resolve) => { return new Promise((resolve) => {
try { try {
const tx = this.db!.transaction(STORE_NAME, 'readwrite'); const tx = this.db!.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME); const store = tx.objectStore(STORE_NAME);
const request = store.put(cached); const request = store.put(cached);
request.onsuccess = () => { request.onsuccess = () => {
this.cleanup(); this.cleanup();
resolve(); resolve();
}; };
request.onerror = () => resolve(); request.onerror = () => resolve();
} catch { } catch {
resolve(); resolve();
} }
}); });
} }
async delete(videoId: string): Promise<void> { async delete(videoId: string): Promise<void> {
if (!this.db) return; if (!this.db) return;
return new Promise((resolve) => { return new Promise((resolve) => {
try { try {
const tx = this.db!.transaction(STORE_NAME, 'readwrite'); const tx = this.db!.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME); const store = tx.objectStore(STORE_NAME);
const request = store.delete(videoId); const request = store.delete(videoId);
request.onsuccess = () => resolve(); request.onsuccess = () => resolve();
request.onerror = () => resolve(); request.onerror = () => resolve();
} catch { } catch {
resolve(); resolve();
} }
}); });
} }
async clear(): Promise<void> { async clear(): Promise<void> {
if (!this.db) return; if (!this.db) return;
return new Promise((resolve) => { return new Promise((resolve) => {
try { try {
const tx = this.db!.transaction(STORE_NAME, 'readwrite'); const tx = this.db!.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME); const store = tx.objectStore(STORE_NAME);
const request = store.clear(); const request = store.clear();
request.onsuccess = () => resolve(); request.onsuccess = () => resolve();
request.onerror = () => resolve(); request.onerror = () => resolve();
} catch { } catch {
resolve(); resolve();
} }
}); });
} }
async getStats(): Promise<{ size_mb: number; count: number }> { async getStats(): Promise<{ size_mb: number; count: number }> {
if (!this.db) return { size_mb: 0, count: 0 }; if (!this.db) return { size_mb: 0, count: 0 };
return new Promise((resolve) => { return new Promise((resolve) => {
try { try {
const tx = this.db!.transaction(STORE_NAME, 'readonly'); const tx = this.db!.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME); const store = tx.objectStore(STORE_NAME);
const request = store.getAll(); const request = store.getAll();
request.onsuccess = () => { request.onsuccess = () => {
const cached = request.result as CachedVideo[]; const cached = request.result as CachedVideo[];
const totalSize = cached.reduce((sum, v) => sum + v.size, 0); const totalSize = cached.reduce((sum, v) => sum + v.size, 0);
resolve({ resolve({
size_mb: Math.round(totalSize / 1024 / 1024 * 100) / 100, size_mb: Math.round(totalSize / 1024 / 1024 * 100) / 100,
count: cached.length count: cached.length
}); });
}; };
request.onerror = () => resolve({ size_mb: 0, count: 0 }); request.onerror = () => resolve({ size_mb: 0, count: 0 });
} catch { } catch {
resolve({ size_mb: 0, count: 0 }); resolve({ size_mb: 0, count: 0 });
} }
}); });
} }
private async cleanup(): Promise<void> { private async cleanup(): Promise<void> {
if (!this.db) return; if (!this.db) return;
const stats = await this.getStats(); const stats = await this.getStats();
if (stats.size_mb < MAX_CACHE_SIZE_MB) return; if (stats.size_mb < MAX_CACHE_SIZE_MB) return;
return new Promise((resolve) => { return new Promise((resolve) => {
try { try {
const tx = this.db!.transaction(STORE_NAME, 'readwrite'); const tx = this.db!.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME); const store = tx.objectStore(STORE_NAME);
const request = store.getAll(); const request = store.getAll();
request.onsuccess = () => { request.onsuccess = () => {
const cached = (request.result as CachedVideo[]).sort( const cached = (request.result as CachedVideo[]).sort(
(a, b) => a.timestamp - b.timestamp (a, b) => a.timestamp - b.timestamp
); );
let totalSize = cached.reduce((sum, v) => sum + v.size, 0); let totalSize = cached.reduce((sum, v) => sum + v.size, 0);
const targetSize = MAX_CACHE_SIZE_MB * 1024 * 1024 * 0.8; const targetSize = MAX_CACHE_SIZE_MB * 1024 * 1024 * 0.8;
for (const video of cached) { for (const video of cached) {
if (totalSize <= targetSize) break; if (totalSize <= targetSize) break;
const deleteReq = store.delete(video.id); const deleteReq = store.delete(video.id);
deleteReq.onsuccess = () => { deleteReq.onsuccess = () => {
totalSize -= video.size; totalSize -= video.size;
}; };
} }
resolve(); resolve();
}; };
request.onerror = () => resolve(); request.onerror = () => resolve();
} catch { } catch {
resolve(); resolve();
} }
}); });
} }
private getVideoId(url: string): string { private getVideoId(url: string): string {
const match = url.match(/video\/(\d+)|id=([^&]+)/); const match = url.match(/video\/(\d+)|id=([^&]+)/);
return match ? match[1] || match[2] : url.substring(0, 50); return match ? match[1] || match[2] : url.substring(0, 50);
} }
} }
export const videoCache = new VideoCache(); export const videoCache = new VideoCache();

View file

@ -1,95 +1,128 @@
import { videoCache } from './videoCache'; import { videoCache } from './videoCache';
import type { Video } from '../types'; import type { Video } from '../types';
interface PrefetchConfig { interface PrefetchConfig {
lookahead: number; lookahead: number;
concurrency: number; concurrency: number;
timeoutMs: number; timeoutMs: number;
} }
const DEFAULT_CONFIG: PrefetchConfig = { const DEFAULT_CONFIG: PrefetchConfig = {
lookahead: 2, lookahead: 3, // Increased from 2 for better buffering
concurrency: 1, concurrency: 2, // Increased from 1 for parallel downloads
timeoutMs: 30000 timeoutMs: 30000
}; };
class VideoPrefetcher { class VideoPrefetcher {
private prefetchQueue: Set<string> = new Set(); private prefetchQueue: Set<string> = new Set();
private config: PrefetchConfig; private config: PrefetchConfig;
private isInitialized = false; private isInitialized = false;
constructor(config: Partial<PrefetchConfig> = {}) { constructor(config: Partial<PrefetchConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config }; this.config = { ...DEFAULT_CONFIG, ...config };
} }
async init(): Promise<void> { async init(): Promise<void> {
if (this.isInitialized) return; if (this.isInitialized) return;
await videoCache.init(); await videoCache.init();
this.isInitialized = true; this.isInitialized = true;
} }
async prefetchNext( async prefetchNext(
videos: Video[], videos: Video[],
currentIndex: number currentIndex: number
): Promise<void> { ): Promise<void> {
if (!this.isInitialized) await this.init(); if (!this.isInitialized) await this.init();
const endIndex = Math.min( const endIndex = Math.min(
currentIndex + this.config.lookahead, currentIndex + this.config.lookahead,
videos.length videos.length
); );
const toPrefetch = videos const toPrefetch = videos
.slice(currentIndex + 1, endIndex) .slice(currentIndex + 1, endIndex)
.filter((v) => v.url && !this.prefetchQueue.has(v.id)); .filter((v) => v.url && !this.prefetchQueue.has(v.id));
for (const video of toPrefetch) { for (const video of toPrefetch) {
this.prefetchQueue.add(video.id); this.prefetchQueue.add(video.id);
this.prefetchVideo(video).catch(console.error); this.prefetchVideo(video).catch(console.error);
} }
} }
private async prefetchVideo(video: Video): Promise<void> { /**
if (!video.url) return; * Prefetch initial batch of videos immediately after feed loads.
* This ensures first few videos are ready before user starts scrolling.
const cached = await videoCache.get(video.url); */
if (cached) { async prefetchInitialBatch(
this.prefetchQueue.delete(video.id); videos: Video[],
return; count: number = 3
} ): Promise<void> {
if (!this.isInitialized) await this.init();
try { if (videos.length === 0) return;
const controller = new AbortController();
const timeoutId = setTimeout( console.log(`PREFETCH: Starting initial batch of ${Math.min(count, videos.length)} videos...`);
() => controller.abort(),
this.config.timeoutMs const toPrefetch = videos
); .slice(0, count)
.filter((v) => v.url && !this.prefetchQueue.has(v.id));
const response = await fetch(video.url, {
signal: controller.signal, // Start all prefetches in parallel (respects concurrency via browser limits)
headers: { Range: 'bytes=0-1048576' } const promises = toPrefetch.map((video) => {
}); this.prefetchQueue.add(video.id);
return this.prefetchVideo(video);
clearTimeout(timeoutId); });
if (response.ok) { await Promise.allSettled(promises);
const blob = await response.blob(); console.log(`PREFETCH: Initial batch complete (${toPrefetch.length} videos buffered)`);
await videoCache.set(video.url, blob); }
}
} catch (error) { private async prefetchVideo(video: Video): Promise<void> {
console.debug(`Prefetch failed for ${video.id}:`, error); if (!video.url) return;
} finally {
this.prefetchQueue.delete(video.id); const cached = await videoCache.get(video.url);
} if (cached) {
} this.prefetchQueue.delete(video.id);
return;
clearQueue(): void { }
this.prefetchQueue.clear();
} const API_BASE_URL = 'http://localhost:8002/api'; // Hardcoded or imported from config
const fullProxyUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}`;
getQueueSize(): number { // Use thin proxy if available for better performance
return this.prefetchQueue.size; const thinProxyUrl = video.cdn_url ? `${API_BASE_URL}/feed/thin-proxy?cdn_url=${encodeURIComponent(video.cdn_url)}` : null;
} const targetUrl = thinProxyUrl || fullProxyUrl;
}
try {
export const videoPrefetcher = new VideoPrefetcher(); const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
this.config.timeoutMs
);
const response = await fetch(targetUrl, {
signal: controller.signal,
headers: { Range: 'bytes=0-1048576' }
});
clearTimeout(timeoutId);
if (response.ok) {
const blob = await response.blob();
await videoCache.set(video.url, blob);
}
} catch (error) {
console.debug(`Prefetch failed for ${video.id}:`, error);
} finally {
this.prefetchQueue.delete(video.id);
}
}
clearQueue(): void {
this.prefetchQueue.clear();
}
getQueueSize(): number {
return this.prefetchQueue.size;
}
}
export const videoPrefetcher = new VideoPrefetcher();

30
simple_test.py Normal file
View file

@ -0,0 +1,30 @@
import urllib.request
import json
try:
print("Testing /health...")
with urllib.request.urlopen("http://localhost:8002/health", timeout=5) as r:
print(f"Health: {r.status}")
print("Testing /api/feed...")
with open("temp_cookies.json", "r") as f:
data = json.load(f)
# Ensure list format
if isinstance(data, dict) and "credentials" in data:
data = data["credentials"]
# Prepare body as dict for safety with new Union type
body = {"credentials": data}
req = urllib.request.Request(
"http://localhost:8002/api/feed",
data=json.dumps(body).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
with urllib.request.urlopen(req, timeout=30) as r:
print(f"Feed: {r.status}")
print(r.read().decode('utf-8')[:100])
except Exception as e:
print(f"Error: {e}")

314
temp_cookies.json Normal file
View file

@ -0,0 +1,314 @@
{
"credentials": [
{
"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": 1768131143.251207,
"hostOnly": false,
"httpOnly": false,
"name": "msToken",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "jntmMFSrdBzHw3GQQ7xigi2HLM03wLgd2s8xW8sa8bm3gVg-VJu63FlYSfvPAW6tmoNM-Ww9ho9sOKZc75EN1XIGwct0ndkyOairFWbXgkiFwPXfDpQaBA9pn2_9mSOYSylT1H60yH1ufg=="
},
{
"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": 1798801628.385793,
"hostOnly": false,
"httpOnly": true,
"name": "ttwid",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "1%7CAYDsetgnxt5vzYX8hD6Wq2DQ4FXiL_pqcdLwHWwz6B8%7C1767265624%7C6121d82381fb651afeae94341e45b87fca1d903fbec0d8a19e4dd5440a89a424"
},
{
"domain": ".www.tiktok.com",
"expirationDate": 1767870430,
"hostOnly": false,
"httpOnly": false,
"name": "perf_feed_cache",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "{%22expireTimestamp%22:1767438000000%2C%22itemIds%22:[%227588749061168123154%22%2C%227589493510613552404%22%2C%227586917939568332054%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": 1793185625,
"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": 1793185625,
"hostOnly": false,
"httpOnly": false,
"name": "tiktok_webapp_theme_source",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "auto"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817624.001151,
"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"
}
]
}

16
test_login.py Normal file
View file

@ -0,0 +1,16 @@
import requests
import time
URL = "http://localhost:8002/api/auth/admin-login"
def test_login():
print("Testing Admin Login...")
try:
res = requests.post(URL, json={"password": "admin123"})
print(f"Status: {res.status_code}")
print(f"Response: {res.text}")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
test_login()

30
test_request.py Normal file
View file

@ -0,0 +1,30 @@
import urllib.request
import json
import os
with open("temp_cookies.json", "r") as f:
data = json.load(f)
# Ensure data is in the expected dict format for the request body
if isinstance(data, list):
# If temp_cookies is just the list, wrap it
body = {"credentials": data}
elif "credentials" not in data:
body = {"credentials": data}
else:
body = data
req = urllib.request.Request(
"http://localhost:8002/api/feed",
data=json.dumps(body).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
try:
with urllib.request.urlopen(req) as response:
print(response.read().decode('utf-8'))
except urllib.error.HTTPError as e:
print(f"HTTP Error: {e.code}")
print(e.read().decode('utf-8'))
except Exception as e:
print(f"Error: {e}")

35
test_search.py Normal file
View file

@ -0,0 +1,35 @@
import requests
import json
import time
BASE_URL = "http://localhost:8002/api/user/search"
def test_search():
print("Testing Search API...")
try:
# Simple query
params = {
"query": "dance",
"limit": 50,
"cursor": 0
}
start = time.time()
res = requests.get(BASE_URL, params=params)
duration = time.time() - start
print(f"Status Code: {res.status_code}")
print(f"Duration: {duration:.2f}s")
if res.status_code == 200:
data = res.json()
print(f"Videos Found: {len(data.get('videos', []))}")
# print(json.dumps(data, indent=2))
else:
print("Error Response:")
print(res.text)
except Exception as e:
print(f"Request Failed: {e}")
if __name__ == "__main__":
test_search()