""" StreamFlow Backend - FastAPI Application High-performance video streaming with yt-dlp integration """ from fastapi import FastAPI, HTTPException, Depends, Query from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, HttpUrl from typing import Optional, Dict, List import time import os from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse, Response from cache import cache from video_extractor import extractor, VideoInfo from database import init_db, get_db, VideoRepository, Video from security import verify_hmac from image_service import get_proxied_image # Initialize FastAPI app app = FastAPI( title="KV-Netflix API", description="Ad-free video streaming with movie catalog", version="1.0.0" ) # CORS middleware app.add_middleware( CORSMiddleware, allow_origins=[ "https://nf.khoavo.myds.me", "http://localhost:5173", "http://localhost:3000", "capacitor://localhost", "http://localhost" ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Request/Response models class ExtractRequest(BaseModel): url: str quality: Optional[str] = None # e.g., "1080p", "720p" class ExtractResponse(BaseModel): title: str thumbnail: str duration: int stream_url: str resolution: str cached: bool extraction_time_ms: int class VideoCreate(BaseModel): title: str source_url: str description: Optional[str] = None thumbnail: Optional[str] = None category: Optional[str] = None class VideoResponse(BaseModel): id: int title: str source_url: str thumbnail: Optional[str] duration: int resolution: Optional[str] category: Optional[str] class Config: from_attributes = True # Startup event @app.on_event("startup") async def startup(): init_db() print("โœ“ KV-Netflix Database initialized") # Auto-update check disabled on startup (can cause hangs) # Use POST /api/admin/update to manually trigger updates print("โ„น Use /api/admin/update to update dependencies") # Get images via proxy @app.get("/api/images/proxy") async def proxy_image(url: str, width: Optional[int] = None): """ Proxy and optimize images (WebP + Resizing) """ response = await get_proxied_image(url, width) if not response: raise HTTPException(status_code=404, detail="Image not found or could not be processed") return response # Health check @app.get("/api/health") async def health_check(): return { "status": "healthy", "cache_type": "redis" if cache.is_redis else "memory", "version": "1.0.0" } # ============================================ # Admin Endpoints - Version & Updates # ============================================ @app.get("/api/admin/version") async def get_versions(authorized: bool = Depends(verify_hmac)): """Get versions of all managed dependencies""" from auto_updater import get_all_versions import asyncio loop = asyncio.get_event_loop() versions = await loop.run_in_executor(None, get_all_versions) return { "status": "ok", "versions": versions } @app.post("/api/admin/update") async def trigger_update(package: str = None, authorized: bool = Depends(verify_hmac)): """Trigger manual update of dependencies Args: package: Specific package to update (yt-dlp, playwright, all) If not specified, updates all packages """ from auto_updater import update_yt_dlp, update_playwright, update_all_dependencies import asyncio loop = asyncio.get_event_loop() if package == "yt-dlp": success, msg = await loop.run_in_executor(None, update_yt_dlp) return {"package": "yt-dlp", "success": success, "message": msg} elif package == "playwright": success, msg = await loop.run_in_executor(None, update_playwright) return {"package": "playwright", "success": success, "message": msg} else: # Update all results = await loop.run_in_executor(None, update_all_dependencies) return { "status": "completed", "results": {pkg: {"success": s, "message": m} for pkg, (s, m) in results.items()} } # Video extraction endpoint @app.post("/api/extract", response_model=ExtractResponse) async def extract_video(request: ExtractRequest, authorized: bool = Depends(verify_hmac)): """ Extract video stream URL from source. Uses cache-aside pattern with 3-hour TTL. """ start_time = time.time() # Check cache first cached_data = cache.get(f"video:{request.url}") if cached_data: extraction_time = int((time.time() - start_time) * 1000) return ExtractResponse( title=cached_data['title'], thumbnail=cached_data['thumbnail'], duration=cached_data['duration'], stream_url=cached_data['stream_url'], resolution=cached_data['resolution'], cached=True, extraction_time_ms=extraction_time ) # Cache miss - extract with yt-dlp try: video_info = await extractor.extract(request.url, request.quality) # Cache the result cache.set(f"video:{request.url}", { 'title': video_info.title, 'thumbnail': video_info.thumbnail, 'duration': video_info.duration, 'stream_url': video_info.stream_url, 'resolution': video_info.resolution, }) extraction_time = int((time.time() - start_time) * 1000) return ExtractResponse( title=video_info.title, thumbnail=video_info.thumbnail, duration=video_info.duration, stream_url=video_info.stream_url, resolution=video_info.resolution, cached=False, extraction_time_ms=extraction_time ) except Exception as e: raise HTTPException(status_code=500, detail=f"Extraction failed: {str(e)}") # Get available qualities @app.get("/api/qualities") async def get_qualities(url: str, authorized: bool = Depends(verify_hmac)): """Get available quality options for a video""" try: qualities = await extractor.get_available_qualities(url) return {"qualities": qualities} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # Video CRUD endpoints @app.post("/api/videos", response_model=VideoResponse) async def create_video(video: VideoCreate, db=Depends(get_db), authorized: bool = Depends(verify_hmac)): """Add a video to the library""" repo = VideoRepository(db) # Check if already exists existing = repo.get_by_url(video.source_url) if existing: raise HTTPException(status_code=400, detail="Video already exists") new_video = repo.create(**video.dict()) return new_video @app.get("/api/videos", response_model=list[VideoResponse]) async def list_videos( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), category: Optional[str] = None, db=Depends(get_db), authorized: bool = Depends(verify_hmac) ): """List all videos with pagination""" repo = VideoRepository(db) if category: return repo.get_by_category(category, limit) return repo.get_all(skip, limit) @app.get("/api/videos/{video_id}", response_model=VideoResponse) async def get_video(video_id: int, db=Depends(get_db), authorized: bool = Depends(verify_hmac)): """Get video by ID""" repo = VideoRepository(db) video = repo.get_by_id(video_id) if not video: raise HTTPException(status_code=404, detail="Video not found") return video @app.delete("/api/videos/{video_id}") async def delete_video(video_id: int, db=Depends(get_db), authorized: bool = Depends(verify_hmac)): """Delete video from library""" repo = VideoRepository(db) if repo.delete(video_id): return {"message": "Video deleted"} raise HTTPException(status_code=404, detail="Video not found") # Search endpoint @app.get("/api/search", response_model=list[VideoResponse]) async def search_videos( q: str = Query(..., min_length=1), limit: int = Query(20, ge=1, le=50), db=Depends(get_db), authorized: bool = Depends(verify_hmac) ): """Search videos by title""" repo = VideoRepository(db) return repo.search(q, limit) # ============================================ # PhimMoiChill Integration Endpoints (using Playwright crawler) # ============================================ @app.get("/api/rophim/catalog") async def get_phimmoichill_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 import aiohttp import ssl # Map categories to ophim slugs 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', # Updated to distinct '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', # Distinct 'new': 'danh-sach/phim-le', # Default to movies '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 mappings for main.js categories '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', 'than-thoai': 'the-loai/than-thoai', 'hoc-duong': 'the-loai/hoc-duong', } # Use mapped slug or fallback to input as-is (for advanced users) slug = category_map.get(category, f'danh-sach/{category}') if category else 'danh-sach/phim-le' # If category starts with known prefixes, use as-is if category and (category.startswith('danh-sach/') or category.startswith('the-loai/') or category.startswith('quoc-gia/')): slug = category try: ssl_ctx = ssl.create_default_context() ssl_ctx.check_hostname = False ssl_ctx.verify_mode = ssl.CERT_NONE connector = aiohttp.TCPConnector(ssl=ssl_ctx) async with aiohttp.ClientSession(connector=connector) as session: # Use ophim JSON API api_url = f"https://ophim1.com/v1/api/{slug}?page={page}" async with session.get(api_url, timeout=aiohttp.ClientTimeout(total=15)) as resp: if resp.status != 200: # Fallback to general movies if specific slug fails print(f"Warning: slug {slug} failed ({resp.status}), falling back...") 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', []) # Parse movies with full metadata including ratings movies = [] for item in items: tmdb_data = item.get('tmdb', {}) imdb_data = item.get('imdb', {}) # Get the best available rating 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) movies.append({ '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'), }) # 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) # 'modified' is already the default sort from API result = { "movies": movies[:limit], "page": page, "category": category or 'movies', "sort": sort, "total": len(movies) } # Cache for 1 hour (3600s) cache.set(cache_key, result, ttl=3600) return result except aiohttp.ClientError as e: raise HTTPException(status_code=500, detail=f"Failed to fetch catalog: {str(e)}") @app.get("/api/rophim/search") async def search_phimmoichill( 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""" import aiohttp import ssl movies = [] seen_slugs = set() ssl_ctx = ssl.create_default_context() ssl_ctx.check_hostname = False ssl_ctx.verify_mode = ssl.CERT_NONE connector = aiohttp.TCPConnector(ssl=ssl_ctx) 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: # 1. 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: print(f"Title search failed: {e}") # 2. Search by actor name (secondary) if len(movies) < limit: try: # ophim actor search endpoint 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: print(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: print(f"Scraper search failed: {e}") return { "movies": movies[:limit], "total": len(movies) } @app.get("/api/rophim/categories/discover") async def discover_categories(authorized: bool = Depends(verify_hmac)): """ Discover all available categories from PhimMoiChill Returns types, genres, countries, and years """ from category_discovery import get_categories try: categories = await get_categories() # Count total movies per category type 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: raise HTTPException(status_code=500, detail=f"Failed to discover categories: {str(e)}") @app.get("/api/rophim/category") async def get_movies_by_category( slug: str = Query(..., description="Category slug (e.g., 'the-loai/hanh-dong', 'danh-sach/phim-le')"), 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 Examples: ?slug=phim-le, ?slug=the-loai/hanh-dong, ?slug=quoc-gia/han-quoc """ from rophim_scraper import RophimScraper try: scraper = RophimScraper() try: # Use the get_category method which supports all category types 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: raise HTTPException(status_code=500, detail=f"Failed to fetch category: {str(e)}") @app.get("/api/rophim/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. This provides a Rotten Tomatoes / Moviewiser style layout. """ # Check cache cache_key = "home:curated_v2" cached = cache.get(cache_key) if cached: return cached import aiohttp import ssl sections = [] ssl_ctx = ssl.create_default_context() ssl_ctx.check_hostname = False ssl_ctx.verify_mode = ssl.CERT_NONE connector = aiohttp.TCPConnector(ssl=ssl_ctx) 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 = [] for item in items[:30]: # Get more to allow better sorting 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 movies.append({ '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'), 'rating': max(tmdb_rating, imdb_rating), 'tmdb_rating': tmdb_rating, 'vote_count': tmdb_data.get('vote_count', 0), 'category': item.get('type', 'single'), 'genres': [cat.get('name') for cat in item.get('category', [])], }) # 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: print(f"Error fetching {title}: {e}") return None try: async with aiohttp.ClientSession(connector=connector) as session: import asyncio # Define curated sections 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 for 6 hours (21600s) cache.set(cache_key, result, ttl=21600) return result except Exception as e: print(f"Error fetching curated sections: {e}") return {"sections": [], "error": str(e)} @app.get("/api/rophim/stream/{slug}") async def get_rophim_stream(slug: str, episode: int = 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 from fastapi.responses import JSONResponse try: print(f"DEBUG: Processing stream request for {slug} ep {episode}") stream_url = await get_video_stream(slug, episode=episode) if not stream_url: print(f"DEBUG: Stream not found for {slug}") return JSONResponse(status_code=404, content={"detail": "Stream not found"}) print(f"DEBUG: Success! Returning stream URL for {slug}") return {"stream_url": stream_url} except Exception as e: print(f"ERROR in get_rophim_stream: {e}") return JSONResponse(status_code=500, content={"detail": str(e)}) @app.post("/api/rophim/stream") async def get_rophim_stream_post(data: dict, authorized: bool = Depends(verify_hmac)): """ Get video stream URL (POST) - supports source_url if needed """ import traceback from fastapi.responses import JSONResponse from rophim_scraper import get_video_stream try: slug = data.get('slug') episode = int(data.get('episode', 1)) if not slug: raise HTTPException(status_code=400, detail="Slug required") stream_url = await get_video_stream(slug, episode=episode) if not stream_url: raise HTTPException(status_code=404, detail="Stream not found") return JSONResponse(content={"stream_url": stream_url}) except HTTPException: raise except Exception as e: print(f"CRITICAL ERROR in get_rophim_stream_post: {e}") traceback.print_exc() return JSONResponse( status_code=500, content={"detail": str(e)} ) @app.get("/api/rophim/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). Returns dynamic sections (Genres, Countries, etc.) or View specific sections. """ from category_scraper import PhimMoiChillCategoryScraper scraper = PhimMoiChillCategoryScraper() try: if view == 'home': # Home logic (Page 2+ usually) # If page < 2, get_mixed_sections might return empty or negative index logic? # My logic: idx_start = (page - 2) * 5. If page=1 => -5. # But Main Page uses get_all_sections for Page 1. # So this endpoint is only for Page 2+ on Home. if page < 2: results = [] else: results = await scraper.get_mixed_sections(page) else: # Category Views using get_view_sections results = await scraper.get_view_sections(view, page) return {"sections": results, "page": page} except Exception as e: print(f"Error fetching more sections: {e}") return {"sections": [], "page": page} finally: await scraper.close() def clean_movie_description(movie: Dict) -> Dict: """Remove messy metadata from description field""" desc = movie.get('description', '') if desc and ('Trแบกng thรกi' in desc or 'Nฤƒm phรกt hร nh' in desc): # Description contains concatenated metadata - clear it movie['description'] = None return movie @app.get("/api/rophim/movie/{slug}") async def get_phimmoichill_movie(slug: str, authorized: bool = Depends(verify_hmac)): """Get detailed movie info from PhimMoiChill with optional TMDB enrichment""" import asyncio from rophim_scraper import get_movie_details try: loop = asyncio.get_event_loop() movie = await loop.run_in_executor( None, lambda: get_movie_details(slug) ) if not movie: raise HTTPException(status_code=404, detail="Movie not found") # Clean up description field movie = clean_movie_description(movie) # 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: print(f"TMDB enrichment failed: {tmdb_error}") # Return base movie data if TMDB fails return movie except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to fetch movie: {str(e)}") @app.get("/api/rophim/stream/{slug}") async def get_phimmoichill_stream( slug: str, episode: int = Query(1, ge=1), server: int = Query(0, ge=0, le=2), authorized: bool = Depends(verify_hmac) ): """Get video stream URL for a movie/episode using ophim API""" import asyncio from rophim_scraper import get_video_stream try: # Run sync scraper in thread pool loop = asyncio.get_event_loop() stream_url = await loop.run_in_executor( None, lambda: get_video_stream(slug, episode, server) ) if not stream_url: raise HTTPException(status_code=404, detail="Stream not found - video source extraction failed") return { "stream_url": stream_url, "episode": episode, "slug": slug } except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to get stream: {str(e)}") class PhimMoiChillStreamRequest(BaseModel): source_url: str slug: str = "" episode: int = 1 server: int = 0 @app.post("/api/rophim/stream") async def get_phimmoichill_stream_by_url(request: PhimMoiChillStreamRequest, authorized: bool = Depends(verify_hmac)): """Get video stream URL using slug from source_url - uses ophim API""" import asyncio import re from rophim_scraper import get_video_stream try: # Extract slug from source_url slug = request.slug if not slug and request.source_url: # e.g., https://phimmoichill.network/phim/slug-name 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") loop = asyncio.get_event_loop() stream_url = await loop.run_in_executor( None, lambda: get_video_stream(slug, request.episode, request.server) ) if not stream_url: raise HTTPException(status_code=404, detail="Stream not found - video source extraction failed") return { "stream_url": stream_url, "episode": request.episode, "slug": slug } except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to get stream: {str(e)}") # ============================================ # Scheduled Crawl Endpoint # ============================================ @app.post("/api/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. Can be called manually or by external scheduler (cron, Docker healthcheck). Returns the number of movies crawled. """ import asyncio 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: raise HTTPException(status_code=500, detail=f"Crawl failed: {str(e)}") @app.get("/api/crawl/status") async def crawl_status(): """Get the last crawl status and timestamp""" return { "status": "ready", "message": "Use POST /api/crawl/trigger to start a crawl" } # ============================================ # Category Endpoints - PhimMoiChill Themed Sections # ============================================ @app.get("/api/rophim/categories/all") async def get_all_categories(authorized: bool = Depends(verify_hmac)): """Get all themed category sections in one call""" import asyncio 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: raise HTTPException(status_code=500, detail=f"Failed to fetch categories: {str(e)}") @app.get("/api/rophim/categories/hot") async def get_hot_category(limit: int = Query(24, ge=1, le=50), authorized: bool = Depends(verify_hmac)): """Get Hot Movies category""" import asyncio 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: 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: raise HTTPException(status_code=500, detail=f"Failed to fetch hot movies: {str(e)}") @app.get("/api/rophim/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""" import asyncio 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: 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: raise HTTPException(status_code=500, detail=f"Failed to fetch new releases: {str(e)}") @app.get("/api/rophim/categories/top10") async def get_top10_category(authorized: bool = Depends(verify_hmac)): """Get Top 10 Most Watched""" import asyncio from category_scraper import PhimMoiChillCategoryScraper try: async def _fetch(): scraper = PhimMoiChillCategoryScraper() try: movies = await scraper.get_top_10() await scraper.close() return movies except: 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: raise HTTPException(status_code=500, detail=f"Failed to fetch top 10: {str(e)}") @app.get("/api/rophim/categories/cinema") async def get_cinema_category(limit: int = Query(24, ge=1, le=50), authorized: bool = Depends(verify_hmac)): """Get Cinema Releases category""" import asyncio 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: 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: raise HTTPException(status_code=500, detail=f"Failed to fetch cinema releases: {str(e)}") # ============================================ # Static Files Serving (Production) # ============================================ # Mount static files from the 'static' directory # In Docker, the built frontend will be copied here frontend_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "static")) print(f"๐Ÿ” DEBUG: Resolved frontend_path to: {frontend_path}") print(f"๐Ÿ” DEBUG: Path exists: {os.path.exists(frontend_path)}") if os.path.exists(frontend_path): print(f"โœ“ Serving frontend from {frontend_path}") # Mount main directories for folder in ["assets", "icons", "scripts", "styles", "js", "public"]: folder_path = os.path.join(frontend_path, folder) if os.path.exists(folder_path): app.mount(f"/{folder}", StaticFiles(directory=folder_path), name=folder) print(f" - Mounted /{folder}") # Direct file responses for root files @app.get("/manifest.json") async def serve_manifest(): return FileResponse(os.path.join(frontend_path, "manifest.json")) @app.get("/sw.js") async def serve_sw(): response = FileResponse(os.path.join(frontend_path, "sw.js")) response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response @app.get("/favicon.ico") async def serve_favicon(): favicon = os.path.join(frontend_path, "favicon.ico") if os.path.exists(favicon): return FileResponse(favicon) return Response(status_code=204) @app.get("/download") @app.get("/download.html") async def serve_download(): return FileResponse(os.path.join(frontend_path, "download.html")) @app.get("/watch") @app.get("/{full_path:path}") async def serve_spa(full_path: str): # 1. Check if it's a file request (has extension) requested_file = os.path.join(frontend_path, full_path) if "." in full_path and os.path.exists(requested_file): return FileResponse(requested_file) # 2. Otherwise serve index.html for SPA routing response = FileResponse(os.path.join(frontend_path, "index.html")) response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response # Catch-all for SPA navigation (only for GET requests and non-API, non-file paths) @app.exception_handler(404) async def custom_404_handler(request: Request, exc): path = request.url.path # Don't intercept API or static file requests if (not path.startswith("/api") and not any(path.startswith(f"/{f}") for f in ["assets", "scripts", "styles", "js", "icons"]) and "." not in path.split("/")[-1]): if os.path.exists(os.path.join(frontend_path, "index.html")): return FileResponse(os.path.join(frontend_path, "index.html")) return JSONResponse( status_code=404, content={"detail": "Not found", "path": path} ) if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)