diff --git a/backend/app/database.py b/backend/app/database.py index 44bb099..45de775 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -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)" + )) diff --git a/backend/app/models.py b/backend/app/models.py index f096381..40f5e44 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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") diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index f9d2a73..2416895 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -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, + ) diff --git a/backend/app/routes/download.py b/backend/app/routes/download.py index dcb8913..62bb031 100644 --- a/backend/app/routes/download.py +++ b/backend/app/routes/download.py @@ -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") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 8152d4b..1e431b1 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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