397 lines
17 KiB
Python
397 lines
17 KiB
Python
"""Tests for the User Favourites feature (Emby Collections / BoxSets).
|
|||
|
|
|
||
|
|
Uses a FakeEmbyClient (no network, no app.py import) and plain ``asyncio.run`` so
|
||
|
|
no pytest-asyncio plugin is required. Run from the repo root: ``python -m pytest``.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||
|
|
|
||
|
|
from services import emby_collections, emby_users, emby_watch_history, favorites
|
||
|
|
from services import recommendations as rec
|
||
|
|
|
||
|
|
|
||
|
|
def run(coro):
|
||
|
|
return asyncio.run(coro)
|
||
|
|
|
||
|
|
|
||
|
|
# ── Item builders ────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def movie(item_id, name, *, genres=None, year=2014, studio="Acme",
|
||
|
|
director="Jane Doe", actor="Bob Roe", ticks=72_000_000_000, rating=7.0):
|
||
|
|
return {
|
||
|
|
"Id": item_id, "Name": name, "Type": "Movie", "MediaType": "Video",
|
||
|
|
"ProductionYear": year, "RunTimeTicks": ticks, "CommunityRating": rating,
|
||
|
|
"Genres": genres or ["Action"],
|
||
|
|
"Studios": [{"Name": studio}] if studio else [],
|
||
|
|
"People": ([{"Name": director, "Type": "Director"}] if director else [])
|
||
|
|
+ ([{"Name": actor, "Type": "Actor"}] if actor else []),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
# ── Fake Emby client ─────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
class FakeEmbyClient:
|
||
|
|
def __init__(self, users=None, collections=None, collection_items=None,
|
||
|
|
watched_by_user=None, watched_history=None, candidates=None):
|
||
|
|
self.users = users or []
|
||
|
|
self.collections = collections or []
|
||
|
|
self.collection_items = collection_items or {} # cid -> [raw items]
|
||
|
|
self.watched_by_user = watched_by_user or {} # uid -> set(item_id)
|
||
|
|
self.watched_history = watched_history or {} # uid -> [raw items]
|
||
|
|
self.candidates = candidates or {} # uid -> [raw items]
|
||
|
|
self.calls = [] # recorded writes
|
||
|
|
|
||
|
|
async def get(self, path, params=None):
|
||
|
|
params = params or {}
|
||
|
|
if path == "/Users":
|
||
|
|
return list(self.users)
|
||
|
|
if path.startswith("/Users/") and path.endswith("/Items"):
|
||
|
|
uid = path.split("/")[2]
|
||
|
|
if "ParentId" in params: # collection children
|
||
|
|
cid = params["ParentId"]
|
||
|
|
watched = self.watched_by_user.get(uid, set())
|
||
|
|
items = []
|
||
|
|
for raw in self.collection_items.get(cid, []):
|
||
|
|
copy = dict(raw)
|
||
|
|
copy["UserData"] = {"Played": raw["Id"] in watched}
|
||
|
|
items.append(copy)
|
||
|
|
return {"Items": items}
|
||
|
|
if "Ids" in params: # is_item_watched
|
||
|
|
wanted = params["Ids"].split(",")
|
||
|
|
watched = self.watched_by_user.get(uid, set())
|
||
|
|
return {"Items": [{"Id": i, "UserData": {"Played": i in watched}} for i in wanted]}
|
||
|
|
if params.get("Filters") == "IsUnplayed": # candidate pool
|
||
|
|
return {"Items": list(self.candidates.get(uid, []))}
|
||
|
|
raise AssertionError(f"unexpected get {path} {params}")
|
||
|
|
|
||
|
|
async def get_all(self, path, params=None):
|
||
|
|
params = params or {}
|
||
|
|
if path == "/Items" and params.get("IncludeItemTypes") == "BoxSet":
|
||
|
|
return list(self.collections)
|
||
|
|
if path.startswith("/Users/") and path.endswith("/Items"):
|
||
|
|
uid = path.split("/")[2]
|
||
|
|
if params.get("Filters") == "IsPlayed":
|
||
|
|
return list(self.watched_history.get(uid, []))
|
||
|
|
raise AssertionError(f"unexpected get_all {path} {params}")
|
||
|
|
|
||
|
|
async def post(self, path, params=None, **kwargs):
|
||
|
|
self.calls.append(("post", path, params or {}))
|
||
|
|
cid = path.split("/")[2]
|
||
|
|
for item_id in (params or {}).get("Ids", "").split(","):
|
||
|
|
if item_id:
|
||
|
|
self.collection_items.setdefault(cid, []).append(
|
||
|
|
{"Id": item_id, "Name": item_id, "Type": "Movie"})
|
||
|
|
return {"ok": True}
|
||
|
|
|
||
|
|
async def delete(self, path, params=None, **kwargs):
|
||
|
|
self.calls.append(("delete", path, params or {}))
|
||
|
|
cid = path.split("/")[2]
|
||
|
|
remove = set((params or {}).get("Ids", "").split(","))
|
||
|
|
self.collection_items[cid] = [
|
||
|
|
i for i in self.collection_items.get(cid, []) if i["Id"] not in remove
|
||
|
|
]
|
||
|
|
return {"ok": True}
|
||
|
|
|
||
|
|
@property
|
||
|
|
def writes(self):
|
||
|
|
return [c for c in self.calls if c[0] in ("post", "delete")]
|
||
|
|
|
||
|
|
|
||
|
|
def base_client():
|
||
|
|
"""Two users, each with a favourites collection; Matt and Dave have watched
|
||
|
|
different items to prove user-specific behaviour. Plus a themed collection
|
||
|
|
that is not a favourites collection."""
|
||
|
|
return FakeEmbyClient(
|
||
|
|
users=[{"Id": "u-matt", "Name": "Matt"}, {"Id": "u-dave", "Name": "Dave"}],
|
||
|
|
collections=[
|
||
|
|
{"Id": "c-matt", "Name": "Matt Favorites", "ChildCount": 3},
|
||
|
|
{"Id": "c-dave", "Name": "Dave Favorites", "ChildCount": 1},
|
||
|
|
{"Id": "c-misc", "Name": "Marvel Universe", "ChildCount": 5},
|
||
|
|
],
|
||
|
|
collection_items={
|
||
|
|
"c-matt": [movie("m1", "Alpha"), movie("m2", "Bravo"), movie("m3", "Charlie")],
|
||
|
|
},
|
||
|
|
# Matt watched m1; Dave watched m2 (same items, different user)
|
||
|
|
watched_by_user={"u-matt": {"m1"}, "u-dave": {"m2"}},
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ── 1. Detecting favourites collections by name ──────────────────────────────
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("name,owner", [
|
||
|
|
("Matt Favorites", "Matt"),
|
||
|
|
("Dave Favorites", "Dave"),
|
||
|
|
("Anna Maria Favorites", "Anna Maria"),
|
||
|
|
("favorites", None),
|
||
|
|
("Favorites", None),
|
||
|
|
("Marvel Universe", None),
|
||
|
|
("Favorites of Matt", None),
|
||
|
|
("", None),
|
||
|
|
(None, None),
|
||
|
|
])
|
||
|
|
def test_parse_favorites_owner(name, owner):
|
||
|
|
assert emby_collections.parse_favorites_owner(name) == owner
|
||
|
|
|
||
|
|
|
||
|
|
def test_list_favorites_users_only_matches_real_users():
|
||
|
|
users = run(favorites.list_favorites_users(base_client()))
|
||
|
|
assert [u["user_name"] for u in users] == ["Dave", "Matt"] # "Marvel Universe" excluded
|
||
|
|
|
||
|
|
|
||
|
|
def test_find_all_collections_includes_non_favorites():
|
||
|
|
cols = run(emby_collections.find_all_collections(base_client()))
|
||
|
|
by_name = {c["collection_name"]: c for c in cols}
|
||
|
|
assert set(by_name) == {"Matt Favorites", "Dave Favorites", "Marvel Universe"}
|
||
|
|
assert by_name["Matt Favorites"]["is_favorites"] is True
|
||
|
|
assert by_name["Marvel Universe"]["is_favorites"] is False
|
||
|
|
assert by_name["Marvel Universe"]["owner_name"] is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_list_collections_overview_maps_owner_user_id():
|
||
|
|
overview = run(favorites.list_collections_overview(base_client()))
|
||
|
|
by_name = {c["collection_name"]: c for c in overview["collections"]}
|
||
|
|
assert by_name["Matt Favorites"]["owner_user_id"] == "u-matt"
|
||
|
|
assert by_name["Marvel Universe"]["owner_user_id"] is None
|
||
|
|
assert {u["name"] for u in overview["users"]} == {"Matt", "Dave"}
|
||
|
|
|
||
|
|
|
||
|
|
# ── 2. Listing collection items, user-specific watched status ────────────────
|
||
|
|
|
||
|
|
def test_list_collection_items_for_user():
|
||
|
|
items = run(emby_collections.list_collection_items(base_client(), "c-matt", "u-matt"))
|
||
|
|
assert [i["title"] for i in items] == ["Alpha", "Bravo", "Charlie"]
|
||
|
|
assert items[0]["runtime_minutes"] == 120
|
||
|
|
|
||
|
|
|
||
|
|
def test_watched_status_is_user_specific():
|
||
|
|
client = base_client()
|
||
|
|
matt = run(emby_collections.list_collection_items(client, "c-matt", "u-matt"))
|
||
|
|
dave = run(emby_collections.list_collection_items(client, "c-matt", "u-dave"))
|
||
|
|
assert [i["watched"] for i in matt] == [True, False, False] # Matt watched m1
|
||
|
|
assert [i["watched"] for i in dave] == [False, True, False] # Dave watched m2
|
||
|
|
|
||
|
|
|
||
|
|
def test_is_item_watched_user_specific():
|
||
|
|
client = base_client()
|
||
|
|
assert run(emby_watch_history.is_item_watched(client, "u-matt", "m1")) is True
|
||
|
|
assert run(emby_watch_history.is_item_watched(client, "u-dave", "m1")) is False
|
||
|
|
|
||
|
|
|
||
|
|
# ── 3. Browse any collection; actions gated to favourites owner ──────────────
|
||
|
|
|
||
|
|
def test_collection_view_actions_enabled_for_any_collection():
|
||
|
|
view = run(favorites.get_collection_items_view(base_client(), "c-matt", "u-matt"))
|
||
|
|
assert view["actions_enabled"] is True
|
||
|
|
assert [i["watched"] for i in view["items"]] == [True, False, False]
|
||
|
|
|
||
|
|
|
||
|
|
def test_collection_view_watched_follows_selected_user():
|
||
|
|
view = run(favorites.get_collection_items_view(base_client(), "c-matt", "u-dave"))
|
||
|
|
assert view["actions_enabled"] is True # available regardless of owner
|
||
|
|
assert [i["watched"] for i in view["items"]] == [False, True, False]
|
||
|
|
|
||
|
|
|
||
|
|
def test_collection_view_actions_enabled_for_non_favorites_collection():
|
||
|
|
client = base_client()
|
||
|
|
client.collection_items["c-misc"] = [movie("x1", "Iron Man")]
|
||
|
|
view = run(favorites.get_collection_items_view(client, "c-misc", "u-matt"))
|
||
|
|
assert view["is_favorites"] is False
|
||
|
|
assert view["actions_enabled"] is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_collection_view_missing_collection_raises_404():
|
||
|
|
with pytest.raises(favorites.FavoritesError) as exc:
|
||
|
|
run(favorites.get_collection_items_view(base_client(), "c-nope", "u-matt"))
|
||
|
|
assert exc.value.status == 404
|
||
|
|
|
||
|
|
|
||
|
|
# ── 4. Cleanup: dry-run vs apply ─────────────────────────────────────────────
|
||
|
|
|
||
|
|
def test_cleanup_dry_run_does_not_modify_emby():
|
||
|
|
client = base_client()
|
||
|
|
res = run(favorites.cleanup_watched(client, "c-matt", "u-matt", dry_run=True))
|
||
|
|
assert res["dry_run"] is True and res["applied"] is False
|
||
|
|
assert res["watched_found"] == 1
|
||
|
|
assert [r["item_id"] for r in res["removed"]] == ["m1"]
|
||
|
|
assert client.writes == []
|
||
|
|
assert len(client.collection_items["c-matt"]) == 3
|
||
|
|
|
||
|
|
|
||
|
|
def test_apply_cleanup_removes_only_watched_items():
|
||
|
|
client = base_client()
|
||
|
|
res = run(favorites.cleanup_watched(client, "c-matt", "u-matt", dry_run=False))
|
||
|
|
assert res["applied"] is True
|
||
|
|
assert res["summary"]["removed_count"] == 1
|
||
|
|
assert res["summary"]["final_count"] == 2
|
||
|
|
deletes = [c for c in client.writes if c[0] == "delete"]
|
||
|
|
assert len(deletes) == 1
|
||
|
|
assert deletes[0][1] == "/Collections/c-matt/Items"
|
||
|
|
assert deletes[0][2]["Ids"] == "m1"
|
||
|
|
assert {i["Id"] for i in client.collection_items["c-matt"]} == {"m2", "m3"}
|
||
|
|
assert res["log"][0]["item_id"] == "m1"
|
||
|
|
assert res["log"][0]["reason"] == "watched-by-user"
|
||
|
|
assert res["log"][0]["user"] == "Matt"
|
||
|
|
assert res["log"][0]["collection"] == "Matt Favorites"
|
||
|
|
|
||
|
|
|
||
|
|
def test_cleanup_does_not_cross_users():
|
||
|
|
"""Dave watched m2; cleaning Matt's collection must not remove m2."""
|
||
|
|
client = base_client()
|
||
|
|
res = run(favorites.cleanup_watched(client, "c-matt", "u-matt", dry_run=False))
|
||
|
|
assert "m2" not in {r["item_id"] for r in res["removed"]}
|
||
|
|
assert "m2" in {i["Id"] for i in client.collection_items["c-matt"]}
|
||
|
|
|
||
|
|
|
||
|
|
def test_cleanup_works_on_non_favorites_collection():
|
||
|
|
"""Cleanup is available for any collection, using the selected user's
|
||
|
|
watched status."""
|
||
|
|
client = base_client()
|
||
|
|
client.collection_items["c-misc"] = [movie("x1", "Iron Man"), movie("x2", "Iron Man 2")]
|
||
|
|
client.watched_by_user["u-matt"] = {"x1"}
|
||
|
|
res = run(favorites.cleanup_watched(client, "c-misc", "u-matt", dry_run=False))
|
||
|
|
assert res["applied"] is True
|
||
|
|
assert [r["item_id"] for r in res["removed"]] == ["x1"]
|
||
|
|
assert {i["Id"] for i in client.collection_items["c-misc"]} == {"x2"}
|
||
|
|
|
||
|
|
|
||
|
|
# ── 5. Recommendation scoring (pure) ─────────────────────────────────────────
|
||
|
|
|
||
|
|
def test_build_profile_and_score_candidate():
|
||
|
|
watched = [emby_collections.normalize_item(movie(
|
||
|
|
"w1", "Seed", genres=["Action", "Sci-Fi"], year=2014,
|
||
|
|
studio="Acme", director="Jane Doe", actor="Bob Roe"))]
|
||
|
|
profile = rec.build_profile(watched)
|
||
|
|
assert profile.genres == {"Action", "Sci-Fi"}
|
||
|
|
assert profile.decades == {2010}
|
||
|
|
|
||
|
|
candidate = emby_collections.normalize_item(movie(
|
||
|
|
"c1", "Match", genres=["Action", "Sci-Fi", "Drama"], year=2013,
|
||
|
|
studio="Acme", director="Jane Doe", actor="Bob Roe"))
|
||
|
|
# 2 genres*5 + director3 + actor2 + studio2 + decade1 + mediatype1 = 19
|
||
|
|
assert rec.score_candidate(candidate, profile) == 19
|
||
|
|
|
||
|
|
|
||
|
|
def test_score_series_franchise_bonus():
|
||
|
|
profile = rec.TasteProfile(series={"The Saga"}, media_types={"Video"})
|
||
|
|
cand = {"series_name": "The Saga", "media_type": "Video", "genres": [], "year": None}
|
||
|
|
assert rec.score_candidate(cand, profile) == rec.SCORE_SERIES + rec.SCORE_MEDIA_TYPE
|
||
|
|
|
||
|
|
|
||
|
|
def test_rank_orders_by_score_then_rating_then_title():
|
||
|
|
profile = rec.TasteProfile(genres={"Action"}, media_types={"Video"},
|
||
|
|
studios={"Acme"}, directors={"Jane Doe"})
|
||
|
|
low = emby_collections.normalize_item(movie("low", "Zeta", genres=["Action"],
|
||
|
|
studio=None, director=None, actor=None, rating=9.0))
|
||
|
|
high = emby_collections.normalize_item(movie("high", "Alpha", genres=["Action"],
|
||
|
|
studio="Acme", director="Jane Doe", actor=None, rating=1.0))
|
||
|
|
ranked = rec.rank_candidates([low, high], profile)
|
||
|
|
assert [r["id"] for r in ranked] == ["high", "low"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_rank_drops_zero_score_candidates():
|
||
|
|
profile = rec.TasteProfile(genres={"Action"})
|
||
|
|
unrelated = {"id": "x", "genres": ["Cooking"], "media_type": "Audio", "year": 1980, "title": "X"}
|
||
|
|
assert rec.rank_candidates([unrelated], profile) == []
|
||
|
|
|
||
|
|
|
||
|
|
# ── 6. Regenerate: exclusions ────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def regen_client():
|
||
|
|
client = base_client()
|
||
|
|
client.watched_history["u-matt"] = [
|
||
|
|
movie("w1", "Seed One", genres=["Action", "Sci-Fi"], year=2014),
|
||
|
|
movie("w2", "Seed Two", genres=["Action"], year=2016),
|
||
|
|
]
|
||
|
|
client.candidates["u-matt"] = [
|
||
|
|
movie("c1", "Good One", genres=["Action", "Sci-Fi"], year=2015),
|
||
|
|
movie("c2", "Good Two", genres=["Action"], year=2012),
|
||
|
|
movie("m2", "Already In Collection", genres=["Action"], year=2015), # in c-matt
|
||
|
|
movie("w1", "Already Watched", genres=["Action"], year=2014), # watched
|
||
|
|
]
|
||
|
|
return client
|
||
|
|
|
||
|
|
|
||
|
|
def test_regenerate_excludes_watched_and_existing_collection_items():
|
||
|
|
res = run(favorites.regenerate(regen_client(), "c-matt", "u-matt", dry_run=True, target_size=25))
|
||
|
|
ids = {r["item_id"] for r in res["recommended"]}
|
||
|
|
assert "m2" not in ids # already in collection
|
||
|
|
assert "w1" not in ids # already watched
|
||
|
|
assert {"c1", "c2"} <= ids
|
||
|
|
|
||
|
|
|
||
|
|
def test_regenerate_dry_run_makes_no_writes():
|
||
|
|
client = regen_client()
|
||
|
|
res = run(favorites.regenerate(client, "c-matt", "u-matt", dry_run=True, target_size=25))
|
||
|
|
assert res["applied"] is False
|
||
|
|
assert client.writes == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_regenerate_apply_adds_up_to_target():
|
||
|
|
client = regen_client()
|
||
|
|
res = run(favorites.regenerate(client, "c-matt", "u-matt", dry_run=False, target_size=4))
|
||
|
|
# collection had 3, target 4 -> add exactly 1
|
||
|
|
assert res["summary"]["added_count"] == 1
|
||
|
|
assert res["summary"]["final_count"] == 4
|
||
|
|
posts = [c for c in client.writes if c[0] == "post"]
|
||
|
|
assert len(posts) == 1
|
||
|
|
assert posts[0][1] == "/Collections/c-matt/Items"
|
||
|
|
assert res["recommended"][0]["item_id"] == "c1" # highest score first
|
||
|
|
|
||
|
|
|
||
|
|
def test_regenerate_never_readds_watched():
|
||
|
|
res = run(favorites.regenerate(regen_client(), "c-matt", "u-matt", dry_run=False, target_size=25))
|
||
|
|
added_ids = {r["item_id"] for r in res["recommended"]}
|
||
|
|
assert "w1" not in added_ids and "w2" not in added_ids
|
||
|
|
|
||
|
|
|
||
|
|
# ── 7. Empty history ─────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def test_regenerate_with_empty_history_recommends_nothing():
|
||
|
|
client = base_client()
|
||
|
|
client.watched_history["u-matt"] = []
|
||
|
|
res = run(favorites.regenerate(client, "c-matt", "u-matt", dry_run=True, target_size=25))
|
||
|
|
assert res["recommended"] == []
|
||
|
|
assert "No watch history" in res["message"]
|
||
|
|
assert client.writes == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_empty_profile_build_candidates_returns_empty():
|
||
|
|
profile = rec.build_profile([])
|
||
|
|
assert profile.is_empty
|
||
|
|
assert run(rec.build_candidates(base_client(), "u-matt", profile, set())) == []
|
||
|
|
|
||
|
|
|
||
|
|
# ── 8. Missing users / collections / API failures ────────────────────────────
|
||
|
|
|
||
|
|
def test_missing_user_raises_404():
|
||
|
|
with pytest.raises(favorites.FavoritesError) as exc:
|
||
|
|
run(favorites.cleanup_watched(base_client(), "c-matt", "u-ghost"))
|
||
|
|
assert exc.value.status == 404
|
||
|
|
|
||
|
|
|
||
|
|
def test_missing_collection_raises_404():
|
||
|
|
with pytest.raises(favorites.FavoritesError) as exc:
|
||
|
|
run(favorites.cleanup_watched(base_client(), "c-nope", "u-matt"))
|
||
|
|
assert exc.value.status == 404
|
||
|
|
|
||
|
|
|
||
|
|
def test_emby_api_failure_propagates():
|
||
|
|
class BoomClient(FakeEmbyClient):
|
||
|
|
async def get_all(self, path, params=None):
|
||
|
|
raise RuntimeError("Emby exploded")
|
||
|
|
|
||
|
|
client = BoomClient(users=[{"Id": "u-matt", "Name": "Matt"}])
|
||
|
|
with pytest.raises(RuntimeError):
|
||
|
|
run(favorites.list_collections_overview(client))
|
||
|
|
|
||
|
|
|
||
|
|
def test_regenerate_rejects_negative_target():
|
||
|
|
with pytest.raises(favorites.FavoritesError):
|
||
|
|
run(favorites.regenerate(base_client(), "c-matt", "u-matt", dry_run=True, target_size=-1))
|