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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,25 +1,25 @@
import sys import sys
import os import os
import asyncio import asyncio
# Fix sys.path for user site-packages where pip installed dependencies # Fix sys.path for user site-packages where pip installed dependencies
user_site = os.path.expanduser("~\\AppData\\Roaming\\Python\\Python312\\site-packages") user_site = os.path.expanduser("~\\AppData\\Roaming\\Python\\Python312\\site-packages")
if os.path.exists(user_site) and user_site not in sys.path: if os.path.exists(user_site) and user_site not in sys.path:
print(f"DEBUG: Adding user site-packages to path: {user_site}") print(f"DEBUG: Adding user site-packages to path: {user_site}")
sys.path.append(user_site) sys.path.append(user_site)
# Enforce ProactorEventLoopPolicy for Playwright on Windows # Enforce ProactorEventLoopPolicy for Playwright on Windows
# This is required for asyncio.create_subprocess_exec used by Playwright # This is required for asyncio.create_subprocess_exec used by Playwright
if sys.platform == "win32": if sys.platform == "win32":
# Check if policy is already set # Check if policy is already set
current_policy = asyncio.get_event_loop_policy() current_policy = asyncio.get_event_loop_policy()
if not isinstance(current_policy, asyncio.WindowsProactorEventLoopPolicy): if not isinstance(current_policy, asyncio.WindowsProactorEventLoopPolicy):
print("DEBUG: Setting WindowsProactorEventLoopPolicy") print("DEBUG: Setting WindowsProactorEventLoopPolicy")
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
else: else:
print("DEBUG: WindowsProactorEventLoopPolicy already active") print("DEBUG: WindowsProactorEventLoopPolicy already active")
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
print("🚀 Bootstrapping Uvicorn with Proactor Loop (Reload Disabled)...") print("🚀 Bootstrapping Uvicorn with Proactor Loop (Reload Disabled)...")
uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=False, loop="asyncio") 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", "domain": ".www.tiktok.com",
"expirationDate": 1784039026, "expirationDate": 1784039026,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "delay_guest_mode_vid", "name": "delay_guest_mode_vid",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "8" "value": "8"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1768142590.076948, "expirationDate": 1768142590.076948,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "msToken", "name": "msToken",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "qKUFutg-Q184Mo5kpuAm4XvPCCucGe3O2KXf5G9pHV61Hb9puK-ZVQ7XzexuVGLLzwmFZ1mVYOgR3QbKBXk58AX9UgPPPkWk_koDZF3e-gqQGg_9GGjcdIOxGN-JTL_g0FM4qN8NKV84LdU=" "value": "qKUFutg-Q184Mo5kpuAm4XvPCCucGe3O2KXf5G9pHV61Hb9puK-ZVQ7XzexuVGLLzwmFZ1mVYOgR3QbKBXk58AX9UgPPPkWk_koDZF3e-gqQGg_9GGjcdIOxGN-JTL_g0FM4qN8NKV84LdU="
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.646103, "expirationDate": 1782817620.646103,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "tt_session_tlb_tag", "name": "tt_session_tlb_tag",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "sttt%7C1%7C6M35zM57kkqAGSs_LSUdRP_________zyplxYaEARSr2PNU_6cKcB0lq4WRz1GKKY43u399i5hs%3D" "value": "sttt%7C1%7C6M35zM57kkqAGSs_LSUdRP_________zyplxYaEARSr2PNU_6cKcB0lq4WRz1GKKY43u399i5hs%3D"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1798369620.645922, "expirationDate": 1798369620.645922,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "sid_guard", "name": "sid_guard",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44%7C1767265616%7C15552000%7CTue%2C+30-Jun-2026+11%3A06%3A56+GMT" "value": "e8cdf9ccce7b924a80192b3f2d251d44%7C1767265616%7C15552000%7CTue%2C+30-Jun-2026+11%3A06%3A56+GMT"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1798814589.722864, "expirationDate": 1798814589.722864,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "ttwid", "name": "ttwid",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "1%7CAYDsetgnxt5vzYX8hD6Wq2DQ4FXiL_pqcdLwHWwz6B8%7C1767278585%7C46a4b0b8d2fc0ee903d0e781593a0d2d7b491a49752c86f034adf69319d371a0" "value": "1%7CAYDsetgnxt5vzYX8hD6Wq2DQ4FXiL_pqcdLwHWwz6B8%7C1767278585%7C46a4b0b8d2fc0ee903d0e781593a0d2d7b491a49752c86f034adf69319d371a0"
}, },
{ {
"domain": ".www.tiktok.com", "domain": ".www.tiktok.com",
"expirationDate": 1767883392, "expirationDate": 1767883392,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "perf_feed_cache", "name": "perf_feed_cache",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "{%22expireTimestamp%22:0%2C%22itemIds%22:[%22%22%2C%227584357225863335188%22%2C%227580369401002659079%22]}" "value": "{%22expireTimestamp%22:0%2C%22itemIds%22:[%22%22%2C%227584357225863335188%22%2C%227580369401002659079%22]}"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.645952, "expirationDate": 1782817620.645952,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "uid_tt", "name": "uid_tt",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c" "value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1772449601.742227, "expirationDate": 1772449601.742227,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "passport_csrf_token_default", "name": "passport_csrf_token_default",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "9c66ab10306611c75fa19c87e54fd31b" "value": "9c66ab10306611c75fa19c87e54fd31b"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "s_v_web_id", "name": "s_v_web_id",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": true, "session": true,
"storeId": null, "storeId": null,
"value": "verify_mjvcbi31_l3mxUEeU_ykis_4x6z_859S_8zEFmNseEnJU" "value": "verify_mjvcbi31_l3mxUEeU_ykis_4x6z_859S_8zEFmNseEnJU"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.64617, "expirationDate": 1782817620.64617,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "ssid_ucp_v1", "name": "ssid_ucp_v1",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw" "value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
}, },
{ {
"domain": ".www.tiktok.com", "domain": ".www.tiktok.com",
"expirationDate": 1793198587, "expirationDate": 1793198587,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "tiktok_webapp_theme", "name": "tiktok_webapp_theme",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "dark" "value": "dark"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1799409936.219767, "expirationDate": 1799409936.219767,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "_ttp", "name": "_ttp",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "32XOXKxwj8YLtBQf0OBn4TvlkPN" "value": "32XOXKxwj8YLtBQf0OBn4TvlkPN"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1772449620.645821, "expirationDate": 1772449620.645821,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "cmpl_token", "name": "cmpl_token",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "AgQYAPOF_hfkTtKPtFExgPKdOPKrXVkNUj-FDmCi6K4" "value": "AgQYAPOF_hfkTtKPtFExgPKdOPKrXVkNUj-FDmCi6K4"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1772449620.645628, "expirationDate": 1772449620.645628,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "multi_sids", "name": "multi_sids",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "6884525631502042113%3Ae8cdf9ccce7b924a80192b3f2d251d44" "value": "6884525631502042113%3Ae8cdf9ccce7b924a80192b3f2d251d44"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1769857620.645892, "expirationDate": 1769857620.645892,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "passport_auth_status_ss", "name": "passport_auth_status_ss",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "966972581a398dbb9ead189c044cf98c%2C" "value": "966972581a398dbb9ead189c044cf98c%2C"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1772449601.742082, "expirationDate": 1772449601.742082,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "passport_csrf_token", "name": "passport_csrf_token",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "9c66ab10306611c75fa19c87e54fd31b" "value": "9c66ab10306611c75fa19c87e54fd31b"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.646041, "expirationDate": 1782817620.646041,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "sessionid", "name": "sessionid",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44" "value": "e8cdf9ccce7b924a80192b3f2d251d44"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.646073, "expirationDate": 1782817620.646073,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "sessionid_ss", "name": "sessionid_ss",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44" "value": "e8cdf9ccce7b924a80192b3f2d251d44"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.646008, "expirationDate": 1782817620.646008,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "sid_tt", "name": "sid_tt",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44" "value": "e8cdf9ccce7b924a80192b3f2d251d44"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.646132, "expirationDate": 1782817620.646132,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "sid_ucp_v1", "name": "sid_ucp_v1",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw" "value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
}, },
{ {
"domain": ".www.tiktok.com", "domain": ".www.tiktok.com",
"expirationDate": 1793198587, "expirationDate": 1793198587,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "tiktok_webapp_theme_source", "name": "tiktok_webapp_theme_source",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "auto" "value": "auto"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782830586.65306, "expirationDate": 1782830586.65306,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "tt_chain_token", "name": "tt_chain_token",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "6deMEWrkAGUe9R0tCISIoQ==" "value": "6deMEWrkAGUe9R0tCISIoQ=="
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "tt_csrf_token", "name": "tt_csrf_token",
"path": "/", "path": "/",
"sameSite": "lax", "sameSite": "lax",
"secure": true, "secure": true,
"session": true, "session": true,
"storeId": null, "storeId": null,
"value": "q0Q4ki72-I7zQB6eLbpBBaqFBGrUF_v85N9s" "value": "q0Q4ki72-I7zQB6eLbpBBaqFBGrUF_v85N9s"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.645979, "expirationDate": 1782817620.645979,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "uid_tt_ss", "name": "uid_tt_ss",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c" "value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
} }
] ]

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,314 +1,314 @@
{ {
"credentials": [ "credentials": [
{ {
"domain": ".www.tiktok.com", "domain": ".www.tiktok.com",
"expirationDate": 1784039026, "expirationDate": 1784039026,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "delay_guest_mode_vid", "name": "delay_guest_mode_vid",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "8" "value": "8"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1768131143.251207, "expirationDate": 1768131143.251207,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "msToken", "name": "msToken",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "jntmMFSrdBzHw3GQQ7xigi2HLM03wLgd2s8xW8sa8bm3gVg-VJu63FlYSfvPAW6tmoNM-Ww9ho9sOKZc75EN1XIGwct0ndkyOairFWbXgkiFwPXfDpQaBA9pn2_9mSOYSylT1H60yH1ufg==" "value": "jntmMFSrdBzHw3GQQ7xigi2HLM03wLgd2s8xW8sa8bm3gVg-VJu63FlYSfvPAW6tmoNM-Ww9ho9sOKZc75EN1XIGwct0ndkyOairFWbXgkiFwPXfDpQaBA9pn2_9mSOYSylT1H60yH1ufg=="
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.646103, "expirationDate": 1782817620.646103,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "tt_session_tlb_tag", "name": "tt_session_tlb_tag",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "sttt%7C1%7C6M35zM57kkqAGSs_LSUdRP_________zyplxYaEARSr2PNU_6cKcB0lq4WRz1GKKY43u399i5hs%3D" "value": "sttt%7C1%7C6M35zM57kkqAGSs_LSUdRP_________zyplxYaEARSr2PNU_6cKcB0lq4WRz1GKKY43u399i5hs%3D"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1798369620.645922, "expirationDate": 1798369620.645922,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "sid_guard", "name": "sid_guard",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44%7C1767265616%7C15552000%7CTue%2C+30-Jun-2026+11%3A06%3A56+GMT" "value": "e8cdf9ccce7b924a80192b3f2d251d44%7C1767265616%7C15552000%7CTue%2C+30-Jun-2026+11%3A06%3A56+GMT"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1798801628.385793, "expirationDate": 1798801628.385793,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "ttwid", "name": "ttwid",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "1%7CAYDsetgnxt5vzYX8hD6Wq2DQ4FXiL_pqcdLwHWwz6B8%7C1767265624%7C6121d82381fb651afeae94341e45b87fca1d903fbec0d8a19e4dd5440a89a424" "value": "1%7CAYDsetgnxt5vzYX8hD6Wq2DQ4FXiL_pqcdLwHWwz6B8%7C1767265624%7C6121d82381fb651afeae94341e45b87fca1d903fbec0d8a19e4dd5440a89a424"
}, },
{ {
"domain": ".www.tiktok.com", "domain": ".www.tiktok.com",
"expirationDate": 1767870430, "expirationDate": 1767870430,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "perf_feed_cache", "name": "perf_feed_cache",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "{%22expireTimestamp%22:1767438000000%2C%22itemIds%22:[%227588749061168123154%22%2C%227589493510613552404%22%2C%227586917939568332054%22]}" "value": "{%22expireTimestamp%22:1767438000000%2C%22itemIds%22:[%227588749061168123154%22%2C%227589493510613552404%22%2C%227586917939568332054%22]}"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.645952, "expirationDate": 1782817620.645952,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "uid_tt", "name": "uid_tt",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c" "value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1772449601.742227, "expirationDate": 1772449601.742227,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "passport_csrf_token_default", "name": "passport_csrf_token_default",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "9c66ab10306611c75fa19c87e54fd31b" "value": "9c66ab10306611c75fa19c87e54fd31b"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "s_v_web_id", "name": "s_v_web_id",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": true, "session": true,
"storeId": null, "storeId": null,
"value": "verify_mjvcbi31_l3mxUEeU_ykis_4x6z_859S_8zEFmNseEnJU" "value": "verify_mjvcbi31_l3mxUEeU_ykis_4x6z_859S_8zEFmNseEnJU"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.64617, "expirationDate": 1782817620.64617,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "ssid_ucp_v1", "name": "ssid_ucp_v1",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw" "value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
}, },
{ {
"domain": ".www.tiktok.com", "domain": ".www.tiktok.com",
"expirationDate": 1793185625, "expirationDate": 1793185625,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "tiktok_webapp_theme", "name": "tiktok_webapp_theme",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "dark" "value": "dark"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1799409936.219767, "expirationDate": 1799409936.219767,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "_ttp", "name": "_ttp",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "32XOXKxwj8YLtBQf0OBn4TvlkPN" "value": "32XOXKxwj8YLtBQf0OBn4TvlkPN"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1772449620.645821, "expirationDate": 1772449620.645821,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "cmpl_token", "name": "cmpl_token",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "AgQYAPOF_hfkTtKPtFExgPKdOPKrXVkNUj-FDmCi6K4" "value": "AgQYAPOF_hfkTtKPtFExgPKdOPKrXVkNUj-FDmCi6K4"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1772449620.645628, "expirationDate": 1772449620.645628,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "multi_sids", "name": "multi_sids",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "6884525631502042113%3Ae8cdf9ccce7b924a80192b3f2d251d44" "value": "6884525631502042113%3Ae8cdf9ccce7b924a80192b3f2d251d44"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1769857620.645892, "expirationDate": 1769857620.645892,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "passport_auth_status_ss", "name": "passport_auth_status_ss",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "966972581a398dbb9ead189c044cf98c%2C" "value": "966972581a398dbb9ead189c044cf98c%2C"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1772449601.742082, "expirationDate": 1772449601.742082,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "passport_csrf_token", "name": "passport_csrf_token",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "9c66ab10306611c75fa19c87e54fd31b" "value": "9c66ab10306611c75fa19c87e54fd31b"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.646041, "expirationDate": 1782817620.646041,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "sessionid", "name": "sessionid",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44" "value": "e8cdf9ccce7b924a80192b3f2d251d44"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.646073, "expirationDate": 1782817620.646073,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "sessionid_ss", "name": "sessionid_ss",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44" "value": "e8cdf9ccce7b924a80192b3f2d251d44"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.646008, "expirationDate": 1782817620.646008,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "sid_tt", "name": "sid_tt",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "e8cdf9ccce7b924a80192b3f2d251d44" "value": "e8cdf9ccce7b924a80192b3f2d251d44"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.646132, "expirationDate": 1782817620.646132,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "sid_ucp_v1", "name": "sid_ucp_v1",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw" "value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
}, },
{ {
"domain": ".www.tiktok.com", "domain": ".www.tiktok.com",
"expirationDate": 1793185625, "expirationDate": 1793185625,
"hostOnly": false, "hostOnly": false,
"httpOnly": false, "httpOnly": false,
"name": "tiktok_webapp_theme_source", "name": "tiktok_webapp_theme_source",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "auto" "value": "auto"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817624.001151, "expirationDate": 1782817624.001151,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "tt_chain_token", "name": "tt_chain_token",
"path": "/", "path": "/",
"sameSite": null, "sameSite": null,
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "6deMEWrkAGUe9R0tCISIoQ==" "value": "6deMEWrkAGUe9R0tCISIoQ=="
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "tt_csrf_token", "name": "tt_csrf_token",
"path": "/", "path": "/",
"sameSite": "lax", "sameSite": "lax",
"secure": true, "secure": true,
"session": true, "session": true,
"storeId": null, "storeId": null,
"value": "q0Q4ki72-I7zQB6eLbpBBaqFBGrUF_v85N9s" "value": "q0Q4ki72-I7zQB6eLbpBBaqFBGrUF_v85N9s"
}, },
{ {
"domain": ".tiktok.com", "domain": ".tiktok.com",
"expirationDate": 1782817620.645979, "expirationDate": 1782817620.645979,
"hostOnly": false, "hostOnly": false,
"httpOnly": true, "httpOnly": true,
"name": "uid_tt_ss", "name": "uid_tt_ss",
"path": "/", "path": "/",
"sameSite": "no_restriction", "sameSite": "no_restriction",
"secure": true, "secure": true,
"session": false, "session": false,
"storeId": null, "storeId": null,
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c" "value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
} }
] ]
} }

View file

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

View file

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

View file

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