455 lines
12 KiB
Go
455 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"streamflow-backend/internal/database"
|
|
"streamflow-backend/internal/models"
|
|
"streamflow-backend/internal/scraper"
|
|
"streamflow-backend/internal/service"
|
|
|
|
"regexp"
|
|
"sync"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
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")
|
|
|
|
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 := p.GetMoviesByCategory(category, page)
|
|
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 {
|
|
json.NewEncoder(w).Encode([]models.RophimMovie{})
|
|
return
|
|
}
|
|
|
|
var allMovies []models.RophimMovie
|
|
seenID := make(map[string]int)
|
|
seenTitle := make(map[string]int)
|
|
|
|
// Interleave results and deduplicate
|
|
for i := 0; i < maxLen; i++ {
|
|
for _, movies := range providerResults {
|
|
if i < len(movies) {
|
|
movie := movies[i]
|
|
|
|
// Dedup by ID
|
|
if idx, found := seenID[movie.ID]; found {
|
|
h.mergeMovieMetadata(&allMovies[idx], &movie)
|
|
continue
|
|
}
|
|
|
|
// Dedup by Title
|
|
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 titleKey != "" {
|
|
seenTitle[titleKey] = currIdx
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(allMovies)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 := p.Search(query, page)
|
|
if err == nil {
|
|
mu.Lock()
|
|
providerResults = append(providerResults, movies)
|
|
if len(movies) > maxLen {
|
|
maxLen = len(movies)
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
}(provider)
|
|
}
|
|
wg.Wait()
|
|
|
|
var allMovies []models.RophimMovie
|
|
seenID := 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]
|
|
|
|
if idx, found := seenID[movie.ID]; found {
|
|
h.mergeMovieMetadata(&allMovies[idx], &movie)
|
|
continue
|
|
}
|
|
|
|
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 titleKey != "" {
|
|
seenTitle[titleKey] = currIdx
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(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
|
|
}
|
|
|
|
// Direct HLS check: if URL ends with .m3u8, just return it as source
|
|
// But the frontend usually calls this if it needs to extract.
|
|
// If frontend handles m3u8 directly (as planned), this is fallback.
|
|
|
|
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) {
|
|
url := r.URL.Query().Get("url")
|
|
width, _ := strconv.Atoi(r.URL.Query().Get("width"))
|
|
|
|
if url == "" {
|
|
http.Error(w, "url parameter required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
data, contentType, err := h.Image.GetProxiedImage(url, width)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", contentType)
|
|
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
|
|
|
|
// 1. Find the primary movie from the provider that owns this slug
|
|
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
|
|
}
|
|
|
|
// 2. Try to find the same movie in other providers to get more episodes
|
|
for i, provider := range h.Providers {
|
|
if i == primaryProviderIdx {
|
|
continue
|
|
}
|
|
|
|
// Search by OriginalTitle or Title
|
|
searchQuery := primaryMovie.OriginalTitle
|
|
if searchQuery == "" {
|
|
searchQuery = primaryMovie.Title
|
|
}
|
|
|
|
results, err := provider.Search(searchQuery, 1)
|
|
if err == nil {
|
|
for _, res := range results {
|
|
// Fuzzy match on normalized title
|
|
if normalizeKey(res.Title) == normalizeKey(primaryMovie.Title) ||
|
|
(primaryMovie.OriginalTitle != "" && normalizeKey(res.OriginalTitle) == normalizeKey(primaryMovie.OriginalTitle)) {
|
|
// Found a match! Get details to retrieve episodes
|
|
details, err := provider.GetMovieDetail(res.Slug)
|
|
if err == nil && details != nil {
|
|
h.mergeMovieMetadata(primaryMovie, details)
|
|
}
|
|
break // Only one match per provider
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Sort episodes numerically
|
|
// 3. Sort episodes numerically
|
|
sort.Slice(primaryMovie.Episodes, func(i, j int) bool {
|
|
return primaryMovie.Episodes[i].Number < primaryMovie.Episodes[j].Number
|
|
})
|
|
|
|
// Final pass: Consolidate episodes by number to remove duplicates from single provider (e.g. Ophim multi-server)
|
|
// We re-build the slice, keeping only the first occurrence of each episode 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
|
|
}
|
|
|
|
// Create request to upstream video server
|
|
req, err := http.NewRequest("GET", videoURL, nil)
|
|
if err != nil {
|
|
http.Error(w, "invalid url", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Set headers to mimic browser request
|
|
req.Header.Set("Referer", "https://phimmoichill.my/")
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
|
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()
|
|
|
|
// Check if this is a manifest (.m3u8)
|
|
if strings.Contains(videoURL, ".m3u8") {
|
|
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)
|
|
|
|
// Regex to find absolute URLs in the manifest
|
|
re := regexp.MustCompile(`(https?://[^\s"']+)`)
|
|
|
|
// Use a relative path so it works through the Vite proxy (localhost:5173 -> localhost:8000)
|
|
// and doesn't trigger CORS on its own.
|
|
proxyBase := "/api/stream?url="
|
|
|
|
newContent := re.ReplaceAllStringFunc(content, func(match string) string {
|
|
// Proxy everything for these masters to be safe
|
|
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))
|
|
return
|
|
}
|
|
|
|
// For normal segments (TS, MP4), stream the body directly
|
|
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) mergeMovieMetadata(existing, new *models.RophimMovie) {
|
|
// Prioritize Ophim thumbnail
|
|
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
|
|
}
|
|
|
|
// Prioritize Quality label that contains episode info (e.g. "Tập" or "Hoàn tất")
|
|
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
|
|
}
|
|
|
|
// Merge episodes by number ONLY to prevent duplicates in UI
|
|
// This means we prioritized the Provider that came first (usually Ophim)
|
|
// unless the existing episode has no URL.
|
|
epMap := make(map[int]int) // map[epNum]existingSliceIndex
|
|
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 duplicate number, only replace if existing is empty
|
|
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 {
|
|
// New episode number
|
|
epMap[newEp.Number] = len(existing.Episodes)
|
|
existing.Episodes = append(existing.Episodes, *newEp)
|
|
}
|
|
}
|
|
}
|
|
|
|
func normalizeKey(s string) string {
|
|
s = strings.ToLower(s)
|
|
// Remove all non-alphanumeric characters for fuzzy title match
|
|
reg := regexp.MustCompile("[^a-z0-9]+")
|
|
return reg.ReplaceAllString(s, "")
|
|
}
|