87f245d3fc
- 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
300 lines
11 KiB
Python
300 lines
11 KiB
Python
"""Application settings routes for Ohm Stream Downloader API"""
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import List, Dict, Any, Optional
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
from fastapi.templating import Jinja2Templates
|
|
from sqlmodel import Session, select
|
|
|
|
from app.database import get_session
|
|
from app.models.auth import User, UserTable
|
|
from app.models.settings import AppSettings, AppSettingsTable, AppSettingsUpdate
|
|
from app.routers.router_auth import get_current_user_from_token, get_optional_user
|
|
from app.providers import get_anime_providers, get_series_providers
|
|
from app.providers_manager import providers_manager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
|
templates = Jinja2Templates(directory="templates")
|
|
|
|
|
|
def _compute_auto_weights(download_dir: str) -> Dict[str, Any]:
|
|
"""Analyze downloaded files to compute anime vs series ratio.
|
|
|
|
Uses filename conventions:
|
|
- Series: contains "Saison" or "Season" keywords
|
|
- Anime: everything else in the downloads folder
|
|
Returns dict with counts and computed weights.
|
|
"""
|
|
base = Path(download_dir)
|
|
if not base.exists():
|
|
return {"anime_count": 0, "series_count": 0, "anime_weight": 1, "series_weight": 1, "total": 0}
|
|
|
|
anime_count = 0
|
|
series_count = 0
|
|
|
|
for f in base.rglob("*"):
|
|
if not f.is_file():
|
|
continue
|
|
if f.suffix.lower() not in (".mp4", ".mkv", ".avi", ".wmv", ".flv", ".mov"):
|
|
continue
|
|
|
|
name = f.stem.lower()
|
|
# Heuristic: series TV files often have "Saison" or "Season" + number
|
|
# Anime files rarely use this format (they use "Episode" or "S01E01")
|
|
import re
|
|
if re.search(r'(?:saison|season)\s*\d+', name):
|
|
series_count += 1
|
|
else:
|
|
anime_count += 1
|
|
|
|
total = anime_count + series_count
|
|
if total == 0:
|
|
return {"anime_count": 0, "series_count": 0, "anime_weight": 1, "series_weight": 1, "total": 0}
|
|
|
|
# Compute weights: proportional to download count, minimum 1
|
|
if anime_count == 0:
|
|
aw, sw = 0, 1
|
|
elif series_count == 0:
|
|
aw, sw = 1, 0
|
|
else:
|
|
# Keep weights small (max 5) for reasonable interleaving
|
|
ratio = anime_count / series_count
|
|
if ratio >= 4:
|
|
aw, sw = 4, 1
|
|
elif ratio >= 2:
|
|
aw, sw = 2, 1
|
|
elif ratio >= 1:
|
|
aw, sw = 1, 1
|
|
elif ratio >= 0.5:
|
|
aw, sw = 1, 2
|
|
else:
|
|
aw, sw = 1, 4
|
|
|
|
return {
|
|
"anime_count": anime_count,
|
|
"series_count": series_count,
|
|
"anime_weight": aw,
|
|
"series_weight": sw,
|
|
"total": total,
|
|
}
|
|
|
|
|
|
@router.get("", response_model=AppSettings)
|
|
async def get_settings(
|
|
current_user: User = Depends(get_current_user_from_token),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
"""Get current application settings for the user"""
|
|
statement = select(AppSettingsTable).where(
|
|
AppSettingsTable.user_id == current_user.id
|
|
)
|
|
settings_obj = session.exec(statement).first()
|
|
|
|
if not settings_obj:
|
|
# Create default settings if they don't exist
|
|
settings_obj = AppSettingsTable(user_id=current_user.id)
|
|
session.add(settings_obj)
|
|
session.commit()
|
|
session.refresh(settings_obj)
|
|
|
|
return AppSettings(
|
|
default_lang=settings_obj.default_lang,
|
|
theme=settings_obj.theme,
|
|
disabled_providers=settings_obj.disabled_providers,
|
|
recommendations_filter=getattr(settings_obj, 'recommendations_filter', 'all'),
|
|
releases_filter=getattr(settings_obj, 'releases_filter', 'all'),
|
|
anime_enabled=getattr(settings_obj, 'anime_enabled', True),
|
|
series_enabled=getattr(settings_obj, 'series_enabled', True),
|
|
download_dir=getattr(settings_obj, 'download_dir', 'downloads'),
|
|
content_weight_mode=getattr(settings_obj, 'content_weight_mode', 'auto'),
|
|
content_weight_anime=getattr(settings_obj, 'content_weight_anime', 2),
|
|
content_weight_series=getattr(settings_obj, 'content_weight_series', 1),
|
|
)
|
|
|
|
|
|
@router.patch("", response_model=AppSettings)
|
|
async def update_settings(
|
|
update_data: AppSettingsUpdate,
|
|
response: Response,
|
|
current_user: User = Depends(get_current_user_from_token),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
"""Update application settings for the user"""
|
|
statement = select(AppSettingsTable).where(
|
|
AppSettingsTable.user_id == current_user.id
|
|
)
|
|
settings_obj = session.exec(statement).first()
|
|
|
|
if not settings_obj:
|
|
settings_obj = AppSettingsTable(user_id=current_user.id)
|
|
session.add(settings_obj)
|
|
|
|
if update_data.default_lang is not None:
|
|
settings_obj.default_lang = update_data.default_lang
|
|
if update_data.theme is not None:
|
|
settings_obj.theme = update_data.theme
|
|
if update_data.disabled_providers is not None:
|
|
settings_obj.disabled_providers = update_data.disabled_providers
|
|
if update_data.recommendations_filter is not None:
|
|
settings_obj.recommendations_filter = update_data.recommendations_filter
|
|
if update_data.releases_filter is not None:
|
|
settings_obj.releases_filter = update_data.releases_filter
|
|
if update_data.anime_enabled is not None:
|
|
# Prevent disabling both categories
|
|
if not update_data.anime_enabled and not getattr(settings_obj, 'series_enabled', True):
|
|
raise HTTPException(status_code=400, detail="Au moins une categorie doit rester active")
|
|
settings_obj.anime_enabled = update_data.anime_enabled
|
|
if update_data.series_enabled is not None:
|
|
# Prevent disabling both categories
|
|
if not update_data.series_enabled and not getattr(settings_obj, 'anime_enabled', True):
|
|
raise HTTPException(status_code=400, detail="Au moins une categorie doit rester active")
|
|
settings_obj.series_enabled = update_data.series_enabled
|
|
if update_data.download_dir is not None:
|
|
settings_obj.download_dir = update_data.download_dir
|
|
if update_data.content_weight_mode is not None:
|
|
settings_obj.content_weight_mode = update_data.content_weight_mode
|
|
if update_data.content_weight_anime is not None:
|
|
settings_obj.content_weight_anime = update_data.content_weight_anime
|
|
if update_data.content_weight_series is not None:
|
|
settings_obj.content_weight_series = update_data.content_weight_series
|
|
|
|
session.add(settings_obj)
|
|
session.commit()
|
|
session.refresh(settings_obj)
|
|
|
|
response.headers["HX-Trigger"] = json.dumps(
|
|
{"show-toast": {"message": "Paramètres enregistrés", "type": "success"}}
|
|
)
|
|
|
|
return settings_obj
|
|
|
|
|
|
@router.get("/content-weight")
|
|
async def get_content_weight(
|
|
current_user: User = Depends(get_current_user_from_token),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
"""Get current effective content weights (auto-computed or manual)"""
|
|
statement = select(AppSettingsTable).where(
|
|
AppSettingsTable.user_id == current_user.id
|
|
)
|
|
settings_obj = session.exec(statement).first()
|
|
download_dir = getattr(settings_obj, 'download_dir', 'downloads') if settings_obj else 'downloads'
|
|
mode = getattr(settings_obj, 'content_weight_mode', 'auto') if settings_obj else 'auto'
|
|
|
|
if mode == "auto":
|
|
weights = _compute_auto_weights(download_dir)
|
|
weights["mode"] = "auto"
|
|
return weights
|
|
else:
|
|
return {
|
|
"mode": "manual",
|
|
"anime_weight": getattr(settings_obj, 'content_weight_anime', 2),
|
|
"series_weight": getattr(settings_obj, 'content_weight_series', 1),
|
|
"anime_count": None,
|
|
"series_count": None,
|
|
"total": None,
|
|
}
|
|
|
|
|
|
@router.get("/providers/availability")
|
|
async def get_providers_availability(
|
|
current_user: User = Depends(get_current_user_from_token),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
"""Get list of providers with their availability and enabled status"""
|
|
# Get user settings
|
|
statement = select(AppSettingsTable).where(
|
|
AppSettingsTable.user_id == current_user.id
|
|
)
|
|
settings_obj = session.exec(statement).first()
|
|
disabled_providers = settings_obj.disabled_providers if settings_obj else []
|
|
|
|
# Get health status
|
|
health_status = providers_manager.get_all_status()
|
|
|
|
# Combine anime and series providers
|
|
all_providers = {**get_anime_providers(), **get_series_providers()}
|
|
|
|
result = []
|
|
for pid, info in all_providers.items():
|
|
status_info = health_status.get(pid, {"status": "unknown"})
|
|
result.append(
|
|
{
|
|
"id": pid,
|
|
"name": info["name"],
|
|
"icon": info.get("icon", "🎬"),
|
|
"status": status_info["status"],
|
|
"enabled": pid not in disabled_providers,
|
|
"error": status_info.get("error"),
|
|
}
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
@router.post("/providers/{provider_id}/toggle")
|
|
async def toggle_provider(
|
|
provider_id: str,
|
|
response: Response,
|
|
current_user: User = Depends(get_current_user_from_token),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
"""Toggle a provider's enabled/disabled status"""
|
|
statement = select(AppSettingsTable).where(
|
|
AppSettingsTable.user_id == current_user.id
|
|
)
|
|
settings_obj = session.exec(statement).first()
|
|
|
|
if not settings_obj:
|
|
settings_obj = AppSettingsTable(user_id=current_user.id)
|
|
session.add(settings_obj)
|
|
|
|
disabled = settings_obj.disabled_providers
|
|
if provider_id in disabled:
|
|
disabled.remove(provider_id)
|
|
enabled = True
|
|
else:
|
|
disabled.append(provider_id)
|
|
enabled = False
|
|
|
|
settings_obj.disabled_providers = disabled
|
|
session.add(settings_obj)
|
|
session.commit()
|
|
|
|
status_text = "activé" if enabled else "désactivé"
|
|
response.headers["HX-Trigger"] = json.dumps(
|
|
{
|
|
"show-toast": {
|
|
"message": f"Fournisseur {provider_id} {status_text}",
|
|
"type": "success",
|
|
}
|
|
}
|
|
)
|
|
|
|
return {"id": provider_id, "enabled": enabled}
|
|
|
|
|
|
@router.get("/ui")
|
|
async def get_settings_ui(
|
|
request: Request,
|
|
current_user: Optional[User] = Depends(get_optional_user),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
if current_user is None:
|
|
return templates.TemplateResponse(
|
|
"components/login_prompt.html", {"request": request}
|
|
)
|
|
|
|
settings = await get_settings(current_user, session)
|
|
providers = await get_providers_availability(current_user, session)
|
|
|
|
return templates.TemplateResponse(
|
|
"components/settings_section.html",
|
|
{"request": request, "settings": settings, "providers": providers},
|
|
)
|