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()