Compare commits
2 Commits
914c085cac
...
08038d165d
Author | SHA1 | Date | |
---|---|---|---|
|
08038d165d | ||
|
0a97556f5e |
460
app.py
460
app.py
@ -1,273 +1,237 @@
|
|||||||
from flask import Flask, request, render_template, redirect, url_for, session
|
import json, math, random
|
||||||
import json
|
from flask import Flask, render_template, request, redirect, url_for, session, flash
|
||||||
import numpy as np
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
import random
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
import math
|
|
||||||
from sklearn.feature_extraction.text import TfidfVectorizer
|
|
||||||
from sklearn.metrics.pairwise import cosine_similarity
|
|
||||||
|
|
||||||
|
# Initialize Flask and database
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = 'your_secret_key_here' # Replace with a secure key in production
|
app.config['SECRET_KEY'] = 'your_secret_key' # Replace with a secure secret key
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db'
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
db = SQLAlchemy(app)
|
||||||
|
|
||||||
# Load movies from top_movies.json with UTF-8 encoding
|
# -------------------- Models --------------------
|
||||||
with open('top_movies.json', 'r', encoding='utf-8') as f:
|
class User(db.Model):
|
||||||
movies = json.load(f)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(150), unique=True, nullable=False)
|
||||||
|
password_hash = db.Column(db.String(150), nullable=False)
|
||||||
|
# Store the profile as a JSON string mapping movie title to rating.
|
||||||
|
profile = db.Column(db.Text, nullable=False, default='{}')
|
||||||
|
|
||||||
# Preprocess each movie
|
def set_password(self, password):
|
||||||
for i, movie in enumerate(movies):
|
self.password_hash = generate_password_hash(password)
|
||||||
movie['id'] = i # Unique ID
|
|
||||||
# Combine genres and tags into one feature string.
|
|
||||||
movie['features'] = ' '.join(movie.get('genres', [])) + ' ' + ' '.join(movie.get('tags', []))
|
|
||||||
# Ensure numeric values for year and runtime:
|
|
||||||
try:
|
|
||||||
movie['year_num'] = int(movie.get('year', '0'))
|
|
||||||
except:
|
|
||||||
movie['year_num'] = 0
|
|
||||||
try:
|
|
||||||
movie['runtime_num'] = float(movie.get('runtime')) if movie.get('runtime') else 0
|
|
||||||
except:
|
|
||||||
movie['runtime_num'] = 0
|
|
||||||
# Ensure vote_count is numeric.
|
|
||||||
try:
|
|
||||||
count = movie.get('vote_count', 0)
|
|
||||||
if isinstance(count, str):
|
|
||||||
count = count.replace(',', '')
|
|
||||||
if 'M' in count:
|
|
||||||
count = float(count.replace('M', '')) * 1e6
|
|
||||||
else:
|
|
||||||
count = int(count)
|
|
||||||
movie['vote_count'] = int(count)
|
|
||||||
except:
|
|
||||||
movie['vote_count'] = 0
|
|
||||||
|
|
||||||
# Build the TF‑IDF vectorizer on movie features.
|
def check_password(self, password):
|
||||||
vectorizer = TfidfVectorizer(stop_words='english')
|
return check_password_hash(self.password_hash, password)
|
||||||
movie_features = [movie['features'] for movie in movies]
|
|
||||||
movie_vectors = vectorizer.fit_transform(movie_features)
|
|
||||||
|
|
||||||
# Precompute overall ranges for numeric features.
|
# Create the database tables (if needed)
|
||||||
years = [m['year_num'] for m in movies if m['year_num'] > 0]
|
with app.app_context():
|
||||||
runtimes = [m['runtime_num'] for m in movies if m['runtime_num'] > 0]
|
db.create_all()
|
||||||
max_vote = max([m['vote_count'] for m in movies]) if movies else 1
|
|
||||||
|
|
||||||
min_year, max_year = (min(years), max(years)) if years else (0, 1)
|
# -------------------- Movie Data & Class --------------------
|
||||||
min_runtime, max_runtime = (min(runtimes), max(runtimes)) if runtimes else (0, 1)
|
class Movie:
|
||||||
year_range = max_year - min_year if max_year != min_year else 1
|
__slots__ = ('title', 'year', 'imdb_rating', 'runtime', 'features', 'description', 'poster')
|
||||||
runtime_range = max_runtime - min_runtime if max_runtime != min_runtime else 1
|
def __init__(self, data):
|
||||||
rating_range = 10.0 # Assuming ratings are on a 0–10 scale
|
self.title = data.get("title", "")
|
||||||
|
try:
|
||||||
|
self.year = int(data.get("year", 0))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
self.year = 0
|
||||||
|
try:
|
||||||
|
self.imdb_rating = float(data.get("imdb_rating", 0))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
self.imdb_rating = 0.0
|
||||||
|
try:
|
||||||
|
self.runtime = int(data.get("runtime", 0))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
self.runtime = 0
|
||||||
|
# For genres/tags, use a frozenset for fast membership testing.
|
||||||
|
genres = data.get("genres", [])
|
||||||
|
tags = data.get("tags", [])
|
||||||
|
self.features = frozenset(genres + tags)
|
||||||
|
self.description = data.get("description", "")
|
||||||
|
self.poster = data.get("poster", "")
|
||||||
|
|
||||||
def get_predicted_movies(num=10):
|
# Preload movies from JSON once.
|
||||||
|
MOVIES = []
|
||||||
|
MOVIE_INDEX = {} # mapping from title to Movie object
|
||||||
|
with open('movies.json', 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
for entry in data:
|
||||||
|
movie = Movie(entry)
|
||||||
|
MOVIES.append(movie)
|
||||||
|
MOVIE_INDEX[movie.title] = movie
|
||||||
|
|
||||||
|
# -------------------- Similarity Functions --------------------
|
||||||
|
def jaccard_similarity(set1, set2):
|
||||||
|
if not set1 and not set2:
|
||||||
|
return 0
|
||||||
|
inter = len(set1 & set2)
|
||||||
|
union = len(set1 | set2)
|
||||||
|
return inter / union if union else 0
|
||||||
|
|
||||||
|
def advanced_similarity(movie1, movie2, weights=None):
|
||||||
|
if weights is None:
|
||||||
|
weights = {
|
||||||
|
"genres_tags": 0.5,
|
||||||
|
"imdb": 0.2,
|
||||||
|
"year": 0.15,
|
||||||
|
"runtime": 0.15
|
||||||
|
}
|
||||||
|
# Genres/tags similarity via Jaccard index.
|
||||||
|
sim_genres = jaccard_similarity(movie1.features, movie2.features)
|
||||||
|
# IMDb rating similarity: difference normalized over a 10-point scale.
|
||||||
|
sim_imdb = max(0, 1 - abs(movie1.imdb_rating - movie2.imdb_rating) / 10)
|
||||||
|
# Year similarity using exponential decay.
|
||||||
|
sim_year = math.exp(-abs(movie1.year - movie2.year) / 10)
|
||||||
|
# Runtime similarity using exponential decay.
|
||||||
|
sim_runtime = math.exp(-abs(movie1.runtime - movie2.runtime) / 30)
|
||||||
|
overall = (weights["genres_tags"] * sim_genres +
|
||||||
|
weights["imdb"] * sim_imdb +
|
||||||
|
weights["year"] * sim_year +
|
||||||
|
weights["runtime"] * sim_runtime)
|
||||||
|
return overall
|
||||||
|
|
||||||
|
def recommend_movie(profile, weights=None):
|
||||||
"""
|
"""
|
||||||
Return up to `num` movies that haven't been shown yet.
|
Given a user profile (dict mapping movie title to rating), return one recommended Movie.
|
||||||
Uses the user's past ratings to predict which unseen movies they might like.
|
The function iterates over candidate movies (those not rated yet) and computes an aggregated
|
||||||
If no ratings exist, falls back to random selection.
|
weighted similarity score against all rated movies.
|
||||||
"""
|
"""
|
||||||
asked = session.get('asked_movies', [])
|
rated_titles = set(profile.keys())
|
||||||
available = [m for m in movies if m['id'] not in asked]
|
candidates = [m for m in MOVIES if m.title not in rated_titles]
|
||||||
if not available:
|
|
||||||
return []
|
|
||||||
rated = session.get('rated_movies', {})
|
|
||||||
# Fallback to random selection if there are no like/dislike ratings.
|
|
||||||
if not rated or not any(r in ['like', 'dislike'] for r in rated.values()):
|
|
||||||
random.shuffle(available)
|
|
||||||
return available[:num]
|
|
||||||
|
|
||||||
# Build prediction profiles.
|
best_movie = None
|
||||||
liked_ids = [int(mid) for mid, rating in rated.items() if rating == 'like']
|
best_score = -1
|
||||||
disliked_ids = [int(mid) for mid, rating in rated.items() if rating == 'dislike']
|
for candidate in candidates:
|
||||||
|
weighted_scores = []
|
||||||
|
for title, rating in profile.items():
|
||||||
|
# Skip movies not seen (rating 0)
|
||||||
|
if rating == 0:
|
||||||
|
continue
|
||||||
|
rated_movie = MOVIE_INDEX.get(title)
|
||||||
|
if not rated_movie:
|
||||||
|
continue
|
||||||
|
# Normalize rating: 1 becomes 0 and 5 becomes 1.
|
||||||
|
norm_rating = (rating - 1) / 4
|
||||||
|
sim = advanced_similarity(candidate, rated_movie, weights=weights)
|
||||||
|
weighted_scores.append(norm_rating * sim)
|
||||||
|
avg_score = sum(weighted_scores) / len(weighted_scores) if weighted_scores else 0
|
||||||
|
if avg_score > best_score:
|
||||||
|
best_score = avg_score
|
||||||
|
best_movie = candidate
|
||||||
|
return best_movie
|
||||||
|
|
||||||
if liked_ids:
|
# -------------------- Helper Functions --------------------
|
||||||
liked_profile = np.asarray(movie_vectors[liked_ids].mean(axis=0))
|
import json as pyjson
|
||||||
|
|
||||||
|
def get_profile():
|
||||||
|
"""Return the current user's profile as a dict.
|
||||||
|
If not logged in, use session storage."""
|
||||||
|
if 'user_id' in session:
|
||||||
|
user = User.query.get(session['user_id'])
|
||||||
|
if user:
|
||||||
|
try:
|
||||||
|
return pyjson.loads(user.profile)
|
||||||
|
except:
|
||||||
|
return {}
|
||||||
|
return session.get('profile', {})
|
||||||
|
|
||||||
|
def save_profile(profile):
|
||||||
|
"""Save the profile to the database (if logged in) or session."""
|
||||||
|
if 'user_id' in session:
|
||||||
|
user = User.query.get(session['user_id'])
|
||||||
|
if user:
|
||||||
|
user.profile = pyjson.dumps(profile)
|
||||||
|
db.session.commit()
|
||||||
else:
|
else:
|
||||||
liked_profile = np.zeros((1, movie_vectors.shape[1]))
|
session['profile'] = profile
|
||||||
if disliked_ids:
|
|
||||||
disliked_profile = np.asarray(movie_vectors[disliked_ids].mean(axis=0))
|
|
||||||
else:
|
|
||||||
disliked_profile = np.zeros((1, movie_vectors.shape[1]))
|
|
||||||
|
|
||||||
# Compute numeric averages for liked movies.
|
|
||||||
liked_years = [movies[i]['year_num'] for i in liked_ids if movies[i]['year_num'] > 0]
|
|
||||||
liked_runtimes = [movies[i]['runtime_num'] for i in liked_ids if movies[i]['runtime_num'] > 0]
|
|
||||||
liked_ratings = [movies[i].get('imdb_rating', 0) for i in liked_ids if movies[i].get('imdb_rating', 0)]
|
|
||||||
|
|
||||||
avg_year = np.mean(liked_years) if liked_years else None
|
|
||||||
avg_runtime = np.mean(liked_runtimes) if liked_runtimes else None
|
|
||||||
avg_rating = np.mean(liked_ratings) if liked_ratings else None
|
|
||||||
|
|
||||||
predictions = []
|
|
||||||
# Tunable weights.
|
|
||||||
w_text = 0.5
|
|
||||||
w_year = 0.1
|
|
||||||
w_runtime = 0.1
|
|
||||||
w_rating = 0.15
|
|
||||||
w_popularity = 0.15
|
|
||||||
|
|
||||||
for movie in available:
|
|
||||||
i = movie['id']
|
|
||||||
# TEXT SIMILARITY.
|
|
||||||
movie_vector = movie_vectors[i].toarray()
|
|
||||||
like_sim = cosine_similarity(movie_vector, liked_profile)[0][0] if np.linalg.norm(liked_profile) != 0 else 0
|
|
||||||
dislike_sim = cosine_similarity(movie_vector, disliked_profile)[0][0] if np.linalg.norm(disliked_profile) != 0 else 0
|
|
||||||
text_score = like_sim - dislike_sim
|
|
||||||
|
|
||||||
# YEAR SIMILARITY.
|
|
||||||
year_score = 0
|
|
||||||
if avg_year is not None and movie['year_num'] > 0:
|
|
||||||
diff_year = abs(movie['year_num'] - avg_year)
|
|
||||||
year_score = 1 - (diff_year / year_range)
|
|
||||||
|
|
||||||
# RUNTIME SIMILARITY.
|
|
||||||
runtime_score = 0
|
|
||||||
if avg_runtime is not None and movie['runtime_num'] > 0:
|
|
||||||
diff_runtime = abs(movie['runtime_num'] - avg_runtime)
|
|
||||||
runtime_score = 1 - (diff_runtime / runtime_range)
|
|
||||||
|
|
||||||
# RATING SIMILARITY.
|
|
||||||
rating_score = 0
|
|
||||||
movie_rating = movie.get('imdb_rating', 0)
|
|
||||||
if avg_rating is not None and movie_rating:
|
|
||||||
diff_rating = abs(movie_rating - avg_rating)
|
|
||||||
rating_score = 1 - (diff_rating / rating_range)
|
|
||||||
|
|
||||||
# POPULARITY SCORE.
|
|
||||||
popularity_score = 0
|
|
||||||
if movie['vote_count'] > 0:
|
|
||||||
popularity_score = math.log(movie['vote_count'] + 1) / math.log(max_vote + 1)
|
|
||||||
|
|
||||||
# Final prediction score.
|
|
||||||
final_score = (w_text * text_score +
|
|
||||||
w_year * year_score +
|
|
||||||
w_runtime * runtime_score +
|
|
||||||
w_rating * rating_score +
|
|
||||||
w_popularity * popularity_score)
|
|
||||||
predictions.append((movie, final_score))
|
|
||||||
|
|
||||||
predictions.sort(key=lambda x: x[1], reverse=True)
|
|
||||||
return [pred[0] for pred in predictions[:num]]
|
|
||||||
|
|
||||||
def enough_info():
|
|
||||||
"""
|
|
||||||
Check if the user has rated at least 3 movies (like/dislike).
|
|
||||||
"""
|
|
||||||
rated = session.get('rated_movies', {})
|
|
||||||
count = sum(1 for rating in rated.values() if rating in ['like', 'dislike'])
|
|
||||||
return count >= 3
|
|
||||||
|
|
||||||
|
# -------------------- Routes --------------------
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def home():
|
def index():
|
||||||
session.setdefault('rated_movies', {}) # {movie_id: rating}
|
if 'user_id' in session:
|
||||||
session.setdefault('asked_movies', []) # list of movie IDs already shown
|
return redirect(url_for('survey'))
|
||||||
return redirect(url_for('questionnaire'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
@app.route('/questionnaire', methods=['GET', 'POST'])
|
# ----- User Login/Register -----
|
||||||
def questionnaire():
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
current_ids = request.form.getlist("movie_id")
|
username = request.form.get('username').strip()
|
||||||
for movie_id in current_ids:
|
password = request.form.get('password')
|
||||||
rating = request.form.get(f"rating_{movie_id}")
|
user = User.query.filter_by(username=username).first()
|
||||||
session['rated_movies'][movie_id] = rating
|
if user and user.check_password(password):
|
||||||
if int(movie_id) not in session['asked_movies']:
|
session['user_id'] = user.id
|
||||||
session['asked_movies'].append(int(movie_id))
|
flash("Logged in successfully.", "success")
|
||||||
remaining = [m for m in movies if m['id'] not in session['asked_movies']]
|
return redirect(url_for('survey'))
|
||||||
if enough_info() or not remaining:
|
|
||||||
return redirect(url_for('recommend'))
|
|
||||||
else:
|
else:
|
||||||
return redirect(url_for('questionnaire'))
|
flash("Invalid username or password.", "danger")
|
||||||
|
return render_template('login.html')
|
||||||
|
|
||||||
|
@app.route('/register', methods=['GET', 'POST'])
|
||||||
|
def register():
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.form.get('username').strip()
|
||||||
|
password = request.form.get('password')
|
||||||
|
if User.query.filter_by(username=username).first():
|
||||||
|
flash("Username already exists.", "danger")
|
||||||
|
else:
|
||||||
|
user = User(username=username)
|
||||||
|
user.set_password(password)
|
||||||
|
user.profile = pyjson.dumps({})
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Registration successful. Please log in.", "success")
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
return render_template('register.html')
|
||||||
|
|
||||||
|
@app.route('/logout')
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
flash("Logged out.", "info")
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
# ----- Survey & Recommendation -----
|
||||||
|
@app.route('/survey', methods=['GET', 'POST'])
|
||||||
|
def survey():
|
||||||
|
if request.method == 'POST':
|
||||||
|
profile = {}
|
||||||
|
for key, value in request.form.items():
|
||||||
|
if key.startswith("rating_"):
|
||||||
|
movie_title = key[len("rating_"):]
|
||||||
|
try:
|
||||||
|
rating = float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
rating = 0
|
||||||
|
profile[movie_title] = rating
|
||||||
|
# Merge with any existing profile.
|
||||||
|
current_profile = get_profile()
|
||||||
|
current_profile.update(profile)
|
||||||
|
save_profile(current_profile)
|
||||||
|
return redirect(url_for('recommend'))
|
||||||
else:
|
else:
|
||||||
# Use prediction to select movies for the questionnaire.
|
# Show 10 random movies for the survey.
|
||||||
selected_movies = get_predicted_movies(num=10)
|
sample_movies = random.sample(MOVIES, min(10, len(MOVIES)))
|
||||||
if not selected_movies:
|
return render_template('survey.html', movies=sample_movies)
|
||||||
return redirect(url_for('recommend'))
|
|
||||||
return render_template('questionnaire.html', movies=selected_movies)
|
|
||||||
|
|
||||||
def advanced_recommendations():
|
|
||||||
"""
|
|
||||||
Compute an advanced hybrid recommendation score on unseen movies.
|
|
||||||
Only movies not already shown (asked) are considered.
|
|
||||||
Combines:
|
|
||||||
1. Text similarity (TF‑IDF) between liked/disliked profiles.
|
|
||||||
2. Year similarity.
|
|
||||||
3. Runtime similarity.
|
|
||||||
4. Rating similarity.
|
|
||||||
5. Popularity (log-scaled vote count).
|
|
||||||
Returns the top 20 recommendations.
|
|
||||||
"""
|
|
||||||
rated = session.get('rated_movies', {})
|
|
||||||
asked = set(session.get('asked_movies', []))
|
|
||||||
# Only consider movies that haven't been shown to the user.
|
|
||||||
available = [m for m in movies if m['id'] not in asked]
|
|
||||||
if not available:
|
|
||||||
available = movies # Fallback if all movies have been shown.
|
|
||||||
|
|
||||||
liked_ids = [int(mid) for mid, rating in rated.items() if rating == 'like']
|
|
||||||
disliked_ids = [int(mid) for mid, rating in rated.items() if rating == 'dislike']
|
|
||||||
|
|
||||||
if liked_ids:
|
|
||||||
liked_profile = np.asarray(movie_vectors[liked_ids].mean(axis=0))
|
|
||||||
else:
|
|
||||||
liked_profile = np.zeros((1, movie_vectors.shape[1]))
|
|
||||||
if disliked_ids:
|
|
||||||
disliked_profile = np.asarray(movie_vectors[disliked_ids].mean(axis=0))
|
|
||||||
else:
|
|
||||||
disliked_profile = np.zeros((1, movie_vectors.shape[1]))
|
|
||||||
|
|
||||||
liked_years = [movies[i]['year_num'] for i in liked_ids if movies[i]['year_num'] > 0]
|
|
||||||
liked_runtimes = [movies[i]['runtime_num'] for i in liked_ids if movies[i]['runtime_num'] > 0]
|
|
||||||
liked_ratings = [movies[i].get('imdb_rating', 0) for i in liked_ids if movies[i].get('imdb_rating', 0)]
|
|
||||||
avg_year = np.mean(liked_years) if liked_years else None
|
|
||||||
avg_runtime = np.mean(liked_runtimes) if liked_runtimes else None
|
|
||||||
avg_rating = np.mean(liked_ratings) if liked_ratings else None
|
|
||||||
|
|
||||||
recommendations = []
|
|
||||||
w_text = 0.5
|
|
||||||
w_year = 0.1
|
|
||||||
w_runtime = 0.1
|
|
||||||
w_rating = 0.15
|
|
||||||
w_popularity = 0.15
|
|
||||||
|
|
||||||
for movie in available:
|
|
||||||
i = movie['id']
|
|
||||||
movie_vector = movie_vectors[i].toarray()
|
|
||||||
like_sim = cosine_similarity(movie_vector, liked_profile)[0][0] if np.linalg.norm(liked_profile) != 0 else 0
|
|
||||||
dislike_sim = cosine_similarity(movie_vector, disliked_profile)[0][0] if np.linalg.norm(disliked_profile) != 0 else 0
|
|
||||||
text_score = like_sim - dislike_sim
|
|
||||||
|
|
||||||
year_score = 0
|
|
||||||
if avg_year is not None and movie['year_num'] > 0:
|
|
||||||
diff_year = abs(movie['year_num'] - avg_year)
|
|
||||||
year_score = 1 - (diff_year / year_range)
|
|
||||||
|
|
||||||
runtime_score = 0
|
|
||||||
if avg_runtime is not None and movie['runtime_num'] > 0:
|
|
||||||
diff_runtime = abs(movie['runtime_num'] - avg_runtime)
|
|
||||||
runtime_score = 1 - (diff_runtime / runtime_range)
|
|
||||||
|
|
||||||
rating_score = 0
|
|
||||||
movie_rating = movie.get('imdb_rating', 0)
|
|
||||||
if avg_rating is not None and movie_rating:
|
|
||||||
diff_rating = abs(movie_rating - avg_rating)
|
|
||||||
rating_score = 1 - (diff_rating / rating_range)
|
|
||||||
|
|
||||||
popularity_score = 0
|
|
||||||
if movie['vote_count'] > 0:
|
|
||||||
popularity_score = math.log(movie['vote_count'] + 1) / math.log(max_vote + 1)
|
|
||||||
|
|
||||||
final_score = (w_text * text_score +
|
|
||||||
w_year * year_score +
|
|
||||||
w_runtime * runtime_score +
|
|
||||||
w_rating * rating_score +
|
|
||||||
w_popularity * popularity_score)
|
|
||||||
recommendations.append((movie, final_score))
|
|
||||||
|
|
||||||
recommendations.sort(key=lambda x: x[1], reverse=True)
|
|
||||||
return recommendations[:20]
|
|
||||||
|
|
||||||
@app.route('/recommend')
|
@app.route('/recommend')
|
||||||
def recommend():
|
def recommend():
|
||||||
recommendations = advanced_recommendations()
|
profile = get_profile()
|
||||||
return render_template('recommendations.html', recommendations=recommendations)
|
movie = recommend_movie(profile)
|
||||||
|
return render_template('recommend.html', movie=movie)
|
||||||
|
|
||||||
|
@app.route('/feedback', methods=['POST'])
|
||||||
|
def feedback():
|
||||||
|
movie_title = request.form.get("movie_title")
|
||||||
|
try:
|
||||||
|
rating = float(request.form.get("rating"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
rating = 0
|
||||||
|
profile = get_profile()
|
||||||
|
profile[movie_title] = rating
|
||||||
|
save_profile(profile)
|
||||||
|
return redirect(url_for('recommend'))
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=True)
|
app.run(debug=True)
|
||||||
|
|
||||||
|
|
||||||
|
BIN
instance/users.db
Normal file
BIN
instance/users.db
Normal file
Binary file not shown.
@ -8,7 +8,7 @@ import concurrent.futures
|
|||||||
api_key = "96f3424d6fe55c2982e6e094416607f5"
|
api_key = "96f3424d6fe55c2982e6e094416607f5"
|
||||||
|
|
||||||
# Output file where results are saved incrementally
|
# Output file where results are saved incrementally
|
||||||
output_filename = "top_movies.json"
|
output_filename = "movies.json"
|
||||||
|
|
||||||
def write_movies(movies, filename=output_filename):
|
def write_movies(movies, filename=output_filename):
|
||||||
"""Helper function to write the movies list to a JSON file."""
|
"""Helper function to write the movies list to a JSON file."""
|
48
templates/base.html
Normal file
48
templates/base.html
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{% block title %}Movie Recommender{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
|
||||||
|
<style>
|
||||||
|
.movie-card { margin-bottom: 20px; }
|
||||||
|
.movie-poster { object-fit: cover; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<a class="navbar-brand" href="{{ url_for('survey') }}">Movie Recommender</a>
|
||||||
|
<div class="collapse navbar-collapse">
|
||||||
|
<ul class="navbar-nav ml-auto">
|
||||||
|
{% if session.get('user_id') %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('logout') }}">Logout</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('login') }}">Login</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('register') }}">Register</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="container mt-4">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="close" data-dismiss="alert">×</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -1,77 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Movie Slideshow</title>
|
|
||||||
<style>
|
|
||||||
/* Basic styling for slideshow */
|
|
||||||
#movie-container {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
#movie-poster {
|
|
||||||
width: 200px;
|
|
||||||
margin: 20px;
|
|
||||||
}
|
|
||||||
.rating-buttons button {
|
|
||||||
margin: 10px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1 style="text-align: center;">Rate Movies</h1>
|
|
||||||
<form id="ratingForm" method="POST" action="/recommend">
|
|
||||||
<!-- Hidden inputs for movie ratings; one per movie -->
|
|
||||||
{% for movie in movies %}
|
|
||||||
<input type="hidden" name="{{ movie.title }}" id="rating-{{ loop.index0 }}" value="not seen">
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<div id="movie-container">
|
|
||||||
<img id="movie-poster" src="" alt="Movie Poster">
|
|
||||||
<h2 id="movie-title"></h2>
|
|
||||||
<p id="movie-description"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rating-buttons" style="text-align: center;">
|
|
||||||
<button type="button" onclick="recordRating('like')">Like</button>
|
|
||||||
<button type="button" onclick="recordRating('dislike')">Dislike</button>
|
|
||||||
<button type="button" onclick="recordRating('not seen')">Not Seen</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const movies = {{ movies | tojson }};
|
|
||||||
let currentIndex = 0;
|
|
||||||
const posterEl = document.getElementById("movie-poster");
|
|
||||||
const titleEl = document.getElementById("movie-title");
|
|
||||||
const descriptionEl = document.getElementById("movie-description");
|
|
||||||
|
|
||||||
// Function to display the movie at the given index
|
|
||||||
function showMovie(index) {
|
|
||||||
if (index >= movies.length) {
|
|
||||||
// All movies rated; submit the form
|
|
||||||
document.getElementById("ratingForm").submit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const movie = movies[index];
|
|
||||||
posterEl.src = movie.poster;
|
|
||||||
posterEl.alt = movie.title;
|
|
||||||
titleEl.textContent = movie.title + " (" + movie.year + ")";
|
|
||||||
descriptionEl.textContent = movie.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record the rating for the current movie and show the next one
|
|
||||||
function recordRating(rating) {
|
|
||||||
// Update the hidden input for the current movie with the chosen rating
|
|
||||||
document.getElementById("rating-" + currentIndex).value = rating;
|
|
||||||
currentIndex++;
|
|
||||||
showMovie(currentIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the slideshow with the first movie
|
|
||||||
showMovie(currentIndex);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
17
templates/login.html
Normal file
17
templates/login.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Login{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Login</h2>
|
||||||
|
<form method="post">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" class="form-control" name="username" id="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" class="form-control" name="password" id="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Login</button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-2">Don't have an account? <a href="{{ url_for('register') }}">Register here</a>.</p>
|
||||||
|
{% endblock %}
|
@ -1,83 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Movie Questionnaire</title>
|
|
||||||
<style>
|
|
||||||
/* Styling for the slideshow */
|
|
||||||
#movie-container {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
#movie-poster {
|
|
||||||
width: 200px;
|
|
||||||
margin: 20px;
|
|
||||||
}
|
|
||||||
.rating-buttons button {
|
|
||||||
margin: 10px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1 style="text-align: center;">Rate Movies</h1>
|
|
||||||
<form id="questionForm" method="POST">
|
|
||||||
<!-- Container where hidden inputs will be added for the round -->
|
|
||||||
<div id="hiddenFields"></div>
|
|
||||||
<div id="movie-container">
|
|
||||||
<img id="movie-poster" src="" alt="Movie Poster">
|
|
||||||
<h2 id="movie-title"></h2>
|
|
||||||
<p id="movie-description"></p>
|
|
||||||
</div>
|
|
||||||
<div class="rating-buttons" style="text-align: center;">
|
|
||||||
<button type="button" onclick="recordRating('like')">Like</button>
|
|
||||||
<button type="button" onclick="recordRating('dislike')">Dislike</button>
|
|
||||||
<button type="button" onclick="recordRating('not seen')">Not Seen</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Movies for the current round are passed from the server.
|
|
||||||
const movies = {{ movies | tojson }};
|
|
||||||
let currentIndex = 0;
|
|
||||||
let movieRatings = {}; // To store ratings for this batch
|
|
||||||
|
|
||||||
function showMovie(index) {
|
|
||||||
if (index >= movies.length) {
|
|
||||||
// All movies rated in this round—append hidden fields and submit the form.
|
|
||||||
const container = document.getElementById("hiddenFields");
|
|
||||||
movies.forEach(movie => {
|
|
||||||
// Hidden input for movie id
|
|
||||||
const movieIdInput = document.createElement("input");
|
|
||||||
movieIdInput.type = "hidden";
|
|
||||||
movieIdInput.name = "movie_id";
|
|
||||||
movieIdInput.value = movie.id;
|
|
||||||
container.appendChild(movieIdInput);
|
|
||||||
// Hidden input for its rating
|
|
||||||
const ratingInput = document.createElement("input");
|
|
||||||
ratingInput.type = "hidden";
|
|
||||||
ratingInput.name = "rating_" + movie.id;
|
|
||||||
ratingInput.value = movieRatings[movie.id] || "not seen";
|
|
||||||
container.appendChild(ratingInput);
|
|
||||||
});
|
|
||||||
document.getElementById("questionForm").submit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const movie = movies[currentIndex];
|
|
||||||
document.getElementById("movie-poster").src = movie.poster;
|
|
||||||
document.getElementById("movie-poster").alt = movie.title;
|
|
||||||
document.getElementById("movie-title").textContent = movie.title + " (" + movie.year + ")";
|
|
||||||
document.getElementById("movie-description").textContent = movie.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
function recordRating(rating) {
|
|
||||||
movieRatings[movies[currentIndex].id] = rating;
|
|
||||||
currentIndex++;
|
|
||||||
showMovie(currentIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
showMovie(currentIndex);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
39
templates/recommend.html
Normal file
39
templates/recommend.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Recommendation{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Your Recommended Movie</h2>
|
||||||
|
{% if movie %}
|
||||||
|
<div class="card movie-card">
|
||||||
|
<div class="row no-gutters">
|
||||||
|
{% if movie.poster %}
|
||||||
|
<div class="col-md-4">
|
||||||
|
<img src="{{ movie.poster }}" class="card-img movie-poster" style="height:400px;" alt="{{ movie.title }}">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{ movie.title }} ({{ movie.year }})</h5>
|
||||||
|
<p class="card-text">{{ movie.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3">Have you seen this movie? If yes, rate it from 1 (disliked) to 5 (loved); otherwise select "Not Seen".</p>
|
||||||
|
<form method="post" action="{{ url_for('feedback') }}">
|
||||||
|
<input type="hidden" name="movie_title" value="{{ movie.title }}">
|
||||||
|
<div class="btn-group btn-group-lg" role="group">
|
||||||
|
<button type="submit" name="rating" value="0" class="btn btn-secondary">Not Seen</button>
|
||||||
|
<button type="submit" name="rating" value="1" class="btn btn-danger">1</button>
|
||||||
|
<button type="submit" name="rating" value="2" class="btn btn-warning">2</button>
|
||||||
|
<button type="submit" name="rating" value="3" class="btn btn-info">3</button>
|
||||||
|
<button type="submit" name="rating" value="4" class="btn btn-primary">4</button>
|
||||||
|
<button type="submit" name="rating" value="5" class="btn btn-success">5</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info mt-4" role="alert">
|
||||||
|
No more recommendations available. Thank you for your feedback!
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('survey') }}" class="btn btn-outline-primary mt-3">Restart Survey</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
@ -1,21 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Movie Recommendations</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Your Movie Recommendations</h1>
|
|
||||||
{% for movie, score in recommendations %}
|
|
||||||
<div style="margin-bottom: 20px;">
|
|
||||||
<img src="{{ movie.poster }}" alt="{{ movie.title }}" width="70" style="vertical-align: middle;" />
|
|
||||||
<strong>{{ movie.title }} ({{ movie.year }})</strong>
|
|
||||||
<p>{{ movie.description }}</p>
|
|
||||||
<a href="{{ movie.url }}" target="_blank">More Info</a>
|
|
||||||
<p>Recommendation Score: {{ score | round(3) }}</p>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
{% endfor %}
|
|
||||||
<a href="/">Back to Questionnaire</a>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
17
templates/register.html
Normal file
17
templates/register.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Register{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Register</h2>
|
||||||
|
<form method="post">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" class="form-control" name="username" id="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" class="form-control" name="password" id="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Register</button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-2">Already have an account? <a href="{{ url_for('login') }}">Login here</a>.</p>
|
||||||
|
{% endblock %}
|
36
templates/survey.html
Normal file
36
templates/survey.html
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Movie Survey{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Initial Movie Survey</h2>
|
||||||
|
<p>Please rate the following movies on a scale from 1 (disliked) to 5 (loved), or choose "Not Seen".</p>
|
||||||
|
<form method="post">
|
||||||
|
{% for movie in movies %}
|
||||||
|
<div class="card movie-card">
|
||||||
|
<div class="row no-gutters">
|
||||||
|
{% if movie.poster %}
|
||||||
|
<div class="col-md-4">
|
||||||
|
<img src="{{ movie.poster }}" class="card-img movie-poster" style="height:300px;" alt="{{ movie.title }}">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{ movie.title }} ({{ movie.year }})</h5>
|
||||||
|
<p class="card-text">{{ movie.description }}</p>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="radio" name="rating_{{ movie.title }}" id="{{ movie.title }}_0" value="0" checked>
|
||||||
|
<label class="form-check-label" for="{{ movie.title }}_0">Not Seen</label>
|
||||||
|
</div>
|
||||||
|
{% for i in range(1, 6) %}
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="radio" name="rating_{{ movie.title }}" id="{{ movie.title }}_{{ i }}" value="{{ i }}">
|
||||||
|
<label class="form-check-label" for="{{ movie.title }}_{{ i }}">{{ i }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit" class="btn btn-success btn-lg btn-block">Submit Survey</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
Loading…
Reference in New Issue
Block a user