From 20ed505ac3d26dc24c0e06135fd2991951fa9799 Mon Sep 17 00:00:00 2001 From: Angie Che Date: Mon, 22 Jun 2026 11:44:54 +0800 Subject: [PATCH 1/6] feat: add match breakdown tooltip and TMDB poster support --- frontend/src/App.jsx | 218 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 194 insertions(+), 24 deletions(-) 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 - + )}
From 97b67f0944e6deffb6b4b8505a54a26193b428c7 Mon Sep 17 00:00:00 2001 From: Angie Che Date: Mon, 22 Jun 2026 11:51:21 +0800 Subject: [PATCH 2/6] feat: add TMDB poster fetching with disk cache to data_loader --- backend/data_loader.py | 114 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/backend/data_loader.py b/backend/data_loader.py index 7bc5cc3..2a37985 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,96 @@ 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): + return f"{str(row['title']).lower().strip()}||{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 # --------------------------------------------------------------------------- @@ -521,6 +626,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) From 966edc62688d93d17edba0a52eee2d7f81d21892 Mon Sep 17 00:00:00 2001 From: Angie Che Date: Mon, 22 Jun 2026 11:59:06 +0800 Subject: [PATCH 3/6] fix: resolve flake8 lint errors in data_loader.py and add requests to requirements.txt for TMDB poster fetching --- backend/data_loader.py | 5 +++-- backend/requirements.txt | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/data_loader.py b/backend/data_loader.py index 2a37985..3627baf 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -322,7 +322,6 @@ 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. @@ -389,7 +388,9 @@ def _build_poster_cache(df: pd.DataFrame) -> pd.Series: # Build a unique key per row: "title||year||content_type" def _cache_key(row): - return f"{str(row['title']).lower().strip()}||{row.get('year', '')}||{row.get('content_type', '')}" + return f"{ + str(row['title']).lower().strip()}||{row.get('year', '')}||{row.get('content_type', '') + }" keys = df.apply(_cache_key, axis=1) missing_mask = ~keys.isin(cache) 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 From 78a8ef9ffa72a5bfb31f24db258546c0002831db Mon Sep 17 00:00:00 2001 From: Angie Che Date: Mon, 22 Jun 2026 12:00:36 +0800 Subject: [PATCH 4/6] fix line 392 in data_loader.py --- backend/data_loader.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/data_loader.py b/backend/data_loader.py index 3627baf..5439e58 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -388,9 +388,8 @@ def _build_poster_cache(df: pd.DataFrame) -> pd.Series: # Build a unique key per row: "title||year||content_type" def _cache_key(row): - return f"{ - str(row['title']).lower().strip()}||{row.get('year', '')}||{row.get('content_type', '') - }" + 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) From afdc967cc9a3ff3343b26a23c19f0f5d795f634d Mon Sep 17 00:00:00 2001 From: Angie Che Date: Mon, 22 Jun 2026 12:04:24 +0800 Subject: [PATCH 5/6] fix: apply black formatting to data_loader.py --- backend/data_loader.py | 51 ++++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/backend/data_loader.py b/backend/data_loader.py index 5439e58..cc9e633 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -119,7 +119,9 @@ def _load_platform_base() -> pd.DataFrame: tv["content_type"] = "tv" movies["imdb_score"] = None - movies["rotten_tomatoes"] = movies["Rotten Tomatoes"].apply(lambda x: _parse_score(x, 100)) + movies["rotten_tomatoes"] = movies["Rotten Tomatoes"].apply( + lambda x: _parse_score(x, 100) + ) tv["imdb_score"] = tv["IMDb"].apply(lambda x: _parse_score(x, 10)) tv["rotten_tomatoes"] = tv["Rotten Tomatoes"].apply(lambda x: _parse_score(x, 100)) @@ -164,7 +166,9 @@ def _load_netflix_content() -> pd.DataFrame: movies = pd.read_csv( os.path.join(DATA_DIR, "netflix", "netflix_movies_detailed_up_to_2025.csv") ) - tv = pd.read_csv(os.path.join(DATA_DIR, "netflix", "netflix_tv_shows_detailed_up_to_2025.csv")) + tv = pd.read_csv( + os.path.join(DATA_DIR, "netflix", "netflix_tv_shows_detailed_up_to_2025.csv") + ) movies["content_type"] = "movie" tv["content_type"] = "tv" @@ -304,13 +308,19 @@ def _load_movielens_content() -> pd.DataFrame: ratings = pd.read_csv(ratings_summary_path) elif os.path.exists(ratings_path): rating_parts = [] - for chunk in pd.read_csv(ratings_path, usecols=["movieId", "rating"], chunksize=1_000_000): + for chunk in pd.read_csv( + ratings_path, usecols=["movieId", "rating"], chunksize=1_000_000 + ): rating_parts.append( chunk.groupby("movieId")["rating"].agg(["sum", "count"]).reset_index() ) if rating_parts: ratings = pd.concat(rating_parts, ignore_index=True) - ratings = ratings.groupby("movieId").agg({"sum": "sum", "count": "sum"}).reset_index() + ratings = ( + ratings.groupby("movieId") + .agg({"sum": "sum", "count": "sum"}) + .reset_index() + ) ratings["movielens_rating"] = (ratings["sum"] / ratings["count"]).round(3) ratings.rename(columns={"count": "movielens_rating_count"}, inplace=True) ratings = ratings[["movieId", "movielens_rating", "movielens_rating_count"]] @@ -319,7 +329,9 @@ def _load_movielens_content() -> pd.DataFrame: if ratings is not None: ml = ml.merge(ratings, on="movieId", how="left") - return ml.drop(columns=["movieId"]).drop_duplicates(subset=["title_key", "year"], keep="first") + 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: @@ -388,7 +400,7 @@ def _build_poster_cache(df: pd.DataFrame) -> pd.Series: # Build a unique key per row: "title||year||content_type" def _cache_key(row): - title_key = str(row['title']).lower().strip() + 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) @@ -396,10 +408,14 @@ def _cache_key(row): missing_count = missing_mask.sum() if missing_count > 0: - print(f"Fetching {missing_count:,} new poster URLs from TMDB (this may take a while)...") + 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")) + 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: @@ -412,6 +428,7 @@ def _cache_key(row): return keys.map(cache) + # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @@ -499,7 +516,11 @@ 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")) @@ -519,7 +540,9 @@ def _make_orphans( source: pd.DataFrame, orig_titles: pd.DataFrame, platform_col: str ) -> pd.DataFrame: """Source rows not already in the platform base.""" - mask = ~source.apply(lambda r: (r["title_key"], r["year"]) in base_keys, axis=1) + mask = ~source.apply( + lambda r: (r["title_key"], r["year"]) in base_keys, axis=1 + ) orphans = orig_titles[mask].copy() orphans["title"] = orig_titles.loc[mask, "title_key"] for col in ("netflix", "hulu", "prime_video", "disney_plus"): @@ -532,10 +555,14 @@ def _make_orphans( os.path.join(DATA_DIR, "netflix", "netflix_movies_detailed_up_to_2025.csv") ) nf_tv_raw = pd.read_csv( - os.path.join(DATA_DIR, "netflix", "netflix_tv_shows_detailed_up_to_2025.csv") + os.path.join( + DATA_DIR, "netflix", "netflix_tv_shows_detailed_up_to_2025.csv" + ) ) nf_titles_lookup = pd.concat([nf_movies_raw[["title"]], nf_tv_raw[["title"]]]) - nf_titles_lookup["title_key"] = nf_titles_lookup["title"].apply(_normalize_title) + nf_titles_lookup["title_key"] = nf_titles_lookup["title"].apply( + _normalize_title + ) nf_with_title = netflix.merge( nf_titles_lookup.drop_duplicates("title_key"), on="title_key", how="left" ) From 3e49e262dab3dc64ccedbe19cb0c908aaca1997e Mon Sep 17 00:00:00 2001 From: Angie Che Date: Mon, 22 Jun 2026 12:07:49 +0800 Subject: [PATCH 6/6] fix: reformat with black --line-length=100 to match CI --- backend/data_loader.py | 48 ++++++++++-------------------------------- 1 file changed, 11 insertions(+), 37 deletions(-) diff --git a/backend/data_loader.py b/backend/data_loader.py index cc9e633..dff9c19 100644 --- a/backend/data_loader.py +++ b/backend/data_loader.py @@ -119,9 +119,7 @@ def _load_platform_base() -> pd.DataFrame: tv["content_type"] = "tv" movies["imdb_score"] = None - movies["rotten_tomatoes"] = movies["Rotten Tomatoes"].apply( - lambda x: _parse_score(x, 100) - ) + movies["rotten_tomatoes"] = movies["Rotten Tomatoes"].apply(lambda x: _parse_score(x, 100)) tv["imdb_score"] = tv["IMDb"].apply(lambda x: _parse_score(x, 10)) tv["rotten_tomatoes"] = tv["Rotten Tomatoes"].apply(lambda x: _parse_score(x, 100)) @@ -166,9 +164,7 @@ def _load_netflix_content() -> pd.DataFrame: movies = pd.read_csv( os.path.join(DATA_DIR, "netflix", "netflix_movies_detailed_up_to_2025.csv") ) - tv = pd.read_csv( - os.path.join(DATA_DIR, "netflix", "netflix_tv_shows_detailed_up_to_2025.csv") - ) + tv = pd.read_csv(os.path.join(DATA_DIR, "netflix", "netflix_tv_shows_detailed_up_to_2025.csv")) movies["content_type"] = "movie" tv["content_type"] = "tv" @@ -308,19 +304,13 @@ def _load_movielens_content() -> pd.DataFrame: ratings = pd.read_csv(ratings_summary_path) elif os.path.exists(ratings_path): rating_parts = [] - for chunk in pd.read_csv( - ratings_path, usecols=["movieId", "rating"], chunksize=1_000_000 - ): + for chunk in pd.read_csv(ratings_path, usecols=["movieId", "rating"], chunksize=1_000_000): rating_parts.append( chunk.groupby("movieId")["rating"].agg(["sum", "count"]).reset_index() ) if rating_parts: ratings = pd.concat(rating_parts, ignore_index=True) - ratings = ( - ratings.groupby("movieId") - .agg({"sum": "sum", "count": "sum"}) - .reset_index() - ) + ratings = ratings.groupby("movieId").agg({"sum": "sum", "count": "sum"}).reset_index() ratings["movielens_rating"] = (ratings["sum"] / ratings["count"]).round(3) ratings.rename(columns={"count": "movielens_rating_count"}, inplace=True) ratings = ratings[["movieId", "movielens_rating", "movielens_rating_count"]] @@ -329,9 +319,7 @@ def _load_movielens_content() -> pd.DataFrame: if ratings is not None: ml = ml.merge(ratings, on="movieId", how="left") - return ml.drop(columns=["movieId"]).drop_duplicates( - subset=["title_key", "year"], keep="first" - ) + 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: @@ -408,14 +396,10 @@ def _cache_key(row): missing_count = missing_mask.sum() if missing_count > 0: - print( - f"Fetching {missing_count:,} new poster URLs from TMDB (this may take a while)..." - ) + 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") - ) + 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: @@ -516,11 +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")) @@ -540,9 +520,7 @@ def _make_orphans( source: pd.DataFrame, orig_titles: pd.DataFrame, platform_col: str ) -> pd.DataFrame: """Source rows not already in the platform base.""" - mask = ~source.apply( - lambda r: (r["title_key"], r["year"]) in base_keys, axis=1 - ) + mask = ~source.apply(lambda r: (r["title_key"], r["year"]) in base_keys, axis=1) orphans = orig_titles[mask].copy() orphans["title"] = orig_titles.loc[mask, "title_key"] for col in ("netflix", "hulu", "prime_video", "disney_plus"): @@ -555,14 +533,10 @@ def _make_orphans( os.path.join(DATA_DIR, "netflix", "netflix_movies_detailed_up_to_2025.csv") ) nf_tv_raw = pd.read_csv( - os.path.join( - DATA_DIR, "netflix", "netflix_tv_shows_detailed_up_to_2025.csv" - ) + os.path.join(DATA_DIR, "netflix", "netflix_tv_shows_detailed_up_to_2025.csv") ) nf_titles_lookup = pd.concat([nf_movies_raw[["title"]], nf_tv_raw[["title"]]]) - nf_titles_lookup["title_key"] = nf_titles_lookup["title"].apply( - _normalize_title - ) + nf_titles_lookup["title_key"] = nf_titles_lookup["title"].apply(_normalize_title) nf_with_title = netflix.merge( nf_titles_lookup.drop_duplicates("title_key"), on="title_key", how="left" )