import streamlit as st import requests from together import Together import os import json # ============================================================================= # CONFIGURATION - Using Secrets Management # ============================================================================= NOCODB_URL = "https://mtoft20-potm.hf.space".strip() # Base URL, ensure no extra spaces # Get sensitive data from Streamlit secrets or environment variables def get_api_credentials(): """Get API credentials from secrets or environment""" try: # Try Streamlit secrets first (for Hugging Face Spaces) api_token = st.secrets.get("NOCODB_API_TOKEN", os.environ.get("NOCODB_API_TOKEN", "")).strip() together_key = st.secrets.get("TOGETHER_API_KEY", os.environ.get("TOGETHER_API_KEY", "")).strip() # Get endpoints for content and similarities content_endpoint = st.secrets.get("NOCODB_CONTENT_ENDPOINT", os.environ.get("NOCODB_CONTENT_ENDPOINT", "")).strip() similarity_endpoints = [endpoint.strip() for endpoint in st.secrets.get("NOCODB_SIMILARITY_ENDPOINTS", os.environ.get("NOCODB_SIMILARITY_ENDPOINTS", "")).split(",") if endpoint.strip()] return api_token, together_key, content_endpoint, similarity_endpoints except: # Fallback to environment variables api_token = os.environ.get("NOCODB_API_TOKEN", "").strip() together_key = os.environ.get("TOGETHER_API_KEY", "").strip() content_endpoint = os.environ.get("NOCODB_CONTENT_ENDPOINT", "").strip() similarity_endpoints = [endpoint.strip() for endpoint in os.environ.get("NOCODB_SIMILARITY_ENDPOINTS", "").split(",") if endpoint.strip()] return api_token, together_key, content_endpoint, similarity_endpoints # Initialize Together AI client @st.cache_resource def get_ai_client(): """Initialize Together AI client""" _, together_key, _, _ = get_api_credentials() if not together_key: st.error("Together AI API key not found. Please configure it in the secrets.") return None return Together(api_key=together_key) # ============================================================================= # HELPER FUNCTIONS # ============================================================================= @st.cache_data(ttl=300) # Cache for 5 minutes def get_streaming_content(): """Fetch streaming content from NocoDB with pagination""" api_token, _, content_endpoint, _ = get_api_credentials() if not api_token or not content_endpoint: st.error("NocoDB credentials not configured. Please set up your secrets.") return [] headers = { "xc-token": api_token, "accept": "application/json" } all_content = [] page = 1 page_size = 1000 # NocoDB default page size try: while True: offset = (page - 1) * page_size url = f"{NOCODB_URL.strip()}{content_endpoint.strip()}?limit={page_size}&offset={offset}" response = requests.get(url, headers=headers) if response.status_code == 200: data = response.json() current_page_data = data.get('list', []) if not current_page_data: # No more data to fetch break all_content.extend(current_page_data) # Check if this is the last page page_info = data.get('pageInfo', {}) if page_info.get('isLastPage', True): break page += 1 else: st.error(f"Failed to fetch data: {response.status_code}") if not all_content: # Only return [] if we haven't fetched any data return [] break # If we have some data, return what we've got return all_content except Exception as e: st.error(f"Error connecting to database: {str(e)}") st.write("Full error details:", e) return [] def filter_content(content_list, filters): """Apply filters to streaming content list""" filtered = [] for content in content_list: if not content or not isinstance(content, dict): continue matches_all_filters = True # Streaming service filter if filters['streaming_services']: if content.get('streaming_service') not in filters['streaming_services']: matches_all_filters = False continue # Type filter - only apply if not "All" if filters['content_type']: if content.get('type') != filters['content_type']: matches_all_filters = False continue # Genre filter - check if ALL selected genres are in the content's genres if filters['genres']: content_genres = set(g.strip().lower() for g in str(content.get('listed_in', '')).split(',')) selected_genres = set(g.strip().lower() for g in filters['genres']) if not selected_genres.issubset(content_genres): matches_all_filters = False continue # Rating filter if filters['ratings']: rating = (content.get('rating') or '').strip() # Only compare if rating is a valid string and not a duration if not rating or not isinstance(rating, str) or rating.endswith('min'): matches_all_filters = False continue if rating not in filters['ratings']: matches_all_filters = False continue # Release year filter try: release_year = int(content.get('release_year', 0)) if release_year < filters['year_range'][0] or release_year > filters['year_range'][1]: matches_all_filters = False continue except (ValueError, TypeError): matches_all_filters = False continue # Duration filter (different handling for movies) if filters['content_type'] == 'Movie': duration = str(content.get('duration', '')) if 'min' in duration: try: minutes = int(duration.split()[0]) if minutes < filters['duration_range'][0] or minutes > filters['duration_range'][1]: matches_all_filters = False continue except (ValueError, IndexError): matches_all_filters = False continue # Director filter (optional) if filters['director']: director = str(content.get('director', '')).lower() if not any(name.strip().lower() in director for name in filters['director'].split(',')): matches_all_filters = False continue # Cast filter (optional) if filters['cast']: cast = str(content.get('cast', '')).lower() if not any(name.strip().lower() in cast for name in filters['cast'].split(',')): matches_all_filters = False continue if matches_all_filters: filtered.append(content) return filtered def create_content_context(content_list): """Create context string about current content for AI""" if not content_list: return "No content matches the current filters." total = len(content_list) movies = sum(1 for c in content_list if c.get('type') == 'Movie') shows = sum(1 for c in content_list if c.get('type') == 'TV Show') context = f"""Currently showing {total} titles ({movies} movies and {shows} TV shows) """ # Add streaming services info services = set(c.get('streaming_service') for c in content_list if c.get('streaming_service')) if services: context += f"available on {', '.join(services)}. " return context def get_ai_response(client, question, context, model_name): """Get response from Together AI""" try: prompt = f"""You are a helpful streaming content expert. Based on the current content data, please answer the user's question accurately and helpfully. Current Content Data Context: {context} User Question: {question} Please provide a helpful, accurate response based on the data provided. Keep your answer concise but informative.""" response = client.chat.completions.create( model=model_name, messages=[ {"role": "system", "content": "You are a helpful content expert with deep knowledge of movies and TV shows."}, {"role": "user", "content": prompt} ], max_tokens=300, temperature=0.7, ) return response.choices[0].message.content except Exception as e: raise Exception(f"Together AI Error: {str(e)}") def extract_unique_names(content_list, field): """Extract unique names from a comma-separated field in content list""" unique_names = set() for content in content_list: names = content.get(field, '') if names: # Split by comma and clean each name for name in names.split(','): cleaned_name = name.strip() if cleaned_name: # Only add non-empty names unique_names.add(cleaned_name) return sorted(list(unique_names)) def get_similar_content(content, n_recommendations=5): """Get pre-computed similar content from database""" try: # Get database credentials api_token, _, content_endpoint, similarity_endpoints = get_api_credentials() if not api_token or not similarity_endpoints: st.error("NocoDB credentials not configured properly.") return [] headers = { "xc-token": api_token, "accept": "application/json" } title = content.get('title', '') show_id = content.get('show_id', '') # Try finding by both show_id and title query = f'where=(show_id,eq,{show_id})~and(title,eq,{title})' params = { "where": query } for endpoint in similarity_endpoints: if not endpoint.strip(): # Skip empty endpoints continue try: url = f"{NOCODB_URL.strip()}{endpoint.strip()}" response = requests.get(url, headers=headers, params=params) if response.status_code == 200: data = response.json() if data.get('list'): for entry in data['list']: try: similar_items = json.loads(entry['similar_items']) # Get full content details for each similar item similar_content = [] for item in similar_items[:n_recommendations]: show_id = item.get('show_id', '') query = f'where=(show_id,eq,{show_id})' content_params = { "where": query } content_url = f"{NOCODB_URL.strip()}{content_endpoint.strip()}" content_response = requests.get(content_url, headers=headers, params=content_params) if content_response.status_code == 200: content_data = content_response.json() if content_data and len(content_data.get('list', [])) > 0: content_dict = content_data['list'][0] content_dict['similarity'] = f"{item['similarity']:.2%}" similar_content.append(content_dict) return similar_content[:n_recommendations] except Exception as parse_error: continue except Exception as e: continue return [] except Exception as e: return [] # ============================================================================= # MAIN APP # ============================================================================= def main(): # Page config st.set_page_config( page_title="StreamButler - Your Personal Streaming Concierge", page_icon="🎩", layout="wide" ) # Header with butler theme st.title("🎩 StreamButler") st.write("*At your service! Allow me to curate the perfect streaming entertainment for you.*") # Check API credentials api_token, together_key, content_endpoint, similarity_endpoints = get_api_credentials() if not together_key: st.error("⚠️ Together AI API key not configured!") st.info("Please set your TOGETHER_API_KEY in the Hugging Face Spaces secrets.") st.stop() if not api_token or not content_endpoint: st.error("⚠️ NocoDB credentials not configured!") st.info("Please set NOCODB_API_TOKEN and NOCODB_CONTENT_ENDPOINT in the Hugging Face Spaces secrets.") st.stop() # Initialize AI client try: client = get_ai_client() if not client: st.stop() except Exception as e: st.error(f"Failed to initialize Together AI client: {e}") st.stop() # Load all content first with st.spinner("Loading streaming content..."): all_content = get_streaming_content() if not all_content: st.error("Could not load streaming content. Please check your NocoDB connection.") st.stop() # Extract unique values for filters all_ratings = sorted(list(set( c.get('rating') for c in all_content if c and isinstance(c, dict) and c.get('rating') and isinstance(c.get('rating'), str) and not c.get('rating').endswith('min') # Exclude duration values and c.get('rating').strip() # Exclude empty strings ))) all_genres = sorted(list(set( genre.strip() for c in all_content for genre in c.get('listed_in', '').split(',') if genre.strip() ))) all_streaming_services = sorted(list(set([c.get('streaming_service') for c in all_content if c.get('streaming_service')]))) # Extract unique directors and cast members all_directors = extract_unique_names(all_content, 'director') all_cast_members = extract_unique_names(all_content, 'cast') # Sidebar filters st.sidebar.header("🔍 Filter Content") with st.sidebar.form("filter_form"): st.subheader("Streaming Services") # Streaming service selection (required) selected_services = st.multiselect( "Select Your Streaming Services", options=all_streaming_services, default=all_streaming_services[:1], # Default to first service help="Select the streaming services you have access to", key="streaming_services" ) if not selected_services: st.warning("Please select at least one streaming service") st.subheader("Content Filters") content_type = st.selectbox( "Content Type", options=["All", "Movie", "TV Show"], index=0 ) selected_genres = st.multiselect( "Genres", options=all_genres, default=[] ) st.subheader("Optional Filters") # Rating filter selected_ratings = st.multiselect( "Ratings", options=all_ratings, default=[], help="Filter by content rating" ) # Year range slider years = [int(c.get('release_year', 0)) for c in all_content if c.get('release_year')] min_year, max_year = min(years), max(years) year_range = st.slider( "Release Year", min_value=min_year, max_value=max_year, value=(min_year, max_year), help="Filter by release year range" ) # Duration range slider (for movies only) movie_durations = [ int(str(c.get('duration', '0 min')).split()[0]) for c in all_content if c and c.get('type') == 'Movie' and 'min' in str(c.get('duration', '')) ] if movie_durations: min_duration = min(d for d in movie_durations if d > 0) max_duration = max(movie_durations) duration_range = st.slider( "Movie Duration (minutes)", min_value=min_duration, max_value=max_duration, value=(min_duration, max_duration), help="This filter only applies to movies" ) else: duration_range = (0, 1000) # Fallback values # Director filter with autocomplete selected_directors = st.multiselect( "Directors", options=all_directors, default=[], help="Select one or more directors (searchable)", placeholder="Start typing to search directors..." ) # Cast filter with autocomplete selected_cast = st.multiselect( "Cast Members", options=all_cast_members, default=[], help="Select one or more cast members (searchable)", placeholder="Start typing to search cast members..." ) # Submit button apply_filters = st.form_submit_button("🔍 Apply Filters", type="primary") # Create filter dictionary filters = { 'streaming_services': selected_services, 'content_type': content_type if content_type != "All" else None, 'ratings': selected_ratings, 'genres': selected_genres, 'year_range': year_range, 'duration_range': duration_range, 'director': ','.join(selected_directors) if selected_directors else '', 'cast': ','.join(selected_cast) if selected_cast else '' } # Only apply filters when the button is clicked if apply_filters: filtered_content = filter_content(all_content, filters) st.session_state.filtered_content = filtered_content else: # Initialize filtered content if not exists if 'filtered_content' not in st.session_state: st.session_state.filtered_content = all_content # Main content area col1, col2 = st.columns([2, 1]) with col1: # Content listings filtered_count = len(st.session_state.filtered_content) if filtered_count == 0: st.subheader("📋 No Titles Found") else: # Header with count and page info st.subheader(f"📋 Found {filtered_count:,} Title{'s' if filtered_count != 1 else ''}") if st.session_state.filtered_content: # Active Filters section with better formatting if any([filters['content_type'], filters['genres'], filters['ratings'], filters['director'], filters['cast']]): with st.expander("🔍 Active Filters", expanded=True): filter_cols = st.columns(2) with filter_cols[0]: if filters['content_type']: st.write(f"**Type:** {filters['content_type']}") if filters['genres']: st.write(f"**Genres:** {', '.join(filters['genres'])}") if filters['ratings']: st.write(f"**Ratings:** {', '.join(filters['ratings'])}") with filter_cols[1]: if filters['director']: st.write(f"**Director:** {filters['director']}") if filters['cast']: st.write(f"**Cast:** {filters['cast']}") st.write("---") # Pagination setup items_per_page = 10 total_pages = (filtered_count + items_per_page - 1) // items_per_page # Initialize page number in session state if not exists if 'current_page' not in st.session_state: st.session_state.current_page = 1 # Calculate slice indices for current page start_idx = (st.session_state.current_page - 1) * items_per_page end_idx = min(start_idx + items_per_page, filtered_count) # Display current range info st.write(f"Showing {start_idx + 1}-{end_idx} of {filtered_count:,} titles") # Show items for current page for i, content in enumerate(st.session_state.filtered_content[start_idx:end_idx], start=start_idx): with st.container(): st.write(f"### {content.get('title', 'N/A')} ({content.get('release_year', 'N/A')})") # Content details in columns detail_col1, detail_col2 = st.columns(2) with detail_col1: st.write(f"**📺 Available on:** {content.get('streaming_service', 'N/A')}") st.write(f"**🎭 Type:** {content.get('type', 'N/A')}") st.write(f"**⭐ Rating:** {content.get('rating', 'N/A')}") st.write(f"**⏱️ Duration:** {content.get('duration', 'N/A')}") with detail_col2: st.write(f"**🎬 Genres:** {content.get('listed_in', 'N/A')}") cast = content.get('cast') cast_display = cast[:100] + "..." if cast and len(cast) > 100 else cast if cast else "N/A" st.write(f"**👥 Cast:** {cast_display}") st.write(f"**📝 Director:** {content.get('director', 'N/A')}") # Description st.write(f"**📖 Description:**") st.write(content.get('description', 'N/A')) # Add Find Similar button with loading state similar_button = st.button(f"🔍 Find Similar Content", key=f"similar_{i}") if similar_button: with st.spinner("Finding similar content..."): similar_content = get_similar_content(content, n_recommendations=5) if similar_content: # Create tabs for different aspects of recommendations sim_tab1, sim_tab2 = st.tabs(["📺 Similar Titles", "🔍 Why These Recommendations"]) with sim_tab1: for sim_content in similar_content[:5]: # Show top 5 similar items with st.container(): col1, col2 = st.columns([3, 1]) with col1: st.write(f"**{sim_content.get('title')}** ({sim_content.get('type')}, {sim_content.get('release_year')})") st.write(f"*Available on:* {sim_content.get('streaming_service')}") st.write(f"*Genres:* {sim_content.get('listed_in')}") st.write(f"*Cast:* {sim_content.get('cast')}") st.write(f"*Director:* {sim_content.get('director')}") st.write(f"*Description:* {sim_content.get('description')}") with col2: st.write(f"**Match:** {sim_content.get('similarity', 'N/A')}") st.write("---") with sim_tab2: st.write("**Why these recommendations?**") st.write(""" These recommendations are based on multiple factors: - Genre and theme matching - Plot similarity analysis - Cast and director relationships - Release year proximity The percentage match indicates how similar each title is to your selection. """) else: st.info("No similar content found.") st.write("---") # Bottom pagination controls with better layout st.write("---") page_cols = st.columns([1, 2, 1, 2, 1]) # Previous button with page_cols[0]: if st.button("← Previous", disabled=st.session_state.current_page == 1, use_container_width=True): st.session_state.current_page -= 1 st.rerun() # Spacer with page_cols[1]: st.write("") # Page input with page_cols[2]: page_input = st.number_input( f"Page (of {total_pages})", min_value=1, max_value=total_pages, value=st.session_state.current_page, key="page_number", help=f"Enter a page number between 1 and {total_pages}" ) if page_input != st.session_state.current_page: st.session_state.current_page = page_input st.rerun() # Spacer with page_cols[3]: st.write("") # Next button with page_cols[4]: if st.button("Next →", disabled=st.session_state.current_page == total_pages, use_container_width=True): st.session_state.current_page += 1 st.rerun() else: st.info("No content matches your current filters. Try adjusting the criteria.") with col2: # AI Chat Section st.subheader("🎩 Your Personal Butler") st.write("How may I be of assistance in finding your perfect entertainment today?") # Model selection for Together AI model_choice = st.selectbox( "Select Your Butler's Expertise Level:", [ "google/gemma-2b-it", "google/gemma-2-27b-it", "mistralai/Mistral-7B-Instruct-v0.1", "NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO", "mistralai/Mixtral-8x7B-Instruct-v0.1" ], help="Select your butler's level of expertise in making recommendations" ) # Example questions with st.expander("💡 How to Address Your Butler"): st.write(""" Your butler understands requests like: • "My good sir, I seek an action film that would also please my companion who favors comedies." • "Would you be so kind as to suggest a family-friendly show in the spirit of Stranger Things, but less frightening?" • "I've quite enjoyed The Crown and Downton Abbey. Might you recommend similar period dramas?" • "The weather is rather gloomy today. Perhaps a charming romantic comedy or musical?" • "I'm in search of enlightening documentaries about technology or artificial intelligence." • "We're hosting a gathering this evening. What entertainment would you suggest for a group?" """) user_question = st.text_area( "How May I Assist You?", placeholder="Tell me your preferences, and I shall curate the perfect selection...", height=100 ) if st.button("🎩 Request Recommendations", type="primary"): if user_question: with st.spinner("Your butler is carefully selecting the perfect entertainment..."): # Create context from current filtered data context = create_content_context(st.session_state.filtered_content) try: # Get AI response ai_response = get_ai_response(client, user_question, context, model_choice) st.success("**🎩 Your Curated Selection:**") st.write(ai_response) # Show streaming availability with st.expander("🎩 Butler's Note"): st.write(""" To access your selected entertainment: 1. Kindly select your preferred streaming services above 2. Locate your chosen title in the curated list 3. For similar recommendations, simply request "Find Similar Content" *Is there anything else I can assist you with?* """) except Exception as e: st.error("My sincerest apologies, but I seem to be unable to process your request at the moment. Might we try again?") else: st.warning("How may I be of assistance? Please share your entertainment preferences.") # Footer stats with butler theme st.markdown("---") if all_content: total_items = len(all_content) filtered_items = len(st.session_state.filtered_content) st.markdown("### 🎩 Your Entertainment Library") # Create columns for stats with better spacing stat_cols = st.columns(len(selected_services) + 3) # Basic stats with improved formatting with stat_cols[0]: st.metric("📚 Complete Collection", f"{total_items:,}") with stat_cols[1]: st.metric("🎯 Curated Selection", f"{filtered_items:,}") with stat_cols[2]: movies = sum(1 for c in st.session_state.filtered_content if c.get('type') == 'Movie') shows = sum(1 for c in st.session_state.filtered_content if c.get('type') == 'TV Show') st.metric("🎬 Films / 📺 Series", f"{movies:,} / {shows:,}") # Streaming service breakdown with icons service_icons = { "Netflix": "🔴", "Amazon Prime": "🔵", "Hulu": "🟢", "Disney+": "🟣" } for i, service in enumerate(selected_services, 3): if i < len(stat_cols): service_count = sum(1 for c in st.session_state.filtered_content if c.get('streaming_service') == service) icon = service_icons.get(service, "📺") with stat_cols[i]: st.metric(f"{icon} {service}", f"{service_count:,}") if __name__ == "__main__": main()