312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""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,
|
||
|
|
},
|
||
|
|
}
|