diff --git a/backend/data_loader.py b/backend/data_loader.py index 7bc5cc3..dff9c19 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -18,12 +18,27 @@ import ast import os import re +import time import pandas as pd +import requests DATA_DIR = os.path.join(os.path.dirname(__file__), "data") MOVIELENS_DIR = os.path.join(DATA_DIR, "ml-32m") +# --------------------------------------------------------------------------- +# TMDB poster configuration +# +# Set the TMDB_API_KEY environment variable before starting the server: +# export TMDB_API_KEY=your_key_here +# +# Poster URLs are cached in data/poster_cache.csv so TMDB is only queried +# once — subsequent startups load from the cache file instantly. +# --------------------------------------------------------------------------- +TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "") +TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w300" +POSTER_CACHE_PATH = os.path.join(DATA_DIR, "poster_cache.csv") + # --------------------------------------------------------------------------- # Helpers @@ -307,6 +322,97 @@ def _load_movielens_content() -> pd.DataFrame: return ml.drop(columns=["movieId"]).drop_duplicates(subset=["title_key", "year"], keep="first") +def _fetch_poster_url(title: str, year, content_type: str) -> str | None: + """ + Look up a single title on TMDB and return the full poster image URL. + + Uses the /search/movie or /search/tv endpoint depending on content_type. + Takes the first result's poster_path and prepends the TMDB image base URL. + Returns None if the title isn't found, has no poster, or the request fails. + + Rate limit: TMDB free tier allows 40 requests/second. The caller should + sleep ~0.03s between calls to stay safely under the limit. + + @param title - Title string to search for + @param year - Release year (used to narrow results, can be None) + @param content_type - "movie" or "tv" + @returns Full poster URL string, or None + """ + if not TMDB_API_KEY: + return None + try: + endpoint = "movie" if content_type == "movie" else "tv" + year_param = "year" if content_type == "movie" else "first_air_date_year" + params = { + "api_key": TMDB_API_KEY, + "query": title, + "include_adult": False, + } + if pd.notna(year) and year: + params[year_param] = int(year) + + res = requests.get( + f"https://api.themoviedb.org/3/search/{endpoint}", + params=params, + timeout=5, + ) + results = res.json().get("results", []) + if results and results[0].get("poster_path"): + return TMDB_IMAGE_BASE + results[0]["poster_path"] + except Exception: + pass + return None + + +def _build_poster_cache(df: pd.DataFrame) -> pd.Series: + """ + Fetch poster URLs for every title in the DataFrame and cache them to disk. + + On first run this queries TMDB for every row (~42k titles) at ~33 req/sec, + which takes roughly 20 minutes. Results are saved to data/poster_cache.csv + so subsequent startups load instantly from the cache. + + Titles already in the cache are skipped, so this is safe to re-run if + new titles are added to the dataset. + + @param df - The master DataFrame (must have title, year, content_type columns) + @returns pd.Series of poster URLs indexed to match df + """ + # Load existing cache if present + if os.path.exists(POSTER_CACHE_PATH): + cache_df = pd.read_csv(POSTER_CACHE_PATH) + cache = dict(zip(cache_df["cache_key"], cache_df["poster_url"])) + print(f"Loaded {len(cache):,} cached poster URLs from disk.") + else: + cache = {} + + # Build a unique key per row: "title||year||content_type" + def _cache_key(row): + title_key = str(row["title"]).lower().strip() + return f"{title_key}||{row.get('year', '')}||{row.get('content_type', '')}" + + keys = df.apply(_cache_key, axis=1) + missing_mask = ~keys.isin(cache) + missing_count = missing_mask.sum() + + if missing_count > 0: + print(f"Fetching {missing_count:,} new poster URLs from TMDB (this may take a while)...") + for i, (idx, row) in enumerate(df[missing_mask].iterrows()): + key = keys[idx] + url = _fetch_poster_url(row["title"], row.get("year"), row.get("content_type", "movie")) + cache[key] = url + time.sleep(0.03) # ~33 req/sec, under TMDB's 40/sec free limit + if (i + 1) % 500 == 0: + print(f" {i + 1:,} / {missing_count:,} fetched...") + + # Save updated cache to disk + cache_rows = [{"cache_key": k, "poster_url": v} for k, v in cache.items()] + pd.DataFrame(cache_rows).to_csv(POSTER_CACHE_PATH, index=False) + print(f"Poster cache saved to {POSTER_CACHE_PATH}") + + return keys.map(cache) + + # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @@ -394,7 +500,7 @@ def load_data(include_orphans: bool = True) -> pd.DataFrame: lambda row: " ".join( part for part in [ - str(row["description"]).strip() if pd.notna(row.get("description")) else "", + (str(row["description"]).strip() if pd.notna(row.get("description")) else ""), ( str(row["movielens_tags"]).strip() if pd.notna(row.get("movielens_tags")) @@ -521,6 +627,15 @@ def _build_soup(row) -> str: "movielens_rating_count", "tfidf_soup", ] + # ----- Fetch TMDB poster URLs (cached after first run) ----- + if TMDB_API_KEY: + df["poster_url"] = _build_poster_cache(df) + else: + df["poster_url"] = None + + # Add poster_url to canonical column order + ordered.append("poster_url") + present = [c for c in ordered if c in df.columns] extra = [c for c in df.columns if c not in ordered] return df[present + extra].reset_index(drop=True) diff --git a/backend/requirements.txt b/backend/requirements.txt index e48e4b4..17f47aa 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,4 @@ numpy>=1.26.0 scikit-learn>=1.4.0 scipy>=1.12.0 pydantic>=2.0.0 +requests>=2.31.0 \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fb4e4a6..8f1ee9d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -112,6 +112,100 @@ function StarRating({ rating }) { ); } +/** + * MatchTooltip — breakdown popup shown when hovering the similarity % badge. + * + * Reads `match_breakdown` from the result object, populated by the backend's + * recommender.py weighted TF-IDF scoring. Each field (description, genres, + * cast, director) has its own similarity score shown as a bar. + * + * @param {Object} breakdown - item.match_breakdown from POST /recommend + * @param {number} simPct - Overall similarity percentage (0–100) + */ +function MatchTooltip({ breakdown, simPct }) { + if (!breakdown) { + return ( +
+

