CineMatch/backend/server.js
2026-02-22 20:10:40 +01:00

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}`);
});