Commit 6cd6f674 by PLN (Algolia)

feat(paradiso): V1

parent 58c84ea4
ALGOLIA_ADMIN_KEY=""
ALGOLIA_APP_ID=""
TMDB_API_READ_TOKEN=""
TMDB_API_KEY=""
\ No newline at end of file
import React, { useState } from 'react';
import Image from 'next/image';
import styles from '@/styles/paradiso.module.css';
const MovieCard = ({ movie, onVote }) => {
const [isHovered, setIsHovered] = useState(false);
const [isVoting, setIsVoting] = useState(false);
const handleVote = async (e) => {
e.preventDefault();
e.stopPropagation();
if (isVoting) return;
setIsVoting(true);
await onVote(movie.objectID);
setIsVoting(false);
};
return (
<div
className={styles.movieCard}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className={styles.posterContainer}>
{movie.poster ? (
<Image
src={movie.poster}
alt={movie.title}
width={200}
height={300}
className={styles.poster}
unoptimized
/>
) : (
<div className={styles.noPoster}>
<span>{movie.title}</span>
</div>
)}
{isHovered && (
<div className={styles.movieOverlay}>
<h3 className={styles.movieTitle}>{movie.title}</h3>
<div className={styles.movieYear}>{movie.year}</div>
<div className={styles.movieRating}>
{movie.imdbRating || 'N/A'}
</div>
<div className={styles.movieGenres}>
{movie.genre && movie.genre.slice(0, 3).join(' • ')}
</div>
<p className={styles.moviePlot}>
{movie.plot && movie.plot.length > 150
? `${movie.plot.substring(0, 150)}...`
: movie.plot}
</p>
<div className={styles.movieActions}>
<button
className={styles.voteButton}
onClick={handleVote}
disabled={isVoting}
>
{isVoting ? 'Voting...' : `Vote (${movie.votes || 0})`}
</button>
</div>
</div>
)}
</div>
<div className={styles.movieInfo}>
<h4>{movie.title}</h4>
<div className={styles.movieMeta}>
<span>{movie.year}</span>
<span className={styles.votes}>{movie.votes || 0} votes</span>
</div>
</div>
</div>
);
};
export default MovieCard;
\ No newline at end of file
import React, { useState } from 'react';
import styles from '../styles/paradiso.module.css';
const MovieSearch = ({ onAddMovie }) => {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [searching, setSearching] = useState(false);
const [error, setError] = useState(null);
// Function to search for movies via TMDB
const searchMovies = async (e) => {
e.preventDefault();
if (!searchQuery.trim()) return;
setSearching(true);
setError(null);
try {
const response = await fetch(
`https://api.themoviedb.org/3/search/movie?api_key=3e1dd2bcd5e1265d986c9a1501d6f8c0&query=${encodeURIComponent(searchQuery)}&language=en-US&page=1&include_adult=false`
);
if (!response.ok) {
throw new Error('Failed to search for movies');
}
const data = await response.json();
setSearchResults(data.results || []);
} catch (error) {
console.error('Error searching for movies:', error);
setError('Failed to search for movies. Please try again later.');
setSearchResults([]);
} finally {
setSearching(false);
}
};
// Function to add a movie from search results
const handleAddMovie = (movie) => {
const newMovieObj = {
id: movie.id.toString(),
title: movie.title,
votes: 0,
addedDate: new Date().toISOString(),
poster: movie.poster_path ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` : null,
description: movie.overview || null,
year: movie.release_date ? movie.release_date.substring(0, 4) : null
};
onAddMovie(newMovieObj);
setSearchResults([]);
setSearchQuery('');
};
return (
<div className={styles.searchContainer}>
<h3>Find a Movie</h3>
{/* Search Form */}
<form onSubmit={searchMovies} className={styles.searchBar}>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search for a movie..."
/>
<button
type="submit"
disabled={searching || !searchQuery.trim()}
>
{searching ? 'Searching...' : 'Search'}
</button>
</form>
{/* Error Message */}
{error && (
<div className={styles.errorMessage}>
{error}
</div>
)}
{/* Search Results */}
{searchResults.length > 0 && (
<div className={styles.searchResults}>
<h4>Search Results</h4>
<div className={styles.movieGrid}>
{searchResults.slice(0, 5).map(movie => (
<div key={movie.id} className={styles.searchResultItem}>
{movie.poster_path ? (
<img
src={`https://image.tmdb.org/t/p/w200${movie.poster_path}`}
alt={movie.title}
className={styles.searchResultPoster}
/>
) : (
<div className={styles.searchResultPoster} style={{
backgroundColor: '#eee',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999'
}}>
No Image
</div>
)}
<div className={styles.searchResultInfo}>
<h5>{movie.title} {movie.release_date && `(${movie.release_date.substring(0, 4)})`}</h5>
{movie.overview && (
<p>{movie.overview.length > 100 ? `${movie.overview.substring(0, 100)}...` : movie.overview}</p>
)}
<button
onClick={() => handleAddMovie(movie)}
className={styles.addButton}
>
Add to List
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* No Results Message */}
{searchResults.length === 0 && searchQuery && !searching && !error && (
<div className={styles.noResults}>
No results found for "{searchQuery}". Try a different search term.
</div>
)}
</div>
);
};
export default MovieSearch;
\ No newline at end of file
...@@ -16,6 +16,8 @@ ...@@ -16,6 +16,8 @@
"next": "^15.3.0", "next": "^15.3.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-instantsearch": "^7.15.7",
"react-instantsearch-dom": "^6.40.4",
"react-player": "^2.14.1", "react-player": "^2.14.1",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"remark": "^14.0.0", "remark": "^14.0.0",
...@@ -29,6 +31,175 @@ ...@@ -29,6 +31,175 @@
"node": ">=18.17.0" "node": ">=18.17.0"
} }
}, },
"node_modules/@algolia/cache-browser-local-storage": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.24.0.tgz",
"integrity": "sha512-t63W9BnoXVrGy9iYHBgObNXqYXM3tYXCjDSHeNwnsc324r4o5UiVKUiAB4THQ5z9U5hTj6qUvwg/Ez43ZD85ww==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/cache-common": "4.24.0"
}
},
"node_modules/@algolia/cache-common": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.24.0.tgz",
"integrity": "sha512-emi+v+DmVLpMGhp0V9q9h5CdkURsNmFC+cOS6uK9ndeJm9J4TiqSvPYVu+THUP8P/S08rxf5x2P+p3CfID0Y4g==",
"license": "MIT",
"peer": true
},
"node_modules/@algolia/cache-in-memory": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.24.0.tgz",
"integrity": "sha512-gDrt2so19jW26jY3/MkFg5mEypFIPbPoXsQGQWAi6TrCPsNOSEYepBMPlucqWigsmEy/prp5ug2jy/N3PVG/8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/cache-common": "4.24.0"
}
},
"node_modules/@algolia/client-account": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.24.0.tgz",
"integrity": "sha512-adcvyJ3KjPZFDybxlqnf+5KgxJtBjwTPTeyG2aOyoJvx0Y8dUQAEOEVOJ/GBxX0WWNbmaSrhDURMhc+QeevDsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "4.24.0",
"@algolia/client-search": "4.24.0",
"@algolia/transporter": "4.24.0"
}
},
"node_modules/@algolia/client-analytics": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.24.0.tgz",
"integrity": "sha512-y8jOZt1OjwWU4N2qr8G4AxXAzaa8DBvyHTWlHzX/7Me1LX8OayfgHexqrsL4vSBcoMmVw2XnVW9MhL+Y2ZDJXg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "4.24.0",
"@algolia/client-search": "4.24.0",
"@algolia/requester-common": "4.24.0",
"@algolia/transporter": "4.24.0"
}
},
"node_modules/@algolia/client-common": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.24.0.tgz",
"integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/requester-common": "4.24.0",
"@algolia/transporter": "4.24.0"
}
},
"node_modules/@algolia/client-personalization": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.24.0.tgz",
"integrity": "sha512-l5FRFm/yngztweU0HdUzz1rC4yoWCFo3IF+dVIVTfEPg906eZg5BOd1k0K6rZx5JzyyoP4LdmOikfkfGsKVE9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "4.24.0",
"@algolia/requester-common": "4.24.0",
"@algolia/transporter": "4.24.0"
}
},
"node_modules/@algolia/client-search": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.24.0.tgz",
"integrity": "sha512-uRW6EpNapmLAD0mW47OXqTP8eiIx5F6qN9/x/7HHO6owL3N1IXqydGwW5nhDFBrV+ldouro2W1VX3XlcUXEFCA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/client-common": "4.24.0",
"@algolia/requester-common": "4.24.0",
"@algolia/transporter": "4.24.0"
}
},
"node_modules/@algolia/events": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz",
"integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==",
"license": "MIT"
},
"node_modules/@algolia/logger-common": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.24.0.tgz",
"integrity": "sha512-LLUNjkahj9KtKYrQhFKCzMx0BY3RnNP4FEtO+sBybCjJ73E8jNdaKJ/Dd8A/VA4imVHP5tADZ8pn5B8Ga/wTMA==",
"license": "MIT",
"peer": true
},
"node_modules/@algolia/logger-console": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.24.0.tgz",
"integrity": "sha512-X4C8IoHgHfiUROfoRCV+lzSy+LHMgkoEEU1BbKcsfnV0i0S20zyy0NLww9dwVHUWNfPPxdMU+/wKmLGYf96yTg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/logger-common": "4.24.0"
}
},
"node_modules/@algolia/recommend": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-4.24.0.tgz",
"integrity": "sha512-P9kcgerfVBpfYHDfVZDvvdJv0lEoCvzNlOy2nykyt5bK8TyieYyiD0lguIJdRZZYGre03WIAFf14pgE+V+IBlw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/cache-browser-local-storage": "4.24.0",
"@algolia/cache-common": "4.24.0",
"@algolia/cache-in-memory": "4.24.0",
"@algolia/client-common": "4.24.0",
"@algolia/client-search": "4.24.0",
"@algolia/logger-common": "4.24.0",
"@algolia/logger-console": "4.24.0",
"@algolia/requester-browser-xhr": "4.24.0",
"@algolia/requester-common": "4.24.0",
"@algolia/requester-node-http": "4.24.0",
"@algolia/transporter": "4.24.0"
}
},
"node_modules/@algolia/requester-browser-xhr": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.24.0.tgz",
"integrity": "sha512-Z2NxZMb6+nVXSjF13YpjYTdvV3032YTBSGm2vnYvYPA6mMxzM3v5rsCiSspndn9rzIW4Qp1lPHBvuoKJV6jnAA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/requester-common": "4.24.0"
}
},
"node_modules/@algolia/requester-common": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.24.0.tgz",
"integrity": "sha512-k3CXJ2OVnvgE3HMwcojpvY6d9kgKMPRxs/kVohrwF5WMr2fnqojnycZkxPoEg+bXm8fi5BBfFmOqgYztRtHsQA==",
"license": "MIT",
"peer": true
},
"node_modules/@algolia/requester-node-http": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.24.0.tgz",
"integrity": "sha512-JF18yTjNOVYvU/L3UosRcvbPMGT9B+/GQWNWnenIImglzNVGpyzChkXLnrSf6uxwVNO6ESGu6oN8MqcGQcjQJw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/requester-common": "4.24.0"
}
},
"node_modules/@algolia/transporter": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.24.0.tgz",
"integrity": "sha512-86nI7w6NzWxd1Zp9q3413dRshDqAzSbsQjhcDhPIatEFiZrL1/TjnHL8S7jVKFePlIMzDsZWXAXwXzcok9c5oA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/cache-common": "4.24.0",
"@algolia/logger-common": "4.24.0",
"@algolia/requester-common": "4.24.0"
}
},
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.19.4", "version": "7.19.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.4.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.4.tgz",
...@@ -640,11 +811,23 @@ ...@@ -640,11 +811,23 @@
"@types/ms": "*" "@types/ms": "*"
} }
}, },
"node_modules/@types/dom-speech-recognition": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.1.tgz",
"integrity": "sha512-udCxb8DvjcDKfk1WTBzDsxFbLgYxmQGKrE/ricoMqHRNjSlSUCcamVTA5lIQqzY10mY5qCY0QDwBfFEwhfoDPw==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==" "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="
}, },
"node_modules/@types/google.maps": {
"version": "3.58.1",
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz",
"integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==",
"license": "MIT"
},
"node_modules/@types/hast": { "node_modules/@types/hast": {
"version": "2.3.4", "version": "2.3.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz",
...@@ -654,6 +837,12 @@ ...@@ -654,6 +837,12 @@
"@types/unist": "*" "@types/unist": "*"
} }
}, },
"node_modules/@types/hogan.js": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/hogan.js/-/hogan.js-3.0.5.tgz",
"integrity": "sha512-/uRaY3HGPWyLqOyhgvW9Aa43BNnLZrNeQxl2p8wqId4UHMfPKolSB+U7BlZyO1ng7MkLnyEAItsBzCG0SDhqrA==",
"license": "MIT"
},
"node_modules/@types/mdast": { "node_modules/@types/mdast": {
"version": "3.0.15", "version": "3.0.15",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz",
...@@ -684,6 +873,12 @@ ...@@ -684,6 +873,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/qs": {
"version": "6.9.18",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz",
"integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==",
"license": "MIT"
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.20", "version": "18.3.20",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz",
...@@ -706,6 +901,48 @@ ...@@ -706,6 +901,48 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"license": "ISC"
},
"node_modules/algoliasearch": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.24.0.tgz",
"integrity": "sha512-bf0QV/9jVejssFBmz2HQLxUadxk574t4iwjCKp5E7NBzwKkrDEhKPISIIjAU/p6K5qDx3qoeh4+26zWN1jmw3g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@algolia/cache-browser-local-storage": "4.24.0",
"@algolia/cache-common": "4.24.0",
"@algolia/cache-in-memory": "4.24.0",
"@algolia/client-account": "4.24.0",
"@algolia/client-analytics": "4.24.0",
"@algolia/client-common": "4.24.0",
"@algolia/client-personalization": "4.24.0",
"@algolia/client-search": "4.24.0",
"@algolia/logger-common": "4.24.0",
"@algolia/logger-console": "4.24.0",
"@algolia/recommend": "4.24.0",
"@algolia/requester-browser-xhr": "4.24.0",
"@algolia/requester-common": "4.24.0",
"@algolia/requester-node-http": "4.24.0",
"@algolia/transporter": "4.24.0"
}
},
"node_modules/algoliasearch-helper": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.14.0.tgz",
"integrity": "sha512-gXDXzsSS0YANn5dHr71CUXOo84cN4azhHKUbg71vAWnH+1JBiR4jf7to3t3JHXknXkbV0F7f055vUSBKrltHLQ==",
"license": "MIT",
"dependencies": {
"@algolia/events": "^4.0.1"
},
"peerDependencies": {
"algoliasearch": ">= 3.1 < 6"
}
},
"node_modules/argparse": { "node_modules/argparse": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
...@@ -1387,6 +1624,24 @@ ...@@ -1387,6 +1624,24 @@
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/hogan.js": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/hogan.js/-/hogan.js-3.0.2.tgz",
"integrity": "sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==",
"dependencies": {
"mkdirp": "0.3.0",
"nopt": "1.0.10"
},
"bin": {
"hulk": "bin/hulk"
}
},
"node_modules/htm": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
"integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==",
"license": "Apache-2.0"
},
"node_modules/html-void-elements": { "node_modules/html-void-elements": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
...@@ -1411,6 +1666,50 @@ ...@@ -1411,6 +1666,50 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
}, },
"node_modules/instantsearch-ui-components": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/instantsearch-ui-components/-/instantsearch-ui-components-0.11.1.tgz",
"integrity": "sha512-ZqUbJYYgObQ47J08ftXV1KNC1vdEoiD4/49qrkCdW46kRzLxLgYXJGuEuk48DQwK4aBtIoccgTyfbMGfcqNjxg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.1.2"
}
},
"node_modules/instantsearch.js": {
"version": "4.78.3",
"resolved": "https://registry.npmjs.org/instantsearch.js/-/instantsearch.js-4.78.3.tgz",
"integrity": "sha512-0i7vyX9jHEIKSfhu+CZHL/ySnbMAe7e98YUJiZX5D7AiXo2WvAPbnV/3CXIPR0whNWOXKGvlv7Ji7Pt4Yrn+Aw==",
"license": "MIT",
"dependencies": {
"@algolia/events": "^4.0.1",
"@types/dom-speech-recognition": "^0.0.1",
"@types/google.maps": "^3.55.12",
"@types/hogan.js": "^3.0.0",
"@types/qs": "^6.5.3",
"algoliasearch-helper": "3.25.0",
"hogan.js": "^3.0.2",
"htm": "^3.0.0",
"instantsearch-ui-components": "0.11.1",
"preact": "^10.10.0",
"qs": "^6.5.1 < 6.10",
"search-insights": "^2.17.2"
},
"peerDependencies": {
"algoliasearch": ">= 3.1 < 6"
}
},
"node_modules/instantsearch.js/node_modules/algoliasearch-helper": {
"version": "3.25.0",
"resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.25.0.tgz",
"integrity": "sha512-vQoK43U6HXA9/euCqLjvyNdM4G2Fiu/VFp4ae0Gau9sZeIKBPvUPnXfLYAe65Bg7PFuw03coeu5K6lTPSXRObw==",
"license": "MIT",
"dependencies": {
"@algolia/events": "^4.0.1"
},
"peerDependencies": {
"algoliasearch": ">= 3.1 < 6"
}
},
"node_modules/is-alphabetical": { "node_modules/is-alphabetical": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz",
...@@ -2162,6 +2461,16 @@ ...@@ -2162,6 +2461,16 @@
} }
] ]
}, },
"node_modules/mkdirp": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz",
"integrity": "sha512-OHsdUcVAQ6pOtg5JYWpCBo9W/GySVuwvP9hueRMW7UqshC0tbfzLv8wjySTPm3tfUZ/21CE9E1pJagOA91Pxew==",
"deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)",
"license": "MIT/X11",
"engines": {
"node": "*"
}
},
"node_modules/mri": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
...@@ -2255,6 +2564,21 @@ ...@@ -2255,6 +2564,21 @@
"node": ">= 0.6.0" "node": ">= 0.6.0"
} }
}, },
"node_modules/nopt": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz",
"integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==",
"license": "MIT",
"dependencies": {
"abbrev": "1"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": "*"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
...@@ -2342,6 +2666,16 @@ ...@@ -2342,6 +2666,16 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/preact": {
"version": "10.26.6",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.6.tgz",
"integrity": "sha512-5SRRBinwpwkaD+OqlBDeITlRgvd8I8QlxHJw9AxSdMNV6O+LodN9nUyYGpSF7sadHjs6RzeFShMexC6DbtWr9g==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prismjs": { "node_modules/prismjs": {
"version": "1.29.0", "version": "1.29.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
...@@ -2375,6 +2709,18 @@ ...@@ -2375,6 +2709,18 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/qs": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz",
"integrity": "sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/raf": { "node_modules/raf": {
"version": "3.4.1", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
...@@ -2425,6 +2771,87 @@ ...@@ -2425,6 +2771,87 @@
"integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==", "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-instantsearch": {
"version": "7.15.7",
"resolved": "https://registry.npmjs.org/react-instantsearch/-/react-instantsearch-7.15.7.tgz",
"integrity": "sha512-UX81UyyuCe0uoAes9M8f7NKv1CkAdRWw1QgR+DucGWqnVeE9srntPprtNbMBGzcXUuV4wur8AP6iRYXn5tm+Vg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.1.2",
"instantsearch-ui-components": "0.11.1",
"instantsearch.js": "4.78.3",
"react-instantsearch-core": "7.15.7"
},
"peerDependencies": {
"algoliasearch": ">= 3.1 < 6",
"react": ">= 16.8.0 < 20",
"react-dom": ">= 16.8.0 < 20"
}
},
"node_modules/react-instantsearch-core": {
"version": "6.40.4",
"resolved": "https://registry.npmjs.org/react-instantsearch-core/-/react-instantsearch-core-6.40.4.tgz",
"integrity": "sha512-sEOgRU2MKL8edO85sNHvKlZ5yq9OFw++CDsEqYpHJvbWLE/2J2N49XAUY90kior09I2kBkbgowBbov+Py1AubQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.1.2",
"algoliasearch-helper": "3.14.0",
"prop-types": "^15.6.2",
"react-fast-compare": "^3.0.0"
},
"peerDependencies": {
"algoliasearch": ">= 3.1 < 5",
"react": ">= 16.3.0 < 19"
}
},
"node_modules/react-instantsearch-dom": {
"version": "6.40.4",
"resolved": "https://registry.npmjs.org/react-instantsearch-dom/-/react-instantsearch-dom-6.40.4.tgz",
"integrity": "sha512-Oy8EKEOg/dfTE8tHc7GZRlzUdbZY4Mxas1x2OtvSNui+YAbIWafIf1g98iOGyVTB2qI5WH91YyUJTLPNfLrs6Q==",
"deprecated": "package has moved to react-instantsearch",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.1.2",
"algoliasearch-helper": "3.14.0",
"classnames": "^2.2.5",
"prop-types": "^15.6.2",
"react-fast-compare": "^3.0.0",
"react-instantsearch-core": "6.40.4"
},
"peerDependencies": {
"algoliasearch": ">= 3.1 < 5",
"react": ">= 16.3.0 < 19",
"react-dom": ">= 16.3.0 < 19"
}
},
"node_modules/react-instantsearch/node_modules/algoliasearch-helper": {
"version": "3.25.0",
"resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.25.0.tgz",
"integrity": "sha512-vQoK43U6HXA9/euCqLjvyNdM4G2Fiu/VFp4ae0Gau9sZeIKBPvUPnXfLYAe65Bg7PFuw03coeu5K6lTPSXRObw==",
"license": "MIT",
"dependencies": {
"@algolia/events": "^4.0.1"
},
"peerDependencies": {
"algoliasearch": ">= 3.1 < 6"
}
},
"node_modules/react-instantsearch/node_modules/react-instantsearch-core": {
"version": "7.15.7",
"resolved": "https://registry.npmjs.org/react-instantsearch-core/-/react-instantsearch-core-7.15.7.tgz",
"integrity": "sha512-9FOHY66VMD0FnxF1dT9g5eEPmGybeKwVAa/T2JX1AqLJCQMHysjEl6qH4+/F8M82KdCqzBof4mpFosPiAVuruA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.1.2",
"algoliasearch-helper": "3.25.0",
"instantsearch.js": "4.78.3",
"use-sync-external-store": "^1.0.0"
},
"peerDependencies": {
"algoliasearch": ">= 3.1 < 6",
"react": ">= 16.8.0 < 20"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
...@@ -2613,6 +3040,12 @@ ...@@ -2613,6 +3040,12 @@
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
}, },
"node_modules/search-insights": {
"version": "2.17.3",
"resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz",
"integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==",
"license": "MIT"
},
"node_modules/section-matter": { "node_modules/section-matter": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
...@@ -2972,6 +3405,15 @@ ...@@ -2972,6 +3405,15 @@
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="
}, },
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/uvu": { "node_modules/uvu": {
"version": "0.5.6", "version": "0.5.6",
"resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz",
......
...@@ -19,6 +19,8 @@ ...@@ -19,6 +19,8 @@
"next": "^15.3.0", "next": "^15.3.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-instantsearch": "^7.15.7",
"react-instantsearch-dom": "^6.40.4",
"react-player": "^2.14.1", "react-player": "^2.14.1",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"remark": "^14.0.0", "remark": "^14.0.0",
......
import { createDefaultMovieService } from './lib/movie-service';
export default async function handler(req, res) {
// Only allow POST requests
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { title, userToken } = req.body;
// Validate required parameters
if (!title || !userToken) {
return res.status(400).json({ error: 'Missing required parameters' });
}
// Create movie service instance
const movieService = createDefaultMovieService();
// Add the movie
const movieData = await movieService.addMovie(title, userToken);
// Return success response
return res.status(200).json({ success: true, movie: movieData });
} catch (error) {
console.error('Error adding movie:', error);
// Return appropriate error messages
if (error.message === 'Movie not found') {
return res.status(404).json({ error: 'Movie not found' });
} else if (error.message === 'Movie already exists') {
return res.status(400).json({ error: 'Movie already exists' });
}
return res.status(500).json({ error: 'Internal server error', message: error.message });
}
}
\ No newline at end of file
/**
* Movie Repository Interface Abstraction
*
* This file defines repository interfaces for storing movie data and votes
* Currently implements Algolia, but could be extended to other storage mechanisms
*/
import { algoliasearch } from 'algoliasearch';
/**
* Base MovieRepository interface
* Any movie repository should implement this interface
*/
class MovieRepository {
/**
* Add a movie to the repository
* @param {object} movieData - Normalized movie data
* @param {string} userId - ID of the user adding the movie
* @returns {Promise<object>} - The stored movie
*/
async addMovie(movieData, userId) {
throw new Error('Method not implemented');
}
/**
* Check if a movie exists in the repository
* @param {string} movieId - The movie ID
* @returns {Promise<boolean>} - True if exists, false otherwise
*/
async movieExists(movieId) {
throw new Error('Method not implemented');
}
/**
* Vote for a movie
* @param {string} movieId - The movie ID
* @param {string} userId - ID of the user voting
* @returns {Promise<boolean>} - True if vote was successful, false otherwise
*/
async voteForMovie(movieId, userId) {
throw new Error('Method not implemented');
}
/**
* Get a movie by ID
* @param {string} movieId - The movie ID
* @returns {Promise<object|null>} - The movie data or null if not found
*/
async getMovie(movieId) {
throw new Error('Method not implemented');
}
/**
* Get top voted movies
* @param {number} count - Number of movies to return
* @returns {Promise<Array<object>>} - Array of movie data
*/
async getTopMovies(count = 5) {
throw new Error('Method not implemented');
}
/**
* Search for movies
* @param {string} query - Search query
* @param {number} limit - Maximum number of results to return
* @returns {Promise<Array<object>>} - Array of movie data
*/
async searchMovies(query, limit = 10) {
throw new Error('Method not implemented');
}
/**
* Get all movies
* @param {number} limit - Maximum number of results to return
* @returns {Promise<Array<object>>} - Array of movie data
*/
async getAllMovies(limit = 100) {
throw new Error('Method not implemented');
}
/**
* Remove a movie
* @param {string} movieId - The movie ID
* @returns {Promise<boolean>} - True if removal was successful
*/
async removeMovie(movieId) {
throw new Error('Method not implemented');
}
}
/**
* Algolia implementation of MovieRepository
*/
class AlgoliaMovieRepository extends MovieRepository {
constructor(appId, apiKey, moviesIndex, votesIndex) {
super();
this.client = algoliasearch(appId, apiKey);
this.moviesIndex = this.client.initIndex(moviesIndex);
this.votesIndex = this.client.initIndex(votesIndex);
}
/**
* Generate a user token for Algolia
* @param {string} userId - User ID
* @returns {string} - User token
*/
generateUserToken(userId) {
return userId.startsWith('discord_') ? userId : `user_${userId}`;
}
async addMovie(movieData, userId) {
// Check if movie already exists
if (await this.movieExists(movieData.id)) {
throw new Error('Movie already exists');
}
// Format the movie data for Algolia
const algoliaMovie = {
objectID: movieData.id,
title: movieData.title,
originalTitle: movieData.originalTitle || movieData.title,
year: movieData.year,
director: movieData.director || 'Unknown',
actors: movieData.actors || [],
genre: movieData.genre || [],
plot: movieData.plot || '',
poster: movieData.poster,
imdbRating: movieData.imdbRating,
imdbID: movieData.imdbID,
tmdbID: movieData.tmdbID,
votes: 0,
addedDate: Date.now(),
addedBy: this.generateUserToken(userId),
source: movieData.source || 'unknown'
};
// Save to Algolia
await this.moviesIndex.saveObject(algoliaMovie);
return algoliaMovie;
}
async movieExists(movieId) {
const searchResponse = await this.moviesIndex.search('', {
filters: `objectID:${movieId}`,
});
return searchResponse.hits.length > 0;
}
async voteForMovie(movieId, userId) {
const userToken = this.generateUserToken(userId);
// Check if user already voted for this movie
const searchResult = await this.votesIndex.search('', {
filters: `userToken:${userToken} AND movieId:${movieId}`
});
if (searchResult.nbHits > 0) {
return false; // User already voted
}
// Record the vote
await this.votesIndex.saveObject({
objectID: `${userToken}_${movieId}`,
userToken: userToken,
movieId: movieId,
timestamp: Date.now()
});
// Increment the movie's vote count
await this.moviesIndex.partialUpdateObject({
objectID: movieId,
votes: {
_operation: 'Increment',
value: 1
}
});
return true;
}
async getMovie(movieId) {
try {
const movie = await this.moviesIndex.getObject(movieId);
return movie;
} catch (error) {
return null;
}
}
async getTopMovies(count = 5) {
const searchResult = await this.moviesIndex.search('', {
filters: 'votes > 0',
hitsPerPage: count,
sortCriteria: ['votes:desc']
});
return searchResult.hits;
}
async searchMovies(query, limit = 10) {
const searchResult = await this.moviesIndex.search(query, {
hitsPerPage: limit
});
return searchResult.hits;
}
async getAllMovies(limit = 100) {
const searchResult = await this.moviesIndex.search('', {
hitsPerPage: limit
});
return searchResult.hits;
}
async removeMovie(movieId) {
try {
await this.moviesIndex.deleteObject(movieId);
// Delete all votes for this movie
const votesResponse = await this.votesIndex.search('', {
filters: `movieId:${movieId}`,
hitsPerPage: 100
});
const voteIds = votesResponse.hits.map(hit => hit.objectID);
if (voteIds.length > 0) {
await this.votesIndex.deleteObjects(voteIds);
}
return true;
} catch (error) {
console.error('Error removing movie:', error);
return false;
}
}
}
/**
* Repository Factory to create the appropriate repository
*/
class MovieRepositoryFactory {
static createRepository(type, config) {
switch (type.toLowerCase()) {
case 'algolia':
return new AlgoliaMovieRepository(
config.appId,
config.apiKey,
config.moviesIndex,
config.votesIndex
);
default:
throw new Error(`Unsupported repository type: ${type}`);
}
}
}
export { MovieRepository, AlgoliaMovieRepository, MovieRepositoryFactory };
\ No newline at end of file
/**
* Movie Service Layer
*
* This service provides a unified interface for interacting with movie data sources
* and repositories, abstracting the underlying implementation details.
*/
import { MovieSourceFactory } from './movie-sources';
import { MovieRepositoryFactory } from './movie-repository';
class MovieService {
/**
* Create a new MovieService instance
* @param {Object} config - Service configuration
* @param {Object} config.dataSource - Data source configuration
* @param {string} config.dataSource.type - Data source type ('omdb', 'tmdb')
* @param {string} config.dataSource.apiKey - API key for the data source
* @param {Object} config.repository - Repository configuration
* @param {string} config.repository.type - Repository type ('algolia')
* @param {Object} config.repository.config - Repository-specific configuration
*/
constructor(config) {
// Set up data sources with fallback cascade
const dataSources = MovieSourceFactory.createDataSourceCascade(config);
this.dataSource = dataSources.primary;
this.fallbackDataSources = dataSources.fallbacks;
// Set up repository
this.repository = MovieRepositoryFactory.createRepository(
config.repository.type,
config.repository.config
);
}
/**
* Search for a movie by title
* @param {string} title - Movie title to search for
* @returns {Promise<Object|null>} - Movie data or null if not found
*/
async searchMovie(title) {
// Try primary data source first
let movieData = await this.dataSource.searchByTitle(title);
// Try each fallback source in order until we find a result
if (!movieData) {
for (const fallbackSource of this.fallbackDataSources) {
movieData = await fallbackSource.searchByTitle(title);
if (movieData) {
console.info(`Found movie using fallback source: ${fallbackSource.constructor.name}`);
break;
}
}
}
return movieData;
}
/**
* Add a movie to the repository
* @param {string} title - Movie title to search and add
* @param {string} userId - ID of the user adding the movie
* @returns {Promise<Object>} - The added movie data
*/
async addMovie(title, userId) {
// First, search for the movie in the data source
const movieData = await this.searchMovie(title);
if (!movieData) {
throw new Error('Movie not found');
}
// Check if the movie already exists in the repository
const movieExists = await this.repository.movieExists(movieData.id);
if (movieExists) {
throw new Error('Movie already exists');
}
// Add the movie to the repository
return await this.repository.addMovie(movieData, userId);
}
/**
* Vote for a movie
* @param {string} movieId - ID of the movie to vote for
* @param {string} userId - ID of the user voting
* @returns {Promise<boolean>} - True if vote was successful, false otherwise
*/
async voteForMovie(movieId, userId) {
return await this.repository.voteForMovie(movieId, userId);
}
/**
* Get top voted movies
* @param {number} count - Number of movies to return
* @returns {Promise<Array<Object>>} - Array of movie data
*/
async getTopMovies(count = 5) {
return await this.repository.getTopMovies(count);
}
/**
* Get all movies
* @param {number} limit - Maximum number of movies to return
* @returns {Promise<Array<Object>>} - Array of movie data
*/
async getAllMovies(limit = 100) {
return await this.repository.getAllMovies(limit);
}
/**
* Search for movies in the repository
* @param {string} query - Search query
* @param {number} limit - Maximum number of results
* @returns {Promise<Array<Object>>} - Array of movie data
*/
async searchMoviesInRepository(query, limit = 10) {
return await this.repository.searchMovies(query, limit);
}
/**
* Remove a movie from the repository
* @param {string} movieId - ID of the movie to remove
* @returns {Promise<boolean>} - True if removal was successful
*/
async removeMovie(movieId) {
return await this.repository.removeMovie(movieId);
}
}
/**
* Create a movie service with the default configuration from environment variables
* @returns {MovieService} - Configured movie service
*/
function createDefaultMovieService() {
// Determine preferred data source
const movieDataSource = process.env.MOVIE_DATA_SOURCE || 'tmdb';
return new MovieService({
dataSource: {
type: movieDataSource,
apiKey: movieDataSource === 'tmdb' ? process.env.TMDB_API_KEY : process.env.OMDB_API_KEY,
fallback: {
type: movieDataSource === 'tmdb' ? 'omdb' : 'tmdb',
apiKey: movieDataSource === 'tmdb' ? process.env.OMDB_API_KEY : process.env.TMDB_API_KEY
}
},
repository: {
type: 'algolia',
config: {
appId: process.env.NEXT_PUBLIC_ALGOLIA_APP_ID,
apiKey: process.env.ALGOLIA_ADMIN_API_KEY,
moviesIndex: process.env.NEXT_PUBLIC_ALGOLIA_INDEX,
votesIndex: process.env.ALGOLIA_VOTES_INDEX || 'paradiso_votes'
}
}
});
}
export { MovieService, createDefaultMovieService };
\ No newline at end of file
/**
* Movie Data Source Interface Abstraction
*
* This file defines interfaces for different movie data sources (OMDB, TMDB)
* and provides concrete implementations.
*/
/**
* Base MovieDataSource interface
* Any movie data provider should implement this interface
*/
class MovieDataSource {
/**
* Search for a movie by title
* @param {string} title - The movie title to search for
* @returns {Promise<object|null>} - Movie data or null if not found
*/
async searchByTitle(title) {
throw new Error('Method not implemented');
}
/**
* Search for a movie by ID
* @param {string} id - The movie ID (could be IMDB ID, TMDB ID, etc.)
* @param {string} idType - The type of ID (imdb, tmdb, etc.)
* @returns {Promise<object|null>} - Movie data or null if not found
*/
async searchById(id, idType) {
throw new Error('Method not implemented');
}
/**
* Normalize the movie data to a consistent format
* @param {object} rawData - Raw data from the API
* @returns {object} - Normalized movie data
*/
normalizeMovie(rawData) {
throw new Error('Method not implemented');
}
}
/**
* OMDB API Implementation
*/
class OMDBDataSource extends MovieDataSource {
constructor(apiKey) {
super();
this.apiKey = apiKey;
this.baseUrl = 'http://www.omdbapi.com/';
}
async searchByTitle(title) {
try {
const response = await fetch(`${this.baseUrl}?apikey=${this.apiKey}&t=${encodeURIComponent(title)}&plot=full`);
if (!response.ok) {
throw new Error(`OMDB API returned ${response.status}`);
}
const data = await response.json();
return data.Response === 'True' ? this.normalizeMovie(data) : null;
} catch (error) {
console.error('Error searching OMDB by title:', error);
return null;
}
}
async searchById(id, idType = 'imdb') {
if (idType !== 'imdb') {
throw new Error('OMDB only supports IMDB IDs');
}
try {
const response = await fetch(`${this.baseUrl}?apikey=${this.apiKey}&i=${id}&plot=full`);
if (!response.ok) {
throw new Error(`OMDB API returned ${response.status}`);
}
const data = await response.json();
return data.Response === 'True' ? this.normalizeMovie(data) : null;
} catch (error) {
console.error('Error searching OMDB by ID:', error);
return null;
}
}
normalizeMovie(rawData) {
return {
id: rawData.imdbID,
title: rawData.Title,
originalTitle: rawData.Title,
year: parseInt(rawData.Year) || null,
director: rawData.Director,
actors: rawData.Actors ? rawData.Actors.split(', ') : [],
genre: rawData.Genre ? rawData.Genre.split(', ') : [],
plot: rawData.Plot,
poster: rawData.Poster !== 'N/A' ? rawData.Poster : null,
imdbRating: rawData.imdbRating !== 'N/A' ? parseFloat(rawData.imdbRating) : null,
imdbID: rawData.imdbID,
source: 'omdb',
rawData: rawData
};
}
}
/**
* TMDB API Implementation
*/
class TMDBDataSource extends MovieDataSource {
constructor(apiKey) {
super();
this.apiKey = apiKey;
this.baseUrl = 'https://api.themoviedb.org/3';
this.imageBaseUrl = 'https://image.tmdb.org/t/p/w500';
}
async searchByTitle(title) {
try {
// First search for the movie
const searchResponse = await fetch(
`${this.baseUrl}/search/movie?api_key=${this.apiKey}&query=${encodeURIComponent(title)}&include_adult=false`
);
if (!searchResponse.ok) {
throw new Error(`TMDB API returned ${searchResponse.status}`);
}
const searchData = await searchResponse.json();
if (!searchData.results || searchData.results.length === 0) {
return null;
}
// Get the most relevant result
const movieId = searchData.results[0].id;
// Get detailed info about the movie
return await this.searchById(movieId, 'tmdb');
} catch (error) {
console.error('Error searching TMDB by title:', error);
return null;
}
}
async searchById(id, idType = 'tmdb') {
try {
let movieId = id;
// If ID is IMDb ID, first search for TMDB ID
if (idType === 'imdb') {
const findResponse = await fetch(
`${this.baseUrl}/find/${id}?api_key=${this.apiKey}&external_source=imdb_id`
);
if (!findResponse.ok) {
throw new Error(`TMDB API returned ${findResponse.status}`);
}
const findData = await findResponse.json();
if (!findData.movie_results || findData.movie_results.length === 0) {
return null;
}
movieId = findData.movie_results[0].id;
}
// Get detailed movie info
const detailsResponse = await fetch(
`${this.baseUrl}/movie/${movieId}?api_key=${this.apiKey}&append_to_response=credits`
);
if (!detailsResponse.ok) {
throw new Error(`TMDB API returned ${detailsResponse.status}`);
}
const movieData = await detailsResponse.json();
return this.normalizeMovie(movieData);
} catch (error) {
console.error('Error searching TMDB by ID:', error);
return null;
}
}
normalizeMovie(rawData) {
// Extract director from credits
let director = 'Unknown';
if (rawData.credits && rawData.credits.crew) {
const directors = rawData.credits.crew
.filter(member => member.job === 'Director')
.map(director => director.name);
director = directors.length > 0 ? directors.join(', ') : 'Unknown';
}
// Extract actors from credits
let actors = [];
if (rawData.credits && rawData.credits.cast) {
actors = rawData.credits.cast
.slice(0, 5) // Get top 5 actors
.map(actor => actor.name);
}
return {
id: rawData.id.toString(),
title: rawData.title,
originalTitle: rawData.original_title,
year: rawData.release_date ? parseInt(rawData.release_date.substring(0, 4)) : null,
director,
actors,
genre: rawData.genres ? rawData.genres.map(genre => genre.name) : [],
plot: rawData.overview,
poster: rawData.poster_path ? `${this.imageBaseUrl}${rawData.poster_path}` : null,
imdbRating: rawData.vote_average ? parseFloat(rawData.vote_average) : null,
imdbID: rawData.imdb_id || null,
tmdbID: rawData.id.toString(),
source: 'tmdb',
rawData: rawData
};
}
}
/**
* Wikipedia Fallback Implementation
* Uses public web APIs to get basic movie information when other sources fail
*/
class WikipediaDataSource extends MovieDataSource {
constructor() {
super();
this.baseUrl = 'https://en.wikipedia.org/api/rest_v1';
}
async searchByTitle(title) {
try {
// Search Wikipedia for the movie
const searchUrl = `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(title)}%20film&format=json&origin=*`;
const searchResponse = await fetch(searchUrl);
if (!searchResponse.ok) {
throw new Error(`Wikipedia API search returned ${searchResponse.status}`);
}
const searchData = await searchResponse.json();
if (!searchData.query || !searchData.query.search || searchData.query.search.length === 0) {
return null;
}
// Get the first result that has "film" in the title or snippet
let pageTitle = null;
for (const result of searchData.query.search) {
if (result.title.toLowerCase().includes('film') ||
result.snippet.toLowerCase().includes('film')) {
pageTitle = result.title;
break;
}
}
// If no film-specific result was found, use the first result
if (!pageTitle && searchData.query.search.length > 0) {
pageTitle = searchData.query.search[0].title;
}
if (!pageTitle) {
return null;
}
// Get the page content
const contentUrl = `${this.baseUrl}/page/summary/${encodeURIComponent(pageTitle)}`;
const contentResponse = await fetch(contentUrl);
if (!contentResponse.ok) {
throw new Error(`Wikipedia API content returned ${contentResponse.status}`);
}
const pageData = await contentResponse.json();
return this.normalizeMovie(pageData);
} catch (error) {
console.error('Error searching Wikipedia by title:', error);
return null;
}
}
async searchById(id, idType = 'wiki') {
// Not directly supported for Wikipedia
return null;
}
normalizeMovie(rawData) {
// Generate an ID from the title
const titleSlug = rawData.title.toLowerCase().replace(/[^a-z0-9]+/g, '_');
const id = `wiki_${titleSlug}`;
// Try to extract the year from the title (often in parentheses)
const yearMatch = rawData.title.match(/\((\d{4})(?: film)?\)/);
const year = yearMatch ? parseInt(yearMatch[1]) : null;
// Clean up the title by removing year and "film" markers
const title = rawData.title.replace(/\s*\(\d{4}(?: film)?\)/, '');
// Extract director using a simple regex if possible
let director = 'Unknown';
if (rawData.extract) {
const directorMatch = rawData.extract.match(/(?:directed|director)[^\n.]*?by\s+([^.,\n]+)/i);
if (directorMatch) {
director = directorMatch[1].trim();
}
}
return {
id,
title,
originalTitle: title,
year,
director,
actors: [], // Would need more complex parsing
genre: [], // Would need more complex parsing
plot: rawData.extract ? rawData.extract.substring(0, 500) : '',
poster: rawData.thumbnail ? rawData.thumbnail.source : null,
imdbRating: null,
imdbID: null,
tmdbID: null,
source: 'wikipedia',
rawData: {
title: rawData.title,
url: rawData.content_urls ? rawData.content_urls.desktop.page : null
}
};
}
}
/**
* Movie Source Factory to create the appropriate data source
*/
class MovieSourceFactory {
static createDataSource(source, apiKey) {
switch (source.toLowerCase()) {
case 'omdb':
return apiKey ? new OMDBDataSource(apiKey) : new WikipediaDataSource();
case 'tmdb':
return apiKey ? new TMDBDataSource(apiKey) : new WikipediaDataSource();
case 'wikipedia':
return new WikipediaDataSource();
default:
throw new Error(`Unsupported movie data source: ${source}`);
}
}
/**
* Creates a cascade of data sources with fallback options
* @param {Object} config - Configuration for data sources
* @returns {Object} An object with primary and fallback data sources
*/
static createDataSourceCascade(config) {
const sources = {
primary: null,
fallbacks: []
};
// Set up primary data source
if (config.dataSource.type === 'tmdb' && config.dataSource.apiKey) {
sources.primary = this.createDataSource('tmdb', config.dataSource.apiKey);
} else if (config.dataSource.type === 'omdb' && config.dataSource.apiKey) {
sources.primary = this.createDataSource('omdb', config.dataSource.apiKey);
} else if (config.dataSource.type === 'fallback') {
// In fallback mode, try to use sources in priority order
if (config.dataSource.apiKey) {
sources.primary = this.createDataSource(config.dataSource.type, config.dataSource.apiKey);
} else if (config.dataSource.fallback && config.dataSource.fallback.apiKey) {
sources.primary = this.createDataSource(
config.dataSource.fallback.type,
config.dataSource.fallback.apiKey
);
}
}
// Set up fallback sources
if (config.dataSource.fallback) {
if (config.dataSource.type !== config.dataSource.fallback.type) {
sources.fallbacks.push(
this.createDataSource(config.dataSource.fallback.type, config.dataSource.fallback.apiKey)
);
}
}
// Add Wikipedia as last resort fallback
sources.fallbacks.push(this.createDataSource('wikipedia'));
// If no primary source, use Wikipedia
if (!sources.primary) {
sources.primary = this.createDataSource('wikipedia');
console.warn('No movie API keys available. Using Wikipedia as primary source.');
}
return sources;
}
}
export { MovieDataSource, OMDBDataSource, TMDBDataSource, WikipediaDataSource, MovieSourceFactory };
\ No newline at end of file
import { createDefaultMovieService } from './lib/movie-service';
export default async function handler(req, res) {
// Only allow GET requests
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { title } = req.query;
// Validate required parameters
if (!title) {
return res.status(400).json({ error: 'Missing title parameter' });
}
// Create movie service instance
const movieService = createDefaultMovieService();
// Search for the movie
const movieData = await movieService.searchMovie(title);
if (!movieData) {
return res.status(404).json({ error: 'Movie not found' });
}
// Return the movie data
return res.status(200).json(movieData);
} catch (error) {
console.error('Error searching for movie:', error);
return res.status(500).json({ error: 'Internal server error', message: error.message });
}
}
\ No newline at end of file
import { createDefaultMovieService } from './lib/movie-service';
export default async function handler(req, res) {
// Only allow POST requests
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const { movieId, userToken } = req.body;
// Validate required parameters
if (!movieId || !userToken) {
return res.status(400).json({ error: 'Missing required parameters' });
}
// Create movie service instance
const movieService = createDefaultMovieService();
// Vote for the movie
const success = await movieService.voteForMovie(movieId, userToken);
if (!success) {
return res.status(400).json({ error: 'User already voted for this movie' });
}
// Return success response
return res.status(200).json({ success: true });
} catch (error) {
console.error('Error voting for movie:', error);
return res.status(500).json({ error: 'Internal server error', message: error.message });
}
}
\ No newline at end of file
# Paradiso Bot Deployment Guide
This guide will help you deploy the Paradiso movie voting bot on your Debian server or using Replit, as you prefer.
## Environment Variables
The bot requires the following environment variables:
- `DISCORD_TOKEN`: Your Discord bot token
- `ALGOLIA_APP_ID`: Your Algolia application ID
- `ALGOLIA_API_KEY`: Your Algolia API key
- `ALGOLIA_MOVIES_INDEX`: The Algolia index for movies (e.g., `paradiso_movies`)
- `ALGOLIA_VOTES_INDEX`: The Algolia index for votes (e.g., `paradiso_votes`)
- `MOVIE_DATA_SOURCE`: Preferred movie data source (`tmdb`, `omdb`, or `fallback`, defaults to `tmdb`)
At least one of the following API keys is required:
- `TMDB_API_KEY`: Your TMDB API key
- `OMDB_API_KEY`: Your OMDB API key
## Option 1: Deploy on Your Debian Server
### Prerequisites
- Debian 9+ (tested on Debian 9.13 Stretch)
- Python 3.7+ (Python 3.5+ should work, but 3.7+ is recommended)
- pip (Python package manager)
- systemd for service management
### Installation Steps
1. Log in to your server and create a directory for the bot:
```bash
mkdir -p /opt/paradiso-bot
cd /opt/paradiso-bot
```
2. Download the bot files or clone the repository:
```bash
# If you have git installed
git clone https://your-repo-url.git .
# Or manually download and upload the files
```
3. Set up a Python virtual environment (recommended):
```bash
# Install venv if not already installed
apt-get update
apt-get install -y python3-venv
# Create and activate virtual environment
python3 -m venv venv
source venv/bin/activate
```
4. Install the required dependencies:
```bash
pip install discord.py python-dotenv algoliasearch requests
```
5. Create a `.env` file:
```bash
nano .env
```
6. Add your environment variables to the `.env` file:
```
DISCORD_TOKEN=your_discord_token
ALGOLIA_APP_ID=your_algolia_app_id
ALGOLIA_API_KEY=your_algolia_api_key
ALGOLIA_MOVIES_INDEX=paradiso_movies
ALGOLIA_VOTES_INDEX=paradiso_votes
MOVIE_DATA_SOURCE=tmdb
TMDB_API_KEY=your_tmdb_api_key
OMDB_API_KEY=your_omdb_api_key
```
7. Create a systemd service file:
```bash
sudo nano /etc/systemd/system/paradiso-bot.service
```
8. Add the following content to the service file:
```
[Unit]
Description=Paradiso Discord Bot
After=network.target
[Service]
User=your_username
WorkingDirectory=/opt/paradiso-bot
ExecStart=/opt/paradiso-bot/venv/bin/python bot.py
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
```
9. Enable and start the service:
```bash
sudo systemctl daemon-reload
sudo systemctl enable paradiso-bot.service
sudo systemctl start paradiso-bot.service
```
10. Check the status of the service:
```bash
sudo systemctl status paradiso-bot.service
```
### Changing Your Server Hostname (from erable.plnech.fr to nech.pl)
To change your server hostname on Debian:
1. Edit the hostname file:
```bash
sudo nano /etc/hostname
```
2. Replace the current hostname with the new one:
```
nech.pl
```
3. Edit the hosts file:
```bash
sudo nano /etc/hosts
```
4. Update the relevant line:
```
127.0.1.1 nech.pl
```
5. Apply the changes:
```bash
sudo hostname nech.pl
```
6. Restart networking and related services:
```bash
sudo systemctl restart networking
```
7. If you have configured DNS records, update them by:
- Logging into your domain registrar or DNS provider
- Updating the A/AAAA record for `nech.pl` to point to your server's IP
- If using SSL certificates, you may need to renew them for the new domain
8. Reboot your server to ensure all services are using the new hostname:
```bash
sudo reboot
```
## Option 2: Deploy on Replit
### Prerequisites
- A Replit account
- A UptimeRobot account (to keep the bot awake)
### Installation Steps
1. Go to [Replit](https://replit.com) and sign up or log in
2. Click the "+ Create" button
3. Select "Python" as the template
4. Name your repl "ParadisoBot" or similar
5. Click "Create Repl"
6. Upload the `bot.py` file to your Repl
7. Create a `keep_alive.py` file with the following content:
```python
from flask import Flask
from threading import Thread
app = Flask('')
@app.route('/')
def home():
return "Paradiso Bot is alive!"
def run():
app.run(host='0.0.0.0', port=8080)
def keep_alive():
t = Thread(target=run)
t.start()
```
8. Modify the end of your `bot.py` file to use the keep_alive function:
```python
# At the top of the file, add:
from keep_alive import keep_alive
# At the bottom of your file, replace:
if __name__ == "__main__":
client.run(DISCORD_TOKEN)
# With:
if __name__ == "__main__":
keep_alive() # Keep the bot alive
client.run(DISCORD_TOKEN)
```
9. Add the environment variables in Replit:
- Click on the 🔒 icon in the sidebar (or find "Secrets" in the "Tools" menu)
- Add each of the environment variables listed above
10. Create a `pyproject.toml` file for dependencies:
```toml
[tool.poetry]
name = "paradiso-bot"
version = "0.1.0"
description = "Discord bot for Paradiso movie night voting"
authors = ["Your Name <your.email@example.com>"]
[tool.poetry.dependencies]
python = "^3.8"
discord = "^2.0.0"
python-dotenv = "^0.21.0"
algoliasearch = "^2.6.2"
requests = "^2.28.1"
Flask = "^2.2.2"
wikipedia = "^1.4.0" # For fallback movie data
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
```
11. Click the "Run" button to start the bot
12. To keep the bot running 24/7, set up UptimeRobot:
- Go to [UptimeRobot](https://uptimerobot.com/)
- Create a free account
- Click "Add New Monitor"
- Select "HTTP(s)" as the monitor type
- Enter a friendly name like "Paradiso Bot"
- Enter the URL of your Repl webview (e.g., `https://paradisobot.yourusername.repl.co`)
- Set the monitoring interval to 5 minutes
- Click "Create Monitor"
## Movie Data Source Configuration
The bot supports multiple movie data sources in a fallback cascade:
1. Configure which API to use as primary with the `MOVIE_DATA_SOURCE` environment variable:
- `tmdb`: Use The Movie Database API (recommended)
- `omdb`: Use Open Movie Database API
- `fallback`: Attempt to use all available sources in priority order
2. The system will work with any of these configurations:
- TMDB API only: Set `TMDB_API_KEY` and `MOVIE_DATA_SOURCE=tmdb`
- OMDB API only: Set `OMDB_API_KEY` and `MOVIE_DATA_SOURCE=omdb`
- Both APIs: Set both keys and choose your preferred order with `MOVIE_DATA_SOURCE`
3. If both APIs fail, the bot will make a best effort to find information using public sources.
## Verifying the Bot is Working
1. Invite the bot to your Discord server using the OAuth2 URL from the Discord Developer Portal
2. Try using the `/help` command in your server to see all available commands
3. Test adding a movie with `/add [movie title]`
## Monitoring and Troubleshooting
### Checking Logs
On your Debian server, you can check the bot logs using:
```bash
sudo journalctl -u paradiso-bot.service
```
For recent logs only:
```bash
sudo journalctl -u paradiso-bot.service -n 50 --no-pager
```
To follow logs in real-time:
```bash
sudo journalctl -u paradiso-bot.service -f
```
### Common Issues
- **Bot not responding to commands**:
- Check if the bot is running: `systemctl status paradiso-bot`
- Verify Discord token is correct
- Ensure the bot has the proper permissions in Discord
- **Movie search not working**:
- Check if TMDB/OMDB API keys are correct
- Look for API rate limiting errors in logs
- Try switching between data sources
- **Algolia operations failing**:
- Verify Algolia credentials and index names
- Check if indices have been properly created
- Ensure you have the right permissions set for your API key
## Updating the Bot
When you want to update the bot:
1. Stop the current running instance:
```bash
sudo systemctl stop paradiso-bot.service
```
2. Update the code files:
```bash
cd /opt/paradiso-bot
# Pull updates from git or upload new files
```
3. Restart the service:
```bash
sudo systemctl start paradiso-bot.service
```
## Additional Resources
- [Discord.py Documentation](https://discordpy.readthedocs.io/)
- [Algolia Documentation](https://www.algolia.com/doc/)
- [TMDB API Documentation](https://developers.themoviedb.org/3)
- [OMDB API Documentation](http://www.omdbapi.com/)
- [Debian Service Management](https://wiki.debian.org/systemd)
- [Replit Documentation](https://docs.replit.com/)
- [UptimeRobot Documentation](https://uptimerobot.com/help/)
\ No newline at end of file
# Paradiso - Movie Night Voting System
Paradiso is a complete movie night voting system consisting of:
1. A Next.js web interface integrated with your personal site
2. A Discord bot for interaction through Discord
3. Algolia as a database and search engine
4. OMDb API for movie metadata
This system allows you and your friends to vote on movies for your next movie night, ensuring a fair and fun selection process.
## System Architecture
- **Data Storage**: [Algolia](https://www.algolia.com/) (Search & Database)
- **Movie Data**: [OMDb API](https://www.omdbapi.com/) (Open Movie Database)
- **Frontend**: Next.js component on your personal site
- **Bot**: Python Discord bot using discord.py
## Setup Instructions
### Step 1: Set Up Algolia
1. Create a free [Algolia](https://www.algolia.com/) account
2. Create a new application (or use an existing one)
3. Get your Application ID and Admin API Key from the dashboard
4. Run the setup script to configure indices and generate API keys:
```bash
# Install the required package
pip install algoliasearch
# Run the setup script
python setup.py --admin-key YOUR_ADMIN_API_KEY --app-id YOUR_APP_ID
```
The setup script creates:
- A `paradiso_movies` index for storing movie data
- A `paradiso_votes` index for tracking votes
- Necessary API keys with appropriate permissions
- Configuration files for the web app and Discord bot
### Step 2: Get an OMDb API Key
1. Go to [OMDb API](https://www.omdbapi.com/apikey.aspx)
2. Sign up for a free API key (1,000 daily requests)
3. Check your email and activate your key
4. Add your key to the `.env.frontend` and `.env.bot` files generated by the setup script
### Step 3: Set Up the Next.js Frontend
1. Create the API endpoints in your Next.js project:
- `pages/api/paradiso/vote.js` - For voting on movies
- `pages/api/paradiso/search-movie.js` - For searching movies via OMDb API
- `pages/api/paradiso/add-movie.js` - For adding movies to Algolia
2. Create the Paradiso page:
- `pages/paradiso/index.js` - The main movie voting interface
3. Install the required packages:
```bash
npm install algoliasearch react-instantsearch-dom
```
4. Add the environment variables from `.env.frontend` to your Next.js project:
```bash
# .env.local in your Next.js project
NEXT_PUBLIC_ALGOLIA_APP_ID=YOUR_APP_ID
NEXT_PUBLIC_ALGOLIA_SEARCH_KEY=YOUR_SEARCH_KEY
NEXT_PUBLIC_ALGOLIA_INDEX=paradiso_movies
ALGOLIA_ADMIN_API_KEY=YOUR_ADMIN_API_KEY
OMDB_API_KEY=YOUR_OMDB_API_KEY
```
### Step 4: Set Up the Discord Bot
1. Create a new Discord application and bot at the [Discord Developer Portal](https://discord.com/developers/applications)
2. Get your bot token
3. Add the bot to your Discord server with appropriate permissions:
- Read Messages/View Channels
- Send Messages
- Use Slash Commands
- Embed Links
4. Install the required Python packages:
```bash
pip install discord.py python-dotenv algoliasearch requests
```
5. Create a `.env` file for the bot with the environment variables from `.env.bot`:
```bash
# .env for the Discord bot
DISCORD_TOKEN=YOUR_DISCORD_BOT_TOKEN
ALGOLIA_APP_ID=YOUR_APP_ID
ALGOLIA_API_KEY=YOUR_SECURED_API_KEY
ALGOLIA_MOVIES_INDEX=paradiso_movies
ALGOLIA_VOTES_INDEX=paradiso_votes
OMDB_API_KEY=YOUR_OMDB_API_KEY
```
6. Run the bot:
```bash
python bot.py
```
## Usage
### Web Interface
The web interface will be available at `https://your-site.com/paradiso`. Here, users can:
- Search for movies
- Add new movies
- Vote for movies
- See the top voted movies
### Discord Bot
The Discord bot provides the following slash commands:
- `/movies` - List all movies in the voting queue
- `/add [title]` - Add a movie to the voting queue
- `/vote [title]` - Vote for a movie in the queue
- `/remove [title]` - Remove a movie from the voting queue (admin only)
- `/top [count]` - Show the top voted movies (default: top 5)
- `/random` - Suggest a random movie from the list
- `/help` - Show help for all commands
## Security Considerations
The system is secured in the following ways:
1. **Frontend**: Uses a secured API key with restricted permissions
2. **Bot**: Uses a different secured API key with its own permissions
3. **Rate Limiting**: API calls are rate-limited to prevent abuse
4. **User Tokens**: Uses tokens to identify users and prevent duplicate votes
## Limitations and Notes
- The free tier of Algolia provides 10,000 records and 10,000 operations per month, more than enough for a personal movie voting system
- The free tier of OMDb API allows 1,000 requests per day
- The secured API keys generated by the setup script are valid for 1 year, after which you'll need to generate new ones
## Customization
You can customize various aspects of the system:
- Change the index prefix in the setup script
- Modify the web interface styling to match your site
- Adjust the Discord bot's embed colors and messages
- Add additional commands to the bot
## Troubleshooting
- If votes aren't being recorded, check your Algolia API keys and permissions
- If movie searches fail, verify your OMDb API key is active
- If the Discord bot isn't responding, ensure it has the correct permissions in your server
## Future Improvements
- Add user authentication for the web interface
- Implement more advanced voting mechanics (e.g., ranked voting)
- Add movie night scheduling features
- Create a shared watchlist for watched movies
## Credits
- Movie data provided by [OMDb API](https://www.omdbapi.com/)
- Search and database powered by [Algolia](https://www.algolia.com/)
- Discord integration using [discord.py](https://discordpy.readthedocs.io/)
\ No newline at end of file
# Hosting the Paradiso Discord Bot for Free
This guide explains how to host your Paradiso Discord bot for free using Replit, ensuring it runs 24/7 without any costs.
## What is Replit?
[Replit](https://replit.com) is a browser-based IDE that allows you to write, run, and host code in the cloud. It's perfect for hosting Discord bots because:
1. It offers a free tier with no credit card required
2. It can keep your bot running 24/7 (with some setup)
3. It's easy to use and doesn't require server management
## Step 1: Create a Replit Account
1. Go to [Replit](https://replit.com)
2. Sign up for a free account
3. Verify your email address
## Step 2: Create a New Repl
1. Click the "+ Create" button
2. Select "Python" as the template
3. Name your repl "ParadisoBot" or something similar
4. Click "Create Repl"
## Step 3: Set Up Your Bot Files
1. Upload the `paradiso_bot.py` file to your Repl
2. Create a new file called `keep_alive.py` with the following code:
```python
from flask import Flask
from threading import Thread
app = Flask('')
@app.route('/')
def home():
return "Paradiso Bot is alive!"
def run():
app.run(host='0.0.0.0', port=8080)
def keep_alive():
t = Thread(target=run)
t.start()
```
3. Modify the end of your `paradiso_bot.py` file to use the keep_alive function:
```python
# At the top of the file, add:
from keep_alive import keep_alive
# At the bottom of your file, replace:
if __name__ == "__main__":
client.run(DISCORD_TOKEN)
# With:
if __name__ == "__main__":
keep_alive() # Keep the bot alive
client.run(DISCORD_TOKEN)
```
## Step 4: Set Up Environment Variables
Replit provides a secure way to store sensitive information like API keys.
1. In your Repl, click on the 🔒 icon in the sidebar (or find "Secrets" in the "Tools" menu)
2. Add the following secrets:
- Key: `DISCORD_TOKEN`, Value: `your-discord-bot-token`
- Key: `ALGOLIA_APP_ID`, Value: `your-algolia-app-id`
- Key: `ALGOLIA_API_KEY`, Value: `your-algolia-api-key`
- Key: `ALGOLIA_MOVIES_INDEX`, Value: `paradiso_movies`
- Key: `ALGOLIA_VOTES_INDEX`, Value: `paradiso_votes`
- Key: `OMDB_API_KEY`, Value: `your-omdb-api-key`
## Step 5: Install Dependencies
1. Create a new file called `pyproject.toml` with the following content:
```toml
[tool.poetry]
name = "paradiso-bot"
version = "0.1.0"
description = "Discord bot for Paradiso movie night voting"
authors = ["Your Name <your.email@example.com>"]
[tool.poetry.dependencies]
python = "^3.8"
discord = "^2.0.0"
python-dotenv = "^0.21.0"
algoliasearch = "^2.6.2"
requests = "^2.28.1"
Flask = "^2.2.2"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
```
2. Replit will automatically install the dependencies when you run the bot
## Step 6: Run Your Bot
1. Click the "Run" button at the top of the page
2. Your bot should start up and connect to Discord
3. You'll see a webview showing "Paradiso Bot is alive!"
## Step 7: Keep Your Bot Running 24/7
By default, Replit will stop your bot after some time of inactivity. To keep it running:
1. Go to [UptimeRobot](https://uptimerobot.com/)
2. Create a free account
3. Click "Add New Monitor"
4. Select "HTTP(s)" as the monitor type
5. Enter a friendly name like "Paradiso Bot"
6. Enter the URL of your Repl webview (e.g., `https://paradisobot.yourusername.repl.co`)
7. Set the monitoring interval to 5 minutes
8. Click "Create Monitor"
UptimeRobot will now ping your bot every 5 minutes, keeping it alive 24/7.
## Step 8: Update Your Bot
To update your bot when you make changes:
1. Make changes to the files in your Repl
2. Click the "Stop" button if your bot is running
3. Click the "Run" button to restart your bot with the changes
## Alternative Free Hosting Options
If you prefer not to use Replit, here are some alternatives:
### Railway
[Railway](https://railway.app/) offers a generous free tier:
- 5 projects
- 500 hours of runtime per month
- 1GB memory per container
### Render
[Render](https://render.com/) offers a free tier for web services:
- Free for web services (sleeps after 15 minutes of inactivity)
- Wakes up when receiving a request
### Oracle Cloud Free Tier
[Oracle Cloud](https://www.oracle.com/cloud/free/) offers always-free services:
- 2 AMD-based Compute VMs
- 4 ARM-based Ampere A1 cores and 24 GB memory
- 200 GB of storage
## Troubleshooting
- **Bot crashes or doesn't respond**: Check the console output in Replit for error messages
- **UptimeRobot says the site is down**: Make sure your Flask app is running on port 8080
- **Bot doesn't respond to commands**: Ensure your bot has the correct permissions in your Discord server
- **Algolia operations fail**: Check that your API keys and indices are correctly configured
## Notes
- Replit's free tier may occasionally experience slowdowns during high-traffic periods
- The bot might briefly go offline when Replit performs maintenance updates
- For a more robust solution, consider upgrading to Replit's paid plan or hosting on a VPS
## Additional Resources
- [Replit Documentation](https://docs.replit.com/)
- [Discord.py Documentation](https://discordpy.readthedocs.io/)
- [UptimeRobot Documentation](https://uptimerobot.com/help/)
\ No newline at end of file
#!/usr/bin/env python
"""
Paradiso Discord Bot
A Discord bot for the Paradiso movie voting system, using Algolia for data storage.
Requirements:
- Python 3.7+
- discord.py
- python-dotenv
- algoliasearch
- requests
Usage:
1. Install dependencies: pip install discord.py python-dotenv algoliasearch requests
2. Set up a Discord bot in the Discord Developer Portal
3. Create a .env file with your Discord bot token and Algolia credentials
4. Run the bot: python paradiso_bot.py
"""
import os
import json
import random
import logging
import time
import datetime
import abc
from typing import List, Dict, Any, Optional, Union
import discord
from discord import app_commands
from dotenv import load_dotenv
import requests
from algoliasearch.search_client import SearchClient
import re
import urllib.parse
try:
import wikipedia
WIKIPEDIA_AVAILABLE = True
except ImportError:
WIKIPEDIA_AVAILABLE = False
print("Wikipedia module not installed. Wikipedia fallback won't be available.")
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("paradiso_bot.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger("paradiso_bot")
# Load environment variables
load_dotenv()
DISCORD_TOKEN = os.getenv('DISCORD_TOKEN')
ALGOLIA_APP_ID = os.getenv('ALGOLIA_APP_ID')
ALGOLIA_API_KEY = os.getenv('ALGOLIA_API_KEY')
ALGOLIA_MOVIES_INDEX = os.getenv('ALGOLIA_MOVIES_INDEX')
ALGOLIA_VOTES_INDEX = os.getenv('ALGOLIA_VOTES_INDEX')
OMDB_API_KEY = os.getenv('OMDB_API_KEY')
TMDB_API_KEY = os.getenv('TMDB_API_KEY')
MOVIE_DATA_SOURCE = os.getenv('MOVIE_DATA_SOURCE', 'tmdb')
# Check if all environment variables are set
if not all([DISCORD_TOKEN, ALGOLIA_APP_ID, ALGOLIA_API_KEY,
ALGOLIA_MOVIES_INDEX, ALGOLIA_VOTES_INDEX]):
logger.error("Missing required environment variables. Please check your .env file.")
exit(1)
# Movie Data Source Abstraction
class MovieDataSource(abc.ABC):
"""Base movie data source interface."""
@abc.abstractmethod
async def search_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Search for a movie by title."""
pass
@abc.abstractmethod
async def search_by_id(self, movie_id: str, id_type: str = 'imdb') -> Optional[Dict[str, Any]]:
"""Search for a movie by ID."""
pass
@abc.abstractmethod
def normalize_movie(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize movie data to a consistent format."""
pass
class OMDBDataSource(MovieDataSource):
"""OMDB API implementation."""
def __init__(self, api_key: str):
self.api_key = api_key
self.base_url = "http://www.omdbapi.com/"
async def search_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Search for a movie using the OMDb API."""
try:
url = f"{self.base_url}?apikey={self.api_key}&t={title}&plot=full"
response = requests.get(url)
response.raise_for_status()
data = response.json()
if data.get('Response') == 'False':
return None
return self.normalize_movie(data)
except Exception as e:
logger.error(f"Error searching movie on OMDb: {e}")
return None
async def search_by_id(self, movie_id: str, id_type: str = 'imdb') -> Optional[Dict[str, Any]]:
"""Search for a movie by ID using the OMDb API."""
if id_type != 'imdb':
raise ValueError("OMDB only supports IMDB IDs")
try:
url = f"{self.base_url}?apikey={self.api_key}&i={movie_id}&plot=full"
response = requests.get(url)
response.raise_for_status()
data = response.json()
if data.get('Response') == 'False':
return None
return self.normalize_movie(data)
except Exception as e:
logger.error(f"Error searching movie by ID on OMDb: {e}")
return None
def normalize_movie(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize OMDB movie data."""
return {
"id": raw_data["imdbID"],
"title": raw_data["Title"],
"original_title": raw_data["Title"],
"year": int(raw_data["Year"]) if raw_data.get("Year", "N/A").isdigit() else None,
"director": raw_data.get("Director", "Unknown"),
"actors": raw_data.get("Actors", "").split(", ") if raw_data.get("Actors") else [],
"genre": raw_data.get("Genre", "").split(", ") if raw_data.get("Genre") else [],
"plot": raw_data.get("Plot", ""),
"poster": raw_data.get("Poster") if raw_data.get("Poster") != "N/A" else None,
"imdb_rating": float(raw_data["imdbRating"]) if raw_data.get("imdbRating", "N/A") != "N/A" else None,
"imdb_id": raw_data["imdbID"],
"tmdb_id": None,
"source": "omdb",
"raw_data": raw_data
}
class TMDBDataSource(MovieDataSource):
"""TMDB API implementation."""
def __init__(self, api_key: str):
self.api_key = api_key
self.base_url = "https://api.themoviedb.org/3"
self.image_base_url = "https://image.tmdb.org/t/p/w500"
async def search_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Search for a movie using the TMDB API."""
try:
# First search for the movie
url = f"{self.base_url}/search/movie?api_key={self.api_key}&query={title}&include_adult=false"
response = requests.get(url)
response.raise_for_status()
data = response.json()
if not data.get('results') or len(data['results']) == 0:
return None
# Get the most relevant result
movie_id = data['results'][0]['id']
# Get detailed info about the movie
return await self.search_by_id(str(movie_id), 'tmdb')
except Exception as e:
logger.error(f"Error searching movie on TMDB: {e}")
return None
async def search_by_id(self, movie_id: str, id_type: str = 'tmdb') -> Optional[Dict[str, Any]]:
"""Search for a movie by ID using the TMDB API."""
try:
tmdb_id = movie_id
# If ID is IMDb ID, first search for TMDB ID
if id_type == 'imdb':
find_url = f"{self.base_url}/find/{movie_id}?api_key={self.api_key}&external_source=imdb_id"
find_response = requests.get(find_url)
find_response.raise_for_status()
find_data = find_response.json()
if not find_data.get('movie_results') or len(find_data['movie_results']) == 0:
return None
tmdb_id = str(find_data['movie_results'][0]['id'])
# Get detailed movie info
details_url = f"{self.base_url}/movie/{tmdb_id}?api_key={self.api_key}&append_to_response=credits"
details_response = requests.get(details_url)
details_response.raise_for_status()
movie_data = details_response.json()
return self.normalize_movie(movie_data)
except Exception as e:
logger.error(f"Error searching movie by ID on TMDB: {e}")
return None
def normalize_movie(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize TMDB movie data."""
# Extract director from credits
director = "Unknown"
if raw_data.get('credits') and raw_data['credits'].get('crew'):
directors = [member['name'] for member in raw_data['credits']['crew']
if member.get('job') == 'Director']
director = ", ".join(directors) if directors else "Unknown"
# Extract actors from credits
actors = []
if raw_data.get('credits') and raw_data['credits'].get('cast'):
actors = [actor['name'] for actor in raw_data['credits']['cast'][:5]]
return {
"id": str(raw_data["id"]),
"title": raw_data["title"],
"original_title": raw_data.get("original_title", raw_data["title"]),
"year": int(raw_data["release_date"][:4]) if raw_data.get("release_date") else None,
"director": director,
"actors": actors,
"genre": [genre['name'] for genre in raw_data.get("genres", [])],
"plot": raw_data.get("overview", ""),
"poster": f"{self.image_base_url}{raw_data['poster_path']}" if raw_data.get("poster_path") else None,
"imdb_rating": float(raw_data["vote_average"]) if raw_data.get("vote_average") else None,
"imdb_id": raw_data.get("imdb_id"),
"tmdb_id": str(raw_data["id"]),
"source": "tmdb",
"raw_data": raw_data
}
class WikipediaDataSource(MovieDataSource):
"""Wikipedia fallback implementation."""
def __init__(self):
self.image_pattern = re.compile(r'https?://.*?\.(?:jpg|jpeg|png|gif)')
async def search_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Search for a movie using Wikipedia."""
if not WIKIPEDIA_AVAILABLE:
return None
try:
# Search for the movie with "film" appended to improve results accuracy
search_query = f"{title} film"
search_results = wikipedia.search(search_query, results=5)
if not search_results:
return None
# Try to find the most relevant page
page_title = None
for result in search_results:
if "film" in result.lower() or "movie" in result.lower():
page_title = result
break
# If no film-specific result was found, use the first result
if not page_title and search_results:
page_title = search_results[0]
if not page_title:
return None
# Get the page content
page = wikipedia.page(page_title, auto_suggest=False)
# Try to extract basic information
return self.normalize_movie({
"title": page.title,
"content": page.content,
"summary": page.summary,
"url": page.url,
"images": page.images
})
except Exception as e:
logger.error(f"Error searching Wikipedia: {e}")
return None
async def search_by_id(self, movie_id: str, id_type: str = 'wiki') -> Optional[Dict[str, Any]]:
"""Not directly supported for Wikipedia."""
return None
def normalize_movie(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
"""Extract structured movie data from Wikipedia content."""
# Get a unique ID based on the URL
url_parts = urllib.parse.urlparse(raw_data["url"])
path_parts = url_parts.path.split('/')
wiki_id = path_parts[-1] if path_parts else "unknown"
# Try to extract the year from the title (often in parentheses)
year_match = re.search(r'\((\d{4})(?: film)?\)', raw_data["title"])
year = int(year_match.group(1)) if year_match else None
# Clean up the title by removing year and "film" markers
title = re.sub(r'\s*\(\d{4}(?: film)?\)', '', raw_data["title"])
# Find a suitable image (movie poster if possible)
poster = None
for img_url in raw_data.get("images", []):
if self.image_pattern.search(img_url):
if 'poster' in img_url.lower():
poster = img_url
break
# If no poster-specific image was found, use the first image
if not poster and raw_data.get("images"):
for img_url in raw_data.get("images", []):
if self.image_pattern.search(img_url):
poster = img_url
break
# Try to extract director from content
director = "Unknown"
director_match = re.search(r'(?:Directed|Director)[^\n.]*?by\s+([^.,\n]+)', raw_data["content"])
if director_match:
director = director_match.group(1).strip()
return {
"id": f"wiki_{wiki_id}",
"title": title,
"original_title": title,
"year": year,
"director": director,
"actors": [], # Would need more complex parsing
"genre": [], # Would need more complex parsing
"plot": raw_data["summary"][:500] if raw_data.get("summary") else "",
"poster": poster,
"imdb_rating": None,
"imdb_id": None,
"tmdb_id": None,
"source": "wikipedia",
"raw_data": {
"title": raw_data["title"],
"url": raw_data["url"]
}
}
def create_movie_data_source(source_type: str, api_key: str = None) -> MovieDataSource:
"""Create a movie data source instance."""
if source_type.lower() == 'omdb':
if not api_key:
logger.warning("OMDB API key not provided, using fallback source")
return WikipediaDataSource() if WIKIPEDIA_AVAILABLE else None
return OMDBDataSource(api_key)
elif source_type.lower() == 'tmdb':
if not api_key:
logger.warning("TMDB API key not provided, using fallback source")
return WikipediaDataSource() if WIKIPEDIA_AVAILABLE else None
return TMDBDataSource(api_key)
elif source_type.lower() == 'wikipedia':
return WikipediaDataSource()
else:
raise ValueError(f"Unsupported movie data source: {source_type}")
# Initialize movie data sources with improved fallback logic
primary_data_source = None
fallback_data_sources = []
# Set up primary data source
if MOVIE_DATA_SOURCE == 'tmdb' and TMDB_API_KEY:
primary_data_source = create_movie_data_source('tmdb', TMDB_API_KEY)
elif MOVIE_DATA_SOURCE == 'omdb' and OMDB_API_KEY:
primary_data_source = create_movie_data_source('omdb', OMDB_API_KEY)
elif MOVIE_DATA_SOURCE == 'fallback':
# In fallback mode, try to use sources in priority order
if TMDB_API_KEY:
primary_data_source = create_movie_data_source('tmdb', TMDB_API_KEY)
elif OMDB_API_KEY:
primary_data_source = create_movie_data_source('omdb', OMDB_API_KEY)
# Set up fallback sources
if MOVIE_DATA_SOURCE != 'omdb' and OMDB_API_KEY:
fallback_data_sources.append(create_movie_data_source('omdb', OMDB_API_KEY))
if MOVIE_DATA_SOURCE != 'tmdb' and TMDB_API_KEY:
fallback_data_sources.append(create_movie_data_source('tmdb', TMDB_API_KEY))
# Add Wikipedia as last resort fallback
if WIKIPEDIA_AVAILABLE:
fallback_data_sources.append(create_movie_data_source('wikipedia'))
# Use Wikipedia directly if no API keys are available
if not primary_data_source and WIKIPEDIA_AVAILABLE:
primary_data_source = create_movie_data_source('wikipedia')
elif not primary_data_source:
logger.error("No movie data sources available. Bot will not be able to search for movies.")
primary_data_source = None
# Initialize Algolia client
algolia_client = SearchClient.create(ALGOLIA_APP_ID, ALGOLIA_API_KEY)
movies_index = algolia_client.init_index(ALGOLIA_MOVIES_INDEX)
votes_index = algolia_client.init_index(ALGOLIA_VOTES_INDEX)
# Set up Discord client
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)
# Helper Functions
def generate_user_token(user_id: str) -> str:
"""Generate a user token for Algolia based on Discord user ID."""
return f"discord_{user_id}"
async def search_movie(title: str) -> Optional[Dict[str, Any]]:
"""Search for a movie using configured data sources with full fallback cascade."""
if not primary_data_source:
logger.error("No movie data sources available")
return None
# Try primary data source first
movie_data = await primary_data_source.search_by_title(title)
# Try each fallback source in order until we find a result
if not movie_data:
for source in fallback_data_sources:
movie_data = await source.search_by_title(title)
if movie_data:
logger.info(f"Found movie using fallback source: {source.__class__.__name__}")
break
return movie_data
async def add_movie_to_algolia(movie_data: Dict[str, Any], user_id: str) -> Dict[str, Any]:
"""Add a movie to Algolia index."""
try:
# Format the movie data for Algolia
movie_obj = {
"objectID": movie_data["id"],
"title": movie_data["title"],
"originalTitle": movie_data["original_title"],
"year": movie_data["year"],
"director": movie_data["director"],
"actors": movie_data["actors"],
"genre": movie_data["genre"],
"plot": movie_data["plot"],
"poster": movie_data["poster"],
"imdbRating": movie_data["imdb_rating"],
"imdbID": movie_data["imdb_id"],
"tmdbID": movie_data["tmdb_id"],
"votes": 0,
"addedDate": int(time.time()),
"addedBy": generate_user_token(user_id),
"source": movie_data["source"]
}
# Save to Algolia
movies_index.save_object(movie_obj)
return movie_obj
except Exception as e:
logger.error(f"Error adding movie to Algolia: {e}")
raise
async def vote_for_movie(movie_id: str, user_id: str) -> bool:
"""Vote for a movie in Algolia."""
try:
user_token = generate_user_token(user_id)
# Check if user already voted for this movie
search_result = votes_index.search("", {
"filters": f"userToken:{user_token} AND movieId:{movie_id}"
})
if search_result["nbHits"] > 0:
return False # User already voted
# Record the vote
votes_index.save_object({
"objectID": f"{user_token}_{movie_id}",
"userToken": user_token,
"movieId": movie_id,
"timestamp": int(time.time())
})
# Increment the movie's vote count
movies_index.partial_update_object({
"objectID": movie_id,
"votes": {
"_operation": "Increment",
"value": 1
}
})
return True
except Exception as e:
logger.error(f"Error voting for movie: {e}")
return False
async def get_top_movies(count: int = 5) -> List[Dict[str, Any]]:
"""Get the top voted movies from Algolia."""
try:
search_result = movies_index.search("", {
"filters": "votes > 0",
"hitsPerPage": count,
"sortCriteria": ["votes:desc"]
})
return search_result["hits"]
except Exception as e:
logger.error(f"Error getting top movies: {e}")
return []
async def get_all_movies() -> List[Dict[str, Any]]:
"""Get all movies from Algolia."""
try:
search_result = movies_index.search("", {
"hitsPerPage": 100
})
return search_result["hits"]
except Exception as e:
logger.error(f"Error getting all movies: {e}")
return []
async def find_movie_by_title(title: str) -> Optional[Dict[str, Any]]:
"""Find a movie by title in Algolia."""
try:
search_result = movies_index.search(title, {
"hitsPerPage": 5
})
if search_result["nbHits"] == 0:
return None
# Try to find an exact match
for hit in search_result["hits"]:
if hit["title"].lower() == title.lower():
return hit
# Return the first result if no exact match
return search_result["hits"][0]
except Exception as e:
logger.error(f"Error finding movie by title: {e}")
return None
async def remove_movie(movie_id: str) -> bool:
"""Remove a movie from Algolia."""
try:
# Delete the movie
movies_index.delete_object(movie_id)
# Delete all votes for this movie
search_result = votes_index.search("", {
"filters": f"movieId:{movie_id}",
"hitsPerPage": 100
})
if search_result["nbHits"] > 0:
object_ids = [hit["objectID"] for hit in search_result["hits"]]
votes_index.delete_objects(object_ids)
return True
except Exception as e:
logger.error(f"Error removing movie: {e}")
return False
# Bot event handlers
@client.event
async def on_ready():
"""Handle bot ready event."""
logger.info(f'{client.user} has connected to Discord!')
# Sync commands
await tree.sync()
logger.info("Commands synced")
# Bot commands
@tree.command(name="movies", description="List all movies in the voting queue")
async def cmd_movies(interaction: discord.Interaction):
"""List all movies in the voting queue."""
await interaction.response.defer()
try:
movies = await get_all_movies()
if not movies:
await interaction.followup.send("No movies have been added yet! Use `/add` to add one.")
return
# Sort movies by vote count
movies.sort(key=lambda m: m.get("votes", 0), reverse=True)
# Create an embed
embed = discord.Embed(
title="🎬 Paradiso Movie Night Voting",
description=f"Here are the movies currently in the queue ({len(movies)} total):",
color=0x03a9f4,
timestamp=datetime.datetime.now()
)
# Add each movie to the embed
for i, movie in enumerate(movies[:10]): # Limit to top 10
title = movie.get("title", "Unknown")
year = f" ({movie.get('year')})" if movie.get("year") else ""
votes = movie.get("votes", 0)
medal = "🥇" if i == 0 else "🥈" if i == 1 else "🥉" if i == 2 else f"{i+1}."
embed.add_field(
name=f"{medal} {title}{year} - {votes} votes",
value=movie.get("plot", "No description available.")[:100] + "..."
if movie.get("plot") and len(movie.get("plot")) > 100
else movie.get("plot", "No description available."),
inline=False
)
if len(movies) > 10:
embed.set_footer(text=f"Showing top 10 out of {len(movies)} movies. Use /movies_page to see more.")
await interaction.followup.send(embed=embed)
except Exception as e:
logger.error(f"Error in /movies command: {e}")
await interaction.followup.send("An error occurred while getting the movies. Please try again.")
@tree.command(name="add", description="Add a movie to the voting queue")
@app_commands.describe(title="Title of the movie to add")
async def cmd_add(interaction: discord.Interaction, title: str):
"""Add a movie to the voting queue."""
await interaction.response.defer(thinking=True)
try:
# Search for the movie
movie_data = await search_movie(title)
if not movie_data:
await interaction.followup.send(f"❌ Could not find movie: '{title}'. Please check the title and try again.")
return
# Check if movie already exists in Algolia
search_result = movies_index.search("", {
"filters": f"objectID:{movie_data['id']}"
})
if search_result["nbHits"] > 0:
await interaction.followup.send(f"❌ '{movie_data['title']}' is already in the voting queue!")
return
# Add the movie to Algolia
movie_obj = await add_movie_to_algolia(movie_data, str(interaction.user.id))
# Create embed for movie
embed = discord.Embed(
title=f"🎬 Added: {movie_obj['title']} ({movie_obj['year'] if movie_obj['year'] else 'N/A'})",
description=movie_obj["plot"] if len(movie_obj["plot"]) < 300 else movie_obj["plot"][:297] + "...",
color=0x00ff00
)
if movie_obj["director"]:
embed.add_field(name="Director", value=movie_obj["director"], inline=True)
if movie_obj["actors"]:
embed.add_field(name="Starring", value=", ".join(movie_obj["actors"][:3]), inline=True)
if movie_obj["imdbRating"]:
embed.add_field(name="Rating", value=f"⭐ {movie_obj['imdbRating']}/10", inline=True)
if movie_obj["poster"]:
embed.set_thumbnail(url=movie_obj["poster"])
embed.set_footer(text=f"Added by {interaction.user.display_name} | Source: {movie_obj['source'].upper()}")
await interaction.followup.send(embed=embed)
except Exception as e:
logger.error(f"Error in add command: {e}")
await interaction.followup.send(f"❌ An error occurred: {str(e)}")
@tree.command(name="vote", description="Vote for a movie in the queue")
@app_commands.describe(title="Title of the movie to vote for")
async def cmd_vote(interaction: discord.Interaction, title: str):
"""Vote for a movie in the queue."""
await interaction.response.defer(thinking=True)
try:
# Find the movie in Algolia
movie = await find_movie_by_title(title)
if not movie:
await interaction.followup.send(f"❌ Could not find '{title}' in the voting queue. Use /movies to see available movies.")
return
# Record the vote
user_token = generate_user_token(str(interaction.user.id))
# Check if user already voted for this movie
search_result = votes_index.search("", {
"filters": f"userToken:{user_token} AND movieId:{movie['objectID']}"
})
if search_result["nbHits"] > 0:
await interaction.followup.send(f"❌ You have already voted for '{movie['title']}'!")
return
# Record the vote
success = await vote_for_movie(movie["objectID"], str(interaction.user.id))
if not success:
await interaction.followup.send("❌ Failed to record vote. Please try again.")
return
# Update movie information
updated_movie = await movies_index.get_object(movie["objectID"])
# Create embed for vote confirmation
embed = discord.Embed(
title=f"✅ Vote recorded for: {updated_movie['title']}",
description=f"This movie now has {updated_movie['votes']} vote(s)!",
color=0x00ff00
)
if updated_movie.get("poster"):
embed.set_thumbnail(url=updated_movie["poster"])
embed.set_footer(text=f"Voted by {interaction.user.display_name}")
await interaction.followup.send(embed=embed)
except Exception as e:
logger.error(f"Error in vote command: {e}")
await interaction.followup.send(f"❌ An error occurred: {str(e)}")
@tree.command(name="remove", description="Remove a movie from the voting queue")
@app_commands.describe(title="Title of the movie to remove")
async def cmd_remove(interaction: discord.Interaction, title: str):
"""Remove a movie from the voting queue."""
await interaction.response.defer()
try:
# Check if user has admin privileges
if not interaction.user.guild_permissions.administrator:
await interaction.followup.send("You need administrator privileges to remove movies.")
return
# Find the movie
movie = await find_movie_by_title(title)
if not movie:
await interaction.followup.send(f"Movie '{title}' not found in the voting queue.")
return
# Remove the movie
success = await remove_movie(movie["objectID"])
if success:
await interaction.followup.send(f"Removed '{movie['title']}' from the voting queue.")
else:
await interaction.followup.send(f"Failed to remove '{movie['title']}'. Please try again.")
except Exception as e:
logger.error(f"Error in /remove command: {e}")
await interaction.followup.send("An error occurred while removing the movie. Please try again.")
@tree.command(name="top", description="Show the top voted movies")
@app_commands.describe(count="Number of top movies to show (default: 5)")
async def cmd_top(interaction: discord.Interaction, count: int = 5):
"""Show the top voted movies."""
await interaction.response.defer(thinking=True)
try:
# Limit count to reasonable values
count = max(1, min(10, count))
# Get top voted movies
top_movies = await get_top_movies(count)
if not top_movies:
await interaction.followup.send("❌ No movies have been voted for yet!")
return
# Create embed for top movies
embed = discord.Embed(
title=f"🏆 Top {len(top_movies)} Voted Movies",
description="Here are the most popular movies for our next movie night!",
color=0x00ff00
)
for i, movie in enumerate(top_movies):
# Get medal emoji for top 3
medal = "🥇" if i == 0 else "🥈" if i == 1 else "🥉" if i == 2 else f"{i+1}."
# Create field for each movie
movie_details = [
f"**Votes**: {movie['votes']}",
f"**Year**: {movie['year'] if movie.get('year') else 'N/A'}",
f"**Rating**: ⭐ {movie.get('imdbRating', 'N/A')}/10"
]
embed.add_field(
name=f"{medal} {movie['title']}",
value="\n".join(movie_details),
inline=False
)
# Add instructions on how to vote
embed.set_footer(text="Use /vote to vote for a movie!")
await interaction.followup.send(embed=embed)
except Exception as e:
logger.error(f"Error in top command: {e}")
await interaction.followup.send(f"❌ An error occurred: {str(e)}")
@tree.command(name="random", description="Suggest a random movie from the list")
async def cmd_random(interaction: discord.Interaction):
"""Suggest a random movie from the list."""
await interaction.response.defer(thinking=True)
try:
# Get all movies
movies = await get_all_movies()
if not movies:
await interaction.followup.send("❌ No movies in the database yet! Add some with /add.")
return
# Choose a random movie
random_movie = random.choice(movies)
# Create embed for random movie
embed = discord.Embed(
title=f"🎲 Random Movie: {random_movie['title']} ({random_movie['year'] if random_movie.get('year') else 'N/A'})",
description=random_movie.get("plot", "No plot available.") if len(random_movie.get("plot", "")) < 300 else random_movie.get("plot", "")[:297] + "...",
color=0x00ff00
)
if random_movie.get("director"):
embed.add_field(name="Director", value=random_movie["director"], inline=True)
if random_movie.get("actors") and len(random_movie["actors"]) > 0:
embed.add_field(name="Starring", value=", ".join(random_movie["actors"][:3]), inline=True)
if random_movie.get("imdbRating"):
embed.add_field(name="Rating", value=f"⭐ {random_movie['imdbRating']}/10", inline=True)
embed.add_field(name="Votes", value=f"👍 {random_movie.get('votes', 0)}", inline=True)
if random_movie.get("poster"):
embed.set_thumbnail(url=random_movie["poster"])
embed.set_footer(text=f"Source: {random_movie.get('source', 'unknown').upper()} | Vote with: /vote {random_movie['title']}")
await interaction.followup.send(embed=embed)
except Exception as e:
logger.error(f"Error in random command: {e}")
await interaction.followup.send(f"❌ An error occurred: {str(e)}")
@tree.command(name="help", description="Show help for Paradiso commands")
async def cmd_help(interaction: discord.Interaction):
"""Show help for Paradiso commands."""
embed = discord.Embed(
title="Paradiso Bot Help",
description="Here are the commands you can use with the Paradiso movie voting bot:",
color=0x03a9f4
)
commands = [
{
"name": "/movies",
"description": "List all movies in the voting queue"
},
{
"name": "/add [title]",
"description": "Add a movie to the voting queue"
},
{
"name": "/vote [title]",
"description": "Vote for a movie in the queue"
},
{
"name": "/remove [title]",
"description": "Remove a movie from the voting queue (admin only)"
},
{
"name": "/top [count]",
"description": "Show the top voted movies (default: top 5)"
},
{
"name": "/random",
"description": "Suggest a random movie from the list"
}
]
for cmd in commands:
embed.add_field(name=cmd["name"], value=cmd["description"], inline=False)
embed.set_footer(text="Happy voting! 🎬")
await interaction.response.send_message(embed=embed)
if __name__ == "__main__":
client.run(DISCORD_TOKEN)
\ No newline at end of file
import React, { useState, useEffect } from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import algoliasearch from 'algoliasearch';
import { InstantSearch, SearchBox, Hits, Configure } from 'react-instantsearch-dom';
import Image from 'next/image';
import Layout from '@/components/layout';
import utilStyles from '@/styles/utils.module.css';
import styles from '@/styles/paradiso.module.css';
// Initialize the Algolia client
// TODO CONFIRM .env IS LOADED
const searchClient = algoliasearch(
process.env.NEXT_PUBLIC_ALGOLIA_APP_ID,
process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY
);
// Unique user token for this session - in a real app, this would be tied to user authentication
const getUserToken = () => {
if (typeof window === 'undefined') return null;
let userToken = localStorage.getItem('paradiso_user_token');
if (!userToken) {
// Generate a random user token
userToken = 'user_' + Math.random().toString(36).substring(2, 15);
localStorage.setItem('paradiso_user_token', userToken);
}
return userToken;
};
// Movie Hit component - Displays a single movie
const MovieHit = ({ hit, onVote }) => {
return (
<div className={styles.movieCard}>
{hit.poster ? (
<div className={styles.moviePoster}>
<Image
src={hit.poster}
alt={hit.title}
width={200}
height={300}
layout="responsive"
/>
</div>
) : (
<div className={styles.noImagePlaceholder}>No Image</div>
)}
<div className={styles.movieInfo}>
<h3 className={styles.movieTitle}>
{hit.title} {hit.year && <span className={styles.movieYear}>({hit.year})</span>}
</h3>
{hit.director && (
<p className={styles.movieDirector}>Director: {hit.director}</p>
)}
{hit.actors && hit.actors.length > 0 && (
<p className={styles.movieActors}>Starring: {hit.actors.join(', ')}</p>
)}
{hit.plot && (
<p className={styles.moviePlot}>{hit.plot}</p>
)}
<div className={styles.movieMeta}>
{hit.imdbRating && (
<span className={styles.movieRating}> {hit.imdbRating}/10</span>
)}
{hit.source && (
<span className={styles.movieSource}>Source: {hit.source.toUpperCase()}</span>
)}
</div>
<div className={styles.movieActions}>
<button
onClick={() => onVote(hit.objectID)}
className={styles.voteButton}
>
👍 Vote ({hit.votes || 0})
</button>
</div>
</div>
</div>
);
};
// Empty results component
const EmptyResults = () => (
<div className={styles.emptyResults}>
<h3>No movies found</h3>
<p>Try a different search or add a new movie below</p>
</div>
);
export default function Paradiso() {
const router = useRouter();
const [userToken, setUserToken] = useState(null);
const [isSearching, setIsSearching] = useState(false);
const [newMovieTitle, setNewMovieTitle] = useState('');
const [isAdding, setIsAdding] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
// Set user token on client-side
useEffect(() => {
setUserToken(getUserToken());
}, []);
// Function to vote for a movie
const handleVote = async (movieId) => {
if (!userToken) return;
try {
// Use Algolia's partial update to increment the votes counter
const response = await fetch('/api/paradiso/vote', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
movieId,
userToken,
}),
});
if (!response.ok) {
throw new Error('Failed to vote for movie');
}
setSuccess('Vote recorded successfully!');
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
console.error('Error voting for movie:', err);
setError('Failed to vote for movie. Please try again.');
setTimeout(() => setError(null), 5000);
}
};
// Function to add a new movie
const handleAddMovie = async (e) => {
e.preventDefault();
if (!newMovieTitle.trim() || !userToken) return;
setIsAdding(true);
setError(null);
try {
// Add the movie directly by title
const addResponse = await fetch('/api/paradiso/add-movie', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: newMovieTitle,
userToken,
}),
});
if (!addResponse.ok) {
const errorData = await addResponse.json();
throw new Error(errorData.error || 'Failed to add movie');
}
setNewMovieTitle('');
setSuccess('Movie added successfully!');
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
console.error('Error adding movie:', err);
setError(err.message || 'Failed to add movie. Please try again.');
setTimeout(() => setError(null), 5000);
} finally {
setIsAdding(false);
}
};
return (
<Layout>
<Head>
<title>Paradiso - Movie Night Voting</title>
<meta name="description" content="Vote for the next movie night film" />
</Head>
<div className={styles.container}>
<h1 className={styles.title}>🎬 Paradiso</h1>
<p className={styles.subtitle}>Vote pour <b>notre</b> film et écris l'avenir du <i>Cinéma Paradiso</i>.</p>
{/* Error and success messages */}
{error && <div className={styles.errorMessage}>{error}</div>}
{success && <div className={styles.successMessage}>{success}</div>}
{/* InstantSearch component */}
{userToken && (
<InstantSearch
searchClient={searchClient}
indexName={process.env.NEXT_PUBLIC_ALGOLIA_INDEX}
>
<div className={styles.searchContainer}>
<SearchBox
className={styles.searchBox}
translations={{
placeholder: 'Search for movies...',
}}
onFocus={() => setIsSearching(true)}
onBlur={() => setTimeout(() => setIsSearching(false), 200)}
/>
<Configure
hitsPerPage={12}
distinct={true}
/>
{isSearching && (
<div className={styles.searchResults}>
<Hits
hitComponent={({ hit }) => (
<MovieHit hit={hit} onVote={handleVote} />
)}
classNames={{
list: styles.hitsList,
item: styles.hitItem,
empty: styles.noResults,
}}
emptyComponent={EmptyResults}
/>
</div>
)}
</div>
{/* Add movie form */}
<div className={styles.addMovieSection}>
<h2>Can't find the movie? Add it</h2>
<form onSubmit={handleAddMovie} className={styles.addMovieForm}>
<input
type="text"
value={newMovieTitle}
onChange={(e) => setNewMovieTitle(e.target.value)}
placeholder="Enter movie title..."
disabled={isAdding}
required
/>
<button
type="submit"
disabled={isAdding || !newMovieTitle.trim()}
>
{isAdding ? 'Adding...' : 'Add Movie'}
</button>
</form>
<p className={styles.infoText}>
Movie data is fetched from TMDB, OMDB, or Wikipedia as a fallback
</p>
</div>
{/* Top voted movies */}
<div className={styles.topMoviesSection}>
<h2>Top Voted Movies</h2>
<Configure
hitsPerPage={5}
filters="votes>0"
sortCriteria={['votes:desc', 'title:asc']}
/>
<Hits
hitComponent={({ hit }) => (
<MovieHit hit={hit} onVote={handleVote} />
)}
classNames={{
list: styles.hitsList,
item: styles.hitItem,
empty: styles.noResults,
}}
/>
</div>
</InstantSearch>
)}
</div>
<style jsx>{`
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.title {
font-size: 2.5rem;
text-align: center;
margin-bottom: 1rem;
}
.subtitle {
font-size: 1.2rem;
text-align: center;
margin-bottom: 2rem;
color: #666;
}
`}</style>
</Layout>
);
}
\ No newline at end of file
#!/usr/bin/env python
"""
Paradiso Setup Script
This script sets up Algolia indices for the Paradiso movie voting system and
generates secured API keys for the frontend and Discord bot.
Usage:
python setup.py --admin-key YOUR_ADMIN_API_KEY --app-id YOUR_APP_ID
Requirements:
- Python 3.7+
- algoliasearch package (pip install algoliasearch)
"""
import argparse
import json
import time
import hashlib
import base64
import urllib.parse
import os
from datetime import datetime, timedelta
from algoliasearch.search_client import SearchClient
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(description='Setup Algolia for Paradiso movie voting system')
parser.add_argument('--admin-key', required=True, help='Algolia Admin API Key')
parser.add_argument('--app-id', required=True, help='Algolia Application ID')
parser.add_argument('--movies-file', default='../../data/movies.json', help='Path to movies JSON file')
parser.add_argument('--actors-file', default='../../data/actors.json', help='Path to actors JSON file')
parser.add_argument('--use-sample-data', action='store_true', help='Use sample data instead of JSON files')
return parser.parse_args()
def create_indices(client, index_prefix):
"""Create and configure indices for the movie voting system."""
# Create main movies index
movies_index_name = f"{index_prefix}_movies"
movies_index = client.init_index(movies_index_name)
# Configure movies index settings
movies_settings = {
# Searchable attributes (in order of importance)
"searchableAttributes": [
"title",
"originalTitle",
"director",
"actors",
"year",
"plot"
],
# Attributes for faceting
"attributesForFaceting": [
"searchable(genre)",
"year",
"voted"
],
# Custom ranking to prioritize more voted movies
"customRanking": [
"desc(votes)",
"desc(year)"
],
# Highlighting and snippeting configuration
"highlightPreTag": "<em>",
"highlightPostTag": "</em>",
# Pagination settings
"hitsPerPage": 20,
# Enable typo tolerance
"minWordSizefor1Typo": 3,
"minWordSizefor2Typos": 6,
# Restrict search to specific query parameters
"queryType": "prefixAll",
# Advanced settings
"removeStopWords": True,
"ignorePlurals": True,
# Disable A/B testing
"enablePersonalization": False,
# Define distinct property
"distinct": True,
"attributeForDistinct": "objectID"
}
# Apply settings to the index
movies_index.set_settings(movies_settings)
print(f"✅ Created and configured {movies_index_name} index")
# Create votes index for storing user votes
votes_index_name = f"{index_prefix}_votes"
votes_index = client.init_index(votes_index_name)
# Configure votes index settings
votes_settings = {
"searchableAttributes": [
"userToken",
"movieId"
],
"attributesForFaceting": [
"userToken",
"movieId"
],
# Simple ranking based on when the vote was cast
"customRanking": [
"desc(timestamp)"
],
"hitsPerPage": 100
}
# Apply settings to the votes index
votes_index.set_settings(votes_settings)
print(f"✅ Created and configured {votes_index_name} index")
# Create actors index
actors_index_name = f"{index_prefix}_actors"
actors_index = client.init_index(actors_index_name)
# Configure actors index settings
actors_settings = {
"searchableAttributes": [
"name",
"alternative_name"
],
"attributesForFaceting": [
"rating"
],
"customRanking": [
"desc(rating)"
],
"highlightPreTag": "<em>",
"highlightPostTag": "</em>",
"hitsPerPage": 20
}
# Apply settings to the actors index
actors_index.set_settings(actors_settings)
print(f"✅ Created and configured {actors_index_name} index")
# Return the configured index names
return {
"movies": movies_index_name,
"votes": votes_index_name,
"actors": actors_index_name
}
def generate_secured_api_key(admin_key, restrictions):
"""
Generate a secured API key with the given restrictions.
This is done manually instead of using the client to ensure compatibility.
"""
# Convert the restrictions to a string
restrictions_str = json.dumps(restrictions)
# Create the message to sign
message = admin_key.encode() + restrictions_str.encode()
# Generate the signature
hash_obj = hashlib.sha256(message).digest()
# Encode the signature in base64
signature = base64.b64encode(hash_obj).decode()
# URL encode the restrictions for the key
url_encoded_restrictions = urllib.parse.quote(restrictions_str)
# Create the secured API key
secured_key = signature + url_encoded_restrictions
return secured_key
# DOCS EXAMPLE
# Create new API Key with specific restrictions
#
# Copy
# # Create a new restricted search-only API key
# params = {
# 'description': 'Restricted search-only API key for algolia.com',
# # Allow searching only in indices with names starting with `dev_*`
# 'indexes': ['dev_*'],
# # Retrieve up to 20 results per search query
# 'maxHitsPerQuery': 20,
# # Rate-limit to 100 requests per hour per IP address
# 'maxQueriesPerIPPerHour': 100,
# # Add fixed query parameters to every search request
# 'queryParameters': 'ignorePlurals=false',
# # Only allow searches from the `algolia.com` domain
# 'referers': ['algolia.com/*'],
# # This API key expires after 300 seconds (5 minutes)
# 'validity': 300,
# }
# acl = ['search']
# res = client.add_api_key(acl, params)
# print(res["key"])
# CURRENT ERROR
# Params: {'description': 'Paradiso search-only API key', 'acl': ['search'], 'indexes': ['paradiso_movies', 'paradiso_votes', 'paradiso_actors'], 'maxQueriesPerIPPerHour': 100, 'maxHitsPerQuery': 50, 'validity': 0}
# Traceback (most recent call last):
# File "/home/pln/Work/Web/www/next/pages/paradiso/setup.py", line 496, in <module>
# main()
# File "/home/pln/Work/Web/www/next/pages/paradiso/setup.py", line 461, in main
# keys = create_api_keys(client, indices)
# File "/home/pln/Work/Web/www/next/pages/paradiso/setup.py", line 182, in create_api_keys
# search_key = client.add_api_key(search_key_params)
# File "/home/pln/.virtualenvs/paradiso/lib/python3.10/site-packages/algoliasearch/search_client.py", line 238, in add_api_key
# raw_response = self._transporter.write(
# File "/home/pln/.virtualenvs/paradiso/lib/python3.10/site-packages/algoliasearch/http/transporter.py", line 35, in write
# return self.request(verb, hosts, path, data, request_options, timeout)
# File "/home/pln/.virtualenvs/paradiso/lib/python3.10/site-packages/algoliasearch/http/transporter.py", line 72, in request
# return self.retry(hosts, request, relative_url)
# File "/home/pln/.virtualenvs/paradiso/lib/python3.10/site-packages/algoliasearch/http/transporter.py", line 91, in retry
# raise RequestException(content, response.status_code)
# algoliasearch.exceptions.RequestException: Expecting an array (near 1:10)
def create_api_keys(client, indices):
"""Create and configure API keys for the movie voting system."""
# Create a search-only API key with rate limiting
search_key_params = {
"description": "Paradiso search-only API key",
"indexes": list(indices.values()),
"maxQueriesPerIPPerHour": 100,
"maxHitsPerQuery": 50,
"validity": 0 # No expiration
}
print("Params:", search_key_params)
search_acl = ["search"]
search_key = client.add_api_key(search_acl, search_key_params)
print(f"✅ Created search-only API key: {search_key['key']}")
# Create an API key for frontend with limited permissions
frontend_key_params = {
"description": "Paradiso frontend API key",
"indexes": list(indices.values()),
"maxQueriesPerIPPerHour": 100,
"maxHitsPerQuery": 50,
"validity": 0 # No expiration
}
frontend_acl = ["search", "browse", "addObject"]
frontend_key = client.add_api_key(frontend_acl, frontend_key_params)
print(f"✅ Created frontend API key: {frontend_key['key']}")
# Create an API key for Discord bot with more permissions
bot_key_params = {
"description": "Paradiso Discord bot API key",
"indexes": list(indices.values()),
"maxQueriesPerIPPerHour": 1000,
"maxHitsPerQuery": 100,
"validity": 0 # No expiration
}
bot_acl = ["search", "browse", "addObject", "deleteObject", "settings"]
bot_key = client.add_api_key(bot_acl, bot_key_params)
print(f"✅ Created Discord bot API key: {bot_key['key']}")
# Generate secured API keys with different restrictions
# For frontend: limited to movies index with increment operation for votes
frontend_restrictions = {
"restrictIndices": [indices["movies"], indices["actors"]],
# Valid for 1 year (adjust as needed)
"validUntil": int(time.time() + 365 * 24 * 60 * 60)
}
frontend_secured_key = generate_secured_api_key(
frontend_key["key"],
frontend_restrictions
)
print(f"✅ Generated secured frontend API key")
# For Discord bot: access to both indices
bot_restrictions = {
"restrictIndices": list(indices.values()),
# Valid for 1 year (adjust as needed)
"validUntil": int(time.time() + 365 * 24 * 60 * 60)
}
bot_secured_key = generate_secured_api_key(
bot_key["key"],
bot_restrictions
)
print(f"✅ Generated secured Discord bot API key")
# Return all the keys
return {
"search_key": search_key["key"],
"frontend_key": frontend_key["key"],
"frontend_secured_key": frontend_secured_key,
"bot_key": bot_key["key"],
"bot_secured_key": bot_secured_key
}
def add_sample_data(client, indices):
"""Add some sample data to the movies index."""
movies_index = client.init_index(indices["movies"])
# Sample movies data
sample_movies = [
{
"objectID": "tt0068646",
"title": "The Godfather",
"originalTitle": "The Godfather",
"year": 1972,
"director": "Francis Ford Coppola",
"actors": ["Marlon Brando", "Al Pacino", "James Caan"],
"genre": ["Crime", "Drama"],
"plot": "The aging patriarch of an organized crime dynasty transfers control of his clandestine empire to his reluctant son.",
"poster": "https://m.media-amazon.com/images/M/MV5BM2MyNjYxNmUtYTAwNi00MTYxLWJmNWYtYzZlODY3ZTk3OTFlXkEyXkFqcGdeQXVyNzkwMjQ5NzM@._V1_SX300.jpg",
"votes": 3,
"addedDate": int(time.time()),
"addedBy": "setup_script",
"imdbRating": 9.2,
"imdbID": "tt0068646",
"tmdbID": "238"
},
{
"objectID": "tt0111161",
"title": "The Shawshank Redemption",
"originalTitle": "The Shawshank Redemption",
"year": 1994,
"director": "Frank Darabont",
"actors": ["Tim Robbins", "Morgan Freeman", "Bob Gunton"],
"genre": ["Drama"],
"plot": "Two imprisoned men bond over a number of years, finding solace and eventual redemption through acts of common decency.",
"poster": "https://m.media-amazon.com/images/M/MV5BMDFkYTc0MGEtZmNhMC00ZDIzLWFmNTEtODM1ZmRlYWMwMWFmXkEyXkFqcGdeQXVyMTMxODk2OTU@._V1_SX300.jpg",
"votes": 5,
"addedDate": int(time.time()),
"addedBy": "setup_script",
"imdbRating": 9.3,
"imdbID": "tt0111161",
"tmdbID": "278"
},
{
"objectID": "tt0468569",
"title": "The Dark Knight",
"originalTitle": "The Dark Knight",
"year": 2008,
"director": "Christopher Nolan",
"actors": ["Christian Bale", "Heath Ledger", "Aaron Eckhart"],
"genre": ["Action", "Crime", "Drama"],
"plot": "When the menace known as the Joker wreaks havoc and chaos on the people of Gotham, Batman must accept one of the greatest psychological and physical tests of his ability to fight injustice.",
"poster": "https://m.media-amazon.com/images/M/MV5BMTMxNTMwODM0NF5BMl5BanBnXkFtZTcwODAyMTk2Mw@@._V1_SX300.jpg",
"votes": 2,
"addedDate": int(time.time()),
"addedBy": "setup_script",
"imdbRating": 9.0,
"imdbID": "tt0468569",
"tmdbID": "155"
}
]
# Add sample movies to the index
movies_index.save_objects(sample_movies)
print(f"✅ Added {len(sample_movies)} sample movies to {indices['movies']} index")
def load_from_json_files(client, indices, movies_file, actors_file):
"""Load data from JSON files into Algolia indices."""
movies_index = client.init_index(indices["movies"])
actors_index = client.init_index(indices["actors"])
# Load movies data
try:
with open(movies_file, 'r', encoding='utf-8') as f:
movies_data = json.load(f)
# Transform movies data to match our schema
formatted_movies = []
for movie in movies_data:
# Skip movies without required fields
if not movie.get('title') or not movie.get('id'):
continue
# Generate a unique objectID
object_id = movie.get('imdb_id') or f"tmdb_{movie.get('id')}"
# Format the movie data
formatted_movie = {
"objectID": object_id,
"title": movie.get('title', ''),
"originalTitle": movie.get('original_title', movie.get('title', '')),
"year": movie.get('release_date', '')[:4] if movie.get('release_date') else None,
"director": movie.get('director', 'Unknown'),
"actors": movie.get('actors', []),
"genre": [genre['name'] for genre in movie.get('genres', [])] if movie.get('genres') else [],
"plot": movie.get('overview', ''),
"poster": f"https://image.tmdb.org/t/p/w500{movie.get('poster_path')}" if movie.get('poster_path') else None,
"votes": 0,
"addedDate": int(time.time()),
"addedBy": "setup_script",
"imdbRating": movie.get('vote_average', 0),
"imdbID": movie.get('imdb_id', ''),
"tmdbID": str(movie.get('id', '')),
"source": "tmdb"
}
formatted_movies.append(formatted_movie)
# Save movies in batches to avoid hitting Algolia limits
batch_size = 1000
for i in range(0, len(formatted_movies), batch_size):
batch = formatted_movies[i:i+batch_size]
movies_index.save_objects(batch)
print(f"✅ Added batch {i//batch_size + 1}/{(len(formatted_movies) + batch_size - 1)//batch_size} of movies to {indices['movies']} index")
print(f"✅ Added {len(formatted_movies)} movies from {movies_file} to {indices['movies']} index")
except Exception as e:
print(f"❌ Error loading movies from {movies_file}: {e}")
# Load actors data
try:
with open(actors_file, 'r', encoding='utf-8') as f:
actors_data = json.load(f)
# Save actors in batches to avoid hitting Algolia limits
batch_size = 1000
for i in range(0, len(actors_data), batch_size):
batch = actors_data[i:i+batch_size]
actors_index.save_objects(batch)
print(f"✅ Added batch {i//batch_size + 1}/{(len(actors_data) + batch_size - 1)//batch_size} of actors to {indices['actors']} index")
print(f"✅ Added {len(actors_data)} actors from {actors_file} to {indices['actors']} index")
except Exception as e:
print(f"❌ Error loading actors from {actors_file}: {e}")
def save_config(app_id, indices, keys):
"""Save the configuration to a local file."""
config = {
"app_id": app_id,
"indices": indices,
"keys": keys,
"created_at": datetime.now().isoformat(),
"expires_at": (datetime.now() + timedelta(days=365)).isoformat()
}
# Save to a file
with open("paradiso_config.json", "w") as f:
json.dump(config, f, indent=2)
print(f"✅ Saved configuration to paradiso_config.json")
# Also create environment files for frontend and bot
with open(".env.frontend", "w") as f:
f.write(f"NEXT_PUBLIC_ALGOLIA_APP_ID={app_id}\n")
f.write(f"NEXT_PUBLIC_ALGOLIA_SEARCH_KEY={keys['search_key']}\n")
f.write(f"NEXT_PUBLIC_ALGOLIA_INDEX={indices['movies']}\n")
f.write(f"NEXT_PUBLIC_ALGOLIA_ACTORS_INDEX={indices['actors']}\n")
f.write(f"ALGOLIA_SECURED_KEY={keys['frontend_secured_key']}\n")
f.write(f"OMDB_API_KEY=YOUR_OMDB_API_KEY\n")
f.write(f"TMDB_API_KEY=YOUR_TMDB_API_KEY\n")
f.write(f"MOVIE_DATA_SOURCE=tmdb\n")
print(f"✅ Saved frontend environment to .env.frontend")
with open(".env.bot", "w") as f:
f.write(f"ALGOLIA_APP_ID={app_id}\n")
f.write(f"ALGOLIA_API_KEY={keys['bot_secured_key']}\n")
f.write(f"ALGOLIA_MOVIES_INDEX={indices['movies']}\n")
f.write(f"ALGOLIA_VOTES_INDEX={indices['votes']}\n")
f.write(f"ALGOLIA_ACTORS_INDEX={indices['actors']}\n")
f.write(f"DISCORD_TOKEN=YOUR_DISCORD_BOT_TOKEN\n")
f.write(f"OMDB_API_KEY=YOUR_OMDB_API_KEY\n")
f.write(f"TMDB_API_KEY=YOUR_TMDB_API_KEY\n")
f.write(f"MOVIE_DATA_SOURCE=tmdb\n")
print(f"✅ Saved Discord bot environment to .env.bot")
def get_api_key_instructions():
"""Instructions for obtaining API keys."""
print("\n== API Key Instructions ==")
print("For movie data, we're using both TMDB and OMDB APIs with fallback support.")
print("\n=== TMDB API Key ===")
print("To get a free TMDB API key:")
print("1. Visit https://www.themoviedb.org/signup")
print("2. Create an account and verify your email")
print("3. Go to https://www.themoviedb.org/settings/api")
print("4. Request an API key for a developer application")
print("5. Fill out the form and submit")
print("6. Update the .env.frontend and .env.bot files with your TMDB API key")
print("\n=== OMDB API Key ===")
print("To get a free OMDB API key:")
print("1. Visit https://www.omdbapi.com/apikey.aspx")
print("2. Sign up for a FREE API key (allows up to 1,000 daily requests)")
print("3. Check your email and activate your key")
print("4. Update the .env.frontend and .env.bot files with your OMDB API key")
print("\nWhen you get your keys, replace 'YOUR_TMDB_API_KEY' and 'YOUR_OMDB_API_KEY' in the .env files with your actual keys.")
def main():
"""Main setup function."""
args = parse_args()
print("\n== Paradiso Algolia Setup ==")
print(f"Setting up Algolia for Paradiso movie voting system...")
# Initialize the Algolia client
client = SearchClient.create(args.app_id, args.admin_key)
# Create a unique prefix for the indices
index_prefix = "paradiso"
# Create and configure indices
indices = create_indices(client, index_prefix)
# Create and configure API keys
keys = create_api_keys(client, indices)
# Add data
if args.use_sample_data:
add_sample_data(client, indices)
else:
# Resolve paths relative to the script location
script_dir = os.path.dirname(os.path.abspath(__file__))
movies_path = os.path.join(script_dir, args.movies_file)
actors_path = os.path.join(script_dir, args.actors_file)
print(f"Loading data from JSON files:")
print(f"- Movies: {movies_path}")
print(f"- Actors: {actors_path}")
load_from_json_files(client, indices, movies_path, actors_path)
# Save the configuration
save_config(args.app_id, indices, keys)
# Instructions for API keys
get_api_key_instructions()
print("\n== Setup Complete ==")
print("Your Algolia-powered Paradiso movie voting system is now set up!")
print("Keep paradiso_config.json in a secure location, as it contains your API keys.")
print("Add the environment variables from .env.frontend to your Next.js project.")
print("Add the environment variables from .env.bot to your Discord bot project.")
print("\nNotes:")
print("- The secured API keys are valid for 1 year. After that, you'll need to generate new ones.")
print("- Rate limits are set to 100 queries per hour for frontend and 1000 for the bot.")
print("- TMDB API has a limit of 1,000 requests per day with the free key.")
print("- OMDB API has a limit of 1,000 requests per day with the free key.")
if __name__ == "__main__":
main()
\ No newline at end of file
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.title {
font-size: 2.5rem;
margin-bottom: 1rem;
color: #333;
}
.subtitle {
font-size: 1.5rem;
margin-bottom: 2rem;
color: #666;
}
.searchContainer {
background-color: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin-bottom: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.searchBar {
display: flex;
margin-bottom: 20px;
}
.searchBar input {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px 0 0 4px;
font-size: 16px;
}
.searchBar button {
background-color: #2196F3;
color: white;
border: none;
padding: 12px 20px;
border-radius: 0 4px 4px 0;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
.searchBar button:hover {
background-color: #0b7dda;
}
.searchBar button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.movieGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
margin-top: 20px;
}
.movieCard {
position: relative;
border-radius: 8px;
overflow: hidden;
transition: transform 0.3s ease;
background-color: #1a1a1a;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.movieCard:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
}
.posterContainer {
position: relative;
width: 100%;
height: 270px;
overflow: hidden;
}
.poster {
width: 100%;
height: 100%;
object-fit: cover;
}
.noPoster {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #2a2a2a;
color: #999;
text-align: center;
padding: 10px;
}
.movieOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0.9) 100%);
color: white;
padding: 15px;
display: flex;
flex-direction: column;
opacity: 0;
transition: opacity 0.3s ease;
}
.movieCard:hover .movieOverlay {
opacity: 1;
}
.movieTitle {
font-size: 16px;
font-weight: 600;
margin: 0 0 5px 0;
}
.movieYear {
font-size: 14px;
color: #ccc;
margin-bottom: 5px;
}
.movieRating {
font-size: 14px;
color: #ffcc00;
margin-bottom: 5px;
}
.movieGenres {
font-size: 12px;
color: #aaa;
margin-bottom: 10px;
}
.moviePlot {
font-size: 12px;
line-height: 1.4;
color: #ddd;
flex-grow: 1;
overflow: hidden;
}
.movieActions {
margin-top: auto;
}
.voteButton {
background-color: #e50914;
color: white;
border: none;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s ease;
width: 100%;
}
.voteButton:hover {
background-color: #f40612;
}
.voteButton:disabled {
background-color: #666;
cursor: not-allowed;
}
.movieInfo {
padding: 10px;
}
.movieInfo h4 {
margin: 0;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.movieMeta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #999;
margin-top: 5px;
}
.votes {
color: #e50914;
}
.searchSection {
margin-bottom: 30px;
}
.searchForm {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.searchInput {
flex-grow: 1;
padding: 12px;
border-radius: 4px;
border: 1px solid #333;
background-color: #222;
color: white;
}
.searchButton {
background-color: #e50914;
color: white;
border: none;
border-radius: 4px;
padding: 0 20px;
cursor: pointer;
}
.filterSection {
display: flex;
gap: 15px;
margin-top: 10px;
}
.filterButton {
background-color: transparent;
border: 1px solid #333;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
}
.winner {
position: relative;
}
.winnerBadge {
position: absolute;
top: -10px;
right: -10px;
background-color: gold;
color: black;
padding: 5px 10px;
border-radius: 20px;
font-weight: bold;
z-index: 1;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.manualAdd {
margin-top: 20px;
}
.manualAdd form {
display: flex;
}
.manualAdd input {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px 0 0 4px;
font-size: 16px;
}
.manualAdd button {
background-color: #2196F3;
color: white;
border: none;
padding: 12px 20px;
border-radius: 0 4px 4px 0;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
.manualAdd button:hover {
background-color: #0b7dda;
}
.manualAdd button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.searchResults {
margin: 20px 0;
}
.noResults {
text-align: center;
padding: 30px;
background-color: #f9f9f9;
border-radius: 4px;
color: #666;
}
/* Responsive styles */
@media (max-width: 768px) {
.movieGrid {
grid-template-columns: 1fr;
}
.searchBar {
flex-direction: column;
}
.searchBar input {
border-radius: 4px;
margin-bottom: 10px;
}
.searchBar button {
border-radius: 4px;
}
.manualAdd form {
flex-direction: column;
}
.manualAdd input {
border-radius: 4px;
margin-bottom: 10px;
}
.manualAdd button {
border-radius: 4px;
}
}
\ No newline at end of file
...@@ -365,7 +365,6 @@ ...@@ -365,7 +365,6 @@
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
} }
/* Hover Popup Styles */ /* Hover Popup Styles */
.hoverReveal { .hoverReveal {
position: relative; position: relative;
...@@ -480,6 +479,8 @@ ...@@ -480,6 +479,8 @@
min-width: 380px; min-width: 380px;
max-width: 500px; max-width: 500px;
width: max-content;
max-width: calc(100vw - 40px); /* Ensure popup doesn't exceed viewport width */
animation: fadeIn 0.2s ease-in-out; animation: fadeIn 0.2s ease-in-out;
} }
...@@ -573,12 +574,25 @@ ...@@ -573,12 +574,25 @@
/* Mobile responsiveness */ /* Mobile responsiveness */
@media (max-width: 768px) { @media (max-width: 768px) {
.hoverPopup { .hoverPopup {
min-width: 280px; min-width: auto;
max-width: 340px; max-width: calc(100vw - 40px); /* 20px margin on each side */
width: max-content;
left: 50%;
transform: translateX(-50%);
} }
.popupContent { .popupContent {
padding: 0.8rem; padding: 0.8rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
.popupLinks {
flex-direction: column;
align-items: stretch;
}
.popupLinks a {
text-align: center;
width: 100%;
}
} }
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment