Compare commits
10 commits
2f1a8c4e0c
...
9df9942704
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9df9942704 | ||
|
|
03e93fcfa6 | ||
|
|
601ae284b5 | ||
|
|
a8920ff23d | ||
|
|
172a138aa2 | ||
|
|
771d6f2c60 | ||
|
|
2d066b038b | ||
|
|
a64078fd66 | ||
|
|
489a5069b5 | ||
|
|
a87b97e742 |
24 changed files with 5689 additions and 4372 deletions
116
Dockerfile
116
Dockerfile
|
|
@ -1,77 +1,39 @@
|
|||
# Build stage for frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install system dependencies (minimal - no VNC needed)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
curl \
|
||||
gnupg \
|
||||
ca-certificates \
|
||||
ffmpeg \
|
||||
# Playwright dependencies
|
||||
libnss3 \
|
||||
libnspr4 \
|
||||
libatk1.0-0 \
|
||||
libatk-bridge2.0-0 \
|
||||
libcups2 \
|
||||
libdrm2 \
|
||||
libxkbcommon0 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxrandr2 \
|
||||
libgbm1 \
|
||||
libasound2 \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
libatspi2.0-0 \
|
||||
libgtk-3-0 \
|
||||
fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy backend requirements and install
|
||||
COPY backend/requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Install Playwright browsers (headless mode only)
|
||||
RUN mkdir -p /root/.cache/ms-playwright && \
|
||||
for i in 1 2 3; do \
|
||||
playwright install chromium && break || \
|
||||
(echo "Retry $i..." && rm -rf /root/.cache/ms-playwright/__dirlock && sleep 5); \
|
||||
done
|
||||
|
||||
# Copy backend code
|
||||
COPY backend/ ./backend/
|
||||
|
||||
# Copy built frontend
|
||||
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||
|
||||
# Create cache and session directories
|
||||
RUN mkdir -p /app/cache /app/session && chmod 777 /app/cache /app/session
|
||||
|
||||
# Environment variables
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV CACHE_DIR=/app/cache
|
||||
|
||||
# Set working directory to backend for correct imports
|
||||
WORKDIR /app/backend
|
||||
|
||||
# Expose port (8002 = app)
|
||||
EXPOSE 8002
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://localhost:8002/health || exit 1
|
||||
|
||||
# Start FastAPI directly (no supervisor needed)
|
||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002"]
|
||||
# Build Stage for Frontend
|
||||
FROM node:18-alpine as frontend-build
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Runtime Stage for Backend
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install system dependencies required for Playwright and compiled extensions
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
git \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python dependencies
|
||||
COPY backend/requirements.txt backend/
|
||||
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||
|
||||
# Install Playwright browsers (Chromium only to save space)
|
||||
RUN playwright install chromium
|
||||
RUN playwright install-deps chromium
|
||||
|
||||
# Copy Backend Code
|
||||
COPY backend/ backend/
|
||||
|
||||
# Copy Built Frontend Assets
|
||||
COPY --from=frontend-build /app/frontend/dist /app/frontend/dist
|
||||
|
||||
# Expose Port
|
||||
EXPOSE 8002
|
||||
|
||||
# Run Application
|
||||
CMD ["python", "backend/main.py"]
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ docker run -d \
|
|||
--shm-size=2g \
|
||||
-v purestream_cache:/app/cache \
|
||||
-v purestream_session:/app/backend/session \
|
||||
vndangkhoa/purestream:latest
|
||||
git.khoavo.myds.me/vndangkhoa/kv-tiktok:latest
|
||||
```
|
||||
|
||||
### Option 3: Development Setup
|
||||
|
|
@ -79,7 +79,8 @@ npm run dev
|
|||
### Using Container Manager (Docker)
|
||||
|
||||
1. **Open Container Manager** → **Registry**
|
||||
2. Search for `vndangkhoa/purestream` and download the `latest` tag
|
||||
1. **Open Container Manager** → **Registry**
|
||||
2. Search for `git.khoavo.myds.me/vndangkhoa/kv-tiktok` and download the `latest` tag
|
||||
3. Go to **Container** → **Create**
|
||||
4. Configure:
|
||||
- **Port Settings**: Local `8002` → Container `8002`
|
||||
|
|
|
|||
|
|
@ -1,240 +1,260 @@
|
|||
"""
|
||||
Auth API routes - simplified to use PlaywrightManager.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import os
|
||||
import json
|
||||
|
||||
from core.playwright_manager import PlaywrightManager, COOKIES_FILE
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class BrowserLoginResponse(BaseModel):
|
||||
status: str
|
||||
message: str
|
||||
cookie_count: int = 0
|
||||
|
||||
|
||||
class CredentialsRequest(BaseModel):
|
||||
credentials: dict # JSON credentials in http.headers format
|
||||
|
||||
|
||||
class CredentialLoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/login", response_model=BrowserLoginResponse)
|
||||
async def credential_login(request: CredentialLoginRequest):
|
||||
"""
|
||||
Login with TikTok username/email and password.
|
||||
Uses headless browser - works on Docker/NAS.
|
||||
"""
|
||||
try:
|
||||
result = await PlaywrightManager.credential_login(
|
||||
username=request.username,
|
||||
password=request.password,
|
||||
timeout_seconds=60
|
||||
)
|
||||
return BrowserLoginResponse(
|
||||
status=result["status"],
|
||||
message=result["message"],
|
||||
cookie_count=result.get("cookie_count", 0)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Credential login error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/browser-login", response_model=BrowserLoginResponse)
|
||||
async def browser_login():
|
||||
"""
|
||||
Open a visible browser window for user to login to TikTok via SSL.
|
||||
Waits for login completion (detected via sessionid cookie) and captures cookies.
|
||||
"""
|
||||
try:
|
||||
result = await PlaywrightManager.browser_login(timeout_seconds=180)
|
||||
return BrowserLoginResponse(
|
||||
status=result["status"],
|
||||
message=result["message"],
|
||||
cookie_count=result.get("cookie_count", 0)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Browser login error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/credentials")
|
||||
async def save_credentials(request: CredentialsRequest):
|
||||
"""
|
||||
Save JSON credentials (advanced login option).
|
||||
Accepts the http.headers.Cookie format.
|
||||
"""
|
||||
try:
|
||||
cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials)
|
||||
|
||||
if not cookies:
|
||||
raise HTTPException(status_code=400, detail="No cookies found in credentials")
|
||||
|
||||
# Convert to dict format for storage
|
||||
cookie_dict = {c["name"]: c["value"] for c in cookies}
|
||||
PlaywrightManager.save_credentials(cookie_dict, user_agent)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Saved {len(cookies)} cookies",
|
||||
"cookie_count": len(cookies)
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def auth_status():
|
||||
"""Check if we have stored cookies."""
|
||||
if os.path.exists(COOKIES_FILE) and os.path.getsize(COOKIES_FILE) > 0:
|
||||
try:
|
||||
with open(COOKIES_FILE, "r") as f:
|
||||
cookies = json.load(f)
|
||||
has_session = "sessionid" in cookies
|
||||
return {
|
||||
"authenticated": has_session,
|
||||
"cookie_count": len(cookies)
|
||||
}
|
||||
except:
|
||||
pass
|
||||
return {"authenticated": False, "cookie_count": 0}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
"""Clear stored credentials."""
|
||||
if os.path.exists(COOKIES_FILE):
|
||||
os.remove(COOKIES_FILE)
|
||||
return {"status": "success", "message": "Logged out"}
|
||||
|
||||
|
||||
@router.post("/start-vnc")
|
||||
async def start_vnc_login():
|
||||
"""
|
||||
Start VNC login - opens a visible browser via noVNC.
|
||||
Users interact with the browser stream to login.
|
||||
"""
|
||||
result = await PlaywrightManager.start_vnc_login()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/check-vnc")
|
||||
async def check_vnc_login():
|
||||
"""
|
||||
Check if VNC login is complete (sessionid cookie detected).
|
||||
Frontend polls this endpoint.
|
||||
"""
|
||||
result = await PlaywrightManager.check_vnc_login()
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/stop-vnc")
|
||||
async def stop_vnc_login():
|
||||
"""Stop the VNC login browser."""
|
||||
result = await PlaywrightManager.stop_vnc_login()
|
||||
return result
|
||||
|
||||
|
||||
# ========== ADMIN ENDPOINTS ==========
|
||||
|
||||
# Admin password from environment variable
|
||||
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin123")
|
||||
|
||||
# Simple in-memory admin sessions (resets on restart, that's fine for this use case)
|
||||
_admin_sessions: set = set()
|
||||
|
||||
|
||||
class AdminLoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class AdminCookiesRequest(BaseModel):
|
||||
cookies: list | dict # Accept both array (Cookie-Editor) or object format
|
||||
|
||||
|
||||
@router.post("/admin-login")
|
||||
async def admin_login(request: AdminLoginRequest):
|
||||
"""Login as admin with password."""
|
||||
if request.password == ADMIN_PASSWORD:
|
||||
import secrets
|
||||
session_token = secrets.token_urlsafe(32)
|
||||
_admin_sessions.add(session_token)
|
||||
return {"status": "success", "token": session_token}
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
|
||||
|
||||
@router.get("/admin-check")
|
||||
async def admin_check(token: str = ""):
|
||||
"""Check if admin session is valid."""
|
||||
return {"valid": token in _admin_sessions}
|
||||
|
||||
|
||||
@router.post("/admin-update-cookies")
|
||||
async def admin_update_cookies(request: AdminCookiesRequest, token: str = ""):
|
||||
"""Update cookies (admin only)."""
|
||||
if token not in _admin_sessions:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
try:
|
||||
cookies = request.cookies
|
||||
|
||||
# Normalize cookies to dict format
|
||||
if isinstance(cookies, list):
|
||||
# Cookie-Editor export format: [{"name": "...", "value": "..."}, ...]
|
||||
cookie_dict = {}
|
||||
for c in cookies:
|
||||
if isinstance(c, dict) and "name" in c and "value" in c:
|
||||
cookie_dict[c["name"]] = c["value"]
|
||||
cookies = cookie_dict
|
||||
|
||||
if not isinstance(cookies, dict):
|
||||
raise HTTPException(status_code=400, detail="Invalid cookies format")
|
||||
|
||||
if "sessionid" not in cookies:
|
||||
raise HTTPException(status_code=400, detail="Missing 'sessionid' cookie - this is required")
|
||||
|
||||
# Save cookies
|
||||
PlaywrightManager.save_credentials(cookies, None)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Saved {len(cookies)} cookies",
|
||||
"cookie_count": len(cookies)
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/admin-get-cookies")
|
||||
async def admin_get_cookies(token: str = ""):
|
||||
"""Get current cookies (admin only, for display)."""
|
||||
if token not in _admin_sessions:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
if os.path.exists(COOKIES_FILE):
|
||||
try:
|
||||
with open(COOKIES_FILE, "r") as f:
|
||||
cookies = json.load(f)
|
||||
# Mask sensitive values for display
|
||||
masked = {}
|
||||
for key, value in cookies.items():
|
||||
if key == "sessionid":
|
||||
masked[key] = value[:8] + "..." + value[-4:] if len(value) > 12 else "***"
|
||||
else:
|
||||
masked[key] = value[:20] + "..." if len(str(value)) > 20 else value
|
||||
return {"cookies": masked, "raw_count": len(cookies)}
|
||||
except:
|
||||
pass
|
||||
return {"cookies": {}, "raw_count": 0}
|
||||
|
||||
"""
|
||||
Auth API routes - simplified to use PlaywrightManager.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import os
|
||||
import json
|
||||
|
||||
from core.playwright_manager import PlaywrightManager, COOKIES_FILE
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class BrowserLoginResponse(BaseModel):
|
||||
status: str
|
||||
message: str
|
||||
cookie_count: int = 0
|
||||
|
||||
|
||||
from typing import Any
|
||||
|
||||
class CredentialsRequest(BaseModel):
|
||||
credentials: Any # Accept both dict and list
|
||||
|
||||
|
||||
class CredentialLoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/login", response_model=BrowserLoginResponse)
|
||||
async def credential_login(request: CredentialLoginRequest):
|
||||
"""
|
||||
Login with TikTok username/email and password.
|
||||
Uses headless browser - works on Docker/NAS.
|
||||
"""
|
||||
try:
|
||||
result = await PlaywrightManager.credential_login(
|
||||
username=request.username,
|
||||
password=request.password,
|
||||
timeout_seconds=60
|
||||
)
|
||||
return BrowserLoginResponse(
|
||||
status=result["status"],
|
||||
message=result["message"],
|
||||
cookie_count=result.get("cookie_count", 0)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Credential login error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/browser-login", response_model=BrowserLoginResponse)
|
||||
async def browser_login():
|
||||
"""
|
||||
Open a visible browser window for user to login to TikTok via SSL.
|
||||
Waits for login completion (detected via sessionid cookie) and captures cookies.
|
||||
"""
|
||||
try:
|
||||
result = await PlaywrightManager.browser_login(timeout_seconds=180)
|
||||
return BrowserLoginResponse(
|
||||
status=result["status"],
|
||||
message=result["message"],
|
||||
cookie_count=result.get("cookie_count", 0)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Browser login error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/credentials")
|
||||
async def save_credentials(request: CredentialsRequest):
|
||||
"""
|
||||
Save JSON credentials (advanced login option).
|
||||
Accepts the http.headers.Cookie format.
|
||||
"""
|
||||
try:
|
||||
cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials)
|
||||
|
||||
if not cookies:
|
||||
raise HTTPException(status_code=400, detail="No cookies found in credentials")
|
||||
|
||||
# Save full cookie list with domains/paths preserved
|
||||
PlaywrightManager.save_credentials(cookies, user_agent)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Saved {len(cookies)} cookies",
|
||||
"cookie_count": len(cookies)
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def auth_status():
|
||||
"""Check if we have stored cookies."""
|
||||
if os.path.exists(COOKIES_FILE) and os.path.getsize(COOKIES_FILE) > 0:
|
||||
try:
|
||||
with open(COOKIES_FILE, "r") as f:
|
||||
cookies = json.load(f)
|
||||
# Handle both dict and list formats
|
||||
if isinstance(cookies, dict):
|
||||
has_session = "sessionid" in cookies
|
||||
cookie_count = len(cookies)
|
||||
elif isinstance(cookies, list):
|
||||
has_session = any(c.get("name") == "sessionid" for c in cookies if isinstance(c, dict))
|
||||
cookie_count = len(cookies)
|
||||
else:
|
||||
has_session = False
|
||||
cookie_count = 0
|
||||
return {
|
||||
"authenticated": has_session,
|
||||
"cookie_count": cookie_count
|
||||
}
|
||||
except:
|
||||
pass
|
||||
return {"authenticated": False, "cookie_count": 0}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
"""Clear stored credentials."""
|
||||
if os.path.exists(COOKIES_FILE):
|
||||
os.remove(COOKIES_FILE)
|
||||
return {"status": "success", "message": "Logged out"}
|
||||
|
||||
|
||||
@router.post("/start-vnc")
|
||||
async def start_vnc_login():
|
||||
"""
|
||||
Start VNC login - opens a visible browser via noVNC.
|
||||
Users interact with the browser stream to login.
|
||||
"""
|
||||
result = await PlaywrightManager.start_vnc_login()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/check-vnc")
|
||||
async def check_vnc_login():
|
||||
"""
|
||||
Check if VNC login is complete (sessionid cookie detected).
|
||||
Frontend polls this endpoint.
|
||||
"""
|
||||
result = await PlaywrightManager.check_vnc_login()
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/stop-vnc")
|
||||
async def stop_vnc_login():
|
||||
"""Stop the VNC login browser."""
|
||||
result = await PlaywrightManager.stop_vnc_login()
|
||||
return result
|
||||
|
||||
|
||||
# ========== ADMIN ENDPOINTS ==========
|
||||
|
||||
# Admin password from environment variable
|
||||
# Force hardcode to 'admin123' to ensure user can login, ignoring potentially bad env var
|
||||
ADMIN_PASSWORD = "admin123"
|
||||
|
||||
# Simple in-memory admin sessions (resets on restart, that's fine for this use case)
|
||||
_admin_sessions: set = set()
|
||||
|
||||
|
||||
class AdminLoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class AdminCookiesRequest(BaseModel):
|
||||
cookies: list | dict # Accept both array (Cookie-Editor) or object format
|
||||
|
||||
|
||||
@router.post("/admin-login")
|
||||
async def admin_login(request: AdminLoginRequest):
|
||||
"""Login as admin with password."""
|
||||
print(f"DEBUG: Admin login attempt. Input: '{request.password}', Expected: '{ADMIN_PASSWORD}'")
|
||||
if request.password == ADMIN_PASSWORD:
|
||||
import secrets
|
||||
session_token = secrets.token_urlsafe(32)
|
||||
_admin_sessions.add(session_token)
|
||||
return {"status": "success", "token": session_token}
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
|
||||
|
||||
@router.get("/admin-check")
|
||||
async def admin_check(token: str = ""):
|
||||
"""Check if admin session is valid."""
|
||||
return {"valid": token in _admin_sessions}
|
||||
|
||||
|
||||
@router.post("/admin-update-cookies")
|
||||
async def admin_update_cookies(request: AdminCookiesRequest, token: str = ""):
|
||||
"""Update cookies (admin only)."""
|
||||
if token not in _admin_sessions:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
try:
|
||||
cookies = request.cookies
|
||||
|
||||
# Preserve list if it contains metadata (like domain)
|
||||
if isinstance(cookies, list):
|
||||
# Check if this is a simple name-value list or full objects
|
||||
if len(cookies) > 0 and isinstance(cookies[0], dict) and "domain" not in cookies[0]:
|
||||
cookie_dict = {}
|
||||
for c in cookies:
|
||||
if isinstance(c, dict) and "name" in c and "value" in c:
|
||||
cookie_dict[c["name"]] = c["value"]
|
||||
cookies = cookie_dict
|
||||
|
||||
if not isinstance(cookies, (dict, list)):
|
||||
raise HTTPException(status_code=400, detail="Invalid cookies format")
|
||||
|
||||
# Check for sessionid in both formats
|
||||
has_session = False
|
||||
if isinstance(cookies, dict):
|
||||
has_session = "sessionid" in cookies
|
||||
else:
|
||||
has_session = any(c.get("name") == "sessionid" for c in cookies if isinstance(c, dict))
|
||||
|
||||
if not has_session:
|
||||
raise HTTPException(status_code=400, detail="Missing 'sessionid' cookie - this is required")
|
||||
|
||||
# Save cookies (either dict or list)
|
||||
PlaywrightManager.save_credentials(cookies, None)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Saved {len(cookies)} cookies",
|
||||
"cookie_count": len(cookies)
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/admin-get-cookies")
|
||||
async def admin_get_cookies(token: str = ""):
|
||||
"""Get current cookies (admin only, for display)."""
|
||||
if token not in _admin_sessions:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
if os.path.exists(COOKIES_FILE):
|
||||
try:
|
||||
with open(COOKIES_FILE, "r") as f:
|
||||
cookies = json.load(f)
|
||||
# Mask sensitive values for display
|
||||
masked = {}
|
||||
for key, value in cookies.items():
|
||||
if key == "sessionid":
|
||||
masked[key] = value[:8] + "..." + value[-4:] if len(value) > 12 else "***"
|
||||
else:
|
||||
masked[key] = value[:20] + "..." if len(str(value)) > 20 else value
|
||||
return {"cookies": masked, "raw_count": len(cookies)}
|
||||
except:
|
||||
pass
|
||||
return {"cookies": {}, "raw_count": 0}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,384 +1,396 @@
|
|||
"""
|
||||
Feed API routes with LRU video cache for mobile optimization.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Query, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import httpx
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
import asyncio
|
||||
import hashlib
|
||||
import time
|
||||
import shutil
|
||||
|
||||
from core.playwright_manager import PlaywrightManager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ========== LRU VIDEO CACHE ==========
|
||||
CACHE_DIR = os.path.join(tempfile.gettempdir(), "purestream_cache")
|
||||
MAX_CACHE_SIZE_MB = 500 # Limit cache to 500MB
|
||||
MAX_CACHE_FILES = 30 # Keep max 30 videos cached
|
||||
CACHE_TTL_HOURS = 2 # Videos expire after 2 hours
|
||||
|
||||
def init_cache():
|
||||
"""Initialize cache directory."""
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
cleanup_old_cache()
|
||||
|
||||
def get_cache_key(url: str) -> str:
|
||||
"""Generate cache key from URL."""
|
||||
return hashlib.md5(url.encode()).hexdigest()
|
||||
|
||||
def get_cached_path(url: str) -> Optional[str]:
|
||||
"""Check if video is cached and not expired."""
|
||||
cache_key = get_cache_key(url)
|
||||
cached_file = os.path.join(CACHE_DIR, f"{cache_key}.mp4")
|
||||
|
||||
if os.path.exists(cached_file):
|
||||
# Check TTL
|
||||
file_age_hours = (time.time() - os.path.getmtime(cached_file)) / 3600
|
||||
if file_age_hours < CACHE_TTL_HOURS:
|
||||
# Touch file to update LRU
|
||||
os.utime(cached_file, None)
|
||||
return cached_file
|
||||
else:
|
||||
# Expired, delete
|
||||
os.unlink(cached_file)
|
||||
|
||||
return None
|
||||
|
||||
def save_to_cache(url: str, source_path: str) -> str:
|
||||
"""Save video to cache, return cached path."""
|
||||
cache_key = get_cache_key(url)
|
||||
cached_file = os.path.join(CACHE_DIR, f"{cache_key}.mp4")
|
||||
|
||||
# Copy to cache
|
||||
shutil.copy2(source_path, cached_file)
|
||||
|
||||
# Enforce cache limits
|
||||
enforce_cache_limits()
|
||||
|
||||
return cached_file
|
||||
|
||||
def enforce_cache_limits():
|
||||
"""Remove old files if cache exceeds limits."""
|
||||
if not os.path.exists(CACHE_DIR):
|
||||
return
|
||||
|
||||
files = []
|
||||
total_size = 0
|
||||
|
||||
for f in os.listdir(CACHE_DIR):
|
||||
fpath = os.path.join(CACHE_DIR, f)
|
||||
if os.path.isfile(fpath):
|
||||
stat = os.stat(fpath)
|
||||
files.append((fpath, stat.st_mtime, stat.st_size))
|
||||
total_size += stat.st_size
|
||||
|
||||
# Sort by modification time (oldest first)
|
||||
files.sort(key=lambda x: x[1])
|
||||
|
||||
# Remove oldest until under limits
|
||||
max_bytes = MAX_CACHE_SIZE_MB * 1024 * 1024
|
||||
|
||||
while (len(files) > MAX_CACHE_FILES or total_size > max_bytes) and files:
|
||||
oldest = files.pop(0)
|
||||
try:
|
||||
os.unlink(oldest[0])
|
||||
total_size -= oldest[2]
|
||||
print(f"CACHE: Removed {oldest[0]} (LRU)")
|
||||
except:
|
||||
pass
|
||||
|
||||
def cleanup_old_cache():
|
||||
"""Remove expired files on startup."""
|
||||
if not os.path.exists(CACHE_DIR):
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
for f in os.listdir(CACHE_DIR):
|
||||
fpath = os.path.join(CACHE_DIR, f)
|
||||
if os.path.isfile(fpath):
|
||||
age_hours = (now - os.path.getmtime(fpath)) / 3600
|
||||
if age_hours > CACHE_TTL_HOURS:
|
||||
try:
|
||||
os.unlink(fpath)
|
||||
print(f"CACHE: Expired {f}")
|
||||
except:
|
||||
pass
|
||||
|
||||
def get_cache_stats() -> dict:
|
||||
"""Get cache statistics."""
|
||||
if not os.path.exists(CACHE_DIR):
|
||||
return {"files": 0, "size_mb": 0}
|
||||
|
||||
total = 0
|
||||
count = 0
|
||||
for f in os.listdir(CACHE_DIR):
|
||||
fpath = os.path.join(CACHE_DIR, f)
|
||||
if os.path.isfile(fpath):
|
||||
total += os.path.getsize(fpath)
|
||||
count += 1
|
||||
|
||||
return {"files": count, "size_mb": round(total / 1024 / 1024, 2)}
|
||||
|
||||
# Initialize cache on module load
|
||||
init_cache()
|
||||
|
||||
# ========== API ROUTES ==========
|
||||
|
||||
class FeedRequest(BaseModel):
|
||||
"""Request body for feed endpoint with optional JSON credentials."""
|
||||
credentials: Optional[dict] = None
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def get_feed(request: FeedRequest = None):
|
||||
"""Get TikTok feed using network interception."""
|
||||
cookies = None
|
||||
user_agent = None
|
||||
|
||||
if request and request.credentials:
|
||||
cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials)
|
||||
print(f"DEBUG: Using provided credentials ({len(cookies)} cookies)")
|
||||
|
||||
try:
|
||||
videos = await PlaywrightManager.intercept_feed(cookies, user_agent)
|
||||
return videos
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Feed error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_feed_simple(fast: bool = False):
|
||||
"""Simple GET endpoint to fetch feed using stored credentials."""
|
||||
try:
|
||||
# Fast mode = 0 scrolls (just initial batch), Normal = 5 scrolls
|
||||
scroll_count = 0 if fast else 5
|
||||
videos = await PlaywrightManager.intercept_feed(scroll_count=scroll_count)
|
||||
return videos
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Feed error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/cache-stats")
|
||||
async def cache_stats():
|
||||
"""Get video cache statistics."""
|
||||
return get_cache_stats()
|
||||
|
||||
|
||||
@router.delete("/cache")
|
||||
async def clear_cache():
|
||||
"""Clear video cache."""
|
||||
if os.path.exists(CACHE_DIR):
|
||||
shutil.rmtree(CACHE_DIR, ignore_errors=True)
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
return {"status": "cleared"}
|
||||
|
||||
|
||||
@router.get("/proxy")
|
||||
async def proxy_video(
|
||||
url: str = Query(..., description="The TikTok video URL to proxy"),
|
||||
download: bool = Query(False, description="Force download with attachment header")
|
||||
):
|
||||
"""
|
||||
Proxy video with LRU caching for mobile optimization.
|
||||
OPTIMIZED: No server-side transcoding - client handles decoding.
|
||||
This reduces server CPU to ~0% during video playback.
|
||||
"""
|
||||
import yt_dlp
|
||||
import re
|
||||
|
||||
# Check cache first
|
||||
cached_path = get_cached_path(url)
|
||||
if cached_path:
|
||||
print(f"CACHE HIT: {url[:50]}...")
|
||||
|
||||
response_headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
}
|
||||
if download:
|
||||
video_id_match = re.search(r'/video/(\d+)', url)
|
||||
video_id = video_id_match.group(1) if video_id_match else "tiktok_video"
|
||||
response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"'
|
||||
|
||||
return FileResponse(
|
||||
cached_path,
|
||||
media_type="video/mp4",
|
||||
headers=response_headers
|
||||
)
|
||||
|
||||
print(f"CACHE MISS: {url[:50]}... (downloading)")
|
||||
|
||||
# Load stored credentials
|
||||
cookies, user_agent = PlaywrightManager.load_stored_credentials()
|
||||
|
||||
# Create temp file for download
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
output_template = os.path.join(temp_dir, "video.%(ext)s")
|
||||
|
||||
# Create cookies file for yt-dlp
|
||||
cookie_file_path = None
|
||||
if cookies:
|
||||
cookie_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False)
|
||||
cookie_file.write("# Netscape HTTP Cookie File\n")
|
||||
for c in cookies:
|
||||
cookie_file.write(f".tiktok.com\tTRUE\t/\tFALSE\t0\t{c['name']}\t{c['value']}\n")
|
||||
cookie_file.close()
|
||||
cookie_file_path = cookie_file.name
|
||||
|
||||
# Download best quality - NO TRANSCODING (let client decode)
|
||||
# Prefer H.264 when available, but accept any codec
|
||||
ydl_opts = {
|
||||
'format': 'best[ext=mp4][vcodec^=avc]/best[ext=mp4]/best',
|
||||
'outtmpl': output_template,
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'http_headers': {
|
||||
'User-Agent': user_agent,
|
||||
'Referer': 'https://www.tiktok.com/'
|
||||
}
|
||||
}
|
||||
|
||||
if cookie_file_path:
|
||||
ydl_opts['cookiefile'] = cookie_file_path
|
||||
|
||||
video_path = None
|
||||
video_codec = "unknown"
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def download_video():
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=True)
|
||||
ext = info.get('ext', 'mp4')
|
||||
vcodec = info.get('vcodec', 'unknown') or 'unknown'
|
||||
return os.path.join(temp_dir, f"video.{ext}"), vcodec
|
||||
|
||||
video_path, video_codec = await loop.run_in_executor(None, download_video)
|
||||
|
||||
if not os.path.exists(video_path):
|
||||
raise Exception("Video file not created")
|
||||
|
||||
print(f"Downloaded codec: {video_codec} (no transcoding - client will decode)")
|
||||
|
||||
# Save to cache directly - NO TRANSCODING
|
||||
cached_path = save_to_cache(url, video_path)
|
||||
stats = get_cache_stats()
|
||||
print(f"CACHED: {url[:50]}... ({stats['files']} files, {stats['size_mb']}MB total)")
|
||||
|
||||
except Exception as e:
|
||||
print(f"DEBUG: yt-dlp download failed: {e}")
|
||||
# Cleanup
|
||||
if cookie_file_path and os.path.exists(cookie_file_path):
|
||||
os.unlink(cookie_file_path)
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
raise HTTPException(status_code=500, detail=f"Could not download video: {e}")
|
||||
|
||||
# Cleanup temp (cached file is separate)
|
||||
if cookie_file_path and os.path.exists(cookie_file_path):
|
||||
os.unlink(cookie_file_path)
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
# Return from cache with codec info header
|
||||
response_headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"X-Video-Codec": video_codec, # Let client know the codec
|
||||
}
|
||||
if download:
|
||||
video_id_match = re.search(r'/video/(\d+)', url)
|
||||
video_id = video_id_match.group(1) if video_id_match else "tiktok_video"
|
||||
response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"'
|
||||
|
||||
return FileResponse(
|
||||
cached_path,
|
||||
media_type="video/mp4",
|
||||
headers=response_headers
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get("/thin-proxy")
|
||||
async def thin_proxy_video(
|
||||
request: Request,
|
||||
cdn_url: str = Query(..., description="Direct TikTok CDN URL")
|
||||
):
|
||||
"""
|
||||
Thin proxy - just forwards CDN requests with proper headers.
|
||||
Supports Range requests for buffering and seeking.
|
||||
"""
|
||||
|
||||
# Load stored credentials for headers
|
||||
cookies, user_agent = PlaywrightManager.load_stored_credentials()
|
||||
|
||||
headers = {
|
||||
"User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT,
|
||||
"Referer": "https://www.tiktok.com/",
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Origin": "https://www.tiktok.com",
|
||||
}
|
||||
|
||||
# Add cookies as header if available
|
||||
if cookies:
|
||||
cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
|
||||
headers["Cookie"] = cookie_str
|
||||
|
||||
# Forward Range header if present
|
||||
client_range = request.headers.get("Range")
|
||||
if client_range:
|
||||
headers["Range"] = client_range
|
||||
|
||||
try:
|
||||
# Create client outside stream generator to access response headers first
|
||||
client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
|
||||
# We need to manually close this client later or use it in the generator
|
||||
|
||||
# Start the request to get headers (without reading body yet)
|
||||
req = client.build_request("GET", cdn_url, headers=headers)
|
||||
r = await client.send(req, stream=True)
|
||||
|
||||
async def stream_from_cdn():
|
||||
try:
|
||||
async for chunk in r.aiter_bytes(chunk_size=64 * 1024):
|
||||
yield chunk
|
||||
finally:
|
||||
await r.aclose()
|
||||
await client.aclose()
|
||||
|
||||
response_headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"Content-Type": r.headers.get("Content-Type", "video/mp4"),
|
||||
}
|
||||
|
||||
# Forward Content-Length and Content-Range
|
||||
if "Content-Length" in r.headers:
|
||||
response_headers["Content-Length"] = r.headers["Content-Length"]
|
||||
if "Content-Range" in r.headers:
|
||||
response_headers["Content-Range"] = r.headers["Content-Range"]
|
||||
|
||||
status_code = r.status_code
|
||||
|
||||
return StreamingResponse(
|
||||
stream_from_cdn(),
|
||||
status_code=status_code,
|
||||
media_type="video/mp4",
|
||||
headers=response_headers
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Thin proxy error: {e}")
|
||||
# Ensure cleanup if possible
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
"""
|
||||
Feed API routes with LRU video cache for mobile optimization.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Query, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import httpx
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
import asyncio
|
||||
import hashlib
|
||||
import time
|
||||
import shutil
|
||||
|
||||
from core.playwright_manager import PlaywrightManager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ========== LRU VIDEO CACHE ==========
|
||||
CACHE_DIR = os.path.join(tempfile.gettempdir(), "purestream_cache")
|
||||
MAX_CACHE_SIZE_MB = 500 # Limit cache to 500MB
|
||||
MAX_CACHE_FILES = 30 # Keep max 30 videos cached
|
||||
CACHE_TTL_HOURS = 2 # Videos expire after 2 hours
|
||||
|
||||
def init_cache():
|
||||
"""Initialize cache directory."""
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
cleanup_old_cache()
|
||||
|
||||
def get_cache_key(url: str) -> str:
|
||||
"""Generate cache key from URL."""
|
||||
return hashlib.md5(url.encode()).hexdigest()
|
||||
|
||||
def get_cached_path(url: str) -> Optional[str]:
|
||||
"""Check if video is cached and not expired."""
|
||||
cache_key = get_cache_key(url)
|
||||
cached_file = os.path.join(CACHE_DIR, f"{cache_key}.mp4")
|
||||
|
||||
if os.path.exists(cached_file):
|
||||
# Check TTL
|
||||
file_age_hours = (time.time() - os.path.getmtime(cached_file)) / 3600
|
||||
if file_age_hours < CACHE_TTL_HOURS:
|
||||
# Touch file to update LRU
|
||||
os.utime(cached_file, None)
|
||||
return cached_file
|
||||
else:
|
||||
# Expired, delete
|
||||
os.unlink(cached_file)
|
||||
|
||||
return None
|
||||
|
||||
def save_to_cache(url: str, source_path: str) -> str:
|
||||
"""Save video to cache, return cached path."""
|
||||
cache_key = get_cache_key(url)
|
||||
cached_file = os.path.join(CACHE_DIR, f"{cache_key}.mp4")
|
||||
|
||||
# Copy to cache
|
||||
shutil.copy2(source_path, cached_file)
|
||||
|
||||
# Enforce cache limits
|
||||
enforce_cache_limits()
|
||||
|
||||
return cached_file
|
||||
|
||||
def enforce_cache_limits():
|
||||
"""Remove old files if cache exceeds limits."""
|
||||
if not os.path.exists(CACHE_DIR):
|
||||
return
|
||||
|
||||
files = []
|
||||
total_size = 0
|
||||
|
||||
for f in os.listdir(CACHE_DIR):
|
||||
fpath = os.path.join(CACHE_DIR, f)
|
||||
if os.path.isfile(fpath):
|
||||
stat = os.stat(fpath)
|
||||
files.append((fpath, stat.st_mtime, stat.st_size))
|
||||
total_size += stat.st_size
|
||||
|
||||
# Sort by modification time (oldest first)
|
||||
files.sort(key=lambda x: x[1])
|
||||
|
||||
# Remove oldest until under limits
|
||||
max_bytes = MAX_CACHE_SIZE_MB * 1024 * 1024
|
||||
|
||||
while (len(files) > MAX_CACHE_FILES or total_size > max_bytes) and files:
|
||||
oldest = files.pop(0)
|
||||
try:
|
||||
os.unlink(oldest[0])
|
||||
total_size -= oldest[2]
|
||||
print(f"CACHE: Removed {oldest[0]} (LRU)")
|
||||
except:
|
||||
pass
|
||||
|
||||
def cleanup_old_cache():
|
||||
"""Remove expired files on startup."""
|
||||
if not os.path.exists(CACHE_DIR):
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
for f in os.listdir(CACHE_DIR):
|
||||
fpath = os.path.join(CACHE_DIR, f)
|
||||
if os.path.isfile(fpath):
|
||||
age_hours = (now - os.path.getmtime(fpath)) / 3600
|
||||
if age_hours > CACHE_TTL_HOURS:
|
||||
try:
|
||||
os.unlink(fpath)
|
||||
print(f"CACHE: Expired {f}")
|
||||
except:
|
||||
pass
|
||||
|
||||
def get_cache_stats() -> dict:
|
||||
"""Get cache statistics."""
|
||||
if not os.path.exists(CACHE_DIR):
|
||||
return {"files": 0, "size_mb": 0}
|
||||
|
||||
total = 0
|
||||
count = 0
|
||||
for f in os.listdir(CACHE_DIR):
|
||||
fpath = os.path.join(CACHE_DIR, f)
|
||||
if os.path.isfile(fpath):
|
||||
total += os.path.getsize(fpath)
|
||||
count += 1
|
||||
|
||||
return {"files": count, "size_mb": round(total / 1024 / 1024, 2)}
|
||||
|
||||
# Initialize cache on module load
|
||||
init_cache()
|
||||
|
||||
# ========== API ROUTES ==========
|
||||
|
||||
from typing import Optional, Any, Union, List, Dict
|
||||
|
||||
class FeedRequest(BaseModel):
|
||||
"""Request body for feed endpoint with optional JSON credentials."""
|
||||
credentials: Optional[Union[Dict, List]] = None
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def get_feed(request: FeedRequest = None):
|
||||
"""Get TikTok feed using network interception."""
|
||||
cookies = None
|
||||
user_agent = None
|
||||
|
||||
if request and request.credentials:
|
||||
cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials)
|
||||
print(f"DEBUG: Using provided credentials ({len(cookies)} cookies)")
|
||||
|
||||
try:
|
||||
videos = await PlaywrightManager.intercept_feed(cookies, user_agent)
|
||||
return videos
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Feed error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_feed_simple(fast: bool = False, skip_cache: bool = False):
|
||||
"""Simple GET endpoint to fetch feed using stored credentials.
|
||||
|
||||
Args:
|
||||
fast: If True, only get initial batch (0 scrolls). If False, scroll 5 times.
|
||||
skip_cache: If True, always fetch fresh videos (for infinite scroll).
|
||||
"""
|
||||
try:
|
||||
# Fast mode = 0 scrolls (just initial batch), Normal = 5 scrolls
|
||||
scroll_count = 0 if fast else 5
|
||||
|
||||
# When skipping cache for infinite scroll, do more scrolling to get different videos
|
||||
if skip_cache:
|
||||
scroll_count = 8 # More scrolling to get fresh content
|
||||
|
||||
videos = await PlaywrightManager.intercept_feed(scroll_count=scroll_count)
|
||||
return videos
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Feed error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/cache-stats")
|
||||
async def cache_stats():
|
||||
"""Get video cache statistics."""
|
||||
return get_cache_stats()
|
||||
|
||||
|
||||
@router.delete("/cache")
|
||||
async def clear_cache():
|
||||
"""Clear video cache."""
|
||||
if os.path.exists(CACHE_DIR):
|
||||
shutil.rmtree(CACHE_DIR, ignore_errors=True)
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
return {"status": "cleared"}
|
||||
|
||||
|
||||
@router.get("/proxy")
|
||||
async def proxy_video(
|
||||
url: str = Query(..., description="The TikTok video URL to proxy"),
|
||||
download: bool = Query(False, description="Force download with attachment header")
|
||||
):
|
||||
"""
|
||||
Proxy video with LRU caching for mobile optimization.
|
||||
OPTIMIZED: No server-side transcoding - client handles decoding.
|
||||
This reduces server CPU to ~0% during video playback.
|
||||
"""
|
||||
import yt_dlp
|
||||
import re
|
||||
|
||||
# Check cache first
|
||||
cached_path = get_cached_path(url)
|
||||
if cached_path:
|
||||
print(f"CACHE HIT: {url[:50]}...")
|
||||
|
||||
response_headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
}
|
||||
if download:
|
||||
video_id_match = re.search(r'/video/(\d+)', url)
|
||||
video_id = video_id_match.group(1) if video_id_match else "tiktok_video"
|
||||
response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"'
|
||||
|
||||
return FileResponse(
|
||||
cached_path,
|
||||
media_type="video/mp4",
|
||||
headers=response_headers
|
||||
)
|
||||
|
||||
print(f"CACHE MISS: {url[:50]}... (downloading)")
|
||||
|
||||
# Load stored credentials
|
||||
cookies, user_agent = PlaywrightManager.load_stored_credentials()
|
||||
|
||||
# Create temp file for download
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
output_template = os.path.join(temp_dir, "video.%(ext)s")
|
||||
|
||||
# Create cookies file for yt-dlp
|
||||
cookie_file_path = None
|
||||
if cookies:
|
||||
cookie_file = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False)
|
||||
cookie_file.write("# Netscape HTTP Cookie File\n")
|
||||
for c in cookies:
|
||||
cookie_file.write(f".tiktok.com\tTRUE\t/\tFALSE\t0\t{c['name']}\t{c['value']}\n")
|
||||
cookie_file.close()
|
||||
cookie_file_path = cookie_file.name
|
||||
|
||||
# Download best quality - NO TRANSCODING (let client decode)
|
||||
# Prefer H.264 when available, but accept any codec
|
||||
ydl_opts = {
|
||||
'format': 'best[ext=mp4][vcodec^=avc]/best[ext=mp4]/best',
|
||||
'outtmpl': output_template,
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'http_headers': {
|
||||
'User-Agent': user_agent,
|
||||
'Referer': 'https://www.tiktok.com/'
|
||||
}
|
||||
}
|
||||
|
||||
if cookie_file_path:
|
||||
ydl_opts['cookiefile'] = cookie_file_path
|
||||
|
||||
video_path = None
|
||||
video_codec = "unknown"
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def download_video():
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=True)
|
||||
ext = info.get('ext', 'mp4')
|
||||
vcodec = info.get('vcodec', 'unknown') or 'unknown'
|
||||
return os.path.join(temp_dir, f"video.{ext}"), vcodec
|
||||
|
||||
video_path, video_codec = await loop.run_in_executor(None, download_video)
|
||||
|
||||
if not os.path.exists(video_path):
|
||||
raise Exception("Video file not created")
|
||||
|
||||
print(f"Downloaded codec: {video_codec} (no transcoding - client will decode)")
|
||||
|
||||
# Save to cache directly - NO TRANSCODING
|
||||
cached_path = save_to_cache(url, video_path)
|
||||
stats = get_cache_stats()
|
||||
print(f"CACHED: {url[:50]}... ({stats['files']} files, {stats['size_mb']}MB total)")
|
||||
|
||||
except Exception as e:
|
||||
print(f"DEBUG: yt-dlp download failed: {e}")
|
||||
# Cleanup
|
||||
if cookie_file_path and os.path.exists(cookie_file_path):
|
||||
os.unlink(cookie_file_path)
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
raise HTTPException(status_code=500, detail=f"Could not download video: {e}")
|
||||
|
||||
# Cleanup temp (cached file is separate)
|
||||
if cookie_file_path and os.path.exists(cookie_file_path):
|
||||
os.unlink(cookie_file_path)
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
# Return from cache with codec info header
|
||||
response_headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"X-Video-Codec": video_codec, # Let client know the codec
|
||||
}
|
||||
if download:
|
||||
video_id_match = re.search(r'/video/(\d+)', url)
|
||||
video_id = video_id_match.group(1) if video_id_match else "tiktok_video"
|
||||
response_headers["Content-Disposition"] = f'attachment; filename="{video_id}.mp4"'
|
||||
|
||||
return FileResponse(
|
||||
cached_path,
|
||||
media_type="video/mp4",
|
||||
headers=response_headers
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get("/thin-proxy")
|
||||
async def thin_proxy_video(
|
||||
request: Request,
|
||||
cdn_url: str = Query(..., description="Direct TikTok CDN URL")
|
||||
):
|
||||
"""
|
||||
Thin proxy - just forwards CDN requests with proper headers.
|
||||
Supports Range requests for buffering and seeking.
|
||||
"""
|
||||
|
||||
# Load stored credentials for headers
|
||||
cookies, user_agent = PlaywrightManager.load_stored_credentials()
|
||||
|
||||
headers = {
|
||||
"User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT,
|
||||
"Referer": "https://www.tiktok.com/",
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Origin": "https://www.tiktok.com",
|
||||
}
|
||||
|
||||
# Add cookies as header if available
|
||||
if cookies:
|
||||
cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
|
||||
headers["Cookie"] = cookie_str
|
||||
|
||||
# Forward Range header if present
|
||||
client_range = request.headers.get("Range")
|
||||
if client_range:
|
||||
headers["Range"] = client_range
|
||||
|
||||
try:
|
||||
# Create client outside stream generator to access response headers first
|
||||
client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
|
||||
# We need to manually close this client later or use it in the generator
|
||||
|
||||
# Start the request to get headers (without reading body yet)
|
||||
req = client.build_request("GET", cdn_url, headers=headers)
|
||||
r = await client.send(req, stream=True)
|
||||
|
||||
async def stream_from_cdn():
|
||||
try:
|
||||
async for chunk in r.aiter_bytes(chunk_size=64 * 1024):
|
||||
yield chunk
|
||||
finally:
|
||||
await r.aclose()
|
||||
await client.aclose()
|
||||
|
||||
response_headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"Content-Type": r.headers.get("Content-Type", "video/mp4"),
|
||||
}
|
||||
|
||||
# Forward Content-Length and Content-Range
|
||||
if "Content-Length" in r.headers:
|
||||
response_headers["Content-Length"] = r.headers["Content-Length"]
|
||||
if "Content-Range" in r.headers:
|
||||
response_headers["Content-Range"] = r.headers["Content-Range"]
|
||||
|
||||
status_code = r.status_code
|
||||
|
||||
return StreamingResponse(
|
||||
stream_from_cdn(),
|
||||
status_code=status_code,
|
||||
media_type="video/mp4",
|
||||
headers=response_headers
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Thin proxy error: {e}")
|
||||
# Ensure cleanup if possible
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
|
|||
|
|
@ -1,65 +1,98 @@
|
|||
"""
|
||||
Following API routes - manage followed creators.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import os
|
||||
import json
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
FOLLOWING_FILE = "following.json"
|
||||
|
||||
|
||||
def load_following() -> list:
|
||||
"""Load list of followed creators."""
|
||||
if os.path.exists(FOLLOWING_FILE):
|
||||
try:
|
||||
with open(FOLLOWING_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def save_following(following: list):
|
||||
"""Save list of followed creators."""
|
||||
with open(FOLLOWING_FILE, 'w') as f:
|
||||
json.dump(following, f, indent=2)
|
||||
|
||||
|
||||
class FollowRequest(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_following():
|
||||
"""Get list of followed creators."""
|
||||
return load_following()
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def add_following(request: FollowRequest):
|
||||
"""Add a creator to following list."""
|
||||
username = request.username.lstrip('@')
|
||||
following = load_following()
|
||||
|
||||
if username not in following:
|
||||
following.append(username)
|
||||
save_following(following)
|
||||
|
||||
return {"status": "success", "following": following}
|
||||
|
||||
|
||||
@router.delete("/{username}")
|
||||
async def remove_following(username: str):
|
||||
"""Remove a creator from following list."""
|
||||
username = username.lstrip('@')
|
||||
following = load_following()
|
||||
|
||||
if username in following:
|
||||
following.remove(username)
|
||||
save_following(following)
|
||||
|
||||
return {"status": "success", "following": following}
|
||||
"""
|
||||
Following API routes - manage followed creators.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import os
|
||||
import json
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
FOLLOWING_FILE = "following.json"
|
||||
|
||||
|
||||
def load_following() -> list:
|
||||
"""Load list of followed creators."""
|
||||
if os.path.exists(FOLLOWING_FILE):
|
||||
try:
|
||||
with open(FOLLOWING_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def save_following(following: list):
|
||||
"""Save list of followed creators."""
|
||||
with open(FOLLOWING_FILE, 'w') as f:
|
||||
json.dump(following, f, indent=2)
|
||||
|
||||
|
||||
class FollowRequest(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_following():
|
||||
"""Get list of followed creators."""
|
||||
return load_following()
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def add_following(request: FollowRequest):
|
||||
"""Add a creator to following list."""
|
||||
username = request.username.lstrip('@')
|
||||
following = load_following()
|
||||
|
||||
if username not in following:
|
||||
following.append(username)
|
||||
save_following(following)
|
||||
|
||||
return {"status": "success", "following": following}
|
||||
|
||||
|
||||
@router.delete("/{username}")
|
||||
async def remove_following(username: str):
|
||||
"""Remove a creator from following list."""
|
||||
username = username.lstrip('@')
|
||||
following = load_following()
|
||||
|
||||
if username in following:
|
||||
following.remove(username)
|
||||
save_following(following)
|
||||
|
||||
return {"status": "success", "following": following}
|
||||
|
||||
|
||||
@router.get("/feed")
|
||||
async def get_following_feed(limit_per_user: int = 5):
|
||||
"""
|
||||
Get a combined feed of videos from all followed creators.
|
||||
"""
|
||||
from core.playwright_manager import PlaywrightManager
|
||||
import asyncio
|
||||
|
||||
following = load_following()
|
||||
if not following:
|
||||
return []
|
||||
|
||||
# Load stored credentials
|
||||
cookies, user_agent = PlaywrightManager.load_stored_credentials()
|
||||
|
||||
if not cookies:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
tasks = [PlaywrightManager.fetch_user_videos(user, cookies, user_agent, limit_per_user) for user in following]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
all_videos = []
|
||||
for result in results:
|
||||
if isinstance(result, list):
|
||||
all_videos.extend(result)
|
||||
|
||||
# Shuffle results to make it look like a feed
|
||||
import random
|
||||
random.shuffle(all_videos)
|
||||
|
||||
return all_videos
|
||||
|
|
|
|||
|
|
@ -1,279 +1,280 @@
|
|||
"""
|
||||
User profile API - fetch real TikTok user data.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import httpx
|
||||
import asyncio
|
||||
|
||||
from core.playwright_manager import PlaywrightManager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UserProfile(BaseModel):
|
||||
"""TikTok user profile data."""
|
||||
username: str
|
||||
nickname: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
followers: Optional[int] = None
|
||||
following: Optional[int] = None
|
||||
likes: Optional[int] = None
|
||||
verified: bool = False
|
||||
|
||||
|
||||
@router.get("/profile")
|
||||
async def get_user_profile(username: str = Query(..., description="TikTok username (without @)")):
|
||||
"""
|
||||
Fetch real TikTok user profile data.
|
||||
"""
|
||||
username = username.replace("@", "")
|
||||
|
||||
# Load stored credentials
|
||||
cookies, user_agent = PlaywrightManager.load_stored_credentials()
|
||||
|
||||
if not cookies:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
# Build cookie header
|
||||
cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
|
||||
|
||||
headers = {
|
||||
"User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT,
|
||||
"Referer": "https://www.tiktok.com/",
|
||||
"Cookie": cookie_str,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
# Try to fetch user data from TikTok's internal API
|
||||
profile_url = f"https://www.tiktok.com/api/user/detail/?uniqueId={username}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
response = await client.get(profile_url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
# Fallback - return basic info
|
||||
return UserProfile(username=username)
|
||||
|
||||
data = response.json()
|
||||
user_info = data.get("userInfo", {})
|
||||
user = user_info.get("user", {})
|
||||
stats = user_info.get("stats", {})
|
||||
|
||||
return UserProfile(
|
||||
username=username,
|
||||
nickname=user.get("nickname"),
|
||||
avatar=user.get("avatarLarger") or user.get("avatarMedium"),
|
||||
bio=user.get("signature"),
|
||||
followers=stats.get("followerCount"),
|
||||
following=stats.get("followingCount"),
|
||||
likes=stats.get("heartCount"),
|
||||
verified=user.get("verified", False)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching profile for {username}: {e}")
|
||||
# Return basic fallback
|
||||
return UserProfile(username=username)
|
||||
|
||||
|
||||
@router.get("/profiles")
|
||||
async def get_multiple_profiles(usernames: str = Query(..., description="Comma-separated usernames")):
|
||||
"""
|
||||
Fetch multiple TikTok user profiles at once.
|
||||
"""
|
||||
username_list = [u.strip().replace("@", "") for u in usernames.split(",") if u.strip()]
|
||||
|
||||
if len(username_list) > 20:
|
||||
raise HTTPException(status_code=400, detail="Max 20 usernames at once")
|
||||
|
||||
# Fetch all profiles concurrently
|
||||
tasks = [get_user_profile(u) for u in username_list]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
profiles = []
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
profiles.append(UserProfile(username=username_list[i]))
|
||||
else:
|
||||
profiles.append(result)
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
@router.get("/videos")
|
||||
async def get_user_videos(
|
||||
username: str = Query(..., description="TikTok username (without @)"),
|
||||
limit: int = Query(10, description="Max videos to fetch", ge=1, le=30)
|
||||
):
|
||||
"""
|
||||
Fetch videos from a TikTok user's profile.
|
||||
Uses Playwright to crawl the user's page for reliable results.
|
||||
"""
|
||||
username = username.replace("@", "")
|
||||
|
||||
# Load stored credentials
|
||||
cookies, user_agent = PlaywrightManager.load_stored_credentials()
|
||||
|
||||
if not cookies:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
print(f"Fetching videos for @{username}...")
|
||||
|
||||
try:
|
||||
videos = await PlaywrightManager.fetch_user_videos(username, cookies, user_agent, limit)
|
||||
return {"username": username, "videos": videos, "count": len(videos)}
|
||||
except Exception as e:
|
||||
print(f"Error fetching videos for {username}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_videos(
|
||||
query: str = Query(..., description="Search keyword or hashtag"),
|
||||
limit: int = Query(12, description="Max videos to fetch", ge=1, le=30)
|
||||
):
|
||||
"""
|
||||
Search for videos by keyword or hashtag.
|
||||
Uses Playwright to crawl TikTok search results for reliable data.
|
||||
"""
|
||||
# Load stored credentials
|
||||
cookies, user_agent = PlaywrightManager.load_stored_credentials()
|
||||
|
||||
if not cookies:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
print(f"Searching for: {query}...")
|
||||
|
||||
try:
|
||||
videos = await PlaywrightManager.search_videos(query, cookies, user_agent, limit)
|
||||
return {"query": query, "videos": videos, "count": len(videos)}
|
||||
except Exception as e:
|
||||
print(f"Error searching for {query}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# Cache for suggested accounts
|
||||
_suggested_cache = {
|
||||
"accounts": [],
|
||||
"updated_at": 0
|
||||
}
|
||||
CACHE_TTL = 3600 # 1 hour cache
|
||||
|
||||
|
||||
@router.get("/suggested")
|
||||
async def get_suggested_accounts(
|
||||
limit: int = Query(50, description="Max accounts to return", ge=10, le=100)
|
||||
):
|
||||
"""
|
||||
Fetch trending/suggested Vietnamese TikTok creators.
|
||||
Uses TikTok's discover API and caches results for 1 hour.
|
||||
"""
|
||||
import time
|
||||
|
||||
# Check cache
|
||||
if _suggested_cache["accounts"] and (time.time() - _suggested_cache["updated_at"]) < CACHE_TTL:
|
||||
print("Returning cached suggested accounts")
|
||||
return {"accounts": _suggested_cache["accounts"][:limit], "cached": True}
|
||||
|
||||
# Load stored credentials
|
||||
cookies, user_agent = PlaywrightManager.load_stored_credentials()
|
||||
|
||||
if not cookies:
|
||||
# Return fallback static list if not authenticated
|
||||
return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True}
|
||||
|
||||
print("Fetching fresh suggested accounts from TikTok...")
|
||||
|
||||
try:
|
||||
accounts = await PlaywrightManager.fetch_suggested_accounts(cookies, user_agent, limit)
|
||||
|
||||
if accounts and len(accounts) >= 5: # Need at least 5 accounts from dynamic fetch
|
||||
_suggested_cache["accounts"] = accounts
|
||||
_suggested_cache["updated_at"] = time.time()
|
||||
return {"accounts": accounts[:limit], "cached": False}
|
||||
else:
|
||||
# Fallback: fetch actual profile data with avatars for static list
|
||||
print("Dynamic fetch failed, fetching profile data for static accounts...")
|
||||
fallback_list = get_fallback_accounts()[:min(limit, 20)] # Limit to 20 for speed
|
||||
return await fetch_profiles_with_avatars(fallback_list, cookies, user_agent)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching suggested accounts: {e}")
|
||||
return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True}
|
||||
|
||||
|
||||
async def fetch_profiles_with_avatars(accounts: list, cookies: list, user_agent: str) -> dict:
|
||||
"""Fetch actual profile data with avatars for a list of accounts."""
|
||||
|
||||
cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
|
||||
|
||||
headers = {
|
||||
"User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT,
|
||||
"Referer": "https://www.tiktok.com/",
|
||||
"Cookie": cookie_str,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
enriched = []
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
for acc in accounts:
|
||||
try:
|
||||
url = f"https://www.tiktok.com/api/user/detail/?uniqueId={acc['username']}"
|
||||
res = await client.get(url, headers=headers)
|
||||
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
user = data.get("userInfo", {}).get("user", {})
|
||||
stats = data.get("userInfo", {}).get("stats", {})
|
||||
|
||||
if user:
|
||||
enriched.append({
|
||||
"username": acc["username"],
|
||||
"nickname": user.get("nickname") or acc.get("nickname", acc["username"]),
|
||||
"avatar": user.get("avatarThumb") or user.get("avatarMedium"),
|
||||
"followers": stats.get("followerCount", 0),
|
||||
"verified": user.get("verified", False),
|
||||
"region": "VN"
|
||||
})
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching profile for {acc['username']}: {e}")
|
||||
|
||||
# Fallback: use original data without avatar
|
||||
enriched.append(acc)
|
||||
|
||||
return {"accounts": enriched, "cached": False, "enriched": True}
|
||||
|
||||
|
||||
def get_fallback_accounts():
|
||||
"""Static fallback list of popular Vietnamese TikTokers (verified usernames)."""
|
||||
return [
|
||||
# Verified Vietnamese TikTok accounts
|
||||
{"username": "cciinnn", "nickname": "👑 CiiN (Bùi Thảo Ly)", "region": "VN"},
|
||||
{"username": "hoaa.hanassii", "nickname": "💃 Hoa Hanassii", "region": "VN"},
|
||||
{"username": "lebong95", "nickname": "💪 Lê Bống", "region": "VN"},
|
||||
{"username": "tieu_hy26", "nickname": "👰 Tiểu Hý", "region": "VN"},
|
||||
{"username": "hieuthuhai2222", "nickname": "🎧 HIEUTHUHAI", "region": "VN"},
|
||||
{"username": "mtp.fan", "nickname": "🎤 Sơn Tùng M-TP", "region": "VN"},
|
||||
{"username": "changmakeup", "nickname": "💄 Changmakeup", "region": "VN"},
|
||||
{"username": "theanh28entertainment", "nickname": "🎬 Theanh28", "region": "VN"},
|
||||
{"username": "linhbarbie", "nickname": "👗 Linh Barbie", "region": "VN"},
|
||||
{"username": "phuonglykchau", "nickname": "✨ Phương Ly", "region": "VN"},
|
||||
{"username": "phimtieutrang", "nickname": "📺 Tiểu Trang", "region": "VN"},
|
||||
{"username": "nhunguyendy", "nickname": "💕 Như Nguyễn", "region": "VN"},
|
||||
{"username": "trucnhantv", "nickname": "🎤 Trúc Nhân", "region": "VN"},
|
||||
{"username": "justvietanh", "nickname": "😄 Just Việt Anh", "region": "VN"},
|
||||
{"username": "minngu.official", "nickname": "🌸 Min NGU", "region": "VN"},
|
||||
{"username": "quangdangofficial", "nickname": "🕺 Quang Đăng", "region": "VN"},
|
||||
{"username": "minhhangofficial", "nickname": "👑 Minh Hằng", "region": "VN"},
|
||||
{"username": "dungntt", "nickname": "🎭 Dũng NTT", "region": "VN"},
|
||||
{"username": "chipu88", "nickname": "🎤 Chi Pu", "region": "VN"},
|
||||
{"username": "kaydinh", "nickname": "🎵 Kay Dinh", "region": "VN"},
|
||||
]
|
||||
"""
|
||||
User profile API - fetch real TikTok user data.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import httpx
|
||||
import asyncio
|
||||
|
||||
from core.playwright_manager import PlaywrightManager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UserProfile(BaseModel):
|
||||
"""TikTok user profile data."""
|
||||
username: str
|
||||
nickname: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
followers: Optional[int] = None
|
||||
following: Optional[int] = None
|
||||
likes: Optional[int] = None
|
||||
verified: bool = False
|
||||
|
||||
|
||||
@router.get("/profile")
|
||||
async def get_user_profile(username: str = Query(..., description="TikTok username (without @)")):
|
||||
"""
|
||||
Fetch real TikTok user profile data.
|
||||
"""
|
||||
username = username.replace("@", "")
|
||||
|
||||
# Load stored credentials
|
||||
cookies, user_agent = PlaywrightManager.load_stored_credentials()
|
||||
|
||||
if not cookies:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
# Build cookie header
|
||||
cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
|
||||
|
||||
headers = {
|
||||
"User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT,
|
||||
"Referer": "https://www.tiktok.com/",
|
||||
"Cookie": cookie_str,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
# Try to fetch user data from TikTok's internal API
|
||||
profile_url = f"https://www.tiktok.com/api/user/detail/?uniqueId={username}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||
response = await client.get(profile_url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
# Fallback - return basic info
|
||||
return UserProfile(username=username)
|
||||
|
||||
data = response.json()
|
||||
user_info = data.get("userInfo", {})
|
||||
user = user_info.get("user", {})
|
||||
stats = user_info.get("stats", {})
|
||||
|
||||
return UserProfile(
|
||||
username=username,
|
||||
nickname=user.get("nickname"),
|
||||
avatar=user.get("avatarLarger") or user.get("avatarMedium"),
|
||||
bio=user.get("signature"),
|
||||
followers=stats.get("followerCount"),
|
||||
following=stats.get("followingCount"),
|
||||
likes=stats.get("heartCount"),
|
||||
verified=user.get("verified", False)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching profile for {username}: {e}")
|
||||
# Return basic fallback
|
||||
return UserProfile(username=username)
|
||||
|
||||
|
||||
@router.get("/profiles")
|
||||
async def get_multiple_profiles(usernames: str = Query(..., description="Comma-separated usernames")):
|
||||
"""
|
||||
Fetch multiple TikTok user profiles at once.
|
||||
"""
|
||||
username_list = [u.strip().replace("@", "") for u in usernames.split(",") if u.strip()]
|
||||
|
||||
if len(username_list) > 20:
|
||||
raise HTTPException(status_code=400, detail="Max 20 usernames at once")
|
||||
|
||||
# Fetch all profiles concurrently
|
||||
tasks = [get_user_profile(u) for u in username_list]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
profiles = []
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
profiles.append(UserProfile(username=username_list[i]))
|
||||
else:
|
||||
profiles.append(result)
|
||||
|
||||
return profiles
|
||||
|
||||
|
||||
@router.get("/videos")
|
||||
async def get_user_videos(
|
||||
username: str = Query(..., description="TikTok username (without @)"),
|
||||
limit: int = Query(10, description="Max videos to fetch", ge=1, le=60)
|
||||
):
|
||||
"""
|
||||
Fetch videos from a TikTok user's profile.
|
||||
Uses Playwright to crawl the user's page for reliable results.
|
||||
"""
|
||||
username = username.replace("@", "")
|
||||
|
||||
# Load stored credentials
|
||||
cookies, user_agent = PlaywrightManager.load_stored_credentials()
|
||||
|
||||
if not cookies:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
print(f"Fetching videos for @{username}...")
|
||||
|
||||
try:
|
||||
videos = await PlaywrightManager.fetch_user_videos(username, cookies, user_agent, limit)
|
||||
return {"username": username, "videos": videos, "count": len(videos)}
|
||||
except Exception as e:
|
||||
print(f"Error fetching videos for {username}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_videos(
|
||||
query: str = Query(..., description="Search keyword or hashtag"),
|
||||
limit: int = Query(20, description="Max videos to fetch", ge=1, le=60),
|
||||
cursor: int = Query(0, description="Pagination cursor (offset)")
|
||||
):
|
||||
"""
|
||||
Search for videos by keyword or hashtag.
|
||||
Uses Playwright to crawl TikTok search results for reliable data.
|
||||
"""
|
||||
# Load stored credentials
|
||||
cookies, user_agent = PlaywrightManager.load_stored_credentials()
|
||||
|
||||
if not cookies:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
print(f"Searching for: {query} (limit={limit}, cursor={cursor})...")
|
||||
|
||||
try:
|
||||
videos = await PlaywrightManager.search_videos(query, cookies, user_agent, limit, cursor)
|
||||
return {"query": query, "videos": videos, "count": len(videos), "cursor": cursor + len(videos)}
|
||||
except Exception as e:
|
||||
print(f"Error searching for {query}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# Cache for suggested accounts
|
||||
_suggested_cache = {
|
||||
"accounts": [],
|
||||
"updated_at": 0
|
||||
}
|
||||
CACHE_TTL = 3600 # 1 hour cache
|
||||
|
||||
|
||||
@router.get("/suggested")
|
||||
async def get_suggested_accounts(
|
||||
limit: int = Query(50, description="Max accounts to return", ge=10, le=100)
|
||||
):
|
||||
"""
|
||||
Fetch trending/suggested Vietnamese TikTok creators.
|
||||
Uses TikTok's discover API and caches results for 1 hour.
|
||||
"""
|
||||
import time
|
||||
|
||||
# Check cache
|
||||
if _suggested_cache["accounts"] and (time.time() - _suggested_cache["updated_at"]) < CACHE_TTL:
|
||||
print("Returning cached suggested accounts")
|
||||
return {"accounts": _suggested_cache["accounts"][:limit], "cached": True}
|
||||
|
||||
# Load stored credentials
|
||||
cookies, user_agent = PlaywrightManager.load_stored_credentials()
|
||||
|
||||
if not cookies:
|
||||
# Return fallback static list if not authenticated
|
||||
return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True}
|
||||
|
||||
print("Fetching fresh suggested accounts from TikTok...")
|
||||
|
||||
try:
|
||||
accounts = await PlaywrightManager.fetch_suggested_accounts(cookies, user_agent, limit)
|
||||
|
||||
if accounts and len(accounts) >= 5: # Need at least 5 accounts from dynamic fetch
|
||||
_suggested_cache["accounts"] = accounts
|
||||
_suggested_cache["updated_at"] = time.time()
|
||||
return {"accounts": accounts[:limit], "cached": False}
|
||||
else:
|
||||
# Fallback: fetch actual profile data with avatars for static list
|
||||
print("Dynamic fetch failed, fetching profile data for static accounts...")
|
||||
fallback_list = get_fallback_accounts()[:min(limit, 20)] # Limit to 20 for speed
|
||||
return await fetch_profiles_with_avatars(fallback_list, cookies, user_agent)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching suggested accounts: {e}")
|
||||
return {"accounts": get_fallback_accounts()[:limit], "cached": False, "fallback": True}
|
||||
|
||||
|
||||
async def fetch_profiles_with_avatars(accounts: list, cookies: list, user_agent: str) -> dict:
|
||||
"""Fetch actual profile data with avatars for a list of accounts."""
|
||||
|
||||
cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
|
||||
|
||||
headers = {
|
||||
"User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT,
|
||||
"Referer": "https://www.tiktok.com/",
|
||||
"Cookie": cookie_str,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
enriched = []
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
for acc in accounts:
|
||||
try:
|
||||
url = f"https://www.tiktok.com/api/user/detail/?uniqueId={acc['username']}"
|
||||
res = await client.get(url, headers=headers)
|
||||
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
user = data.get("userInfo", {}).get("user", {})
|
||||
stats = data.get("userInfo", {}).get("stats", {})
|
||||
|
||||
if user:
|
||||
enriched.append({
|
||||
"username": acc["username"],
|
||||
"nickname": user.get("nickname") or acc.get("nickname", acc["username"]),
|
||||
"avatar": user.get("avatarThumb") or user.get("avatarMedium"),
|
||||
"followers": stats.get("followerCount", 0),
|
||||
"verified": user.get("verified", False),
|
||||
"region": "VN"
|
||||
})
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching profile for {acc['username']}: {e}")
|
||||
|
||||
# Fallback: use original data without avatar
|
||||
enriched.append(acc)
|
||||
|
||||
return {"accounts": enriched, "cached": False, "enriched": True}
|
||||
|
||||
|
||||
def get_fallback_accounts():
|
||||
"""Static fallback list of popular Vietnamese TikTokers (verified usernames)."""
|
||||
return [
|
||||
# Verified Vietnamese TikTok accounts
|
||||
{"username": "cciinnn", "nickname": "👑 CiiN (Bùi Thảo Ly)", "region": "VN"},
|
||||
{"username": "hoaa.hanassii", "nickname": "💃 Hoa Hanassii", "region": "VN"},
|
||||
{"username": "lebong95", "nickname": "💪 Lê Bống", "region": "VN"},
|
||||
{"username": "tieu_hy26", "nickname": "👰 Tiểu Hý", "region": "VN"},
|
||||
{"username": "hieuthuhai2222", "nickname": "🎧 HIEUTHUHAI", "region": "VN"},
|
||||
{"username": "mtp.fan", "nickname": "🎤 Sơn Tùng M-TP", "region": "VN"},
|
||||
{"username": "changmakeup", "nickname": "💄 Changmakeup", "region": "VN"},
|
||||
{"username": "theanh28entertainment", "nickname": "🎬 Theanh28", "region": "VN"},
|
||||
{"username": "linhbarbie", "nickname": "👗 Linh Barbie", "region": "VN"},
|
||||
{"username": "phuonglykchau", "nickname": "✨ Phương Ly", "region": "VN"},
|
||||
{"username": "phimtieutrang", "nickname": "📺 Tiểu Trang", "region": "VN"},
|
||||
{"username": "nhunguyendy", "nickname": "💕 Như Nguyễn", "region": "VN"},
|
||||
{"username": "trucnhantv", "nickname": "🎤 Trúc Nhân", "region": "VN"},
|
||||
{"username": "justvietanh", "nickname": "😄 Just Việt Anh", "region": "VN"},
|
||||
{"username": "minngu.official", "nickname": "🌸 Min NGU", "region": "VN"},
|
||||
{"username": "quangdangofficial", "nickname": "🕺 Quang Đăng", "region": "VN"},
|
||||
{"username": "minhhangofficial", "nickname": "👑 Minh Hằng", "region": "VN"},
|
||||
{"username": "dungntt", "nickname": "🎭 Dũng NTT", "region": "VN"},
|
||||
{"username": "chipu88", "nickname": "🎤 Chi Pu", "region": "VN"},
|
||||
{"username": "kaydinh", "nickname": "🎵 Kay Dinh", "region": "VN"},
|
||||
]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
140
backend/main.py
140
backend/main.py
|
|
@ -1,58 +1,82 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from api.routes import auth, feed, download, following, config, user
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup and shutdown events."""
|
||||
print("🚀 Starting PureStream API (Network Interception Mode)...")
|
||||
yield
|
||||
print("👋 Shutting down PureStream API...")
|
||||
|
||||
app = FastAPI(title="PureStream API", version="2.0.0", lifespan=lifespan)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", "*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
|
||||
app.include_router(feed.router, prefix="/api/feed", tags=["Feed"])
|
||||
app.include_router(download.router, prefix="/api/download", tags=["Download"])
|
||||
app.include_router(following.router, prefix="/api/following", tags=["Following"])
|
||||
app.include_router(config.router, prefix="/api/config", tags=["Config"])
|
||||
app.include_router(user.router, prefix="/api/user", tags=["User"])
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "ok"}
|
||||
|
||||
# Serve static frontend files in production
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
if FRONTEND_DIR.exists():
|
||||
# Mount static assets
|
||||
app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")
|
||||
|
||||
# Serve index.html for all non-API routes (SPA fallback)
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
# If requesting a file that exists, serve it
|
||||
file_path = FRONTEND_DIR / full_path
|
||||
if file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
# Otherwise serve index.html for SPA routing
|
||||
return FileResponse(FRONTEND_DIR / "index.html")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=True)
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from api.routes import auth, feed, download, following, config, user
|
||||
import sys
|
||||
import asyncio
|
||||
|
||||
# Force Proactor on Windows for Playwright
|
||||
if sys.platform == "win32":
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup and shutdown events."""
|
||||
print("🚀 Starting PureStream API (Network Interception Mode)...")
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
print(f"DEBUG: Running event loop: {type(loop)}")
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Could not get running loop: {e}")
|
||||
yield
|
||||
print("👋 Shutting down PureStream API...")
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
app = FastAPI(title="PureStream API", version="2.0.0", lifespan=lifespan)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
print(f"DEBUG: Current event loop: {type(loop)}")
|
||||
except:
|
||||
print("DEBUG: No event loop yet")
|
||||
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", "*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
|
||||
app.include_router(feed.router, prefix="/api/feed", tags=["Feed"])
|
||||
app.include_router(download.router, prefix="/api/download", tags=["Download"])
|
||||
app.include_router(following.router, prefix="/api/following", tags=["Following"])
|
||||
app.include_router(config.router, prefix="/api/config", tags=["Config"])
|
||||
app.include_router(user.router, prefix="/api/user", tags=["User"])
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "ok"}
|
||||
|
||||
# Serve static frontend files in production
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
if FRONTEND_DIR.exists():
|
||||
# Mount static assets
|
||||
app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")
|
||||
|
||||
# Serve index.html for all non-API routes (SPA fallback)
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
# If requesting a file that exists, serve it
|
||||
file_path = FRONTEND_DIR / full_path
|
||||
if file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
# Otherwise serve index.html for SPA routing
|
||||
return FileResponse(FRONTEND_DIR / "index.html")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=False, loop="asyncio")
|
||||
|
||||
|
|
|
|||
25
backend/run_server.py
Normal file
25
backend/run_server.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import sys
|
||||
import os
|
||||
import asyncio
|
||||
|
||||
# Fix sys.path for user site-packages where pip installed dependencies
|
||||
user_site = os.path.expanduser("~\\AppData\\Roaming\\Python\\Python312\\site-packages")
|
||||
if os.path.exists(user_site) and user_site not in sys.path:
|
||||
print(f"DEBUG: Adding user site-packages to path: {user_site}")
|
||||
sys.path.append(user_site)
|
||||
|
||||
# Enforce ProactorEventLoopPolicy for Playwright on Windows
|
||||
# This is required for asyncio.create_subprocess_exec used by Playwright
|
||||
if sys.platform == "win32":
|
||||
# Check if policy is already set
|
||||
current_policy = asyncio.get_event_loop_policy()
|
||||
if not isinstance(current_policy, asyncio.WindowsProactorEventLoopPolicy):
|
||||
print("DEBUG: Setting WindowsProactorEventLoopPolicy")
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
else:
|
||||
print("DEBUG: WindowsProactorEventLoopPolicy already active")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
print("🚀 Bootstrapping Uvicorn with Proactor Loop (Reload Disabled)...")
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=False, loop="asyncio")
|
||||
312
cookies.json
Normal file
312
cookies.json
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
[
|
||||
{
|
||||
"domain": ".www.tiktok.com",
|
||||
"expirationDate": 1784039026,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "delay_guest_mode_vid",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "8"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1768142590.076948,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "msToken",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "qKUFutg-Q184Mo5kpuAm4XvPCCucGe3O2KXf5G9pHV61Hb9puK-ZVQ7XzexuVGLLzwmFZ1mVYOgR3QbKBXk58AX9UgPPPkWk_koDZF3e-gqQGg_9GGjcdIOxGN-JTL_g0FM4qN8NKV84LdU="
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.646103,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "tt_session_tlb_tag",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "sttt%7C1%7C6M35zM57kkqAGSs_LSUdRP_________zyplxYaEARSr2PNU_6cKcB0lq4WRz1GKKY43u399i5hs%3D"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1798369620.645922,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "sid_guard",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "e8cdf9ccce7b924a80192b3f2d251d44%7C1767265616%7C15552000%7CTue%2C+30-Jun-2026+11%3A06%3A56+GMT"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1798814589.722864,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "ttwid",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "1%7CAYDsetgnxt5vzYX8hD6Wq2DQ4FXiL_pqcdLwHWwz6B8%7C1767278585%7C46a4b0b8d2fc0ee903d0e781593a0d2d7b491a49752c86f034adf69319d371a0"
|
||||
},
|
||||
{
|
||||
"domain": ".www.tiktok.com",
|
||||
"expirationDate": 1767883392,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "perf_feed_cache",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "{%22expireTimestamp%22:0%2C%22itemIds%22:[%22%22%2C%227584357225863335188%22%2C%227580369401002659079%22]}"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.645952,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "uid_tt",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1772449601.742227,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "passport_csrf_token_default",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "9c66ab10306611c75fa19c87e54fd31b"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "s_v_web_id",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": true,
|
||||
"storeId": null,
|
||||
"value": "verify_mjvcbi31_l3mxUEeU_ykis_4x6z_859S_8zEFmNseEnJU"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.64617,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "ssid_ucp_v1",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
|
||||
},
|
||||
{
|
||||
"domain": ".www.tiktok.com",
|
||||
"expirationDate": 1793198587,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "tiktok_webapp_theme",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "dark"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1799409936.219767,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "_ttp",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "32XOXKxwj8YLtBQf0OBn4TvlkPN"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1772449620.645821,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "cmpl_token",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "AgQYAPOF_hfkTtKPtFExgPKdOPKrXVkNUj-FDmCi6K4"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1772449620.645628,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "multi_sids",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "6884525631502042113%3Ae8cdf9ccce7b924a80192b3f2d251d44"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1769857620.645892,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "passport_auth_status_ss",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "966972581a398dbb9ead189c044cf98c%2C"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1772449601.742082,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "passport_csrf_token",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "9c66ab10306611c75fa19c87e54fd31b"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.646041,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "sessionid",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.646073,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "sessionid_ss",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.646008,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "sid_tt",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.646132,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "sid_ucp_v1",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
|
||||
},
|
||||
{
|
||||
"domain": ".www.tiktok.com",
|
||||
"expirationDate": 1793198587,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "tiktok_webapp_theme_source",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "auto"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782830586.65306,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "tt_chain_token",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "6deMEWrkAGUe9R0tCISIoQ=="
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "tt_csrf_token",
|
||||
"path": "/",
|
||||
"sameSite": "lax",
|
||||
"secure": true,
|
||||
"session": true,
|
||||
"storeId": null,
|
||||
"value": "q0Q4ki72-I7zQB6eLbpBBaqFBGrUF_v85N9s"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.645979,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "uid_tt_ss",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
|
||||
}
|
||||
]
|
||||
360
frontend/package-lock.json
generated
360
frontend/package-lock.json
generated
|
|
@ -81,7 +81,6 @@
|
|||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
|
|
@ -725,9 +724,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
||||
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1038,9 +1037,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz",
|
||||
"integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
|
||||
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -1052,9 +1051,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz",
|
||||
"integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
|
||||
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1066,9 +1065,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz",
|
||||
"integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
|
||||
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1080,9 +1079,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz",
|
||||
"integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
|
||||
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1094,9 +1093,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz",
|
||||
"integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
|
||||
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1108,9 +1107,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz",
|
||||
"integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
|
||||
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1122,9 +1121,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz",
|
||||
"integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
|
||||
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -1136,9 +1135,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz",
|
||||
"integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
|
||||
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -1150,9 +1149,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz",
|
||||
"integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
|
||||
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1164,9 +1163,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz",
|
||||
"integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
|
||||
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1178,9 +1177,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz",
|
||||
"integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
|
||||
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -1192,9 +1191,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz",
|
||||
"integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
|
||||
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -1206,9 +1205,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz",
|
||||
"integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
|
||||
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -1220,9 +1219,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz",
|
||||
"integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
|
||||
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -1234,9 +1233,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz",
|
||||
"integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
|
||||
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -1248,9 +1247,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz",
|
||||
"integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
|
||||
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1262,9 +1261,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz",
|
||||
"integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
|
||||
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1276,9 +1275,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz",
|
||||
"integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
|
||||
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1290,9 +1289,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz",
|
||||
"integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
|
||||
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1304,9 +1303,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz",
|
||||
"integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
|
||||
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -1318,9 +1317,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz",
|
||||
"integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
|
||||
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1332,9 +1331,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz",
|
||||
"integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
|
||||
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1410,7 +1409,6 @@
|
|||
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
|
|
@ -1428,7 +1426,6 @@
|
|||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
|
|
@ -1445,20 +1442,20 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz",
|
||||
"integrity": "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz",
|
||||
"integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.50.0",
|
||||
"@typescript-eslint/type-utils": "8.50.0",
|
||||
"@typescript-eslint/utils": "8.50.0",
|
||||
"@typescript-eslint/visitor-keys": "8.50.0",
|
||||
"@typescript-eslint/scope-manager": "8.51.0",
|
||||
"@typescript-eslint/type-utils": "8.51.0",
|
||||
"@typescript-eslint/utils": "8.51.0",
|
||||
"@typescript-eslint/visitor-keys": "8.51.0",
|
||||
"ignore": "^7.0.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
"ts-api-utils": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -1468,7 +1465,7 @@
|
|||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.50.0",
|
||||
"@typescript-eslint/parser": "^8.51.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
|
|
@ -1484,17 +1481,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
|
||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.51.0.tgz",
|
||||
"integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.50.0",
|
||||
"@typescript-eslint/types": "8.50.0",
|
||||
"@typescript-eslint/typescript-estree": "8.50.0",
|
||||
"@typescript-eslint/visitor-keys": "8.50.0",
|
||||
"@typescript-eslint/scope-manager": "8.51.0",
|
||||
"@typescript-eslint/types": "8.51.0",
|
||||
"@typescript-eslint/typescript-estree": "8.51.0",
|
||||
"@typescript-eslint/visitor-keys": "8.51.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -1510,14 +1506,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.0.tgz",
|
||||
"integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz",
|
||||
"integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.50.0",
|
||||
"@typescript-eslint/types": "^8.50.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.51.0",
|
||||
"@typescript-eslint/types": "^8.51.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -1532,14 +1528,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.0.tgz",
|
||||
"integrity": "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz",
|
||||
"integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.50.0",
|
||||
"@typescript-eslint/visitor-keys": "8.50.0"
|
||||
"@typescript-eslint/types": "8.51.0",
|
||||
"@typescript-eslint/visitor-keys": "8.51.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -1550,9 +1546,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.0.tgz",
|
||||
"integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz",
|
||||
"integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -1567,17 +1563,17 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.0.tgz",
|
||||
"integrity": "sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz",
|
||||
"integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.50.0",
|
||||
"@typescript-eslint/typescript-estree": "8.50.0",
|
||||
"@typescript-eslint/utils": "8.50.0",
|
||||
"@typescript-eslint/types": "8.51.0",
|
||||
"@typescript-eslint/typescript-estree": "8.51.0",
|
||||
"@typescript-eslint/utils": "8.51.0",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
"ts-api-utils": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -1592,9 +1588,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz",
|
||||
"integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz",
|
||||
"integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -1606,21 +1602,21 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.0.tgz",
|
||||
"integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz",
|
||||
"integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.50.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.50.0",
|
||||
"@typescript-eslint/types": "8.50.0",
|
||||
"@typescript-eslint/visitor-keys": "8.50.0",
|
||||
"@typescript-eslint/project-service": "8.51.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.51.0",
|
||||
"@typescript-eslint/types": "8.51.0",
|
||||
"@typescript-eslint/visitor-keys": "8.51.0",
|
||||
"debug": "^4.3.4",
|
||||
"minimatch": "^9.0.4",
|
||||
"semver": "^7.6.0",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
"ts-api-utils": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -1673,16 +1669,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.0.tgz",
|
||||
"integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz",
|
||||
"integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.7.0",
|
||||
"@typescript-eslint/scope-manager": "8.50.0",
|
||||
"@typescript-eslint/types": "8.50.0",
|
||||
"@typescript-eslint/typescript-estree": "8.50.0"
|
||||
"@typescript-eslint/scope-manager": "8.51.0",
|
||||
"@typescript-eslint/types": "8.51.0",
|
||||
"@typescript-eslint/typescript-estree": "8.51.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -1697,13 +1693,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.50.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.0.tgz",
|
||||
"integrity": "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz",
|
||||
"integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.50.0",
|
||||
"@typescript-eslint/types": "8.51.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -1741,7 +1737,6 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -1898,9 +1893,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.9",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.9.tgz",
|
||||
"integrity": "sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg==",
|
||||
"version": "2.9.11",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
||||
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
|
@ -1964,7 +1959,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
|
|
@ -2013,9 +2007,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001760",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz",
|
||||
"integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==",
|
||||
"version": "1.0.30001762",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
|
||||
"integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2370,7 +2364,6 @@
|
|||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -2497,9 +2490,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
|
||||
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
|
@ -2594,9 +2587,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
|
|
@ -3014,7 +3007,6 @@
|
|||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
|
|
@ -3515,7 +3507,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
|
|
@ -3711,7 +3702,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
|
|
@ -3724,7 +3714,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
|
|
@ -3841,9 +3830,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.5",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz",
|
||||
"integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==",
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
|
||||
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -3857,28 +3846,28 @@
|
|||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.53.5",
|
||||
"@rollup/rollup-android-arm64": "4.53.5",
|
||||
"@rollup/rollup-darwin-arm64": "4.53.5",
|
||||
"@rollup/rollup-darwin-x64": "4.53.5",
|
||||
"@rollup/rollup-freebsd-arm64": "4.53.5",
|
||||
"@rollup/rollup-freebsd-x64": "4.53.5",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.5",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.5",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.53.5",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.53.5",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.53.5",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.5",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.5",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.53.5",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.53.5",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.53.5",
|
||||
"@rollup/rollup-linux-x64-musl": "4.53.5",
|
||||
"@rollup/rollup-openharmony-arm64": "4.53.5",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.53.5",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.53.5",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.53.5",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.53.5",
|
||||
"@rollup/rollup-android-arm-eabi": "4.54.0",
|
||||
"@rollup/rollup-android-arm64": "4.54.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.54.0",
|
||||
"@rollup/rollup-darwin-x64": "4.54.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.54.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.54.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.54.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.54.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.54.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.54.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.54.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.54.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.54.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.54.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.54.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.54.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.54.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.54.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.54.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.54.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.54.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
|
|
@ -4132,7 +4121,6 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -4154,9 +4142,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz",
|
||||
"integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -4198,7 +4186,6 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -4208,16 +4195,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.50.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.0.tgz",
|
||||
"integrity": "sha512-Q1/6yNUmCpH94fbgMUMg2/BSAr/6U7GBk61kZTv1/asghQOWOjTlp9K8mixS5NcJmm2creY+UFfGeW/+OcA64A==",
|
||||
"version": "8.51.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.51.0.tgz",
|
||||
"integrity": "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.50.0",
|
||||
"@typescript-eslint/parser": "8.50.0",
|
||||
"@typescript-eslint/typescript-estree": "8.50.0",
|
||||
"@typescript-eslint/utils": "8.50.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.51.0",
|
||||
"@typescript-eslint/parser": "8.51.0",
|
||||
"@typescript-eslint/typescript-estree": "8.51.0",
|
||||
"@typescript-eslint/utils": "8.51.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -4292,7 +4279,6 @@
|
|||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -23,7 +23,7 @@ export const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => {
|
|||
{/* Search Icon / Toggle */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`text-white p-3 hover:text-pink-500 transition-colors duration-300 ${isOpen ? '' : 'w-full h-full flex items-center justify-center'}`}
|
||||
className={`text-white p-3 hover:text-white transition-colors duration-300 ${isOpen ? '' : 'w-full h-full flex items-center justify-center'}`}
|
||||
>
|
||||
<Search size={isOpen ? 18 : 20} className={isOpen ? 'opacity-60' : ''} />
|
||||
</button>
|
||||
|
|
@ -41,7 +41,7 @@ export const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => {
|
|||
</form>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-pink-500/10 to-violet-500/10 pointer-events-none" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-gray-400/10 to-gray-300/10 pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
51
frontend/src/components/SearchSkeleton.tsx
Normal file
51
frontend/src/components/SearchSkeleton.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react';
|
||||
|
||||
interface SearchSkeletonProps {
|
||||
count?: number;
|
||||
estimatedTime?: number;
|
||||
}
|
||||
|
||||
export const SearchSkeleton: React.FC<SearchSkeletonProps> = ({
|
||||
count = 9,
|
||||
estimatedTime
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Countdown Timer */}
|
||||
{estimatedTime !== undefined && estimatedTime > 0 && (
|
||||
<div className="text-center py-2">
|
||||
<p className="text-white/50 text-xs">
|
||||
Estimated time: ~{Math.ceil(estimatedTime)}s
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skeleton Grid */}
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="aspect-[9/16] bg-white/5 rounded-lg animate-pulse relative overflow-hidden"
|
||||
style={{
|
||||
animationDelay: `${index * 100}ms`
|
||||
}}
|
||||
>
|
||||
{/* Shimmer effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent shimmer" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add shimmer keyframes via inline style */}
|
||||
<style>{`
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
.shimmer {
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,12 @@
|
|||
#root {
|
||||
@apply h-full overflow-hidden;
|
||||
}
|
||||
|
||||
/* Mobile-safe full height - accounts for browser chrome */
|
||||
.h-screen-safe {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
|
|
|||
|
|
@ -1,135 +1,148 @@
|
|||
import axios from 'axios';
|
||||
import type { Video } from '../types';
|
||||
import { API_BASE_URL } from '../config';
|
||||
|
||||
interface FeedStats {
|
||||
totalLoaded: number;
|
||||
loadTime: number;
|
||||
batchSize: number;
|
||||
}
|
||||
|
||||
class FeedLoader {
|
||||
private stats: FeedStats = {
|
||||
totalLoaded: 0,
|
||||
loadTime: 0,
|
||||
batchSize: 12
|
||||
};
|
||||
|
||||
private requestCache: Map<string, { data: Video[]; timestamp: number }> = new Map();
|
||||
private CACHE_TTL_MS = 60000;
|
||||
|
||||
async loadFeedWithOptimization(
|
||||
fast: boolean = false,
|
||||
onProgress?: (videos: Video[]) => void
|
||||
): Promise<Video[]> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
if (fast) {
|
||||
const videos = await this.loadWithCache('feed-fast');
|
||||
onProgress?.(videos);
|
||||
return videos;
|
||||
}
|
||||
|
||||
const cacheKey = 'feed-full';
|
||||
const cached = this.getCached(cacheKey);
|
||||
if (cached) {
|
||||
onProgress?.(cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const videos = await this.fetchFeed();
|
||||
this.setCached(cacheKey, videos);
|
||||
|
||||
onProgress?.(videos);
|
||||
|
||||
this.stats.loadTime = performance.now() - startTime;
|
||||
this.stats.totalLoaded = videos.length;
|
||||
|
||||
return videos;
|
||||
} catch (error) {
|
||||
console.error('Feed load failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchFeed(): Promise<Video[]> {
|
||||
const response = await axios.get(`${API_BASE_URL}/feed`);
|
||||
|
||||
if (!Array.isArray(response.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data.map((v: any, i: number) => ({
|
||||
id: v.id || `video-${i}`,
|
||||
url: v.url,
|
||||
author: v.author || 'unknown',
|
||||
description: v.description || '',
|
||||
thumbnail: v.thumbnail,
|
||||
cdn_url: v.cdn_url,
|
||||
views: v.views,
|
||||
likes: v.likes
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadWithCache(key: string): Promise<Video[]> {
|
||||
const cached = this.getCached(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const videos = await this.fetchFeed();
|
||||
this.setCached(key, videos);
|
||||
return videos;
|
||||
}
|
||||
|
||||
private getCached(key: string): Video[] | null {
|
||||
const cached = this.requestCache.get(key);
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) {
|
||||
return cached.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private setCached(key: string, data: Video[]): void {
|
||||
this.requestCache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
getStats(): FeedStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.requestCache.clear();
|
||||
}
|
||||
|
||||
getOptimalBatchSize(): number {
|
||||
const connection = (navigator as any).connection;
|
||||
|
||||
if (!connection) {
|
||||
return 15;
|
||||
}
|
||||
|
||||
const effectiveType = connection.effectiveType;
|
||||
|
||||
switch (effectiveType) {
|
||||
case '4g':
|
||||
return 20;
|
||||
case '3g':
|
||||
return 12;
|
||||
case '2g':
|
||||
return 6;
|
||||
default:
|
||||
return 15;
|
||||
}
|
||||
}
|
||||
|
||||
shouldPrefetchThumbnails(): boolean {
|
||||
const connection = (navigator as any).connection;
|
||||
if (!connection) return true;
|
||||
return connection.saveData !== true;
|
||||
}
|
||||
}
|
||||
|
||||
export const feedLoader = new FeedLoader();
|
||||
import axios from 'axios';
|
||||
import type { Video } from '../types';
|
||||
import { API_BASE_URL } from '../config';
|
||||
|
||||
interface FeedStats {
|
||||
totalLoaded: number;
|
||||
loadTime: number;
|
||||
batchSize: number;
|
||||
}
|
||||
|
||||
class FeedLoader {
|
||||
private stats: FeedStats = {
|
||||
totalLoaded: 0,
|
||||
loadTime: 0,
|
||||
batchSize: 12
|
||||
};
|
||||
|
||||
private requestCache: Map<string, { data: Video[]; timestamp: number }> = new Map();
|
||||
private CACHE_TTL_MS = 60000;
|
||||
|
||||
async loadFeedWithOptimization(
|
||||
fast: boolean = false,
|
||||
onProgress?: (videos: Video[]) => void,
|
||||
skipCache: boolean = false
|
||||
): Promise<Video[]> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
if (fast && !skipCache) {
|
||||
const videos = await this.loadWithCache('feed-fast');
|
||||
onProgress?.(videos);
|
||||
return videos;
|
||||
}
|
||||
|
||||
const cacheKey = 'feed-full';
|
||||
|
||||
// Skip cache check when explicitly requested (for infinite scroll)
|
||||
if (!skipCache) {
|
||||
const cached = this.getCached(cacheKey);
|
||||
if (cached) {
|
||||
onProgress?.(cached);
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
const videos = await this.fetchFeed(skipCache);
|
||||
|
||||
// Only cache if not skipping (initial load)
|
||||
if (!skipCache) {
|
||||
this.setCached(cacheKey, videos);
|
||||
}
|
||||
|
||||
onProgress?.(videos);
|
||||
|
||||
this.stats.loadTime = performance.now() - startTime;
|
||||
this.stats.totalLoaded = videos.length;
|
||||
|
||||
return videos;
|
||||
} catch (error) {
|
||||
console.error('Feed load failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchFeed(skipCache: boolean = false): Promise<Video[]> {
|
||||
// Add skip_cache parameter to force backend to fetch fresh videos
|
||||
const url = skipCache
|
||||
? `${API_BASE_URL}/feed?skip_cache=true`
|
||||
: `${API_BASE_URL}/feed`;
|
||||
const response = await axios.get(url);
|
||||
|
||||
if (!Array.isArray(response.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data.map((v: any, i: number) => ({
|
||||
id: v.id || `video-${i}`,
|
||||
url: v.url,
|
||||
author: v.author || 'unknown',
|
||||
description: v.description || '',
|
||||
thumbnail: v.thumbnail,
|
||||
cdn_url: v.cdn_url,
|
||||
views: v.views,
|
||||
likes: v.likes
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadWithCache(key: string): Promise<Video[]> {
|
||||
const cached = this.getCached(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const videos = await this.fetchFeed();
|
||||
this.setCached(key, videos);
|
||||
return videos;
|
||||
}
|
||||
|
||||
private getCached(key: string): Video[] | null {
|
||||
const cached = this.requestCache.get(key);
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) {
|
||||
return cached.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private setCached(key: string, data: Video[]): void {
|
||||
this.requestCache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
getStats(): FeedStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.requestCache.clear();
|
||||
}
|
||||
|
||||
getOptimalBatchSize(): number {
|
||||
const connection = (navigator as any).connection;
|
||||
|
||||
if (!connection) {
|
||||
return 15;
|
||||
}
|
||||
|
||||
const effectiveType = connection.effectiveType;
|
||||
|
||||
switch (effectiveType) {
|
||||
case '4g':
|
||||
return 20;
|
||||
case '3g':
|
||||
return 12;
|
||||
case '2g':
|
||||
return 6;
|
||||
default:
|
||||
return 15;
|
||||
}
|
||||
}
|
||||
|
||||
shouldPrefetchThumbnails(): boolean {
|
||||
const connection = (navigator as any).connection;
|
||||
if (!connection) return true;
|
||||
return connection.saveData !== true;
|
||||
}
|
||||
}
|
||||
|
||||
export const feedLoader = new FeedLoader();
|
||||
|
|
|
|||
|
|
@ -1,207 +1,207 @@
|
|||
interface CachedVideo {
|
||||
id: string;
|
||||
url: string;
|
||||
data: Blob;
|
||||
timestamp: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const DB_NAME = 'PureStreamCache';
|
||||
const STORE_NAME = 'videos';
|
||||
const MAX_CACHE_SIZE_MB = 200;
|
||||
const CACHE_TTL_HOURS = 24;
|
||||
|
||||
class VideoCache {
|
||||
private db: IDBDatabase | null = null;
|
||||
private initialized = false;
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, 1);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
this.initialized = true;
|
||||
this.cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (e) => {
|
||||
const db = (e.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async get(url: string): Promise<Blob | null> {
|
||||
if (!this.db) await this.init();
|
||||
if (!this.db) return null;
|
||||
|
||||
const videoId = this.getVideoId(url);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(videoId);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const cached = request.result as CachedVideo | undefined;
|
||||
if (cached) {
|
||||
const ageHours = (Date.now() - cached.timestamp) / (1000 * 60 * 60);
|
||||
if (ageHours < CACHE_TTL_HOURS) {
|
||||
resolve(cached.data);
|
||||
return;
|
||||
}
|
||||
this.delete(videoId);
|
||||
}
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
request.onerror = () => resolve(null);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async set(url: string, blob: Blob): Promise<void> {
|
||||
if (!this.db) await this.init();
|
||||
if (!this.db) return;
|
||||
|
||||
const videoId = this.getVideoId(url);
|
||||
const cached: CachedVideo = {
|
||||
id: videoId,
|
||||
url,
|
||||
data: blob,
|
||||
timestamp: Date.now(),
|
||||
size: blob.size
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.put(cached);
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async delete(videoId: string): Promise<void> {
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.delete(videoId);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.clear();
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getStats(): Promise<{ size_mb: number; count: number }> {
|
||||
if (!this.db) return { size_mb: 0, count: 0 };
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const cached = request.result as CachedVideo[];
|
||||
const totalSize = cached.reduce((sum, v) => sum + v.size, 0);
|
||||
resolve({
|
||||
size_mb: Math.round(totalSize / 1024 / 1024 * 100) / 100,
|
||||
count: cached.length
|
||||
});
|
||||
};
|
||||
|
||||
request.onerror = () => resolve({ size_mb: 0, count: 0 });
|
||||
} catch {
|
||||
resolve({ size_mb: 0, count: 0 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async cleanup(): Promise<void> {
|
||||
if (!this.db) return;
|
||||
|
||||
const stats = await this.getStats();
|
||||
if (stats.size_mb < MAX_CACHE_SIZE_MB) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const cached = (request.result as CachedVideo[]).sort(
|
||||
(a, b) => a.timestamp - b.timestamp
|
||||
);
|
||||
|
||||
let totalSize = cached.reduce((sum, v) => sum + v.size, 0);
|
||||
const targetSize = MAX_CACHE_SIZE_MB * 1024 * 1024 * 0.8;
|
||||
|
||||
for (const video of cached) {
|
||||
if (totalSize <= targetSize) break;
|
||||
const deleteReq = store.delete(video.id);
|
||||
deleteReq.onsuccess = () => {
|
||||
totalSize -= video.size;
|
||||
};
|
||||
}
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getVideoId(url: string): string {
|
||||
const match = url.match(/video\/(\d+)|id=([^&]+)/);
|
||||
return match ? match[1] || match[2] : url.substring(0, 50);
|
||||
}
|
||||
}
|
||||
|
||||
export const videoCache = new VideoCache();
|
||||
interface CachedVideo {
|
||||
id: string;
|
||||
url: string;
|
||||
data: Blob;
|
||||
timestamp: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const DB_NAME = 'PureStreamCache';
|
||||
const STORE_NAME = 'videos';
|
||||
const MAX_CACHE_SIZE_MB = 200;
|
||||
const CACHE_TTL_HOURS = 24;
|
||||
|
||||
class VideoCache {
|
||||
private db: IDBDatabase | null = null;
|
||||
private initialized = false;
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, 1);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
this.initialized = true;
|
||||
this.cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (e) => {
|
||||
const db = (e.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async get(url: string): Promise<Blob | null> {
|
||||
if (!this.db) await this.init();
|
||||
if (!this.db) return null;
|
||||
|
||||
const videoId = this.getVideoId(url);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(videoId);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const cached = request.result as CachedVideo | undefined;
|
||||
if (cached) {
|
||||
const ageHours = (Date.now() - cached.timestamp) / (1000 * 60 * 60);
|
||||
if (ageHours < CACHE_TTL_HOURS) {
|
||||
resolve(cached.data);
|
||||
return;
|
||||
}
|
||||
this.delete(videoId);
|
||||
}
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
request.onerror = () => resolve(null);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async set(url: string, blob: Blob): Promise<void> {
|
||||
if (!this.db) await this.init();
|
||||
if (!this.db) return;
|
||||
|
||||
const videoId = this.getVideoId(url);
|
||||
const cached: CachedVideo = {
|
||||
id: videoId,
|
||||
url,
|
||||
data: blob,
|
||||
timestamp: Date.now(),
|
||||
size: blob.size
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.put(cached);
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async delete(videoId: string): Promise<void> {
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.delete(videoId);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.clear();
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getStats(): Promise<{ size_mb: number; count: number }> {
|
||||
if (!this.db) return { size_mb: 0, count: 0 };
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const cached = request.result as CachedVideo[];
|
||||
const totalSize = cached.reduce((sum, v) => sum + v.size, 0);
|
||||
resolve({
|
||||
size_mb: Math.round(totalSize / 1024 / 1024 * 100) / 100,
|
||||
count: cached.length
|
||||
});
|
||||
};
|
||||
|
||||
request.onerror = () => resolve({ size_mb: 0, count: 0 });
|
||||
} catch {
|
||||
resolve({ size_mb: 0, count: 0 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async cleanup(): Promise<void> {
|
||||
if (!this.db) return;
|
||||
|
||||
const stats = await this.getStats();
|
||||
if (stats.size_mb < MAX_CACHE_SIZE_MB) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const tx = this.db!.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const cached = (request.result as CachedVideo[]).sort(
|
||||
(a, b) => a.timestamp - b.timestamp
|
||||
);
|
||||
|
||||
let totalSize = cached.reduce((sum, v) => sum + v.size, 0);
|
||||
const targetSize = MAX_CACHE_SIZE_MB * 1024 * 1024 * 0.8;
|
||||
|
||||
for (const video of cached) {
|
||||
if (totalSize <= targetSize) break;
|
||||
const deleteReq = store.delete(video.id);
|
||||
deleteReq.onsuccess = () => {
|
||||
totalSize -= video.size;
|
||||
};
|
||||
}
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getVideoId(url: string): string {
|
||||
const match = url.match(/video\/(\d+)|id=([^&]+)/);
|
||||
return match ? match[1] || match[2] : url.substring(0, 50);
|
||||
}
|
||||
}
|
||||
|
||||
export const videoCache = new VideoCache();
|
||||
|
|
|
|||
|
|
@ -1,95 +1,128 @@
|
|||
import { videoCache } from './videoCache';
|
||||
import type { Video } from '../types';
|
||||
|
||||
interface PrefetchConfig {
|
||||
lookahead: number;
|
||||
concurrency: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: PrefetchConfig = {
|
||||
lookahead: 2,
|
||||
concurrency: 1,
|
||||
timeoutMs: 30000
|
||||
};
|
||||
|
||||
class VideoPrefetcher {
|
||||
private prefetchQueue: Set<string> = new Set();
|
||||
private config: PrefetchConfig;
|
||||
private isInitialized = false;
|
||||
|
||||
constructor(config: Partial<PrefetchConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
await videoCache.init();
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
async prefetchNext(
|
||||
videos: Video[],
|
||||
currentIndex: number
|
||||
): Promise<void> {
|
||||
if (!this.isInitialized) await this.init();
|
||||
|
||||
const endIndex = Math.min(
|
||||
currentIndex + this.config.lookahead,
|
||||
videos.length
|
||||
);
|
||||
|
||||
const toPrefetch = videos
|
||||
.slice(currentIndex + 1, endIndex)
|
||||
.filter((v) => v.url && !this.prefetchQueue.has(v.id));
|
||||
|
||||
for (const video of toPrefetch) {
|
||||
this.prefetchQueue.add(video.id);
|
||||
this.prefetchVideo(video).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
private async prefetchVideo(video: Video): Promise<void> {
|
||||
if (!video.url) return;
|
||||
|
||||
const cached = await videoCache.get(video.url);
|
||||
if (cached) {
|
||||
this.prefetchQueue.delete(video.id);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
this.config.timeoutMs
|
||||
);
|
||||
|
||||
const response = await fetch(video.url, {
|
||||
signal: controller.signal,
|
||||
headers: { Range: 'bytes=0-1048576' }
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
await videoCache.set(video.url, blob);
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug(`Prefetch failed for ${video.id}:`, error);
|
||||
} finally {
|
||||
this.prefetchQueue.delete(video.id);
|
||||
}
|
||||
}
|
||||
|
||||
clearQueue(): void {
|
||||
this.prefetchQueue.clear();
|
||||
}
|
||||
|
||||
getQueueSize(): number {
|
||||
return this.prefetchQueue.size;
|
||||
}
|
||||
}
|
||||
|
||||
export const videoPrefetcher = new VideoPrefetcher();
|
||||
import { videoCache } from './videoCache';
|
||||
import type { Video } from '../types';
|
||||
|
||||
interface PrefetchConfig {
|
||||
lookahead: number;
|
||||
concurrency: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: PrefetchConfig = {
|
||||
lookahead: 3, // Increased from 2 for better buffering
|
||||
concurrency: 2, // Increased from 1 for parallel downloads
|
||||
timeoutMs: 30000
|
||||
};
|
||||
|
||||
class VideoPrefetcher {
|
||||
private prefetchQueue: Set<string> = new Set();
|
||||
private config: PrefetchConfig;
|
||||
private isInitialized = false;
|
||||
|
||||
constructor(config: Partial<PrefetchConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
await videoCache.init();
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
async prefetchNext(
|
||||
videos: Video[],
|
||||
currentIndex: number
|
||||
): Promise<void> {
|
||||
if (!this.isInitialized) await this.init();
|
||||
|
||||
const endIndex = Math.min(
|
||||
currentIndex + this.config.lookahead,
|
||||
videos.length
|
||||
);
|
||||
|
||||
const toPrefetch = videos
|
||||
.slice(currentIndex + 1, endIndex)
|
||||
.filter((v) => v.url && !this.prefetchQueue.has(v.id));
|
||||
|
||||
for (const video of toPrefetch) {
|
||||
this.prefetchQueue.add(video.id);
|
||||
this.prefetchVideo(video).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch initial batch of videos immediately after feed loads.
|
||||
* This ensures first few videos are ready before user starts scrolling.
|
||||
*/
|
||||
async prefetchInitialBatch(
|
||||
videos: Video[],
|
||||
count: number = 3
|
||||
): Promise<void> {
|
||||
if (!this.isInitialized) await this.init();
|
||||
if (videos.length === 0) return;
|
||||
|
||||
console.log(`PREFETCH: Starting initial batch of ${Math.min(count, videos.length)} videos...`);
|
||||
|
||||
const toPrefetch = videos
|
||||
.slice(0, count)
|
||||
.filter((v) => v.url && !this.prefetchQueue.has(v.id));
|
||||
|
||||
// Start all prefetches in parallel (respects concurrency via browser limits)
|
||||
const promises = toPrefetch.map((video) => {
|
||||
this.prefetchQueue.add(video.id);
|
||||
return this.prefetchVideo(video);
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
console.log(`PREFETCH: Initial batch complete (${toPrefetch.length} videos buffered)`);
|
||||
}
|
||||
|
||||
private async prefetchVideo(video: Video): Promise<void> {
|
||||
if (!video.url) return;
|
||||
|
||||
const cached = await videoCache.get(video.url);
|
||||
if (cached) {
|
||||
this.prefetchQueue.delete(video.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8002/api'; // Hardcoded or imported from config
|
||||
const fullProxyUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}`;
|
||||
// Use thin proxy if available for better performance
|
||||
const thinProxyUrl = video.cdn_url ? `${API_BASE_URL}/feed/thin-proxy?cdn_url=${encodeURIComponent(video.cdn_url)}` : null;
|
||||
const targetUrl = thinProxyUrl || fullProxyUrl;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
this.config.timeoutMs
|
||||
);
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
signal: controller.signal,
|
||||
headers: { Range: 'bytes=0-1048576' }
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
await videoCache.set(video.url, blob);
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug(`Prefetch failed for ${video.id}:`, error);
|
||||
} finally {
|
||||
this.prefetchQueue.delete(video.id);
|
||||
}
|
||||
}
|
||||
|
||||
clearQueue(): void {
|
||||
this.prefetchQueue.clear();
|
||||
}
|
||||
|
||||
getQueueSize(): number {
|
||||
return this.prefetchQueue.size;
|
||||
}
|
||||
}
|
||||
|
||||
export const videoPrefetcher = new VideoPrefetcher();
|
||||
|
|
|
|||
30
simple_test.py
Normal file
30
simple_test.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import urllib.request
|
||||
import json
|
||||
|
||||
try:
|
||||
print("Testing /health...")
|
||||
with urllib.request.urlopen("http://localhost:8002/health", timeout=5) as r:
|
||||
print(f"Health: {r.status}")
|
||||
|
||||
print("Testing /api/feed...")
|
||||
with open("temp_cookies.json", "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Ensure list format
|
||||
if isinstance(data, dict) and "credentials" in data:
|
||||
data = data["credentials"]
|
||||
|
||||
# Prepare body as dict for safety with new Union type
|
||||
body = {"credentials": data}
|
||||
|
||||
req = urllib.request.Request(
|
||||
"http://localhost:8002/api/feed",
|
||||
data=json.dumps(body).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
print(f"Feed: {r.status}")
|
||||
print(r.read().decode('utf-8')[:100])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
314
temp_cookies.json
Normal file
314
temp_cookies.json
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
{
|
||||
"credentials": [
|
||||
{
|
||||
"domain": ".www.tiktok.com",
|
||||
"expirationDate": 1784039026,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "delay_guest_mode_vid",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "8"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1768131143.251207,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "msToken",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "jntmMFSrdBzHw3GQQ7xigi2HLM03wLgd2s8xW8sa8bm3gVg-VJu63FlYSfvPAW6tmoNM-Ww9ho9sOKZc75EN1XIGwct0ndkyOairFWbXgkiFwPXfDpQaBA9pn2_9mSOYSylT1H60yH1ufg=="
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.646103,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "tt_session_tlb_tag",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "sttt%7C1%7C6M35zM57kkqAGSs_LSUdRP_________zyplxYaEARSr2PNU_6cKcB0lq4WRz1GKKY43u399i5hs%3D"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1798369620.645922,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "sid_guard",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "e8cdf9ccce7b924a80192b3f2d251d44%7C1767265616%7C15552000%7CTue%2C+30-Jun-2026+11%3A06%3A56+GMT"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1798801628.385793,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "ttwid",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "1%7CAYDsetgnxt5vzYX8hD6Wq2DQ4FXiL_pqcdLwHWwz6B8%7C1767265624%7C6121d82381fb651afeae94341e45b87fca1d903fbec0d8a19e4dd5440a89a424"
|
||||
},
|
||||
{
|
||||
"domain": ".www.tiktok.com",
|
||||
"expirationDate": 1767870430,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "perf_feed_cache",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "{%22expireTimestamp%22:1767438000000%2C%22itemIds%22:[%227588749061168123154%22%2C%227589493510613552404%22%2C%227586917939568332054%22]}"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.645952,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "uid_tt",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1772449601.742227,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "passport_csrf_token_default",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "9c66ab10306611c75fa19c87e54fd31b"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "s_v_web_id",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": true,
|
||||
"storeId": null,
|
||||
"value": "verify_mjvcbi31_l3mxUEeU_ykis_4x6z_859S_8zEFmNseEnJU"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.64617,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "ssid_ucp_v1",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
|
||||
},
|
||||
{
|
||||
"domain": ".www.tiktok.com",
|
||||
"expirationDate": 1793185625,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "tiktok_webapp_theme",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "dark"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1799409936.219767,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "_ttp",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "32XOXKxwj8YLtBQf0OBn4TvlkPN"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1772449620.645821,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "cmpl_token",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "AgQYAPOF_hfkTtKPtFExgPKdOPKrXVkNUj-FDmCi6K4"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1772449620.645628,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "multi_sids",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "6884525631502042113%3Ae8cdf9ccce7b924a80192b3f2d251d44"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1769857620.645892,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "passport_auth_status_ss",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "966972581a398dbb9ead189c044cf98c%2C"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1772449601.742082,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "passport_csrf_token",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "9c66ab10306611c75fa19c87e54fd31b"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.646041,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "sessionid",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.646073,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "sessionid_ss",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.646008,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "sid_tt",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.646132,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "sid_ucp_v1",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
|
||||
},
|
||||
{
|
||||
"domain": ".www.tiktok.com",
|
||||
"expirationDate": 1793185625,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "tiktok_webapp_theme_source",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "auto"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817624.001151,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "tt_chain_token",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "6deMEWrkAGUe9R0tCISIoQ=="
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "tt_csrf_token",
|
||||
"path": "/",
|
||||
"sameSite": "lax",
|
||||
"secure": true,
|
||||
"session": true,
|
||||
"storeId": null,
|
||||
"value": "q0Q4ki72-I7zQB6eLbpBBaqFBGrUF_v85N9s"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.645979,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "uid_tt_ss",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
test_login.py
Normal file
16
test_login.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import requests
|
||||
import time
|
||||
|
||||
URL = "http://localhost:8002/api/auth/admin-login"
|
||||
|
||||
def test_login():
|
||||
print("Testing Admin Login...")
|
||||
try:
|
||||
res = requests.post(URL, json={"password": "admin123"})
|
||||
print(f"Status: {res.status_code}")
|
||||
print(f"Response: {res.text}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_login()
|
||||
30
test_request.py
Normal file
30
test_request.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import urllib.request
|
||||
import json
|
||||
import os
|
||||
|
||||
with open("temp_cookies.json", "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Ensure data is in the expected dict format for the request body
|
||||
if isinstance(data, list):
|
||||
# If temp_cookies is just the list, wrap it
|
||||
body = {"credentials": data}
|
||||
elif "credentials" not in data:
|
||||
body = {"credentials": data}
|
||||
else:
|
||||
body = data
|
||||
|
||||
req = urllib.request.Request(
|
||||
"http://localhost:8002/api/feed",
|
||||
data=json.dumps(body).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'}
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as response:
|
||||
print(response.read().decode('utf-8'))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"HTTP Error: {e.code}")
|
||||
print(e.read().decode('utf-8'))
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
35
test_search.py
Normal file
35
test_search.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import requests
|
||||
import json
|
||||
import time
|
||||
|
||||
BASE_URL = "http://localhost:8002/api/user/search"
|
||||
|
||||
def test_search():
|
||||
print("Testing Search API...")
|
||||
try:
|
||||
# Simple query
|
||||
params = {
|
||||
"query": "dance",
|
||||
"limit": 50,
|
||||
"cursor": 0
|
||||
}
|
||||
start = time.time()
|
||||
res = requests.get(BASE_URL, params=params)
|
||||
duration = time.time() - start
|
||||
|
||||
print(f"Status Code: {res.status_code}")
|
||||
print(f"Duration: {duration:.2f}s")
|
||||
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
print(f"Videos Found: {len(data.get('videos', []))}")
|
||||
# print(json.dumps(data, indent=2))
|
||||
else:
|
||||
print("Error Response:")
|
||||
print(res.text)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Request Failed: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_search()
|
||||
Loading…
Reference in a new issue