feat: Enhance UI (Immersive Mode), Fix Search (50+ results, Speed), Add Follow Button
This commit is contained in:
parent
489a5069b5
commit
a64078fd66
13 changed files with 888 additions and 448 deletions
|
|
@ -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",
|
||||
|
|
@ -187,22 +188,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 {
|
||||
|
|
|
|||
|
|
@ -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("")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,18 @@ 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):
|
||||
cookies = data
|
||||
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 +155,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 +457,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 +512,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)
|
||||
|
|
@ -694,10 +728,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 +750,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 +769,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 +800,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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
@ -104,8 +104,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 +119,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 +139,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 +150,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 +251,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 +314,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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -396,225 +471,66 @@ 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);
|
||||
|
||||
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);
|
||||
const newVideos = data.videos || [];
|
||||
|
||||
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 +676,6 @@ export const Feed: React.FC = () => {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ========== FEED VIEW WITH TABS ==========
|
||||
return (
|
||||
<div
|
||||
|
|
@ -769,58 +683,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 +932,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 +954,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 +969,23 @@ 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 */}
|
||||
{isSearching && (
|
||||
{isSearching && searchResults.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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 +1000,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 +1033,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 +1075,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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
30
simple_test.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import urllib.request
|
||||
import json
|
||||
|
||||
try:
|
||||
print("Testing /health...")
|
||||
with urllib.request.urlopen("http://localhost:8002/health", timeout=5) as r:
|
||||
print(f"Health: {r.status}")
|
||||
|
||||
print("Testing /api/feed...")
|
||||
with open("temp_cookies.json", "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Ensure list format
|
||||
if isinstance(data, dict) and "credentials" in data:
|
||||
data = data["credentials"]
|
||||
|
||||
# Prepare body as dict for safety with new Union type
|
||||
body = {"credentials": data}
|
||||
|
||||
req = urllib.request.Request(
|
||||
"http://localhost:8002/api/feed",
|
||||
data=json.dumps(body).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
print(f"Feed: {r.status}")
|
||||
print(r.read().decode('utf-8')[:100])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
314
temp_cookies.json
Normal file
314
temp_cookies.json
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
{
|
||||
"credentials": [
|
||||
{
|
||||
"domain": ".www.tiktok.com",
|
||||
"expirationDate": 1784039026,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "delay_guest_mode_vid",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "8"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1768131143.251207,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "msToken",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "jntmMFSrdBzHw3GQQ7xigi2HLM03wLgd2s8xW8sa8bm3gVg-VJu63FlYSfvPAW6tmoNM-Ww9ho9sOKZc75EN1XIGwct0ndkyOairFWbXgkiFwPXfDpQaBA9pn2_9mSOYSylT1H60yH1ufg=="
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.646103,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "tt_session_tlb_tag",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "sttt%7C1%7C6M35zM57kkqAGSs_LSUdRP_________zyplxYaEARSr2PNU_6cKcB0lq4WRz1GKKY43u399i5hs%3D"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1798369620.645922,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "sid_guard",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "e8cdf9ccce7b924a80192b3f2d251d44%7C1767265616%7C15552000%7CTue%2C+30-Jun-2026+11%3A06%3A56+GMT"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1798801628.385793,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "ttwid",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "1%7CAYDsetgnxt5vzYX8hD6Wq2DQ4FXiL_pqcdLwHWwz6B8%7C1767265624%7C6121d82381fb651afeae94341e45b87fca1d903fbec0d8a19e4dd5440a89a424"
|
||||
},
|
||||
{
|
||||
"domain": ".www.tiktok.com",
|
||||
"expirationDate": 1767870430,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "perf_feed_cache",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "{%22expireTimestamp%22:1767438000000%2C%22itemIds%22:[%227588749061168123154%22%2C%227589493510613552404%22%2C%227586917939568332054%22]}"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.645952,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "uid_tt",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1772449601.742227,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "passport_csrf_token_default",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "9c66ab10306611c75fa19c87e54fd31b"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "s_v_web_id",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": true,
|
||||
"storeId": null,
|
||||
"value": "verify_mjvcbi31_l3mxUEeU_ykis_4x6z_859S_8zEFmNseEnJU"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.64617,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "ssid_ucp_v1",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
|
||||
},
|
||||
{
|
||||
"domain": ".www.tiktok.com",
|
||||
"expirationDate": 1793185625,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "tiktok_webapp_theme",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "dark"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1799409936.219767,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "_ttp",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "32XOXKxwj8YLtBQf0OBn4TvlkPN"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1772449620.645821,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "cmpl_token",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "AgQYAPOF_hfkTtKPtFExgPKdOPKrXVkNUj-FDmCi6K4"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1772449620.645628,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "multi_sids",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "6884525631502042113%3Ae8cdf9ccce7b924a80192b3f2d251d44"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1769857620.645892,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "passport_auth_status_ss",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "966972581a398dbb9ead189c044cf98c%2C"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1772449601.742082,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "passport_csrf_token",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "9c66ab10306611c75fa19c87e54fd31b"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.646041,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "sessionid",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.646073,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "sessionid_ss",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.646008,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "sid_tt",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "e8cdf9ccce7b924a80192b3f2d251d44"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.646132,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "sid_ucp_v1",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "1.0.1-KDlhMTg2NTQ1MmJiZmNlZjgzYzNiZGU4ZjAyNzk1NWRkNTlkOTYxNjIKIQiBiIHG4PKvxV8Q0KrZygYYswsgDDC50ZfDBjgIQBJIBBADGgJteSIgZThjZGY5Y2NjZTdiOTI0YTgwMTkyYjNmMmQyNTFkNDQyTgog40q2JTBb3lGgiNKowpX3zbxplmW4zO3AUFhAo6LMB-wSIDpAp_OQ2Q5qEBZvL59v7fgLmw27UIxLQHoimzDg3U5BGAIiBnRpa3Rvaw"
|
||||
},
|
||||
{
|
||||
"domain": ".www.tiktok.com",
|
||||
"expirationDate": 1793185625,
|
||||
"hostOnly": false,
|
||||
"httpOnly": false,
|
||||
"name": "tiktok_webapp_theme_source",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "auto"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817624.001151,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "tt_chain_token",
|
||||
"path": "/",
|
||||
"sameSite": null,
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "6deMEWrkAGUe9R0tCISIoQ=="
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "tt_csrf_token",
|
||||
"path": "/",
|
||||
"sameSite": "lax",
|
||||
"secure": true,
|
||||
"session": true,
|
||||
"storeId": null,
|
||||
"value": "q0Q4ki72-I7zQB6eLbpBBaqFBGrUF_v85N9s"
|
||||
},
|
||||
{
|
||||
"domain": ".tiktok.com",
|
||||
"expirationDate": 1782817620.645979,
|
||||
"hostOnly": false,
|
||||
"httpOnly": true,
|
||||
"name": "uid_tt_ss",
|
||||
"path": "/",
|
||||
"sameSite": "no_restriction",
|
||||
"secure": true,
|
||||
"session": false,
|
||||
"storeId": null,
|
||||
"value": "44deb1e89d254f610eefd18c39ec97fa708e9c0f22c0207f7140c6ffd6c81b2c"
|
||||
}
|
||||
]
|
||||
}
|
||||
30
test_request.py
Normal file
30
test_request.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import urllib.request
|
||||
import json
|
||||
import os
|
||||
|
||||
with open("temp_cookies.json", "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Ensure data is in the expected dict format for the request body
|
||||
if isinstance(data, list):
|
||||
# If temp_cookies is just the list, wrap it
|
||||
body = {"credentials": data}
|
||||
elif "credentials" not in data:
|
||||
body = {"credentials": data}
|
||||
else:
|
||||
body = data
|
||||
|
||||
req = urllib.request.Request(
|
||||
"http://localhost:8002/api/feed",
|
||||
data=json.dumps(body).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'}
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as response:
|
||||
print(response.read().decode('utf-8'))
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"HTTP Error: {e.code}")
|
||||
print(e.read().decode('utf-8'))
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
35
test_search.py
Normal file
35
test_search.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import requests
|
||||
import json
|
||||
import time
|
||||
|
||||
BASE_URL = "http://localhost:8002/api/user/search"
|
||||
|
||||
def test_search():
|
||||
print("Testing Search API...")
|
||||
try:
|
||||
# Simple query
|
||||
params = {
|
||||
"query": "dance",
|
||||
"limit": 50,
|
||||
"cursor": 0
|
||||
}
|
||||
start = time.time()
|
||||
res = requests.get(BASE_URL, params=params)
|
||||
duration = time.time() - start
|
||||
|
||||
print(f"Status Code: {res.status_code}")
|
||||
print(f"Duration: {duration:.2f}s")
|
||||
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
print(f"Videos Found: {len(data.get('videos', []))}")
|
||||
# print(json.dumps(data, indent=2))
|
||||
else:
|
||||
print("Error Response:")
|
||||
print(res.text)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Request Failed: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_search()
|
||||
Loading…
Reference in a new issue