381 lines
16 KiB
JavaScript
381 lines
16 KiB
JavaScript
require('dotenv').config();
|
|
const express = require('express');
|
|
const cors = require('cors');
|
|
const bcrypt = require('bcrypt');
|
|
const jwt = require('jsonwebtoken');
|
|
const path = require('path');
|
|
const db = require('./database');
|
|
|
|
const app = express();
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
|
|
const JWT_SECRET = 'cinematch-super-secret-key';
|
|
const TMDB_API_KEY = process.env.TMDB_API_KEY || '3fd2be6f0c70a2a598f084ddfb75487c'; // Fallback to a public tutorial key if not set
|
|
|
|
// Helper to fetch from TMDB
|
|
const fetchTMDB = async (endpoint, params = {}) => {
|
|
const url = new URL(`https://api.themoviedb.org/3${endpoint}`);
|
|
url.searchParams.append('api_key', TMDB_API_KEY);
|
|
for (const [key, value] of Object.entries(params)) {
|
|
url.searchParams.append(key, value);
|
|
}
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error('TMDB API error');
|
|
return res.json();
|
|
};
|
|
|
|
// Map TMDB movie to our Movie format
|
|
const mapMovie = (m) => ({
|
|
id: m.id,
|
|
title: m.title,
|
|
posterUrl: m.poster_path ? `https://image.tmdb.org/t/p/w500${m.poster_path}` : 'https://via.placeholder.com/500x750?text=No+Poster',
|
|
rating: m.vote_average,
|
|
synopsis: m.overview,
|
|
year: m.release_date ? parseInt(m.release_date.substring(0, 4)) : null,
|
|
genre: m.genre_ids.map(String)
|
|
});
|
|
|
|
// Helper to generate random code
|
|
const generateCode = () => Math.random().toString(36).substring(2, 8).toUpperCase();
|
|
|
|
// Middleware to authenticate
|
|
const authenticate = (req, res, next) => {
|
|
const token = req.headers.authorization?.split(' ')[1];
|
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
|
try {
|
|
const decoded = jwt.verify(token, JWT_SECRET);
|
|
req.userId = decoded.userId;
|
|
next();
|
|
} catch (err) {
|
|
res.status(401).json({ error: 'Invalid token' });
|
|
}
|
|
};
|
|
|
|
// Register
|
|
app.post('/api/auth/register', async (req, res) => {
|
|
const { username, password } = req.body;
|
|
if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
|
|
|
|
const hashedPassword = await bcrypt.hash(password, 10);
|
|
const code = generateCode();
|
|
|
|
db.run('INSERT INTO users (username, password, code) VALUES (?, ?, ?)', [username, hashedPassword, code], function (err) {
|
|
if (err) return res.status(400).json({ error: 'Username already exists' });
|
|
const token = jwt.sign({ userId: this.lastID }, JWT_SECRET);
|
|
res.json({ token, user: { id: this.lastID, username, code, partner_code: null, genres: null } });
|
|
});
|
|
});
|
|
|
|
// Login
|
|
app.post('/api/auth/login', (req, res) => {
|
|
const { username, password } = req.body;
|
|
db.get('SELECT * FROM users WHERE username = ?', [username], async (err, user) => {
|
|
if (err || !user) return res.status(400).json({ error: 'Invalid credentials' });
|
|
const match = await bcrypt.compare(password, user.password);
|
|
if (!match) return res.status(400).json({ error: 'Invalid credentials' });
|
|
|
|
const token = jwt.sign({ userId: user.id }, JWT_SECRET);
|
|
res.json({ token, user: { id: user.id, username: user.username, code: user.code, partner_code: user.partner_code, genres: user.genres ? JSON.parse(user.genres) : null } });
|
|
});
|
|
});
|
|
|
|
// Anonymous Login
|
|
app.post('/api/auth/anonymous', (req, res) => {
|
|
const code = generateCode();
|
|
db.run('INSERT INTO users (code) VALUES (?)', [code], function (err) {
|
|
if (err) return res.status(500).json({ error: 'Failed to create anonymous user' });
|
|
const token = jwt.sign({ userId: this.lastID }, JWT_SECRET);
|
|
res.json({ token, user: { id: this.lastID, username: null, code, partner_code: null, genres: null } });
|
|
});
|
|
});
|
|
|
|
// Get Me
|
|
app.get('/api/auth/me', authenticate, (req, res) => {
|
|
db.get('SELECT id, username, code, partner_code, genres FROM users WHERE id = ?', [req.userId], (err, user) => {
|
|
if (err || !user) return res.status(404).json({ error: 'User not found' });
|
|
res.json({ user: { ...user, genres: user.genres ? JSON.parse(user.genres) : null } });
|
|
});
|
|
});
|
|
|
|
// Update Genres
|
|
app.post('/api/user/genres', authenticate, (req, res) => {
|
|
const { genres } = req.body;
|
|
db.run('UPDATE users SET genres = ? WHERE id = ?', [JSON.stringify(genres), req.userId], (err) => {
|
|
if (err) return res.status(500).json({ error: 'Failed to update genres' });
|
|
res.json({ success: true });
|
|
});
|
|
});
|
|
|
|
// Link Partner
|
|
app.post('/api/link', authenticate, (req, res) => {
|
|
const { partnerCode } = req.body;
|
|
db.get('SELECT id, code FROM users WHERE code = ?', [partnerCode], (err, partner) => {
|
|
if (err || !partner) return res.status(404).json({ error: 'Partner code not found' });
|
|
if (partner.id === req.userId) return res.status(400).json({ error: 'Cannot link with yourself' });
|
|
|
|
db.get('SELECT code FROM users WHERE id = ?', [req.userId], (err, user) => {
|
|
if (err || !user) return res.status(500).json({ error: 'User not found' });
|
|
|
|
db.run('UPDATE users SET partner_code = ? WHERE id = ?', [partnerCode, req.userId], (err) => {
|
|
if (err) return res.status(500).json({ error: 'Failed to link partner' });
|
|
|
|
db.run('UPDATE users SET partner_code = ? WHERE id = ?', [user.code, partner.id], (err) => {
|
|
if (err) return res.status(500).json({ error: 'Failed to link partner on the other side' });
|
|
res.json({ success: true, partner_code: partnerCode });
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// Unlink Partner
|
|
app.post('/api/unlink', authenticate, (req, res) => {
|
|
db.get('SELECT partner_code FROM users WHERE id = ?', [req.userId], (err, user) => {
|
|
if (err || !user) return res.status(500).json({ error: 'User not found' });
|
|
|
|
db.run('UPDATE users SET partner_code = NULL WHERE id = ?', [req.userId], (err) => {
|
|
if (err) return res.status(500).json({ error: 'Failed to unlink partner' });
|
|
|
|
if (user.partner_code) {
|
|
db.run('UPDATE users SET partner_code = NULL WHERE code = ?', [user.partner_code], (err) => {
|
|
res.json({ success: true });
|
|
});
|
|
} else {
|
|
res.json({ success: true });
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Swipe
|
|
app.post('/api/swipe', authenticate, (req, res) => {
|
|
const { movieId, direction } = req.body;
|
|
db.run('INSERT OR REPLACE INTO swipes (user_id, movie_id, direction) VALUES (?, ?, ?)', [req.userId, movieId, direction], function (err) {
|
|
if (err) return res.status(500).json({ error: 'Failed to record swipe' });
|
|
res.json({ success: true });
|
|
});
|
|
});
|
|
|
|
// Get Swipes
|
|
app.get('/api/swipes', authenticate, (req, res) => {
|
|
db.all('SELECT movie_id, direction FROM swipes WHERE user_id = ?', [req.userId], (err, rows) => {
|
|
if (err) return res.status(500).json({ error: 'Failed to get swipes' });
|
|
res.json({ swipes: rows });
|
|
});
|
|
});
|
|
|
|
// Get Matches
|
|
app.get('/api/matches', authenticate, (req, res) => {
|
|
db.get('SELECT partner_code FROM users WHERE id = ?', [req.userId], (err, user) => {
|
|
if (err || !user || !user.partner_code) return res.json({ matches: [] });
|
|
|
|
db.get('SELECT id FROM users WHERE code = ?', [user.partner_code], (err, partner) => {
|
|
if (err || !partner) return res.json({ matches: [] });
|
|
|
|
const query = `
|
|
SELECT s1.movie_id
|
|
FROM swipes s1
|
|
JOIN swipes s2 ON s1.movie_id = s2.movie_id
|
|
WHERE s1.user_id = ? AND s2.user_id = ? AND s1.direction = 'right' AND s2.direction = 'right'
|
|
`;
|
|
db.all(query, [req.userId, partner.id], async (err, rows) => {
|
|
if (err) return res.status(500).json({ error: 'Failed to get matches' });
|
|
|
|
const matchIds = rows.map(r => r.movie_id);
|
|
const matchDetails = [];
|
|
|
|
for (const id of matchIds) {
|
|
try {
|
|
const movieData = await fetchTMDB(`/movie/${id}`);
|
|
if (movieData && !movieData.error) {
|
|
matchDetails.push({
|
|
id: movieData.id,
|
|
title: movieData.title,
|
|
poster_path: movieData.poster_path,
|
|
overview: movieData.overview,
|
|
release_date: movieData.release_date,
|
|
vote_average: movieData.vote_average
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error('Error fetching match details:', e);
|
|
}
|
|
}
|
|
|
|
res.json({ matches: matchDetails });
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// Mark as watched
|
|
app.post('/api/watched', authenticate, (req, res) => {
|
|
const { movieId } = req.body;
|
|
db.run('INSERT OR IGNORE INTO watched (user_id, movie_id) VALUES (?, ?)', [req.userId, movieId], function (err) {
|
|
if (err) return res.status(500).json({ error: 'Failed to mark as watched' });
|
|
res.json({ success: true });
|
|
});
|
|
});
|
|
|
|
// Get watched
|
|
app.get('/api/watched', authenticate, (req, res) => {
|
|
db.all('SELECT movie_id FROM watched WHERE user_id = ?', [req.userId], (err, rows) => {
|
|
if (err) return res.status(500).json({ error: 'Failed to get watched movies' });
|
|
res.json({ watched: rows.map(r => r.movie_id) });
|
|
});
|
|
});
|
|
|
|
// Get Movie Feed
|
|
app.get('/api/movies/feed', authenticate, async (req, res) => {
|
|
console.log('Fetching feed for user:', req.userId);
|
|
try {
|
|
// 1. Get user info (genres, partner_code)
|
|
const user = await new Promise((resolve, reject) => {
|
|
db.get('SELECT * FROM users WHERE id = ?', [req.userId], (err, row) => {
|
|
if (err) reject(err);
|
|
else resolve(row);
|
|
});
|
|
});
|
|
|
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
|
|
const userGenres = user.genres ? JSON.parse(user.genres) : [];
|
|
|
|
// 2. Get user's swipes to filter out already seen movies
|
|
const userSwipes = await new Promise((resolve, reject) => {
|
|
db.all('SELECT movie_id FROM swipes WHERE user_id = ?', [req.userId], (err, rows) => {
|
|
if (err) reject(err);
|
|
else resolve(rows.map(r => r.movie_id));
|
|
});
|
|
});
|
|
|
|
let feedMovies = [];
|
|
|
|
// 3. If user has a partner, get partner's liked movies that user hasn't swiped on
|
|
if (user.partner_code) {
|
|
const partner = await new Promise((resolve, reject) => {
|
|
db.get('SELECT id FROM users WHERE code = ?', [user.partner_code], (err, row) => {
|
|
if (err) reject(err);
|
|
else resolve(row);
|
|
});
|
|
});
|
|
|
|
if (partner) {
|
|
const partnerLikes = await new Promise((resolve, reject) => {
|
|
db.all('SELECT movie_id FROM swipes WHERE user_id = ? AND direction = "right"', [partner.id], (err, rows) => {
|
|
if (err) reject(err);
|
|
else resolve(rows.map(r => r.movie_id));
|
|
});
|
|
});
|
|
|
|
const unseenPartnerLikes = partnerLikes.filter(id => !userSwipes.includes(id));
|
|
|
|
// Fetch details for these movies from TMDB
|
|
for (const movieId of unseenPartnerLikes.slice(0, 5)) { // Limit to 5 partner likes per feed request
|
|
try {
|
|
const movieData = await fetchTMDB(`/movie/${movieId}`, { append_to_response: 'credits' });
|
|
if (movieData && !movieData.error) {
|
|
const director = movieData.credits?.crew?.find(c => c.job === 'Director')?.name || 'Unknown';
|
|
const genres = movieData.genres?.map(g => g.name) || [];
|
|
feedMovies.push({
|
|
id: movieData.id,
|
|
title: movieData.title,
|
|
poster_path: movieData.poster_path,
|
|
overview: movieData.overview,
|
|
release_date: movieData.release_date,
|
|
vote_average: movieData.vote_average,
|
|
director: director,
|
|
genres: genres,
|
|
imdb_id: movieData.imdb_id,
|
|
isPartnerLike: true // Flag to show it's a partner like
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error('Error fetching partner movie:', e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Fetch recommendations from TMDB based on genres
|
|
let tmdbEndpoint = '/movie/popular';
|
|
let queryParams = new URLSearchParams();
|
|
|
|
if (userGenres && userGenres.length > 0) {
|
|
tmdbEndpoint = '/discover/movie';
|
|
// Use pipe '|' for OR condition (movies with ANY of the selected genres)
|
|
queryParams.append('with_genres', userGenres.join('|'));
|
|
queryParams.append('sort_by', 'popularity.desc');
|
|
}
|
|
|
|
// Try to fetch movies until we have enough for the feed (at least 10)
|
|
let attempts = 0;
|
|
while (feedMovies.length < 10 && attempts < 5) {
|
|
// Limit to top 50 pages to ensure we get relatively popular/good movies
|
|
const randomPage = Math.floor(Math.random() * 50) + 1;
|
|
queryParams.set('page', randomPage.toString());
|
|
|
|
const tmdbUrl = `${tmdbEndpoint}?${queryParams.toString()}`;
|
|
const tmdbResponse = await fetchTMDB(tmdbUrl);
|
|
|
|
if (tmdbResponse && tmdbResponse.results) {
|
|
const recommendedMovies = tmdbResponse.results
|
|
.filter(m => !userSwipes.includes(m.id) && !feedMovies.some(fm => fm.id === m.id));
|
|
|
|
for (const m of recommendedMovies) {
|
|
if (feedMovies.length >= 10) break; // Limit feed to 10 movies per request
|
|
try {
|
|
const details = await fetchTMDB(`/movie/${m.id}`, { append_to_response: 'credits' });
|
|
if (details && !details.error) {
|
|
const director = details.credits?.crew?.find(c => c.job === 'Director')?.name || 'Unknown';
|
|
const genres = details.genres?.map(g => g.name) || [];
|
|
feedMovies.push({
|
|
id: details.id,
|
|
title: details.title,
|
|
poster_path: details.poster_path,
|
|
overview: details.overview,
|
|
release_date: details.release_date,
|
|
vote_average: details.vote_average,
|
|
director: director,
|
|
genres: genres,
|
|
imdb_id: details.imdb_id,
|
|
isPartnerLike: false
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error('Error fetching recommended movie details:', e);
|
|
}
|
|
}
|
|
}
|
|
attempts++;
|
|
}
|
|
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.json({ movies: feedMovies });
|
|
|
|
} catch (error) {
|
|
console.error('Feed error:', error);
|
|
res.status(500).json({ error: 'Failed to generate feed' });
|
|
}
|
|
});
|
|
|
|
// Serve frontend static files in production
|
|
app.use(express.static(path.join(__dirname, '../frontend/dist'), {
|
|
setHeaders: (res, path) => {
|
|
if (path.endsWith('.html')) {
|
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
res.setHeader('Expires', '0');
|
|
}
|
|
}
|
|
}));
|
|
app.use((req, res) => {
|
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
|
|
});
|
|
|
|
const PORT = process.env.PORT || 3001;
|
|
app.listen(PORT, () => {
|
|
console.log(`Server running on port ${PORT}`);
|
|
});
|