Use user-scoped Items queries for Jellyfin movie discovery
This commit is contained in:
1 parent
8507a77215
commit
641cb68da2
3 files changed
+130
-26
No files matched your search
+34
-23
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user