+ Match breakdown not available +

+
+ ); + } + + const fields = [ + { key: "description", label: "Description", weight: breakdown.description_weight || 0.50 }, + { key: "genres", label: "Genres", weight: breakdown.genres_weight || 0.30 }, + { key: "cast", label: "Cast", weight: breakdown.cast_weight || 0.10 }, + { key: "director", label: "Director", weight: breakdown.director_weight || 0.10 }, + ]; + + return ( +
e.stopPropagation()} + > +
+ + Why {simPct}% match? + +
+ +
+ {fields.map(({ key, label }) => { + const raw = breakdown[key] || 0; + const barWidth = Math.min(100, Math.round(raw * 100)); + return ( +
+
+ {label} + + {barWidth}% + +
+
+
60 ? "#00E5FF" : barWidth > 30 ? "#4dd0e1" : "#1e6b78", + borderRadius: "2px", + transition: "width 0.3s ease", + }} /> +
+
+ ); + })} +
+ +
+
+ IMDb + + {breakdown.imdb ? `${Math.round(breakdown.imdb * 10)}/10` : "N/A"} + +
+
+ Popularity + + {breakdown.popularity ? `${Math.round(breakdown.popularity * 100)}%` : "N/A"} + +
+
+
+ ); +} + /** * TitleCard — individual movie/show card in a ScrollRow. * @@ -129,6 +223,7 @@ function StarRating({ rating }) { */ function TitleCard({ item, onClick, isActive }) { const [hovered, setHovered] = useState(false); + const [badgeHovered, setBadgeHovered] = useState(false); const initials = item.title.split(" ").slice(0, 2).map((w) => w[0]).join("").toUpperCase(); const simPct = item.similarity_score ? Math.round(item.similarity_score * 100) : null; @@ -136,7 +231,7 @@ function TitleCard({ item, onClick, isActive }) {
onClick(item)} onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} + onMouseLeave={() => { setHovered(false); setBadgeHovered(false); }} style={{ flexShrink: 0, width: "148px", cursor: "pointer", transition: "transform 0.18s ease", @@ -156,26 +251,40 @@ function TitleCard({ item, onClick, isActive }) { transition: "border-color 0.18s, background 0.18s", }}> {hovered && ( -
+
)} - {/* Monogram placeholder — replace with when poster URLs are available */} -
- {initials} -
+ {/* Poster image — shown when backend provides poster_url (TMDB). + Falls back to monogram initials until poster_url is available. */} + {item.poster_url ? ( + {item.title} { e.target.style.display = "none"; }} + /> + ) : ( +
+ {initials} +
+ )} {/* Title + year overlay at the bottom */}
- {/* Similarity badge — only shown for high-confidence matches (>= 80%) */} - {simPct >= 80 && ( +
+ + {/* Similarity badge — positioned over the top-right corner of the card face. + The outer wrapper is relative and sits between the card div and genre label, + with negative margin to pull it up into the card. The tooltip drops DOWN + from the badge, overlaying the card face. overflow:visible so it escapes. */} + {simPct >= 80 && ( +
{ e.stopPropagation(); setBadgeHovered(true); }} + onMouseLeave={(e) => { e.stopPropagation(); setBadgeHovered(false); }} + onClick={(e) => e.stopPropagation()} + > + {/* Badge pill — top-right corner of the card, same as original */}
{simPct}%
- )} -
+ + {/* Tooltip — drops DOWN from the badge, overlaying the card face */} + {badgeHovered && ( +
+ +
+ )} +
+ )} {/* Genre label below the card — always shows genre, never platform names */}
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + {simPct}% match ↗ + + {hovered && ( +
+ +
+ )} +
+ ); +} + /** * DetailDrawer — slide-up panel showing full metadata for a selected title. * @@ -326,10 +497,9 @@ function DetailDrawer({ item, onClose }) { {item.content_type === "tv" ? "TV Show" : "Movie"} + {/* % match — hoverable, shows same MatchTooltip breakdown as the card badge */} {item.similarity_score && ( - - {Math.round(item.similarity_score * 100)}% match - + )}