Update UI with immersive video mode, progressive loading, and grayscale theme

This commit is contained in:
Khoa Vo 2026-01-02 08:42:50 +07:00
parent 601ae284b5
commit 03e93fcfa6
23 changed files with 5684 additions and 5285 deletions

View file

@ -1,39 +1,39 @@
# Build Stage for Frontend
FROM node:18-alpine as frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Runtime Stage for Backend
FROM python:3.11-slim
# Install system dependencies required for Playwright and compiled extensions
RUN apt-get update && apt-get install -y \
curl \
git \
build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install Python dependencies
COPY backend/requirements.txt backend/
RUN pip install --no-cache-dir -r backend/requirements.txt
# Install Playwright browsers (Chromium only to save space)
RUN playwright install chromium
RUN playwright install-deps chromium
# Copy Backend Code
COPY backend/ backend/
# Copy Built Frontend Assets
COPY --from=frontend-build /app/frontend/dist /app/frontend/dist
# Expose Port
EXPOSE 8002
# Run Application
CMD ["python", "backend/main.py"]
# Build Stage for Frontend
FROM node:18-alpine as frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Runtime Stage for Backend
FROM python:3.11-slim
# Install system dependencies required for Playwright and compiled extensions
RUN apt-get update && apt-get install -y \
curl \
git \
build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install Python dependencies
COPY backend/requirements.txt backend/
RUN pip install --no-cache-dir -r backend/requirements.txt
# Install Playwright browsers (Chromium only to save space)
RUN playwright install chromium
RUN playwright install-deps chromium
# Copy Backend Code
COPY backend/ backend/
# Copy Built Frontend Assets
COPY --from=frontend-build /app/frontend/dist /app/frontend/dist
# Expose Port
EXPOSE 8002
# Run Application
CMD ["python", "backend/main.py"]

View file

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

View file

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

View file

