diff --git a/app/models.py b/app/models.py index 23be37e..67970bb 100644 --- a/app/models.py +++ b/app/models.py @@ -20,6 +20,10 @@ class Movie(BaseModel): class MoodRequest(BaseModel): mood: str additional_user_ids: list[str] = [] + max_runtime: int | None = None # Max runtime in minutes (None = no limit) + kid_friendly: bool = False # Force PG-13 max rating + boost family genres + exclude_ids: list[str] = [] # Jellyfin IDs to exclude (for re-roll) + history_id: int | None = None # If set, append results to this history entry class Recommendation(BaseModel): diff --git a/app/routers/mood.py b/app/routers/mood.py index 64bab76..9b18834 100644 --- a/app/routers/mood.py +++ b/app/routers/mood.py @@ -32,6 +32,16 @@ async def get_mood_recommendations(request: Request, body: MoodRequest): # Get unwatched movies from cache unwatched_raw = await get_unwatched_movies(user_ids) + + # Exclude previously shown movies (re-roll) + if body.exclude_ids: + exclude_set = set(body.exclude_ids) + unwatched_raw = [m for m in unwatched_raw if m["jellyfin_id"] not in exclude_set] + + # Filter by max runtime + if body.max_runtime: + unwatched_raw = [m for m in unwatched_raw if not m.get("runtime_minutes") or m["runtime_minutes"] <= body.max_runtime] + total_unwatched = len(unwatched_raw) if total_unwatched == 0: @@ -47,7 +57,8 @@ async def get_mood_recommendations(request: Request, body: MoodRequest): # Pre-filter to narrow candidates candidates = prefilter_candidates( - unwatched_raw, body.mood, max_candidates=settings.max_candidates + unwatched_raw, body.mood, max_candidates=settings.max_candidates, + kid_friendly=body.kid_friendly, ) logger.info(f"Pre-filtered {total_unwatched} movies to {len(candidates)} candidates for mood: '{body.mood}'") @@ -98,25 +109,45 @@ async def get_mood_recommendations(request: Request, body: MoodRequest): } # Save to search history + history_id = None try: db = await get_db() try: - await db.execute( - "INSERT INTO search_history (user_id, mood, results, meta, created_at) VALUES (?, ?, ?, ?, ?)", - ( - user["id"], - body.mood, - json.dumps([r.model_dump() for r in recommendations]), - json.dumps(meta), - datetime.now(timezone.utc).isoformat(), - ), - ) + if body.history_id: + # Re-roll: append new results to existing history entry + cursor = await db.execute( + "SELECT results FROM search_history WHERE id = ? AND user_id = ?", + (body.history_id, user["id"]), + ) + row = await cursor.fetchone() + if row: + existing = json.loads(row["results"]) + combined = existing + [r.model_dump() for r in recommendations] + await db.execute( + "UPDATE search_history SET results = ?, meta = ? WHERE id = ? AND user_id = ?", + (json.dumps(combined), json.dumps(meta), body.history_id, user["id"]), + ) + history_id = body.history_id + else: + # Fallback: create new entry if original not found + cursor = await db.execute( + "INSERT INTO search_history (user_id, mood, results, meta, created_at) VALUES (?, ?, ?, ?, ?)", + (user["id"], body.mood, json.dumps([r.model_dump() for r in recommendations]), json.dumps(meta), datetime.now(timezone.utc).isoformat()), + ) + history_id = cursor.lastrowid + else: + cursor = await db.execute( + "INSERT INTO search_history (user_id, mood, results, meta, created_at) VALUES (?, ?, ?, ?, ?)", + (user["id"], body.mood, json.dumps([r.model_dump() for r in recommendations]), json.dumps(meta), datetime.now(timezone.utc).isoformat()), + ) + history_id = cursor.lastrowid await db.commit() finally: await db.close() except Exception as e: logger.warning(f"Failed to save search history: {e}") + meta["history_id"] = history_id return MoodResponse(recommendations=recommendations, meta=meta) diff --git a/app/services/prefilter.py b/app/services/prefilter.py index a683c40..f9576cd 100644 --- a/app/services/prefilter.py +++ b/app/services/prefilter.py @@ -98,7 +98,7 @@ def _parse_movie(raw: dict) -> Movie: ) -def prefilter_candidates(movies_raw: list[dict], mood: str, max_candidates: int = 200) -> list[Movie]: +def prefilter_candidates(movies_raw: list[dict], mood: str, max_candidates: int = 200, kid_friendly: bool = False) -> list[Movie]: """Score and filter movies based on mood signals. Returns top candidates as Movie models.""" mood_lower = mood.lower() @@ -108,6 +108,12 @@ def prefilter_candidates(movies_raw: list[dict], mood: str, max_candidates: int max_rating: str | None = None decade = _parse_decade(mood) + # Kid-friendly toggle overrides + if kid_friendly: + max_rating = "PG-13" + boost_genres.update(["Family", "Animation", "Comedy", "Adventure"]) + penalize_genres.update(["Horror", "Thriller"]) + for keyword, signals in MOOD_SIGNALS.items(): if keyword in mood_lower: boost_genres.update(signals["boost"]) diff --git a/app/static/app.js b/app/static/app.js index 9274f79..05d7bf0 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -3,6 +3,34 @@ const API = ''; let currentUser = null; let selectedUserIds = []; +let maxRuntime = null; +let kidFriendly = false; +let shownMovieIds = []; +let lastMood = ''; +let currentHistoryId = null; + +const SURPRISE_PROMPTS = [ + "pizza night with the kids", + "something scary but not too gory", + "light fun movie after a hard week", + "80s action classic", + "mind-bending sci-fi", + "feel-good comedy", + "epic adventure", + "rainy Sunday afternoon", + "date night romance", + "underrated hidden gem", + "visually stunning cinematography", + "based on a true story", + "twisty thriller with a great ending", + "nostalgic 90s vibes", + "animated movie for all ages", + "something weird and quirky", + "inspiring sports movie", + "cozy mystery", + "space exploration", + "laugh out loud comedy", +]; // --- Auth --- @@ -90,10 +118,18 @@ function toggleUser(userId, pill) { // --- Mood / Recommendations --- -async function findMovies() { +async function findMovies(excludeIds = []) { const mood = document.getElementById('mood-input').value.trim(); if (!mood) return; + // Track mood for re-roll; reset shown IDs if mood changed + const isReroll = excludeIds.length > 0; + if (mood !== lastMood) { + shownMovieIds = []; + currentHistoryId = null; + lastMood = mood; + } + document.getElementById('empty-state').classList.add('hidden'); document.getElementById('results').classList.add('hidden'); document.getElementById('error-state').classList.add('hidden'); @@ -101,10 +137,19 @@ async function findMovies() { try { const additionalIds = selectedUserIds.filter(id => id !== currentUser.id); + const payload = { + mood, + additional_user_ids: additionalIds, + exclude_ids: excludeIds, + kid_friendly: kidFriendly, + }; + if (maxRuntime) payload.max_runtime = maxRuntime; + if (isReroll && currentHistoryId) payload.history_id = currentHistoryId; + const res = await fetch(`${API}/api/mood`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mood, additional_user_ids: additionalIds }) + body: JSON.stringify(payload) }); if (!res.ok) { @@ -113,6 +158,17 @@ async function findMovies() { } const data = await res.json(); + // Track shown movie IDs and history ID for re-roll + if (data.recommendations) { + data.recommendations.forEach(r => { + if (!shownMovieIds.includes(r.jellyfin_id)) { + shownMovieIds.push(r.jellyfin_id); + } + }); + } + if (data.meta && data.meta.history_id) { + currentHistoryId = data.meta.history_id; + } renderResults(data); loadHistory(); } catch (err) { @@ -293,7 +349,7 @@ document.getElementById('login-form').addEventListener('submit', async (e) => { } }); -document.getElementById('find-btn').addEventListener('click', findMovies); +document.getElementById('find-btn').addEventListener('click', () => findMovies()); document.getElementById('mood-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') findMovies(); }); @@ -305,5 +361,37 @@ document.getElementById('show-history-btn').addEventListener('click', () => { loadHistory(); }); +// Runtime filter buttons +document.querySelectorAll('.runtime-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.runtime-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + maxRuntime = btn.dataset.runtime ? parseInt(btn.dataset.runtime) : null; + }); +}); + +// Kid-friendly toggle +document.getElementById('kid-friendly-btn').addEventListener('click', () => { + kidFriendly = !kidFriendly; + const btn = document.getElementById('kid-friendly-btn'); + if (kidFriendly) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } +}); + +// Surprise me +document.getElementById('surprise-btn').addEventListener('click', () => { + const prompt = SURPRISE_PROMPTS[Math.floor(Math.random() * SURPRISE_PROMPTS.length)]; + document.getElementById('mood-input').value = prompt; + findMovies(); +}); + +// Re-roll +document.getElementById('reroll-btn').addEventListener('click', () => { + findMovies(shownMovieIds); +}); + // --- Init --- checkAuth(); diff --git a/app/static/index.html b/app/static/index.html index b1a2e79..d6f11eb 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -65,9 +65,32 @@ Find Movies -

- Try: "pizza night with the kids" · "something scary" · "light fun movie after a hard week" · "80s action" -

+ + +
+ +
+ Max: + + + + +
+ +
+ + + + +
+ + + +
@@ -83,6 +106,11 @@ diff --git a/app/static/styles.css b/app/static/styles.css index d83d9d7..e347cb3 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -31,3 +31,22 @@ border-color: #374151; color: #6b7280; } + +.runtime-btn { + cursor: pointer; +} +.runtime-btn.active { + background: rgba(99, 102, 241, 0.3); + border-color: #6366f1; + color: #c7d2fe; +} +.runtime-btn:not(.active):hover { + border-color: #4b5563; + color: #9ca3af; +} + +#kid-friendly-btn.active { + background: rgba(34, 197, 94, 0.2); + border-color: #22c55e; + color: #86efac; +}