feat: flat design Sunset Glitch + download manager + settings + recommendations overhaul
CI / Test (Python 3.11) (pull_request) Has been cancelled
CI / Test (Python 3.12) (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / Type Check (pull_request) Has been cancelled
CI / Summary (pull_request) Has been cancelled

- 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:
root
2026-04-11 19:30:32 +00:00
parent 9e53579b36
commit 87f245d3fc
36 changed files with 1549 additions and 215 deletions
+116 -4
View File
@@ -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")