Files
embycovers/services/favorites.py
T

312 lines
11 KiB
Python
Raw Normal View History

2026-06-08 00:01:55 +12:00
"""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,
},
}