Files
embycovers/tests/test_favorites.py
2026-06-08 00:01:55 +12:00

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))