Initial commit: PureStream - Distraction-free TikTok viewer

This commit is contained in:
Khoa.vo 2025-12-19 12:28:57 +07:00
commit a5ffc8d4aa
51 changed files with 9014 additions and 0 deletions

15
.dockerignore Normal file
View file

@ -0,0 +1,15 @@
node_modules/
venv/
__pycache__/
*.pyc
.git/
.gitignore
*.log
*.md
.DS_Store
.vscode/
.idea/
cache/
backend/session/
playwright-report/
test-results/

49
.gitignore vendored Normal file
View file

@ -0,0 +1,49 @@
# Dependencies
node_modules/
venv/
__pycache__/
*.pyc
.env
# Build outputs
frontend/dist/
*.egg-info/
# Cache
cache/
*.cache
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Session data (sensitive)
backend/session/
*.session
backend/cookies.json
backend/cookies_netscape.txt
backend/session_metadata.json
backend/user_agent.json
# Downloads (large files)
backend/downloads/
downloads/
# Debug files
backend/debug_*.html
# Playwright
playwright-report/
test-results/
# Test files
backend/test_*.py

71
Dockerfile Normal file
View file

@ -0,0 +1,71 @@
# Build stage for frontend
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Production stage
FROM python:3.11-slim
# Install system dependencies for Playwright and yt-dlp
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
curl \
gnupg \
ca-certificates \
ffmpeg \
# Playwright dependencies
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libasound2 \
libpango-1.0-0 \
libcairo2 \
libatspi2.0-0 \
libgtk-3-0 \
fonts-liberation \
xvfb \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy backend requirements and install
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright browsers
RUN playwright install chromium
# Copy backend code
COPY backend/ ./backend/
# Copy built frontend
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
# Create cache directory
RUN mkdir -p /app/cache && chmod 777 /app/cache
# Environment variables
ENV PYTHONUNBUFFERED=1
ENV CACHE_DIR=/app/cache
# Expose port
EXPOSE 8002
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8002/health || exit 1
# Start the application with xvfb for headless browser support
CMD ["sh", "-c", "xvfb-run --auto-servernum --server-args='-screen 0 1920x1080x24' python -m uvicorn backend.main:app --host 0.0.0.0 --port 8002"]

186
README.md Normal file
View file

