Compare commits

..

No commits in common. "601ae284b5934f37bd65b9ffc51f6113637b9afd" and "489a5069b548fabdba4157bd46f8fd129d7d3d35" have entirely different histories.

18 changed files with 535 additions and 1398 deletions

View file

@ -1,39 +1,77 @@
# Build Stage for Frontend # Build stage for frontend
FROM node:18-alpine as frontend-build FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm ci RUN npm ci
COPY frontend/ ./ COPY frontend/ ./
RUN npm run build RUN npm run build
# Runtime Stage for Backend # Production stage
FROM python:3.11-slim FROM python:3.11-slim
# Install system dependencies required for Playwright and compiled extensions # Install system dependencies (minimal - no VNC needed)
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
curl \ curl \
git \ gnupg \
build-essential \ 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/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Install Python dependencies # Copy backend requirements and install
COPY backend/requirements.txt backend/ COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r backend/requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright browsers (Chromium only to save space) # Install Playwright browsers (headless mode only)
RUN playwright install chromium RUN mkdir -p /root/.cache/ms-playwright && \
RUN playwright install-deps chromium 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 code
COPY backend/ backend/ COPY backend/ ./backend/
# Copy Built Frontend Assets # Copy built frontend
COPY --from=frontend-build /app/frontend/dist /app/frontend/dist COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
# Expose Port # 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 EXPOSE 8002
# Run Application # Health check
CMD ["python", "backend/main.py"] HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8002/health || exit 1
# Start FastAPI directly (no supervisor needed)
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002"]

View file

