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
+1
View File
@@ -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
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")
+27 -4
View File
@@ -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
+149 -14
View File
@@ -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
+1
View File
@@ -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
+40
View File
@@ -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))
+13
View File
@@ -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
View File
@@ -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:
+194 -21
View File
@@ -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))
+103
View File
@@ -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),
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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