Initial commit: XDL Twitter/X video downloader
This commit is contained in:
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)}")
|
||||
Reference in New Issue
Block a user