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,157 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.services.jellyfin import get_all_movies, get_played_movie_ids, get_users
|
||||
|
||||
logger = logging.getLogger("movie-night.sync")
|
||||
|
||||
|
||||
async def sync_movie_metadata():
|
||||
"""Full sync of movie metadata from Jellyfin."""
|
||||
logger.info("Starting movie metadata sync...")
|
||||
|
||||
users = await get_users()
|
||||
if not users:
|
||||
logger.warning("No Jellyfin users found, skipping sync")
|
||||
return
|
||||
|
||||
# Use first user to fetch library (all users see the same movies)
|
||||
user_id = users[0]["id"]
|
||||
movies = await get_all_movies(user_id)
|
||||
logger.info(f"Fetched {len(movies)} movies from Jellyfin")
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for movie in movies:
|
||||
await db.execute(
|
||||
"""INSERT OR REPLACE INTO movies
|
||||
(jellyfin_id, title, sort_title, year, genres, overview,
|
||||
community_rating, critic_rating, runtime_minutes, content_rating,
|
||||
studios, people, tags, synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
movie["jellyfin_id"],
|
||||
movie["title"],
|
||||
movie["sort_title"],
|
||||
movie["year"],
|
||||
json.dumps(movie["genres"]),
|
||||
movie["overview"],
|
||||
movie["community_rating"],
|
||||
movie["critic_rating"],
|
||||
movie["runtime_minutes"],
|
||||
movie["content_rating"],
|
||||
json.dumps(movie["studios"]),
|
||||
json.dumps(movie["people"]),
|
||||
json.dumps(movie["tags"]),
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
# Update sync timestamp
|
||||
await db.execute(
|
||||
"INSERT OR REPLACE INTO sync_status (key, value) VALUES ('last_metadata_sync', ?)",
|
||||
(now,),
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(f"Movie metadata sync complete: {len(movies)} movies")
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def sync_watch_state():
|
||||
"""Sync watch state for all users."""
|
||||
logger.info("Starting watch state sync...")
|
||||
|
||||
users = await get_users()
|
||||
db = await get_db()
|
||||
try:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
for user in users:
|
||||
played_ids = await get_played_movie_ids(user["id"])
|
||||
logger.info(f"User {user['name']}: {len(played_ids)} played movies")
|
||||
|
||||
# Get all movie IDs
|
||||
cursor = await db.execute("SELECT jellyfin_id FROM movies")
|
||||
all_movie_ids = {row["jellyfin_id"] for row in await cursor.fetchall()}
|
||||
|
||||
for movie_id in all_movie_ids:
|
||||
is_played = 1 if movie_id in played_ids else 0
|
||||
await db.execute(
|
||||
"""INSERT OR REPLACE INTO watch_state
|
||||
(jellyfin_id, user_id, is_played, synced_at)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(movie_id, user["id"], is_played, now),
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"INSERT OR REPLACE INTO sync_status (key, value) VALUES ('last_watch_sync', ?)",
|
||||
(now,),
|
||||
)
|
||||
await db.commit()
|
||||
logger.info("Watch state sync complete")
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def needs_metadata_sync() -> bool:
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"SELECT value FROM sync_status WHERE key = 'last_metadata_sync'"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return True
|
||||
last_sync = datetime.fromisoformat(row["value"])
|
||||
age_hours = (datetime.now(timezone.utc) - last_sync).total_seconds() / 3600
|
||||
return age_hours > settings.sync_interval_hours
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def needs_watch_sync() -> bool:
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"SELECT value FROM sync_status WHERE key = 'last_watch_sync'"
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return True
|
||||
last_sync = datetime.fromisoformat(row["value"])
|
||||
age_hours = (datetime.now(timezone.utc) - last_sync).total_seconds() / 3600
|
||||
return age_hours > settings.watch_state_sync_hours
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def run_periodic_sync():
|
||||
"""Background task that periodically syncs library data."""
|
||||
# Initial sync on startup
|
||||
try:
|
||||
if await needs_metadata_sync():
|
||||
await sync_movie_metadata()
|
||||
if await needs_watch_sync():
|
||||
await sync_watch_state()
|
||||
except Exception as e:
|
||||
logger.error(f"Initial sync failed: {e}")
|
||||
|
||||
# Periodic sync loop
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(3600) # Check every hour
|
||||
|
||||
if await needs_metadata_sync():
|
||||
await sync_movie_metadata()
|
||||
if await needs_watch_sync():
|
||||
await sync_watch_state()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Periodic sync failed: {e}")
|
||||
Reference in New Issue
Block a user