feat: auto cleanup with retention period, storage limit, settings UI
This commit is contained in:
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
146
backend/app/services/cleanup.py
Normal file
146
backend/app/services/cleanup.py
Normal 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)
|
||||
Reference in New Issue
Block a user