@ -18,10 +18,8 @@ class BrowserLoginResponse(BaseModel):
cookie_count: int = 0 cookie_count: int = 0
from typing import Any
class CredentialsRequest(BaseModel): class CredentialsRequest(BaseModel):
credentials: Any # Accept both dict and list credentials: dict # JSON credentials in http.headers format
class CredentialLoginRequest(BaseModel): class CredentialLoginRequest(BaseModel):
@ -81,8 +79,9 @@ async def save_credentials(request: CredentialsRequest):
if not cookies: if not cookies:
raise HTTPException(status_code=400, detail="No cookies found in credentials") raise HTTPException(status_code=400, detail="No cookies found in credentials")
# Save full cookie list with domains/paths preserved # Convert to dict format for storage
PlaywrightManager.save_credentials(cookies, user_agent) cookie_dict = {c["name"]: c["value"] for c in cookies}
PlaywrightManager.save_credentials(cookie_dict, user_agent)
return { return {
"status": "success", "status": "success",
@ -100,19 +99,10 @@ async def auth_status():
try: try:
with open(COOKIES_FILE, "r") as f: with open(COOKIES_FILE, "r") as f:
cookies = json.load(f) cookies = json.load(f)
# Handle both dict and list formats has_session = "sessionid" in cookies
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 { return {
"authenticated": has_session, "authenticated": has_session,
"cookie_count": cookie_count "cookie_count": len(cookies)
} }
except: except:
pass pass
@ -157,8 +147,7 @@ async def stop_vnc_login():
# ========== ADMIN ENDPOINTS ========== # ========== ADMIN ENDPOINTS ==========
# Admin password from environment variable # Admin password from environment variable
# Force hardcode to 'admin123' to ensure user can login, ignoring potentially bad env var ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin123")
ADMIN_PASSWORD = "admin123"
# Simple in-memory admin sessions (resets on restart, that's fine for this use case) # Simple in-memory admin sessions (resets on restart, that's fine for this use case)
_admin_sessions: set = set() _admin_sessions: set = set()
@ -175,7 +164,6 @@ class AdminCookiesRequest(BaseModel):
@router.post("/admin-login") @router.post("/admin-login")
async def admin_login(request: AdminLoginRequest): async def admin_login(request: AdminLoginRequest):
"""Login as admin with password.""" """Login as admin with password."""
print(f"DEBUG: Admin login attempt. Input: '{request.password}', Expected: '{ADMIN_PASSWORD}'")
if request.password == ADMIN_PASSWORD: if request.password == ADMIN_PASSWORD:
import secrets import secrets
session_token = secrets.token_urlsafe(32) session_token = secrets.token_urlsafe(32)
@ -199,30 +187,22 @@ async def admin_update_cookies(request: AdminCookiesRequest, token: str = ""):
try: try:
cookies = request.cookies cookies = request.cookies
# Preserve list if it contains metadata (like domain) # Normalize cookies to dict format
if isinstance(cookies, list): if isinstance(cookies, list):
# Check if this is a simple name-value list or full objects # Cookie-Editor export format: [{"name": "...", "value": "..."}, ...]
if len(cookies) > 0 and isinstance(cookies[0], dict) and "domain" not in cookies[0]: cookie_dict = {}
cookie_dict = {} for c in cookies:
for c in cookies: if isinstance(c, dict) and "name" in c and "value" in c:
if isinstance(c, dict) and "name" in c and "value" in c: cookie_dict[c["name"]] = c["value"]
cookie_dict[c["name"]] = c["value"] cookies = cookie_dict
cookies = cookie_dict
if not isinstance(cookies, (dict, list)): if not isinstance(cookies, dict):
raise HTTPException(status_code=400, detail="Invalid cookies format") raise HTTPException(status_code=400, detail="Invalid cookies format")
# Check for sessionid in both formats if "sessionid" not in cookies:
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") raise HTTPException(status_code=400, detail="Missing 'sessionid' cookie - this is required")
# Save cookies (either dict or list) # Save cookies
PlaywrightManager.save_credentials(cookies, None) PlaywrightManager.save_credentials(cookies, None)
return { return {

View file

@ -132,11 +132,9 @@ init_cache()
# ========== API ROUTES ========== # ========== API ROUTES ==========
from typing import Optional, Any, Union, List, Dict
class FeedRequest(BaseModel): class FeedRequest(BaseModel):
"""Request body for feed endpoint with optional JSON credentials.""" """Request body for feed endpoint with optional JSON credentials."""
credentials: Optional[Union[Dict, List]] = None credentials: Optional[dict] = None
@router.post("") @router.post("")

View file

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

View file

@ -108,7 +108,7 @@ async def get_multiple_profiles(usernames: str = Query(..., description="Comma-s
@router.get("/videos") @router.get("/videos")
async def get_user_videos( async def get_user_videos(
username: str = Query(..., description="TikTok username (without @)"), username: str = Query(..., description="TikTok username (without @)"),
limit: int = Query(10, description="Max videos to fetch", ge=1, le=60) limit: int = Query(10, description="Max videos to fetch", ge=1, le=30)
): ):
""" """
Fetch videos from a TikTok user's profile. Fetch videos from a TikTok user's profile.
@ -135,8 +135,7 @@ async def get_user_videos(
@router.get("/search") @router.get("/search")
async def search_videos( async def search_videos(
query: str = Query(..., description="Search keyword or hashtag"), query: str = Query(..., description="Search keyword or hashtag"),
limit: int = Query(20, description="Max videos to fetch", ge=1, le=60), limit: int = Query(12, description="Max videos to fetch", ge=1, le=30)
cursor: int = Query(0, description="Pagination cursor (offset)")
): ):
""" """
Search for videos by keyword or hashtag. Search for videos by keyword or hashtag.
@ -148,11 +147,11 @@ async def search_videos(
if not cookies: if not cookies:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
print(f"Searching for: {query} (limit={limit}, cursor={cursor})...") print(f"Searching for: {query}...")
try: try:
videos = await PlaywrightManager.search_videos(query, cookies, user_agent, limit, cursor) videos = await PlaywrightManager.search_videos(query, cookies, user_agent, limit)
return {"query": query, "videos": videos, "count": len(videos), "cursor": cursor + len(videos)} return {"query": query, "videos": videos, "count": len(videos)}
except Exception as e: except Exception as e:
print(f"Error searching for {query}: {e}") print(f"Error searching for {query}: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View file

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

View file

@ -5,40 +5,16 @@ from fastapi.responses import FileResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from api.routes import auth, feed, download, following, config, user from api.routes import auth, feed, download, following, config, user
import sys
import asyncio
# Force Proactor on Windows for Playwright
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Startup and shutdown events.""" """Startup and shutdown events."""
print("🚀 Starting PureStream API (Network Interception Mode)...") print("🚀 Starting PureStream API (Network Interception Mode)...")
import asyncio
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 yield
print("👋 Shutting down PureStream API...") print("👋 Shutting down PureStream API...")
import asyncio
import sys
app = FastAPI(title="PureStream API", version="2.0.0", lifespan=lifespan) app = FastAPI(title="PureStream API", version="2.0.0", lifespan=lifespan)
if __name__ == "__main__":
if 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 # CORS middleware
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@ -78,5 +54,5 @@ if FRONTEND_DIR.exists():
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=False, loop="asyncio") uvicorn.run("main:app", host="0.0.0.0", port=8002, reload=True)

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import { VideoPlayer } from './VideoPlayer';
import type { Video, UserProfile } from '../types'; import type { Video, UserProfile } from '../types';
import axios from 'axios'; import axios from 'axios';
import { API_BASE_URL } from '../config'; import { API_BASE_URL } from '../config';
import { Search, X, Plus } from 'lucide-react'; import { Home, Users, Search, X, Plus } from 'lucide-react';
import { videoPrefetcher } from '../utils/videoPrefetch'; import { videoPrefetcher } from '../utils/videoPrefetch';
import { feedLoader } from '../utils/feedLoader'; import { feedLoader } from '../utils/feedLoader';
@ -84,6 +84,19 @@ const SUGGESTED_ACCOUNTS = [
{ username: '@taylerholder', label: '🔥 Tayler Holder' }, { 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 // NOTE: Keyword search is now handled by the backend /api/user/search endpoint
export const Feed: React.FC = () => { export const Feed: React.FC = () => {
@ -91,11 +104,8 @@ export const Feed: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('foryou'); const [activeTab, setActiveTab] = useState<TabType>('foryou');
const [videos, setVideos] = useState<Video[]>([]); const [videos, setVideos] = useState<Video[]>([]);
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [searchCursor, setSearchCursor] = useState(0); const [error, setError] = useState<string | null>(null);
const [searchHasMore, setSearchHasMore] = useState(true); const [showAdvanced, setShowAdvanced] = useState(false);
const [isInSearchPlayback, setIsInSearchPlayback] = useState(false);
const [originalVideos, setOriginalVideos] = useState<Video[]>([]);
const [originalIndex, setOriginalIndex] = useState(0);
const [jsonInput, setJsonInput] = useState(''); const [jsonInput, setJsonInput] = useState('');
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -106,17 +116,12 @@ export const Feed: React.FC = () => {
// Suggested profiles with real data // Suggested profiles with real data
const [suggestedProfiles, setSuggestedProfiles] = useState<UserProfile[]>([]); const [suggestedProfiles, setSuggestedProfiles] = useState<UserProfile[]>([]);
const [loadingProfiles, setLoadingProfiles] = useState(false); const [loadingProfiles, setLoadingProfiles] = useState(false);
const [suggestedLimit, setSuggestedLimit] = useState(12); const [suggestedLimit, setSuggestedLimit] = useState(12); // Lazy load - start with 12
const [showHeader, setShowHeader] = useState(false);
const [isFollowingFeed, setIsFollowingFeed] = useState(false);
// Lazy load - start with 12
// Search state // Search state
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
const [searchResults, setSearchResults] = useState<Video[]>([]); const [searchResults, setSearchResults] = useState<Video[]>([]);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
// Global mute state - persists across video scrolling // Global mute state - persists across video scrolling
const [isMuted, setIsMuted] = useState(true); const [isMuted, setIsMuted] = useState(true);
@ -126,7 +131,6 @@ export const Feed: React.FC = () => {
const touchEnd = useRef<number | null>(null); const touchEnd = useRef<number | null>(null);
const minSwipeDistance = 50; const minSwipeDistance = 50;
// Touch Handling (Mobile)
const onTouchStart = (e: React.TouchEvent) => { const onTouchStart = (e: React.TouchEvent) => {
touchEnd.current = null; touchEnd.current = null;
touchStart.current = e.targetTouches[0].clientX; touchStart.current = e.targetTouches[0].clientX;
@ -137,51 +141,18 @@ export const Feed: React.FC = () => {
}; };
const onTouchEnd = () => { 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; if (!touchStart.current || !touchEnd.current) return;
const distance = touchStart.current - touchEnd.current;
const distanceX = touchStart.current - touchEnd.current; const isLeftSwipe = distance > minSwipeDistance;
const isLeftSwipe = distanceX > minSwipeDistance; const isRightSwipe = distance < -minSwipeDistance;
const isRightSwipe = distanceX < -minSwipeDistance;
if (isLeftSwipe) { if (isLeftSwipe) {
if (activeTab === 'foryou') { setActiveTab('following'); setShowHeader(true); } if (activeTab === 'foryou') setActiveTab('following');
else if (activeTab === 'following') { setActiveTab('search'); setShowHeader(true); } else if (activeTab === 'following') setActiveTab('search');
} 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 === 'foryou') { if (activeTab === 'search') setActiveTab('following');
setTimeout(() => setShowHeader(false), 3000); else if (activeTab === 'following') setActiveTab('foryou');
} }
}; };
@ -238,21 +209,6 @@ export const Feed: React.FC = () => {
prefetch(); prefetch();
}, [currentIndex, videos, activeTab]); }, [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 () => { const loadSuggestedProfiles = async () => {
setLoadingProfiles(true); setLoadingProfiles(true);
try { try {
@ -301,28 +257,10 @@ export const Feed: React.FC = () => {
const loadFollowing = async () => { const loadFollowing = async () => {
try { try {
const { data } = await axios.get(`${API_BASE_URL}/following`); const res = await axios.get(`${API_BASE_URL}/following`);
setFollowing(data); setFollowing(res.data);
} catch (err) {
// If on following tab, load actual feed instead of just profiles console.error('Failed to load following');
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);
} }
}; };
@ -402,23 +340,13 @@ export const Feed: React.FC = () => {
); );
if (videos.length === 0) { if (videos.length === 0) {
// If authenticated but no videos, stay in feed view but show empty state setError('No videos found.');
// Do NOT go back to login, as that confuses the user (they are logged in) setViewState('login');
console.warn('Feed empty, but authenticated.');
setViewState('feed');
setError('No videos found. Pull to refresh or try searching.');
} }
} catch (err: any) { } catch (err: any) {
console.error('Feed load failed:', err); console.error('Feed load failed:', err);
// Only go back to login if it's explicitly an Auth error (401) setError(err.response?.data?.detail || 'Failed to load feed');
if (err.response?.status === 401) { setViewState('login');
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');
}
} }
}; };
@ -468,92 +396,227 @@ export const Feed: React.FC = () => {
setViewState('login'); setViewState('login');
}; };
// Direct username search // Direct username search - bypasses state update delay
// Falls back to keyword search if user not found
const searchByUsername = async (username: string) => { const searchByUsername = async (username: string) => {
setSearchInput(`@${username}`); setSearchInput(`@${username}`);
setActiveTab('search'); setActiveTab('search');
handleSearch(false, `@${username}`);
};
// 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); setIsSearching(true);
setError(null); setSearchResults([]);
// Clear previous results immediately if starting a new search
// This ensures the skeleton loader is shown instead of old results
if (!isMore) {
setSearchResults([]);
}
try { try {
const cursor = isMore ? searchCursor : 0; const res = await axios.get(`${API_BASE_URL}/user/videos?username=${username}&limit=12`);
// "Search must show at least 50 result" - fetching 50 at a time with infinite scroll const userVideos = res.data.videos as Video[];
const limit = 50;
let endpoint = `${API_BASE_URL}/user/search?query=${encodeURIComponent(inputToSearch)}&limit=${limit}&cursor=${cursor}`; if (userVideos.length > 0) {
setSearchResults(userVideos);
// 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 { } else {
setSearchResults(newVideos); // No videos from user profile, try keyword search
console.log(`No videos from @${username}, trying keyword search...`);
await fallbackToKeywordSearch(username);
} }
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) { } catch (err) {
console.error('Search failed:', err); console.error('Error fetching user videos, trying keyword search:', err);
setError('Search failed. Please try again.'); // User not found or error - fallback to keyword search
await fallbackToKeywordSearch(username);
} finally { } finally {
setIsSearching(false); setIsSearching(false);
} }
}; };
const handleSearchScroll = (e: React.UIEvent<HTMLDivElement>) => { // Fallback search when user profile fails
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; const fallbackToKeywordSearch = async (keyword: string) => {
if (scrollHeight - scrollTop <= clientHeight + 100 && searchHasMore && !isSearching) { try {
handleSearch(true); 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
const searchByKeyword = async (keyword: string) => {
setSearchInput(keyword);
setActiveTab('search');
setIsSearching(true);
setSearchResults([]);
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 {
setSearchResults([{
id: `no-results`,
url: '',
author: 'search',
description: `No videos found for "${keyword}"`
}]);
}
} catch (err) {
console.error('Error searching:', err);
setSearchResults([{
id: `error-search`,
url: '',
author: 'search',
description: `Search failed`
}]);
} 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}`
});
}
// 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 ========== // ========== LOGIN VIEW ==========
if (viewState === 'login') { if (viewState === 'login') {
return ( return (
@ -697,6 +760,8 @@ export const Feed: React.FC = () => {
); );
} }
// ========== FEED VIEW WITH TABS ========== // ========== FEED VIEW WITH TABS ==========
return ( return (
<div <div
@ -704,68 +769,58 @@ export const Feed: React.FC = () => {
onTouchStart={onTouchStart} onTouchStart={onTouchStart}
onTouchMove={onTouchMove} onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd} onTouchEnd={onTouchEnd}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
> >
{/* Tab Navigation */} {/* Tab Navigation */}
{/* 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 bg-gradient-to-b from-black via-black/80 to-transparent">
<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-white/10 backdrop-blur-md rounded-full p-1">
<div className="flex gap-1 bg-black/40 backdrop-blur-md rounded-full p-1 border border-white/5 shadow-2xl">
<button <button
onClick={() => { onClick={() => setActiveTab('foryou')}
setActiveTab('foryou'); className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'foryou'
setIsFollowingFeed(false); ? 'bg-white text-black'
if (videos.length === 0) loadFeed(); : 'text-white/70 hover:text-white'
}}
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" title="For You"
> >
<span className="font-bold">For You</span> <Home size={16} />
<span className="hidden md:inline">For You</span>
</button> </button>
<button <button
onClick={() => setActiveTab('following')} onClick={() => setActiveTab('following')}
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'following' className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'following'
? 'bg-white/20 text-white shadow-sm' ? 'bg-white text-black'
: 'text-white/60 hover:text-white' : 'text-white/70 hover:text-white'
}`} }`}
title="Following" title="Following"
> >
<span className="font-bold">Following</span> <Users size={16} />
<span className="hidden md:inline">Following</span>
</button> </button>
<button <button
onClick={() => setActiveTab('search')} onClick={() => setActiveTab('search')}
className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'search' className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-all ${activeTab === 'search'
? 'bg-white/20 text-white shadow-sm' ? 'bg-white text-black'
: 'text-white/60 hover:text-white' : 'text-white/70 hover:text-white'
}`} }`}
title="Search" title="Search"
> >
<Search size={16} /> <Search size={16} />
<span className="hidden md:inline">Search</span>
</button> </button>
</div> </div>
</div> </div>
{/* Logout Button - Left Corner Icon */} {/* Logout Button - Left Corner Icon */}
{/* Logout Button / Back Button Logic */} <button
{/* "make the go back button on the top right conner, replace, swith from the log out 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"
{!isInSearchPlayback ? ( title="Logout"
<button >
onClick={handleLogout} <svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
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'}`} <path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
title="Logout" <polyline points="16,17 21,12 16,7" />
> <line x1="21" y1="12" x2="9" y2="12" />
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> </svg>
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" /> </button>
<polyline points="16,17 21,12 16,7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
) : null}
{/* FOR YOU TAB */} {/* FOR YOU TAB */}
<div className={`absolute inset-0 w-full h-full transition-all duration-300 ease-out ${activeTab === 'foryou' <div className={`absolute inset-0 w-full h-full transition-all duration-300 ease-out ${activeTab === 'foryou'
@ -953,15 +1008,13 @@ export const Feed: React.FC = () => {
</div> </div>
</div> </div>
{/* SEARCH TAB */} {/* SEARCH TAB - Minimal Style matching Following */}
<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' <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' ? 'translate-x-0 opacity-100'
: activeTab === 'following' || activeTab === 'foryou' : activeTab === 'following' || activeTab === 'foryou'
? 'translate-x-full opacity-0 pointer-events-none' ? 'translate-x-full opacity-0 pointer-events-none'
: '-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"> <div className="max-w-lg mx-auto">
{/* Minimal Search Input */} {/* Minimal Search Input */}
<div className="relative mb-8"> <div className="relative mb-8">
@ -975,7 +1028,7 @@ export const Feed: React.FC = () => {
disabled={isSearching} disabled={isSearching}
/> />
<button <button
onClick={() => handleSearch()} onClick={handleSearch}
disabled={isSearching} 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" className="absolute right-0 top-1/2 -translate-y-1/2 p-2 text-white/50 hover:text-white transition-colors disabled:opacity-50"
> >
@ -990,24 +1043,27 @@ export const Feed: React.FC = () => {
</svg> </svg>
)} )}
</button> </button>
{/* Subtle hint dropdown */}
<p className="text-white/20 text-xs mt-2">@username · video link · keyword</p> <p className="text-white/20 text-xs mt-2">@username · video link · keyword</p>
</div> </div>
{/* Loading Animation - Skeleton Grid */} {/* Loading Animation with Quote */}
{isSearching && ( {isSearching && (
<div className="mt-8"> <div className="flex flex-col items-center justify-center py-16">
<div className="grid grid-cols-3 gap-1 animate-pulse"> <div className="w-10 h-10 border-2 border-white/10 border-t-cyan-500 rounded-full animate-spin mb-6"></div>
{[...Array(12)].map((_, i) => ( <p className="text-white/60 text-sm italic text-center max-w-xs">
<div key={i} className="aspect-[9/16] bg-white/5 rounded-sm"></div> "{INSPIRATION_QUOTES[Math.floor(Math.random() * INSPIRATION_QUOTES.length)].text}"
))} </p>
</div> <p className="text-white/30 text-xs mt-2">
{INSPIRATION_QUOTES[Math.floor(Math.random() * INSPIRATION_QUOTES.length)].author}
</p>
</div> </div>
)} )}
{/* Empty State / Suggestions */} {/* Empty State - Following-style layout */}
{!isSearching && searchResults.length === 0 && ( {!isSearching && searchResults.length === 0 && (
<> <>
{/* Trending */} {/* Trending - 2 columns */}
<div className="mb-10"> <div className="mb-10">
<p className="text-white/40 text-xs uppercase tracking-wider mb-3">Trending</p> <p className="text-white/40 text-xs uppercase tracking-wider mb-3">Trending</p>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@ -1022,26 +1078,62 @@ export const Feed: React.FC = () => {
))} ))}
</div> </div>
</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 */} {/* Search Results */}
{searchResults.length > 0 && ( {!isSearching && searchResults.length > 0 && (
<div className="mt-8"> <div className="mt-8">
{/* Results Header with Creator & Follow */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<span className="text-white/50 text-sm">{searchResults.length} videos</span> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <span className="text-white/50 text-sm">{searchResults.length} videos</span>
{searchInput.startsWith('@') && ( {searchResults[0]?.author && searchResults[0].author !== 'search' && (
<button <button
onClick={() => handleFollow(searchInput.substring(1))} onClick={() => handleFollow(searchResults[0].author)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${following.includes(searchInput.substring(1)) className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${following.includes(searchResults[0].author)
? 'bg-white/10 text-white border border-white/20' ? 'bg-pink-500 text-white'
: 'bg-pink-500 text-white' : 'bg-white/10 text-white/70 hover:bg-white/20'
}`} }`}
> >
{following.includes(searchInput.substring(1)) ? 'Following' : 'Follow'} {following.includes(searchResults[0].author) ? 'Following' : '+ Follow @' + searchResults[0].author}
</button> </button>
)} )}
</div>
<div className="flex items-center gap-2">
{/* Play All Button */}
<button <button
onClick={() => { onClick={() => {
const playableVideos = searchResults.filter(v => v.url); const playableVideos = searchResults.filter(v => v.url);
@ -1055,40 +1147,70 @@ export const Feed: React.FC = () => {
> >
Play All Play All
</button> </button>
<button
onClick={() => setSearchResults([])}
className="text-white/30 text-xs hover:text-white/60"
>
Clear
</button>
</div> </div>
</div> </div>
{/* Video Grid */}
<div className="grid grid-cols-3 gap-1"> <div className="grid grid-cols-3 gap-1">
{searchResults.map((video) => ( {searchResults.map((video) => (
<div <div
key={video.id} key={video.id}
className="relative aspect-[9/16] overflow-hidden group cursor-pointer" className={`relative aspect-[9/16] overflow-hidden group ${video.url
? 'cursor-pointer'
: 'opacity-40'
}`}
onClick={() => { onClick={() => {
if (!video.url) return; if (!video.url) return;
setOriginalVideos(videos); // Load ALL search results into the feed, starting from clicked video
setOriginalIndex(currentIndex);
const playableVideos = searchResults.filter(v => v.url); const playableVideos = searchResults.filter(v => v.url);
setVideos(playableVideos); if (playableVideos.length > 0) {
const newIndex = playableVideos.findIndex(v => v.id === video.id); setVideos(playableVideos);
setCurrentIndex(newIndex >= 0 ? newIndex : 0); // Set current index to the clicked video's position in playable videos
setIsInSearchPlayback(true); const newIndex = playableVideos.findIndex(v => v.id === video.id);
setActiveTab('foryou'); setCurrentIndex(newIndex >= 0 ? newIndex : 0);
setActiveTab('foryou');
}
}} }}
> >
{/* Thumbnail with loading placeholder */}
{video.thumbnail ? ( {video.thumbnail ? (
<img <img
src={video.thumbnail} src={video.thumbnail}
alt={video.author} alt={video.author}
className="w-full h-full object-cover" className="w-full h-full object-cover transition-opacity group-hover:opacity-80"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/> />
) : ( ) : (
<div className="w-full h-full bg-white/5 flex items-center justify-center"> <div className="w-full h-full bg-white/5 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-white/20 border-t-cyan-500 rounded-full animate-spin"></div> {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> </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>
))} ))}
</div> </div>
@ -1097,21 +1219,6 @@ export const Feed: React.FC = () => {
</div> </div>
</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> </div>
); );
}; };

View file

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

View file

@ -41,7 +41,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
const progressBarRef = useRef<HTMLDivElement>(null); const progressBarRef = useRef<HTMLDivElement>(null);
const [isPaused, setIsPaused] = useState(false); const [isPaused, setIsPaused] = useState(false);
const [showControls, setShowControls] = useState(false); const [showControls, setShowControls] = useState(false);
const [objectFit] = useState<'cover' | 'contain'>('contain'); const [objectFit, setObjectFit] = useState<'cover' | 'contain'>('contain');
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [isSeeking, setIsSeeking] = useState(false); const [isSeeking, setIsSeeking] = useState(false);
@ -112,15 +112,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [isActive]); }, [isActive]);
const [showSidebar, setShowSidebar] = useState(false);
// Reset fallback and loading state when video changes // Reset fallback and loading state when video changes
useEffect(() => { useEffect(() => {
setUseFallback(false); setUseFallback(false);
setIsLoading(true); // Show loading for new video setIsLoading(true); // Show loading for new video
setCodecError(false); // Reset codec error for new video setCodecError(false); // Reset codec error for new video
setCachedUrl(null); setCachedUrl(null);
setShowSidebar(false); // Reset sidebar for new video
const checkCache = async () => { const checkCache = async () => {
const cached = await videoCache.get(video.url); const cached = await videoCache.get(video.url);
@ -212,6 +209,9 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
} }
}; };
const toggleObjectFit = () => {
setObjectFit(prev => prev === 'contain' ? 'cover' : 'contain');
};
const toggleMute = (e: React.MouseEvent | React.TouchEvent) => { const toggleMute = (e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation(); // Prevent video tap e.stopPropagation(); // Prevent video tap
@ -247,17 +247,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
touches.forEach((touch, index) => { touches.forEach((touch, index) => {
const timeSinceLastTap = now - lastTapRef.current; 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: // Show heart if:
// 1. Double tap (< 400ms) // 1. Double tap (< 400ms)
// 2. OR Multi-touch (2+ fingers) // 2. OR Multi-touch (2+ fingers)
@ -306,13 +295,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left; const x = e.clientX - rect.left;
const y = e.clientY - rect.top; 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(); const heartId = Date.now() + Math.random();
setHearts(prev => [...prev, { id: heartId, x, y }]); setHearts(prev => [...prev, { id: heartId, x, y }]);
setTimeout(() => { setTimeout(() => {
@ -478,15 +460,15 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
)} )}
</div> </div>
{/* Side Controls - Hidden by default, reveal on swipe/interaction */} {/* Side Controls */}
<div <div
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' className={`absolute bottom-36 right-4 flex flex-col gap-3 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'
}`} }`}
> >
{/* Follow Button */} {/* Follow Button */}
{onFollow && ( {onFollow && (
<button <button
onClick={(e) => { e.stopPropagation(); onFollow(video.author); }} onClick={() => 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 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-pink-500 text-white'
: 'bg-white/10 hover:bg-white/20 text-white' : 'bg-white/10 hover:bg-white/20 text-white'
@ -507,6 +489,15 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
<Download size={20} /> <Download size={20} />
</a> </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 */} {/* Mute Toggle */}
<button <button
onClick={toggleMute} onClick={toggleMute}
@ -553,19 +544,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
{/* Bottom Gradient */} {/* 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" /> <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> </div>
); );
}; };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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