@ -0,0 +1,186 @@
# 🎵 PureStream
**Distraction-free TikTok viewing** - A clean, ad-free TikTok client with a beautiful minimal interface.
![PureStream Demo](https://img.shields.io/badge/Platform-Web-blue) ![Docker](https://img.shields.io/badge/Docker-Ready-blue) ![License](https://img.shields.io/badge/License-MIT-green)
## ✨ Features
- 🎬 **Clean Video Feed** - No ads, no distractions, just content
- 🔍 **Powerful Search** - Search by username, video URL, or keywords
- 👥 **Follow System** - Keep track of your favorite creators
- 💾 **Tab Persistence** - Switch tabs without losing your place
- 👆 **Swipe Navigation** - Swipe left/right to switch tabs (mobile)
- ⌨️ **Keyboard Controls** - Arrow keys for tabs, Space for pause, Up/Down for scroll
- ❤️ **Heart Animations** - Double-tap to show love
- 🔇 **Smart Autoplay** - Videos autoplay muted (tap to unmute)
- 📱 **Responsive Design** - Works on desktop and mobile
- 🐳 **Docker Ready** - Easy deployment on any platform
## 🚀 Quick Start
### Option 1: Docker Compose (Recommended)
The easiest way to run PureStream on your server or Synology NAS.
```bash
# Create a directory
mkdir purestream && cd purestream
# Download docker-compose.yml
curl -O https://raw.githubusercontent.com/YOUR_USERNAME/purestream/main/docker-compose.yml
# Start the application
docker-compose up -d
# View logs
docker-compose logs -f
```
Access the app at: `http://your-server-ip:8002`
### Option 2: Docker Run
```bash
docker run -d \
--name purestream \
-p 8002:8002 \
--shm-size=2g \
-v purestream_cache:/app/cache \
-v purestream_session:/app/backend/session \
vndangkhoa/purestream:latest
```
### Option 3: Development Setup
```bash
# Clone the repository
git clone https://github.com/YOUR_USERNAME/purestream.git
cd purestream
# Backend setup
cd backend
python -m venv venv
source venv/bin/activate # or `venv\Scripts\activate` on Windows
pip install -r requirements.txt
playwright install chromium
# Start backend
uvicorn main:app --host 0.0.0.0 --port 8002 --reload
# Frontend setup (new terminal)
cd frontend
npm install
npm run dev
```
## 🖥️ Synology NAS Deployment
### Using Container Manager (Docker)
1. **Open Container Manager** → **Registry**
2. Search for `vndangkhoa/purestream` and download the `latest` tag
3. Go to **Container** → **Create**
4. Configure:
- **Port Settings**: Local `8002` → Container `8002`
- **Volume**: Create a folder for cache and map to `/app/cache`
- **Environment**: Add `PYTHONUNBUFFERED=1`
- **Resources**: Allocate at least 2GB RAM (for browser)
5. **Apply** and start the container
### Using docker-compose on Synology
```bash
# SSH into your NAS
ssh admin@your-nas-ip
# Create directory
mkdir -p /volume1/docker/purestream
cd /volume1/docker/purestream
# Create docker-compose.yml (paste the content from this repo)
nano docker-compose.yml
# Start
docker-compose up -d
```
## ⌨️ Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `←` `→` | Switch tabs |
| `↑` `↓` | Scroll videos |
| `Space` | Play/Pause |
| `M` | Mute/Unmute |
## 🔧 Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CACHE_DIR` | `/app/cache` | Video cache directory |
| `MAX_CACHE_SIZE_MB` | `500` | Maximum cache size in MB |
| `CACHE_TTL_HOURS` | `24` | Cache expiration time |
## 📁 Project Structure
```
purestream/
├── backend/
│ ├── api/
│ │ └── routes/
│ │ ├── auth.py # Authentication endpoints
│ │ └── feed.py # Feed & video proxy endpoints
│ ├── core/
│ │ └── playwright_manager.py # Browser automation
│ └── main.py # FastAPI application
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ │ ├── Feed.tsx # Main feed component
│ │ │ └── VideoPlayer.tsx # Video player
│ │ └── App.tsx
│ └── package.json
├── Dockerfile
├── docker-compose.yml
└── README.md
```
## 🔐 Authentication
PureStream uses your TikTok session to fetch content. On first launch:
1. Click **"Login with TikTok"**
2. A browser window opens - log in to TikTok normally
3. Your session is saved locally for future use
> **Note**: Your credentials are stored locally and never sent to any external server.
## 🐛 Troubleshooting
### Videos not loading?
- Check if the backend is running: `curl http://localhost:8002/health`
- Check logs: `docker-compose logs -f`
- Try re-logging in (sessions can expire)
### Browser errors on headless server?
- Ensure `shm_size: '2gb'` is set in docker-compose
- Xvfb is included in the Docker image for virtual display
### Cache issues?
- Clear cache: `docker exec purestream rm -rf /app/cache/*`
- Restart container: `docker-compose restart`
## 📄 License
MIT License - feel free to use, modify, and distribute.
## 🙏 Acknowledgments
- Built with [FastAPI](https://fastapi.tiangolo.com/) & [React](https://react.dev/)
- Browser automation by [Playwright](https://playwright.dev/)
- Video extraction by [yt-dlp](https://github.com/yt-dlp/yt-dlp)
---
**Made with ❤️ for distraction-free viewing**

0
backend/api/__init__.py Normal file
View file

View file

View file

@ -0,0 +1,90 @@
"""
Auth API routes - simplified to use PlaywrightManager.
"""
from fastapi import APIRouter, Form, HTTPException
from pydantic import BaseModel
import os
import json
from core.playwright_manager import PlaywrightManager, COOKIES_FILE
router = APIRouter()
class BrowserLoginResponse(BaseModel):
status: str
message: str
cookie_count: int = 0
class CredentialsRequest(BaseModel):
credentials: dict # JSON credentials in http.headers format
@router.post("/browser-login", response_model=BrowserLoginResponse)
async def browser_login():
"""
Open a visible browser window for user to login to TikTok via SSL.
Waits for login completion (detected via sessionid cookie) and captures cookies.
"""
try:
result = await PlaywrightManager.browser_login(timeout_seconds=180)
return BrowserLoginResponse(
status=result["status"],
message=result["message"],
cookie_count=result.get("cookie_count", 0)
)
except Exception as e:
print(f"DEBUG: Browser login error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/credentials")
async def save_credentials(request: CredentialsRequest):
"""
Save JSON credentials (advanced login option).
Accepts the http.headers.Cookie format.
"""
try:
cookies, user_agent = PlaywrightManager.parse_json_credentials(request.credentials)
if not cookies:
raise HTTPException(status_code=400, detail="No cookies found in credentials")
# Convert to dict format for storage
cookie_dict = {c["name"]: c["value"] for c in cookies}
PlaywrightManager.save_credentials(cookie_dict, user_agent)
return {
"status": "success",
"message": f"Saved {len(cookies)} cookies",
"cookie_count": len(cookies)
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/status")
async def auth_status():
"""Check if we have stored cookies."""
if os.path.exists(COOKIES_FILE) and os.path.getsize(COOKIES_FILE) > 0:
try:
with open(COOKIES_FILE, "r") as f:
cookies = json.load(f)
has_session = "sessionid" in cookies
return {
"authenticated": has_session,
"cookie_count": len(cookies)
}
except:
pass
return {"authenticated": False, "cookie_count": 0}
@router.post("/logout")
async def logout():
"""Clear stored credentials."""
if os.path.exists(COOKIES_FILE):
os.remove(COOKIES_FILE)
return {"status": "success", "message": "Logged out"}

View file

@ -0,0 +1,89 @@
"""
Config API route - allows frontend to fetch settings from server.
"""
from fastapi import APIRouter
from pydantic import BaseModel
from typing import List, Optional
import json
import os
router = APIRouter()
# Config file path
CONFIG_FILE = os.path.join(os.path.dirname(__file__), "../../config.json")
# Default config
DEFAULT_CONFIG = {
"proxy_mode": "auto", # "thin" (faster, CDN), "full" (yt-dlp), or "auto" (prefer thin)
"cache_enabled": True,
"cache_max_mb": 500,
"suggested_accounts": [
{"username": "@ciin_rubi", "label": "👑 CiiN - Lisa of Vietnam"},
{"username": "@hoaa.hanassii", "label": "💃 Đào Lê Phương Hoa - Queen of Wiggle"},
{"username": "@hoa_2309", "label": "🔥 Ngô Ngọc Hòa - Hot Trend"},
{"username": "@minah.ne", "label": "🎵 Minah - K-pop Dancer"},
{"username": "@lebong95", "label": "💪 Lê Bống - Fitness Dance"},
{"username": "@po.trann77", "label": "✨ Trần Thanh Tâm"},
{"username": "@gamkami", "label": "🎱 Gấm Kami - Cute Style"},
{"username": "@quynhalee", "label": "🎮 Quỳnh Alee - Gaming Dance"},
{"username": "@tieu_hy26", "label": "👰 Tiểu Hý - National Wife"},
{"username": "@changmie", "label": "🎤 Changmie - Singer/Mashups"},
{"username": "@vuthuydien", "label": "😄 Vũ Thụy Điển - Humor"},
],
"app_name": "PureStream",
"version": "1.0.0"
}
def load_config() -> dict:
"""Load config from file or return default."""
try:
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "r") as f:
return json.load(f)
except Exception as e:
print(f"Config load error: {e}")
return DEFAULT_CONFIG.copy()
def save_config(config: dict):
"""Save config to file."""
try:
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"Config save error: {e}")
@router.get("")
async def get_config():
"""Get app configuration for frontend."""
return load_config()
class ConfigUpdate(BaseModel):
"""Config update request."""
proxy_mode: Optional[str] = None
cache_enabled: Optional[bool] = None
cache_max_mb: Optional[int] = None
suggested_accounts: Optional[List[dict]] = None
@router.patch("")
async def update_config(updates: ConfigUpdate):
"""Update specific config values."""
config = load_config()
if updates.proxy_mode is not None:
config["proxy_mode"] = updates.proxy_mode
if updates.cache_enabled is not None:
config["cache_enabled"] = updates.cache_enabled
if updates.cache_max_mb is not None:
config["cache_max_mb"] = updates.cache_max_mb
if updates.suggested_accounts is not None:
config["suggested_accounts"] = updates.suggested_accounts
save_config(config)
return config

View file

@ -0,0 +1,40 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from core.download_service import download_service
from fastapi.responses import FileResponse
import os
router = APIRouter()
class DownloadRequest(BaseModel):
url: str
@router.post("")
async def download_video(req: DownloadRequest):
result = await download_service.download_video(req.url)
if result["status"] == "error":
raise HTTPException(status_code=500, detail=result["message"])
# In a real app, we might upload to cloud storage and return a URL
# Or stream it. For now, let's return the file info.
# To actually serve the file, we can add a GET endpoint or return FileResponse here
# (though returning FileResponse on POST is valid but less common for "trigger download" flows).
return result
@router.get("/file/{video_id}")
async def get_downloaded_file(video_id: str):
# Security risk: path traversal. MVP only.
# We need to find the file extension... or just allow the service to return the full path in the previous call
# Let's assume content content-disposition
# This is a bit tricky without knowing the extension.
# For MVP, we'll search the dir.
download_dir = "downloads"
for filename in os.listdir(download_dir):
if filename.startswith(video_id):
return FileResponse(path=os.path.join(download_dir, filename), filename=filename)
raise HTTPException(status_code=404, detail="File not found")

369
backend/api/routes/feed.py Normal file
View file

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

View file

@ -0,0 +1,65 @@
"""
Following API routes - manage followed creators.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import os
import json
router = APIRouter()
FOLLOWING_FILE = "following.json"
def load_following() -> list:
"""Load list of followed creators."""
if os.path.exists(FOLLOWING_FILE):
try:
with open(FOLLOWING_FILE, 'r') as f:
return json.load(f)
except:
return []
return []
def save_following(following: list):
"""Save list of followed creators."""
with open(FOLLOWING_FILE, 'w') as f:
json.dump(following, f, indent=2)
class FollowRequest(BaseModel):
username: str
@router.get("")
async def get_following():
"""Get list of followed creators."""
return load_following()
@router.post("")
async def add_following(request: FollowRequest):
"""Add a creator to following list."""
username = request.username.lstrip('@')
following = load_following()
if username not in following:
following.append(username)
save_following(following)
return {"status": "success", "following": following}
@router.delete("/{username}")
async def remove_following(username: str):
"""Remove a creator from following list."""
username = username.lstrip('@')
following = load_following()
if username in following:
following.remove(username)
save_following(following)
return {"status": "success", "following": following}

157
backend/api/routes/user.py Normal file
View file

@ -0,0 +1,157 @@
"""
User profile API - fetch real TikTok user data.
"""
from fastapi import APIRouter, Query, HTTPException
from pydantic import BaseModel
from typing import Optional, List
import httpx
import asyncio
from core.playwright_manager import PlaywrightManager
router = APIRouter()
class UserProfile(BaseModel):
"""TikTok user profile data."""
username: str
nickname: Optional[str] = None
avatar: Optional[str] = None
bio: Optional[str] = None
followers: Optional[int] = None
following: Optional[int] = None
likes: Optional[int] = None
verified: bool = False
@router.get("/profile")
async def get_user_profile(username: str = Query(..., description="TikTok username (without @)")):
"""
Fetch real TikTok user profile data.
"""
username = username.replace("@", "")
# Load stored credentials
cookies, user_agent = PlaywrightManager.load_stored_credentials()
if not cookies:
raise HTTPException(status_code=401, detail="Not authenticated")
# Build cookie header
cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
headers = {
"User-Agent": user_agent or PlaywrightManager.DEFAULT_USER_AGENT,
"Referer": "https://www.tiktok.com/",
"Cookie": cookie_str,
"Accept": "application/json",
}
# Try to fetch user data from TikTok's internal API
profile_url = f"https://www.tiktok.com/api/user/detail/?uniqueId={username}"
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
response = await client.get(profile_url, headers=headers)
if response.status_code != 200:
# Fallback - return basic info
return UserProfile(username=username)
data = response.json()
user_info = data.get("userInfo", {})
user = user_info.get("user", {})
stats = user_info.get("stats", {})
return UserProfile(
username=username,
nickname=user.get("nickname"),
avatar=user.get("avatarLarger") or user.get("avatarMedium"),
bio=user.get("signature"),
followers=stats.get("followerCount"),
following=stats.get("followingCount"),
likes=stats.get("heartCount"),
verified=user.get("verified", False)
)
except Exception as e:
print(f"Error fetching profile for {username}: {e}")
# Return basic fallback
return UserProfile(username=username)
@router.get("/profiles")
async def get_multiple_profiles(usernames: str = Query(..., description="Comma-separated usernames")):
"""
Fetch multiple TikTok user profiles at once.
"""
username_list = [u.strip().replace("@", "") for u in usernames.split(",") if u.strip()]
if len(username_list) > 20:
raise HTTPException(status_code=400, detail="Max 20 usernames at once")
# Fetch all profiles concurrently
tasks = [get_user_profile(u) for u in username_list]
results = await asyncio.gather(*tasks, return_exceptions=True)
profiles = []
for i, result in enumerate(results):
if isinstance(result, Exception):
profiles.append(UserProfile(username=username_list[i]))
else:
profiles.append(result)
return profiles
@router.get("/videos")
async def get_user_videos(
username: str = Query(..., description="TikTok username (without @)"),
limit: int = Query(10, description="Max videos to fetch", ge=1, le=30)
):
"""
Fetch videos from a TikTok user's profile.
Uses Playwright to intercept the user's video list API.
"""
username = username.replace("@", "")
# Load stored credentials
cookies, user_agent = PlaywrightManager.load_stored_credentials()
if not cookies:
raise HTTPException(status_code=401, detail="Not authenticated")
print(f"Fetching videos for @{username}...")
try:
videos = await PlaywrightManager.fetch_user_videos(username, cookies, user_agent, limit)
return {"username": username, "videos": videos, "count": len(videos)}
except Exception as e:
print(f"Error fetching videos for {username}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/search")
async def search_videos(
query: str = Query(..., description="Search keyword or hashtag"),
limit: int = Query(12, description="Max videos to fetch", ge=1, le=30)
):
"""
Search for videos by keyword or hashtag.
Uses Playwright to intercept TikTok search results.
"""
# Load stored credentials
cookies, user_agent = PlaywrightManager.load_stored_credentials()
if not cookies:
raise HTTPException(status_code=401, detail="Not authenticated")
print(f"Searching for: {query}...")
try:
videos = await PlaywrightManager.search_videos(query, cookies, user_agent, limit)
return {"query": query, "videos": videos, "count": len(videos)}
except Exception as e:
print(f"Error searching for {query}: {e}")
raise HTTPException(status_code=500, detail=str(e))

0
backend/core/__init__.py Normal file
View file

View file

@ -0,0 +1,38 @@
import yt_dlp
import os
import asyncio
class DownloadService:
def __init__(self):
self.download_dir = "downloads"
if not os.path.exists(self.download_dir):
os.makedirs(self.download_dir)
async def download_video(self, url: str) -> dict:
"""
Download video using yt-dlp and return metadata/file path.
"""
ydl_opts = {
'format': 'best',
'outtmpl': f'{self.download_dir}/%(id)s.%(ext)s',
'noplaylist': True,
'quiet': True,
}
loop = asyncio.get_event_loop()
# Run synchronous yt-dlp in a separate thread
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = await loop.run_in_executor(None, lambda: ydl.extract_info(url, download=True))
filename = ydl.prepare_filename(info)
return {
"status": "success",
"filename": filename,
"title": info.get('title'),
"id": info.get('id')
}
except Exception as e:
return {"status": "error", "message": str(e)}
download_service = DownloadService()

View file

@ -0,0 +1,302 @@
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode, LLMConfig
from crawl4ai.extraction_strategy import LLMExtractionStrategy
from pydantic import BaseModel, Field
import os
import json
import asyncio
from typing import List, Optional
import yt_dlp
from cachetools import TTLCache
import time
class VideoSchema(BaseModel):
url: str = Field(..., description="The URL to the video content")
description: str = Field(..., description="The video caption/description")
author: str = Field(..., description="The username of the creator")
class FeedService:
# Class-level TTL cache for feed results (60 second expiry, max 10 entries)
_feed_cache: TTLCache = TTLCache(maxsize=10, ttl=60)
_browser_warmed_up: bool = False
_persistent_session_id: str = "tiktok_feed_session"
def __init__(self):
self.api_key = os.getenv("OPENAI_API_KEY")
async def warmup(self):
"""Pre-warm the browser session on startup for faster first request."""
if FeedService._browser_warmed_up:
return
print("DEBUG: Warming up browser session...")
try:
browser_config = BrowserConfig(headless=True, java_script_enabled=True)
run_config = CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
session_id=FeedService._persistent_session_id,
wait_until="domcontentloaded"
)
async with AsyncWebCrawler(config=browser_config) as crawler:
await crawler.arun(url="https://www.tiktok.com", config=run_config)
FeedService._browser_warmed_up = True
print("DEBUG: Browser session warmed up successfully!")
except Exception as e:
print(f"DEBUG: Warmup failed (non-critical): {e}")
async def _resolve_video_url(self, url: str) -> Optional[str]:
"""Resolve direct media URL using yt-dlp."""
cookie_header = ""
user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"
if os.path.exists("cookies.json"):
try:
with open("cookies.json", 'r') as f:
cookies_dict = json.load(f)
cookie_header = "; ".join([f"{k}={v}" for k, v in cookies_dict.items()])
except Exception as e:
print(f"Error preparing cookies for yt-dlp: {e}")
if os.path.exists("session_metadata.json"):
try:
with open("session_metadata.json", "r") as f:
meta = json.load(f)
user_agent = meta.get("user_agent", user_agent)
except:
pass
ydl_opts = {
'quiet': True,
'no_warnings': True,
'format': 'best',
'http_headers': {
'Cookie': cookie_header,
'User-Agent': user_agent
} if cookie_header else None,
'socket_timeout': 10,
}
try:
loop = asyncio.get_event_loop()
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = await loop.run_in_executor(None, lambda: ydl.extract_info(url, download=False))
return info.get('url')
except Exception as e:
print(f"Failed to resolve URL {url}: {e}")
return None
async def get_feed(self, source_url: str = "https://www.tiktok.com/foryou", skip_cache: bool = False) -> List[dict]:
# Check cache first (unless skip_cache is True for infinite scroll)
cache_key = source_url
if not skip_cache and cache_key in FeedService._feed_cache:
print(f"DEBUG: Returning cached results for {source_url}")
return FeedService._feed_cache[cache_key]
# 1. Load cookies
crawl_cookies = []
cookies_path = "cookies.json"
own_user_id = None # Track logged-in user's ID to filter out their videos
if os.path.exists(cookies_path):
try:
with open(cookies_path, 'r') as f:
cookie_dict = json.load(f)
# Extract the logged-in user's ID from cookies
own_user_id = cookie_dict.get("living_user_id")
for k, v in cookie_dict.items():
crawl_cookies.append({
"name": k,
"value": v,
"domain": ".tiktok.com",
"path": "/"
})
print(f"DEBUG: Loaded {len(crawl_cookies)} cookies. User ID: {own_user_id}")
except Exception as e:
print(f"Error loading cookies: {e}")
# 2. Config Crawler
default_ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
user_agent = default_ua
if os.path.exists("session_metadata.json"):
try:
with open("session_metadata.json", "r") as f:
meta = json.load(f)
user_agent = meta.get("user_agent", default_ua)
except:
pass
browser_config = BrowserConfig(
headless=True,
java_script_enabled=True,
cookies=crawl_cookies if crawl_cookies else None,
headers={
"User-Agent": user_agent
}
)
# Aggressive scrolling to load many videos (12 scrolls = ~30+ videos)
run_config = CrawlerRunConfig(
cache_mode=CacheMode.BYPASS,
session_id=FeedService._persistent_session_id,
js_code="""
// Scroll aggressively to load ~30 videos
for (let i = 0; i < 12; i++) {
window.scrollBy(0, 1500);
await new Promise(r => setTimeout(r, 800));
}
""",
wait_for="body",
wait_until="domcontentloaded",
delay_before_return_html=10.0,
page_timeout=60000,
magic=True
)
try:
print(f"DEBUG: Starting crawl for: {source_url}")
async with AsyncWebCrawler(config=browser_config) as crawler:
result = await asyncio.wait_for(
crawler.arun(url=source_url, config=run_config),
timeout=90.0
)
print(f"DEBUG: Crawl Success: {result.success}")
if not result.success:
print(f"DEBUG: Crawl Error: {result.error_message}")
return []
# Parse SIGI_STATE from HTML (TikTok's embedded data)
html = result.html if result.html else ""
videos = []
# Try to find video links directly from HTML
import re
# TikTok uses relative URLs like /@username/video/1234567890
video_pattern = r'/@([a-zA-Z0-9_.]+)/video/(\d+)'
matches = re.findall(video_pattern, html)
# Dedupe by video ID, skip own videos, and keep first 20
seen_ids = set()
unique_videos = []
skipped_own = 0
for author, video_id in matches:
# Skip videos from the logged-in user's account
if own_user_id and author == own_user_id:
skipped_own += 1
continue
if video_id not in seen_ids:
seen_ids.add(video_id)
unique_videos.append((author, video_id))
if len(unique_videos) >= 30: # Get up to 30 videos per batch
break
if skipped_own > 0:
print(f"DEBUG: Skipped {skipped_own} videos from own account")
print(f"DEBUG: Found {len(unique_videos)} unique videos in HTML")
print(f"DEBUG: HTML length: {len(html)} characters")
# Debug: Save HTML to file for inspection
try:
with open("debug_tiktok.html", "w") as f:
f.write(html)
print("DEBUG: Saved HTML to debug_tiktok.html")
except:
pass
if unique_videos:
# Build video objects (author and video_id already extracted)
for author, video_id in unique_videos:
videos.append({
"url": f"https://www.tiktok.com/@{author}/video/{video_id}",
"author": author,
"description": f"Video by @{author}"
})
# Resolve direct URLs in parallel
print(f"DEBUG: Resolving direct URLs for {len(videos)} videos...")
async def resolve_item(item):
direct_url = await self._resolve_video_url(item['url'])
if direct_url:
item['url'] = direct_url
return item
return None
resolved_items = await asyncio.gather(*[resolve_item(item) for item in videos])
final_results = [item for item in resolved_items if item]
# Cache results
if final_results:
FeedService._feed_cache[cache_key] = final_results
print(f"DEBUG: Cached {len(final_results)} videos")
return final_results
else:
print("DEBUG: No video IDs found in HTML, trying SIGI_STATE...")
# Try parsing SIGI_STATE JSON
sigi_pattern = r'<script id="SIGI_STATE" type="application/json">(.+?)</script>'
sigi_match = re.search(sigi_pattern, html, re.DOTALL)
if sigi_match:
try:
sigi_data = json.loads(sigi_match.group(1))
items = sigi_data.get("ItemModule", {})
for item_id, item_data in list(items.items())[:10]:
author = item_data.get("author", "unknown")
desc = item_data.get("desc", "")
video_url = f"https://www.tiktok.com/@{author}/video/{item_id}"
videos.append({
"url": video_url,
"author": author,
"description": desc or f"Video by @{author}"
})
if videos:
# Resolve URLs
async def resolve_item(item):
direct_url = await self._resolve_video_url(item['url'])
if direct_url:
item['url'] = direct_url
return item
return None
resolved_items = await asyncio.gather(*[resolve_item(item) for item in videos])
final_results = [item for item in resolved_items if item]
if final_results:
FeedService._feed_cache[cache_key] = final_results
print(f"DEBUG: Cached {len(final_results)} videos from SIGI_STATE")
return final_results
except Exception as e:
print(f"DEBUG: Failed to parse SIGI_STATE: {e}")
return []
except asyncio.TimeoutError:
print("DEBUG: Crawl timed out after 90s")
return []
except Exception as e:
print(f"DEBUG: Crawl process failed: {e}")
return []
async def search_videos(self, query: str) -> List[dict]:
search_url = f"https://www.tiktok.com/search?q={query}"
return await self.get_feed(source_url=search_url)
async def check_cookie_health(self) -> bool:
"""Check if cookies are still valid by hitting a simple endpoint."""
if not os.path.exists("cookies.json"):
return False
# In a real scenario, we'd hit https://www.tiktok.com/api/user/detail/ or similar
# For now, we'll just check if the file exists and is non-empty
return os.path.getsize("cookies.json") > 0
feed_service = FeedService()

View file

@ -0,0 +1,509 @@
"""
PlaywrightManager - Core class for TikTok network interception.
Uses Playwright to:
1. Parse cookies from JSON format
2. Handle browser-based SSL login
3. Intercept /item_list API responses (instead of scraping HTML)
"""
import asyncio
import json
import os
from typing import List, Dict, Optional
from playwright.async_api import async_playwright, Response, Browser, BrowserContext
COOKIES_FILE = "cookies.json"
USER_AGENT_FILE = "user_agent.json"
class PlaywrightManager:
"""Manages Playwright browser for TikTok feed interception."""
# Anti-detection browser args
BROWSER_ARGS = [
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
"--disable-dev-shm-usage",
]
DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
@staticmethod
def parse_json_credentials(json_creds: dict) -> tuple[List[dict], str]:
"""
Parse JSON credentials in the format:
{
"http": {
"headers": {"User-Agent": "...", "Cookie": "..."},
"cookies": {"sessionid": "...", "ttwid": "..."}
}
}
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)
cookies.append({
"name": name.strip(),
"value": value.strip(),
"domain": ".tiktok.com",
"path": "/"
})
return cookies, user_agent
@staticmethod
def load_stored_credentials() -> tuple[List[dict], str]:
"""Load cookies and user agent from stored files."""
cookies = []
user_agent = PlaywrightManager.DEFAULT_USER_AGENT
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": "/"
})
except Exception as e:
print(f"Error loading cookies: {e}")
if os.path.exists(USER_AGENT_FILE):
try:
with open(USER_AGENT_FILE, "r") as f:
data = json.load(f)
user_agent = data.get("user_agent", user_agent)
except:
pass
return cookies, user_agent
@staticmethod
def save_credentials(cookies: dict, user_agent: str):
"""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)
@staticmethod
async def browser_login(timeout_seconds: int = 180) -> dict:
"""
Open visible browser for user to login via TikTok's SSL login.
Waits for sessionid cookie to be set.
Returns: {"status": "success/timeout", "cookies": {...}, "cookie_count": N}
"""
print("DEBUG: Opening browser for TikTok login...")
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=False,
args=PlaywrightManager.BROWSER_ARGS
)
context = await browser.new_context(
user_agent=PlaywrightManager.DEFAULT_USER_AGENT
)
page = await context.new_page()
# Navigate to TikTok login
await page.goto("https://www.tiktok.com/login", wait_until="domcontentloaded")
print("DEBUG: Login page opened. Waiting for user to complete login...")
# Poll for sessionid cookie
elapsed = 0
check_interval = 2
cookies_found = {}
while elapsed < timeout_seconds:
await asyncio.sleep(check_interval)
elapsed += check_interval
all_cookies = await context.cookies()
for cookie in all_cookies:
if cookie.get("domain", "").endswith("tiktok.com"):
cookies_found[cookie["name"]] = cookie["value"]
if "sessionid" in cookies_found:
print(f"DEBUG: Login detected! Found {len(cookies_found)} cookies.")
break
print(f"DEBUG: Waiting for login... ({elapsed}s)")
await browser.close()
if "sessionid" not in cookies_found:
return {
"status": "timeout",
"message": "Login timed out. Please try again.",
"cookie_count": 0
}
# Save credentials
PlaywrightManager.save_credentials(cookies_found, PlaywrightManager.DEFAULT_USER_AGENT)
return {
"status": "success",
"message": "Successfully connected to TikTok!",
"cookie_count": len(cookies_found)
}
@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)
Returns: List of video objects
"""
if not cookies:
cookies, user_agent = PlaywrightManager.load_stored_credentials()
if not user_agent:
user_agent = PlaywrightManager.DEFAULT_USER_AGENT
if not cookies:
print("DEBUG: No cookies available")
return []
print(f"DEBUG: Starting network interception with {len(cookies)} cookies (scrolls={scroll_count})")
captured_videos = []
async def handle_response(response: Response):
"""Capture /item_list API responses."""
nonlocal captured_videos
url = response.url
# Look for TikTok's feed API
if "item_list" in url or "recommend/item" in url:
try:
data = await response.json()
# TikTok returns videos in "itemList" or "aweme_list"
items = data.get("itemList", []) or data.get("aweme_list", [])
for item in items:
video_data = PlaywrightManager._extract_video_data(item)
if video_data:
captured_videos.append(video_data)
print(f"DEBUG: Captured {len(items)} videos from API")
except Exception as e:
print(f"DEBUG: Error parsing API response: {e}")
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=PlaywrightManager.BROWSER_ARGS
)
context = await browser.new_context(user_agent=user_agent)
await context.add_cookies(cookies)
page = await context.new_page()
# Set up response listener
page.on("response", handle_response)
try:
# Navigate to For You page
await page.goto(
"https://www.tiktok.com/foryou",
wait_until="domcontentloaded",
timeout=30000
)
# Wait for initial load - ensure we capture at least one batch
# Poll for videos if in fast mode
for _ in range(10): # Max 10 seconds wait
if len(captured_videos) > 0:
break
await asyncio.sleep(1)
# If still no videos, maybe scroll once to trigger
if len(captured_videos) == 0:
print("DEBUG: No videos after initial load, scrolling once...")
await page.evaluate("window.scrollBy(0, 800)")
await asyncio.sleep(2)
# Scroll loop
for i in range(scroll_count):
await page.evaluate("window.scrollBy(0, 800)")
await asyncio.sleep(1)
# Give time for API responses to be captured
await asyncio.sleep(2)
except Exception as e:
print(f"DEBUG: Navigation error: {e}")
await browser.close()
print(f"DEBUG: Total captured videos: {len(captured_videos)}")
return captured_videos
@staticmethod
def _extract_video_data(item: dict) -> Optional[dict]:
"""Extract video data from TikTok API item."""
try:
# Handle different API response formats
video_id = item.get("id") or item.get("aweme_id")
# Get author info
author_data = item.get("author", {})
author = author_data.get("uniqueId") or author_data.get("unique_id") or "unknown"
# Get description
desc = item.get("desc") or item.get("description") or ""
# Get thumbnail/cover image
thumbnail = None
video_data = item.get("video", {})
# Try different thumbnail sources
if video_data.get("cover"):
thumbnail = video_data["cover"]
elif video_data.get("dynamicCover"):
thumbnail = video_data["dynamicCover"]
elif video_data.get("originCover"):
thumbnail = video_data["originCover"]
# Get direct CDN URL (for thin proxy mode)
cdn_url = None
if video_data.get("playAddr"):
cdn_url = video_data["playAddr"]
elif video_data.get("downloadAddr"):
cdn_url = video_data["downloadAddr"]
elif video_data.get("play_addr", {}).get("url_list"):
cdn_url = video_data["play_addr"]["url_list"][0]
# Use TikTok page URL as fallback (yt-dlp resolves this)
video_url = f"https://www.tiktok.com/@{author}/video/{video_id}"
# Get stats (views, likes)
stats = item.get("stats", {}) or item.get("statistics", {})
views = stats.get("playCount") or stats.get("play_count") or 0
likes = stats.get("diggCount") or stats.get("digg_count") or 0
if video_id and author:
result = {
"id": str(video_id),
"url": video_url,
"author": author,
"description": desc[:200] if desc else f"Video by @{author}"
}
if thumbnail:
result["thumbnail"] = thumbnail
if cdn_url:
result["cdn_url"] = cdn_url # Direct CDN URL for thin proxy
if views:
result["views"] = views
if likes:
result["likes"] = likes
return result
except Exception as e:
print(f"DEBUG: Error extracting video data: {e}")
return None
@staticmethod
async def fetch_user_videos(username: str, cookies: list, user_agent: str = None, limit: int = 10) -> list:
"""
Fetch videos from a specific user's profile page.
Uses Playwright to intercept the user's video list API.
"""
from playwright.async_api import async_playwright, Response
if not user_agent:
user_agent = PlaywrightManager.DEFAULT_USER_AGENT
if not cookies:
print("DEBUG: No cookies available for user videos")
return []
print(f"DEBUG: Fetching videos for @{username}...")
captured_videos = []
async def handle_response(response: Response):
"""Capture user's video list API responses."""
nonlocal captured_videos
url = response.url
# Look for user's video list API
if "item_list" in url or "post/item_list" in url:
try:
data = await response.json()
items = data.get("itemList", []) or data.get("aweme_list", [])
for item in items:
if len(captured_videos) >= limit:
break
video_data = PlaywrightManager._extract_video_data(item)
if video_data:
captured_videos.append(video_data)
print(f"DEBUG: Captured {len(items)} videos from user API")
except Exception as e:
print(f"DEBUG: Error parsing user API response: {e}")
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=PlaywrightManager.BROWSER_ARGS
)
context = await browser.new_context(user_agent=user_agent)
await context.add_cookies(cookies)
page = await context.new_page()
page.on("response", handle_response)
try:
# Navigate to user's profile page
profile_url = f"https://www.tiktok.com/@{username}"
await page.goto(profile_url, wait_until="networkidle", timeout=30000)
# Wait for videos to load
await asyncio.sleep(2)
# Scroll a bit to trigger more video loading
await page.evaluate("window.scrollBy(0, 500)")
await asyncio.sleep(1)
except Exception as e:
print(f"DEBUG: Error navigating to profile: {e}")
await browser.close()
print(f"DEBUG: Total captured user videos: {len(captured_videos)}")
return captured_videos
@staticmethod
async def search_videos(query: str, cookies: list, user_agent: str = None, limit: int = 12) -> list:
"""
Search for videos by keyword or hashtag.
Uses Playwright to intercept TikTok search results API.
"""
from playwright.async_api import async_playwright, Response
from urllib.parse import quote
if not user_agent:
user_agent = PlaywrightManager.DEFAULT_USER_AGENT
if not cookies:
print("DEBUG: No cookies available for search")
return []
print(f"DEBUG: Searching for '{query}'...")
captured_videos = []
async def handle_response(response: Response):
"""Capture search results API responses."""
nonlocal captured_videos
url = response.url
# Look for search results API
if "search" in url and ("item_list" in url or "video" in url or "general" in url):
try:
data = await response.json()
# Try different response formats
items = data.get("itemList", []) or data.get("data", []) or data.get("item_list", [])
for item in items:
if len(captured_videos) >= limit:
break
video_data = PlaywrightManager._extract_video_data(item)
if video_data:
captured_videos.append(video_data)
print(f"DEBUG: Captured {len(items)} videos from search API")
except Exception as e:
print(f"DEBUG: Error parsing search API response: {e}")
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=PlaywrightManager.BROWSER_ARGS
)
context = await browser.new_context(user_agent=user_agent)
await context.add_cookies(cookies)
page = await context.new_page()
page.on("response", handle_response)
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)
# Wait for videos to load
await asyncio.sleep(3)
# Scroll to trigger more loading
for _ in range(2):
await page.evaluate("window.scrollBy(0, 800)")
await asyncio.sleep(1)
except Exception as e:
print(f"DEBUG: Error during search: {e}")
await browser.close()
print(f"DEBUG: Total captured search videos: {len(captured_videos)}")
return captured_videos
# Singleton instance
playwright_manager = PlaywrightManager()

