Initial commit: XDL Twitter/X video downloader
This commit is contained in:
24
.drone.yml
Normal file
24
.drone.yml
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
.vite/
|
||||||
25
README.md
Normal file
25
README.md
Normal 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
5
backend/.env.example
Normal 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
14
backend/Dockerfile
Normal 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
0
backend/app/__init__.py
Normal file
42
backend/app/auth.py
Normal file
42
backend/app/auth.py
Normal 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
23
backend/app/database.py
Normal 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
38
backend/app/main.py
Normal 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
26
backend/app/models.py
Normal 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)
|
||||||
0
backend/app/routes/__init__.py
Normal file
0
backend/app/routes/__init__.py
Normal file
70
backend/app/routes/admin.py
Normal file
70
backend/app/routes/admin.py
Normal 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))
|
||||||
18
backend/app/routes/auth.py
Normal file
18
backend/app/routes/auth.py
Normal 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)
|
||||||
120
backend/app/routes/download.py
Normal file
120
backend/app/routes/download.py
Normal 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")
|
||||||
21
backend/app/routes/parse.py
Normal file
21
backend/app/routes/parse.py
Normal 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
85
backend/app/schemas.py
Normal 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"
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
120
backend/app/services/downloader.py
Normal file
120
backend/app/services/downloader.py
Normal 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
10
backend/requirements.txt
Normal 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
20
docker-compose.yml
Normal 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
11
frontend/Dockerfile
Normal 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
16
frontend/index.html
Normal 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
17
frontend/nginx.conf
Normal 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
21
frontend/package.json
Normal 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
34
frontend/src/App.vue
Normal 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
9
frontend/src/main.js
Normal 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')
|
||||||
22
frontend/src/router/index.js
Normal file
22
frontend/src/router/index.js
Normal 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
|
||||||
25
frontend/src/stores/auth.js
Normal file
25
frontend/src/stores/auth.js
Normal 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 }
|
||||||
|
})
|
||||||
174
frontend/src/views/Admin.vue
Normal file
174
frontend/src/views/Admin.vue
Normal 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
170
frontend/src/views/Home.vue
Normal 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>
|
||||||
52
frontend/src/views/Login.vue
Normal file
52
frontend/src/views/Login.vue
Normal 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
11
frontend/vite.config.js
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user