From 87f245d3fcb5a784a5fd66603f24c26b30ab6cec Mon Sep 17 00:00:00 2001 From: root Date: Sat, 11 Apr 2026 19:30:32 +0000 Subject: [PATCH] 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 --- app/database.py | 1 + app/download_manager.py | 120 +++++- app/downloaders/anime_sites/animesama.py | 31 +- app/downloaders/series_sites/fs7.py | 163 +++++++- app/models/__init__.py | 1 + app/models/download.py | 40 ++ app/models/settings.py | 13 + app/routers/router_anime.py | 25 +- app/routers/router_recommendations.py | 215 +++++++++- app/routers/router_settings.py | 103 +++++ app/routers/router_watchlist.py | 2 +- app/utils.py | 19 +- app/watchlist.py | 12 +- main.py | 15 +- static/css/style.css | 174 ++++++-- static/js/settings.js | 206 ++++++++++ static/vendor/alpine.min.js | 5 + static/vendor/htmx.min.js | 1 + templates/base.html | 7 +- templates/components/series_card.html | 27 +- .../components/series_releases_list.html | 11 + templates/components/settings_section.html | 149 +++---- templates/components/toast_container.html | 5 + templates/index.html | 20 +- tests/auto/results/report.md | 28 ++ .../results/screenshots/01_landing_page.png | Bin 0 -> 51909 bytes .../results/screenshots/02_login_page.png | Bin 0 -> 22147 bytes .../auto/results/screenshots/03_tab_anime.png | Bin 0 -> 726251 bytes .../results/screenshots/03_tab_downloads.png | Bin 0 -> 52758 bytes .../auto/results/screenshots/03_tab_home.png | Bin 0 -> 1319724 bytes .../results/screenshots/03_tab_providers.png | Bin 0 -> 35810 bytes .../results/screenshots/03_tab_series.png | Bin 0 -> 566238 bytes .../results/screenshots/03_tab_settings.png | Bin 0 -> 173516 bytes .../results/screenshots/03_tab_watchlist.png | Bin 0 -> 92914 bytes .../results/screenshots/07_mobile_home.png | Bin 0 -> 165803 bytes tests/auto/run_tests.mjs | 371 ++++++++++++++++++ 36 files changed, 1549 insertions(+), 215 deletions(-) create mode 100644 app/models/download.py create mode 100644 static/js/settings.js create mode 100644 static/vendor/alpine.min.js create mode 100644 static/vendor/htmx.min.js create mode 100644 templates/components/series_releases_list.html create mode 100644 tests/auto/results/report.md create mode 100644 tests/auto/results/screenshots/01_landing_page.png create mode 100644 tests/auto/results/screenshots/02_login_page.png create mode 100644 tests/auto/results/screenshots/03_tab_anime.png create mode 100644 tests/auto/results/screenshots/03_tab_downloads.png create mode 100644 tests/auto/results/screenshots/03_tab_home.png create mode 100644 tests/auto/results/screenshots/03_tab_providers.png create mode 100644 tests/auto/results/screenshots/03_tab_series.png create mode 100644 tests/auto/results/screenshots/03_tab_settings.png create mode 100644 tests/auto/results/screenshots/03_tab_watchlist.png create mode 100644 tests/auto/results/screenshots/07_mobile_home.png create mode 100644 tests/auto/run_tests.mjs 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 = ` +
+ ${ac} anime${ac > 1 ? 's' : ''} (${pctA}%) — ${sc} serie${sc > 1 ? 's' : ''} (${pctS}%) +
+
+
+
+
+
+ Ratio applique : ${aw} anime / ${sw} serie +
+ `; + } + } catch (e) { + details.innerHTML = 'Erreur de chargement'; + } +} + +function updateWeightPreview() { + const awEl = document.getElementById('content_weight_anime_range'); + const swEl = document.getElementById('content_weight_series_range'); + const preview = document.getElementById('weight-preview'); + if (!awEl || !swEl || !preview) return; + + const aw = parseInt(awEl.value) || 0; + const sw = parseInt(swEl.value) || 0; + const total = aw + sw; + + if (total === 0) { + preview.innerHTML = 'Les deux poids ne peuvent pas etre a 0'; + return; + } + + const pctA = Math.round(aw / total * 100); + const pctS = 100 - pctA; + + preview.innerHTML = ` +
+ ${pctA}% animes  /  + ${pctS}% series +
+
+
+
+
+ `; +} + +async function saveManualWeights() { + const awEl = document.getElementById('content_weight_anime_range'); + const swEl = document.getElementById('content_weight_series_range'); + if (!awEl || !swEl) return; + + const aw = parseInt(awEl.value) || 0; + const sw = parseInt(swEl.value) || 0; + + if (aw === 0 && sw === 0) { + showToast('Les deux poids ne peuvent pas etre a 0', 'error'); + 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({ content_weight_mode: 'manual', content_weight_anime: aw, content_weight_series: sw }) + }); + if (r.ok) showToast('Equilibre mis a jour', 'success'); + } catch (e) { + showToast('Erreur: ' + e.message, 'error'); + } +} + +function showToast(message, type) { + const event = new CustomEvent('show-toast', { detail: { message, type } }); + document.dispatchEvent(event); +} + +// Initialize weight display when settings tab content is loaded via HTMX +document.addEventListener('htmx:afterSettle', function(evt) { + if (evt.detail.target) { + const mode = evt.detail.target.querySelector('#content_weight_mode'); + if (mode && mode.value === 'auto') { + loadAutoWeights(); + } else if (mode && mode.value === 'manual') { + updateWeightPreview(); + } + } +}); diff --git a/static/vendor/alpine.min.js b/static/vendor/alpine.min.js new file mode 100644 index 0000000..a3be81c --- /dev/null +++ b/static/vendor/alpine.min.js @@ -0,0 +1,5 @@ +(()=>{var nt=!1,it=!1,W=[],ot=-1;function Ut(e){Rn(e)}function Rn(e){W.includes(e)||W.push(e),Mn()}function Wt(e){let t=W.indexOf(e);t!==-1&&t>ot&&W.splice(t,1)}function Mn(){!it&&!nt&&(nt=!0,queueMicrotask(Nn))}function Nn(){nt=!1,it=!0;for(let e=0;ee.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>$(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function te(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Ae(e){Xt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function ue(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){kn(),ut.disconnect(),ft=!1}var le=[];function kn(){let e=ut.takeRecords();le.push(()=>e.length>0&&mt(e));let t=le.length;queueMicrotask(()=>{if(le.length===t)for(;le.length>0;)le.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return ue(),t}var pt=!1,Se=[];function rr(){pt=!0}function nr(){pt=!1,mt(Se),Se=[]}function mt(e){if(pt){Se=Se.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Ce(e){return z(B(e))}function k(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function z(e){return new Proxy({objects:e},Dn)}var Dn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Pn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Pn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>In(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function In(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function fe(e,t){let r=Ln(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=_t(e),n={interceptor:Re,...t};return te(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){re(i,e,t)}}function re(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function ke(e){let t=Me;Me=!1;let r=e();return Me=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=xt;function ar(e){sr=e}function xt(e,t){let r={};fe(r,e);let n=[r,...B(e)],i=typeof t=="function"?$n(n,t):Fn(n,t,e);return or.bind(null,e,t,i)}function $n(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(z([n,...e]),i);Ne(r,o)}}var gt={};function jn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return re(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Fn(e,t,r){let n=jn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=z([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>re(l,r,t));n.finished?(Ne(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Ne(i,l,a,s,r)}).catch(l=>re(l,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>re(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var wt="x-";function C(e=""){return wt+e}function cr(e){wt=e}var De={};function d(e,t){return De[e]=t,{before(r){if(!De[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(De).includes(e)}function pe(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(zn(n,r)).sort(Kn).map(o=>Bn(e,o))}function Et(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var yt=!1,de=new Map,ur=Symbol();function fr(e){yt=!0;let t=Symbol();ur=t,de.set(t,[]);let r=()=>{for(;de.get(t).length;)de.get(t).shift()();de.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Bn(e,t){let r=()=>{},n=De[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?de.get(ur).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function ne(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function zn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Kn(e,t){let r=G.indexOf(e.type)===-1?bt:e.type,n=G.indexOf(t.type)===-1?bt:t.type;return G.indexOf(r)-G.indexOf(n)}function J(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function D(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>D(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)D(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` - + + +