1
backend/following.json Normal file
View file

@ -0,0 +1 @@
[]

58
backend/main.py Normal file
View file

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

8
backend/requirements.txt Normal file
View file

@ -0,0 +1,8 @@
fastapi
uvicorn
yt-dlp
requests
python-multipart
websockets
python-dotenv
crawl4ai

35
docker-compose.yml Normal file
View file

@ -0,0 +1,35 @@
version: '3.8'
services:
purestream:
image: vndangkhoa/purestream:latest
container_name: purestream
restart: unless-stopped
ports:
- "8002:8002"
volumes:
# Persist video cache
- purestream_cache:/app/cache
# Persist login session (optional - for persistent TikTok login)
- purestream_session:/app/backend/session
environment:
- PYTHONUNBUFFERED=1
- CACHE_DIR=/app/cache
- MAX_CACHE_SIZE_MB=500
- CACHE_TTL_HOURS=24
# Required for Playwright browser
shm_size: '2gb'
# Security: run as non-root (optional)
# user: "1000:1000"
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8002/health" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
volumes:
purestream_cache:
driver: local
purestream_session:
driver: local

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View file

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

48
frontend/index.html Normal file
View file

@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- Mobile Optimization -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="PureStream" />
<meta name="theme-color" content="#000000" />
<!-- SEO -->
<meta name="description" content="PureStream - Distraction-free TikTok viewing experience" />
<meta name="keywords" content="tiktok, video, streaming, purestream, dance" />
<!-- PWA -->
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png" />
<title>PureStream</title>
<style>
/* Prevent overscroll/bounce on iOS */
html, body {
overscroll-behavior: none;
overflow: hidden;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
}
/* Safe area padding for notched devices */
body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4412
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

