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
|
import httpx
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -74,8 +75,9 @@ async def get_all_movies(user_id: str) -> list[dict]:
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
res = await client.get(
|
res = await client.get(
|
||||||
f"/Users/{user_id}/Items",
|
"/Items",
|
||||||
params={
|
params={
|
||||||
|
"UserId": user_id,
|
||||||
"IncludeItemTypes": "Movie",
|
"IncludeItemTypes": "Movie",
|
||||||
"Recursive": "true",
|
"Recursive": "true",
|
||||||
"Fields": fields,
|
"Fields": fields,
|
||||||
@@ -95,31 +97,39 @@ async def get_all_movies(user_id: str) -> list[dict]:
|
|||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
runtime_ticks = item.get("RunTimeTicks")
|
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 = []
|
people = []
|
||||||
for person in (item.get("People") or [])[:5]:
|
for person in (item.get("People") or [])[:5]:
|
||||||
people.append({
|
people.append(
|
||||||
"name": person.get("Name", ""),
|
{
|
||||||
"role": person.get("Role", person.get("Type", "")),
|
"name": person.get("Name", ""),
|
||||||
})
|
"role": person.get("Role", person.get("Type", "")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
movies.append({
|
movies.append(
|
||||||
"jellyfin_id": item["Id"],
|
{
|
||||||
"title": item.get("Name", "Unknown"),
|
"jellyfin_id": item["Id"],
|
||||||
"sort_title": item.get("SortName"),
|
"title": item.get("Name", "Unknown"),
|
||||||
"year": item.get("ProductionYear"),
|
"sort_title": item.get("SortName"),
|
||||||
"genres": item.get("Genres", []),
|
"year": item.get("ProductionYear"),
|
||||||
"overview": item.get("Overview"),
|
"genres": item.get("Genres", []),
|
||||||
"community_rating": item.get("CommunityRating"),
|
"overview": item.get("Overview"),
|
||||||
"critic_rating": item.get("CriticRating"),
|
"community_rating": item.get("CommunityRating"),
|
||||||
"runtime_minutes": runtime_minutes,
|
"critic_rating": item.get("CriticRating"),
|
||||||
"content_rating": item.get("OfficialRating"),
|
"runtime_minutes": runtime_minutes,
|
||||||
"studios": [s.get("Name", "") for s in (item.get("Studios") or [])],
|
"content_rating": item.get("OfficialRating"),
|
||||||
"people": people,
|
"studios": [
|
||||||
"tags": item.get("Tags", []),
|
s.get("Name", "") for s in (item.get("Studios") or [])
|
||||||
"is_played": item.get("UserData", {}).get("Played", False),
|
],
|
||||||
})
|
"people": people,
|
||||||
|
"tags": item.get("Tags", []),
|
||||||
|
"is_played": item.get("UserData", {}).get("Played", False),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
total = data.get("TotalRecordCount", 0)
|
total = data.get("TotalRecordCount", 0)
|
||||||
start_index += page_size
|
start_index += page_size
|
||||||
@@ -141,8 +151,9 @@ async def get_played_movie_ids(user_id: str) -> set[str]:
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
res = await client.get(
|
res = await client.get(
|
||||||
f"/Users/{user_id}/Items",
|
"/Items",
|
||||||
params={
|
params={
|
||||||
|
"UserId": user_id,
|
||||||
"IncludeItemTypes": "Movie",
|
"IncludeItemTypes": "Movie",
|
||||||
"Recursive": "true",
|
"Recursive": "true",
|
||||||
"IsPlayed": "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
|
## Custom Build — Integration Points on bondelie-media
|
||||||
|
|
||||||
### Jellyfin API (port 8096)
|
### Jellyfin API (port 8096)
|
||||||
- `GET /Users/{userId}/Items` — Full library listing with metadata
|
- `GET /Items?UserId={userId}` — Full library listing with metadata
|
||||||
- `GET /Users/{userId}/Items?IsPlayed=false` — Unwatched items
|
- `GET /Items?UserId={userId}&IsPlayed=false` — Unwatched items
|
||||||
- `GET /Users/{userId}/Items?IsPlayed=true` — Watch history
|
- `GET /Items?UserId={userId}&IsPlayed=true` — Watch history
|
||||||
- `GET /Items/{itemId}` — Detailed metadata (genres, cast, overview, ratings, year)
|
- `GET /Items/{itemId}` — Detailed metadata (genres, cast, overview, ratings, year)
|
||||||
- `GET /Items/{itemId}/Images` — Poster art for card UI
|
- `GET /Items/{itemId}/Images` — Poster art for card UI
|
||||||
- Watch status per user (Kenny vs. wife may have different watch states)
|
- 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