feat: track download logs (ip, browser, device, time)

This commit is contained in:
mini
2026-02-18 23:20:50 +08:00
parent 0bab021e21
commit 27c9c87f5c
5 changed files with 149 additions and 15 deletions

View File

@@ -4,8 +4,8 @@ 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.models import Video, DownloadLog
from app.schemas import VideoInfo, VideoListResponse, StorageStats, DownloadLogInfo, DownloadLogListResponse
from app.auth import get_current_user
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -68,3 +68,26 @@ async def storage_stats(user: dict = Depends(get_current_user), db: AsyncSession
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))
@router.get("/download-logs", response_model=DownloadLogListResponse)
async def download_logs(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
video_id: int = Query(None),
user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(DownloadLog).order_by(DownloadLog.downloaded_at.desc())
count_query = select(func.count(DownloadLog.id))
if video_id is not None:
query = query.where(DownloadLog.video_id == video_id)
count_query = count_query.where(DownloadLog.video_id == video_id)
total = (await db.execute(count_query)).scalar() or 0
logs = (await db.execute(query.offset((page - 1) * page_size).limit(page_size))).scalars().all()
return DownloadLogListResponse(
logs=[DownloadLogInfo.model_validate(l) for l in logs],
total=total,
page=page,
page_size=page_size,
)

View File

@@ -1,14 +1,16 @@
"""Download task routes."""
import uuid
import os
import re
import logging
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
from datetime import datetime
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks, Request
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.database import get_db, async_session
from app.models import Video, DownloadLog
from app.auth import get_current_user, optional_auth
from app.services.downloader import download_video, get_video_path
@@ -16,6 +18,70 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["download"])
# ── UA parsing ──────────────────────────────────────────────────────────────
def _parse_ua(ua: str) -> tuple[str, str]:
"""Return (browser, device) from User-Agent string."""
ua_lower = ua.lower()
# Device
if any(k in ua_lower for k in ("bot", "crawler", "spider", "slurp", "curl", "wget", "python", "axios")):
device = "bot"
elif "tablet" in ua_lower or "ipad" in ua_lower:
device = "tablet"
elif any(k in ua_lower for k in ("mobile", "android", "iphone", "ipod", "windows phone")):
device = "mobile"
else:
device = "desktop"
# Browser
if "edg/" in ua_lower or "edghtml" in ua_lower:
browser = "Edge"
elif "opr/" in ua_lower or "opera" in ua_lower:
browser = "Opera"
elif "samsungbrowser" in ua_lower:
browser = "Samsung"
elif "chrome/" in ua_lower:
browser = "Chrome"
elif "firefox/" in ua_lower:
browser = "Firefox"
elif "safari/" in ua_lower:
browser = "Safari"
else:
m = re.search(r"(\w+)/[\d.]+$", ua)
browser = m.group(1).capitalize() if m else "Unknown"
return browser, device
def _client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return ""
async def _log_download(video_id: int, request: Request):
"""Write a DownloadLog entry (fire-and-forget)."""
try:
ua = request.headers.get("user-agent", "")
browser, device = _parse_ua(ua)
async with async_session() as db:
db.add(DownloadLog(
video_id=video_id,
ip=_client_ip(request),
user_agent=ua[:512],
browser=browser,
device=device,
downloaded_at=datetime.utcnow(),
))
await db.commit()
except Exception as e:
logger.warning(f"Failed to log download: {e}")
async def _do_download(task_id: str, url: str, format_id: str):
"""Background download task."""
from app.database import async_session
@@ -87,17 +153,18 @@ async def get_download_status(task_id: str, db: AsyncSession = Depends(get_db)):
@router.get("/file/{video_id}")
async def download_file(video_id: int, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
async def download_file(video_id: int, request: Request, background_tasks: BackgroundTasks, 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")
background_tasks.add_task(_log_download, video.id, request)
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)):
async def stream_video(video_id: int, request: Request, background_tasks: BackgroundTasks, 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
@@ -110,6 +177,7 @@ async def stream_video(video_id: int, token: str = None, user: dict = Depends(op
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")
background_tasks.add_task(_log_download, video.id, request)
def iter_file():
with open(video.file_path, "rb") as f:
@@ -123,11 +191,12 @@ async def stream_video(video_id: int, token: str = None, user: dict = Depends(op
@router.get("/file/task/{task_id}")
async def download_file_by_task(task_id: str, db: AsyncSession = Depends(get_db)):
async def download_file_by_task(task_id: str, request: Request, background_tasks: BackgroundTasks, 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")
background_tasks.add_task(_log_download, video.id, request)
return FileResponse(video.file_path, filename=video.filename, media_type="video/mp4")