12 Commits

Author SHA1 Message Date
root 87f245d3fc feat: flat design Sunset Glitch + download manager + settings + recommendations overhaul
CI / Test (Python 3.11) (pull_request) Has been cancelled
CI / Test (Python 3.12) (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / Type Check (pull_request) Has been cancelled
CI / Summary (pull_request) Has been cancelled
- Sunset Glitch color palette applied to all templates
- Font Awesome icons throughout UI
- Download manager with parallel queue and progress tracking
- Settings page with dynamic configuration
- Recommendations router enhanced with scoring
- Local vendor libs (Alpine.js, HTMX) for offline support
- Auto test suite with screenshots
- Series releases list component
- New download model
2026-04-11 19:30:32 +00:00
root 9e53579b36 feat: flat design Sunset Glitch palette + Font Awesome icons
CI / Test (Python 3.11) (pull_request) Has been cancelled
CI / Test (Python 3.12) (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / Type Check (pull_request) Has been cancelled
CI / Summary (pull_request) Has been cancelled
2026-04-04 07:59:46 +00:00
root 0179ddbdf4 feat: flat design avec palette Blazing Flame
CI / Test (Python 3.11) (pull_request) Has been cancelled
CI / Test (Python 3.12) (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / Type Check (pull_request) Has been cancelled
CI / Summary (pull_request) Has been cancelled
2026-04-03 15:35:39 +00:00
root 693615a7dc fix: corriger les imports cassés dans router_watchlist.py
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Summary (push) Has been cancelled
Remplace 'from main import watchlist_manager' par 'from app.watchlist import watchlist_manager'
et 'from main import auto_download_scheduler' par 'from app.auto_download_scheduler import auto_download_scheduler'.
watchlist_manager n'est pas exposé dans main.py, ce qui causait un ImportError 500
sur GET /api/watchlist.

Lié à #15
2026-04-03 06:39:34 +00:00
root 7529449f86 feat: refonte UI Material Design (#18)
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Summary (push) Has been cancelled
- Variables Material Design (primary, secondary, surface, elevation)
- Boutons avec elevation, ripple, letter-spacing
- Cards avec hover elevation et border-radius 16px
- Tabs avec indicator 3px bottom
- Inputs underline style Material
- Toasts bottom-center avec slide-up animation
- Skeleton loader et circular spinner
- Scrollbar custom stylisee
- Responsive breakpoints (mobile/tablet/desktop)
- Variables light theme pretes
- Toutes les classes existantes conservees

Closes #18
2026-04-02 22:46:54 +00:00
root 555816bf30 feat: recherche amelioree - scoring fuzzy multi-niveaux (#7)
- Algorithme de scoring: exact > starts-with > substring > all words > any word
- Scores: 1.0 > 0.95 > 0.85 > 0.7 > 0.5 > 0.3
- Tolérance aux fautes de frappe via matching partiel sur mots
- Résultats triés par pertinence décroissante
- Supporte les titres en français, anglais, romaji

Closes #7
2026-04-02 22:45:15 +00:00
root 2da2a5bb27 feat: panel admin - gestion utilisateurs (#16)
- Route /api/admin avec middleware require_admin
- Liste utilisateurs avec statut, role, dates
- Actions: activer/desactiver, promouvoir/rétrograder admin, supprimer
- Dashboard stats (utilisateurs, téléchargements)
- Template admin_panel.html avec table responsive
- Champ is_admin ajoute au modele User
- Migration automatique colonne is_admin
- Protection: impossible de modifier son propre compte

Closes #16
2026-04-02 22:44:33 +00:00
root c921aafadd feat: filtre par type pour recommandations et sorties (#14)
- Parametre content_type sur /api/recommendations et /api/releases/latest
- Section anime: filtre content_type=anime sur releases
- Section series: filtre content_type=series sur recommendations et releases
- Nettoyage emojis dans titres de section

Closes #14
2026-04-02 22:42:36 +00:00
root e5b30741fe feat: parametres - filtres contenu, categories, repertoire (#9, #10, #11, #12)
- Filtre recommandations (all/anime/series)
- Filtre dernieres sorties (all/anime/series)
- Toggle categories anime/series (min 1 active)
- Repertoire de telechargement personnalisable
- Migration automatique des nouvelles colonnes SQLite
- Template settings avec tous les nouveaux controles
- Validation cote backend (400 si les deux categories desactivees)

Closes #9, Closes #10, Closes #11, Closes #12
2026-04-02 22:41:18 +00:00
root 0af537e032 feat: watchlist fonctionnelle CRUD complete (#13)
- Template watchlist_items_list.html refait avec filtres par statut
- Cards avec poster, titre, provider, statut, episodes
- Boutons Pause/Resume/Terminer/Supprimer via HTMX
- Bouton Suivre dans resultats anime et series search
- Poster image envoye dans les donnees watchlist
- Design responsive et moderne

Closes #13
2026-04-02 22:39:32 +00:00
root 9f9df600c1 fix: boutons telechargement fonctionnels + refonte UI downloads (#17, #8)
- Route GET /api/downloads/video/{task_id} pour streamer les videos
- Route POST /api/downloads/{task_id}/retry pour relancer les failed
- Route POST /api/downloads/cancel-all pour annuler tous les actifs
- Barre de progression animee (shimmer + pulse)
- Indicateurs visuels par status (bordures colorees)
- Bouton Retry pour telechargements echoues/annules
- Actions groupees (Nettoyer termines, Tout annuler)
- Compteur de telechargements actifs
- hx-on::after-request pour refresh auto

Closes #17, Closes #8
2026-04-02 22:35:49 +00:00
root 5d264d8f3b fix: sécuriser watchlist, favorites, downloads et recommendations sans auth (#15)
- router_favorites.py: toutes les routes requièrent maintenant l'auth
  - GET utilise get_optional_user + login_prompt.html pour HTMX
  - POST/DELETE/toggle requièrent get_current_user_from_token
  - Filtrage par user_id dans toutes les requêtes favorites
- router_downloads.py: GET list et GET status protégés (401 sans token)
- router_recommendations.py: GET protégé (login_prompt HTMX, 401 JSON)
- router_sonarr.py: tous les endpoints de gestion protégés
  - Webhooks restent publics (reçus de Sonarr)
- app/favorites.py: ajout du paramètre user_id à toutes les méthodes

Closes #15
2026-04-02 22:20:29 +00:00
65 changed files with 3747 additions and 646 deletions
+37
View File
@@ -23,9 +23,46 @@ 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)
# Add new columns to existing tables if they don't exist (SQLite workaround)
_ensure_columns(engine)
def _ensure_columns(engine):
"""Add new columns to app_settings table if they don't exist"""
from sqlalchemy import inspect, text
inspector = inspect(engine)
if 'app_settings' not in inspector.get_table_names():
return
existing = {col['name'] for col in inspector.get_columns('app_settings')}
new_columns = {
'recommendations_filter': 'TEXT DEFAULT "all"',
'releases_filter': 'TEXT DEFAULT "all"',
'anime_enabled': 'BOOLEAN DEFAULT 1',
'series_enabled': 'BOOLEAN DEFAULT 1',
'download_dir': 'TEXT DEFAULT "downloads"',
}
# Add is_admin to users table if missing
if 'users' in inspector.get_table_names():
user_cols = {col['name'] for col in inspector.get_columns('users')}
if 'is_admin' not in user_cols:
with engine.connect() as conn:
conn.execute(text('ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0'))
conn.commit()
with engine.connect() as conn:
for col_name, col_def in new_columns.items():
if col_name not in existing:
conn.execute(text(f'ALTER TABLE app_settings ADD COLUMN {col_name} {col_def}'))
conn.commit()
def get_session() -> Generator[Session, None, None]: def get_session() -> Generator[Session, None, None]:
"""Dependency for getting a database session""" """Dependency for getting a database session"""
+114 -2
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:
try:
shutil.move(download_url, task.file_path) shutil.move(download_url, task.file_path)
logger.debug(f"Moved file to: {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")
+25 -2
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("|")
# Correctly identify anime_page_url (2nd to last) and episode_title (last)
if len(parts) >= 3:
# Multiple video URLs + anime_page_url + episode_title
potential_anime_url = parts[-2].strip()
potential_title = parts[-1].strip()
# Validate: anime_page_url should look like a URL
# episode_title should NOT look like a URL
if potential_title and not potential_title.startswith("http"):
anime_page_url = potential_anime_url if potential_anime_url.startswith("http") else None
episode_title = potential_title
elif len(parts) >= 5 and parts[-2].startswith("http"):
# Last part is also a URL (no episode title) - 2nd to last is anime page URL
anime_page_url = potential_anime_url
episode_title = None
else:
anime_page_url = None
episode_title = None
# Pass the full URL to fallback (it parses correctly)
video_url = url
else:
video_url = parts[0] video_url = parts[0]
anime_page_url = parts[1] if len(parts) > 1 else None anime_page_url = parts[1] if len(parts) > 1 else None
episode_title = parts[2] if len(parts) > 2 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
+147 -12
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)
# --- Poster: div.fleft > img ---
poster_image = ""
fleft = soup.find("div", class_="fleft")
if fleft:
img = fleft.find("img")
if img:
poster_image = (
img.get("data-src")
or img.get("data-original")
or img.get("src")
or ""
) )
# Extract cover image # Fallback: img.poster, then og:image
if not poster_image:
img = soup.find("img", class_="poster") img = soup.find("img", class_="poster")
poster_image = img.get("src", "") if img else "" poster_image = img.get("src", "") if img else ""
# Try to get poster from meta tag if not found
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
+32 -16
View File
@@ -27,11 +27,15 @@ class FavoritesManager:
url: str, url: str,
provider: str, provider: str,
metadata: Optional[Dict] = None, metadata: Optional[Dict] = None,
poster_url: Optional[str] = None poster_url: Optional[str] = None,
user_id: str = "default"
) -> Dict: ) -> Dict:
"""Add an anime to favorites""" """Add an anime to favorites"""
with Session(engine) as session: with Session(engine) as session:
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id) statement = select(FavoriteTable).where(
FavoriteTable.anime_id == anime_id,
FavoriteTable.user_id == user_id
)
existing = session.exec(statement).first() existing = session.exec(statement).first()
if existing: if existing:
@@ -53,17 +57,21 @@ class FavoritesManager:
url=url, url=url,
provider=provider, provider=provider,
anime_metadata=metadata or {}, anime_metadata=metadata or {},
poster_url=poster_url poster_url=poster_url,
user_id=user_id
) )
session.add(fav) session.add(fav)
session.commit() session.commit()
session.refresh(fav) session.refresh(fav)
return self._to_dict(fav) return self._to_dict(fav)
async def remove_favorite(self, anime_id: str) -> bool: async def remove_favorite(self, anime_id: str, user_id: str = "default") -> bool:
"""Remove an anime from favorites""" """Remove an anime from favorites"""
with Session(engine) as session: with Session(engine) as session:
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id) statement = select(FavoriteTable).where(
FavoriteTable.anime_id == anime_id,
FavoriteTable.user_id == user_id
)
existing = session.exec(statement).first() existing = session.exec(statement).first()
if existing: if existing:
session.delete(existing) session.delete(existing)
@@ -71,10 +79,13 @@ class FavoritesManager:
return True return True
return False return False
async def get_favorite(self, anime_id: str) -> Optional[Dict]: async def get_favorite(self, anime_id: str, user_id: str = "default") -> Optional[Dict]:
"""Get a specific favorite by ID""" """Get a specific favorite by ID"""
with Session(engine) as session: with Session(engine) as session:
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id) statement = select(FavoriteTable).where(
FavoriteTable.anime_id == anime_id,
FavoriteTable.user_id == user_id
)
existing = session.exec(statement).first() existing = session.exec(statement).first()
if existing: if existing:
return self._to_dict(existing) return self._to_dict(existing)
@@ -82,6 +93,7 @@ class FavoritesManager:
async def list_favorites( async def list_favorites(
self, self,
user_id: str = "default",
sort_by: str = "created_at", sort_by: str = "created_at",
order: str = "desc", order: str = "desc",
filter_provider: Optional[str] = None, filter_provider: Optional[str] = None,
@@ -89,7 +101,7 @@ class FavoritesManager:
) -> List[Dict]: ) -> List[Dict]:
"""List all favorites with optional sorting and filtering""" """List all favorites with optional sorting and filtering"""
with Session(engine) as session: with Session(engine) as session:
statement = select(FavoriteTable) statement = select(FavoriteTable).where(FavoriteTable.user_id == user_id)
if filter_provider: if filter_provider:
statement = statement.where(FavoriteTable.provider == filter_provider) statement = statement.where(FavoriteTable.provider == filter_provider)
@@ -123,10 +135,13 @@ class FavoritesManager:
return favorites return favorites
async def is_favorite(self, anime_id: str) -> bool: async def is_favorite(self, anime_id: str, user_id: str = "default") -> bool:
"""Check if an anime is in favorites""" """Check if an anime is in favorites"""
with Session(engine) as session: with Session(engine) as session:
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id) statement = select(FavoriteTable).where(
FavoriteTable.anime_id == anime_id,
FavoriteTable.user_id == user_id
)
return session.exec(statement).first() is not None return session.exec(statement).first() is not None
async def toggle_favorite( async def toggle_favorite(
@@ -136,21 +151,22 @@ class FavoritesManager:
url: str, url: str,
provider: str, provider: str,
metadata: Optional[Dict] = None, metadata: Optional[Dict] = None,
poster_url: Optional[str] = None poster_url: Optional[str] = None,
user_id: str = "default"
) -> Dict: ) -> Dict:
"""Toggle an anime in favorites (add if not exists, remove if exists)""" """Toggle an anime in favorites (add if not exists, remove if exists)"""
is_fav = await self.is_favorite(anime_id) is_fav = await self.is_favorite(anime_id, user_id=user_id)
if is_fav: if is_fav:
await self.remove_favorite(anime_id) await self.remove_favorite(anime_id, user_id=user_id)
return {"action": "removed", "anime_id": anime_id} return {"action": "removed", "anime_id": anime_id}
else: else:
fav = await self.add_favorite(anime_id, title, url, provider, metadata, poster_url) fav = await self.add_favorite(anime_id, title, url, provider, metadata, poster_url, user_id=user_id)
return {"action": "added", "anime_id": anime_id, "favorite": fav} return {"action": "added", "anime_id": anime_id, "favorite": fav}
async def get_stats(self) -> Dict: async def get_stats(self, user_id: str = "default") -> Dict:
"""Get statistics about favorites""" """Get statistics about favorites"""
favorites = await self.list_favorites() favorites = await self.list_favorites(user_id=user_id)
total = len(favorites) total = len(favorites)
# Count by provider # Count by provider
+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
+1
View File
@@ -12,6 +12,7 @@ class UserBase(SQLModel):
email: Optional[str] = Field(default=None, index=True) email: Optional[str] = Field(default=None, index=True)
full_name: Optional[str] = None full_name: Optional[str] = None
is_active: bool = Field(default=True) is_active: bool = Field(default=True)
is_admin: bool = Field(default=False)
class UserTable(UserBase, table=True): class UserTable(UserBase, table=True):
+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))
+36
View File
@@ -15,6 +15,26 @@ class AppSettingsBase(SQLModel):
# Store list of disabled providers as a JSON string # Store list of disabled providers as a JSON string
disabled_providers_json: str = Field(default="[]", sa_column=Column(String)) disabled_providers_json: str = Field(default="[]", sa_column=Column(String))
# #9: Filter for recommendations section ("all", "anime", "series")
recommendations_filter: str = Field(default="all", sa_column=Column(String))
# #10: Filter for latest releases section ("all", "anime", "series")
releases_filter: str = Field(default="all", sa_column=Column(String))
# #11: Enable/disable categories
anime_enabled: bool = Field(default=True)
series_enabled: bool = Field(default=True)
# #12: Custom download directory
download_dir: str = Field(default="downloads")
# #13: Content weight mode ("auto" = based on download habits, "manual" = user-defined)
content_weight_mode: str = Field(default="auto", sa_column=Column(String))
# #14: Manual content weights (used when content_weight_mode = "manual")
content_weight_anime: int = Field(default=2)
content_weight_series: int = Field(default=1)
@property @property
def disabled_providers(self) -> List[str]: def disabled_providers(self) -> List[str]:
try: try:
@@ -46,6 +66,14 @@ class AppSettings(BaseModel):
default_lang: str = "vostfr" default_lang: str = "vostfr"
theme: str = "dark" theme: str = "dark"
disabled_providers: List[str] = [] disabled_providers: List[str] = []
recommendations_filter: str = "all"
releases_filter: str = "all"
anime_enabled: bool = True
series_enabled: bool = True
download_dir: str = "downloads"
content_weight_mode: str = "auto"
content_weight_anime: int = 2
content_weight_series: int = 1
class Config: class Config:
from_attributes = True from_attributes = True
@@ -56,3 +84,11 @@ class AppSettingsUpdate(BaseModel):
default_lang: Optional[str] = None default_lang: Optional[str] = None
theme: Optional[str] = None theme: Optional[str] = None
disabled_providers: Optional[List[str]] = None disabled_providers: Optional[List[str]] = None
recommendations_filter: Optional[str] = None
releases_filter: Optional[str] = None
anime_enabled: Optional[bool] = None
series_enabled: Optional[bool] = None
download_dir: Optional[str] = None
content_weight_mode: Optional[str] = None
content_weight_anime: Optional[int] = None
content_weight_series: Optional[int] = None
+2
View File
@@ -13,6 +13,7 @@ from app.routers.router_player import router as player_router
from .router_static import router as static_router from .router_static import router as static_router
from .router_root import router as root_router from .router_root import router as root_router
from .router_settings import router as settings_router from .router_settings import router as settings_router
from .router_admin import router as admin_router
__all__ = [ __all__ = [
"auth_router", "auth_router",
@@ -26,5 +27,6 @@ __all__ = [
"static_router", "static_router",
"root_router", "root_router",
"settings_router", "settings_router",
"admin_router",
] ]
+165
View File
@@ -0,0 +1,165 @@
"""
Admin panel routes for Ohm Stream Downloader API.
"""
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.templating import Jinja2Templates
from sqlmodel import Session, select
from app.database import get_session, engine
from app.models.auth import User, UserTable
from app.routers.router_auth import get_current_user_from_token
router = APIRouter(prefix="/api/admin", tags=["admin"])
templates = Jinja2Templates(directory="templates")
async def require_admin(current_user: User = Depends(get_current_user_from_token)) -> User:
"""Dependency that requires the current user to be an admin."""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
return current_user
@router.get("/users")
async def list_users(
current_user: User = Depends(require_admin),
):
"""List all users (admin only)"""
with Session(engine) as session:
statement = select(UserTable)
users = session.exec(statement).all()
return {
"users": [
{
"id": u.id,
"username": u.username,
"email": u.email,
"full_name": u.full_name,
"is_active": u.is_active,
"is_admin": u.is_admin,
"created_at": u.created_at.isoformat() if u.created_at else None,
"last_login": u.last_login.isoformat() if u.last_login else None,
}
for u in users
],
"total": len(users),
}
@router.get("/stats")
async def get_admin_stats(
current_user: User = Depends(require_admin),
):
"""Get admin dashboard statistics"""
from app.download_manager import DownloadManager
from main import download_manager
with Session(engine) as session:
total_users = len(session.exec(select(UserTable)).all())
active_users = len(session.exec(select(UserTable).where(UserTable.is_active == True)).all())
admin_users = len(session.exec(select(UserTable).where(UserTable.is_admin == True)).all())
tasks = download_manager.get_all_tasks()
total_downloads = len(tasks)
completed_downloads = len([t for t in tasks if t.status == "completed"])
active_downloads = len([t for t in tasks if t.status in ("downloading", "pending")])
return {
"users": {
"total": total_users,
"active": active_users,
"admins": admin_users,
},
"downloads": {
"total": total_downloads,
"completed": completed_downloads,
"active": active_downloads,
},
}
@router.put("/users/{user_id}/toggle-active")
async def toggle_user_active(
user_id: str,
response: Response,
current_user: User = Depends(require_admin),
):
"""Activate or deactivate a user"""
with Session(engine) as session:
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot modify your own account")
user.is_active = not user.is_active
session.add(user)
session.commit()
status = "active" if user.is_active else "inactive"
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "User {user.username} is now {status}", "type": "success"}}}}'
return {"id": user_id, "is_active": user.is_active}
@router.put("/users/{user_id}/toggle-admin")
async def toggle_user_admin(
user_id: str,
response: Response,
current_user: User = Depends(require_admin),
):
"""Promote or demote a user to/from admin"""
with Session(engine) as session:
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot modify your own admin status")
user.is_admin = not user.is_admin
session.add(user)
session.commit()
role = "admin" if user.is_admin else "user"
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "{user.username} is now {role}", "type": "success"}}}}'
return {"id": user_id, "is_admin": user.is_admin}
@router.delete("/users/{user_id}")
async def delete_user(
user_id: str,
response: Response,
current_user: User = Depends(require_admin),
):
"""Delete a user"""
with Session(engine) as session:
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
username = user.username
session.delete(user)
session.commit()
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "User {username} deleted", "type": "info"}}}}'
return {"deleted": user_id}
@router.get("/ui")
async def get_admin_ui(
request: Request,
current_user: Optional[User] = Depends(get_current_user_from_token),
):
"""Get admin panel UI"""
if current_user is None or not current_user.is_admin:
from app.routers.router_auth import get_optional_user
return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
with Session(engine) as session:
users = session.exec(select(UserTable)).all()
return templates.TemplateResponse(
"components/admin_panel.html",
{"request": request, "users": users, "current_user": current_user},
)
+28 -15
View File
@@ -174,10 +174,28 @@ async def search_anime_unified(
if url and url not in seen_urls: if url and url not in seen_urls:
seen_urls.add(url) seen_urls.add(url)
if q.lower() in (item_dict.get("title") or "").lower(): # Fuzzy relevance scoring
title = (item_dict.get("title") or "").lower()
query_lower = q.lower()
# Exact match
if query_lower == title:
item_dict["_relevance_boost"] = 1.0 item_dict["_relevance_boost"] = 1.0
else: # Title starts with query
elif title.startswith(query_lower):
item_dict["_relevance_boost"] = 0.95
# Query is a substring of title
elif query_lower in title:
item_dict["_relevance_boost"] = 0.85
# Words from query all appear in title
elif all(word in title.split() for word in query_lower.split() if len(word) > 1):
item_dict["_relevance_boost"] = 0.7
# At least one word matches
elif any(word in title.split() for word in query_lower.split() if len(word) > 2):
item_dict["_relevance_boost"] = 0.5 item_dict["_relevance_boost"] = 0.5
else:
item_dict["_relevance_boost"] = 0.3
results[pid].append(item_dict) results[pid].append(item_dict)
# Prepare enrichment task for top 15 results per provider # Prepare enrichment task for top 15 results per provider
@@ -278,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 = []
@@ -290,15 +307,13 @@ 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"):
for idx, item in enumerate(result[:10]):
if isinstance(item, dict) and item.get("url"):
enrichment_tasks.append( enrichment_tasks.append(
enricher.enrich_metadata( downloader.get_anime_metadata(item["url"])
item.get("metadata") or {},
item.get("title") or "",
item.get("url") or "",
)
) )
enrichment_mapping.append((provider_id, idx)) enrichment_mapping.append((provider_id, idx))
else: else:
@@ -316,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:
+90 -7
View File
@@ -2,13 +2,17 @@
Download management routes for Ohm Stream Downloader API. Download management routes for Ohm Stream Downloader API.
""" """
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response import json
from typing import Optional
from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, FileResponse
from app.download_manager import DownloadManager from app.download_manager import DownloadManager
from app.models import DownloadRequest from app.models import DownloadRequest, DownloadStatus
from app.routers.router_auth import get_current_user_from_token from app.models.auth import User
from app.routers.router_auth import get_current_user_from_token, get_optional_user
router = APIRouter(prefix="/api/downloads", tags=["downloads"]) router = APIRouter(prefix="/api/downloads", tags=["downloads"])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -24,13 +28,21 @@ async def get_downloads(
request: Request, request: Request,
html: bool = Query(False), html: bool = Query(False),
download_manager: DownloadManager = Depends(get_download_manager), download_manager: DownloadManager = Depends(get_download_manager),
current_user: Optional[User] = Depends(get_optional_user),
): ):
"""Get list of all download tasks. Returns HTML for HTMX requests.""" """Get list of all download tasks. Returns HTML for HTMX requests."""
tasks = download_manager.get_all_tasks()
# Strictly check for HTMX or explicit HTML flag
is_htmx = request.headers.get("HX-Request") == "true" or request.headers.get("HX-Request") is_htmx = request.headers.get("HX-Request") == "true" or request.headers.get("HX-Request")
if current_user is None and (html or is_htmx):
return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
tasks = download_manager.get_all_tasks()
if html or is_htmx: if html or is_htmx:
print(f"[DOWNLOADS] HTML Request. Found {len(tasks)} tasks.") print(f"[DOWNLOADS] HTML Request. Found {len(tasks)} tasks.")
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -56,8 +68,12 @@ async def create_download(
async def get_download_status( async def get_download_status(
task_id: str, task_id: str,
download_manager: DownloadManager = Depends(get_download_manager), download_manager: DownloadManager = Depends(get_download_manager),
current_user: Optional[User] = Depends(get_optional_user),
): ):
"""Get status of a specific download task""" """Get status of a specific download task"""
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
task = download_manager.get_task(task_id) task = download_manager.get_task(task_id)
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
@@ -106,6 +122,73 @@ async def cancel_download(
raise HTTPException(status_code=400, detail="Failed to cancel download") raise HTTPException(status_code=400, detail="Failed to cancel download")
@router.get("/video/{task_id}")
async def stream_video(
task_id: str,
download_manager: DownloadManager = Depends(get_download_manager),
current_user: Optional[User] = Depends(get_optional_user),
):
"""Stream a completed download as video"""
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status != DownloadStatus.COMPLETED or not task.file_path:
raise HTTPException(status_code=400, detail="Download not completed")
file_path = Path(task.file_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk")
media_types = {
".mp4": "video/mp4", ".mkv": "video/x-matroska", ".avi": "video/x-msvideo",
".webm": "video/webm", ".flv": "video/x-flv",
}
media_type = media_types.get(file_path.suffix.lower(), "video/mp4")
return FileResponse(str(file_path), media_type=media_type)
@router.post("/{task_id}/retry")
async def retry_download(
task_id: str,
background_tasks: BackgroundTasks,
response: Response,
download_manager: DownloadManager = Depends(get_download_manager),
current_user: User = Depends(get_current_user_from_token),
):
"""Retry a failed or cancelled download"""
task = download_manager.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status not in ("failed", "cancelled"):
raise HTTPException(status_code=400, detail="Can only retry failed or cancelled downloads")
task.status = DownloadStatus.PENDING
task.progress = 0.0
if hasattr(download_manager, "_process_download"):
background_tasks.add_task(download_manager._process_download, task_id)
response.headers["HX-Trigger"] = json.dumps(
{"show-toast": {"message": "Telechargement relance", "type": "success"}}
)
return {"status": "retrying"}
@router.post("/cancel-all")
async def cancel_all_downloads(
response: Response,
download_manager: DownloadManager = Depends(get_download_manager),
current_user: User = Depends(get_current_user_from_token),
):
"""Cancel all active downloads"""
count = 0
for tid, task in list(download_manager.tasks.items()):
if task.status in ("downloading", "pending"):
download_manager.cancel_download(tid) if hasattr(download_manager, "cancel_download") else download_manager.tasks.pop(tid, None)
count += 1
response.headers["HX-Trigger"] = json.dumps(
{"show-toast": {"message": f"{count} telechargement(s) annule(s)", "type": "info"}}
)
return {"status": "cancelled", "count": count}
@router.post("/cleanup") @router.post("/cleanup")
async def cleanup_completed( async def cleanup_completed(
download_manager: DownloadManager = Depends(get_download_manager), download_manager: DownloadManager = Depends(get_download_manager),
+56 -12
View File
@@ -2,24 +2,42 @@
Favorites management routes for Ohm Stream Downloader API. Favorites management routes for Ohm Stream Downloader API.
""" """
from fastapi import APIRouter, HTTPException from typing import Optional
from fastapi.requests import Request from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.templating import Jinja2Templates
from app.favorites import get_favorites_manager from app.favorites import get_favorites_manager
from app.models.auth import User
from app.routers.router_auth import get_current_user_from_token, get_optional_user
router = APIRouter(prefix="/api/favorites", tags=["favorites"]) router = APIRouter(prefix="/api/favorites", tags=["favorites"])
templates = Jinja2Templates(directory="templates")
@router.get("") @router.get("")
async def list_favorites( async def list_favorites(
request: Request,
sort_by: str = "created_at", sort_by: str = "created_at",
order: str = "desc", order: str = "desc",
filter_provider: str = None, filter_provider: Optional[str] = None,
filter_genre: str = None, filter_genre: Optional[str] = None,
html: bool = Query(False),
current_user: Optional[User] = Depends(get_optional_user),
): ):
"""List all favorite anime with optional sorting and filtering""" """List all favorite anime with optional sorting and filtering"""
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")
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
favorites = await fav_manager.list_favorites( favorites = await fav_manager.list_favorites(
user_id=current_user.id,
sort_by=sort_by, sort_by=sort_by,
order=order, order=order,
filter_provider=filter_provider, filter_provider=filter_provider,
@@ -38,7 +56,11 @@ async def list_favorites(
@router.post("") @router.post("")
async def add_favorite(request: Request): async def add_favorite(
request: Request,
response: Response,
current_user: User = Depends(get_current_user_from_token),
):
"""Add an anime to favorites""" """Add an anime to favorites"""
data = await request.json() data = await request.json()
@@ -51,6 +73,7 @@ async def add_favorite(request: Request):
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
favorite = await fav_manager.add_favorite( favorite = await fav_manager.add_favorite(
user_id=current_user.id,
anime_id=data["anime_id"], anime_id=data["anime_id"],
title=data["title"], title=data["title"],
url=data["url"], url=data["url"],
@@ -59,34 +82,45 @@ async def add_favorite(request: Request):
poster_url=data.get("poster_url"), poster_url=data.get("poster_url"),
) )
response.headers["HX-Trigger"] = '{"show-toast": {"message": "' + favorite['title'] + ' ajouté aux favoris", "type": "success"}}'
return {"status": "added", "favorite": favorite} return {"status": "added", "favorite": favorite}
@router.delete("/{anime_id}") @router.delete("/{anime_id}")
async def remove_favorite(anime_id: str): async def remove_favorite(
anime_id: str,
response: Response,
current_user: User = Depends(get_current_user_from_token),
):
"""Remove an anime from favorites""" """Remove an anime from favorites"""
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
removed = await fav_manager.remove_favorite(anime_id) removed = await fav_manager.remove_favorite(anime_id, user_id=current_user.id)
if not removed: if not removed:
raise HTTPException(status_code=404, detail="Favorite not found") raise HTTPException(status_code=404, detail="Favorite not found")
response.headers["HX-Trigger"] = '{"show-toast": {"message": "Favori supprimé", "type": "info"}}'
return {"status": "removed", "anime_id": anime_id} return {"status": "removed", "anime_id": anime_id}
@router.get("/stats") @router.get("/stats")
async def get_favorites_stats(): async def get_favorites_stats(
current_user: User = Depends(get_current_user_from_token),
):
"""Get statistics about favorites""" """Get statistics about favorites"""
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
stats = await fav_manager.get_stats() stats = await fav_manager.get_stats(user_id=current_user.id)
return stats return stats
@router.get("/{anime_id}") @router.get("/{anime_id}")
async def get_favorite(anime_id: str): async def get_favorite(
anime_id: str,
current_user: User = Depends(get_current_user_from_token),
):
"""Get details of a specific favorite anime""" """Get details of a specific favorite anime"""
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
favorite = await fav_manager.get_favorite(anime_id) favorite = await fav_manager.get_favorite(anime_id, user_id=current_user.id)
if not favorite: if not favorite:
raise HTTPException(status_code=404, detail="Favorite not found") raise HTTPException(status_code=404, detail="Favorite not found")
@@ -95,7 +129,11 @@ async def get_favorite(anime_id: str):
@router.post("/toggle") @router.post("/toggle")
async def toggle_favorite(request: Request): async def toggle_favorite(
request: Request,
response: Response,
current_user: User = Depends(get_current_user_from_token),
):
"""Toggle an anime in favorites""" """Toggle an anime in favorites"""
data = await request.json() data = await request.json()
@@ -108,6 +146,7 @@ async def toggle_favorite(request: Request):
fav_manager = get_favorites_manager() fav_manager = get_favorites_manager()
result = await fav_manager.toggle_favorite( result = await fav_manager.toggle_favorite(
user_id=current_user.id,
anime_id=data["anime_id"], anime_id=data["anime_id"],
title=data["title"], title=data["title"],
url=data["url"], url=data["url"],
@@ -116,4 +155,9 @@ async def toggle_favorite(request: Request):
poster_url=data.get("poster_url"), poster_url=data.get("poster_url"),
) )
action = result.get("action", "unknown")
message = f"'{data['title']}' {'ajouté aux' if action == 'added' else 'retiré des'} favoris"
toast_type = "success" if action == "added" else "info"
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "{message}", "type": "{toast_type}"}}}}'
return result return result
+215 -17
View File
@@ -3,13 +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 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.settings import AppSettingsTable
from app.database import get_session
from app.routers.router_auth import get_optional_user, get_current_user_from_token
from app.routers.router_settings import _compute_auto_weights
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["recommendations"]) router = APIRouter(prefix="/api", tags=["recommendations"])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -21,19 +30,133 @@ 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,
limit: int = 15, limit: int = 15,
html: bool = Query(False), html: bool = Query(False),
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
current_user: Optional[User] = Depends(get_optional_user),
session: Session = Depends(get_session),
): ):
"""Get personalized anime recommendations based on download history""" """Get personalized recommendations based on user settings (anime + series)"""
engine = RecommendationEngine(download_dir="downloads") 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)
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 html or request.headers.get("HX-Request"): if series_enabled:
try:
from app.downloaders.series_sites.fs7 import FS7Downloader
downloader = FS7Downloader()
series_recs = await downloader.get_latest_series(limit=limit)
for r in series_recs:
r['content_type'] = 'series'
recommendations.extend(series_recs)
except Exception as e:
logger.warning(f"Series recommendations fetch failed: {e}")
if content_type and content_type != "all":
recommendations = [r for r in recommendations if r.get("content_type") == content_type]
else:
anime_items = [r for r in recommendations if r.get("content_type") == "anime"]
series_items = [r for r in recommendations if r.get("content_type") == "series"]
recommendations = _weighted_mix(anime_items, series_items, limit,
weight_a=anime_weight, weight_b=series_weight)
if html or is_htmx:
return templates.TemplateResponse( return templates.TemplateResponse(
"components/recommendations_list.html", "components/recommendations_list.html",
{"request": request, "recommendations": recommendations} {"request": request, "recommendations": recommendations}
@@ -41,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")
@@ -53,14 +173,53 @@ async def get_latest_releases(
request: Request, request: Request,
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"),
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
try: is_htmx = request.headers.get("HX-Request")
releases = await get_latest_releases_with_info(limit=limit)
if html or request.headers.get("HX-Request"): if current_user is None and (html or is_htmx):
return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
anime_enabled, series_enabled, anime_weight, series_weight = _get_effective_weights(session, current_user.id)
releases = []
try:
if anime_enabled:
anime_releases = await get_latest_releases_with_info(limit=limit)
for r in anime_releases:
r['content_type'] = 'anime'
releases.extend(anime_releases)
if series_enabled:
try:
from app.downloaders.series_sites.fs7 import FS7Downloader
downloader = FS7Downloader()
series_releases = await downloader.get_latest_series(limit=limit)
for r in series_releases:
r['content_type'] = 'series'
releases.extend(series_releases)
except Exception as e:
logger.warning(f"Series releases fetch failed: {e}")
if content_type and content_type != "all":
releases = [r for r in releases if r.get("content_type") == content_type]
else:
anime_items = [r for r in releases if r.get("content_type") == "anime"]
series_items = [r for r in releases if r.get("content_type") == "series"]
releases = _weighted_mix(anime_items, series_items, limit,
weight_a=anime_weight, weight_b=series_weight)
if html or is_htmx:
return templates.TemplateResponse( return templates.TemplateResponse(
"components/releases_list.html", "components/releases_list.html",
{"request": request, "releases": releases} {"request": request, "releases": releases}
@@ -72,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))
@@ -140,7 +298,9 @@ async def get_top_anime(
@router.get("/stats/downloads") @router.get("/stats/downloads")
async def get_download_statistics(): async def get_download_statistics(
current_user: User = Depends(get_current_user_from_token),
):
"""Get download statistics and preferences""" """Get download statistics and preferences"""
engine = RecommendationEngine(download_dir="downloads") engine = RecommendationEngine(download_dir="downloads")
@@ -152,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))
+124
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),
@@ -39,6 +105,14 @@ async def get_settings(
default_lang=settings_obj.default_lang, default_lang=settings_obj.default_lang,
theme=settings_obj.theme, theme=settings_obj.theme,
disabled_providers=settings_obj.disabled_providers, disabled_providers=settings_obj.disabled_providers,
recommendations_filter=getattr(settings_obj, 'recommendations_filter', 'all'),
releases_filter=getattr(settings_obj, 'releases_filter', 'all'),
anime_enabled=getattr(settings_obj, 'anime_enabled', True),
series_enabled=getattr(settings_obj, 'series_enabled', True),
download_dir=getattr(settings_obj, 'download_dir', 'downloads'),
content_weight_mode=getattr(settings_obj, 'content_weight_mode', 'auto'),
content_weight_anime=getattr(settings_obj, 'content_weight_anime', 2),
content_weight_series=getattr(settings_obj, 'content_weight_series', 1),
) )
@@ -65,6 +139,28 @@ async def update_settings(
settings_obj.theme = update_data.theme settings_obj.theme = update_data.theme
if update_data.disabled_providers is not None: if update_data.disabled_providers is not None:
settings_obj.disabled_providers = update_data.disabled_providers settings_obj.disabled_providers = update_data.disabled_providers
if update_data.recommendations_filter is not None:
settings_obj.recommendations_filter = update_data.recommendations_filter
if update_data.releases_filter is not None:
settings_obj.releases_filter = update_data.releases_filter
if update_data.anime_enabled is not None:
# Prevent disabling both categories
if not update_data.anime_enabled and not getattr(settings_obj, 'series_enabled', True):
raise HTTPException(status_code=400, detail="Au moins une categorie doit rester active")
settings_obj.anime_enabled = update_data.anime_enabled
if update_data.series_enabled is not None:
# Prevent disabling both categories
if not update_data.series_enabled and not getattr(settings_obj, 'anime_enabled', True):
raise HTTPException(status_code=400, detail="Au moins une categorie doit rester active")
settings_obj.series_enabled = update_data.series_enabled
if update_data.download_dir is not None:
settings_obj.download_dir = update_data.download_dir
if update_data.content_weight_mode is not None:
settings_obj.content_weight_mode = update_data.content_weight_mode
if update_data.content_weight_anime is not None:
settings_obj.content_weight_anime = update_data.content_weight_anime
if update_data.content_weight_series is not None:
settings_obj.content_weight_series = update_data.content_weight_series
session.add(settings_obj) session.add(settings_obj)
session.commit() session.commit()
@@ -77,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),
+26 -6
View File
@@ -68,14 +68,19 @@ async def test_sonarr_webhook(request: Request):
@router.get("/sonarr/config") @router.get("/sonarr/config")
async def get_sonarr_config(): async def get_sonarr_config(
current_user: User = Depends(get_current_user_from_token),
):
"""Get Sonarr webhook configuration""" """Get Sonarr webhook configuration"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
return sonarr_handler.get_config() return sonarr_handler.get_config()
@router.put("/sonarr/config") @router.put("/sonarr/config")
async def update_sonarr_config(config: SonarrConfig): async def update_sonarr_config(
config: SonarrConfig,
current_user: User = Depends(get_current_user_from_token),
):
"""Update Sonarr webhook configuration""" """Update Sonarr webhook configuration"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
try: try:
@@ -87,14 +92,19 @@ async def update_sonarr_config(config: SonarrConfig):
@router.get("/sonarr/mappings") @router.get("/sonarr/mappings")
async def get_sonarr_mappings(): async def get_sonarr_mappings(
current_user: User = Depends(get_current_user_from_token),
):
"""Get all Sonarr to anime mappings""" """Get all Sonarr to anime mappings"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
return sonarr_handler.get_mappings() return sonarr_handler.get_mappings()
@router.get("/sonarr/mappings/{series_id}") @router.get("/sonarr/mappings/{series_id}")
async def get_sonarr_mapping(series_id: int): async def get_sonarr_mapping(
series_id: int,
current_user: User = Depends(get_current_user_from_token),
):
"""Get specific mapping by Sonarr series ID""" """Get specific mapping by Sonarr series ID"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
mapping = sonarr_handler.get_mapping(series_id) mapping = sonarr_handler.get_mapping(series_id)
@@ -104,7 +114,10 @@ async def get_sonarr_mapping(series_id: int):
@router.post("/sonarr/mappings") @router.post("/sonarr/mappings")
async def create_sonarr_mapping(mapping: SonarrMapping): async def create_sonarr_mapping(
mapping: SonarrMapping,
current_user: User = Depends(get_current_user_from_token),
):
"""Create or update a Sonarr to anime mapping""" """Create or update a Sonarr to anime mapping"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
try: try:
@@ -116,7 +129,10 @@ async def create_sonarr_mapping(mapping: SonarrMapping):
@router.delete("/sonarr/mappings/{series_id}") @router.delete("/sonarr/mappings/{series_id}")
async def delete_sonarr_mapping(series_id: int): async def delete_sonarr_mapping(
series_id: int,
current_user: User = Depends(get_current_user_from_token),
):
"""Delete a Sonarr mapping""" """Delete a Sonarr mapping"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
success = sonarr_handler.delete_mapping(series_id) success = sonarr_handler.delete_mapping(series_id)
@@ -130,6 +146,7 @@ async def search_anime_for_sonarr(
q: str = Query(..., description="Series title to search"), q: str = Query(..., description="Series title to search"),
provider: str = Query("anime-sama", description="Anime provider to search"), provider: str = Query("anime-sama", description="Anime provider to search"),
lang: str = Query("vostfr", description="Language (vostfr, vf)"), lang: str = Query("vostfr", description="Language (vostfr, vf)"),
current_user: User = Depends(get_current_user_from_token),
): ):
"""Search for anime on providers to create Sonarr mappings""" """Search for anime on providers to create Sonarr mappings"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
@@ -152,6 +169,7 @@ async def get_anime_episodes(
url: str = Query(..., description="Anime URL from provider"), url: str = Query(..., description="Anime URL from provider"),
provider: str = Query("anime-sama", description="Anime provider"), provider: str = Query("anime-sama", description="Anime provider"),
lang: str = Query("vostfr", description="Language (vostfr, vf)"), lang: str = Query("vostfr", description="Language (vostfr, vf)"),
current_user: User = Depends(get_current_user_from_token),
): ):
"""Get episode list for anime""" """Get episode list for anime"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
@@ -174,6 +192,7 @@ async def suggest_anime_mapping(
sonarr_title: str = Query(..., description="Sonarr series title"), sonarr_title: str = Query(..., description="Sonarr series title"),
provider: str = Query("anime-sama", description="Anime provider"), provider: str = Query("anime-sama", description="Anime provider"),
lang: str = Query("vostfr", description="Language"), lang: str = Query("vostfr", description="Language"),
current_user: User = Depends(get_current_user_from_token),
): ):
"""Suggest possible anime mappings based on Sonarr series title""" """Suggest possible anime mappings based on Sonarr series title"""
sonarr_handler = get_sonarr_handler() sonarr_handler = get_sonarr_handler()
@@ -195,6 +214,7 @@ async def suggest_anime_mapping(
async def trigger_sonarr_download( async def trigger_sonarr_download(
request: SonarrDownloadRequest, request: SonarrDownloadRequest,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user_from_token),
): ):
"""Manually trigger a download based on Sonarr information""" """Manually trigger a download based on Sonarr information"""
from main import download_manager from main import download_manager
+11 -10
View File
@@ -47,7 +47,7 @@ async def add_to_watchlist(
current_user: User = Depends(get_current_user_from_token), current_user: User = Depends(get_current_user_from_token),
): ):
"""Add an anime to the watchlist""" """Add an anime to the watchlist"""
from main import watchlist_manager from app.watchlist import watchlist_manager
try: try:
existing = watchlist_manager.get_by_anime_url( existing = watchlist_manager.get_by_anime_url(
@@ -81,7 +81,7 @@ async def get_watchlist(
html: bool = Query(False), html: bool = Query(False),
current_user: Optional[User] = Depends(get_optional_user), current_user: Optional[User] = Depends(get_optional_user),
): ):
from main import watchlist_manager from app.watchlist import watchlist_manager
is_htmx = request.headers.get("HX-Request") is_htmx = request.headers.get("HX-Request")
@@ -108,7 +108,7 @@ async def get_watchlist_settings(
current_user: User = Depends(get_current_user_from_token), current_user: User = Depends(get_current_user_from_token),
): ):
"""Get global watchlist settings""" """Get global watchlist settings"""
from main import watchlist_manager from app.watchlist import watchlist_manager
return watchlist_manager.get_settings() return watchlist_manager.get_settings()
@@ -120,7 +120,8 @@ async def update_watchlist_settings(
current_user: User = Depends(get_current_user_from_token), current_user: User = Depends(get_current_user_from_token),
): ):
"""Update global watchlist settings""" """Update global watchlist settings"""
from main import auto_download_scheduler, watchlist_manager from app.auto_download_scheduler import auto_download_scheduler
from app.watchlist import watchlist_manager
try: try:
updated_settings = watchlist_manager.update_settings(settings) updated_settings = watchlist_manager.update_settings(settings)
@@ -148,7 +149,7 @@ async def get_watchlist_item(
current_user: User = Depends(get_current_user_from_token), current_user: User = Depends(get_current_user_from_token),
): ):
"""Get a specific watchlist item""" """Get a specific watchlist item"""
from main import watchlist_manager from app.watchlist import watchlist_manager
item = watchlist_manager.get_by_id(item_id) item = watchlist_manager.get_by_id(item_id)
if not item or item.user_id != current_user.id: if not item or item.user_id != current_user.id:
@@ -164,7 +165,7 @@ async def update_watchlist_item(
current_user: User = Depends(get_current_user_from_token), current_user: User = Depends(get_current_user_from_token),
): ):
"""Update a watchlist item""" """Update a watchlist item"""
from main import watchlist_manager from app.watchlist import watchlist_manager
item = watchlist_manager.get_by_id(item_id) item = watchlist_manager.get_by_id(item_id)
if not item or item.user_id != current_user.id: if not item or item.user_id != current_user.id:
@@ -190,7 +191,7 @@ async def delete_from_watchlist(
current_user: User = Depends(get_current_user_from_token), current_user: User = Depends(get_current_user_from_token),
): ):
"""Remove an anime from the watchlist""" """Remove an anime from the watchlist"""
from main import watchlist_manager from app.watchlist import watchlist_manager
item = watchlist_manager.get_by_id(item_id) item = watchlist_manager.get_by_id(item_id)
if not item or item.user_id != current_user.id: if not item or item.user_id != current_user.id:
@@ -212,14 +213,14 @@ 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,
current_user: User = Depends(get_current_user_from_token), current_user: User = Depends(get_current_user_from_token),
): ):
"""Trigger an immediate check for new episodes""" """Trigger an immediate check for new episodes"""
from main import auto_download_scheduler from app.auto_download_scheduler import auto_download_scheduler
background_tasks.add_task(auto_download_scheduler.trigger_check_now) background_tasks.add_task(auto_download_scheduler.trigger_check_now)
response.headers["HX-Trigger"] = json.dumps( response.headers["HX-Trigger"] = json.dumps(
@@ -239,6 +240,6 @@ async def get_watchlist_stats(
current_user: User = Depends(get_current_user_from_token), current_user: User = Depends(get_current_user_from_token),
): ):
"""Get watchlist statistics for the user""" """Get watchlist statistics for the user"""
from main import watchlist_manager from app.watchlist import watchlist_manager
return watchlist_manager.get_stats(current_user.id) return watchlist_manager.get_stats(current_user.id)
+6 -1
View File
@@ -95,7 +95,12 @@ 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
content = response.text.lower()
body_size = len(response.text)
# Valid pages should be reasonably large and contain expected keywords
if body_size > 10000 and ('french' in content or 'stream' in content or 'serie' in content or 'anime' in content or 'film' in content or 'telechargement' in content or 'zone' in content):
logger.info(f"Active domain found for {provider_id}: {domain} ({body_size} bytes)")
cls._cache[provider_id] = { cls._cache[provider_id] = {
'domain': domain, 'domain': domain,
'last_check': datetime.now().isoformat() 'last_check': datetime.now().isoformat()
+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 ===');
})();
+15 -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
@@ -144,6 +155,7 @@ from app.routers import (
static_router, static_router,
root_router, root_router,
settings_router, settings_router,
admin_router,
) )
@@ -159,6 +171,7 @@ app.include_router(sonarr_router)
app.include_router(player_router) app.include_router(player_router)
app.include_router(static_router) app.include_router(static_router)
app.include_router(settings_router) app.include_router(settings_router)
app.include_router(admin_router)
if __name__ == "__main__": if __name__ == "__main__":
+721 -184
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" %}
+102
View File
@@ -0,0 +1,102 @@
<div class="settings-container section-container">
<div class="section-header">
<h2>Administration</h2>
</div>
<!-- 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 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="color: var(--text-dim); font-size: 0.85rem;">Utilisateurs</div>
</div>
<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="color: var(--text-dim); font-size: 0.85rem;">Actifs</div>
</div>
<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="color: var(--text-dim); font-size: 0.85rem;">Admins</div>
</div>
</div>
<!-- Users Table -->
<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 #2a2d32;">
<h3 style="margin: 0; color: var(--primary);">Gestion des utilisateurs</h3>
</div>
{% if users %}
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<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 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;">Role</th>
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Derniere connexion</th>
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Inscription</th>
<th style="padding: 12px 20px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr style="border-bottom: 1px solid #2a2d32; {% if not user.is_active %}opacity: 0.5;{% endif %}">
<td style="padding: 12px 20px;">
<div style="font-weight: 600;">{{ user.username }}</div>
{% if user.full_name %}
<div style="font-size: 0.8rem; color: var(--text-dim);">{{ user.full_name }}</div>
{% endif %}
</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;">
<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 %}
</span>
</td>
<td style="padding: 12px 15px; text-align: center;">
<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 %}
</span>
</td>
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.85rem;">
{{ user.last_login.strftime('%d/%m/%Y %H:%M') if user.last_login else '-' }}
</td>
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.85rem;">
{{ user.created_at.strftime('%d/%m/%Y') if user.created_at else '-' }}
</td>
<td style="padding: 12px 20px; text-align: center; white-space: nowrap;">
{% if user.id != current_user.id %}
<button class="btn btn-sm {% if user.is_active %}btn-secondary{% else %}btn-accent{% endif %}"
hx-put="/api/admin/users/{{ user.id }}/toggle-active" hx-swap="none"
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
title="{% if user.is_active %}Desactiver{% else %}Activer{% endif %}">
{% if user.is_active %}Desactiver{% else %}Activer{% endif %}
</button>
<button class="btn btn-sm {% if user.is_admin %}btn-secondary{% else %}btn-accent{% endif %}"
hx-put="/api/admin/users/{{ user.id }}/toggle-admin" hx-swap="none"
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
title="{% if user.is_admin %}Retrograder{% else %}Promouvoir{% endif %}">
{% if user.is_admin %}Retrograder{% else %}Admin{% endif %}
</button>
<button class="btn btn-sm btn-danger"
hx-delete="/api/admin/users/{{ user.id }}" hx-swap="none"
hx-confirm="Supprimer {{ user.username }} ?"
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
title="Supprimer">
<i class="fas fa-trash"></i>
</button>
{% else %}
<span style="color: var(--text-dim); font-size: 0.8rem;">Vous</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="padding: 40px; text-align: center; color: var(--text-dim);">Aucun utilisateur</div>
{% endif %}
</div>
</div>
+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 %}
+18 -18
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">
@@ -92,7 +92,7 @@
</div> </div>
<button class="sr-btn sr-btn-follow" <button class="sr-btn sr-btn-follow"
hx-post="/api/watchlist" hx-post="/api/watchlist"
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}"}' hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
hx-swap="none" hx-swap="none"
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}"> hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}">
<i class="fas fa-plus"></i> Suivre <i class="fas fa-plus"></i> Suivre
@@ -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) {
+18 -8
View File
@@ -1,7 +1,7 @@
{% if tasks %} {% if tasks %}
<div class="downloads-grid"> <div class="downloads-grid">
{% for task in tasks %} {% for task in tasks %}
<div class="download-item task-{{ task.status }}"> <div class="download-item status-{{ task.status }}">
<div class="download-info"> <div class="download-info">
<span class="download-name" title="{{ task.filename }}">{{ task.filename }}</span> <span class="download-name" title="{{ task.filename }}">{{ task.filename }}</span>
<span class="badge badge-{{ task.status }}">{{ task.status | upper }}</span> <span class="badge badge-{{ task.status }}">{{ task.status | upper }}</span>
@@ -19,28 +19,38 @@
<div class="download-actions"> <div class="download-actions">
{% if task.status == 'downloading' or task.status == 'pending' %} {% if task.status == 'downloading' or task.status == 'pending' %}
<button class="btn-icon" hx-post="/api/downloads/{{ task.id }}/pause" hx-swap="none"> <button class="btn-icon" hx-post="/api/downloads/{{ task.id }}/pause" hx-swap="none"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Pause">
<i class="fas fa-pause"></i> <i class="fas fa-pause"></i>
</button> </button>
{% elif task.status == 'paused' %} {% elif task.status == 'paused' %}
<button class="btn-icon success" hx-post="/api/downloads/{{ task.id }}/resume" hx-swap="none"> <button class="btn-icon success" hx-post="/api/downloads/{{ task.id }}/resume" hx-swap="none"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Reprendre">
<i class="fas fa-play"></i> <i class="fas fa-play"></i>
</button> </button>
{% endif %} {% endif %}
{% if task.status == 'failed' or task.status == 'cancelled' %}
<button class="btn-icon warning" hx-post="/api/downloads/{{ task.id }}/retry" hx-swap="none"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Relancer">
<i class="fas fa-redo"></i>
</button>
{% endif %}
{% if task.status == 'completed' %} {% if task.status == 'completed' %}
<a href="/video/{{ task.id }}" class="btn-icon success" title="Voir la vidéo"> <a href="/api/downloads/video/{{ task.id }}" class="btn-icon success" title="Streamer">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-play-circle"></i>
</a> </a>
<a href="/downloads/{{ task.filename }}" class="btn-icon" download title="Télécharger le fichier"> <a href="/downloads/{{ task.filename }}" class="btn-icon" download title="Telecharger">
<i class="fas fa-file-download"></i> <i class="fas fa-file-download"></i>
</a> </a>
{% endif %} {% endif %}
<button class="btn-icon danger" <button class="btn-icon danger"
hx-delete="/api/downloads/{{ task.id }}" hx-delete="/api/downloads/{{ task.id }}"
hx-confirm="Supprimer ce téléchargement ?" hx-confirm="Supprimer ce telechargement ?"
hx-swap="none" hx-swap="none"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')"
title="Supprimer"> title="Supprimer">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
@@ -51,6 +61,6 @@
{% else %} {% else %}
<div class="empty-state" style="text-align: center; padding: 60px 20px; color: var(--text-dim);"> <div class="empty-state" style="text-align: center; padding: 60px 20px; color: var(--text-dim);">
<i class="fas fa-cloud-download-alt" style="font-size: 3rem; margin-bottom: 20px; opacity: 0.1; display: block;"></i> <i class="fas fa-cloud-download-alt" style="font-size: 3rem; margin-bottom: 20px; opacity: 0.1; display: block;"></i>
<p>Aucun téléchargement en cours</p> <p>Aucun telechargement en cours</p>
</div> </div>
{% endif %} {% endif %}
+20 -4
View File
@@ -1,12 +1,20 @@
<div class="section-container"> <div class="section-container">
<div class="section-header"> <div class="section-header">
<h2>📥 Téléchargements</h2> <h2>Telechargements <span id="activeDownloadsCount" class="active-downloads-counter" style="display:none;">(0 actif)</span></h2>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-sm btn-secondary" <button class="btn btn-sm btn-secondary"
hx-post="/api/downloads/cleanup" hx-post="/api/downloads/cleanup"
hx-swap="none" hx-swap="none"
hx-confirm="Nettoyer tous les telechargements termines ?"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')"> hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')">
Nettoyer terminés <i class="fas fa-broom"></i> Nettoyer termines
</button>
<button class="btn btn-sm btn-danger"
hx-post="/api/downloads/cancel-all"
hx-swap="none"
hx-confirm="Annuler tous les telechargements actifs ?"
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')">
<i class="fas fa-stop-circle"></i> Tout annuler
</button> </button>
</div> </div>
</div> </div>
@@ -17,12 +25,20 @@
hx-trigger="load, refresh, every 3s" hx-trigger="load, refresh, every 3s"
hx-swap="innerHTML"> hx-swap="innerHTML">
<div class="loading-placeholder"> <div class="loading-placeholder">
<div class="spinner"></div> Chargement des téléchargements... <div class="spinner"></div> Chargement des telechargements...
</div> </div>
</div> </div>
</div> </div>
<style> <style>
.section-container { margin-bottom: 40px; } .section-container { margin-bottom: 40px; }
/* Styles already defined or moved to downloads_list.html */ .active-downloads-counter {
font-size: 0.85rem;
font-weight: 600;
color: var(--primary);
background: rgba(241, 80, 37, 0.1);
padding: 2px 10px;
border-radius: 12px;
margin-left: 10px;
}
</style> </style>
+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 %}
+17 -17
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>
@@ -71,7 +71,7 @@
</div> </div>
<button class="sr-btn sr-btn-follow" <button class="sr-btn sr-btn-follow"
hx-post="/api/watchlist" hx-post="/api/watchlist"
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}"}' hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
hx-swap="none" hx-swap="none"
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}"> hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}">
<i class="fas fa-plus"></i> Suivre <i class="fas fa-plus"></i> Suivre
@@ -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) {
+136 -17
View File
@@ -1,23 +1,23 @@
<div class="settings-container section-container"> <div class="settings-container section-container">
<div class="section-header"> <div class="section-header">
<h2>⚙️ Paramètres</h2> <h2>Parametres</h2>
</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);">Général</h3> <h3 style="margin-bottom: 20px; color: var(--primary);">General</h3>
<form hx-patch="/api/settings" hx-swap="none" hx-ext="json-enc" class="settings-form"> <form id="settings-form" class="settings-form">
<div class="form-group"> <div class="form-group">
<label for="default_lang">Langue par défaut</label> <label for="default_lang">Langue par defaut</label>
<select name="default_lang" id="default_lang" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;"> <select name="default_lang" id="default_lang" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;">
<option value="vostfr" {% if settings.default_lang == 'vostfr' %}selected{% endif %}>VOSTFR (Version Originale Sous-Titrée Français)</option> <option value="vostfr" {% if settings.default_lang == 'vostfr' %}selected{% endif %}>VOSTFR</option>
<option value="vf" {% if settings.default_lang == 'vf' %}selected{% endif %}>VF (Version Française)</option> <option value="vf" {% if settings.default_lang == 'vf' %}selected{% endif %}>VF</option>
</select> </select>
</div> </div>
<div class="form-group" style="margin-top: 20px;"> <div class="form-group" style="margin-top: 20px;">
<label for="theme">Thème</label> <label for="theme">Theme</label>
<select name="theme" id="theme" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;"> <select name="theme" id="theme" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;">
<option value="dark" {% if settings.theme == 'dark' %}selected{% endif %}>Sombre (Premium Dark)</option> <option value="dark" {% if settings.theme == 'dark' %}selected{% endif %}>Sombre (Premium Dark)</option>
<option value="light" {% if settings.theme == 'light' %}selected{% endif %}>Clair (Soon)</option> <option value="light" {% if settings.theme == 'light' %}selected{% endif %}>Clair (Soon)</option>
@@ -25,30 +25,150 @@
</select> </select>
</div> </div>
<button type="submit" class="btn btn-primary" style="margin-top: 20px; width: 100%;"> <div class="form-group" style="margin-top: 20px;">
<i class="fas fa-save"></i> Enregistrer les préférences <label for="download_dir">Repertoire de telechargement</label>
<div style="display: flex; gap: 8px;">
<input type="text" name="download_dir" id="download_dir" value="{{ settings.download_dir }}"
class="btn btn-secondary" style="flex: 1; text-align: left; padding: 12px 15px;">
</div>
<small style="color: var(--text-dim); font-size: 12px; margin-top: 5px; display: block;">
Repertoire ou les fichiers seront telecharges (defaut: downloads/)
</small>
</div>
<button type="submit" class="btn btn-primary" style="margin-top: 20px; width: 100%;" onclick="event.preventDefault(); saveSettings();">
<i class="fas fa-save"></i> Enregistrer les preferences
</button> </button>
</form> </form>
</div> </div>
<!-- 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 #2a2d32;">
<h3 style="margin-bottom: 20px; color: var(--primary);">Filtres de contenu</h3>
<div class="form-group">
<label for="recommendations_filter">Recommande pour vous : afficher</label>
<select name="recommendations_filter" id="recommendations_filter" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;" onchange="saveFilter('recommendations_filter', this.value)">
<option value="all" {% if settings.recommendations_filter == 'all' %}selected{% endif %}>Les deux (Animes + Series)</option>
<option value="anime" {% if settings.recommendations_filter == 'anime' %}selected{% endif %}>Animes uniquement</option>
<option value="series" {% if settings.recommendations_filter == 'series' %}selected{% endif %}>Series uniquement</option>
</select>
</div>
<div class="form-group" style="margin-top: 15px;">
<label for="releases_filter">Dernieres sorties : afficher</label>
<select name="releases_filter" id="releases_filter" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;" onchange="saveFilter('releases_filter', this.value)">
<option value="all" {% if settings.releases_filter == 'all' %}selected{% endif %}>Les deux (Animes + Series)</option>
<option value="anime" {% if settings.releases_filter == 'anime' %}selected{% endif %}>Animes uniquement</option>
<option value="series" {% if settings.releases_filter == 'series' %}selected{% endif %}>Series uniquement</option>
</select>
</div>
</div>
<!-- Categories -->
<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>
<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;">
<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 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>
<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 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 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>
<input type="checkbox" id="series_enabled" {% if settings.series_enabled %}checked{% endif %} onchange="toggleCategory('series_enabled', this.checked)" style="width: 20px; height: 20px; cursor: pointer; accent-color: var(--primary);">
</label>
</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);">Disponibilité 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">
<i class="fas fa-sync-alt"></i> Forcer vérification <i class="fas fa-sync-alt"></i> Forcer verification
</button> </button>
</div> </div>
<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>
@@ -61,7 +181,7 @@
hx-swap="none" hx-swap="none"
hx-on::after-request="htmx.trigger('#tab-settings > div', 'refresh-settings')" hx-on::after-request="htmx.trigger('#tab-settings > div', 'refresh-settings')"
style="min-width: 100px;"> style="min-width: 100px;">
{% if provider.enabled %}Désactiver{% else %}Activer{% endif %} {% if provider.enabled %}Desactiver{% else %}Activer{% endif %}
</button> </button>
</div> </div>
{% endfor %} {% endfor %}
@@ -78,6 +198,5 @@
} }
.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>
+471 -21
View File
@@ -1,30 +1,141 @@
{% if items %} {% set status_filter = request.query_params.get('status', 'all') %}
<div class="watchlist-container" x-data="{ currentFilter: '{{ status_filter }}' }">
<!-- Filter Tabs -->
<div class="filter-tabs">
<button class="filter-tab {{ 'active' if status_filter == 'all' or not status_filter else '' }}"
hx-get="/api/watchlist?status=all"
hx-target="#watchlist-items-container"
hx-swap="outerHTML"
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
<i class="fas fa-list"></i> Tous
</button>
<button class="filter-tab {{ 'active' if status_filter == 'active' else '' }}"
hx-get="/api/watchlist?status=active"
hx-target="#watchlist-items-container"
hx-swap="outerHTML"
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
<i class="fas fa-play"></i> Actifs
</button>
<button class="filter-tab {{ 'active' if status_filter == 'paused' else '' }}"
hx-get="/api/watchlist?status=paused"
hx-target="#watchlist-items-container"
hx-swap="outerHTML"
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
<i class="fas fa-pause"></i> En pause
</button>
<button class="filter-tab {{ 'active' if status_filter == 'completed' else '' }}"
hx-get="/api/watchlist?status=completed"
hx-target="#watchlist-items-container"
hx-swap="outerHTML"
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
<i class="fas fa-check"></i> Terminés
</button>
</div>
<!-- Watchlist Items Grid -->
{% if items and items | length > 0 %}
<div class="watchlist-grid"> <div class="watchlist-grid">
{% for item in items %} {% for item in items %}
<div class="watchlist-item card" id="watchlist-{{ item.id }}"> <div class="watchlist-card" id="watchlist-{{ item.id }}" data-item-id="{{ item.id }}">
<div class="item-poster"> <!-- Poster -->
<img src="{{ item.poster_image or '/static/img/no-poster.png' }}" alt="{{ item.anime_title }}"> <div class="watchlist-poster">
<img src="{{ item.poster_image or item.cover_image or '/static/img/no-poster.png' }}"
alt="{{ item.anime_title }}"
onerror="this.src='/static/img/no-poster.png'">
<div class="poster-badge {{ item.status }}">
{% if item.status == 'active' %}
<i class="fas fa-play"></i> Actif
{% elif item.status == 'paused' %}
<i class="fas fa-pause"></i> En pause
{% elif item.status == 'completed' %}
<i class="fas fa-check"></i> Terminé
{% else %}
<i class="fas fa-archive"></i> Archivé
{% endif %}
</div> </div>
<div class="item-info"> {% if item.auto_download %}
<h3>{{ item.anime_title }}</h3> <div class="auto-download-badge">
<div class="item-meta"> <i class="fas fa-magic"></i> Auto
<span class="badge">{{ item.provider_id }}</span>
<span class="badge badge-{{ item.status }}">{{ item.status }}</span>
</div> </div>
<div class="item-stats"> {% endif %}
<span>Épisode: {{ item.last_episode_downloaded }}</span>
</div> </div>
<div class="item-actions">
<button class="btn btn-sm btn-primary" <!-- Content -->
hx-get="/api/anime/episodes?url={{ item.anime_url | urlencode }}" <div class="watchlist-content">
hx-target="#player-container"> <h3 class="watchlist-title">{{ item.anime_title }}</h3>
<div class="watchlist-meta">
<span class="meta-provider">
<i class="fas fa-tv"></i> {{ item.provider_id | upper }}
</span>
<span class="meta-lang">{{ item.lang | upper }}</span>
{% if item.quality_preference and item.quality_preference != 'auto' %}
<span class="meta-quality">{{ item.quality_preference }}</span>
{% endif %}
</div>
{% if item.synopsis %}
<p class="watchlist-synopsis">{{ item.synopsis | truncate(150) }}</p>
{% endif %}
<div class="watchlist-stats">
<span class="stat">
<i class="fas fa-download"></i>
Ép. {{ item.last_episode_downloaded }}
{% if item.total_episodes %}
/ {{ item.total_episodes }}
{% endif %}
</span>
{% if item.added_at %}
<span class="stat" title="Ajouté le {{ item.added_at.strftime('%d/%m/%Y') }}">
<i class="fas fa-calendar"></i>
{{ item.added_at.strftime('%d/%m/%Y') }}
</span>
{% endif %}
</div>
<!-- Actions -->
<div class="watchlist-actions">
<!-- Pause/Resume Toggle -->
{% if item.status == 'active' %}
<button class="action-btn btn-pause"
hx-put="/api/watchlist/{{ item.id }}"
hx-vals='{"status": "paused"}'
hx-swap="none"
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
title="Mettre en pause">
<i class="fas fa-pause"></i>
</button>
{% elif item.status == 'paused' %}
<button class="action-btn btn-resume"
hx-put="/api/watchlist/{{ item.id }}"
hx-vals='{"status": "active"}'
hx-swap="none"
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
title="Reprendre">
<i class="fas fa-play"></i> <i class="fas fa-play"></i>
</button> </button>
<button class="btn btn-sm btn-danger" {% endif %}
<!-- Mark as completed -->
{% if item.status not in ['completed', 'archived'] %}
<button class="action-btn btn-complete"
hx-put="/api/watchlist/{{ item.id }}"
hx-vals='{"status": "completed"}'
hx-swap="none"
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
title="Marquer comme terminé">
<i class="fas fa-check"></i>
</button>
{% endif %}
<!-- Delete -->
<button class="action-btn btn-delete"
hx-delete="/api/watchlist/{{ item.id }}" hx-delete="/api/watchlist/{{ item.id }}"
hx-target="#watchlist-{{ item.id }}" hx-target="#watchlist-{{ item.id }}"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-confirm="Retirer de la watchlist ?"> hx-confirm="Supprimer '{{ item.anime_title }}' de votre watchlist ?"
title="Supprimer">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
</div> </div>
@@ -32,8 +143,347 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<div class="empty-state"> <div class="watchlist-empty">
<p>Votre watchlist est vide.</p> <i class="fas fa-inbox"></i>
<h3>Votre watchlist est vide</h3>
<p>Ajoutez des animes ou séries depuis l'onglet Recherche pour commencer à suivre leurs sorties.</p>
<button class="btn btn-primary" @click="$dispatch('set-tab', {tab: 'anime'})">
<i class="fas fa-search"></i> Rechercher des animes
</button>
</div> </div>
{% endif %} {% endif %}
</div>
<style>
/* Container */
.watchlist-container {
display: flex;
flex-direction: column;
gap: 20px;
}
/* Filter Tabs */
.filter-tabs {
display: flex;
gap: 8px;
border-bottom: 1px solid #2a2d32;
padding-bottom: 12px;
margin-bottom: 8px;
}
.filter-tab {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
border: none;
background: transparent;
color: var(--text-dim);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
border-radius: var(--input-radius);
transition: var(--transition);
}
.filter-tab:hover {
background: var(--bg-elevated);
color: var(--text-main);
}
.filter-tab.active {
background: var(--primary);
color: var(--bg-dark);
}
/* Grid */
.watchlist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
/* Card */
.watchlist-card {
display: flex;
gap: 16px;
background: var(--bg-card);
border-radius: var(--card-radius);
padding: 16px;
border: 1px solid #2a2d32;
transition: var(--transition);
}
.watchlist-card:hover {
border-color: var(--primary);
}
/* Poster */
.watchlist-poster {
position: relative;
flex-shrink: 0;
width: 100px;
aspect-ratio: 2/3;
border-radius: 8px;
overflow: hidden;
background: var(--bg-dark);
}
.watchlist-poster img {
width: 100%;
height: 100%;
object-fit: cover;
}
.poster-badge {
position: absolute;
top: 8px;
left: 8px;
padding: 4px 10px;
border-radius: 20px;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 4px;
}
.poster-badge.active {
background: #2d936c;
color: #fff;
}
.poster-badge.paused {
background: #f4a261;
color: #15171A;
}
.poster-badge.completed {
background: #FF9F1C;
color: #15171A;
}
.poster-badge.archived {
background: rgba(206, 208, 206, 0.2);
color: var(--text-dim);
}
.auto-download-badge {
position: absolute;
bottom: 8px;
left: 8px;
padding: 4px 10px;
border-radius: 20px;
background: #FF9F1C;
color: var(--bg-dark);
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 4px;
}
/* Content */
.watchlist-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.watchlist-title {
font-size: 1rem;
font-weight: 700;
margin: 0;
color: var(--text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.watchlist-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 0.75rem;
}
.meta-provider,
.meta-lang,
.meta-quality {
padding: 3px 10px;
border-radius: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meta-provider {
background: rgba(255, 191, 105, 0.1);
color: var(--primary);
border: 1px solid rgba(255, 191, 105, 0.3);
}
.meta-lang {
background: rgba(206, 208, 206, 0.3);
color: var(--text-dim);
border: 1px solid var(--text-dim);
}
.meta-quality {
background: rgba(45, 147, 108, 0.1);
color: var(--success);
border: 1px solid rgba(45, 147, 108, 0.3);
}
.watchlist-synopsis {
font-size: 0.8rem;
color: var(--text-dim);
margin: 0;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.watchlist-stats {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 0.75rem;
color: var(--text-dim);
}
.stat {
display: flex;
align-items: center;
gap: 4px;
}
/* Actions */
.watchlist-actions {
display: flex;
gap: 6px;
margin-top: auto;
padding-top: 8px;
border-top: 1px solid #2a2d32;
}
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
transition: var(--transition);
background: var(--bg-elevated);
color: var(--text-dim);
}
.action-btn:hover {
background: var(--secondary);
color: var(--text-main);
}
.btn-pause {
color: #ffc107;
}
.btn-pause:hover {
background: rgba(255, 193, 7, 0.15);
}
.btn-resume {
color: var(--accent);
}
.btn-resume:hover {
background: rgba(0, 255, 136, 0.15);
}
.btn-complete {
color: #FFBF69;
}
.btn-complete:hover {
background: rgba(255, 191, 105, 0.15);
}
.btn-delete {
color: #f44336;
}
.btn-delete:hover {
background: rgba(244, 67, 54, 0.15);
}
/* Empty State */
.watchlist-empty {
text-align: center;
padding: 80px 40px;
background: var(--bg-card);
border-radius: var(--card-radius);
border: 1px dashed #2a2d32;
}
.watchlist-empty i {
font-size: 4rem;
color: var(--text-dim);
opacity: 0.3;
margin-bottom: 20px;
display: block;
}
.watchlist-empty h3 {
font-size: 1.3rem;
margin-bottom: 8px;
color: var(--text-main);
}
.watchlist-empty p {
color: var(--text-dim);
margin-bottom: 24px;
}
/* Responsive */
@media (max-width: 768px) {
.watchlist-grid {
grid-template-columns: 1fr;
}
.filter-tabs {
overflow-x: auto;
flex-wrap: nowrap;
}
.filter-tab {
white-space: nowrap;
}
.watchlist-card {
flex-direction: column;
align-items: center;
text-align: center;
}
.watchlist-poster {
width: 140px;
}
.watchlist-meta,
.watchlist-stats {
justify-content: center;
}
}
</style>
+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>
+22 -34
View File
@@ -12,7 +12,7 @@
<div id="tab-anime" class="tab-content" x-show="activeTab === 'anime'"> <div id="tab-anime" class="tab-content" x-show="activeTab === 'anime'">
<!-- Anime Search Section --> <!-- Anime Search Section -->
<div class="section-header"> <div class="section-header">
<h2>🎬 Rechercher un Anime</h2> <h2>Rechercher un Anime</h2>
</div> </div>
<div class="url-form"> <div class="url-form">
<form hx-get="/api/anime/search" <form hx-get="/api/anime/search"
@@ -38,9 +38,6 @@
<div id="search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--primary);"> <div id="search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--primary);">
<div class="spinner"></div> Recherche en cours... <div class="spinner"></div> Recherche en cours...
</div> </div>
<div style="margin-top: 15px; padding: 12px; background: rgba(0, 217, 255, 0.05); border: 1px solid rgba(0, 217, 255, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);">
💡 <strong>Astuce :</strong> La recherche unifiée explore plusieurs sources pour trouver vos animes préférés.
</div>
</div> </div>
<!-- Anime search results --> <!-- Anime search results -->
@@ -49,13 +46,13 @@
<!-- 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 --> <!-- Latest Releases Section - Anime only -->
<div class="section-header"> <div class="section-header">
<h2>🔥 Dernières sorties Anime</h2> <h2>Dernieres sorties Anime</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?content_type=anime&html=1"
hx-target="#animeReleasesList"> hx-target="#animeReleasesList">
<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>
@@ -63,13 +60,13 @@
Actualiser Actualiser
</button> </button>
</div> </div>
<div id="animeReleasesList" class="recommendations-carousel" hx-get="/api/releases/latest" hx-trigger="load delay:500ms"></div> <div id="animeReleasesList" class="recommendations-carousel" hx-get="/api/releases/latest?content_type=anime&html=1" hx-trigger="load delay:500ms"></div>
</div> </div>
<div id="tab-series" class="tab-content" x-show="activeTab === 'series'"> <div id="tab-series" class="tab-content" x-show="activeTab === 'series'">
<!-- Series Search Section --> <!-- Series Search Section -->
<div class="section-header"> <div class="section-header">
<h2>📺 Rechercher une Série TV</h2> <h2>Rechercher une Serie TV</h2>
</div> </div>
<div class="url-form"> <div class="url-form">
<form hx-get="/api/series/search" <form hx-get="/api/series/search"
@@ -82,7 +79,7 @@
type="text" type="text"
name="q" name="q"
id="seriesSearchInput" id="seriesSearchInput"
placeholder="Rechercher une série (ex: Breaking Bad, Game of Thrones...)" placeholder="Rechercher une serie (ex: Breaking Bad, Game of Thrones...)"
required required
> >
<button type="submit" class="btn btn-primary btn-search"> <button type="submit" class="btn btn-primary btn-search">
@@ -95,35 +92,18 @@
<div id="series-search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--secondary);"> <div id="series-search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--secondary);">
<div class="spinner"></div> Recherche en cours... <div class="spinner"></div> Recherche en cours...
</div> </div>
<div style="margin-top: 15px; padding: 12px; background: rgba(255, 107, 107, 0.05); border: 1px solid rgba(255, 107, 107, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);">
💡 <strong>Info:</strong> La recherche utilise FS7 pour trouver des séries TV américaines et européennes.
</div>
</div> </div>
<!-- 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 --> <!-- Latest Releases Section - Series only -->
<div class="section-header"> <div class="section-header">
<h2>🎯 Recommandé pour vous</h2> <h2>Dernieres sorties Series TV</h2>
<button class="btn btn-secondary btn-small" <button class="btn btn-secondary btn-small"
hx-get="/api/recommendations" hx-get="/api/series/latest?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" hx-trigger="load delay:600ms"></div>
<!-- Latest Releases Section -->
<div class="section-header" style="margin-top: 40px;">
<h2>🔥 Dernières sorties Séries TV</h2>
<button class="btn btn-secondary btn-small"
hx-get="/api/releases/latest"
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>
@@ -131,7 +111,7 @@
Actualiser Actualiser
</button> </button>
</div> </div>
<div id="seriesReleasesList" class="releases-carousel" hx-get="/api/releases/latest" 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'">
@@ -145,7 +125,15 @@
<div id="tab-settings" class="tab-content" x-show="activeTab === 'settings'"> <div id="tab-settings" class="tab-content" x-show="activeTab === 'settings'">
<div hx-get="/api/settings/ui" hx-trigger="set-tab[detail.tab === 'settings'] from:window, refresh-settings" hx-swap="innerHTML"> <div hx-get="/api/settings/ui" hx-trigger="set-tab[detail.tab === 'settings'] from:window, refresh-settings" hx-swap="innerHTML">
<div class="loading-placeholder"> <div class="loading-placeholder">
<div class="spinner"></div> Chargement des paramètres... <div class="spinner"></div> Chargement des parametres...
</div>
</div>
</div>
<div id="tab-admin" class="tab-content" x-show="activeTab === 'admin'">
<div id="admin-panel-content" hx-get="/api/admin/ui" hx-trigger="set-tab[detail.tab === 'admin'] from:window" hx-swap="innerHTML">
<div class="loading-placeholder">
<div class="spinner"></div> Chargement du panel admin...
</div> </div>
</div> </div>
</div> </div>
+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);
})();