9 Commits

13 changed files with 1022 additions and 86 deletions

View File

@@ -21,3 +21,37 @@ 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 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)"
))
# 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
# Seed default cleanup settings (only if not already set)
defaults = {
"cleanup_enabled": "true",
"cleanup_retention_minutes": "10080", # 7 days
"cleanup_storage_limit_pct": "80",
"cleanup_last_run": "",
"cleanup_last_result": "",
}
for k, v in defaults.items():
await conn.execute(text(
"INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES (:k, :v, datetime('now'))"
), {"k": k, "v": v})

View File

@@ -1,4 +1,5 @@
"""XDL - Twitter/X Video Downloader API.""" """XDL - Twitter/X Video Downloader API."""
import asyncio
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -9,12 +10,19 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.database import init_db from app.database import init_db
from app.routes import auth, parse, download, admin from app.routes import auth, parse, download, admin
from app.services.cleanup import cleanup_loop
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await init_db() await init_db()
task = asyncio.create_task(cleanup_loop())
yield yield
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(title="XDL - Video Downloader", version="1.0.0", lifespan=lifespan) app = FastAPI(title="XDL - Video Downloader", version="1.0.0", lifespan=lifespan)

View File

@@ -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 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
@@ -9,7 +10,7 @@ class Video(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
task_id = Column(String(64), unique=True, index=True, nullable=False) task_id = Column(String(64), unique=True, index=True, nullable=False)
url = Column(String(512), nullable=False) url = Column(String(512), nullable=False, index=True)
title = Column(String(512), default="") title = Column(String(512), default="")
platform = Column(String(32), default="twitter") platform = Column(String(32), default="twitter")
thumbnail = Column(String(1024), default="") thumbnail = Column(String(1024), default="")
@@ -24,3 +25,34 @@ class Video(Base):
progress = Column(Integer, default=0) # 0-100 progress = Column(Integer, default=0) # 0-100
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
__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
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")
class AppSetting(Base):
__tablename__ = "app_settings"
key = Column(String(64), primary_key=True)
value = Column(Text, default="")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@@ -1,12 +1,18 @@
"""Admin management routes.""" """Admin management routes."""
import json
import os import os
from fastapi import APIRouter, HTTPException, Depends, Query 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, AppSetting
from app.schemas import VideoInfo, VideoListResponse, StorageStats from app.schemas import (
VideoInfo, VideoListResponse, StorageStats,
DownloadLogInfo, DownloadLogListResponse,
CleanupConfig, CleanupStatus, DiskStats,
)
from app.auth import get_current_user from app.auth import get_current_user
from app.services.cleanup import get_setting, set_setting, disk_stats, run_cleanup
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -68,3 +74,69 @@ 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),
):
from sqlalchemy.orm import joinedload
query = (
select(DownloadLog)
.options(joinedload(DownloadLog.video))
.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()
items = []
for l in logs:
d = DownloadLogInfo.model_validate(l)
if l.video:
d.video_title = l.video.title or ""
d.video_platform = l.video.platform or ""
items.append(d)
return DownloadLogListResponse(logs=items, total=total, page=page, page_size=page_size)
@router.get("/settings/cleanup", response_model=CleanupStatus)
async def get_cleanup_settings(user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
video_base = os.getenv("VIDEO_BASE_PATH", "/home/xdl/xdl_videos")
last_result_raw = await get_setting(db, "cleanup_last_result", "{}")
try:
last_result = json.loads(last_result_raw) if last_result_raw else {}
except Exception:
last_result = {}
return CleanupStatus(
config=CleanupConfig(
enabled=(await get_setting(db, "cleanup_enabled", "true")) == "true",
retention_minutes=int(await get_setting(db, "cleanup_retention_minutes", "10080")),
storage_limit_pct=int(await get_setting(db, "cleanup_storage_limit_pct", "80")),
),
disk=DiskStats(**disk_stats(video_base)),
last_run=await get_setting(db, "cleanup_last_run", ""),
last_result=last_result,
)
@router.put("/settings/cleanup", response_model=CleanupStatus)
async def update_cleanup_settings(cfg: CleanupConfig, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
await set_setting(db, "cleanup_enabled", "true" if cfg.enabled else "false")
await set_setting(db, "cleanup_retention_minutes", str(cfg.retention_minutes))
await set_setting(db, "cleanup_storage_limit_pct", str(cfg.storage_limit_pct))
return await get_cleanup_settings(user=user, db=db)
@router.post("/cleanup/run")
async def trigger_cleanup(user: dict = Depends(get_current_user)):
result = await run_cleanup()
return result

View File

@@ -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,94 @@ 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 _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 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=ip,
user_agent=ua[:512],
browser=browser,
device=device,
country_code=country_code,
country=country,
city=city,
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
@@ -50,6 +140,19 @@ async def _do_download(task_id: str, url: str, format_id: str):
@router.post("/download", response_model=DownloadResponse) @router.post("/download", response_model=DownloadResponse)
async def start_download(req: DownloadRequest, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db)): async def start_download(req: DownloadRequest, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db)):
# Dedup: reuse existing completed download if file still on disk
existing = (await db.execute(
select(Video).where(
Video.url == req.url,
Video.format_id == req.format_id,
Video.status == "done",
).order_by(Video.created_at.desc()).limit(1)
)).scalar_one_or_none()
if existing and os.path.exists(existing.file_path):
logger.info(f"Reusing existing download task_id={existing.task_id} for url={req.url} format={req.format_id}")
return DownloadResponse(task_id=existing.task_id, status="done")
task_id = str(uuid.uuid4())[:8] task_id = str(uuid.uuid4())[:8]
video = Video(task_id=task_id, url=req.url, quality=req.quality, format_id=req.format_id, status="pending") video = Video(task_id=task_id, url=req.url, quality=req.quality, format_id=req.format_id, status="pending")
db.add(video) db.add(video)
@@ -74,17 +177,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
@@ -97,6 +201,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:
@@ -110,11 +215,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")

View File

@@ -16,6 +16,7 @@ async def parse_url(req: ParseRequest):
duration=info["duration"], duration=info["duration"],
formats=[FormatInfo(**f) for f in info["formats"]], formats=[FormatInfo(**f) for f in info["formats"]],
url=info["url"], url=info["url"],
platform=info.get("platform", "twitter"),
) )
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to parse URL: {str(e)}") raise HTTPException(status_code=400, detail=f"Failed to parse URL: {str(e)}")

