StreamButler / src /streamlit_app.py
mtoft20's picture
Update src/streamlit_app.py
46c56e9 verified
Raw
History Blame Contribute Delete
32.6 kB
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()