feat: refonte flat design #20

Open
Roman wants to merge 3 commits from feat/flat-design into main
57 changed files with 2202 additions and 830 deletions
+1
View File
@@ -23,6 +23,7 @@ def create_db_and_tables():
from app.models.favorites import FavoriteTable
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
from app.models.settings import AppSettingsTable
from app.models.download import DownloadTaskTable
SQLModel.metadata.create_all(engine)
+116 -4
View File
@@ -2,13 +2,16 @@ import asyncio
import os
import uuid
import logging
import asyncio
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
import httpx
from app.models import DownloadTask, DownloadStatus, DownloadRequest
from app.models.download import DownloadTaskTable
from app.database import engine
from sqlmodel import Session, select
from app.downloaders import get_downloader
from app.utils import sanitize_filename
logger = logging.getLogger(__name__)
@@ -24,6 +27,92 @@ class DownloadManager:
self.active_downloads: Dict[str, asyncio.Task] = {}
self._semaphore = asyncio.Semaphore(max_parallel)
# ==================== DB Persistence ====================
def _save_task_to_db(self, task: DownloadTask) -> None:
"""Persist a download task to the database (upsert)."""
try:
with Session(engine) as session:
existing = session.get(DownloadTaskTable, task.id)
if existing:
existing.url = task.url
existing.filename = task.filename
existing.host = task.host.value if hasattr(task.host, 'value') else str(task.host)
existing.status = task.status.value if hasattr(task.status, 'value') else str(task.status)
existing.progress = task.progress
existing.downloaded_bytes = task.downloaded_bytes
existing.total_bytes = task.total_bytes
existing.speed = task.speed
existing.error = task.error
existing.started_at = task.started_at
existing.completed_at = task.completed_at
existing.file_path = task.file_path
session.add(existing)
session.commit()
else:
db_task = DownloadTaskTable(
id=task.id,
url=task.url,
filename=task.filename,
host=task.host.value if hasattr(task.host, 'value') else str(task.host),
status=task.status.value if hasattr(task.status, 'value') else str(task.status),
progress=task.progress,
downloaded_bytes=task.downloaded_bytes,
total_bytes=task.total_bytes,
speed=task.speed,
error=task.error,
created_at=task.created_at,
started_at=task.started_at,
completed_at=task.completed_at,
file_path=task.file_path,
)
session.add(db_task)
session.commit()
except Exception as e:
logger.error(f"Failed to save task {task.id} to DB: {e}", exc_info=True)
def _delete_task_from_db(self, task_id: str) -> None:
"""Remove a download task from the database."""
try:
with Session(engine) as session:
db_task = session.get(DownloadTaskTable, task_id)
if db_task:
session.delete(db_task)
session.commit()
except Exception as e:
logger.error(f"Failed to delete task {task_id} from DB: {e}", exc_info=True)
def _load_tasks_from_db(self) -> None:
"""Load persisted download tasks from the database into memory."""
try:
with Session(engine) as session:
statement = select(DownloadTaskTable)
db_tasks = session.exec(statement).all()
for db_task in db_tasks:
if db_task.id not in self.tasks:
task = DownloadTask(
id=db_task.id,
url=db_task.url,
filename=db_task.filename,
host="other",
status=DownloadStatus(db_task.status),
progress=db_task.progress,
downloaded_bytes=db_task.downloaded_bytes,
total_bytes=db_task.total_bytes,
speed=db_task.speed,
error=db_task.error,
created_at=db_task.created_at,
started_at=db_task.started_at,
completed_at=db_task.completed_at,
file_path=db_task.file_path,
)
self.tasks[task.id] = task
logger.info(f"Loaded {len(db_tasks)} download tasks from database")
except Exception as e:
logger.error(f"Failed to load tasks from DB: {e}", exc_info=True)
# ==================== Task Management ====================
def get_task(self, task_id: str) -> Optional[DownloadTask]:
return self.tasks.get(task_id)
@@ -60,6 +149,8 @@ class DownloadManager:
created_at=datetime.now()
)
self.tasks[task_id] = task
# Persist to database
self._save_task_to_db(task)
return task
async def start_download(self, task_id: str):
@@ -82,6 +173,7 @@ class DownloadManager:
task = self.tasks.get(task_id)
if task and task.status == DownloadStatus.DOWNLOADING:
task.status = DownloadStatus.PAUSED
self._save_task_to_db(task)
if task_id in self.active_downloads:
self.active_downloads[task_id].cancel()
del self.active_downloads[task_id]
@@ -90,6 +182,7 @@ class DownloadManager:
task = self.tasks.get(task_id)
if task:
task.status = DownloadStatus.CANCELLED
self._save_task_to_db(task)
if task_id in self.active_downloads:
self.active_downloads[task_id].cancel()
del self.active_downloads[task_id]
@@ -112,14 +205,16 @@ class DownloadManager:
if task.file_path and os.path.exists(task.file_path):
os.remove(task.file_path)
# Remove from tasks dict
# Remove from tasks dict and database
del self.tasks[task_id]
self._delete_task_from_db(task_id)
async def _download(self, task: DownloadTask):
async with self._semaphore:
try:
task.status = DownloadStatus.DOWNLOADING
task.started_at = datetime.now()
self._save_task_to_db(task)
# Get downloader and extract link
downloader = get_downloader(task.url)
@@ -150,6 +245,9 @@ class DownloadManager:
else:
logger.debug(f"Task filename kept as: {task.filename}")
# Sanitize filename to prevent path traversal and invalid characters
task.filename = sanitize_filename(task.filename)
task.file_path = str(self.download_dir / task.filename)
# Check if URL is HLS/m3u8 - use ffmpeg to download
@@ -157,6 +255,7 @@ class DownloadManager:
logger.info(f"Detected HLS stream, using ffmpeg to download: {task.filename}")
success = await self._download_hls(download_url, task)
if success:
self._save_task_to_db(task)
return
# If ffmpeg fails, fall through to regular download attempt
logger.warning("ffmpeg download failed, trying regular download")
@@ -167,8 +266,12 @@ class DownloadManager:
# Move file to expected location if different
import shutil
if download_url != task.file_path:
shutil.move(download_url, task.file_path)
logger.debug(f"Moved file to: {task.file_path}")
try:
shutil.move(download_url, task.file_path)
logger.debug(f"Moved file to: {task.file_path}")
except shutil.Error:
# Same file, no move needed
pass
# Mark as complete
file_size = os.path.getsize(task.file_path)
@@ -178,6 +281,7 @@ class DownloadManager:
task.downloaded_bytes = file_size
task.total_bytes = file_size
task.completed_at = datetime.now()
self._save_task_to_db(task)
return
# Check if file already exists and is complete (for VidMoly which downloads directly)
@@ -190,6 +294,7 @@ class DownloadManager:
task.downloaded_bytes = file_size
task.total_bytes = file_size
task.completed_at = datetime.now()
self._save_task_to_db(task)
return
# Check for partial download (resume)
@@ -241,6 +346,7 @@ class DownloadManager:
except Exception as e:
task.status = DownloadStatus.FAILED
task.error = str(e)
self._save_task_to_db(task)
finally:
if task.id in self.active_downloads:
del self.active_downloads[task.id]
@@ -269,9 +375,11 @@ class DownloadManager:
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
if task.status == DownloadStatus.CANCELLED:
self._save_task_to_db(task)
return
if task.status == DownloadStatus.PAUSED:
self._save_task_to_db(task)
return
f.write(chunk)
@@ -295,6 +403,9 @@ class DownloadManager:
final_size = os.path.getsize(task.file_path) if os.path.exists(task.file_path) else 0
logger.info(f" ✅ Completed: {task.filename} ({final_size / (1024*1024):.2f} MB)")
# Persist to database
self._save_task_to_db(task)
async def _download_hls(self, m3u8_url: str, task: DownloadTask) -> bool:
"""Download HLS/m3u8 stream using ffmpeg"""
import subprocess
@@ -386,6 +497,7 @@ class DownloadManager:
task.downloaded_bytes = file_size
task.total_bytes = file_size
task.completed_at = datetime.now()
self._save_task_to_db(task)
return True
else:
logger.error(f"HLS download failed: file not created")
+27 -4
View File
@@ -144,12 +144,34 @@ class AnimeSamaDownloader(BaseAnimeSite):
logger.info(f"Direct video URL detected: {url[:60]}... -> {filename}")
return url, filename
# Check if URL contains the anime page context (format: video_url|anime_page_url|episode_title?)
# Check if URL contains the anime page context (format: video_url|...|anime_page_url|episode_title)
# The LAST two parts are always anime_page_url and episode_title.
# Everything before them is video URLs (multiple sources for fallback).
if "|" in url:
parts = url.split("|")
video_url = parts[0]
anime_page_url = parts[1] if len(parts) > 1 else None
episode_title = parts[2] if len(parts) > 2 else None
# Correctly identify anime_page_url (2nd to last) and episode_title (last)
if len(parts) >= 3:
# Multiple video URLs + anime_page_url + episode_title
potential_anime_url = parts[-2].strip()
potential_title = parts[-1].strip()
# Validate: anime_page_url should look like a URL
# episode_title should NOT look like a URL
if potential_title and not potential_title.startswith("http"):
anime_page_url = potential_anime_url if potential_anime_url.startswith("http") else None
episode_title = potential_title
elif len(parts) >= 5 and parts[-2].startswith("http"):
# Last part is also a URL (no episode title) - 2nd to last is anime page URL
anime_page_url = potential_anime_url
episode_title = None
else:
anime_page_url = None
episode_title = None
# Pass the full URL to fallback (it parses correctly)
video_url = url
else:
video_url = parts[0]
anime_page_url = parts[1] if len(parts) > 1 else None
episode_title = None
logger.debug(
f"Split URL - video: {video_url[:60]}..., anime: {anime_page_url}, episode: {episode_title}"
@@ -160,6 +182,7 @@ class AnimeSamaDownloader(BaseAnimeSite):
video_url,
anime_page_url=anime_page_url,
episode_title=episode_title,
target_filename=target_filename,
)
# Check if this is a third-party host URL
+149 -14
View File
@@ -23,7 +23,7 @@ class FS7Downloader(BaseSeriesSite):
self.id = "fs7"
self.provider_id = "fs7"
self.default_domain = "fs7.lol"
self.test_tlds = ["lol", "com", "net", "org", "tv", "ws", "cc", "co"]
self.test_tlds = ["lol", "one", "site", "vip", "fun", "stream", "com", "net", "org", "tv", "ws", "cc", "co"]
self.base_url = f"https://{self.default_domain}"
self._domain_checked = False
self.client.headers.update(
@@ -234,35 +234,93 @@ class FS7Downloader(BaseSeriesSite):
# Clean up title: remove "affiche" suffix
title = re.sub(r"\s+affiche$", "", title, flags=re.IGNORECASE).strip()
# Extract description/synopsis
description_elem = soup.find("div", class_="full-text")
description = (
description_elem.get_text(strip=True) if description_elem else ""
)
# --- Synopsis: div.fdesc > p ---
description = ""
fdesc = soup.find("div", class_="fdesc")
if fdesc:
p = fdesc.find("p")
if p:
description = p.get_text(strip=True)
else:
description = fdesc.get_text(strip=True)
# Extract cover image
img = soup.find("img", class_="poster")
poster_image = img.get("src", "") if img else ""
# --- Poster: div.fleft > img ---
poster_image = ""
fleft = soup.find("div", class_="fleft")
if fleft:
img = fleft.find("img")
if img:
poster_image = (
img.get("data-src")
or img.get("data-original")
or img.get("src")
or ""
)
# Try to get poster from meta tag if not found
# Fallback: img.poster, then og:image
if not poster_image:
img = soup.find("img", class_="poster")
poster_image = img.get("src", "") if img else ""
if not poster_image:
meta_img = soup.find("meta", property="og:image")
poster_image = meta_img.get("content", "") if meta_img else ""
# Extract year
year_match = re.search(r"\b(19|20)\d{2}\b", description)
release_year = int(year_match.group()) if year_match else None
# --- Year: span.release ---
release_year = None
release_span = soup.find("span", class_="release")
if release_span:
year_match = re.search(r"\b(19|20)\d{2}\b", release_span.get_text())
if year_match:
release_year = int(year_match.group())
# --- Genres: span.genres ---
genres = []
genres_span = soup.find("span", class_="genres")
if genres_span:
genres = [
g.strip()
for g in genres_span.get_text().split(",")
if g.strip()
]
# --- Runtime: span.runtime ---
runtime = None
runtime_span = soup.find("span", class_="runtime")
if runtime_span:
runtime = runtime_span.get_text(strip=True)
# --- Casting info from second div.flist ---
original_title = ""
director = ""
cast = []
flists = soup.find_all("div", class_="flist")
for fl in flists:
text = fl.get_text(strip=True)
if "Titre Original" in text:
m = re.search(r"Titre Original\s*:\s*(.+?)(?:Réalisateur|$)", text)
if m:
original_title = m.group(1).strip()
m2 = re.search(r"Réalisateur\s*:\s*(.+?)(?:Avec\s*:|$)", text)
if m2:
director = m2.group(1).strip()
m3 = re.search(r"Avec\s*:\s*(.+?)(?:plus|$)", text)
if m3:
cast = [c.strip() for c in m3.group(1).split(",") if c.strip()]
return {
"title": title,
"synopsis": description,
"poster_image": poster_image,
"release_year": release_year,
"genres": [],
"genres": genres,
"rating": None,
"studio": None,
"total_episodes": None,
"status": None,
"original_title": original_title,
"director": director,
"cast": cast,
"runtime": runtime,
}
except Exception as e:
@@ -301,3 +359,80 @@ class FS7Downloader(BaseSeriesSite):
return await player.get_download_link(url, target_filename)
else:
raise ValueError(f"No video player found for URL: {url}")
async def get_latest_series(self, limit: int = 20) -> List[Dict[str, Any]]:
"""
Scrape the 'Nouveautés Séries' section from FS7 homepage.
Returns:
List of dicts with title, url, cover_image, synopsis, lang, provider_id.
"""
await self._ensure_base_url()
try:
resp = await self.client.get(self.base_url + "/", timeout=15)
soup = BeautifulSoup(resp.text, "html.parser")
except Exception as e:
logger.error(f"Failed to fetch FS7 homepage: {e}")
return []
results = []
# Find the 'Nouveautés Séries' section
for section in soup.find_all("div", class_="pages"):
title_el = section.find("div", class_="sect-t")
if not title_el:
continue
title = title_el.get_text(strip=True)
if "Nouveautés" not in title or "Séries" not in title:
continue
for item in section.find_all("div", class_="short"):
# Get the poster link (contains real URL)
poster_a = item.find("a", class_="short-poster", href=True)
if not poster_a:
continue
url = poster_a["href"]
if url.startswith("/"):
url = self.base_url + url
# Title from alt attribute
title_attr = poster_a.get("alt", "").strip()
if not title_attr:
continue
# Poster image
img = poster_a.find("img")
cover_image = img.get("src", "") if img else ""
# Synopsis from hidden span
desc_span = item.find("span", id=re.compile(r"^desc-\d+"))
synopsis = desc_span.get_text(strip=True) if desc_span else ""
# Language (VF/VOSTFR)
lang = "vf"
version_span = item.find("span", class_="film-version")
if version_span:
version_text = version_span.get_text(strip=True).upper()
if "VOSTFR" in version_text:
lang = "vostfr"
elif "VF" in version_text:
lang = "vf"
results.append({
"title": title_attr,
"url": url,
"cover_image": cover_image,
"synopsis": synopsis,
"lang": lang,
"provider_id": self.provider_id,
"content_type": "series",
})
if len(results) >= limit:
break
break # Only process the first matching section
logger.info(f"FS7 latest series: found {len(results)} items")
return results
+1
View File
@@ -70,3 +70,4 @@ from .watchlist import WatchlistItemTable, WatchlistSettingsTable
from .favorites import FavoriteTable
from .sonarr import SonarrMappingTable, SonarrConfigTable
from .settings import AppSettingsTable
from .download import DownloadTaskTable
+40
View File
@@ -0,0 +1,40 @@
"""Models for download task persistence with SQLModel support"""
import uuid
from typing import Optional
from datetime import datetime
from sqlmodel import SQLModel, Field, Column, String
from enum import Enum
class DownloadStatus(str, Enum):
PENDING = "pending"
DOWNLOADING = "downloading"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class DownloadTaskTable(SQLModel, table=True):
"""Database table for persisting download tasks across server restarts."""
__tablename__ = "download_tasks"
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
primary_key=True,
index=True,
nullable=False,
)
url: str = Field(default="", sa_column=Column(String))
filename: str = Field(sa_column=Column(String))
host: str = Field(default="other", sa_column=Column(String))
status: str = Field(default="pending", sa_column=Column(String))
progress: float = Field(default=0.0)
downloaded_bytes: int = Field(default=0)
total_bytes: Optional[int] = Field(default=None)
speed: float = Field(default=0.0)
error: Optional[str] = Field(default=None, sa_column=Column(String))
created_at: datetime = Field(default_factory=datetime.now)
started_at: Optional[datetime] = Field(default=None)
completed_at: Optional[datetime] = Field(default=None)
file_path: Optional[str] = Field(default=None, sa_column=Column(String))
+13
View File
@@ -27,6 +27,13 @@ class AppSettingsBase(SQLModel):
# #12: Custom download directory
download_dir: str = Field(default="downloads")
# #13: Content weight mode ("auto" = based on download habits, "manual" = user-defined)
content_weight_mode: str = Field(default="auto", sa_column=Column(String))
# #14: Manual content weights (used when content_weight_mode = "manual")
content_weight_anime: int = Field(default=2)
content_weight_series: int = Field(default=1)
@property
def disabled_providers(self) -> List[str]:
@@ -64,6 +71,9 @@ class AppSettings(BaseModel):
anime_enabled: bool = True
series_enabled: bool = True
download_dir: str = "downloads"
content_weight_mode: str = "auto"
content_weight_anime: int = 2
content_weight_series: int = 1
class Config:
from_attributes = True
@@ -79,3 +89,6 @@ class AppSettingsUpdate(BaseModel):
anime_enabled: Optional[bool] = None
series_enabled: Optional[bool] = None
download_dir: Optional[str] = None
content_weight_mode: Optional[str] = None
content_weight_anime: Optional[int] = None
content_weight_series: Optional[int] = None
+10 -15
View File
@@ -296,8 +296,7 @@ async def search_series_unified(
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
# Enrich results with metadata (synopsis, rating, genres)
enricher = await get_metadata_enricher()
# Enrich results with metadata from scrapers (not Kitsu — Kitsu is anime-only)
enrichment_tasks = []
enrichment_mapping = []
@@ -308,17 +307,15 @@ async def search_series_unified(
elif result:
results[provider_id] = result
print(f"[SERIES SEARCH] {provider_id}: Found {len(result)} results")
# Prepare enrichment for top 15 results
for idx, item in enumerate(result[:15]):
if isinstance(item, dict):
enrichment_tasks.append(
enricher.enrich_metadata(
item.get("metadata") or {},
item.get("title") or "",
item.get("url") or "",
# Enrich top 10 results with metadata from the scraper itself
downloader = series_downloaders.get(provider_id)
if downloader and hasattr(downloader, "get_anime_metadata"):
for idx, item in enumerate(result[:10]):
if isinstance(item, dict) and item.get("url"):
enrichment_tasks.append(
downloader.get_anime_metadata(item["url"])
)
)
enrichment_mapping.append((provider_id, idx))
enrichment_mapping.append((provider_id, idx))
else:
print(f"[SERIES SEARCH] {provider_id}: No results returned")
@@ -334,9 +331,7 @@ async def search_series_unified(
and provider_id in results
and pos < len(results[provider_id])
):
results[provider_id][pos]["metadata"] = (
meta.model_dump() if hasattr(meta, "model_dump") else meta
)
results[provider_id][pos]["metadata"] = meta
# Truncate synopses at sentence boundaries
for pid in results:
+194 -21
View File
@@ -3,15 +3,22 @@ Recommendations and releases routes for Ohm Stream Downloader API.
"""
import hashlib
import logging
from datetime import datetime
from typing import Optional
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, Request, Query, HTTPException, Depends
from fastapi.templating import Jinja2Templates
from sqlmodel import Session, select
from app.recommendation_engine import RecommendationEngine
from app.models.auth import User
from app.models.settings import AppSettingsTable
from app.database import get_session
from app.routers.router_auth import get_optional_user, get_current_user_from_token
from app.routers.router_settings import _compute_auto_weights
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["recommendations"])
templates = Jinja2Templates(directory="templates")
@@ -23,6 +30,79 @@ def hash_filter(s):
templates.env.filters["hash"] = hash_filter
def _get_effective_weights(session: Session, user_id: str) -> tuple:
"""Return (anime_enabled, series_enabled, anime_weight, series_weight)."""
settings = session.exec(
select(AppSettingsTable).where(AppSettingsTable.user_id == user_id)
).first()
if settings is None:
return True, True, 1, 1
anime_enabled = getattr(settings, 'anime_enabled', True)
series_enabled = getattr(settings, 'series_enabled', True)
mode = getattr(settings, 'content_weight_mode', 'auto')
download_dir = getattr(settings, 'download_dir', 'downloads')
if mode == "auto":
weights = _compute_auto_weights(download_dir)
return anime_enabled, series_enabled, weights["anime_weight"], weights["series_weight"]
else:
aw = getattr(settings, 'content_weight_anime', 2)
sw = getattr(settings, 'content_weight_series', 1)
return anime_enabled, series_enabled, int(aw), int(sw)
def _weighted_mix(items_a: List[Dict], items_b: List[Dict], limit: int,
weight_a: int = 2, weight_b: int = 1) -> List[Dict]:
"""Mix two lists using weights. Distributes items proportionally and interleaves.
If weight_a=2, weight_b=1 and limit=15:
- slots_a ≈ 10, slots_b ≈ 5
- B items are spaced evenly across the list
If one list is shorter, the other fills remaining slots.
"""
total_weight = weight_a + weight_b
if total_weight == 0:
return (items_a + items_b)[:limit]
slots_a = round(limit * weight_a / total_weight)
slots_b = limit - slots_a
pick_a = min(slots_a, len(items_a))
pick_b = min(slots_b, len(items_b))
# Redistribute unfilled slots
if pick_a < slots_a:
pick_b = min(pick_b + (slots_a - pick_a), len(items_b))
elif pick_b < slots_b:
pick_a = min(pick_a + (slots_b - pick_b), len(items_a))
a = items_a[:pick_a]
b = items_b[:pick_b]
total = pick_a + pick_b
if total == 0:
return []
if pick_b == 0:
return a[:limit]
if pick_a == 0:
return b[:limit]
# Place B items at evenly spaced positions, fill gaps with A
result = [None] * total
for i, item in enumerate(b):
pos = round(i * (total - 1) / max(pick_b - 1, 1))
result[pos] = item
a_idx = 0
for i in range(total):
if result[i] is None:
result[i] = a[a_idx]
a_idx += 1
return result[:limit]
@router.get("/recommendations")
async def get_recommendations(
request: Request,
@@ -30,8 +110,9 @@ async def get_recommendations(
html: bool = Query(False),
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
current_user: Optional[User] = Depends(get_optional_user),
session: Session = Depends(get_session),
):
"""Get personalized anime recommendations based on download history"""
"""Get personalized recommendations based on user settings (anime + series)"""
is_htmx = request.headers.get("HX-Request")
if current_user is None and (html or is_htmx):
@@ -42,14 +123,38 @@ async def get_recommendations(
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
engine = RecommendationEngine(download_dir="downloads")
anime_enabled, series_enabled, anime_weight, series_weight = _get_effective_weights(session, current_user.id)
recommendations = []
try:
recommendations = await engine.get_personalized_recommendations(limit=limit)
# Filter by content_type if specified
if anime_enabled:
engine = RecommendationEngine(download_dir="downloads")
try:
anime_recs = await engine.get_personalized_recommendations(limit=limit)
for r in anime_recs:
r['content_type'] = 'anime'
recommendations.extend(anime_recs)
finally:
await engine.close()
if series_enabled:
try:
from app.downloaders.series_sites.fs7 import FS7Downloader
downloader = FS7Downloader()
series_recs = await downloader.get_latest_series(limit=limit)
for r in series_recs:
r['content_type'] = 'series'
recommendations.extend(series_recs)
except Exception as e:
logger.warning(f"Series recommendations fetch failed: {e}")
if content_type and content_type != "all":
recommendations = [r for r in recommendations if r.get("content_type", r.get("type", "")) == content_type]
recommendations = [r for r in recommendations if r.get("content_type") == content_type]
else:
anime_items = [r for r in recommendations if r.get("content_type") == "anime"]
series_items = [r for r in recommendations if r.get("content_type") == "series"]
recommendations = _weighted_mix(anime_items, series_items, limit,
weight_a=anime_weight, weight_b=series_weight)
if html or is_htmx:
return templates.TemplateResponse(
@@ -59,11 +164,8 @@ async def get_recommendations(
return {"recommendations": recommendations, "count": len(recommendations)}
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Recommendations error: {e}", exc_info=True)
logger.error(f"Recommendations error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
finally:
await engine.close()
@router.get("/releases/latest")
@@ -72,18 +174,52 @@ async def get_latest_releases(
limit: int = 20,
html: bool = Query(False),
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
current_user: Optional[User] = Depends(get_optional_user),
session: Session = Depends(get_session),
):
"""Get latest anime releases"""
"""Get latest releases based on user settings (anime + series)"""
from app.recommendations import get_latest_releases_with_info
try:
releases = await get_latest_releases_with_info(limit=limit)
# Filter by content_type if specified
if content_type and content_type != "all":
releases = [r for r in releases if r.get("content_type", r.get("type", "")) == content_type]
is_htmx = request.headers.get("HX-Request")
if html or request.headers.get("HX-Request"):
if current_user is None and (html or is_htmx):
return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
anime_enabled, series_enabled, anime_weight, series_weight = _get_effective_weights(session, current_user.id)
releases = []
try:
if anime_enabled:
anime_releases = await get_latest_releases_with_info(limit=limit)
for r in anime_releases:
r['content_type'] = 'anime'
releases.extend(anime_releases)
if series_enabled:
try:
from app.downloaders.series_sites.fs7 import FS7Downloader
downloader = FS7Downloader()
series_releases = await downloader.get_latest_series(limit=limit)
for r in series_releases:
r['content_type'] = 'series'
releases.extend(series_releases)
except Exception as e:
logger.warning(f"Series releases fetch failed: {e}")
if content_type and content_type != "all":
releases = [r for r in releases if r.get("content_type") == content_type]
else:
anime_items = [r for r in releases if r.get("content_type") == "anime"]
series_items = [r for r in releases if r.get("content_type") == "series"]
releases = _weighted_mix(anime_items, series_items, limit,
weight_a=anime_weight, weight_b=series_weight)
if html or is_htmx:
return templates.TemplateResponse(
"components/releases_list.html",
{"request": request, "releases": releases}
@@ -95,8 +231,7 @@ async def get_latest_releases(
"updated": datetime.now().isoformat(),
}
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Latest releases error: {e}", exc_info=True)
logger.error(f"Latest releases error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@@ -177,3 +312,41 @@ async def get_download_statistics(
raise HTTPException(status_code=500, detail=str(e))
finally:
await engine.close()
@router.get("/series/latest")
async def get_latest_series(
request: Request,
limit: int = 20,
html: bool = Query(False),
current_user: Optional[User] = Depends(get_optional_user),
):
"""Get latest TV series releases from FS7 homepage"""
if current_user is None and (html or request.headers.get("HX-Request")):
return templates.TemplateResponse(
"components/login_prompt.html", {"request": request}
)
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
try:
from app.downloaders.series_sites.fs7 import FS7Downloader
downloader = FS7Downloader()
series = await downloader.get_latest_series(limit=limit)
if html or request.headers.get("HX-Request"):
return templates.TemplateResponse(
"components/series_releases_list.html",
{"request": request, "releases": series}
)
return {
"releases": series,
"count": len(series),
"updated": datetime.now().isoformat(),
}
except Exception as e:
logger.error(f"Latest series error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
+103
View File
@@ -1,6 +1,8 @@
"""Application settings routes for Ohm Stream Downloader API"""
import json
import logging
from pathlib import Path
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.templating import Jinja2Templates
@@ -13,10 +15,74 @@ from app.routers.router_auth import get_current_user_from_token, get_optional_us
from app.providers import get_anime_providers, get_series_providers
from app.providers_manager import providers_manager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/settings", tags=["settings"])
templates = Jinja2Templates(directory="templates")
def _compute_auto_weights(download_dir: str) -> Dict[str, Any]:
"""Analyze downloaded files to compute anime vs series ratio.
Uses filename conventions:
- Series: contains "Saison" or "Season" keywords
- Anime: everything else in the downloads folder
Returns dict with counts and computed weights.
"""
base = Path(download_dir)
if not base.exists():
return {"anime_count": 0, "series_count": 0, "anime_weight": 1, "series_weight": 1, "total": 0}
anime_count = 0
series_count = 0
for f in base.rglob("*"):
if not f.is_file():
continue
if f.suffix.lower() not in (".mp4", ".mkv", ".avi", ".wmv", ".flv", ".mov"):
continue
name = f.stem.lower()
# Heuristic: series TV files often have "Saison" or "Season" + number
# Anime files rarely use this format (they use "Episode" or "S01E01")
import re
if re.search(r'(?:saison|season)\s*\d+', name):
series_count += 1
else:
anime_count += 1
total = anime_count + series_count
if total == 0:
return {"anime_count": 0, "series_count": 0, "anime_weight": 1, "series_weight": 1, "total": 0}
# Compute weights: proportional to download count, minimum 1
if anime_count == 0:
aw, sw = 0, 1
elif series_count == 0:
aw, sw = 1, 0
else:
# Keep weights small (max 5) for reasonable interleaving
ratio = anime_count / series_count
if ratio >= 4:
aw, sw = 4, 1
elif ratio >= 2:
aw, sw = 2, 1
elif ratio >= 1:
aw, sw = 1, 1
elif ratio >= 0.5:
aw, sw = 1, 2
else:
aw, sw = 1, 4
return {
"anime_count": anime_count,
"series_count": series_count,
"anime_weight": aw,
"series_weight": sw,
"total": total,
}
@router.get("", response_model=AppSettings)
async def get_settings(
current_user: User = Depends(get_current_user_from_token),
@@ -44,6 +110,9 @@ async def get_settings(
anime_enabled=getattr(settings_obj, 'anime_enabled', True),
series_enabled=getattr(settings_obj, 'series_enabled', True),
download_dir=getattr(settings_obj, 'download_dir', 'downloads'),
content_weight_mode=getattr(settings_obj, 'content_weight_mode', 'auto'),
content_weight_anime=getattr(settings_obj, 'content_weight_anime', 2),
content_weight_series=getattr(settings_obj, 'content_weight_series', 1),
)
@@ -86,6 +155,12 @@ async def update_settings(
settings_obj.series_enabled = update_data.series_enabled
if update_data.download_dir is not None:
settings_obj.download_dir = update_data.download_dir
if update_data.content_weight_mode is not None:
settings_obj.content_weight_mode = update_data.content_weight_mode
if update_data.content_weight_anime is not None:
settings_obj.content_weight_anime = update_data.content_weight_anime
if update_data.content_weight_series is not None:
settings_obj.content_weight_series = update_data.content_weight_series
session.add(settings_obj)
session.commit()
@@ -98,6 +173,34 @@ async def update_settings(
return settings_obj
@router.get("/content-weight")
async def get_content_weight(
current_user: User = Depends(get_current_user_from_token),
session: Session = Depends(get_session),
):
"""Get current effective content weights (auto-computed or manual)"""
statement = select(AppSettingsTable).where(
AppSettingsTable.user_id == current_user.id
)
settings_obj = session.exec(statement).first()
download_dir = getattr(settings_obj, 'download_dir', 'downloads') if settings_obj else 'downloads'
mode = getattr(settings_obj, 'content_weight_mode', 'auto') if settings_obj else 'auto'
if mode == "auto":
weights = _compute_auto_weights(download_dir)
weights["mode"] = "auto"
return weights
else:
return {
"mode": "manual",
"anime_weight": getattr(settings_obj, 'content_weight_anime', 2),
"series_weight": getattr(settings_obj, 'content_weight_series', 1),
"anime_count": None,
"series_count": None,
"total": None,
}
@router.get("/providers/availability")
async def get_providers_availability(
current_user: User = Depends(get_current_user_from_token),
+1 -1
View File
@@ -213,7 +213,7 @@ async def delete_from_watchlist(
raise HTTPException(status_code=500, detail="Failed to delete item")
@router.post("/check", response_model=List)
@router.post("/check")
async def check_watchlist_now(
background_tasks: BackgroundTasks,
response: Response,
+12 -7
View File
@@ -95,13 +95,18 @@ class DomainManager:
response = await client.get(url)
if response.status_code == 200:
logger.info(f"Active domain found for {provider_id}: {domain}")
cls._cache[provider_id] = {
'domain': domain,
'last_check': datetime.now().isoformat()
}
cls._save_cache()
return domain
# Verify it's actually the right site, not a parking/placeholder page
content = response.text.lower()
body_size = len(response.text)
# Valid pages should be reasonably large and contain expected keywords
if body_size > 10000 and ('french' in content or 'stream' in content or 'serie' in content or 'anime' in content or 'film' in content or 'telechargement' in content or 'zone' in content):
logger.info(f"Active domain found for {provider_id}: {domain} ({body_size} bytes)")
cls._cache[provider_id] = {
'domain': domain,
'last_check': datetime.now().isoformat()
}
cls._save_cache()
return domain
except Exception as e:
logger.debug(f"Domain test failed for {domain}: {e}")
continue
+11 -1
View File
@@ -216,8 +216,12 @@ class WatchlistManager:
update_check_time = update_last_checked
def get_due_items(self) -> List[WatchlistItem]:
"""Get all items that are due for a check based on current settings"""
return self.get_due_for_check(self.settings.check_interval_hours if self.settings else 6)
def get_due_for_check(self, interval_hours: int) -> List[WatchlistItem]:
"""Get all items that are due for a check based on settings"""
interval = timedelta(hours=self.settings.check_interval_hours)
interval = timedelta(hours=interval_hours)
now = datetime.now()
with Session(engine) as session:
@@ -234,6 +238,12 @@ class WatchlistManager:
return due_items
def get_settings(self) -> WatchlistSettings:
"""Get global watchlist settings"""
if self.settings is None:
self._load_settings()
return self.settings
def update_settings(self, settings: WatchlistSettings) -> WatchlistSettings:
"""Update global watchlist settings"""
self.settings = settings
+174
View File
@@ -0,0 +1,174 @@
import { chromium } from 'playwright';
const BASE = 'http://127.0.0.1:3000';
const opts = { waitUntil: 'domcontentloaded', timeout: 15000 };
(async () => {
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
// Obtenir un token via API
const apiCtx = await browser.newContext();
const apiPage = await apiCtx.newPage();
await apiPage.goto(BASE + '/api/auth/login', opts);
const token = await apiPage.evaluate(async () => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'roman', password: 'roman123' })
});
const data = await res.json();
return data.access_token || null;
});
await apiCtx.close();
console.log(`Token obtained: ${token ? token.substring(0, 20) + '...' : 'FAILED'}`);
if (!token) {
console.error('Cannot get token, aborting');
process.exit(1);
}
// ========== NON AUTHENTIFIE ==========
console.log('\n=== NON AUTHENTIFIE ===');
const anonCtx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const anon = await anonCtx.newPage();
const snap = async (p, name, url, wait = 3000) => {
try {
await p.goto(url, opts);
await p.waitForTimeout(wait);
await p.screenshot({ path: `/tmp/screenshots/${name}.png`, fullPage: false });
console.log(`OK: ${name}`);
} catch(e) {
console.log(`FAIL: ${name} - ${e.message}`);
}
};
await snap(anon, 'anon_01_home', `${BASE}/`);
await snap(anon, 'anon_02_watchlist', `${BASE}/watchlist`);
await snap(anon, 'anon_03_favorites', `${BASE}/favorites`);
await snap(anon, 'anon_04_downloads', `${BASE}/downloads`);
await snap(anon, 'anon_05_settings', `${BASE}/settings`);
await snap(anon, 'anon_06_recommendations', `${BASE}/recommendations`);
// ========== AUTHENTIFIE (cookie + localStorage) ==========
console.log('\n=== AUTHENTIFIE ===');
const authCtx = await browser.newContext({
viewport: { width: 1440, height: 900 },
});
// Injecter le token comme cookie AVANT toute navigation
await authCtx.addCookies([{
name: 'auth_token',
value: token,
domain: '127.0.0.1',
path: '/',
sameSite: 'Strict',
httpOnly: false,
}]);
const auth = await authCtx.newPage();
// Injecter dans localStorage au premier chargement
await auth.goto(BASE + '/', opts);
await auth.evaluate((t) => {
localStorage.setItem('auth_token', t);
}, token);
await auth.waitForTimeout(3000);
await auth.screenshot({ path: '/tmp/screenshots/auth_01_home.png', fullPage: false });
console.log('OK: auth_01_home');
await snap(auth, 'auth_02_watchlist', `${BASE}/watchlist`);
await snap(auth, 'auth_03_favorites', `${BASE}/favorites`);
await snap(auth, 'auth_04_downloads', `${BASE}/downloads`);
await snap(auth, 'auth_05_settings', `${BASE}/settings`);
await snap(auth, 'auth_06_recommendations', `${BASE}/recommendations`);
// ========== TESTS FONCTIONNELS ==========
console.log('\n=== TESTS FONCTIONNELS ===');
// Test API: toggle favori
const favResult = await auth.evaluate(async (t) => {
try {
const res = await fetch('/api/favorites/toggle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${t}`
},
body: JSON.stringify({ content_type: 'anime', anime_id: 'test-screenshot-1', title: 'Test Screenshot Anime' })
});
const data = await res.json();
return { status: res.status, is_favorite: data.is_favorite };
} catch(e) {
return { error: e.message };
}
}, token);
console.log(`Favorite toggle: ${JSON.stringify(favResult)}`);
// Voir les favoris
await snap(auth, 'auth_07_favorites_after_add', `${BASE}/favorites`);
// Test API: ajouter watchlist item
const wlResult = await auth.evaluate(async (t) => {
try {
const res = await fetch('/api/watchlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${t}`
},
body: JSON.stringify({
anime_title: 'Test Screenshot Anime',
anime_url: 'https://example.com/anime/1',
episode_count: 12,
current_episode: 0,
status: 'watching'
})
});
const data = await res.json();
return { status: res.status, id: data.id, title: data.anime_title };
} catch(e) {
return { error: e.message };
}
}, token);
console.log(`Watchlist add: ${JSON.stringify(wlResult)}`);
// Voir la watchlist
await snap(auth, 'auth_08_watchlist_with_item', `${BASE}/watchlist`);
// Scroller sur la home
await auth.goto(`${BASE}/`, opts);
await auth.waitForTimeout(2000);
await auth.evaluate(() => window.scrollTo(0, 600));
await auth.waitForTimeout(1000);
await auth.screenshot({ path: '/tmp/screenshots/auth_09_home_scrolled.png', fullPage: false });
console.log('OK: auth_09_home_scrolled');
// ========== NETTOYAGE ==========
console.log('\n=== Nettoyage ===');
// Retirer le favori de test
await auth.evaluate(async (t) => {
await fetch('/api/favorites/toggle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${t}`
},
body: JSON.stringify({ content_type: 'anime', anime_id: 'test-screenshot-1', title: 'Test Screenshot Anime' })
});
});
// Retirer le watchlist item de test
if (wlResult.id) {
await auth.evaluate(async ({t, id}) => {
await fetch(`/api/watchlist/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${t}` }
});
}, { t: token, id: wlResult.id });
console.log('Test watchlist item deleted');
}
console.log('Test favorite removed');
await browser.close();
console.log('\n=== ALL DONE ===');
})();
+13 -2
View File
@@ -86,12 +86,17 @@ async def startup_event():
def restore_completed_downloads():
"""Scan downloads directory and restore completed download tasks"""
"""Restore download tasks: first from the database, then scan for untracked files."""
# Step 1: Load persisted tasks from database
download_manager._load_tasks_from_db()
# Step 2: Scan downloads directory for files not yet tracked in the database
download_dir = Path("downloads")
if not download_dir.exists():
return
video_extensions = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm"}
tracked_filenames = {t.filename for t in download_manager.tasks.values()}
for file_path in download_dir.iterdir():
if file_path.is_file() and file_path.suffix.lower() in video_extensions:
@@ -99,6 +104,11 @@ def restore_completed_downloads():
continue
filename = file_path.name
# Skip if already tracked in DB
if filename in tracked_filenames:
continue
file_size = file_path.stat().st_size
task_id = str(uuid.uuid4())
@@ -118,7 +128,8 @@ def restore_completed_downloads():
)
download_manager.tasks[task_id] = task
logger.info(f"Restored completed download: {filename}")
download_manager._save_task_to_db(task)
logger.info(f"Restored untracked completed download: {filename}")
# Restore completed downloads on startup
+361 -369
View File
File diff suppressed because it is too large Load Diff
+24 -24
View File
@@ -82,7 +82,7 @@ async function searchAnimeDetails(query, malId = null) {
if (hasResults) {
streamingParts.unshift(
`<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 class="search-results" style="margin-top: 20px;">`
);
@@ -110,7 +110,7 @@ async function searchAnimeDetails(query, malId = null) {
if (streamingHtml) {
resultsContainer.innerHTML = `
<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;">
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End")
</p>
@@ -125,7 +125,7 @@ async function searchAnimeDetails(query, malId = null) {
} else {
resultsContainer.innerHTML = `
<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;">
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End", "One Piece")
</p>
@@ -138,7 +138,7 @@ async function searchAnimeDetails(query, malId = null) {
console.error('Error searching anime details:', error);
resultsContainer.innerHTML = `
<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>
</div>
`;
@@ -177,7 +177,7 @@ async function getProviderSearchResults(query) {
if (hasResults) {
htmlParts.unshift(
`<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 class="search-results" style="margin-top: 20px;">`
);
@@ -249,16 +249,16 @@ function renderAnimeDetails(anime) {
` : ''}
<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>` : ''}
${popularity > 0 ? `<div class="anime-details-popularity">Popularity #${popularity}</div>` : ''}
</div>
<div class="anime-details-stats">
${anime.episodes ? `<span>📺 ${anime.episodes} épisodes</span>` : ''}
${anime.status ? `<span>📡 ${translateStatus(anime.status)}</span>` : ''}
${anime.duration ? `<span>⏱️ ${escapeHtml(anime.duration)}</span>` : ''}
${anime.year ? `<span>📅 ${anime.year}</span>` : ''}
${anime.episodes ? `<span><i class="fa-solid fa-tv"></i> ${anime.episodes} épisodes</span>` : ''}
${anime.status ? `<span><i class="fa-solid fa-tower-broadcast"></i> ${translateStatus(anime.status)}</span>` : ''}
${anime.duration ? `<span><i class="fa-solid fa-clock"></i> ${escapeHtml(anime.duration)}</span>` : ''}
${anime.year ? `<span><i class="fa-solid fa-calendar"></i> ${anime.year}</span>` : ''}
</div>
${studios.length > 0 ? `
@@ -269,10 +269,10 @@ function renderAnimeDetails(anime) {
<div class="anime-details-actions">
<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>
<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>
</div>
</div>
@@ -290,9 +290,9 @@ function renderAnimeDetails(anime) {
${synopsis ? `
<div class="anime-details-section">
<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;">
🌐 Traduire en français
<i class="fa-solid fa-globe"></i> Traduire en français
</button>
</div>
<p id="${synopsisId}" class="anime-details-synopsis">${escapeHtml(synopsis)}</p>
@@ -302,7 +302,7 @@ function renderAnimeDetails(anime) {
<!-- Seasons (Sequel/Prequel) -->
${seasons.length > 0 ? `
<div class="anime-details-section">
<h3>📺 Saisons</h3>
<h3><i class="fa-solid fa-tv"></i> Saisons</h3>
<div class="anime-related-list">
${seasons.map(season => `
<div class="anime-related-group">
@@ -310,7 +310,7 @@ function renderAnimeDetails(anime) {
<div class="anime-related-items">
${season.entries.map(entry => `
<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)}
${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>
@@ -358,7 +358,7 @@ async function loadStreamingResults(query) {
if (successfulResults.length === 0) {
container.innerHTML = `
<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>
`;
return;
@@ -367,7 +367,7 @@ async function loadStreamingResults(query) {
// Display results
container.innerHTML = `
<div class="streaming-results-header">
<h3>🎬 Disponible sur</h3>
<h3><i class="fa-solid fa-film"></i> Disponible sur</h3>
</div>
<div class="streaming-results-grid">
${successfulResults.map(result => renderStreamingResult(result, query)).join('')}
@@ -378,7 +378,7 @@ async function loadStreamingResults(query) {
console.error('Error loading streaming results:', error);
container.innerHTML = `
<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>
`;
}
@@ -406,7 +406,7 @@ function renderStreamingResult(result, query) {
</select>
<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>
</div>
@@ -475,7 +475,7 @@ async function translateSynopsis(synopsisId, button) {
// Revert to original
synopsisElement.textContent = originalText;
synopsisElement.dataset.translated = 'false';
button.innerHTML = '🌐 Traduire en français';
button.innerHTML = '<i class="fa-solid fa-globe"></i> Traduire en français';
return;
}
@@ -484,7 +484,7 @@ async function translateSynopsis(synopsisId, button) {
// Show loading state
button.disabled = true;
button.innerHTML = ' Traduction...';
button.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Traduction...';
synopsisElement.style.opacity = '0.5';
try {
@@ -509,7 +509,7 @@ async function translateSynopsis(synopsisId, button) {
synopsisElement.textContent = data.translatedText;
synopsisElement.dataset.translated = 'true';
button.innerHTML = '🔄 Voir l\'original';
button.innerHTML = '<i class="fa-solid fa-rotate"></i> Voir l\'original';
} else {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
console.error('Translation API error:', errorData);
@@ -523,7 +523,7 @@ async function translateSynopsis(synopsisId, button) {
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.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>
`;
+28 -28
View File
@@ -22,12 +22,12 @@ async function loadRecommendations() {
} else {
container.innerHTML = `
<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;">
Soit l'API MyAnimeList est inaccessible, soit vous n'avez pas encore de téléchargements.
</p>
<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>
</div>
`;
@@ -38,10 +38,10 @@ async function loadRecommendations() {
console.error('Error loading recommendations:', error);
container.innerHTML = `
<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>
<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>
</div>
`;
@@ -71,12 +71,12 @@ async function loadLatestReleases() {
} else {
container.innerHTML = `
<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;">
L'API MyAnimeList pourrait être temporairement inaccessible.
</p>
<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>
</div>
`;
@@ -87,10 +87,10 @@ async function loadLatestReleases() {
console.error('Error loading releases:', error);
container.innerHTML = `
<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>
<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>
</div>
`;
@@ -100,7 +100,7 @@ async function loadLatestReleases() {
// Load all home content
async function loadHomeContent() {
console.log('🏠 loadHomeContent() called');
console.log('loadHomeContent() called');
const loading = document.getElementById('homeLoading');
const recommendationsSection = document.getElementById('recommendationsSection');
@@ -123,13 +123,13 @@ async function loadHomeContent() {
loadRecommendations(),
loadLatestReleases()
]);
console.log('Home content loaded successfully');
console.log('Home content loaded successfully');
// Show sections if they have content
if (recommendationsSection) recommendationsSection.style.display = 'block';
if (releasesSection) releasesSection.style.display = 'block';
} catch (error) {
console.error('Error loading home content:', error);
console.error('Error loading home content:', error);
if (loading) {
loading.innerHTML = 'Erreur lors du chargement. Consultez la console pour plus de détails.';
}
@@ -149,11 +149,11 @@ function renderRecommendationCard(anime) {
return `
<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-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 class="anime-card-content">
@@ -165,7 +165,7 @@ function renderRecommendationCard(anime) {
</div>
<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.status ? translateStatus(anime.status) : ''}
</div>
@@ -174,17 +174,17 @@ function renderRecommendationCard(anime) {
${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>
</details>
` : ''}
<div class="anime-card-actions">
<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 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>
</div>
</div>
@@ -202,11 +202,11 @@ function renderReleaseCard(anime) {
return `
<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-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 class="anime-card-content">
@@ -218,7 +218,7 @@ function renderReleaseCard(anime) {
</div>
<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.status ? translateStatus(anime.status) : ''}
</div>
@@ -227,17 +227,17 @@ function renderReleaseCard(anime) {
${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>
</details>
` : ''}
<div class="anime-card-actions">
<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 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>
</div>
</div>
@@ -246,11 +246,11 @@ function renderReleaseCard(anime) {
// Get rating color based on score
function getRatingColor(score) {
if (score >= 9) return 'linear-gradient(45deg, #ffd700, #ffed4e)';
if (score >= 8) return 'linear-gradient(45deg, #00ff88, #00d9ff)';
if (score >= 7) return 'linear-gradient(45deg, #00d9ff, #6c5ce7)';
if (score >= 6) return 'linear-gradient(45deg, #ffa500, #ff6b6b)';
return 'linear-gradient(45deg, #666, #888)';
if (score >= 9) return '#ffd700';
if (score >= 8) return '#2d936c';
if (score >= 7) return '#FF9F1C';
if (score >= 6) return '#f4a261';
return '#888888';
}
// Search anime on providers (redirects to anime tab)
+13 -13
View File
@@ -26,7 +26,7 @@ async function handleSeriesSearch() {
const series = data.results['fs7'];
let html = `
<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 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-header">
<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>
${coverImage ? `
<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 class="anime-card-actions">
<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 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>
</div>
<div id="episodes-fs7-${encodeURIComponent(s.url)}" style="margin-top: 10px;"></div>
@@ -71,7 +71,7 @@ async function handleSeriesSearch() {
} else {
resultsContainer.innerHTML = `
<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;">
Essayez avec un autre titre ou vérifiez l'orthographe
</p>
@@ -81,7 +81,7 @@ async function handleSeriesSearch() {
console.error('Error searching series:', error);
resultsContainer.innerHTML = `
<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>
</div>`;
}
@@ -102,10 +102,10 @@ async function loadSeriesEpisodesDirect(url, title) {
if (data.episodes && data.episodes.length > 0) {
let html = `
<div style="margin-top: 15px;">
<label style="font-size: 12px; color: #00d9ff; margin-bottom: 5px; display: block;">
📺 Sélectionner un épisode:
<label style="font-size: 12px; color: #FF9F1C; margin-bottom: 5px; display: block;">
<i class="fa-solid fa-tv"></i> Sélectionner un épisode:
</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>
${data.episodes.map(ep => `
<option value="${escapeHtml(ep.url)}">Épisode ${escapeHtml(ep.episode)}</option>
@@ -145,7 +145,7 @@ async function downloadSeriesEpisode(url, title) {
});
if (response.ok) {
alert(`Téléchargement démarré pour "${title}"`);
alert(`Téléchargement démarré pour "${title}"`);
// Refresh downloads
if (typeof loadDownloads === 'function') {
loadDownloads();
@@ -155,11 +155,11 @@ async function downloadSeriesEpisode(url, title) {
const errorMessage = error.detail
? (typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail))
: 'Impossible de démarrer le téléchargement';
alert(`Erreur: ${errorMessage}`);
alert(`Erreur : ${errorMessage}`);
}
} catch (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 `
<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-title">${escapeHtml(series.title)}</div>
@@ -30,17 +30,17 @@ function renderSeriesRecommendationCard(series) {
<div class="anime-card-info">
<div class="anime-card-meta">
📺 Série TV
<i class="fa-solid fa-tv"></i> Série TV
</div>
</div>
</div>
<div class="anime-card-actions">
<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 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>
</div>
</div>
@@ -92,17 +92,17 @@ function renderSeriesReleaseCard(series) {
<div class="anime-card-info">
<div class="anime-card-meta">
📺 Série TV • Nouveau
<i class="fa-solid fa-tv"></i> Série TV • Nouveau
</div>
</div>
</div>
<div class="anime-card-actions">
<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 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>
</div>
</div>
@@ -236,10 +236,10 @@ async function loadSeriesReleases() {
if (container) {
container.innerHTML = `
<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>
<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>
</div>`;
}
@@ -260,7 +260,7 @@ async function loadProvidersGrid() {
let html = '';
// 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">';
const animeProviders = Object.entries(data.anime_providers || {});
@@ -281,11 +281,11 @@ async function loadProvidersGrid() {
<div class="anime-card-actions">
${domains.length > 0 ? `
<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 class="btn btn-secondary btn-small" onclick="showProviderSearch('${id}')">
🔍 Rechercher
<i class="fa-solid fa-magnifying-glass"></i> Rechercher
</button>
</div>
</div>
@@ -298,7 +298,7 @@ async function loadProvidersGrid() {
html += '</div>';
// 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">';
const fileHosts = Object.entries(data.file_hosts || {});
@@ -311,7 +311,7 @@ async function loadProvidersGrid() {
</div>
<div class="anime-card-actions">
<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>
</div>
</div>
@@ -330,10 +330,10 @@ async function loadProvidersGrid() {
if (container) {
container.innerHTML = `
<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>
<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>
</div>
`;
@@ -349,7 +349,7 @@ function showProviderSearch(providerId) {
// Show download info (explains how to download)
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
+9 -9
View File
@@ -346,10 +346,10 @@ async function handleStartScheduler() {
try {
await startScheduler();
await loadSchedulerStatus();
alert('Planificateur démarré!');
alert('Planificateur démarré !');
} catch (error) {
console.error('Error starting scheduler:', error);
alert(`Erreur: ${error.message}`);
alert(`Erreur : ${error.message}`);
}
}
@@ -360,10 +360,10 @@ async function handleStopScheduler() {
try {
await stopScheduler();
await loadSchedulerStatus();
alert('Planificateur arrêté!');
alert('Planificateur arrêté !');
} catch (error) {
console.error('Error stopping scheduler:', error);
alert(`Erreur: ${error.message}`);
alert(`Erreur : ${error.message}`);
}
}
@@ -376,7 +376,7 @@ async function handleCheckAll() {
await loadSchedulerStatus();
} catch (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);
} catch (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) {
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 {
// Scheduler running but no next_run yet (just started)
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 {
// Update buttons if they exist
if (startBtn) startBtn.style.display = 'inline-block';
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 -->
<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" />
<!-- External Libraries -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<!-- External Libraries (local first, CDN fallback) -->
<script src="/static/vendor/htmx.min.js"></script>
<script src="/static/vendor/alpine.min.js" defer></script>
<script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script>
<style>
@@ -40,6 +41,7 @@
<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/main.js?v=1.11" defer></script>
<script src="/static/js/settings.js?v=1.0" defer></script>
</head>
<body x-data="globalAppState">
{% include "components/toast_container.html" %}
+9 -9
View File
@@ -5,23 +5,23 @@
<!-- 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 rgba(255,255,255,0.05); text-align: center;">
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;text-align: center;">
<div style="font-size: 2rem; font-weight: 800; color: var(--primary);" id="stat-total-users">{{ users|length }}</div>
<div style="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 rgba(255,255,255,0.05); text-align: center;">
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;text-align: center;">
<div style="font-size: 2rem; font-weight: 800; color: var(--accent);" id="stat-active-users">{{ users|selectattr('is_active')|list|length }}</div>
<div style="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 rgba(255,255,255,0.05); text-align: center;">
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;text-align: center;">
<div style="font-size: 2rem; font-weight: 800; color: #f0a500;" id="stat-admin-users">{{ users|selectattr('is_admin')|list|length }}</div>
<div style="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 rgba(255,255,255,0.05); overflow: hidden;">
<div style="padding: 20px 25px; border-bottom: 1px solid rgba(255,255,255,0.05);">
<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>
@@ -29,7 +29,7 @@
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);">
<tr style="border-bottom: 1px solid #2a2d32;">
<th style="padding: 12px 20px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Utilisateur</th>
<th style="padding: 12px 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>
@@ -41,7 +41,7 @@
</thead>
<tbody>
{% for user in users %}
<tr style="border-bottom: 1px solid rgba(255,255,255,0.03); {% if not user.is_active %}opacity: 0.5;{% endif %}">
<tr style="border-bottom: 1px solid #2a2d32; {% if not user.is_active %}opacity: 0.5;{% endif %}">
<td style="padding: 12px 20px;">
<div style="font-weight: 600;">{{ user.username }}</div>
{% if user.full_name %}
@@ -50,12 +50,12 @@
</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: 12px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_active %}rgba(0,255,136,0.15); color: var(--accent){% else %}rgba(255,77,77,0.15); color: var(--danger){% endif %};">
<span style="display: inline-block; padding: 3px 10px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_active %}rgba(45,147,108,0.1); color: #2d936c{% else %}rgba(230,57,70,0.1); color: #e63946{% endif %};">
{% if user.is_active %}Actif{% else %}Inactif{% endif %}
</span>
</td>
<td style="padding: 12px 15px; text-align: center;">
<span style="display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_admin %}rgba(240,165,0,0.15); color: #f0a500{% else %}rgba(255,255,255,0.05); color: var(--text-dim){% endif %};">
<span style="display: inline-block; padding: 3px 10px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_admin %}rgba(244,162,97,0.15); color: #f4a261{% else %}var(--bg-elevated); color: var(--text-dim){% endif %};">
{% if user.is_admin %}Admin{% else %}User{% endif %}
</span>
</td>
+2 -2
View File
@@ -2,9 +2,9 @@
<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'); } });">
<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"
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 %}
<span class="hc-rating"><i class="fas fa-star"></i> {{ anime.metadata.rating }}</span>
{% endif %}
+17 -17
View File
@@ -1,4 +1,4 @@
{% set accent = "#00d9ff" %}
{% set accent = "#FF9F1C" %}
{% set default_lang = settings.default_lang if settings else 'vostfr' %}
{% set _groups = namespace(items={}) %}
@@ -36,9 +36,9 @@
{% set first_url = group.providers[0].url %}
<div class="sr-card" style="--sr-accent: {{ accent }};">
<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"
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>
<div class="sr-body">
<div class="sr-top">
@@ -114,11 +114,11 @@
.sr-card {
display: flex; gap: 20px;
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);
}
.sr-card:hover { border-color: var(--sr-accent); box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
.sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #000; }
.sr-card:hover { border-color: var(--sr-accent); }
.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-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.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-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-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-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-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:hover { border-color: rgba(255,255,255,0.2); background: rgba(255,255,255,0.05); }
.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: var(--text-main); background: var(--bg-card); }
.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: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:hover { background: var(--accent); color: var(--bg-dark); }
.sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(0,255,136,0.1); pointer-events: none; }
.sr-btn-follow:hover { background: var(--accent); color: #fff; }
.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-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-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:hover { background: rgba(255,255,255,0.06); }
.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: 4px; transition: var(--transition); text-align: left; }
.sr-dropdown-item:hover { background: var(--bg-elevated); }
.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; }
@media (max-width: 768px) {
+1 -1
View File
@@ -36,7 +36,7 @@
font-size: 0.85rem;
font-weight: 600;
color: var(--primary);
background: rgba(0, 217, 255, 0.1);
background: rgba(241, 80, 37, 0.1);
padding: 2px 10px;
border-radius: 12px;
margin-left: 10px;
+10 -12
View File
@@ -60,7 +60,7 @@
background: var(--bg-card);
border-radius: var(--card-radius);
padding: 30px;
border: 1px solid rgba(255, 255, 255, 0.05);
border: 1px solid var(--secondary);
animation: fadeIn 0.3s ease-out;
}
@@ -71,21 +71,20 @@
}
.view-grid .episode-item {
background: rgba(255, 255, 255, 0.03);
background: var(--bg-elevated);
padding: 20px 15px;
border-radius: 12px;
border-radius: 4px;
text-align: center;
transition: var(--transition);
border: 1px solid rgba(255, 255, 255, 0.05);
border: 1px solid var(--secondary);
display: flex;
flex-direction: column;
gap: 12px;
}
.view-grid .episode-item:hover {
background: rgba(255, 255, 255, 0.07);
background: var(--text-dim);
border-color: var(--primary);
transform: translateY(-3px);
}
.view-grid .ep-title { display: none; }
@@ -103,15 +102,15 @@
display: flex;
align-items: center;
gap: 20px;
background: rgba(255, 255, 255, 0.03);
background: var(--bg-elevated);
padding: 12px 20px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 4px;
border: 1px solid var(--secondary);
transition: var(--transition);
}
.view-list .episode-item:hover {
background: rgba(255, 255, 255, 0.07);
background: var(--text-dim);
border-color: var(--primary);
}
@@ -123,9 +122,8 @@
margin: 20px 0 30px 0;
padding: 25px;
background: #000;
border-radius: 12px;
border-radius: 4px;
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); } }
+5 -5
View File
@@ -1,25 +1,25 @@
<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>
<!-- User info and logout button -->
<div id="userInfo" x-show="isAuthenticated" class="auth-panel" x-cloak>
<div style="display: flex; align-items: center; gap: 10px;">
<span style="color: var(--primary); font-size: 1.2rem;">👤</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(--primary); font-size: 1.2rem;"><i class="fa-solid fa-user"></i></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>
<button class="btn btn-secondary btn-small"
onclick="removeToken(); isAuthenticated = false"
hx-post="/api/auth/logout"
hx-on::after-request="window.location.href = '/login'">
🚪 Déconnexion
<i class="fa-solid fa-right-from-bracket"></i> Déconnexion
</button>
</div>
<!-- Login prompt (shown when not logged in) -->
<div id="loginPrompt" x-show="!isAuthenticated" class="auth-panel" style="justify-content: center;" x-cloak>
<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>
</div>
+2 -2
View File
@@ -2,7 +2,7 @@
<div class="section-container">
<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"
hx-get="/api/recommendations"
hx-target="#recommendationsList">
@@ -19,7 +19,7 @@
<div class="section-container">
<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"
hx-get="/api/releases/latest"
hx-target="#releasesList">
+2 -2
View File
@@ -1,4 +1,4 @@
<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>
<p style="color: #888; font-size: 0.95rem;">Connectez-vous pour accéder à cette section.</p>
<i class="fa-solid fa-lock" style="font-size: 2rem; color: #FF9F1C; margin-bottom: 15px;"></i>
<p style="color: #8a8f98; font-size: 0.95rem;">Connectez-vous pour accéder à cette section.</p>
</div>
+4 -4
View File
@@ -19,7 +19,7 @@
mozallowfullscreen></iframe>
</div>
<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>
{% else %}
<div class="video-wrapper">
@@ -41,8 +41,8 @@
margin: 20px 0;
padding: 15px;
background: #000;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
border-radius: 4px;
border: 1px solid #2a2d32;
}
.iframe-container {
position: relative;
@@ -65,7 +65,7 @@
}
.player-info-hint {
font-size: 0.8rem;
color: #888;
color: var(--text-dim);
margin-top: 10px;
text-align: center;
}
+13 -14
View File
@@ -1,18 +1,17 @@
{% macro series_card(series, in_watchlist=False, lang='vf') %}
<div class="ac" id="series-{{ series.url | hash }}">
<div class="ac-poster">
<img src="{{ series.cover_image or 'https://placehold.co/400x600/161625/ff6b6b?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;">
<button class="ac-play"
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
hx-target="#player-container" hx-swap="innerHTML">
<i class="fas fa-play"></i>
</button>
{% macro series_card(series) %}
<div class="hc"
@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'); } });">
<div class="hc-poster">
<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/202327/FF9F1C?text=Error'; this.onerror=null;">
{% if series.lang %}
<span class="hc-rating" style="text-transform: uppercase;">{{ series.lang }}</span>
{% endif %}
<span class="hc-play"><i class="fas fa-search"></i></span>
</div>
<div class="ac-info">
<span class="ac-provider" style="--ac-color: var(--secondary)">{{ series.provider_id | upper if series.provider_id else 'FS7' }}</span>
<h3 class="ac-title" title="{{ series.title }}">{{ series.title }}</h3>
<div class="hc-info">
<span class="hc-src">{{ series.provider_id or 'FS7' }}</span>
<span class="hc-title">{{ series.title }}</span>
</div>
</div>
{% endmacro %}
@@ -0,0 +1,11 @@
{% from "components/series_card.html" import series_card %}
{% if releases %}
{% for series in releases %}
{{ series_card(series) }}
{% endfor %}
{% else %}
<div class="empty-state">
<p>Aucune sortie recente trouvee.</p>
</div>
{% endif %}
+16 -16
View File
@@ -1,4 +1,4 @@
{% set accent = "#ff6b6b" %}
{% set accent = "#FF9F1C" %}
{% set default_lang = settings.default_lang if settings else 'vf' %}
{% set _groups = namespace(items={}) %}
@@ -28,9 +28,9 @@
{% set first_url = group.providers[0].url %}
<div class="sr-card" style="--sr-accent: {{ accent }};">
<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"
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>
<div class="sr-body">
<h3 class="sr-title">{{ group.title }}</h3>
@@ -93,32 +93,32 @@
.sr-card {
display: flex; gap: 20px;
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);
}
.sr-card:hover { border-color: var(--sr-accent); box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
.sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #000; }
.sr-card:hover { border-color: var(--sr-accent); }
.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-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-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-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-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:hover { border-color: rgba(255,255,255,0.2); background: rgba(255,255,255,0.05); }
.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: var(--text-main); background: var(--bg-card); }
.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: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:hover { background: var(--accent); color: var(--bg-dark); }
.sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(0,255,136,0.1); pointer-events: none; }
.sr-btn-follow:hover { background: var(--accent); color: #fff; }
.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-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-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:hover { background: rgba(255,255,255,0.06); }
.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: 4px; transition: var(--transition); text-align: left; }
.sr-dropdown-item:hover { background: var(--bg-elevated); }
.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; }
@media (max-width: 768px) {
+70 -96
View File
@@ -4,7 +4,7 @@
</div>
<!-- General Preferences -->
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);">
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;">
<h3 style="margin-bottom: 20px; color: var(--primary);">General</h3>
<form id="settings-form" class="settings-form">
@@ -43,7 +43,7 @@
</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 rgba(255,255,255,0.05);">
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;">
<h3 style="margin-bottom: 20px; color: var(--primary);">Filtres de contenu</h3>
<div class="form-group">
@@ -66,12 +66,12 @@
</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 rgba(255,255,255,0.05);">
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;">
<h3 style="margin-bottom: 20px; color: var(--primary);">Categories</h3>
<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: rgba(255,255,255,0.02); border-radius: 10px; border: 1px solid rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
<label class="toggle-card" style="flex: 1; min-width: 200px; padding: 15px; background: var(--bg-elevated); border-radius: 4px; border: 1px solid var(--secondary); display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
<div>
<div 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>
@@ -79,7 +79,7 @@
<input type="checkbox" id="anime_enabled" {% if settings.anime_enabled %}checked{% endif %} onchange="toggleCategory('anime_enabled', this.checked)" style="width: 20px; height: 20px; cursor: pointer; accent-color: var(--primary);">
</label>
<label class="toggle-card" style="flex: 1; min-width: 200px; padding: 15px; background: rgba(255,255,255,0.02); border-radius: 10px; border: 1px solid rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
<label class="toggle-card" style="flex: 1; min-width: 200px; padding: 15px; background: var(--bg-elevated); border-radius: 4px; border: 1px solid var(--secondary); display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
<div>
<div 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>
@@ -89,8 +89,70 @@
</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 -->
<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;">
<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">
@@ -100,13 +162,13 @@
<div class="providers-settings-list" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 15px;">
{% 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;">
<span style="font-size: 1.5rem;">{{ provider.icon }}</span>
<div>
<div style="font-weight: 600;">{{ provider.name }}</div>
<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;">
{{ provider.status | upper }}
</span>
@@ -127,93 +189,6 @@
</div>
</div>
<script>
function getToken() {
return localStorage.getItem('auth_token') || null;
}
async function saveSettings() {
const token = getToken();
if (!token) return;
const data = {
default_lang: document.getElementById('default_lang').value,
theme: document.getElementById('theme').value,
download_dir: document.getElementById('download_dir').value,
};
try {
const r = await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (r.ok) {
showToast('Preferences enregistrees', 'success');
}
} catch (e) {
showToast('Erreur: ' + e.message, 'error');
}
}
async function saveFilter(field, value) {
const token = getToken();
if (!token) return;
try {
const r = await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value })
});
if (r.ok) {
showToast('Filtre mis a jour', 'success');
}
} catch (e) {
showToast('Erreur: ' + e.message, 'error');
}
}
async function toggleCategory(field, value) {
const token = getToken();
if (!token) return;
// Prevent disabling both
if (!value) {
const otherField = field === 'anime_enabled' ? 'series_enabled' : 'anime_enabled';
const otherCheckbox = document.getElementById(otherField);
if (otherCheckbox && !otherCheckbox.checked) {
showToast('Au moins une categorie doit rester active', 'error');
document.getElementById(field).checked = true;
return;
}
}
try {
const r = await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value })
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
showToast(err.detail || 'Erreur', 'error');
document.getElementById(field).checked = !value;
} else {
showToast('Categorie ' + (value ? 'activee' : 'desactivee'), 'success');
}
} catch (e) {
showToast('Erreur: ' + e.message, 'error');
document.getElementById(field).checked = !value;
}
}
function showToast(message, type) {
const event = new CustomEvent('show-toast', { detail: { message, type } });
document.dispatchEvent(event);
}
</script>
<style>
.settings-form label {
display: block;
@@ -223,6 +198,5 @@ function showToast(message, type) {
}
.status-dot {
display: inline-block;
box-shadow: 0 0 5px currentColor;
}
</style>
+13 -8
View File
@@ -1,10 +1,12 @@
<div id="toast-container"
class="toast-container"
style="pointer-events: none;"
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)">
<template x-for="toast in toasts" :key="toast.id">
<div class="toast"
style="pointer-events: auto;"
:class="'toast-' + toast.type"
x-show="true"
x-transition:enter="toast-enter"
@@ -33,22 +35,25 @@
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
max-height: 80vh;
overflow: hidden;
}
.toast {
min-width: 250px;
padding: 12px 16px;
border-radius: 8px;
background: #2d2d2d;
color: white;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
border-radius: 4px;
background: var(--bg-card);
color: var(--text-main);
border: 1px solid var(--secondary);
display: flex;
justify-content: space-between;
align-items: center;
border-left: 4px solid #ccc;
border-left: 4px solid var(--secondary);
}
.toast-success { border-left-color: #4caf50; }
.toast-error { border-left-color: #f44336; }
.toast-info { border-left-color: #2196f3; }
.toast-success { border-left-color: #2d936c; }
.toast-error { border-left-color: #e63946; }
.toast-info { border-left-color: #FFBF69; }
.toast-content { display: flex; align-items: center; gap: 10px; }
.toast-close { background: none; border: none; color: #aaa; cursor: pointer; }
</style>
+25 -27
View File
@@ -167,7 +167,7 @@
.filter-tabs {
display: flex;
gap: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid #2a2d32;
padding-bottom: 12px;
margin-bottom: 8px;
}
@@ -188,7 +188,7 @@
}
.filter-tab:hover {
background: rgba(255, 255, 255, 0.05);
background: var(--bg-elevated);
color: var(--text-main);
}
@@ -211,14 +211,12 @@
background: var(--bg-card);
border-radius: var(--card-radius);
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
border: 1px solid #2a2d32;
transition: var(--transition);
}
.watchlist-card:hover {
border-color: var(--primary);
box-shadow: 0 4px 24px rgba(0, 217, 255, 0.15);
transform: translateY(-2px);
}
/* Poster */
@@ -254,22 +252,22 @@
}
.poster-badge.active {
background: rgba(0, 255, 136, 0.9);
color: var(--bg-dark);
background: #2d936c;
color: #fff;
}
.poster-badge.paused {
background: rgba(255, 193, 7, 0.9);
color: var(--bg-dark);
background: #f4a261;
color: #15171A;
}
.poster-badge.completed {
background: rgba(156, 39, 176, 0.9);
color: var(--bg-dark);
background: #FF9F1C;
color: #15171A;
}
.poster-badge.archived {
background: rgba(255, 255, 255, 0.15);
background: rgba(206, 208, 206, 0.2);
color: var(--text-dim);
}
@@ -279,7 +277,7 @@
left: 8px;
padding: 4px 10px;
border-radius: 20px;
background: rgba(0, 217, 255, 0.9);
background: #FF9F1C;
color: var(--bg-dark);
font-size: 0.65rem;
font-weight: 700;
@@ -327,21 +325,21 @@
}
.meta-provider {
background: rgba(0, 217, 255, 0.15);
background: rgba(255, 191, 105, 0.1);
color: var(--primary);
border: 1px solid rgba(0, 217, 255, 0.3);
border: 1px solid rgba(255, 191, 105, 0.3);
}
.meta-lang {
background: rgba(255, 107, 107, 0.15);
color: var(--secondary);
border: 1px solid rgba(255, 107, 107, 0.3);
background: rgba(206, 208, 206, 0.3);
color: var(--text-dim);
border: 1px solid var(--text-dim);
}
.meta-quality {
background: rgba(0, 255, 136, 0.15);
color: var(--accent);
border: 1px solid rgba(0, 255, 136, 0.3);
background: rgba(45, 147, 108, 0.1);
color: var(--success);
border: 1px solid rgba(45, 147, 108, 0.3);
}
.watchlist-synopsis {
@@ -375,7 +373,7 @@
gap: 6px;
margin-top: auto;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
border-top: 1px solid #2a2d32;
}
.action-btn {
@@ -389,12 +387,12 @@
font-size: 0.85rem;
cursor: pointer;
transition: var(--transition);
background: rgba(255, 255, 255, 0.05);
background: var(--bg-elevated);
color: var(--text-dim);
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.1);
background: var(--secondary);
color: var(--text-main);
}
@@ -415,11 +413,11 @@
}
.btn-complete {
color: #9c27b0;
color: #FFBF69;
}
.btn-complete:hover {
background: rgba(156, 39, 176, 0.15);
background: rgba(255, 191, 105, 0.15);
}
.btn-delete {
@@ -436,7 +434,7 @@
padding: 80px 40px;
background: var(--bg-card);
border-radius: var(--card-radius);
border: 1px dashed rgba(255, 255, 255, 0.1);
border: 1px dashed #2a2d32;
}
.watchlist-empty i {
+8 -8
View File
@@ -1,6 +1,6 @@
<div class="section-container">
<div class="section-header">
<h2>📋 Ma Watchlist</h2>
<h2><i class="fa-solid fa-clipboard-list"></i> Ma Watchlist</h2>
<div class="header-actions">
<button class="btn btn-sm btn-primary" hx-post="/api/watchlist/check" hx-swap="none">
<i class="fas fa-sync"></i> Vérifier épisodes
@@ -35,15 +35,15 @@
display: flex;
gap: 15px;
padding: 15px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: transform 0.2s;
background: var(--bg-card);
border-radius: 4px;
border: 1px solid var(--secondary);
transition: border-color 0.2s;
}
.watchlist-item:hover { transform: translateY(-3px); border-color: #00d9ff; }
.item-poster img { width: 80px; height: 120px; border-radius: 8px; object-fit: cover; }
.watchlist-item:hover { border-color: #FFBF69; }
.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 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-actions { display: flex; gap: 10px; margin-top: 10px; }
</style>
+5 -19
View File
@@ -46,7 +46,7 @@
<!-- Player container for HTMX injections -->
<div id="player-container"></div>
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.05); margin: 40px 0;">
<hr style="border: none; border-top: 1px solid #2a2d32; margin: 40px 0;">
<!-- Latest Releases Section - Anime only -->
<div class="section-header">
@@ -97,27 +97,13 @@
<!-- Series search results -->
<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;">
<!-- Recommendations Section - Series only -->
<div class="section-header">
<h2>Recommande pour vous</h2>
<button class="btn btn-secondary btn-small"
hx-get="/api/recommendations?content_type=series&html=1"
hx-target="#seriesRecommendationsList">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Actualiser
</button>
</div>
<div id="seriesRecommendationsList" class="recommendations-carousel" style="margin-bottom: 40px;" hx-get="/api/recommendations?content_type=series&html=1" hx-trigger="load delay:600ms"></div>
<hr style="border: none; border-top: 1px solid #2a2d32; margin: 40px 0;">
<!-- Latest Releases Section - Series only -->
<div class="section-header" style="margin-top: 40px;">
<div class="section-header">
<h2>Dernieres sorties Series TV</h2>
<button class="btn btn-secondary btn-small"
hx-get="/api/releases/latest?content_type=series&html=1"
hx-get="/api/series/latest?html=1"
hx-target="#seriesReleasesList">
<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>
@@ -125,7 +111,7 @@
Actualiser
</button>
</div>
<div id="seriesReleasesList" class="releases-carousel" hx-get="/api/releases/latest?content_type=series&html=1" hx-trigger="load delay:700ms"></div>
<div id="seriesReleasesList" class="releases-carousel" hx-get="/api/series/latest?html=1" hx-trigger="load delay:700ms"></div>
</div>
<div id="tab-watchlist" class="tab-content" x-show="activeTab === 'watchlist'">
+1 -1
View File
@@ -8,7 +8,7 @@
</head>
<body>
<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-tab active" data-tab="login">Connexion</div>
+29 -29
View File
@@ -14,13 +14,13 @@
body {
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;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
color: #fff;
color: #F2F2F2;
}
.container {
@@ -36,13 +36,14 @@
.header h1 {
font-size: 1.5rem;
margin-bottom: 10px;
color: #00d9ff;
color: #FFBF69;
}
.video-info {
background: rgba(255, 255, 255, 0.05);
background: #202327;
padding: 15px 20px;
border-radius: 10px;
border-radius: 4px;
border: 1px solid #2a2d32;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
@@ -57,19 +58,18 @@
}
.video-info .filesize {
color: #aaa;
color: #8a8f98;
font-size: 0.9rem;
}
.video-wrapper {
background: #000;
border-radius: 15px;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.plyr {
border-radius: 15px;
border-radius: 4px;
}
.controls {
@@ -82,13 +82,13 @@
.btn {
padding: 12px 24px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
border-radius: 8px;
background: #202327;
border: 1px solid #2a2d32;
color: #F2F2F2;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s ease;
transition: all 0.2s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
@@ -96,28 +96,28 @@
}
.btn:hover {
background: rgba(0, 217, 255, 0.2);
border-color: #00d9ff;
transform: translateY(-2px);
background: #2a2d32;
border-color: #FFBF69;
}
.btn-primary {
background: linear-gradient(135deg, #00d9ff 0%, #00ff88 100%);
border: none;
color: #000;
background: #FF9F1C;
border: 1px solid #FF9F1C;
color: #fff;
font-weight: 600;
}
.btn-primary:hover {
background: linear-gradient(135deg, #00ff88 0%, #00d9ff 100%);
background: #e08a15;
border-color: #e08a15;
}
.error-message {
background: rgba(255, 71, 87, 0.1);
border: 1px solid #ff4757;
color: #ff4757;
background: rgba(230, 57, 70, 0.1);
border: 1px solid #e63946;
color: #e63946;
padding: 20px;
border-radius: 10px;
border-radius: 4px;
text-align: center;
margin-top: 20px;
}
@@ -135,7 +135,7 @@
<body>
<div class="container">
<div class="header">
<h1>🎬 Ohm Stream Player</h1>
<h1><i class="fa-solid fa-film"></i> Ohm Stream Player</h1>
</div>
<div class="video-info">
@@ -151,7 +151,7 @@
<div class="controls">
<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>
@@ -169,8 +169,8 @@
wrapper.innerHTML = `
<div class="error-message">
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="/stream/{{ filename }}" style="color: #00d9ff; text-decoration: underline;" download>Télécharger</a>
<a href="/video/{{ task_id }}" style="color: #FF9F1C; text-decoration: underline;">Réessayer</a> ou
<a href="/stream/{{ filename }}" style="color: #FF9F1C; text-decoration: underline;" download>Télécharger</a>
</div>
`;
});
+26 -26
View File
@@ -10,29 +10,29 @@
<body class="watchlist-body">
<!-- Main Header -->
<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>
<p style="color: #888; font-size: 14px; margin: 5px 0 0;">Téléchargez vos vidéos, animes et séries</p>
<h1 style="color: #FF9F1C; font-size: 32px; margin: 0;"><i class="fa-solid fa-bolt"></i> Ohm Stream Downloader</h1>
<p style="color: #8a8f98; font-size: 14px; margin: 5px 0 0;">Téléchargez vos vidéos, animes et séries</p>
</div>
<!-- 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;">
<span style="color: #00d9ff;">👤 Connecté</span>
<button class="btn-secondary btn-small" onclick="handleLogout()">🚪 Déconnexion</button>
<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: #FF9F1C;"><i class="fa-solid fa-user"></i> Connecté</span>
<button class="btn-secondary btn-small" onclick="handleLogout()"><i class="fa-solid fa-right-from-bracket"></i> Déconnexion</button>
</div>
<!-- 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;">
<button class="tab" onclick="window.location.href='/web'">🏠 Accueil</button>
<button class="tab" onclick="window.location.href='/web#anime'">🎬 Anime</button>
<button class="tab" onclick="window.location.href='/web#series'">📺 Série</button>
<button class="tab" onclick="window.location.href='/web#providers'">📦 Fournisseurs</button>
<button class="tab active" onclick="window.location.href='/watchlist'">📋 Watchlist</button>
<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'"><i class="fa-solid fa-house"></i> Accueil</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'"><i class="fa-solid fa-tv"></i> Série</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'"><i class="fa-solid fa-clipboard-list"></i> Watchlist</button>
</div>
<div class="watchlist-container">
<!-- 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>
<button type="button" class="btn-secondary watchlist-header-back-btn" onclick="window.location.href = '/web'">
← Retour à l'accueil
@@ -43,21 +43,21 @@
<div class="scheduler-status" id="schedulerStatus">
<div class="scheduler-status-header">
<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>
<div class="scheduler-controls">
<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 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 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 class="btn-secondary btn-small watchlist-btn-small" onclick="handleOpenSettings()">
⚙️ Paramètres
<i class="fa-solid fa-gear"></i> Paramètres
</button>
</div>
</div>
@@ -161,17 +161,17 @@
if (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 {
// Scheduler running but no next_run yet (just started)
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 {
// Update buttons if they exist
if (startBtn) startBtn.style.display = 'inline-block';
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 {
await startScheduler();
await loadSchedulerStatus();
alert('Planificateur démarré!');
alert('Planificateur démarré !');
} catch (error) {
console.error('Error starting scheduler:', error);
alert(`Erreur: ${error.message}`);
alert(`Erreur : ${error.message}`);
}
}
@@ -212,10 +212,10 @@
try {
await stopScheduler();
await loadSchedulerStatus();
alert('Planificateur arrêté!');
alert('Planificateur arrêté !');
} catch (error) {
console.error('Error stopping scheduler:', error);
alert(`Erreur: ${error.message}`);
alert(`Erreur : ${error.message}`);
}
}
@@ -228,7 +228,7 @@
await loadSchedulerStatus();
} catch (error) {
console.error('Error checking all:', error);
alert(`Erreur: ${error.message}`);
alert(`Erreur : ${error.message}`);
}
}
@@ -246,7 +246,7 @@
document.body.appendChild(modalContainer);
} catch (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);
})();