Saumya / app.py
mrpoddaa's picture
Update app.py
dfda1ba verified
"""
Saumya β€” AI Movie & TV Show Assistant
Created by Samith Dilshan
Runs on HuggingFace Spaces (Docker SDK) Β· FastAPI + Gradio
"""
from __future__ import annotations
import os
import re
import requests
import gradio as gr
from fastapi import FastAPI, Query, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from typing import Optional
import uvicorn
# ══════════════════════════════════════════════════════════════════
# CONFIGURATION (set via HF Space Secrets)
# ══════════════════════════════════════════════════════════════════
TMDB_API_KEY = os.getenv("TMDB_API_KEY", "YOUR_TMDB_API_KEY")
HF_TOKEN = os.getenv("HF_TOKEN", "YOUR_HF_TOKEN")
ADMIN_KEY = os.getenv("ADMIN_KEY", "change_this_secret")
# router.huggingface.co β€” model goes in the request BODY, not the URL path
# SinLlama β€” Llama-3-8B fine-tuned on 10.7M Sinhala sentences (polyglots/SinLlama_v01)
# Llama-3.3-70B β€” strong multilingual fallback
_HF_BASE = "https://router.huggingface.co/featherless-ai/v1/chat/completions"
_HF_MODELS = [
("polyglots/SinLlama_v01", _HF_BASE), # primary: dedicated Sinhala LLM
("meta-llama/Llama-3.3-70B-Instruct", _HF_BASE), # fallback: strong multilingual
("google/gemma-3-27b-it", _HF_BASE), # last resort
]
# Mutable toggle β€” flip via POST /api/admin/gradio-toggle
_gradio_enabled: bool = True
# ══════════════════════════════════════════════════════════════════
# LANGUAGE DETECTION
# ══════════════════════════════════════════════════════════════════
def detect_lang(text: str) -> str:
"""'si' if Sinhala Unicode chars present, else 'en'."""
return "si" if re.search(r"[\u0D80-\u0DFF]", text) else "en"
# ══════════════════════════════════════════════════════════════════
# HF INFERENCE CALL (retry + model fallback)
# ══════════════════════════════════════════════════════════════════
import time
def _call_model(name: str, url: str, prompt: str, max_tokens: int) -> str:
"""OpenAI-compatible chat/completions via router.huggingface.co"""
headers = {
"Authorization": f"Bearer {HF_TOKEN}",
"Content-Type": "application/json",
}
payload = {
"model": name,
"messages": [
{"role": "system", "content": "You are Saumya, a friendly movie and TV show AI assistant. You support both English and Sinhala. Reply naturally and helpfully."},
{"role": "user", "content": prompt},
],
"max_tokens": max_tokens,
"stream": False,
}
for attempt in range(3):
r = requests.post(url, headers=headers, json=payload, timeout=90)
print(f"[HF] {name} attempt {attempt+1} β†’ {r.status_code}")
if r.status_code in (503, 429):
wait = min(int(r.headers.get("Retry-After", 20)), 30)
print(f"[HF] Rate-limited/loading β€” waiting {wait}s …")
time.sleep(wait)
continue
if not r.ok:
raise RuntimeError(f"HTTP {r.status_code}: {r.text[:250]}")
data = r.json()
if isinstance(data, dict) and "error" in data:
raise RuntimeError(data["error"])
return data["choices"][0]["message"]["content"].strip()
raise TimeoutError(f"{name} still busy after 3 retries")
def _hf(prompt: str, max_tokens: int = 350) -> str:
"""Try each model in _HF_MODELS until one succeeds."""
if not HF_TOKEN or HF_TOKEN == "YOUR_HF_TOKEN":
print("[HF] ⚠️ HF_TOKEN not set")
return ""
for name, url in _HF_MODELS:
try:
result = _call_model(name, url, prompt, max_tokens)
if result:
print(f"[HF] βœ… {name}")
return result
except Exception as e:
print(f"[HF] ❌ {name}: {e}")
continue
print("[HF] ⚠️ All models failed")
return ""
# ══════════════════════════════════════════════════════════════════
# INTENT CLASSIFICATION
# ══════════════════════════════════════════════════════════════════
_RX_ABOUT_AI = re.compile(
r"(who\s+(are|made|created|built)\s+you"
r"|what\s+are\s+you"
r"|your\s+(name|creator|developer|maker)"
r"|introduce\s+yourself"
r"|about\s+you"
r"|࢔ࢺා\s*ΰΆšΰ·€ΰ·”ΰΆ―|ΰΆ”ΰΆΆ\s*ΰΆšΰ·€ΰ·”ΰΆ―|ΰ·„ΰ·ΰΆ―ΰ·”ΰ·€ΰ·š|ΰΆ±ΰ·’ΰΆ»ΰ·ŠΰΆΈΰ·ΰΆ«ΰΆΊ|AI\s*ΰΆšΰ·€ΰ·”ΰΆ―"
r"|࢔ࢺාව\s*ΰ·„ΰ·ΰΆ―ΰ·”ΰ·€ΰ·š|ΰΆ”ΰΆΆΰ·€\s*ΰ·„ΰ·ΰΆ―ΰ·”ΰ·€ΰ·š)",
re.I,
)
_RX_CODE = re.compile(
r"\b(code|script|program|implement|algorithm|function|class"
r"|sql|python|javascript|typescript|html|css|api\s+endpoint"
r"|࢚ෝࢩ්|ΰΆ΄ΰ·Šβ€ΰΆ»ΰ·œΰΆœΰ·Šβ€ΰΆ»ΰ·‘ΰΆΈΰ·Š|ࢽිࢺࢽා\s*ΰΆ―ΰ·™ΰΆ±ΰ·ŠΰΆ±)\b",
re.I,
)
_RX_TV = re.compile(
r"\b(tv\s*show|web\s*series|series|episode|season|serial|anime"
r"|netflix|hbo|amazon\s*prime|disney\+|peacock|hulu|apple\s*tv"
r"|ΰΆ§ΰ·™ΰΆ½ΰ·’|ΰ·ƒΰ·“ΰΆ»ΰ·“ΰ·ƒΰ·Š|ෂෝ|episodes|ΰΆ§ΰ·“ΰ·€ΰ·“)\b",
re.I,
)
_RX_MOVIE = re.compile(
r"\b(movie|film|cinema|watch|download|trailer|cast|plot"
r"|ΰ·†ΰ·’ΰΆ½ΰ·ŠΰΆΈΰ·Š|ΰΆ ΰ·’ΰΆ­ΰ·Šβ€ΰΆ»ΰΆ΄ΰΆ§|ࢢࢽࢱ්ࢱ|ΰΆ•ΰΆ±ΰ·š\s*ΰ·†ΰ·’ΰΆ½ΰ·ŠΰΆΈΰ·Š|ΰΆΈΰ·Šΰ·†ΰ·’ΰΆ½ΰ·ŠΰΆΈΰ·Š)\b",
re.I,
)
_RX_RECOMMEND = re.compile(
r"\b(recommend|suggest|best|top|good|popular|latest|trending|new\s+movie"
r"|ΰΆ»ΰ·’ΰΆšΰΆΈΰ·™ΰΆ±ΰ·ŠΰΆ©ΰ·Š|ΰ·„ΰ·œΰΆ³|ΰΆ’ΰΆ±ΰΆ΄ΰ·Šβ€ΰΆ»ΰ·’ΰΆΊ|ΰΆ±ΰ·€|ΰ·„ΰ·œΰΆ―ΰΆΈ|suggest)\b",
re.I,
)
def classify_intent(q: str) -> str:
if _RX_ABOUT_AI.search(q): return "about_ai"
if _RX_CODE.search(q): return "code_request"
if _RX_TV.search(q): return "tv"
if _RX_MOVIE.search(q): return "movie"
if _RX_RECOMMEND.search(q): return "recommend"
return "general"
# ══════════════════════════════════════════════════════════════════
# TITLE EXTRACTION
# ══════════════════════════════════════════════════════════════════
_NOISE = re.compile(
r"\b(i\s+want\s+to|want\s+to|i'?d\s+like\s+to|can\s+i|please"
r"|help\s+me|show\s+me|tell\s+me\s+about|looking\s+for|find\s+me"
r"|watch|see|find|get|download|the\s+movie|a\s+movie|the\s+film"
r"|the\s+tv\s+show|the\s+series|the\s+show|movie|film|show|series|tv"
r"|ΰ·†ΰ·’ΰΆ½ΰ·ŠΰΆΈΰ·Š\s*ΰΆ‘ΰΆš|ΰ·†ΰ·’ΰΆ½ΰ·ŠΰΆΈΰ·Š|ΰΆ ΰ·’ΰΆ­ΰ·Šβ€ΰΆ»ΰΆ΄ΰΆ§ΰΆΊ|ΰΆ ΰ·’ΰΆ­ΰ·Šβ€ΰΆ»ΰΆ΄ΰΆ§"
r"|ࢢࢽࢱ්ࢱ\s*ΰΆ•ΰΆ±ΰ·š|ࢢࢽࢱ්ࢱ|ΰΆ•ΰΆ±ΰ·š|ΰΆœΰ·™ΰΆ±ΰΆ½ΰ·ŠΰΆ½ΰ·|ΰΆ―ΰ·™ΰΆ±ΰ·ŠΰΆ±|ΰΆœΰ·™ΰΆ±ΰΆ½ΰ·ŠΰΆ½ΰ·\s*ΰΆ―ΰ·™ΰΆ±ΰ·ŠΰΆ±"
r"|ΰΆΈΰΆ§|ΰΆΈΰΆΈΰΆ§|ΰΆ’|ΰΆΈΰΆΈ)\b",
re.I,
)
def extract_title(query: str, kind: str) -> str:
"""Use LLM to extract media title; fall back to simple regex cleaning."""
prompt = (
f"Extract only the {kind} title from this query. "
f"Return ONLY the bare title β€” no explanation, no quotes, no punctuation.\n"
f"Query: {query}\n"
f"Title:"
)
result = _hf(prompt, max_tokens=20).split("\n")[0].strip().strip("\"'.,!?")
if result and 2 <= len(result) <= 80:
return result
# Fallback: strip noise words
cleaned = _NOISE.sub(" ", query)
return re.sub(r"\s+", " ", cleaned).strip() or query
# ══════════════════════════════════════════════════════════════════
# TMDb HELPERS
# ══════════════════════════════════════════════════════════════════
def _tmdb(path: str, **params) -> dict:
r = requests.get(
f"https://api.themoviedb.org/3{path}",
params={"api_key": TMDB_API_KEY, **params},
timeout=10,
)
r.raise_for_status()
return r.json()
def find_movie(title: str) -> tuple:
"""Returns (imdb_id, tmdb_detail) or (None, None)."""
try:
results = _tmdb("/search/movie", query=title, language="en-US").get("results", [])
if not results:
return None, None
tid = results[0]["id"]
detail = _tmdb(f"/movie/{tid}")
return detail.get("imdb_id"), detail
except Exception:
return None, None
def find_tv(title: str) -> tuple:
"""Returns (imdb_id, tmdb_detail) or (None, None)."""
try:
results = _tmdb("/search/tv", query=title, language="en-US").get("results", [])
if not results:
return None, None
tid = results[0]["id"]
ext = _tmdb(f"/tv/{tid}/external_ids")
detail = _tmdb(f"/tv/{tid}")
return ext.get("imdb_id"), detail
except Exception:
return None, None
# ══════════════════════════════════════════════════════════════════
# CUSTOM MOVIES API
# ══════════════════════════════════════════════════════════════════
def fetch_custom(imdb_id: str) -> dict:
try:
r = requests.get(
"https://movies-api.accel.li/api/v2/movie_details.json",
params={"imdb_id": imdb_id},
timeout=10,
)
r.raise_for_status()
return r.json()
except Exception:
return {}
# ══════════════════════════════════════════════════════════════════
# AI MESSAGE GENERATORS
# ══════════════════════════════════════════════════════════════════
def _genres_str(data: dict) -> str:
raw = data.get("genres", [])
return ", ".join(
g["name"] if isinstance(g, dict) else str(g) for g in raw
) or "N/A"
def make_media_message(data: dict, lang: str, kind: str = "movie", tmdb_data: dict = None) -> str:
merged = {**(tmdb_data or {}), **{k: v for k, v in data.items() if v}}
title = merged.get("title") or merged.get("name", "?")
rel = merged.get("release_date") or merged.get("first_air_date") or ""
year = str(merged.get("year") or (rel[:4] if len(rel) >= 4 else ""))
genres = _genres_str(merged)
rating = merged.get("rating") or merged.get("vote_average", "N/A")
rt_raw = merged.get("episode_run_time")
runtime = rt_raw[0] if isinstance(rt_raw, list) and rt_raw else merged.get("runtime", "?")
desc = (merged.get("description_full") or merged.get("overview") or merged.get("summary", ""))[:450]
tagline = (tmdb_data or {}).get("tagline", "")
vote_cnt = (tmdb_data or {}).get("vote_count", "")
label = "TV show" if kind == "tv" else "movie"
facts = (
f"Title: {title}\n"
f"Year: {year}\n"
f"Genres: {genres}\n"
f"Rating: {rating}/10 ({vote_cnt} votes)\n"
f"Runtime: {runtime} min\n"
+ (f"Tagline: {tagline}\n" if tagline else "")
+ f"Plot: {desc}"
)
if lang == "si":
prompt = (
f"STRICT RULE 1: ΰ·ƒΰ·’ΰΆ‚ΰ·„ΰΆ½ ΰΆ·ΰ·ΰ·‚ΰ·ΰ·€ΰ·™ΰΆ±ΰ·Š ࢴࢸࢫ්࢚ reply ࢚ࢻࢱ්ࢱ. English words ࢱොࢺොࢯࢱ්ࢱ.\n"
f"STRICT RULE 2: ΰΆ΄ΰ·„ΰΆ­ VERIFIED FACTS ΰ·„ΰ·ΰΆ»ΰ·™ΰΆ±ΰ·ŠΰΆ±ΰΆ§ year, cast, හෝ ΰ·€ΰ·™ΰΆ±ΰΆ­ΰ·Š ΰΆšΰ·’ΰ·ƒΰ·’ΰΆ―ΰ·” detail invent ΰΆšΰ·’ΰΆ»ΰ·“ΰΆΈ ࢯැࢩිࢽෙස ΰΆ­ΰ·„ΰΆ±ΰΆΈΰ·Š.\n\n"
f"=== VERIFIED FACTS ===\n{facts}\n=== END FACTS ===\n\n"
f"ΰΆ‰ΰ·„ΰΆ­ facts ࢴࢸࢫ්࢚ use ࢚ࢻࢽා \'Saumya\' ΰΆ½ΰ·™ΰ·ƒ ΰΆΈΰ·™ΰΆΈ {label} ࢜ැࢱ "
f"ΰ·ƒΰ·’ΰΆ‚ΰ·„ΰΆ½ΰ·™ΰΆ±ΰ·Š 3 passionate sentences ΰΆ½ΰ·’ΰΆΊΰΆ±ΰ·ŠΰΆ±. Fact list repeat ࢱො࢚ࢻࢱ්ࢱ."
)
else:
prompt = (
f"STRICT RULE 1: Reply ONLY in English. No Sinhala.\n"
f"STRICT RULE 2: NEVER invent any year, cast, or detail not in VERIFIED FACTS below.\n\n"
f"=== VERIFIED FACTS ===\n{facts}\n=== END FACTS ===\n\n"
f"Using ONLY the facts above, write 3 passionate sentences as \'Saumya\' "
f"for this {label}. Do not repeat the fact list β€” bring the emotional hook."
)
result = _hf(prompt, max_tokens=320)
if result:
return result
if lang == "si":
return f"**{title}** ({year}) β€” {genres} | ⭐ {rating}/10\n\n{desc}"
return f"**{title}** ({year}) β€” {genres} | ⭐ {rating}/10\n\n{desc}"
def make_general_reply(query: str, lang: str) -> str:
if lang == "si":
prompt = (
f"IMPORTANT: ΰ·ƒΰ·’ΰΆ‚ΰ·„ΰΆ½ ΰΆ·ΰ·ΰ·‚ΰ·ΰ·€ΰ·™ΰΆ±ΰ·Š ࢴࢸࢫ්࢚ reply ࢚ࢻࢱ්ࢱ. English words ࢱොࢺොࢯࢱ්ࢱ.\n\n"
f"ΰΆ”ΰΆΆ \'Saumya\', ΰ·„ΰ·’ΰΆ­ΰ·€ΰΆ­ΰ·Š සිࢱࢸා AI ΰ·ƒΰ·„ΰ·ΰΆΊΰΆšΰΆΊΰ·™ΰΆšΰ·’. "
f"ΰΆ΄ΰ·„ΰΆ­ ΰΆ΄ΰ·Šβ€ΰΆ»ΰ·ΰ·ŠΰΆ±ΰΆΊΰΆ§ ΰ·ƒΰ·’ΰΆ‚ΰ·„ΰΆ½ΰ·™ΰΆ±ΰ·Š ࢴࢸࢫ්࢚ ΰΆšΰ·™ΰΆ§ΰ·’ හා ΰ·„ΰ·’ΰΆ­ΰ·€ΰΆ­ΰ·Š ΰΆ½ΰ·™ΰ·ƒ ΰΆ΄ΰ·’ΰ·…ΰ·’ΰΆ­ΰ·”ΰΆ»ΰ·” ΰΆ―ΰ·™ΰΆ±ΰ·ŠΰΆ±:\n{query}"
)
else:
prompt = (
f"IMPORTANT: Reply ONLY in English. Do not use any Sinhala.\n\n"
f"You are \'Saumya\', a friendly movie AI assistant. "
f"Answer the following concisely and helpfully in English only:\n{query}"
)
result = _hf(prompt, max_tokens=300)
if result:
return result
if lang == "si":
return "🎬 AI model ΰΆ§ΰ·’ΰΆšΰΆšΰ·Š busy. ΰ·†ΰ·’ΰΆ½ΰ·ŠΰΆΈΰ·Š ࢱࢸ්࢚ ΰΆšΰ·™ΰΆ½ΰ·’ΰΆ±ΰ·ŠΰΆΈ ΰΆšΰ·’ΰΆΊΰΆ±ΰ·ŠΰΆ±: *\"Interstellar ࢜ැࢱ ΰΆšΰ·’ΰΆΊΰΆ±ΰ·ŠΰΆ±\"* πŸš€"
return "🎬 AI model is warming up. Try a direct movie name: *\"Tell me about Interstellar\"* πŸš€"
def make_recommendation(query: str, lang: str) -> str:
if lang == "si":
prompt = (
f"IMPORTANT: ΰ·ƒΰ·’ΰΆ‚ΰ·„ΰΆ½ ΰΆ·ΰ·ΰ·‚ΰ·ΰ·€ΰ·™ΰΆ±ΰ·Š ࢴࢸࢫ්࢚ reply ࢚ࢻࢱ්ࢱ. English words ࢱොࢺොࢯࢱ්ࢱ.\n\n"
f"ΰΆ”ΰΆΆ \'Saumya\' සිࢱࢸා AI ΰ·ƒΰ·„ΰ·ΰΆΊΰΆšΰΆΊΰ·™ΰΆšΰ·’. "
f"\'{query}\' ࢜ැࢱ ΰΆ‡ΰ·ƒΰ·– ΰΆ΄ΰ·Šβ€ΰΆ»ΰ·ΰ·ŠΰΆ±ΰΆΊΰΆ§ ΰΆœΰ·ΰ·…ΰΆ΄ΰ·™ΰΆ± ΰ·„ΰ·œΰΆ³ ΰ·†ΰ·’ΰΆ½ΰ·ŠΰΆΈΰ·Š හෝ TV show 3්࢚ "
f"ΰ·ƒΰ·’ΰΆ‚ΰ·„ΰΆ½ΰ·™ΰΆ±ΰ·Š ࢴࢸࢫ්࢚ ΰΆ½ΰ·’ΰ·ƒΰ·ŠΰΆ§ΰ·Š ΰΆ†ΰΆšΰ·ΰΆ»ΰΆΊΰ·™ΰΆ±ΰ·Š recommend ࢚ࢻࢱ්ࢱ."
)
else:
prompt = (
f"IMPORTANT: Reply ONLY in English. Do not use any Sinhala.\n\n"
f"You are \'Saumya\', an expert movie curator. "
f"Based on \'{query}\', recommend 3 great movies or TV shows "
f"with a brief reason for each. Friendly list format, English only."
)
return _hf(prompt, max_tokens=380) or (
"ΰΆ»ΰ·’ΰΆšΰΆΈΰ·™ΰΆ±ΰ·ŠΰΆ©ΰ·Š ΰΆ½ΰ·’ΰ·ƒΰ·ŠΰΆ§ΰ·Š ΰΆ―ΰ·™ΰΆ±ΰ·ŠΰΆ± ࢢැࢻි වුࢱා, ΰΆ§ΰ·’ΰΆšΰΆšΰ·Š try ࢚ࢻࢱ්ࢱ࢚ෝ." if lang == "si"
else "Unable to generate recommendations right now. Please try again."
)
# ══════════════════════════════════════════════════════════════════
# CORE PIPELINE
# ══════════════════════════════════════════════════════════════════
def process(user_query: str) -> dict:
lang = detect_lang(user_query)
intent = classify_intent(user_query)
# ── About AI ─────────────────────────────────────────────────
if intent == "about_ai":
if lang == "si":
msg = (
"හෙࢽෝ! πŸ‘‹ ΰΆΈΰΆΈ **Saumya** β€” ΰΆ”ΰΆΆΰ·š AI ΰ·†ΰ·’ΰΆ½ΰ·ŠΰΆΈΰ·Š හා TV show ΰ·ƒΰ·„ΰ·ΰΆΊΰΆšΰΆΊΰ·! 🎬\n\n"
"ࢸාව ΰΆ±ΰ·’ΰΆ»ΰ·ŠΰΆΈΰ·ΰΆ«ΰΆΊ ΰΆšΰ·…ΰ·š **ΰ·ƒΰΆΈΰ·’ΰΆ­ΰ·Š ΰΆ©ΰ·’ΰΆ½ΰ·Šΰ·‚ΰ·ΰΆ±ΰ·Š** ΰ·€ΰ·’ΰ·ƒΰ·’ΰΆ±ΰ·’.\n\n"
"ΰ·†ΰ·’ΰΆ½ΰ·ŠΰΆΈΰ·Š හෝ TV show ࢜ැࢱ ΰΆ•ΰΆ±ΰ·‘ΰΆΈ ΰΆ―ΰ·™ΰΆΊΰΆšΰ·Š Sinhala හෝ English ΰ·€ΰΆ½ΰ·’ΰΆ±ΰ·Š ΰΆ‡ΰ·„ΰ·”ΰ·€ΰ·œΰΆ­ΰ·Š "
"ࢸࢸ࢜ේ ΰ·„ΰ·œΰΆ³ΰΆΈ info ࢽࢢා ΰΆ―ΰ·™ΰΆ±ΰ·ŠΰΆ±ΰΆΈΰ·Š! 😊"
)
else:
msg = (
"Hey there! πŸ‘‹ I'm **Saumya** β€” your AI Movie & TV Show companion! 🎬\n\n"
"I was crafted by **Samith Dilshan**.\n\n"
"Ask me about any movie or TV show in **English or Sinhala πŸ‡±πŸ‡°** "
"and I'll hook you up with everything you need! 😊"
)
return {"intent": "about_ai", "response_language": lang, "message": msg}
# ── Code request ──────────────────────────────────────────────
if intent == "code_request":
if lang == "si":
msg = (
"πŸ˜… ΰΆ’ ΰΆ§ΰ·’ΰΆšΰΆ§ Saumya ΰΆœΰ·™ expertise ΰΆ±ΰ·™ΰ·€ΰ·™ΰΆΊΰ·’! "
"Code, programming, tech help ࢜ࢱ්ࢱ **GitHub Copilot**, "
"**ChatGPT**, හෝ **Claude** try ࢚ࢻࢱ්ࢱ.\n\n"
"ΰ·†ΰ·’ΰΆ½ΰ·ŠΰΆΈΰ·Š recommendation ΰΆ•ΰΆ±ΰ·š ࢱࢸ් ࢸࢸේ ΰ·…ΰΆŸ ΰΆ‰ΰΆ±ΰ·ŠΰΆ±ΰ·€! 🎬"
)
else:
msg = (
"πŸ˜… Coding is a little outside my lane β€” I live and breathe movies & TV!\n\n"
"For coding help, check out **GitHub Copilot**, **ChatGPT**, or **Claude**.\n\n"
"But if you need a movie pick for tonight? That's ALL me! 🎬"
)
return {"intent": "code_request", "response_language": lang, "message": msg}
# ── Recommendation ────────────────────────────────────────────
if intent == "recommend":
msg = make_recommendation(user_query, lang)
return {"intent": "recommend", "response_language": lang, "message": msg}
# ── Movie / TV Show ───────────────────────────────────────────
if intent in ("movie", "tv"):
title = extract_title(user_query, intent)
if intent == "tv":
imdb_id, tmdb_data = find_tv(title)
else:
imdb_id, tmdb_data = find_movie(title)
if not imdb_id:
err = (
"ΰΆšΰΆ«ΰΆœΰ·ΰΆ§ΰ·”ΰΆΊΰ·’, ΰΆ’ ΰ·†ΰ·’ΰΆ½ΰ·ŠΰΆΈΰ·Š/show ΰ·„ΰ·œΰΆΊΰ·ΰΆœΰΆ±ΰ·ŠΰΆ± ࢢැࢻි වුࢱා 😒 ΰΆ±ΰΆΈ ΰΆ±ΰ·’ΰ·€ΰΆ»ΰΆ―ΰ·’ΰ·€ ΰΆ½ΰ·’ΰ·€ΰ·Šΰ·€ΰ·ΰΆ―?"
if lang == "si" else
"Sorry, couldn't find that movie/show 😒 Could you double-check the title?"
)
return {"intent": intent, "response_language": lang, "error": err}
# Always fetch both custom API AND TMDB β€” merge for best data
custom = fetch_custom(imdb_id)
use_custom = bool(custom and custom.get("id", 0) > 0 and custom.get("title"))
# TMDB poster is always preferred (higher quality)
tmdb_poster_path = (tmdb_data or {}).get("poster_path", "")
tmdb_poster = f"https://image.tmdb.org/t/p/w500{tmdb_poster_path}" if tmdb_poster_path else ""
if use_custom:
media_data = custom
# Prefer TMDB poster, fall back to custom API poster
poster = tmdb_poster or custom.get("large_cover_image", "")
title_display = custom.get("title", title)
genres = custom.get("genres", [])
torrents = custom.get("torrents") or []
dl_links = [
{
"quality": t.get("quality", "?"),
"url": t.get("url", ""),
"size": t.get("size", "?"),
}
for t in torrents if isinstance(t, dict)
]
else:
media_data = tmdb_data or {}
poster = tmdb_poster
title_display = (tmdb_data or {}).get("title") or (tmdb_data or {}).get("name", title)
genres_raw = (tmdb_data or {}).get("genres", [])
genres = [g["name"] for g in genres_raw if isinstance(g, dict)]
dl_links = []
# Always pass tmdb_data separately so AI gets rich plot/runtime facts
ai_msg = make_media_message(media_data, lang, intent, tmdb_data=tmdb_data)
return {
"intent": intent,
"poster": poster,
"movie_name": title_display,
"categories": genres,
"download_links": dl_links,
"response_language": lang,
"message": ai_msg,
}
# ── General β€” try movie lookup first before free-text AI ────────
# Even "general" queries might mention a film (e.g. "when was Inception released?")
title_guess = extract_title(user_query, "movie")
imdb_id_g, tmdb_data_g = find_movie(title_guess) if title_guess else (None, None)
if imdb_id_g:
custom_g = fetch_custom(imdb_id_g)
use_cg = bool(custom_g and custom_g.get("id", 0) > 0 and custom_g.get("title"))
media_data_g = custom_g if use_cg else (tmdb_data_g or {})
tmdb_pp = (tmdb_data_g or {}).get("poster_path", "")
poster_g = f"https://image.tmdb.org/t/p/w500{tmdb_pp}" if tmdb_pp else (custom_g or {}).get("large_cover_image", "")
title_disp = (media_data_g.get("title") or media_data_g.get("name") or title_guess)
genres_raw = (tmdb_data_g or {}).get("genres", [])
genres_g = custom_g.get("genres", []) if use_cg else [g["name"] for g in genres_raw if isinstance(g, dict)]
torrents_g = (custom_g or {}).get("torrents") or []
dl_g = [{"quality": t.get("quality","?"), "url": t.get("url",""), "size": t.get("size","?")} for t in torrents_g if isinstance(t, dict)]
ai_msg_g = make_media_message(media_data_g, lang, "movie", tmdb_data=tmdb_data_g)
return {
"intent": "movie", "poster": poster_g, "movie_name": title_disp,
"categories": genres_g, "download_links": dl_g,
"response_language": lang, "message": ai_msg_g,
}
msg = make_general_reply(user_query, lang)
return {"intent": "general", "response_language": lang, "message": msg}
# ══════════════════════════════════════════════════════════════════
# GRADIO CHAT FORMATTER
# ══════════════════════════════════════════════════════════════════
def format_for_gradio(result: dict) -> str:
if "error" in result:
return f"❌ {result['error']}"
intent = result.get("intent", "general")
msg = result.get("message", "")
if intent not in ("movie", "tv"):
return msg
lines = []
if result.get("poster"):
lines.append(f"<img src='{result['poster']}' style='max-width:220px;border-radius:10px;float:right;margin:0 0 12px 16px'>")
if result.get("movie_name"):
label = "πŸ“Ί" if intent == "tv" else "🎬"
lines.append(f"## {label} {result['movie_name']}")
if result.get("categories"):
lines.append(f"🎭 **{' &nbsp;·&nbsp; '.join(result['categories'])}**\n")
lines.append(msg)
dl = result.get("download_links", [])
if dl:
lines.append("\n---\n### πŸ“₯ Download Links")
for item in dl[:6]:
q = item.get("quality", "?")
s = item.get("size", "?")
u = item.get("url", "#")
lines.append(f"- **[{q} &nbsp;Β·&nbsp; {s}]({u})**")
elif intent == "tv":
lines.append(
"\n_ℹ️ Direct download links are not available for TV shows "
"via our database β€” check streaming services instead._"
)
return "\n".join(lines)
# ══════════════════════════════════════════════════════════════════
# FastAPI APPLICATION
# ══════════════════════════════════════════════════════════════════
api = FastAPI(
title="Saumya API",
description="Multilingual (EN/SI) Movie & TV Show AI Assistant by Samith Dilshan",
version="2.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
)
class QueryBody(BaseModel):
query: Optional[str] = None
user_query: Optional[str] = None
message: Optional[str] = None
class ToggleBody(BaseModel):
admin_key: str
enabled: Optional[bool] = None
def _resolve_query(body: QueryBody) -> str:
q = body.query or body.user_query or body.message
if not q:
raise HTTPException(400, "Provide 'query', 'user_query', or 'message'.")
return q
# ── GET ──────────────────────────────────────────────────────────
@api.get("/api/chat")
async def chat_get(q: str = Query(..., description="Query in English or Sinhala")):
return JSONResponse(process(q))
@api.get("/api/movie")
async def movie_get(
user_query: Optional[str] = Query(None),
imdb_id: Optional[str] = Query(None),
):
if imdb_id:
custom = fetch_custom(imdb_id)
if not custom or custom.get("id", 0) == 0:
return JSONResponse({"error": "Movie not found."}, status_code=404)
lang = "en"
ts = custom.get("torrents") or []
return JSONResponse({
"poster": custom.get("large_cover_image", ""),
"movie_name": custom.get("title"),
"categories": custom.get("genres", []),
"download_links": [
{"quality": t.get("quality"), "url": t.get("url"), "size": t.get("size")}
for t in ts if isinstance(t, dict)
],
"response_language": lang,
"message": make_media_message(custom, lang),
})
if not user_query:
raise HTTPException(400, "Provide user_query or imdb_id.")
return JSONResponse(process(user_query))
@api.get("/api/status")
async def status_get():
return {
"assistant": "Saumya",
"created_by": "Samith Dilshan",
"version": "2.0.0",
"gradio": _gradio_enabled,
"status": "online",
"supported": ["movies", "tv_shows", "recommendations", "general_qa"],
"languages": ["en", "si"],
}
@api.get("/health")
async def health():
return {"status": "ok"}
@api.get("/api/debug")
async def debug():
"""Quick diagnostic β€” checks token + pings first model."""
token_ok = bool(HF_TOKEN and HF_TOKEN != "YOUR_HF_TOKEN")
tmdb_ok = bool(TMDB_API_KEY and TMDB_API_KEY != "YOUR_TMDB_API_KEY")
model_status = {}
if token_ok:
for name, url in _HF_MODELS:
try:
r = requests.post(
url,
headers={"Authorization": f"Bearer {HF_TOKEN}", "Content-Type": "application/json"},
json={
"model": name,
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 5,
"stream": False,
},
timeout=20,
)
if r.status_code in (503, 429):
model_status[name] = "⏳ loading / rate-limited β€” retry in ~20s"
elif r.status_code == 200:
model_status[name] = "βœ… online"
else:
model_status[name] = f"❌ HTTP {r.status_code}: {r.text[:150]}"
except Exception as e:
model_status[name] = f"❌ {e}"
else:
model_status = {"all": "skipped β€” HF_TOKEN not set"}
return {
"HF_TOKEN_set": token_ok,
"TMDB_KEY_set": tmdb_ok,
"gradio_enabled": _gradio_enabled,
"models": model_status,
}
# ── POST ─────────────────────────────────────────────────────────
@api.post("/api/chat")
async def chat_post(body: QueryBody):
return JSONResponse(process(_resolve_query(body)))
@api.post("/api/movie")
async def movie_post(body: QueryBody):
return JSONResponse(process(_resolve_query(body)))
@api.post("/api/admin/gradio-toggle")
async def gradio_toggle(body: ToggleBody):
global _gradio_enabled
if body.admin_key != ADMIN_KEY:
raise HTTPException(403, "Invalid admin key.")
_gradio_enabled = body.enabled if body.enabled is not None else not _gradio_enabled
return {
"gradio_enabled": _gradio_enabled,
"message": f"Gradio {'enabled βœ…' if _gradio_enabled else 'disabled πŸ”΄'}",
}
# ══════════════════════════════════════════════════════════════════
# GRADIO INTERFACE (cinema dark theme)
# ══════════════════════════════════════════════════════════════════
_CSS = """
/* ── Base & fonts ── */
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:wght@300;400;500;600&display=swap');
body, .gradio-container {
background: #0d0d0f !important;
font-family: 'DM Sans', sans-serif !important;
}
footer { display: none !important; }
/* ── Header ── */
#saumya-header {
text-align: center;
padding: 28px 0 10px;
background: linear-gradient(180deg, #1a0a2e 0%, #0d0d0f 100%);
border-bottom: 1px solid #2a1a4a;
margin-bottom: 12px;
}
#saumya-header h1 {
font-family: 'Bebas Neue', sans-serif;
font-size: 3.4em;
letter-spacing: 6px;
background: linear-gradient(135deg, #c084fc 20%, #f472b6 80%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin: 0 0 4px;
}
#saumya-header .sub {
color: #9ca3af;
font-size: 0.92em;
font-weight: 300;
letter-spacing: 1px;
}
#saumya-header .creator {
color: #7c3aed;
font-weight: 600;
}
/* ── Chat bubbles ── */
.message.user {
background: linear-gradient(135deg, #3b1f6e 0%, #1e1040 100%) !important;
border: 1px solid #5b21b6 !important;
border-radius: 14px 14px 4px 14px !important;
color: #e9d5ff !important;
}
.message.bot {
background: linear-gradient(135deg, #111827 0%, #1c1030 100%) !important;
border: 1px solid #374151 !important;
border-radius: 14px 14px 14px 4px !important;
color: #e5e7eb !important;
}
.message.bot a { color: #c084fc !important; }
.message.bot img { border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,.5); }
/* ── Input bar ── */
.input-row textarea, #input-row textarea {
background: #1a1a2e !important;
border: 1px solid #3b1f6e !important;
color: #e5e7eb !important;
border-radius: 12px !important;
}
.input-row textarea::placeholder { color: #6b7280 !important; }
.input-row button { border-radius: 10px !important; }
/* ── Example pills ── */
.examples-row .example {
background: #1a1a2e !important;
border: 1px solid #3b1f6e !important;
color: #c084fc !important;
border-radius: 20px !important;
font-size: 0.82em !important;
transition: all .2s;
}
.examples-row .example:hover {
background: #2d1b69 !important;
border-color: #7c3aed !important;
}
/* ── Footer bar ── */
#saumya-footer {
text-align: center;
padding: 14px;
color: #4b5563;
font-size: 0.78em;
border-top: 1px solid #1f2937;
margin-top: 10px;
letter-spacing: .3px;
}
#saumya-footer code {
background: #1a1a2e;
color: #a78bfa;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
"""
def _chat_fn(message: str, history: list) -> str:
if not _gradio_enabled:
return (
"πŸ”§ **Saumya is temporarily offline for maintenance.**\n\n"
"You can still use the REST API endpoints β€” see `/api/docs`."
)
return format_for_gradio(process(message))
with gr.Blocks(title="Saumya 🎬 · Movie AI") as gradio_ui:
# Inject CSS via <style> tag β€” Gradio 6 requires css in launch(), but
# since we use mount_gradio_app (no launch call), we inject it here.
gr.HTML(f"<style>{_CSS}</style>")
gr.HTML("""
<div id="saumya-header">
<h1>SAUMYA</h1>
<div class="sub">
🎬 AI Movie &amp; TV Show Assistant
&nbsp;&nbsp;Β·&nbsp;&nbsp;
Created by <span class="creator">Samith Dilshan</span>
&nbsp;&nbsp;Β·&nbsp;&nbsp;
πŸ‡±πŸ‡° English &amp; Sinhala
</div>
</div>
""")
gr.ChatInterface(
fn=_chat_fn,
# type="messages" removed β€” Gradio 6 uses dict-based history by default
examples=[
"I want to watch Interstellar",
"Tell me about Breaking Bad",
"What is Oppenheimer about?",
"Recommend a good horror movie",
"Who are you?",
"ΰΆΈΰΆ§ Inception ΰ·†ΰ·’ΰΆ½ΰ·ŠΰΆΈΰ·Š ΰΆ‘ΰΆš ࢜ැࢱ ΰΆšΰ·’ΰΆΊΰΆ±ΰ·ŠΰΆ±",
"ΰ·„ΰ·œΰΆ³ action ΰ·†ΰ·’ΰΆ½ΰ·ŠΰΆΈΰ·Š 3්࢚ recommend ࢚ࢻࢱ්ࢱ",
"Stranger Things series ࢜ැࢱ ΰΆšΰ·’ΰΆΊΰΆ±ΰ·ŠΰΆ±",
],
) # retry_btn/undo_btn/clear_btn/submit_btn removed in Gradio 6
gr.HTML("""
<div id="saumya-footer">
πŸ”Œ &nbsp;
<b>REST API:</b> &nbsp;
<code>GET /api/chat?q=Inception</code> &nbsp;|&nbsp;
<code>POST /api/movie {"user_query":"..."}</code> &nbsp;|&nbsp;
<code>GET /api/status</code> &nbsp;|&nbsp;
<code>GET /api/docs</code>
</div>
""")
# ══════════════════════════════════════════════════════════════════
# MOUNT GRADIO ON FastAPI + LAUNCH
# ══════════════════════════════════════════════════════════════════
app = gr.mount_gradio_app(api, gradio_ui, path="/")
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=7860)