Compare commits

...

6 commits

18 changed files with 1394 additions and 531 deletions

View file

@ -1,77 +1,39 @@
# Build stage for frontend
FROM node:20-alpine AS frontend-builder
# 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
# Production stage
# Runtime Stage for Backend
FROM python:3.11-slim
# Install system dependencies (minimal - no VNC needed)
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
# Install system dependencies required for Playwright and compiled extensions
RUN apt-get update && apt-get install -y \
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 \
git \
build-essential \
&& 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 Python dependencies
COPY backend/requirements.txt backend/
RUN pip install --no-cache-dir -r backend/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
# Install Playwright browsers (Chromium only to save space)
RUN playwright install chromium
RUN playwright install-deps chromium
# Copy backend code
COPY backend/ ./backend/
# Copy Backend Code
COPY backend/ backend/
# Copy built frontend
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
# Copy Built Frontend Assets
COPY --from=frontend-build /app/frontend/dist /app/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 Port
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"]
# Run Application
CMD ["python", "backend/main.py"]

View file

@ -18,8 +18,10 @@ class BrowserLoginResponse(BaseModel):
cookie_count: int = 0
from typing import Any
class CredentialsRequest(BaseModel):
credentials: dict # JSON credentials in http.headers format
credentials: Any # Accept both dict and list
class CredentialLoginRequest(BaseModel):
@ -79,9 +81,8 @@ async def save_credentials(request: CredentialsRequest):
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)
# Save full cookie list with domains/paths preserved
PlaywrightManager.save_credentials(cookies, user_agent)
return {
"status": "success",
@ -99,10 +100,19 @@ async def auth_status():
try:
with open(COOKIES_FILE, "r") as f:
cookies = json.load(f)
has_session = "sessionid" in cookies
# 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": len(cookies)
"cookie_count": cookie_count
}
except:
pass
@ -147,7 +157,8 @@ async def stop_vnc_login():
# ========== ADMIN ENDPOINTS ==========
# Admin password from environment variable
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin123")
# 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()
@ -164,6 +175,7 @@ class AdminCookiesRequest(BaseModel):
@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)
@ -187,22 +199,30 @@ async def admin_update_cookies(request: AdminCookiesRequest, token: str = ""):
try:
cookies = request.cookies
# Normalize cookies to dict format
# Preserve list if it contains metadata (like domain)
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
# 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):
if not isinstance(cookies, (dict, list)):
raise HTTPException(status_code=400, detail="Invalid cookies format")
if "sessionid" not in cookies:
# 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
# Save cookies (either dict or list)
PlaywrightManager.save_credentials(cookies, None)
return {

View file

@ -132,9 +132,11 @@ 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[dict] = None
credentials: Optional[Union[Dict, List]] = None
@router.post("")

View file

@ -63,3 +63,36 @@ async def remove_following(username: str):
save_following(following)
return {"status": "success", "following": following}
@router.get("/feed")
async def get_following_feed(limit_per_user: int = 5):
"""
Get a combined feed of videos from all followed creators.
"""
from core.playwright_manager import PlaywrightManager
import asyncio
following = load_following()
if not following:
return []
# Load stored credentials
cookies, user_agent = PlaywrightManager.load_stored_credentials()
if not cookies:
raise HTTPException(status_code=401, detail="Not authenticated")
tasks = [PlaywrightManager.fetch_user_videos(user, cookies, user_agent, limit_per_user) for user in following]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_videos = []
for result in results:
if isinstance(result, list):
all_videos.extend(result)
# Shuffle results to make it look like a feed
import random
random.shuffle(all_videos)
return all_videos

View file

@ -108,7 +108,7 @@ async def get_multiple_profiles(usernames: str = Query(..., description="Comma-s
@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)
limit: int = Query(10, description="Max videos to fetch", ge=1, le=60)
):
"""
Fetch videos from a TikTok user's profile.
@ -135,7 +135,8 @@ async def get_user_videos(
@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)
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.
@ -147,11 +148,11 @@ async def search_videos(
if not cookies:
raise HTTPException(status_code=401, detail="Not authenticated")
print(f"Searching for: {query}...")
print(f"Searching for: {query} (limit={limit}, cursor={cursor})...")
try:
videos = await PlaywrightManager.search_videos(query, cookies, user_agent, limit)
return {"query": query, "videos": videos, "count": len(videos)}
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))

View file

@ -7,10 +7,11 @@ Uses Playwright to:
3. Intercept /item_list API responses (instead of scraping HTML)
"""
import asyncio
import json
import os
from typing import List, Dict, Optional
import json
import asyncio
import traceback
from typing import List, Dict, Optional, Any
from playwright.async_api import async_playwright, Response, Browser, BrowserContext
try:
@ -52,51 +53,70 @@ class PlaywrightManager:
_vnc_active = False
@staticmethod
def parse_json_credentials(json_creds: dict) -> tuple[List[dict], str]:
def parse_json_credentials(json_creds: Any) -> tuple[List[dict], str]:
"""
Parse JSON credentials in the format:
{
"http": {
"headers": {"User-Agent": "...", "Cookie": "..."},
"cookies": {"sessionid": "...", "ttwid": "..."}
}
}
Parse JSON credentials. Supports:
1. Array format: [{"name": "...", "value": "..."}, ...]
2. http object format: {"http": {"headers": {...}, "cookies": {...}}}
Returns: (cookies_list, user_agent)
"""
cookies = []
user_agent = PlaywrightManager.DEFAULT_USER_AGENT
http_data = json_creds.get("http", {})
headers = http_data.get("headers", {})
cookies_dict = http_data.get("cookies", {})
# Get User-Agent from headers
if "User-Agent" in headers:
user_agent = headers["User-Agent"]
# Parse cookies from the cookies dict (preferred)
if cookies_dict:
for name, value in cookies_dict.items():
cookies.append({
"name": name,
"value": str(value),
"domain": ".tiktok.com",
"path": "/"
})
# Fallback: parse from Cookie header string
elif "Cookie" in headers:
cookie_str = headers["Cookie"]
for part in cookie_str.split(";"):
part = part.strip()
if "=" in part:
name, value = part.split("=", 1)
# Handle array format (Cookie-Editor)
if isinstance(json_creds, list):
for c in json_creds:
if isinstance(c, dict) and "name" in c and "value" in c:
cookie = {
"name": c["name"],
"value": str(c["value"]),
"domain": c.get("domain") or ".tiktok.com",
"path": c.get("path") or "/",
"secure": c.get("secure", True),
"httpOnly": c.get("httpOnly", False)
}
if "sameSite" in c and c["sameSite"]:
# Playwright expects "Strict", "Lax", or "None"
ss = str(c["sameSite"]).capitalize()
if ss in ["Strict", "Lax", "None"]:
cookie["sameSite"] = ss
cookies.append(cookie)
return cookies, user_agent
# Handle object format
if isinstance(json_creds, dict):
http_data = json_creds.get("http", {})
headers = http_data.get("headers", {})
cookies_dict = http_data.get("cookies", {})
# Get User-Agent from headers
if "User-Agent" in headers:
user_agent = headers["User-Agent"]
# Parse cookies from the cookies dict (preferred)
if cookies_dict:
for name, value in cookies_dict.items():
cookies.append({
"name": name.strip(),
"value": value.strip(),
"name": name,
"value": str(value),
"domain": ".tiktok.com",
"path": "/"
})
# Fallback: parse from Cookie header string
elif "Cookie" in headers:
cookie_str = headers["Cookie"]
for part in cookie_str.split(";"):
part = part.strip()
if "=" in part:
name, value = part.split("=", 1)
cookies.append({
"name": name.strip(),
"value": value.strip(),
"domain": ".tiktok.com",
"path": "/"
})
return cookies, user_agent
@ -109,14 +129,38 @@ class PlaywrightManager:
if os.path.exists(COOKIES_FILE):
try:
with open(COOKIES_FILE, "r") as f:
cookie_dict = json.load(f)
for name, value in cookie_dict.items():
cookies.append({
"name": name,
"value": str(value),
"domain": ".tiktok.com",
"path": "/"
})
data = json.load(f)
if isinstance(data, list):
# Sanitize each cookie for Playwright compatibility
for c in data:
if isinstance(c, dict) and "name" in c and "value" in c:
cookie = {
"name": c["name"],
"value": str(c["value"]),
"domain": c.get("domain") or ".tiktok.com",
"path": c.get("path") or "/",
}
# Only add optional fields if they have valid values
if c.get("secure") is not None:
cookie["secure"] = bool(c["secure"])
if c.get("httpOnly") is not None:
cookie["httpOnly"] = bool(c["httpOnly"])
# Sanitize sameSite - Playwright only accepts Strict|Lax|None
if c.get("sameSite"):
ss = str(c["sameSite"]).capitalize()
if ss in ["Strict", "Lax", "None"]:
cookie["sameSite"] = ss
# If invalid, just omit it
cookies.append(cookie)
elif isinstance(data, dict):
# Backward compatibility or simple dict format
for name, value in data.items():
cookies.append({
"name": name,
"value": str(value),
"domain": ".tiktok.com",
"path": "/"
})
except Exception as e:
print(f"Error loading cookies: {e}")
@ -131,13 +175,14 @@ class PlaywrightManager:
return cookies, user_agent
@staticmethod
def save_credentials(cookies: dict, user_agent: str):
def save_credentials(cookies: List[dict] | dict, user_agent: str = None):
"""Save cookies and user agent to files."""
with open(COOKIES_FILE, "w") as f:
json.dump(cookies, f, indent=2)
with open(USER_AGENT_FILE, "w") as f:
json.dump({"user_agent": user_agent}, f)
if user_agent:
with open(USER_AGENT_FILE, "w") as f:
json.dump({"user_agent": user_agent}, f)
@classmethod
async def start_vnc_login(cls) -> dict:
@ -432,16 +477,16 @@ class PlaywrightManager:
@staticmethod
async def intercept_feed(cookies: List[dict] = None, user_agent: str = None, scroll_count: int = 5) -> List[dict]:
"""
Navigate to TikTok For You page and intercept the /item_list API response.
Args:
cookies: Optional list of cookies
user_agent: Optional user agent
scroll_count: Number of times to scroll to fetch more videos (0 = initial load only)
"""Navigate to TikTok feed and intercept API responses."""
try:
return await PlaywrightManager._intercept_feed_impl(cookies, user_agent, scroll_count)
except Exception as e:
print(f"DEBUG: Error in intercept_feed: {e}")
print(traceback.format_exc())
raise e
Returns: List of video objects
"""
@staticmethod
async def _intercept_feed_impl(cookies: List[dict] = None, user_agent: str = None, scroll_count: int = 5) -> List[dict]:
if not cookies:
cookies, user_agent = PlaywrightManager.load_stored_credentials()
@ -487,7 +532,16 @@ class PlaywrightManager:
)
context = await browser.new_context(user_agent=user_agent)
await context.add_cookies(cookies)
if cookies:
try:
await context.add_cookies(cookies)
print(f"DEBUG: Applied {len(cookies)} cookies to browser context")
except Exception as e:
print(f"DEBUG: Error applying cookies: {e}")
if len(cookies) > 0:
print(f"DEBUG: Sample cookie: {cookies[0]}")
raise e
page = await context.new_page()
await stealth_async(page)
@ -536,6 +590,10 @@ class PlaywrightManager:
def _extract_video_data(item: dict) -> Optional[dict]:
"""Extract video data from TikTok API item, including product/shop videos."""
try:
if not isinstance(item, dict):
print(f"DEBUG: Skipping invalid item (type: {type(item)})")
return None
# Handle different API response formats
video_id = item.get("id") or item.get("aweme_id")
@ -694,10 +752,17 @@ class PlaywrightManager:
return captured_videos
@staticmethod
async def search_videos(query: str, cookies: list, user_agent: str = None, limit: int = 12) -> list:
async def search_videos(query: str, cookies: list, user_agent: str = None, limit: int = 20, cursor: int = 0) -> list:
"""
Search for videos by keyword or hashtag.
Uses Playwright to intercept TikTok search results API.
Args:
query: Search query
cookies: Auth cookies
user_agent: Browser user agent
limit: Max videos to capture in this batch
cursor: Starting offset for pagination
"""
from playwright.async_api import async_playwright, Response
from urllib.parse import quote
@ -709,7 +774,7 @@ class PlaywrightManager:
print("DEBUG: No cookies available for search")
return []
print(f"DEBUG: Searching for '{query}'...")
print(f"DEBUG: Searching for '{query}' (limit={limit}, cursor={cursor})...")
captured_videos = []
@ -728,13 +793,17 @@ class PlaywrightManager:
items = data.get("itemList", []) or data.get("data", []) or data.get("item_list", [])
for item in items:
# If we have enough for this specific batch, we don't need more
if len(captured_videos) >= limit:
break
video_data = PlaywrightManager._extract_video_data(item)
if video_data:
captured_videos.append(video_data)
# Avoid duplicates within the same capture session
if not any(v['id'] == video_data['id'] for v in captured_videos):
captured_videos.append(video_data)
print(f"DEBUG: Captured {len(items)} videos from search API")
print(f"DEBUG: Captured {len(items)} videos from search API (Total batch: {len(captured_videos)})")
except Exception as e:
print(f"DEBUG: Error parsing search API response: {e}")
@ -755,22 +824,40 @@ class PlaywrightManager:
try:
# Navigate to TikTok search page
search_url = f"https://www.tiktok.com/search/video?q={quote(query)}"
await page.goto(search_url, wait_until="networkidle", timeout=30000)
try:
await page.goto(search_url, wait_until="domcontentloaded", timeout=15000)
except:
print("DEBUG: Navigation timeout, proceeding anyway")
# Wait for videos to load
# Wait for initial results
await asyncio.sleep(3)
# Scroll to trigger more loading
for _ in range(2):
await page.evaluate("window.scrollBy(0, 800)")
await asyncio.sleep(1)
# Scroll based on cursor to reach previous results and then capture new ones
# Each scroll typically loads 12-20 items
# We scroll more as the cursor increases
scroll_count = (cursor // 10) + 1
# Limit total scrolls to avoid hanging
scroll_count = min(scroll_count, 10)
for i in range(scroll_count):
await page.evaluate("window.scrollBy(0, 1500)")
await asyncio.sleep(1.5)
# After reaching the offset, scroll a bit more to trigger the specific batch capture
batch_scrolls = (limit // 10) + 2 # Add extra scrolls to be safe
for _ in range(batch_scrolls):
await page.evaluate("window.scrollBy(0, 2000)") # Larger scroll
await asyncio.sleep(1.0) # Faster scroll cadence
# Wait a bit after scrolling for all responses to settle
await asyncio.sleep(2.5)
except Exception as e:
print(f"DEBUG: Error during search: {e}")
await browser.close()
print(f"DEBUG: Total captured search videos: {len(captured_videos)}")
print(f"DEBUG: Total captured search videos in this batch: {len(captured_videos)}")
return captured_videos
@staticmethod

View file

@ -5,16 +5,40 @@ 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,
@ -54,5 +78,5 @@ if FRONTEND_DIR.exists():
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=True)
uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=False, loop="asyncio")