@ -1,98 +1,98 @@
"""
Following API routes - manage followed creators.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import os
import json
router = APIRouter()
FOLLOWING_FILE = "following.json"
def load_following() -> list:
"""Load list of followed creators."""
if os.path.exists(FOLLOWING_FILE):
try:
with open(FOLLOWING_FILE, 'r') as f:
return json.load(f)
except:
return []
return []
def save_following(following: list):
"""Save list of followed creators."""
with open(FOLLOWING_FILE, 'w') as f:
json.dump(following, f, indent=2)
class FollowRequest(BaseModel):
username: str
@router.get("")
async def get_following():
"""Get list of followed creators."""
return load_following()
@router.post("")
async def add_following(request: FollowRequest):
"""Add a creator to following list."""
username = request.username.lstrip('@')
following = load_following()
if username not in following:
following.append(username)
save_following(following)
return {"status": "success", "following": following}
@router.delete("/{username}")
async def remove_following(username: str):
"""Remove a creator from following list."""
username = username.lstrip('@')
following = load_following()
if username in following:
following.remove(username)
save_following(following)
return {"status": "success", "following": following}
@router.get("/feed")
async def get_following_feed(limit_per_user: int = 5):
"""
Get a combined feed of videos from all followed creators.
"""
from core.playwright_manager import PlaywrightManager
import asyncio
following = load_following()
if not following:
return []
# Load stored credentials
cookies, user_agent = PlaywrightManager.load_stored_credentials()
if not cookies:
raise HTTPException(status_code=401, detail="Not authenticated")
tasks = [PlaywrightManager.fetch_user_videos(user, cookies, user_agent, limit_per_user) for user in following]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_videos = []
for result in results:
if isinstance(result, list):
all_videos.extend(result)
# Shuffle results to make it look like a feed
import random
random.shuffle(all_videos)
return all_videos
"""
Following API routes - manage followed creators.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import os
import json
router = APIRouter()
FOLLOWING_FILE = "following.json"
def load_following() -> list:
"""Load list of followed creators."""
if os.path.exists(FOLLOWING_FILE):
try:
with open(FOLLOWING_FILE, 'r') as f:
return json.load(f)
except:
return []
return []
def save_following(following: list):
"""Save list of followed creators."""
with open(FOLLOWING_FILE, 'w') as f:
json.dump(following, f, indent=2)
class FollowRequest(BaseModel):
username: str
@router.get("")
async def get_following():
"""Get list of followed creators."""
return load_following()
@router.post("")
async def add_following(request: FollowRequest):
"""Add a creator to following list."""
username = request.username.lstrip('@')
following = load_following()
if username not in following:
following.append(username)
save_following(following)
return {"status": "success", "following": following}
@router.delete("/{username}")
async def remove_following(username: str):
"""Remove a creator from following list."""
username = username.lstrip('@')
following = load_following()
if username in following:
following.remove(username)
save_following(following)
return {"status": "success", "following": following}
@router.get("/feed")
async def get_following_feed(limit_per_user: int = 5):
"""
Get a combined feed of videos from all followed creators.
"""
from core.playwright_manager import PlaywrightManager
import asyncio
following = load_following()
if not following:
return []
# Load stored credentials
cookies, user_agent = PlaywrightManager.load_stored_credentials()
if not cookies:
raise HTTPException(status_code=401, detail="Not authenticated")
tasks = [PlaywrightManager.fetch_user_videos(user, cookies, user_agent, limit_per_user) for user in following]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_videos = []
for result in results:
if isinstance(result, list):
all_videos.extend(result)
# Shuffle results to make it look like a feed
import random
random.shuffle(all_videos)
return all_videos

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,25 +1,25 @@
import sys
import os
import asyncio
# Fix sys.path for user site-packages where pip installed dependencies
user_site = os.path.expanduser("~\\AppData\\Roaming\\Python\\Python312\\site-packages")
if os.path.exists(user_site) and user_site not in sys.path:
print(f"DEBUG: Adding user site-packages to path: {user_site}")
sys.path.append(user_site)
# Enforce ProactorEventLoopPolicy for Playwright on Windows
# This is required for asyncio.create_subprocess_exec used by Playwright
if sys.platform == "win32":
# Check if policy is already set
current_policy = asyncio.get_event_loop_policy()
if not isinstance(current_policy, asyncio.WindowsProactorEventLoopPolicy):
print("DEBUG: Setting WindowsProactorEventLoopPolicy")
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
else:
print("DEBUG: WindowsProactorEventLoopPolicy already active")
if __name__ == "__main__":
import uvicorn
print("🚀 Bootstrapping Uvicorn with Proactor Loop (Reload Disabled)...")
uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=False, loop="asyncio")
import sys
import os
import asyncio
# Fix sys.path for user site-packages where pip installed dependencies
user_site = os.path.expanduser("~\\AppData\\Roaming\\Python\\Python312\\site-packages")
if os.path.exists(user_site) and user_site not in sys.path:
print(f"DEBUG: Adding user site-packages to path: {user_site}")
sys.path.append(user_site)
# Enforce ProactorEventLoopPolicy for Playwright on Windows
# This is required for asyncio.create_subprocess_exec used by Playwright
if sys.platform == "win32":
# Check if policy is already set
current_policy = asyncio.get_event_loop_policy()
if not isinstance(current_policy, asyncio.WindowsProactorEventLoopPolicy):
print("DEBUG: Setting WindowsProactorEventLoopPolicy")
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
else:
print("DEBUG: WindowsProactorEventLoopPolicy already active")
if __name__ == "__main__":
import uvicorn
print("🚀 Bootstrapping Uvicorn with Proactor Loop (Reload Disabled)...")
uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=False, loop="asyncio")

View file

