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