36 Commits

Author SHA1 Message Date
mini
5b92050b1a revert: remove torrent/magnet feature
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-17 21:06:20 +08:00
mini
7d71ba2986 feat: support magnet/torrent download via aria2c
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-17 20:29:18 +08:00
mini
001e6b5239 fix: ensure HLS download shows progress > 0
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-17 20:25:15 +08:00
mini
b76f0aa1f6 feat: support HLS/m3u8 stream download
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-17 20:11:34 +08:00
mini
f03fae2e2e Add friendly error message for Twitter restricted content (TweetTombstone)
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-21 17:55:50 +08:00
mini
c132b13421 Revert "feat: redesign admin download overlay - ring progress + % inside + cancel btn bottom-right"
All checks were successful
continuous-integration/drone/push Build is passing
This reverts commit bc5662bc5f.
2026-02-19 01:36:34 +08:00
mini
bc5662bc5f feat: redesign admin download overlay - ring progress + % inside + cancel btn bottom-right
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-19 01:31:05 +08:00
mini
94eac4a0c2 refactor: remove all download progress UI from admin (to be redesigned)
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-19 01:26:16 +08:00
mini
24b42010b9 config: set admin credentials via env vars
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-19 01:20:23 +08:00
mini
a4064a5373 ci: fix compose project name and missing .env
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-19 01:18:26 +08:00
mini
f2429d0512 ci: trigger Drone pipeline
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-19 01:17:13 +08:00
mini
d1ba5a30f0 ci: add Drone CI pipeline for docker-compose deploy
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-19 01:16:11 +08:00
mini
ec9455f989 revert: remove New Download button from admin 2026-02-19 01:10:54 +08:00
mini
8a6a82a0ea feat: add 'New Download' button in admin video library
-  New Download toggle in search bar
- Inline URL input form with Enter key support
- Calls POST /api/download with format_id=best
- Shows success/error feedback, auto-closes on success
- Refreshes video list and stats after submission
2026-02-19 01:08:50 +08:00
mini
1542c1fc0e ui: add download progress bar + percentage in admin video card 2026-02-19 01:06:55 +08:00
mini
b58d62f745 ui: delete confirm truncate title to 20 chars 2026-02-19 01:03:59 +08:00
mini
b1f705f007 ui: delete confirm shows #id + truncated title (30 chars) 2026-02-19 01:02:42 +08:00
mini
b5c50b0779 fix: cascade delete download_logs when video is deleted
DownloadLog has ondelete=CASCADE on FK but ORM relationship lacked
cascade='all, delete-orphan', causing IntegrityError (NOT NULL) on
video deletion.
2026-02-19 01:00:51 +08:00
mini
d419158c80 fix: DASH multi-phase progress + HLS fragment progress + indeterminate animation 2026-02-19 00:51:15 +08:00
mini
deae827252 fix: run download in thread pool to unblock event loop 2026-02-19 00:43:07 +08:00
mini
b62213f8b5 style: circular progress ring + X cancel button on downloading video thumb 2026-02-19 00:40:29 +08:00
mini
5741945531 feat: real-time download progress, cancel support, admin auto-poll 2026-02-19 00:27:25 +08:00
mini
62a51305c3 fix: detect platform on video create, remove hardcoded twitter default 2026-02-19 00:14:50 +08:00
mini
8e31c4b954 fix: admin panel shows placeholder, error msg and URL for non-done videos 2026-02-19 00:11:09 +08:00
mini
0299aea39b fix: pornhub download format spec and add UA headers 2026-02-19 00:06:08 +08:00
mini
0856b001a9 feat: add Pornhub video download support 2026-02-18 23:54:36 +08:00
mini
58ace106b8 feat: YouTube download, dedup, download logs with geo, admin cleanup settings 2026-02-18 23:52:06 +08:00
mini
f106763723 feat: auto cleanup with retention period, storage limit, settings UI 2026-02-18 23:47:33 +08:00
mini
5bc7f8d1df style: wider container, larger table rows and video cards in admin 2026-02-18 23:41:22 +08:00
mini
64cfccdcc5 fix: move Location column to last in download logs table 2026-02-18 23:36:55 +08:00
mini
97c58ce3f8 feat: add geolocation to download logs (country, city via ip-api.com) 2026-02-18 23:28:39 +08:00
mini
4ac8cf2f66 feat: add Download Logs tab in admin panel 2026-02-18 23:24:34 +08:00
mini
27c9c87f5c feat: track download logs (ip, browser, device, time) 2026-02-18 23:20:50 +08:00
mini
0bab021e21 feat: dedup downloads by (url, format_id) index, reuse existing files 2026-02-18 23:00:49 +08:00
mini
25c4973f57 fix: disable Download button after completion, reset on format change 2026-02-18 22:44:54 +08:00
mini
d3eed07e44 feat: add YouTube video download support 2026-02-18 21:00:17 +08:00
15 changed files with 1564 additions and 136 deletions

View File

