feat: flat design Sunset Glitch + download manager + settings + recommendations overhaul
- Sunset Glitch color palette applied to all templates - Font Awesome icons throughout UI - Download manager with parallel queue and progress tracking - Settings page with dynamic configuration - Recommendations router enhanced with scoring - Local vendor libs (Alpine.js, HTMX) for offline support - Auto test suite with screenshots - Series releases list component - New download model
@@ -23,6 +23,7 @@ def create_db_and_tables():
|
|||||||
from app.models.favorites import FavoriteTable
|
from app.models.favorites import FavoriteTable
|
||||||
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
|
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
|
||||||
from app.models.settings import AppSettingsTable
|
from app.models.settings import AppSettingsTable
|
||||||
|
from app.models.download import DownloadTaskTable
|
||||||
|
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
import httpx
|
import httpx
|
||||||
from app.models import DownloadTask, DownloadStatus, DownloadRequest
|
from app.models import DownloadTask, DownloadStatus, DownloadRequest
|
||||||
|
from app.models.download import DownloadTaskTable
|
||||||
|
from app.database import engine
|
||||||
|
from sqlmodel import Session, select
|
||||||
from app.downloaders import get_downloader
|
from app.downloaders import get_downloader
|
||||||
|
from app.utils import sanitize_filename
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -24,6 +27,92 @@ class DownloadManager:
|
|||||||
self.active_downloads: Dict[str, asyncio.Task] = {}
|
self.active_downloads: Dict[str, asyncio.Task] = {}
|
||||||
self._semaphore = asyncio.Semaphore(max_parallel)
|
self._semaphore = asyncio.Semaphore(max_parallel)
|
||||||
|
|
||||||
|
# ==================== DB Persistence ====================
|
||||||
|
|
||||||
|
def _save_task_to_db(self, task: DownloadTask) -> None:
|
||||||
|
"""Persist a download task to the database (upsert)."""
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
existing = session.get(DownloadTaskTable, task.id)
|
||||||
|
if existing:
|
||||||
|
existing.url = task.url
|
||||||
|
existing.filename = task.filename
|
||||||
|
existing.host = task.host.value if hasattr(task.host, 'value') else str(task.host)
|
||||||
|
existing.status = task.status.value if hasattr(task.status, 'value') else str(task.status)
|
||||||
|
existing.progress = task.progress
|
||||||
|
existing.downloaded_bytes = task.downloaded_bytes
|
||||||
|
existing.total_bytes = task.total_bytes
|
||||||
|
existing.speed = task.speed
|
||||||
|
existing.error = task.error
|
||||||
|
existing.started_at = task.started_at
|
||||||
|
existing.completed_at = task.completed_at
|
||||||
|
existing.file_path = task.file_path
|
||||||
|
session.add(existing)
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
db_task = DownloadTaskTable(
|
||||||
|
id=task.id,
|
||||||
|
url=task.url,
|
||||||
|
filename=task.filename,
|
||||||
|
host=task.host.value if hasattr(task.host, 'value') else str(task.host),
|
||||||
|
status=task.status.value if hasattr(task.status, 'value') else str(task.status),
|
||||||
|
progress=task.progress,
|
||||||
|
downloaded_bytes=task.downloaded_bytes,
|
||||||
|
total_bytes=task.total_bytes,
|
||||||
|
speed=task.speed,
|
||||||
|
error=task.error,
|
||||||
|
created_at=task.created_at,
|
||||||
|
started_at=task.started_at,
|
||||||
|
completed_at=task.completed_at,
|
||||||
|
file_path=task.file_path,
|
||||||
|
)
|
||||||
|
session.add(db_task)
|
||||||
|
session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save task {task.id} to DB: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _delete_task_from_db(self, task_id: str) -> None:
|
||||||
|
"""Remove a download task from the database."""
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
db_task = session.get(DownloadTaskTable, task_id)
|
||||||
|
if db_task:
|
||||||
|
session.delete(db_task)
|
||||||
|
session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete task {task_id} from DB: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _load_tasks_from_db(self) -> None:
|
||||||
|
"""Load persisted download tasks from the database into memory."""
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
statement = select(DownloadTaskTable)
|
||||||
|
db_tasks = session.exec(statement).all()
|
||||||
|
for db_task in db_tasks:
|
||||||
|
if db_task.id not in self.tasks:
|
||||||
|
task = DownloadTask(
|
||||||
|
id=db_task.id,
|
||||||
|
url=db_task.url,
|
||||||
|
filename=db_task.filename,
|
||||||
|
host="other",
|
||||||
|
status=DownloadStatus(db_task.status),
|
||||||
|
progress=db_task.progress,
|
||||||
|
downloaded_bytes=db_task.downloaded_bytes,
|
||||||
|
total_bytes=db_task.total_bytes,
|
||||||
|
speed=db_task.speed,
|
||||||
|
error=db_task.error,
|
||||||
|
created_at=db_task.created_at,
|
||||||
|
started_at=db_task.started_at,
|
||||||
|
completed_at=db_task.completed_at,
|
||||||
|
file_path=db_task.file_path,
|
||||||
|
)
|
||||||
|
self.tasks[task.id] = task
|
||||||
|
logger.info(f"Loaded {len(db_tasks)} download tasks from database")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load tasks from DB: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# ==================== Task Management ====================
|
||||||
|
|
||||||
def get_task(self, task_id: str) -> Optional[DownloadTask]:
|
def get_task(self, task_id: str) -> Optional[DownloadTask]:
|
||||||
return self.tasks.get(task_id)
|
return self.tasks.get(task_id)
|
||||||
|
|
||||||
@@ -60,6 +149,8 @@ class DownloadManager:
|
|||||||
created_at=datetime.now()
|
created_at=datetime.now()
|
||||||
)
|
)
|
||||||
self.tasks[task_id] = task
|
self.tasks[task_id] = task
|
||||||
|
# Persist to database
|
||||||
|
self._save_task_to_db(task)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
async def start_download(self, task_id: str):
|
async def start_download(self, task_id: str):
|
||||||
@@ -82,6 +173,7 @@ class DownloadManager:
|
|||||||
task = self.tasks.get(task_id)
|
task = self.tasks.get(task_id)
|
||||||
if task and task.status == DownloadStatus.DOWNLOADING:
|
if task and task.status == DownloadStatus.DOWNLOADING:
|
||||||
task.status = DownloadStatus.PAUSED
|
task.status = DownloadStatus.PAUSED
|
||||||
|
self._save_task_to_db(task)
|
||||||
if task_id in self.active_downloads:
|
if task_id in self.active_downloads:
|
||||||
self.active_downloads[task_id].cancel()
|
self.active_downloads[task_id].cancel()
|
||||||
del self.active_downloads[task_id]
|
del self.active_downloads[task_id]
|
||||||
@@ -90,6 +182,7 @@ class DownloadManager:
|
|||||||
task = self.tasks.get(task_id)
|
task = self.tasks.get(task_id)
|
||||||
if task:
|
if task:
|
||||||
task.status = DownloadStatus.CANCELLED
|
task.status = DownloadStatus.CANCELLED
|
||||||
|
self._save_task_to_db(task)
|
||||||
if task_id in self.active_downloads:
|
if task_id in self.active_downloads:
|
||||||
self.active_downloads[task_id].cancel()
|
self.active_downloads[task_id].cancel()
|
||||||
del self.active_downloads[task_id]
|
del self.active_downloads[task_id]
|
||||||
@@ -112,14 +205,16 @@ class DownloadManager:
|
|||||||
if task.file_path and os.path.exists(task.file_path):
|
if task.file_path and os.path.exists(task.file_path):
|
||||||
os.remove(task.file_path)
|
os.remove(task.file_path)
|
||||||
|
|
||||||
# Remove from tasks dict
|
# Remove from tasks dict and database
|
||||||
del self.tasks[task_id]
|
del self.tasks[task_id]
|
||||||
|
self._delete_task_from_db(task_id)
|
||||||
|
|
||||||
async def _download(self, task: DownloadTask):
|
async def _download(self, task: DownloadTask):
|
||||||
async with self._semaphore:
|
async with self._semaphore:
|
||||||
try:
|
try:
|
||||||
task.status = DownloadStatus.DOWNLOADING
|
task.status = DownloadStatus.DOWNLOADING
|
||||||
task.started_at = datetime.now()
|
task.started_at = datetime.now()
|
||||||
|
self._save_task_to_db(task)
|
||||||
|
|
||||||
# Get downloader and extract link
|
# Get downloader and extract link
|
||||||
downloader = get_downloader(task.url)
|
downloader = get_downloader(task.url)
|
||||||
@@ -150,6 +245,9 @@ class DownloadManager:
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"Task filename kept as: {task.filename}")
|
logger.debug(f"Task filename kept as: {task.filename}")
|
||||||
|
|
||||||
|
# Sanitize filename to prevent path traversal and invalid characters
|
||||||
|
task.filename = sanitize_filename(task.filename)
|
||||||
|
|
||||||
task.file_path = str(self.download_dir / task.filename)
|
task.file_path = str(self.download_dir / task.filename)
|
||||||
|
|
||||||
# Check if URL is HLS/m3u8 - use ffmpeg to download
|
# Check if URL is HLS/m3u8 - use ffmpeg to download
|
||||||
@@ -157,6 +255,7 @@ class DownloadManager:
|
|||||||
logger.info(f"Detected HLS stream, using ffmpeg to download: {task.filename}")
|
logger.info(f"Detected HLS stream, using ffmpeg to download: {task.filename}")
|
||||||
success = await self._download_hls(download_url, task)
|
success = await self._download_hls(download_url, task)
|
||||||
if success:
|
if success:
|
||||||
|
self._save_task_to_db(task)
|
||||||
return
|
return
|
||||||
# If ffmpeg fails, fall through to regular download attempt
|
# If ffmpeg fails, fall through to regular download attempt
|
||||||
logger.warning("ffmpeg download failed, trying regular download")
|
logger.warning("ffmpeg download failed, trying regular download")
|
||||||
@@ -167,8 +266,12 @@ class DownloadManager:
|
|||||||
# Move file to expected location if different
|
# Move file to expected location if different
|
||||||
import shutil
|
import shutil
|
||||||
if download_url != task.file_path:
|
if download_url != task.file_path:
|
||||||
shutil.move(download_url, task.file_path)
|
try:
|
||||||
logger.debug(f"Moved file to: {task.file_path}")
|
shutil.move(download_url, task.file_path)
|
||||||
|
logger.debug(f"Moved file to: {task.file_path}")
|
||||||
|
except shutil.Error:
|
||||||
|
# Same file, no move needed
|
||||||
|
pass
|
||||||
|
|
||||||
# Mark as complete
|
# Mark as complete
|
||||||
file_size = os.path.getsize(task.file_path)
|
file_size = os.path.getsize(task.file_path)
|
||||||
@@ -178,6 +281,7 @@ class DownloadManager:
|
|||||||
task.downloaded_bytes = file_size
|
task.downloaded_bytes = file_size
|
||||||
task.total_bytes = file_size
|
task.total_bytes = file_size
|
||||||
task.completed_at = datetime.now()
|
task.completed_at = datetime.now()
|
||||||
|
self._save_task_to_db(task)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if file already exists and is complete (for VidMoly which downloads directly)
|
# Check if file already exists and is complete (for VidMoly which downloads directly)
|
||||||
@@ -190,6 +294,7 @@ class DownloadManager:
|
|||||||
task.downloaded_bytes = file_size
|
task.downloaded_bytes = file_size
|
||||||
task.total_bytes = file_size
|
task.total_bytes = file_size
|
||||||
task.completed_at = datetime.now()
|
task.completed_at = datetime.now()
|
||||||
|
self._save_task_to_db(task)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check for partial download (resume)
|
# Check for partial download (resume)
|
||||||
@@ -241,6 +346,7 @@ class DownloadManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
task.status = DownloadStatus.FAILED
|
task.status = DownloadStatus.FAILED
|
||||||
task.error = str(e)
|
task.error = str(e)
|
||||||
|
self._save_task_to_db(task)
|
||||||
finally:
|
finally:
|
||||||
if task.id in self.active_downloads:
|
if task.id in self.active_downloads:
|
||||||
del self.active_downloads[task.id]
|
del self.active_downloads[task.id]
|
||||||
@@ -269,9 +375,11 @@ class DownloadManager:
|
|||||||
|
|
||||||
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
|
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
|
||||||
if task.status == DownloadStatus.CANCELLED:
|
if task.status == DownloadStatus.CANCELLED:
|
||||||
|
self._save_task_to_db(task)
|
||||||
return
|
return
|
||||||
|
|
||||||
if task.status == DownloadStatus.PAUSED:
|
if task.status == DownloadStatus.PAUSED:
|
||||||
|
self._save_task_to_db(task)
|
||||||
return
|
return
|
||||||
|
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
@@ -295,6 +403,9 @@ class DownloadManager:
|
|||||||
final_size = os.path.getsize(task.file_path) if os.path.exists(task.file_path) else 0
|
final_size = os.path.getsize(task.file_path) if os.path.exists(task.file_path) else 0
|
||||||
logger.info(f" ✅ Completed: {task.filename} ({final_size / (1024*1024):.2f} MB)")
|
logger.info(f" ✅ Completed: {task.filename} ({final_size / (1024*1024):.2f} MB)")
|
||||||
|
|
||||||
|
# Persist to database
|
||||||
|
self._save_task_to_db(task)
|
||||||
|
|
||||||
async def _download_hls(self, m3u8_url: str, task: DownloadTask) -> bool:
|
async def _download_hls(self, m3u8_url: str, task: DownloadTask) -> bool:
|
||||||
"""Download HLS/m3u8 stream using ffmpeg"""
|
"""Download HLS/m3u8 stream using ffmpeg"""
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -386,6 +497,7 @@ class DownloadManager:
|
|||||||
task.downloaded_bytes = file_size
|
task.downloaded_bytes = file_size
|
||||||
task.total_bytes = file_size
|
task.total_bytes = file_size
|
||||||
task.completed_at = datetime.now()
|
task.completed_at = datetime.now()
|
||||||
|
self._save_task_to_db(task)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error(f"HLS download failed: file not created")
|
logger.error(f"HLS download failed: file not created")
|
||||||
|
|||||||
@@ -144,12 +144,34 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
|||||||
logger.info(f"Direct video URL detected: {url[:60]}... -> {filename}")
|
logger.info(f"Direct video URL detected: {url[:60]}... -> {filename}")
|
||||||
return url, filename
|
return url, filename
|
||||||
|
|
||||||
# Check if URL contains the anime page context (format: video_url|anime_page_url|episode_title?)
|
# Check if URL contains the anime page context (format: video_url|...|anime_page_url|episode_title)
|
||||||
|
# The LAST two parts are always anime_page_url and episode_title.
|
||||||
|
# Everything before them is video URLs (multiple sources for fallback).
|
||||||
if "|" in url:
|
if "|" in url:
|
||||||
parts = url.split("|")
|
parts = url.split("|")
|
||||||
video_url = parts[0]
|
# Correctly identify anime_page_url (2nd to last) and episode_title (last)
|
||||||
anime_page_url = parts[1] if len(parts) > 1 else None
|
if len(parts) >= 3:
|
||||||
episode_title = parts[2] if len(parts) > 2 else None
|
# Multiple video URLs + anime_page_url + episode_title
|
||||||
|
potential_anime_url = parts[-2].strip()
|
||||||
|
potential_title = parts[-1].strip()
|
||||||
|
# Validate: anime_page_url should look like a URL
|
||||||
|
# episode_title should NOT look like a URL
|
||||||
|
if potential_title and not potential_title.startswith("http"):
|
||||||
|
anime_page_url = potential_anime_url if potential_anime_url.startswith("http") else None
|
||||||
|
episode_title = potential_title
|
||||||
|
elif len(parts) >= 5 and parts[-2].startswith("http"):
|
||||||
|
# Last part is also a URL (no episode title) - 2nd to last is anime page URL
|
||||||
|
anime_page_url = potential_anime_url
|
||||||
|
episode_title = None
|
||||||
|
else:
|
||||||
|
anime_page_url = None
|
||||||
|
episode_title = None
|
||||||
|
# Pass the full URL to fallback (it parses correctly)
|
||||||
|
video_url = url
|
||||||
|
else:
|
||||||
|
video_url = parts[0]
|
||||||
|
anime_page_url = parts[1] if len(parts) > 1 else None
|
||||||
|
episode_title = None
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Split URL - video: {video_url[:60]}..., anime: {anime_page_url}, episode: {episode_title}"
|
f"Split URL - video: {video_url[:60]}..., anime: {anime_page_url}, episode: {episode_title}"
|
||||||
@@ -160,6 +182,7 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
|||||||
video_url,
|
video_url,
|
||||||
anime_page_url=anime_page_url,
|
anime_page_url=anime_page_url,
|
||||||
episode_title=episode_title,
|
episode_title=episode_title,
|
||||||
|
target_filename=target_filename,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if this is a third-party host URL
|
# Check if this is a third-party host URL
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
self.id = "fs7"
|
self.id = "fs7"
|
||||||
self.provider_id = "fs7"
|
self.provider_id = "fs7"
|
||||||
self.default_domain = "fs7.lol"
|
self.default_domain = "fs7.lol"
|
||||||
self.test_tlds = ["lol", "com", "net", "org", "tv", "ws", "cc", "co"]
|
self.test_tlds = ["lol", "one", "site", "vip", "fun", "stream", "com", "net", "org", "tv", "ws", "cc", "co"]
|
||||||
self.base_url = f"https://{self.default_domain}"
|
self.base_url = f"https://{self.default_domain}"
|
||||||
self._domain_checked = False
|
self._domain_checked = False
|
||||||
self.client.headers.update(
|
self.client.headers.update(
|
||||||
@@ -234,35 +234,93 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
# Clean up title: remove "affiche" suffix
|
# Clean up title: remove "affiche" suffix
|
||||||
title = re.sub(r"\s+affiche$", "", title, flags=re.IGNORECASE).strip()
|
title = re.sub(r"\s+affiche$", "", title, flags=re.IGNORECASE).strip()
|
||||||
|
|
||||||
# Extract description/synopsis
|
# --- Synopsis: div.fdesc > p ---
|
||||||
description_elem = soup.find("div", class_="full-text")
|
description = ""
|
||||||
description = (
|
fdesc = soup.find("div", class_="fdesc")
|
||||||
description_elem.get_text(strip=True) if description_elem else ""
|
if fdesc:
|
||||||
)
|
p = fdesc.find("p")
|
||||||
|
if p:
|
||||||
|
description = p.get_text(strip=True)
|
||||||
|
else:
|
||||||
|
description = fdesc.get_text(strip=True)
|
||||||
|
|
||||||
# Extract cover image
|
# --- Poster: div.fleft > img ---
|
||||||
img = soup.find("img", class_="poster")
|
poster_image = ""
|
||||||
poster_image = img.get("src", "") if img else ""
|
fleft = soup.find("div", class_="fleft")
|
||||||
|
if fleft:
|
||||||
|
img = fleft.find("img")
|
||||||
|
if img:
|
||||||
|
poster_image = (
|
||||||
|
img.get("data-src")
|
||||||
|
or img.get("data-original")
|
||||||
|
or img.get("src")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
# Try to get poster from meta tag if not found
|
# Fallback: img.poster, then og:image
|
||||||
|
if not poster_image:
|
||||||
|
img = soup.find("img", class_="poster")
|
||||||
|
poster_image = img.get("src", "") if img else ""
|
||||||
if not poster_image:
|
if not poster_image:
|
||||||
meta_img = soup.find("meta", property="og:image")
|
meta_img = soup.find("meta", property="og:image")
|
||||||
poster_image = meta_img.get("content", "") if meta_img else ""
|
poster_image = meta_img.get("content", "") if meta_img else ""
|
||||||
|
|
||||||
# Extract year
|
# --- Year: span.release ---
|
||||||
year_match = re.search(r"\b(19|20)\d{2}\b", description)
|
release_year = None
|
||||||
release_year = int(year_match.group()) if year_match else None
|
release_span = soup.find("span", class_="release")
|
||||||
|
if release_span:
|
||||||
|
year_match = re.search(r"\b(19|20)\d{2}\b", release_span.get_text())
|
||||||
|
if year_match:
|
||||||
|
release_year = int(year_match.group())
|
||||||
|
|
||||||
|
# --- Genres: span.genres ---
|
||||||
|
genres = []
|
||||||
|
genres_span = soup.find("span", class_="genres")
|
||||||
|
if genres_span:
|
||||||
|
genres = [
|
||||||
|
g.strip()
|
||||||
|
for g in genres_span.get_text().split(",")
|
||||||
|
if g.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- Runtime: span.runtime ---
|
||||||
|
runtime = None
|
||||||
|
runtime_span = soup.find("span", class_="runtime")
|
||||||
|
if runtime_span:
|
||||||
|
runtime = runtime_span.get_text(strip=True)
|
||||||
|
|
||||||
|
# --- Casting info from second div.flist ---
|
||||||
|
original_title = ""
|
||||||
|
director = ""
|
||||||
|
cast = []
|
||||||
|
flists = soup.find_all("div", class_="flist")
|
||||||
|
for fl in flists:
|
||||||
|
text = fl.get_text(strip=True)
|
||||||
|
if "Titre Original" in text:
|
||||||
|
m = re.search(r"Titre Original\s*:\s*(.+?)(?:Réalisateur|$)", text)
|
||||||
|
if m:
|
||||||
|
original_title = m.group(1).strip()
|
||||||
|
m2 = re.search(r"Réalisateur\s*:\s*(.+?)(?:Avec\s*:|$)", text)
|
||||||
|
if m2:
|
||||||
|
director = m2.group(1).strip()
|
||||||
|
m3 = re.search(r"Avec\s*:\s*(.+?)(?:plus|$)", text)
|
||||||
|
if m3:
|
||||||
|
cast = [c.strip() for c in m3.group(1).split(",") if c.strip()]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"title": title,
|
"title": title,
|
||||||
"synopsis": description,
|
"synopsis": description,
|
||||||
"poster_image": poster_image,
|
"poster_image": poster_image,
|
||||||
"release_year": release_year,
|
"release_year": release_year,
|
||||||
"genres": [],
|
"genres": genres,
|
||||||
"rating": None,
|
"rating": None,
|
||||||
"studio": None,
|
"studio": None,
|
||||||
"total_episodes": None,
|
"total_episodes": None,
|
||||||
"status": None,
|
"status": None,
|
||||||
|
"original_title": original_title,
|
||||||
|
"director": director,
|
||||||
|
"cast": cast,
|
||||||
|
"runtime": runtime,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -301,3 +359,80 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
return await player.get_download_link(url, target_filename)
|
return await player.get_download_link(url, target_filename)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"No video player found for URL: {url}")
|
raise ValueError(f"No video player found for URL: {url}")
|
||||||
|
|
||||||
|
async def get_latest_series(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Scrape the 'Nouveautés Séries' section from FS7 homepage.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with title, url, cover_image, synopsis, lang, provider_id.
|
||||||
|
"""
|
||||||
|
await self._ensure_base_url()
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await self.client.get(self.base_url + "/", timeout=15)
|
||||||
|
soup = BeautifulSoup(resp.text, "html.parser")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch FS7 homepage: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Find the 'Nouveautés Séries' section
|
||||||
|
for section in soup.find_all("div", class_="pages"):
|
||||||
|
title_el = section.find("div", class_="sect-t")
|
||||||
|
if not title_el:
|
||||||
|
continue
|
||||||
|
title = title_el.get_text(strip=True)
|
||||||
|
if "Nouveautés" not in title or "Séries" not in title:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for item in section.find_all("div", class_="short"):
|
||||||
|
# Get the poster link (contains real URL)
|
||||||
|
poster_a = item.find("a", class_="short-poster", href=True)
|
||||||
|
if not poster_a:
|
||||||
|
continue
|
||||||
|
|
||||||
|
url = poster_a["href"]
|
||||||
|
if url.startswith("/"):
|
||||||
|
url = self.base_url + url
|
||||||
|
|
||||||
|
# Title from alt attribute
|
||||||
|
title_attr = poster_a.get("alt", "").strip()
|
||||||
|
if not title_attr:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Poster image
|
||||||
|
img = poster_a.find("img")
|
||||||
|
cover_image = img.get("src", "") if img else ""
|
||||||
|
|
||||||
|
# Synopsis from hidden span
|
||||||
|
desc_span = item.find("span", id=re.compile(r"^desc-\d+"))
|
||||||
|
synopsis = desc_span.get_text(strip=True) if desc_span else ""
|
||||||
|
|
||||||
|
# Language (VF/VOSTFR)
|
||||||
|
lang = "vf"
|
||||||
|
version_span = item.find("span", class_="film-version")
|
||||||
|
if version_span:
|
||||||
|
version_text = version_span.get_text(strip=True).upper()
|
||||||
|
if "VOSTFR" in version_text:
|
||||||
|
lang = "vostfr"
|
||||||
|
elif "VF" in version_text:
|
||||||
|
lang = "vf"
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"title": title_attr,
|
||||||
|
"url": url,
|
||||||
|
"cover_image": cover_image,
|
||||||
|
"synopsis": synopsis,
|
||||||
|
"lang": lang,
|
||||||
|
"provider_id": self.provider_id,
|
||||||
|
"content_type": "series",
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
break # Only process the first matching section
|
||||||
|
|
||||||
|
logger.info(f"FS7 latest series: found {len(results)} items")
|
||||||
|
return results
|
||||||
|
|||||||
@@ -70,3 +70,4 @@ from .watchlist import WatchlistItemTable, WatchlistSettingsTable
|
|||||||
from .favorites import FavoriteTable
|
from .favorites import FavoriteTable
|
||||||
from .sonarr import SonarrMappingTable, SonarrConfigTable
|
from .sonarr import SonarrMappingTable, SonarrConfigTable
|
||||||
from .settings import AppSettingsTable
|
from .settings import AppSettingsTable
|
||||||
|
from .download import DownloadTaskTable
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -27,6 +27,13 @@ class AppSettingsBase(SQLModel):
|
|||||||
|
|
||||||
# #12: Custom download directory
|
# #12: Custom download directory
|
||||||
download_dir: str = Field(default="downloads")
|
download_dir: str = Field(default="downloads")
|
||||||
|
|
||||||
|
# #13: Content weight mode ("auto" = based on download habits, "manual" = user-defined)
|
||||||
|
content_weight_mode: str = Field(default="auto", sa_column=Column(String))
|
||||||
|
|
||||||
|
# #14: Manual content weights (used when content_weight_mode = "manual")
|
||||||
|
content_weight_anime: int = Field(default=2)
|
||||||
|
content_weight_series: int = Field(default=1)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def disabled_providers(self) -> List[str]:
|
def disabled_providers(self) -> List[str]:
|
||||||
@@ -64,6 +71,9 @@ class AppSettings(BaseModel):
|
|||||||
anime_enabled: bool = True
|
anime_enabled: bool = True
|
||||||
series_enabled: bool = True
|
series_enabled: bool = True
|
||||||
download_dir: str = "downloads"
|
download_dir: str = "downloads"
|
||||||
|
content_weight_mode: str = "auto"
|
||||||
|
content_weight_anime: int = 2
|
||||||
|
content_weight_series: int = 1
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -79,3 +89,6 @@ class AppSettingsUpdate(BaseModel):
|
|||||||
anime_enabled: Optional[bool] = None
|
anime_enabled: Optional[bool] = None
|
||||||
series_enabled: Optional[bool] = None
|
series_enabled: Optional[bool] = None
|
||||||
download_dir: Optional[str] = None
|
download_dir: Optional[str] = None
|
||||||
|
content_weight_mode: Optional[str] = None
|
||||||
|
content_weight_anime: Optional[int] = None
|
||||||
|
content_weight_series: Optional[int] = None
|
||||||
|
|||||||
@@ -296,8 +296,7 @@ async def search_series_unified(
|
|||||||
|
|
||||||
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||||
|
|
||||||
# Enrich results with metadata (synopsis, rating, genres)
|
# Enrich results with metadata from scrapers (not Kitsu — Kitsu is anime-only)
|
||||||
enricher = await get_metadata_enricher()
|
|
||||||
enrichment_tasks = []
|
enrichment_tasks = []
|
||||||
enrichment_mapping = []
|
enrichment_mapping = []
|
||||||
|
|
||||||
@@ -308,17 +307,15 @@ async def search_series_unified(
|
|||||||
elif result:
|
elif result:
|
||||||
results[provider_id] = result
|
results[provider_id] = result
|
||||||
print(f"[SERIES SEARCH] {provider_id}: Found {len(result)} results")
|
print(f"[SERIES SEARCH] {provider_id}: Found {len(result)} results")
|
||||||
# Prepare enrichment for top 15 results
|
# Enrich top 10 results with metadata from the scraper itself
|
||||||
for idx, item in enumerate(result[:15]):
|
downloader = series_downloaders.get(provider_id)
|
||||||
if isinstance(item, dict):
|
if downloader and hasattr(downloader, "get_anime_metadata"):
|
||||||
enrichment_tasks.append(
|
for idx, item in enumerate(result[:10]):
|
||||||
enricher.enrich_metadata(
|
if isinstance(item, dict) and item.get("url"):
|
||||||
item.get("metadata") or {},
|
enrichment_tasks.append(
|
||||||
item.get("title") or "",
|
downloader.get_anime_metadata(item["url"])
|
||||||
item.get("url") or "",
|
|
||||||
)
|
)
|
||||||
)
|
enrichment_mapping.append((provider_id, idx))
|
||||||
enrichment_mapping.append((provider_id, idx))
|
|
||||||
else:
|
else:
|
||||||
print(f"[SERIES SEARCH] {provider_id}: No results returned")
|
print(f"[SERIES SEARCH] {provider_id}: No results returned")
|
||||||
|
|
||||||
@@ -334,9 +331,7 @@ async def search_series_unified(
|
|||||||
and provider_id in results
|
and provider_id in results
|
||||||
and pos < len(results[provider_id])
|
and pos < len(results[provider_id])
|
||||||
):
|
):
|
||||||
results[provider_id][pos]["metadata"] = (
|
results[provider_id][pos]["metadata"] = meta
|
||||||
meta.model_dump() if hasattr(meta, "model_dump") else meta
|
|
||||||
)
|
|
||||||
|
|
||||||
# Truncate synopses at sentence boundaries
|
# Truncate synopses at sentence boundaries
|
||||||
for pid in results:
|
for pid in results:
|
||||||
|
|||||||
@@ -3,15 +3,22 @@ Recommendations and releases routes for Ohm Stream Downloader API.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Query, HTTPException, Depends
|
from fastapi import APIRouter, Request, Query, HTTPException, Depends
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.recommendation_engine import RecommendationEngine
|
from app.recommendation_engine import RecommendationEngine
|
||||||
from app.models.auth import User
|
from app.models.auth import User
|
||||||
|
from app.models.settings import AppSettingsTable
|
||||||
|
from app.database import get_session
|
||||||
from app.routers.router_auth import get_optional_user, get_current_user_from_token
|
from app.routers.router_auth import get_optional_user, get_current_user_from_token
|
||||||
|
from app.routers.router_settings import _compute_auto_weights
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["recommendations"])
|
router = APIRouter(prefix="/api", tags=["recommendations"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
@@ -23,6 +30,79 @@ def hash_filter(s):
|
|||||||
templates.env.filters["hash"] = hash_filter
|
templates.env.filters["hash"] = hash_filter
|
||||||
|
|
||||||
|
|
||||||
|
def _get_effective_weights(session: Session, user_id: str) -> tuple:
|
||||||
|
"""Return (anime_enabled, series_enabled, anime_weight, series_weight)."""
|
||||||
|
settings = session.exec(
|
||||||
|
select(AppSettingsTable).where(AppSettingsTable.user_id == user_id)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if settings is None:
|
||||||
|
return True, True, 1, 1
|
||||||
|
|
||||||
|
anime_enabled = getattr(settings, 'anime_enabled', True)
|
||||||
|
series_enabled = getattr(settings, 'series_enabled', True)
|
||||||
|
mode = getattr(settings, 'content_weight_mode', 'auto')
|
||||||
|
download_dir = getattr(settings, 'download_dir', 'downloads')
|
||||||
|
|
||||||
|
if mode == "auto":
|
||||||
|
weights = _compute_auto_weights(download_dir)
|
||||||
|
return anime_enabled, series_enabled, weights["anime_weight"], weights["series_weight"]
|
||||||
|
else:
|
||||||
|
aw = getattr(settings, 'content_weight_anime', 2)
|
||||||
|
sw = getattr(settings, 'content_weight_series', 1)
|
||||||
|
return anime_enabled, series_enabled, int(aw), int(sw)
|
||||||
|
|
||||||
|
|
||||||
|
def _weighted_mix(items_a: List[Dict], items_b: List[Dict], limit: int,
|
||||||
|
weight_a: int = 2, weight_b: int = 1) -> List[Dict]:
|
||||||
|
"""Mix two lists using weights. Distributes items proportionally and interleaves.
|
||||||
|
|
||||||
|
If weight_a=2, weight_b=1 and limit=15:
|
||||||
|
- slots_a ≈ 10, slots_b ≈ 5
|
||||||
|
- B items are spaced evenly across the list
|
||||||
|
If one list is shorter, the other fills remaining slots.
|
||||||
|
"""
|
||||||
|
total_weight = weight_a + weight_b
|
||||||
|
if total_weight == 0:
|
||||||
|
return (items_a + items_b)[:limit]
|
||||||
|
|
||||||
|
slots_a = round(limit * weight_a / total_weight)
|
||||||
|
slots_b = limit - slots_a
|
||||||
|
|
||||||
|
pick_a = min(slots_a, len(items_a))
|
||||||
|
pick_b = min(slots_b, len(items_b))
|
||||||
|
|
||||||
|
# Redistribute unfilled slots
|
||||||
|
if pick_a < slots_a:
|
||||||
|
pick_b = min(pick_b + (slots_a - pick_a), len(items_b))
|
||||||
|
elif pick_b < slots_b:
|
||||||
|
pick_a = min(pick_a + (slots_b - pick_b), len(items_a))
|
||||||
|
|
||||||
|
a = items_a[:pick_a]
|
||||||
|
b = items_b[:pick_b]
|
||||||
|
|
||||||
|
total = pick_a + pick_b
|
||||||
|
if total == 0:
|
||||||
|
return []
|
||||||
|
if pick_b == 0:
|
||||||
|
return a[:limit]
|
||||||
|
if pick_a == 0:
|
||||||
|
return b[:limit]
|
||||||
|
|
||||||
|
# Place B items at evenly spaced positions, fill gaps with A
|
||||||
|
result = [None] * total
|
||||||
|
for i, item in enumerate(b):
|
||||||
|
pos = round(i * (total - 1) / max(pick_b - 1, 1))
|
||||||
|
result[pos] = item
|
||||||
|
a_idx = 0
|
||||||
|
for i in range(total):
|
||||||
|
if result[i] is None:
|
||||||
|
result[i] = a[a_idx]
|
||||||
|
a_idx += 1
|
||||||
|
|
||||||
|
return result[:limit]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recommendations")
|
@router.get("/recommendations")
|
||||||
async def get_recommendations(
|
async def get_recommendations(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -30,8 +110,9 @@ async def get_recommendations(
|
|||||||
html: bool = Query(False),
|
html: bool = Query(False),
|
||||||
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
||||||
current_user: Optional[User] = Depends(get_optional_user),
|
current_user: Optional[User] = Depends(get_optional_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Get personalized anime recommendations based on download history"""
|
"""Get personalized recommendations based on user settings (anime + series)"""
|
||||||
is_htmx = request.headers.get("HX-Request")
|
is_htmx = request.headers.get("HX-Request")
|
||||||
|
|
||||||
if current_user is None and (html or is_htmx):
|
if current_user is None and (html or is_htmx):
|
||||||
@@ -42,14 +123,38 @@ async def get_recommendations(
|
|||||||
if current_user is None:
|
if current_user is None:
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
engine = RecommendationEngine(download_dir="downloads")
|
anime_enabled, series_enabled, anime_weight, series_weight = _get_effective_weights(session, current_user.id)
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
recommendations = await engine.get_personalized_recommendations(limit=limit)
|
if anime_enabled:
|
||||||
|
engine = RecommendationEngine(download_dir="downloads")
|
||||||
# Filter by content_type if specified
|
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":
|
if content_type and content_type != "all":
|
||||||
recommendations = [r for r in recommendations if r.get("content_type", r.get("type", "")) == content_type]
|
recommendations = [r for r in recommendations if r.get("content_type") == content_type]
|
||||||
|
else:
|
||||||
|
anime_items = [r for r in recommendations if r.get("content_type") == "anime"]
|
||||||
|
series_items = [r for r in recommendations if r.get("content_type") == "series"]
|
||||||
|
recommendations = _weighted_mix(anime_items, series_items, limit,
|
||||||
|
weight_a=anime_weight, weight_b=series_weight)
|
||||||
|
|
||||||
if html or is_htmx:
|
if html or is_htmx:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -59,11 +164,8 @@ async def get_recommendations(
|
|||||||
|
|
||||||
return {"recommendations": recommendations, "count": len(recommendations)}
|
return {"recommendations": recommendations, "count": len(recommendations)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
logger.error(f"Recommendations error: {e}", exc_info=True)
|
||||||
logging.getLogger(__name__).error(f"Recommendations error: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
finally:
|
|
||||||
await engine.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/releases/latest")
|
@router.get("/releases/latest")
|
||||||
@@ -72,18 +174,52 @@ async def get_latest_releases(
|
|||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
html: bool = Query(False),
|
html: bool = Query(False),
|
||||||
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
||||||
|
current_user: Optional[User] = Depends(get_optional_user),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Get latest anime releases"""
|
"""Get latest releases based on user settings (anime + series)"""
|
||||||
from app.recommendations import get_latest_releases_with_info
|
from app.recommendations import get_latest_releases_with_info
|
||||||
|
|
||||||
try:
|
is_htmx = request.headers.get("HX-Request")
|
||||||
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]
|
|
||||||
|
|
||||||
if html or request.headers.get("HX-Request"):
|
if current_user is None and (html or is_htmx):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"components/login_prompt.html", {"request": request}
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
|
anime_enabled, series_enabled, anime_weight, series_weight = _get_effective_weights(session, current_user.id)
|
||||||
|
releases = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if anime_enabled:
|
||||||
|
anime_releases = await get_latest_releases_with_info(limit=limit)
|
||||||
|
for r in anime_releases:
|
||||||
|
r['content_type'] = 'anime'
|
||||||
|
releases.extend(anime_releases)
|
||||||
|
|
||||||
|
if series_enabled:
|
||||||
|
try:
|
||||||
|
from app.downloaders.series_sites.fs7 import FS7Downloader
|
||||||
|
downloader = FS7Downloader()
|
||||||
|
series_releases = await downloader.get_latest_series(limit=limit)
|
||||||
|
for r in series_releases:
|
||||||
|
r['content_type'] = 'series'
|
||||||
|
releases.extend(series_releases)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Series releases fetch failed: {e}")
|
||||||
|
|
||||||
|
if content_type and content_type != "all":
|
||||||
|
releases = [r for r in releases if r.get("content_type") == content_type]
|
||||||
|
else:
|
||||||
|
anime_items = [r for r in releases if r.get("content_type") == "anime"]
|
||||||
|
series_items = [r for r in releases if r.get("content_type") == "series"]
|
||||||
|
releases = _weighted_mix(anime_items, series_items, limit,
|
||||||
|
weight_a=anime_weight, weight_b=series_weight)
|
||||||
|
|
||||||
|
if html or is_htmx:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"components/releases_list.html",
|
"components/releases_list.html",
|
||||||
{"request": request, "releases": releases}
|
{"request": request, "releases": releases}
|
||||||
@@ -95,8 +231,7 @@ async def get_latest_releases(
|
|||||||
"updated": datetime.now().isoformat(),
|
"updated": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
logger.error(f"Latest releases error: {e}", exc_info=True)
|
||||||
logging.getLogger(__name__).error(f"Latest releases error: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@@ -177,3 +312,41 @@ async def get_download_statistics(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
finally:
|
finally:
|
||||||
await engine.close()
|
await engine.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/series/latest")
|
||||||
|
async def get_latest_series(
|
||||||
|
request: Request,
|
||||||
|
limit: int = 20,
|
||||||
|
html: bool = Query(False),
|
||||||
|
current_user: Optional[User] = Depends(get_optional_user),
|
||||||
|
):
|
||||||
|
"""Get latest TV series releases from FS7 homepage"""
|
||||||
|
if current_user is None and (html or request.headers.get("HX-Request")):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"components/login_prompt.html", {"request": request}
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.downloaders.series_sites.fs7 import FS7Downloader
|
||||||
|
|
||||||
|
downloader = FS7Downloader()
|
||||||
|
series = await downloader.get_latest_series(limit=limit)
|
||||||
|
|
||||||
|
if html or request.headers.get("HX-Request"):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"components/series_releases_list.html",
|
||||||
|
{"request": request, "releases": series}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"releases": series,
|
||||||
|
"count": len(series),
|
||||||
|
"updated": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Latest series error: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Application settings routes for Ohm Stream Downloader API"""
|
"""Application settings routes for Ohm Stream Downloader API"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -13,10 +15,74 @@ from app.routers.router_auth import get_current_user_from_token, get_optional_us
|
|||||||
from app.providers import get_anime_providers, get_series_providers
|
from app.providers import get_anime_providers, get_series_providers
|
||||||
from app.providers_manager import providers_manager
|
from app.providers_manager import providers_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_auto_weights(download_dir: str) -> Dict[str, Any]:
|
||||||
|
"""Analyze downloaded files to compute anime vs series ratio.
|
||||||
|
|
||||||
|
Uses filename conventions:
|
||||||
|
- Series: contains "Saison" or "Season" keywords
|
||||||
|
- Anime: everything else in the downloads folder
|
||||||
|
Returns dict with counts and computed weights.
|
||||||
|
"""
|
||||||
|
base = Path(download_dir)
|
||||||
|
if not base.exists():
|
||||||
|
return {"anime_count": 0, "series_count": 0, "anime_weight": 1, "series_weight": 1, "total": 0}
|
||||||
|
|
||||||
|
anime_count = 0
|
||||||
|
series_count = 0
|
||||||
|
|
||||||
|
for f in base.rglob("*"):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
if f.suffix.lower() not in (".mp4", ".mkv", ".avi", ".wmv", ".flv", ".mov"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = f.stem.lower()
|
||||||
|
# Heuristic: series TV files often have "Saison" or "Season" + number
|
||||||
|
# Anime files rarely use this format (they use "Episode" or "S01E01")
|
||||||
|
import re
|
||||||
|
if re.search(r'(?:saison|season)\s*\d+', name):
|
||||||
|
series_count += 1
|
||||||
|
else:
|
||||||
|
anime_count += 1
|
||||||
|
|
||||||
|
total = anime_count + series_count
|
||||||
|
if total == 0:
|
||||||
|
return {"anime_count": 0, "series_count": 0, "anime_weight": 1, "series_weight": 1, "total": 0}
|
||||||
|
|
||||||
|
# Compute weights: proportional to download count, minimum 1
|
||||||
|
if anime_count == 0:
|
||||||
|
aw, sw = 0, 1
|
||||||
|
elif series_count == 0:
|
||||||
|
aw, sw = 1, 0
|
||||||
|
else:
|
||||||
|
# Keep weights small (max 5) for reasonable interleaving
|
||||||
|
ratio = anime_count / series_count
|
||||||
|
if ratio >= 4:
|
||||||
|
aw, sw = 4, 1
|
||||||
|
elif ratio >= 2:
|
||||||
|
aw, sw = 2, 1
|
||||||
|
elif ratio >= 1:
|
||||||
|
aw, sw = 1, 1
|
||||||
|
elif ratio >= 0.5:
|
||||||
|
aw, sw = 1, 2
|
||||||
|
else:
|
||||||
|
aw, sw = 1, 4
|
||||||
|
|
||||||
|
return {
|
||||||
|
"anime_count": anime_count,
|
||||||
|
"series_count": series_count,
|
||||||
|
"anime_weight": aw,
|
||||||
|
"series_weight": sw,
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=AppSettings)
|
@router.get("", response_model=AppSettings)
|
||||||
async def get_settings(
|
async def get_settings(
|
||||||
current_user: User = Depends(get_current_user_from_token),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
@@ -44,6 +110,9 @@ async def get_settings(
|
|||||||
anime_enabled=getattr(settings_obj, 'anime_enabled', True),
|
anime_enabled=getattr(settings_obj, 'anime_enabled', True),
|
||||||
series_enabled=getattr(settings_obj, 'series_enabled', True),
|
series_enabled=getattr(settings_obj, 'series_enabled', True),
|
||||||
download_dir=getattr(settings_obj, 'download_dir', 'downloads'),
|
download_dir=getattr(settings_obj, 'download_dir', 'downloads'),
|
||||||
|
content_weight_mode=getattr(settings_obj, 'content_weight_mode', 'auto'),
|
||||||
|
content_weight_anime=getattr(settings_obj, 'content_weight_anime', 2),
|
||||||
|
content_weight_series=getattr(settings_obj, 'content_weight_series', 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -86,6 +155,12 @@ async def update_settings(
|
|||||||
settings_obj.series_enabled = update_data.series_enabled
|
settings_obj.series_enabled = update_data.series_enabled
|
||||||
if update_data.download_dir is not None:
|
if update_data.download_dir is not None:
|
||||||
settings_obj.download_dir = update_data.download_dir
|
settings_obj.download_dir = update_data.download_dir
|
||||||
|
if update_data.content_weight_mode is not None:
|
||||||
|
settings_obj.content_weight_mode = update_data.content_weight_mode
|
||||||
|
if update_data.content_weight_anime is not None:
|
||||||
|
settings_obj.content_weight_anime = update_data.content_weight_anime
|
||||||
|
if update_data.content_weight_series is not None:
|
||||||
|
settings_obj.content_weight_series = update_data.content_weight_series
|
||||||
|
|
||||||
session.add(settings_obj)
|
session.add(settings_obj)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -98,6 +173,34 @@ async def update_settings(
|
|||||||
return settings_obj
|
return settings_obj
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/content-weight")
|
||||||
|
async def get_content_weight(
|
||||||
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Get current effective content weights (auto-computed or manual)"""
|
||||||
|
statement = select(AppSettingsTable).where(
|
||||||
|
AppSettingsTable.user_id == current_user.id
|
||||||
|
)
|
||||||
|
settings_obj = session.exec(statement).first()
|
||||||
|
download_dir = getattr(settings_obj, 'download_dir', 'downloads') if settings_obj else 'downloads'
|
||||||
|
mode = getattr(settings_obj, 'content_weight_mode', 'auto') if settings_obj else 'auto'
|
||||||
|
|
||||||
|
if mode == "auto":
|
||||||
|
weights = _compute_auto_weights(download_dir)
|
||||||
|
weights["mode"] = "auto"
|
||||||
|
return weights
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"mode": "manual",
|
||||||
|
"anime_weight": getattr(settings_obj, 'content_weight_anime', 2),
|
||||||
|
"series_weight": getattr(settings_obj, 'content_weight_series', 1),
|
||||||
|
"anime_count": None,
|
||||||
|
"series_count": None,
|
||||||
|
"total": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/providers/availability")
|
@router.get("/providers/availability")
|
||||||
async def get_providers_availability(
|
async def get_providers_availability(
|
||||||
current_user: User = Depends(get_current_user_from_token),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ async def delete_from_watchlist(
|
|||||||
raise HTTPException(status_code=500, detail="Failed to delete item")
|
raise HTTPException(status_code=500, detail="Failed to delete item")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/check", response_model=List)
|
@router.post("/check")
|
||||||
async def check_watchlist_now(
|
async def check_watchlist_now(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
response: Response,
|
response: Response,
|
||||||
|
|||||||
@@ -95,13 +95,18 @@ class DomainManager:
|
|||||||
response = await client.get(url)
|
response = await client.get(url)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
logger.info(f"Active domain found for {provider_id}: {domain}")
|
# Verify it's actually the right site, not a parking/placeholder page
|
||||||
cls._cache[provider_id] = {
|
content = response.text.lower()
|
||||||
'domain': domain,
|
body_size = len(response.text)
|
||||||
'last_check': datetime.now().isoformat()
|
# Valid pages should be reasonably large and contain expected keywords
|
||||||
}
|
if body_size > 10000 and ('french' in content or 'stream' in content or 'serie' in content or 'anime' in content or 'film' in content or 'telechargement' in content or 'zone' in content):
|
||||||
cls._save_cache()
|
logger.info(f"Active domain found for {provider_id}: {domain} ({body_size} bytes)")
|
||||||
return domain
|
cls._cache[provider_id] = {
|
||||||
|
'domain': domain,
|
||||||
|
'last_check': datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
cls._save_cache()
|
||||||
|
return domain
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Domain test failed for {domain}: {e}")
|
logger.debug(f"Domain test failed for {domain}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -216,8 +216,12 @@ class WatchlistManager:
|
|||||||
update_check_time = update_last_checked
|
update_check_time = update_last_checked
|
||||||
|
|
||||||
def get_due_items(self) -> List[WatchlistItem]:
|
def get_due_items(self) -> List[WatchlistItem]:
|
||||||
|
"""Get all items that are due for a check based on current settings"""
|
||||||
|
return self.get_due_for_check(self.settings.check_interval_hours if self.settings else 6)
|
||||||
|
|
||||||
|
def get_due_for_check(self, interval_hours: int) -> List[WatchlistItem]:
|
||||||
"""Get all items that are due for a check based on settings"""
|
"""Get all items that are due for a check based on settings"""
|
||||||
interval = timedelta(hours=self.settings.check_interval_hours)
|
interval = timedelta(hours=interval_hours)
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
@@ -234,6 +238,12 @@ class WatchlistManager:
|
|||||||
|
|
||||||
return due_items
|
return due_items
|
||||||
|
|
||||||
|
def get_settings(self) -> WatchlistSettings:
|
||||||
|
"""Get global watchlist settings"""
|
||||||
|
if self.settings is None:
|
||||||
|
self._load_settings()
|
||||||
|
return self.settings
|
||||||
|
|
||||||
def update_settings(self, settings: WatchlistSettings) -> WatchlistSettings:
|
def update_settings(self, settings: WatchlistSettings) -> WatchlistSettings:
|
||||||
"""Update global watchlist settings"""
|
"""Update global watchlist settings"""
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
|
|||||||
@@ -86,12 +86,17 @@ async def startup_event():
|
|||||||
|
|
||||||
|
|
||||||
def restore_completed_downloads():
|
def restore_completed_downloads():
|
||||||
"""Scan downloads directory and restore completed download tasks"""
|
"""Restore download tasks: first from the database, then scan for untracked files."""
|
||||||
|
# Step 1: Load persisted tasks from database
|
||||||
|
download_manager._load_tasks_from_db()
|
||||||
|
|
||||||
|
# Step 2: Scan downloads directory for files not yet tracked in the database
|
||||||
download_dir = Path("downloads")
|
download_dir = Path("downloads")
|
||||||
if not download_dir.exists():
|
if not download_dir.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
video_extensions = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm"}
|
video_extensions = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm"}
|
||||||
|
tracked_filenames = {t.filename for t in download_manager.tasks.values()}
|
||||||
|
|
||||||
for file_path in download_dir.iterdir():
|
for file_path in download_dir.iterdir():
|
||||||
if file_path.is_file() and file_path.suffix.lower() in video_extensions:
|
if file_path.is_file() and file_path.suffix.lower() in video_extensions:
|
||||||
@@ -99,6 +104,11 @@ def restore_completed_downloads():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
filename = file_path.name
|
filename = file_path.name
|
||||||
|
|
||||||
|
# Skip if already tracked in DB
|
||||||
|
if filename in tracked_filenames:
|
||||||
|
continue
|
||||||
|
|
||||||
file_size = file_path.stat().st_size
|
file_size = file_path.stat().st_size
|
||||||
|
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
@@ -118,7 +128,8 @@ def restore_completed_downloads():
|
|||||||
)
|
)
|
||||||
|
|
||||||
download_manager.tasks[task_id] = task
|
download_manager.tasks[task_id] = task
|
||||||
logger.info(f"Restored completed download: {filename}")
|
download_manager._save_task_to_db(task)
|
||||||
|
logger.info(f"Restored untracked completed download: {filename}")
|
||||||
|
|
||||||
|
|
||||||
# Restore completed downloads on startup
|
# Restore completed downloads on startup
|
||||||
|
|||||||
@@ -970,6 +970,99 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
/* --- Header --- */
|
||||||
|
header {
|
||||||
|
padding: 16px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Auth panel: compact on mobile --- */
|
||||||
|
.auth-panel {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel > div {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel .btn {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tabs: compact icon mode, scrollable --- */
|
||||||
|
.tabs {
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 0;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 48px;
|
||||||
|
min-width: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active::after {
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Touch targets: iOS minimum 44px --- */
|
||||||
|
.btn,
|
||||||
|
.btn-small,
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-xs {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Input groups --- */
|
||||||
|
.input-group {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input {
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: 16px; /* prevent iOS zoom */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Cards --- */
|
||||||
.anime-grid {
|
.anime-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -979,32 +1072,47 @@ h1 {
|
|||||||
flex: 0 0 140px;
|
flex: 0 0 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs {
|
.hc-play {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
opacity: 1; /* always visible on mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Sections --- */
|
||||||
|
.section-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.section-header h2 {
|
||||||
padding: 10px 16px;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-panel {
|
.section-header .btn {
|
||||||
flex-direction: column;
|
align-self: stretch;
|
||||||
gap: 15px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Layout --- */
|
||||||
|
.container {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Auth container (login page) --- */
|
||||||
.auth-container {
|
.auth-container {
|
||||||
margin: 40px 20px;
|
margin: 20px 16px;
|
||||||
padding: 24px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Downloads --- */
|
||||||
.downloads-grid {
|
.downloads-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Toast --- */
|
||||||
.toast-container {
|
.toast-container {
|
||||||
left: 20px;
|
left: 16px;
|
||||||
right: 20px;
|
right: 16px;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1012,20 +1120,42 @@ h1 {
|
|||||||
min-width: auto;
|
min-width: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Horizontal carousels: full bleed on mobile --- */
|
||||||
|
.home-row,
|
||||||
|
.streaming-row,
|
||||||
|
.recommendations-carousel,
|
||||||
|
.releases-carousel {
|
||||||
|
padding: 10px 0 16px;
|
||||||
|
margin: 0 -16px;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Settings form --- */
|
||||||
|
.settings-section {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Watchlist --- */
|
||||||
|
.watchlist-item {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.container {
|
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 2rem;
|
font-size: 1.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.tab {
|
||||||
padding: 8px 16px;
|
padding: 12px 10px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hc {
|
.hc {
|
||||||
@@ -1035,12 +1165,6 @@ h1 {
|
|||||||
.hc-info {
|
.hc-info {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1400px) {
|
@media (min-width: 1400px) {
|
||||||
|
|||||||
@@ -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}%) — <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 /
|
||||||
|
<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -10,9 +10,9 @@
|
|||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
|
||||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
|
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
|
||||||
|
|
||||||
<!-- External Libraries -->
|
<!-- External Libraries (local first, CDN fallback) -->
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
<script src="/static/vendor/htmx.min.js"></script>
|
||||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
<script src="/static/vendor/alpine.min.js" defer></script>
|
||||||
<script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script>
|
<script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
<script src="/static/js/watchlist.js?v=1.11" defer></script>
|
<script src="/static/js/watchlist.js?v=1.11" defer></script>
|
||||||
<!-- <script src="/static/js/watchlist-ui.js?v=1.11" defer></script> -->
|
<!-- <script src="/static/js/watchlist-ui.js?v=1.11" defer></script> -->
|
||||||
<script src="/static/js/main.js?v=1.11" defer></script>
|
<script src="/static/js/main.js?v=1.11" defer></script>
|
||||||
|
<script src="/static/js/settings.js?v=1.0" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="globalAppState">
|
<body x-data="globalAppState">
|
||||||
{% include "components/toast_container.html" %}
|
{% include "components/toast_container.html" %}
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
{% macro series_card(series, in_watchlist=False, lang='vf') %}
|
{% macro series_card(series) %}
|
||||||
<div class="ac" id="series-{{ series.url | hash }}">
|
<div class="hc"
|
||||||
<div class="ac-poster">
|
@click="activeTab = 'series'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'series' } })); $nextTick(() => { const input = document.getElementById('seriesSearchInput'); if (input) { input.value = '{{ series.title | e }}'; htmx.trigger(input, 'keyup'); } });">
|
||||||
<img src="{{ series.cover_image or 'https://placehold.co/400x600/e6e8e6/f15025?text=No+Image' }}"
|
<div class="hc-poster">
|
||||||
alt="{{ series.title }}" loading="lazy" referrerpolicy="no-referrer"
|
<img src="{{ series.cover_image or 'https://placehold.co/400x600/202327/FF9F1C?text=No+Image' }}" alt="{{ series.title }}" loading="lazy" referrerpolicy="no-referrer"
|
||||||
onerror="this.src='https://placehold.co/400x600/e6e8e6/f15025?text=Error'; this.onerror=null;">
|
onerror="this.src='https://placehold.co/400x600/202327/FF9F1C?text=Error'; this.onerror=null;">
|
||||||
<button class="ac-play"
|
{% if series.lang %}
|
||||||
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
|
<span class="hc-rating" style="text-transform: uppercase;">{{ series.lang }}</span>
|
||||||
hx-target="#player-container" hx-swap="innerHTML">
|
{% endif %}
|
||||||
<i class="fas fa-play"></i>
|
<span class="hc-play"><i class="fas fa-search"></i></span>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ac-info">
|
<div class="hc-info">
|
||||||
<span class="ac-provider" style="--ac-color: var(--secondary)">{{ series.provider_id | upper if series.provider_id else 'FS7' }}</span>
|
<span class="hc-src">{{ series.provider_id or 'FS7' }}</span>
|
||||||
<h3 class="ac-title" title="{{ series.title }}">{{ series.title }}</h3>
|
<span class="hc-title">{{ series.title }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{% from "components/series_card.html" import series_card %}
|
||||||
|
|
||||||
|
{% if releases %}
|
||||||
|
{% for series in releases %}
|
||||||
|
{{ series_card(series) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Aucune sortie recente trouvee.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -89,6 +89,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Weight -->
|
||||||
|
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;">
|
||||||
|
<h3 style="margin-bottom: 5px; color: var(--primary);">Equilibre du fil d'actualite</h3>
|
||||||
|
<p style="color: var(--text-dim); font-size: 13px; margin-bottom: 20px;">
|
||||||
|
Definissez la proportion d'animes et de series affiches dans les recommandations et dernieres sorties.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="content_weight_mode" style="font-weight: 600; margin-bottom: 10px; display: block;">Mode</label>
|
||||||
|
<select name="content_weight_mode" id="content_weight_mode" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;" onchange="onWeightModeChange(this.value)">
|
||||||
|
<option value="auto" {% if settings.content_weight_mode == 'auto' %}selected{% endif %}>Automatique (basé sur vos telechargements)</option>
|
||||||
|
<option value="manual" {% if settings.content_weight_mode == 'manual' %}selected{% endif %}>Manuel</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto mode info -->
|
||||||
|
<div id="weight-auto-info" style="margin-top: 15px; padding: 15px; background: var(--bg-elevated); border-radius: 4px; border: 1px solid var(--secondary); display: {% if settings.content_weight_mode == 'auto' %}block{% else %}none{% endif %};">
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
|
||||||
|
<i class="fas fa-chart-pie" style="color: var(--primary);"></i>
|
||||||
|
<span style="font-weight: 600;">Analyse de vos telechargements</span>
|
||||||
|
</div>
|
||||||
|
<div id="weight-auto-details" style="font-size: 14px; color: var(--text-dim);">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual mode controls -->
|
||||||
|
<div id="weight-manual-controls" style="margin-top: 15px; display: {% if settings.content_weight_mode == 'manual' %}block{% else %}none{% endif %};">
|
||||||
|
<div style="display: flex; gap: 15px; align-items: center;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label for="content_weight_anime" style="font-weight: 600; font-size: 0.9rem; display: block; margin-bottom: 8px;">
|
||||||
|
<i class="fas fa-dragon" style="color: var(--primary);"></i> Poids Animes
|
||||||
|
</label>
|
||||||
|
<input type="range" id="content_weight_anime_range" min="0" max="5" step="1" value="{{ settings.content_weight_anime }}"
|
||||||
|
style="width: 100%; accent-color: var(--primary);"
|
||||||
|
oninput="document.getElementById('content_weight_anime').value = this.value; updateWeightPreview();">
|
||||||
|
<div style="display: flex; justify-content: space-between; font-size: 11px; color: var(--text-dim); margin-top: 2px;">
|
||||||
|
<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label for="content_weight_series" style="font-weight: 600; font-size: 0.9rem; display: block; margin-bottom: 8px;">
|
||||||
|
<i class="fas fa-tv" style="color: #6CB4EE;"></i> Poids Series
|
||||||
|
</label>
|
||||||
|
<input type="range" id="content_weight_series_range" min="0" max="5" step="1" value="{{ settings.content_weight_series }}"
|
||||||
|
style="width: 100%; accent-color: #6CB4EE;"
|
||||||
|
oninput="document.getElementById('content_weight_series').value = this.value; updateWeightPreview();">
|
||||||
|
<div style="display: flex; justify-content: space-between; font-size: 11px; color: var(--text-dim); margin-top: 2px;">
|
||||||
|
<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="content_weight_anime" value="{{ settings.content_weight_anime }}">
|
||||||
|
<input type="hidden" id="content_weight_series" value="{{ settings.content_weight_series }}">
|
||||||
|
<div id="weight-preview" style="margin-top: 15px; padding: 12px; background: var(--bg-elevated); border-radius: 4px; text-align: center; font-size: 14px;">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" style="margin-top: 15px; width: 100%;" onclick="saveManualWeights()">
|
||||||
|
<i class="fas fa-balance-scale"></i> Appliquer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Providers Management -->
|
<!-- Providers Management -->
|
||||||
<div class="settings-card card" style="padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;">
|
<div class="settings-card card" style="padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid #2a2d32;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
@@ -127,93 +189,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
function getToken() {
|
|
||||||
return localStorage.getItem('auth_token') || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSettings() {
|
|
||||||
const token = getToken();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
default_lang: document.getElementById('default_lang').value,
|
|
||||||
theme: document.getElementById('theme').value,
|
|
||||||
download_dir: document.getElementById('download_dir').value,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/settings', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
|
||||||
showToast('Preferences enregistrees', 'success');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Erreur: ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveFilter(field, value) {
|
|
||||||
const token = getToken();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/settings', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ [field]: value })
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
|
||||||
showToast('Filtre mis a jour', 'success');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Erreur: ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleCategory(field, value) {
|
|
||||||
const token = getToken();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
// Prevent disabling both
|
|
||||||
if (!value) {
|
|
||||||
const otherField = field === 'anime_enabled' ? 'series_enabled' : 'anime_enabled';
|
|
||||||
const otherCheckbox = document.getElementById(otherField);
|
|
||||||
if (otherCheckbox && !otherCheckbox.checked) {
|
|
||||||
showToast('Au moins une categorie doit rester active', 'error');
|
|
||||||
document.getElementById(field).checked = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/settings', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ [field]: value })
|
|
||||||
});
|
|
||||||
if (!r.ok) {
|
|
||||||
const err = await r.json().catch(() => ({}));
|
|
||||||
showToast(err.detail || 'Erreur', 'error');
|
|
||||||
document.getElementById(field).checked = !value;
|
|
||||||
} else {
|
|
||||||
showToast('Categorie ' + (value ? 'activee' : 'desactivee'), 'success');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Erreur: ' + e.message, 'error');
|
|
||||||
document.getElementById(field).checked = !value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(message, type) {
|
|
||||||
const event = new CustomEvent('show-toast', { detail: { message, type } });
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.settings-form label {
|
.settings-form label {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<div id="toast-container"
|
<div id="toast-container"
|
||||||
class="toast-container"
|
class="toast-container"
|
||||||
|
style="pointer-events: none;"
|
||||||
x-data="{ toasts: [] }"
|
x-data="{ toasts: [] }"
|
||||||
@show-toast.window="toasts.push({ id: Date.now(), message: $event.detail.message, type: $event.detail.type || 'info' }); setTimeout(() => { toasts = toasts.filter(t => t.id !== toasts[0].id) }, 5000)">
|
@show-toast.window="toasts.push({ id: Date.now(), message: $event.detail.message, type: $event.detail.type || 'info' }); setTimeout(() => { toasts = toasts.filter(t => t.id !== toasts[0].id) }, 5000)">
|
||||||
|
|
||||||
<template x-for="toast in toasts" :key="toast.id">
|
<template x-for="toast in toasts" :key="toast.id">
|
||||||
<div class="toast"
|
<div class="toast"
|
||||||
|
style="pointer-events: auto;"
|
||||||
:class="'toast-' + toast.type"
|
:class="'toast-' + toast.type"
|
||||||
x-show="true"
|
x-show="true"
|
||||||
x-transition:enter="toast-enter"
|
x-transition:enter="toast-enter"
|
||||||
@@ -33,6 +35,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.toast {
|
.toast {
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
|
|||||||
@@ -99,25 +99,11 @@
|
|||||||
|
|
||||||
<hr style="border: none; border-top: 1px solid #2a2d32; margin: 40px 0;">
|
<hr style="border: none; border-top: 1px solid #2a2d32; margin: 40px 0;">
|
||||||
|
|
||||||
<!-- Recommendations Section - Series only -->
|
|
||||||
<div class="section-header">
|
|
||||||
<h2>Recommande pour vous</h2>
|
|
||||||
<button class="btn btn-secondary btn-small"
|
|
||||||
hx-get="/api/recommendations?content_type=series&html=1"
|
|
||||||
hx-target="#seriesRecommendationsList">
|
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
||||||
</svg>
|
|
||||||
Actualiser
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="seriesRecommendationsList" class="recommendations-carousel" style="margin-bottom: 40px;" hx-get="/api/recommendations?content_type=series&html=1" hx-trigger="load delay:600ms"></div>
|
|
||||||
|
|
||||||
<!-- Latest Releases Section - Series only -->
|
<!-- Latest Releases Section - Series only -->
|
||||||
<div class="section-header" style="margin-top: 40px;">
|
<div class="section-header">
|
||||||
<h2>Dernieres sorties Series TV</h2>
|
<h2>Dernieres sorties Series TV</h2>
|
||||||
<button class="btn btn-secondary btn-small"
|
<button class="btn btn-secondary btn-small"
|
||||||
hx-get="/api/releases/latest?content_type=series&html=1"
|
hx-get="/api/series/latest?html=1"
|
||||||
hx-target="#seriesReleasesList">
|
hx-target="#seriesReleasesList">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
@@ -125,7 +111,7 @@
|
|||||||
Actualiser
|
Actualiser
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="seriesReleasesList" class="releases-carousel" hx-get="/api/releases/latest?content_type=series&html=1" hx-trigger="load delay:700ms"></div>
|
<div id="seriesReleasesList" class="releases-carousel" hx-get="/api/series/latest?html=1" hx-trigger="load delay:700ms"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="tab-watchlist" class="tab-content" x-show="activeTab === 'watchlist'">
|
<div id="tab-watchlist" class="tab-content" x-show="activeTab === 'watchlist'">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
- 
|
||||||
|
- 
|
||||||
|
- 
|
||||||
|
- 
|
||||||
|
- 
|
||||||
|
- 
|
||||||
|
- 
|
||||||
|
- 
|
||||||
|
- 
|
||||||
|
- 
|
||||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 709 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 553 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 162 KiB |
@@ -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 => `- `).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);
|
||||||
|
})();
|
||||||