diff --git a/backend/app/database.py b/backend/app/database.py index 45de775..98f09c8 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -32,3 +32,13 @@ async def init_db(): await conn.execute(text( "CREATE INDEX IF NOT EXISTS ix_download_logs_downloaded_at ON download_logs (downloaded_at)" )) + # Migrate: add geo columns to existing download_logs table (idempotent) + for col_def in [ + "ALTER TABLE download_logs ADD COLUMN country_code VARCHAR(8) DEFAULT ''", + "ALTER TABLE download_logs ADD COLUMN country VARCHAR(128) DEFAULT ''", + "ALTER TABLE download_logs ADD COLUMN city VARCHAR(128) DEFAULT ''", + ]: + try: + await conn.execute(text(col_def)) + except Exception: + pass # Column already exists diff --git a/backend/app/models.py b/backend/app/models.py index 40f5e44..7990255 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -42,6 +42,9 @@ class DownloadLog(Base): user_agent = Column(Text, default="") browser = Column(String(64), default="") # Chrome / Firefox / Safari / Edge / … device = Column(String(32), default="") # desktop / mobile / tablet / bot + country_code = Column(String(8), default="") # e.g. CN + country = Column(String(128), default="") # e.g. China + city = Column(String(128), default="") # e.g. Shanghai downloaded_at = Column(DateTime, default=datetime.utcnow, index=True) video = relationship("Video", back_populates="logs") diff --git a/backend/app/routes/download.py b/backend/app/routes/download.py index 62bb031..37a89a1 100644 --- a/backend/app/routes/download.py +++ b/backend/app/routes/download.py @@ -63,18 +63,42 @@ def _client_ip(request: Request) -> str: return "" +async def _geo_lookup(ip: str) -> tuple[str, str, str]: + """Return (country_code, country, city) via ip-api.com. Falls back to empty strings.""" + if not ip or ip in ("127.0.0.1", "::1"): + return "", "", "" + try: + import httpx + async with httpx.AsyncClient(timeout=5) as client: + res = await client.get( + f"http://ip-api.com/json/{ip}", + params={"fields": "status,countryCode,country,city"}, + ) + data = res.json() + if data.get("status") == "success": + return data.get("countryCode", ""), data.get("country", ""), data.get("city", "") + except Exception as e: + logger.debug(f"Geo lookup failed for {ip}: {e}") + return "", "", "" + + async def _log_download(video_id: int, request: Request): - """Write a DownloadLog entry (fire-and-forget).""" + """Write a DownloadLog entry with geo info (fire-and-forget).""" try: ua = request.headers.get("user-agent", "") browser, device = _parse_ua(ua) + ip = _client_ip(request) + country_code, country, city = await _geo_lookup(ip) async with async_session() as db: db.add(DownloadLog( video_id=video_id, - ip=_client_ip(request), + ip=ip, user_agent=ua[:512], browser=browser, device=device, + country_code=country_code, + country=country, + city=city, downloaded_at=datetime.utcnow(), )) await db.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 507d0a2..ebb963a 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -95,6 +95,9 @@ class DownloadLogInfo(BaseModel): user_agent: str browser: str device: str + country_code: str = "" + country: str = "" + city: str = "" downloaded_at: datetime class Config: diff --git a/backend/requirements.txt b/backend/requirements.txt index 8047b62..82d72d2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,3 +8,4 @@ python-dotenv==1.0.1 python-multipart==0.0.12 yt-dlp>=2024.1.0 pydantic>=2.0.0 +httpx>=0.27.0 diff --git a/frontend/src/views/Admin.vue b/frontend/src/views/Admin.vue index 0a437ce..b2d3746 100644 --- a/frontend/src/views/Admin.vue +++ b/frontend/src/views/Admin.vue @@ -66,6 +66,7 @@ Time IP + Location Browser Device Video @@ -75,6 +76,10 @@ {{ fmtTime(l.downloaded_at) }} {{ l.ip }} + + {{ countryFlag(l.country_code) }} + {{ [l.city, l.country].filter(Boolean).join(', ') || '—' }} + {{ browserIcon(l.browser) }} {{ l.browser }} {{ deviceIcon(l.device) }} {{ l.device }} @@ -166,6 +171,12 @@ function deviceIcon(d) { return { mobile: '📱', tablet: '📟', desktop: '💻', bot: '🤖' }[d] || '💻' } +function countryFlag(code) { + if (!code || code.length !== 2) return '' + // Convert country code to regional indicator emoji + return [...code.toUpperCase()].map(c => String.fromCodePoint(0x1F1E6 + c.charCodeAt(0) - 65)).join('') +} + // ── Video methods ── let debounceTimer function debouncedFetch() { @@ -295,8 +306,11 @@ onMounted(() => { fetchVideos(); fetchStats() }) .log-table td { padding: 0.6rem 1rem; border-bottom: 1px solid #222; vertical-align: middle; } .log-table tr:hover td { background: rgba(255,255,255,0.03); } .td-time { color: #888; white-space: nowrap; font-size: 0.82rem; } -.td-ip { font-family: monospace; color: #7fdbff; } -.td-browser, .td-device { color: #ddd; } +.td-ip { font-family: monospace; color: #7fdbff; white-space: nowrap; } +.td-location { white-space: nowrap; } +.flag { margin-right: 0.3rem; } +.location-text { color: #ccc; font-size: 0.88rem; } +.td-browser, .td-device { color: #ddd; white-space: nowrap; } .td-video { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .platform-badge { display: inline-block; font-size: 0.7rem; padding: 0.1rem 0.4rem;