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:
@@ -23,6 +23,7 @@ def create_db_and_tables():
|
||||
from app.models.favorites import FavoriteTable
|
||||
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
|
||||
from app.models.settings import AppSettingsTable
|
||||
from app.models.download import DownloadTaskTable
|
||||
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
+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")
|
||||
|
||||
@@ -144,12 +144,34 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
logger.info(f"Direct video URL detected: {url[:60]}... -> {filename}")
|
||||
return url, filename
|
||||
|
||||
# Check if URL contains the anime page context (format: video_url|anime_page_url|episode_title?)
|
||||
# Check if URL contains the anime page context (format: video_url|...|anime_page_url|episode_title)
|
||||
# The LAST two parts are always anime_page_url and episode_title.
|
||||
# Everything before them is video URLs (multiple sources for fallback).
|
||||
if "|" in url:
|
||||
parts = url.split("|")
|
||||
video_url = parts[0]
|
||||
anime_page_url = parts[1] if len(parts) > 1 else None
|
||||
episode_title = parts[2] if len(parts) > 2 else None
|
||||
# Correctly identify anime_page_url (2nd to last) and episode_title (last)
|
||||
if len(parts) >= 3:
|
||||
# Multiple video URLs + anime_page_url + episode_title
|
||||
potential_anime_url = parts[-2].strip()
|
||||
potential_title = parts[-1].strip()
|
||||
# Validate: anime_page_url should look like a URL
|
||||
# episode_title should NOT look like a URL
|
||||
if potential_title and not potential_title.startswith("http"):
|
||||
anime_page_url = potential_anime_url if potential_anime_url.startswith("http") else None
|
||||
episode_title = potential_title
|
||||
elif len(parts) >= 5 and parts[-2].startswith("http"):
|
||||
# Last part is also a URL (no episode title) - 2nd to last is anime page URL
|
||||
anime_page_url = potential_anime_url
|
||||
episode_title = None
|
||||
else:
|
||||
anime_page_url = None
|
||||
episode_title = None
|
||||
# Pass the full URL to fallback (it parses correctly)
|
||||
video_url = url
|
||||
else:
|
||||
video_url = parts[0]
|
||||
anime_page_url = parts[1] if len(parts) > 1 else None
|
||||
episode_title = None
|
||||
|
||||
logger.debug(
|
||||
f"Split URL - video: {video_url[:60]}..., anime: {anime_page_url}, episode: {episode_title}"
|
||||
@@ -160,6 +182,7 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
video_url,
|
||||
anime_page_url=anime_page_url,
|
||||
episode_title=episode_title,
|
||||
target_filename=target_filename,
|
||||
)
|
||||
|
||||
# Check if this is a third-party host URL
|
||||
|
||||
@@ -23,7 +23,7 @@ class FS7Downloader(BaseSeriesSite):
|
||||
self.id = "fs7"
|
||||
self.provider_id = "fs7"
|
||||
self.default_domain = "fs7.lol"
|
||||
self.test_tlds = ["lol", "com", "net", "org", "tv", "ws", "cc", "co"]
|
||||
self.test_tlds = ["lol", "one", "site", "vip", "fun", "stream", "com", "net", "org", "tv", "ws", "cc", "co"]
|
||||
self.base_url = f"https://{self.default_domain}"
|
||||
self._domain_checked = False
|
||||
self.client.headers.update(
|
||||
@@ -234,35 +234,93 @@ class FS7Downloader(BaseSeriesSite):
|
||||
# Clean up title: remove "affiche" suffix
|
||||
title = re.sub(r"\s+affiche$", "", title, flags=re.IGNORECASE).strip()
|
||||
|
||||
# Extract description/synopsis
|
||||
description_elem = soup.find("div", class_="full-text")
|
||||
description = (
|
||||
description_elem.get_text(strip=True) if description_elem else ""
|
||||
)
|
||||
# --- Synopsis: div.fdesc > p ---
|
||||
description = ""
|
||||
fdesc = soup.find("div", class_="fdesc")
|
||||
if fdesc:
|
||||
p = fdesc.find("p")
|
||||
if p:
|
||||
description = p.get_text(strip=True)
|
||||
else:
|
||||
description = fdesc.get_text(strip=True)
|
||||
|
||||
# Extract cover image
|
||||
img = soup.find("img", class_="poster")
|
||||
poster_image = img.get("src", "") if img else ""
|
||||
# --- Poster: div.fleft > img ---
|
||||
poster_image = ""
|
||||
fleft = soup.find("div", class_="fleft")
|
||||
if fleft:
|
||||
img = fleft.find("img")
|
||||
if img:
|
||||
poster_image = (
|
||||
img.get("data-src")
|
||||
or img.get("data-original")
|
||||
or img.get("src")
|
||||
or ""
|
||||
)
|
||||
|
||||
# Try to get poster from meta tag if not found
|
||||
# Fallback: img.poster, then og:image
|
||||
if not poster_image:
|
||||
img = soup.find("img", class_="poster")
|
||||
poster_image = img.get("src", "") if img else ""
|
||||
if not poster_image:
|
||||
meta_img = soup.find("meta", property="og:image")
|
||||
poster_image = meta_img.get("content", "") if meta_img else ""
|
||||
|
||||
# Extract year
|
||||
year_match = re.search(r"\b(19|20)\d{2}\b", description)
|
||||
release_year = int(year_match.group()) if year_match else None
|
||||
# --- Year: span.release ---
|
||||
release_year = None
|
||||
release_span = soup.find("span", class_="release")
|
||||
if release_span:
|
||||
year_match = re.search(r"\b(19|20)\d{2}\b", release_span.get_text())
|
||||
if year_match:
|
||||
release_year = int(year_match.group())
|
||||
|
||||
# --- Genres: span.genres ---
|
||||
genres = []
|
||||
genres_span = soup.find("span", class_="genres")
|
||||
if genres_span:
|
||||
genres = [
|
||||
g.strip()
|
||||
for g in genres_span.get_text().split(",")
|
||||
if g.strip()
|
||||
]
|
||||
|
||||
# --- Runtime: span.runtime ---
|
||||
runtime = None
|
||||
runtime_span = soup.find("span", class_="runtime")
|
||||
if runtime_span:
|
||||
runtime = runtime_span.get_text(strip=True)
|
||||
|
||||
# --- Casting info from second div.flist ---
|
||||
original_title = ""
|
||||
director = ""
|
||||
cast = []
|
||||
flists = soup.find_all("div", class_="flist")
|
||||
for fl in flists:
|
||||
text = fl.get_text(strip=True)
|
||||
if "Titre Original" in text:
|
||||
m = re.search(r"Titre Original\s*:\s*(.+?)(?:Réalisateur|$)", text)
|
||||
if m:
|
||||
original_title = m.group(1).strip()
|
||||
m2 = re.search(r"Réalisateur\s*:\s*(.+?)(?:Avec\s*:|$)", text)
|
||||
if m2:
|
||||
director = m2.group(1).strip()
|
||||
m3 = re.search(r"Avec\s*:\s*(.+?)(?:plus|$)", text)
|
||||
if m3:
|
||||
cast = [c.strip() for c in m3.group(1).split(",") if c.strip()]
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"synopsis": description,
|
||||
"poster_image": poster_image,
|
||||
"release_year": release_year,
|
||||
"genres": [],
|
||||
"genres": genres,
|
||||
"rating": None,
|
||||
"studio": None,
|
||||
"total_episodes": None,
|
||||
"status": None,
|
||||
"original_title": original_title,
|
||||
"director": director,
|
||||
"cast": cast,
|
||||
"runtime": runtime,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -301,3 +359,80 @@ class FS7Downloader(BaseSeriesSite):
|
||||
return await player.get_download_link(url, target_filename)
|
||||
else:
|
||||
raise ValueError(f"No video player found for URL: {url}")
|
||||
|
||||
async def get_latest_series(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Scrape the 'Nouveautés Séries' section from FS7 homepage.
|
||||
|
||||
Returns:
|
||||
List of dicts with title, url, cover_image, synopsis, lang, provider_id.
|
||||
"""
|
||||
await self._ensure_base_url()
|
||||
|
||||
try:
|
||||
resp = await self.client.get(self.base_url + "/", timeout=15)
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch FS7 homepage: {e}")
|
||||
return []
|
||||
|
||||
results = []
|
||||
|
||||
# Find the 'Nouveautés Séries' section
|
||||
for section in soup.find_all("div", class_="pages"):
|
||||
title_el = section.find("div", class_="sect-t")
|
||||
if not title_el:
|
||||
continue
|
||||
title = title_el.get_text(strip=True)
|
||||
if "Nouveautés" not in title or "Séries" not in title:
|
||||
continue
|
||||
|
||||
for item in section.find_all("div", class_="short"):
|
||||
# Get the poster link (contains real URL)
|
||||
poster_a = item.find("a", class_="short-poster", href=True)
|
||||
if not poster_a:
|
||||
continue
|
||||
|
||||
url = poster_a["href"]
|
||||
if url.startswith("/"):
|
||||
url = self.base_url + url
|
||||
|
||||
# Title from alt attribute
|
||||
title_attr = poster_a.get("alt", "").strip()
|
||||
if not title_attr:
|
||||
continue
|
||||
|
||||
# Poster image
|
||||
img = poster_a.find("img")
|
||||
cover_image = img.get("src", "") if img else ""
|
||||
|
||||
# Synopsis from hidden span
|
||||
desc_span = item.find("span", id=re.compile(r"^desc-\d+"))
|
||||
synopsis = desc_span.get_text(strip=True) if desc_span else ""
|
||||
|
||||
# Language (VF/VOSTFR)
|
||||
lang = "vf"
|
||||
version_span = item.find("span", class_="film-version")
|
||||
if version_span:
|
||||
version_text = version_span.get_text(strip=True).upper()
|
||||
if "VOSTFR" in version_text:
|
||||
lang = "vostfr"
|
||||
elif "VF" in version_text:
|
||||
lang = "vf"
|
||||
|
||||
results.append({
|
||||
"title": title_attr,
|
||||
"url": url,
|
||||
"cover_image": cover_image,
|
||||
"synopsis": synopsis,
|
||||
"lang": lang,
|
||||
"provider_id": self.provider_id,
|
||||
"content_type": "series",
|
||||
})
|
||||
|
||||
if len(results) >= limit:
|
||||
break
|
||||
break # Only process the first matching section
|
||||
|
||||
logger.info(f"FS7 latest series: found {len(results)} items")
|
||||
return results
|
||||
|
||||
@@ -70,3 +70,4 @@ from .watchlist import WatchlistItemTable, WatchlistSettingsTable
|
||||
from .favorites import FavoriteTable
|
||||
from .sonarr import SonarrMappingTable, SonarrConfigTable
|
||||
from .settings import AppSettingsTable
|
||||
from .download import DownloadTaskTable
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Models for download task persistence with SQLModel support"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from sqlmodel import SQLModel, Field, Column, String
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DownloadStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
DOWNLOADING = "downloading"
|
||||
PAUSED = "paused"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class DownloadTaskTable(SQLModel, table=True):
|
||||
"""Database table for persisting download tasks across server restarts."""
|
||||
__tablename__ = "download_tasks"
|
||||
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
primary_key=True,
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
url: str = Field(default="", sa_column=Column(String))
|
||||
filename: str = Field(sa_column=Column(String))
|
||||
host: str = Field(default="other", sa_column=Column(String))
|
||||
status: str = Field(default="pending", sa_column=Column(String))
|
||||
progress: float = Field(default=0.0)
|
||||
downloaded_bytes: int = Field(default=0)
|
||||
total_bytes: Optional[int] = Field(default=None)
|
||||
speed: float = Field(default=0.0)
|
||||
error: Optional[str] = Field(default=None, sa_column=Column(String))
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
started_at: Optional[datetime] = Field(default=None)
|
||||
completed_at: Optional[datetime] = Field(default=None)
|
||||
file_path: Optional[str] = Field(default=None, sa_column=Column(String))
|
||||
@@ -27,6 +27,13 @@ class AppSettingsBase(SQLModel):
|
||||
|
||||
# #12: Custom download directory
|
||||
download_dir: str = Field(default="downloads")
|
||||
|
||||
# #13: Content weight mode ("auto" = based on download habits, "manual" = user-defined)
|
||||
content_weight_mode: str = Field(default="auto", sa_column=Column(String))
|
||||
|
||||
# #14: Manual content weights (used when content_weight_mode = "manual")
|
||||
content_weight_anime: int = Field(default=2)
|
||||
content_weight_series: int = Field(default=1)
|
||||
|
||||
@property
|
||||
def disabled_providers(self) -> List[str]:
|
||||
@@ -64,6 +71,9 @@ class AppSettings(BaseModel):
|
||||
anime_enabled: bool = True
|
||||
series_enabled: bool = True
|
||||
download_dir: str = "downloads"
|
||||
content_weight_mode: str = "auto"
|
||||
content_weight_anime: int = 2
|
||||
content_weight_series: int = 1
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -79,3 +89,6 @@ class AppSettingsUpdate(BaseModel):
|
||||
anime_enabled: Optional[bool] = None
|
||||
series_enabled: Optional[bool] = None
|
||||
download_dir: Optional[str] = None
|
||||
content_weight_mode: Optional[str] = None
|
||||
content_weight_anime: Optional[int] = None
|
||||
content_weight_series: Optional[int] = None
|
||||
|
||||
+10
-15
@@ -296,8 +296,7 @@ async def search_series_unified(
|
||||
|
||||
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
|
||||
# Enrich results with metadata (synopsis, rating, genres)
|
||||
enricher = await get_metadata_enricher()
|
||||
# Enrich results with metadata from scrapers (not Kitsu — Kitsu is anime-only)
|
||||
enrichment_tasks = []
|
||||
enrichment_mapping = []
|
||||
|
||||
@@ -308,17 +307,15 @@ async def search_series_unified(
|
||||
elif result:
|
||||
results[provider_id] = result
|
||||
print(f"[SERIES SEARCH] {provider_id}: Found {len(result)} results")
|
||||
# Prepare enrichment for top 15 results
|
||||
for idx, item in enumerate(result[:15]):
|
||||
if isinstance(item, dict):
|
||||
enrichment_tasks.append(
|
||||
enricher.enrich_metadata(
|
||||
item.get("metadata") or {},
|
||||
item.get("title") or "",
|
||||
item.get("url") or "",
|
||||
# Enrich top 10 results with metadata from the scraper itself
|
||||
downloader = series_downloaders.get(provider_id)
|
||||
if downloader and hasattr(downloader, "get_anime_metadata"):
|
||||
for idx, item in enumerate(result[:10]):
|
||||
if isinstance(item, dict) and item.get("url"):
|
||||
enrichment_tasks.append(
|
||||
downloader.get_anime_metadata(item["url"])
|
||||
)
|
||||
)
|
||||
enrichment_mapping.append((provider_id, idx))
|
||||
enrichment_mapping.append((provider_id, idx))
|
||||
else:
|
||||
print(f"[SERIES SEARCH] {provider_id}: No results returned")
|
||||
|
||||
@@ -334,9 +331,7 @@ async def search_series_unified(
|
||||
and provider_id in results
|
||||
and pos < len(results[provider_id])
|
||||
):
|
||||
results[provider_id][pos]["metadata"] = (
|
||||
meta.model_dump() if hasattr(meta, "model_dump") else meta
|
||||
)
|
||||
results[provider_id][pos]["metadata"] = meta
|
||||
|
||||
# Truncate synopses at sentence boundaries
|
||||
for pid in results:
|
||||
|
||||
@@ -3,15 +3,22 @@ Recommendations and releases routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Request, Query, HTTPException, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.recommendation_engine import RecommendationEngine
|
||||
from app.models.auth import User
|
||||
from app.models.settings import AppSettingsTable
|
||||
from app.database import get_session
|
||||
from app.routers.router_auth import get_optional_user, get_current_user_from_token
|
||||
from app.routers.router_settings import _compute_auto_weights
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["recommendations"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
@@ -23,6 +30,79 @@ def hash_filter(s):
|
||||
templates.env.filters["hash"] = hash_filter
|
||||
|
||||
|
||||
def _get_effective_weights(session: Session, user_id: str) -> tuple:
|
||||
"""Return (anime_enabled, series_enabled, anime_weight, series_weight)."""
|
||||
settings = session.exec(
|
||||
select(AppSettingsTable).where(AppSettingsTable.user_id == user_id)
|
||||
).first()
|
||||
|
||||
if settings is None:
|
||||
return True, True, 1, 1
|
||||
|
||||
anime_enabled = getattr(settings, 'anime_enabled', True)
|
||||
series_enabled = getattr(settings, 'series_enabled', True)
|
||||
mode = getattr(settings, 'content_weight_mode', 'auto')
|
||||
download_dir = getattr(settings, 'download_dir', 'downloads')
|
||||
|
||||
if mode == "auto":
|
||||
weights = _compute_auto_weights(download_dir)
|
||||
return anime_enabled, series_enabled, weights["anime_weight"], weights["series_weight"]
|
||||
else:
|
||||
aw = getattr(settings, 'content_weight_anime', 2)
|
||||
sw = getattr(settings, 'content_weight_series', 1)
|
||||
return anime_enabled, series_enabled, int(aw), int(sw)
|
||||
|
||||
|
||||
def _weighted_mix(items_a: List[Dict], items_b: List[Dict], limit: int,
|
||||
weight_a: int = 2, weight_b: int = 1) -> List[Dict]:
|
||||
"""Mix two lists using weights. Distributes items proportionally and interleaves.
|
||||
|
||||
If weight_a=2, weight_b=1 and limit=15:
|
||||
- slots_a ≈ 10, slots_b ≈ 5
|
||||
- B items are spaced evenly across the list
|
||||
If one list is shorter, the other fills remaining slots.
|
||||
"""
|
||||
total_weight = weight_a + weight_b
|
||||
if total_weight == 0:
|
||||
return (items_a + items_b)[:limit]
|
||||
|
||||
slots_a = round(limit * weight_a / total_weight)
|
||||
slots_b = limit - slots_a
|
||||
|
||||
pick_a = min(slots_a, len(items_a))
|
||||
pick_b = min(slots_b, len(items_b))
|
||||
|
||||
# Redistribute unfilled slots
|
||||
if pick_a < slots_a:
|
||||
pick_b = min(pick_b + (slots_a - pick_a), len(items_b))
|
||||
elif pick_b < slots_b:
|
||||
pick_a = min(pick_a + (slots_b - pick_b), len(items_a))
|
||||
|
||||
a = items_a[:pick_a]
|
||||
b = items_b[:pick_b]
|
||||
|
||||
total = pick_a + pick_b
|
||||
if total == 0:
|
||||
return []
|
||||
if pick_b == 0:
|
||||
return a[:limit]
|
||||
if pick_a == 0:
|
||||
return b[:limit]
|
||||
|
||||
# Place B items at evenly spaced positions, fill gaps with A
|
||||
result = [None] * total
|
||||
for i, item in enumerate(b):
|
||||
pos = round(i * (total - 1) / max(pick_b - 1, 1))
|
||||
result[pos] = item
|
||||
a_idx = 0
|
||||
for i in range(total):
|
||||
if result[i] is None:
|
||||
result[i] = a[a_idx]
|
||||
a_idx += 1
|
||||
|
||||
return result[:limit]
|
||||
|
||||
|
||||
@router.get("/recommendations")
|
||||
async def get_recommendations(
|
||||
request: Request,
|
||||
@@ -30,8 +110,9 @@ async def get_recommendations(
|
||||
html: bool = Query(False),
|
||||
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Get personalized anime recommendations based on download history"""
|
||||
"""Get personalized recommendations based on user settings (anime + series)"""
|
||||
is_htmx = request.headers.get("HX-Request")
|
||||
|
||||
if current_user is None and (html or is_htmx):
|
||||
@@ -42,14 +123,38 @@ async def get_recommendations(
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
engine = RecommendationEngine(download_dir="downloads")
|
||||
anime_enabled, series_enabled, anime_weight, series_weight = _get_effective_weights(session, current_user.id)
|
||||
recommendations = []
|
||||
|
||||
try:
|
||||
recommendations = await engine.get_personalized_recommendations(limit=limit)
|
||||
|
||||
# Filter by content_type if specified
|
||||
if anime_enabled:
|
||||
engine = RecommendationEngine(download_dir="downloads")
|
||||
try:
|
||||
anime_recs = await engine.get_personalized_recommendations(limit=limit)
|
||||
for r in anime_recs:
|
||||
r['content_type'] = 'anime'
|
||||
recommendations.extend(anime_recs)
|
||||
finally:
|
||||
await engine.close()
|
||||
|
||||
if series_enabled:
|
||||
try:
|
||||
from app.downloaders.series_sites.fs7 import FS7Downloader
|
||||
downloader = FS7Downloader()
|
||||
series_recs = await downloader.get_latest_series(limit=limit)
|
||||
for r in series_recs:
|
||||
r['content_type'] = 'series'
|
||||
recommendations.extend(series_recs)
|
||||
except Exception as e:
|
||||
logger.warning(f"Series recommendations fetch failed: {e}")
|
||||
|
||||
if content_type and content_type != "all":
|
||||
recommendations = [r for r in recommendations if r.get("content_type", r.get("type", "")) == content_type]
|
||||
recommendations = [r for r in recommendations if r.get("content_type") == content_type]
|
||||
else:
|
||||
anime_items = [r for r in recommendations if r.get("content_type") == "anime"]
|
||||
series_items = [r for r in recommendations if r.get("content_type") == "series"]
|
||||
recommendations = _weighted_mix(anime_items, series_items, limit,
|
||||
weight_a=anime_weight, weight_b=series_weight)
|
||||
|
||||
if html or is_htmx:
|
||||
return templates.TemplateResponse(
|
||||
@@ -59,11 +164,8 @@ async def get_recommendations(
|
||||
|
||||
return {"recommendations": recommendations, "count": len(recommendations)}
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Recommendations error: {e}", exc_info=True)
|
||||
logger.error(f"Recommendations error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await engine.close()
|
||||
|
||||
|
||||
@router.get("/releases/latest")
|
||||
@@ -72,18 +174,52 @@ async def get_latest_releases(
|
||||
limit: int = 20,
|
||||
html: bool = Query(False),
|
||||
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Get latest anime releases"""
|
||||
"""Get latest releases based on user settings (anime + series)"""
|
||||
from app.recommendations import get_latest_releases_with_info
|
||||
|
||||
try:
|
||||
releases = await get_latest_releases_with_info(limit=limit)
|
||||
|
||||
# Filter by content_type if specified
|
||||
if content_type and content_type != "all":
|
||||
releases = [r for r in releases if r.get("content_type", r.get("type", "")) == content_type]
|
||||
is_htmx = request.headers.get("HX-Request")
|
||||
|
||||
if html or request.headers.get("HX-Request"):
|
||||
if current_user is None and (html or is_htmx):
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
anime_enabled, series_enabled, anime_weight, series_weight = _get_effective_weights(session, current_user.id)
|
||||
releases = []
|
||||
|
||||
try:
|
||||
if anime_enabled:
|
||||
anime_releases = await get_latest_releases_with_info(limit=limit)
|
||||
for r in anime_releases:
|
||||
r['content_type'] = 'anime'
|
||||
releases.extend(anime_releases)
|
||||
|
||||
if series_enabled:
|
||||
try:
|
||||
from app.downloaders.series_sites.fs7 import FS7Downloader
|
||||
downloader = FS7Downloader()
|
||||
series_releases = await downloader.get_latest_series(limit=limit)
|
||||
for r in series_releases:
|
||||
r['content_type'] = 'series'
|
||||
releases.extend(series_releases)
|
||||
except Exception as e:
|
||||
logger.warning(f"Series releases fetch failed: {e}")
|
||||
|
||||
if content_type and content_type != "all":
|
||||
releases = [r for r in releases if r.get("content_type") == content_type]
|
||||
else:
|
||||
anime_items = [r for r in releases if r.get("content_type") == "anime"]
|
||||
series_items = [r for r in releases if r.get("content_type") == "series"]
|
||||
releases = _weighted_mix(anime_items, series_items, limit,
|
||||
weight_a=anime_weight, weight_b=series_weight)
|
||||
|
||||
if html or is_htmx:
|
||||
return templates.TemplateResponse(
|
||||
"components/releases_list.html",
|
||||
{"request": request, "releases": releases}
|
||||
@@ -95,8 +231,7 @@ async def get_latest_releases(
|
||||
"updated": datetime.now().isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Latest releases error: {e}", exc_info=True)
|
||||
logger.error(f"Latest releases error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@@ -177,3 +312,41 @@ async def get_download_statistics(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
await engine.close()
|
||||
|
||||
|
||||
@router.get("/series/latest")
|
||||
async def get_latest_series(
|
||||
request: Request,
|
||||
limit: int = 20,
|
||||
html: bool = Query(False),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""Get latest TV series releases from FS7 homepage"""
|
||||
if current_user is None and (html or request.headers.get("HX-Request")):
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
from app.downloaders.series_sites.fs7 import FS7Downloader
|
||||
|
||||
downloader = FS7Downloader()
|
||||
series = await downloader.get_latest_series(limit=limit)
|
||||
|
||||
if html or request.headers.get("HX-Request"):
|
||||
return templates.TemplateResponse(
|
||||
"components/series_releases_list.html",
|
||||
{"request": request, "releases": series}
|
||||
)
|
||||
|
||||
return {
|
||||
"releases": series,
|
||||
"count": len(series),
|
||||
"updated": datetime.now().isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Latest series error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Application settings routes for Ohm Stream Downloader API"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@@ -13,10 +15,74 @@ from app.routers.router_auth import get_current_user_from_token, get_optional_us
|
||||
from app.providers import get_anime_providers, get_series_providers
|
||||
from app.providers_manager import providers_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
def _compute_auto_weights(download_dir: str) -> Dict[str, Any]:
|
||||
"""Analyze downloaded files to compute anime vs series ratio.
|
||||
|
||||
Uses filename conventions:
|
||||
- Series: contains "Saison" or "Season" keywords
|
||||
- Anime: everything else in the downloads folder
|
||||
Returns dict with counts and computed weights.
|
||||
"""
|
||||
base = Path(download_dir)
|
||||
if not base.exists():
|
||||
return {"anime_count": 0, "series_count": 0, "anime_weight": 1, "series_weight": 1, "total": 0}
|
||||
|
||||
anime_count = 0
|
||||
series_count = 0
|
||||
|
||||
for f in base.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
if f.suffix.lower() not in (".mp4", ".mkv", ".avi", ".wmv", ".flv", ".mov"):
|
||||
continue
|
||||
|
||||
name = f.stem.lower()
|
||||
# Heuristic: series TV files often have "Saison" or "Season" + number
|
||||
# Anime files rarely use this format (they use "Episode" or "S01E01")
|
||||
import re
|
||||
if re.search(r'(?:saison|season)\s*\d+', name):
|
||||
series_count += 1
|
||||
else:
|
||||
anime_count += 1
|
||||
|
||||
total = anime_count + series_count
|
||||
if total == 0:
|
||||
return {"anime_count": 0, "series_count": 0, "anime_weight": 1, "series_weight": 1, "total": 0}
|
||||
|
||||
# Compute weights: proportional to download count, minimum 1
|
||||
if anime_count == 0:
|
||||
aw, sw = 0, 1
|
||||
elif series_count == 0:
|
||||
aw, sw = 1, 0
|
||||
else:
|
||||
# Keep weights small (max 5) for reasonable interleaving
|
||||
ratio = anime_count / series_count
|
||||
if ratio >= 4:
|
||||
aw, sw = 4, 1
|
||||
elif ratio >= 2:
|
||||
aw, sw = 2, 1
|
||||
elif ratio >= 1:
|
||||
aw, sw = 1, 1
|
||||
elif ratio >= 0.5:
|
||||
aw, sw = 1, 2
|
||||
else:
|
||||
aw, sw = 1, 4
|
||||
|
||||
return {
|
||||
"anime_count": anime_count,
|
||||
"series_count": series_count,
|
||||
"anime_weight": aw,
|
||||
"series_weight": sw,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=AppSettings)
|
||||
async def get_settings(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
@@ -44,6 +110,9 @@ async def get_settings(
|
||||
anime_enabled=getattr(settings_obj, 'anime_enabled', True),
|
||||
series_enabled=getattr(settings_obj, 'series_enabled', True),
|
||||
download_dir=getattr(settings_obj, 'download_dir', 'downloads'),
|
||||
content_weight_mode=getattr(settings_obj, 'content_weight_mode', 'auto'),
|
||||
content_weight_anime=getattr(settings_obj, 'content_weight_anime', 2),
|
||||
content_weight_series=getattr(settings_obj, 'content_weight_series', 1),
|
||||
)
|
||||
|
||||
|
||||
@@ -86,6 +155,12 @@ async def update_settings(
|
||||
settings_obj.series_enabled = update_data.series_enabled
|
||||
if update_data.download_dir is not None:
|
||||
settings_obj.download_dir = update_data.download_dir
|
||||
if update_data.content_weight_mode is not None:
|
||||
settings_obj.content_weight_mode = update_data.content_weight_mode
|
||||
if update_data.content_weight_anime is not None:
|
||||
settings_obj.content_weight_anime = update_data.content_weight_anime
|
||||
if update_data.content_weight_series is not None:
|
||||
settings_obj.content_weight_series = update_data.content_weight_series
|
||||
|
||||
session.add(settings_obj)
|
||||
session.commit()
|
||||
@@ -98,6 +173,34 @@ async def update_settings(
|
||||
return settings_obj
|
||||
|
||||
|
||||
@router.get("/content-weight")
|
||||
async def get_content_weight(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Get current effective content weights (auto-computed or manual)"""
|
||||
statement = select(AppSettingsTable).where(
|
||||
AppSettingsTable.user_id == current_user.id
|
||||
)
|
||||
settings_obj = session.exec(statement).first()
|
||||
download_dir = getattr(settings_obj, 'download_dir', 'downloads') if settings_obj else 'downloads'
|
||||
mode = getattr(settings_obj, 'content_weight_mode', 'auto') if settings_obj else 'auto'
|
||||
|
||||
if mode == "auto":
|
||||
weights = _compute_auto_weights(download_dir)
|
||||
weights["mode"] = "auto"
|
||||
return weights
|
||||
else:
|
||||
return {
|
||||
"mode": "manual",
|
||||
"anime_weight": getattr(settings_obj, 'content_weight_anime', 2),
|
||||
"series_weight": getattr(settings_obj, 'content_weight_series', 1),
|
||||
"anime_count": None,
|
||||
"series_count": None,
|
||||
"total": None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/providers/availability")
|
||||
async def get_providers_availability(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
|
||||
@@ -213,7 +213,7 @@ async def delete_from_watchlist(
|
||||
raise HTTPException(status_code=500, detail="Failed to delete item")
|
||||
|
||||
|
||||
@router.post("/check", response_model=List)
|
||||
@router.post("/check")
|
||||
async def check_watchlist_now(
|
||||
background_tasks: BackgroundTasks,
|
||||
response: Response,
|
||||
|
||||
+12
-7
@@ -95,13 +95,18 @@ class DomainManager:
|
||||
response = await client.get(url)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Active domain found for {provider_id}: {domain}")
|
||||
cls._cache[provider_id] = {
|
||||
'domain': domain,
|
||||
'last_check': datetime.now().isoformat()
|
||||
}
|
||||
cls._save_cache()
|
||||
return domain
|
||||
# Verify it's actually the right site, not a parking/placeholder page
|
||||
content = response.text.lower()
|
||||
body_size = len(response.text)
|
||||
# Valid pages should be reasonably large and contain expected keywords
|
||||
if body_size > 10000 and ('french' in content or 'stream' in content or 'serie' in content or 'anime' in content or 'film' in content or 'telechargement' in content or 'zone' in content):
|
||||
logger.info(f"Active domain found for {provider_id}: {domain} ({body_size} bytes)")
|
||||
cls._cache[provider_id] = {
|
||||
'domain': domain,
|
||||
'last_check': datetime.now().isoformat()
|
||||
}
|
||||
cls._save_cache()
|
||||
return domain
|
||||
except Exception as e:
|
||||
logger.debug(f"Domain test failed for {domain}: {e}")
|
||||
continue
|
||||
|
||||
+11
-1
@@ -216,8 +216,12 @@ class WatchlistManager:
|
||||
update_check_time = update_last_checked
|
||||
|
||||
def get_due_items(self) -> List[WatchlistItem]:
|
||||
"""Get all items that are due for a check based on current settings"""
|
||||
return self.get_due_for_check(self.settings.check_interval_hours if self.settings else 6)
|
||||
|
||||
def get_due_for_check(self, interval_hours: int) -> List[WatchlistItem]:
|
||||
"""Get all items that are due for a check based on settings"""
|
||||
interval = timedelta(hours=self.settings.check_interval_hours)
|
||||
interval = timedelta(hours=interval_hours)
|
||||
now = datetime.now()
|
||||
|
||||
with Session(engine) as session:
|
||||
@@ -234,6 +238,12 @@ class WatchlistManager:
|
||||
|
||||
return due_items
|
||||
|
||||
def get_settings(self) -> WatchlistSettings:
|
||||
"""Get global watchlist settings"""
|
||||
if self.settings is None:
|
||||
self._load_settings()
|
||||
return self.settings
|
||||
|
||||
def update_settings(self, settings: WatchlistSettings) -> WatchlistSettings:
|
||||
"""Update global watchlist settings"""
|
||||
self.settings = settings
|
||||
|
||||
Reference in New Issue
Block a user