3d5de06b44
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>
190 lines
5.8 KiB
Python
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}"
|