diff --git a/backend/app/database.py b/backend/app/database.py index 98f09c8..f2e8d91 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -42,3 +42,16 @@ async def init_db(): 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}) diff --git a/backend/app/main.py b/backend/app/main.py index 4dd892e..54ff2be 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,5 @@ """XDL - Twitter/X Video Downloader API.""" +import asyncio import os from contextlib import asynccontextmanager from dotenv import load_dotenv @@ -9,12 +10,19 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.database import init_db from app.routes import auth, parse, download, admin +from app.services.cleanup import cleanup_loop @asynccontextmanager async def lifespan(app: FastAPI): await init_db() + task = asyncio.create_task(cleanup_loop()) yield + task.cancel() + try: + await task + except asyncio.CancelledError: + pass app = FastAPI(title="XDL - Video Downloader", version="1.0.0", lifespan=lifespan) diff --git a/backend/app/models.py b/backend/app/models.py index 7990255..886d7f7 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -48,3 +48,11 @@ class DownloadLog(Base): 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) diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index d395397..7afdbef 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -1,12 +1,18 @@ """Admin management routes.""" +import json import os 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, DownloadLog -from app.schemas import VideoInfo, VideoListResponse, StorageStats, DownloadLogInfo, DownloadLogListResponse +from app.models import Video, DownloadLog, AppSetting +from app.schemas import ( + VideoInfo, VideoListResponse, StorageStats, + DownloadLogInfo, DownloadLogListResponse, + CleanupConfig, CleanupStatus, DiskStats, +) 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"]) @@ -100,3 +106,37 @@ async def download_logs( 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 diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ebb963a..c06e017 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -109,3 +109,23 @@ class DownloadLogListResponse(BaseModel): 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 = {} diff --git a/backend/app/services/cleanup.py b/backend/app/services/cleanup.py new file mode 100644 index 0000000..afc4f8b --- /dev/null +++ b/backend/app/services/cleanup.py @@ -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) diff --git a/frontend/src/views/Admin.vue b/frontend/src/views/Admin.vue index 2cad36a..fefa0c9 100644 --- a/frontend/src/views/Admin.vue +++ b/frontend/src/views/Admin.vue @@ -4,6 +4,7 @@
+
📊 {{ stats.total_videos }} videos · {{ stats.total_size_human }}
@@ -99,6 +100,83 @@ + + +