View File

@@ -22,6 +22,7 @@ class ParseResponse(BaseModel):
duration: int duration: int
formats: list[FormatInfo] formats: list[FormatInfo]
url: str url: str
platform: str = ""
class DownloadRequest(BaseModel): class DownloadRequest(BaseModel):
@@ -83,3 +84,48 @@ 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
video_title: str = ""
video_platform: str = ""
ip: str
user_agent: str
browser: str
device: str
country_code: str = ""
country: str = ""
city: str = ""
downloaded_at: datetime
class Config:
from_attributes = True
class DownloadLogListResponse(BaseModel):
logs: list[DownloadLogInfo]
total: int
page: int
page_size: int
class CleanupConfig(BaseModel):
enabled: bool = True
retention_minutes: int = 10080 # 7 days
storage_limit_pct: int = 80
class DiskStats(BaseModel):
total: int
used: int
free: int
used_pct: float
class CleanupStatus(BaseModel):
config: CleanupConfig
disk: DiskStats
last_run: str = ""
last_result: dict = {}

View File

@@ -0,0 +1,146 @@
"""Scheduled video cleanup service."""
import asyncio
import json
import logging
import os
import shutil
from datetime import datetime, timedelta
from sqlalchemy import select
from app.database import async_session
from app.models import AppSetting, Video
logger = logging.getLogger(__name__)
CHECK_INTERVAL_SECONDS = 60 * 10 # Run check every 10 minutes
# ── Setting helpers ──────────────────────────────────────────────────────────
async def get_setting(db, key: str, default: str = "") -> str:
row = (await db.execute(select(AppSetting).where(AppSetting.key == key))).scalar_one_or_none()
return row.value if row else default
async def set_setting(db, key: str, value: str):
row = (await db.execute(select(AppSetting).where(AppSetting.key == key))).scalar_one_or_none()
if row:
row.value = value
row.updated_at = datetime.utcnow()
else:
db.add(AppSetting(key=key, value=value))
await db.commit()
# ── Disk helpers ─────────────────────────────────────────────────────────────
def disk_stats(path: str) -> dict:
"""Return disk usage stats for the given path."""
try:
usage = shutil.disk_usage(path)
used_pct = round(usage.used / usage.total * 100, 1)
return {
"total": usage.total,
"used": usage.used,
"free": usage.free,
"used_pct": used_pct,
}
except Exception:
return {"total": 0, "used": 0, "free": 0, "used_pct": 0}
def _delete_video_file(video: Video) -> int:
"""Delete file, return bytes freed (0 if file missing)."""
if video.file_path and os.path.exists(video.file_path):
size = video.file_size or 0
try:
os.remove(video.file_path)
except OSError:
pass
return size
return 0
# ── Main cleanup logic ───────────────────────────────────────────────────────
async def run_cleanup() -> dict:
"""Execute cleanup. Returns a stats dict."""
async with async_session() as db:
enabled = await get_setting(db, "cleanup_enabled", "true")
if enabled != "true":
return {"skipped": True, "reason": "disabled", "ran_at": datetime.utcnow().isoformat()}
retention_min = int(await get_setting(db, "cleanup_retention_minutes", "10080"))
storage_limit_pct = int(await get_setting(db, "cleanup_storage_limit_pct", "80"))
video_base = os.getenv("VIDEO_BASE_PATH", "/home/xdl/xdl_videos")
cutoff = datetime.utcnow() - timedelta(minutes=retention_min)
time_deleted = 0
storage_deleted = 0
freed_bytes = 0
# ── Phase 1: time-based cleanup ──────────────────────────────────────
old_videos = (await db.execute(
select(Video)
.where(Video.status == "done", Video.created_at < cutoff)
.order_by(Video.created_at.asc())
)).scalars().all()
for v in old_videos:
freed_bytes += _delete_video_file(v)
v.status = "deleted"
v.file_path = ""
time_deleted += 1
if time_deleted:
await db.commit()
logger.info(f"Cleanup: deleted {time_deleted} expired videos, freed {freed_bytes // 1024 // 1024} MB")
# ── Phase 2: storage limit enforcement ───────────────────────────────
stats = disk_stats(video_base)
if stats["used_pct"] > storage_limit_pct:
remaining = (await db.execute(
select(Video)
.where(Video.status == "done")
.order_by(Video.created_at.asc())
)).scalars().all()
for v in remaining:
stats = disk_stats(video_base)
if stats["used_pct"] <= storage_limit_pct:
break
freed_bytes += _delete_video_file(v)
v.status = "deleted"
v.file_path = ""
storage_deleted += 1
if storage_deleted:
await db.commit()
logger.info(f"Cleanup: storage limit reached, deleted {storage_deleted} extra videos")
ran_at = datetime.utcnow().isoformat()
result = {
"time_deleted": time_deleted,
"storage_deleted": storage_deleted,
"freed_mb": round(freed_bytes / 1024 / 1024, 1),
"disk_used_pct": disk_stats(video_base)["used_pct"],
"ran_at": ran_at,
}
await set_setting(db, "cleanup_last_run", ran_at)
await set_setting(db, "cleanup_last_result", json.dumps(result))
return result
# ── Background loop ──────────────────────────────────────────────────────────
async def cleanup_loop():
"""Long-running background task. Starts after 60s, then every 10 min."""
await asyncio.sleep(60)
while True:
try:
result = await run_cleanup()
logger.info(f"Cleanup finished: {result}")
except Exception as e:
logger.error(f"Cleanup loop error: {e}", exc_info=True)
await asyncio.sleep(CHECK_INTERVAL_SECONDS)

