Initial commit: XDL Twitter/X video downloader

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

View File

View File

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

View File

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

View File

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

View File

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