- Optimized mobile image loading (180px vs 200px desktop) - Fixed Install App navigation not working on desktop - Fixed replaceChild null error in hero rendering - Added PWA icon (512x512) - Fixed back button navigation issues - Added mobile bottom padding for nav bar - Moved Get App FAB higher to avoid nav overlap - Removed unnecessary pushState from video navigation - Made Search/MyList tabs not scroll to top on mobile - Removed duplicate Android TV section from download page
1175 lines
43 KiB
Python
1175 lines
43 KiB
Python
"""
|
||
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)
|
||
|