@@ -1,24 +1,28 @@
kind: pipeline
type: ssh
name: deploy
server:
host: 217.216.32.230
user: root
ssh_key:
from_secret: ssh_key
type: docker
name: default
trigger:
branch:
- main
event:
- push
- feature/*
steps:
- name: deploy
image: docker:latest
environment:
COMPOSE_PROJECT_NAME: xdl
commands:
- cd /home/xdl/xdl
- git pull origin main
- docker compose build --no-cache
- docker compose up -d
- echo "✅ XDL deployed successfully"
- apk add --no-cache docker-compose
- touch .env
- docker-compose down
- docker-compose build
- docker-compose up -d
volumes:
- name: docker-sock
path: /var/run/docker.sock
volumes:
- name: docker-sock
host:
path: /var/run/docker.sock

View File

@@ -21,3 +21,37 @@ async def get_db():
async def init_db():
async with engine.begin() as conn:
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."""
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)

View File

@@ -1,6 +1,7 @@
"""SQLAlchemy models."""
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
@@ -9,9 +10,9 @@ class Video(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
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="")
platform = Column(String(32), default="twitter")
platform = Column(String(32), default="")
thumbnail = Column(String(1024), default="")
quality = Column(String(32), default="")
format_id = Column(String(64), default="")
@@ -24,3 +25,34 @@ class Video(Base):
progress = Column(Integer, default=0) # 0-100
created_at = Column(DateTime, default=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", cascade="all, delete-orphan")
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,19 @@
"""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
from app.schemas import VideoInfo, VideoListResponse, StorageStats
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
from app.services.downloader import get_progress
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -42,12 +49,13 @@ async def list_videos(
total = (await db.execute(count_query)).scalar() or 0
videos = (await db.execute(query.offset((page - 1) * page_size).limit(page_size))).scalars().all()
return VideoListResponse(
videos=[VideoInfo.model_validate(v) for v in videos],
total=total,
page=page,
page_size=page_size,
)
items = []
for v in videos:
info = VideoInfo.model_validate(v)
if v.status == "downloading":
info.progress = get_progress(v.task_id)
items.append(info)
return VideoListResponse(videos=items, total=total, page=page, page_size=page_size)
@router.delete("/videos/{video_id}")
@@ -68,3 +76,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_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))
@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,23 +1,116 @@
"""Download task routes."""
import uuid
import os
import re
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 sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.schemas import DownloadRequest, DownloadResponse, TaskStatus
from app.database import get_db
from app.models import Video
from app.database import get_db, async_session
from app.models import Video, DownloadLog
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, detect_platform,
register_task, get_progress, request_cancel, cleanup_task,
)
logger = logging.getLogger(__name__)
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):
"""Background download task."""
"""Background download task with real-time progress and cancel support."""
from app.database import async_session
async with async_session() as db:
video = (await db.execute(select(Video).where(Video.task_id == task_id))).scalar_one_or_none()
@@ -27,10 +120,10 @@ async def _do_download(task_id: str, url: str, format_id: str):
video.status = "downloading"
await db.commit()
def update_progress(pct):
pass # Progress tracking in sync context is complex; keep simple
register_task(task_id)
import asyncio
result = await asyncio.to_thread(download_video, url, format_id, None, task_id)
result = download_video(url, format_id, progress_callback=update_progress)
video.title = result["title"]
video.thumbnail = result["thumbnail"]
video.duration = result["duration"]
@@ -43,15 +136,33 @@ async def _do_download(task_id: str, url: str, format_id: str):
await db.commit()
except Exception as e:
logger.error(f"Download failed for {task_id}: {e}")
is_cancel = "Cancelled" in str(e) or "DownloadCancelled" in type(e).__name__
video.status = "error"
video.error_message = str(e)[:500]
video.error_message = "下载已取消,请重试" if is_cancel else str(e)[:500]
video.progress = 0
await db.commit()
finally:
cleanup_task(task_id)
@router.post("/download", response_model=DownloadResponse)
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]
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", platform=detect_platform(req.url))
db.add(video)
await db.commit()
background_tasks.add_task(_do_download, task_id, req.url, req.format_id)
@@ -63,28 +174,42 @@ async def get_download_status(task_id: str, db: AsyncSession = Depends(get_db)):
video = (await db.execute(select(Video).where(Video.task_id == task_id))).scalar_one_or_none()
if not video:
raise HTTPException(status_code=404, detail="Task not found")
# Inject real-time progress for active downloads
progress = get_progress(task_id) if video.status == "downloading" else video.progress
return TaskStatus(
task_id=video.task_id,
status=video.status,
progress=video.progress,
progress=progress,
title=video.title,
error_message=video.error_message or "",
video_id=video.id if video.status == "done" else None,
)
@router.post("/download/{task_id}/cancel")
async def cancel_download(task_id: str, db: AsyncSession = Depends(get_db)):
video = (await db.execute(select(Video).where(Video.task_id == task_id))).scalar_one_or_none()
if not video:
raise HTTPException(status_code=404, detail="Task not found")
if video.status != "downloading":
raise HTTPException(status_code=400, detail="Task is not downloading")
request_cancel(task_id)
return {"ok": True, "message": "Cancel requested"}
@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()
if not video or video.status != "done":
raise HTTPException(status_code=404, detail="Video not found")
if not os.path.exists(video.file_path):
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")
@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
if not user and token:
from app.auth import verify_token
@@ -97,6 +222,7 @@ async def stream_video(video_id: int, token: str = None, user: dict = Depends(op
raise HTTPException(status_code=404, detail="Video not found")
if not os.path.exists(video.file_path):
raise HTTPException(status_code=404, detail="File not found on disk")
background_tasks.add_task(_log_download, video.id, request)
def iter_file():
with open(video.file_path, "rb") as f:
@@ -110,11 +236,12 @@ async def stream_video(video_id: int, token: str = None, user: dict = Depends(op
@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)."""
video = (await db.execute(select(Video).where(Video.task_id == task_id))).scalar_one_or_none()
if not video or video.status != "done":
raise HTTPException(status_code=404, detail="Video not found")
if not os.path.exists(video.file_path):
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")

View File

@@ -16,6 +16,7 @@ async def parse_url(req: ParseRequest):
duration=info["duration"],
formats=[FormatInfo(**f) for f in info["formats"]],
url=info["url"],
platform=info.get("platform", "twitter"),
)
except Exception as 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
formats: list[FormatInfo]
url: str
platform: str = ""
class DownloadRequest(BaseModel):
@@ -56,6 +57,8 @@ class VideoInfo(BaseModel):
file_size: int
duration: int
status: str
progress: int = 0
error_message: str = ""
created_at: datetime
class Config:
@@ -83,3 +86,48 @@ class LoginRequest(BaseModel):
class TokenResponse(BaseModel):
access_token: str
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

@@ -5,6 +5,7 @@ import uuid
import json
import asyncio
import logging
import threading
import urllib.request
from pathlib import Path
from typing import Optional
@@ -12,26 +13,144 @@ import yt_dlp
logger = logging.getLogger(__name__)
# ── In-memory progress / cancel store (thread-safe via GIL) ─────────────────
_download_progress: dict[str, int] = {} # task_id → 0-100
_cancel_flags: dict[str, threading.Event] = {} # task_id → Event
def register_task(task_id: str):
_cancel_flags[task_id] = threading.Event()
_download_progress[task_id] = 0
def get_progress(task_id: str) -> int:
return _download_progress.get(task_id, 0)
def request_cancel(task_id: str):
flag = _cancel_flags.get(task_id)
if flag:
flag.set()
def cleanup_task(task_id: str):
_cancel_flags.pop(task_id, None)
_download_progress.pop(task_id, None)
def _make_hook(task_id: str):
"""yt-dlp progress hook: handles DASH multi-phase + HLS fragments + cancel."""
state = {"phase": 0} # counts "finished" events (video phase, audio phase…)
PHASE_WEIGHTS = [0.80, 0.19] # phase-0 → 0-80%, phase-1 → 80-99%
def hook(d):
flag = _cancel_flags.get(task_id)
if flag and flag.is_set():
raise yt_dlp.utils.DownloadCancelled("Cancelled by user")
if d["status"] == "downloading":
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
done = d.get("downloaded_bytes", 0)
if total > 0:
phase_pct = done / total # 0.01.0
else:
# HLS / unknown size: use fragment index
fc = d.get("fragment_count") or 0
fi = d.get("fragment_index") or 0
phase_pct = (fi / fc) if fc > 0 else 0.5 # 0.5 = "working"
ph = min(state["phase"], len(PHASE_WEIGHTS) - 1)
base = sum(PHASE_WEIGHTS[:ph]) * 100
span = PHASE_WEIGHTS[ph] * 100
pct = int(base + phase_pct * span)
_download_progress[task_id] = max(1, pct) # at least 1 to show activity
elif d["status"] == "finished":
state["phase"] += 1
done_pct = int(sum(PHASE_WEIGHTS[:state["phase"]]) * 100)
_download_progress[task_id] = min(done_pct, 99)
# Ensure at least 1% progress so UI shows activity
if _download_progress.get(task_id, 0) == 0:
_download_progress[task_id] = 1
return hook
VIDEO_BASE_PATH = os.getenv("VIDEO_BASE_PATH", "/home/xdl/xdl_videos")
X_VIDEOS_PATH = os.path.join(VIDEO_BASE_PATH, "x_videos")
YOUTUBE_VIDEOS_PATH = os.path.join(VIDEO_BASE_PATH, "youtube_videos")
PH_VIDEOS_PATH = os.path.join(VIDEO_BASE_PATH, "ph_videos")
HLS_VIDEOS_PATH = os.path.join(VIDEO_BASE_PATH, "hls_videos")
# Ensure directories exist
os.makedirs(X_VIDEOS_PATH, exist_ok=True)
os.makedirs(YOUTUBE_VIDEOS_PATH, exist_ok=True)
os.makedirs(PH_VIDEOS_PATH, exist_ok=True)
os.makedirs(HLS_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
TWITTER_URL_RE = re.compile(
r'https?://(?:(?:www\.)?(?:twitter\.com|x\.com)|[a-z]*twitter\.com)/\w+/status/(\d+)'
)
# Pattern to match Pornhub URLs
PORNHUB_URL_RE = re.compile(
r'https?://(?:[\w-]+\.)?pornhub\.com/(?:view_video\.php\?viewkey=|video/|embed/)[\w-]+'
r'|https?://phub\.to/[\w-]+'
)
def get_video_path(filename: str) -> str:
# Pattern to match HLS / m3u8 URLs (direct stream links)
HLS_URL_RE = re.compile(
r'https?://[^\s]+\.m3u8(?:[?#][^\s]*)?',
re.IGNORECASE,
)
def get_video_path(filename: str, platform: str = "twitter") -> str:
if platform == "youtube":
return os.path.join(YOUTUBE_VIDEOS_PATH, filename)
if platform == "pornhub":
return os.path.join(PH_VIDEOS_PATH, filename)
if platform == "hls":
return os.path.join(HLS_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_pornhub_url(url: str) -> bool:
return bool(PORNHUB_URL_RE.match(url))
def detect_platform(url: str) -> str:
"""Detect platform from URL."""
if _is_twitter_url(url):
return "twitter"
if _is_youtube_url(url):
return "youtube"
if _is_pornhub_url(url):
return "pornhub"
if _is_hls_url(url):
return "hls"
return "unknown"
def _is_twitter_url(url: str) -> bool:
return bool(TWITTER_URL_RE.match(url))
def _is_hls_url(url: str) -> bool:
return bool(HLS_URL_RE.match(url))
def _extract_tweet_id(url: str) -> Optional[str]:
m = TWITTER_URL_RE.match(url)
return m.group(1) if m else None
@@ -44,7 +163,13 @@ def _twitter_syndication_info(tweet_id: str) -> dict:
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
resp = urllib.request.urlopen(req, timeout=15)
return json.loads(resp.read().decode())
data = json.loads(resp.read().decode())
# Check for restricted content (TweetTombstone)
if data.get('__typename') == 'TweetTombstone':
raise ValueError('内容受限不支持下载(敏感内容/年龄限制),需要登录账号访问')
return data
def _parse_twitter_video(url: str) -> dict:
@@ -111,7 +236,7 @@ def _parse_twitter_video(url: str) -> dict:
}
def _download_twitter_video(url: str, format_id: str = "best", progress_callback=None) -> dict:
def _download_twitter_video(url: str, format_id: str = "best", progress_callback=None, task_id: str = None) -> dict:
"""Download Twitter video using syndication API."""
tweet_id = _extract_tweet_id(url)
if not tweet_id:
@@ -160,14 +285,22 @@ def _download_twitter_video(url: str, format_id: str = "best", progress_callback
with open(filename, 'wb') as f:
while True:
# Check cancel flag
if task_id and _cancel_flags.get(task_id, threading.Event()).is_set():
raise yt_dlp.utils.DownloadCancelled("Cancelled by user")
chunk = resp.read(65536)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
pct = int(downloaded * 100 / total) if total > 0 else 0
if task_id:
_download_progress[task_id] = pct
if progress_callback and total > 0:
progress_callback(int(downloaded * 100 / total))
progress_callback(pct)
if task_id:
_download_progress[task_id] = 99
if progress_callback:
progress_callback(100)
@@ -184,19 +317,341 @@ def _download_twitter_video(url: str, format_id: str = "best", progress_callback
}
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)
# Remove internal keys before returning
result.pop('_formats_full', None)
return result
except Exception as e:
logger.warning(f"Twitter syndication failed, falling back to yt-dlp: {e}")
def _parse_youtube_video(url: str) -> dict:
"""Parse YouTube video info using yt-dlp."""
ydl_opts = {
"quiet": True,
"no_warnings": True,
"extract_flat": False,
"skip_download": True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
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, task_id: str = 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"
hooks = [_make_hook(task_id)] if task_id else []
ydl_opts = {
"format": format_spec,
"outtmpl": output_template,
"merge_output_format": "mp4",
"quiet": True,
"no_warnings": True,
"progress_hooks": hooks,
}
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",
}
_PH_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
"Referer": "https://www.pornhub.com/",
}
def _parse_pornhub_video(url: str) -> dict:
"""Parse Pornhub video info using yt-dlp."""
ydl_opts = {
"quiet": True,
"no_warnings": True,
"extract_flat": False,
"skip_download": True,
"http_headers": _PH_HEADERS,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
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"
if quality in seen:
continue
seen.add(quality)
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": "pornhub",
}
def _download_pornhub_video(url: str, format_id: str = "best", progress_callback=None, task_id: str = None) -> dict:
"""Download Pornhub video using yt-dlp."""
task_id = str(uuid.uuid4())[:8]
output_template = os.path.join(PH_VIDEOS_PATH, f"%(id)s_{task_id}.%(ext)s")
if format_id == "best":
# Prefer mp4 with audio; fall back to best available
format_spec = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio/best[ext=mp4]/best"
else:
# The format may already contain audio (merged); try with audio fallback gracefully
format_spec = f"{format_id}+bestaudio/{format_id}/best"
hooks = [_make_hook(task_id)] if task_id else []
ydl_opts = {
"format": format_spec,
"outtmpl": output_template,
"merge_output_format": "mp4",
"quiet": True,
"no_warnings": True,
"http_headers": _PH_HEADERS,
"progress_hooks": hooks,
}
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": "pornhub",
}
def _parse_hls_video(url: str) -> dict:
"""Parse HLS/m3u8 stream info using yt-dlp."""
ydl_opts = {
"quiet": True,
"no_warnings": True,
"skip_download": True,
"allowed_extractors": ["generic"],
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
except Exception:
# If yt-dlp can't parse, return minimal info to allow direct download
return {
"title": "HLS Stream",
"thumbnail": "",
"duration": 0,
"formats": [{"format_id": "best", "quality": "best", "ext": "mp4", "filesize": 0, "note": "HLS stream (auto-merge)"}],
"url": url,
"platform": "hls",
}
formats = []
seen = set()
for f in (info.get("formats") or []):
if f.get("vcodec", "none") == "none":
continue
height = f.get("height", 0)
fmt_id = f.get("format_id", "")
quality = f"{height}p" if height else f.get("format_note", "HLS")
key = quality
if key in seen:
continue
seen.add(key)
formats.append({
"format_id": fmt_id,
"quality": quality,
"ext": "mp4",
"filesize": f.get("filesize") or f.get("filesize_approx") or 0,
"note": f.get("format_note", "HLS"),
})
formats.sort(key=lambda x: int(x["quality"].replace("p", "")) if x["quality"].endswith("p") else 0, reverse=True)
formats.insert(0, {"format_id": "best", "quality": "best", "ext": "mp4", "filesize": 0, "note": "Best available quality"})
return {
"title": info.get("title") or "HLS Stream",
"thumbnail": info.get("thumbnail", ""),
"duration": info.get("duration", 0) or 0,
"formats": formats,
"url": url,
"platform": "hls",
}
def _download_hls_video(url: str, format_id: str = "best", progress_callback=None, task_id: str = None) -> dict:
"""Download HLS/m3u8 stream using yt-dlp (handles segment merge automatically)."""
uid = str(uuid.uuid4())[:8]
output_template = os.path.join(HLS_VIDEOS_PATH, f"hls_{uid}.%(ext)s")
if format_id == "best":
format_spec = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio/best[ext=mp4]/best"
else:
format_spec = f"{format_id}+bestaudio/{format_id}/best"
hooks = [_make_hook(task_id)] if task_id else []
ydl_opts = {
"format": format_spec,
"outtmpl": output_template,
"merge_output_format": "mp4",
"quiet": True,
"no_warnings": True,
"progress_hooks": hooks,
"allowed_extractors": ["generic", "m3u8"],
# HLS-specific: concurrent fragment download for speed
"concurrent_fragment_downloads": 5,
}
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
title = info.get("title") or "HLS Stream"
return {
"title": title,
"thumbnail": info.get("thumbnail", ""),
"duration": info.get("duration", 0) or 0,
"filename": os.path.basename(filename),
"file_path": filename,
"file_size": file_size,
"platform": "hls",
}
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 ValueError as e:
error_msg = str(e)
# If it's restricted content error, don't fallback to yt-dlp
if '内容受限不支持下载' in error_msg:
logger.error(f"Twitter content restricted: {error_msg}")
raise
# For other errors, fallback to yt-dlp
logger.warning(f"Twitter syndication failed, falling back to yt-dlp: {e}")
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)
# Pornhub URLs
if _is_pornhub_url(url):
logger.info(f"Parsing Pornhub video: {url}")
return _parse_pornhub_video(url)
# HLS / m3u8 direct stream URLs
if _is_hls_url(url):
logger.info(f"Parsing HLS stream: {url}")
return _parse_hls_video(url)
# Fallback to generic yt-dlp
ydl_opts = {
"quiet": True,
"no_warnings": True,
@@ -209,7 +664,6 @@ def parse_video_url(url: str) -> dict:
formats = []
seen = set()
for f in info.get("formats", []):
# Only video formats with both video and audio, or video-only
if f.get("vcodec", "none") == "none":
continue
height = f.get("height", 0)
@@ -228,10 +682,8 @@ def parse_video_url(url: str) -> dict:
"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)
# Add a "best" option
formats.insert(0, {
"format_id": "best",
"quality": "best",
@@ -249,29 +701,45 @@ def parse_video_url(url: str) -> dict:
}
def download_video(url: str, format_id: str = "best", progress_callback=None) -> dict:
def download_video(url: str, format_id: str = "best", progress_callback=None, task_id: str = None) -> dict:
"""Download video and return file info."""
# Use syndication API for Twitter/X URLs
if _is_twitter_url(url):
logger.info(f"Using Twitter syndication API for download: {url}")
try:
return _download_twitter_video(url, format_id, progress_callback)
return _download_twitter_video(url, format_id, progress_callback, task_id=task_id)
except ValueError as e:
error_msg = str(e)
# If it's restricted content error, don't fallback to yt-dlp
if '内容受限不支持下载' in error_msg:
logger.error(f"Twitter content restricted: {error_msg}")
raise
# For other errors, fallback to yt-dlp
logger.warning(f"Twitter syndication download failed, falling back to yt-dlp: {e}")
except Exception as 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=task_id)
# Pornhub URLs
if _is_pornhub_url(url):
logger.info(f"Downloading Pornhub video: {url}")
return _download_pornhub_video(url, format_id, progress_callback, task_id=task_id)
# HLS / m3u8 direct stream URLs
if _is_hls_url(url):
logger.info(f"Downloading HLS stream: {url}")
return _download_hls_video(url, format_id, progress_callback, task_id=task_id)
task_id = str(uuid.uuid4())[:8]
output_template = os.path.join(X_VIDEOS_PATH, f"%(id)s_{task_id}.%(ext)s")
format_spec = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" if format_id == "best" else 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)
hooks = [_make_hook(task_id)] if task_id else []
ydl_opts = {
"format": format_spec,
@@ -279,7 +747,7 @@ def download_video(url: str, format_id: str = "best", progress_callback=None) ->
"merge_output_format": "mp4",
"quiet": True,
"no_warnings": True,
"progress_hooks": [hook],
"progress_hooks": hooks,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
@@ -299,5 +767,5 @@ def download_video(url: str, format_id: str = "best", progress_callback=None) ->
"filename": os.path.basename(filename),
"file_path": filename,
"file_size": file_size,
"platform": "twitter",
"platform": detect_platform(url),
}

View File

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

View File

@@ -8,6 +8,8 @@ services:
- xdl_data:/app/data
environment:
- DATABASE_URL=sqlite+aiosqlite:///./data/xdl.db
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=Qweewqzzx1
frontend:
build: ./frontend

View File

@@ -30,5 +30,5 @@ const auth = useAuthStore()
.nav-links { display: flex; gap: 1.5rem; }
.nav-links a { color: #aaa; text-decoration: none; transition: color 0.2s; }
.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>

View File

@@ -1,12 +1,15 @@
<template>
<div class="admin">
<div class="header">
<h2>Video Library</h2>
<div v-if="stats" class="stats">
📊 {{ stats.total_videos }} videos · {{ stats.total_size_human }}
</div>
<!-- Tab switcher -->
<div class="tabs">
<button :class="{ active: tab === 'videos' }" @click="tab = 'videos'">📹 Video Library</button>
<button :class="{ active: tab === 'logs' }" @click="tab = 'logs'; fetchLogs()">📋 Download Logs</button>
<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>
<!-- Video Library -->
<template v-if="tab === 'videos'">
<div class="search-bar">
<input v-model="search" placeholder="Search videos..." @input="debouncedFetch" />
<select v-model="filterStatus" @change="fetchVideos">
@@ -21,22 +24,43 @@
<div class="video-list">
<div v-for="v in videos" :key="v.id" class="video-card">
<div class="video-main">
<img v-if="v.thumbnail" :src="v.thumbnail" class="thumb" />
<div class="info">
<h4>{{ v.title || 'Untitled' }}</h4>
<div class="meta">
<span :class="'status-' + v.status">{{ v.status }}</span>
<span>{{ v.quality }}</span>
<span>{{ humanSize(v.file_size) }}</span>
<span>{{ new Date(v.created_at).toLocaleString() }}</span>
<!-- Thumbnail or placeholder -->
<div class="thumb-wrap">
<img v-if="v.thumbnail && v.status === 'done'" :src="v.thumbnail" class="thumb" />
<div v-else class="thumb-placeholder">
<span>{{ platformIcon(v.platform || v.url) }}</span>
</div>
<div v-if="v.status === 'downloading'" class="thumb-overlay"></div>
<div v-else-if="v.status === 'pending'" class="thumb-overlay">🕐</div>
<div v-else-if="v.status === 'error'" class="thumb-overlay err-overlay"></div>
</div>
<div class="info">
<h4 :title="v.title || v.url">{{ v.title || truncateUrl(v.url) }}</h4>
<div class="meta">
<span :class="'status-badge status-' + v.status">{{ statusLabel(v.status) }}</span>
<span v-if="v.platform" class="platform-tag">{{ v.platform }}</span>
<span v-if="v.quality">{{ v.quality }}</span>
<span v-if="v.file_size">{{ humanSize(v.file_size) }}</span>
<span>{{ fmtTime(v.created_at) }}</span>
</div>
<!-- Error message -->
<div v-if="v.status === 'error' && v.error_message" class="error-msg" :title="v.error_message">
{{ v.error_message }}
</div>
<!-- URL for non-done -->
<div v-if="v.status !== 'done'" class="video-url">{{ v.url }}</div>
</div>
</div>
<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>
<a v-if="v.status === 'done'" :href="'/api/file/' + v.id" class="btn-dl"
:download="v.filename" @click.prevent="downloadAuth(v)">💾</a>
<button @click="deleteVideo(v)" class="btn-del">🗑</button>
<button v-if="v.status !== 'downloading'" @click="deleteVideo(v)" class="btn-del">🗑</button>
</div>
</div>
<div v-if="!videos.length" class="empty">No videos found</div>
@@ -47,6 +71,130 @@
<span>{{ page }} / {{ totalPages }}</span>
<button :disabled="page >= totalPages" @click="page++; fetchVideos()">Next</button>
</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 -->
<div v-if="playing" class="modal" @click.self="playing = null">
@@ -64,6 +212,9 @@ import axios from 'axios'
import { useAuthStore } from '../stores/auth.js'
const auth = useAuthStore()
const tab = ref('videos')
// ── Videos ──
const videos = ref([])
const stats = ref(null)
const search = ref('')
@@ -73,9 +224,98 @@ const total = ref(0)
const pageSize = 20
const playing = ref(null)
const playUrl = ref('')
const totalPages = computed(() => Math.ceil(total.value / pageSize) || 1)
let pollTimer = null
// ── 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) {
if (!bytes) return '0 B'
for (const u of ['B', 'KB', 'MB', 'GB']) {
@@ -85,6 +325,43 @@ function humanSize(bytes) {
return `${bytes.toFixed(1)} TB`
}
function fmtTime(ts) {
if (!ts) return ''
return new Date(ts).toLocaleString('zh-CN', { hour12: false })
}
function platformIcon(platformOrUrl) {
const s = (platformOrUrl || '').toLowerCase()
if (s.includes('youtube')) return '▶'
if (s.includes('pornhub')) return '🔞'
if (s.includes('twitter') || s.includes('x.com')) return '𝕏'
return '🎬'
}
function statusLabel(status) {
return { done: '✅ Done', downloading: '⏳ Downloading', pending: '🕐 Pending', error: '❌ Error', deleted: '🗑 Deleted' }[status] || status
}
function truncateUrl(url) {
if (!url) return 'Unknown'
try { return new URL(url).hostname + '...' } catch { return url.slice(0, 40) + '...' }
}
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
function debouncedFetch() {
clearTimeout(debounceTimer)
@@ -99,11 +376,29 @@ async function fetchVideos() {
})
videos.value = res.data.videos
total.value = res.data.total
// Auto-poll while any video is downloading
const hasDownloading = res.data.videos.some(v => v.status === 'downloading')
if (hasDownloading && tab.value === 'videos') {
if (!pollTimer) pollTimer = setInterval(fetchVideos, 2000)
} else {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
} catch (e) {
if (e.response?.status === 401) { auth.logout(); location.href = '/login' }
}
}
async function cancelDownload(v) {
try {
await axios.post(`/api/download/${v.task_id}/cancel`, {}, { headers: auth.getHeaders() })
v.status = 'error'
v.error_message = '下载已取消,请重试'
await fetchVideos()
} catch (e) {
alert('Cancel failed: ' + (e.response?.data?.detail || e.message))
}
}
async function fetchStats() {
try {
const res = await axios.get('/api/admin/stats', { headers: auth.getHeaders() })
@@ -124,38 +419,108 @@ async function downloadAuth(v) {
}
async function deleteVideo(v) {
if (!confirm(`Delete "${v.title}"?`)) return
const shortTitle = v.title && v.title.length > 20 ? v.title.slice(0, 20) + '…' : (v.title || 'Untitled')
if (!confirm(`Delete #${v.id} "${shortTitle}"?`)) return
try {
await axios.delete(`/api/admin/videos/${v.id}`, { headers: auth.getHeaders() })
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) {
alert('Delete failed')
if (e.response?.status === 401) { auth.logout(); location.href = '/login' }
}
}
onMounted(() => { fetchVideos(); fetchStats() })
// Clean up poll timer when leaving videos tab
import { watch, onUnmounted } from 'vue'
watch(tab, (val) => {
if (val !== 'videos' && pollTimer) { clearInterval(pollTimer); pollTimer = null }
if (val === 'videos') fetchVideos()
})
onUnmounted(() => { if (pollTimer) clearInterval(pollTimer) })
</script>
<style scoped>
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.stats { color: #888; }
.tabs { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1.5rem; border-bottom: 1px solid #333; padding-bottom: 0.8rem; }
.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 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; }
.video-card {
display: flex; justify-content: space-between; align-items: center;
background: #1a1a2e; border-radius: 10px; padding: 1rem; margin-bottom: 0.8rem;
gap: 1rem;
background: #1a1a2e; border-radius: 10px; padding: 1.1rem 1.3rem; margin-bottom: 0.9rem; gap: 1.2rem;
border: 1px solid #2a2a3e;
}
.video-main { display: flex; gap: 1rem; flex: 1; min-width: 0; }
.thumb { width: 80px; height: 45px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.video-main { display: flex; gap: 1.2rem; flex: 1; min-width: 0; }
.thumb-wrap { position: relative; flex-shrink: 0; width: 112px; height: 63px; }
.thumb { width: 112px; height: 63px; object-fit: cover; border-radius: 6px; }
.thumb-placeholder {
width: 112px; height: 63px; border-radius: 6px; background: #12122a;
border: 1px solid #333; display: flex; align-items: center; justify-content: center;
font-size: 1.6rem; color: #555;
}
.thumb-overlay {
position: absolute; inset: 0; border-radius: 6px;
background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center;
font-size: 1.3rem;
}
.err-overlay { background: rgba(180,0,0,0.45); }
.info { min-width: 0; }
.info h4 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 0.3rem; }
.meta { display: flex; gap: 0.8rem; font-size: 0.85rem; color: #888; flex-wrap: wrap; }
.info h4 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 0.4rem; font-size: 1rem; }
.meta { display: flex; gap: 1rem; font-size: 0.88rem; color: #888; flex-wrap: wrap; }
.status-badge { font-size: 0.82rem; font-weight: 600; }
.status-done { color: #27ae60; }
.status-downloading { color: #f39c12; }
.status-error { color: #e74c3c; }
.status-pending { color: #888; }
.status-deleted { color: #555; }
.platform-tag { color: #1da1f2; font-size: 0.82rem; text-transform: capitalize; }
.error-msg {
color: #e74c3c; font-size: 0.8rem; margin-top: 0.3rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 500px;
}
.video-url { color: #555; font-size: 0.78rem; margin-top: 0.2rem; word-break: break-all; }
.actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
.actions button, .actions a {
padding: 0.4rem 0.6rem; border: 1px solid #444; border-radius: 6px;
@@ -163,6 +528,85 @@ onMounted(() => { fetchVideos(); fetchStats() })
}
.actions button:hover, .actions a:hover { border-color: #1da1f2; }
.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; }
.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; }
@@ -171,4 +615,5 @@ onMounted(() => { fetchVideos(); fetchStats() })
.player-wrap { position: relative; max-width: 90vw; max-height: 90vh; }
.player { max-width: 90vw; max-height: 85vh; border-radius: 8px; }
.close-btn { position: absolute; top: -40px; right: 0; background: none; border: none; color: #fff; font-size: 1.5rem; cursor: pointer; }
</style>

View File

@@ -1,10 +1,10 @@
<template>
<div class="home">
<h1>Twitter/X Video Downloader</h1>
<p class="subtitle">Paste a Twitter/X video link to download</p>
<h1>Video Downloader</h1>
<p class="subtitle">Paste a Twitter/X, YouTube, or Pornhub video link to download</p>
<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=... or https://pornhub.com/view_video.php?viewkey=..." @keyup.enter="parseUrl" :disabled="loading" />
<button @click="parseUrl" :disabled="loading || !url.trim()">
{{ loading ? '⏳ Parsing...' : '🔍 Parse' }}
</button>
@@ -20,23 +20,24 @@
<div class="formats">
<h4>Select Quality:</h4>
<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="ext">{{ fmt.ext }}</span>
<span v-if="fmt.filesize" class="size">~{{ humanSize(fmt.filesize) }}</span>
</div>
</div>
<button class="download-btn" @click="startDownload" :disabled="downloading">
{{ downloading ? '⏳ Downloading...' : '📥 Download' }}
<button class="download-btn" @click="startDownload" :disabled="downloading || downloadReady">
{{ downloading ? '⏳ Downloading...' : downloadReady ? '✅ Downloaded' : '📥 Download' }}
</button>
<button v-if="downloading && taskId" class="cancel-btn" @click="cancelDownload"> Cancel</button>
</div>
<div v-if="taskId" class="task-status">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
</div>
<p>{{ statusText }}</p>
<p>{{ statusText.replace('0%', '…') }}</p>
<a v-if="downloadReady" :href="downloadUrl" class="download-link" download>💾 Save to device</a>
</div>
</div>
@@ -94,8 +95,17 @@ async function startDownload() {
url: url.value, format_id: selectedFormat.value, quality: selectedFormat.value
})
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...'
pollStatus()
}
} catch (e) {
error.value = e.response?.data?.detail || 'Failed to start download'
downloading.value = false
@@ -114,7 +124,9 @@ async function pollStatus() {
downloadReady.value = true
downloadUrl.value = `/api/file/task/${taskId.value}`
} else if (d.status === 'error') {
statusText.value = `❌ Error: ${d.error_message}`
const msg = d.error_message || '下载出错,请重试'
statusText.value = `${msg}`
error.value = msg
downloading.value = false
} else {
statusText.value = `${d.status}... ${d.progress}%`
@@ -124,6 +136,19 @@ async function pollStatus() {
setTimeout(pollStatus, 3000)
}
}
async function cancelDownload() {
if (!taskId.value) return
try {
await axios.post(`/api/download/${taskId.value}/cancel`)
downloading.value = false
error.value = '下载已取消,请重试'
statusText.value = ''
progress.value = 0
} catch {
error.value = '取消失败,请稍后重试'
}
}
</script>
<style scoped>
@@ -160,9 +185,22 @@ h1 { font-size: 2rem; margin-bottom: 0.5rem; }
.ext { color: #888; }
.size { color: #888; margin-left: auto; }
.download-btn { width: 100%; margin-top: 1rem; padding: 1rem; font-size: 1.1rem; }
.cancel-btn {
width: 100%; margin-top: 0.5rem; padding: 0.7rem; font-size: 0.95rem;
border: 1px solid #e74c3c; border-radius: 8px; background: transparent;
color: #e74c3c; cursor: pointer;
}
.cancel-btn:hover { background: rgba(231,76,60,0.1); }
.task-status { margin-top: 1.5rem; }
.progress-bar { height: 8px; background: #333; border-radius: 4px; overflow: hidden; margin-bottom: 0.5rem; }
.progress-fill { height: 100%; background: #1da1f2; transition: width 0.3s; }
.progress-fill { height: 100%; background: #1da1f2; transition: width 0.5s; }
.progress-bar:has(.progress-fill[style="width: 0%"]),
.progress-bar:has(.progress-fill[style="width: 1%"]) {
background: linear-gradient(90deg, #1a1a2e 25%, #1da1f2 50%, #1a1a2e 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer { to { background-position: -200% 0; } }
.download-link {
display: inline-block; margin-top: 0.5rem; padding: 0.8rem 2rem;
background: #27ae60; color: #fff; border-radius: 8px; text-decoration: none; font-weight: 600;