"""Deterministic, watch-history-based recommendation engine. The scoring model is intentionally simple and explainable (section 8 of the spec). It is pure: ``build_profile`` and ``score_candidate`` touch no I/O and are the unit under test. ``build_candidates`` is the only function that calls Emby. """ from __future__ import annotations from dataclasses import dataclass, field from .emby_collections import ITEM_FIELDS, normalize_item # Points awarded per matching facet. Genre is the strongest signal. SCORE_GENRE = 5 # per shared genre SCORE_SERIES = 4 # same series / franchise SCORE_DIRECTOR = 3 # per shared director SCORE_ACTOR = 2 # per shared actor SCORE_STUDIO = 2 # per shared studio SCORE_DECADE = 1 # release decade seen in history SCORE_MEDIA_TYPE = 1 # media type seen in history DEFAULT_TARGET_SIZE = 25 _CANDIDATE_FETCH_LIMIT = 300 _MAX_GENRE_QUERY = 8 def _decade(year) -> int | None: try: return (int(year) // 10) * 10 except (TypeError, ValueError): return None @dataclass class TasteProfile: """Aggregated facets of a user's watch history.""" genres: set[str] = field(default_factory=set) series: set[str] = field(default_factory=set) directors: set[str] = field(default_factory=set) actors: set[str] = field(default_factory=set) studios: set[str] = field(default_factory=set) decades: set[int] = field(default_factory=set) media_types: set[str] = field(default_factory=set) @property def is_empty(self) -> bool: return not ( self.genres or self.series or self.directors or self.actors or self.studios or self.decades or self.media_types ) def build_profile(watched_items: list[dict]) -> TasteProfile: """Aggregate normalized watched items into a :class:`TasteProfile`.""" profile = TasteProfile() for item in watched_items: profile.genres.update(item.get("genres") or []) profile.directors.update(item.get("directors") or []) profile.actors.update(item.get("actors") or []) profile.studios.update(item.get("studios") or []) profile.media_types.add(item.get("media_type") or item.get("type") or "") series = item.get("series_name") if series: profile.series.add(series) # A watched series is itself a "franchise" anchor for similar items. if (item.get("type") == "Series") and item.get("title"): profile.series.add(item["title"]) decade = _decade(item.get("year")) if decade is not None: profile.decades.add(decade) profile.media_types.discard("") return profile def score_candidate(candidate: dict, profile: TasteProfile) -> int: """Score a candidate against the profile. Higher is more similar.""" score = 0 score += SCORE_GENRE * len(set(candidate.get("genres") or []) & profile.genres) score += SCORE_DIRECTOR * len(set(candidate.get("directors") or []) & profile.directors) score += SCORE_ACTOR * len(set(candidate.get("actors") or []) & profile.actors) score += SCORE_STUDIO * len(set(candidate.get("studios") or []) & profile.studios) series = candidate.get("series_name") or (candidate.get("title") if candidate.get("type") == "Series" else None) if series and series in profile.series: score += SCORE_SERIES decade = _decade(candidate.get("year")) if decade is not None and decade in profile.decades: score += SCORE_DECADE media_type = candidate.get("media_type") or candidate.get("type") if media_type and media_type in profile.media_types: score += SCORE_MEDIA_TYPE return score def rank_candidates(candidates: list[dict], profile: TasteProfile) -> list[dict]: """Score, filter to score > 0, and sort candidates. Order: score desc, then community rating desc, then title asc. Each returned item carries an added ``score`` key for transparency in the UI/logs. """ scored = [] for candidate in candidates: score = score_candidate(candidate, profile) if score <= 0: continue scored.append({**candidate, "score": score}) scored.sort( key=lambda c: (-c["score"], -(c.get("community_rating") or 0.0), (c.get("title") or "").casefold()) ) return scored async def build_candidates(client, user_id: str, profile: TasteProfile, exclude_ids: set[str]) -> list[dict]: """Fetch unplayed library items similar to the profile, minus exclusions. Uses the user-scoped ``IsUnplayed`` filter so already-watched items never enter the pool. Anything in ``exclude_ids`` (playlist members, defensively re-checked watched ids) is dropped. """ if profile.is_empty: return [] genres = sorted(profile.genres)[:_MAX_GENRE_QUERY] media_types = ",".join(sorted(t for t in profile.media_types if t)) or "Movie,Series" params = { "Recursive": "true", "Filters": "IsUnplayed", "IsPlayed": "false", "IncludeItemTypes": media_types if media_types in ("Movie", "Series", "Movie,Series") else "Movie,Series", "Fields": ITEM_FIELDS, "EnableUserData": "true", "SortBy": "CommunityRating", "SortOrder": "Descending", "Limit": str(_CANDIDATE_FETCH_LIMIT), } if genres: # Emby treats "|" as OR across genre values. params["Genres"] = "|".join(genres) data = await client.get(f"/Users/{user_id}/Items", params) raw_items = data.get("Items", []) if isinstance(data, dict) else (data or []) seen: set[str] = set() candidates: list[dict] = [] for raw in raw_items: item = normalize_item(raw) item_id = item["id"] if not item_id or item_id in exclude_ids or item_id in seen: continue if item.get("watched"): # defensive: never recommend a watched item continue seen.add(item_id) candidates.append(item) return candidates