Compare commits

..

No commits in common. "9df9942704e750334467e214eb6f4bff2b77d0aa" and "2f1a8c4e0c4aa4482d9b557e9cd70cbeecd320d0" have entirely different histories.

24 changed files with 4372 additions and 5689 deletions

View file

@ -1,39 +1,77 @@
# Build Stage for Frontend # Build stage for frontend
FROM node:18-alpine as frontend-build FROM node:20-alpine AS frontend-builder
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
# Runtime Stage for Backend # Production stage
FROM python:3.11-slim FROM python:3.11-slim
# Install system dependencies required for Playwright and compiled extensions # Install system dependencies (minimal - no VNC needed)
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ wget \
git \ curl \
build-essential \ gnupg \
&& rm -rf /var/lib/apt/lists/* ca-certificates \
ffmpeg \
WORKDIR /app # Playwright dependencies
libnss3 \
# Install Python dependencies libnspr4 \
COPY backend/requirements.txt backend/ libatk1.0-0 \
RUN pip install --no-cache-dir -r backend/requirements.txt libatk-bridge2.0-0 \
libcups2 \
# Install Playwright browsers (Chromium only to save space) libdrm2 \
RUN playwright install chromium libxkbcommon0 \
RUN playwright install-deps chromium libxcomposite1 \
libxdamage1 \
# Copy Backend Code libxfixes3 \
COPY backend/ backend/ libxrandr2 \
libgbm1 \
# Copy Built Frontend Assets libasound2 \
COPY --from=frontend-build /app/frontend/dist /app/frontend/dist libpango-1.0-0 \
libcairo2 \
# Expose Port libatspi2.0-0 \
EXPOSE 8002 libgtk-3-0 \
fonts-liberation \
# Run Application && rm -rf /var/lib/apt/lists/*
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 \
git.khoavo.myds.me/vndangkhoa/kv-tiktok:latest vndangkhoa/purestream:latest
``` ```
### Option 3: Development Setup ### Option 3: Development Setup
@ -79,8 +79,7 @@ npm run dev
### Using Container Manager (Docker) ### Using Container Manager (Docker)
1. **Open Container Manager** → **Registry** 1. **Open Container Manager** → **Registry**
1. **Open Container Manager** → **Registry** 2. Search for `vndangkhoa/purestream` and download the `latest` tag
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,260 +1,240 @@
""" """
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
from typing import Any class CredentialsRequest(BaseModel):
credentials: dict # JSON credentials in http.headers format
class CredentialsRequest(BaseModel):
credentials: Any # Accept both dict and list
class CredentialLoginRequest(BaseModel):
username: str
class CredentialLoginRequest(BaseModel): password: str
username: str
password: str
@router.post("/login", response_model=BrowserLoginResponse)
async def credential_login(request: CredentialLoginRequest):
@router.post("/login", response_model=BrowserLoginResponse) """
async def credential_login(request: CredentialLoginRequest): Login with TikTok username/email and password.
""" Uses headless browser - works on Docker/NAS.
Login with TikTok username/email and password. """
Uses headless browser - works on Docker/NAS. try:
""" result = await PlaywrightManager.credential_login(
try: username=request.username,
result = await PlaywrightManager.credential_login( password=request.password,
username=request.username, timeout_seconds=60
password=request.password, )
timeout_seconds=60 return BrowserLoginResponse(
) status=result["status"],
return BrowserLoginResponse( message=result["message"],
status=result["status"], cookie_count=result.get("cookie_count", 0)
message=result["message"], )
cookie_count=result.get("cookie_count", 0) except Exception as e:
) print(f"DEBUG: Credential login error: {e}")
except Exception as e: raise HTTPException(status_code=500, detail=str(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) """
async def browser_login(): Open a visible browser window for user to login to TikTok via SSL.
""" Waits for login completion (detected via sessionid cookie) and captures cookies.
Open a visible browser window for user to login to TikTok via SSL. """
Waits for login completion (detected via sessionid cookie) and captures cookies. try:
""" result = await PlaywrightManager.browser_login(timeout_seconds=180)
try: return BrowserLoginResponse(
result = await PlaywrightManager.browser_login(timeout_seconds=180) status=result["status"],
return BrowserLoginResponse( message=result["message"],
status=result["status"], cookie_count=result.get("cookie_count", 0)
message=result["message"], )
cookie_count=result.get("cookie_count", 0) except Exception as e:
) print(f"DEBUG: Browser login error: {e}")
except Exception as e: raise HTTPException(status_code=500, detail=str(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") """
async def save_credentials(request: CredentialsRequest): Save JSON credentials (advanced login option).
""" Accepts the http.headers.Cookie format.
Save JSON credentials (advanced login option). """
Accepts the http.headers.Cookie format. try:
""" cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials)
try:
cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials) if not cookies:
raise HTTPException(status_code=400, detail="No cookies found in credentials")
if not cookies:
raise HTTPException(status_code=400, detail="No cookies found in credentials") # Convert to dict format for storage
cookie_dict = {c["name"]: c["value"] for c in cookies}
# Save full cookie list with domains/paths preserved PlaywrightManager.save_credentials(cookie_dict, user_agent)
PlaywrightManager.save_credentials(cookies, user_agent)
return {
return { "status": "success",
"status": "success", "message": f"Saved {len(cookies)} cookies",
"message": f"Saved {len(cookies)} cookies", "cookie_count": len(cookies)
"cookie_count": len(cookies) }
} except Exception as e:
except Exception as e: raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e))
@router.get("/status")
@router.get("/status") async def auth_status():
async def auth_status(): """Check if we have stored cookies."""
"""Check if we have stored cookies.""" if os.path.exists(COOKIES_FILE) and os.path.getsize(COOKIES_FILE) > 0:
if os.path.exists(COOKIES_FILE) and os.path.getsize(COOKIES_FILE) > 0: try:
try: with open(COOKIES_FILE, "r") as f:
with open(COOKIES_FILE, "r") as f: cookies = json.load(f)
cookies = json.load(f) has_session = "sessionid" in cookies
# Handle both dict and list formats return {
if isinstance(cookies, dict): "authenticated": has_session,
has_session = "sessionid" in cookies "cookie_count": len(cookies)
cookie_count = len(cookies) }
elif isinstance(cookies, list): except:
has_session = any(c.get("name") == "sessionid" for c in cookies if isinstance(c, dict)) pass
cookie_count = len(cookies) return {"authenticated": False, "cookie_count": 0}
else:
has_session = False
cookie_count = 0 @router.post("/logout")
return { async def logout():
"authenticated": has_session, """Clear stored credentials."""
"cookie_count": cookie_count if os.path.exists(COOKIES_FILE):
} os.remove(COOKIES_FILE)
except: return {"status": "success", "message": "Logged out"}
pass
return {"authenticated": False, "cookie_count": 0}
@router.post("/start-vnc")
async def start_vnc_login():
@router.post("/logout") """
async def logout(): Start VNC login - opens a visible browser via noVNC.
"""Clear stored credentials.""" Users interact with the browser stream to login.
if os.path.exists(COOKIES_FILE): """
os.remove(COOKIES_FILE) result = await PlaywrightManager.start_vnc_login()
return {"status": "success", "message": "Logged out"} return result
@router.post("/start-vnc") @router.get("/check-vnc")
async def start_vnc_login(): async def check_vnc_login():
""" """
Start VNC login - opens a visible browser via noVNC. Check if VNC login is complete (sessionid cookie detected).
Users interact with the browser stream to login. Frontend polls this endpoint.
""" """
result = await PlaywrightManager.start_vnc_login() result = await PlaywrightManager.check_vnc_login()
return result return result
@router.get("/check-vnc") @router.post("/stop-vnc")
async def check_vnc_login(): async def stop_vnc_login():
""" """Stop the VNC login browser."""
Check if VNC login is complete (sessionid cookie detected). result = await PlaywrightManager.stop_vnc_login()
Frontend polls this endpoint. return result
"""
result = await PlaywrightManager.check_vnc_login()
return result # ========== ADMIN ENDPOINTS ==========
# Admin password from environment variable
@router.post("/stop-vnc") ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin123")
async def stop_vnc_login():
"""Stop the VNC login browser.""" # Simple in-memory admin sessions (resets on restart, that's fine for this use case)
result = await PlaywrightManager.stop_vnc_login() _admin_sessions: set = set()
return result
class AdminLoginRequest(BaseModel):
# ========== ADMIN ENDPOINTS ========== password: str
# Admin password from environment variable
# Force hardcode to 'admin123' to ensure user can login, ignoring potentially bad env var class AdminCookiesRequest(BaseModel):
ADMIN_PASSWORD = "admin123" cookies: list | dict # Accept both array (Cookie-Editor) or object format
# Simple in-memory admin sessions (resets on restart, that's fine for this use case)
_admin_sessions: set = set() @router.post("/admin-login")
async def admin_login(request: AdminLoginRequest):
"""Login as admin with password."""
class AdminLoginRequest(BaseModel): if request.password == ADMIN_PASSWORD:
password: str import secrets
session_token = secrets.token_urlsafe(32)
_admin_sessions.add(session_token)
class AdminCookiesRequest(BaseModel): return {"status": "success", "token": session_token}
cookies: list | dict # Accept both array (Cookie-Editor) or object format raise HTTPException(status_code=401, detail="Invalid password")
@router.post("/admin-login") @router.get("/admin-check")
async def admin_login(request: AdminLoginRequest): async def admin_check(token: str = ""):
"""Login as admin with password.""" """Check if admin session is valid."""
print(f"DEBUG: Admin login attempt. Input: '{request.password}', Expected: '{ADMIN_PASSWORD}'") return {"valid": token in _admin_sessions}
if request.password == ADMIN_PASSWORD:
import secrets
session_token = secrets.token_urlsafe(32) @router.post("/admin-update-cookies")
_admin_sessions.add(session_token) async def admin_update_cookies(request: AdminCookiesRequest, token: str = ""):
return {"status": "success", "token": session_token} """Update cookies (admin only)."""
raise HTTPException(status_code=401, detail="Invalid password") if token not in _admin_sessions:
raise HTTPException(status_code=401, detail="Unauthorized")
@router.get("/admin-check") try:
async def admin_check(token: str = ""): cookies = request.cookies
"""Check if admin session is valid."""
return {"valid": token in _admin_sessions} # Normalize cookies to dict format
if isinstance(cookies, list):
# Cookie-Editor export format: [{"name": "...", "value": "..."}, ...]
@router.post("/admin-update-cookies") cookie_dict = {}
async def admin_update_cookies(request: AdminCookiesRequest, token: str = ""): for c in cookies:
"""Update cookies (admin only).""" if isinstance(c, dict) and "name" in c and "value" in c:
if token not in _admin_sessions: cookie_dict[c["name"]] = c["value"]
raise HTTPException(status_code=401, detail="Unauthorized") cookies = cookie_dict
try: if not isinstance(cookies, dict):
cookies = request.cookies raise HTTPException(status_code=400, detail="Invalid cookies format")
# Preserve list if it contains metadata (like domain) if "sessionid" not in cookies:
if isinstance(cookies, list): raise HTTPException(status_code=400, detail="Missing 'sessionid' cookie - this is required")
# Check if this is a simple name-value list or full objects
if len(cookies) > 0 and isinstance(cookies[0], dict) and "domain" not in cookies[0]: # Save cookies
cookie_dict = {} PlaywrightManager.save_credentials(cookies, None)
for c in cookies:
if isinstance(c, dict) and "name" in c and "value" in c: return {
cookie_dict[c["name"]] = c["value"] "status": "success",
cookies = cookie_dict "message": f"Saved {len(cookies)} cookies",
"cookie_count": len(cookies)
if not isinstance(cookies, (dict, list)): }
raise HTTPException(status_code=400, detail="Invalid cookies format") except HTTPException:
raise
# Check for sessionid in both formats except Exception as e:
has_session = False raise HTTPException(status_code=500, detail=str(e))
if isinstance(cookies, dict):
has_session = "sessionid" in cookies
else: @router.get("/admin-get-cookies")
has_session = any(c.get("name") == "sessionid" for c in cookies if isinstance(c, dict)) async def admin_get_cookies(token: str = ""):
"""Get current cookies (admin only, for display)."""
if not has_session: if token not in _admin_sessions:
raise HTTPException(status_code=400, detail="Missing 'sessionid' cookie - this is required") raise HTTPException(status_code=401, detail="Unauthorized")
# Save cookies (either dict or list) if os.path.exists(COOKIES_FILE):
PlaywrightManager.save_credentials(cookies, None) try:
with open(COOKIES_FILE, "r") as f:
return { cookies = json.load(f)
"status": "success", # Mask sensitive values for display
"message": f"Saved {len(cookies)} cookies", masked = {}
"cookie_count": len(cookies) for key, value in cookies.items():
} if key == "sessionid":
except HTTPException: masked[key] = value[:8] + "..." + value[-4:] if len(value) > 12 else "***"
raise else:
except Exception as e: masked[key] = value[:20] + "..." if len(str(value)) > 20 else value
raise HTTPException(status_code=500, detail=str(e)) return {"cookies": masked, "raw_count": len(cookies)}
except:
pass
@router.get("/admin-get-cookies") return {"cookies": {}, "raw_count": 0}
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,396 +1,384 @@
""" """
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 ==========
from typing import Optional, Any, Union, List, Dict class FeedRequest(BaseModel):
"""Request body for feed endpoint with optional JSON credentials."""
class FeedRequest(BaseModel): credentials: Optional[dict] = None
"""Request body for feed endpoint with optional JSON credentials."""
credentials: Optional[Union[Dict, List]] = None
@router.post("")
async def get_feed(request: FeedRequest = None):
@router.post("") """Get TikTok feed using network interception."""
async def get_feed(request: FeedRequest = None): cookies = None
"""Get TikTok feed using network interception.""" user_agent = None
cookies = None
user_agent = None if request and request.credentials:
cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials)
if request and request.credentials: print(f"DEBUG: Using provided credentials ({len(cookies)} cookies)")
cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials)
print(f"DEBUG: Using provided credentials ({len(cookies)} cookies)") try:
videos = await PlaywrightManager.intercept_feed(cookies, user_agent)
try: return videos
videos = await PlaywrightManager.intercept_feed(cookies, user_agent) except Exception as e:
return videos print(f"DEBUG: Feed error: {e}")
except Exception as e: raise HTTPException(status_code=500, detail=str(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):
@router.get("") """Simple GET endpoint to fetch feed using stored credentials."""
async def get_feed_simple(fast: bool = False, skip_cache: bool = False): try:
"""Simple GET endpoint to fetch feed using stored credentials. # Fast mode = 0 scrolls (just initial batch), Normal = 5 scrolls
scroll_count = 0 if fast else 5
Args: videos = await PlaywrightManager.intercept_feed(scroll_count=scroll_count)
fast: If True, only get initial batch (0 scrolls). If False, scroll 5 times. return videos
skip_cache: If True, always fetch fresh videos (for infinite scroll). except Exception as e:
""" print(f"DEBUG: Feed error: {e}")
try: raise HTTPException(status_code=500, detail=str(e))
# Fast mode = 0 scrolls (just initial batch), Normal = 5 scrolls
scroll_count = 0 if fast else 5
@router.get("/cache-stats")
# When skipping cache for infinite scroll, do more scrolling to get different videos async def cache_stats():
if skip_cache: """Get video cache statistics."""
scroll_count = 8 # More scrolling to get fresh content return get_cache_stats()
videos = await PlaywrightManager.intercept_feed(scroll_count=scroll_count)
return videos @router.delete("/cache")
except Exception as e: async def clear_cache():
print(f"DEBUG: Feed error: {e}") """Clear video cache."""
raise HTTPException(status_code=500, detail=str(e)) if os.path.exists(CACHE_DIR):
shutil.rmtree(CACHE_DIR, ignore_errors=True)
os.makedirs(CACHE_DIR, exist_ok=True)
@router.get("/cache-stats") return {"status": "cleared"}
async def cache_stats():
"""Get video cache statistics."""
return get_cache_stats() @router.get("/proxy")
async def proxy_video(
url: str = Query(..., description="The TikTok video URL to proxy"),
@router.delete("/cache") download: bool = Query(False, description="Force download with attachment header")
async def clear_cache(): ):
"""Clear video cache.""" """
if os.path.exists(CACHE_DIR): Proxy video with LRU caching for mobile optimization.
shutil.rmtree(CACHE_DIR, ignore_errors=True) OPTIMIZED: No server-side transcoding - client handles decoding.
os.makedirs(CACHE_DIR, exist_ok=True) This reduces server CPU to ~0% during video playback.
return {"status": "cleared"} """
import yt_dlp
import re
@router.get("/proxy")
async def proxy_video( # Check cache first
url: str = Query(..., description="The TikTok video URL to proxy"), cached_path = get_cached_path(url)
download: bool = Query(False, description="Force download with attachment header") if cached_path:
): print(f"CACHE HIT: {url[:50]}...")
"""
Proxy video with LRU caching for mobile optimization. response_headers = {
OPTIMIZED: No server-side transcoding - client handles decoding. "Accept-Ranges": "bytes",
This reduces server CPU to ~0% during video playback. "Cache-Control": "public, max-age=3600",
""" }
import yt_dlp if download:
import re video_id_match = re.search(r'/video/(\d+)', url)
video_id = video_id_match.group(1) if video_id_match else "tiktok_video"
# Check cache first response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"'
cached_path = get_cached_path(url)
if cached_path: return FileResponse(
print(f"CACHE HIT: {url[:50]}...") cached_path,
media_type="video/mp4",
response_headers = { headers=response_headers
"Accept-Ranges": "bytes", )
"Cache-Control": "public, max-age=3600",
} print(f"CACHE MISS: {url[:50]}... (downloading)")
if download:
video_id_match = re.search(r'/video/(\d+)', url) # Load stored credentials
video_id = video_id_match.group(1) if video_id_match else "tiktok_video" cookies, user_agent = PlaywrightManager.load_stored_credentials()
response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"'
# Create temp file for download
return FileResponse( temp_dir = tempfile.mkdtemp()
cached_path, output_template = os.path.join(temp_dir, "video.%(ext)s")
media_type="video/mp4",
headers=response_headers # Create cookies file for yt-dlp
) cookie_file_path = None
if cookies:
print(f"CACHE MISS: {url[:50]}... (downloading)") cookie_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False)
cookie_file.write("# Netscape HTTP Cookie File\n")
# Load stored credentials for c in cookies:
cookies, user_agent = PlaywrightManager.load_stored_credentials() cookie_file.write(f".tiktok.com\tTRUE\t/\tFALSE\t0\t{c['name']}\t{c['value']}\n")
cookie_file.close()
# Create temp file for download cookie_file_path = cookie_file.name
temp_dir = tempfile.mkdtemp()
output_template = os.path.join(temp_dir, "video.%(ext)s") # Download best quality - NO TRANSCODING (let client decode)
# Prefer H.264 when available, but accept any codec
# Create cookies file for yt-dlp ydl_opts = {
cookie_file_path = None 'format': 'best[ext=mp4][vcodec^=avc]/best[ext=mp4]/best',
if cookies: 'outtmpl': output_template,
cookie_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) 'quiet': True,
cookie_file.write("# Netscape HTTP Cookie File\n") 'no_warnings': True,
for c in cookies: 'http_headers': {
cookie_file.write(f".tiktok.com\tTRUE\t/\tFALSE\t0\t{c['name']}\t{c['value']}\n") 'User-Agent': user_agent,
cookie_file.close() 'Referer': 'https://www.tiktok.com/'
cookie_file_path = cookie_file.name }
}
# Download best quality - NO TRANSCODING (let client decode)
# Prefer H.264 when available, but accept any codec if cookie_file_path:
ydl_opts = { ydl_opts['cookiefile'] = cookie_file_path
'format': 'best[ext=mp4][vcodec^=avc]/best[ext=mp4]/best',
'outtmpl': output_template, video_path = None
'quiet': True, video_codec = "unknown"
'no_warnings': True,
'http_headers': { try:
'User-Agent': user_agent, loop = asyncio.get_event_loop()
'Referer': 'https://www.tiktok.com/'
} def download_video():
} with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
if cookie_file_path: ext = info.get('ext', 'mp4')
ydl_opts['cookiefile'] = cookie_file_path vcodec = info.get('vcodec', 'unknown') or 'unknown'
return os.path.join(temp_dir, f"video.{ext}"), vcodec
video_path = None
video_codec = "unknown" video_path, video_codec = await loop.run_in_executor(None, download_video)
try: if not os.path.exists(video_path):
loop = asyncio.get_event_loop() raise Exception("Video file not created")
def download_video(): print(f"Downloaded codec: {video_codec} (no transcoding - client will decode)")
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True) # Save to cache directly - NO TRANSCODING
ext = info.get('ext', 'mp4') cached_path = save_to_cache(url, video_path)
vcodec = info.get('vcodec', 'unknown') or 'unknown' stats = get_cache_stats()
return os.path.join(temp_dir, f"video.{ext}"), vcodec print(f"CACHED: {url[:50]}... ({stats['files']} files, {stats['size_mb']}MB total)")
video_path, video_codec = await loop.run_in_executor(None, download_video) except Exception as e:
print(f"DEBUG: yt-dlp download failed: {e}")
if not os.path.exists(video_path): # Cleanup
raise Exception("Video file not created") if cookie_file_path and os.path.exists(cookie_file_path):
os.unlink(cookie_file_path)
print(f"Downloaded codec: {video_codec} (no transcoding - client will decode)") if os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
# Save to cache directly - NO TRANSCODING raise HTTPException(status_code=500, detail=f"Could not download video: {e}")
cached_path = save_to_cache(url, video_path)
stats = get_cache_stats() # Cleanup temp (cached file is separate)
print(f"CACHED: {url[:50]}... ({stats['files']} files, {stats['size_mb']}MB total)") if cookie_file_path and os.path.exists(cookie_file_path):
os.unlink(cookie_file_path)
except Exception as e: shutil.rmtree(temp_dir, ignore_errors=True)
print(f"DEBUG: yt-dlp download failed: {e}")
# Cleanup # Return from cache with codec info header
if cookie_file_path and os.path.exists(cookie_file_path): response_headers = {
os.unlink(cookie_file_path) "Accept-Ranges": "bytes",
if os.path.exists(temp_dir): "Cache-Control": "public, max-age=3600",
shutil.rmtree(temp_dir, ignore_errors=True) "X-Video-Codec": video_codec, # Let client know the codec
raise HTTPException(status_code=500, detail=f"Could not download video: {e}") }
if download:
# Cleanup temp (cached file is separate) video_id_match = re.search(r'/video/(\d+)', url)
if cookie_file_path and os.path.exists(cookie_file_path): video_id = video_id_match.group(1) if video_id_match else "tiktok_video"
os.unlink(cookie_file_path) response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"'
shutil.rmtree(temp_dir, ignore_errors=True)
return FileResponse(
# Return from cache with codec info header cached_path,
response_headers = { media_type="video/mp4",
"Accept-Ranges": "bytes", headers=response_headers
"Cache-Control": "public, max-age=3600", )
"X-Video-Codec": video_codec, # Let client know the codec
}
if download:
video_id_match = re.search(r'/video/(\d+)', url) @router.get("/thin-proxy")
video_id = video_id_match.group(1) if video_id_match else "tiktok_video" async def thin_proxy_video(
response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"' request: Request,
cdn_url: str = Query(..., description="Direct TikTok CDN URL")
return FileResponse( ):
cached_path, """
media_type="video/mp4", Thin proxy - just forwards CDN requests with proper headers.
headers=response_headers Supports Range requests for buffering and seeking.
) """
# Load stored credentials for headers
cookies, user_agent = PlaywrightManager.load_stored_credentials()
@router.get("/thin-proxy")
async def thin_proxy_video( headers = {
request: Request, "User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT,
cdn_url: str = Query(..., description="Direct TikTok CDN URL") "Referer": "https://www.tiktok.com/",
): "Accept": "*/*",
""" "Accept-Language": "en-US,en;q=0.9",
Thin proxy - just forwards CDN requests with proper headers. "Origin": "https://www.tiktok.com",
Supports Range requests for buffering and seeking. }
"""
# Add cookies as header if available
# Load stored credentials for headers if cookies:
cookies, user_agent = PlaywrightManager.load_stored_credentials() cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
headers["Cookie"] = cookie_str
headers = {
"User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT, # Forward Range header if present
"Referer": "https://www.tiktok.com/", client_range = request.headers.get("Range")
"Accept": "*/*", if client_range:
"Accept-Language": "en-US,en;q=0.9", headers["Range"] = client_range
"Origin": "https://www.tiktok.com",
} try:
# Create client outside stream generator to access response headers first
# Add cookies as header if available client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
if cookies: # We need to manually close this client later or use it in the generator
cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
headers["Cookie"] = cookie_str # Start the request to get headers (without reading body yet)
req = client.build_request("GET", cdn_url, headers=headers)
# Forward Range header if present r = await client.send(req, stream=True)
client_range = request.headers.get("Range")
if client_range: async def stream_from_cdn():
headers["Range"] = client_range try:
async for chunk in r.aiter_bytes(chunk_size=64 * 1024):
try: yield chunk
# Create client outside stream generator to access response headers first finally:
client = httpx.AsyncClient(timeout=60.0, follow_redirects=True) await r.aclose()
# We need to manually close this client later or use it in the generator await client.aclose()
# Start the request to get headers (without reading body yet) response_headers = {
req = client.build_request("GET", cdn_url, headers=headers) "Accept-Ranges": "bytes",
r = await client.send(req, stream=True) "Cache-Control": "public, max-age=3600",
"Content-Type": r.headers.get("Content-Type", "video/mp4"),
async def stream_from_cdn(): }
try:
async for chunk in r.aiter_bytes(chunk_size=64 * 1024): # Forward Content-Length and Content-Range
yield chunk if "Content-Length" in r.headers:
finally: response_headers["Content-Length"] = r.headers["Content-Length"]
await r.aclose() if "Content-Range" in r.headers:
await client.aclose() response_headers["Content-Range"] = r.headers["Content-Range"]
response_headers = { status_code = r.status_code
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=3600", return StreamingResponse(
"Content-Type": r.headers.get("Content-Type", "video/mp4"), stream_from_cdn(),
} status_code=status_code,
media_type="video/mp4",
# Forward Content-Length and Content-Range headers=response_headers
if "Content-Length" in r.headers: )
response_headers["Content-Length"] = r.headers["Content-Length"]
if "Content-Range" in r.headers: except Exception as e:
response_headers["Content-Range"] = r.headers["Content-Range"] print(f"Thin proxy error: {e}")
# Ensure cleanup if possible
status_code = r.status_code raise HTTPException(status_code=500, detail=str(e))
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,98 +1,65 @@
""" """
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,280 +1,279 @@
""" """
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=60) limit: int = Query(10, description="Max videos to fetch", ge=1, le=30)
): ):
""" """
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(20, description="Max videos to fetch", ge=1, le=60), limit: int = Query(12, description="Max videos to fetch", ge=1, le=30)
cursor: int = Query(0, description="Pagination cursor (offset)") ):
): """
""" Search for videos by keyword or hashtag.
Search for videos by keyword or hashtag. Uses Playwright to crawl TikTok search results for reliable data.
Uses Playwright to crawl TikTok search results for reliable data. """
""" # 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"Searching for: {query}...")
print(f"Searching for: {query} (limit={limit}, cursor={cursor})...")
try:
try: videos = await PlaywrightManager.search_videos(query, cookies, user_agent, limit)
videos = await PlaywrightManager.search_videos(query, cookies, user_agent, limit, cursor) return {"query": query, "videos": videos, "count": len(videos)}
return {"query": query, "videos": videos, "count": len(videos), "cursor": cursor + len(videos)} except Exception as e:
except Exception as e: print(f"Error searching for {query}: {e}")
print(f"Error searching for {query}: {e}") raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e))
# Cache for suggested accounts
# Cache for suggested accounts _suggested_cache = {
_suggested_cache = { "accounts": [],
"accounts": [], "updated_at": 0
"updated_at": 0 }
} CACHE_TTL = 3600 # 1 hour cache
CACHE_TTL = 3600 # 1 hour cache
@router.get("/suggested")
@router.get("/suggested") async def get_suggested_accounts(
async def get_suggested_accounts( limit: int = Query(50, description="Max accounts to return", ge=10, le=100)
limit: int = Query(50, description="Max accounts to return", ge=10, le=100) ):
): """
""" Fetch trending/suggested Vietnamese TikTok creators.
Fetch trending/suggested Vietnamese TikTok creators. Uses TikTok's discover API and caches results for 1 hour.
Uses TikTok's discover API and caches results for 1 hour. """
""" import time
import time
# Check cache
# Check cache if _suggested_cache["accounts"] and (time.time() - _suggested_cache["updated_at"]) < CACHE_TTL:
if _suggested_cache["accounts"] and (time.time() - _suggested_cache["updated_at"]) < CACHE_TTL: print("Returning cached suggested accounts")
print("Returning cached suggested accounts") return {"accounts": _suggested_cache["accounts"][:limit], "cached": True}
return {"accounts": _suggested_cache["accounts"][:limit], "cached": True}
# 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: # Return fallback static list if not authenticated
# Return fallback static list if not authenticated return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True}
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:
try: accounts = await PlaywrightManager.fetch_suggested_accounts(cookies, user_agent, limit)
accounts = await PlaywrightManager.fetch_suggested_accounts(cookies, user_agent, limit)
if accounts and len(accounts) >= 5: # Need at least 5 accounts from dynamic fetch
if accounts and len(accounts) >= 5: # Need at least 5 accounts from dynamic fetch _suggested_cache["accounts"] = accounts
_suggested_cache["accounts"] = accounts _suggested_cache["updated_at"] = time.time()
_suggested_cache["updated_at"] = time.time() return {"accounts": accounts[:limit], "cached": False}
return {"accounts": accounts[:limit], "cached": False} else:
else: # Fallback: fetch actual profile data with avatars for static list
# Fallback: fetch actual profile data with avatars for static list print("Dynamic fetch failed, fetching profile data for static accounts...")
print("Dynamic fetch failed, fetching profile data for static accounts...") fallback_list = get_fallback_accounts()[:min(limit, 20)] # Limit to 20 for speed
fallback_list = get_fallback_accounts()[:min(limit, 20)] # Limit to 20 for speed return await fetch_profiles_with_avatars(fallback_list, cookies, user_agent)
return await fetch_profiles_with_avatars(fallback_list, cookies, user_agent)
except Exception as e:
except Exception as e: print(f"Error fetching suggested accounts: {e}")
print(f"Error fetching suggested accounts: {e}") return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True}
return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True}
async def fetch_profiles_with_avatars(accounts: list, cookies: list, user_agent: str) -> dict:
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."""
"""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 = {
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", }
}
enriched = []
enriched = []
async with httpx.AsyncClient(timeout=10.0) as client:
async with httpx.AsyncClient(timeout=10.0) as client: for acc in accounts:
for acc in accounts: try:
try: url = f"https://www.tiktok.com/api/user/detail/?uniqueId={acc['username']}"
url = f"https://www.tiktok.com/api/user/detail/?uniqueId={acc['username']}" res = await client.get(url, headers=headers)
res = await client.get(url, headers=headers)
if res.status_code == 200:
if res.status_code == 200: data = res.json()
data = res.json() user = data.get("userInfo", {}).get("user", {})
user = data.get("userInfo", {}).get("user", {}) stats = data.get("userInfo", {}).get("stats", {})
stats = data.get("userInfo", {}).get("stats", {})
if user:
if user: enriched.append({
enriched.append({ "username": acc["username"],
"username": acc["username"], "nickname": user.get("nickname") or acc.get("nickname", acc["username"]),
"nickname": user.get("nickname") or acc.get("nickname", acc["username"]), "avatar": user.get("avatarThumb") or user.get("avatarMedium"),
"avatar": user.get("avatarThumb") or user.get("avatarMedium"), "followers": stats.get("followerCount", 0),
"followers": stats.get("followerCount", 0), "verified": user.get("verified", False),
"verified": user.get("verified", False), "region": "VN"
"region": "VN" })
}) continue
continue
except Exception as e:
except Exception as e: print(f"Error fetching profile for {acc['username']}: {e}")
print(f"Error fetching profile for {acc['username']}: {e}")
# Fallback: use original data without avatar
# Fallback: use original data without avatar enriched.append(acc)
enriched.append(acc)
return {"accounts": enriched, "cached": False, "enriched": True}
return {"accounts": enriched, "cached": False, "enriched": True}
def get_fallback_accounts():
def get_fallback_accounts(): """Static fallback list of popular Vietnamese TikTokers (verified usernames)."""
"""Static fallback list of popular Vietnamese TikTokers (verified usernames).""" return [
return [ # Verified Vietnamese TikTok accounts
# Verified Vietnamese TikTok accounts {"username": "cciinnn", "nickname": "👑 CiiN (Bùi Thảo Ly)", "region": "VN"},
{"username": "cciinnn", "nickname": "👑 CiiN (Bùi Thảo Ly)", "region": "VN"}, {"username": "hoaa.hanassii", "nickname": "💃 Hoa Hanassii", "region": "VN"},
{"username": "hoaa.hanassii", "nickname": "💃 Hoa Hanassii", "region": "VN"}, {"username": "lebong95", "nickname": "💪 Lê Bống", "region": "VN"},
{"username": "lebong95", "nickname": "💪 Lê Bống", "region": "VN"}, {"username": "tieu_hy26", "nickname": "👰 Tiểu Hý", "region": "VN"},
{"username": "tieu_hy26", "nickname": "👰 Tiểu Hý", "region": "VN"}, {"username": "hieuthuhai2222", "nickname": "🎧 HIEUTHUHAI", "region": "VN"},
{"username": "hieuthuhai2222", "nickname": "🎧 HIEUTHUHAI", "region": "VN"}, {"username": "mtp.fan", "nickname": "🎤 Sơn Tùng M-TP", "region": "VN"},
{"username": "mtp.fan", "nickname": "🎤 Sơn Tùng M-TP", "region": "VN"}, {"username": "changmakeup", "nickname": "💄 Changmakeup", "region": "VN"},
{"username": "changmakeup", "nickname": "💄 Changmakeup", "region": "VN"}, {"username": "theanh28entertainment", "nickname": "🎬 Theanh28", "region": "VN"},
{"username": "theanh28entertainment", "nickname": "🎬 Theanh28", "region": "VN"}, {"username": "linhbarbie", "nickname": "👗 Linh Barbie", "region": "VN"},
{"username": "linhbarbie", "nickname": "👗 Linh Barbie", "region": "VN"}, {"username": "phuonglykchau", "nickname": "✨ Phương Ly", "region": "VN"},
{"username": "phuonglykchau", "nickname": "✨ Phương Ly", "region": "VN"}, {"username": "phimtieutrang", "nickname": "📺 Tiểu Trang", "region": "VN"},
{"username": "phimtieutrang", "nickname": "📺 Tiểu Trang", "region": "VN"}, {"username": "nhunguyendy", "nickname": "💕 Như Nguyễn", "region": "VN"},
{"username": "nhunguyendy", "nickname": "💕 Như Nguyễn", "region": "VN"}, {"username": "trucnhantv", "nickname": "🎤 Trúc Nhân", "region": "VN"},
{"username": "trucnhantv", "nickname": "🎤 Trúc Nhân", "region": "VN"}, {"username": "justvietanh", "nickname": "😄 Just Việt Anh", "region": "VN"},
{"username": "justvietanh", "nickname": "😄 Just Việt Anh", "region": "VN"}, {"username": "minngu.official", "nickname": "🌸 Min NGU", "region": "VN"},
{"username": "minngu.official", "nickname": "🌸 Min NGU", "region": "VN"}, {"username": "quangdangofficial", "nickname": "🕺 Quang Đăng", "region": "VN"},
{"username": "quangdangofficial", "nickname": "🕺 Quang Đăng", "region": "VN"}, {"username": "minhhangofficial", "nickname": "👑 Minh Hằng", "region": "VN"},
{"username": "minhhangofficial", "nickname": "👑 Minh Hằng", "region": "VN"}, {"username": "dungntt", "nickname": "🎭 Dũng NTT", "region": "VN"},
{"username": "dungntt", "nickname": "🎭 Dũng NTT", "region": "VN"}, {"username": "chipu88", "nickname": "🎤 Chi Pu", "region": "VN"},
{"username": "chipu88", "nickname": "🎤 Chi Pu", "region": "VN"}, {"username": "kaydinh", "nickname": "🎵 Kay Dinh", "region": "VN"},
{"username": "kaydinh", "nickname": "🎵 Kay Dinh", "region": "VN"}, ]
]

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -1,51 +0,0 @@
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,12 +9,6 @@
#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,148 +1,135 @@
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
skipCache: boolean = false ): Promise<Video[]> {
): Promise<Video[]> { const startTime = performance.now();
const startTime = performance.now();
try {
try { if (fast) {
if (fast && !skipCache) { const videos = await this.loadWithCache('feed-fast');
const videos = await this.loadWithCache('feed-fast'); onProgress?.(videos);
onProgress?.(videos); return videos;
return videos; }
}
const cacheKey = 'feed-full';
const cacheKey = 'feed-full'; const cached = this.getCached(cacheKey);
if (cached) {
// Skip cache check when explicitly requested (for infinite scroll) onProgress?.(cached);
if (!skipCache) { return cached;
const cached = this.getCached(cacheKey); }
if (cached) {
onProgress?.(cached); const videos = await this.fetchFeed();
return cached; this.setCached(cacheKey, videos);
}
} onProgress?.(videos);
const videos = await this.fetchFeed(skipCache); this.stats.loadTime = performance.now() - startTime;
this.stats.totalLoaded = videos.length;
// Only cache if not skipping (initial load)
if (!skipCache) { return videos;
this.setCached(cacheKey, videos); } catch (error) {
} console.error('Feed load failed:', error);
return [];
onProgress?.(videos); }
}
this.stats.loadTime = performance.now() - startTime;
this.stats.totalLoaded = videos.length; private async fetchFeed(): Promise<Video[]> {
const response = await axios.get(`${API_BASE_URL}/feed`);
return videos;
} catch (error) { if (!Array.isArray(response.data)) {
console.error('Feed load failed:', error); return [];
return []; }
}
} return response.data.map((v: any, i: number) => ({
id: v.id || `video-${i}`,
private async fetchFeed(skipCache: boolean = false): Promise<Video[]> { url: v.url,
// Add skip_cache parameter to force backend to fetch fresh videos author: v.author || 'unknown',
const url = skipCache description: v.description || '',
? `${API_BASE_URL}/feed?skip_cache=true` thumbnail: v.thumbnail,
: `${API_BASE_URL}/feed`; cdn_url: v.cdn_url,
const response = await axios.get(url); views: v.views,
likes: v.likes
if (!Array.isArray(response.data)) { }));
return []; }
}
private async loadWithCache(key: string): Promise<Video[]> {
return response.data.map((v: any, i: number) => ({ const cached = this.getCached(key);
id: v.id || `video-${i}`, if (cached) return cached;
url: v.url,
author: v.author || 'unknown', const videos = await this.fetchFeed();
description: v.description || '', this.setCached(key, videos);
thumbnail: v.thumbnail, return videos;
cdn_url: v.cdn_url, }
views: v.views,
likes: v.likes private getCached(key: string): Video[] | null {
})); 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[]> { }
const cached = this.getCached(key); return null;
if (cached) return cached; }
const videos = await this.fetchFeed(); private setCached(key: string, data: Video[]): void {
this.setCached(key, videos); this.requestCache.set(key, {
return videos; data,
} timestamp: Date.now()
});
private getCached(key: string): Video[] | null { }
const cached = this.requestCache.get(key);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) { getStats(): FeedStats {
return cached.data; return { ...this.stats };
} }
return null;
} clearCache(): void {
this.requestCache.clear();
private setCached(key: string, data: Video[]): void { }
this.requestCache.set(key, {
data, getOptimalBatchSize(): number {
timestamp: Date.now() const connection = (navigator as any).connection;
});
} if (!connection) {
return 15;
getStats(): FeedStats { }
return { ...this.stats };
} const effectiveType = connection.effectiveType;
clearCache(): void { switch (effectiveType) {
this.requestCache.clear(); case '4g':
} return 20;
case '3g':
getOptimalBatchSize(): number { return 12;
const connection = (navigator as any).connection; case '2g':
return 6;
if (!connection) { default:
return 15; return 15;
} }
}
const effectiveType = connection.effectiveType;
shouldPrefetchThumbnails(): boolean {
switch (effectiveType) { const connection = (navigator as any).connection;
case '4g': if (!connection) return true;
return 20; return connection.saveData !== true;
case '3g': }
return 12; }
case '2g':
return 6; export const feedLoader = new FeedLoader();
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,128 +1,95 @@
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: 3, // Increased from 2 for better buffering lookahead: 2,
concurrency: 2, // Increased from 1 for parallel downloads concurrency: 1,
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> {
* Prefetch initial batch of videos immediately after feed loads. if (!video.url) return;
* This ensures first few videos are ready before user starts scrolling.
*/ const cached = await videoCache.get(video.url);
async prefetchInitialBatch( if (cached) {
videos: Video[], this.prefetchQueue.delete(video.id);
count: number = 3 return;
): Promise<void> { }
if (!this.isInitialized) await this.init();
if (videos.length === 0) return; try {
const controller = new AbortController();
console.log(`PREFETCH: Starting initial batch of ${Math.min(count, videos.length)} videos...`); const timeoutId = setTimeout(
() => controller.abort(),
const toPrefetch = videos this.config.timeoutMs
.slice(0, count) );
.filter((v) => v.url && !this.prefetchQueue.has(v.id));
const response = await fetch(video.url, {
// Start all prefetches in parallel (respects concurrency via browser limits) signal: controller.signal,
const promises = toPrefetch.map((video) => { headers: { Range: 'bytes=0-1048576' }
this.prefetchQueue.add(video.id); });
return this.prefetchVideo(video);
}); clearTimeout(timeoutId);
await Promise.allSettled(promises); if (response.ok) {
console.log(`PREFETCH: Initial batch complete (${toPrefetch.length} videos buffered)`); const blob = await response.blob();
} await videoCache.set(video.url, blob);
}
private async prefetchVideo(video: Video): Promise<void> { } catch (error) {
if (!video.url) return; console.debug(`Prefetch failed for ${video.id}:`, error);
} finally {
const cached = await videoCache.get(video.url); this.prefetchQueue.delete(video.id);
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)}`;
// Use thin proxy if available for better performance getQueueSize(): number {
const thinProxyUrl = video.cdn_url ? `${API_BASE_URL}/feed/thin-proxy?cdn_url=${encodeURIComponent(video.cdn_url)}` : null; return this.prefetchQueue.size;
const targetUrl = thinProxyUrl || fullProxyUrl; }
}
try {
const controller = new AbortController(); export const videoPrefetcher = new VideoPrefetcher();
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();

View file

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

View file

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

View file

@ -1,16 +0,0 @@
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()

View file

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

View file

@ -1,35 +0,0 @@
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()