mirror of
https://github.com/vndangkhoa/purestream.git
synced 2026-04-05 01:17:58 +07:00
Initial commit: PureStream - Distraction-free TikTok viewer
This commit is contained in:
commit
a5ffc8d4aa
51 changed files with 9014 additions and 0 deletions
15
.dockerignore
Normal file
15
.dockerignore
Normal 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
49
.gitignore
vendored
Normal 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
71
Dockerfile
Normal 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
186
README.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
# 🎵 PureStream
|
||||
|
||||
**Distraction-free TikTok viewing** - A clean, ad-free TikTok client with a beautiful minimal interface.
|
||||
|
||||
  
|
||||
|
||||
## ✨ 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
0
backend/api/__init__.py
Normal file
0
backend/api/routes/__init__.py
Normal file
0
backend/api/routes/__init__.py
Normal file
90
backend/api/routes/auth.py
Normal file
90
backend/api/routes/auth.py
Normal 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"}
|
||||
89
backend/api/routes/config.py
Normal file
89
backend/api/routes/config.py
Normal 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
|
||||
40
backend/api/routes/download.py
Normal file
40
backend/api/routes/download.py
Normal 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
369
backend/api/routes/feed.py
Normal 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))
|
||||
65
backend/api/routes/following.py
Normal file
65
backend/api/routes/following.py
Normal 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
157
backend/api/routes/user.py
Normal 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
0
backend/core/__init__.py
Normal file
38
backend/core/download_service.py
Normal file
38
backend/core/download_service.py
Normal 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()
|
||||
302
backend/core/feed_service.py
Normal file
302
backend/core/feed_service.py
Normal 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()
|
||||
509
backend/core/playwright_manager.py
Normal file
509
backend/core/playwright_manager.py
Normal 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
1
backend/following.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
58
backend/main.py
Normal file
58
backend/main.py
Normal 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
8
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fastapi
|
||||
uvicorn
|
||||
yt-dlp
|
||||
requests
|
||||
python-multipart
|
||||
websockets
|
||||
python-dotenv
|
||||
crawl4ai
|
||||
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal 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
24
frontend/.gitignore
vendored
Normal 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
73
frontend/README.md
Normal 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
23
frontend/eslint.config.js
Normal 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
48
frontend/index.html
Normal 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
4412
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
frontend/public/icon-192.png
Normal file
BIN
frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8 KiB |
BIN
frontend/public/icon-512.png
Normal file
BIN
frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 376 KiB |
35
frontend/public/manifest.json
Normal file
35
frontend/public/manifest.json
Normal 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
1
frontend/public/vite.svg
Normal 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
42
frontend/src/App.css
Normal 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
58
frontend/src/App.tsx
Normal 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;
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
57
frontend/src/components/DownloadList.tsx
Normal file
57
frontend/src/components/DownloadList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1100
frontend/src/components/Feed.tsx
Normal file
1100
frontend/src/components/Feed.tsx
Normal file
File diff suppressed because it is too large
Load diff
48
frontend/src/components/SearchBar.tsx
Normal file
48
frontend/src/components/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
74
frontend/src/components/Toast.tsx
Normal file
74
frontend/src/components/Toast.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
444
frontend/src/components/VideoPlayer.tsx
Normal file
444
frontend/src/components/VideoPlayer.tsx
Normal 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
1
frontend/src/config.ts
Normal 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
96
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
141
frontend/src/pages/Login.tsx
Normal file
141
frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
63
frontend/src/store/authStore.ts
Normal file
63
frontend/src/store/authStore.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}));
|
||||
28
frontend/src/store/downloadStore.ts
Normal file
28
frontend/src/store/downloadStore.ts
Normal 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
|
||||
)
|
||||
}))
|
||||
}));
|
||||
27
frontend/src/types/index.ts
Normal file
27
frontend/src/types/index.ts
Normal 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;
|
||||
}
|
||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
7
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Loading…
Reference in a new issue