feat: add geolocation to download logs (country, city via ip-api.com)
This commit is contained in:
@@ -32,3 +32,13 @@ async def init_db():
|
|||||||
await conn.execute(text(
|
await conn.execute(text(
|
||||||
"CREATE INDEX IF NOT EXISTS ix_download_logs_downloaded_at ON download_logs (downloaded_at)"
|
"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
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ class DownloadLog(Base):
|
|||||||
user_agent = Column(Text, default="")
|
user_agent = Column(Text, default="")
|
||||||
browser = Column(String(64), default="") # Chrome / Firefox / Safari / Edge / …
|
browser = Column(String(64), default="") # Chrome / Firefox / Safari / Edge / …
|
||||||
device = Column(String(32), default="") # desktop / mobile / tablet / bot
|
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)
|
downloaded_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
video = relationship("Video", back_populates="logs")
|
video = relationship("Video", back_populates="logs")
|
||||||
|
|||||||
@@ -63,18 +63,42 @@ def _client_ip(request: Request) -> str:
|
|||||||
return ""
|
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):
|
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:
|
try:
|
||||||
ua = request.headers.get("user-agent", "")
|
ua = request.headers.get("user-agent", "")
|
||||||
browser, device = _parse_ua(ua)
|
browser, device = _parse_ua(ua)
|
||||||
|
ip = _client_ip(request)
|
||||||
|
country_code, country, city = await _geo_lookup(ip)
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
db.add(DownloadLog(
|
db.add(DownloadLog(
|
||||||
video_id=video_id,
|
video_id=video_id,
|
||||||
ip=_client_ip(request),
|
ip=ip,
|
||||||
user_agent=ua[:512],
|
user_agent=ua[:512],
|
||||||
browser=browser,
|
browser=browser,
|
||||||
device=device,
|
device=device,
|
||||||
|
country_code=country_code,
|
||||||
|
country=country,
|
||||||
|
city=city,
|
||||||
downloaded_at=datetime.utcnow(),
|
downloaded_at=datetime.utcnow(),
|
||||||
))
|
))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ class DownloadLogInfo(BaseModel):
|
|||||||
user_agent: str
|
user_agent: str
|
||||||
browser: str
|
browser: str
|
||||||
device: str
|
device: str
|
||||||
|
country_code: str = ""
|
||||||
|
country: str = ""
|
||||||
|
city: str = ""
|
||||||
downloaded_at: datetime
|
downloaded_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ python-dotenv==1.0.1
|
|||||||
python-multipart==0.0.12
|
python-multipart==0.0.12
|
||||||
yt-dlp>=2024.1.0
|
yt-dlp>=2024.1.0
|
||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
<th>IP</th>
|
<th>IP</th>
|
||||||
|
<th>Location</th>
|
||||||
<th>Browser</th>
|
<th>Browser</th>
|
||||||
<th>Device</th>
|
<th>Device</th>
|
||||||
<th>Video</th>
|
<th>Video</th>
|
||||||
@@ -75,6 +76,10 @@
|
|||||||
<tr v-for="l in filteredLogs" :key="l.id">
|
<tr v-for="l in filteredLogs" :key="l.id">
|
||||||
<td class="td-time">{{ fmtTime(l.downloaded_at) }}</td>
|
<td class="td-time">{{ fmtTime(l.downloaded_at) }}</td>
|
||||||
<td class="td-ip">{{ l.ip }}</td>
|
<td class="td-ip">{{ l.ip }}</td>
|
||||||
|
<td class="td-location">
|
||||||
|
<span v-if="l.country_code" class="flag">{{ countryFlag(l.country_code) }}</span>
|
||||||
|
<span class="location-text">{{ [l.city, l.country].filter(Boolean).join(', ') || '—' }}</span>
|
||||||
|
</td>
|
||||||
<td class="td-browser">{{ browserIcon(l.browser) }} {{ l.browser }}</td>
|
<td class="td-browser">{{ browserIcon(l.browser) }} {{ l.browser }}</td>
|
||||||
<td class="td-device">{{ deviceIcon(l.device) }} {{ l.device }}</td>
|
<td class="td-device">{{ deviceIcon(l.device) }} {{ l.device }}</td>
|
||||||
<td class="td-video">
|
<td class="td-video">
|
||||||
@@ -166,6 +171,12 @@ function deviceIcon(d) {
|
|||||||
return { mobile: '📱', tablet: '📟', desktop: '💻', bot: '🤖' }[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 ──
|
// ── Video methods ──
|
||||||
let debounceTimer
|
let debounceTimer
|
||||||
function debouncedFetch() {
|
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 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); }
|
.log-table tr:hover td { background: rgba(255,255,255,0.03); }
|
||||||
.td-time { color: #888; white-space: nowrap; font-size: 0.82rem; }
|
.td-time { color: #888; white-space: nowrap; font-size: 0.82rem; }
|
||||||
.td-ip { font-family: monospace; color: #7fdbff; }
|
.td-ip { font-family: monospace; color: #7fdbff; white-space: nowrap; }
|
||||||
.td-browser, .td-device { color: #ddd; }
|
.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; }
|
.td-video { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.platform-badge {
|
.platform-badge {
|
||||||
display: inline-block; font-size: 0.7rem; padding: 0.1rem 0.4rem;
|
display: inline-block; font-size: 0.7rem; padding: 0.1rem 0.4rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user