import requests
import streamlit as st
import os
# =============================
# CONFIG
# =============================
API_BASE = os.getenv("API_BASE_URL", "http://127.0.0.1:8000")
TMDB_IMG = "https://image.tmdb.org/t/p/w500"
st.set_page_config(page_title="Movie Match Maker", page_icon="๐ฌ", layout="wide")
# =============================
# STYLES (Premium Streamlit Polish)
# =============================
st.markdown(
"""
""",
unsafe_allow_html=True,
)
# =============================
# STATE + ROUTING (single-file pages)
# =============================
if "view" not in st.session_state:
st.session_state.view = "home" # home | details | watchlist | genre | chat
if "selected_tmdb_id" not in st.session_state:
st.session_state.selected_tmdb_id = None
if "selected_genre" not in st.session_state:
st.session_state.selected_genre = "Action"
if "chat_history" not in st.session_state:
st.session_state.chat_history = [] # [{"role": "user"|"assistant", "content": "..."}]
qp_view = st.query_params.get("view")
qp_id = st.query_params.get("id")
if qp_view in ("home", "details", "watchlist", "genre", "chat"):
st.session_state.view = qp_view
if qp_id:
try:
st.session_state.selected_tmdb_id = int(qp_id)
st.session_state.view = "details"
except:
pass
def goto_home():
st.session_state.view = "home"
st.query_params["view"] = "home"
if "id" in st.query_params:
del st.query_params["id"]
st.rerun()
def goto_details(tmdb_id: int):
st.session_state.view = "details"
st.session_state.selected_tmdb_id = int(tmdb_id)
st.query_params["view"] = "details"
st.query_params["id"] = str(int(tmdb_id))
st.rerun()
# =============================
# API HELPERS
# =============================
@st.cache_data(ttl=600) # 10-min cache for heavy TMDB calls - prevents rate limiting
def api_get_json(path: str, params: dict | None = None):
try:
r = requests.get(f"{API_BASE}{path}", params=params, timeout=45)
if r.status_code >= 400:
return None, f"HTTP {r.status_code}: {r.text[:300]}"
return r.json(), None
except Exception as e:
return None, f"Request failed: {e}"
# No-cache version for live data (watchlist check, search suggestions)
def api_get_live(path: str, params: dict | None = None):
try:
r = requests.get(f"{API_BASE}{path}", params=params, timeout=10)
if r.status_code >= 400:
return None, f"HTTP {r.status_code}: {r.text[:300]}"
return r.json(), None
except Exception as e:
return None, f"Request failed: {e}"
def poster_grid(cards, cols=6, key_prefix="grid"):
if not cards:
st.info("No movies to show.")
return
rows = (len(cards) + cols - 1) // cols
idx = 0
for r in range(rows):
colset = st.columns(cols)
for c in range(cols):
if idx >= len(cards):
break
m = cards[idx]
idx += 1
tmdb_id = m.get("tmdb_id")
title = m.get("title", "Untitled")
poster = m.get("poster_url")
with colset[c]:
if poster:
st.image(poster, use_column_width=True)
else:
st.write("๐ผ๏ธ No poster")
if st.button("Open", key=f"{key_prefix}_{r}_{c}_{idx}_{tmdb_id}"):
if tmdb_id:
goto_details(tmdb_id)
st.markdown(
f"
{title}
", unsafe_allow_html=True
)
def to_cards_from_tfidf_items(tfidf_items):
cards = []
for x in tfidf_items or []:
tmdb = x.get("tmdb") or {}
if tmdb.get("tmdb_id"):
hybrid = x.get("hybrid_score") or x.get("score") or 0
cards.append(
{
"tmdb_id": tmdb["tmdb_id"],
"title": tmdb.get("title") or x.get("title") or "Untitled",
"poster_url": tmdb.get("poster_url"),
"hybrid_score": hybrid,
}
)
return cards
# =============================
# IMPORTANT: Robust TMDB search parsing
# Supports BOTH API shapes:
# 1) raw TMDB: {"results":[{id,title,poster_path,...}]}
# 2) list cards: [{tmdb_id,title,poster_url,...}]
# =============================
def parse_tmdb_search_to_cards(data, keyword: str, limit: int = 24):
"""
Returns:
suggestions: list[(label, tmdb_id)]
cards: list[{tmdb_id,title,poster_url}]
"""
keyword_l = keyword.strip().lower()
# A) If API returns dict with 'results'
if isinstance(data, dict) and "results" in data:
raw = data.get("results") or []
raw_items = []
for m in raw:
title = (m.get("title") or "").strip()
tmdb_id = m.get("id")
poster_path = m.get("poster_path")
if not title or not tmdb_id:
continue
raw_items.append(
{
"tmdb_id": int(tmdb_id),
"title": title,
"poster_url": f"{TMDB_IMG}{poster_path}" if poster_path else None,
"release_date": m.get("release_date", ""),
}
)
# B) If API returns already as list
elif isinstance(data, list):
raw_items = []
for m in data:
# might be {tmdb_id,title,poster_url}
tmdb_id = m.get("tmdb_id") or m.get("id")
title = (m.get("title") or "").strip()
poster_url = m.get("poster_url")
if not title or not tmdb_id:
continue
raw_items.append(
{
"tmdb_id": int(tmdb_id),
"title": title,
"poster_url": poster_url,
"release_date": m.get("release_date", ""),
}
)
else:
return [], []
# Word-match filtering (contains)
matched = [x for x in raw_items if keyword_l in x["title"].lower()]
# If nothing matched, fallback to raw list (so never blank)
final_list = matched if matched else raw_items
# Suggestions = top 10 labels
suggestions = []
for x in final_list[:10]:
year = (x.get("release_date") or "")[:4]
label = f"{x['title']} ({year})" if year else x["title"]
suggestions.append((label, x["tmdb_id"]))
# Cards = top N
cards = [
{"tmdb_id": x["tmdb_id"], "title": x["title"], "poster_url": x["poster_url"]}
for x in final_list[:limit]
]
return suggestions, cards
# =============================
# SIDEBAR (clean)
# =============================
with st.sidebar:
st.markdown("## ๐ฌ Menu")
if st.button("๐ Home"):
goto_home()
if st.button("๐๏ธ My Watchlist"):
st.session_state.view = "watchlist"
st.rerun()
if st.button("๐ญ Browse by Genre"):
st.session_state.view = "genre"
st.rerun()
if st.button("๐ค AI Movie Chat"):
st.session_state.view = "chat"
st.rerun()
st.markdown("---")
st.markdown("### ๐ Home Feed (only home)")
home_category = st.selectbox(
"Category",
["trending", "popular", "top_rated", "now_playing", "upcoming"],
index=0,
)
grid_cols = st.slider("Grid columns", 4, 8, 6)
# =============================
# HEADER
# =============================
st.title("๐ฌ Movie Match Maker")
# HERO SECTION REMOVAL: Moving header content into hero banner below.
st.divider()
# ==========================================================
# VIEW: HOME
# ==========================================================
if st.session_state.view == "home":
# --- HERO AREA ---
# Try a faster, non-caching call for the hero so we don't hold up the page
try:
hero_data, h_err = api_get_live("/home", params={"category": "trending", "limit": 1})
except Exception:
hero_data, h_err = None, "Timeout"
if h_err is None and hero_data:
hero = hero_data[0]
# Use high-res backdrop if available, fallback to poster
bg_url = hero.get("backdrop_url") or hero.get("poster_url") or "https://images.unsplash.com/photo-1489599849927-2ee91cede3ba"
st.markdown(f"""
โจ Featured Match of the Day
{hero.get('title', 'Your Next Favorite Movie')}
Trending now with a {hero.get('vote_average', '8.0')}/10 rating. Discover why this movie is matching with millions!
""", unsafe_allow_html=True)
typed = st.text_input(
"Search by movie title (keyword)", placeholder="Type: avenger, batman, love..."
)
# === CONVERSATIONAL AI SEARCH ===
with st.expander("๐ฃ๏ธ Describe what you want to watch (AI Search)"):
conv_query = st.text_area(
"Natural language search",
placeholder="e.g. 'A funny movie for kids from the 2010s' or 'Scary thriller from the 90s rated above 7'",
key="conv_query", height=80, label_visibility="collapsed"
)
if st.button("๐ Search with AI", key="conv_search_btn") and conv_query.strip():
with st.spinner("๐ค AI is understanding your request..."):
ai_resp = requests.post(f"{API_BASE}/ai/search", json={"query": conv_query.strip()}, timeout=20)
if ai_resp.status_code == 200:
ai_data = ai_resp.json()
st.caption(f"๐ค AI understood: *{ai_data.get('description', '')}*")
col_a, col_b, col_c = st.columns(3)
col_a.metric("๐ฌ Genre", ai_data.get('genre') or 'Any')
col_b.metric("๐
Years", f"{ai_data.get('year_from', 1990)}โ{ai_data.get('year_to', 2025)}")
col_c.metric("โญ Min Rating", ai_data.get('min_rating', 5.0))
# Fetch results using extracted filters
if ai_data.get('genre'):
res_data, res_err = api_get_json("/discover/genre", params={
"genre": ai_data['genre'],
"year_from": ai_data.get('year_from', 1990),
"year_to": ai_data.get('year_to', 2025),
"min_rating": ai_data.get('min_rating', 5.0),
})
if not res_err and res_data and res_data.get('results'):
st.markdown(f"### ๐ฏ AI Results โ {ai_data.get('genre')} Movies")
poster_grid(res_data['results'], cols=grid_cols, key_prefix="ai_search")
else:
st.info("No results found. Try rephrasing your request.")
else:
st.info("AI couldn't detect a genre. Try being more specific, e.g. 'action movies from 2020'.")
else:
st.error("AI Search failed. Please try again.")
st.divider()
# SEARCH MODE (Autocomplete + word-match results)
if typed.strip():
if len(typed.strip()) < 2:
st.caption("Type at least 2 characters for suggestions.")
else:
data, err = api_get_json("/tmdb/search", params={"query": typed.strip()})
if err or data is None:
st.error(f"Search failed: {err}")
else:
suggestions, cards = parse_tmdb_search_to_cards(
data, typed.strip(), limit=24
)
# Dropdown
if suggestions:
labels = ["-- Select a movie --"] + [s[0] for s in suggestions]
selected = st.selectbox("Suggestions", labels, index=0)
if selected != "-- Select a movie --":
# map label -> id
label_to_id = {s[0]: s[1] for s in suggestions}
goto_details(label_to_id[selected])
else:
st.info("No suggestions found. Try another keyword.")
st.markdown("### Results")
poster_grid(cards, cols=grid_cols, key_prefix="search_results")
st.stop()
# HOME FEED MODE
st.markdown(f"### ๐ Home โ {home_category.replace('_',' ').title()}")
home_cards, err = api_get_json(
"/home", params={"category": home_category, "limit": 24}
)
if err or not home_cards:
st.error(f"Home feed failed: {err or 'Unknown error'}")
st.stop()
poster_grid(home_cards, cols=grid_cols, key_prefix="home_feed")
# ==========================================================
# VIEW: DETAILS
# ==========================================================
elif st.session_state.view == "details":
tmdb_id = st.session_state.selected_tmdb_id
if not tmdb_id:
st.warning("No movie selected.")
if st.button("โ Back to Home"):
goto_home()
st.stop()
# Top bar
a, b = st.columns([3, 1])
with a:
st.markdown("### ๐ Movie Details")
with b:
if st.button("โ Back to Home"):
goto_home()
# Watchlist Toggle
wl_check, _ = api_get_live(f"/watchlist/{tmdb_id}")
is_saved = (wl_check or {}).get("saved", False)
col_wl, col_rate = st.columns([1, 2])
with col_wl:
if is_saved:
if st.button("โค๏ธ Saved to Watchlist โ Click to Remove"):
requests.delete(f"{API_BASE}/watchlist/{tmdb_id}")
st.rerun()
else:
if st.button("โ Add to Watchlist"):
movie_data, _ = api_get_json(f"/movie/id/{tmdb_id}")
if movie_data:
requests.post(f"{API_BASE}/watchlist", json={
"tmdb_id": tmdb_id,
"title": movie_data.get("title", ""),
"poster_url": movie_data.get("poster_url", "")
})
st.rerun()
with col_rate:
rating_data, _ = api_get_live(f"/ratings/{tmdb_id}")
current_rating = (rating_data or {}).get("rating") or 0
star_map = {"โญ 1 โ Poor": 1, "โญโญ 2 โ Fair": 2, "โญโญโญ 3 โ Good": 3, "โญโญโญโญ 4 โ Great": 4, "โญโญโญโญโญ 5 โ Excellent": 5}
labels = ["(no rating yet)"] + list(star_map.keys())
current_label = next((k for k, v in star_map.items() if v == current_rating), "(no rating yet)")
chosen = st.selectbox("๐ Your Rating", labels, index=labels.index(current_label), key=f"rate_{tmdb_id}")
if chosen != "(no rating yet)" and star_map[chosen] != current_rating:
movie_title = (api_get_json(f"/movie/id/{tmdb_id}")[0] or {}).get("title", "")
requests.post(f"{API_BASE}/ratings", json={"tmdb_id": tmdb_id, "title": movie_title, "rating": star_map[chosen]})
st.rerun()
# Details (your FastAPI safe route)
data, err = api_get_json(f"/movie/id/{tmdb_id}")
if err or not data:
st.error(f"Could not load details: {err or 'Unknown error'}")
st.stop()
# Layout: Poster LEFT, Details RIGHT
left, right = st.columns([1, 2.4], gap="large")
with left:
st.markdown("", unsafe_allow_html=True)
if data.get("poster_url"):
st.image(data["poster_url"], use_column_width=True)
else:
st.write("๐ผ๏ธ No poster")
st.markdown("
", unsafe_allow_html=True)
with right:
st.markdown("", unsafe_allow_html=True)
st.markdown(f"## {data.get('title','')}")
release = data.get("release_date") or "-"
genres = ", ".join([g["name"] for g in data.get("genres", [])]) or "-"
st.markdown(
f"
Release: {release}
", unsafe_allow_html=True
)
st.markdown(
f"
Genres: {genres}
", unsafe_allow_html=True
)
st.markdown("---")
col_data = data.get("collection")
if col_data:
st.info(f"โจ **Part of the {col_data.get('name')}**")
st.markdown("### Overview")
st.write(data.get("overview") or "No overview available.")
st.markdown("
", unsafe_allow_html=True)
if data.get("backdrop_url"):
st.markdown("#### Backdrop")
st.image(data["backdrop_url"], use_column_width=True)
if data.get("trailer_youtube_id"):
st.markdown("#### ๐ฅ Trailer")
st.video(f"https://www.youtube.com/watch?v={data['trailer_youtube_id']}")
providers = data.get("watch_providers", [])
watch_link = data.get("watch_link", "")
if providers:
if watch_link:
st.markdown(f"#### ๐บ [Where to Watch (US)]({watch_link})")
else:
st.markdown("#### ๐บ Where to Watch (US)")
cols = st.columns(len(providers) + 12) # padding columns so they stay small
for i, p in enumerate(providers):
with cols[i]:
if watch_link:
st.markdown(f"""
{p.get('name')}
""", unsafe_allow_html=True)
else:
st.image(p.get("logo_url"), width=40, caption=p.get("name"))
reviews = data.get("reviews", [])
if reviews:
st.write("") # small spacer
with st.expander("๐ Read User Reviews"):
for r in reviews:
st.markdown(f"**{r['author']}**")
content = r['content']
if len(content) > 600:
content = content[:600] + "..." # Truncate massive reviews
st.markdown(f"> *{content}*")
if r.get("url"):
st.markdown(f"[Read full review]({r['url']})")
st.markdown("---")
# === AI MOVIE SUMMARY ===
with st.expander("โจ Get AI Movie Summary (Powered by Groq)"):
ai_sum_key = f"ai_summary_{tmdb_id}"
if ai_sum_key not in st.session_state:
st.session_state[ai_sum_key] = None
if st.button("๐ค Generate AI Summary", key=f"sum_btn_{tmdb_id}"):
with st.spinner("Writing your personalized movie summary..."):
sum_resp = requests.post(f"{API_BASE}/ai/summary", json={
"title": data.get('title', ''),
"overview": data.get('overview', ''),
"genres": [g['name'] for g in data.get('genres', [])],
"release_date": data.get('release_date', ''),
"vote_average": data.get('vote_average', 0),
}, timeout=20)
if sum_resp.status_code == 200:
st.session_state[ai_sum_key] = sum_resp.json().get('summary', '')
else:
st.error("Could not generate summary. Try again.")
if st.session_state.get(ai_sum_key):
st.info(f"๐ฌ {st.session_state[ai_sum_key]}")
st.divider()
st.markdown("### โ
Recommendations")
# Recommendations (TF-IDF + Genre) via your bundle endpoint
title = (data.get("title") or "").strip()
if title:
bundle, err2 = api_get_json(
"/movie/search",
params={"query": title, "tfidf_top_n": 12, "genre_limit": 12},
)
if not err2 and bundle:
st.markdown("#### ๐ Similar Movies (TF-IDF)")
poster_grid(
to_cards_from_tfidf_items(bundle.get("tfidf_recommendations")),
cols=grid_cols,
key_prefix="details_tfidf",
)
st.markdown("#### ๐ญ More Like This (Genre)")
poster_grid(
bundle.get("genre_recommendations", []),
cols=grid_cols,
key_prefix="details_genre",
)
else:
st.info("Showing Genre recommendations (fallback).")
genre_only, err3 = api_get_json(
"/recommend/genre", params={"tmdb_id": tmdb_id, "limit": 18}
)
if not err3 and genre_only:
poster_grid(
genre_only, cols=grid_cols, key_prefix="details_genre_fallback"
)
else:
st.warning("No recommendations available right now.")
else:
st.warning("No title available to compute recommendations.")
# ==========================================================
# VIEW: WATCHLIST
# ==========================================================
elif st.session_state.view == "watchlist":
st.title("๐๏ธ My Watchlist")
if st.button("โ Back to Home"):
goto_home()
# === AI WATCHLIST ANALYZER ===
col_wl_hdr, col_wl_btn = st.columns([3, 1])
with col_wl_btn:
analyze_clicked = st.button("๐ง Analyze My Taste", use_container_width=True)
if analyze_clicked:
with st.spinner("๐ค AI is analyzing your movie taste..."):
ana_resp = requests.get(f"{API_BASE}/ai/watchlist-analysis", timeout=30)
if ana_resp.status_code == 200:
ana_data = ana_resp.json()
st.session_state['wl_analysis'] = ana_data.get('analysis', '')
st.session_state['wl_count'] = ana_data.get('movie_count', 0)
else:
st.error("Analysis failed. Make sure you have movies in your watchlist.")
if st.session_state.get('wl_analysis'):
st.markdown(st.session_state['wl_analysis'])
st.divider()
wl_data, wl_err = api_get_live("/watchlist")
if wl_err:
st.error(f"Could not load watchlist: {wl_err}")
elif not wl_data:
st.info("Your watchlist is empty! Go browse movies and click โ Add to Watchlist.")
else:
st.caption(f"{len(wl_data)} saved movies")
cards = [
{
"tmdb_id": m["tmdb_id"],
"title": m["title"],
"poster_url": m.get("poster_url")
}
for m in wl_data
]
poster_grid(cards, cols=grid_cols, key_prefix="watchlist")
# ==========================================================
# VIEW: GENRE BROWSER (Advanced Search & Filtering)
# ==========================================================
elif st.session_state.view == "genre":
GENRES = [
"Action", "Adventure", "Animation", "Comedy", "Crime",
"Documentary", "Drama", "Family", "Fantasy", "History",
"Horror", "Music", "Mystery", "Romance", "Science Fiction",
"Thriller", "War", "Western"
]
st.title("๐ญ Advanced Search & Filtering")
if st.button("โ Back to Home"):
goto_home()
st.divider()
# === FILTER ROW ===
col1, col2, col3 = st.columns([1.5, 2, 1.5])
with col1:
selected_genre = st.selectbox(
"๐ฌ Genre",
GENRES,
index=GENRES.index(st.session_state.selected_genre) if st.session_state.selected_genre in GENRES else 0,
key="genre_selectbox"
)
st.session_state.selected_genre = selected_genre
with col2:
year_range = st.slider(
"๐
Release Year Range",
min_value=1970, max_value=2025,
value=(2000, 2025), step=1
)
with col3:
min_rating = st.slider(
"โญ Min TMDB Rating",
min_value=0.0, max_value=9.0,
value=5.0, step=0.5
)
genre_page = st.number_input("Page", min_value=1, max_value=20, value=1, step=1)
st.markdown(f"### ๐ฌ {selected_genre} Movies ({year_range[0]}โ{year_range[1]}, Rating โฅ {min_rating})")
genre_data, err = api_get_json(
"/discover/genre",
params={
"genre": selected_genre,
"page": int(genre_page),
"year_from": year_range[0],
"year_to": year_range[1],
"min_rating": min_rating,
}
)
if err:
st.error(f"Could not load results: {err}")
elif not genre_data or not genre_data.get("results"):
st.info(f"No movies found matching your filters. Try adjusting the year range or rating.")
else:
results = genre_data["results"]
st.caption(f"Showing {len(results)} movies")
poster_grid(results, cols=grid_cols, key_prefix=f"genre_{selected_genre}_{genre_page}_{year_range}_{min_rating}")
# ==========================================================
# VIEW: AI MOVIE CHAT
# ==========================================================
elif st.session_state.view == "chat":
st.title("๐ค CineBot โ AI Movie Assistant")
st.markdown("Powered by Grok ยท Ask me anything about movies!
", unsafe_allow_html=True)
if st.button("โ Back to Home"):
goto_home()
st.divider()
# Render existing chat history
for msg in st.session_state.chat_history:
if msg["role"] == "user":
with st.chat_message("user"):
st.markdown(msg["content"])
else:
with st.chat_message("assistant", avatar="๐ฌ"):
st.markdown(msg["content"])
# Chat input
user_input = st.chat_input("Ask me about movies... (e.g. 'Suggest a scary movie for tonight')")
if user_input:
# Show user message immediately
with st.chat_message("user"):
st.markdown(user_input)
st.session_state.chat_history.append({"role": "user", "content": user_input})
# Call backend
with st.chat_message("assistant", avatar="๐ฌ"):
with st.spinner("CineBot is thinking..."):
try:
resp = requests.post(
f"{API_BASE}/chat",
json={
"message": user_input,
"history": st.session_state.chat_history[:-1] # exclude current msg
},
timeout=30
)
if resp.status_code == 200:
reply = resp.json().get("reply", "Sorry, I couldn't process that.")
else:
reply = f"โ ๏ธ Error: {resp.text[:200]}"
except Exception as e:
reply = f"โ ๏ธ Connection error: {e}"
st.markdown(reply)
st.session_state.chat_history.append({"role": "assistant", "content": reply})
# Clear button
if st.session_state.chat_history:
if st.button("๐๏ธ Clear Chat History"):
st.session_state.chat_history = []
st.rerun()