Initial commit: XDL Twitter/X video downloader

This commit is contained in:
mini
2026-02-18 17:15:12 +08:00
commit 7fdd181728
32 changed files with 1230 additions and 0 deletions

24
.drone.yml Normal file
View File

@@ -0,0 +1,24 @@
kind: pipeline
type: ssh
name: deploy
server:
host: 217.216.32.230
user: root
ssh_key:
from_secret: ssh_key
trigger:
branch:
- main
event:
- push
steps:
- name: deploy
commands:
- cd /home/xdl/xdl
- git pull origin main
- docker compose build --no-cache
- docker compose up -d
- echo "✅ XDL deployed successfully"

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
__pycache__/
*.pyc
.env
*.db
.vite/

25
README.md Normal file
View File

@@ -0,0 +1,25 @@
# XDL - Twitter/X Video Downloader
Download videos from Twitter/X with quality selection.
## Features
- Parse Twitter/X video links
- Select video quality
- Download videos
- Admin panel with video library management
- Video playback (authenticated users)
## Deploy
```bash
docker compose up -d --build
```
## Default Admin
- Username: `admin`
- Password: see `.env` file
## API
- `POST /api/parse` — Parse video URL
- `POST /api/download` — Start download
- `GET /api/download/{task_id}` — Check progress
- `GET /api/admin/videos` — List videos (auth required)

5
backend/.env.example Normal file
View File

@@ -0,0 +1,5 @@
SECRET_KEY=change-me-to-random-string
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me
VIDEO_BASE_PATH=/home/xdl/xdl_videos
DATABASE_URL=sqlite+aiosqlite:///./xdl.db

14
backend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && pip install --no-cache-dir -U yt-dlp
COPY app/ ./app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/app/__init__.py Normal file
View File

42
backend/app/auth.py Normal file
View File

@@ -0,0 +1,42 @@
"""JWT authentication utilities."""
import os
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 72
security = HTTPBearer()
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str) -> dict:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
return verify_token(credentials.credentials)
def optional_auth(credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False))) -> Optional[dict]:
"""Optional authentication - returns None if no valid token."""
if credentials is None:
return None
try:
return verify_token(credentials.credentials)
except HTTPException:
return None

23
backend/app/database.py Normal file
View File

