Files
movie-night/app/services/jellyfin.py
T

208 lines
6.3 KiB
Python
Raw Normal View History

import httpx
from app.config import settings
2026-09-09 20:49:42 -07:00
def _authorization_header(token: str | None = None) -> str:
values = [
'Client="Movie Night"',
'Device="Web"',
'DeviceId="movie-night-app"',
'Version="1.0.0"',
]
if token:
values.insert(0, f'Token="{token}"')
return f"MediaBrowser {', '.join(values)}"
_headers = {"Authorization": _authorization_header(settings.jellyfin_api_key)}
_client: httpx.AsyncClient | None = None
def _get_client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(
base_url=settings.jellyfin_url,
headers=_headers,
timeout=30.0,
)
return _client
async def authenticate_user(username: str, password: str) -> dict | None:
"""Authenticate a user via Jellyfin. Returns user info + token or None."""
client = _get_client()
try:
res = await client.post(
"/Users/AuthenticateByName",
json={"Username": username, "Pw": password},
2026-09-09 20:49:42 -07:00
headers={"Authorization": _authorization_header()},
)
if res.status_code == 200:
data = res.json()
return {
"user_id": data["User"]["Id"],
"username": data["User"]["Name"],
"token": data["AccessToken"],
}
except httpx.HTTPError:
pass
return None
async def get_users() -> list[dict]:
"""Get all Jellyfin users."""
client = _get_client()
try:
res = await client.get("/Users")
if res.status_code == 200:
return [{"id": u["Id"], "name": u["Name"]} for u in res.json()]
except httpx.HTTPError:
pass
return []
async def get_all_movies(user_id: str) -> list[dict]:
"""Fetch all movies from Jellyfin with full metadata, paginated."""
client = _get_client()
movies = []
start_index = 0
page_size = 200
fields = "Genres,Overview,CommunityRating,CriticRating,OfficialRating,Studios,People,Tags,RunTimeTicks"
while True:
try:
res = await client.get(
"/Items",
params={
"UserId": user_id,
"IncludeItemTypes": "Movie",
"Recursive": "true",
"Fields": fields,
"Limit": page_size,
"StartIndex": start_index,
"SortBy": "SortName",
"SortOrder": "Ascending",
},
)
if res.status_code != 200:
break
data = res.json()
items = data.get("Items", [])
if not items:
break
for item in items:
runtime_ticks = item.get("RunTimeTicks")
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", "")),
}
)
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
if start_index >= total:
break
except httpx.HTTPError:
break
return movies
async def get_played_movie_ids(user_id: str) -> set[str]:
"""Get set of movie IDs that have been played by a user."""
client = _get_client()
played_ids = set()
start_index = 0
page_size = 200
while True:
try:
res = await client.get(
"/Items",
params={
"UserId": user_id,
"IncludeItemTypes": "Movie",
"Recursive": "true",
"IsPlayed": "true",
"Limit": page_size,
"StartIndex": start_index,
"Fields": "",
},
)
if res.status_code != 200:
break
data = res.json()
items = data.get("Items", [])
if not items:
break
for item in items:
played_ids.add(item["Id"])
total = data.get("TotalRecordCount", 0)
start_index += page_size
if start_index >= total:
break
except httpx.HTTPError:
break
return played_ids
async def get_poster(item_id: str) -> bytes | None:
"""Fetch poster image bytes for a movie."""
client = _get_client()
try:
res = await client.get(
f"/Items/{item_id}/Images/Primary",
params={"maxWidth": 400, "quality": 80},
)
if res.status_code == 200:
return res.content
except httpx.HTTPError:
pass
return None
def get_poster_url(item_id: str) -> str:
"""Get the proxied poster URL."""
return f"/api/poster/{item_id}"
def get_deep_link(item_id: str) -> str:
"""Get a direct link to the movie in Jellyfin."""
return f"{settings.jellyfin_external_url}/web/index.html#!/details?id={item_id}"