Spaces:
Running
Running
Eric Hierholzer commited on
Commit ·
0a2f730
1
Parent(s): de1dce8
1st commit
Browse files- Dockerfile +24 -0
- README.md +119 -11
- netflix_titles.csv +0 -0
- package-lock.json +13 -0
- package.json +12 -0
- recommend_app.py +136 -0
- recommendation_engine.py +157 -0
- requirements.txt +19 -0
- static/index.html +53 -0
- static/js/api.js +16 -0
- static/js/app.js +53 -0
- static/js/autocomplete.js +70 -0
- static/js/config.js +3 -0
- static/js/ui.js +32 -0
- static/js/visualizations.js +55 -0
- static/styles.css +60 -0
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use official Python runtime as base image
|
| 2 |
+
FROM python:3.12-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory in container
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Copy requirements first (optimizes caching)
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
|
| 10 |
+
# Install dependencies
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
# Copy the rest of the application code
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
# Expose port 7860 (Hugging Face Spaces default)
|
| 17 |
+
EXPOSE 7860
|
| 18 |
+
|
| 19 |
+
# Set environment variables (optional, adjust as needed)
|
| 20 |
+
ENV FLASK_ENV=production
|
| 21 |
+
ENV PORT=7860
|
| 22 |
+
|
| 23 |
+
# Command to run the app with Gunicorn (adjust workers/threads as needed)
|
| 24 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "--threads", "8", "--timeout", "0", "recommend_app:app"]
|
README.md
CHANGED
|
@@ -1,11 +1,119 @@
|
|
| 1 |
-
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Netflix Content-Based Recommender
|
| 2 |
+
|
| 3 |
+
A Flask-based web application that provides personalized recommendations for Netflix content using a content-based filtering approach.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
- Content-based recommendations based on TF-IDF and cosine similarity
|
| 7 |
+
- Search and autocomplete functionality
|
| 8 |
+
- Visualizations of Netflix content distribution
|
| 9 |
+
- Works on both macOS and Windows
|
| 10 |
+
|
| 11 |
+
## Prerequisites
|
| 12 |
+
Ensure you have the following installed:
|
| 13 |
+
- **Python 3.12+** (Check with `python --version` or `python3 --version`)
|
| 14 |
+
- **Node.js and npm** (Check with `node -v` and `npm -v`)
|
| 15 |
+
- **pip** (Python package manager)
|
| 16 |
+
|
| 17 |
+
## Installation
|
| 18 |
+
|
| 19 |
+
### 1. Clone the Repository
|
| 20 |
+
```sh
|
| 21 |
+
git clone https://github.com/yourusername/flick_picker.git
|
| 22 |
+
cd flick_picker
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
### 2. Set Up a Virtual Environment
|
| 26 |
+
|
| 27 |
+
#### macOS & Linux
|
| 28 |
+
```sh
|
| 29 |
+
python3 -m venv venv
|
| 30 |
+
source venv/bin/activate
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
#### Windows (Command Prompt)
|
| 34 |
+
```sh
|
| 35 |
+
python -m venv venv
|
| 36 |
+
venv\Scripts\activate
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
#### Windows (PowerShell)
|
| 40 |
+
```powershell
|
| 41 |
+
python -m venv venv
|
| 42 |
+
venv\Scripts\Activate.ps1
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
### 3. Install Dependencies
|
| 46 |
+
|
| 47 |
+
```sh
|
| 48 |
+
pip install -r requirements.txt
|
| 49 |
+
npm install
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
### 4. Download Dataset
|
| 53 |
+
Ensure `netflix_titles.csv` is placed in the project root directory.
|
| 54 |
+
|
| 55 |
+
### 5. Build or Load Model
|
| 56 |
+
Run the following command to preprocess the dataset and generate a similarity model:
|
| 57 |
+
|
| 58 |
+
```sh
|
| 59 |
+
python recommend_app.py
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
This will load the Netflix dataset, process it, and save a cached similarity model (`cosine_sim_cache.pkl`).
|
| 63 |
+
|
| 64 |
+
## Running the Application
|
| 65 |
+
|
| 66 |
+
Once setup is complete, start the Flask server:
|
| 67 |
+
|
| 68 |
+
```sh
|
| 69 |
+
python recommend_app.py
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
The app will be available at:
|
| 73 |
+
[http://127.0.0.1:5020](http://127.0.0.1:5020)
|
| 74 |
+
|
| 75 |
+
## Frontend Development
|
| 76 |
+
If making changes to the frontend, ensure Node.js dependencies are installed. Run:
|
| 77 |
+
|
| 78 |
+
```sh
|
| 79 |
+
npm run dev
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
## File Structure
|
| 83 |
+
|
| 84 |
+
```
|
| 85 |
+
flick_picker/
|
| 86 |
+
│── recommend_app.py # Flask API entry point
|
| 87 |
+
│── recommendation_engine.py # Content-based filtering logic
|
| 88 |
+
│── netflix_titles.csv # Dataset file
|
| 89 |
+
│── requirements.txt # Python dependencies
|
| 90 |
+
│── package.json # Frontend dependencies
|
| 91 |
+
│── static/
|
| 92 |
+
│ ├── index.html # Main frontend page
|
| 93 |
+
│ ├── js/ # Frontend scripts
|
| 94 |
+
│ ├── styles.css # Stylesheet
|
| 95 |
+
│── venv/ # Python virtual environment
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
## Stopping the Server
|
| 99 |
+
To stop the Flask server, press **Ctrl + C** in the terminal.
|
| 100 |
+
|
| 101 |
+
To deactivate the virtual environment:
|
| 102 |
+
|
| 103 |
+
#### macOS & Linux
|
| 104 |
+
```sh
|
| 105 |
+
deactivate
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
#### Windows
|
| 109 |
+
```sh
|
| 110 |
+
venv\Scripts\deactivate
|
| 111 |
+
```
|
| 112 |
+
#### DOCKER Deployment (EASY MODE)
|
| 113 |
+
|
| 114 |
+
```sh
|
| 115 |
+
docker build -t my-recommend-app .
|
| 116 |
+
docker run -p 7860:7860 my-recommend-app
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
access app at http://0.0.0.0:7860
|
netflix_titles.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package-lock.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "flick_picker",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"lockfileVersion": 3,
|
| 5 |
+
"requires": true,
|
| 6 |
+
"packages": {
|
| 7 |
+
"": {
|
| 8 |
+
"name": "flick_picker",
|
| 9 |
+
"version": "1.0.0",
|
| 10 |
+
"license": "ISC"
|
| 11 |
+
}
|
| 12 |
+
}
|
| 13 |
+
}
|
package.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "flick_picker",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "A Flask-based movie recommendation system that uses **TF-IDF and cosine similarity** to suggest movies based on a given title.",
|
| 5 |
+
"main": "index.js",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"test": "echo \"Error: no test specified\" && exit 1"
|
| 8 |
+
},
|
| 9 |
+
"keywords": [],
|
| 10 |
+
"author": "",
|
| 11 |
+
"license": "ISC"
|
| 12 |
+
}
|
recommend_app.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from flask import Flask, send_from_directory, request, jsonify
|
| 4 |
+
from recommendation_engine import load_and_preprocess_data, build_or_load_model, get_recommendations
|
| 5 |
+
from collections import Counter
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
+
def normalize_title(title):
|
| 9 |
+
"""Convert title to lowercase, strip spaces, and normalize '&' to 'and' for consistency."""
|
| 10 |
+
title = title.lower().strip()
|
| 11 |
+
title = re.sub(r"[^\w\s&]", "", title) # Keep '&' but remove other special characters
|
| 12 |
+
title = re.sub(r"\s+", " ", title) # Replace multiple spaces with a single space
|
| 13 |
+
title = title.replace("&", "and") # Normalize '&' to 'and'
|
| 14 |
+
return title
|
| 15 |
+
|
| 16 |
+
# Configure logging once
|
| 17 |
+
logging.basicConfig(
|
| 18 |
+
level=logging.INFO,
|
| 19 |
+
filename="app.log",
|
| 20 |
+
filemode="a",
|
| 21 |
+
format="%(asctime)s - %(levelname)s - %(message)s"
|
| 22 |
+
)
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
app = Flask(__name__, static_folder='static')
|
| 26 |
+
|
| 27 |
+
# Load data and model at startup
|
| 28 |
+
try:
|
| 29 |
+
df = load_and_preprocess_data("netflix_titles.csv")
|
| 30 |
+
_, cosine_sim_matrix, title_to_index = build_or_load_model(df, "cosine_sim_cache.pkl")
|
| 31 |
+
logger.info("Application started successfully with data and model loaded.")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.error(f"Startup failed: {str(e)}")
|
| 34 |
+
raise
|
| 35 |
+
|
| 36 |
+
@app.route('/')
|
| 37 |
+
def serve_frontend():
|
| 38 |
+
"""Serve the frontend index.html from the static folder."""
|
| 39 |
+
return send_from_directory(app.static_folder, 'index.html')
|
| 40 |
+
|
| 41 |
+
@app.route('/recommend', methods=['GET'])
|
| 42 |
+
def recommend():
|
| 43 |
+
raw_title = request.args.get("title", "").strip()
|
| 44 |
+
title = normalize_title(raw_title) # Normalize once
|
| 45 |
+
|
| 46 |
+
limit = int(request.args.get("limit", 10))
|
| 47 |
+
offset = int(request.args.get("offset", 0))
|
| 48 |
+
content_type = request.args.get("type", None)
|
| 49 |
+
fields = request.args.getlist("fields")
|
| 50 |
+
|
| 51 |
+
# 🚀 Debugging logs
|
| 52 |
+
logger.info(f"Received API request: {request.url}")
|
| 53 |
+
logger.info(f"Raw title received: '{raw_title}'")
|
| 54 |
+
logger.info(f"Normalized title used for lookup: '{title}'")
|
| 55 |
+
print(f"API request received: {request.url}")
|
| 56 |
+
print(f"RAW title received: '{raw_title}'")
|
| 57 |
+
print(f"Normalized title for lookup: '{title}'")
|
| 58 |
+
print(f"Checking if '{title}' exists in title_to_index:", title in title_to_index)
|
| 59 |
+
|
| 60 |
+
if not title:
|
| 61 |
+
return jsonify({"message": "Title required", "recommendations": []}), 400
|
| 62 |
+
|
| 63 |
+
# 🚀 Print available keys to debug mismatches
|
| 64 |
+
if title not in title_to_index or title_to_index[title] is None:
|
| 65 |
+
logger.error(f"Title '{title}' is missing from title_to_index or maps to None!")
|
| 66 |
+
return jsonify({"message": f"'{raw_title}' not found", "recommendations": []}), 404
|
| 67 |
+
print(f"'{title}' NOT FOUND in title_to_index!")
|
| 68 |
+
|
| 69 |
+
# Debugging - Print first 20 keys in title_to_index
|
| 70 |
+
print("Sample titles available in title_to_index:")
|
| 71 |
+
print(list(title_to_index.keys())[:20])
|
| 72 |
+
|
| 73 |
+
return jsonify({"message": f"'{raw_title}' not found", "recommendations": []}), 404
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
recs = get_recommendations(
|
| 77 |
+
title, df, title_to_index, cosine_sim_matrix,
|
| 78 |
+
top_n=limit + offset, content_type=content_type, fields=fields or None
|
| 79 |
+
)
|
| 80 |
+
if not recs:
|
| 81 |
+
logger.warning(f"No recommendations found for '{title}'")
|
| 82 |
+
print(f"⚠️ No recommendations found for '{title}'")
|
| 83 |
+
return jsonify({"message": f"'{raw_title}' not found", "recommendations": []}), 404
|
| 84 |
+
|
| 85 |
+
return jsonify({
|
| 86 |
+
"message": "Similar Movies",
|
| 87 |
+
"recommendations": recs[offset:offset + limit],
|
| 88 |
+
"total": len(recs)
|
| 89 |
+
})
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.error(f"Error generating recommendations for '{title}': {str(e)}")
|
| 92 |
+
print(f"ERROR generating recommendations for '{title}': {str(e)}")
|
| 93 |
+
return jsonify({"message": f"Server error: {str(e)}", "recommendations": []}), 500
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@app.route('/search', methods=['GET'])
|
| 97 |
+
def search_titles():
|
| 98 |
+
"""Return title suggestions based on a query."""
|
| 99 |
+
query = request.args.get("q", "").strip().lower()
|
| 100 |
+
if not query:
|
| 101 |
+
return jsonify([])
|
| 102 |
+
|
| 103 |
+
try:
|
| 104 |
+
filtered = df[df['title'].str.lower().str.contains(query, na=False)]
|
| 105 |
+
suggestions = filtered['title'].head(10).tolist()
|
| 106 |
+
return jsonify(suggestions)
|
| 107 |
+
except Exception as e:
|
| 108 |
+
logger.error(f"Error in search for '{query}': {str(e)}")
|
| 109 |
+
return jsonify([]), 500
|
| 110 |
+
|
| 111 |
+
@app.route('/visualizations', methods=['GET'])
|
| 112 |
+
def get_visualizations():
|
| 113 |
+
"""Return data for visualizations (genre, type, country distributions)."""
|
| 114 |
+
try:
|
| 115 |
+
# Genre Distribution (top 10)
|
| 116 |
+
genres = df['listed_in'].str.split(', ').explode().value_counts().head(10).to_dict()
|
| 117 |
+
|
| 118 |
+
# Type Distribution
|
| 119 |
+
types = df['type'].value_counts().to_dict()
|
| 120 |
+
|
| 121 |
+
# Top Countries (top 5)
|
| 122 |
+
countries = df['country'].str.split(', ').explode().value_counts().head(5).to_dict()
|
| 123 |
+
|
| 124 |
+
logger.info("Generated visualization data successfully.")
|
| 125 |
+
return jsonify({
|
| 126 |
+
"message": "Success",
|
| 127 |
+
"genre_distribution": genres,
|
| 128 |
+
"type_distribution": types,
|
| 129 |
+
"top_countries": countries
|
| 130 |
+
})
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.error(f"Visualization error: {str(e)}")
|
| 133 |
+
return jsonify({"message": f"Error generating visualizations: {str(e)}"}), 500
|
| 134 |
+
|
| 135 |
+
if __name__ == "__main__":
|
| 136 |
+
app.run(debug=False, host="0.0.0.0", port=7860)
|
recommendation_engine.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 4 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 5 |
+
import joblib
|
| 6 |
+
import os
|
| 7 |
+
import logging
|
| 8 |
+
import re
|
| 9 |
+
from scipy.sparse import csr_matrix
|
| 10 |
+
|
| 11 |
+
# Setup logging
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
def normalize_title(title):
|
| 15 |
+
"""Convert title to lowercase, strip spaces, and normalize '&' for consistency."""
|
| 16 |
+
title = title.lower().strip()
|
| 17 |
+
title = re.sub(r"[^\w\s&]", "", title) # Keep '&' but remove other special characters
|
| 18 |
+
title = re.sub(r"\s+", " ", title) # Replace multiple spaces with a single space
|
| 19 |
+
title = title.replace("&", "and") # Normalize '&' to 'and'
|
| 20 |
+
return title
|
| 21 |
+
|
| 22 |
+
def load_and_preprocess_data(csv_file="netflix_titles.csv"):
|
| 23 |
+
"""Loads Netflix dataset, cleans, and prepares it for TF-IDF."""
|
| 24 |
+
try:
|
| 25 |
+
df = pd.read_csv(csv_file)
|
| 26 |
+
logger.info(f"Loaded dataset from {csv_file} with {len(df)} rows.")
|
| 27 |
+
|
| 28 |
+
# Drop duplicates by title
|
| 29 |
+
df.drop_duplicates(subset='title', keep='first', inplace=True)
|
| 30 |
+
|
| 31 |
+
# Fill missing text fields with 'unknown'
|
| 32 |
+
text_cols = ['director', 'cast', 'country', 'listed_in', 'description']
|
| 33 |
+
for col in text_cols:
|
| 34 |
+
df[col] = df[col].fillna('unknown').astype(str).str.lower()
|
| 35 |
+
|
| 36 |
+
# Combine text features for recommendations
|
| 37 |
+
df['combined_features'] = (
|
| 38 |
+
df['director'] + ' ' +
|
| 39 |
+
df['cast'] + ' ' +
|
| 40 |
+
df['listed_in'] + ' ' +
|
| 41 |
+
df['description']
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
return df
|
| 45 |
+
|
| 46 |
+
except FileNotFoundError:
|
| 47 |
+
logger.error(f"Dataset file '{csv_file}' not found.")
|
| 48 |
+
raise FileNotFoundError(f"Dataset file '{csv_file}' not found.")
|
| 49 |
+
except Exception as e:
|
| 50 |
+
logger.error(f"Error loading data from {csv_file}: {str(e)}")
|
| 51 |
+
raise Exception(f"Error loading data: {str(e)}")
|
| 52 |
+
|
| 53 |
+
def build_or_load_model(df, cache_file="cosine_sim_cache.pkl"):
|
| 54 |
+
"""Builds or loads TF-IDF matrix and cosine similarity, with caching."""
|
| 55 |
+
if os.path.exists(cache_file):
|
| 56 |
+
try:
|
| 57 |
+
tfidf_matrix, cosine_sim_matrix, title_to_index = joblib.load(cache_file)
|
| 58 |
+
logger.info(f"Loaded cached model from {cache_file}.")
|
| 59 |
+
return tfidf_matrix, cosine_sim_matrix, title_to_index
|
| 60 |
+
except Exception as e:
|
| 61 |
+
logger.warning(f"Failed to load cache from {cache_file}: {str(e)}. Rebuilding model.")
|
| 62 |
+
|
| 63 |
+
# Build model if cache doesn’t exist or fails
|
| 64 |
+
try:
|
| 65 |
+
tfidf = TfidfVectorizer(
|
| 66 |
+
stop_words='english',
|
| 67 |
+
ngram_range=(1, 2), # Capture word pairs (bigrams) for better similarity
|
| 68 |
+
min_df=2 # Ignore rare words appearing in only 1 document
|
| 69 |
+
)
|
| 70 |
+
tfidf_matrix = tfidf.fit_transform(df['combined_features'])
|
| 71 |
+
|
| 72 |
+
# Ensure matrix is valid
|
| 73 |
+
if tfidf_matrix.shape[0] == 0 or tfidf_matrix.shape[1] == 0:
|
| 74 |
+
raise ValueError("TF-IDF matrix is empty! Check feature extraction.")
|
| 75 |
+
|
| 76 |
+
# Compute cosine similarity
|
| 77 |
+
cosine_sim_matrix = cosine_similarity(tfidf_matrix, tfidf_matrix)
|
| 78 |
+
|
| 79 |
+
# Convert to sparse matrix for efficiency
|
| 80 |
+
cosine_sim_matrix = csr_matrix(cosine_sim_matrix)
|
| 81 |
+
|
| 82 |
+
# Map normalized titles -> index
|
| 83 |
+
df["normalized_title"] = df["title"].apply(normalize_title)
|
| 84 |
+
title_to_index = pd.Series(df.index, index=df["normalized_title"]).drop_duplicates()
|
| 85 |
+
|
| 86 |
+
# Debugging logs
|
| 87 |
+
logger.info(f"Sample normalized titles in title_to_index: {list(title_to_index.keys())[:20]}")
|
| 88 |
+
logger.info(f"Checking if 'carole and tuesday' exists in title_to_index: {'carole and tuesday' in title_to_index}")
|
| 89 |
+
|
| 90 |
+
# Cache the results
|
| 91 |
+
joblib.dump((tfidf_matrix, cosine_sim_matrix, title_to_index), cache_file)
|
| 92 |
+
logger.info(f"Built and cached model to {cache_file}.")
|
| 93 |
+
|
| 94 |
+
return tfidf_matrix, cosine_sim_matrix, title_to_index
|
| 95 |
+
|
| 96 |
+
except Exception as e:
|
| 97 |
+
logger.error(f"Error building model: {str(e)}")
|
| 98 |
+
raise
|
| 99 |
+
|
| 100 |
+
def get_recommendations(title, df, title_to_index, cosine_sim_matrix, top_n=10, content_type=None, fields=None):
|
| 101 |
+
"""Returns a list of recommendation dictionaries based on cosine similarity."""
|
| 102 |
+
|
| 103 |
+
if not all([df is not None, title_to_index is not None, cosine_sim_matrix is not None]):
|
| 104 |
+
logger.error("One or more critical components (df, title_to_index, cosine_sim_matrix) are None!")
|
| 105 |
+
raise ValueError("DataFrame, title_to_index, and cosine_sim_matrix must not be None.")
|
| 106 |
+
|
| 107 |
+
if not isinstance(top_n, int) or top_n <= 0:
|
| 108 |
+
raise ValueError("top_n must be a positive integer.")
|
| 109 |
+
|
| 110 |
+
if not isinstance(title, str) or not title.strip():
|
| 111 |
+
raise ValueError("Title must be a non-empty string.")
|
| 112 |
+
|
| 113 |
+
# Normalize title for lookup
|
| 114 |
+
title = normalize_title(title)
|
| 115 |
+
|
| 116 |
+
# Ensure title exists
|
| 117 |
+
if title not in title_to_index:
|
| 118 |
+
logger.warning(f"'{title}' NOT found in title_to_index!")
|
| 119 |
+
return []
|
| 120 |
+
|
| 121 |
+
idx = title_to_index[title]
|
| 122 |
+
|
| 123 |
+
# Get similarity scores
|
| 124 |
+
try:
|
| 125 |
+
sim_scores = list(enumerate(cosine_sim_matrix[idx].toarray()[0]))
|
| 126 |
+
except Exception as e:
|
| 127 |
+
logger.error(f"Error computing similarity scores for '{title}': {str(e)}")
|
| 128 |
+
return []
|
| 129 |
+
|
| 130 |
+
logger.info(f"🔍 Raw similarity scores for '{title}': {sim_scores[:10]}")
|
| 131 |
+
|
| 132 |
+
# Sort by similarity
|
| 133 |
+
sim_scores = sorted(sim_scores, key=lambda x: x[1], reverse=True)
|
| 134 |
+
sim_scores = sim_scores[1:] # Exclude the input title itself
|
| 135 |
+
|
| 136 |
+
logger.info(f"🔍 Sorted similarity scores for '{title}': {sim_scores[:10]}")
|
| 137 |
+
|
| 138 |
+
# If all similarity scores are 0, issue a warning
|
| 139 |
+
if all(score[1] == 0 for score in sim_scores):
|
| 140 |
+
logger.warning(f"⚠️ All similarity scores for '{title}' are 0! No recommendations possible.")
|
| 141 |
+
return []
|
| 142 |
+
|
| 143 |
+
# Build recommendations list
|
| 144 |
+
recommendations = []
|
| 145 |
+
for movie_idx, score in sim_scores:
|
| 146 |
+
if content_type and df['type'].iloc[movie_idx].lower() != content_type.lower():
|
| 147 |
+
continue
|
| 148 |
+
|
| 149 |
+
recommendation = {field: df[field].iloc[movie_idx] for field in (fields or ['title']) if field in df.columns}
|
| 150 |
+
recommendation['similarity'] = float(score)
|
| 151 |
+
recommendations.append(recommendation)
|
| 152 |
+
|
| 153 |
+
if len(recommendations) >= top_n:
|
| 154 |
+
break
|
| 155 |
+
|
| 156 |
+
logger.info(f"Found {len(recommendations)} recommendations for '{title}'")
|
| 157 |
+
return recommendations
|
requirements.txt
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
blinker==1.9.0
|
| 2 |
+
click==8.1.8
|
| 3 |
+
Flask==3.1.0
|
| 4 |
+
Flask-Cors==5.0.0
|
| 5 |
+
gunicorn
|
| 6 |
+
itsdangerous==2.2.0
|
| 7 |
+
Jinja2==3.1.5
|
| 8 |
+
joblib==1.4.2
|
| 9 |
+
MarkupSafe==3.0.2
|
| 10 |
+
numpy==2.2.3
|
| 11 |
+
pandas==2.2.3
|
| 12 |
+
python-dateutil==2.9.0.post0
|
| 13 |
+
pytz==2025.1
|
| 14 |
+
scikit-learn==1.6.1
|
| 15 |
+
scipy==1.15.2
|
| 16 |
+
six==1.17.0
|
| 17 |
+
threadpoolctl==3.5.0
|
| 18 |
+
tzdata==2025.1
|
| 19 |
+
Werkzeug==3.1.3
|
static/index.html
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Netflix Recommender</title>
|
| 7 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
| 8 |
+
<link rel="stylesheet" href="/static/styles.css">
|
| 9 |
+
</head>
|
| 10 |
+
<body class="container my-4">
|
| 11 |
+
<h1 class="text-center">Netflix Content-Based Recommender</h1>
|
| 12 |
+
|
| 13 |
+
<!-- Input + Autocomplete -->
|
| 14 |
+
<div class="mb-3 position-relative">
|
| 15 |
+
<label for="movie-input" class="form-label">Enter a Movie or Show Title:</label>
|
| 16 |
+
<input type="text" class="form-control" id="movie-input" placeholder="e.g., 3 Idiots" autocomplete="off" />
|
| 17 |
+
<div id="autocomplete-list" class="list-group position-absolute w-100 d-none"
|
| 18 |
+
style="z-index: 1000; max-height: 200px; overflow-y: auto;"></div>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<!-- Recommendation Button -->
|
| 22 |
+
<button id="recommend-btn" class="btn btn-primary">Get Recommendations</button>
|
| 23 |
+
|
| 24 |
+
<!-- Recommendation Results with Spinner -->
|
| 25 |
+
<div id="results" class="mt-4">
|
| 26 |
+
<div class="spinner-border text-primary d-none" role="status">
|
| 27 |
+
<span class="visually-hidden">Loading...</span>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<!-- Visualizations -->
|
| 32 |
+
<div id="visualizations" class="mt-5"></div>
|
| 33 |
+
|
| 34 |
+
<!-- NoScript Message -->
|
| 35 |
+
<noscript>
|
| 36 |
+
<p class="text-danger text-center">JavaScript is required to use this site. Please enable JavaScript in your browser settings.</p>
|
| 37 |
+
</noscript>
|
| 38 |
+
|
| 39 |
+
<!-- Scripts (Now with type="module") -->
|
| 40 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 41 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 42 |
+
|
| 43 |
+
<script type="module">
|
| 44 |
+
import "/static/js/config.js";
|
| 45 |
+
import "/static/js/api.js";
|
| 46 |
+
import "/static/js/ui.js";
|
| 47 |
+
import "/static/js/autocomplete.js";
|
| 48 |
+
import "/static/js/visualizations.js";
|
| 49 |
+
import "/static/js/app.js";
|
| 50 |
+
</script>
|
| 51 |
+
|
| 52 |
+
</body>
|
| 53 |
+
</html>
|
static/js/api.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { API_ENDPOINT, SEARCH_ENDPOINT, VISUALIZATION_ENDPOINT } from './config.js';
|
| 2 |
+
|
| 3 |
+
export async function fetchRecommendations(title) {
|
| 4 |
+
const response = await fetch(`${API_ENDPOINT}?title=${encodeURIComponent(title)}`);
|
| 5 |
+
return response.json();
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export async function fetchAutocomplete(query) {
|
| 9 |
+
const response = await fetch(`${SEARCH_ENDPOINT}?q=${encodeURIComponent(query)}`);
|
| 10 |
+
return response.json();
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export async function fetchVisualizations() {
|
| 14 |
+
const response = await fetch(VISUALIZATION_ENDPOINT);
|
| 15 |
+
return response.json();
|
| 16 |
+
}
|
static/js/app.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { fetchRecommendations } from './api.js';
|
| 2 |
+
import { showSpinner, hideSpinner, displayRecommendations, showError } from './ui.js';
|
| 3 |
+
import { handleAutocomplete } from './autocomplete.js';
|
| 4 |
+
import { loadVisualizations } from './visualizations.js';
|
| 5 |
+
|
| 6 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 7 |
+
const recommendBtn = document.getElementById('recommend-btn');
|
| 8 |
+
const movieInput = document.getElementById('movie-input');
|
| 9 |
+
|
| 10 |
+
if (recommendBtn) {
|
| 11 |
+
recommendBtn.addEventListener('click', getRecommendations);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
if (movieInput) {
|
| 15 |
+
movieInput.addEventListener('keyup', event => {
|
| 16 |
+
console.log("Key pressed:", event.key);
|
| 17 |
+
if (event.key === 'Enter') {
|
| 18 |
+
console.log("Enter pressed, fetching recommendations...");
|
| 19 |
+
getRecommendations();
|
| 20 |
+
} else {
|
| 21 |
+
console.log("Triggering autocomplete for:", event.target.value);
|
| 22 |
+
handleAutocomplete(event);
|
| 23 |
+
}
|
| 24 |
+
});
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
loadVisualizations();
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
async function getRecommendations() {
|
| 31 |
+
const titleInput = document.getElementById('movie-input');
|
| 32 |
+
const resultsDiv = document.getElementById('results');
|
| 33 |
+
|
| 34 |
+
if (!titleInput || !resultsDiv) return;
|
| 35 |
+
const title = titleInput.value.trim();
|
| 36 |
+
if (!title) {
|
| 37 |
+
showError(resultsDiv, 'Please enter a title.');
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
showSpinner(resultsDiv);
|
| 42 |
+
try {
|
| 43 |
+
const data = await fetchRecommendations(title);
|
| 44 |
+
hideSpinner(resultsDiv);
|
| 45 |
+
displayRecommendations(resultsDiv, data, title);
|
| 46 |
+
} catch (error) {
|
| 47 |
+
console.error('Error fetching recommendations:', error);
|
| 48 |
+
hideSpinner(resultsDiv);
|
| 49 |
+
showError(resultsDiv, 'Error fetching recommendations.');
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export { getRecommendations };
|
static/js/autocomplete.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { fetchAutocomplete } from './api.js';
|
| 2 |
+
import { getRecommendations } from "./app.js";
|
| 3 |
+
|
| 4 |
+
let autocompleteTimer = null;
|
| 5 |
+
|
| 6 |
+
export function handleAutocomplete(event) {
|
| 7 |
+
console.log("Autocomplete triggered with:", event.target.value);
|
| 8 |
+
|
| 9 |
+
const query = event.target.value.trim();
|
| 10 |
+
const suggestionBox = document.getElementById('autocomplete-list');
|
| 11 |
+
|
| 12 |
+
if (!suggestionBox) {
|
| 13 |
+
console.error("Autocomplete list element not found in DOM");
|
| 14 |
+
return;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
suggestionBox.innerHTML = '';
|
| 18 |
+
if (query.length < 2) return;
|
| 19 |
+
|
| 20 |
+
clearTimeout(autocompleteTimer);
|
| 21 |
+
autocompleteTimer = setTimeout(async () => {
|
| 22 |
+
try {
|
| 23 |
+
console.log("Fetching autocomplete results for:", query);
|
| 24 |
+
const suggestions = await fetchAutocomplete(query);
|
| 25 |
+
console.log("Received autocomplete suggestions:", suggestions);
|
| 26 |
+
showSuggestions(suggestions);
|
| 27 |
+
} catch (error) {
|
| 28 |
+
console.error("Error fetching autocomplete suggestions:", error);
|
| 29 |
+
}
|
| 30 |
+
}, 300);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
export function showSuggestions(suggestions) {
|
| 35 |
+
const suggestionBox = document.getElementById("autocomplete-list");
|
| 36 |
+
if (!suggestionBox) {
|
| 37 |
+
console.error("Autocomplete list not found in the DOM");
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
console.log("Updating UI with suggestions:", suggestions);
|
| 42 |
+
|
| 43 |
+
suggestionBox.innerHTML = ""; // Clear previous suggestions
|
| 44 |
+
|
| 45 |
+
if (!suggestions.length) {
|
| 46 |
+
console.log("No autocomplete suggestions found.");
|
| 47 |
+
suggestionBox.classList.add("d-none"); // Hide if no results
|
| 48 |
+
return;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
suggestionBox.classList.remove("d-none"); // Ensure it's visible
|
| 52 |
+
|
| 53 |
+
suggestions.forEach((title) => {
|
| 54 |
+
const item = document.createElement("div");
|
| 55 |
+
item.classList.add("list-group-item", "list-group-item-action");
|
| 56 |
+
item.textContent = title;
|
| 57 |
+
item.addEventListener("click", () => {
|
| 58 |
+
const movieInput = document.getElementById("movie-input");
|
| 59 |
+
if (movieInput) {
|
| 60 |
+
movieInput.value = title;
|
| 61 |
+
}
|
| 62 |
+
suggestionBox.innerHTML = "";
|
| 63 |
+
suggestionBox.classList.add("d-none");
|
| 64 |
+
getRecommendations(); // Now correctly imported
|
| 65 |
+
});
|
| 66 |
+
suggestionBox.appendChild(item);
|
| 67 |
+
});
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
static/js/config.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const API_ENDPOINT = "/recommend";
|
| 2 |
+
export const SEARCH_ENDPOINT = "/search";
|
| 3 |
+
export const VISUALIZATION_ENDPOINT = "/visualizations";
|
static/js/ui.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function showSpinner(container) {
|
| 2 |
+
container.innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
|
| 3 |
+
}
|
| 4 |
+
|
| 5 |
+
export function hideSpinner(container) {
|
| 6 |
+
const spinner = container.querySelector('.spinner-border');
|
| 7 |
+
if (spinner) spinner.style.display = 'none';
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function displayRecommendations(resultsDiv, data, title) {
|
| 11 |
+
if (data.recommendations && data.recommendations.length > 0) {
|
| 12 |
+
let html = `<h5>${data.message}</h5><ul class="list-group mt-2">`;
|
| 13 |
+
|
| 14 |
+
data.recommendations.forEach(item => {
|
| 15 |
+
html += `<li class="list-group-item"><strong>${item.title}</strong>`;
|
| 16 |
+
if (item.similarity) {
|
| 17 |
+
html += `<span class="badge bg-primary float-end">Similarity: ${(item.similarity * 100).toFixed(2)}%</span>`;
|
| 18 |
+
}
|
| 19 |
+
html += `</li>`;
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
html += '</ul>';
|
| 23 |
+
resultsDiv.innerHTML = html;
|
| 24 |
+
} else {
|
| 25 |
+
resultsDiv.innerHTML = `<p>No recommendations found for '${title}'.</p>`;
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export function showError(container, message) {
|
| 30 |
+
container.innerHTML = `<p class="text-danger">${message}</p>`;
|
| 31 |
+
}
|
| 32 |
+
|
static/js/visualizations.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { fetchVisualizations } from './api.js';
|
| 2 |
+
|
| 3 |
+
export async function loadVisualizations() {
|
| 4 |
+
const visDiv = document.getElementById('visualizations');
|
| 5 |
+
if (!visDiv) return;
|
| 6 |
+
|
| 7 |
+
visDiv.innerHTML = '<div class="spinner-border text-primary"></div>';
|
| 8 |
+
|
| 9 |
+
try {
|
| 10 |
+
const data = await fetchVisualizations();
|
| 11 |
+
visDiv.innerHTML = '';
|
| 12 |
+
renderVisualizations(data);
|
| 13 |
+
} catch (error) {
|
| 14 |
+
console.error('Error loading visualizations:', error);
|
| 15 |
+
visDiv.innerHTML = '<p class="text-danger">Error loading visualizations.</p>';
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function renderVisualizations(data) {
|
| 20 |
+
const visDiv = document.getElementById('visualizations');
|
| 21 |
+
visDiv.innerHTML = `
|
| 22 |
+
<h5>Data Insights</h5>
|
| 23 |
+
<div class="row">
|
| 24 |
+
<div class="col-md-4"><canvas id="genreChart"></canvas></div>
|
| 25 |
+
<div class="col-md-4"><canvas id="typeChart"></canvas></div>
|
| 26 |
+
<div class="col-md-4"><canvas id="countryChart"></canvas></div>
|
| 27 |
+
</div>
|
| 28 |
+
`;
|
| 29 |
+
|
| 30 |
+
new Chart(document.getElementById('genreChart').getContext('2d'), {
|
| 31 |
+
type: 'bar',
|
| 32 |
+
data: {
|
| 33 |
+
labels: Object.keys(data.genre_distribution),
|
| 34 |
+
datasets: [{ label: 'Number of Titles', data: Object.values(data.genre_distribution), backgroundColor: 'rgba(75, 192, 192, 0.6)' }]
|
| 35 |
+
},
|
| 36 |
+
options: { scales: { y: { beginAtZero: true } } }
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
new Chart(document.getElementById('typeChart').getContext('2d'), {
|
| 40 |
+
type: 'pie',
|
| 41 |
+
data: {
|
| 42 |
+
labels: Object.keys(data.type_distribution),
|
| 43 |
+
datasets: [{ data: Object.values(data.type_distribution), backgroundColor: ['#FF6384', '#36A2EB'] }]
|
| 44 |
+
}
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
new Chart(document.getElementById('countryChart').getContext('2d'), {
|
| 48 |
+
type: 'bar',
|
| 49 |
+
data: {
|
| 50 |
+
labels: Object.keys(data.top_countries),
|
| 51 |
+
datasets: [{ label: 'Number of Titles', data: Object.values(data.top_countries), backgroundColor: 'rgba(255, 159, 64, 0.6)' }]
|
| 52 |
+
},
|
| 53 |
+
options: { indexAxis: 'y', scales: { x: { beginAtZero: true } } }
|
| 54 |
+
});
|
| 55 |
+
}
|
static/styles.css
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* General styling */
|
| 2 |
+
body {
|
| 3 |
+
font-family: Arial, sans-serif;
|
| 4 |
+
color: #333;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
/* Input and Autocomplete */
|
| 8 |
+
#movie-input {
|
| 9 |
+
margin-top: 0.5em;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
#autocomplete-list {
|
| 13 |
+
z-index: 1000;
|
| 14 |
+
border: 1px solid #ccc;
|
| 15 |
+
border-radius: 0.25rem;
|
| 16 |
+
background-color: #fff;
|
| 17 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
#autocomplete-list .list-group-item:hover {
|
| 21 |
+
background-color: #f8f9fa;
|
| 22 |
+
cursor: pointer;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/* Field Selection Checkboxes */
|
| 26 |
+
.form-check-label {
|
| 27 |
+
margin-left: 5px;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* Results */
|
| 31 |
+
#results ul {
|
| 32 |
+
margin-top: 1em;
|
| 33 |
+
padding-left: 0;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
#results li {
|
| 37 |
+
margin: 0.5em 0;
|
| 38 |
+
border-radius: 8px;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
#results .spinner-border {
|
| 42 |
+
margin: 20px auto;
|
| 43 |
+
display: block;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/* Visualizations */
|
| 47 |
+
#visualizations {
|
| 48 |
+
margin-top: 2em;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
#visualizations canvas {
|
| 52 |
+
max-height: 300px;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/* Responsive adjustments */
|
| 56 |
+
@media (max-width: 768px) {
|
| 57 |
+
#visualizations .col-md-4 {
|
| 58 |
+
margin-bottom: 1em;
|
| 59 |
+
}
|
| 60 |
+
}
|