""" Recommendations and releases routes for Ohm Stream Downloader API. """ import hashlib import logging from datetime import datetime from typing import Optional, List, Dict, Any from fastapi import APIRouter, Request, Query, HTTPException, Depends from fastapi.templating import Jinja2Templates from sqlmodel import Session, select from app.recommendation_engine import RecommendationEngine from app.models.auth import User from app.models.settings import AppSettingsTable from app.database import get_session from app.routers.router_auth import get_optional_user, get_current_user_from_token from app.routers.router_settings import _compute_auto_weights logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["recommendations"]) templates = Jinja2Templates(directory="templates") # Add custom filters to Jinja2 def hash_filter(s): return hashlib.md5(s.encode()).hexdigest()[:10] templates.env.filters["hash"] = hash_filter def _get_effective_weights(session: Session, user_id: str) -> tuple: """Return (anime_enabled, series_enabled, anime_weight, series_weight).""" settings = session.exec( select(AppSettingsTable).where(AppSettingsTable.user_id == user_id) ).first() if settings is None: return True, True, 1, 1 anime_enabled = getattr(settings, 'anime_enabled', True) series_enabled = getattr(settings, 'series_enabled', True) mode = getattr(settings, 'content_weight_mode', 'auto') download_dir = getattr(settings, 'download_dir', 'downloads') if mode == "auto": weights = _compute_auto_weights(download_dir) return anime_enabled, series_enabled, weights["anime_weight"], weights["series_weight"] else: aw = getattr(settings, 'content_weight_anime', 2) sw = getattr(settings, 'content_weight_series', 1) return anime_enabled, series_enabled, int(aw), int(sw) def _weighted_mix(items_a: List[Dict], items_b: List[Dict], limit: int, weight_a: int = 2, weight_b: int = 1) -> List[Dict]: """Mix two lists using weights. Distributes items proportionally and interleaves. If weight_a=2, weight_b=1 and limit=15: - slots_a ≈ 10, slots_b ≈ 5 - B items are spaced evenly across the list If one list is shorter, the other fills remaining slots. """ total_weight = weight_a + weight_b if total_weight == 0: return (items_a + items_b)[:limit] slots_a = round(limit * weight_a / total_weight) slots_b = limit - slots_a pick_a = min(slots_a, len(items_a)) pick_b = min(slots_b, len(items_b)) # Redistribute unfilled slots if pick_a < slots_a: pick_b = min(pick_b + (slots_a - pick_a), len(items_b)) elif pick_b < slots_b: pick_a = min(pick_a + (slots_b - pick_b), len(items_a)) a = items_a[:pick_a] b = items_b[:pick_b] total = pick_a + pick_b if total == 0: return [] if pick_b == 0: return a[:limit] if pick_a == 0: return b[:limit] # Place B items at evenly spaced positions, fill gaps with A result = [None] * total for i, item in enumerate(b): pos = round(i * (total - 1) / max(pick_b - 1, 1)) result[pos] = item a_idx = 0 for i in range(total): if result[i] is None: result[i] = a[a_idx] a_idx += 1 return result[:limit] @router.get("/recommendations") async def get_recommendations( request: Request, limit: int = 15, html: bool = Query(False), content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"), current_user: Optional[User] = Depends(get_optional_user), session: Session = Depends(get_session), ): """Get personalized recommendations based on user settings (anime + series)""" is_htmx = request.headers.get("HX-Request") if current_user is None and (html or is_htmx): return templates.TemplateResponse( "components/login_prompt.html", {"request": request} ) if current_user is None: raise HTTPException(status_code=401, detail="Authentication required") anime_enabled, series_enabled, anime_weight, series_weight = _get_effective_weights(session, current_user.id) recommendations = [] try: if anime_enabled: engine = RecommendationEngine(download_dir="downloads") try: anime_recs = await engine.get_personalized_recommendations(limit=limit) for r in anime_recs: r['content_type'] = 'anime' recommendations.extend(anime_recs) finally: await engine.close() if series_enabled: try: from app.downloaders.series_sites.fs7 import FS7Downloader downloader = FS7Downloader() series_recs = await downloader.get_latest_series(limit=limit) for r in series_recs: r['content_type'] = 'series' recommendations.extend(series_recs) except Exception as e: logger.warning(f"Series recommendations fetch failed: {e}") if content_type and content_type != "all": recommendations = [r for r in recommendations if r.get("content_type") == content_type] else: anime_items = [r for r in recommendations if r.get("content_type") == "anime"] series_items = [r for r in recommendations if r.get("content_type") == "series"] recommendations = _weighted_mix(anime_items, series_items, limit, weight_a=anime_weight, weight_b=series_weight) if html or is_htmx: return templates.TemplateResponse( "components/recommendations_list.html", {"request": request, "recommendations": recommendations} ) return {"recommendations": recommendations, "count": len(recommendations)} except Exception as e: logger.error(f"Recommendations error: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/releases/latest") async def get_latest_releases( request: Request, limit: int = 20, html: bool = Query(False), content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"), current_user: Optional[User] = Depends(get_optional_user), session: Session = Depends(get_session), ): """Get latest releases based on user settings (anime + series)""" from app.recommendations import get_latest_releases_with_info is_htmx = request.headers.get("HX-Request") if current_user is None and (html or is_htmx): return templates.TemplateResponse( "components/login_prompt.html", {"request": request} ) if current_user is None: raise HTTPException(status_code=401, detail="Authentication required") anime_enabled, series_enabled, anime_weight, series_weight = _get_effective_weights(session, current_user.id) releases = [] try: if anime_enabled: anime_releases = await get_latest_releases_with_info(limit=limit) for r in anime_releases: r['content_type'] = 'anime' releases.extend(anime_releases) if series_enabled: try: from app.downloaders.series_sites.fs7 import FS7Downloader downloader = FS7Downloader() series_releases = await downloader.get_latest_series(limit=limit) for r in series_releases: r['content_type'] = 'series' releases.extend(series_releases) except Exception as e: logger.warning(f"Series releases fetch failed: {e}") if content_type and content_type != "all": releases = [r for r in releases if r.get("content_type") == content_type] else: anime_items = [r for r in releases if r.get("content_type") == "anime"] series_items = [r for r in releases if r.get("content_type") == "series"] releases = _weighted_mix(anime_items, series_items, limit, weight_a=anime_weight, weight_b=series_weight) if html or is_htmx: return templates.TemplateResponse( "components/releases_list.html", {"request": request, "releases": releases} ) return { "releases": releases, "count": len(releases), "updated": datetime.now().isoformat(), } except Exception as e: logger.error(f"Latest releases error: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/releases/seasonal") async def get_seasonal_anime( year: Optional[int] = None, season: Optional[str] = None, ): """Get current/previously seasonal anime""" from app.recommendations import AnimeReleasesFetcher fetcher = AnimeReleasesFetcher() try: anime = await fetcher.get_seasonal_anime(year, season) return { "anime": anime, "count": len(anime), "year": year or datetime.now().year, "season": season or "current", } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: await fetcher.close() @router.get("/releases/scheduled") async def get_scheduled_anime(day: Optional[str] = None): """Get anime scheduled for a specific day""" from app.recommendations import AnimeReleasesFetcher fetcher = AnimeReleasesFetcher() try: anime = await fetcher.get_scheduled_anime(day) return {"anime": anime, "count": len(anime), "day": day or "today"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: await fetcher.close() @router.get("/releases/top") async def get_top_anime( type: str = "tv", limit: int = 15, ): """Get top rated anime""" from app.recommendations import AnimeReleasesFetcher fetcher = AnimeReleasesFetcher() try: anime = await fetcher.get_top_anime(type=type, limit=limit) return {"anime": anime, "count": len(anime)} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: await fetcher.close() @router.get("/stats/downloads") async def get_download_statistics( current_user: User = Depends(get_current_user_from_token), ): """Get download statistics and preferences""" engine = RecommendationEngine(download_dir="downloads") try: stats = await engine.get_download_stats() return stats except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: await engine.close() @router.get("/series/latest") async def get_latest_series( request: Request, limit: int = 20, html: bool = Query(False), current_user: Optional[User] = Depends(get_optional_user), ): """Get latest TV series releases from FS7 homepage""" if current_user is None and (html or request.headers.get("HX-Request")): return templates.TemplateResponse( "components/login_prompt.html", {"request": request} ) if current_user is None: raise HTTPException(status_code=401, detail="Authentication required") try: from app.downloaders.series_sites.fs7 import FS7Downloader downloader = FS7Downloader() series = await downloader.get_latest_series(limit=limit) if html or request.headers.get("HX-Request"): return templates.TemplateResponse( "components/series_releases_list.html", {"request": request, "releases": series} ) return { "releases": series, "count": len(series), "updated": datetime.now().isoformat(), } except Exception as e: logger.error(f"Latest series error: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e))