feat: track download logs (ip, browser, device, time)
This commit is contained in:
@@ -21,9 +21,14 @@ async def get_db():
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Ensure composite index exists on already-created tables (idempotent)
|
||||
await conn.execute(
|
||||
__import__("sqlalchemy").text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_video_url_format_id ON videos (url, format_id)"
|
||||
)
|
||||
)
|
||||
# Ensure indexes exist on already-created tables (idempotent)
|
||||
from sqlalchemy import text
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_video_url_format_id ON videos (url, format_id)"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_download_logs_video_id ON download_logs (video_id)"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_download_logs_downloaded_at ON download_logs (downloaded_at)"
|
||||
))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""SQLAlchemy models."""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, BigInteger, Text, Index
|
||||
from sqlalchemy import Column, Integer, String, DateTime, BigInteger, Text, Index, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
@@ -28,3 +29,19 @@ class Video(Base):
|
||||
__table_args__ = (
|
||||
Index("ix_video_url_format_id", "url", "format_id"),
|
||||
)
|
||||
|
||||
logs = relationship("DownloadLog", back_populates="video", lazy="select")
|
||||
|
||||
|
||||
class DownloadLog(Base):
|
||||
__tablename__ = "download_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
video_id = Column(Integer, ForeignKey("videos.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
ip = Column(String(64), default="")
|
||||
user_agent = Column(Text, default="")
|
||||
browser = Column(String(64), default="") # Chrome / Firefox / Safari / Edge / …
|
||||
device = Column(String(32), default="") # desktop / mobile / tablet / bot
|
||||
downloaded_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
video = relationship("Video", back_populates="logs")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -84,3 +84,23 @@ class LoginRequest(BaseModel):
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class DownloadLogInfo(BaseModel):
|
||||
id: int
|
||||
video_id: int
|
||||
ip: str
|
||||
user_agent: str
|
||||
browser: str
|
||||
device: str
|
||||
downloaded_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DownloadLogListResponse(BaseModel):
|
||||
logs: list[DownloadLogInfo]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
Reference in New Issue
Block a user