25
backend/run_server.py Normal file
View file

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

312
cookies.json Normal file
View file

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

View file

@ -3,7 +3,7 @@ import { VideoPlayer } from './VideoPlayer';
import type { Video, UserProfile } from '../types';
import axios from 'axios';
import { API_BASE_URL } from '../config';
import { Home, Users, Search, X, Plus } from 'lucide-react';
import { Search, X, Plus } from 'lucide-react';
import { videoPrefetcher } from '../utils/videoPrefetch';
import { feedLoader } from '../utils/feedLoader';
@ -84,19 +84,6 @@ const SUGGESTED_ACCOUNTS = [
{ username: '@taylerholder', label: '🔥 Tayler Holder' },
];
// Inspirational quotes for loading states
const INSPIRATION_QUOTES = [
{ text: "Dance like nobody's watching", author: "William W. Purkey" },
{ text: "Life is short, make every moment count", author: "Unknown" },
{ text: "Create the things you wish existed", author: "Unknown" },
{ text: "Be yourself; everyone else is taken", author: "Oscar Wilde" },
{ text: "Stay hungry, stay foolish", author: "Steve Jobs" },
{ text: "The only way to do great work is to love what you do", author: "Steve Jobs" },
{ text: "Dream big, start small", author: "Unknown" },
{ text: "Creativity takes courage", author: "Henri Matisse" },
];
// NOTE: Keyword search is now handled by the backend /api/user/search endpoint
export const Feed: React.FC = () => {
@ -104,8 +91,11 @@ export const Feed: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('foryou');
const [videos, setVideos] = useState<Video[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [error, setError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [searchCursor, setSearchCursor] = useState(0);
const [searchHasMore, setSearchHasMore] = useState(true);
const [isInSearchPlayback, setIsInSearchPlayback] = useState(false);
const [originalVideos, setOriginalVideos] = useState<Video[]>([]);
const [originalIndex, setOriginalIndex] = useState(0);
const [jsonInput, setJsonInput] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
@ -116,12 +106,17 @@ export const Feed: React.FC = () => {
// Suggested profiles with real data
const [suggestedProfiles, setSuggestedProfiles] = useState<UserProfile[]>([]);
const [loadingProfiles, setLoadingProfiles] = useState(false);
const [suggestedLimit, setSuggestedLimit] = useState(12); // Lazy load - start with 12
const [suggestedLimit, setSuggestedLimit] = useState(12);
const [showHeader, setShowHeader] = useState(false);
const [isFollowingFeed, setIsFollowingFeed] = useState(false);
// Lazy load - start with 12
// Search state
const [searchInput, setSearchInput] = useState('');
const [searchResults, setSearchResults] = useState<Video[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
// Global mute state - persists across video scrolling
const [isMuted, setIsMuted] = useState(true);
@ -131,6 +126,7 @@ export const Feed: React.FC = () => {
const touchEnd = useRef<number | null>(null);
const minSwipeDistance = 50;
// Touch Handling (Mobile)
const onTouchStart = (e: React.TouchEvent) => {
touchEnd.current = null;
touchStart.current = e.targetTouches[0].clientX;
@ -141,18 +137,51 @@ export const Feed: React.FC = () => {
};
const onTouchEnd = () => {
handleSwipeEnd();
};
// Mouse Handling (Desktop)
const onMouseDown = (e: React.MouseEvent) => {
touchEnd.current = null;
touchStart.current = e.clientX;
};
const onMouseMove = (e: React.MouseEvent) => {
if (e.buttons === 1) { // Only track if left button held
touchEnd.current = e.clientX;
}
};
const onMouseUp = () => {
if (touchStart.current) {
// Handle click vs swipe
// If minimal movement, treat as click/tap (handled by onClick elsewhere, but for header toggle we need it here?)
// Actually, onMouseUp is better for swipe end.
handleSwipeEnd();
}
touchStart.current = null;
touchEnd.current = null;
};
const handleSwipeEnd = () => {
if (!touchStart.current || !touchEnd.current) return;
const distance = touchStart.current - touchEnd.current;
const isLeftSwipe = distance > minSwipeDistance;
const isRightSwipe = distance < -minSwipeDistance;
const distanceX = touchStart.current - touchEnd.current;
const isLeftSwipe = distanceX > minSwipeDistance;
const isRightSwipe = distanceX < -minSwipeDistance;
if (isLeftSwipe) {
if (activeTab === 'foryou') setActiveTab('following');
else if (activeTab === 'following') setActiveTab('search');
if (activeTab === 'foryou') { setActiveTab('following'); setShowHeader(true); }
else if (activeTab === 'following') { setActiveTab('search'); setShowHeader(true); }
} else if (isRightSwipe) {
if (activeTab === 'search') { setActiveTab('following'); setShowHeader(true); }
else if (activeTab === 'following') { setActiveTab('foryou'); setShowHeader(true); }
} else {
// Minor movement - Do nothing (Tap is handled by video click)
}
if (isRightSwipe) {
if (activeTab === 'search') setActiveTab('following');
else if (activeTab === 'following') setActiveTab('foryou');
if (activeTab === 'foryou') {
setTimeout(() => setShowHeader(false), 3000);
}
};
@ -209,6 +238,21 @@ export const Feed: React.FC = () => {
prefetch();
}, [currentIndex, videos, activeTab]);
// Scrolls to the current index when the videos list changes or when we enter/exit search playback
// This fixes the "blur screen" bug where the previous scroll position persisted after swapping video lists
useEffect(() => {
if (activeTab === 'foryou' && containerRef.current && videos.length > 0) {
const targetScroll = currentIndex * containerRef.current.clientHeight;
// Only scroll if significantly off (allow small manual adjustments)
if (Math.abs(containerRef.current.scrollTop - targetScroll) > 50) {
containerRef.current.scrollTo({
top: targetScroll,
behavior: 'auto' // Instant jump to prevent weird visual sliding on list swap
});
}
}
}, [videos, activeTab, isInSearchPlayback]); // Dependencies needed to trigger on list swap
const loadSuggestedProfiles = async () => {
setLoadingProfiles(true);
try {
@ -257,10 +301,28 @@ export const Feed: React.FC = () => {
const loadFollowing = async () => {
try {
const res = await axios.get(`${API_BASE_URL}/following`);
setFollowing(res.data);
} catch (err) {
console.error('Failed to load following');
const { data } = await axios.get(`${API_BASE_URL}/following`);
setFollowing(data);
// If on following tab, load actual feed instead of just profiles
if (activeTab === 'following') {
setIsFetching(true);
try {
const res = await axios.get(`${API_BASE_URL}/following/feed`);
if (res.data && res.data.length > 0) {
setVideos(res.data);
setCurrentIndex(0);
setIsFollowingFeed(true);
setActiveTab('foryou'); // Switch to feed view but with following content
}
} catch (e) {
console.error('Error loading following feed:', e);
} finally {
setIsFetching(false);
}
}
} catch (error) {
console.error('Error loading following list:', error);
}
};
@ -340,13 +402,23 @@ export const Feed: React.FC = () => {
);
if (videos.length === 0) {
setError('No videos found.');
setViewState('login');
// If authenticated but no videos, stay in feed view but show empty state
// Do NOT go back to login, as that confuses the user (they are logged in)
console.warn('Feed empty, but authenticated.');
setViewState('feed');
setError('No videos found. Pull to refresh or try searching.');
}
} catch (err: any) {
console.error('Feed load failed:', err);
setError(err.response?.data?.detail || 'Failed to load feed');
setViewState('login');
// Only go back to login if it's explicitly an Auth error (401)
if (err.response?.status === 401) {
setError('Session expired. Please login again.');
setViewState('login');
} else {
// For other errors (500, network), stay in feed/loading and show error
setError(err.response?.data?.detail || 'Failed to load feed');
setViewState('feed');
}
}
};
@ -396,225 +468,90 @@ export const Feed: React.FC = () => {
setViewState('login');
};
// Direct username search - bypasses state update delay
// Falls back to keyword search if user not found
// Direct username search
const searchByUsername = async (username: string) => {
setSearchInput(`@${username}`);
setActiveTab('search');
setIsSearching(true);
setSearchResults([]);
try {
const res = await axios.get(`${API_BASE_URL}/user/videos?username=${username}&limit=12`);
const userVideos = res.data.videos as Video[];
if (userVideos.length > 0) {
setSearchResults(userVideos);
} else {
// No videos from user profile, try keyword search
console.log(`No videos from @${username}, trying keyword search...`);
await fallbackToKeywordSearch(username);
}
} catch (err) {
console.error('Error fetching user videos, trying keyword search:', err);
// User not found or error - fallback to keyword search
await fallbackToKeywordSearch(username);
} finally {
setIsSearching(false);
}
handleSearch(false, `@${username}`);
};
// Fallback search when user profile fails
const fallbackToKeywordSearch = async (keyword: string) => {
try {
const res = await axios.get(`${API_BASE_URL}/user/search?query=${encodeURIComponent(keyword)}&limit=12`);
const searchVideos = res.data.videos as Video[];
if (searchVideos.length > 0) {
setSearchResults(searchVideos);
} else {
// Still no results - show friendly message
setSearchResults([{
id: `no-results-${keyword}`,
url: '',
author: 'search',
description: `No videos found for "${keyword}". Try a different search term.`
}]);
}
} catch (searchErr) {
console.error('Keyword search also failed:', searchErr);
setSearchResults([{
id: `search-error`,
url: '',
author: 'search',
description: `Search is temporarily unavailable. Please try again later.`
}]);
}
};
// Direct keyword search - bypasses state update delay
// Direct keyword search
const searchByKeyword = async (keyword: string) => {
setSearchInput(keyword);
setActiveTab('search');
handleSearch(false, keyword);
};
const handleSearch = async (isMore = false, overrideInput?: string) => {
const inputToSearch = overrideInput || searchInput;
if (!inputToSearch.trim() || isSearching) return;
setIsSearching(true);
setSearchResults([]);
setError(null);
// Clear previous results immediately if starting a new search
// This ensures the skeleton loader is shown instead of old results
if (!isMore) {
setSearchResults([]);
}
try {
const res = await axios.get(`${API_BASE_URL}/user/search?query=${encodeURIComponent(keyword)}&limit=12`);
const searchVideos = res.data.videos as Video[];
const cursor = isMore ? searchCursor : 0;
// "Search must show at least 50 result" - fetching 50 at a time with infinite scroll
const limit = 50;
if (searchVideos.length > 0) {
setSearchResults(searchVideos);
} else {
setSearchResults([{
id: `no-results`,
url: '',
author: 'search',
description: `No videos found for "${keyword}"`
}]);
let endpoint = `${API_BASE_URL}/user/search?query=${encodeURIComponent(inputToSearch)}&limit=${limit}&cursor=${cursor}`;
// If direct username search
if (inputToSearch.startsWith('@')) {
endpoint = `${API_BASE_URL}/user/videos?username=${inputToSearch.substring(1)}&limit=${limit}`;
}
const { data } = await axios.get(endpoint);
let newVideos = data.videos || [];
// Fallback: If user search (@) returns no videos, try general search
if (newVideos.length === 0 && !isMore && inputToSearch.startsWith('@')) {
console.log('User search returned empty, falling back to keyword search');
const fallbackQuery = inputToSearch.substring(1); // Remove @
const fallbackEndpoint = `${API_BASE_URL}/user/search?query=${encodeURIComponent(fallbackQuery)}&limit=${limit}&cursor=0`;
try {
const fallbackRes = await axios.get(fallbackEndpoint);
if (fallbackRes.data.videos && fallbackRes.data.videos.length > 0) {
newVideos = fallbackRes.data.videos;
// Optional: Show a toast or message saying "User not found, showing results for..."
setError(`User '${inputToSearch}' not found. Showing related videos.`);
}
} catch (fallbackErr) {
console.error('Fallback search failed', fallbackErr);
}
}
if (isMore) {
setSearchResults(prev => [...prev, ...newVideos]);
} else {
setSearchResults(newVideos);
}
setSearchCursor(data.cursor || 0);
// If we got results, assume there's more (TikTok has endless content)
// unless the count is very small (e.g. < 5) which might indicate end
setSearchHasMore(newVideos.length >= 5);
} catch (err) {
console.error('Error searching:', err);
setSearchResults([{
id: `error-search`,
url: '',
author: 'search',
description: `Search failed`
}]);
console.error('Search failed:', err);
setError('Search failed. Please try again.');
} finally {
setIsSearching(false);
}
};
const handleSearch = async () => {
if (!searchInput.trim()) return;
setIsSearching(true);
let input = searchInput.trim();
const results: Video[] = [];
// ========== PARSE INPUT TYPE ==========
// Type 1: Full TikTok video URL (tiktok.com/@user/video/123)
const videoUrlMatch = input.match(/tiktok\.com\/@([\w.]+)\/video\/(\d+)/);
if (videoUrlMatch) {
const [, author, videoId] = videoUrlMatch;
results.push({
id: videoId,
url: input.startsWith('http') ? input : `https://www.${input}`,
author: author,
description: `Video ${videoId} by @${author}`
});
const handleSearchScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (scrollHeight - scrollTop <= clientHeight + 100 && searchHasMore && !isSearching) {
handleSearch(true);
}
// Type 2: Short share links (vm.tiktok.com, vt.tiktok.com)
else if (input.includes('vm.tiktok.com') || input.includes('vt.tiktok.com')) {
// These are short links - add as-is, backend will resolve
const shortId = input.split('/').pop() || 'unknown';
results.push({
id: `short-${shortId}`,
url: input.startsWith('http') ? input : `https://${input}`,
author: 'unknown',
description: 'Shared TikTok video (click to watch)'
});
}
// Type 3: Username (@user or just user) - Fetch user's videos
else if (input.startsWith('@') || /^[\w.]+$/.test(input)) {
const username = input.replace('@', '');
// Show loading state
results.push({
id: `loading-${username}`,
url: '',
author: username,
description: `⏳ Loading videos from @${username}...`
});
setSearchResults(results);
// Fetch user's videos from backend
try {
const res = await axios.get(`${API_BASE_URL}/user/videos?username=${username}&limit=12`);
const userVideos = res.data.videos as Video[];
if (userVideos.length > 0) {
// Replace loading with actual videos
setSearchResults(userVideos);
setIsSearching(false);
return;
} else {
// No videos found
setSearchResults([{
id: `no-videos-${username}`,
url: '',
author: username,
description: `No videos found for @${username}`
}]);
setIsSearching(false);
return;
}
} catch (err) {
console.error('Error fetching user videos:', err);
// Fallback message
setSearchResults([{
id: `error-${username}`,
url: '',
author: username,
description: `Could not fetch videos`
}]);
setIsSearching(false);
return;
}
}
// Type 4: Hashtag (#trend) or Generic search term - use search API
else {
// Show loading for keyword search
results.push({
id: `loading-search`,
url: '',
author: 'search',
description: `Searching for "${input}"...`
});
setSearchResults(results);
// Fetch videos using keyword search API
try {
const res = await axios.get(`${API_BASE_URL}/user/search?query=${encodeURIComponent(input)}&limit=12`);
const searchVideos = res.data.videos as Video[];
if (searchVideos.length > 0) {
setSearchResults(searchVideos);
setIsSearching(false);
return;
} else {
setSearchResults([{
id: `no-results`,
url: '',
author: 'search',
description: `No videos found for "${input}"`
}]);
setIsSearching(false);
return;
}
} catch (err) {
console.error('Error searching:', err);
setSearchResults([{
id: `error-search`,
url: '',
author: 'search',
description: `Search failed. Try a different term.`
}]);
setIsSearching(false);
return;
}
}
setSearchResults(results);
setIsSearching(false);
// Log for debugging
console.log('Search input:', input);
console.log('Search results:', results);
};
// ========== LOGIN VIEW ==========
@ -760,8 +697,6 @@ export const Feed: React.FC = () => {
);
}
// ========== FEED VIEW WITH TABS ==========
return (
<div
@ -769,58 +704,68 @@ export const Feed: React.FC = () => {
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
>
{/* Tab Navigation */}
<div className="absolute top-0 left-0 right-0 z-50 flex justify-center pt-4 pb-2 bg-gradient-to-b from-black via-black/80 to-transparent">
<div className="flex gap-1 bg-white/10 backdrop-blur-md rounded-full p-1">
{/* Tab Navigation - Hidden by default, show on toggle/swipe */}
<div className={`absolute top-0 left-0 right-0 z-50 flex justify-center pt-4 pb-2 transition-all duration-300 ${showHeader ? 'translate-y-0 opacity-100' : '-translate-y-full opacity-0 pointer-events-none'}`}>
<div className="flex gap-1 bg-black/40 backdrop-blur-md rounded-full p-1 border border-white/5 shadow-2xl">
<button
onClick={() => setActiveTab('foryou')}
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'foryou'
? 'bg-white text-black'
: 'text-white/70 hover:text-white'
onClick={() => {
setActiveTab('foryou');
setIsFollowingFeed(false);
if (videos.length === 0) loadFeed();
}}
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'foryou' && !isFollowingFeed
? 'bg-white/20 text-white shadow-sm'
: 'text-white/60 hover:text-white'
}`}
title="For You"
>
<Home size={16} />
<span className="hidden md:inline">For You</span>
<span className="font-bold">For You</span>
</button>
<button
onClick={() => setActiveTab('following')}
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'following'
? 'bg-white text-black'
: 'text-white/70 hover:text-white'
? 'bg-white/20 text-white shadow-sm'
: 'text-white/60 hover:text-white'
}`}
title="Following"
>
<Users size={16} />
<span className="hidden md:inline">Following</span>
<span className="font-bold">Following</span>
</button>
<button
onClick={() => setActiveTab('search')}
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'search'
? 'bg-white text-black'
: 'text-white/70 hover:text-white'
? 'bg-white/20 text-white shadow-sm'
: 'text-white/60 hover:text-white'
}`}
title="Search"
>
<Search size={16} />
<span className="hidden md:inline">Search</span>
</button>
</div>
</div>
{/* Logout Button - Left Corner Icon */}
<button
onClick={handleLogout}
className="absolute top-4 left-4 z-50 w-10 h-10 flex items-center justify-center bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-full text-white transition-colors"
title="Logout"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
<polyline points="16,17 21,12 16,7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
{/* Logout Button / Back Button Logic */}
{/* "make the go back button on the top right conner, replace, swith from the log out button" */}
{!isInSearchPlayback ? (
<button
onClick={handleLogout}
className={`absolute top-6 right-6 z-50 w-10 h-10 flex items-center justify-center bg-black/20 hover:bg-black/40 backdrop-blur-md rounded-full text-white/70 hover:text-white transition-all duration-300 ${showHeader ? 'translate-y-0 opacity-100' : '-translate-y-20 opacity-0'}`}
title="Logout"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
<polyline points="16,17 21,12 16,7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
) : null}
{/* FOR YOU TAB */}
<div className={`absolute inset-0 w-full h-full transition-all duration-300 ease-out ${activeTab === 'foryou'
@ -1008,13 +953,15 @@ export const Feed: React.FC = () => {
</div>
</div>
{/* SEARCH TAB - Minimal Style matching Following */}
{/* SEARCH TAB */}
<div className={`absolute inset-0 w-full h-full pt-16 px-4 pb-6 overflow-y-auto transition-all duration-300 ease-out ${activeTab === 'search'
? 'translate-x-0 opacity-100'
: activeTab === 'following' || activeTab === 'foryou'
? 'translate-x-full opacity-0 pointer-events-none'
: '-translate-x-full opacity-0 pointer-events-none'
}`}>
}`}
onScroll={handleSearchScroll}
>
<div className="max-w-lg mx-auto">
{/* Minimal Search Input */}
<div className="relative mb-8">
@ -1028,7 +975,7 @@ export const Feed: React.FC = () => {
disabled={isSearching}
/>
<button
onClick={handleSearch}
onClick={() => handleSearch()}
disabled={isSearching}
className="absolute right-0 top-1/2 -translate-y-1/2 p-2 text-white/50 hover:text-white transition-colors disabled:opacity-50"
>
@ -1043,27 +990,24 @@ export const Feed: React.FC = () => {
</svg>
)}
</button>
{/* Subtle hint dropdown */}
<p className="text-white/20 text-xs mt-2">@username · video link · keyword</p>
</div>
{/* Loading Animation with Quote */}
{/* Loading Animation - Skeleton Grid */}
{isSearching && (
<div className="flex flex-col items-center justify-center py-16">
<div className="w-10 h-10 border-2 border-white/10 border-t-cyan-500 rounded-full animate-spin mb-6"></div>
<p className="text-white/60 text-sm italic text-center max-w-xs">
"{INSPIRATION_QUOTES[Math.floor(Math.random() * INSPIRATION_QUOTES.length)].text}"
</p>
<p className="text-white/30 text-xs mt-2">
{INSPIRATION_QUOTES[Math.floor(Math.random() * INSPIRATION_QUOTES.length)].author}
</p>
<div className="mt-8">
<div className="grid grid-cols-3 gap-1 animate-pulse">
{[...Array(12)].map((_, i) => (
<div key={i} className="aspect-[9/16] bg-white/5 rounded-sm"></div>
))}
</div>
</div>
)}
{/* Empty State - Following-style layout */}
{/* Empty State / Suggestions */}
{!isSearching && searchResults.length === 0 && (
<>
{/* Trending - 2 columns */}
{/* Trending */}
<div className="mb-10">
<p className="text-white/40 text-xs uppercase tracking-wider mb-3">Trending</p>
<div className="grid grid-cols-2 gap-2">
@ -1078,62 +1022,26 @@ export const Feed: React.FC = () => {
))}
</div>
</div>
{/* Quick Search - Account avatars */}
<div>
<p className="text-white/40 text-xs uppercase tracking-wider mb-4">Popular</p>
<div className="grid grid-cols-4 gap-4">
{(suggestedProfiles.length > 0 ? suggestedProfiles : SUGGESTED_ACCOUNTS.map(a => ({ username: a.username.replace('@', '') }))).slice(0, 4).map((profile: UserProfile | { username: string }) => {
const username = 'username' in profile ? profile.username : '';
return (
<button
key={username}
onClick={() => searchByUsername(username)}
className="flex flex-col items-center gap-2 group"
>
{'avatar' in profile && profile.avatar ? (
<img
src={profile.avatar}
alt={username}
className="w-12 h-12 rounded-full object-cover border-2 border-transparent group-hover:border-pink-500/50 transition-colors"
/>
) : (
<div className="w-12 h-12 rounded-full bg-white/10 flex items-center justify-center text-white/60 group-hover:bg-white/20 transition-colors">
{username.charAt(0).toUpperCase()}
</div>
)}
<span className="text-white/40 text-xs truncate w-full text-center group-hover:text-white/60">
@{username.slice(0, 6)}
</span>
</button>
);
})}
</div>
</div>
</>
)}
{/* Search Results */}
{!isSearching && searchResults.length > 0 && (
{searchResults.length > 0 && (
<div className="mt-8">
{/* Results Header with Creator & Follow */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<span className="text-white/50 text-sm">{searchResults.length} videos</span>
{searchResults[0]?.author && searchResults[0].author !== 'search' && (
<span className="text-white/50 text-sm">{searchResults.length} videos</span>
<div className="flex items-center gap-2">
{searchInput.startsWith('@') && (
<button
onClick={() => handleFollow(searchResults[0].author)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${following.includes(searchResults[0].author)
? 'bg-pink-500 text-white'
: 'bg-white/10 text-white/70 hover:bg-white/20'
onClick={() => handleFollow(searchInput.substring(1))}
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${following.includes(searchInput.substring(1))
? 'bg-white/10 text-white border border-white/20'
: 'bg-pink-500 text-white'
}`}
>
{following.includes(searchResults[0].author) ? 'Following' : '+ Follow @' + searchResults[0].author}
{following.includes(searchInput.substring(1)) ? 'Following' : 'Follow'}
</button>
)}
</div>
<div className="flex items-center gap-2">
{/* Play All Button */}
<button
onClick={() => {
const playableVideos = searchResults.filter(v => v.url);
@ -1147,70 +1055,40 @@ export const Feed: React.FC = () => {
>
Play All
</button>
<button
onClick={() => setSearchResults([])}
className="text-white/30 text-xs hover:text-white/60"
>
Clear
</button>
</div>
</div>
{/* Video Grid */}
<div className="grid grid-cols-3 gap-1">
{searchResults.map((video) => (
<div
key={video.id}
className={`relative aspect-[9/16] overflow-hidden group ${video.url
? 'cursor-pointer'
: 'opacity-40'
}`}
className="relative aspect-[9/16] overflow-hidden group cursor-pointer"
onClick={() => {
if (!video.url) return;
// Load ALL search results into the feed, starting from clicked video
setOriginalVideos(videos);
setOriginalIndex(currentIndex);
const playableVideos = searchResults.filter(v => v.url);
if (playableVideos.length > 0) {
setVideos(playableVideos);
// Set current index to the clicked video's position in playable videos
const newIndex = playableVideos.findIndex(v => v.id === video.id);
setCurrentIndex(newIndex >= 0 ? newIndex : 0);
setActiveTab('foryou');
}
setVideos(playableVideos);
const newIndex = playableVideos.findIndex(v => v.id === video.id);
setCurrentIndex(newIndex >= 0 ? newIndex : 0);
setIsInSearchPlayback(true);
setActiveTab('foryou');
}}
>
{/* Thumbnail with loading placeholder */}
{video.thumbnail ? (
<img
src={video.thumbnail}
alt={video.author}
className="w-full h-full object-cover transition-opacity group-hover:opacity-80"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-white/5 flex items-center justify-center">
{video.url ? (
<div className="w-6 h-6 border-2 border-white/20 border-t-cyan-500 rounded-full animate-spin"></div>
) : (
<span className="text-2xl"></span>
)}
</div>
)}
{/* Overlay with author */}
{video.url && (
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/80 to-transparent">
<p className="text-white text-xs truncate">@{video.author}</p>
</div>
)}
{/* Message for non-playable */}
{!video.url && video.description && (
<div className="absolute inset-0 flex items-center justify-center p-2">
<p className="text-white/60 text-xs text-center">{video.description}</p>
<div className="w-6 h-6 border-2 border-white/20 border-t-cyan-500 rounded-full animate-spin"></div>
</div>
)}
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/80 to-transparent">
<p className="text-white text-xs truncate">@{video.author}</p>
</div>
</div>
))}
</div>
@ -1219,6 +1097,21 @@ export const Feed: React.FC = () => {
</div>
</div>
{/* In-Search Back Button */}
{isInSearchPlayback && (
<button
onClick={() => {
setVideos(originalVideos);
setCurrentIndex(originalIndex);
setIsInSearchPlayback(false);
setActiveTab('search');
}}
className="absolute top-6 right-6 z-[60] w-10 h-10 flex items-center justify-center bg-black/40 hover:bg-black/60 backdrop-blur-md rounded-full text-white transition-all shadow-xl border border-white/10"
title="Back to Search"
>
<X size={20} />
</button>
)}
</div>
);
};

View file

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

View file

@ -41,7 +41,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
const progressBarRef = useRef<HTMLDivElement>(null);
const [isPaused, setIsPaused] = useState(false);
const [showControls, setShowControls] = useState(false);
const [objectFit, setObjectFit] = useState<'cover' | 'contain'>('contain');
const [objectFit] = useState<'cover' | 'contain'>('contain');
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
const [isSeeking, setIsSeeking] = useState(false);
@ -112,12 +112,15 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isActive]);
const [showSidebar, setShowSidebar] = useState(false);
// Reset fallback and loading state when video changes
useEffect(() => {
setUseFallback(false);
setIsLoading(true); // Show loading for new video
setCodecError(false); // Reset codec error for new video
setCachedUrl(null);
setShowSidebar(false); // Reset sidebar for new video
const checkCache = async () => {
const cached = await videoCache.get(video.url);
@ -209,9 +212,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
}
};
const toggleObjectFit = () => {
setObjectFit(prev => prev === 'contain' ? 'cover' : 'contain');
};
const toggleMute = (e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation(); // Prevent video tap
@ -247,6 +247,17 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
touches.forEach((touch, index) => {
const timeSinceLastTap = now - lastTapRef.current;
// Swipe Left from right edge check (to open sidebar)
const rectWidth = rect.width;
const startX = touch.clientX - rect.left;
// If touch starts near right edge (last 15% of screen)
if (startX > rectWidth * 0.85 && touches.length === 1) {
// We'll handle the actual swipe logic in touchMove/End,
// but setting a flag or using the existing click logic might be easier.
// For now, let's allow a simple tap on the edge to toggle too, as per existing click logic.
}
// Show heart if:
// 1. Double tap (< 400ms)
// 2. OR Multi-touch (2+ fingers)
@ -295,6 +306,13 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// If clicked on the right edge, toggle sidebar
if (x > rect.width * 0.9) {
setShowSidebar(prev => !prev);
return;
}
const heartId = Date.now() + Math.random();
setHearts(prev => [...prev, { id: heartId, x, y }]);
setTimeout(() => {
@ -460,15 +478,15 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
)}
</div>
{/* Side Controls */}
{/* Side Controls - Hidden by default, reveal on swipe/interaction */}
<div
className={`absolute bottom-36 right-4 flex flex-col gap-3 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'
className={`absolute bottom-36 right-4 flex flex-col gap-3 transition-all duration-300 transform ${showSidebar ? 'translate-x-0 opacity-100' : 'translate-x-[200%] opacity-0'
}`}
>
{/* Follow Button */}
{onFollow && (
<button
onClick={() => onFollow(video.author)}
onClick={(e) => { e.stopPropagation(); onFollow(video.author); }}
className={`w-12 h-12 flex items-center justify-center backdrop-blur-xl border border-white/10 rounded-full transition-all ${isFollowing
? 'bg-pink-500 text-white'
: 'bg-white/10 hover:bg-white/20 text-white'
@ -489,15 +507,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
<Download size={20} />
</a>
{/* Object Fit Toggle */}
<button
onClick={toggleObjectFit}
className="w-12 h-12 flex items-center justify-center bg-white/10 hover:bg-white/20 backdrop-blur-xl border border-white/10 rounded-full text-white text-xs font-bold transition-all"
title={objectFit === 'contain' ? 'Fill Screen' : 'Fit Content'}
>
{objectFit === 'contain' ? '⛶' : '⊡'}
</button>
{/* Mute Toggle */}
<button
onClick={toggleMute}
@ -544,6 +553,19 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
{/* Bottom Gradient */}
<div className="absolute bottom-0 left-0 w-full h-32 bg-gradient-to-t from-black/80 to-transparent pointer-events-none" />
{/* Right Sidebar Hint - Small vertical bar/glow on edge */}
<div
className={`absolute top-0 right-0 w-6 h-full z-40 flex items-center justify-end cursor-pointer ${showSidebar ? 'pointer-events-none' : ''}`}
onClick={(e) => { e.stopPropagation(); setShowSidebar(true); }}
onTouchEnd={() => {
// Check if it was a swipe logic here or just rely on the click/tap
setShowSidebar(true);
}}
>
{/* Visual Hint */}
<div className="w-1 h-12 bg-white/20 rounded-full mr-1 shadow-[0_0_10px_rgba(255,255,255,0.3)] animate-pulse" />
</div>
</div>
);
};

View file

@ -85,6 +85,12 @@ class VideoPrefetcher {
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(
@ -92,7 +98,7 @@ class VideoPrefetcher {
this.config.timeoutMs
);
const response = await fetch(video.url, {
const response = await fetch(targetUrl, {
signal: controller.signal,
headers: { Range: 'bytes=0-1048576' }
});

30
simple_test.py Normal file
View file

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

314
temp_cookies.json Normal file
View file

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

16
test_login.py Normal file
View file

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

30
test_request.py Normal file
View file

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

35
test_search.py Normal file
View file

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