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>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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}"
|
||||
Reference in New Issue
Block a user