feat: flat design Sunset Glitch + download manager + settings + recommendations overhaul
- Sunset Glitch color palette applied to all templates - Font Awesome icons throughout UI - Download manager with parallel queue and progress tracking - Settings page with dynamic configuration - Recommendations router enhanced with scoring - Local vendor libs (Alpine.js, HTMX) for offline support - Auto test suite with screenshots - Series releases list component - New download model
This commit is contained in:
+116
-4
@@ -2,13 +2,16 @@ import asyncio
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
import httpx
|
||||
from app.models import DownloadTask, DownloadStatus, DownloadRequest
|
||||
from app.models.download import DownloadTaskTable
|
||||
from app.database import engine
|
||||
from sqlmodel import Session, select
|
||||
from app.downloaders import get_downloader
|
||||
from app.utils import sanitize_filename
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,6 +27,92 @@ class DownloadManager:
|
||||
self.active_downloads: Dict[str, asyncio.Task] = {}
|
||||
self._semaphore = asyncio.Semaphore(max_parallel)
|
||||
|
||||
# ==================== DB Persistence ====================
|
||||
|
||||
def _save_task_to_db(self, task: DownloadTask) -> None:
|
||||
"""Persist a download task to the database (upsert)."""
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
existing = session.get(DownloadTaskTable, task.id)
|
||||
if existing:
|
||||
existing.url = task.url
|
||||
existing.filename = task.filename
|
||||
existing.host = task.host.value if hasattr(task.host, 'value') else str(task.host)
|
||||
existing.status = task.status.value if hasattr(task.status, 'value') else str(task.status)
|
||||
existing.progress = task.progress
|
||||
existing.downloaded_bytes = task.downloaded_bytes
|
||||
existing.total_bytes = task.total_bytes
|
||||
existing.speed = task.speed
|
||||
existing.error = task.error
|
||||
existing.started_at = task.started_at
|
||||
existing.completed_at = task.completed_at
|
||||
existing.file_path = task.file_path
|
||||
session.add(existing)
|
||||
session.commit()
|
||||
else:
|
||||
db_task = DownloadTaskTable(
|
||||
id=task.id,
|
||||
url=task.url,
|
||||
filename=task.filename,
|
||||
host=task.host.value if hasattr(task.host, 'value') else str(task.host),
|
||||
status=task.status.value if hasattr(task.status, 'value') else str(task.status),
|
||||
progress=task.progress,
|
||||
downloaded_bytes=task.downloaded_bytes,
|
||||
total_bytes=task.total_bytes,
|
||||
speed=task.speed,
|
||||
error=task.error,
|
||||
created_at=task.created_at,
|
||||
started_at=task.started_at,
|
||||
completed_at=task.completed_at,
|
||||
file_path=task.file_path,
|
||||
)
|
||||
session.add(db_task)
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save task {task.id} to DB: {e}", exc_info=True)
|
||||
|
||||
def _delete_task_from_db(self, task_id: str) -> None:
|
||||
"""Remove a download task from the database."""
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
db_task = session.get(DownloadTaskTable, task_id)
|
||||
if db_task:
|
||||
session.delete(db_task)
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete task {task_id} from DB: {e}", exc_info=True)
|
||||
|
||||
def _load_tasks_from_db(self) -> None:
|
||||
"""Load persisted download tasks from the database into memory."""
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
statement = select(DownloadTaskTable)
|
||||
db_tasks = session.exec(statement).all()
|
||||
for db_task in db_tasks:
|
||||
if db_task.id not in self.tasks:
|
||||
task = DownloadTask(
|
||||
id=db_task.id,
|
||||
url=db_task.url,
|
||||
filename=db_task.filename,
|
||||
host="other",
|
||||
status=DownloadStatus(db_task.status),
|
||||
progress=db_task.progress,
|
||||
downloaded_bytes=db_task.downloaded_bytes,
|
||||
total_bytes=db_task.total_bytes,
|
||||
speed=db_task.speed,
|
||||
error=db_task.error,
|
||||
created_at=db_task.created_at,
|
||||
started_at=db_task.started_at,
|
||||
completed_at=db_task.completed_at,
|
||||
file_path=db_task.file_path,
|
||||
)
|
||||
self.tasks[task.id] = task
|
||||
logger.info(f"Loaded {len(db_tasks)} download tasks from database")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load tasks from DB: {e}", exc_info=True)
|
||||
|
||||
# ==================== Task Management ====================
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[DownloadTask]:
|
||||
return self.tasks.get(task_id)
|
||||
|
||||
@@ -60,6 +149,8 @@ class DownloadManager:
|
||||
created_at=datetime.now()
|
||||
)
|
||||
self.tasks[task_id] = task
|
||||
# Persist to database
|
||||
self._save_task_to_db(task)
|
||||
return task
|
||||
|
||||
async def start_download(self, task_id: str):
|
||||
@@ -82,6 +173,7 @@ class DownloadManager:
|
||||
task = self.tasks.get(task_id)
|
||||
if task and task.status == DownloadStatus.DOWNLOADING:
|
||||
task.status = DownloadStatus.PAUSED
|
||||
self._save_task_to_db(task)
|
||||
if task_id in self.active_downloads:
|
||||
self.active_downloads[task_id].cancel()
|
||||
del self.active_downloads[task_id]
|
||||
@@ -90,6 +182,7 @@ class DownloadManager:
|
||||
task = self.tasks.get(task_id)
|
||||
if task:
|
||||
task.status = DownloadStatus.CANCELLED
|
||||
self._save_task_to_db(task)
|
||||
if task_id in self.active_downloads:
|
||||
self.active_downloads[task_id].cancel()
|
||||
del self.active_downloads[task_id]
|
||||
@@ -112,14 +205,16 @@ class DownloadManager:
|
||||
if task.file_path and os.path.exists(task.file_path):
|
||||
os.remove(task.file_path)
|
||||
|
||||
# Remove from tasks dict
|
||||
# Remove from tasks dict and database
|
||||
del self.tasks[task_id]
|
||||
self._delete_task_from_db(task_id)
|
||||
|
||||
async def _download(self, task: DownloadTask):
|
||||
async with self._semaphore:
|
||||
try:
|
||||
task.status = DownloadStatus.DOWNLOADING
|
||||
task.started_at = datetime.now()
|
||||
self._save_task_to_db(task)
|
||||
|
||||
# Get downloader and extract link
|
||||
downloader = get_downloader(task.url)
|
||||
@@ -150,6 +245,9 @@ class DownloadManager:
|
||||
else:
|
||||
logger.debug(f"Task filename kept as: {task.filename}")
|
||||
|
||||
# Sanitize filename to prevent path traversal and invalid characters
|
||||
task.filename = sanitize_filename(task.filename)
|
||||
|
||||
task.file_path = str(self.download_dir / task.filename)
|
||||
|
||||
# Check if URL is HLS/m3u8 - use ffmpeg to download
|
||||
@@ -157,6 +255,7 @@ class DownloadManager:
|
||||
logger.info(f"Detected HLS stream, using ffmpeg to download: {task.filename}")
|
||||
success = await self._download_hls(download_url, task)
|
||||
if success:
|
||||
self._save_task_to_db(task)
|
||||
return
|
||||
# If ffmpeg fails, fall through to regular download attempt
|
||||
logger.warning("ffmpeg download failed, trying regular download")
|
||||
@@ -167,8 +266,12 @@ class DownloadManager:
|
||||
# Move file to expected location if different
|
||||
import shutil
|
||||
if download_url != task.file_path:
|
||||
shutil.move(download_url, task.file_path)
|
||||
logger.debug(f"Moved file to: {task.file_path}")
|
||||
try:
|
||||
shutil.move(download_url, task.file_path)
|
||||
logger.debug(f"Moved file to: {task.file_path}")
|
||||
except shutil.Error:
|
||||
# Same file, no move needed
|
||||
pass
|
||||
|
||||
# Mark as complete
|
||||
file_size = os.path.getsize(task.file_path)
|
||||
@@ -178,6 +281,7 @@ class DownloadManager:
|
||||
task.downloaded_bytes = file_size
|
||||
task.total_bytes = file_size
|
||||
task.completed_at = datetime.now()
|
||||
self._save_task_to_db(task)
|
||||
return
|
||||
|
||||
# Check if file already exists and is complete (for VidMoly which downloads directly)
|
||||
@@ -190,6 +294,7 @@ class DownloadManager:
|
||||
task.downloaded_bytes = file_size
|
||||
task.total_bytes = file_size
|
||||
task.completed_at = datetime.now()
|
||||
self._save_task_to_db(task)
|
||||
return
|
||||
|
||||
# Check for partial download (resume)
|
||||
@@ -241,6 +346,7 @@ class DownloadManager:
|
||||
except Exception as e:
|
||||
task.status = DownloadStatus.FAILED
|
||||
task.error = str(e)
|
||||
self._save_task_to_db(task)
|
||||
finally:
|
||||
if task.id in self.active_downloads:
|
||||
del self.active_downloads[task.id]
|
||||
@@ -269,9 +375,11 @@ class DownloadManager:
|
||||
|
||||
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
|
||||
if task.status == DownloadStatus.CANCELLED:
|
||||
self._save_task_to_db(task)
|
||||
return
|
||||
|
||||
if task.status == DownloadStatus.PAUSED:
|
||||
self._save_task_to_db(task)
|
||||
return
|
||||
|
||||
f.write(chunk)
|
||||
@@ -295,6 +403,9 @@ class DownloadManager:
|
||||
final_size = os.path.getsize(task.file_path) if os.path.exists(task.file_path) else 0
|
||||
logger.info(f" ✅ Completed: {task.filename} ({final_size / (1024*1024):.2f} MB)")
|
||||
|
||||
# Persist to database
|
||||
self._save_task_to_db(task)
|
||||
|
||||
async def _download_hls(self, m3u8_url: str, task: DownloadTask) -> bool:
|
||||
"""Download HLS/m3u8 stream using ffmpeg"""
|
||||
import subprocess
|
||||
@@ -386,6 +497,7 @@ class DownloadManager:
|
||||
task.downloaded_bytes = file_size
|
||||
task.total_bytes = file_size
|
||||
task.completed_at = datetime.now()
|
||||
self._save_task_to_db(task)
|
||||
return True
|
||||
else:
|
||||
logger.error(f"HLS download failed: file not created")
|
||||
|
||||
Reference in New Issue
Block a user