27 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
9 changed files with 563 additions and 71 deletions

View File

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

@@ -12,7 +12,7 @@ class Video(Base):
task_id = Column(String(64), unique=True, index=True, nullable=False) task_id = Column(String(64), unique=True, index=True, nullable=False)
url = Column(String(512), nullable=False, index=True) url = Column(String(512), nullable=False, index=True)
title = Column(String(512), default="") title = Column(String(512), default="")
platform = Column(String(32), default="twitter") platform = Column(String(32), default="")
thumbnail = Column(String(1024), default="") thumbnail = Column(String(1024), default="")
quality = Column(String(32), default="") quality = Column(String(32), default="")
format_id = Column(String(64), default="") format_id = Column(String(64), default="")
@@ -30,7 +30,7 @@ class Video(Base):
Index("ix_video_url_format_id", "url", "format_id"), Index("ix_video_url_format_id", "url", "format_id"),
) )
logs = relationship("DownloadLog", back_populates="video", lazy="select") logs = relationship("DownloadLog", back_populates="video", lazy="select", cascade="all, delete-orphan")
class DownloadLog(Base): class DownloadLog(Base):

View File

@@ -13,6 +13,7 @@ from app.schemas import (
) )
from app.auth import get_current_user from app.auth import get_current_user
from app.services.cleanup import get_setting, set_setting, disk_stats, run_cleanup 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"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -48,12 +49,13 @@ async def list_videos(
total = (await db.execute(count_query)).scalar() or 0 total = (await db.execute(count_query)).scalar() or 0
videos = (await db.execute(query.offset((page - 1) * page_size).limit(page_size))).scalars().all() videos = (await db.execute(query.offset((page - 1) * page_size).limit(page_size))).scalars().all()
return VideoListResponse( items = []
videos=[VideoInfo.model_validate(v) for v in videos], for v in videos:
total=total, info = VideoInfo.model_validate(v)
page=page, if v.status == "downloading":
page_size=page_size, 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}") @router.delete("/videos/{video_id}")

View File

