diff --git a/app/services/jellyfin.py b/app/services/jellyfin.py index 221eb4d..6faeb29 100644 --- a/app/services/jellyfin.py +++ b/app/services/jellyfin.py @@ -1,4 +1,5 @@ import httpx + from app.config import settings @@ -74,8 +75,9 @@ async def get_all_movies(user_id: str) -> list[dict]: while True: try: res = await client.get( - f"/Users/{user_id}/Items", + "/Items", params={ + "UserId": user_id, "IncludeItemTypes": "Movie", "Recursive": "true", "Fields": fields, @@ -95,31 +97,39 @@ async def get_all_movies(user_id: str) -> list[dict]: for item in items: runtime_ticks = item.get("RunTimeTicks") - runtime_minutes = int(runtime_ticks / 600_000_000) if runtime_ticks else None + runtime_minutes = ( + int(runtime_ticks / 600_000_000) if runtime_ticks else None + ) people = [] for person in (item.get("People") or [])[:5]: - people.append({ - "name": person.get("Name", ""), - "role": person.get("Role", person.get("Type", "")), - }) + people.append( + { + "name": person.get("Name", ""), + "role": person.get("Role", person.get("Type", "")), + } + ) - movies.append({ - "jellyfin_id": item["Id"], - "title": item.get("Name", "Unknown"), - "sort_title": item.get("SortName"), - "year": item.get("ProductionYear"), - "genres": item.get("Genres", []), - "overview": item.get("Overview"), - "community_rating": item.get("CommunityRating"), - "critic_rating": item.get("CriticRating"), - "runtime_minutes": runtime_minutes, - "content_rating": item.get("OfficialRating"), - "studios": [s.get("Name", "") for s in (item.get("Studios") or [])], - "people": people, - "tags": item.get("Tags", []), - "is_played": item.get("UserData", {}).get("Played", False), - }) + movies.append( + { + "jellyfin_id": item["Id"], + "title": item.get("Name", "Unknown"), + "sort_title": item.get("SortName"), + "year": item.get("ProductionYear"), + "genres": item.get("Genres", []), + "overview": item.get("Overview"), + "community_rating": item.get("CommunityRating"), + "critic_rating": item.get("CriticRating"), + "runtime_minutes": runtime_minutes, + "content_rating": item.get("OfficialRating"), + "studios": [ + s.get("Name", "") for s in (item.get("Studios") or []) + ], + "people": people, + "tags": item.get("Tags", []), + "is_played": item.get("UserData", {}).get("Played", False), + } + ) total = data.get("TotalRecordCount", 0) start_index += page_size @@ -141,8 +151,9 @@ async def get_played_movie_ids(user_id: str) -> set[str]: while True: try: res = await client.get( - f"/Users/{user_id}/Items", + "/Items", params={ + "UserId": user_id, "IncludeItemTypes": "Movie", "Recursive": "true", "IsPlayed": "true", diff --git a/research.md b/research.md index 2346979..afe4525 100644 --- a/research.md +++ b/research.md @@ -74,9 +74,9 @@ With ~32TB of media (mostly movies and TV shows), choosing what to watch has bec ## Custom Build — Integration Points on bondelie-media ### Jellyfin API (port 8096) -- `GET /Users/{userId}/Items` — Full library listing with metadata -- `GET /Users/{userId}/Items?IsPlayed=false` — Unwatched items -- `GET /Users/{userId}/Items?IsPlayed=true` — Watch history +- `GET /Items?UserId={userId}` — Full library listing with metadata +- `GET /Items?UserId={userId}&IsPlayed=false` — Unwatched items +- `GET /Items?UserId={userId}&IsPlayed=true` — Watch history - `GET /Items/{itemId}` — Detailed metadata (genres, cast, overview, ratings, year) - `GET /Items/{itemId}/Images` — Poster art for card UI - Watch status per user (Kenny vs. wife may have different watch states) diff --git a/tests/test_jellyfin.py b/tests/test_jellyfin.py new file mode 100644 index 0000000..b026036 --- /dev/null +++ b/tests/test_jellyfin.py @@ -0,0 +1,93 @@ +"""Exercise user-scoped movie queries without loading local credentials.""" + +import importlib.util +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import patch + +import httpx + + +class JellyfinQueryTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + config = types.ModuleType("app.config") + config.settings = types.SimpleNamespace( + jellyfin_api_key="fixture-token", + jellyfin_url="https://jellyfin.invalid", + jellyfin_external_url="https://jellyfin.invalid", + ) + source = Path(__file__).resolve().parents[1] / "app/services/jellyfin.py" + spec = importlib.util.spec_from_file_location("jellyfin_fixture", source) + self.service = importlib.util.module_from_spec(spec) + with patch.dict(sys.modules, {"app.config": config}): + spec.loader.exec_module(self.service) + + async def asyncTearDown(self): + if self.service._client is not None: + await self.service._client.aclose() + + async def query(self, user_id, *, played_only): + requests = [] + + def respond(request): + requests.append(request) + self.assertEqual(request.url.path, "/Items") + self.assertEqual(request.url.params["UserId"], user_id) + self.assertEqual(request.url.params["IncludeItemTypes"], "Movie") + self.assertEqual(request.url.params["Recursive"], "true") + self.assertEqual(request.url.params["Limit"], "200") + self.assertIn('Token="fixture-token"', request.headers["Authorization"]) + if played_only: + self.assertEqual(request.url.params["IsPlayed"], "true") + else: + self.assertNotIn("IsPlayed", request.url.params) + self.assertIn("RunTimeTicks", request.url.params["Fields"]) + start = int(request.url.params["StartIndex"]) + self.assertIn(start, (0, 200)) + items = [ + { + "Id": f"{user_id}-{index}", + "Name": f"Movie {index}", + "RunTimeTicks": 60_000_000_000, + "UserData": {"Played": played_only or index % 2 == 0}, + } + for index in range(start, min(start + 200, 201)) + ] + return httpx.Response(200, json={"Items": items, "TotalRecordCount": 201}) + + self.service._client = httpx.AsyncClient( + base_url="https://jellyfin.invalid", + headers=self.service._headers, + transport=httpx.MockTransport(respond), + ) + if played_only: + actual = await self.service.get_played_movie_ids(user_id) + self.assertEqual(actual, {f"{user_id}-{i}" for i in range(201)}) + else: + actual = await self.service.get_all_movies(user_id) + self.assertEqual( + [movie["jellyfin_id"] for movie in actual], + [f"{user_id}-{i}" for i in range(201)], + ) + self.assertEqual(actual[0]["runtime_minutes"], 100) + self.assertTrue(actual[0]["is_played"]) + self.assertFalse(actual[1]["is_played"]) + self.assertEqual([r.url.params["StartIndex"] for r in requests], ["0", "200"]) + + async def test_first_user_movie_library(self): + await self.query("first-user", played_only=False) + + async def test_second_user_movie_library(self): + await self.query("second-user", played_only=False) + + async def test_first_user_watch_history(self): + await self.query("first-user", played_only=True) + + async def test_second_user_watch_history(self): + await self.query("second-user", played_only=True) + + +if __name__ == "__main__": + unittest.main()