Spaces:
Running
Running
| import gradio as gr | |
| import logging | |
| from services.tmdb_client import tmdb_client | |
| from utils.config import config | |
| # Configure logging | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', | |
| handlers=[ | |
| logging.FileHandler('movie_app.log'), | |
| logging.StreamHandler() | |
| ] | |
| ) | |
| # Create logger for this module | |
| logger = logging.getLogger(__name__) | |
| def extract_country_code(country_selection): | |
| """Extract country code from selection like 'United States (US)' -> 'US'""" | |
| if not country_selection: | |
| return "US" # Default fallback | |
| # Extract code from parentheses | |
| if "(" in country_selection and ")" in country_selection: | |
| return country_selection.split("(")[-1].replace(")", "").strip() | |
| # Fallback if format is unexpected | |
| return "US" | |
| def get_movie_recommendations(decade, country, genre, num_movies): | |
| """Get movie recommendations based on user preferences.""" | |
| logger.info(f"Recommendation request: decade={decade}, country={country}, genre={genre}, num_movies={num_movies}") | |
| if not decade or not country: | |
| logger.warning("Missing required parameters: decade or country") | |
| return "Please select decade and country." | |
| # Check if country requires genre selection | |
| if country in config.TOP_COUNTRIES and not genre: | |
| logger.warning(f"Genre required for top country {country} but not provided") | |
| return "Please select a genre for this country." | |
| # Convert decade string to integer (e.g., "2000s" -> 2000) | |
| decade_year = int(decade.replace("s", "")) | |
| # Extract country code from the selection | |
| country_code = extract_country_code(country) | |
| # Get recommendations (genre is optional for non-top countries) | |
| logger.info(f"Fetching movies for {country_code}, {decade_year}, genre: {genre}") | |
| movies = tmdb_client.get_random_movies( | |
| decade=decade_year, | |
| country=country_code, | |
| genre=genre if country in config.TOP_COUNTRIES else None, | |
| n=num_movies | |
| ) | |
| # Update result message based on country type | |
| if country in config.TOP_COUNTRIES: | |
| search_criteria = f"{genre} from {country} ({decade})" | |
| else: | |
| search_criteria = f"from {country} ({decade})" | |
| if not movies: | |
| logger.warning(f"No movies found {search_criteria}") | |
| return f"No movies found {search_criteria}." | |
| logger.info(f"Successfully found {len(movies)} movies {search_criteria}") | |
| # Format results with mobile-friendly movie information | |
| output = f"π¬ **{len(movies)} movies {search_criteria}:**\n\n" | |
| for i, movie in enumerate(movies, 1): | |
| # Format title - show original title if different from English title | |
| title_display = movie['title'] | |
| if movie.get('original_title') and movie['original_title'] != movie['title']: | |
| title_display = f"{movie['title']} ({movie['original_title']})" | |
| # Create a mobile-friendly vertical card layout | |
| output += f"<div style='margin-bottom: 20px; padding: 15px; border: 2px solid #e0e0e0; border-radius: 12px; background: linear-gradient(145deg, #ffffff, #f5f5f5); box-shadow: 0 6px 12px rgba(0,0,0,0.1); max-width: 100%;'>\n" | |
| # Movie title header | |
| output += f"<h2 style='margin: 0 0 15px 0; color: #2c3e50; font-size: 1.3em; text-align: center;'>{i}. {title_display}</h2>\n" | |
| # Poster image section - centered and smaller | |
| if movie.get('poster_url'): | |
| output += f"<div style='display: flex; justify-content: center; align-items: center; margin: 20px 0; padding: 10px;'>\n" | |
| output += f"<img src='{movie['poster_url']}' style='width: 130px; height: 195px; object-fit: cover; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2);' alt='{movie['title']} poster'>\n" | |
| output += f"</div>\n" | |
| # Basic Info Row - more compact | |
| output += f"<div style='display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; justify-content: center;'>\n" | |
| output += f"<div style='background: #ecf0f1; padding: 6px 10px; border-radius: 6px; font-size: 0.9em;'><strong>Year:</strong> {movie['year']}</div>\n" | |
| if movie.get('has_omdb_data') and movie.get('runtime') != 'N/A': | |
| output += f"<div style='background: #ecf0f1; padding: 6px 10px; border-radius: 6px; font-size: 0.9em;'><strong>Runtime:</strong> {movie['runtime']}</div>\n" | |
| if movie.get('has_omdb_data') and movie.get('rated') != 'N/A': | |
| output += f"<div style='background: #ecf0f1; padding: 6px 10px; border-radius: 6px; font-size: 0.9em;'><strong>Rated:</strong> {movie['rated']}</div>\n" | |
| output += f"</div>\n" | |
| # Director and Cast (if available) - more compact | |
| if movie.get('has_omdb_data'): | |
| if movie.get('director') != 'N/A': | |
| output += f"<p style='margin: 6px 0; color: #34495e; font-size: 0.95em; text-align: center;'><strong>π¬ Director:</strong> {movie['director']}</p>\n" | |
| if movie.get('actors') != 'N/A': | |
| actors = movie['actors'][:120] + '...' if len(movie['actors']) > 120 else movie['actors'] | |
| output += f"<p style='margin: 6px 0; color: #34495e; font-size: 0.95em; text-align: center;'><strong>π Cast:</strong> {actors}</p>\n" | |
| # Ratings Section - 2x2 grid layout for mobile | |
| output += f"<div style='background: #f8f9fa; padding: 12px; border-radius: 8px; margin: 12px 0;'>\n" | |
| output += f"<h4 style='margin: 0 0 10px 0; color: #2c3e50; text-align: center;'>π Ratings</h4>\n" | |
| output += f"<div style='display: grid; grid-template-columns: 1fr 1fr; gap: 8px; max-width: 300px; margin: 0 auto;'>\n" | |
| # TMDB Rating | |
| output += f"<div style='background: #3498db; color: white; padding: 8px; border-radius: 6px; text-align: center;'>\n" | |
| output += f"<div style='font-weight: bold; font-size: 0.9em;'>TMDB</div>\n" | |
| output += f"<div style='font-size: 0.9em;'>β {movie['rating']}/10</div>\n" | |
| output += f"<div style='font-size: 0.7em;'>({movie['vote_count']} votes)</div>\n" | |
| output += f"</div>\n" | |
| # IMDB Rating (if available) | |
| if movie.get('has_omdb_data') and movie.get('imdb_rating') != 'N/A': | |
| output += f"<div style='background: #f39c12; color: white; padding: 8px; border-radius: 6px; text-align: center;'>\n" | |
| output += f"<div style='font-weight: bold; font-size: 0.9em;'>IMDb</div>\n" | |
| output += f"<div style='font-size: 0.9em;'>β {movie['imdb_rating']}/10</div>\n" | |
| if movie.get('imdb_votes') != 'N/A': | |
| output += f"<div style='font-size: 0.7em;'>({movie['imdb_votes']} votes)</div>\n" | |
| output += f"</div>\n" | |
| # Rotten Tomatoes (if available) | |
| if movie.get('has_omdb_data') and movie.get('rotten_tomatoes') != 'N/A': | |
| output += f"<div style='background: #e74c3c; color: white; padding: 8px; border-radius: 6px; text-align: center;'>\n" | |
| output += f"<div style='font-weight: bold; font-size: 0.9em;'>RT</div>\n" | |
| output += f"<div style='font-size: 0.9em;'>π {movie.get('rotten_tomatoes')}</div>\n" | |
| output += f"</div>\n" | |
| # Metacritic (if available) | |
| if movie.get('has_omdb_data') and movie.get('metascore') != 'N/A': | |
| output += f"<div style='background: #9b59b6; color: white; padding: 8px; border-radius: 6px; text-align: center;'>\n" | |
| output += f"<div style='font-weight: bold; font-size: 0.9em;'>Meta</div>\n" | |
| output += f"<div style='font-size: 0.9em;'>π {movie['metascore']}/100</div>\n" | |
| output += f"</div>\n" | |
| output += f"</div>\n" | |
| output += f"</div>\n" | |
| # Plot/Overview - more compact | |
| plot_text = movie.get('plot', movie['overview']) if movie.get('has_omdb_data') else movie['overview'] | |
| plot_display = plot_text[:250] + '...' if len(plot_text) > 250 else plot_text | |
| output += f"<p style='margin: 12px 0; color: #555; line-height: 1.4; background: #fff; padding: 10px; border-radius: 6px; border-left: 3px solid #3498db; font-size: 0.95em;'><strong>π Plot:</strong> {plot_display}</p>\n" | |
| # Awards (if available) - more compact | |
| if movie.get('has_omdb_data') and movie.get('awards') != 'N/A' and movie['awards'] != 'No awards.': | |
| awards_text = movie['awards'][:150] + '...' if len(movie['awards']) > 150 else movie['awards'] | |
| output += f"<p style='margin: 8px 0; color: #e67e22; background: #fef9e7; padding: 8px; border-radius: 6px; border-left: 3px solid #f39c12; font-size: 0.9em;'><strong>π Awards:</strong> {awards_text}</p>\n" | |
| # Streaming Services Section - more compact | |
| streaming_services = movie.get('all_services', []) | |
| if streaming_services: | |
| output += f"<div style='background: #e8f5e8; padding: 12px; border-radius: 8px; margin: 12px 0; border: 2px solid #27ae60;'>\n" | |
| output += f"<h4 style='margin: 0 0 10px 0; color: #27ae60; display: flex; align-items: center; justify-content: center; font-size: 1em;'><span style='margin-right: 6px;'>πΊ</span>Watch Now</h4>\n" | |
| # Free services | |
| free_services = movie.get('free_services', []) | |
| if free_services: | |
| output += f"<div style='margin-bottom: 10px;'>\n" | |
| output += f"<div style='font-weight: bold; color: #2c5530; margin-bottom: 6px; font-size: 0.9em; text-align: center;'>π Free</div>\n" | |
| output += f"<div style='display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;'>\n" | |
| for service in free_services: | |
| if service.get('logo_url'): | |
| output += f"<div style='display: flex; align-items: center; background: white; padding: 4px 8px; border-radius: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); border: 1px solid #ddd;'>\n" | |
| output += f"<img src='{service['logo_url']}' style='width: 20px; height: 20px; margin-right: 4px; border-radius: 3px;' alt='{service['name']} logo'>\n" | |
| output += f"<span style='font-size: 0.8em; font-weight: 500; color: #2c3e50;'>{service['name']}</span>\n" | |
| output += f"</div>\n" | |
| else: | |
| output += f"<div style='background: white; padding: 4px 8px; border-radius: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); border: 1px solid #ddd; font-size: 0.8em; font-weight: 500; color: #2c3e50;'>{service['name']}</div>\n" | |
| output += f"</div>\n" | |
| output += f"</div>\n" | |
| # Subscription services | |
| subscription_services = movie.get('subscription_services', []) | |
| if subscription_services: | |
| output += f"<div>\n" | |
| output += f"<div style='font-weight: bold; color: #2c5530; margin-bottom: 6px; font-size: 0.9em; text-align: center;'>π³ Free with Subscription</div>\n" | |
| output += f"<div style='display: flex; flex-wrap: wrap; gap: 6px; justify-content: center;'>\n" | |
| for service in subscription_services: | |
| if service.get('logo_url'): | |
| output += f"<div style='display: flex; align-items: center; background: white; padding: 4px 8px; border-radius: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); border: 1px solid #ddd;'>\n" | |
| output += f"<img src='{service['logo_url']}' style='width: 20px; height: 20px; margin-right: 4px; border-radius: 3px;' alt='{service['name']} logo'>\n" | |
| output += f"<span style='font-size: 0.8em; font-weight: 500; color: #2c3e50;'>{service['name']}</span>\n" | |
| output += f"</div>\n" | |
| else: | |
| output += f"<div style='background: white; padding: 4px 8px; border-radius: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); border: 1px solid #ddd; font-size: 0.8em; font-weight: 500; color: #2c3e50;'>{service['name']}</div>\n" | |
| output += f"</div>\n" | |
| output += f"</div>\n" | |
| # JustWatch attribution (required by TMDB terms) | |
| output += f"<div style='margin-top: 8px; font-size: 0.7em; color: #666; text-align: center;'>Streaming data provided by <strong>JustWatch</strong></div>\n" | |
| output += f"</div>\n" | |
| # Box Office (if available) - more compact | |
| if movie.get('has_omdb_data') and movie.get('box_office') != 'N/A': | |
| output += f"<p style='margin: 8px 0; color: #27ae60; background: #eafaf1; padding: 8px; border-radius: 6px; font-size: 0.9em; text-align: center;'><strong>π° Box Office:</strong> {movie['box_office']}</p>\n" | |
| # External Links - centered and more compact | |
| links_html = "" | |
| if movie.get('imdb_url'): | |
| links_html += f"<div style='text-align: center; margin: 12px 0;'>\n" | |
| links_html += f"<a href='{movie['imdb_url']}' target='_blank' style='display: inline-block; background: linear-gradient(45deg, #f5c518, #f39c12); color: #000; padding: 8px 16px; text-decoration: none; border-radius: 6px; font-weight: bold; font-size: 0.9em; box-shadow: 0 3px 6px rgba(0,0,0,0.2); transition: transform 0.2s;' onmouseover='this.style.transform=\"translateY(-1px)\"' onmouseout='this.style.transform=\"translateY(0)\"'>π¬ View on IMDb</a>\n" | |
| links_html += f"</div>\n" | |
| if links_html: | |
| output += links_html | |
| output += f"</div>\n\n" | |
| return output | |
| # Create simple Gradio interface | |
| with gr.Blocks( | |
| title="Movie Recommendation", | |
| theme="gstaff/xkcd", | |
| css=""" | |
| footer { | |
| display: none !important; | |
| } | |
| .gradio-container .footer { | |
| display: none !important; | |
| } | |
| .gradio-container .footer-text { | |
| display: none !important; | |
| } | |
| .gradio-container .built-with { | |
| display: none !important; | |
| } | |
| """ | |
| ) as app: | |
| gr.Markdown("# π¬ Random Movie Recommendations") | |
| gr.Markdown("Select what you are in the mood for and get a movie recommendation!") | |
| with gr.Row(): | |
| with gr.Column(): | |
| decade = gr.Dropdown( | |
| choices=config.DECADES, | |
| label="Decade", | |
| value="2000s" | |
| ) | |
| country = gr.Dropdown( | |
| choices=config.COUNTRIES, | |
| label="Country", | |
| value="United States of America (US)", | |
| filterable=True | |
| ) | |
| genre = gr.Dropdown( | |
| choices=config.GENRES, | |
| label="Genre", | |
| value="Action", | |
| visible=True # Initially visible since default is US | |
| ) | |
| num_movies = gr.Slider( | |
| minimum=1, | |
| maximum=10, | |
| value=5, | |
| step=1, | |
| label="Number of Movies" | |
| ) | |
| get_btn = gr.Button("Get Recommendations", variant="primary") | |
| results = gr.Markdown(label="Recommendations") | |
| # Function to update genre visibility based on country selection | |
| def update_genre_visibility(selected_country): | |
| if selected_country in config.TOP_COUNTRIES: | |
| return gr.update(visible=True) | |
| else: | |
| return gr.update(visible=False, value=None) | |
| # Update genre dropdown when country changes | |
| country.change( | |
| update_genre_visibility, | |
| inputs=[country], | |
| outputs=[genre] | |
| ) | |
| # Connect the recommendation function | |
| get_btn.click( | |
| get_movie_recommendations, | |
| inputs=[decade, country, genre, num_movies], | |
| outputs=results | |
| ) | |
| if __name__ == "__main__": | |
| # Validate configuration before starting the app | |
| try: | |
| config.validate() | |
| logger.info("Starting Movie Recommendation App") | |
| app.launch() | |
| except Exception as e: | |
| logger.error(f"Failed to start application: {e}") | |
| raise |