@@ -12,7 +12,10 @@ from app.schemas import DownloadRequest, DownloadResponse, TaskStatus
from app.database import get_db, async_session from app.database import get_db, async_session
from app.models import Video, DownloadLog from app.models import Video, DownloadLog
from app.auth import get_current_user, optional_auth from app.auth import get_current_user, optional_auth
from app.services.downloader import download_video, get_video_path from app.services.downloader import (
download_video, get_video_path, detect_platform,
register_task, get_progress, request_cancel, cleanup_task,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["download"]) router = APIRouter(prefix="/api", tags=["download"])
@@ -107,7 +110,7 @@ async def _log_download(video_id: int, request: Request):
async def _do_download(task_id: str, url: str, format_id: str): async def _do_download(task_id: str, url: str, format_id: str):
"""Background download task.""" """Background download task with real-time progress and cancel support."""
from app.database import async_session from app.database import async_session
async with async_session() as db: async with async_session() as db:
video = (await db.execute(select(Video).where(Video.task_id == task_id))).scalar_one_or_none() video = (await db.execute(select(Video).where(Video.task_id == task_id))).scalar_one_or_none()
@@ -117,10 +120,10 @@ async def _do_download(task_id: str, url: str, format_id: str):
video.status = "downloading" video.status = "downloading"
await db.commit() await db.commit()
def update_progress(pct): register_task(task_id)
pass # Progress tracking in sync context is complex; keep simple 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.title = result["title"]
video.thumbnail = result["thumbnail"] video.thumbnail = result["thumbnail"]
video.duration = result["duration"] video.duration = result["duration"]
@@ -133,9 +136,13 @@ async def _do_download(task_id: str, url: str, format_id: str):
await db.commit() await db.commit()
except Exception as e: except Exception as e:
logger.error(f"Download failed for {task_id}: {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.status = "error"
video.error_message = str(e)[:500] video.error_message = "下载已取消,请重试" if is_cancel else str(e)[:500]
video.progress = 0
await db.commit() await db.commit()
finally:
cleanup_task(task_id)
@router.post("/download", response_model=DownloadResponse) @router.post("/download", response_model=DownloadResponse)
@@ -154,7 +161,8 @@ async def start_download(req: DownloadRequest, background_tasks: BackgroundTasks
return DownloadResponse(task_id=existing.task_id, status="done") return DownloadResponse(task_id=existing.task_id, status="done")
task_id = str(uuid.uuid4())[:8] task_id = str(uuid.uuid4())[:8]
video = Video(task_id=task_id, url=req.url, quality=req.quality, format_id=req.format_id, status="pending") video = Video(task_id=task_id, url=req.url, quality=req.quality, format_id=req.format_id,
status="pending", platform=detect_platform(req.url))
db.add(video) db.add(video)
await db.commit() await db.commit()
background_tasks.add_task(_do_download, task_id, req.url, req.format_id) background_tasks.add_task(_do_download, task_id, req.url, req.format_id)
@@ -166,16 +174,29 @@ 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() video = (await db.execute(select(Video).where(Video.task_id == task_id))).scalar_one_or_none()
if not video: if not video:
raise HTTPException(status_code=404, detail="Task not found") 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( return TaskStatus(
task_id=video.task_id, task_id=video.task_id,
status=video.status, status=video.status,
progress=video.progress, progress=progress,
title=video.title, title=video.title,
error_message=video.error_message or "", error_message=video.error_message or "",
video_id=video.id if video.status == "done" else None, 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}") @router.get("/file/{video_id}")
async def download_file(video_id: int, request: Request, background_tasks: BackgroundTasks, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def download_file(video_id: int, request: Request, background_tasks: BackgroundTasks, user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
video = (await db.execute(select(Video).where(Video.id == video_id))).scalar_one_or_none() video = (await db.execute(select(Video).where(Video.id == video_id))).scalar_one_or_none()

View File

@@ -57,6 +57,8 @@ class VideoInfo(BaseModel):
file_size: int file_size: int
duration: int duration: int
status: str status: str
progress: int = 0
error_message: str = ""
created_at: datetime created_at: datetime
class Config: class Config:

View File

@@ -5,6 +5,7 @@ import uuid
import json import json
import asyncio import asyncio
import logging import logging
import threading
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -12,13 +13,81 @@ import yt_dlp
logger = logging.getLogger(__name__) 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") VIDEO_BASE_PATH = os.getenv("VIDEO_BASE_PATH", "/home/xdl/xdl_videos")
X_VIDEOS_PATH = os.path.join(VIDEO_BASE_PATH, "x_videos") X_VIDEOS_PATH = os.path.join(VIDEO_BASE_PATH, "x_videos")
YOUTUBE_VIDEOS_PATH = os.path.join(VIDEO_BASE_PATH, "youtube_videos") 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 # Ensure directories exist
os.makedirs(X_VIDEOS_PATH, exist_ok=True) os.makedirs(X_VIDEOS_PATH, exist_ok=True)
os.makedirs(YOUTUBE_VIDEOS_PATH, exist_ok=True) 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 # Pattern to match YouTube URLs
YOUTUBE_URL_RE = re.compile( YOUTUBE_URL_RE = re.compile(
@@ -30,10 +99,26 @@ TWITTER_URL_RE = re.compile(
r'https?://(?:(?:www\.)?(?:twitter\.com|x\.com)|[a-z]*twitter\.com)/\w+/status/(\d+)' 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-]+'
)
# 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: def get_video_path(filename: str, platform: str = "twitter") -> str:
if platform == "youtube": if platform == "youtube":
return os.path.join(YOUTUBE_VIDEOS_PATH, filename) 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) return os.path.join(X_VIDEOS_PATH, filename)
@@ -41,10 +126,31 @@ def _is_youtube_url(url: str) -> bool:
return bool(YOUTUBE_URL_RE.match(url)) 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: def _is_twitter_url(url: str) -> bool:
return bool(TWITTER_URL_RE.match(url)) 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]: def _extract_tweet_id(url: str) -> Optional[str]:
m = TWITTER_URL_RE.match(url) m = TWITTER_URL_RE.match(url)
return m.group(1) if m else None return m.group(1) if m else None
@@ -57,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' 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}) })
resp = urllib.request.urlopen(req, timeout=15) 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: def _parse_twitter_video(url: str) -> dict:
@@ -124,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.""" """Download Twitter video using syndication API."""
tweet_id = _extract_tweet_id(url) tweet_id = _extract_tweet_id(url)
if not tweet_id: if not tweet_id:
@@ -173,14 +285,22 @@ def _download_twitter_video(url: str, format_id: str = "best", progress_callback
with open(filename, 'wb') as f: with open(filename, 'wb') as f:
while True: 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) chunk = resp.read(65536)
if not chunk: if not chunk:
break break
f.write(chunk) f.write(chunk)
downloaded += len(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: 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: if progress_callback:
progress_callback(100) progress_callback(100)
@@ -251,7 +371,7 @@ def _parse_youtube_video(url: str) -> dict:
} }
def _download_youtube_video(url: str, format_id: str = "best", progress_callback=None) -> dict: def _download_youtube_video(url: str, format_id: str = "best", progress_callback=None, task_id: str = None) -> dict:
"""Download YouTube video using yt-dlp.""" """Download YouTube video using yt-dlp."""
task_id = str(uuid.uuid4())[:8] task_id = str(uuid.uuid4())[:8]
output_template = os.path.join(YOUTUBE_VIDEOS_PATH, f"%(id)s_{task_id}.%(ext)s") output_template = os.path.join(YOUTUBE_VIDEOS_PATH, f"%(id)s_{task_id}.%(ext)s")
@@ -261,14 +381,7 @@ def _download_youtube_video(url: str, format_id: str = "best", progress_callback
else: else:
format_spec = f"{format_id}+bestaudio/best" format_spec = f"{format_id}+bestaudio/best"
def hook(d): hooks = [_make_hook(task_id)] if task_id else []
if d["status"] == "downloading" and progress_callback:
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
downloaded = d.get("downloaded_bytes", 0)
pct = int(downloaded * 100 / total) if total > 0 else 0
progress_callback(pct)
elif d["status"] == "finished" and progress_callback:
progress_callback(100)
ydl_opts = { ydl_opts = {
"format": format_spec, "format": format_spec,
@@ -276,7 +389,7 @@ def _download_youtube_video(url: str, format_id: str = "best", progress_callback
"merge_output_format": "mp4", "merge_output_format": "mp4",
"quiet": True, "quiet": True,
"no_warnings": True, "no_warnings": True,
"progress_hooks": [hook], "progress_hooks": hooks,
} }
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
@@ -299,6 +412,210 @@ def _download_youtube_video(url: str, format_id: str = "best", progress_callback
} }
_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: def parse_video_url(url: str) -> dict:
"""Extract video info without downloading.""" """Extract video info without downloading."""
# Use syndication API for Twitter/X URLs # Use syndication API for Twitter/X URLs
@@ -308,6 +625,14 @@ def parse_video_url(url: str) -> dict:
result = _parse_twitter_video(url) result = _parse_twitter_video(url)
result.pop('_formats_full', None) result.pop('_formats_full', None)
return result 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: except Exception as e:
logger.warning(f"Twitter syndication failed, falling back to yt-dlp: {e}") logger.warning(f"Twitter syndication failed, falling back to yt-dlp: {e}")
@@ -316,6 +641,16 @@ def parse_video_url(url: str) -> dict:
logger.info(f"Parsing YouTube video: {url}") logger.info(f"Parsing YouTube video: {url}")
return _parse_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 # Fallback to generic yt-dlp
ydl_opts = { ydl_opts = {
"quiet": True, "quiet": True,
@@ -366,34 +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.""" """Download video and return file info."""
# Use syndication API for Twitter/X URLs # Use syndication API for Twitter/X URLs
if _is_twitter_url(url): if _is_twitter_url(url):
logger.info(f"Using Twitter syndication API for download: {url}") logger.info(f"Using Twitter syndication API for download: {url}")
try: 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: except Exception as e:
logger.warning(f"Twitter syndication download failed, falling back to yt-dlp: {e}") logger.warning(f"Twitter syndication download failed, falling back to yt-dlp: {e}")
# YouTube URLs # YouTube URLs
if _is_youtube_url(url): if _is_youtube_url(url):
logger.info(f"Downloading YouTube video: {url}") logger.info(f"Downloading YouTube video: {url}")
return _download_youtube_video(url, format_id, progress_callback) 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] task_id = str(uuid.uuid4())[:8]
output_template = os.path.join(X_VIDEOS_PATH, f"%(id)s_{task_id}.%(ext)s") output_template = os.path.join(X_VIDEOS_PATH, f"%(id)s_{task_id}.%(ext)s")
format_spec = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" if format_id == "best" else f"{format_id}+bestaudio/best" 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): hooks = [_make_hook(task_id)] if task_id else []
if d["status"] == "downloading" and progress_callback:
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
downloaded = d.get("downloaded_bytes", 0)
pct = int(downloaded * 100 / total) if total > 0 else 0
progress_callback(pct)
elif d["status"] == "finished" and progress_callback:
progress_callback(100)
ydl_opts = { ydl_opts = {
"format": format_spec, "format": format_spec,
@@ -401,7 +747,7 @@ def download_video(url: str, format_id: str = "best", progress_callback=None) ->
"merge_output_format": "mp4", "merge_output_format": "mp4",
"quiet": True, "quiet": True,
"no_warnings": True, "no_warnings": True,
"progress_hooks": [hook], "progress_hooks": hooks,
} }
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
@@ -421,5 +767,5 @@ def download_video(url: str, format_id: str = "best", progress_callback=None) ->
"filename": os.path.basename(filename), "filename": os.path.basename(filename),
"file_path": filename, "file_path": filename,
"file_size": file_size, "file_size": file_size,
"platform": "twitter", "platform": detect_platform(url),
} }

View File

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

View File

@@ -24,24 +24,43 @@
<div class="video-list"> <div class="video-list">
<div v-for="v in videos" :key="v.id" class="video-card"> <div v-for="v in videos" :key="v.id" class="video-card">
<div class="video-main"> <div class="video-main">
<img v-if="v.thumbnail" :src="v.thumbnail" class="thumb" /> <!-- 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"> <div class="info">
<h4>{{ v.title || 'Untitled' }}</h4> <h4 :title="v.title || v.url">{{ v.title || truncateUrl(v.url) }}</h4>
<div class="meta"> <div class="meta">
<span :class="'status-' + v.status">{{ v.status }}</span> <span :class="'status-badge status-' + v.status">{{ statusLabel(v.status) }}</span>
<span>{{ v.platform }}</span> <span v-if="v.platform" class="platform-tag">{{ v.platform }}</span>
<span>{{ v.quality }}</span> <span v-if="v.quality">{{ v.quality }}</span>
<span>{{ humanSize(v.file_size) }}</span> <span v-if="v.file_size">{{ humanSize(v.file_size) }}</span>
<span>{{ fmtTime(v.created_at) }}</span> <span>{{ fmtTime(v.created_at) }}</span>
</div> </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> </div>
<div class="actions"> <div class="actions">
<button v-if="v.status === 'done'" @click="showLogs(v)" class="btn-log" title="Download logs">📋</button> <button v-if="v.status === 'done'" @click="showLogs(v)" class="btn-log" title="Download logs">📋</button>
<button v-if="v.status === 'done'" @click="playVideo(v)" class="btn-play"> Play</button> <button v-if="v.status === 'done'" @click="playVideo(v)" class="btn-play"> Play</button>
<a v-if="v.status === 'done'" :href="'/api/file/' + v.id" class="btn-dl" <a v-if="v.status === 'done'" :href="'/api/file/' + v.id" class="btn-dl"
:download="v.filename" @click.prevent="downloadAuth(v)">💾</a> :download="v.filename" @click.prevent="downloadAuth(v)">💾</a>
<button @click="deleteVideo(v)" class="btn-del">🗑</button> <button v-if="v.status !== 'downloading'" @click="deleteVideo(v)" class="btn-del">🗑</button>
</div> </div>
</div> </div>
<div v-if="!videos.length" class="empty">No videos found</div> <div v-if="!videos.length" class="empty">No videos found</div>
@@ -206,6 +225,7 @@ const pageSize = 20
const playing = ref(null) const playing = ref(null)
const playUrl = ref('') const playUrl = ref('')
const totalPages = computed(() => Math.ceil(total.value / pageSize) || 1) const totalPages = computed(() => Math.ceil(total.value / pageSize) || 1)
let pollTimer = null
// ── Settings / Cleanup ── // ── Settings / Cleanup ──
const cleanupStatus = ref(null) const cleanupStatus = ref(null)
@@ -310,6 +330,23 @@ function fmtTime(ts) {
return new Date(ts).toLocaleString('zh-CN', { hour12: false }) 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) { function browserIcon(b) {
return { Chrome: '🌐', Firefox: '🦊', Safari: '🧭', Edge: '🔷', Opera: '🔴', Samsung: '📱' }[b] || '🌐' return { Chrome: '🌐', Firefox: '🦊', Safari: '🧭', Edge: '🔷', Opera: '🔴', Samsung: '📱' }[b] || '🌐'
} }
@@ -339,11 +376,29 @@ async function fetchVideos() {
}) })
videos.value = res.data.videos videos.value = res.data.videos
total.value = res.data.total 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) { } catch (e) {
if (e.response?.status === 401) { auth.logout(); location.href = '/login' } 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() { async function fetchStats() {
try { try {
const res = await axios.get('/api/admin/stats', { headers: auth.getHeaders() }) const res = await axios.get('/api/admin/stats', { headers: auth.getHeaders() })
@@ -364,7 +419,8 @@ async function downloadAuth(v) {
} }
async function deleteVideo(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 { try {
await axios.delete(`/api/admin/videos/${v.id}`, { headers: auth.getHeaders() }) await axios.delete(`/api/admin/videos/${v.id}`, { headers: auth.getHeaders() })
fetchVideos(); fetchStats() fetchVideos(); fetchStats()
@@ -406,6 +462,14 @@ async function fetchLogs() {
} }
onMounted(() => { fetchVideos(); fetchStats() }) 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> </script>
<style scoped> <style scoped>
@@ -428,14 +492,35 @@ onMounted(() => { fetchVideos(); fetchStats() })
border: 1px solid #2a2a3e; border: 1px solid #2a2a3e;
} }
.video-main { display: flex; gap: 1.2rem; flex: 1; min-width: 0; } .video-main { display: flex; gap: 1.2rem; flex: 1; min-width: 0; }
.thumb { width: 112px; height: 63px; object-fit: cover; border-radius: 6px; flex-shrink: 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 { min-width: 0; }
.info h4 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 0.4rem; font-size: 1rem; } .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; } .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-done { color: #27ae60; }
.status-downloading { color: #f39c12; } .status-downloading { color: #f39c12; }
.status-error { color: #e74c3c; } .status-error { color: #e74c3c; }
.status-pending { color: #888; } .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 { display: flex; gap: 0.4rem; flex-shrink: 0; }
.actions button, .actions a { .actions button, .actions a {
padding: 0.4rem 0.6rem; border: 1px solid #444; border-radius: 6px; padding: 0.4rem 0.6rem; border: 1px solid #444; border-radius: 6px;
@@ -530,4 +615,5 @@ onMounted(() => { fetchVideos(); fetchStats() })
.player-wrap { position: relative; max-width: 90vw; max-height: 90vh; } .player-wrap { position: relative; max-width: 90vw; max-height: 90vh; }
.player { max-width: 90vw; max-height: 85vh; border-radius: 8px; } .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; } .close-btn { position: absolute; top: -40px; right: 0; background: none; border: none; color: #fff; font-size: 1.5rem; cursor: pointer; }
</style> </style>

View File

@@ -1,10 +1,10 @@
<template> <template>
<div class="home"> <div class="home">
<h1>Video Downloader</h1> <h1>Video Downloader</h1>
<p class="subtitle">Paste a Twitter/X or YouTube video link to download</p> <p class="subtitle">Paste a Twitter/X, YouTube, or Pornhub video link to download</p>
<div class="input-group"> <div class="input-group">
<input v-model="url" placeholder="https://x.com/... or https://youtube.com/watch?v=..." @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()"> <button @click="parseUrl" :disabled="loading || !url.trim()">
{{ loading ? '⏳ Parsing...' : '🔍 Parse' }} {{ loading ? '⏳ Parsing...' : '🔍 Parse' }}
</button> </button>
@@ -30,13 +30,14 @@
<button class="download-btn" @click="startDownload" :disabled="downloading || downloadReady"> <button class="download-btn" @click="startDownload" :disabled="downloading || downloadReady">
{{ downloading ? '⏳ Downloading...' : downloadReady ? '✅ Downloaded' : '📥 Download' }} {{ downloading ? '⏳ Downloading...' : downloadReady ? '✅ Downloaded' : '📥 Download' }}
</button> </button>
<button v-if="downloading && taskId" class="cancel-btn" @click="cancelDownload"> Cancel</button>
</div> </div>
<div v-if="taskId" class="task-status"> <div v-if="taskId" class="task-status">
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-fill" :style="{ width: progress + '%' }"></div> <div class="progress-fill" :style="{ width: progress + '%' }"></div>
</div> </div>
<p>{{ statusText }}</p> <p>{{ statusText.replace('0%', '…') }}</p>
<a v-if="downloadReady" :href="downloadUrl" class="download-link" download>💾 Save to device</a> <a v-if="downloadReady" :href="downloadUrl" class="download-link" download>💾 Save to device</a>
</div> </div>
</div> </div>
@@ -123,7 +124,9 @@ async function pollStatus() {
downloadReady.value = true downloadReady.value = true
downloadUrl.value = `/api/file/task/${taskId.value}` downloadUrl.value = `/api/file/task/${taskId.value}`
} else if (d.status === 'error') { } 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 downloading.value = false
} else { } else {
statusText.value = `${d.status}... ${d.progress}%` statusText.value = `${d.status}... ${d.progress}%`
@@ -133,6 +136,19 @@ async function pollStatus() {
setTimeout(pollStatus, 3000) 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> </script>
<style scoped> <style scoped>
@@ -169,9 +185,22 @@ h1 { font-size: 2rem; margin-bottom: 0.5rem; }
.ext { color: #888; } .ext { color: #888; }
.size { color: #888; margin-left: auto; } .size { color: #888; margin-left: auto; }
.download-btn { width: 100%; margin-top: 1rem; padding: 1rem; font-size: 1.1rem; } .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; } .task-status { margin-top: 1.5rem; }
.progress-bar { height: 8px; background: #333; border-radius: 4px; overflow: hidden; margin-bottom: 0.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 { .download-link {
display: inline-block; margin-top: 0.5rem; padding: 0.8rem 2rem; display: inline-block; margin-top: 0.5rem; padding: 0.8rem 2rem;
background: #27ae60; color: #fff; border-radius: 8px; text-decoration: none; font-weight: 600; background: #27ae60; color: #fff; border-radius: 8px; text-decoration: none; font-weight: 600;