Files
ohm_streaming/app/routers/router_recommendations.py
T
root 87f245d3fc
CI / Test (Python 3.11) (pull_request) Has been cancelled
CI / Test (Python 3.12) (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / Type Check (pull_request) Has been cancelled
CI / Summary (pull_request) Has been cancelled
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
2026-04-11 19:30:32 +00:00

353 lines
12 KiB
Python

"""
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))