Files
kbondelie 3d5de06b44 Initial commit — Movie Night media discovery app
AI-powered web app that recommends unwatched movies from a Jellyfin
library based on natural language mood input. Jellyfin auth, modular
LLM backend (Claude/OpenAI/Ollama), two-tier pre-filter + AI ranking,
mobile-responsive dark theme UI with poster cards and deep links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 19:20:56 -07:00

190 lines
5.8 KiB
Python

import httpx
from app.config import settings
_headers = {
"X-MediaBrowser-Token": 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()
auth_header = (
'MediaBrowser Client="Movie Night", Device="Web", DeviceId="movie-night-app", Version="1.0.0"'
)
try:
res = await client.post(
"/Users/AuthenticateByName",
json={"Username": username, "Pw": password},
headers={"X-Emby-Authorization": auth_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(
f"/Users/{user_id}/Items",
params={
"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(
f"/Users/{user_id}/Items",
params={
"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}"