Eric Hierholzer commited on
Commit
0a2f730
·
1 Parent(s): de1dce8

1st commit

Browse files
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
- title: Finalcapstone
3
- emoji: 🏃
4
- colorFrom: blue
5
- colorTo: pink
6
- sdk: docker
7
- pinned: false
8
- short_description: 'Final Capstone Project '
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }