Use user-scoped Items queries for Jellyfin movie discovery

This commit is contained in:
Kenny Bondelie committed 2026-09-16 14:44:53 -07:00
1 parent 8507a77215
commit 641cb68da2
3 files changed
+130 -26

No files matched your search

+34 -23
View File
@@ -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",
+3 -3
View File
@@ -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)
+93
View File
@@ -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()