@@ -0,0 +1,23 @@
"""Database configuration and session management."""
import os
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./xdl.db")
engine = create_async_engine(DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
yield session
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

38
backend/app/main.py Normal file
View File

@@ -0,0 +1,38 @@
"""XDL - Twitter/X Video Downloader API."""
import os
from contextlib import asynccontextmanager
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import init_db
from app.routes import auth, parse, download, admin
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(title="XDL - Video Downloader", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(parse.router)
app.include_router(download.router)
app.include_router(admin.router)
@app.get("/api/health")
async def health():
return {"status": "ok"}

26
backend/app/models.py Normal file
View File

@@ -0,0 +1,26 @@
"""SQLAlchemy models."""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, BigInteger, Text
from app.database import Base
class Video(Base):
__tablename__ = "videos"
id = Column(Integer, primary_key=True, autoincrement=True)
task_id = Column(String(64), unique=True, index=True, nullable=False)
url = Column(String(512), nullable=False)
title = Column(String(512), default="")
platform = Column(String(32), default="twitter")
thumbnail = Column(String(1024), default="")
quality = Column(String(32), default="")
format_id = Column(String(64), default="")
filename = Column(String(512), default="")
file_path = Column(String(1024), default="")
file_size = Column(BigInteger, default=0)
duration = Column(Integer, default=0)
status = Column(String(16), default="pending") # pending, downloading, done, error
error_message = Column(Text, default="")
progress = Column(Integer, default=0) # 0-100
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

View File

@@ -0,0 +1,70 @@
"""Admin management routes."""
import os
from fastapi import APIRouter, HTTPException, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from app.database import get_db
from app.models import Video
from app.schemas import VideoInfo, VideoListResponse, StorageStats
from app.auth import get_current_user
router = APIRouter(prefix="/api/admin", tags=["admin"])
def human_size(size_bytes: int) -> str:
for unit in ["B", "KB", "MB", "GB", "TB"]:
if size_bytes < 1024:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024
return f"{size_bytes:.1f} PB"
@router.get("/videos", response_model=VideoListResponse)
async def list_videos(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: str = Query("", max_length=200),
status: str = Query("", max_length=20),
user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(Video).order_by(Video.created_at.desc())
count_query = select(func.count(Video.id))
if search:
flt = or_(Video.title.contains(search), Video.url.contains(search))
query = query.where(flt)
count_query = count_query.where(flt)
if status:
query = query.where(Video.status == status)
count_query = count_query.where(Video.status == status)
total = (await db.execute(count_query)).scalar() or 0
videos = (await db.execute(query.offset((page - 1) * page_size).limit(page_size))).scalars().all()
return VideoListResponse(
videos=[VideoInfo.model_validate(v) for v in videos],
total=total,
page=page,
page_size=page_size,
)
@router.delete("/videos/{video_id}")
async def delete_video(video_id: int, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
video = (await db.execute(select(Video).where(Video.id == video_id))).scalar_one_or_none()
if not video:
raise HTTPException(status_code=404, detail="Video not found")
# Delete file from disk
if video.file_path and os.path.exists(video.file_path):
os.remove(video.file_path)
await db.delete(video)
await db.commit()
return {"ok": True}
@router.get("/stats", response_model=StorageStats)
async def storage_stats(user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
total = (await db.execute(select(func.count(Video.id)).where(Video.status == "done"))).scalar() or 0
total_size = (await db.execute(select(func.sum(Video.file_size)).where(Video.status == "done"))).scalar() or 0
return StorageStats(total_videos=total, total_size=total_size, total_size_human=human_size(total_size))

View File

@@ -0,0 +1,18 @@
"""Authentication routes."""
import os
from fastapi import APIRouter, HTTPException, status
from app.schemas import LoginRequest, TokenResponse
from app.auth import create_access_token
router = APIRouter(prefix="/api/auth", tags=["auth"])
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin")
@router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest):
if req.username != ADMIN_USERNAME or req.password != ADMIN_PASSWORD:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_access_token({"sub": req.username})
return TokenResponse(access_token=token)

View File

@@ -0,0 +1,120 @@
"""Download task routes."""
import uuid
import os
import logging
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.schemas import DownloadRequest, DownloadResponse, TaskStatus
from app.database import get_db
from app.models import Video
from app.auth import get_current_user, optional_auth
from app.services.downloader import download_video, get_video_path
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["download"])
async def _do_download(task_id: str, url: str, format_id: str):
"""Background download task."""
from app.database import async_session
async with async_session() as db:
video = (await db.execute(select(Video).where(Video.task_id == task_id))).scalar_one_or_none()
if not video:
return
try:
video.status = "downloading"
await db.commit()
def update_progress(pct):
pass # Progress tracking in sync context is complex; keep simple
result = download_video(url, format_id, progress_callback=update_progress)
video.title = result["title"]
video.thumbnail = result["thumbnail"]
video.duration = result["duration"]
video.filename = result["filename"]
video.file_path = result["file_path"]
video.file_size = result["file_size"]
video.platform = result["platform"]
video.status = "done"
video.progress = 100
await db.commit()
except Exception as e:
logger.error(f"Download failed for {task_id}: {e}")
video.status = "error"
video.error_message = str(e)[:500]
await db.commit()
@router.post("/download", response_model=DownloadResponse)
async def start_download(req: DownloadRequest, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db)):
task_id = str(uuid.uuid4())[:8]
video = Video(task_id=task_id, url=req.url, quality=req.quality, format_id=req.format_id, status="pending")
db.add(video)
await db.commit()
background_tasks.add_task(_do_download, task_id, req.url, req.format_id)
return DownloadResponse(task_id=task_id, status="pending")
@router.get("/download/{task_id}", response_model=TaskStatus)
async def get_download_status(task_id: str, db: AsyncSession = Depends(get_db)):
video = (await db.execute(select(Video).where(Video.task_id == task_id))).scalar_one_or_none()
if not video:
raise HTTPException(status_code=404, detail="Task not found")
return TaskStatus(
task_id=video.task_id,
status=video.status,
progress=video.progress,
title=video.title,
error_message=video.error_message or "",
video_id=video.id if video.status == "done" else None,
)
@router.get("/file/{video_id}")
async def download_file(video_id: int, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
video = (await db.execute(select(Video).where(Video.id == video_id))).scalar_one_or_none()
if not video or video.status != "done":
raise HTTPException(status_code=404, detail="Video not found")
if not os.path.exists(video.file_path):
raise HTTPException(status_code=404, detail="File not found on disk")
return FileResponse(video.file_path, filename=video.filename, media_type="video/mp4")
@router.get("/stream/{video_id}")
async def stream_video(video_id: int, token: str = None, user: dict = Depends(optional_auth), db: AsyncSession = Depends(get_db)):
# Allow token via query param for video player
if not user and token:
from app.auth import verify_token
user = verify_token(token)
if not user:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
video = (await db.execute(select(Video).where(Video.id == video_id))).scalar_one_or_none()
if not video or video.status != "done":
raise HTTPException(status_code=404, detail="Video not found")
if not os.path.exists(video.file_path):
raise HTTPException(status_code=404, detail="File not found on disk")
def iter_file():
with open(video.file_path, "rb") as f:
while chunk := f.read(1024 * 1024):
yield chunk
return StreamingResponse(iter_file(), media_type="video/mp4", headers={
"Content-Disposition": f"inline; filename={video.filename}",
"Content-Length": str(video.file_size),
})
@router.get("/file/task/{task_id}")
async def download_file_by_task(task_id: str, db: AsyncSession = Depends(get_db)):
"""Download file by task_id - no auth required (public download)."""
video = (await db.execute(select(Video).where(Video.task_id == task_id))).scalar_one_or_none()
if not video or video.status != "done":
raise HTTPException(status_code=404, detail="Video not found")
if not os.path.exists(video.file_path):
raise HTTPException(status_code=404, detail="File not found on disk")
return FileResponse(video.file_path, filename=video.filename, media_type="video/mp4")

View File

@@ -0,0 +1,21 @@
"""Video URL parsing routes."""
from fastapi import APIRouter, HTTPException
from app.schemas import ParseRequest, ParseResponse, FormatInfo
from app.services.downloader import parse_video_url
router = APIRouter(prefix="/api", tags=["parse"])
@router.post("/parse", response_model=ParseResponse)
async def parse_url(req: ParseRequest):
try:
info = parse_video_url(req.url)
return ParseResponse(
title=info["title"],
thumbnail=info["thumbnail"],
duration=info["duration"],
formats=[FormatInfo(**f) for f in info["formats"]],
url=info["url"],
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to parse URL: {str(e)}")

85
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,85 @@
"""Pydantic schemas for request/response validation."""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class ParseRequest(BaseModel):
url: str
class FormatInfo(BaseModel):
format_id: str
quality: str
ext: str
filesize: Optional[int] = None
note: str = ""
class ParseResponse(BaseModel):
title: str
thumbnail: str
duration: int
formats: list[FormatInfo]
url: str
class DownloadRequest(BaseModel):
url: str
format_id: str = "best"
quality: str = ""
class DownloadResponse(BaseModel):
task_id: str
status: str
class TaskStatus(BaseModel):
task_id: str
status: str
progress: int
title: str = ""
error_message: str = ""
video_id: Optional[int] = None
class VideoInfo(BaseModel):
id: int
task_id: str
url: str
title: str
platform: str
thumbnail: str
quality: str
filename: str
file_size: int
duration: int
status: str
created_at: datetime
class Config:
from_attributes = True
class VideoListResponse(BaseModel):
videos: list[VideoInfo]
total: int
page: int
page_size: int
class StorageStats(BaseModel):
total_videos: int
total_size: int
total_size_human: str
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"

View File

View File

@@ -0,0 +1,120 @@
"""yt-dlp wrapper service for video downloading."""
import os
import uuid
import asyncio
import logging
from pathlib import Path
from typing import Optional
import yt_dlp
logger = logging.getLogger(__name__)
VIDEO_BASE_PATH = os.getenv("VIDEO_BASE_PATH", "/home/xdl/xdl_videos")
X_VIDEOS_PATH = os.path.join(VIDEO_BASE_PATH, "x_videos")
# Ensure directories exist
os.makedirs(X_VIDEOS_PATH, exist_ok=True)
def get_video_path(filename: str) -> str:
return os.path.join(X_VIDEOS_PATH, filename)
def parse_video_url(url: str) -> dict:
"""Extract video info without downloading."""
ydl_opts = {
"quiet": True,
"no_warnings": True,
"extract_flat": False,
"skip_download": True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
formats = []
seen = set()
for f in info.get("formats", []):
# Only video formats with both video and audio, or video-only
if f.get("vcodec", "none") == "none":
continue
height = f.get("height", 0)
ext = f.get("ext", "mp4")
fmt_id = f.get("format_id", "")
quality = f"{height}p" if height else f.get("format_note", "unknown")
key = f"{quality}-{ext}"
if key in seen:
continue
seen.add(key)
formats.append({
"format_id": fmt_id,
"quality": quality,
"ext": ext,
"filesize": f.get("filesize") or f.get("filesize_approx") or 0,
"note": f.get("format_note", ""),
})
# Sort by resolution descending
formats.sort(key=lambda x: int(x["quality"].replace("p", "")) if x["quality"].endswith("p") else 0, reverse=True)
# Add a "best" option
formats.insert(0, {
"format_id": "best",
"quality": "best",
"ext": "mp4",
"filesize": 0,
"note": "Best available quality",
})
return {
"title": info.get("title", "Untitled"),
"thumbnail": info.get("thumbnail", ""),
"duration": info.get("duration", 0) or 0,
"formats": formats,
"url": url,
}
def download_video(url: str, format_id: str = "best", progress_callback=None) -> dict:
"""Download video and return file info."""
task_id = str(uuid.uuid4())[:8]
output_template = os.path.join(X_VIDEOS_PATH, f"%(id)s_{task_id}.%(ext)s")
format_spec = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" if format_id == "best" else f"{format_id}+bestaudio/best"
def hook(d):
if d["status"] == "downloading" and progress_callback:
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
downloaded = d.get("downloaded_bytes", 0)
pct = int(downloaded * 100 / total) if total > 0 else 0
progress_callback(pct)
elif d["status"] == "finished" and progress_callback:
progress_callback(100)
ydl_opts = {
"format": format_spec,
"outtmpl": output_template,
"merge_output_format": "mp4",
"quiet": True,
"no_warnings": True,
"progress_hooks": [hook],
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
filename = ydl.prepare_filename(info)
# yt-dlp may change extension after merge
if not os.path.exists(filename):
base = os.path.splitext(filename)[0]
filename = base + ".mp4"
file_size = os.path.getsize(filename) if os.path.exists(filename) else 0
return {
"title": info.get("title", "Untitled"),
"thumbnail": info.get("thumbnail", ""),
"duration": info.get("duration", 0) or 0,
"filename": os.path.basename(filename),
"file_path": filename,
"file_size": file_size,
"platform": "twitter",
}

10
backend/requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy==2.0.35
aiosqlite==0.20.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-dotenv==1.0.1
python-multipart==0.0.12
yt-dlp>=2024.1.0
pydantic>=2.0.0

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
version: "3.8"
services:
backend:
build: ./backend
restart: unless-stopped
env_file: .env
volumes:
- /home/xdl/xdl_videos:/home/xdl/xdl_videos
- ./backend/xdl.db:/app/xdl.db
environment:
- DATABASE_URL=sqlite+aiosqlite:///./xdl.db
frontend:
build: ./frontend
restart: unless-stopped
ports:
- "127.0.0.1:8580:80"
depends_on:
- backend

11
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XDL - Twitter Video Downloader</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f0f0f; color: #e0e0e0; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

17
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,17 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_read_timeout 300;
}
}

21
frontend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "xdl-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.0",
"axios": "^1.7.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.4.0"
}
}

34
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,34 @@
<template>
<div class="app">
<nav class="navbar">
<router-link to="/" class="logo">📥 XDL</router-link>
<div class="nav-links">
<router-link to="/">Home</router-link>
<router-link v-if="auth.isLoggedIn" to="/admin">Admin</router-link>
<a v-if="auth.isLoggedIn" href="#" @click.prevent="auth.logout()">Logout</a>
<router-link v-else to="/login">Login</router-link>
</div>
</nav>
<main class="container">
<router-view />
</main>
</div>
</template>
<script setup>
import { useAuthStore } from './stores/auth.js'
const auth = useAuthStore()
</script>
<style>
.app { min-height: 100vh; }
.navbar {
display: flex; justify-content: space-between; align-items: center;
padding: 1rem 2rem; background: #1a1a2e; border-bottom: 1px solid #333;
}
.logo { font-size: 1.5rem; font-weight: bold; color: #1da1f2; text-decoration: none; }
.nav-links { display: flex; gap: 1.5rem; }
.nav-links a { color: #aaa; text-decoration: none; transition: color 0.2s; }
.nav-links a:hover, .nav-links a.router-link-active { color: #1da1f2; }
.container { max-width: 800px; margin: 0 auto; padding: 2rem 1rem; }
</style>

9
frontend/src/main.js Normal file
View File

@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router/index.js'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,22 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import Login from '../views/Login.vue'
import Admin from '../views/Admin.vue'
const routes = [
{ path: '/', component: Home },
{ path: '/login', component: Login },
{ path: '/admin', component: Admin, meta: { requiresAuth: true } },
]
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !localStorage.getItem('token')) {
next('/login')
} else {
next()
}
})
export default router

View File

@@ -0,0 +1,25 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from 'axios'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '')
const isLoggedIn = computed(() => !!token.value)
async function login(username, password) {
const res = await axios.post('/api/auth/login', { username, password })
token.value = res.data.access_token
localStorage.setItem('token', token.value)
}
function logout() {
token.value = ''
localStorage.removeItem('token')
}
function getHeaders() {
return token.value ? { Authorization: `Bearer ${token.value}` } : {}
}
return { token, isLoggedIn, login, logout, getHeaders }
})