View File

@@ -14,9 +14,16 @@ logger = logging.getLogger(__name__)
VIDEO_BASE_PATH = os.getenv("VIDEO_BASE_PATH", "/home/xdl/xdl_videos") VIDEO_BASE_PATH = os.getenv("VIDEO_BASE_PATH", "/home/xdl/xdl_videos")
X_VIDEOS_PATH = os.path.join(VIDEO_BASE_PATH, "x_videos") X_VIDEOS_PATH = os.path.join(VIDEO_BASE_PATH, "x_videos")
YOUTUBE_VIDEOS_PATH = os.path.join(VIDEO_BASE_PATH, "youtube_videos")
# Ensure directories exist # Ensure directories exist
os.makedirs(X_VIDEOS_PATH, exist_ok=True) os.makedirs(X_VIDEOS_PATH, exist_ok=True)
os.makedirs(YOUTUBE_VIDEOS_PATH, exist_ok=True)
# Pattern to match YouTube URLs
YOUTUBE_URL_RE = re.compile(
r'https?://(?:(?:www\.|m\.)?youtube\.com/(?:watch\?.*v=|shorts/|embed/|v/)|youtu\.be/)[\w-]+'
)
# Pattern to match Twitter/X URLs and extract tweet ID # Pattern to match Twitter/X URLs and extract tweet ID
TWITTER_URL_RE = re.compile( TWITTER_URL_RE = re.compile(
@@ -24,10 +31,16 @@ TWITTER_URL_RE = re.compile(
) )
def get_video_path(filename: str) -> str: def get_video_path(filename: str, platform: str = "twitter") -> str:
if platform == "youtube":
return os.path.join(YOUTUBE_VIDEOS_PATH, filename)
return os.path.join(X_VIDEOS_PATH, filename) return os.path.join(X_VIDEOS_PATH, filename)
def _is_youtube_url(url: str) -> bool:
return bool(YOUTUBE_URL_RE.match(url))
def _is_twitter_url(url: str) -> bool: def _is_twitter_url(url: str) -> bool:
return bool(TWITTER_URL_RE.match(url)) return bool(TWITTER_URL_RE.match(url))
@@ -184,19 +197,126 @@ def _download_twitter_video(url: str, format_id: str = "best", progress_callback
} }
def parse_video_url(url: str) -> dict: def _parse_youtube_video(url: str) -> dict:
"""Extract video info without downloading.""" """Parse YouTube video info using yt-dlp."""
# Use syndication API for Twitter/X URLs ydl_opts = {
if _is_twitter_url(url): "quiet": True,
logger.info(f"Using Twitter syndication API for: {url}") "no_warnings": True,
try: "extract_flat": False,
result = _parse_twitter_video(url) "skip_download": True,
# Remove internal keys before returning }
result.pop('_formats_full', None) with yt_dlp.YoutubeDL(ydl_opts) as ydl:
return result info = ydl.extract_info(url, download=False)
except Exception as e:
logger.warning(f"Twitter syndication failed, falling back to yt-dlp: {e}") formats = []
seen = set()
for f in info.get("formats", []):
if f.get("vcodec", "none") == "none":
continue
height = f.get("height", 0)
if not height:
continue
ext = f.get("ext", "mp4")
fmt_id = f.get("format_id", "")
quality = f"{height}p"
key = f"{quality}"
if key in seen:
continue
seen.add(key)
formats.append({
"format_id": fmt_id,
"quality": quality,
"ext": ext,
"filesize": f.get("filesize") or f.get("filesize_approx") or 0,
"note": f.get("format_note", ""),
})
formats.sort(key=lambda x: int(x["quality"].replace("p", "")), reverse=True)
formats.insert(0, {
"format_id": "best",
"quality": "best",
"ext": "mp4",
"filesize": 0,
"note": "Best available quality",
})
return {
"title": info.get("title", "Untitled"),
"thumbnail": info.get("thumbnail", ""),
"duration": info.get("duration", 0) or 0,
"formats": formats,
"url": url,
"platform": "youtube",
}
def _download_youtube_video(url: str, format_id: str = "best", progress_callback=None) -> dict:
"""Download YouTube video using yt-dlp."""
task_id = str(uuid.uuid4())[:8]
output_template = os.path.join(YOUTUBE_VIDEOS_PATH, f"%(id)s_{task_id}.%(ext)s")
if format_id == "best":
format_spec = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best"
else:
format_spec = f"{format_id}+bestaudio/best"
def hook(d):
if d["status"] == "downloading" and progress_callback:
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
downloaded = d.get("downloaded_bytes", 0)
pct = int(downloaded * 100 / total) if total > 0 else 0
progress_callback(pct)
elif d["status"] == "finished" and progress_callback:
progress_callback(100)
ydl_opts = {
"format": format_spec,
"outtmpl": output_template,
"merge_output_format": "mp4",
"quiet": True,
"no_warnings": True,
"progress_hooks": [hook],
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
filename = ydl.prepare_filename(info)
if not os.path.exists(filename):
base = os.path.splitext(filename)[0]
filename = base + ".mp4"
file_size = os.path.getsize(filename) if os.path.exists(filename) else 0
return {
"title": info.get("title", "Untitled"),
"thumbnail": info.get("thumbnail", ""),
"duration": info.get("duration", 0) or 0,
"filename": os.path.basename(filename),
"file_path": filename,
"file_size": file_size,
"platform": "youtube",
}
def parse_video_url(url: str) -> dict:
"""Extract video info without downloading."""
# Use syndication API for Twitter/X URLs
if _is_twitter_url(url):
logger.info(f"Using Twitter syndication API for: {url}")
try:
result = _parse_twitter_video(url)
result.pop('_formats_full', None)
return result
except Exception as e:
logger.warning(f"Twitter syndication failed, falling back to yt-dlp: {e}")
# YouTube URLs
if _is_youtube_url(url):
logger.info(f"Parsing YouTube video: {url}")
return _parse_youtube_video(url)
# Fallback to generic yt-dlp
ydl_opts = { ydl_opts = {
"quiet": True, "quiet": True,
"no_warnings": True, "no_warnings": True,
@@ -209,7 +329,6 @@ def parse_video_url(url: str) -> dict:
formats = [] formats = []
seen = set() seen = set()
for f in info.get("formats", []): for f in info.get("formats", []):
# Only video formats with both video and audio, or video-only
if f.get("vcodec", "none") == "none": if f.get("vcodec", "none") == "none":
continue continue
height = f.get("height", 0) height = f.get("height", 0)
@@ -228,10 +347,8 @@ def parse_video_url(url: str) -> dict:
"note": f.get("format_note", ""), "note": f.get("format_note", ""),
}) })
# Sort by resolution descending
formats.sort(key=lambda x: int(x["quality"].replace("p", "")) if x["quality"].endswith("p") else 0, reverse=True) formats.sort(key=lambda x: int(x["quality"].replace("p", "")) if x["quality"].endswith("p") else 0, reverse=True)
# Add a "best" option
formats.insert(0, { formats.insert(0, {
"format_id": "best", "format_id": "best",
"quality": "best", "quality": "best",
@@ -259,6 +376,11 @@ def download_video(url: str, format_id: str = "best", progress_callback=None) ->
except Exception as e: except Exception as e:
logger.warning(f"Twitter syndication download failed, falling back to yt-dlp: {e}") logger.warning(f"Twitter syndication download failed, falling back to yt-dlp: {e}")
# YouTube URLs
if _is_youtube_url(url):
logger.info(f"Downloading YouTube video: {url}")
return _download_youtube_video(url, format_id, progress_callback)
task_id = str(uuid.uuid4())[:8] task_id = str(uuid.uuid4())[:8]
output_template = os.path.join(X_VIDEOS_PATH, f"%(id)s_{task_id}.%(ext)s") output_template = os.path.join(X_VIDEOS_PATH, f"%(id)s_{task_id}.%(ext)s")

View File

@@ -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

View File

@@ -30,5 +30,5 @@ const auth = useAuthStore()
.nav-links { display: flex; gap: 1.5rem; } .nav-links { display: flex; gap: 1.5rem; }
.nav-links a { color: #aaa; text-decoration: none; transition: color 0.2s; } .nav-links a { color: #aaa; text-decoration: none; transition: color 0.2s; }
.nav-links a:hover, .nav-links a.router-link-active { color: #1da1f2; } .nav-links a:hover, .nav-links a.router-link-active { color: #1da1f2; }
.container { max-width: 800px; margin: 0 auto; padding: 2rem 1rem; } .container { max-width: 1200px; margin: 0 auto; padding: 2rem 1.5rem; }
</style> </style>

View File

@@ -1,12 +1,15 @@
<template> <template>
<div class="admin"> <div class="admin">
<div class="header"> <!-- Tab switcher -->
<h2>Video Library</h2> <div class="tabs">
<div v-if="stats" class="stats"> <button :class="{ active: tab === 'videos' }" @click="tab = 'videos'">📹 Video Library</button>
📊 {{ stats.total_videos }} videos · {{ stats.total_size_human }} <button :class="{ active: tab === 'logs' }" @click="tab = 'logs'; fetchLogs()">📋 Download Logs</button>
</div> <button :class="{ active: tab === 'settings' }" @click="tab = 'settings'; fetchCleanup()"> Settings</button>
<div class="stats" v-if="stats">📊 {{ stats.total_videos }} videos · {{ stats.total_size_human }}</div>
</div> </div>
<!-- Video Library -->
<template v-if="tab === 'videos'">
<div class="search-bar"> <div class="search-bar">
<input v-model="search" placeholder="Search videos..." @input="debouncedFetch" /> <input v-model="search" placeholder="Search videos..." @input="debouncedFetch" />
<select v-model="filterStatus" @change="fetchVideos"> <select v-model="filterStatus" @change="fetchVideos">
@@ -26,13 +29,15 @@
<h4>{{ v.title || 'Untitled' }}</h4> <h4>{{ v.title || 'Untitled' }}</h4>
<div class="meta"> <div class="meta">
<span :class="'status-' + v.status">{{ v.status }}</span> <span :class="'status-' + v.status">{{ v.status }}</span>
<span>{{ v.platform }}</span>
<span>{{ v.quality }}</span> <span>{{ v.quality }}</span>
<span>{{ humanSize(v.file_size) }}</span> <span>{{ humanSize(v.file_size) }}</span>
<span>{{ new Date(v.created_at).toLocaleString() }}</span> <span>{{ fmtTime(v.created_at) }}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<button v-if="v.status === 'done'" @click="showLogs(v)" class="btn-log" title="Download logs">📋</button>
<button v-if="v.status === 'done'" @click="playVideo(v)" class="btn-play"> Play</button> <button v-if="v.status === 'done'" @click="playVideo(v)" class="btn-play"> Play</button>
<a v-if="v.status === 'done'" :href="'/api/file/' + v.id" class="btn-dl" <a v-if="v.status === 'done'" :href="'/api/file/' + v.id" class="btn-dl"
:download="v.filename" @click.prevent="downloadAuth(v)">💾</a> :download="v.filename" @click.prevent="downloadAuth(v)">💾</a>
@@ -47,6 +52,130 @@
<span>{{ page }} / {{ totalPages }}</span> <span>{{ page }} / {{ totalPages }}</span>
<button :disabled="page >= totalPages" @click="page++; fetchVideos()">Next</button> <button :disabled="page >= totalPages" @click="page++; fetchVideos()">Next</button>
</div> </div>
</template>
<!-- Download Logs -->
<template v-if="tab === 'logs'">
<div class="log-filter">
<input v-model="logSearch" placeholder="Filter by IP or video title..." @input="debouncedLogs" />
<button v-if="logVideoFilter" @click="clearLogFilter" class="clear-btn"> {{ logVideoTitle }}</button>
</div>
<div class="log-table-wrap">
<table class="log-table">
<thead>
<tr>
<th>Time</th>
<th>IP</th>
<th>Browser</th>
<th>Device</th>
<th>Video</th>
<th>Location</th>
</tr>
</thead>
<tbody>
<tr v-for="l in filteredLogs" :key="l.id">
<td class="td-time">{{ fmtTime(l.downloaded_at) }}</td>
<td class="td-ip">{{ l.ip }}</td>
<td class="td-browser">{{ browserIcon(l.browser) }} {{ l.browser }}</td>
<td class="td-device">{{ deviceIcon(l.device) }} {{ l.device }}</td>
<td class="td-video">
<span class="platform-badge" :class="'plat-' + l.video_platform">{{ l.video_platform }}</span>
{{ l.video_title || `#${l.video_id}` }}
</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>
</tr>
</tbody>
</table>
<div v-if="!filteredLogs.length" class="empty">No logs found</div>
</div>
<div v-if="logTotalPages > 1" class="pagination">
<button :disabled="logPage <= 1" @click="logPage--; fetchLogs()">Prev</button>
<span>{{ logPage }} / {{ logTotalPages }}</span>
<button :disabled="logPage >= logTotalPages" @click="logPage++; fetchLogs()">Next</button>
</div>
</template>
<!-- Settings -->
<template v-if="tab === 'settings'">
<div class="settings-card">
<h3>🗑 Auto Cleanup</h3>
<p class="settings-desc">Automatically delete downloaded videos based on age or disk usage.</p>
<div class="setting-row">
<label>Enable auto cleanup</label>
<label class="toggle">
<input type="checkbox" v-model="cleanup.enabled" />
<span class="slider"></span>
</label>
</div>
<div class="setting-row">
<label>Retention period</label>
<div class="retention-input">
<input type="number" v-model.number="retentionValue" min="1" class="num-input" />
<select v-model="retentionUnit" class="unit-select">
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
<option value="months">Months</option>
</select>
<span class="minutes-hint">= {{ cleanupMinutes.toLocaleString() }} min</span>
</div>
</div>
<div class="setting-row">
<label>Storage limit</label>
<div class="storage-limit-input">
<input type="number" v-model.number="cleanup.storage_limit_pct" min="10" max="95" class="num-input" />
<span>%</span>
<span class="hint">of total disk</span>
</div>
</div>
<!-- Disk usage bar -->
<div class="disk-section" v-if="cleanupStatus">
<div class="disk-header">
<span>Disk Usage</span>
<span :class="diskClass">{{ cleanupStatus.disk.used_pct }}%</span>
</div>
<div class="disk-bar">
<div class="disk-fill" :style="{ width: cleanupStatus.disk.used_pct + '%' }" :class="diskClass"></div>
<div class="disk-limit-line" :style="{ left: cleanup.storage_limit_pct + '%' }" title="Storage limit"></div>
</div>
<div class="disk-labels">
<span>{{ humanSize(cleanupStatus.disk.used) }} used</span>
<span>{{ humanSize(cleanupStatus.disk.free) }} free</span>
<span>{{ humanSize(cleanupStatus.disk.total) }} total</span>
</div>
</div>
<!-- Last run info -->
<div class="last-run" v-if="cleanupStatus?.last_run">
<span class="last-run-label">Last run:</span>
{{ fmtTime(cleanupStatus.last_run) }}
<span v-if="cleanupStatus.last_result?.time_deleted !== undefined" class="run-result">
deleted {{ cleanupStatus.last_result.time_deleted + (cleanupStatus.last_result.storage_deleted || 0) }} videos,
freed {{ cleanupStatus.last_result.freed_mb }} MB
</span>
</div>
<div class="last-run" v-else-if="cleanupStatus">Never run yet</div>
<div class="settings-actions">
<button class="btn-save" @click="saveCleanup" :disabled="saving">
{{ saving ? '⏳ Saving...' : '💾 Save Settings' }}
</button>
<button class="btn-run" @click="runCleanup" :disabled="running">
{{ running ? '⏳ Running...' : '🗑 Run Now' }}
</button>
</div>
<div v-if="settingsMsg" :class="['settings-msg', settingsMsgType]">{{ settingsMsg }}</div>
</div>
</template>
<!-- Video Player Modal --> <!-- Video Player Modal -->
<div v-if="playing" class="modal" @click.self="playing = null"> <div v-if="playing" class="modal" @click.self="playing = null">
@@ -64,6 +193,9 @@ import axios from 'axios'
import { useAuthStore } from '../stores/auth.js' import { useAuthStore } from '../stores/auth.js'
const auth = useAuthStore() const auth = useAuthStore()
const tab = ref('videos')
// ── Videos ──
const videos = ref([]) const videos = ref([])
const stats = ref(null) const stats = ref(null)
const search = ref('') const search = ref('')
@@ -73,9 +205,97 @@ const total = ref(0)
const pageSize = 20 const pageSize = 20
const playing = ref(null) const playing = ref(null)
const playUrl = ref('') const playUrl = ref('')
const totalPages = computed(() => Math.ceil(total.value / pageSize) || 1) const totalPages = computed(() => Math.ceil(total.value / pageSize) || 1)
// ── Settings / Cleanup ──
const cleanupStatus = ref(null)
const cleanup = ref({ enabled: true, retention_minutes: 10080, storage_limit_pct: 80 })
const retentionValue = ref(7)
const retentionUnit = ref('days')
const saving = ref(false)
const running = ref(false)
const settingsMsg = ref('')
const settingsMsgType = ref('ok')
const cleanupMinutes = computed(() => {
const m = { hours: 60, days: 1440, weeks: 10080, months: 43200 }
return retentionValue.value * (m[retentionUnit.value] || 1440)
})
const diskClass = computed(() => {
const pct = cleanupStatus.value?.disk?.used_pct || 0
if (pct >= 90) return 'disk-danger'
if (pct >= 75) return 'disk-warn'
return 'disk-ok'
})
function minutesToUnit(min) {
if (min % 43200 === 0) return { value: min / 43200, unit: 'months' }
if (min % 10080 === 0) return { value: min / 10080, unit: 'weeks' }
if (min % 1440 === 0) return { value: min / 1440, unit: 'days' }
return { value: min / 60, unit: 'hours' }
}
async function fetchCleanup() {
try {
const res = await axios.get('/api/admin/settings/cleanup', { headers: auth.getHeaders() })
cleanupStatus.value = res.data
cleanup.value = { ...res.data.config }
const { value, unit } = minutesToUnit(res.data.config.retention_minutes)
retentionValue.value = value
retentionUnit.value = unit
} catch {}
}
async function saveCleanup() {
saving.value = true; settingsMsg.value = ''
try {
cleanup.value.retention_minutes = cleanupMinutes.value
const res = await axios.put('/api/admin/settings/cleanup', cleanup.value, { headers: auth.getHeaders() })
cleanupStatus.value = res.data
settingsMsg.value = '✅ Settings saved'
settingsMsgType.value = 'ok'
} catch { settingsMsg.value = '❌ Save failed'; settingsMsgType.value = 'err' }
finally { saving.value = false; setTimeout(() => settingsMsg.value = '', 3000) }
}
async function runCleanup() {
running.value = true; settingsMsg.value = ''
try {
const res = await axios.post('/api/admin/cleanup/run', {}, { headers: auth.getHeaders() })
const r = res.data
if (r.skipped) {
settingsMsg.value = '⚠️ Cleanup is disabled'
settingsMsgType.value = 'warn'
} else {
settingsMsg.value = `✅ Done — deleted ${(r.time_deleted||0) + (r.storage_deleted||0)} videos, freed ${r.freed_mb} MB`
settingsMsgType.value = 'ok'
}
await fetchCleanup()
} catch { settingsMsg.value = '❌ Run failed'; settingsMsgType.value = 'err' }
finally { running.value = false }
}
// ── Logs ──
const logs = ref([])
const logSearch = ref('')
const logPage = ref(1)
const logTotal = ref(0)
const logPageSize = 50
const logVideoFilter = ref(null)
const logVideoTitle = ref('')
const logTotalPages = computed(() => Math.ceil(logTotal.value / logPageSize) || 1)
const filteredLogs = computed(() => {
if (!logSearch.value) return logs.value
const q = logSearch.value.toLowerCase()
return logs.value.filter(l =>
l.ip.includes(q) || l.video_title.toLowerCase().includes(q) ||
l.browser.toLowerCase().includes(q) || l.device.toLowerCase().includes(q)
)
})
// ── Helpers ──
function humanSize(bytes) { function humanSize(bytes) {
if (!bytes) return '0 B' if (!bytes) return '0 B'
for (const u of ['B', 'KB', 'MB', 'GB']) { for (const u of ['B', 'KB', 'MB', 'GB']) {
@@ -85,6 +305,26 @@ function humanSize(bytes) {
return `${bytes.toFixed(1)} TB` return `${bytes.toFixed(1)} TB`
} }
function fmtTime(ts) {
if (!ts) return ''
return new Date(ts).toLocaleString('zh-CN', { hour12: false })
}
function browserIcon(b) {
return { Chrome: '🌐', Firefox: '🦊', Safari: '🧭', Edge: '🔷', Opera: '🔴', Samsung: '📱' }[b] || '🌐'
}
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 let debounceTimer
function debouncedFetch() { function debouncedFetch() {
clearTimeout(debounceTimer) clearTimeout(debounceTimer)
@@ -128,8 +368,40 @@ async function deleteVideo(v) {
try { try {
await axios.delete(`/api/admin/videos/${v.id}`, { headers: auth.getHeaders() }) await axios.delete(`/api/admin/videos/${v.id}`, { headers: auth.getHeaders() })
fetchVideos(); fetchStats() fetchVideos(); fetchStats()
} catch { alert('Delete failed') }
}
function showLogs(v) {
logVideoFilter.value = v.id
logVideoTitle.value = v.title || `#${v.id}`
logPage.value = 1
tab.value = 'logs'
fetchLogs()
}
function clearLogFilter() {
logVideoFilter.value = null
logVideoTitle.value = ''
logPage.value = 1
fetchLogs()
}
// ── Log methods ──
let logDebounce
function debouncedLogs() {
clearTimeout(logDebounce)
logDebounce = setTimeout(() => { logPage.value = 1; fetchLogs() }, 300)
}
async function fetchLogs() {
try {
const params = { page: logPage.value, page_size: logPageSize }
if (logVideoFilter.value) params.video_id = logVideoFilter.value
const res = await axios.get('/api/admin/download-logs', { params, headers: auth.getHeaders() })
logs.value = res.data.logs
logTotal.value = res.data.total
} catch (e) { } catch (e) {
alert('Delete failed') if (e.response?.status === 401) { auth.logout(); location.href = '/login' }
} }
} }
@@ -137,21 +409,29 @@ onMounted(() => { fetchVideos(); fetchStats() })
</script> </script>
<style scoped> <style scoped>
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; } .tabs { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1.5rem; border-bottom: 1px solid #333; padding-bottom: 0.8rem; }
.stats { color: #888; } .tabs button {
padding: 0.5rem 1.2rem; border: 1px solid #444; border-radius: 8px;
background: transparent; color: #aaa; cursor: pointer; font-size: 0.95rem; transition: all 0.2s;
}
.tabs button.active { background: #1da1f2; border-color: #1da1f2; color: #fff; }
.tabs button:hover:not(.active) { border-color: #888; color: #fff; }
.stats { margin-left: auto; color: #888; font-size: 0.9rem; }
/* Video library */
.search-bar { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; } .search-bar { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
.search-bar input { flex: 1; padding: 0.6rem 1rem; border: 1px solid #444; border-radius: 8px; background: #1a1a2e; color: #fff; } .search-bar input { flex: 1; padding: 0.6rem 1rem; border: 1px solid #444; border-radius: 8px; background: #1a1a2e; color: #fff; }
.search-bar select { padding: 0.6rem; border: 1px solid #444; border-radius: 8px; background: #1a1a2e; color: #fff; } .search-bar select { padding: 0.6rem; border: 1px solid #444; border-radius: 8px; background: #1a1a2e; color: #fff; }
.video-card { .video-card {
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
background: #1a1a2e; border-radius: 10px; padding: 1rem; margin-bottom: 0.8rem; background: #1a1a2e; border-radius: 10px; padding: 1.1rem 1.3rem; margin-bottom: 0.9rem; gap: 1.2rem;
gap: 1rem; border: 1px solid #2a2a3e;
} }
.video-main { display: flex; gap: 1rem; flex: 1; min-width: 0; } .video-main { display: flex; gap: 1.2rem; flex: 1; min-width: 0; }
.thumb { width: 80px; height: 45px; object-fit: cover; border-radius: 6px; flex-shrink: 0; } .thumb { width: 112px; height: 63px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.info { min-width: 0; } .info { min-width: 0; }
.info h4 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 0.3rem; } .info h4 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 0.4rem; font-size: 1rem; }
.meta { display: flex; gap: 0.8rem; font-size: 0.85rem; color: #888; flex-wrap: wrap; } .meta { display: flex; gap: 1rem; font-size: 0.88rem; color: #888; flex-wrap: wrap; }
.status-done { color: #27ae60; } .status-done { color: #27ae60; }
.status-downloading { color: #f39c12; } .status-downloading { color: #f39c12; }
.status-error { color: #e74c3c; } .status-error { color: #e74c3c; }
@@ -163,6 +443,85 @@ onMounted(() => { fetchVideos(); fetchStats() })
} }
.actions button:hover, .actions a:hover { border-color: #1da1f2; } .actions button:hover, .actions a:hover { border-color: #1da1f2; }
.btn-del:hover { border-color: #e74c3c !important; } .btn-del:hover { border-color: #e74c3c !important; }
/* Logs */
.log-filter { display: flex; gap: 0.5rem; margin-bottom: 1.2rem; align-items: center; }
.log-filter input { flex: 1; padding: 0.7rem 1rem; border: 1px solid #444; border-radius: 8px; background: #1a1a2e; color: #fff; font-size: 0.95rem; }
.clear-btn { padding: 0.6rem 1rem; border: 1px solid #f39c12; border-radius: 8px; background: transparent; color: #f39c12; cursor: pointer; white-space: nowrap; font-size: 0.88rem; }
.log-table-wrap { overflow-x: auto; border-radius: 10px; border: 1px solid #2a2a3e; }
.log-table { width: 100%; border-collapse: collapse; font-size: 0.95rem; min-width: 900px; }
.log-table th {
text-align: left; padding: 0.9rem 1.2rem;
background: #12122a; color: #999; font-size: 0.82rem; letter-spacing: 0.05em; text-transform: uppercase;
border-bottom: 1px solid #2a2a3e; white-space: nowrap;
}
.log-table td { padding: 0.85rem 1.2rem; border-bottom: 1px solid #1e1e30; vertical-align: middle; }
.log-table tr:last-child td { border-bottom: none; }
.log-table tr:hover td { background: rgba(255,255,255,0.04); }
.td-time { color: #888; white-space: nowrap; font-size: 0.88rem; min-width: 140px; }
.td-ip { font-family: monospace; color: #7fdbff; white-space: nowrap; font-size: 0.92rem; min-width: 120px; }
.td-location { white-space: nowrap; min-width: 160px; }
.flag { margin-right: 0.35rem; font-size: 1.1rem; }
.location-text { color: #ccc; }
.td-browser, .td-device { color: #ddd; white-space: nowrap; min-width: 100px; }
.td-video { max-width: 380px; min-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.platform-badge {
display: inline-block; font-size: 0.72rem; padding: 0.15rem 0.5rem;
border-radius: 4px; margin-right: 0.4rem; vertical-align: middle; font-weight: 700; letter-spacing: 0.03em;
}
.plat-youtube { background: #c00; color: #fff; }
.plat-twitter { background: #1da1f2; color: #fff; }
/* Settings */
.settings-card { background: #1a1a2e; border: 1px solid #2a2a3e; border-radius: 12px; padding: 2rem; max-width: 680px; }
.settings-card h3 { margin-bottom: 0.4rem; font-size: 1.15rem; }
.settings-desc { color: #888; font-size: 0.9rem; margin-bottom: 1.8rem; }
.setting-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; gap: 1rem; }
.setting-row label:first-child { color: #ccc; font-size: 0.95rem; white-space: nowrap; }
.retention-input, .storage-limit-input { display: flex; align-items: center; gap: 0.6rem; }
.num-input { width: 80px; padding: 0.5rem 0.7rem; border: 1px solid #444; border-radius: 8px; background: #12122a; color: #fff; font-size: 0.95rem; text-align: center; }
.unit-select { padding: 0.5rem 0.7rem; border: 1px solid #444; border-radius: 8px; background: #12122a; color: #fff; font-size: 0.95rem; }
.minutes-hint { color: #666; font-size: 0.82rem; white-space: nowrap; }
.hint { color: #666; font-size: 0.85rem; }
/* Toggle switch */
.toggle { position: relative; display: inline-block; width: 48px; height: 26px; flex-shrink: 0; }
.toggle input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; inset: 0; background: #444; border-radius: 26px; transition: 0.3s; }
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 4px; bottom: 4px; background: #fff; border-radius: 50%; transition: 0.3s; }
.toggle input:checked + .slider { background: #1da1f2; }
.toggle input:checked + .slider:before { transform: translateX(22px); }
/* Disk bar */
.disk-section { margin: 1.4rem 0; }
.disk-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-size: 0.9rem; color: #aaa; }
.disk-bar { position: relative; height: 12px; background: #333; border-radius: 6px; overflow: visible; margin-bottom: 0.5rem; }
.disk-fill { height: 100%; border-radius: 6px; transition: width 0.4s; }
.disk-limit-line { position: absolute; top: -4px; bottom: -4px; width: 2px; background: #f39c12; border-radius: 2px; transform: translateX(-50%); }
.disk-labels { display: flex; justify-content: space-between; font-size: 0.8rem; color: #666; }
.disk-ok { color: #27ae60; } .disk-fill.disk-ok { background: #27ae60; }
.disk-warn { color: #f39c12; } .disk-fill.disk-warn { background: #f39c12; }
.disk-danger { color: #e74c3c; } .disk-fill.disk-danger { background: #e74c3c; }
/* Last run */
.last-run { font-size: 0.88rem; color: #888; margin-bottom: 1.4rem; }
.last-run-label { color: #666; margin-right: 0.3rem; }
.run-result { color: #27ae60; }
/* Action buttons */
.settings-actions { display: flex; gap: 0.8rem; flex-wrap: wrap; }
.btn-save, .btn-run {
padding: 0.7rem 1.4rem; border: none; border-radius: 8px; cursor: pointer;
font-size: 0.95rem; font-weight: 600; transition: opacity 0.2s;
}
.btn-save { background: #1da1f2; color: #fff; }
.btn-run { background: #e74c3c; color: #fff; }
.btn-save:disabled, .btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
.settings-msg { margin-top: 0.8rem; font-size: 0.9rem; padding: 0.5rem 0; }
.settings-msg.ok { color: #27ae60; }
.settings-msg.warn { color: #f39c12; }
.settings-msg.err { color: #e74c3c; }
.empty { text-align: center; color: #666; padding: 3rem; } .empty { text-align: center; color: #666; padding: 3rem; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 1rem; margin-top: 1.5rem; } .pagination { display: flex; justify-content: center; align-items: center; gap: 1rem; margin-top: 1.5rem; }
.pagination button { padding: 0.5rem 1rem; border: 1px solid #444; border-radius: 6px; background: transparent; color: #fff; cursor: pointer; } .pagination button { padding: 0.5rem 1rem; border: 1px solid #444; border-radius: 6px; background: transparent; color: #fff; cursor: pointer; }

View File

@@ -1,10 +1,10 @@
<template> <template>
<div class="home"> <div class="home">
<h1>Twitter/X Video Downloader</h1> <h1>Video Downloader</h1>
<p class="subtitle">Paste a Twitter/X video link to download</p> <p class="subtitle">Paste a Twitter/X or YouTube video link to download</p>
<div class="input-group"> <div class="input-group">
<input v-model="url" placeholder="https://x.com/user/status/123..." @keyup.enter="parseUrl" :disabled="loading" /> <input v-model="url" placeholder="https://x.com/... or https://youtube.com/watch?v=..." @keyup.enter="parseUrl" :disabled="loading" />
<button @click="parseUrl" :disabled="loading || !url.trim()"> <button @click="parseUrl" :disabled="loading || !url.trim()">
{{ loading ? '⏳ Parsing...' : '🔍 Parse' }} {{ loading ? '⏳ Parsing...' : '🔍 Parse' }}
</button> </button>
@@ -20,15 +20,15 @@
<div class="formats"> <div class="formats">
<h4>Select Quality:</h4> <h4>Select Quality:</h4>
<div v-for="fmt in videoInfo.formats" :key="fmt.format_id" class="format-item" <div v-for="fmt in videoInfo.formats" :key="fmt.format_id" class="format-item"
:class="{ selected: selectedFormat === fmt.format_id }" @click="selectedFormat = fmt.format_id"> :class="{ selected: selectedFormat === fmt.format_id }" @click="selectedFormat = fmt.format_id; downloadReady = false; taskId = ''; progress = 0">
<span class="quality">{{ fmt.quality }}</span> <span class="quality">{{ fmt.quality }}</span>
<span class="ext">{{ fmt.ext }}</span> <span class="ext">{{ fmt.ext }}</span>
<span v-if="fmt.filesize" class="size">~{{ humanSize(fmt.filesize) }}</span> <span v-if="fmt.filesize" class="size">~{{ humanSize(fmt.filesize) }}</span>
</div> </div>
</div> </div>
<button class="download-btn" @click="startDownload" :disabled="downloading"> <button class="download-btn" @click="startDownload" :disabled="downloading || downloadReady">
{{ downloading ? '⏳ Downloading...' : '📥 Download' }} {{ downloading ? '⏳ Downloading...' : downloadReady ? '✅ Downloaded' : '📥 Download' }}
</button> </button>
</div> </div>
@@ -94,8 +94,17 @@ async function startDownload() {
url: url.value, format_id: selectedFormat.value, quality: selectedFormat.value url: url.value, format_id: selectedFormat.value, quality: selectedFormat.value
}) })
taskId.value = res.data.task_id taskId.value = res.data.task_id
if (res.data.status === 'done') {
// Already downloaded — skip polling, show save button immediately
progress.value = 100
statusText.value = '✅ Already downloaded'
downloadReady.value = true
downloadUrl.value = `/api/file/task/${res.data.task_id}`
downloading.value = false
} else {
statusText.value = 'Starting download...' statusText.value = 'Starting download...'
pollStatus() pollStatus()
}
} catch (e) { } catch (e) {
error.value = e.response?.data?.detail || 'Failed to start download' error.value = e.response?.data?.detail || 'Failed to start download'
downloading.value = false downloading.value = false