Add runtime filter, kid-friendly toggle, surprise me, and re-roll

- Runtime quick-select buttons (Any/90m/2h/2.5h) filter movies by length
- Kid-friendly toggle forces PG-13 max and boosts Family/Animation genres
- Surprise Me picks a random mood prompt from 20 curated options
- Show Me More re-rolls same mood excluding already-shown movies
- Re-roll appends new results to the existing search history entry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 20:07:05 -07:00
parent d8c8b473ad
commit 9f96a91986
6 changed files with 194 additions and 18 deletions
+4
View File
@@ -20,6 +20,10 @@ class Movie(BaseModel):
class MoodRequest(BaseModel): class MoodRequest(BaseModel):
mood: str mood: str
additional_user_ids: list[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): class Recommendation(BaseModel):
+41 -10
View File
@@ -32,6 +32,16 @@ async def get_mood_recommendations(request: Request, body: MoodRequest):
# Get unwatched movies from cache # Get unwatched movies from cache
unwatched_raw = await get_unwatched_movies(user_ids) 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) total_unwatched = len(unwatched_raw)
if total_unwatched == 0: if total_unwatched == 0:
@@ -47,7 +57,8 @@ async def get_mood_recommendations(request: Request, body: MoodRequest):
# Pre-filter to narrow candidates # Pre-filter to narrow candidates
candidates = prefilter_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}'") 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 # Save to search history
history_id = None
try: try:
db = await get_db() db = await get_db()
try: try:
await db.execute( if body.history_id:
"INSERT INTO search_history (user_id, mood, results, meta, created_at) VALUES (?, ?, ?, ?, ?)", # Re-roll: append new results to existing history entry
( cursor = await db.execute(
user["id"], "SELECT results FROM search_history WHERE id = ? AND user_id = ?",
body.mood, (body.history_id, user["id"]),
json.dumps([r.model_dump() for r in recommendations]),
json.dumps(meta),
datetime.now(timezone.utc).isoformat(),
),
) )
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() await db.commit()
finally: finally:
await db.close() await db.close()
except Exception as e: except Exception as e:
logger.warning(f"Failed to save search history: {e}") logger.warning(f"Failed to save search history: {e}")
meta["history_id"] = history_id
return MoodResponse(recommendations=recommendations, meta=meta) return MoodResponse(recommendations=recommendations, meta=meta)
+7 -1
View File
@@ -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.""" """Score and filter movies based on mood signals. Returns top candidates as Movie models."""
mood_lower = mood.lower() 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 max_rating: str | None = None
decade = _parse_decade(mood) 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(): for keyword, signals in MOOD_SIGNALS.items():
if keyword in mood_lower: if keyword in mood_lower:
boost_genres.update(signals["boost"]) boost_genres.update(signals["boost"])
+91 -3
View File
@@ -3,6 +3,34 @@
const API = ''; const API = '';
let currentUser = null; let currentUser = null;
let selectedUserIds = []; 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 --- // --- Auth ---
@@ -90,10 +118,18 @@ function toggleUser(userId, pill) {
// --- Mood / Recommendations --- // --- Mood / Recommendations ---
async function findMovies() { async function findMovies(excludeIds = []) {
const mood = document.getElementById('mood-input').value.trim(); const mood = document.getElementById('mood-input').value.trim();
if (!mood) return; 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('empty-state').classList.add('hidden');
document.getElementById('results').classList.add('hidden'); document.getElementById('results').classList.add('hidden');
document.getElementById('error-state').classList.add('hidden'); document.getElementById('error-state').classList.add('hidden');
@@ -101,10 +137,19 @@ async function findMovies() {
try { try {
const additionalIds = selectedUserIds.filter(id => id !== currentUser.id); 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`, { const res = await fetch(`${API}/api/mood`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mood, additional_user_ids: additionalIds }) body: JSON.stringify(payload)
}); });
if (!res.ok) { if (!res.ok) {
@@ -113,6 +158,17 @@ async function findMovies() {
} }
const data = await res.json(); 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); renderResults(data);
loadHistory(); loadHistory();
} catch (err) { } 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) => { document.getElementById('mood-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') findMovies(); if (e.key === 'Enter') findMovies();
}); });
@@ -305,5 +361,37 @@ document.getElementById('show-history-btn').addEventListener('click', () => {
loadHistory(); 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 --- // --- Init ---
checkAuth(); checkAuth();
+31 -3
View File
@@ -65,9 +65,32 @@
Find Movies Find Movies
</button> </button>
</div> </div>
<p class="text-gray-600 text-sm mt-2">
Try: "pizza night with the kids" &middot; "something scary" &middot; "light fun movie after a hard week" &middot; "80s action" <!-- Filters Row -->
</p> <div class="flex flex-wrap items-center gap-3 mt-3">
<!-- Runtime Filter -->
<div class="flex items-center gap-1.5">
<span class="text-xs text-gray-500">Max:</span>
<button data-runtime="" class="runtime-btn active text-xs px-2.5 py-1 rounded-full border border-gray-700 text-gray-400 transition-colors">Any</button>
<button data-runtime="90" class="runtime-btn text-xs px-2.5 py-1 rounded-full border border-gray-700 text-gray-400 transition-colors">90m</button>
<button data-runtime="120" class="runtime-btn text-xs px-2.5 py-1 rounded-full border border-gray-700 text-gray-400 transition-colors">2h</button>
<button data-runtime="150" class="runtime-btn text-xs px-2.5 py-1 rounded-full border border-gray-700 text-gray-400 transition-colors">2.5h</button>
</div>
<div class="w-px h-5 bg-gray-700"></div>
<!-- Kid-Friendly Toggle -->
<button id="kid-friendly-btn" class="flex items-center gap-1.5 text-xs px-3 py-1 rounded-full border border-gray-700 text-gray-400 transition-colors">
<span>Kid-Friendly</span>
</button>
<div class="flex-1"></div>
<!-- Surprise Me -->
<button id="surprise-btn" class="text-xs px-3 py-1 rounded-full border border-indigo-600/50 text-indigo-400 hover:bg-indigo-600/20 transition-colors">
Surprise Me
</button>
</div>
</div> </div>
<!-- Loading State --> <!-- Loading State -->
@@ -83,6 +106,11 @@
<div id="results" class="hidden"> <div id="results" class="hidden">
<div id="results-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"></div> <div id="results-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"></div>
<div id="results-meta" class="mt-6 text-center text-gray-500 text-sm"></div> <div id="results-meta" class="mt-6 text-center text-gray-500 text-sm"></div>
<div class="mt-4 text-center">
<button id="reroll-btn" class="px-6 py-2 border border-indigo-600/50 text-indigo-400 hover:bg-indigo-600/20 rounded-lg font-semibold transition-colors text-sm">
Show Me More
</button>
</div>
</div> </div>
<!-- Empty State --> <!-- Empty State -->
+19
View File
@@ -31,3 +31,22 @@
border-color: #374151; border-color: #374151;
color: #6b7280; 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;
}