kv-netflix/backend/internal/api/handlers.go
vndangkhoa b647bc8272
Some checks failed
Release APKs / Build TV APK (push) Has been cancelled
Release APKs / Build Mobile APK (push) Has been cancelled
Release APKs / Create Release (push) Has been cancelled
v3.9: Add Next/Prev episode buttons, replace PhimMoiChill with Phim30 scraper, filter blank thumbnails
- Add Next/Previous episode navigation to Android TV ExoPlayer UI
- Implement Phim30.me scraper as replacement for unstable PhimMoiChill
- Remove all PhimMoiChill code (scraper, extractor, fallback URLs)
- Filter out movies without thumbnails from API responses
- Fix HTTP 500 error caused by dead phimmoichill.network fallback
- Include Android TV APK in webapp for download
2026-02-28 18:45:48 +07:00

493 lines
12 KiB
Go

package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"unicode"
"streamflow-backend/internal/database"
"streamflow-backend/internal/models"
"streamflow-backend/internal/scraper"
"streamflow-backend/internal/service"
"github.com/go-chi/chi/v5"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
const (
defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
defaultReferer = "https://phimmoichill.my/"
)
var (
blockedHosts = []string{
"localhost",
"127.0.0.1",
"0.0.0.0",
"169.254.169.254",
"[::1]",
}
privateIPRegex = regexp.MustCompile(`^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.)`)
)
type Handler struct {
Repo *database.VideoRepository
Providers []scraper.MovieProvider
TMDB *service.TMDBService
Extractor *service.VideoExtractor
Image *service.ImageService
}
func NewHandler(
repo *database.VideoRepository,
providers []scraper.MovieProvider,
tmdb *service.TMDBService,
extractor *service.VideoExtractor,
image *service.ImageService,
) *Handler {
return &Handler{
Repo: repo,
Providers: providers,
TMDB: tmdb,
Extractor: extractor,
Image: image,
}
}
func (h *Handler) GetHomeVideos(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
category := r.URL.Query().Get("category")
movies := h.fetchAndMergeMovies(func(p scraper.MovieProvider) ([]models.RophimMovie, error) {
return p.GetMoviesByCategory(category, page)
})
json.NewEncoder(w).Encode(movies)
}
func (h *Handler) SearchVideos(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "query parameter required", http.StatusBadRequest)
return
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
movies := h.fetchAndMergeMovies(func(p scraper.MovieProvider) ([]models.RophimMovie, error) {
return p.Search(query, page)
})
json.NewEncoder(w).Encode(movies)
}
type movieFetcher func(p scraper.MovieProvider) ([]models.RophimMovie, error)
func (h *Handler) fetchAndMergeMovies(fetch movieFetcher) []models.RophimMovie {
var providerResults [][]models.RophimMovie
maxLen := 0
var mu sync.Mutex
var wg sync.WaitGroup
for _, provider := range h.Providers {
wg.Add(1)
go func(p scraper.MovieProvider) {
defer wg.Done()
movies, err := fetch(p)
if err == nil {
mu.Lock()
providerResults = append(providerResults, movies)
if len(movies) > maxLen {
maxLen = len(movies)
}
mu.Unlock()
}
}(provider)
}
wg.Wait()
if len(providerResults) == 0 {
return []models.RophimMovie{}
}
merged := h.mergeMovies(providerResults, maxLen)
// Filter out movies with empty thumbnails to avoid blank cover cards
filtered := make([]models.RophimMovie, 0, len(merged))
for _, m := range merged {
if m.Thumbnail != "" {
filtered = append(filtered, m)
}
}
return filtered
}
func (h *Handler) mergeMovies(providerResults [][]models.RophimMovie, maxLen int) []models.RophimMovie {
var allMovies []models.RophimMovie
seenID := make(map[string]int)
seenSlug := make(map[string]int)
seenTitle := make(map[string]int)
for i := 0; i < maxLen; i++ {
for _, movies := range providerResults {
if i < len(movies) {
movie := movies[i]
// Check 1: Exact ID match
if idx, found := seenID[movie.ID]; found {
h.mergeMovieMetadata(&allMovies[idx], &movie)
continue
}
// Check 2: Slug match (e.g. "vu-tru-cua-doi-ta" from both providers)
slugKey := normalizeKey(movie.Slug)
if slugKey != "" {
if idx, found := seenSlug[slugKey]; found {
h.mergeMovieMetadata(&allMovies[idx], &movie)
continue
}
}
// Check 3: Normalized title match
titleKey := normalizeKey(movie.OriginalTitle)
if titleKey == "" {
titleKey = normalizeKey(movie.Title)
}
if idx, found := seenTitle[titleKey]; found && titleKey != "" {
h.mergeMovieMetadata(&allMovies[idx], &movie)
continue
}
allMovies = append(allMovies, movie)
currIdx := len(allMovies) - 1
seenID[movie.ID] = currIdx
if slugKey != "" {
seenSlug[slugKey] = currIdx
}
if titleKey != "" {
seenTitle[titleKey] = currIdx
}
}
}
}
return allMovies
}
func (h *Handler) ExtractVideo(w http.ResponseWriter, r *http.Request) {
var req struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := validateURL(req.URL); err != nil {
http.Error(w, "invalid URL: "+err.Error(), http.StatusBadRequest)
return
}
info, err := h.Extractor.Extract(req.URL, "1080p")
if err != nil {
fmt.Printf("Extraction error: %v\n", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(info)
}
func (h *Handler) ProxyImage(w http.ResponseWriter, r *http.Request) {
imgURL := r.URL.Query().Get("url")
width, _ := strconv.Atoi(r.URL.Query().Get("width"))
if imgURL == "" {
http.Error(w, "url parameter required", http.StatusBadRequest)
return
}
if err := validateURL(imgURL); err != nil {
http.Error(w, "invalid URL: "+err.Error(), http.StatusBadRequest)
return
}
data, contentType, err := h.Image.GetProxiedImage(imgURL, width)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(data)
}
func (h *Handler) GetMovieDetail(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if slug == "" {
http.Error(w, "slug required", http.StatusBadRequest)
return
}
var primaryMovie *models.RophimMovie
var primaryProviderIdx int = -1
var success bool
for i, provider := range h.Providers {
movie, err := provider.GetMovieDetail(slug)
if err == nil && movie != nil {
primaryMovie = movie
primaryProviderIdx = i
success = true
break
}
}
if !success || primaryMovie == nil {
http.Error(w, "movie not found", http.StatusNotFound)
return
}
for i, provider := range h.Providers {
if i == primaryProviderIdx {
continue
}
searchQuery := primaryMovie.OriginalTitle
if searchQuery == "" {
searchQuery = primaryMovie.Title
}
results, err := provider.Search(searchQuery, 1)
if err == nil {
for _, res := range results {
if normalizeKey(res.Title) == normalizeKey(primaryMovie.Title) ||
(primaryMovie.OriginalTitle != "" && normalizeKey(res.OriginalTitle) == normalizeKey(primaryMovie.OriginalTitle)) {
details, err := provider.GetMovieDetail(res.Slug)
if err == nil && details != nil {
h.mergeMovieMetadata(primaryMovie, details)
}
break
}
}
}
}
sort.Slice(primaryMovie.Episodes, func(i, j int) bool {
return primaryMovie.Episodes[i].Number < primaryMovie.Episodes[j].Number
})
if len(primaryMovie.Episodes) > 0 {
uniqueEps := make([]models.Episode, 0)
seenEpNums := make(map[int]bool)
for _, ep := range primaryMovie.Episodes {
if !seenEpNums[ep.Number] {
seenEpNums[ep.Number] = true
uniqueEps = append(uniqueEps, ep)
}
}
primaryMovie.Episodes = uniqueEps
}
json.NewEncoder(w).Encode(primaryMovie)
}
func (h *Handler) GetGenres(w http.ResponseWriter, r *http.Request) {
for _, p := range h.Providers {
if gp, ok := p.(interface {
GetGenres() ([]models.Category, error)
}); ok {
genres, err := gp.GetGenres()
if err == nil {
json.NewEncoder(w).Encode(genres)
return
}
}
}
http.Error(w, "not found", http.StatusNotFound)
}
func (h *Handler) GetCountries(w http.ResponseWriter, r *http.Request) {
for _, p := range h.Providers {
if cp, ok := p.(interface {
GetCountries() ([]models.Category, error)
}); ok {
countries, err := cp.GetCountries()
if err == nil {
json.NewEncoder(w).Encode(countries)
return
}
}
}
http.Error(w, "not found", http.StatusNotFound)
}
func (h *Handler) StreamVideo(w http.ResponseWriter, r *http.Request) {
videoURL := r.URL.Query().Get("url")
if videoURL == "" {
http.Error(w, "url required", http.StatusBadRequest)
return
}
if err := validateURL(videoURL); err != nil {
http.Error(w, "invalid URL: "+err.Error(), http.StatusBadRequest)
return
}
parsedURL, err := url.Parse(videoURL)
if err != nil {
http.Error(w, "invalid url", http.StatusBadRequest)
return
}
req, err := http.NewRequest("GET", videoURL, nil)
if err != nil {
http.Error(w, "invalid url", http.StatusBadRequest)
return
}
req.Header.Set("Referer", defaultReferer)
req.Header.Set("User-Agent", defaultUserAgent)
req.Header.Set("Range", r.Header.Get("Range"))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if strings.HasSuffix(parsedURL.Path, ".m3u8") {
h.handleHLSManifest(w, resp)
return
}
for k, v := range resp.Header {
w.Header()[k] = v
}
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func (h *Handler) handleHLSManifest(w http.ResponseWriter, resp *http.Response) {
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Stream proxy read error: %v\n", err)
http.Error(w, "read error", http.StatusInternalServerError)
return
}
content := string(body)
re := regexp.MustCompile(`(https?://[^\s"']+)`)
proxyBase := "/api/stream?url="
newContent := re.ReplaceAllStringFunc(content, func(match string) string {
return proxyBase + url.QueryEscape(match)
})
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(resp.StatusCode)
w.Write([]byte(newContent))
}
func (h *Handler) mergeMovieMetadata(existing, new *models.RophimMovie) {
isNewOphim := strings.Contains(new.Thumbnail, "ophim") || strings.Contains(new.Thumbnail, "img.ophim1.com")
isExistingOphim := strings.Contains(existing.Thumbnail, "ophim") || strings.Contains(existing.Thumbnail, "img.ophim1.com")
if isNewOphim && !isExistingOphim {
existing.Thumbnail = new.Thumbnail
}
isNewDetailed := strings.Contains(new.Quality, "Tập") || strings.Contains(new.Quality, "Hoàn tất")
isExistingDetailed := strings.Contains(existing.Quality, "Tập") || strings.Contains(existing.Quality, "Hoàn tất")
if isNewDetailed && !isExistingDetailed {
existing.Quality = new.Quality
}
epMap := make(map[int]int)
for i := range existing.Episodes {
epMap[existing.Episodes[i].Number] = i
}
for i := range new.Episodes {
newEp := &new.Episodes[i]
if idx, exists := epMap[newEp.Number]; exists {
if existing.Episodes[idx].URL == "" && newEp.URL != "" {
existing.Episodes[idx].URL = newEp.URL
existing.Episodes[idx].Title = newEp.Title
existing.Episodes[idx].ServerName = newEp.ServerName
}
} else {
epMap[newEp.Number] = len(existing.Episodes)
existing.Episodes = append(existing.Episodes, *newEp)
}
}
}
func normalizeKey(s string) string {
if s == "" {
return ""
}
s = strings.ToLower(s)
// Strip Vietnamese diacritics: Vũ Trụ Của Đôi Ta → vu tru cua doi ta
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
result, _, err := transform.String(t, s)
if err == nil {
s = result
}
// Replace đ/Đ which NFD doesn't decompose
s = strings.ReplaceAll(s, "đ", "d")
// Keep only alphanumeric
reg := regexp.MustCompile("[^a-z0-9]+")
return reg.ReplaceAllString(s, "")
}
func validateURL(rawURL string) error {
if rawURL == "" {
return fmt.Errorf("URL is empty")
}
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL format")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("only http and https protocols are allowed")
}
host := strings.ToLower(parsed.Hostname())
for _, blocked := range blockedHosts {
if host == blocked || strings.HasPrefix(host, blocked+".") {
return fmt.Errorf("access to this host is blocked")
}
}
if privateIPRegex.MatchString(host) {
return fmt.Errorf("access to private IP addresses is blocked")
}
return nil
}