kv-netflix/backend/routers/catalog.py

675 lines
24 KiB
Python

"""
Catalog Router
Movie catalog and streaming endpoints for ophim/PhimMoiChill integration
"""
import asyncio
import aiohttp
import ssl
import re
from typing import Optional, Dict, List
from fastapi import APIRouter, HTTPException, Depends, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from cache import cache
from config import settings
from security import verify_hmac
from logging_config import get_logger
logger = get_logger("catalog")
router = APIRouter(prefix="/api/rophim", tags=["catalog"])
# Category slug mappings
CATEGORY_MAP = {
# Main categories
'movies': 'danh-sach/phim-le',
'series': 'danh-sach/phim-bo',
'tv-shows': 'danh-sach/phim-bo',
'animation': 'danh-sach/hoat-hinh',
'cinema': 'danh-sach/phim-chieu-rap',
# Vietnamese slugs (passthrough)
'phim-le': 'danh-sach/phim-le',
'phim-bo': 'danh-sach/phim-bo',
'phim-moi': 'danh-sach/phim-moi-cap-nhat',
'phim-moi-cap-nhat': 'danh-sach/phim-moi-cap-nhat',
'hoat-hinh': 'danh-sach/hoat-hinh',
'phim-chieu-rap': 'danh-sach/phim-chieu-rap',
# New/trending/popular
'trending': 'danh-sach/phim-moi-cap-nhat',
'new': 'danh-sach/phim-le',
'popular': 'danh-sach/phim-le',
'all': 'danh-sach/phim-le',
# Genre categories
'action': 'the-loai/hanh-dong',
'comedy': 'the-loai/hai-huoc',
'drama': 'the-loai/chinh-kich',
'horror': 'the-loai/kinh-di',
'romance': 'the-loai/tinh-cam',
'scifi': 'the-loai/vien-tuong',
# Country categories
'korean': 'quoc-gia/han-quoc',
'han-quoc': 'quoc-gia/han-quoc',
'usa': 'quoc-gia/au-my',
'au-my': 'quoc-gia/au-my',
'china': 'quoc-gia/trung-quoc',
'trung-quoc': 'quoc-gia/trung-quoc',
'japan': 'quoc-gia/nhat-ban',
'nhat-ban': 'quoc-gia/nhat-ban',
'thailand': 'quoc-gia/thai-lan',
'thai-lan': 'quoc-gia/thai-lan',
'vietnam': 'quoc-gia/viet-nam',
'viet-nam': 'quoc-gia/viet-nam',
'my': 'quoc-gia/au-my',
'hong-kong': 'quoc-gia/hong-kong',
'dai-loan': 'quoc-gia/dai-loan',
'an-do': 'quoc-gia/an-do',
# Additional genre mappings
'hanh-dong': 'the-loai/hanh-dong',
'kinh-di': 'the-loai/kinh-di',
'tinh-cam': 'the-loai/tinh-cam',
'vien-tuong': 'the-loai/vien-tuong',
'hai-huoc': 'the-loai/hai-huoc',
'han-quoc-hits': 'quoc-gia/han-quoc',
'phieu-luu': 'the-loai/phieu-luu',
'vo-thuat': 'the-loai/vo-thuat',
'hinh-su': 'the-loai/hinh-su',
'tai-lieu': 'the-loai/tai-lieu',
'gia-dinh': 'the-loai/gia-dinh',
'co-trang': 'the-loai/co-trang',
'hoc-duong': 'the-loai/hoc-duong',
'tam-ly': 'the-loai/tam-ly',
'than-thoai': 'the-loai/than-thoai',
'chien-tranh': 'the-loai/chien-tranh',
'the-thao': 'the-loai/the-thao',
'am-nhac': 'the-loai/am-nhac',
}
def _get_ssl_connector():
"""Create SSL connector with verification disabled for ophim"""
ssl_ctx = ssl.create_default_context()
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
return aiohttp.TCPConnector(ssl=ssl_ctx)
def _parse_movie_item(item: Dict) -> Dict:
"""Parse a movie item from ophim API response"""
tmdb_data = item.get('tmdb', {})
imdb_data = item.get('imdb', {})
tmdb_rating = tmdb_data.get('vote_average', 0) or 0
imdb_rating = imdb_data.get('vote_average', 0) or 0
best_rating = max(tmdb_rating, imdb_rating)
return {
'id': item.get('slug', ''),
'title': item.get('name', ''),
'original_title': item.get('origin_name'),
'slug': item.get('slug', ''),
'thumbnail': f"https://img.ophim.live/uploads/movies/{item.get('thumb_url', '')}",
'poster_url': f"https://img.ophim.live/uploads/movies/{item.get('poster_url', '')}",
'year': item.get('year'),
'quality': item.get('quality', 'HD'),
'duration': item.get('time'),
'category': item.get('type', 'single'),
'tmdb_rating': tmdb_rating,
'imdb_rating': imdb_rating,
'rating': best_rating,
'vote_count': tmdb_data.get('vote_count', 0),
'genres': [cat.get('name') for cat in item.get('category', [])],
'country': [c.get('name') for c in item.get('country', [])],
'modified': item.get('modified', {}).get('time'),
'episode_current': item.get('episode_current'),
'lang': item.get('lang'),
}
def _resolve_category_slug(category: Optional[str]) -> str:
"""Resolve category to ophim API slug"""
if not category:
return 'danh-sach/phim-le'
# Use mapped slug or fallback to input as-is
slug = CATEGORY_MAP.get(category, f'danh-sach/{category}')
# If category starts with known prefixes, use as-is
if category.startswith(('danh-sach/', 'the-loai/', 'quoc-gia/')):
slug = category
return slug
@router.get("/catalog")
async def get_catalog(
category: Optional[str] = None,
page: int = Query(1, ge=1),
limit: int = Query(24, ge=1, le=50),
sort: str = Query("modified", description="Sort by: modified, year, rating"),
authorized: bool = Depends(verify_hmac)
):
"""Get movie catalog from ophim API with sorting support."""
# Check cache first
cache_key = f"catalog:{category}:{page}:{limit}:{sort}"
cached = cache.get(cache_key)
if cached:
return cached
slug = _resolve_category_slug(category)
try:
connector = _get_ssl_connector()
async with aiohttp.ClientSession(connector=connector) as session:
api_url = f"https://ophim1.com/v1/api/{slug}?page={page}"
async with session.get(api_url, timeout=aiohttp.ClientTimeout(total=settings.request_timeout)) as resp:
if resp.status != 200:
logger.warning(f"Slug {slug} failed ({resp.status}), falling back to phim-le")
api_url = f"https://ophim1.com/v1/api/danh-sach/phim-le?page={page}"
async with session.get(api_url) as fallback_resp:
data = await fallback_resp.json()
else:
data = await resp.json()
items = data.get('data', {}).get('items', [])
movies = [_parse_movie_item(item) for item in items]
# Apply sorting
if sort == 'year':
movies.sort(key=lambda x: x.get('year') or 0, reverse=True)
elif sort == 'rating':
movies.sort(key=lambda x: x.get('rating') or 0, reverse=True)
result = {
"movies": movies[:limit],
"page": page,
"category": category or 'movies',
"sort": sort,
"total": len(movies)
}
# Cache for 1 hour
cache.set(cache_key, result, ttl=settings.cache_catalog_ttl)
return result
except aiohttp.ClientError as e:
logger.error(f"Failed to fetch catalog: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch catalog: {str(e)}")
@router.get("/search")
async def search_movies(
q: str = Query(..., min_length=1),
limit: int = Query(20, ge=1, le=50),
authorized: bool = Depends(verify_hmac)
):
"""Search movies by title AND actors using ophim API"""
movies = []
seen_slugs = set()
connector = _get_ssl_connector()
def add_movie(item):
"""Helper to add movie avoiding duplicates"""
slug = item.get('slug', '')
if slug and slug not in seen_slugs:
seen_slugs.add(slug)
movies.append({
'id': slug,
'title': item.get('name', ''),
'original_title': item.get('origin_name'),
'slug': slug,
'thumbnail': f"https://img.ophim.live/uploads/movies/{item.get('thumb_url', '')}",
'backdrop': f"https://img.ophim.live/uploads/movies/{item.get('poster_url', '')}",
'year': item.get('year'),
'rating': None,
'duration': None,
'quality': item.get('quality', 'HD'),
'genre': None,
'description': None,
'category': item.get('type', 'movies')
})
async with aiohttp.ClientSession(connector=connector) as session:
# Search by movie title (primary)
try:
api_url = f"https://ophim1.com/v1/api/tim-kiem?keyword={q}&limit={limit}"
async with session.get(api_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
if resp.status == 200:
data = await resp.json()
items = data.get('data', {}).get('items', [])
for item in items:
add_movie(item)
except Exception as e:
logger.warning(f"Title search failed: {e}")
# Search by actor name (secondary)
if len(movies) < limit:
try:
actor_slug = q.lower().replace(' ', '-')
actor_url = f"https://ophim1.com/v1/api/danh-sach/dien-vien/{actor_slug}"
async with session.get(actor_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
if resp.status == 200:
data = await resp.json()
items = data.get('data', {}).get('items', [])
for item in items:
if len(movies) >= limit:
break
add_movie(item)
except Exception as e:
logger.warning(f"Actor search failed: {e}")
# Fallback to phimmoichill scraper if no results
if not movies:
from rophim_scraper import RophimScraper
try:
scraper = RophimScraper()
try:
results = await scraper.search(q, limit)
movies = [movie.__dict__ for movie in results]
finally:
await scraper.close()
except Exception as e:
logger.warning(f"Scraper search failed: {e}")
return {"movies": movies[:limit], "total": len(movies)}
@router.get("/categories/discover")
async def discover_categories(authorized: bool = Depends(verify_hmac)):
"""Discover all available categories from PhimMoiChill"""
from category_discovery import get_categories
try:
categories = await get_categories()
totals = {cat_type: len(cat_list) for cat_type, cat_list in categories.items()}
return {
"categories": categories,
"totals": totals,
"total_categories": sum(totals.values())
}
except Exception as e:
logger.error(f"Failed to discover categories: {e}")
raise HTTPException(status_code=500, detail=f"Failed to discover categories: {str(e)}")
@router.get("/home/curated")
async def get_curated_homepage_sections(authorized: bool = Depends(verify_hmac)):
"""Get curated homepage sections with TOP RATED, NEW RELEASES, and popular genres."""
cache_key = "home:curated_v2"
cached = cache.get(cache_key)
if cached:
return cached
connector = _get_ssl_connector()
async def fetch_section(session, title: str, slug: str, sort_key: str = None, limit: int = 15):
"""Fetch a single section"""
try:
api_url = f"https://ophim1.com/v1/api/{slug}?page=1"
async with session.get(api_url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
if resp.status != 200:
return None
data = await resp.json()
items = data.get('data', {}).get('items', [])
movies = [_parse_movie_item(item) for item in items[:30]]
# Apply sorting
if sort_key == 'rating':
movies.sort(key=lambda x: (x.get('rating') or 0, x.get('vote_count') or 0), reverse=True)
elif sort_key == 'year':
movies.sort(key=lambda x: x.get('year') or 0, reverse=True)
return {'title': title, 'key': slug, 'movies': movies[:limit]}
except Exception as e:
logger.warning(f"Error fetching {title}: {e}")
return None
try:
async with aiohttp.ClientSession(connector=connector) as session:
section_configs = [
("🏆 Top Rated Movies", "danh-sach/phim-le", "rating"),
("🎬 New Releases", "danh-sach/phim-le", "year"),
("📺 Top Rated Series", "danh-sach/phim-bo", "rating"),
("💥 Action & Adventure", "the-loai/hanh-dong", "rating"),
("😱 Horror & Thriller", "the-loai/kinh-di", "rating"),
("❤️ Romance", "the-loai/tinh-cam", "rating"),
("🎭 Drama", "the-loai/chinh-kich", "rating"),
("😂 Comedy", "the-loai/hai-huoc", "rating"),
("🌟 Sci-Fi & Fantasy", "the-loai/vien-tuong", "rating"),
("🎌 Animation & Anime", "danh-sach/hoat-hinh", "rating"),
("🇰🇷 Korean Movies", "quoc-gia/han-quoc", "rating"),
("🇺🇸 Western Movies", "quoc-gia/au-my", "rating"),
]
tasks = [fetch_section(session, title, slug, sort_key) for title, slug, sort_key in section_configs]
results = await asyncio.gather(*tasks)
sections = [r for r in results if r and r.get('movies')]
result = {"sections": sections, "total": len(sections)}
cache.set(cache_key, result, ttl=settings.cache_home_ttl)
return result
except Exception as e:
logger.error(f"Error fetching curated sections: {e}")
return {"sections": [], "error": str(e)}
@router.get("/stream/{slug}")
async def get_stream(
slug: str,
episode: int = Query(1, ge=1),
authorized: bool = Depends(verify_hmac)
):
"""Get video stream URL from ophim API for a specific slug and episode."""
from rophim_scraper import get_video_stream
try:
logger.debug(f"Processing stream request for {slug} ep {episode}")
stream_url = await get_video_stream(slug, episode=episode)
if not stream_url:
logger.warning(f"Stream not found for {slug}")
return JSONResponse(status_code=404, content={"detail": "Stream not found"})
return {"stream_url": stream_url, "episode": episode, "slug": slug}
except Exception as e:
logger.error(f"Error in get_stream: {e}")
return JSONResponse(status_code=500, content={"detail": str(e)})
class StreamRequest(BaseModel):
"""Request for video stream URL"""
slug: str = ""
source_url: str = ""
episode: int = 1
server: int = 0
@router.post("/stream")
async def get_stream_post(
request: StreamRequest,
authorized: bool = Depends(verify_hmac)
):
"""Get video stream URL (POST) - supports source_url if needed"""
import traceback
from rophim_scraper import get_video_stream
try:
slug = request.slug
if not slug and request.source_url:
# Extract slug from source_url
match = re.search(r'/phim/([^/\?]+)', request.source_url)
if match:
slug = match.group(1)
if not slug:
raise HTTPException(status_code=400, detail="Could not extract slug from URL")
stream_url = await get_video_stream(slug, episode=request.episode)
if not stream_url:
raise HTTPException(status_code=404, detail="Stream not found")
return JSONResponse(content={"stream_url": stream_url, "episode": request.episode, "slug": slug})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error in get_stream_post: {e}")
traceback.print_exc()
return JSONResponse(status_code=500, content={"detail": str(e)})
@router.get("/movie/{slug}")
async def get_movie_details(slug: str, authorized: bool = Depends(verify_hmac)):
"""Get detailed movie info from PhimMoiChill with optional TMDB enrichment"""
from rophim_scraper import get_movie_details as fetch_movie_details
try:
loop = asyncio.get_event_loop()
movie = await loop.run_in_executor(None, lambda: fetch_movie_details(slug))
if not movie:
raise HTTPException(status_code=404, detail="Movie not found")
# Clean up description field
desc = movie.get('description', '')
if desc and ('Trạng thái' in desc or 'Năm phát hành' in desc):
movie['description'] = None
# Try to enrich with TMDB data
try:
from tmdb_service import tmdb_service
enriched = await tmdb_service.enrich_movie_data(movie)
return enriched
except Exception as tmdb_error:
logger.warning(f"TMDB enrichment failed: {tmdb_error}")
return movie
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to fetch movie: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch movie: {str(e)}")
@router.get("/home/sections")
async def get_home_more_sections(
page: int = Query(1, ge=1),
view: str = Query('home'),
authorized: bool = Depends(verify_hmac)
):
"""Get paginated sections for homepage OR specific views (infinite scroll)."""
from category_scraper import PhimMoiChillCategoryScraper
scraper = PhimMoiChillCategoryScraper()
try:
if view == 'home':
if page < 2:
results = []
else:
results = await scraper.get_mixed_sections(page)
else:
results = await scraper.get_view_sections(view, page)
return {"sections": results, "page": page}
except Exception as e:
logger.error(f"Error fetching more sections: {e}")
return {"sections": [], "page": page}
finally:
await scraper.close()
@router.get("/category")
async def get_movies_by_category(
slug: str = Query(..., description="Category slug"),
page: int = Query(1, ge=1),
limit: int = Query(24, ge=1, le=50),
authorized: bool = Depends(verify_hmac)
):
"""Get movies for a specific category"""
from rophim_scraper import RophimScraper
try:
scraper = RophimScraper()
try:
results = await scraper.get_category(slug, page, limit)
movies = [movie.__dict__ for movie in results]
return {
"movies": movies,
"category": slug,
"page": page,
"total": len(movies)
}
finally:
await scraper.close()
except Exception as e:
logger.error(f"Failed to fetch category: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch category: {str(e)}")
@router.get("/categories/all")
async def get_all_categories(authorized: bool = Depends(verify_hmac)):
"""Get all themed category sections in one call"""
from category_scraper import get_categories_sync
try:
loop = asyncio.get_event_loop()
categories = await loop.run_in_executor(None, get_categories_sync)
return {
"categories": categories,
"total_sections": len(categories)
}
except Exception as e:
logger.error(f"Failed to fetch categories: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch categories: {str(e)}")
@router.get("/categories/hot")
async def get_hot_category(
limit: int = Query(24, ge=1, le=50),
authorized: bool = Depends(verify_hmac)
):
"""Get Hot Movies category"""
from category_scraper import PhimMoiChillCategoryScraper
try:
async def _fetch():
scraper = PhimMoiChillCategoryScraper()
try:
movies = await scraper.get_hot_movies(limit)
await scraper.close()
return movies
except Exception:
await scraper.close()
raise
loop = asyncio.get_event_loop()
movies = await loop.run_in_executor(None, lambda: asyncio.run(_fetch()))
return {"movies": movies, "category": "hot", "total": len(movies)}
except Exception as e:
logger.error(f"Failed to fetch hot movies: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch hot movies: {str(e)}")
@router.get("/categories/new-releases")
async def get_new_releases_category(
limit: int = Query(24, ge=1, le=50),
authorized: bool = Depends(verify_hmac)
):
"""Get New Releases category"""
from category_scraper import PhimMoiChillCategoryScraper
try:
async def _fetch():
scraper = PhimMoiChillCategoryScraper()
try:
movies = await scraper.get_new_releases(limit)
await scraper.close()
return movies
except Exception:
await scraper.close()
raise
loop = asyncio.get_event_loop()
movies = await loop.run_in_executor(None, lambda: asyncio.run(_fetch()))
return {"movies": movies, "category": "new_releases", "total": len(movies)}
except Exception as e:
logger.error(f"Failed to fetch new releases: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch new releases: {str(e)}")
@router.get("/categories/top10")
async def get_top10_category(authorized: bool = Depends(verify_hmac)):
"""Get Top 10 Most Watched"""
from category_scraper import PhimMoiChillCategoryScraper
try:
async def _fetch():
scraper = PhimMoiChillCategoryScraper()
try:
movies = await scraper.get_top_10()
await scraper.close()
return movies
except Exception:
await scraper.close()
raise
loop = asyncio.get_event_loop()
movies = await loop.run_in_executor(None, lambda: asyncio.run(_fetch()))
return {"movies": movies, "category": "top10", "total": len(movies)}
except Exception as e:
logger.error(f"Failed to fetch top 10: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch top 10: {str(e)}")
@router.get("/categories/cinema")
async def get_cinema_category(
limit: int = Query(24, ge=1, le=50),
authorized: bool = Depends(verify_hmac)
):
"""Get Cinema Releases category"""
from category_scraper import PhimMoiChillCategoryScraper
try:
async def _fetch():
scraper = PhimMoiChillCategoryScraper()
try:
movies = await scraper.get_cinema_releases(limit)
await scraper.close()
return movies
except Exception:
await scraper.close()
raise
loop = asyncio.get_event_loop()
movies = await loop.run_in_executor(None, lambda: asyncio.run(_fetch()))
return {"movies": movies, "category": "cinema", "total": len(movies)}
except Exception as e:
logger.error(f"Failed to fetch cinema releases: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch cinema releases: {str(e)}")
@router.post("/crawl/trigger")
async def trigger_crawl(
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=100),
authorized: bool = Depends(verify_hmac)
):
"""Trigger a movie catalog crawl."""
from rophim_scraper import get_movies
try:
loop = asyncio.get_event_loop()
movies = await loop.run_in_executor(None, lambda: get_movies(page, limit))
return {
"success": True,
"crawled_count": len(movies),
"page": page,
"message": f"Successfully crawled {len(movies)} movies"
}
except Exception as e:
logger.error(f"Crawl failed: {e}")
raise HTTPException(status_code=500, detail=f"Crawl failed: {str(e)}")
@router.get("/crawl/status")
async def crawl_status():
"""Get the last crawl status and timestamp"""
return {
"status": "ready",
"message": "Use POST /api/rophim/crawl/trigger to start a crawl"
}