View File

@@ -0,0 +1,174 @@
<template>
<div class="admin">
<div class="header">
<h2>Video Library</h2>
<div v-if="stats" class="stats">
📊 {{ stats.total_videos }} videos · {{ stats.total_size_human }}
</div>
</div>
<div class="search-bar">
<input v-model="search" placeholder="Search videos..." @input="debouncedFetch" />
<select v-model="filterStatus" @change="fetchVideos">
<option value="">All Status</option>
<option value="done">Done</option>
<option value="downloading">Downloading</option>
<option value="error">Error</option>
<option value="pending">Pending</option>
</select>
</div>
<div class="video-list">
<div v-for="v in videos" :key="v.id" class="video-card">
<div class="video-main">
<img v-if="v.thumbnail" :src="v.thumbnail" class="thumb" />
<div class="info">
<h4>{{ v.title || 'Untitled' }}</h4>
<div class="meta">
<span :class="'status-' + v.status">{{ v.status }}</span>
<span>{{ v.quality }}</span>
<span>{{ humanSize(v.file_size) }}</span>
<span>{{ new Date(v.created_at).toLocaleString() }}</span>
</div>
</div>
</div>
<div class="actions">
<button v-if="v.status === 'done'" @click="playVideo(v)" class="btn-play"> Play</button>
<a v-if="v.status === 'done'" :href="'/api/file/' + v.id" class="btn-dl"
:download="v.filename" @click.prevent="downloadAuth(v)">💾</a>
<button @click="deleteVideo(v)" class="btn-del">🗑</button>
</div>
</div>
<div v-if="!videos.length" class="empty">No videos found</div>
</div>
<div v-if="totalPages > 1" class="pagination">
<button :disabled="page <= 1" @click="page--; fetchVideos()">Prev</button>
<span>{{ page }} / {{ totalPages }}</span>
<button :disabled="page >= totalPages" @click="page++; fetchVideos()">Next</button>
</div>
<!-- Video Player Modal -->
<div v-if="playing" class="modal" @click.self="playing = null">
<div class="player-wrap">
<video :src="playUrl" controls autoplay class="player"></video>
<button @click="playing = null" class="close-btn"></button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import axios from 'axios'
import { useAuthStore } from '../stores/auth.js'
const auth = useAuthStore()
const videos = ref([])
const stats = ref(null)
const search = ref('')
const filterStatus = ref('')
const page = ref(1)
const total = ref(0)
const pageSize = 20
const playing = ref(null)
const playUrl = ref('')
const totalPages = computed(() => Math.ceil(total.value / pageSize) || 1)
function humanSize(bytes) {
if (!bytes) return '0 B'
for (const u of ['B', 'KB', 'MB', 'GB']) {
if (bytes < 1024) return `${bytes.toFixed(1)} ${u}`
bytes /= 1024
}
return `${bytes.toFixed(1)} TB`
}
let debounceTimer
function debouncedFetch() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => { page.value = 1; fetchVideos() }, 300)
}
async function fetchVideos() {
try {
const res = await axios.get('/api/admin/videos', {
params: { page: page.value, page_size: pageSize, search: search.value, status: filterStatus.value },
headers: auth.getHeaders()
})
videos.value = res.data.videos
total.value = res.data.total
} catch (e) {
if (e.response?.status === 401) { auth.logout(); location.href = '/login' }
}
}
async function fetchStats() {
try {
const res = await axios.get('/api/admin/stats', { headers: auth.getHeaders() })
stats.value = res.data
} catch {}
}
function playVideo(v) {
playing.value = v
playUrl.value = `/api/stream/${v.id}?token=${auth.token}`
}
async function downloadAuth(v) {
const res = await axios.get(`/api/file/${v.id}`, { headers: auth.getHeaders(), responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a'); a.href = url; a.download = v.filename; a.click()
URL.revokeObjectURL(url)
}
async function deleteVideo(v) {
if (!confirm(`Delete "${v.title}"?`)) return
try {
await axios.delete(`/api/admin/videos/${v.id}`, { headers: auth.getHeaders() })
fetchVideos(); fetchStats()
} catch (e) {
alert('Delete failed')
}
}
onMounted(() => { fetchVideos(); fetchStats() })
</script>
<style scoped>
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.stats { color: #888; }
.search-bar { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
.search-bar input { flex: 1; padding: 0.6rem 1rem; border: 1px solid #444; border-radius: 8px; background: #1a1a2e; color: #fff; }
.search-bar select { padding: 0.6rem; border: 1px solid #444; border-radius: 8px; background: #1a1a2e; color: #fff; }
.video-card {
display: flex; justify-content: space-between; align-items: center;
background: #1a1a2e; border-radius: 10px; padding: 1rem; margin-bottom: 0.8rem;
gap: 1rem;
}
.video-main { display: flex; gap: 1rem; flex: 1; min-width: 0; }
.thumb { width: 80px; height: 45px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.info { min-width: 0; }
.info h4 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 0.3rem; }
.meta { display: flex; gap: 0.8rem; font-size: 0.85rem; color: #888; flex-wrap: wrap; }
.status-done { color: #27ae60; }
.status-downloading { color: #f39c12; }
.status-error { color: #e74c3c; }
.status-pending { color: #888; }
.actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
.actions button, .actions a {
padding: 0.4rem 0.6rem; border: 1px solid #444; border-radius: 6px;
background: transparent; color: #fff; cursor: pointer; text-decoration: none; font-size: 0.9rem;
}
.actions button:hover, .actions a:hover { border-color: #1da1f2; }
.btn-del:hover { border-color: #e74c3c !important; }
.empty { text-align: center; color: #666; padding: 3rem; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 1rem; margin-top: 1.5rem; }
.pagination button { padding: 0.5rem 1rem; border: 1px solid #444; border-radius: 6px; background: transparent; color: #fff; cursor: pointer; }
.pagination button:disabled { opacity: 0.3; cursor: not-allowed; }
.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.9); display: flex; align-items: center; justify-content: center; z-index: 999; }
.player-wrap { position: relative; max-width: 90vw; max-height: 90vh; }
.player { max-width: 90vw; max-height: 85vh; border-radius: 8px; }
.close-btn { position: absolute; top: -40px; right: 0; background: none; border: none; color: #fff; font-size: 1.5rem; cursor: pointer; }
</style>

170
frontend/src/views/Home.vue Normal file
View File

@@ -0,0 +1,170 @@
<template>
<div class="home">
<h1>Twitter/X Video Downloader</h1>
<p class="subtitle">Paste a Twitter/X video link to download</p>
<div class="input-group">
<input v-model="url" placeholder="https://x.com/user/status/123..." @keyup.enter="parseUrl" :disabled="loading" />
<button @click="parseUrl" :disabled="loading || !url.trim()">
{{ loading ? '⏳ Parsing...' : '🔍 Parse' }}
</button>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="videoInfo" class="video-info">
<img v-if="videoInfo.thumbnail" :src="videoInfo.thumbnail" class="thumbnail" />
<h3>{{ videoInfo.title }}</h3>
<p class="duration">Duration: {{ formatDuration(videoInfo.duration) }}</p>
<div class="formats">
<h4>Select Quality:</h4>
<div v-for="fmt in videoInfo.formats" :key="fmt.format_id" class="format-item"
:class="{ selected: selectedFormat === fmt.format_id }" @click="selectedFormat = fmt.format_id">
<span class="quality">{{ fmt.quality }}</span>
<span class="ext">{{ fmt.ext }}</span>
<span v-if="fmt.filesize" class="size">~{{ humanSize(fmt.filesize) }}</span>
</div>
</div>
<button class="download-btn" @click="startDownload" :disabled="downloading">
{{ downloading ? '⏳ Downloading...' : '📥 Download' }}
</button>
</div>
<div v-if="taskId" class="task-status">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
</div>
<p>{{ statusText }}</p>
<a v-if="downloadReady" :href="downloadUrl" class="download-link" download>💾 Save to device</a>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
const url = ref('')
const loading = ref(false)
const error = ref('')
const videoInfo = ref(null)
const selectedFormat = ref('best')
const downloading = ref(false)
const taskId = ref('')
const progress = ref(0)
const statusText = ref('')
const downloadReady = ref(false)
const downloadUrl = ref('')
function formatDuration(s) {
if (!s) return 'N/A'
const m = Math.floor(s / 60), sec = s % 60
return `${m}:${String(sec).padStart(2, '0')}`
}
function humanSize(bytes) {
for (const u of ['B', 'KB', 'MB', 'GB']) {
if (bytes < 1024) return `${bytes.toFixed(1)} ${u}`
bytes /= 1024
}
return `${bytes.toFixed(1)} TB`
}
async function parseUrl() {
if (!url.value.trim()) return
loading.value = true; error.value = ''; videoInfo.value = null; taskId.value = ''
downloadReady.value = false
try {
const res = await axios.post('/api/parse', { url: url.value })
videoInfo.value = res.data
selectedFormat.value = 'best'
} catch (e) {
error.value = e.response?.data?.detail || 'Failed to parse URL'
} finally {
loading.value = false
}
}
async function startDownload() {
downloading.value = true; error.value = ''
try {
const res = await axios.post('/api/download', {
url: url.value, format_id: selectedFormat.value, quality: selectedFormat.value
})
taskId.value = res.data.task_id
statusText.value = 'Starting download...'
pollStatus()
} catch (e) {
error.value = e.response?.data?.detail || 'Failed to start download'
downloading.value = false
}
}
async function pollStatus() {
if (!taskId.value) return
try {
const res = await axios.get(`/api/download/${taskId.value}`)
const d = res.data
progress.value = d.progress
if (d.status === 'done') {
statusText.value = `✅ Done: ${d.title}`
downloading.value = false
downloadReady.value = true
downloadUrl.value = `/api/file/task/${taskId.value}`
} else if (d.status === 'error') {
statusText.value = `❌ Error: ${d.error_message}`
downloading.value = false
} else {
statusText.value = `${d.status}... ${d.progress}%`
setTimeout(pollStatus, 2000)
}
} catch {
setTimeout(pollStatus, 3000)
}
}
</script>
<style scoped>
.home { text-align: center; }
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
.subtitle { color: #888; margin-bottom: 2rem; }
.input-group { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
.input-group input {
flex: 1; padding: 0.8rem 1rem; border: 1px solid #444; border-radius: 8px;
background: #1a1a2e; color: #fff; font-size: 1rem;
}
.input-group button, .download-btn {
padding: 0.8rem 1.5rem; border: none; border-radius: 8px; cursor: pointer;
background: #1da1f2; color: #fff; font-size: 1rem; font-weight: 600;
transition: background 0.2s;
}
.input-group button:hover, .download-btn:hover { background: #1991db; }
.input-group button:disabled, .download-btn:disabled { background: #555; cursor: not-allowed; }
.error { color: #ff6b6b; margin: 1rem 0; padding: 0.8rem; background: #2a1515; border-radius: 8px; }
.video-info { text-align: left; background: #1a1a2e; border-radius: 12px; padding: 1.5rem; margin-top: 1.5rem; }
.thumbnail { width: 100%; max-height: 300px; object-fit: cover; border-radius: 8px; margin-bottom: 1rem; }
.video-info h3 { margin-bottom: 0.5rem; }
.duration { color: #888; margin-bottom: 1rem; }
.formats { margin: 1rem 0; }
.formats h4 { margin-bottom: 0.5rem; }
.format-item {
display: flex; gap: 1rem; align-items: center; padding: 0.6rem 1rem;
border: 1px solid #333; border-radius: 8px; margin-bottom: 0.4rem; cursor: pointer;
transition: all 0.2s;
}
.format-item:hover { border-color: #1da1f2; }
.format-item.selected { border-color: #1da1f2; background: rgba(29, 161, 242, 0.1); }
.quality { font-weight: 600; min-width: 60px; }
.ext { color: #888; }
.size { color: #888; margin-left: auto; }
.download-btn { width: 100%; margin-top: 1rem; padding: 1rem; font-size: 1.1rem; }
.task-status { margin-top: 1.5rem; }
.progress-bar { height: 8px; background: #333; border-radius: 4px; overflow: hidden; margin-bottom: 0.5rem; }
.progress-fill { height: 100%; background: #1da1f2; transition: width 0.3s; }
.download-link {
display: inline-block; margin-top: 0.5rem; padding: 0.8rem 2rem;
background: #27ae60; color: #fff; border-radius: 8px; text-decoration: none; font-weight: 600;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div class="login">
<h2>Admin Login</h2>
<form @submit.prevent="handleLogin">
<input v-model="username" placeholder="Username" required />
<input v-model="password" type="password" placeholder="Password" required />
<div v-if="error" class="error">{{ error }}</div>
<button type="submit" :disabled="loading">{{ loading ? 'Logging in...' : 'Login' }}</button>
</form>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth.js'
const auth = useAuthStore()
const router = useRouter()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function handleLogin() {
loading.value = true; error.value = ''
try {
await auth.login(username.value, password.value)
router.push('/admin')
} catch (e) {
error.value = e.response?.data?.detail || 'Login failed'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login { max-width: 360px; margin: 4rem auto; text-align: center; }
h2 { margin-bottom: 1.5rem; }
form { display: flex; flex-direction: column; gap: 1rem; }
input {
padding: 0.8rem 1rem; border: 1px solid #444; border-radius: 8px;
background: #1a1a2e; color: #fff; font-size: 1rem;
}
button {
padding: 0.8rem; border: none; border-radius: 8px; cursor: pointer;
background: #1da1f2; color: #fff; font-size: 1rem; font-weight: 600;
}
button:disabled { background: #555; }
.error { color: #ff6b6b; padding: 0.5rem; background: #2a1515; border-radius: 8px; }
</style>

11
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://localhost:8000'
}
}
})