Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
756 changes: 379 additions & 377 deletions Pipfile.lock

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions mediabridge-frontend/src/api/movie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,15 @@ export const searchMovies = async (query: string) => {
});
return response.data;
};

export const getRecommendations = async (movies: string[]) => {
const response = await axios.get(`${API_ENDPOINT}/v1/movie/recommend`, {
params: { movies },
});
return response.data;
};

export const getMovieById = async (id: string) => {
const response = await axios.get(`${API_ENDPOINT}/v1/movie/${id}`);
return response.data;
};
31 changes: 16 additions & 15 deletions mediabridge-frontend/src/components/MovieItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@ import {
} from "@/components/ui/card";
import { Movie } from "@/types/Movie";

const MovieItem = ({
movie,
onRemove,
}: {
type Props = {
movie: Movie;
onRemove: () => void;
}) => {
onRemove?: () => void;
};

const MovieItem = ({ movie, onRemove }: Props) => {
return (
<Card
className="flex flex-col items-center mx-2 w-64 text-center break-words"
Expand All @@ -22,14 +21,16 @@ const MovieItem = ({
<CardHeader className="w-full px-4">
<div className="flex justify-between items-center w-full">
<CardTitle className="text-base text-left">{movie.title}</CardTitle>
<button
onClick={onRemove}
className="w-6 h-6 rounded-full bg-red-500 text-white text-xs flex items-center justify-center hover:bg-red-600 transition-colors"
aria-label="Remove movie"
id={`remove-movie`}
>
</button>
{onRemove && (
<button
onClick={onRemove}
className="w-6 h-6 rounded-full bg-red-500 text-white text-xs flex items-center justify-center hover:bg-red-600 transition-colors"
aria-label="Remove movie"
id={`remove-movie`}
>
</button>
)}
</div>
</CardHeader>
<CardContent>
Expand All @@ -44,4 +45,4 @@ const MovieItem = ({
);
};

export default MovieItem;
export default MovieItem;
17 changes: 10 additions & 7 deletions mediabridge-frontend/src/components/MovieList.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { Movie } from "../types/Movie";
import MovieItem from "./MovieItem";

const MovieList = ({
movies,
removeMovie,
}: {
type Props = {
movies: Movie[];
removeMovie: (id: string) => void;
}) => {
removeMovie?: (id: string) => void;
};

const MovieList = ({ movies, removeMovie }: Props) => {
return (
<div className="flex flex-wrap justify-center gap-4 w-full">
{movies.map((movie) => (
<MovieItem movie={movie} key={movie.id} onRemove={() => removeMovie(movie.id)} />
<MovieItem
movie={movie}
key={movie.id}
{...(removeMovie ? { onRemove: () => removeMovie(movie.id) } : {})}
/>
))}
</div>
);
Expand Down
46 changes: 30 additions & 16 deletions mediabridge-frontend/src/components/MovieSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,61 +9,75 @@ import {
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import SearchBar from "@/components/ui/searchbar";
import { Movie } from "../types/Movie";
import { searchMovies } from "@/api/movie";

import { Movie } from "../types/Movie";
import { getRecommendations, searchMovies, getMovieById } from "@/api/movie";

type Props = {
movies: Movie[];
addMovie: (movie: Movie) => void;
setRecommendations: (movies: Movie[]) => void;
};

const isMoviePresent = (movies: Movie[], title: string) => {
return movies.some((movie) => movie.title.toLowerCase() === title.toLowerCase());
};

const MovieSearch = ({ movies, addMovie }: Props) => {
const MovieSearch = ({ movies, addMovie, setRecommendations }: Props) => {
const [title, setTitle] = useState("");
const [warning, setWarning] = useState("");
const [suggestions, setSuggestions] = useState<Movie[]>([]);


const handleAddMovie = async (selectedMovie?: Movie) => {
const movieToAdd = selectedMovie ?? null;

if (!title.trim() && !movieToAdd) return;

try {
const data = await searchMovies(title);

const foundMovie =
movieToAdd ||
data.find((movie: Movie) => movie.title === title);

if (!foundMovie) {
setWarning(`${title} not found. Please check your spelling.`);
setWarning(`'${title}' not found. Please check your spelling.`);
return;
}

if (isMoviePresent(movies, foundMovie.title)) {
setWarning(`${foundMovie.title} already added.`);
setWarning(`'${foundMovie.title}' already added.`);
return;
}

addMovie({
id: foundMovie.id.toString(),
title: foundMovie.title,
year: foundMovie.year,
image: `https://picsum.photos/seed/${foundMovie.id}/200/300`,
});

setWarning("");
setTitle("");
} catch (error) {
setWarning("Failed to search for movie: database might be down");
console.error("Error searching for movie:", error);
}
};

const handleRecommendations = async () => {
const movieIds = movies.map((movie: Movie) => movie.id);
const data = await getRecommendations(movieIds);
const recommendedMovies = (
await Promise.all(
data.recommendations.map(async (id: string) => {
return await getMovieById(id);
})
)
);
recommendedMovies.map(movie => movie.image = `https://picsum.photos/seed/${movie.id}/200/300`)
setRecommendations(recommendedMovies);
};

return (
<Card className="w-full max-w-md flex flex-col items-center">
<CardHeader className="flex flex-col items-center text-center">
Expand All @@ -90,10 +104,10 @@ const MovieSearch = ({ movies, addMovie }: Props) => {
</div>
</CardContent>
<CardFooter>
<Button type="submit">Get Recommendations</Button>
<Button type="submit" onClick={handleRecommendations}>Get Recommendations</Button>
</CardFooter>
</Card>
);
};

export default MovieSearch;
export default MovieSearch;
18 changes: 15 additions & 3 deletions mediabridge-frontend/src/pages/SelectMovies.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import MovieList from "../components/MovieList";
import MovieSearch from "../components/MovieSearch";
import { Movie } from "../types/Movie.ts";
import { useState } from "react";

const SelectMovies = ({
movies,
Expand All @@ -11,21 +12,32 @@ const SelectMovies = ({
addMovie: (movie: Movie) => void;
removeMovie: (id: string) => void;
}) => {
const [recommendations, setRecommendations] = useState<Movie[]>([]);

return (
<div className="min-h-screen flex flex-col">
{/* Top full-width section */}
<div className="w-screen">
<div className="max-w-screen-mx flex justify-center">
<MovieSearch movies={movies} addMovie={addMovie} />
<MovieSearch movies={movies} addMovie={addMovie} setRecommendations={setRecommendations} />
</div>
</div>

{/* Content section below */}
<div className="px-4 py-8">
{
recommendations.length > 0 ?
<div style={{display: "flex", flexDirection: "column", gap: "10px", alignItems: "center"}}>
<h2>Recommendations:</h2>
<MovieList movies={recommendations} />
</div>
:
<div className="px-4 py-8">
<MovieList movies={movies} removeMovie={removeMovie} />
</div>
}

</div>
);
};

export default SelectMovies;
export default SelectMovies;
31 changes: 31 additions & 0 deletions mediabridge/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from mediabridge.config.backend import ENV_TO_CONFIG
from mediabridge.db.tables import Base
from mediabridge.recommender.make_recommendation import recommend

typer_app = typer.Typer()
db = SQLAlchemy(model_class=Base)
Expand Down Expand Up @@ -51,6 +52,36 @@ def search_movies() -> tuple[Response, int]:
movies_list = [row._asdict() for row in movies]
return jsonify(movies_list), 200

@app.route("/api/v1/movie/<movie_id>")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Technically for REST, this would be /api/v1/movies/<movie_id>

def get_movie_by_id(movie_id):
with db.engine.connect() as conn:
movie = conn.execute(
text("SELECT * FROM movie_title WHERE id = :id"),
{"id": movie_id},
).fetchone()
if not movie:
return jsonify({"error": "Movie not found"}), 404
return jsonify(dict(movie._mapping)), 200

@app.route("/api/v1/movie/recommend", methods=["GET"]) # type: ignore
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: methods=["GET"] is not required, that's the default.

def recommend_movies() -> tuple[Response, int]:
movies = request.args.getlist("movies[]", type=int)
if not movies:
return jsonify(
{
"error": "Query parameter 'movies[]' is required and must be a list of integers."
}
), 400
if not all(isinstance(m, int) for m in movies):
return jsonify({"error": "'movies' must be a list of integers."}), 400

try:
rec_ids = recommend()
except Exception as e:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's considered bad practice to except Exception, because if there are logical errors in your code (NameError, IndexError, etc), they will also get caught here. Prefer to catch the precise exception you're worried about.

return jsonify({"error": f"Recommendation failed: {str(e)}"}), 500

return jsonify({"recommendations": list(rec_ids)}), 200

return app


Expand Down
2 changes: 1 addition & 1 deletion mediabridge/engine/recommendation_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def get_movie_id(self, title: str) -> str:
movies = self.db["movies"]
movie = movies.find_one({"title": title})
assert movie, title
return f"{movie.get("netflix_id")}"
return f"{movie.get('netflix_id')}"

def get_movie_title(self, netflix_id: str) -> str:
movies = self.db["movies"]
Expand Down
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading