"""Orchestration for the User Favourites feature. Combines the user / collection / watch-history / recommendation services into the operations the API exposes: list users, browse collections, view a collection's items, clean up watched items, and regenerate recommendations. Dry-run is the default for both destructive (cleanup) and bulk (regenerate) actions, and the destructive actions only run on a user's own ``"{Name} Favorites"`` collection. """ from __future__ import annotations import logging from datetime import datetime, timezone from pathlib import Path from . import emby_collections, emby_users, emby_watch_history, recommendations from .recommendations import DEFAULT_TARGET_SIZE LOG_DIR = Path("logs") _logger: logging.Logger | None = None class FavoritesError(Exception): """Raised for expected, user-facing problems (missing user/collection, etc.). Carries an HTTP-ish ``status`` so the route layer can map it cleanly. """ def __init__(self, message: str, status: int = 400): super().__init__(message) self.message = message self.status = status def _get_logger() -> logging.Logger: """Lazily configure a dedicated favourites logger with a file handler. Guards against duplicate handlers across uvicorn reloads. """ global _logger if _logger is not None: return _logger log = logging.getLogger("homelabtoolkit.favorites") log.setLevel(logging.INFO) if not any(isinstance(h, logging.FileHandler) for h in log.handlers): try: LOG_DIR.mkdir(exist_ok=True) handler = logging.FileHandler(LOG_DIR / "favorites.log", encoding="utf-8") handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) log.addHandler(handler) except OSError: # Filesystem unavailable (read-only container): fall back to console. pass _logger = log return log def _now_iso() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") def _log_action(user_name, collection_name, item, reason, action): record = { "timestamp": _now_iso(), "action": action, "user": user_name, "collection": collection_name, "title": item.get("title", ""), "item_id": item.get("id", ""), "reason": reason, } _get_logger().info( "%s | user=%s | collection=%s | item=%s (%s) | reason=%s", action, user_name, collection_name, record["title"], record["item_id"], reason, ) return record async def _resolve_collection(client, collection_id: str, user_id: str) -> tuple[dict, dict]: """Resolve ``(user, collection)`` by id or raise :class:`FavoritesError`.""" user = await emby_users.get_user(client, user_id) if not user: raise FavoritesError(f"No Emby user found for id {user_id!r}.", status=404) collection = next( (c for c in await emby_collections.find_all_collections(client) if c["collection_id"] == collection_id), None, ) if not collection: raise FavoritesError(f"No collection found for id {collection_id!r}.", status=404) return user, collection async def list_favorites_users(client) -> list[dict]: """Users that have a detected ``"{Name} Favorites"`` collection.""" users = await emby_users.fetch_users(client) by_name = {u["name"].casefold(): u for u in users} result = [] for collection in await emby_collections.find_favorites_collections(client): user = by_name.get(collection["owner_name"].casefold()) if not user: continue # orphan collection with no matching user result.append( { "user_id": user["id"], "user_name": user["name"], "collection_id": collection["collection_id"], "collection_name": collection["collection_name"], "item_count": collection.get("item_count"), } ) result.sort(key=lambda r: r["user_name"].casefold()) return result async def list_collections_overview(client) -> dict: """All collections plus all users, for the browse pickers. Each collection is annotated with ``owner_user_id`` when its ``"{Name} Favorites"`` owner resolves to a real Emby user. """ users = await emby_users.fetch_users(client) by_name = {u["name"].casefold(): u for u in users} collections = [] for collection in await emby_collections.find_all_collections(client): owner = by_name.get(collection["owner_name"].casefold()) if collection["owner_name"] else None collections.append({**collection, "owner_user_id": owner["id"] if owner else None}) return {"collections": collections, "users": users} async def get_collection_items_view(client, collection_id: str, user_id: str) -> dict: """View any collection's items with watched status resolved for ``user_id``. Watched status is user-specific. ``actions_enabled`` is true only when the collection is a ``"{Name} Favorites"`` collection and the selected user is its owner, so cleanup/regenerate never touch shared or themed collections. """ user = await emby_users.get_user(client, user_id) if not user: raise FavoritesError(f"No Emby user found for id {user_id!r}.", status=404) collection = next( (c for c in await emby_collections.find_all_collections(client) if c["collection_id"] == collection_id), None, ) if not collection: raise FavoritesError(f"No collection found for id {collection_id!r}.", status=404) items = await emby_collections.list_collection_items(client, collection_id, user_id) watched_count = sum(1 for i in items if i["watched"]) # Cleanup/regenerate are available for any collection. They act on the # selected user's watch data; removal affects the shared collection itself. actions_enabled = True actions_reason = "" return { "collection_id": collection_id, "collection_name": collection["collection_name"], "is_favorites": collection["is_favorites"], "owner_name": collection["owner_name"], "user_id": user["id"], "user_name": user["name"], "actions_enabled": actions_enabled, "actions_reason": actions_reason, "items": items, "summary": { "current_count": len(items), "watched_count": watched_count, "unwatched_count": len(items) - watched_count, }, } async def cleanup_watched(client, collection_id: str, user_id: str, dry_run: bool = True) -> dict: """Preview or remove items the selected user has already watched. Which items are "watched" is resolved per user (via ``UserData.Played`` for this user only). The removal itself operates on the collection, which is shared, so removed items leave the collection for everyone. """ user, collection = await _resolve_collection(client, collection_id, user_id) items = await emby_collections.list_collection_items(client, collection_id, user_id) watched = [i for i in items if i["watched"]] records = [] applied = False if not dry_run and watched: await emby_collections.remove_collection_items( client, collection["collection_id"], [i["id"] for i in watched] ) applied = True records = [ _log_action(user["name"], collection["collection_name"], i, "watched-by-user", "removed") for i in watched ] final_count = len(items) - (len(watched) if applied else 0) return { "dry_run": dry_run, "applied": applied, "user_id": user["id"], "user_name": user["name"], "collection_name": collection["collection_name"], "watched_found": len(watched), "removed": [ {"title": i["title"], "item_id": i["id"], "reason": "watched-by-user"} for i in watched ], "log": records, "summary": { "current_count": len(items), "watched_count": len(watched), "removed_count": len(watched) if applied else 0, "final_count": final_count, }, } async def regenerate( client, collection_id: str, user_id: str, dry_run: bool = True, target_size: int = DEFAULT_TARGET_SIZE, ) -> dict: """Preview or add recommendations derived from the selected user's history. Candidates exclude items the user has watched and items already in the collection, and are never watched items. New items are added until the collection reaches ``target_size``. """ if target_size < 0: raise FavoritesError("targetSize must be zero or greater.", status=400) user, collection = await _resolve_collection(client, collection_id, user_id) items = await emby_collections.list_collection_items(client, collection_id, user_id) current_ids = {i["id"] for i in items if i["id"]} watched_items = await emby_watch_history.get_watched_items(client, user_id) watched_ids = {i["id"] for i in watched_items if i["id"]} profile = recommendations.build_profile(watched_items) summary_base = { "current_count": len(items), "watched_history_count": len(watched_items), "target_size": target_size, } if profile.is_empty: return { "dry_run": dry_run, "applied": False, "user_id": user["id"], "user_name": user["name"], "collection_name": collection["collection_name"], "message": "No watch history for this user yet, so no recommendations can be made.", "recommended": [], "log": [], "summary": {**summary_base, "recommended_count": 0, "added_count": 0, "final_count": len(items)}, } exclude_ids = current_ids | watched_ids candidates = await recommendations.build_candidates(client, user_id, profile, exclude_ids) ranked = recommendations.rank_candidates(candidates, profile) need = max(0, target_size - len(items)) chosen = ranked[:need] records = [] applied = False if not dry_run and chosen: await emby_collections.add_collection_items( client, collection["collection_id"], [c["id"] for c in chosen] ) applied = True records = [ _log_action( user["name"], collection["collection_name"], c, f"recommended (score={c['score']})", "added", ) for c in chosen ] final_count = len(items) + (len(chosen) if applied else 0) return { "dry_run": dry_run, "applied": applied, "user_id": user["id"], "user_name": user["name"], "collection_name": collection["collection_name"], "recommended": [ { "title": c["title"], "item_id": c["id"], "type": c["type"], "year": c["year"], "runtime_minutes": c["runtime_minutes"], "community_rating": c["community_rating"], "score": c["score"], } for c in chosen ], "log": records, "summary": { **summary_base, "recommended_count": len(chosen), "added_count": len(chosen) if applied else 0, "final_count": final_count, }, }