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:
@@ -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):
|
||||||
|
|||||||
+42
-11
@@ -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),
|
row = await cursor.fetchone()
|
||||||
datetime.now(timezone.utc).isoformat(),
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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" · "something scary" · "light fun movie after a hard week" · "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 -->
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user