@ -1,312 +1,312 @@
[
{
"domain": ".www.tiktok.com",
"expirationDate": 1784039026,
"hostOnly": false,
"httpOnly": false,
"name": "delay_guest_mode_vid",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "8"
},
{
"domain": ".tiktok.com",
"expirationDate": 1768142590.076948,
"hostOnly": false,
"httpOnly": false,
"name": "msToken",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "qKUFutg-Q184Mo5kpuAm4XvPCCucGe3O2KXf5G9pHV61Hb9puK-ZVQ7XzexuVGLLzwmFZ1mVYOgR3QbKBXk58AX9UgPPPkWk_koDZF3e-gqQGg_9GGjcdIOxGN-JTL_g0FM4qN8NKV84LdU="
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.646103,
"hostOnly": false,
"httpOnly": true,
"name": "tt_session_tlb_tag",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "sttt%7C1%7C6M35zM57kkqAGSs_LSUdRP_________zyplxYaEARSr2PNU_6cKcB0lq4WRz1GKKY43u399i5hs%3D"
},
{
"domain": ".tiktok.com",
"expirationDate": 1798369620.645922,
"hostOnly": false,
"httpOnly": true,
"name": "sid_guard",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44%7C1767265616%7C15552000%7CTue%2C+30-Jun-2026+11%3A06%3A56+GMT"
},
{
"domain": ".tiktok.com",
"expirationDate": 1798814589.722864,
"hostOnly": false,
"httpOnly": true,
"name": "ttwid",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "1%7CAYDsetgnxt5vzYX8hD6Wq2DQ4FXiL_pqcdLwHWwz6B8%7C1767278585%7C46a4b0b8d2fc0ee903d0e781593a0d2d7b491a49752c86f034adf69319d371a0"
},
{
"domain": ".www.tiktok.com",
"expirationDate": 1767883392,
"hostOnly": false,
"httpOnly": false,
"name": "perf_feed_cache",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "{%22expireTimestamp%22:0%2C%22itemIds%22:[%22%22%2C%227584357225863335188%22%2C%227580369401002659079%22]}"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.645952,
"hostOnly": false,
"httpOnly": true,
"name": "uid_tt",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
},
{
"domain": ".tiktok.com",
"expirationDate": 1772449601.742227,
"hostOnly": false,
"httpOnly": false,
"name": "passport_csrf_token_default",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "9c66ab10306611c75fa19c87e54fd31b"
},
{
"domain": ".tiktok.com",
"hostOnly": false,
"httpOnly": false,
"name": "s_v_web_id",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": true,
"storeId": null,
"value": "verify_mjvcbi31_l3mxUEeU_ykis_4x6z_859S_8zEFmNseEnJU"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.64617,
"hostOnly": false,
"httpOnly": true,
"name": "ssid_ucp_v1",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
},
{
"domain": ".www.tiktok.com",
"expirationDate": 1793198587,
"hostOnly": false,
"httpOnly": false,
"name": "tiktok_webapp_theme",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "dark"
},
{
"domain": ".tiktok.com",
"expirationDate": 1799409936.219767,
"hostOnly": false,
"httpOnly": false,
"name": "_ttp",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "32XOXKxwj8YLtBQf0OBn4TvlkPN"
},
{
"domain": ".tiktok.com",
"expirationDate": 1772449620.645821,
"hostOnly": false,
"httpOnly": true,
"name": "cmpl_token",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "AgQYAPOF_hfkTtKPtFExgPKdOPKrXVkNUj-FDmCi6K4"
},
{
"domain": ".tiktok.com",
"expirationDate": 1772449620.645628,
"hostOnly": false,
"httpOnly": true,
"name": "multi_sids",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "6884525631502042113%3Ae8cdf9ccce7b924a80192b3f2d251d44"
},
{
"domain": ".tiktok.com",
"expirationDate": 1769857620.645892,
"hostOnly": false,
"httpOnly": true,
"name": "passport_auth_status_ss",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "966972581a398dbb9ead189c044cf98c%2C"
},
{
"domain": ".tiktok.com",
"expirationDate": 1772449601.742082,
"hostOnly": false,
"httpOnly": false,
"name": "passport_csrf_token",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "9c66ab10306611c75fa19c87e54fd31b"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.646041,
"hostOnly": false,
"httpOnly": true,
"name": "sessionid",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.646073,
"hostOnly": false,
"httpOnly": true,
"name": "sessionid_ss",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.646008,
"hostOnly": false,
"httpOnly": true,
"name": "sid_tt",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.646132,
"hostOnly": false,
"httpOnly": true,
"name": "sid_ucp_v1",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
},
{
"domain": ".www.tiktok.com",
"expirationDate": 1793198587,
"hostOnly": false,
"httpOnly": false,
"name": "tiktok_webapp_theme_source",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "auto"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782830586.65306,
"hostOnly": false,
"httpOnly": true,
"name": "tt_chain_token",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "6deMEWrkAGUe9R0tCISIoQ=="
},
{
"domain": ".tiktok.com",
"hostOnly": false,
"httpOnly": true,
"name": "tt_csrf_token",
"path": "/",
"sameSite": "lax",
"secure": true,
"session": true,
"storeId": null,
"value": "q0Q4ki72-I7zQB6eLbpBBaqFBGrUF_v85N9s"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.645979,
"hostOnly": false,
"httpOnly": true,
"name": "uid_tt_ss",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
}
[
{
"domain": ".www.tiktok.com",
"expirationDate": 1784039026,
"hostOnly": false,
"httpOnly": false,
"name": "delay_guest_mode_vid",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "8"
},
{
"domain": ".tiktok.com",
"expirationDate": 1768142590.076948,
"hostOnly": false,
"httpOnly": false,
"name": "msToken",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "qKUFutg-Q184Mo5kpuAm4XvPCCucGe3O2KXf5G9pHV61Hb9puK-ZVQ7XzexuVGLLzwmFZ1mVYOgR3QbKBXk58AX9UgPPPkWk_koDZF3e-gqQGg_9GGjcdIOxGN-JTL_g0FM4qN8NKV84LdU="
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.646103,
"hostOnly": false,
"httpOnly": true,
"name": "tt_session_tlb_tag",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "sttt%7C1%7C6M35zM57kkqAGSs_LSUdRP_________zyplxYaEARSr2PNU_6cKcB0lq4WRz1GKKY43u399i5hs%3D"
},
{
"domain": ".tiktok.com",
"expirationDate": 1798369620.645922,
"hostOnly": false,
"httpOnly": true,
"name": "sid_guard",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44%7C1767265616%7C15552000%7CTue%2C+30-Jun-2026+11%3A06%3A56+GMT"
},
{
"domain": ".tiktok.com",
"expirationDate": 1798814589.722864,
"hostOnly": false,
"httpOnly": true,
"name": "ttwid",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "1%7CAYDsetgnxt5vzYX8hD6Wq2DQ4FXiL_pqcdLwHWwz6B8%7C1767278585%7C46a4b0b8d2fc0ee903d0e781593a0d2d7b491a49752c86f034adf69319d371a0"
},
{
"domain": ".www.tiktok.com",
"expirationDate": 1767883392,
"hostOnly": false,
"httpOnly": false,
"name": "perf_feed_cache",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "{%22expireTimestamp%22:0%2C%22itemIds%22:[%22%22%2C%227584357225863335188%22%2C%227580369401002659079%22]}"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.645952,
"hostOnly": false,
"httpOnly": true,
"name": "uid_tt",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
},
{
"domain": ".tiktok.com",
"expirationDate": 1772449601.742227,
"hostOnly": false,
"httpOnly": false,
"name": "passport_csrf_token_default",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "9c66ab10306611c75fa19c87e54fd31b"
},
{
"domain": ".tiktok.com",
"hostOnly": false,
"httpOnly": false,
"name": "s_v_web_id",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": true,
"storeId": null,
"value": "verify_mjvcbi31_l3mxUEeU_ykis_4x6z_859S_8zEFmNseEnJU"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.64617,
"hostOnly": false,
"httpOnly": true,
"name": "ssid_ucp_v1",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
},
{
"domain": ".www.tiktok.com",
"expirationDate": 1793198587,
"hostOnly": false,
"httpOnly": false,
"name": "tiktok_webapp_theme",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "dark"
},
{
"domain": ".tiktok.com",
"expirationDate": 1799409936.219767,
"hostOnly": false,
"httpOnly": false,
"name": "_ttp",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "32XOXKxwj8YLtBQf0OBn4TvlkPN"
},
{
"domain": ".tiktok.com",
"expirationDate": 1772449620.645821,
"hostOnly": false,
"httpOnly": true,
"name": "cmpl_token",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "AgQYAPOF_hfkTtKPtFExgPKdOPKrXVkNUj-FDmCi6K4"
},
{
"domain": ".tiktok.com",
"expirationDate": 1772449620.645628,
"hostOnly": false,
"httpOnly": true,
"name": "multi_sids",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "6884525631502042113%3Ae8cdf9ccce7b924a80192b3f2d251d44"
},
{
"domain": ".tiktok.com",
"expirationDate": 1769857620.645892,
"hostOnly": false,
"httpOnly": true,
"name": "passport_auth_status_ss",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "966972581a398dbb9ead189c044cf98c%2C"
},
{
"domain": ".tiktok.com",
"expirationDate": 1772449601.742082,
"hostOnly": false,
"httpOnly": false,
"name": "passport_csrf_token",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "9c66ab10306611c75fa19c87e54fd31b"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.646041,
"hostOnly": false,
"httpOnly": true,
"name": "sessionid",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.646073,
"hostOnly": false,
"httpOnly": true,
"name": "sessionid_ss",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.646008,
"hostOnly": false,
"httpOnly": true,
"name": "sid_tt",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.646132,
"hostOnly": false,
"httpOnly": true,
"name": "sid_ucp_v1",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
},
{
"domain": ".www.tiktok.com",
"expirationDate": 1793198587,
"hostOnly": false,
"httpOnly": false,
"name": "tiktok_webapp_theme_source",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "auto"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782830586.65306,
"hostOnly": false,
"httpOnly": true,
"name": "tt_chain_token",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "6deMEWrkAGUe9R0tCISIoQ=="
},
{
"domain": ".tiktok.com",
"hostOnly": false,
"httpOnly": true,
"name": "tt_csrf_token",
"path": "/",
"sameSite": "lax",
"secure": true,
"session": true,
"storeId": null,
"value": "q0Q4ki72-I7zQB6eLbpBBaqFBGrUF_v85N9s"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.645979,
"hostOnly": false,
"httpOnly": true,
"name": "uid_tt_ss",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
}
]

View file

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

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

@ -1,30 +1,30 @@
import urllib.request
import json
try:
print("Testing /health...")
with urllib.request.urlopen("http://localhost:8002/health", timeout=5) as r:
print(f"Health: {r.status}")
print("Testing /api/feed...")
with open("temp_cookies.json", "r") as f:
data = json.load(f)
# Ensure list format
if isinstance(data, dict) and "credentials" in data:
data = data["credentials"]
# Prepare body as dict for safety with new Union type
body = {"credentials": data}
req = urllib.request.Request(
"http://localhost:8002/api/feed",
data=json.dumps(body).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
with urllib.request.urlopen(req, timeout=30) as r:
print(f"Feed: {r.status}")
print(r.read().decode('utf-8')[:100])
except Exception as e:
print(f"Error: {e}")
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 +1,314 @@
{
"credentials": [
{
"domain": ".www.tiktok.com",
"expirationDate": 1784039026,
"hostOnly": false,
"httpOnly": false,
"name": "delay_guest_mode_vid",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "8"
},
{
"domain": ".tiktok.com",
"expirationDate": 1768131143.251207,
"hostOnly": false,
"httpOnly": false,
"name": "msToken",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "jntmMFSrdBzHw3GQQ7xigi2HLM03wLgd2s8xW8sa8bm3gVg-VJu63FlYSfvPAW6tmoNM-Ww9ho9sOKZc75EN1XIGwct0ndkyOairFWbXgkiFwPXfDpQaBA9pn2_9mSOYSylT1H60yH1ufg=="
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.646103,
"hostOnly": false,
"httpOnly": true,
"name": "tt_session_tlb_tag",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "sttt%7C1%7C6M35zM57kkqAGSs_LSUdRP_________zyplxYaEARSr2PNU_6cKcB0lq4WRz1GKKY43u399i5hs%3D"
},
{
"domain": ".tiktok.com",
"expirationDate": 1798369620.645922,
"hostOnly": false,
"httpOnly": true,
"name": "sid_guard",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44%7C1767265616%7C15552000%7CTue%2C+30-Jun-2026+11%3A06%3A56+GMT"
},
{
"domain": ".tiktok.com",
"expirationDate": 1798801628.385793,
"hostOnly": false,
"httpOnly": true,
"name": "ttwid",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "1%7CAYDsetgnxt5vzYX8hD6Wq2DQ4FXiL_pqcdLwHWwz6B8%7C1767265624%7C6121d82381fb651afeae94341e45b87fca1d903fbec0d8a19e4dd5440a89a424"
},
{
"domain": ".www.tiktok.com",
"expirationDate": 1767870430,
"hostOnly": false,
"httpOnly": false,
"name": "perf_feed_cache",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "{%22expireTimestamp%22:1767438000000%2C%22itemIds%22:[%227588749061168123154%22%2C%227589493510613552404%22%2C%227586917939568332054%22]}"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.645952,
"hostOnly": false,
"httpOnly": true,
"name": "uid_tt",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
},
{
"domain": ".tiktok.com",
"expirationDate": 1772449601.742227,
"hostOnly": false,
"httpOnly": false,
"name": "passport_csrf_token_default",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "9c66ab10306611c75fa19c87e54fd31b"
},
{
"domain": ".tiktok.com",
"hostOnly": false,
"httpOnly": false,
"name": "s_v_web_id",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": true,
"storeId": null,
"value": "verify_mjvcbi31_l3mxUEeU_ykis_4x6z_859S_8zEFmNseEnJU"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.64617,
"hostOnly": false,
"httpOnly": true,
"name": "ssid_ucp_v1",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
},
{
"domain": ".www.tiktok.com",
"expirationDate": 1793185625,
"hostOnly": false,
"httpOnly": false,
"name": "tiktok_webapp_theme",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "dark"
},
{
"domain": ".tiktok.com",
"expirationDate": 1799409936.219767,
"hostOnly": false,
"httpOnly": false,
"name": "_ttp",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "32XOXKxwj8YLtBQf0OBn4TvlkPN"
},
{
"domain": ".tiktok.com",
"expirationDate": 1772449620.645821,
"hostOnly": false,
"httpOnly": true,
"name": "cmpl_token",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "AgQYAPOF_hfkTtKPtFExgPKdOPKrXVkNUj-FDmCi6K4"
},
{
"domain": ".tiktok.com",
"expirationDate": 1772449620.645628,
"hostOnly": false,
"httpOnly": true,
"name": "multi_sids",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "6884525631502042113%3Ae8cdf9ccce7b924a80192b3f2d251d44"
},
{
"domain": ".tiktok.com",
"expirationDate": 1769857620.645892,
"hostOnly": false,
"httpOnly": true,
"name": "passport_auth_status_ss",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "966972581a398dbb9ead189c044cf98c%2C"
},
{
"domain": ".tiktok.com",
"expirationDate": 1772449601.742082,
"hostOnly": false,
"httpOnly": false,
"name": "passport_csrf_token",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "9c66ab10306611c75fa19c87e54fd31b"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.646041,
"hostOnly": false,
"httpOnly": true,
"name": "sessionid",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.646073,
"hostOnly": false,
"httpOnly": true,
"name": "sessionid_ss",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.646008,
"hostOnly": false,
"httpOnly": true,
"name": "sid_tt",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.646132,
"hostOnly": false,
"httpOnly": true,
"name": "sid_ucp_v1",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
},
{
"domain": ".www.tiktok.com",
"expirationDate": 1793185625,
"hostOnly": false,
"httpOnly": false,
"name": "tiktok_webapp_theme_source",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "auto"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817624.001151,
"hostOnly": false,
"httpOnly": true,
"name": "tt_chain_token",
"path": "/",
"sameSite": null,
"secure": true,
"session": false,
"storeId": null,
"value": "6deMEWrkAGUe9R0tCISIoQ=="
},
{
"domain": ".tiktok.com",
"hostOnly": false,
"httpOnly": true,
"name": "tt_csrf_token",
"path": "/",
"sameSite": "lax",
"secure": true,
"session": true,
"storeId": null,
"value": "q0Q4ki72-I7zQB6eLbpBBaqFBGrUF_v85N9s"
},
{
"domain": ".tiktok.com",
"expirationDate": 1782817620.645979,
"hostOnly": false,
"httpOnly": true,
"name": "uid_tt_ss",
"path": "/",
"sameSite": "no_restriction",
"secure": true,
"session": false,
"storeId": null,
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
}
]
{
"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 +1,16 @@
import requests
import time
URL = "http://localhost:8002/api/auth/admin-login"
def test_login():
print("Testing Admin Login...")
try:
res = requests.post(URL, json={"password": "admin123"})
print(f"Status: {res.status_code}")
print(f"Response: {res.text}")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
test_login()
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 +1,30 @@
import urllib.request
import json
import os
with open("temp_cookies.json", "r") as f:
data = json.load(f)
# Ensure data is in the expected dict format for the request body
if isinstance(data, list):
# If temp_cookies is just the list, wrap it
body = {"credentials": data}
elif "credentials" not in data:
body = {"credentials": data}
else:
body = data
req = urllib.request.Request(
"http://localhost:8002/api/feed",
data=json.dumps(body).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
try:
with urllib.request.urlopen(req) as response:
print(response.read().decode('utf-8'))
except urllib.error.HTTPError as e:
print(f"HTTP Error: {e.code}")
print(e.read().decode('utf-8'))
except Exception as e:
print(f"Error: {e}")
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 +1,35 @@
import requests
import json
import time
BASE_URL = "http://localhost:8002/api/user/search"
def test_search():
print("Testing Search API...")
try:
# Simple query
params = {
"query": "dance",
"limit": 50,
"cursor": 0
}
start = time.time()
res = requests.get(BASE_URL, params=params)
duration = time.time() - start
print(f"Status Code: {res.status_code}")
print(f"Duration: {duration:.2f}s")
if res.status_code == 200:
data = res.json()
print(f"Videos Found: {len(data.get('videos', []))}")
# print(json.dumps(data, indent=2))
else:
print("Error Response:")
print(res.text)
except Exception as e:
print(f"Request Failed: {e}")
if __name__ == "__main__":
test_search()
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()