41
frontend/package.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"artplayer": "^5.3.0",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"framer-motion": "^12.23.26",
"lucide-react": "^0.561.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.2",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@types/node": "^20.14.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

View file

@ -0,0 +1,35 @@
{
"name": "PureStream",
"short_name": "PureStream",
"description": "Distraction-free TikTok viewing experience",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": [
"entertainment",
"social"
],
"shortcuts": [
{
"name": "For You",
"url": "/",
"description": "Open For You feed"
}
]
}

1
frontend/public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View file

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

58
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,58 @@
import React, { useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { Login } from './pages/Login';
import { useAuthStore } from './store/authStore';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated, isLoading } = useAuthStore();
if (isLoading) {
return <div className="flex items-center justify-center min-h-screen bg-gray-900 text-white">Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
import { Feed } from './components/Feed';
const Dashboard = () => {
return (
<div className="h-screen bg-black">
<Feed />
</div>
)
}
import { ToastProvider } from './components/Toast';
function App() {
const checkAuth = useAuthStore((state) => state.checkAuth);
useEffect(() => {
checkAuth();
}, [checkAuth]);
return (
<ToastProvider>
<Router future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
</Routes>
</Router>
</ToastProvider>
);
}
export default App;

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,57 @@
import React from 'react';
import { useDownloadStore } from '../store/downloadStore';
import { X, Check, AlertCircle, Loader2, Download } from 'lucide-react';
interface DownloadListProps {
onClose: () => void;
}
export const DownloadList: React.FC<DownloadListProps> = ({ onClose }) => {
const { downloads } = useDownloadStore();
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="bg-gray-800 w-full max-w-md rounded-xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh]">
<div className="p-4 border-b border-gray-700 flex justify-between items-center">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Download size={20} /> Downloads
</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-700 rounded-full text-gray-400 hover:text-white transition">
<X size={20} />
</button>
</div>
<div className="overflow-y-auto flex-1 p-4 space-y-3">
{downloads.length === 0 && (
<div className="text-center text-gray-500 py-8">
No downloads yet.
</div>
)}
{downloads.map((item) => (
<div key={item.id} className="bg-gray-700/50 p-3 rounded-lg flex items-center gap-3">
<div className="bg-gray-600 p-2 rounded shrink-0">
{item.status === 'pending' && <Loader2 size={20} className="animate-spin text-blue-400" />}
{item.status === 'success' && <Check size={20} className="text-green-400" />}
{item.status === 'error' && <AlertCircle size={20} className="text-red-400" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{item.title || 'Unknown Video'}</p>
<p className="text-xs text-gray-400">{new Date(item.timestamp).toLocaleTimeString()}</p>
</div>
{item.status === 'success' && (
<a
href={`${import.meta.env.VITE_API_URL || 'http://localhost:8002/api'}/download/file/${item.id}`}
target="_blank"
className="text-xs bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded transition"
>
Open
</a>
)}
</div>
))}
</div>
</div>
</div>
);
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,48 @@
import React, { useState } from 'react';
import { Search } from 'lucide-react';
interface SearchBarProps {
onSearch: (query: string) => void;
}
export const SearchBar: React.FC<SearchBarProps> = ({ onSearch }) => {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
onSearch(query);
setIsOpen(false);
setQuery('');
}
};
return (
<div className={`relative flex items-center bg-white/10 backdrop-blur-2xl border border-white/10 rounded-full transition-all duration-500 shadow-2xl overflow-hidden group ${isOpen ? 'w-full max-w-md px-4' : 'w-12 h-12 justify-center'}`}>
{/* Search Icon / Toggle */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`text-white p-3 hover:text-pink-500 transition-colors duration-300 ${isOpen ? '' : 'w-full h-full flex items-center justify-center'}`}
>
<Search size={isOpen ? 18 : 20} className={isOpen ? 'opacity-60' : ''} />
</button>
{/* Input Field */}
<form onSubmit={handleSubmit} className={`flex-1 flex items-center transition-all duration-500 ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search TikTok Clean..."
className="w-full bg-transparent border-none outline-none text-white placeholder-white/40 ml-2 font-medium py-2"
autoFocus={isOpen}
/>
</form>
{isOpen && (
<div className="absolute inset-0 bg-gradient-to-r from-pink-500/10 to-violet-500/10 pointer-events-none" />
)}
</div>
);
};

View file

@ -0,0 +1,74 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: number;
message: string;
type: ToastType;
}
interface ToastContextType {
addToast: (message: string, type?: ToastType) => void;
removeToast: (id: number) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((message: string, type: ToastType = 'info') => {
const id = Date.now() + Math.random();
setToasts((prev) => [...prev, { id, message, type }]);
// Auto remove after 3 seconds
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 3000);
}, []);
const removeToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ addToast, removeToast }}>
{children}
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={`
flex items-center gap-2 px-4 py-3 rounded-lg shadow-lg text-white min-w-[300px]
transform transition-all duration-300 animate-in slide-in-from-right
${toast.type === 'success' ? 'bg-green-600' : ''}
${toast.type === 'error' ? 'bg-red-600' : ''}
${toast.type === 'info' ? 'bg-blue-600' : ''}
`}
>
{toast.type === 'success' && <CheckCircle size={20} />}
{toast.type === 'error' && <AlertCircle size={20} />}
{toast.type === 'info' && <Info size={20} />}
<p className="flex-1 text-sm font-medium">{toast.message}</p>
<button
onClick={() => removeToast(toast.id)}
className="p-1 hover:bg-white/20 rounded-full transition-colors"
>
<X size={16} />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
};

View file

@ -0,0 +1,444 @@
import React, { useRef, useState, useEffect } from 'react';
import { Download, UserPlus, Check, Volume2, VolumeX } from 'lucide-react';
import type { Video } from '../types';
import { API_BASE_URL } from '../config';
interface HeartParticle {
id: number;
x: number;
y: number;
}
interface VideoPlayerProps {
video: Video;
isActive: boolean;
isFollowing?: boolean;
onFollow?: (author: string) => void;
onAuthorClick?: (author: string) => void; // In-app navigation to creator
}
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
video,
isActive,
isFollowing = false,
onFollow,
onAuthorClick
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const [isPaused, setIsPaused] = useState(false);
const [showControls, setShowControls] = useState(false);
const [objectFit, setObjectFit] = useState<'cover' | 'contain'>('contain');
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
const [isSeeking, setIsSeeking] = useState(false);
const [useFallback, setUseFallback] = useState(false); // Fallback to full proxy
const [isMuted, setIsMuted] = useState(true); // Start muted for autoplay policy
const [hearts, setHearts] = useState<HeartParticle[]>([]);
const lastTapRef = useRef<number>(0);
// Full proxy URL (yt-dlp, always works but heavier)
const fullProxyUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}`;
// Thin proxy URL (direct CDN stream, lighter)
const thinProxyUrl = video.cdn_url ? `${API_BASE_URL}/feed/thin-proxy?cdn_url=${encodeURIComponent(video.cdn_url)}` : null;
// Use thin proxy first, fallback to full if needed (or if no cdn_url)
const proxyUrl = (thinProxyUrl && !useFallback) ? thinProxyUrl : fullProxyUrl;
const downloadUrl = `${API_BASE_URL}/feed/proxy?url=${encodeURIComponent(video.url)}&download=true`;
useEffect(() => {
if (isActive && videoRef.current) {
// Auto-play when becoming active
if (videoRef.current.paused) {
videoRef.current.currentTime = 0;
videoRef.current.muted = true; // Always start muted for autoplay policy
videoRef.current.play().catch((err) => {
// If autoplay fails even muted, show paused state
console.log('Autoplay blocked:', err.message);
setIsPaused(true);
});
setIsPaused(false);
}
} else if (!isActive && videoRef.current) {
videoRef.current.pause();
}
}, [isActive]); // Only trigger on isActive change
// Spacebar to pause/play when this video is active
useEffect(() => {
if (!isActive) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle spacebar when not typing
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
if (e.code === 'Space') {
e.preventDefault();
if (videoRef.current) {
if (videoRef.current.paused) {
videoRef.current.play();
setIsPaused(false);
} else {
videoRef.current.pause();
setIsPaused(true);
}
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isActive]);
// Reset fallback when video changes
useEffect(() => {
setUseFallback(false);
}, [video.id]);
// Progress tracking
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
setProgress(video.currentTime);
};
const handleLoadedMetadata = () => {
setDuration(video.duration);
};
// Fallback on error - if thin proxy fails, switch to full proxy
const handleError = () => {
if (thinProxyUrl && !useFallback) {
console.log('Thin proxy failed, falling back to full proxy...');
setUseFallback(true);
}
};
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('error', handleError);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('error', handleError);
};
}, [thinProxyUrl, useFallback]);
const togglePlayPause = () => {
if (!videoRef.current) return;
if (videoRef.current.paused) {
videoRef.current.play();
setIsPaused(false);
} else {
videoRef.current.pause();
setIsPaused(true);
}
};
const toggleObjectFit = () => {
setObjectFit(prev => prev === 'contain' ? 'cover' : 'contain');
};
const toggleMute = (e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation(); // Prevent video tap
if (!videoRef.current) return;
const newMuted = !videoRef.current.muted;
videoRef.current.muted = newMuted;
setIsMuted(newMuted);
};
// Handle tap - double tap shows heart, single tap toggles play
const tapTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Touch handler for multi-touch support
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
setShowControls(true); // Ensure controls show on touch
const now = Date.now();
const touches = Array.from(e.changedTouches);
// Use container rect for stable coordinates vs e.target
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const isMultiTouch = e.touches.length > 1; // Any simultaneous touches = hearts
// Check if this is a rapid tap sequence (potential hearts)
let isRapid = false;
touches.forEach((touch, index) => {
const timeSinceLastTap = now - lastTapRef.current;
// Show heart if:
// 1. Double tap (< 400ms)
// 2. OR Multi-touch (2+ fingers)
// 3. OR Secondary touch in this event
if (timeSinceLastTap < 400 || isMultiTouch || index > 0) {
isRapid = true;
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
// Add heart
const heartId = Date.now() + index + Math.random(); // Unique ID
setHearts(prev => [...prev, { id: heartId, x, y }]);
setTimeout(() => {
setHearts(prev => prev.filter(h => h.id !== heartId));
}, 1000);
}
});
if (isRapid) {
// It was a heart tap - prevent default click (toggle pause)
if (tapTimeoutRef.current) {
clearTimeout(tapTimeoutRef.current);
tapTimeoutRef.current = null;
}
}
lastTapRef.current = now;
};
// Click handler (Mouse / Single Touch fallback)
const handleVideoClick = (e: React.MouseEvent<HTMLDivElement>) => {
// If we recently parsed a rapid touch (heart), ignore this click
const now = Date.now();
if (now - lastTapRef.current < 100) return;
// Check for double-click (for hearts)
if (tapTimeoutRef.current) {
// Double click detected - show heart instead of toggle
clearTimeout(tapTimeoutRef.current);
tapTimeoutRef.current = null;
// Add heart at click position
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const heartId = Date.now() + Math.random();
setHearts(prev => [...prev, { id: heartId, x, y }]);
setTimeout(() => {
setHearts(prev => prev.filter(h => h.id !== heartId));
}, 1000);
}
} else {
// First click - set timeout for double-click detection
tapTimeoutRef.current = setTimeout(() => {
togglePlayPause();
tapTimeoutRef.current = null;
}, 250);
}
lastTapRef.current = now;
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
if (!videoRef.current || !duration || !progressBarRef.current) return;
const rect = progressBarRef.current.getBoundingClientRect();
let clientX: number;
if ('touches' in e) {
clientX = e.touches[0].clientX;
} else {
clientX = e.clientX;
}
const clickX = Math.max(0, Math.min(clientX - rect.left, rect.width));
const percent = clickX / rect.width;
videoRef.current.currentTime = percent * duration;
setProgress(percent * duration);
};
const handleSeekStart = (e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
setIsSeeking(true);
handleSeek(e);
};
const handleSeekEnd = () => {
setIsSeeking(false);
};
const formatTime = (time: number) => {
const mins = Math.floor(time / 60);
const secs = Math.floor(time % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<div
ref={containerRef}
className="relative w-full h-full bg-black flex items-center justify-center overflow-hidden"
onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)}
onClick={handleVideoClick}
onTouchStart={handleTouchStart}
>
{/* Video Element */}
<video
ref={videoRef}
src={proxyUrl}
loop
playsInline
preload="auto"
muted={isMuted}
className="w-full h-full"
style={{ objectFit }}
/>
{/* Heart Animation Particles */}
{hearts.map(heart => (
<div
key={heart.id}
className="absolute z-50 pointer-events-none animate-heart-float"
style={{
left: heart.x - 24,
top: heart.y - 24,
}}
>
<svg className="w-16 h-16 text-pink-500 drop-shadow-xl filter drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
</div>
))}
{/* Pause Icon Overlay */}
{isPaused && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 pointer-events-none">
<div className="w-20 h-20 flex items-center justify-center bg-white/20 backdrop-blur-sm rounded-full">
<svg className="w-10 h-10 text-white ml-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
)}
{/* Video Timeline/Progress Bar */}
<div className="absolute bottom-0 left-0 right-0 z-30">
<div
ref={progressBarRef}
className={`h-2 bg-white/20 cursor-pointer group ${isSeeking ? 'h-3' : ''}`}
onClick={handleSeek}
onMouseDown={handleSeekStart}
onMouseMove={(e) => isSeeking && handleSeek(e)}
onMouseUp={handleSeekEnd}
onMouseLeave={handleSeekEnd}
onTouchStart={handleSeekStart}
onTouchMove={(e) => isSeeking && handleSeek(e)}
onTouchEnd={handleSeekEnd}
>
<div
className="h-full bg-gradient-to-r from-cyan-400 to-pink-500 transition-all pointer-events-none"
style={{ width: duration ? `${(progress / duration) * 100}%` : '0%' }}
/>
{/* Scrubber Thumb (always visible when seeking or on hover) */}
<div
className={`absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white rounded-full shadow-lg transition-opacity pointer-events-none ${isSeeking ? 'opacity-100 scale-110' : 'opacity-0 group-hover:opacity-100'
}`}
style={{ left: duration ? `calc(${(progress / duration) * 100}% - 8px)` : '0' }}
/>
</div>
{/* Time Display */}
{showControls && duration > 0 && (
<div className="flex justify-between px-4 py-1 text-xs text-white/60">
<span>{formatTime(progress)}</span>
<span>{formatTime(duration)}</span>
</div>
)}
</div>
{/* Side Controls */}
<div
className={`absolute bottom-36 right-4 flex flex-col gap-3 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'
}`}
>
{/* Follow Button */}
{onFollow && (
<button
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
? 'bg-pink-500 text-white'
: 'bg-white/10 hover:bg-white/20 text-white'
}`}
title={isFollowing ? 'Following' : 'Follow'}
>
{isFollowing ? <Check size={20} /> : <UserPlus size={20} />}
</button>
)}
{/* Download Button */}
<a
href={downloadUrl}
download
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 transition-all"
title="Download"
>
<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}
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 transition-all"
title={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
</div>
{/* Author Info */}
<div className="absolute bottom-10 left-4 right-20 z-10">
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onAuthorClick?.(video.author);
}}
className="text-white font-semibold text-sm truncate hover:text-cyan-400 transition-colors inline-flex items-center gap-1"
>
@{video.author}
<svg className="w-3 h-3 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
</button>
{video.views && (
<span className="text-white/40 text-xs">
{video.views >= 1000000
? `${(video.views / 1000000).toFixed(1)}M views`
: video.views >= 1000
? `${(video.views / 1000).toFixed(0)}K views`
: `${video.views} views`
}
</span>
)}
</div>
{video.description && (
<p className="text-white/70 text-xs line-clamp-2 mt-1">
{video.description}
</p>
)}
</div>
{/* 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>
);
};

1
frontend/src/config.ts Normal file
View file

@ -0,0 +1 @@
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8002/api';

96
frontend/src/index.css Normal file
View file

@ -0,0 +1,96 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html,
body,
#root {
@apply h-full overflow-hidden;
}
}
@layer utilities {
.snap-always {
scroll-snap-stop: always;
}
.scrollbar-hide {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
/* Chrome, Safari and Opera */
}
}
@layer utilities {
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}
.animate-shake {
animation: shake 0.2s ease-in-out 0s 2;
}
@keyframes heart-float {
0% {
opacity: 1;
transform: scale(0) rotate(-15deg);
}
25% {
opacity: 1;
transform: scale(1.2) rotate(10deg);
}
50% {
opacity: 0.8;
transform: scale(1) translateY(-30px) rotate(-5deg);
}
100% {
opacity: 0;
transform: scale(0.6) translateY(-80px) rotate(15deg);
}
}
.animate-heart-float {
animation: heart-float 1s ease-out forwards;
}
body {
@apply bg-black antialiased;
color-scheme: dark;
}
.artplayer-app {
@apply rounded-none !important;
}

10
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View file

@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { useAuthStore } from '../store/authStore';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import { API_BASE_URL } from '../config';
export const Login: React.FC = () => {
const [cookies, setCookies] = useState('');
const [error, setError] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const [connectionStatus, setConnectionStatus] = useState('');
const login = useAuthStore((state) => state.login);
const navigate = useNavigate();
const handleBrowserLogin = async () => {
setError('');
setIsConnecting(true);
setConnectionStatus('Opening TikTok login...');
try {
const res = await axios.post(`${API_BASE_URL}/auth/browser-login`);
if (res.data.status === 'success') {
setConnectionStatus('Connected! Redirecting...');
setTimeout(() => navigate('/'), 1000);
} else if (res.data.status === 'timeout') {
setError(res.data.message);
setIsConnecting(false);
}
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to connect. Please try manual method.');
setIsConnecting(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await login(cookies);
navigate('/');
} catch (err) {
setError('Invalid format or server error. Ensure you paste the full JSON or Netscape text.');
}
};
return (
<div className="flex items-center justify-center min-h-screen bg-black text-white font-sans overflow-hidden relative">
{/* Ambient Background Safelight */}
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-pink-500/20 blur-[120px] rounded-full" />
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-violet-500/20 blur-[120px] rounded-full" />
<div className="w-full max-w-xl p-8 sm:p-12 bg-white/5 backdrop-blur-3xl rounded-[2.5rem] shadow-2xl border border-white/10 z-10 mx-4 relative overflow-hidden">
<div className="text-center mb-10 space-y-2">
<h2 className="text-4xl font-black tracking-tight bg-gradient-to-br from-white via-white to-white/40 bg-clip-text text-transparent">
TikTok Clean
</h2>
<p className="text-white/50 text-base font-medium">Your personalized feed, reimagined.</p>
</div>
{/* Primary: Browser Login Button */}
<div className="space-y-6 mb-8">
<button
onClick={handleBrowserLogin}
disabled={isConnecting}
className={`group relative w-full py-5 px-8 rounded-2xl transition-all font-black text-lg
${isConnecting
? 'bg-gray-800 text-gray-400 cursor-not-allowed'
: 'bg-gradient-to-r from-pink-500 via-red-500 to-orange-500 text-white shadow-[0_0_40px_rgba(236,72,153,0.3)] hover:shadow-[0_0_60px_rgba(236,72,153,0.5)] active:scale-[0.98]'
}`}
>
{isConnecting ? (
<span className="flex items-center justify-center gap-3">
<div className="w-5 h-5 border-2 border-gray-500 border-t-white rounded-full animate-spin" />
{connectionStatus}
</span>
) : (
<span className="flex items-center justify-center gap-2">
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z" />
</svg>
Connect with TikTok
</span>
)}
</button>
<p className="text-center text-white/30 text-sm">
A browser window will open for you to log in securely.
</p>
</div>
{/* Divider */}
<div className="flex items-center gap-4 mb-8">
<div className="flex-1 h-px bg-white/10" />
<span className="text-white/30 text-sm font-medium">or paste manually</span>
<div className="flex-1 h-px bg-white/10" />
</div>
{/* Secondary: Manual Paste */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<label className="text-xs font-bold text-white/50 uppercase tracking-widest">
Session Data
</label>
<span className="text-[10px] py-1 px-2 bg-white/10 rounded-md text-white/40 font-mono">JSON / NETSCAPE</span>
</div>
<textarea
className="w-full h-32 p-4 bg-black/40 rounded-xl border border-white/10 focus:border-pink-500/50 focus:ring-4 focus:ring-pink-500/10 focus:outline-none transition-all font-mono text-[11px] leading-relaxed placeholder:text-white/20"
value={cookies}
onChange={(e) => setCookies(e.target.value)}
placeholder='Paste captured JSON here...'
/>
</div>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3 animate-shake">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse" />
<p className="text-red-400 text-sm font-medium">{error}</p>
</div>
)}
<button
type="submit"
className="w-full py-4 px-6 bg-white/10 hover:bg-white/20 text-white rounded-xl transition-all font-bold text-base border border-white/10"
>
Initialize Feed
</button>
</form>
{/* Help Section */}
<div className="mt-8 pt-6 border-t border-white/5">
<p className="text-center text-white/20 text-xs">
Need help? Install <a href="https://github.com/botzvn/curl-websocket-capture" target="_blank" className="text-white/40 hover:text-white underline">curl-websocket-capture</a> for manual method.
</p>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,63 @@
import { create } from 'zustand';
import axios from 'axios';
import { API_BASE_URL } from '../config';
interface User {
id: string;
username: string;
avatar: string;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (cookies: string) => Promise<void>;
logout: () => void;
checkAuth: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
isLoading: true,
login: async (cookies: string) => {
set({ isLoading: true });
try {
const formData = new FormData();
formData.append('cookies', cookies);
await axios.post(`${API_BASE_URL}/auth/cookies`, formData);
// Mock user for now until scraper is ready
set({
user: { id: '1', username: 'TikTok User', avatar: '' },
isAuthenticated: true
});
} catch (error) {
console.error('Login failed:', error);
throw error;
} finally {
set({ isLoading: false });
}
},
logout: () => {
set({ user: null, isAuthenticated: false });
},
checkAuth: async () => {
set({ isLoading: true });
try {
const res = await axios.get(`${API_BASE_URL}/auth/status`);
if (res.data.authenticated) {
set({
user: { id: '1', username: 'TikTok User', avatar: '' },
isAuthenticated: true
});
} else {
set({ user: null, isAuthenticated: false });
}
} catch {
set({ user: null, isAuthenticated: false });
} finally {
set({ isLoading: false });
}
}
}));

View file

@ -0,0 +1,28 @@
import { create } from 'zustand';
export interface DownloadItem {
id: string;
title: string;
url: string; // Original URL
filePath?: string; // Resulting filename/path
status: 'pending' | 'success' | 'error';
timestamp: number;
}
interface DownloadState {
downloads: DownloadItem[];
addDownload: (item: DownloadItem) => void;
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
}
export const useDownloadStore = create<DownloadState>((set) => ({
downloads: [],
addDownload: (item) => set((state) => ({
downloads: [item, ...state.downloads]
})),
updateDownload: (id, updates) => set((state) => ({
downloads: state.downloads.map((d) =>
d.id === id ? { ...d, ...updates } : d
)
}))
}));

View file

@ -0,0 +1,27 @@
export interface Video {
id: string;
url: string;
author: string;
description?: string;
thumbnail?: string; // TikTok video cover image
cdn_url?: string; // Direct CDN URL for thin proxy (lower server load)
views?: number; // View count
likes?: number; // Like count
}
export interface User {
id: string;
username: string;
avatar: string;
}
export interface UserProfile {
username: string;
nickname?: string;
avatar?: string;
bio?: string;
followers?: number;
following?: number;
likes?: number;
verified?: boolean;
}

View file

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})