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/full_test2.mjs b/full_test2.mjs new file mode 100644 index 0000000..6d074cc --- /dev/null +++ b/full_test2.mjs @@ -0,0 +1,174 @@ +import { chromium } from 'playwright'; + +const BASE = 'http://127.0.0.1:3000'; +const opts = { waitUntil: 'domcontentloaded', timeout: 15000 }; + +(async () => { + const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); + + // Obtenir un token via API + const apiCtx = await browser.newContext(); + const apiPage = await apiCtx.newPage(); + await apiPage.goto(BASE + '/api/auth/login', opts); + const token = await apiPage.evaluate(async () => { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'roman', password: 'roman123' }) + }); + const data = await res.json(); + return data.access_token || null; + }); + await apiCtx.close(); + console.log(`Token obtained: ${token ? token.substring(0, 20) + '...' : 'FAILED'}`); + + if (!token) { + console.error('Cannot get token, aborting'); + process.exit(1); + } + + // ========== NON AUTHENTIFIE ========== + console.log('\n=== NON AUTHENTIFIE ==='); + const anonCtx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const anon = await anonCtx.newPage(); + + const snap = async (p, name, url, wait = 3000) => { + try { + await p.goto(url, opts); + await p.waitForTimeout(wait); + await p.screenshot({ path: `/tmp/screenshots/${name}.png`, fullPage: false }); + console.log(`OK: ${name}`); + } catch(e) { + console.log(`FAIL: ${name} - ${e.message}`); + } + }; + + await snap(anon, 'anon_01_home', `${BASE}/`); + await snap(anon, 'anon_02_watchlist', `${BASE}/watchlist`); + await snap(anon, 'anon_03_favorites', `${BASE}/favorites`); + await snap(anon, 'anon_04_downloads', `${BASE}/downloads`); + await snap(anon, 'anon_05_settings', `${BASE}/settings`); + await snap(anon, 'anon_06_recommendations', `${BASE}/recommendations`); + + // ========== AUTHENTIFIE (cookie + localStorage) ========== + console.log('\n=== AUTHENTIFIE ==='); + const authCtx = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + }); + // Injecter le token comme cookie AVANT toute navigation + await authCtx.addCookies([{ + name: 'auth_token', + value: token, + domain: '127.0.0.1', + path: '/', + sameSite: 'Strict', + httpOnly: false, + }]); + + const auth = await authCtx.newPage(); + + // Injecter dans localStorage au premier chargement + await auth.goto(BASE + '/', opts); + await auth.evaluate((t) => { + localStorage.setItem('auth_token', t); + }, token); + await auth.waitForTimeout(3000); + await auth.screenshot({ path: '/tmp/screenshots/auth_01_home.png', fullPage: false }); + console.log('OK: auth_01_home'); + + await snap(auth, 'auth_02_watchlist', `${BASE}/watchlist`); + await snap(auth, 'auth_03_favorites', `${BASE}/favorites`); + await snap(auth, 'auth_04_downloads', `${BASE}/downloads`); + await snap(auth, 'auth_05_settings', `${BASE}/settings`); + await snap(auth, 'auth_06_recommendations', `${BASE}/recommendations`); + + // ========== TESTS FONCTIONNELS ========== + console.log('\n=== TESTS FONCTIONNELS ==='); + + // Test API: toggle favori + const favResult = await auth.evaluate(async (t) => { + try { + const res = await fetch('/api/favorites/toggle', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${t}` + }, + body: JSON.stringify({ content_type: 'anime', anime_id: 'test-screenshot-1', title: 'Test Screenshot Anime' }) + }); + const data = await res.json(); + return { status: res.status, is_favorite: data.is_favorite }; + } catch(e) { + return { error: e.message }; + } + }, token); + console.log(`Favorite toggle: ${JSON.stringify(favResult)}`); + + // Voir les favoris + await snap(auth, 'auth_07_favorites_after_add', `${BASE}/favorites`); + + // Test API: ajouter watchlist item + const wlResult = await auth.evaluate(async (t) => { + try { + const res = await fetch('/api/watchlist', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${t}` + }, + body: JSON.stringify({ + anime_title: 'Test Screenshot Anime', + anime_url: 'https://example.com/anime/1', + episode_count: 12, + current_episode: 0, + status: 'watching' + }) + }); + const data = await res.json(); + return { status: res.status, id: data.id, title: data.anime_title }; + } catch(e) { + return { error: e.message }; + } + }, token); + console.log(`Watchlist add: ${JSON.stringify(wlResult)}`); + + // Voir la watchlist + await snap(auth, 'auth_08_watchlist_with_item', `${BASE}/watchlist`); + + // Scroller sur la home + await auth.goto(`${BASE}/`, opts); + await auth.waitForTimeout(2000); + await auth.evaluate(() => window.scrollTo(0, 600)); + await auth.waitForTimeout(1000); + await auth.screenshot({ path: '/tmp/screenshots/auth_09_home_scrolled.png', fullPage: false }); + console.log('OK: auth_09_home_scrolled'); + + // ========== NETTOYAGE ========== + console.log('\n=== Nettoyage ==='); + // Retirer le favori de test + await auth.evaluate(async (t) => { + await fetch('/api/favorites/toggle', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${t}` + }, + body: JSON.stringify({ content_type: 'anime', anime_id: 'test-screenshot-1', title: 'Test Screenshot Anime' }) + }); + }); + + // Retirer le watchlist item de test + if (wlResult.id) { + await auth.evaluate(async ({t, id}) => { + await fetch(`/api/watchlist/${id}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${t}` } + }); + }, { t: token, id: wlResult.id }); + console.log('Test watchlist item deleted'); + } + console.log('Test favorite removed'); + + await browser.close(); + console.log('\n=== ALL DONE ==='); +})(); 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 363d2a7..5f55a04 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,90 +1,28 @@ -/* Ohm Streaming - Material Design Dark Theme */ +/* Ohm Streaming - Flat Design Theme */ :root { - /* ========== EXISTING VARIABLES (kept for compatibility) ========== */ - --bg-dark: #0b0b14; - --bg-card: #161625; - --primary: #00d9ff; - --primary-hover: #00b8d9; - --primary-glow: rgba(0, 217, 255, 0.3); - --secondary: #ff6b6b; - --secondary-hover: #e55a5a; - --text-main: #ffffff; - --text-dim: #a0a0b0; - --accent: #00ff88; - --danger: #ff4d4d; - --card-radius: 12px; - --input-radius: 8px; - --transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); - - /* ========== MATERIAL DESIGN VARIABLES ========== */ - /* Material Colors */ - --md-primary: #6C63FF; - --md-primary-variant: #5a52d5; - --md-secondary: #00BCD4; - --md-secondary-variant: #0097a7; - --md-surface: #1E1E2E; - --md-surface-variant: #2A2A3C; - --md-background: #12121c; - --md-error: #ff5252; - --md-success: #4caf50; - --md-warning: #ffc107; - --md-on-primary: #ffffff; - --md-on-surface: #e0e0e0; - --md-on-background: #ffffff; - --md-outline: rgba(255, 255, 255, 0.12); - - /* Material Elevation (Box Shadows) */ - --md-elevation-0: none; - --md-elevation-1: 0 2px 8px rgba(0, 0, 0, 0.3); - --md-elevation-2: 0 4px 16px rgba(0, 0, 0, 0.4); - --md-elevation-3: 0 6px 24px rgba(0, 0, 0, 0.5); - --md-elevation-4: 0 8px 32px rgba(0, 0, 0, 0.6); - --md-elevation-hover: 0 12px 40px rgba(0, 0, 0, 0.7); - - /* Material Radius */ - --md-radius: 16px; - --md-radius-sm: 8px; - --md-radius-lg: 24px; - --md-radius-pill: 50px; - - /* Material Transitions */ - --md-transition-fast: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); - --md-transition-standard: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); - --md-transition-slow: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); - - /* Material Letter Spacing */ - --md-letter-spacing: 0.5px; - --md-letter-spacing-lg: 1px; - - /* ========== LIGHT THEME VARIABLES (commented, ready for toggle) ========== */ - /* - [data-theme="light"] { - --md-primary: #6C63FF; - --md-primary-variant: #5a52d5; - --md-secondary: #00BCD4; - --md-secondary-variant: #0097a7; - --md-surface: #ffffff; - --md-surface-variant: #f5f5f5; - --md-background: #fafafa; - --md-error: #d32f2f; - --md-success: #388e3c; - --md-warning: #ffa000; - --md-on-primary: #ffffff; - --md-on-surface: #1c1b1f; - --md-on-background: #1c1b1f; - --md-outline: rgba(0, 0, 0, 0.12); - --md-elevation-1: 0 2px 8px rgba(0, 0, 0, 0.15); - --md-elevation-2: 0 4px 16px rgba(0, 0, 0, 0.2); - --md-elevation-3: 0 6px 24px rgba(0, 0, 0, 0.25); - --md-elevation-4: 0 8px 32px rgba(0, 0, 0, 0.3); - --md-elevation-hover: 0 12px 40px rgba(0, 0, 0, 0.35); - --bg-dark: #f5f5f5; - --bg-card: #ffffff; - --primary: #6C63FF; - --text-main: #1c1b1f; - --text-dim: #605d62; - } - */ + /* ========== FLAT DESIGN VARIABLES - SUNSET GLITCH PALETTE ========== */ + --primary: #FF9F1C; + --primary-hover: #e08a15; + --bg-dark: #15171A; + --bg-card: #202327; + --bg-elevated: #2a2d32; + --text-main: #F2F2F2; + --text-dim: #8a8f98; + --text-muted: #5a5f68; + --secondary: #FF9F1C; + --border: #2a2d32; + --border-hover: #FFBF69; + --accent: #FF9F1C; + --hover: rgba(255, 191, 105, 0.15); + --lilac: #8a8f98; + --pastel-petal: #F2F2F2; + --surface-hover: rgba(255, 191, 105, 0.08); + --danger: #e63946; + --success: #2d936c; + --warning: #f4a261; + --card-radius: 4px; + --input-radius: 4px; + --transition: all 0.2s ease; } /* ========== BASE RESET ========== */ @@ -102,31 +40,31 @@ body { overflow-x: hidden; } -/* ========== CUSTOM SCROLLBAR (Material Design) ========== */ +/* ========== CUSTOM SCROLLBAR ========== */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.03); - border-radius: 10px; + background: #202327; + border-radius: 4px; } ::-webkit-scrollbar-thumb { - background: var(--md-primary); - border-radius: 10px; - transition: var(--md-transition-standard); + background: var(--text-dim); + border-radius: 4px; + transition: var(--transition); } ::-webkit-scrollbar-thumb:hover { - background: var(--md-primary-variant); + background: var(--text-main); } /* Firefox scrollbar */ * { scrollbar-width: thin; - scrollbar-color: var(--md-primary) rgba(255, 255, 255, 0.03); + scrollbar-color: var(--text-dim) #202327; } /* ========== CONTAINER ========== */ @@ -148,9 +86,7 @@ h1 { font-weight: 800; letter-spacing: -1px; margin-bottom: 5px; - background: linear-gradient(90deg, var(--primary), var(--accent)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; + color: var(--primary); } .subtitle { @@ -169,48 +105,28 @@ h1 { .section-header h2 { font-size: 1.5rem; font-weight: 700; - border-left: 4px solid var(--primary); + border-left: 4px solid #FFBF69; padding-left: 15px; + color: var(--text-main); } -/* ========== MATERIAL DESIGN BUTTONS ========== */ +/* ========== FLAT BUTTONS ========== */ .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 20px; - border-radius: var(--md-radius-sm); + border-radius: var(--input-radius); font-size: 0.9rem; font-weight: 600; - letter-spacing: var(--md-letter-spacing); cursor: pointer; - transition: var(--md-transition-standard); + transition: var(--transition); border: 1px solid transparent; text-decoration: none; color: #fff; white-space: nowrap; position: relative; - overflow: hidden; -} - -/* Ripple effect (CSS-only) */ -.btn::before { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 0; - height: 0; - border-radius: 50%; - background: rgba(255, 255, 255, 0.3); - transform: translate(-50%, -50%); - transition: width 0.6s, height 0.6s; -} - -.btn:active::before { - width: 300px; - height: 300px; } .btn:disabled { @@ -218,94 +134,81 @@ h1 { cursor: not-allowed; } -.btn:disabled::before { - display: none; -} - -/* Material Button Variants */ -.btn-contained { - background: var(--md-primary); - color: var(--md-on-primary); - box-shadow: var(--md-elevation-1); -} - -.btn-contained:hover:not(:disabled) { - background: var(--md-primary-variant); - box-shadow: var(--md-elevation-2); - transform: translateY(-2px); -} - -.btn-outlined { - background: transparent; - border: 1px solid var(--md-outline); - color: var(--md-on-surface); -} - -.btn-outlined:hover:not(:disabled) { - background: rgba(108, 99, 255, 0.08); - border-color: var(--md-primary); - color: var(--md-primary); -} - -.btn-text { - background: transparent; - color: var(--md-primary); - padding: 10px 15px; -} - -.btn-text:hover:not(:disabled) { - background: rgba(108, 99, 255, 0.08); -} - -/* Primary Button (legacy compatibility) */ +/* Primary Button */ .btn-primary { background: var(--primary); - color: #000; - box-shadow: var(--md-elevation-1); + color: #fff; } .btn-primary:hover:not(:disabled) { background: var(--primary-hover); - box-shadow: 0 0 15px var(--primary-glow), var(--md-elevation-2); - transform: translateY(-2px); } /* Secondary Button */ .btn-secondary { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.1); + background: var(--bg-card); + border-color: var(--text-dim); color: var(--text-main); } .btn-secondary:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.2); - box-shadow: var(--md-elevation-1); + background: var(--text-dim); + border-color: var(--text-main); } /* Accent Button */ .btn-accent { background: var(--accent); - color: #000; - box-shadow: var(--md-elevation-1); + color: #fff; } .btn-accent:hover:not(:disabled) { - box-shadow: var(--md-elevation-2); - transform: translateY(-2px); + background: var(--primary-hover); } /* Danger Button */ .btn-danger { - background: rgba(255, 77, 77, 0.15); - border-color: rgba(255, 77, 77, 0.3); + background: transparent; + border-color: var(--danger); color: var(--danger); } .btn-danger:hover:not(:disabled) { background: var(--danger); color: #fff; - box-shadow: var(--md-elevation-2); +} + +/* Contained Button */ +.btn-contained { + background: var(--primary); + color: #fff; +} + +.btn-contained:hover:not(:disabled) { + background: var(--primary-hover); +} + +/* Outlined Button */ +.btn-outlined { + background: transparent; + border: 1px solid var(--text-dim); + color: var(--text-main); +} + +.btn-outlined:hover:not(:disabled) { + border-color: var(--text-dim); + color: var(--text-dim); +} + +/* Text Button */ +.btn-text { + background: transparent; + color: var(--primary); + padding: 10px 15px; +} + +.btn-text:hover:not(:disabled) { + background: var(--bg-card); } /* Button Sizes */ @@ -343,42 +246,37 @@ h1 { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; - letter-spacing: var(--md-letter-spacing-lg); } .btn-watch { background: var(--primary); - color: #000; - box-shadow: var(--md-elevation-1); -} - -.btn-watch:hover { - box-shadow: var(--md-elevation-2); -} - -.btn-download { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; } -.btn-download:hover { - background: rgba(255, 255, 255, 0.1); +.btn-watch:hover:not(:disabled) { + background: var(--primary-hover); } -/* ========== MATERIAL DESIGN CARDS ========== */ +.btn-download { + background: var(--bg-card); + border: 1px solid var(--text-dim); + color: var(--text-main); +} + +.btn-download:hover:not(:disabled) { + background: var(--text-dim); +} + +/* ========== FLAT CARDS ========== */ .card, .hc, .download-item { - background: var(--md-surface); - border-radius: var(--md-radius); - border: 1px solid rgba(255, 255, 255, 0.06); - box-shadow: var(--md-elevation-1); - transition: var(--md-transition-standard); + background: var(--bg-card); + border-radius: var(--card-radius); + border: 1px solid var(--text-dim); + transition: var(--transition); } .card:hover, .hc:hover, .download-item:hover { - transform: translateY(-2px); - box-shadow: var(--md-elevation-2); - border-color: rgba(255, 255, 255, 0.1); + border-color: #FFBF69; } /* ========== HORIZONTAL SCROLL ROW ========== */ @@ -402,36 +300,32 @@ h1 { .streaming-row::-webkit-scrollbar-thumb, .recommendations-carousel::-webkit-scrollbar-thumb, .releases-carousel::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.1); - border-radius: 10px; + background: var(--text-dim); + border-radius: 4px; } .home-row::-webkit-scrollbar-thumb:hover, .streaming-row::-webkit-scrollbar-thumb:hover, .recommendations-carousel::-webkit-scrollbar-thumb:hover, .releases-carousel::-webkit-scrollbar-thumb:hover { - background: var(--primary); + background: var(--text-main); } -/* ========== HOME CARD (Material Card) ========== */ +/* ========== HOME CARD ========== */ .hc { flex: 0 0 180px; display: block; - background: var(--md-surface); - border-radius: var(--md-radius); + background: var(--bg-card); + border-radius: var(--card-radius); overflow: hidden; - transition: var(--md-transition-standard); - border: 1px solid rgba(255, 255, 255, 0.06); + transition: var(--transition); + border: 1px solid var(--text-dim); text-decoration: none; color: inherit; - box-shadow: var(--md-elevation-1); } .hc:hover { - transform: translateY(-2px) scale(1.02); - z-index: 10; - border-color: var(--primary); - box-shadow: var(--md-elevation-hover); + border-color: #FFBF69; } .hc-poster { @@ -453,14 +347,12 @@ h1 { position: absolute; top: 8px; right: 8px; - background: rgba(0, 0, 0, 0.85); + background: rgba(0, 0, 0, 0.75); color: #ffcc00; padding: 4px 8px; - border-radius: var(--md-radius-sm); + border-radius: 4px; font-size: 0.7rem; font-weight: 800; - letter-spacing: var(--md-letter-spacing); - backdrop-filter: blur(4px); } .hc-play { @@ -471,19 +363,17 @@ h1 { height: 36px; border-radius: 50%; background: var(--primary); - color: var(--bg-dark); + color: #fff; display: flex; align-items: center; justify-content: center; font-size: 0.75rem; opacity: 0; - transition: var(--md-transition-standard); - box-shadow: var(--md-elevation-2); + transition: var(--transition); } .hc:hover .hc-play { opacity: 1; - transform: scale(1.1); } .hc-info { @@ -495,7 +385,6 @@ h1 { font-weight: 700; text-transform: uppercase; color: var(--primary); - letter-spacing: var(--md-letter-spacing-lg); display: block; margin-bottom: 4px; } @@ -517,11 +406,11 @@ h1 { gap: 20px; } -/* ========== MATERIAL TABS ========== */ +/* ========== FLAT TABS ========== */ .tabs { display: flex; gap: 20px; - border-bottom: 1px solid var(--md-outline); + border-bottom: 1px solid var(--text-dim); margin-bottom: 30px; overflow-x: auto; } @@ -534,12 +423,11 @@ h1 { font-weight: 600; cursor: pointer; position: relative; - transition: var(--md-transition-standard); + transition: var(--transition); display: flex; align-items: center; gap: 8px; white-space: nowrap; - letter-spacing: var(--md-letter-spacing); } .tab svg { @@ -552,7 +440,7 @@ h1 { } .tab.active { - color: var(--primary); + color: #FFBF69; } .tab.active::after { @@ -562,32 +450,29 @@ h1 { left: 0; width: 100%; height: 3px; - background: var(--primary); - box-shadow: 0 0 10px var(--primary-glow); + background: #FF9F1C; border-radius: 3px 3px 0 0; } -/* ========== MATERIAL INPUTS ========== */ +/* ========== FLAT INPUTS ========== */ .input-group { display: flex; - background: rgba(255, 255, 255, 0.03); - border-radius: var(--md-radius); + background: var(--bg-card); + border-radius: var(--input-radius); padding: 6px; - border: 1px solid var(--md-outline); - transition: var(--md-transition-standard); + border: 1px solid var(--text-dim); + transition: var(--transition); } .input-group:focus-within { - border-color: var(--primary); - background: rgba(255, 255, 255, 0.05); - box-shadow: 0 0 15px rgba(0, 217, 255, 0.1); + border-color: #FFBF69; } .input-group input { background: none; border: none; padding: 12px 16px; - color: #fff; + color: var(--text-main); flex-grow: 1; font-size: 1rem; } @@ -603,10 +488,10 @@ h1 { .btn-search { padding: 0 25px; - border-radius: var(--md-radius-sm); + border-radius: var(--input-radius); } -/* Material Underline Input Style */ +/* Flat Input Style */ .form-group { margin-bottom: 24px; position: relative; @@ -618,8 +503,7 @@ h1 { color: var(--text-dim); font-size: 0.85rem; font-weight: 500; - letter-spacing: var(--md-letter-spacing); - transition: var(--md-transition-standard); + transition: var(--transition); } .form-group input { @@ -627,22 +511,22 @@ h1 { padding: 16px 0; background: transparent; border: none; - border-bottom: 2px solid var(--md-outline); - color: #fff; + border-bottom: 2px solid var(--text-dim); + color: var(--text-main); font-size: 1rem; - transition: var(--md-transition-standard); + transition: var(--transition); } .form-group input:focus { outline: none; - border-bottom-color: var(--primary); + border-bottom-color: #FFBF69; } .form-group input:focus + label, .form-group input:not(:placeholder-shown) + label { transform: translateY(-24px); font-size: 0.75rem; - color: var(--primary); + color: var(--text-dim); } .form-group input::placeholder { @@ -653,34 +537,33 @@ h1 { .auth-panel { margin-bottom: 25px; padding: 16px 20px; - background: linear-gradient(90deg, rgba(0, 217, 255, 0.1), transparent); - border: 1px solid rgba(0, 217, 255, 0.15); - border-radius: var(--md-radius); + background: var(--bg-card); + border: 1px solid var(--text-dim); + border-radius: var(--card-radius); display: flex; justify-content: space-between; align-items: center; - box-shadow: var(--md-elevation-1); } .auth-container { max-width: 450px; margin: 80px auto; padding: 40px; - background: var(--md-surface); - border-radius: var(--md-radius-lg); - border: 1px solid var(--md-outline); - box-shadow: var(--md-elevation-3); + background: var(--bg-card); + border-radius: 6px; + border: 1px solid rgba(255, 191, 105, 0.3); } .auth-title { text-align: center; margin-bottom: 30px; + color: var(--text-dim); } .auth-tabs { display: flex; margin-bottom: 30px; - border-bottom: 1px solid var(--md-outline); + border-bottom: 1px solid var(--text-dim); } .auth-tab { @@ -689,13 +572,13 @@ h1 { text-align: center; cursor: pointer; color: var(--text-dim); - transition: var(--md-transition-standard); + transition: var(--transition); font-weight: 600; position: relative; } .auth-tab.active { - color: var(--primary); + color: #FFBF69; } .auth-tab.active::after { @@ -705,7 +588,7 @@ h1 { left: 0; width: 100%; height: 3px; - background: var(--primary); + background: #FF9F1C; } .auth-form { @@ -718,32 +601,32 @@ h1 { .auth-error, .auth-success { padding: 12px 16px; - border-radius: var(--md-radius-sm); + border-radius: var(--input-radius); margin-bottom: 20px; font-size: 0.9rem; display: none; } .auth-error { - background: rgba(255, 77, 77, 0.1); - border: 1px solid rgba(255, 77, 77, 0.3); + background: rgba(230, 57, 70, 0.1); + border: 1px solid var(--danger); color: var(--danger); } .auth-success { - background: rgba(0, 255, 136, 0.1); - border: 1px solid rgba(0, 255, 136, 0.3); - color: var(--accent); + background: rgba(255, 191, 105, 0.1); + border: 1px solid var(--success); + color: var(--success); } .show { display: block !important; } -/* ========== MATERIAL PROGRESS BARS ========== */ +/* ========== FLAT PROGRESS BARS ========== */ .progress-container { height: 6px; - background: rgba(255, 255, 255, 0.1); + background: rgba(255, 191, 105, 0.15); border-radius: 3px; margin: 12px 0; overflow: hidden; @@ -751,7 +634,7 @@ h1 { .progress-bar { height: 100%; - background: linear-gradient(90deg, var(--primary), var(--accent)); + background: #FF9F1C; transition: width 0.3s ease; border-radius: 3px; } @@ -761,21 +644,20 @@ h1 { width: 40px; height: 40px; padding: 0; - border-radius: var(--md-radius-sm); - background: rgba(255, 255, 255, 0.05); - border: 1px solid var(--md-outline); + border-radius: var(--input-radius); + background: var(--bg-card); + border: 1px solid var(--text-dim); color: var(--text-main); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; - transition: var(--md-transition-standard); + transition: var(--transition); } .btn-icon:hover { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.2); - box-shadow: var(--md-elevation-1); + background: var(--text-dim); + border-color: var(--text-main); } .btn-icon.danger { @@ -783,17 +665,19 @@ h1 { } .btn-icon.danger:hover { - background: rgba(255, 77, 77, 0.15); - border-color: rgba(255, 77, 77, 0.3); + background: var(--danger); + color: #fff; + border-color: var(--danger); } .btn-icon.success { - color: var(--accent); + color: var(--success); } .btn-icon.success:hover { - background: rgba(0, 255, 136, 0.15); - border-color: rgba(0, 255, 136, 0.3); + background: var(--success); + color: #fff; + border-color: var(--success); } /* ========== DOWNLOAD ITEMS ========== */ @@ -804,20 +688,18 @@ h1 { } .download-item { - background: var(--md-surface); - border-radius: var(--md-radius); + background: var(--bg-card); + border-radius: var(--card-radius); padding: 20px; - border: 1px solid rgba(255, 255, 255, 0.06); - transition: var(--md-transition-standard); - box-shadow: var(--md-elevation-1); + border: 1px solid var(--text-dim); + transition: var(--transition); border-left: 4px solid var(--text-dim); margin-bottom: 10px; } .download-item:hover { - border-color: var(--primary); - transform: translateY(-2px); - box-shadow: var(--md-elevation-2); + border-color: #FFBF69; + border-left-color: #FFBF69; } .download-info { @@ -852,11 +734,13 @@ h1 { } .download-actions .btn-icon.warning { - color: #f0a500; + color: var(--warning); } .download-actions .btn-icon.warning:hover { - background: rgba(240, 165, 0, 0.2); + background: var(--warning); + color: #fff; + border-color: var(--warning); } /* Download Status Colors */ @@ -866,7 +750,7 @@ h1 { } .download-item.status-completed { - border-left-color: var(--accent); + border-left-color: var(--success); } .download-item.status-failed, @@ -875,7 +759,7 @@ h1 { } .download-item.status-paused { - border-left-color: #f0a500; + border-left-color: var(--warning); } .download-item.status-pending { @@ -887,56 +771,45 @@ h1 { border-left-color: var(--primary); } 50% { - border-left-color: rgba(0, 217, 255, 0.3); + border-left-color: var(--warning); } } -/* Progress bar shimmer */ +/* Progress bar for downloading */ .download-item.status-downloading .progress-bar { - background: linear-gradient(90deg, var(--primary) 0%, var(--accent) 50%, var(--primary) 100%); - background-size: 200% 100%; - animation: shimmer 1.5s ease-in-out infinite; -} - -@keyframes shimmer { - 0% { - background-position: 200% 0; - } - 100% { - background-position: -200% 0; - } + background: var(--text-dim); } /* ========== BADGE SYSTEM ========== */ .badge-completed { - color: var(--accent); - background: rgba(0, 255, 136, 0.1); + color: var(--success); + background: rgba(255, 191, 105, 0.1); padding: 4px 8px; - border-radius: var(--md-radius-sm); + border-radius: var(--input-radius); } .badge-failed { color: var(--danger); - background: rgba(255, 77, 77, 0.1); + background: rgba(230, 57, 70, 0.1); padding: 4px 8px; - border-radius: var(--md-radius-sm); + border-radius: var(--input-radius); } .badge-downloading { color: var(--primary); - background: rgba(0, 217, 255, 0.1); + background: rgba(255, 191, 105, 0.15); padding: 4px 8px; - border-radius: var(--md-radius-sm); + border-radius: var(--input-radius); } .badge-paused { - color: #ffcc00; - background: rgba(255, 204, 0, 0.1); + color: var(--warning); + background: rgba(244, 162, 97, 0.1); padding: 4px 8px; - border-radius: var(--md-radius-sm); + border-radius: var(--input-radius); } -/* ========== MATERIAL LOADING STATES ========== */ +/* ========== LOADING STATES ========== */ .loading-placeholder, .loading-spinner-container { display: flex; flex-direction: column; @@ -947,12 +820,12 @@ h1 { gap: 15px; } -/* Material Circular Spinner */ +/* Circular Spinner */ .spinner { width: 40px; height: 40px; - border: 3px solid rgba(108, 99, 255, 0.2); - border-top-color: var(--md-primary); + border: 3px solid var(--text-dim); + border-top-color: var(--text-main); border-radius: 50%; animation: spin 1s linear infinite; } @@ -963,25 +836,19 @@ h1 { } } -/* Material Skeleton Loading */ +/* Skeleton Loading */ .skeleton { - background: linear-gradient( - 90deg, - var(--md-surface) 0%, - var(--md-surface-variant) 50%, - var(--md-surface) 100% - ); - background-size: 200% 100%; + background: rgba(255, 191, 105, 0.1); animation: skeleton-loading 1.5s ease-in-out infinite; - border-radius: var(--md-radius-sm); + border-radius: var(--input-radius); } @keyframes skeleton-loading { - 0% { - background-position: 200% 0; + 0%, 100% { + opacity: 0.5; } - 100% { - background-position: -200% 0; + 50% { + opacity: 1; } } @@ -998,7 +865,7 @@ h1 { .skeleton-card { height: 200px; - border-radius: var(--md-radius); + border-radius: var(--card-radius); } .empty-state, .no-results { @@ -1029,7 +896,7 @@ h1 { margin-bottom: 50px; } -/* ========== MATERIAL TOAST/SNACKBAR ========== */ +/* ========== FLAT TOAST ========== */ .toast-container { position: fixed; bottom: 24px; @@ -1044,32 +911,33 @@ h1 { .toast { padding: 16px 24px; - background: var(--md-surface); - border-left: 4px solid var(--primary); - border-radius: var(--md-radius); - box-shadow: var(--md-elevation-3); + background: var(--bg-card); + border-left: 4px solid var(--text-dim); + border-radius: var(--card-radius); + border: 1px solid var(--text-dim); display: flex; align-items: center; gap: 12px; animation: slide-up 0.3s ease-out; min-width: 300px; max-width: 500px; + color: var(--text-main); } .toast.success { - border-left-color: var(--md-success); + border-left-color: var(--success); } .toast.error { - border-left-color: var(--md-error); + border-left-color: var(--danger); } .toast.warning { - border-left-color: var(--md-warning); + border-left-color: var(--warning); } .toast.info { - border-left-color: var(--md-primary); + border-left-color: #FFBF69; } @keyframes slide-up { @@ -1102,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; @@ -1111,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; } @@ -1144,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 { @@ -1167,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/anime-details.js b/static/js/anime-details.js index 51d36ca..b530340 100644 --- a/static/js/anime-details.js +++ b/static/js/anime-details.js @@ -82,7 +82,7 @@ async function searchAnimeDetails(query, malId = null) { if (hasResults) { streamingParts.unshift( `
ℹ️ Aucune fiche trouvée sur MyAnimeList pour "${escapeHtml(query)}"
+Aucune fiche trouvée sur MyAnimeList pour "${escapeHtml(query)}"
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End")
@@ -125,7 +125,7 @@ async function searchAnimeDetails(query, malId = null) { } else { resultsContainer.innerHTML = `❌ Aucun résultat trouvé pour "${escapeHtml(query)}"
+Aucun résultat trouvé pour "${escapeHtml(query)}"
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End", "One Piece")
@@ -138,7 +138,7 @@ async function searchAnimeDetails(query, malId = null) { console.error('Error searching anime details:', error); resultsContainer.innerHTML = `❌ Erreur lors de la recherche.
+Erreur lors de la recherche.
${error.message}
${escapeHtml(synopsis)}
@@ -302,7 +302,7 @@ function renderAnimeDetails(anime) { ${seasons.length > 0 ? `