feat: refonte flat design #20

Open
Roman wants to merge 3 commits from feat/flat-design into main
57 changed files with 2202 additions and 830 deletions
+1
View File
@@ -23,6 +23,7 @@ def create_db_and_tables():
from app.models.favorites import FavoriteTable from app.models.favorites import FavoriteTable
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
from app.models.settings import AppSettingsTable from app.models.settings import AppSettingsTable
from app.models.download import DownloadTaskTable
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
+116 -4
View File
@@ -2,13 +2,16 @@ import asyncio
import os import os
import uuid import uuid
import logging import logging
import asyncio
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, Optional from typing import Dict, Optional
import httpx import httpx
from app.models import DownloadTask, DownloadStatus, DownloadRequest 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.downloaders import get_downloader
from app.utils import sanitize_filename
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,6 +27,92 @@ class DownloadManager:
self.active_downloads: Dict[str, asyncio.Task] = {} self.active_downloads: Dict[str, asyncio.Task] = {}
self._semaphore = asyncio.Semaphore(max_parallel) 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]: def get_task(self, task_id: str) -> Optional[DownloadTask]:
return self.tasks.get(task_id) return self.tasks.get(task_id)
@@ -60,6 +149,8 @@ class DownloadManager:
created_at=datetime.now() created_at=datetime.now()
) )
self.tasks[task_id] = task self.tasks[task_id] = task
# Persist to database
self._save_task_to_db(task)
return task return task
async def start_download(self, task_id: str): async def start_download(self, task_id: str):
@@ -82,6 +173,7 @@ class DownloadManager:
task = self.tasks.get(task_id) task = self.tasks.get(task_id)
if task and task.status == DownloadStatus.DOWNLOADING: if task and task.status == DownloadStatus.DOWNLOADING:
task.status = DownloadStatus.PAUSED task.status = DownloadStatus.PAUSED
self._save_task_to_db(task)
if task_id in self.active_downloads: if task_id in self.active_downloads:
self.active_downloads[task_id].cancel() self.active_downloads[task_id].cancel()
del self.active_downloads[task_id] del self.active_downloads[task_id]
@@ -90,6 +182,7 @@ class DownloadManager:
task = self.tasks.get(task_id) task = self.tasks.get(task_id)
if task: if task:
task.status = DownloadStatus.CANCELLED task.status = DownloadStatus.CANCELLED
self._save_task_to_db(task)
if task_id in self.active_downloads: if task_id in self.active_downloads:
self.active_downloads[task_id].cancel() self.active_downloads[task_id].cancel()
del self.active_downloads[task_id] del self.active_downloads[task_id]
@@ -112,14 +205,16 @@ class DownloadManager:
if task.file_path and os.path.exists(task.file_path): if task.file_path and os.path.exists(task.file_path):
os.remove(task.file_path) os.remove(task.file_path)
# Remove from tasks dict # Remove from tasks dict and database
del self.tasks[task_id] del self.tasks[task_id]
self._delete_task_from_db(task_id)
async def _download(self, task: DownloadTask): async def _download(self, task: DownloadTask):
async with self._semaphore: async with self._semaphore:
try: try:
task.status = DownloadStatus.DOWNLOADING task.status = DownloadStatus.DOWNLOADING
task.started_at = datetime.now() task.started_at = datetime.now()
self._save_task_to_db(task)
# Get downloader and extract link # Get downloader and extract link
downloader = get_downloader(task.url) downloader = get_downloader(task.url)
@@ -150,6 +245,9 @@ class DownloadManager:
else: else:
logger.debug(f"Task filename kept as: {task.filename}") 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) task.file_path = str(self.download_dir / task.filename)
# Check if URL is HLS/m3u8 - use ffmpeg to download # 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}") logger.info(f"Detected HLS stream, using ffmpeg to download: {task.filename}")
success = await self._download_hls(download_url, task) success = await self._download_hls(download_url, task)
if success: if success:
self._save_task_to_db(task)
return return
# If ffmpeg fails, fall through to regular download attempt # If ffmpeg fails, fall through to regular download attempt
logger.warning("ffmpeg download failed, trying regular download") logger.warning("ffmpeg download failed, trying regular download")
@@ -167,8 +266,12 @@ class DownloadManager:
# Move file to expected location if different # Move file to expected location if different
import shutil import shutil
if download_url != task.file_path: if download_url != task.file_path:
shutil.move(download_url, task.file_path) try:
logger.debug(f"Moved file to: {task.file_path}") 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 # Mark as complete
file_size = os.path.getsize(task.file_path) file_size = os.path.getsize(task.file_path)
@@ -178,6 +281,7 @@ class DownloadManager:
task.downloaded_bytes = file_size task.downloaded_bytes = file_size
task.total_bytes = file_size task.total_bytes = file_size
task.completed_at = datetime.now() task.completed_at = datetime.now()
self._save_task_to_db(task)
return return
# Check if file already exists and is complete (for VidMoly which downloads directly) # 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.downloaded_bytes = file_size
task.total_bytes = file_size task.total_bytes = file_size
task.completed_at = datetime.now() task.completed_at = datetime.now()
self._save_task_to_db(task)
return return
# Check for partial download (resume) # Check for partial download (resume)
@@ -241,6 +346,7 @@ class DownloadManager:
except Exception as e: except Exception as e:
task.status = DownloadStatus.FAILED task.status = DownloadStatus.FAILED
task.error = str(e) task.error = str(e)
self._save_task_to_db(task)
finally: finally:
if task.id in self.active_downloads: if task.id in self.active_downloads:
del self.active_downloads[task.id] del self.active_downloads[task.id]
@@ -269,9 +375,11 @@ class DownloadManager:
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024): async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
if task.status == DownloadStatus.CANCELLED: if task.status == DownloadStatus.CANCELLED:
self._save_task_to_db(task)
return return
if task.status == DownloadStatus.PAUSED: if task.status == DownloadStatus.PAUSED:
self._save_task_to_db(task)
return return
f.write(chunk) 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 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)") 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: async def _download_hls(self, m3u8_url: str, task: DownloadTask) -> bool:
"""Download HLS/m3u8 stream using ffmpeg""" """Download HLS/m3u8 stream using ffmpeg"""
import subprocess import subprocess
@@ -386,6 +497,7 @@ class DownloadManager:
task.downloaded_bytes = file_size task.downloaded_bytes = file_size
task.total_bytes = file_size task.total_bytes = file_size
task.completed_at = datetime.now() task.completed_at = datetime.now()
self._save_task_to_db(task)
return True return True
else: else:
logger.error(f"HLS download failed: file not created") logger.error(f"HLS download failed: file not created")
+27 -4
View File
@@ -144,12 +144,34 @@ class AnimeSamaDownloader(BaseAnimeSite):
logger.info(f"Direct video URL detected: {url[:60]}... -> {filename}") logger.info(f"Direct video URL detected: {url[:60]}... -> {filename}")
return url, 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: if "|" in url:
parts = url.split("|") parts = url.split("|")
video_url = parts[0] # Correctly identify anime_page_url (2nd to last) and episode_title (last)
anime_page_url = parts[1] if len(parts) > 1 else None if len(parts) >= 3:
episode_title = parts[2] if len(parts) > 2 else None # 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( logger.debug(
f"Split URL - video: {video_url[:60]}..., anime: {anime_page_url}, episode: {episode_title}" f"Split URL - video: {video_url[:60]}..., anime: {anime_page_url}, episode: {episode_title}"
@@ -160,6 +182,7 @@ class AnimeSamaDownloader(BaseAnimeSite):
video_url, video_url,
anime_page_url=anime_page_url, anime_page_url=anime_page_url,
episode_title=episode_title, episode_title=episode_title,
target_filename=target_filename,
) )
# Check if this is a third-party host URL # Check if this is a third-party host URL
+149 -14
View File
@@ -23,7 +23,7 @@ class FS7Downloader(BaseSeriesSite):
self.id = "fs7" self.id = "fs7"
self.provider_id = "fs7" self.provider_id = "fs7"
self.default_domain = "fs7.lol" 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.base_url = f"https://{self.default_domain}"
self._domain_checked = False self._domain_checked = False
self.client.headers.update( self.client.headers.update(
@@ -234,35 +234,93 @@ class FS7Downloader(BaseSeriesSite):
# Clean up title: remove "affiche" suffix # Clean up title: remove "affiche" suffix
title = re.sub(r"\s+affiche$", "", title, flags=re.IGNORECASE).strip() title = re.sub(r"\s+affiche$", "", title, flags=re.IGNORECASE).strip()
# Extract description/synopsis # --- Synopsis: div.fdesc > p ---
description_elem = soup.find("div", class_="full-text") description = ""
description = ( fdesc = soup.find("div", class_="fdesc")
description_elem.get_text(strip=True) if description_elem else "" if fdesc:
) p = fdesc.find("p")
if p:
description = p.get_text(strip=True)
else:
description = fdesc.get_text(strip=True)
# Extract cover image # --- Poster: div.fleft > img ---
img = soup.find("img", class_="poster") poster_image = ""
poster_image = img.get("src", "") if img else "" 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: if not poster_image:
meta_img = soup.find("meta", property="og:image") meta_img = soup.find("meta", property="og:image")
poster_image = meta_img.get("content", "") if meta_img else "" poster_image = meta_img.get("content", "") if meta_img else ""
# Extract year # --- Year: span.release ---
year_match = re.search(r"\b(19|20)\d{2}\b", description) release_year = None
release_year = int(year_match.group()) if year_match else 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 { return {
"title": title, "title": title,
"synopsis": description, "synopsis": description,
"poster_image": poster_image, "poster_image": poster_image,
"release_year": release_year, "release_year": release_year,
"genres": [], "genres": genres,
"rating": None, "rating": None,
"studio": None, "studio": None,
"total_episodes": None, "total_episodes": None,
"status": None, "status": None,
"original_title": original_title,
"director": director,
"cast": cast,
"runtime": runtime,
} }
except Exception as e: except Exception as e:
@@ -301,3 +359,80 @@ class FS7Downloader(BaseSeriesSite):
return await player.get_download_link(url, target_filename) return await player.get_download_link(url, target_filename)
else: else:
raise ValueError(f"No video player found for URL: {url}") raise ValueError(f"No video player found for URL: {url}")
async def get_latest_series(self, limit: int = 20) -> List[Dict[str, Any]]:
"""
Scrape the 'Nouveautés Séries' section from FS7 homepage.
Returns:
List of dicts with title, url, cover_image, synopsis, lang, provider_id.
"""
await self._ensure_base_url()
try:
resp = await self.client.get(self.base_url + "/", timeout=15)
soup = BeautifulSoup(resp.text, "html.parser")
except Exception as e:
logger.error(f"Failed to fetch FS7 homepage: {e}")
return []
results = []
# Find the 'Nouveautés Séries' section
for section in soup.find_all("div", class_="pages"):
title_el = section.find("div", class_="sect-t")
if not title_el:
continue
title = title_el.get_text(strip=True)
if "Nouveautés" not in title or "Séries" not in title:
continue
for item in section.find_all("div", class_="short"):
# Get the poster link (contains real URL)
poster_a = item.find("a", class_="short-poster", href=True)
if not poster_a:
continue
url = poster_a["href"]
if url.startswith("/"):
url = self.base_url + url
# Title from alt attribute
title_attr = poster_a.get("alt", "").strip()
if not title_attr:
continue
# Poster image
img = poster_a.find("img")
cover_image = img.get("src", "") if img else ""
# Synopsis from hidden span
desc_span = item.find("span", id=re.compile(r"^desc-\d+"))
synopsis = desc_span.get_text(strip=True) if desc_span else ""
# Language (VF/VOSTFR)
lang = "vf"
version_span = item.find("span", class_="film-version")
if version_span:
version_text = version_span.get_text(strip=True).upper()
if "VOSTFR" in version_text:
lang = "vostfr"
elif "VF" in version_text:
lang = "vf"
results.append({
"title": title_attr,
"url": url,
"cover_image": cover_image,
"synopsis": synopsis,
"lang": lang,
"provider_id": self.provider_id,
"content_type": "series",
})
if len(results) >= limit:
break
break # Only process the first matching section
logger.info(f"FS7 latest series: found {len(results)} items")
return results
+1
View File
@@ -70,3 +70,4 @@ from .watchlist import WatchlistItemTable, WatchlistSettingsTable
from .favorites import FavoriteTable from .favorites import FavoriteTable
from .sonarr import SonarrMappingTable, SonarrConfigTable from .sonarr import SonarrMappingTable, SonarrConfigTable
from .settings import AppSettingsTable from .settings import AppSettingsTable
from .download import DownloadTaskTable
+40
View File
@@ -0,0 +1,40 @@
"""Models for download task persistence with SQLModel support"""
import uuid
from typing import Optional
from datetime import datetime
from sqlmodel import SQLModel, Field, Column, String
from enum import Enum
class DownloadStatus(str, Enum):
PENDING = "pending"
DOWNLOADING = "downloading"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class DownloadTaskTable(SQLModel, table=True):
"""Database table for persisting download tasks across server restarts."""
__tablename__ = "download_tasks"
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
primary_key=True,
index=True,
nullable=False,
)
url: str = Field(default="", sa_column=Column(String))
filename: str = Field(sa_column=Column(String))
host: str = Field(default="other", sa_column=Column(String))
status: str = Field(default="pending", sa_column=Column(String))
progress: float = Field(default=0.0)
downloaded_bytes: int = Field(default=0)
total_bytes: Optional[int] = Field(default=None)
speed: float = Field(default=0.0)
error: Optional[str] = Field(default=None, sa_column=Column(String))
created_at: datetime = Field(default_factory=datetime.now)
started_at: Optional[datetime] = Field(default=None)
completed_at: Optional[datetime] = Field(default=None)
file_path: Optional[str] = Field(default=None, sa_column=Column(String))
+13
View File
@@ -28,6 +28,13 @@ class AppSettingsBase(SQLModel):
# #12: Custom download directory # #12: Custom download directory
download_dir: str = Field(default="downloads") 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 @property
def disabled_providers(self) -> List[str]: def disabled_providers(self) -> List[str]:
try: try:
@@ -64,6 +71,9 @@ class AppSettings(BaseModel):
anime_enabled: bool = True anime_enabled: bool = True
series_enabled: bool = True series_enabled: bool = True
download_dir: str = "downloads" download_dir: str = "downloads"
content_weight_mode: str = "auto"
content_weight_anime: int = 2
content_weight_series: int = 1
class Config: class Config:
from_attributes = True from_attributes = True
@@ -79,3 +89,6 @@ class AppSettingsUpdate(BaseModel):
anime_enabled: Optional[bool] = None anime_enabled: Optional[bool] = None
series_enabled: Optional[bool] = None series_enabled: Optional[bool] = None
download_dir: Optional[str] = None download_dir: Optional[str] = None
content_weight_mode: Optional[str] = None
content_weight_anime: Optional[int] = None
content_weight_series: Optional[int] = None
+10 -15
View File
@@ -296,8 +296,7 @@ async def search_series_unified(
search_results = await asyncio.gather(*search_tasks, return_exceptions=True) search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
# Enrich results with metadata (synopsis, rating, genres) # Enrich results with metadata from scrapers (not Kitsu — Kitsu is anime-only)
enricher = await get_metadata_enricher()
enrichment_tasks = [] enrichment_tasks = []
enrichment_mapping = [] enrichment_mapping = []
@@ -308,17 +307,15 @@ async def search_series_unified(
elif result: elif result:
results[provider_id] = result results[provider_id] = result
print(f"[SERIES SEARCH] {provider_id}: Found {len(result)} results") print(f"[SERIES SEARCH] {provider_id}: Found {len(result)} results")
# Prepare enrichment for top 15 results # Enrich top 10 results with metadata from the scraper itself
for idx, item in enumerate(result[:15]): downloader = series_downloaders.get(provider_id)
if isinstance(item, dict): if downloader and hasattr(downloader, "get_anime_metadata"):
enrichment_tasks.append( for idx, item in enumerate(result[:10]):
enricher.enrich_metadata( if isinstance(item, dict) and item.get("url"):
item.get("metadata") or {}, enrichment_tasks.append(
item.get("title") or "", downloader.get_anime_metadata(item["url"])
item.get("url") or "",
) )
) enrichment_mapping.append((provider_id, idx))
enrichment_mapping.append((provider_id, idx))
else: else:
print(f"[SERIES SEARCH] {provider_id}: No results returned") print(f"[SERIES SEARCH] {provider_id}: No results returned")
@@ -334,9 +331,7 @@ async def search_series_unified(
and provider_id in results and provider_id in results
and pos < len(results[provider_id]) and pos < len(results[provider_id])
): ):
results[provider_id][pos]["metadata"] = ( results[provider_id][pos]["metadata"] = meta
meta.model_dump() if hasattr(meta, "model_dump") else meta
)
# Truncate synopses at sentence boundaries # Truncate synopses at sentence boundaries
for pid in results: for pid in results:
+190 -17
View File
@@ -3,15 +3,22 @@ Recommendations and releases routes for Ohm Stream Downloader API.
""" """
import hashlib import hashlib
import logging
from datetime import datetime 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 import APIRouter, Request, Query, HTTPException, Depends
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlmodel import Session, select
from app.recommendation_engine import RecommendationEngine from app.recommendation_engine import RecommendationEngine
from app.models.auth import User 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_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"]) router = APIRouter(prefix="/api", tags=["recommendations"])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -23,6 +30,79 @@ def hash_filter(s):
templates.env.filters["hash"] = hash_filter 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") @router.get("/recommendations")
async def get_recommendations( async def get_recommendations(
request: Request, request: Request,
@@ -30,8 +110,9 @@ async def get_recommendations(
html: bool = Query(False), html: bool = Query(False),
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"), content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
current_user: Optional[User] = Depends(get_optional_user), 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") is_htmx = request.headers.get("HX-Request")
if current_user is None and (html or is_htmx): if current_user is None and (html or is_htmx):
@@ -42,14 +123,38 @@ async def get_recommendations(
if current_user is None: if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required") 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: try:
recommendations = await engine.get_personalized_recommendations(limit=limit) 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}")
# Filter by content_type if specified
if content_type and content_type != "all": 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: if html or is_htmx:
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -59,11 +164,8 @@ async def get_recommendations(
return {"recommendations": recommendations, "count": len(recommendations)} return {"recommendations": recommendations, "count": len(recommendations)}
except Exception as e: except Exception as e:
import logging logger.error(f"Recommendations error: {e}", exc_info=True)
logging.getLogger(__name__).error(f"Recommendations error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
finally:
await engine.close()
@router.get("/releases/latest") @router.get("/releases/latest")
@@ -72,18 +174,52 @@ async def get_latest_releases(
limit: int = 20, limit: int = 20,
html: bool = Query(False), html: bool = Query(False),
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"), 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 from app.recommendations import get_latest_releases_with_info
is_htmx = 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: try:
releases = await get_latest_releases_with_info(limit=limit) 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}")
# Filter by content_type if specified
if content_type and content_type != "all": if content_type and content_type != "all":
releases = [r for r in releases if r.get("content_type", r.get("type", "")) == content_type] 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 request.headers.get("HX-Request"): if html or is_htmx:
return templates.TemplateResponse( return templates.TemplateResponse(
"components/releases_list.html", "components/releases_list.html",
{"request": request, "releases": releases} {"request": request, "releases": releases}
@@ -95,8 +231,7 @@ async def get_latest_releases(
"updated": datetime.now().isoformat(), "updated": datetime.now().isoformat(),
} }
except Exception as e: except Exception as e:
import logging logger.error(f"Latest releases error: {e}", exc_info=True)
logging.getLogger(__name__).error(f"Latest releases error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) 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)) raise HTTPException(status_code=500, detail=str(e))
finally: finally:
await engine.close() await engine.close()
@router.get("/series/latest")
async def get_latest_series(
request: Request,
limit: int = 20,
html: bool = Query(False),
current_user: Optional[User] = Depends(get_optional_user),
):
"""Get latest TV series releases from FS7 homepage"""
if current_user is None and (html or request.headers.get("HX-Request")):
return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
try:
from app.downloaders.series_sites.fs7 import FS7Downloader
downloader = FS7Downloader()
series = await downloader.get_latest_series(limit=limit)
if html or request.headers.get("HX-Request"):
return templates.TemplateResponse(
"components/series_releases_list.html",
{"request": request, "releases": series}
)
return {
"releases": series,
"count": len(series),
"updated": datetime.now().isoformat(),
}
except Exception as e:
logger.error(f"Latest series error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
+103
View File
@@ -1,6 +1,8 @@
"""Application settings routes for Ohm Stream Downloader API""" """Application settings routes for Ohm Stream Downloader API"""
import json import json
import logging
from pathlib import Path
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.templating import Jinja2Templates 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 import get_anime_providers, get_series_providers
from app.providers_manager import providers_manager from app.providers_manager import providers_manager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/settings", tags=["settings"]) router = APIRouter(prefix="/api/settings", tags=["settings"])
templates = Jinja2Templates(directory="templates") 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) @router.get("", response_model=AppSettings)
async def get_settings( async def get_settings(
current_user: User = Depends(get_current_user_from_token), 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), anime_enabled=getattr(settings_obj, 'anime_enabled', True),
series_enabled=getattr(settings_obj, 'series_enabled', True), series_enabled=getattr(settings_obj, 'series_enabled', True),
download_dir=getattr(settings_obj, 'download_dir', 'downloads'), 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 settings_obj.series_enabled = update_data.series_enabled
if update_data.download_dir is not None: if update_data.download_dir is not None:
settings_obj.download_dir = update_data.download_dir 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.add(settings_obj)
session.commit() session.commit()
@@ -98,6 +173,34 @@ async def update_settings(
return settings_obj 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") @router.get("/providers/availability")
async def get_providers_availability( async def get_providers_availability(
current_user: User = Depends(get_current_user_from_token), current_user: User = Depends(get_current_user_from_token),
+1 -1
View File
@@ -213,7 +213,7 @@ async def delete_from_watchlist(
raise HTTPException(status_code=500, detail="Failed to delete item") raise HTTPException(status_code=500, detail="Failed to delete item")
@router.post("/check", response_model=List) @router.post("/check")
async def check_watchlist_now( async def check_watchlist_now(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
response: Response, response: Response,
+12 -7
View File
@@ -95,13 +95,18 @@ class DomainManager:
response = await client.get(url) response = await client.get(url)
if response.status_code == 200: if response.status_code == 200:
logger.info(f"Active domain found for {provider_id}: {domain}") # Verify it's actually the right site, not a parking/placeholder page
cls._cache[provider_id] = { content = response.text.lower()
'domain': domain, body_size = len(response.text)
'last_check': datetime.now().isoformat() # 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):
cls._save_cache() logger.info(f"Active domain found for {provider_id}: {domain} ({body_size} bytes)")
return domain cls._cache[provider_id] = {
'domain': domain,
'last_check': datetime.now().isoformat()
}
cls._save_cache()
return domain
except Exception as e: except Exception as e:
logger.debug(f"Domain test failed for {domain}: {e}") logger.debug(f"Domain test failed for {domain}: {e}")
continue continue
+11 -1
View File
@@ -216,8 +216,12 @@ class WatchlistManager:
update_check_time = update_last_checked update_check_time = update_last_checked
def get_due_items(self) -> List[WatchlistItem]: 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""" """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() now = datetime.now()
with Session(engine) as session: with Session(engine) as session:
@@ -234,6 +238,12 @@ class WatchlistManager:
return due_items 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: def update_settings(self, settings: WatchlistSettings) -> WatchlistSettings:
"""Update global watchlist settings""" """Update global watchlist settings"""
self.settings = settings self.settings = settings
+174
View File
@@ -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 ===');
})();
+13 -2
View File
@@ -86,12 +86,17 @@ async def startup_event():
def restore_completed_downloads(): 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") download_dir = Path("downloads")
if not download_dir.exists(): if not download_dir.exists():
return return
video_extensions = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm"} 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(): for file_path in download_dir.iterdir():
if file_path.is_file() and file_path.suffix.lower() in video_extensions: if file_path.is_file() and file_path.suffix.lower() in video_extensions:
@@ -99,6 +104,11 @@ def restore_completed_downloads():
continue continue
filename = file_path.name filename = file_path.name
# Skip if already tracked in DB
if filename in tracked_filenames:
continue
file_size = file_path.stat().st_size file_size = file_path.stat().st_size
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
@@ -118,7 +128,8 @@ def restore_completed_downloads():
) )
download_manager.tasks[task_id] = task 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 # Restore completed downloads on startup
+361 -369
View File
File diff suppressed because it is too large Load Diff
+24 -24
View File
@@ -82,7 +82,7 @@ async function searchAnimeDetails(query, malId = null) {
if (hasResults) { if (hasResults) {
streamingParts.unshift( streamingParts.unshift(
`<div class="streaming-results-header"> `<div class="streaming-results-header">
<h3>🎬 Résultats de streaming</h3> <h3><i class="fa-solid fa-film"></i> Résultats de streaming</h3>
</div> </div>
<div class="search-results" style="margin-top: 20px;">` <div class="search-results" style="margin-top: 20px;">`
); );
@@ -110,7 +110,7 @@ async function searchAnimeDetails(query, malId = null) {
if (streamingHtml) { if (streamingHtml) {
resultsContainer.innerHTML = ` resultsContainer.innerHTML = `
<div class="no-results" style="margin-bottom: 20px;"> <div class="no-results" style="margin-bottom: 20px;">
<p> Aucune fiche trouvée sur MyAnimeList pour "${escapeHtml(query)}"</p> <p><i class="fa-solid fa-circle-info"></i> Aucune fiche trouvée sur MyAnimeList pour "${escapeHtml(query)}"</p>
<p style="font-size: 12px; margin-top: 10px; color: #888;"> <p style="font-size: 12px; margin-top: 10px; color: #888;">
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End") Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End")
</p> </p>
@@ -125,7 +125,7 @@ async function searchAnimeDetails(query, malId = null) {
} else { } else {
resultsContainer.innerHTML = ` resultsContainer.innerHTML = `
<div class="no-results"> <div class="no-results">
<p> Aucun résultat trouvé pour "${escapeHtml(query)}"</p> <p><i class="fa-solid fa-xmark"></i> Aucun résultat trouvé pour "${escapeHtml(query)}"</p>
<p style="font-size: 12px; margin-top: 10px; color: #888;"> <p style="font-size: 12px; margin-top: 10px; color: #888;">
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End", "One Piece") Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End", "One Piece")
</p> </p>
@@ -138,7 +138,7 @@ async function searchAnimeDetails(query, malId = null) {
console.error('Error searching anime details:', error); console.error('Error searching anime details:', error);
resultsContainer.innerHTML = ` resultsContainer.innerHTML = `
<div class="no-results"> <div class="no-results">
<p> Erreur lors de la recherche.</p> <p><i class="fa-solid fa-xmark"></i> Erreur lors de la recherche.</p>
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p> <p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
</div> </div>
`; `;
@@ -177,7 +177,7 @@ async function getProviderSearchResults(query) {
if (hasResults) { if (hasResults) {
htmlParts.unshift( htmlParts.unshift(
`<div class="streaming-results-header"> `<div class="streaming-results-header">
<h3>🎬 Résultats de streaming</h3> <h3><i class="fa-solid fa-film"></i> Résultats de streaming</h3>
</div> </div>
<div class="search-results" style="margin-top: 20px;">` <div class="search-results" style="margin-top: 20px;">`
); );
@@ -249,16 +249,16 @@ function renderAnimeDetails(anime) {
` : ''} ` : ''}
<div class="anime-details-meta"> <div class="anime-details-meta">
${score > 0 ? `<div class="anime-details-rating"> ${score.toFixed(2)}</div>` : ''} ${score > 0 ? `<div class="anime-details-rating"><i class="fa-solid fa-star"></i> ${score.toFixed(2)}</div>` : ''}
${rank > 0 ? `<div class="anime-details-rank">#${rank}</div>` : ''} ${rank > 0 ? `<div class="anime-details-rank">#${rank}</div>` : ''}
${popularity > 0 ? `<div class="anime-details-popularity">Popularity #${popularity}</div>` : ''} ${popularity > 0 ? `<div class="anime-details-popularity">Popularity #${popularity}</div>` : ''}
</div> </div>
<div class="anime-details-stats"> <div class="anime-details-stats">
${anime.episodes ? `<span>📺 ${anime.episodes} épisodes</span>` : ''} ${anime.episodes ? `<span><i class="fa-solid fa-tv"></i> ${anime.episodes} épisodes</span>` : ''}
${anime.status ? `<span>📡 ${translateStatus(anime.status)}</span>` : ''} ${anime.status ? `<span><i class="fa-solid fa-tower-broadcast"></i> ${translateStatus(anime.status)}</span>` : ''}
${anime.duration ? `<span>⏱️ ${escapeHtml(anime.duration)}</span>` : ''} ${anime.duration ? `<span><i class="fa-solid fa-clock"></i> ${escapeHtml(anime.duration)}</span>` : ''}
${anime.year ? `<span>📅 ${anime.year}</span>` : ''} ${anime.year ? `<span><i class="fa-solid fa-calendar"></i> ${anime.year}</span>` : ''}
</div> </div>
${studios.length > 0 ? ` ${studios.length > 0 ? `
@@ -269,10 +269,10 @@ function renderAnimeDetails(anime) {
<div class="anime-details-actions"> <div class="anime-details-actions">
<a href="${escapeHtml(anime.url)}" target="_blank" class="btn btn-secondary btn-small"> <a href="${escapeHtml(anime.url)}" target="_blank" class="btn btn-secondary btn-small">
🔗 Voir sur MAL <i class="fa-solid fa-link"></i> Voir sur MAL
</a> </a>
<button onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')" class="btn btn-primary btn-small"> <button onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')" class="btn btn-primary btn-small">
📥 Télécharger <i class="fa-solid fa-download"></i> Télécharger
</button> </button>
</div> </div>
</div> </div>
@@ -290,9 +290,9 @@ function renderAnimeDetails(anime) {
${synopsis ? ` ${synopsis ? `
<div class="anime-details-section"> <div class="anime-details-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">📖 Synopsis</h3> <h3 style="margin: 0;"><i class="fa-solid fa-book"></i> Synopsis</h3>
<button onclick="translateSynopsis('${synopsisId}', this)" class="btn btn-secondary btn-small" style="font-size: 12px;"> <button onclick="translateSynopsis('${synopsisId}', this)" class="btn btn-secondary btn-small" style="font-size: 12px;">
🌐 Traduire en français <i class="fa-solid fa-globe"></i> Traduire en français
</button> </button>
</div> </div>
<p id="${synopsisId}" class="anime-details-synopsis">${escapeHtml(synopsis)}</p> <p id="${synopsisId}" class="anime-details-synopsis">${escapeHtml(synopsis)}</p>
@@ -302,7 +302,7 @@ function renderAnimeDetails(anime) {
<!-- Seasons (Sequel/Prequel) --> <!-- Seasons (Sequel/Prequel) -->
${seasons.length > 0 ? ` ${seasons.length > 0 ? `
<div class="anime-details-section"> <div class="anime-details-section">
<h3>📺 Saisons</h3> <h3><i class="fa-solid fa-tv"></i> Saisons</h3>
<div class="anime-related-list"> <div class="anime-related-list">
${seasons.map(season => ` ${seasons.map(season => `
<div class="anime-related-group"> <div class="anime-related-group">
@@ -310,7 +310,7 @@ function renderAnimeDetails(anime) {
<div class="anime-related-items"> <div class="anime-related-items">
${season.entries.map(entry => ` ${season.entries.map(entry => `
<div class="anime-related-item" onclick="searchAnimeDetails('${escapeHtml(entry.title)}', ${entry.mal_id})" style="cursor: pointer;"> <div class="anime-related-item" onclick="searchAnimeDetails('${escapeHtml(entry.title)}', ${entry.mal_id})" style="cursor: pointer;">
${entry.type ? `<span style="color: #00d9ff; font-size: 11px; margin-right: 8px;">${escapeHtml(entry.type)}</span>` : ''} ${entry.type ? `<span style="color: #FFBF69; font-size: 11px; margin-right: 8px;">${escapeHtml(entry.type)}</span>` : ''}
${escapeHtml(entry.title)} ${escapeHtml(entry.title)}
${entry.url ? `<a href="${escapeHtml(entry.url)}" target="_blank" style="margin-left: auto; color: #888; font-size: 18px; text-decoration: none;" title="Voir sur MyAnimeList">↗</a>` : ''} ${entry.url ? `<a href="${escapeHtml(entry.url)}" target="_blank" style="margin-left: auto; color: #888; font-size: 18px; text-decoration: none;" title="Voir sur MyAnimeList">↗</a>` : ''}
</div> </div>
@@ -358,7 +358,7 @@ async function loadStreamingResults(query) {
if (successfulResults.length === 0) { if (successfulResults.length === 0) {
container.innerHTML = ` container.innerHTML = `
<div class="no-results"> <div class="no-results">
<p>⚠️ Aucun résultat de streaming trouvé pour "${escapeHtml(query)}"</p> <p><i class="fa-solid fa-triangle-exclamation"></i> Aucun résultat de streaming trouvé pour "${escapeHtml(query)}"</p>
</div> </div>
`; `;
return; return;
@@ -367,7 +367,7 @@ async function loadStreamingResults(query) {
// Display results // Display results
container.innerHTML = ` container.innerHTML = `
<div class="streaming-results-header"> <div class="streaming-results-header">
<h3>🎬 Disponible sur</h3> <h3><i class="fa-solid fa-film"></i> Disponible sur</h3>
</div> </div>
<div class="streaming-results-grid"> <div class="streaming-results-grid">
${successfulResults.map(result => renderStreamingResult(result, query)).join('')} ${successfulResults.map(result => renderStreamingResult(result, query)).join('')}
@@ -378,7 +378,7 @@ async function loadStreamingResults(query) {
console.error('Error loading streaming results:', error); console.error('Error loading streaming results:', error);
container.innerHTML = ` container.innerHTML = `
<div class="no-results"> <div class="no-results">
<p> Erreur lors de la recherche des sources de streaming.</p> <p><i class="fa-solid fa-xmark"></i> Erreur lors de la recherche des sources de streaming.</p>
</div> </div>
`; `;
} }
@@ -406,7 +406,7 @@ function renderStreamingResult(result, query) {
</select> </select>
<button class="btn btn-primary btn-small streaming-download-btn" onclick="downloadSelectedEpisode(this)"> <button class="btn btn-primary btn-small streaming-download-btn" onclick="downloadSelectedEpisode(this)">
📥 Télécharger <i class="fa-solid fa-download"></i> Télécharger
</button> </button>
</div> </div>
@@ -475,7 +475,7 @@ async function translateSynopsis(synopsisId, button) {
// Revert to original // Revert to original
synopsisElement.textContent = originalText; synopsisElement.textContent = originalText;
synopsisElement.dataset.translated = 'false'; synopsisElement.dataset.translated = 'false';
button.innerHTML = '🌐 Traduire en français'; button.innerHTML = '<i class="fa-solid fa-globe"></i> Traduire en français';
return; return;
} }
@@ -484,7 +484,7 @@ async function translateSynopsis(synopsisId, button) {
// Show loading state // Show loading state
button.disabled = true; button.disabled = true;
button.innerHTML = ' Traduction...'; button.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Traduction...';
synopsisElement.style.opacity = '0.5'; synopsisElement.style.opacity = '0.5';
try { try {
@@ -509,7 +509,7 @@ async function translateSynopsis(synopsisId, button) {
synopsisElement.textContent = data.translatedText; synopsisElement.textContent = data.translatedText;
synopsisElement.dataset.translated = 'true'; synopsisElement.dataset.translated = 'true';
button.innerHTML = '🔄 Voir l\'original'; button.innerHTML = '<i class="fa-solid fa-rotate"></i> Voir l\'original';
} else { } else {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' })); const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
console.error('Translation API error:', errorData); console.error('Translation API error:', errorData);
@@ -523,7 +523,7 @@ async function translateSynopsis(synopsisId, button) {
const errorMessage = document.createElement('div'); const errorMessage = document.createElement('div');
errorMessage.style.cssText = 'margin-top: 10px; padding: 10px; background: rgba(255, 107, 107, 0.2); border-radius: 8px; font-size: 12px; color: #ff6b6b;'; errorMessage.style.cssText = 'margin-top: 10px; padding: 10px; background: rgba(255, 107, 107, 0.2); border-radius: 8px; font-size: 12px; color: #ff6b6b;';
errorMessage.innerHTML = ` errorMessage.innerHTML = `
⚠️ Service de traduction temporairement indisponible.<br> <i class="fa-solid fa-triangle-exclamation"></i> Service de traduction temporairement indisponible.<br>
<small>Essayez à nouveau dans quelques instants.</small> <small>Essayez à nouveau dans quelques instants.</small>
`; `;
+28 -28
View File
@@ -22,12 +22,12 @@ async function loadRecommendations() {
} else { } else {
container.innerHTML = ` container.innerHTML = `
<div class="no-results"> <div class="no-results">
<p>⚠️ Aucune recommandation disponible pour le moment.</p> <p><i class="fa-solid fa-triangle-exclamation"></i> Aucune recommandation disponible pour le moment.</p>
<p style="font-size: 12px; margin-top: 10px; color: #888;"> <p style="font-size: 12px; margin-top: 10px; color: #888;">
Soit l'API MyAnimeList est inaccessible, soit vous n'avez pas encore de téléchargements. Soit l'API MyAnimeList est inaccessible, soit vous n'avez pas encore de téléchargements.
</p> </p>
<button class="btn btn-secondary btn-small" onclick="loadRecommendations()" style="margin-top: 10px;"> <button class="btn btn-secondary btn-small" onclick="loadRecommendations()" style="margin-top: 10px;">
🔄 Réessayer <i class="fa-solid fa-rotate"></i> Réessayer
</button> </button>
</div> </div>
`; `;
@@ -38,10 +38,10 @@ async function loadRecommendations() {
console.error('Error loading recommendations:', error); console.error('Error loading recommendations:', error);
container.innerHTML = ` container.innerHTML = `
<div class="no-results"> <div class="no-results">
<p> Erreur lors du chargement des recommandations.</p> <p><i class="fa-solid fa-xmark"></i> Erreur lors du chargement des recommandations.</p>
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p> <p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
<button class="btn btn-secondary btn-small" onclick="loadRecommendations()" style="margin-top: 10px;"> <button class="btn btn-secondary btn-small" onclick="loadRecommendations()" style="margin-top: 10px;">
🔄 Réessayer <i class="fa-solid fa-rotate"></i> Réessayer
</button> </button>
</div> </div>
`; `;
@@ -71,12 +71,12 @@ async function loadLatestReleases() {
} else { } else {
container.innerHTML = ` container.innerHTML = `
<div class="no-results"> <div class="no-results">
<p>⚠️ Aucune sortie disponible pour le moment.</p> <p><i class="fa-solid fa-triangle-exclamation"></i> Aucune sortie disponible pour le moment.</p>
<p style="font-size: 12px; margin-top: 10px; color: #888;"> <p style="font-size: 12px; margin-top: 10px; color: #888;">
L'API MyAnimeList pourrait être temporairement inaccessible. L'API MyAnimeList pourrait être temporairement inaccessible.
</p> </p>
<button class="btn btn-secondary btn-small" onclick="loadLatestReleases()" style="margin-top: 10px;"> <button class="btn btn-secondary btn-small" onclick="loadLatestReleases()" style="margin-top: 10px;">
🔄 Réessayer <i class="fa-solid fa-rotate"></i> Réessayer
</button> </button>
</div> </div>
`; `;
@@ -87,10 +87,10 @@ async function loadLatestReleases() {
console.error('Error loading releases:', error); console.error('Error loading releases:', error);
container.innerHTML = ` container.innerHTML = `
<div class="no-results"> <div class="no-results">
<p> Erreur lors du chargement des sorties.</p> <p><i class="fa-solid fa-xmark"></i> Erreur lors du chargement des sorties.</p>
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p> <p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
<button class="btn btn-secondary btn-small" onclick="loadLatestReleases()" style="margin-top: 10px;"> <button class="btn btn-secondary btn-small" onclick="loadLatestReleases()" style="margin-top: 10px;">
🔄 Réessayer <i class="fa-solid fa-rotate"></i> Réessayer
</button> </button>
</div> </div>
`; `;
@@ -100,7 +100,7 @@ async function loadLatestReleases() {
// Load all home content // Load all home content
async function loadHomeContent() { async function loadHomeContent() {
console.log('🏠 loadHomeContent() called'); console.log('loadHomeContent() called');
const loading = document.getElementById('homeLoading'); const loading = document.getElementById('homeLoading');
const recommendationsSection = document.getElementById('recommendationsSection'); const recommendationsSection = document.getElementById('recommendationsSection');
@@ -123,13 +123,13 @@ async function loadHomeContent() {
loadRecommendations(), loadRecommendations(),
loadLatestReleases() loadLatestReleases()
]); ]);
console.log('Home content loaded successfully'); console.log('Home content loaded successfully');
// Show sections if they have content // Show sections if they have content
if (recommendationsSection) recommendationsSection.style.display = 'block'; if (recommendationsSection) recommendationsSection.style.display = 'block';
if (releasesSection) releasesSection.style.display = 'block'; if (releasesSection) releasesSection.style.display = 'block';
} catch (error) { } catch (error) {
console.error('Error loading home content:', error); console.error('Error loading home content:', error);
if (loading) { if (loading) {
loading.innerHTML = 'Erreur lors du chargement. Consultez la console pour plus de détails.'; loading.innerHTML = 'Erreur lors du chargement. Consultez la console pour plus de détails.';
} }
@@ -149,11 +149,11 @@ function renderRecommendationCard(anime) {
return ` return `
<div class="anime-card-horizontal recommendation-card"> <div class="anime-card-horizontal recommendation-card">
${reason ? `<div class="recommendation-badge">💡 ${escapeHtml(reason)}</div>` : ''} ${reason ? `<div class="recommendation-badge"><i class="fa-solid fa-lightbulb"></i> ${escapeHtml(reason)}</div>` : ''}
<div class="anime-card-header"> <div class="anime-card-header">
<div class="anime-card-title">${escapeHtml(anime.title)}</div> <div class="anime-card-title">${escapeHtml(anime.title)}</div>
${score > 0 ? `<div class="anime-card-rating"> ${score.toFixed(1)}</div>` : ''} ${score > 0 ? `<div class="anime-card-rating"><i class="fa-solid fa-star"></i> ${score.toFixed(1)}</div>` : ''}
</div> </div>
<div class="anime-card-content"> <div class="anime-card-content">
@@ -165,7 +165,7 @@ function renderRecommendationCard(anime) {
</div> </div>
<div class="anime-card-meta"> <div class="anime-card-meta">
${anime.episodes ? `📺 ${anime.episodes} ep` : ''} ${anime.episodes ? `<i class="fa-solid fa-tv"></i> ${anime.episodes} ep` : ''}
${anime.episodes && anime.status ? ' • ' : ''} ${anime.episodes && anime.status ? ' • ' : ''}
${anime.status ? translateStatus(anime.status) : ''} ${anime.status ? translateStatus(anime.status) : ''}
</div> </div>
@@ -174,17 +174,17 @@ function renderRecommendationCard(anime) {
${anime.synopsis ? ` ${anime.synopsis ? `
<details class="anime-synopsis"> <details class="anime-synopsis">
<summary>📖 Synopsis</summary> <summary><i class="fa-solid fa-book"></i> Synopsis</summary>
<p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p> <p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p>
</details> </details>
` : ''} ` : ''}
<div class="anime-card-actions"> <div class="anime-card-actions">
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(anime.url)}', '_blank')"> <button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
🔗 MAL <i class="fa-solid fa-link"></i> MAL
</button> </button>
<button class="btn btn-primary btn-small" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')"> <button class="btn btn-primary btn-small" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
📥 Télécharger <i class="fa-solid fa-download"></i> Télécharger
</button> </button>
</div> </div>
</div> </div>
@@ -202,11 +202,11 @@ function renderReleaseCard(anime) {
return ` return `
<div class="anime-card-horizontal release-card"> <div class="anime-card-horizontal release-card">
<div class="release-badge">🔥 ${escapeHtml(releaseType)}</div> <div class="release-badge"><i class="fa-solid fa-fire"></i> ${escapeHtml(releaseType)}</div>
<div class="anime-card-header"> <div class="anime-card-header">
<div class="anime-card-title">${escapeHtml(anime.title)}</div> <div class="anime-card-title">${escapeHtml(anime.title)}</div>
${score > 0 ? `<div class="anime-card-rating"> ${score.toFixed(1)}</div>` : ''} ${score > 0 ? `<div class="anime-card-rating"><i class="fa-solid fa-star"></i> ${score.toFixed(1)}</div>` : ''}
</div> </div>
<div class="anime-card-content"> <div class="anime-card-content">
@@ -218,7 +218,7 @@ function renderReleaseCard(anime) {
</div> </div>
<div class="anime-card-meta"> <div class="anime-card-meta">
${anime.episodes ? `📺 ${anime.episodes} ep` : ''} ${anime.episodes ? `<i class="fa-solid fa-tv"></i> ${anime.episodes} ep` : ''}
${anime.episodes && anime.status ? ' • ' : ''} ${anime.episodes && anime.status ? ' • ' : ''}
${anime.status ? translateStatus(anime.status) : ''} ${anime.status ? translateStatus(anime.status) : ''}
</div> </div>
@@ -227,17 +227,17 @@ function renderReleaseCard(anime) {
${anime.synopsis ? ` ${anime.synopsis ? `
<details class="anime-synopsis"> <details class="anime-synopsis">
<summary>📖 Synopsis</summary> <summary><i class="fa-solid fa-book"></i> Synopsis</summary>
<p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p> <p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p>
</details> </details>
` : ''} ` : ''}
<div class="anime-card-actions"> <div class="anime-card-actions">
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(anime.url)}', '_blank')"> <button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
🔗 MAL <i class="fa-solid fa-link"></i> MAL
</button> </button>
<button class="btn btn-primary btn-small" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')"> <button class="btn btn-primary btn-small" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
📥 Télécharger <i class="fa-solid fa-download"></i> Télécharger
</button> </button>
</div> </div>
</div> </div>
@@ -246,11 +246,11 @@ function renderReleaseCard(anime) {
// Get rating color based on score // Get rating color based on score
function getRatingColor(score) { function getRatingColor(score) {
if (score >= 9) return 'linear-gradient(45deg, #ffd700, #ffed4e)'; if (score >= 9) return '#ffd700';
if (score >= 8) return 'linear-gradient(45deg, #00ff88, #00d9ff)'; if (score >= 8) return '#2d936c';
if (score >= 7) return 'linear-gradient(45deg, #00d9ff, #6c5ce7)'; if (score >= 7) return '#FF9F1C';
if (score >= 6) return 'linear-gradient(45deg, #ffa500, #ff6b6b)'; if (score >= 6) return '#f4a261';
return 'linear-gradient(45deg, #666, #888)'; return '#888888';
} }
// Search anime on providers (redirects to anime tab) // Search anime on providers (redirects to anime tab)
+13 -13
View File
@@ -26,7 +26,7 @@ async function handleSeriesSearch() {
const series = data.results['fs7']; const series = data.results['fs7'];
let html = ` let html = `
<div class="streaming-results-header"> <div class="streaming-results-header">
<h3>📺 Résultats pour "${escapeHtml(query)}"</h3> <h3><i class="fa-solid fa-tv"></i> Résultats pour "${escapeHtml(query)}"</h3>
</div> </div>
<div class="search-results" style="margin-top: 20px;"> <div class="search-results" style="margin-top: 20px;">
`; `;
@@ -46,19 +46,19 @@ async function handleSeriesSearch() {
<div class="anime-card" id="series-fs7-${encodeURIComponent(s.url)}"> <div class="anime-card" id="series-fs7-${encodeURIComponent(s.url)}">
<div class="anime-card-header"> <div class="anime-card-header">
<div class="anime-card-title">${escapeHtml(s.title)}</div> <div class="anime-card-title">${escapeHtml(s.title)}</div>
<div class="anime-card-provider">📺 French Stream</div> <div class="anime-card-provider"><i class="fa-solid fa-tv"></i> French Stream</div>
</div> </div>
${coverImage ? ` ${coverImage ? `
<div style="text-align: center; margin: 10px 0;"> <div style="text-align: center; margin: 10px 0;">
<img src="${escapeHtml(coverImage)}" alt="" style="max-width: 200px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.3);" onerror="this.style.display='none'"> <img src="${escapeHtml(coverImage)}" alt="" style="max-width: 200px; border-radius: 4px;" onerror="this.style.display='none'">
</div> </div>
` : ''} ` : ''}
<div class="anime-card-actions"> <div class="anime-card-actions">
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(s.url)}', '_blank')"> <button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(s.url)}', '_blank')">
🔗 Voir sur FS7 <i class="fa-solid fa-link"></i> Voir sur FS7
</button> </button>
<button class="btn btn-primary btn-small" onclick="loadSeriesEpisodesDirect('${escapeHtml(s.url)}', '${escapeHtml(s.title)}')"> <button class="btn btn-primary btn-small" onclick="loadSeriesEpisodesDirect('${escapeHtml(s.url)}', '${escapeHtml(s.title)}')">
📥 Voir les épisodes <i class="fa-solid fa-download"></i> Voir les épisodes
</button> </button>
</div> </div>
<div id="episodes-fs7-${encodeURIComponent(s.url)}" style="margin-top: 10px;"></div> <div id="episodes-fs7-${encodeURIComponent(s.url)}" style="margin-top: 10px;"></div>
@@ -71,7 +71,7 @@ async function handleSeriesSearch() {
} else { } else {
resultsContainer.innerHTML = ` resultsContainer.innerHTML = `
<div class="no-results"> <div class="no-results">
<p> Aucune série trouvée pour "${escapeHtml(query)}"</p> <p><i class="fa-solid fa-xmark"></i> Aucune série trouvée pour "${escapeHtml(query)}"</p>
<p style="font-size: 12px; margin-top: 10px; opacity: 0.7;"> <p style="font-size: 12px; margin-top: 10px; opacity: 0.7;">
Essayez avec un autre titre ou vérifiez l'orthographe Essayez avec un autre titre ou vérifiez l'orthographe
</p> </p>
@@ -81,7 +81,7 @@ async function handleSeriesSearch() {
console.error('Error searching series:', error); console.error('Error searching series:', error);
resultsContainer.innerHTML = ` resultsContainer.innerHTML = `
<div class="no-results"> <div class="no-results">
<p> Erreur lors de la recherche</p> <p><i class="fa-solid fa-xmark"></i> Erreur lors de la recherche</p>
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p> <p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
</div>`; </div>`;
} }
@@ -102,10 +102,10 @@ async function loadSeriesEpisodesDirect(url, title) {
if (data.episodes && data.episodes.length > 0) { if (data.episodes && data.episodes.length > 0) {
let html = ` let html = `
<div style="margin-top: 15px;"> <div style="margin-top: 15px;">
<label style="font-size: 12px; color: #00d9ff; margin-bottom: 5px; display: block;"> <label style="font-size: 12px; color: #FF9F1C; margin-bottom: 5px; display: block;">
📺 Sélectionner un épisode: <i class="fa-solid fa-tv"></i> Sélectionner un épisode:
</label> </label>
<select id="select-episodes-${encodeURIComponent(url)}" style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid rgba(0, 217, 255, 0.3); background: rgba(0, 0, 0, 0.3); color: white;"> <select id="select-episodes-${encodeURIComponent(url)}" style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #2a2d32; background: #202327; color: #F2F2F2;">
<option value="">Sélectionner un épisode</option> <option value="">Sélectionner un épisode</option>
${data.episodes.map(ep => ` ${data.episodes.map(ep => `
<option value="${escapeHtml(ep.url)}">Épisode ${escapeHtml(ep.episode)}</option> <option value="${escapeHtml(ep.url)}">Épisode ${escapeHtml(ep.episode)}</option>
@@ -145,7 +145,7 @@ async function downloadSeriesEpisode(url, title) {
}); });
if (response.ok) { if (response.ok) {
alert(`Téléchargement démarré pour "${title}"`); alert(`Téléchargement démarré pour "${title}"`);
// Refresh downloads // Refresh downloads
if (typeof loadDownloads === 'function') { if (typeof loadDownloads === 'function') {
loadDownloads(); loadDownloads();
@@ -155,11 +155,11 @@ async function downloadSeriesEpisode(url, title) {
const errorMessage = error.detail const errorMessage = error.detail
? (typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail)) ? (typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail))
: 'Impossible de démarrer le téléchargement'; : 'Impossible de démarrer le téléchargement';
alert(`Erreur: ${errorMessage}`); alert(`Erreur : ${errorMessage}`);
} }
} catch (error) { } catch (error) {
console.error('Download error:', error); console.error('Download error:', error);
alert(`Erreur lors du téléchargement: ${error.message}`); alert(`Erreur lors du téléchargement : ${error.message}`);
} }
} }
+206
View File
@@ -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 = '<span style="color: var(--text-dim);">Aucun telechargement detecte. Ratio par defaut : ' + aw + ' anime / ' + sw + ' serie.</span>';
} else {
const pctA = total > 0 ? Math.round(ac / total * 100) : 50;
const pctS = total > 0 ? Math.round(sc / total * 100) : 50;
details.innerHTML = `
<div style="margin-bottom: 8px;">
<strong>${ac}</strong> anime${ac > 1 ? 's' : ''} (${pctA}%) &mdash; <strong>${sc}</strong> serie${sc > 1 ? 's' : ''} (${pctS}%)
</div>
<div style="height: 8px; background: var(--secondary); border-radius: 4px; overflow: hidden; display: flex;">
<div style="width: ${pctA}%; background: var(--primary);"></div>
<div style="width: ${pctS}%; background: #6CB4EE;"></div>
</div>
<div style="margin-top: 8px; font-size: 12px;">
Ratio applique : <strong style="color: var(--primary);">${aw}</strong> anime / <strong style="color: #6CB4EE;">${sw}</strong> serie
</div>
`;
}
} catch (e) {
details.innerHTML = '<span style="color: var(--danger);">Erreur de chargement</span>';
}
}
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 = '<span style="color: var(--danger);">Les deux poids ne peuvent pas etre a 0</span>';
return;
}
const pctA = Math.round(aw / total * 100);
const pctS = 100 - pctA;
preview.innerHTML = `
<div style="margin-bottom: 6px;">
<span style="color: var(--primary); font-weight: 700;">${pctA}%</span> animes &nbsp;/&nbsp;
<span style="color: #6CB4EE; font-weight: 700;">${pctS}%</span> series
</div>
<div style="height: 8px; background: var(--secondary); border-radius: 4px; overflow: hidden; display: flex;">
<div style="width: ${pctA}%; background: var(--primary); transition: width 0.2s;"></div>
<div style="width: ${pctS}%; background: #6CB4EE; transition: width 0.2s;"></div>
</div>
`;
}
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();
}
}
});
+17 -17
View File
@@ -19,7 +19,7 @@ function renderSeriesRecommendationCard(series) {
return ` return `
<div class="anime-card-horizontal recommendation-card"> <div class="anime-card-horizontal recommendation-card">
<div class="recommendation-badge">🎺 Série TV populaire</div> <div class="recommendation-badge"><i class="fa-solid fa-music"></i> Série TV populaire</div>
<div class="anime-card-header"> <div class="anime-card-header">
<div class="anime-card-title">${escapeHtml(series.title)}</div> <div class="anime-card-title">${escapeHtml(series.title)}</div>
@@ -30,17 +30,17 @@ function renderSeriesRecommendationCard(series) {
<div class="anime-card-info"> <div class="anime-card-info">
<div class="anime-card-meta"> <div class="anime-card-meta">
📺 Série TV <i class="fa-solid fa-tv"></i> Série TV
</div> </div>
</div> </div>
</div> </div>
<div class="anime-card-actions"> <div class="anime-card-actions">
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')"> <button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
🔗 Voir sur FS7 <i class="fa-solid fa-link"></i> Voir sur FS7
</button> </button>
<button class="btn btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')"> <button class="btn btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
📥 Voir les épisodes <i class="fa-solid fa-download"></i> Voir les épisodes
</button> </button>
</div> </div>
</div> </div>
@@ -92,17 +92,17 @@ function renderSeriesReleaseCard(series) {
<div class="anime-card-info"> <div class="anime-card-info">
<div class="anime-card-meta"> <div class="anime-card-meta">
📺 Série TV • Nouveau <i class="fa-solid fa-tv"></i> Série TV • Nouveau
</div> </div>
</div> </div>
</div> </div>
<div class="anime-card-actions"> <div class="anime-card-actions">
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')"> <button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
🔗 Voir sur FS7 <i class="fa-solid fa-link"></i> Voir sur FS7
</button> </button>
<button class="btn btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')"> <button class="btn btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
📥 Voir les épisodes <i class="fa-solid fa-download"></i> Voir les épisodes
</button> </button>
</div> </div>
</div> </div>
@@ -236,10 +236,10 @@ async function loadSeriesReleases() {
if (container) { if (container) {
container.innerHTML = ` container.innerHTML = `
<div class="no-results"> <div class="no-results">
<p> Erreur lors du chargement des séries</p> <p><i class="fa-solid fa-xmark"></i> Erreur lors du chargement des séries</p>
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p> <p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
<button class="btn btn-secondary btn-small" onclick="loadSeriesReleases()" style="margin-top: 10px;"> <button class="btn btn-secondary btn-small" onclick="loadSeriesReleases()" style="margin-top: 10px;">
🔄 Réessayer <i class="fa-solid fa-rotate"></i> Réessayer
</button> </button>
</div>`; </div>`;
} }
@@ -260,7 +260,7 @@ async function loadProvidersGrid() {
let html = ''; let html = '';
// Section Anime providers // Section Anime providers
html += '<div class="section-header"><h3 style="margin-top: 20px;">🎬 Sites Anime</h3></div>'; html += '<div class="section-header"><h3 style="margin-top: 20px;"><i class="fa-solid fa-film"></i> Sites Anime</h3></div>';
html += '<div class="search-results">'; html += '<div class="search-results">';
const animeProviders = Object.entries(data.anime_providers || {}); const animeProviders = Object.entries(data.anime_providers || {});
@@ -281,11 +281,11 @@ async function loadProvidersGrid() {
<div class="anime-card-actions"> <div class="anime-card-actions">
${domains.length > 0 ? ` ${domains.length > 0 ? `
<button class="btn btn-primary btn-small" onclick="window.open('https://${domains[0]}', '_blank')"> <button class="btn btn-primary btn-small" onclick="window.open('https://${domains[0]}', '_blank')">
🔗 Visiter le site <i class="fa-solid fa-link"></i> Visiter le site
</button> </button>
` : ''} ` : ''}
<button class="btn btn-secondary btn-small" onclick="showProviderSearch('${id}')"> <button class="btn btn-secondary btn-small" onclick="showProviderSearch('${id}')">
🔍 Rechercher <i class="fa-solid fa-magnifying-glass"></i> Rechercher
</button> </button>
</div> </div>
</div> </div>
@@ -298,7 +298,7 @@ async function loadProvidersGrid() {
html += '</div>'; html += '</div>';
// Section File hosts // Section File hosts
html += '<div class="section-header" style="margin-top: 40px;"><h3>💾 Hébergeurs de fichiers</h3></div>'; html += '<div class="section-header" style="margin-top: 40px;"><h3><i class="fa-solid fa-floppy-disk"></i> Hébergeurs de fichiers</h3></div>';
html += '<div class="search-results">'; html += '<div class="search-results">';
const fileHosts = Object.entries(data.file_hosts || {}); const fileHosts = Object.entries(data.file_hosts || {});
@@ -311,7 +311,7 @@ async function loadProvidersGrid() {
</div> </div>
<div class="anime-card-actions"> <div class="anime-card-actions">
<button class="btn btn-secondary btn-small" onclick="showDownloadInfo()"> <button class="btn btn-secondary btn-small" onclick="showDownloadInfo()">
📥 Télécharger un fichier <i class="fa-solid fa-download"></i> Télécharger un fichier
</button> </button>
</div> </div>
</div> </div>
@@ -330,10 +330,10 @@ async function loadProvidersGrid() {
if (container) { if (container) {
container.innerHTML = ` container.innerHTML = `
<div class="no-results"> <div class="no-results">
<p> Erreur lors du chargement des fournisseurs</p> <p><i class="fa-solid fa-xmark"></i> Erreur lors du chargement des fournisseurs</p>
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p> <p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
<button class="btn btn-secondary btn-small" onclick="loadProvidersGrid()" style="margin-top: 10px;"> <button class="btn btn-secondary btn-small" onclick="loadProvidersGrid()" style="margin-top: 10px;">
🔄 Réessayer <i class="fa-solid fa-rotate"></i> Réessayer
</button> </button>
</div> </div>
`; `;
@@ -349,7 +349,7 @@ function showProviderSearch(providerId) {
// Show download info (explains how to download) // Show download info (explains how to download)
function showDownloadInfo() { function showDownloadInfo() {
alert('💡 Pour télécharger un fichier:\n\n1. Utilisez l\'onglet "Recherche"\n2. Entrez le nom de l\'anime/série\n3. Cliquez sur "Télécharger" sur un épisode\n\nOu bien:\n- Copiez directement un lien de téléchargement dans la barre d\'adresse de votre navigateur'); alert('Pour télécharger un fichier:\n\n1. Utilisez l\'onglet "Recherche"\n2. Entrez le nom de l\'anime/série\n3. Cliquez sur "Télécharger" sur un épisode\n\nOu bien:\n- Copiez directement un lien de téléchargement dans la barre d\'adresse de votre navigateur');
} }
// Make additional functions available globally // Make additional functions available globally
+9 -9
View File
@@ -346,10 +346,10 @@ async function handleStartScheduler() {
try { try {
await startScheduler(); await startScheduler();
await loadSchedulerStatus(); await loadSchedulerStatus();
alert('Planificateur démarré!'); alert('Planificateur démarré !');
} catch (error) { } catch (error) {
console.error('Error starting scheduler:', error); console.error('Error starting scheduler:', error);
alert(`Erreur: ${error.message}`); alert(`Erreur : ${error.message}`);
} }
} }
@@ -360,10 +360,10 @@ async function handleStopScheduler() {
try { try {
await stopScheduler(); await stopScheduler();
await loadSchedulerStatus(); await loadSchedulerStatus();
alert('Planificateur arrêté!'); alert('Planificateur arrêté !');
} catch (error) { } catch (error) {
console.error('Error stopping scheduler:', error); console.error('Error stopping scheduler:', error);
alert(`Erreur: ${error.message}`); alert(`Erreur : ${error.message}`);
} }
} }
@@ -376,7 +376,7 @@ async function handleCheckAll() {
await loadSchedulerStatus(); await loadSchedulerStatus();
} catch (error) { } catch (error) {
console.error('Error checking all:', error); console.error('Error checking all:', error);
alert(`Erreur: ${error.message}`); alert(`Erreur : ${error.message}`);
} }
} }
@@ -394,7 +394,7 @@ async function handleOpenSettings() {
document.body.appendChild(modalContainer); document.body.appendChild(modalContainer);
} catch (error) { } catch (error) {
console.error('Error loading settings:', error); console.error('Error loading settings:', error);
alert(`Erreur: ${error.message}`); alert(`Erreur : ${error.message}`);
} }
} }
@@ -438,17 +438,17 @@ function updateSchedulerUI(status) {
if (status.next_run) { if (status.next_run) {
const nextRun = new Date(status.next_run); const nextRun = new Date(status.next_run);
nextRunInfo.innerHTML = ` En cours<br>Prochaine vérification: ${nextRun.toLocaleString('fr-FR')}`; nextRunInfo.innerHTML = `<i class="fa-solid fa-check"></i> En cours<br>Prochaine vérification: ${nextRun.toLocaleString('fr-FR')}`;
} else { } else {
// Scheduler running but no next_run yet (just started) // Scheduler running but no next_run yet (just started)
const interval = status.settings?.check_interval_hours || 6; const interval = status.settings?.check_interval_hours || 6;
nextRunInfo.innerHTML = ` En cours<br>Vérification toutes les ${interval}h`; nextRunInfo.innerHTML = `<i class="fa-solid fa-check"></i> En cours<br>Vérification toutes les ${interval}h`;
} }
} else { } else {
// Update buttons if they exist // Update buttons if they exist
if (startBtn) startBtn.style.display = 'inline-block'; if (startBtn) startBtn.style.display = 'inline-block';
if (stopBtn) stopBtn.style.display = 'none'; if (stopBtn) stopBtn.style.display = 'none';
nextRunInfo.innerHTML = '⏸️ Arrêté'; nextRunInfo.innerHTML = '<i class="fa-solid fa-pause"></i> Arrêté';
} }
} }
+5
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+5 -3
View File
@@ -7,11 +7,12 @@
<!-- CSS --> <!-- CSS -->
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" /> <link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
<!-- External Libraries --> <!-- External Libraries (local first, CDN fallback) -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <script src="/static/vendor/htmx.min.js"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script> <script src="/static/vendor/alpine.min.js" defer></script>
<script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script> <script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script>
<style> <style>
@@ -40,6 +41,7 @@
<script src="/static/js/watchlist.js?v=1.11" defer></script> <script src="/static/js/watchlist.js?v=1.11" defer></script>
<!-- <script src="/static/js/watchlist-ui.js?v=1.11" defer></script> --> <!-- <script src="/static/js/watchlist-ui.js?v=1.11" defer></script> -->
<script src="/static/js/main.js?v=1.11" defer></script> <script src="/static/js/main.js?v=1.11" defer></script>
<script src="/static/js/settings.js?v=1.0" defer></script>
</head> </head>
<body x-data="globalAppState"> <body x-data="globalAppState">
{% include "components/toast_container.html" %} {% include "components/toast_container.html" %}
+9 -9
View File
@@ -5,23 +5,23 @@
<!-- Stats Cards --> <!-- Stats Cards -->
<div id="admin-stats" class="admin-stats-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; margin-bottom: 30px;"> <div id="admin-stats" class="admin-stats-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; margin-bottom: 30px;">
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); text-align: center;"> <div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;text-align: center;">
<div style="font-size: 2rem; font-weight: 800; color: var(--primary);" id="stat-total-users">{{ users|length }}</div> <div style="font-size: 2rem; font-weight: 800; color: var(--primary);" id="stat-total-users">{{ users|length }}</div>
<div style="color: var(--text-dim); font-size: 0.85rem;">Utilisateurs</div> <div style="color: var(--text-dim); font-size: 0.85rem;">Utilisateurs</div>
</div> </div>
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); text-align: center;"> <div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;text-align: center;">
<div style="font-size: 2rem; font-weight: 800; color: var(--accent);" id="stat-active-users">{{ users|selectattr('is_active')|list|length }}</div> <div style="font-size: 2rem; font-weight: 800; color: var(--accent);" id="stat-active-users">{{ users|selectattr('is_active')|list|length }}</div>
<div style="color: var(--text-dim); font-size: 0.85rem;">Actifs</div> <div style="color: var(--text-dim); font-size: 0.85rem;">Actifs</div>
</div> </div>
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); text-align: center;"> <div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;text-align: center;">
<div style="font-size: 2rem; font-weight: 800; color: #f0a500;" id="stat-admin-users">{{ users|selectattr('is_admin')|list|length }}</div> <div style="font-size: 2rem; font-weight: 800; color: #f0a500;" id="stat-admin-users">{{ users|selectattr('is_admin')|list|length }}</div>
<div style="color: var(--text-dim); font-size: 0.85rem;">Admins</div> <div style="color: var(--text-dim); font-size: 0.85rem;">Admins</div>
</div> </div>
</div> </div>
<!-- Users Table --> <!-- Users Table -->
<div style="background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); overflow: hidden;"> <div style="background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;overflow: hidden;">
<div style="padding: 20px 25px; border-bottom: 1px solid rgba(255,255,255,0.05);"> <div style="padding: 20px 25px; border-bottom: 1px solid #2a2d32;">
<h3 style="margin: 0; color: var(--primary);">Gestion des utilisateurs</h3> <h3 style="margin: 0; color: var(--primary);">Gestion des utilisateurs</h3>
</div> </div>
@@ -29,7 +29,7 @@
<div style="overflow-x: auto;"> <div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;"> <table style="width: 100%; border-collapse: collapse;">
<thead> <thead>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);"> <tr style="border-bottom: 1px solid #2a2d32;">
<th style="padding: 12px 20px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Utilisateur</th> <th style="padding: 12px 20px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Utilisateur</th>
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Email</th> <th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Email</th>
<th style="padding: 12px 15px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Statut</th> <th style="padding: 12px 15px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Statut</th>
@@ -41,7 +41,7 @@
</thead> </thead>
<tbody> <tbody>
{% for user in users %} {% for user in users %}
<tr style="border-bottom: 1px solid rgba(255,255,255,0.03); {% if not user.is_active %}opacity: 0.5;{% endif %}"> <tr style="border-bottom: 1px solid #2a2d32; {% if not user.is_active %}opacity: 0.5;{% endif %}">
<td style="padding: 12px 20px;"> <td style="padding: 12px 20px;">
<div style="font-weight: 600;">{{ user.username }}</div> <div style="font-weight: 600;">{{ user.username }}</div>
{% if user.full_name %} {% if user.full_name %}
@@ -50,12 +50,12 @@
</td> </td>
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.9rem;">{{ user.email or '-' }}</td> <td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.9rem;">{{ user.email or '-' }}</td>
<td style="padding: 12px 15px; text-align: center;"> <td style="padding: 12px 15px; text-align: center;">
<span style="display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_active %}rgba(0,255,136,0.15); color: var(--accent){% else %}rgba(255,77,77,0.15); color: var(--danger){% endif %};"> <span style="display: inline-block; padding: 3px 10px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_active %}rgba(45,147,108,0.1); color: #2d936c{% else %}rgba(230,57,70,0.1); color: #e63946{% endif %};">
{% if user.is_active %}Actif{% else %}Inactif{% endif %} {% if user.is_active %}Actif{% else %}Inactif{% endif %}
</span> </span>
</td> </td>
<td style="padding: 12px 15px; text-align: center;"> <td style="padding: 12px 15px; text-align: center;">
<span style="display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_admin %}rgba(240,165,0,0.15); color: #f0a500{% else %}rgba(255,255,255,0.05); color: var(--text-dim){% endif %};"> <span style="display: inline-block; padding: 3px 10px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_admin %}rgba(244,162,97,0.15); color: #f4a261{% else %}var(--bg-elevated); color: var(--text-dim){% endif %};">
{% if user.is_admin %}Admin{% else %}User{% endif %} {% if user.is_admin %}Admin{% else %}User{% endif %}
</span> </span>
</td> </td>
+2 -2
View File
@@ -2,9 +2,9 @@
<div class="hc" id="anime-{{ anime.url | hash }}" <div class="hc" id="anime-{{ anime.url | hash }}"
@click="activeTab = 'anime'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'anime' } })); $nextTick(() => { const input = document.getElementById('animeSearchInput'); if (input) { input.value = '{{ anime.title | e }}'; htmx.trigger(input, 'keyup'); } });"> @click="activeTab = 'anime'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'anime' } })); $nextTick(() => { const input = document.getElementById('animeSearchInput'); if (input) { input.value = '{{ anime.title | e }}'; htmx.trigger(input, 'keyup'); } });">
<div class="hc-poster"> <div class="hc-poster">
{% set poster = anime.cover_image or (anime.metadata.poster_image if anime.metadata else None) or 'https://placehold.co/400x600/161625/00d9ff?text=No+Image' %} {% set poster = anime.cover_image or (anime.metadata.poster_image if anime.metadata else None) or 'https://placehold.co/400x600/e6e8e6/f15025?text=No+Image' %}
<img src="{{ poster }}" alt="{{ anime.title }}" loading="lazy" referrerpolicy="no-referrer" <img src="{{ poster }}" alt="{{ anime.title }}" loading="lazy" referrerpolicy="no-referrer"
onerror="this.src='https://placehold.co/400x600/161625/00d9ff?text=Error'; this.onerror=null;"> onerror="this.src='https://placehold.co/400x600/e6e8e6/f15025?text=Error'; this.onerror=null;">
{% if anime.metadata and anime.metadata.rating %} {% if anime.metadata and anime.metadata.rating %}
<span class="hc-rating"><i class="fas fa-star"></i> {{ anime.metadata.rating }}</span> <span class="hc-rating"><i class="fas fa-star"></i> {{ anime.metadata.rating }}</span>
{% endif %} {% endif %}
+17 -17
View File
@@ -1,4 +1,4 @@
{% set accent = "#00d9ff" %} {% set accent = "#FF9F1C" %}
{% set default_lang = settings.default_lang if settings else 'vostfr' %} {% set default_lang = settings.default_lang if settings else 'vostfr' %}
{% set _groups = namespace(items={}) %} {% set _groups = namespace(items={}) %}
@@ -36,9 +36,9 @@
{% set first_url = group.providers[0].url %} {% set first_url = group.providers[0].url %}
<div class="sr-card" style="--sr-accent: {{ accent }};"> <div class="sr-card" style="--sr-accent: {{ accent }};">
<a class="sr-poster-link" href="{{ first_url }}" target="_blank" rel="noopener"> <a class="sr-poster-link" href="{{ first_url }}" target="_blank" rel="noopener">
<img class="sr-poster-img" src="{{ group.cover or 'https://placehold.co/240x360/161625/00d9ff?text=No+Image' }}" <img class="sr-poster-img" src="{{ group.cover or 'https://placehold.co/240x360/29274c/7e52a0?text=No+Image' }}"
alt="{{ group.title }}" loading="lazy" referrerpolicy="no-referrer" alt="{{ group.title }}" loading="lazy" referrerpolicy="no-referrer"
onerror="this.src='https://placehold.co/240x360/161625/00d9ff?text=Error'; this.onerror=null;"> onerror="this.src='https://placehold.co/240x360/29274c/7e52a0?text=Error'; this.onerror=null;">
</a> </a>
<div class="sr-body"> <div class="sr-body">
<div class="sr-top"> <div class="sr-top">
@@ -114,11 +114,11 @@
.sr-card { .sr-card {
display: flex; gap: 20px; display: flex; gap: 20px;
background: var(--bg-card); border-radius: var(--card-radius); background: var(--bg-card); border-radius: var(--card-radius);
padding: 20px; border: 1px solid rgba(255,255,255,0.05); padding: 20px; border: 1px solid #2a2d32;"
transition: var(--transition); transition: var(--transition);
} }
.sr-card:hover { border-color: var(--sr-accent); box-shadow: 0 4px 24px rgba(0,0,0,0.4); } .sr-card:hover { border-color: var(--sr-accent); }
.sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #000; } .sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 4px; overflow: hidden; background: #000; }
.sr-poster-img { width: 100%; height: 100%; object-fit: cover; display: block; } .sr-poster-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.sr-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; } .sr-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.sr-top { display: flex; align-items: baseline; gap: 12px; } .sr-top { display: flex; align-items: baseline; gap: 12px; }
@@ -126,24 +126,24 @@
.sr-rating { flex-shrink: 0; font-size: 0.8rem; font-weight: 700; color: #ffcc00; } .sr-rating { flex-shrink: 0; font-size: 0.8rem; font-weight: 700; color: #ffcc00; }
.sr-synopsis { font-size: 0.85rem; color: var(--text-dim); margin: 0; line-height: 1.5; } .sr-synopsis { font-size: 0.85rem; color: var(--text-dim); margin: 0; line-height: 1.5; }
.sr-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; } .sr-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; }
.sr-tag { font-size: 0.65rem; font-weight: 600; padding: 2px 8px; border-radius: 4px; background: rgba(255,255,255,0.06); color: var(--text-dim); } .sr-tag { font-size: 0.65rem; font-weight: 600; padding: 2px 8px; border-radius: 4px; background: var(--bg-elevated); color: var(--text-dim); }
.sr-providers { display: flex; flex-wrap: wrap; gap: 6px; } .sr-providers { display: flex; flex-wrap: wrap; gap: 6px; }
.sr-provider-badge { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; padding: 4px 12px; border-radius: 20px; border: 1px solid var(--sr-accent); color: var(--sr-accent); background: transparent; cursor: pointer; transition: var(--transition); letter-spacing: 0.5px; text-decoration: none; } .sr-provider-badge { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; padding: 4px 12px; border-radius: 20px; border: 1px solid var(--sr-accent); color: var(--sr-accent); background: transparent; cursor: pointer; transition: var(--transition); letter-spacing: 0.5px; text-decoration: none; }
.sr-provider-badge:hover { background: var(--sr-accent); color: var(--bg-dark); } .sr-provider-badge:hover { background: var(--sr-accent); color: #fff; }
.sr-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; } .sr-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.sr-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 16px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; border: 1px solid rgba(255,255,255,0.1); cursor: pointer; transition: var(--transition); text-decoration: none; background: transparent; color: var(--text-main); min-height: 34px; } .sr-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 16px; border-radius: 4px; font-size: 0.8rem; font-weight: 600; border: 1px solid #2a2d32; cursor: pointer; transition: var(--transition); text-decoration: none; background: transparent; color: var(--text-main); min-height: 34px; }
.sr-btn:hover { border-color: rgba(255,255,255,0.2); background: rgba(255,255,255,0.05); } .sr-btn:hover { border-color: var(--text-main); background: var(--bg-card); }
.sr-btn-dl { border-color: var(--secondary); color: var(--secondary); } .sr-btn-dl { border-color: var(--secondary); color: var(--secondary); }
.sr-btn-dl:hover { background: var(--secondary); color: var(--bg-dark); } .sr-btn-dl:hover { background: var(--secondary); color: #ffffff; }
.sr-btn-watch { border-color: var(--sr-accent); color: var(--sr-accent); } .sr-btn-watch { border-color: var(--sr-accent); color: var(--sr-accent); }
.sr-btn-watch:hover { background: var(--sr-accent); color: var(--bg-dark); } .sr-btn-watch:hover { background: var(--sr-accent); color: #fff; }
.sr-btn-follow { border-color: var(--accent); color: var(--accent); } .sr-btn-follow { border-color: var(--accent); color: var(--accent); }
.sr-btn-follow:hover { background: var(--accent); color: var(--bg-dark); } .sr-btn-follow:hover { background: var(--accent); color: #fff; }
.sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(0,255,136,0.1); pointer-events: none; } .sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(255,191,105,0.1); pointer-events: none; }
.sr-dropdown { position: relative; } .sr-dropdown { position: relative; }
.sr-dropdown-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: var(--bg-card); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 4px; z-index: 100; box-shadow: 0 8px 32px rgba(0,0,0,0.6); } .sr-dropdown-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: var(--bg-card); border: 1px solid #2a2d32; border-radius: 4px; padding: 4px; z-index: 100; }
.sr-dropdown-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: none; background: transparent; color: var(--text-main); font-size: 0.8rem; cursor: pointer; border-radius: 6px; transition: var(--transition); text-align: left; } .sr-dropdown-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: none; background: transparent; color: var(--text-main); font-size: 0.8rem; cursor: pointer; border-radius: 4px; transition: var(--transition); text-align: left; }
.sr-dropdown-item:hover { background: rgba(255,255,255,0.06); } .sr-dropdown-item:hover { background: var(--bg-elevated); }
.sr-empty { text-align: center; padding: 100px 20px; color: var(--text-dim); } .sr-empty { text-align: center; padding: 100px 20px; color: var(--text-dim); }
.sr-empty i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; } .sr-empty i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
@media (max-width: 768px) { @media (max-width: 768px) {
+1 -1
View File
@@ -36,7 +36,7 @@
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
color: var(--primary); color: var(--primary);
background: rgba(0, 217, 255, 0.1); background: rgba(241, 80, 37, 0.1);
padding: 2px 10px; padding: 2px 10px;
border-radius: 12px; border-radius: 12px;
margin-left: 10px; margin-left: 10px;
+10 -12
View File
@@ -60,7 +60,7 @@
background: var(--bg-card); background: var(--bg-card);
border-radius: var(--card-radius); border-radius: var(--card-radius);
padding: 30px; padding: 30px;
border: 1px solid rgba(255, 255, 255, 0.05); border: 1px solid var(--secondary);
animation: fadeIn 0.3s ease-out; animation: fadeIn 0.3s ease-out;
} }
@@ -71,21 +71,20 @@
} }
.view-grid .episode-item { .view-grid .episode-item {
background: rgba(255, 255, 255, 0.03); background: var(--bg-elevated);
padding: 20px 15px; padding: 20px 15px;
border-radius: 12px; border-radius: 4px;
text-align: center; text-align: center;
transition: var(--transition); transition: var(--transition);
border: 1px solid rgba(255, 255, 255, 0.05); border: 1px solid var(--secondary);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
.view-grid .episode-item:hover { .view-grid .episode-item:hover {
background: rgba(255, 255, 255, 0.07); background: var(--text-dim);
border-color: var(--primary); border-color: var(--primary);
transform: translateY(-3px);
} }
.view-grid .ep-title { display: none; } .view-grid .ep-title { display: none; }
@@ -103,15 +102,15 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: 20px;
background: rgba(255, 255, 255, 0.03); background: var(--bg-elevated);
padding: 12px 20px; padding: 12px 20px;
border-radius: 10px; border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.05); border: 1px solid var(--secondary);
transition: var(--transition); transition: var(--transition);
} }
.view-list .episode-item:hover { .view-list .episode-item:hover {
background: rgba(255, 255, 255, 0.07); background: var(--text-dim);
border-color: var(--primary); border-color: var(--primary);
} }
@@ -123,9 +122,8 @@
margin: 20px 0 30px 0; margin: 20px 0 30px 0;
padding: 25px; padding: 25px;
background: #000; background: #000;
border-radius: 12px; border-radius: 4px;
border: 1px solid var(--primary); border: 1px solid var(--primary);
box-shadow: 0 0 30px rgba(0, 217, 255, 0.15);
} }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
+5 -5
View File
@@ -1,25 +1,25 @@
<header> <header>
<h1> Ohm Stream Downloader</h1> <h1><i class="fa-solid fa-bolt"></i> Ohm Stream Downloader</h1>
<p class="subtitle">Téléchargez vos vidéos, animes et séries depuis vos hébergeurs préférés</p> <p class="subtitle">Téléchargez vos vidéos, animes et séries depuis vos hébergeurs préférés</p>
<!-- User info and logout button --> <!-- User info and logout button -->
<div id="userInfo" x-show="isAuthenticated" class="auth-panel" x-cloak> <div id="userInfo" x-show="isAuthenticated" class="auth-panel" x-cloak>
<div style="display: flex; align-items: center; gap: 10px;"> <div style="display: flex; align-items: center; gap: 10px;">
<span style="color: var(--primary); font-size: 1.2rem;">👤</span> <span style="color: var(--primary); font-size: 1.2rem;"><i class="fa-solid fa-user"></i></span>
<span style="color: #fff; font-size: 14px;">Connecté en tant que <strong x-text="username" style="color: var(--primary);">-</strong></span> <span style="color: var(--text-main); font-size: 14px;">Connecté en tant que <strong x-text="username" style="color: var(--primary);">-</strong></span>
</div> </div>
<button class="btn btn-secondary btn-small" <button class="btn btn-secondary btn-small"
onclick="removeToken(); isAuthenticated = false" onclick="removeToken(); isAuthenticated = false"
hx-post="/api/auth/logout" hx-post="/api/auth/logout"
hx-on::after-request="window.location.href = '/login'"> hx-on::after-request="window.location.href = '/login'">
🚪 Déconnexion <i class="fa-solid fa-right-from-bracket"></i> Déconnexion
</button> </button>
</div> </div>
<!-- Login prompt (shown when not logged in) --> <!-- Login prompt (shown when not logged in) -->
<div id="loginPrompt" x-show="!isAuthenticated" class="auth-panel" style="justify-content: center;" x-cloak> <div id="loginPrompt" x-show="!isAuthenticated" class="auth-panel" style="justify-content: center;" x-cloak>
<p style="color: var(--primary); margin: 0;"> <p style="color: var(--primary); margin: 0;">
👋 Bienvenue! <a href="/login" class="btn btn-secondary btn-small" style="margin-left: 10px;">Se connecter</a> pour accéder à toutes les fonctionnalités. <i class="fa-solid fa-hand"></i> Bienvenue! <a href="/login" class="btn btn-secondary btn-small" style="margin-left: 10px;">Se connecter</a> pour accéder à toutes les fonctionnalités.
</p> </p>
</div> </div>
+2 -2
View File
@@ -2,7 +2,7 @@
<div class="section-container"> <div class="section-container">
<div class="section-header"> <div class="section-header">
<h2>🎯 Recommandé pour vous</h2> <h2><i class="fa-solid fa-bullseye"></i> Recommandé pour vous</h2>
<button class="btn btn-secondary btn-small" <button class="btn btn-secondary btn-small"
hx-get="/api/recommendations" hx-get="/api/recommendations"
hx-target="#recommendationsList"> hx-target="#recommendationsList">
@@ -19,7 +19,7 @@
<div class="section-container"> <div class="section-container">
<div class="section-header"> <div class="section-header">
<h2>🔥 Dernières sorties</h2> <h2><i class="fa-solid fa-fire"></i> Dernières sorties</h2>
<button class="btn btn-secondary btn-small" <button class="btn btn-secondary btn-small"
hx-get="/api/releases/latest" hx-get="/api/releases/latest"
hx-target="#releasesList"> hx-target="#releasesList">
+2 -2
View File
@@ -1,4 +1,4 @@
<div class="login-prompt" style="text-align: center; padding: 40px 20px;"> <div class="login-prompt" style="text-align: center; padding: 40px 20px;">
<i class="fas fa-lock" style="font-size: 2rem; color: #00d9ff; margin-bottom: 15px;"></i> <i class="fa-solid fa-lock" style="font-size: 2rem; color: #FF9F1C; margin-bottom: 15px;"></i>
<p style="color: #888; font-size: 0.95rem;">Connectez-vous pour accéder à cette section.</p> <p style="color: #8a8f98; font-size: 0.95rem;">Connectez-vous pour accéder à cette section.</p>
</div> </div>
+4 -4
View File
@@ -19,7 +19,7 @@
mozallowfullscreen></iframe> mozallowfullscreen></iframe>
</div> </div>
<div class="player-info-hint"> <div class="player-info-hint">
💡 Lecteur externe utilisé. Les contrôles dépendent de l'hébergeur. <i class="fa-solid fa-lightbulb"></i> Lecteur externe utilisé. Les contrôles dépendent de l'hébergeur.
</div> </div>
{% else %} {% else %}
<div class="video-wrapper"> <div class="video-wrapper">
@@ -41,8 +41,8 @@
margin: 20px 0; margin: 20px 0;
padding: 15px; padding: 15px;
background: #000; background: #000;
border-radius: 12px; border-radius: 4px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5); border: 1px solid #2a2d32;
} }
.iframe-container { .iframe-container {
position: relative; position: relative;
@@ -65,7 +65,7 @@
} }
.player-info-hint { .player-info-hint {
font-size: 0.8rem; font-size: 0.8rem;
color: #888; color: var(--text-dim);
margin-top: 10px; margin-top: 10px;
text-align: center; text-align: center;
} }
+13 -14
View File
@@ -1,18 +1,17 @@
{% macro series_card(series, in_watchlist=False, lang='vf') %} {% macro series_card(series) %}
<div class="ac" id="series-{{ series.url | hash }}"> <div class="hc"
<div class="ac-poster"> @click="activeTab = 'series'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'series' } })); $nextTick(() => { const input = document.getElementById('seriesSearchInput'); if (input) { input.value = '{{ series.title | e }}'; htmx.trigger(input, 'keyup'); } });">
<img src="{{ series.cover_image or 'https://placehold.co/400x600/161625/ff6b6b?text=No+Image' }}" <div class="hc-poster">
alt="{{ series.title }}" loading="lazy" referrerpolicy="no-referrer" <img src="{{ series.cover_image or 'https://placehold.co/400x600/202327/FF9F1C?text=No+Image' }}" alt="{{ series.title }}" loading="lazy" referrerpolicy="no-referrer"
onerror="this.src='https://placehold.co/400x600/161625/ff6b6b?text=Error'; this.onerror=null;"> onerror="this.src='https://placehold.co/400x600/202327/FF9F1C?text=Error'; this.onerror=null;">
<button class="ac-play" {% if series.lang %}
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}" <span class="hc-rating" style="text-transform: uppercase;">{{ series.lang }}</span>
hx-target="#player-container" hx-swap="innerHTML"> {% endif %}
<i class="fas fa-play"></i> <span class="hc-play"><i class="fas fa-search"></i></span>
</button>
</div> </div>
<div class="ac-info"> <div class="hc-info">
<span class="ac-provider" style="--ac-color: var(--secondary)">{{ series.provider_id | upper if series.provider_id else 'FS7' }}</span> <span class="hc-src">{{ series.provider_id or 'FS7' }}</span>
<h3 class="ac-title" title="{{ series.title }}">{{ series.title }}</h3> <span class="hc-title">{{ series.title }}</span>
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
@@ -0,0 +1,11 @@
{% from "components/series_card.html" import series_card %}
{% if releases %}
{% for series in releases %}
{{ series_card(series) }}
{% endfor %}
{% else %}
<div class="empty-state">
<p>Aucune sortie recente trouvee.</p>
</div>
{% endif %}
+16 -16
View File
@@ -1,4 +1,4 @@
{% set accent = "#ff6b6b" %} {% set accent = "#FF9F1C" %}
{% set default_lang = settings.default_lang if settings else 'vf' %} {% set default_lang = settings.default_lang if settings else 'vf' %}
{% set _groups = namespace(items={}) %} {% set _groups = namespace(items={}) %}
@@ -28,9 +28,9 @@
{% set first_url = group.providers[0].url %} {% set first_url = group.providers[0].url %}
<div class="sr-card" style="--sr-accent: {{ accent }};"> <div class="sr-card" style="--sr-accent: {{ accent }};">
<a class="sr-poster-link" href="{{ first_url }}" target="_blank" rel="noopener"> <a class="sr-poster-link" href="{{ first_url }}" target="_blank" rel="noopener">
<img class="sr-poster-img" src="{{ group.cover or 'https://placehold.co/240x360/161625/ff6b6b?text=No+Image' }}" <img class="sr-poster-img" src="{{ group.cover or 'https://placehold.co/240x360/29274c/7e52a0?text=No+Image' }}"
alt="{{ group.title }}" loading="lazy" referrerpolicy="no-referrer" alt="{{ group.title }}" loading="lazy" referrerpolicy="no-referrer"
onerror="this.src='https://placehold.co/240x360/161625/ff6b6b?text=Error'; this.onerror=null;"> onerror="this.src='https://placehold.co/240x360/29274c/7e52a0?text=Error'; this.onerror=null;">
</a> </a>
<div class="sr-body"> <div class="sr-body">
<h3 class="sr-title">{{ group.title }}</h3> <h3 class="sr-title">{{ group.title }}</h3>
@@ -93,32 +93,32 @@
.sr-card { .sr-card {
display: flex; gap: 20px; display: flex; gap: 20px;
background: var(--bg-card); border-radius: var(--card-radius); background: var(--bg-card); border-radius: var(--card-radius);
padding: 20px; border: 1px solid rgba(255,255,255,0.05); padding: 20px; border: 1px solid #2a2d32;"
transition: var(--transition); transition: var(--transition);
} }
.sr-card:hover { border-color: var(--sr-accent); box-shadow: 0 4px 24px rgba(0,0,0,0.4); } .sr-card:hover { border-color: var(--sr-accent); }
.sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #000; } .sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 4px; overflow: hidden; background: #000; }
.sr-poster-img { width: 100%; height: 100%; object-fit: cover; display: block; } .sr-poster-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.sr-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; } .sr-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.sr-title { font-size: 1.1rem; font-weight: 700; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sr-title { font-size: 1.1rem; font-weight: 700; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sr-synopsis { font-size: 0.85rem; color: var(--text-dim); margin: 0; line-height: 1.5; } .sr-synopsis { font-size: 0.85rem; color: var(--text-dim); margin: 0; line-height: 1.5; }
.sr-providers { display: flex; flex-wrap: wrap; gap: 6px; } .sr-providers { display: flex; flex-wrap: wrap; gap: 6px; }
.sr-provider-badge { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; padding: 4px 12px; border-radius: 20px; border: 1px solid var(--sr-accent); color: var(--sr-accent); background: transparent; cursor: pointer; transition: var(--transition); letter-spacing: 0.5px; text-decoration: none; } .sr-provider-badge { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; padding: 4px 12px; border-radius: 20px; border: 1px solid var(--sr-accent); color: var(--sr-accent); background: transparent; cursor: pointer; transition: var(--transition); letter-spacing: 0.5px; text-decoration: none; }
.sr-provider-badge:hover { background: var(--sr-accent); color: var(--bg-dark); } .sr-provider-badge:hover { background: var(--sr-accent); color: #fff; }
.sr-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; } .sr-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.sr-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 16px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; border: 1px solid rgba(255,255,255,0.1); cursor: pointer; transition: var(--transition); text-decoration: none; background: transparent; color: var(--text-main); min-height: 34px; } .sr-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 16px; border-radius: 4px; font-size: 0.8rem; font-weight: 600; border: 1px solid #2a2d32; cursor: pointer; transition: var(--transition); text-decoration: none; background: transparent; color: var(--text-main); min-height: 34px; }
.sr-btn:hover { border-color: rgba(255,255,255,0.2); background: rgba(255,255,255,0.05); } .sr-btn:hover { border-color: var(--text-main); background: var(--bg-card); }
.sr-btn-dl { border-color: var(--secondary); color: var(--secondary); } .sr-btn-dl { border-color: var(--secondary); color: var(--secondary); }
.sr-btn-dl:hover { background: var(--secondary); color: var(--bg-dark); } .sr-btn-dl:hover { background: var(--secondary); color: #ffffff; }
.sr-btn-watch { border-color: var(--sr-accent); color: var(--sr-accent); } .sr-btn-watch { border-color: var(--sr-accent); color: var(--sr-accent); }
.sr-btn-watch:hover { background: var(--sr-accent); color: var(--bg-dark); } .sr-btn-watch:hover { background: var(--sr-accent); color: #fff; }
.sr-btn-follow { border-color: var(--accent); color: var(--accent); } .sr-btn-follow { border-color: var(--accent); color: var(--accent); }
.sr-btn-follow:hover { background: var(--accent); color: var(--bg-dark); } .sr-btn-follow:hover { background: var(--accent); color: #fff; }
.sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(0,255,136,0.1); pointer-events: none; } .sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(255,191,105,0.1); pointer-events: none; }
.sr-dropdown { position: relative; } .sr-dropdown { position: relative; }
.sr-dropdown-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: var(--bg-card); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 4px; z-index: 100; box-shadow: 0 8px 32px rgba(0,0,0,0.6); } .sr-dropdown-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: var(--bg-card); border: 1px solid #2a2d32; border-radius: 4px; padding: 4px; z-index: 100; }
.sr-dropdown-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: none; background: transparent; color: var(--text-main); font-size: 0.8rem; cursor: pointer; border-radius: 6px; transition: var(--transition); text-align: left; } .sr-dropdown-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: none; background: transparent; color: var(--text-main); font-size: 0.8rem; cursor: pointer; border-radius: 4px; transition: var(--transition); text-align: left; }
.sr-dropdown-item:hover { background: rgba(255,255,255,0.06); } .sr-dropdown-item:hover { background: var(--bg-elevated); }
.sr-empty { text-align: center; padding: 100px 20px; color: var(--text-dim); } .sr-empty { text-align: center; padding: 100px 20px; color: var(--text-dim); }
.sr-empty i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; } .sr-empty i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
@media (max-width: 768px) { @media (max-width: 768px) {
+70 -96
View File
@@ -4,7 +4,7 @@
</div> </div>
<!-- General Preferences --> <!-- General Preferences -->
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);"> <div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;">
<h3 style="margin-bottom: 20px; color: var(--primary);">General</h3> <h3 style="margin-bottom: 20px; color: var(--primary);">General</h3>
<form id="settings-form" class="settings-form"> <form id="settings-form" class="settings-form">
@@ -43,7 +43,7 @@
</div> </div>
<!-- Content Filters --> <!-- Content Filters -->
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);"> <div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;">
<h3 style="margin-bottom: 20px; color: var(--primary);">Filtres de contenu</h3> <h3 style="margin-bottom: 20px; color: var(--primary);">Filtres de contenu</h3>
<div class="form-group"> <div class="form-group">
@@ -66,12 +66,12 @@
</div> </div>
<!-- Categories --> <!-- Categories -->
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);"> <div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;">
<h3 style="margin-bottom: 20px; color: var(--primary);">Categories</h3> <h3 style="margin-bottom: 20px; color: var(--primary);">Categories</h3>
<p style="color: var(--text-dim); font-size: 13px; margin-bottom: 15px;">Activez ou desactivez les categories. Au moins une doit rester active.</p> <p style="color: var(--text-dim); font-size: 13px; margin-bottom: 15px;">Activez ou desactivez les categories. Au moins une doit rester active.</p>
<div style="display: flex; gap: 15px; flex-wrap: wrap;"> <div style="display: flex; gap: 15px; flex-wrap: wrap;">
<label class="toggle-card" style="flex: 1; min-width: 200px; padding: 15px; background: rgba(255,255,255,0.02); border-radius: 10px; border: 1px solid rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: space-between; cursor: pointer;"> <label class="toggle-card" style="flex: 1; min-width: 200px; padding: 15px; background: var(--bg-elevated); border-radius: 4px; border: 1px solid var(--secondary); display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
<div> <div>
<div style="font-weight: 600; font-size: 1.1rem;">Animes</div> <div style="font-weight: 600; font-size: 1.1rem;">Animes</div>
<div style="font-size: 0.8rem; color: var(--text-dim);">Films et series anime</div> <div style="font-size: 0.8rem; color: var(--text-dim);">Films et series anime</div>
@@ -79,7 +79,7 @@
<input type="checkbox" id="anime_enabled" {% if settings.anime_enabled %}checked{% endif %} onchange="toggleCategory('anime_enabled', this.checked)" style="width: 20px; height: 20px; cursor: pointer; accent-color: var(--primary);"> <input type="checkbox" id="anime_enabled" {% if settings.anime_enabled %}checked{% endif %} onchange="toggleCategory('anime_enabled', this.checked)" style="width: 20px; height: 20px; cursor: pointer; accent-color: var(--primary);">
</label> </label>
<label class="toggle-card" style="flex: 1; min-width: 200px; padding: 15px; background: rgba(255,255,255,0.02); border-radius: 10px; border: 1px solid rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: space-between; cursor: pointer;"> <label class="toggle-card" style="flex: 1; min-width: 200px; padding: 15px; background: var(--bg-elevated); border-radius: 4px; border: 1px solid var(--secondary); display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
<div> <div>
<div style="font-weight: 600; font-size: 1.1rem;">Series TV</div> <div style="font-weight: 600; font-size: 1.1rem;">Series TV</div>
<div style="font-size: 0.8rem; color: var(--text-dim);">Series americaines et europeennes</div> <div style="font-size: 0.8rem; color: var(--text-dim);">Series americaines et europeennes</div>
@@ -89,8 +89,70 @@
</div> </div>
</div> </div>
<!-- Content Weight -->
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;">
<h3 style="margin-bottom: 5px; color: var(--primary);">Equilibre du fil d'actualite</h3>
<p style="color: var(--text-dim); font-size: 13px; margin-bottom: 20px;">
Definissez la proportion d'animes et de series affiches dans les recommandations et dernieres sorties.
</p>
<div class="form-group">
<label for="content_weight_mode" style="font-weight: 600; margin-bottom: 10px; display: block;">Mode</label>
<select name="content_weight_mode" id="content_weight_mode" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;" onchange="onWeightModeChange(this.value)">
<option value="auto" {% if settings.content_weight_mode == 'auto' %}selected{% endif %}>Automatique (basé sur vos telechargements)</option>
<option value="manual" {% if settings.content_weight_mode == 'manual' %}selected{% endif %}>Manuel</option>
</select>
</div>
<!-- Auto mode info -->
<div id="weight-auto-info" style="margin-top: 15px; padding: 15px; background: var(--bg-elevated); border-radius: 4px; border: 1px solid var(--secondary); display: {% if settings.content_weight_mode == 'auto' %}block{% else %}none{% endif %};">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<i class="fas fa-chart-pie" style="color: var(--primary);"></i>
<span style="font-weight: 600;">Analyse de vos telechargements</span>
</div>
<div id="weight-auto-details" style="font-size: 14px; color: var(--text-dim);">
Chargement...
</div>
</div>
<!-- Manual mode controls -->
<div id="weight-manual-controls" style="margin-top: 15px; display: {% if settings.content_weight_mode == 'manual' %}block{% else %}none{% endif %};">
<div style="display: flex; gap: 15px; align-items: center;">
<div style="flex: 1;">
<label for="content_weight_anime" style="font-weight: 600; font-size: 0.9rem; display: block; margin-bottom: 8px;">
<i class="fas fa-dragon" style="color: var(--primary);"></i> Poids Animes
</label>
<input type="range" id="content_weight_anime_range" min="0" max="5" step="1" value="{{ settings.content_weight_anime }}"
style="width: 100%; accent-color: var(--primary);"
oninput="document.getElementById('content_weight_anime').value = this.value; updateWeightPreview();">
<div style="display: flex; justify-content: space-between; font-size: 11px; color: var(--text-dim); margin-top: 2px;">
<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span>
</div>
</div>
<div style="flex: 1;">
<label for="content_weight_series" style="font-weight: 600; font-size: 0.9rem; display: block; margin-bottom: 8px;">
<i class="fas fa-tv" style="color: #6CB4EE;"></i> Poids Series
</label>
<input type="range" id="content_weight_series_range" min="0" max="5" step="1" value="{{ settings.content_weight_series }}"
style="width: 100%; accent-color: #6CB4EE;"
oninput="document.getElementById('content_weight_series').value = this.value; updateWeightPreview();">
<div style="display: flex; justify-content: space-between; font-size: 11px; color: var(--text-dim); margin-top: 2px;">
<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span>
</div>
</div>
</div>
<input type="hidden" id="content_weight_anime" value="{{ settings.content_weight_anime }}">
<input type="hidden" id="content_weight_series" value="{{ settings.content_weight_series }}">
<div id="weight-preview" style="margin-top: 15px; padding: 12px; background: var(--bg-elevated); border-radius: 4px; text-align: center; font-size: 14px;">
</div>
<button class="btn btn-primary" style="margin-top: 15px; width: 100%;" onclick="saveManualWeights()">
<i class="fas fa-balance-scale"></i> Appliquer
</button>
</div>
</div>
<!-- Providers Management --> <!-- Providers Management -->
<div class="settings-card card" style="padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);"> <div class="settings-card card" style="padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3 style="margin: 0; color: var(--primary);">Disponibilite des Fournisseurs</h3> <h3 style="margin: 0; color: var(--primary);">Disponibilite des Fournisseurs</h3>
<button class="btn btn-secondary btn-small" hx-post="/api/providers/health/check" hx-swap="none"> <button class="btn btn-secondary btn-small" hx-post="/api/providers/health/check" hx-swap="none">
@@ -100,13 +162,13 @@
<div class="providers-settings-list" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 15px;"> <div class="providers-settings-list" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 15px;">
{% for provider in providers %} {% for provider in providers %}
<div class="provider-status-card" style="padding: 15px; background: rgba(255,255,255,0.02); border-radius: 10px; border: 1px solid rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: space-between;"> <div class="provider-status-card" style="padding: 15px; background: var(--bg-elevated); border-radius: 4px; border: 1px solid var(--secondary); display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 12px;"> <div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 1.5rem;">{{ provider.icon }}</span> <span style="font-size: 1.5rem;">{{ provider.icon }}</span>
<div> <div>
<div style="font-weight: 600;">{{ provider.name }}</div> <div style="font-weight: 600;">{{ provider.name }}</div>
<div style="font-size: 0.75rem; display: flex; align-items: center; gap: 5px;"> <div style="font-size: 0.75rem; display: flex; align-items: center; gap: 5px;">
<span class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: {% if provider.status == 'up' %}var(--accent){% elif provider.status == 'down' %}var(--danger){% else %}#aaa{% endif %};"></span> <span class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: {% if provider.status == 'up' %}var(--accent){% elif provider.status == 'down' %}var(--danger){% else %}var(--text-muted){% endif %};"></span>
<span style="color: {% if provider.status == 'up' %}var(--accent){% elif provider.status == 'down' %}var(--danger){% else %}var(--text-dim){% endif %}; text-transform: uppercase; font-weight: 800;"> <span style="color: {% if provider.status == 'up' %}var(--accent){% elif provider.status == 'down' %}var(--danger){% else %}var(--text-dim){% endif %}; text-transform: uppercase; font-weight: 800;">
{{ provider.status | upper }} {{ provider.status | upper }}
</span> </span>
@@ -127,93 +189,6 @@
</div> </div>
</div> </div>
<script>
function getToken() {
return localStorage.getItem('auth_token') || null;
}
async function saveSettings() {
const token = getToken();
if (!token) return;
const data = {
default_lang: document.getElementById('default_lang').value,
theme: document.getElementById('theme').value,
download_dir: document.getElementById('download_dir').value,
};
try {
const r = await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (r.ok) {
showToast('Preferences enregistrees', 'success');
}
} catch (e) {
showToast('Erreur: ' + e.message, 'error');
}
}
async function saveFilter(field, value) {
const token = getToken();
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) {
showToast('Filtre mis a jour', 'success');
}
} catch (e) {
showToast('Erreur: ' + e.message, 'error');
}
}
async function toggleCategory(field, value) {
const token = getToken();
if (!token) return;
// Prevent disabling both
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;
}
}
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 showToast(message, type) {
const event = new CustomEvent('show-toast', { detail: { message, type } });
document.dispatchEvent(event);
}
</script>
<style> <style>
.settings-form label { .settings-form label {
display: block; display: block;
@@ -223,6 +198,5 @@ function showToast(message, type) {
} }
.status-dot { .status-dot {
display: inline-block; display: inline-block;
box-shadow: 0 0 5px currentColor;
} }
</style> </style>
+13 -8
View File
@@ -1,10 +1,12 @@
<div id="toast-container" <div id="toast-container"
class="toast-container" class="toast-container"
style="pointer-events: none;"
x-data="{ toasts: [] }" x-data="{ toasts: [] }"
@show-toast.window="toasts.push({ id: Date.now(), message: $event.detail.message, type: $event.detail.type || 'info' }); setTimeout(() => { toasts = toasts.filter(t => t.id !== toasts[0].id) }, 5000)"> @show-toast.window="toasts.push({ id: Date.now(), message: $event.detail.message, type: $event.detail.type || 'info' }); setTimeout(() => { toasts = toasts.filter(t => t.id !== toasts[0].id) }, 5000)">
<template x-for="toast in toasts" :key="toast.id"> <template x-for="toast in toasts" :key="toast.id">
<div class="toast" <div class="toast"
style="pointer-events: auto;"
:class="'toast-' + toast.type" :class="'toast-' + toast.type"
x-show="true" x-show="true"
x-transition:enter="toast-enter" x-transition:enter="toast-enter"
@@ -33,22 +35,25 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
pointer-events: none;
max-height: 80vh;
overflow: hidden;
} }
.toast { .toast {
min-width: 250px; min-width: 250px;
padding: 12px 16px; padding: 12px 16px;
border-radius: 8px; border-radius: 4px;
background: #2d2d2d; background: var(--bg-card);
color: white; color: var(--text-main);
box-shadow: 0 4px 12px rgba(0,0,0,0.3); border: 1px solid var(--secondary);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-left: 4px solid #ccc; border-left: 4px solid var(--secondary);
} }
.toast-success { border-left-color: #4caf50; } .toast-success { border-left-color: #2d936c; }
.toast-error { border-left-color: #f44336; } .toast-error { border-left-color: #e63946; }
.toast-info { border-left-color: #2196f3; } .toast-info { border-left-color: #FFBF69; }
.toast-content { display: flex; align-items: center; gap: 10px; } .toast-content { display: flex; align-items: center; gap: 10px; }
.toast-close { background: none; border: none; color: #aaa; cursor: pointer; } .toast-close { background: none; border: none; color: #aaa; cursor: pointer; }
</style> </style>
+25 -27
View File
@@ -167,7 +167,7 @@
.filter-tabs { .filter-tabs {
display: flex; display: flex;
gap: 8px; gap: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1); border-bottom: 1px solid #2a2d32;
padding-bottom: 12px; padding-bottom: 12px;
margin-bottom: 8px; margin-bottom: 8px;
} }
@@ -188,7 +188,7 @@
} }
.filter-tab:hover { .filter-tab:hover {
background: rgba(255, 255, 255, 0.05); background: var(--bg-elevated);
color: var(--text-main); color: var(--text-main);
} }
@@ -211,14 +211,12 @@
background: var(--bg-card); background: var(--bg-card);
border-radius: var(--card-radius); border-radius: var(--card-radius);
padding: 16px; padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.05); border: 1px solid #2a2d32;
transition: var(--transition); transition: var(--transition);
} }
.watchlist-card:hover { .watchlist-card:hover {
border-color: var(--primary); border-color: var(--primary);
box-shadow: 0 4px 24px rgba(0, 217, 255, 0.15);
transform: translateY(-2px);
} }
/* Poster */ /* Poster */
@@ -254,22 +252,22 @@
} }
.poster-badge.active { .poster-badge.active {
background: rgba(0, 255, 136, 0.9); background: #2d936c;
color: var(--bg-dark); color: #fff;
} }
.poster-badge.paused { .poster-badge.paused {
background: rgba(255, 193, 7, 0.9); background: #f4a261;
color: var(--bg-dark); color: #15171A;
} }
.poster-badge.completed { .poster-badge.completed {
background: rgba(156, 39, 176, 0.9); background: #FF9F1C;
color: var(--bg-dark); color: #15171A;
} }
.poster-badge.archived { .poster-badge.archived {
background: rgba(255, 255, 255, 0.15); background: rgba(206, 208, 206, 0.2);
color: var(--text-dim); color: var(--text-dim);
} }
@@ -279,7 +277,7 @@
left: 8px; left: 8px;
padding: 4px 10px; padding: 4px 10px;
border-radius: 20px; border-radius: 20px;
background: rgba(0, 217, 255, 0.9); background: #FF9F1C;
color: var(--bg-dark); color: var(--bg-dark);
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 700; font-weight: 700;
@@ -327,21 +325,21 @@
} }
.meta-provider { .meta-provider {
background: rgba(0, 217, 255, 0.15); background: rgba(255, 191, 105, 0.1);
color: var(--primary); color: var(--primary);
border: 1px solid rgba(0, 217, 255, 0.3); border: 1px solid rgba(255, 191, 105, 0.3);
} }
.meta-lang { .meta-lang {
background: rgba(255, 107, 107, 0.15); background: rgba(206, 208, 206, 0.3);
color: var(--secondary); color: var(--text-dim);
border: 1px solid rgba(255, 107, 107, 0.3); border: 1px solid var(--text-dim);
} }
.meta-quality { .meta-quality {
background: rgba(0, 255, 136, 0.15); background: rgba(45, 147, 108, 0.1);
color: var(--accent); color: var(--success);
border: 1px solid rgba(0, 255, 136, 0.3); border: 1px solid rgba(45, 147, 108, 0.3);
} }
.watchlist-synopsis { .watchlist-synopsis {
@@ -375,7 +373,7 @@
gap: 6px; gap: 6px;
margin-top: auto; margin-top: auto;
padding-top: 8px; padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.05); border-top: 1px solid #2a2d32;
} }
.action-btn { .action-btn {
@@ -389,12 +387,12 @@
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
transition: var(--transition); transition: var(--transition);
background: rgba(255, 255, 255, 0.05); background: var(--bg-elevated);
color: var(--text-dim); color: var(--text-dim);
} }
.action-btn:hover { .action-btn:hover {
background: rgba(255, 255, 255, 0.1); background: var(--secondary);
color: var(--text-main); color: var(--text-main);
} }
@@ -415,11 +413,11 @@
} }
.btn-complete { .btn-complete {
color: #9c27b0; color: #FFBF69;
} }
.btn-complete:hover { .btn-complete:hover {
background: rgba(156, 39, 176, 0.15); background: rgba(255, 191, 105, 0.15);
} }
.btn-delete { .btn-delete {
@@ -436,7 +434,7 @@
padding: 80px 40px; padding: 80px 40px;
background: var(--bg-card); background: var(--bg-card);
border-radius: var(--card-radius); border-radius: var(--card-radius);
border: 1px dashed rgba(255, 255, 255, 0.1); border: 1px dashed #2a2d32;
} }
.watchlist-empty i { .watchlist-empty i {
+8 -8
View File
@@ -1,6 +1,6 @@
<div class="section-container"> <div class="section-container">
<div class="section-header"> <div class="section-header">
<h2>📋 Ma Watchlist</h2> <h2><i class="fa-solid fa-clipboard-list"></i> Ma Watchlist</h2>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-sm btn-primary" hx-post="/api/watchlist/check" hx-swap="none"> <button class="btn btn-sm btn-primary" hx-post="/api/watchlist/check" hx-swap="none">
<i class="fas fa-sync"></i> Vérifier épisodes <i class="fas fa-sync"></i> Vérifier épisodes
@@ -35,15 +35,15 @@
display: flex; display: flex;
gap: 15px; gap: 15px;
padding: 15px; padding: 15px;
background: rgba(255, 255, 255, 0.05); background: var(--bg-card);
border-radius: 12px; border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--secondary);
transition: transform 0.2s; transition: border-color 0.2s;
} }
.watchlist-item:hover { transform: translateY(-3px); border-color: #00d9ff; } .watchlist-item:hover { border-color: #FFBF69; }
.item-poster img { width: 80px; height: 120px; border-radius: 8px; object-fit: cover; } .item-poster img { width: 80px; height: 120px; border-radius: 4px; object-fit: cover; }
.item-info { flex: 1; display: flex; flex-direction: column; justify-content: space-between; } .item-info { flex: 1; display: flex; flex-direction: column; justify-content: space-between; }
.item-info h3 { font-size: 1rem; margin-bottom: 5px; color: #fff; } .item-info h3 { font-size: 1rem; margin-bottom: 5px; color: #F2F2F2; }
.item-meta { display: flex; gap: 8px; margin-bottom: 8px; } .item-meta { display: flex; gap: 8px; margin-bottom: 8px; }
.item-actions { display: flex; gap: 10px; margin-top: 10px; } .item-actions { display: flex; gap: 10px; margin-top: 10px; }
</style> </style>
+5 -19
View File
@@ -46,7 +46,7 @@
<!-- Player container for HTMX injections --> <!-- Player container for HTMX injections -->
<div id="player-container"></div> <div id="player-container"></div>
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.05); margin: 40px 0;"> <hr style="border: none; border-top: 1px solid #2a2d32; margin: 40px 0;">
<!-- Latest Releases Section - Anime only --> <!-- Latest Releases Section - Anime only -->
<div class="section-header"> <div class="section-header">
@@ -97,27 +97,13 @@
<!-- Series search results --> <!-- Series search results -->
<div id="seriesSearchResults" style="margin-bottom: 40px;"></div> <div id="seriesSearchResults" style="margin-bottom: 40px;"></div>
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.05); margin: 40px 0;"> <hr style="border: none; border-top: 1px solid #2a2d32; margin: 40px 0;">
<!-- Recommendations Section - Series only -->
<div class="section-header">
<h2>Recommande pour vous</h2>
<button class="btn btn-secondary btn-small"
hx-get="/api/recommendations?content_type=series&html=1"
hx-target="#seriesRecommendationsList">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Actualiser
</button>
</div>
<div id="seriesRecommendationsList" class="recommendations-carousel" style="margin-bottom: 40px;" hx-get="/api/recommendations?content_type=series&html=1" hx-trigger="load delay:600ms"></div>
<!-- Latest Releases Section - Series only --> <!-- Latest Releases Section - Series only -->
<div class="section-header" style="margin-top: 40px;"> <div class="section-header">
<h2>Dernieres sorties Series TV</h2> <h2>Dernieres sorties Series TV</h2>
<button class="btn btn-secondary btn-small" <button class="btn btn-secondary btn-small"
hx-get="/api/releases/latest?content_type=series&html=1" hx-get="/api/series/latest?html=1"
hx-target="#seriesReleasesList"> hx-target="#seriesReleasesList">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;"> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
@@ -125,7 +111,7 @@
Actualiser Actualiser
</button> </button>
</div> </div>
<div id="seriesReleasesList" class="releases-carousel" hx-get="/api/releases/latest?content_type=series&html=1" hx-trigger="load delay:700ms"></div> <div id="seriesReleasesList" class="releases-carousel" hx-get="/api/series/latest?html=1" hx-trigger="load delay:700ms"></div>
</div> </div>
<div id="tab-watchlist" class="tab-content" x-show="activeTab === 'watchlist'"> <div id="tab-watchlist" class="tab-content" x-show="activeTab === 'watchlist'">
+1 -1
View File
@@ -8,7 +8,7 @@
</head> </head>
<body> <body>
<div class="auth-container"> <div class="auth-container">
<h1 class="auth-title">🎬 Ohm Stream</h1> <h1 class="auth-title"><i class="fa-solid fa-film"></i> Ohm Stream</h1>
<div class="auth-tabs"> <div class="auth-tabs">
<div class="auth-tab active" data-tab="login">Connexion</div> <div class="auth-tab active" data-tab="login">Connexion</div>
+29 -29
View File
@@ -14,13 +14,13 @@
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); background: #15171A;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 20px; padding: 20px;
color: #fff; color: #F2F2F2;
} }
.container { .container {
@@ -36,13 +36,14 @@
.header h1 { .header h1 {
font-size: 1.5rem; font-size: 1.5rem;
margin-bottom: 10px; margin-bottom: 10px;
color: #00d9ff; color: #FFBF69;
} }
.video-info { .video-info {
background: rgba(255, 255, 255, 0.05); background: #202327;
padding: 15px 20px; padding: 15px 20px;
border-radius: 10px; border-radius: 4px;
border: 1px solid #2a2d32;
margin-bottom: 20px; margin-bottom: 20px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -57,19 +58,18 @@
} }
.video-info .filesize { .video-info .filesize {
color: #aaa; color: #8a8f98;
font-size: 0.9rem; font-size: 0.9rem;
} }
.video-wrapper { .video-wrapper {
background: #000; background: #000;
border-radius: 15px; border-radius: 4px;
overflow: hidden; overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
} }
.plyr { .plyr {
border-radius: 15px; border-radius: 4px;
} }
.controls { .controls {
@@ -82,13 +82,13 @@
.btn { .btn {
padding: 12px 24px; padding: 12px 24px;
background: rgba(255, 255, 255, 0.1); background: #202327;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid #2a2d32;
color: #fff; color: #F2F2F2;
border-radius: 8px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.9rem;
transition: all 0.3s ease; transition: all 0.2s ease;
text-decoration: none; text-decoration: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -96,28 +96,28 @@
} }
.btn:hover { .btn:hover {
background: rgba(0, 217, 255, 0.2); background: #2a2d32;
border-color: #00d9ff; border-color: #FFBF69;
transform: translateY(-2px);
} }
.btn-primary { .btn-primary {
background: linear-gradient(135deg, #00d9ff 0%, #00ff88 100%); background: #FF9F1C;
border: none; border: 1px solid #FF9F1C;
color: #000; color: #fff;
font-weight: 600; font-weight: 600;
} }
.btn-primary:hover { .btn-primary:hover {
background: linear-gradient(135deg, #00ff88 0%, #00d9ff 100%); background: #e08a15;
border-color: #e08a15;
} }
.error-message { .error-message {
background: rgba(255, 71, 87, 0.1); background: rgba(230, 57, 70, 0.1);
border: 1px solid #ff4757; border: 1px solid #e63946;
color: #ff4757; color: #e63946;
padding: 20px; padding: 20px;
border-radius: 10px; border-radius: 4px;
text-align: center; text-align: center;
margin-top: 20px; margin-top: 20px;
} }
@@ -135,7 +135,7 @@
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<h1>🎬 Ohm Stream Player</h1> <h1><i class="fa-solid fa-film"></i> Ohm Stream Player</h1>
</div> </div>
<div class="video-info"> <div class="video-info">
@@ -151,7 +151,7 @@
<div class="controls"> <div class="controls">
<a href="/web" class="btn">← Retour à l'accueil</a> <a href="/web" class="btn">← Retour à l'accueil</a>
<a href="/stream/{{ filename }}" class="btn btn-primary" download>⬇️ Télécharger</a> <a href="/stream/{{ filename }}" class="btn btn-primary" download><i class="fa-solid fa-download"></i> Télécharger</a>
</div> </div>
</div> </div>
@@ -169,8 +169,8 @@
wrapper.innerHTML = ` wrapper.innerHTML = `
<div class="error-message"> <div class="error-message">
Erreur lors de la lecture du flux vidéo.<br> Erreur lors de la lecture du flux vidéo.<br>
<a href="/video/{{ task_id }}" style="color: #00d9ff; text-decoration: underline;">Réessayer</a> ou <a href="/video/{{ task_id }}" style="color: #FF9F1C; text-decoration: underline;">Réessayer</a> ou
<a href="/stream/{{ filename }}" style="color: #00d9ff; text-decoration: underline;" download>Télécharger</a> <a href="/stream/{{ filename }}" style="color: #FF9F1C; text-decoration: underline;" download>Télécharger</a>
</div> </div>
`; `;
}); });
+26 -26
View File
@@ -10,29 +10,29 @@
<body class="watchlist-body"> <body class="watchlist-body">
<!-- Main Header --> <!-- Main Header -->
<div style="text-align: center; margin-bottom: 20px;"> <div style="text-align: center; margin-bottom: 20px;">
<h1 style="background: linear-gradient(45deg, #00d9ff, #00ff88); -webkit-background-clip: text; -webkit-text-fill-color: transparent; font-size: 32px; margin: 0;"> Ohm Stream Downloader</h1> <h1 style="color: #FF9F1C; font-size: 32px; margin: 0;"><i class="fa-solid fa-bolt"></i> Ohm Stream Downloader</h1>
<p style="color: #888; font-size: 14px; margin: 5px 0 0;">Téléchargez vos vidéos, animes et séries</p> <p style="color: #8a8f98; font-size: 14px; margin: 5px 0 0;">Téléchargez vos vidéos, animes et séries</p>
</div> </div>
<!-- User Info --> <!-- User Info -->
<div id="userInfo" style="display: none; max-width: 1200px; margin: 0 auto 15px; padding: 10px; background: rgba(0,217,255,0.1); border-radius: 8px; display: flex; justify-content: space-between; align-items: center;"> <div id="userInfo" style="display: none; max-width: 1200px; margin: 0 auto 15px; padding: 10px; background: rgba(255,191,105,0.1); border: 1px solid #FF9F1C; border-radius: 4px;">
<span style="color: #00d9ff;">👤 Connecté</span> <span style="color: #FF9F1C;"><i class="fa-solid fa-user"></i> Connecté</span>
<button class="btn-secondary btn-small" onclick="handleLogout()">🚪 Déconnexion</button> <button class="btn-secondary btn-small" onclick="handleLogout()"><i class="fa-solid fa-right-from-bracket"></i> Déconnexion</button>
</div> </div>
<!-- Tabs --> <!-- Tabs -->
<div class="tabs" style="max-width: 1200px; margin: 0 auto 20px; display: flex; gap: 5px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 10px;"> <div class="tabs" style="max-width: 1200px; margin: 0 auto 20px; display: flex; gap: 5px; border-bottom: 1px solid #2a2d32; padding-bottom: 10px;">
<button class="tab" onclick="window.location.href='/web'">🏠 Accueil</button> <button class="tab" onclick="window.location.href='/web'"><i class="fa-solid fa-house"></i> Accueil</button>
<button class="tab" onclick="window.location.href='/web#anime'">🎬 Anime</button> <button class="tab" onclick="window.location.href='/web#anime'"><i class="fa-solid fa-film"></i> Anime</button>
<button class="tab" onclick="window.location.href='/web#series'">📺 Série</button> <button class="tab" onclick="window.location.href='/web#series'"><i class="fa-solid fa-tv"></i> Série</button>
<button class="tab" onclick="window.location.href='/web#providers'">📦 Fournisseurs</button> <button class="tab" onclick="window.location.href='/web#providers'"><i class="fa-solid fa-box"></i> Fournisseurs</button>
<button class="tab active" onclick="window.location.href='/watchlist'">📋 Watchlist</button> <button class="tab active" onclick="window.location.href='/watchlist'"><i class="fa-solid fa-clipboard-list"></i> Watchlist</button>
</div> </div>
<div class="watchlist-container"> <div class="watchlist-container">
<!-- Header --> <!-- Header -->
<div class="watchlist-header"> <div class="watchlist-header">
<h1>📋 Ma Watchlist</h1> <h1><i class="fa-solid fa-clipboard-list"></i> Ma Watchlist</h1>
<p>Suivez vos animes préférés et téléchargez automatiquement les nouveaux épisodes</p> <p>Suivez vos animes préférés et téléchargez automatiquement les nouveaux épisodes</p>
<button type="button" class="btn-secondary watchlist-header-back-btn" onclick="window.location.href = '/web'"> <button type="button" class="btn-secondary watchlist-header-back-btn" onclick="window.location.href = '/web'">
← Retour à l'accueil ← Retour à l'accueil
@@ -43,21 +43,21 @@
<div class="scheduler-status" id="schedulerStatus"> <div class="scheduler-status" id="schedulerStatus">
<div class="scheduler-status-header"> <div class="scheduler-status-header">
<div> <div>
<h3> Planificateur Automatique</h3> <h3><i class="fa-solid fa-clock"></i> Planificateur Automatique</h3>
<div id="nextRunInfo" class="next-run-info">Chargement...</div> <div id="nextRunInfo" class="next-run-info">Chargement...</div>
</div> </div>
<div class="scheduler-controls"> <div class="scheduler-controls">
<button id="startSchedulerBtn" class="btn-primary btn-small watchlist-btn-small" onclick="handleStartScheduler()" style="display:none;"> <button id="startSchedulerBtn" class="btn-primary btn-small watchlist-btn-small" onclick="handleStartScheduler()" style="display:none;">
▶️ Démarrer <i class="fa-solid fa-play"></i> Démarrer
</button> </button>
<button id="stopSchedulerBtn" class="btn-secondary btn-small watchlist-btn-small" onclick="handleStopScheduler()" style="display:none;"> <button id="stopSchedulerBtn" class="btn-secondary btn-small watchlist-btn-small" onclick="handleStopScheduler()" style="display:none;">
⏸️ Arrêter <i class="fa-solid fa-pause"></i> Arrêter
</button> </button>
<button class="btn-secondary btn-small watchlist-btn-small" onclick="handleCheckAll()"> <button class="btn-secondary btn-small watchlist-btn-small" onclick="handleCheckAll()">
🔍 Vérifier tout <i class="fa-solid fa-magnifying-glass"></i> Vérifier tout
</button> </button>
<button class="btn-secondary btn-small watchlist-btn-small" onclick="handleOpenSettings()"> <button class="btn-secondary btn-small watchlist-btn-small" onclick="handleOpenSettings()">
⚙️ Paramètres <i class="fa-solid fa-gear"></i> Paramètres
</button> </button>
</div> </div>
</div> </div>
@@ -161,17 +161,17 @@
if (status.next_run) { if (status.next_run) {
const nextRun = new Date(status.next_run); const nextRun = new Date(status.next_run);
nextRunInfo.innerHTML = ` En cours<br>Prochaine vérification: ${nextRun.toLocaleString('fr-FR')}`; nextRunInfo.innerHTML = `<i class="fa-solid fa-check"></i> En cours<br>Prochaine vérification: ${nextRun.toLocaleString('fr-FR')}`;
} else { } else {
// Scheduler running but no next_run yet (just started) // Scheduler running but no next_run yet (just started)
const interval = status.settings?.check_interval_hours || 6; const interval = status.settings?.check_interval_hours || 6;
nextRunInfo.innerHTML = ` En cours<br>Vérification toutes les ${interval}h`; nextRunInfo.innerHTML = `<i class="fa-solid fa-check"></i> En cours<br>Vérification toutes les ${interval}h`;
} }
} else { } else {
// Update buttons if they exist // Update buttons if they exist
if (startBtn) startBtn.style.display = 'inline-block'; if (startBtn) startBtn.style.display = 'inline-block';
if (stopBtn) stopBtn.style.display = 'none'; if (stopBtn) stopBtn.style.display = 'none';
nextRunInfo.innerHTML = '⏸️ Arrêté'; nextRunInfo.innerHTML = '<i class="fa-solid fa-pause"></i> Arrêté';
} }
} }
@@ -198,10 +198,10 @@
try { try {
await startScheduler(); await startScheduler();
await loadSchedulerStatus(); await loadSchedulerStatus();
alert('Planificateur démarré!'); alert('Planificateur démarré !');
} catch (error) { } catch (error) {
console.error('Error starting scheduler:', error); console.error('Error starting scheduler:', error);
alert(`Erreur: ${error.message}`); alert(`Erreur : ${error.message}`);
} }
} }
@@ -212,10 +212,10 @@
try { try {
await stopScheduler(); await stopScheduler();
await loadSchedulerStatus(); await loadSchedulerStatus();
alert('Planificateur arrêté!'); alert('Planificateur arrêté !');
} catch (error) { } catch (error) {
console.error('Error stopping scheduler:', error); console.error('Error stopping scheduler:', error);
alert(`Erreur: ${error.message}`); alert(`Erreur : ${error.message}`);
} }
} }
@@ -228,7 +228,7 @@
await loadSchedulerStatus(); await loadSchedulerStatus();
} catch (error) { } catch (error) {
console.error('Error checking all:', error); console.error('Error checking all:', error);
alert(`Erreur: ${error.message}`); alert(`Erreur : ${error.message}`);
} }
} }
@@ -246,7 +246,7 @@
document.body.appendChild(modalContainer); document.body.appendChild(modalContainer);
} catch (error) { } catch (error) {
console.error('Error loading settings:', error); console.error('Error loading settings:', error);
alert(`Erreur: ${error.message}`); alert(`Erreur : ${error.message}`);
} }
} }
+28
View File
@@ -0,0 +1,28 @@
# Ohm Streaming - Automated Test Report
**Date:** 2026-04-09T15:34:39.316Z
**Duration:** 62.0s
**Base URL:** http://127.0.0.1:3000
## Summary
| Metric | Value |
|--------|-------|
| ✅ Passed | 30 |
| ❌ Failed | 0 |
| 📊 Total | 30 |
| 📊 Pass Rate | 100.0% |
## All tests passed!
## Screenshots
- ![](screenshots/01_landing_page.png)
- ![](screenshots/02_login_page.png)
- ![](screenshots/03_tab_anime.png)
- ![](screenshots/03_tab_downloads.png)
- ![](screenshots/03_tab_home.png)
- ![](screenshots/03_tab_providers.png)
- ![](screenshots/03_tab_series.png)
- ![](screenshots/03_tab_settings.png)
- ![](screenshots/03_tab_watchlist.png)
- ![](screenshots/07_mobile_home.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

+371
View File
@@ -0,0 +1,371 @@
/**
* Ohm Streaming - Automated E2E Test Suite
* Run: node tests/auto/run_tests.mjs
* Output: tests/auto/results/report.md + screenshots/
*/
import { chromium } from 'playwright';
import fs from 'fs';
import path from 'path';
const BASE = 'http://127.0.0.1:3000';
const RESULTS_DIR = path.join(import.meta.dirname, 'results');
const SCREENSHOT_DIR = path.join(RESULTS_DIR, 'screenshots');
const CREDS = { username: 'roman', password: 'roman123' };
// ── Helpers ──
const results = { passed: 0, failed: 0, errors: [], duration: 0 };
const startTime = Date.now();
function screenshot(page, name) {
const p = path.join(SCREENSHOT_DIR, `${name}.png`);
return page.screenshot({ path: p, fullPage: true }).then(() => p);
}
async function test(name, fn) {
try {
await fn();
results.passed++;
console.log(`${name}`);
} catch (err) {
results.failed++;
const msg = `${name}: ${err.message}`;
results.errors.push(msg);
console.error(`${name}: ${err.message}`);
}
}
function assert(condition, message) {
if (!condition) throw new Error(message || 'Assertion failed');
}
// ── Main ──
(async () => {
// Ensure output dirs
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
// Collect console errors
const consoleErrors = [];
page.on('console', msg => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
// Network error tracking
const networkErrors = [];
page.on('requestfailed', req => {
networkErrors.push(`${req.method()} ${req.url()}: ${req.failure()?.errorText}`);
});
console.log('\n🧪 Ohm Streaming - Automated Test Suite\n');
console.log('═══ Phase 1: API Health ═══');
// ── Phase 1: API Health Checks ──
await page.goto(`${BASE}/health`, { waitUntil: 'domcontentloaded', timeout: 10000 });
await page.waitForTimeout(1000);
await test('GET /health returns 200', async () => {
const text = await page.textContent('body');
const json = JSON.parse(text);
assert(json.status === 'healthy' || json.status === 'ok', `Unexpected status: ${json.status}`);
});
await test('GET / returns landing page', async () => {
const resp = await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded', timeout: 10000 });
assert(resp.status() === 200, `Status ${resp.status()}`);
await page.waitForTimeout(1500);
const screenshotPath = await screenshot(page, '01_landing_page');
console.log(` 📸 ${screenshotPath}`);
});
await test('GET /login returns login page', async () => {
const resp = await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 });
assert(resp.status() === 200);
await page.waitForTimeout(1500);
const screenshotPath = await screenshot(page, '02_login_page');
console.log(` 📸 ${screenshotPath}`);
});
// ── Phase 2: Authentication ──
console.log('\n═══ Phase 2: Authentication ═══');
await test('Login with valid credentials (roman/roman123)', async () => {
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 });
await page.waitForTimeout(2000);
// Use API to login (SPA approach)
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 });
await page.waitForTimeout(2000);
const token = await page.evaluate(async (creds) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(creds)
});
if (!res.ok) throw new Error(`Login failed: ${res.status} ${await res.text()}`);
return (await res.json()).access_token;
}, CREDS);
assert(token && token.length > 10, 'No valid token received');
// Inject token into localStorage
await page.evaluate((t) => {
localStorage.setItem('auth_token', t);
}, token);
console.log(` 🔑 Token received (${token.substring(0, 20)}...)`);
});
await test('GET /api/auth/me returns user info', async () => {
const user = await page.evaluate(async () => {
const token = localStorage.getItem('auth_token');
const res = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${token}` }
});
return res.json();
});
// Response may be { username, ... } or { user: { username, ... } }
const name = user.username || user.user?.username || user.id;
assert(name, `No username found in /me response: ${JSON.stringify(user).substring(0, 200)}`);
console.log(` 👤 User: ${name} (admin: ${user.is_admin || user.user?.is_admin || false})`);
});
// ── Phase 3: SPA Navigation ──
console.log('\n═══ Phase 3: SPA Navigation (/web) ═══');
const tabs = ['home', 'anime', 'series', 'providers', 'downloads', 'watchlist', 'settings'];
for (const tab of tabs) {
await test(`Navigate to tab: ${tab}`, async () => {
await page.goto(`${BASE}/web`, { waitUntil: 'domcontentloaded', timeout: 10000 });
await page.waitForTimeout(2000);
// Inject auth
await page.evaluate(() => {
// Token should already be in localStorage from login test
// but let's verify
const token = localStorage.getItem('auth_token');
if (!token) throw new Error('No auth token in localStorage');
});
// Switch tab using the app's own mechanism
await page.evaluate((tabName) => {
window.location.hash = tabName;
window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: tabName } }));
}, tab);
await page.waitForTimeout(3000);
// Check no JS errors during navigation
const currentErrors = consoleErrors.length;
// Just verify page didn't crash
const content = await page.textContent('body');
assert(content && content.length > 10, `Tab ${tab} rendered empty content`);
const screenshotPath = await screenshot(page, `03_tab_${tab}`);
console.log(` 📸 ${screenshotPath}`);
});
}
// ── Phase 4: API Endpoints ──
console.log('\n═══ Phase 4: API Endpoints ═══');
const apiTests = [
{ name: 'GET /api/settings', endpoint: '/api/settings', method: 'GET' },
{ name: 'GET /api/favorites', endpoint: '/api/favorites', method: 'GET' },
{ name: 'GET /api/watchlist', endpoint: '/api/watchlist', method: 'GET' },
{ name: 'GET /api/downloads', endpoint: '/api/downloads', method: 'GET' },
{ name: 'GET /api/watchlist/settings', endpoint: '/api/watchlist/settings', method: 'GET' },
{ name: 'GET /api/watchlist/stats/summary', endpoint: '/api/watchlist/stats/summary', method: 'GET' },
{ name: 'GET /api/providers/health', endpoint: '/api/providers/health', method: 'GET' },
{ name: 'GET /api/recommendations', endpoint: '/api/recommendations', method: 'GET' },
{ name: 'GET /api/releases/latest', endpoint: '/api/releases/latest', method: 'GET' },
{ name: 'GET /api/favorites/stats', endpoint: '/api/favorites/stats', method: 'GET' },
];
for (const apiTest of apiTests) {
await test(`${apiTest.name} returns 200`, async () => {
const result = await page.evaluate(async ({ endpoint, method }) => {
const token = localStorage.getItem('auth_token');
const res = await fetch(endpoint, {
method,
headers: { 'Authorization': `Bearer ${token}` }
});
let body = null;
try { body = await res.json(); } catch(e) { /* body stays null */ }
return { status: res.status, body };
}, apiTest);
assert(result.status === 200, `${apiTest.name} returned ${result.status}: ${JSON.stringify(result.body).substring(0, 200)}`);
// Verify it's valid JSON
assert(typeof result.body === 'object', `${apiTest.name} returned non-JSON`);
});
}
// ── Phase 5: Content Validation ──
console.log('\n═══ Phase 5: Content Validation ═══');
await test('Home tab renders content (not blank)', async () => {
await page.goto(`${BASE}/web#home`, { waitUntil: 'domcontentloaded', timeout: 10000 });
await page.waitForTimeout(3000);
const content = await page.textContent('body');
assert(content.length > 100, 'Home tab content too short - may be blank');
console.log(` 📝 Content length: ${content.length} chars`);
});
await test('Alpine.js loaded correctly', async () => {
const alpineLoaded = await page.evaluate(() => typeof window.Alpine !== 'undefined');
assert(alpineLoaded, 'Alpine.js not loaded - x-* directives are dead');
console.log(` ⚡ Alpine.js: loaded`);
});
await test('HTMX loaded correctly', async () => {
const htmxLoaded = await page.evaluate(() => typeof window.htmx !== 'undefined');
assert(htmxLoaded, 'HTMX not loaded');
console.log(` ⚡ HTMX: loaded`);
});
await test('No critical JS errors in console', async () => {
// Filter out non-critical errors (network, extensions)
const critical = consoleErrors.filter(e =>
!e.includes('favicon') &&
!e.includes('net::ERR_CONNECTION') &&
!e.includes('404') &&
!e.includes('DevTools')
);
assert(critical.length === 0, `${critical.length} critical JS errors: ${critical.slice(0, 3).join('; ')}`);
console.log(` ✨ Console clean (${consoleErrors.length} total, 0 critical)`);
});
// ── Phase 6: Search Functionality ──
console.log('\n═══ Phase 6: Search Functionality ═══');
await test('Anime search API works', async () => {
const result = await page.evaluate(async () => {
const token = localStorage.getItem('auth_token');
const res = await fetch('/api/anime/search?q=naruto&limit=3', {
headers: { 'Authorization': `Bearer ${token}` }
});
return { status: res.status, body: await res.json() };
});
// Search may return empty if providers are down, but should not error
assert(result.status === 200, `Search returned ${result.status}`);
console.log(` 🔍 Search results: ${JSON.stringify(result.body).substring(0, 100)}`);
});
// ── Phase 7: Responsive Design ──
console.log('\n═══ Phase 7: Responsive Design ═══');
await test('Mobile viewport rendering', async () => {
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15'
});
const mobilePage = await context.newPage();
// Re-auth on mobile
await mobilePage.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 });
await mobilePage.waitForTimeout(2000);
const token = await mobilePage.evaluate(async (creds) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(creds)
});
return (await res.json()).access_token;
}, CREDS);
await mobilePage.evaluate((t) => localStorage.setItem('auth_token', t), token);
await mobilePage.goto(`${BASE}/web#home`, { waitUntil: 'domcontentloaded', timeout: 10000 });
await mobilePage.waitForTimeout(3000);
const screenshotPath = await mobilePage.screenshot({ path: path.join(SCREENSHOT_DIR, '07_mobile_home.png'), fullPage: true });
console.log(` 📸 ${screenshotPath}`);
// Check for horizontal overflow
const overflow = await mobilePage.evaluate(() => {
const w = window.innerWidth;
return Array.from(document.querySelectorAll('*'))
.filter(el => el.getBoundingClientRect().width > w)
.length;
});
assert(overflow === 0, `${overflow} elements overflow horizontally on mobile`);
await context.close();
console.log(` 📱 Mobile: no horizontal overflow`);
});
// ── Phase 8: Settings API ──
console.log('\n═══ Phase 8: Settings & Providers ═══');
await test('GET /api/settings returns valid config', async () => {
const settings = await page.evaluate(async () => {
const token = localStorage.getItem('auth_token');
const res = await fetch('/api/settings', {
headers: { 'Authorization': `Bearer ${token}` }
});
return res.json();
});
assert(settings && typeof settings === 'object', 'Settings not an object');
console.log(` ⚙️ Settings keys: ${Object.keys(settings).join(', ')}`);
});
await test('GET /api/providers/health check', async () => {
const health = await page.evaluate(async () => {
const token = localStorage.getItem('auth_token');
const res = await fetch('/api/providers/health', {
headers: { 'Authorization': `Bearer ${token}` }
});
return res.json();
});
assert(health !== null, 'Provider health returned null');
const providerCount = Array.isArray(health) ? health.length : Object.keys(health).length;
console.log(` 🏥 Providers checked: ${providerCount}`);
});
await browser.close();
// ── Generate Report ──
results.duration = ((Date.now() - startTime) / 1000).toFixed(1);
consoleErrors.length = 0;
const report = `# Ohm Streaming - Automated Test Report
**Date:** ${new Date().toISOString()}
**Duration:** ${results.duration}s
**Base URL:** ${BASE}
## Summary
| Metric | Value |
|--------|-------|
| ✅ Passed | ${results.passed} |
| ❌ Failed | ${results.failed} |
| 📊 Total | ${results.passed + results.failed} |
| 📊 Pass Rate | ${((results.passed / (results.passed + results.failed)) * 100).toFixed(1)}% |
${results.errors.length > 0 ? `## Failed Tests\n\n${results.errors.map((e, i) => `${i + 1}. ${e}`).join('\n')}` : '## All tests passed!'}
## Screenshots
${fs.readdirSync(SCREENSHOT_DIR).map(f => `- ![](screenshots/${f})`).join('\n')}
`;
fs.writeFileSync(path.join(RESULTS_DIR, 'report.md'), report);
console.log('\n═══════════════════════════════════');
console.log(` Results: ${results.passed}/${results.passed + results.failed} passed (${results.duration}s)`);
console.log(` Report: ${path.join(RESULTS_DIR, 'report.md')}`);
console.log(` Screenshots: ${SCREENSHOT_DIR}`);
if (results.errors.length > 0) {
console.log(`\n Failed tests:`);
results.errors.forEach(e => console.log(` ${e}`));
}
console.log('═══════════════════════════════════\n');
process.exit(results.failed > 0 ? 1 : 0);
})();