diff --git a/app/database.py b/app/database.py index a11cbe0..ce29310 100644 --- a/app/database.py +++ b/app/database.py @@ -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) diff --git a/app/download_manager.py b/app/download_manager.py index 8ff3a4a..1dbf3fe 100644 --- a/app/download_manager.py +++ b/app/download_manager.py @@ -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") diff --git a/app/downloaders/anime_sites/animesama.py b/app/downloaders/anime_sites/animesama.py index a09da34..23e37ec 100644 --- a/app/downloaders/anime_sites/animesama.py +++ b/app/downloaders/anime_sites/animesama.py @@ -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 diff --git a/app/downloaders/series_sites/fs7.py b/app/downloaders/series_sites/fs7.py index a5ad7d2..01673da 100644 --- a/app/downloaders/series_sites/fs7.py +++ b/app/downloaders/series_sites/fs7.py @@ -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 diff --git a/app/models/__init__.py b/app/models/__init__.py index 21bcbca..d924cab 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/download.py b/app/models/download.py new file mode 100644 index 0000000..7e55f34 --- /dev/null +++ b/app/models/download.py @@ -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)) diff --git a/app/models/settings.py b/app/models/settings.py index c705c92..e131a13 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -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 diff --git a/app/routers/router_anime.py b/app/routers/router_anime.py index 1e156c8..75104df 100644 --- a/app/routers/router_anime.py +++ b/app/routers/router_anime.py @@ -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: diff --git a/app/routers/router_recommendations.py b/app/routers/router_recommendations.py index 69cff44..cea7953 100644 --- a/app/routers/router_recommendations.py +++ b/app/routers/router_recommendations.py @@ -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)) diff --git a/app/routers/router_settings.py b/app/routers/router_settings.py index c4238b0..3e3c15f 100644 --- a/app/routers/router_settings.py +++ b/app/routers/router_settings.py @@ -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), diff --git a/app/routers/router_watchlist.py b/app/routers/router_watchlist.py index 752a88e..04cc9dc 100644 --- a/app/routers/router_watchlist.py +++ b/app/routers/router_watchlist.py @@ -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, diff --git a/app/utils.py b/app/utils.py index 545e882..4077398 100644 --- a/app/utils.py +++ b/app/utils.py @@ -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 diff --git a/app/watchlist.py b/app/watchlist.py index e1480a4..aa345c8 100644 --- a/app/watchlist.py +++ b/app/watchlist.py @@ -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 diff --git a/main.py b/main.py index d1cdf11..93df5a1 100644 --- a/main.py +++ b/main.py @@ -86,12 +86,17 @@ async def startup_event(): def restore_completed_downloads(): - """Scan downloads directory and restore completed download tasks""" + """Restore download tasks: first from the database, then scan for untracked files.""" + # Step 1: Load persisted tasks from database + download_manager._load_tasks_from_db() + + # Step 2: Scan downloads directory for files not yet tracked in the database download_dir = Path("downloads") if not download_dir.exists(): return video_extensions = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm"} + tracked_filenames = {t.filename for t in download_manager.tasks.values()} for file_path in download_dir.iterdir(): if file_path.is_file() and file_path.suffix.lower() in video_extensions: @@ -99,6 +104,11 @@ def restore_completed_downloads(): continue filename = file_path.name + + # Skip if already tracked in DB + if filename in tracked_filenames: + continue + file_size = file_path.stat().st_size task_id = str(uuid.uuid4()) @@ -118,7 +128,8 @@ def restore_completed_downloads(): ) download_manager.tasks[task_id] = task - logger.info(f"Restored completed download: {filename}") + download_manager._save_task_to_db(task) + logger.info(f"Restored untracked completed download: {filename}") # Restore completed downloads on startup diff --git a/static/css/style.css b/static/css/style.css index 1170286..5f55a04 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -970,6 +970,99 @@ h1 { } @media (max-width: 768px) { + /* --- Header --- */ + header { + padding: 16px 0 0; + } + + h1 { + font-size: 1.5rem; + letter-spacing: 0; + } + + .subtitle { + font-size: 0.85rem; + margin-bottom: 16px; + } + + /* --- Auth panel: compact on mobile --- */ + .auth-panel { + flex-direction: column; + gap: 6px; + text-align: center; + padding: 4px 0; + font-size: 0.8rem; + } + + .auth-panel > div { + justify-content: center; + } + + .auth-panel span { + font-size: 0.75rem; + } + + .auth-panel .btn { + margin: 0 auto; + } + + /* --- Tabs: compact icon mode, scrollable --- */ + .tabs { + gap: 0; + margin-bottom: 16px; + padding-bottom: 0; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .tabs::-webkit-scrollbar { + display: none; + } + + .tab { + padding: 12px 14px; + font-size: 0.7rem; + gap: 4px; + min-height: 48px; + min-width: auto; + flex: 0 0 auto; + } + + .tab svg { + width: 18px; + height: 18px; + flex-shrink: 0; + } + + .tab.active::after { + height: 2px; + } + + /* --- Touch targets: iOS minimum 44px --- */ + .btn, + .btn-small, + .btn-sm { + min-height: 44px; + padding: 10px 16px; + font-size: 0.85rem; + } + + .btn-xs { + min-height: 36px; + padding: 6px 10px; + } + + /* --- Input groups --- */ + .input-group { + flex-wrap: nowrap; + } + + .input-group input { + min-height: 44px; + font-size: 16px; /* prevent iOS zoom */ + } + + /* --- Cards --- */ .anime-grid { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 12px; @@ -979,32 +1072,47 @@ h1 { flex: 0 0 140px; } - .tabs { + .hc-play { + width: 40px; + height: 40px; + opacity: 1; /* always visible on mobile */ + } + + /* --- Sections --- */ + .section-header { + flex-direction: column; + align-items: flex-start; gap: 10px; } - .tab { - padding: 10px 16px; + .section-header h2 { + font-size: 1.2rem; } - .auth-panel { - flex-direction: column; - gap: 15px; - text-align: center; + .section-header .btn { + align-self: stretch; } + /* --- Layout --- */ + .container { + padding: 0 16px; + } + + /* --- Auth container (login page) --- */ .auth-container { - margin: 40px 20px; - padding: 24px; + margin: 20px 16px; + padding: 20px; } + /* --- Downloads --- */ .downloads-grid { grid-template-columns: 1fr; } + /* --- Toast --- */ .toast-container { - left: 20px; - right: 20px; + left: 16px; + right: 16px; transform: none; } @@ -1012,20 +1120,42 @@ h1 { min-width: auto; width: 100%; } + + /* --- Horizontal carousels: full bleed on mobile --- */ + .home-row, + .streaming-row, + .recommendations-carousel, + .releases-carousel { + padding: 10px 0 16px; + margin: 0 -16px; + padding-left: 16px; + padding-right: 16px; + } + + /* --- Settings form --- */ + .settings-section { + padding: 16px; + } + + /* --- Watchlist --- */ + .watchlist-item { + padding: 12px; + } } @media (max-width: 480px) { - .container { - padding: 0 16px; - } - h1 { - font-size: 2rem; + font-size: 1.3rem; } - .btn { - padding: 8px 16px; - font-size: 0.85rem; + .tab { + padding: 12px 10px; + font-size: 0.65rem; + } + + .tab svg { + width: 20px; + height: 20px; } .hc { @@ -1035,12 +1165,6 @@ h1 { .hc-info { padding: 8px; } - - .section-header { - flex-direction: column; - align-items: flex-start; - gap: 10px; - } } @media (min-width: 1400px) { diff --git a/static/js/settings.js b/static/js/settings.js new file mode 100644 index 0000000..5f7cd5f --- /dev/null +++ b/static/js/settings.js @@ -0,0 +1,206 @@ +/** + * Settings page - form handlers for user preferences, filters, and weights. + * Loaded on all pages via base.html so functions are available when + * the settings section is dynamically loaded via HTMX. + */ + +function saveSettings() { + const data = { + default_lang: document.getElementById('default_lang')?.value, + theme: document.getElementById('theme')?.value, + download_dir: document.getElementById('download_dir')?.value, + }; + const token = localStorage.getItem('auth_token'); + if (!token) return; + fetch('/api/settings', { + method: 'PATCH', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }).then(r => { + if (r.ok) showToast('Preferences enregistrees', 'success'); + }).catch(e => { + showToast('Erreur: ' + e.message, 'error'); + }); +} + +function saveFilter(field, value) { + const token = localStorage.getItem('auth_token'); + if (!token) return; + fetch('/api/settings', { + method: 'PATCH', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ [field]: value }) + }).then(r => { + if (r.ok) showToast('Filtre mis a jour', 'success'); + }).catch(e => { + showToast('Erreur: ' + e.message, 'error'); + }); +} + +async function toggleCategory(field, value) { + if (!value) { + const otherField = field === 'anime_enabled' ? 'series_enabled' : 'anime_enabled'; + const otherCheckbox = document.getElementById(otherField); + if (otherCheckbox && !otherCheckbox.checked) { + showToast('Au moins une categorie doit rester active', 'error'); + document.getElementById(field).checked = true; + return; + } + } + const token = localStorage.getItem('auth_token'); + if (!token) return; + try { + const r = await fetch('/api/settings', { + method: 'PATCH', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ [field]: value }) + }); + if (!r.ok) { + const err = await r.json().catch(() => ({})); + showToast(err.detail || 'Erreur', 'error'); + document.getElementById(field).checked = !value; + } else { + showToast('Categorie ' + (value ? 'activee' : 'desactivee'), 'success'); + } + } catch (e) { + showToast('Erreur: ' + e.message, 'error'); + document.getElementById(field).checked = !value; + } +} + +function onWeightModeChange(mode) { + const autoInfo = document.getElementById('weight-auto-info'); + const manualControls = document.getElementById('weight-manual-controls'); + + if (mode === 'auto') { + if (autoInfo) autoInfo.style.display = 'block'; + if (manualControls) manualControls.style.display = 'none'; + loadAutoWeights(); + } else { + if (autoInfo) autoInfo.style.display = 'none'; + if (manualControls) manualControls.style.display = 'block'; + updateWeightPreview(); + } + + const token = localStorage.getItem('auth_token'); + if (!token) return; + fetch('/api/settings', { + method: 'PATCH', + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, + body: JSON.stringify({ content_weight_mode: mode }) + }); +} + +async function loadAutoWeights() { + const details = document.getElementById('weight-auto-details'); + if (!details) return; + const token = localStorage.getItem('auth_token'); + if (!token) return; + try { + const r = await fetch('/api/settings/content-weight', { + headers: { 'Authorization': 'Bearer ' + token } + }); + if (!r.ok) return; + const data = await r.json(); + const aw = data.anime_weight; + const sw = data.series_weight; + const ac = data.anime_count; + const sc = data.series_count; + const total = data.total || 0; + + if (total === 0) { + details.innerHTML = 'Aucun telechargement detecte. Ratio par defaut : ' + aw + ' anime / ' + sw + ' serie.'; + } else { + const pctA = total > 0 ? Math.round(ac / total * 100) : 50; + const pctS = total > 0 ? Math.round(sc / total * 100) : 50; + details.innerHTML = ` +