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 def init_db():
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
# Ensure composite index exists on already-created tables (idempotent)
|
# Ensure indexes exist on already-created tables (idempotent)
|
||||||
await conn.execute(
|
from sqlalchemy import text
|
||||||
__import__("sqlalchemy").text(
|
await conn.execute(text(
|
||||||
"CREATE INDEX IF NOT EXISTS ix_video_url_format_id ON videos (url, format_id)"
|
"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."""
|
"""SQLAlchemy models."""
|
||||||
from datetime import datetime
|
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
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -28,3 +29,19 @@ class Video(Base):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_video_url_format_id", "url", "format_id"),
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, or_
|
from sqlalchemy import select, func, or_
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Video
|
from app.models import Video, DownloadLog
|
||||||
from app.schemas import VideoInfo, VideoListResponse, StorageStats
|
from app.schemas import VideoInfo, VideoListResponse, StorageStats, DownloadLogInfo, DownloadLogListResponse
|
||||||
from app.auth import get_current_user
|
from app.auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
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 = (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
|
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))
|
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."""
|
"""Download task routes."""
|
||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import logging
|
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 fastapi.responses import FileResponse, StreamingResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.schemas import DownloadRequest, DownloadResponse, TaskStatus
|
from app.schemas import DownloadRequest, DownloadResponse, TaskStatus
|
||||||
from app.database import get_db
|
from app.database import get_db, async_session
|
||||||
from app.models import Video
|
from app.models import Video, DownloadLog
|
||||||
from app.auth import get_current_user, optional_auth
|
from app.auth import get_current_user, optional_auth
|
||||||
from app.services.downloader import download_video, get_video_path
|
from app.services.downloader import download_video, get_video_path
|
||||||
|
|
||||||
@@ -16,6 +18,70 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api", tags=["download"])
|
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):
|
async def _do_download(task_id: str, url: str, format_id: str):
|
||||||
"""Background download task."""
|
"""Background download task."""
|
||||||
from app.database import async_session
|
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}")
|
@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()
|
video = (await db.execute(select(Video).where(Video.id == video_id))).scalar_one_or_none()
|
||||||
if not video or video.status != "done":
|
if not video or video.status != "done":
|
||||||
raise HTTPException(status_code=404, detail="Video not found")
|
raise HTTPException(status_code=404, detail="Video not found")
|
||||||
if not os.path.exists(video.file_path):
|
if not os.path.exists(video.file_path):
|
||||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
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")
|
return FileResponse(video.file_path, filename=video.filename, media_type="video/mp4")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream/{video_id}")
|
@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
|
# Allow token via query param for video player
|
||||||
if not user and token:
|
if not user and token:
|
||||||
from app.auth import verify_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")
|
raise HTTPException(status_code=404, detail="Video not found")
|
||||||
if not os.path.exists(video.file_path):
|
if not os.path.exists(video.file_path):
|
||||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||||
|
background_tasks.add_task(_log_download, video.id, request)
|
||||||
|
|
||||||
def iter_file():
|
def iter_file():
|
||||||
with open(video.file_path, "rb") as f:
|
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}")
|
@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)."""
|
"""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()
|
video = (await db.execute(select(Video).where(Video.task_id == task_id))).scalar_one_or_none()
|
||||||
if not video or video.status != "done":
|
if not video or video.status != "done":
|
||||||
raise HTTPException(status_code=404, detail="Video not found")
|
raise HTTPException(status_code=404, detail="Video not found")
|
||||||
if not os.path.exists(video.file_path):
|
if not os.path.exists(video.file_path):
|
||||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
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")
|
return FileResponse(video.file_path, filename=video.filename, media_type="video/mp4")
|
||||||
|
|||||||
@@ -84,3 +84,23 @@ class LoginRequest(BaseModel):
|
|||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
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