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
This commit is contained in:
parent
46818cced5
commit
b647bc8272
17 changed files with 296 additions and 587 deletions
|
|
@ -32,6 +32,12 @@
|
|||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="streamflow" android:host="player" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ fun StreamFlowTvApp() {
|
|||
try {
|
||||
currentTheme = userRepo.theme.first()
|
||||
val serverUrl = userRepo.serverUrl.first()
|
||||
if (serverUrl.isNotBlank()) {
|
||||
/*if (serverUrl.isNotBlank()) {
|
||||
ApiClient.baseUrl = serverUrl
|
||||
}
|
||||
}*/
|
||||
Log.d("StreamFlowTvApp", "Settings loaded: theme=$currentTheme, url=$serverUrl")
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamFlowTvApp", "Error loading settings", e)
|
||||
|
|
@ -128,7 +128,8 @@ fun StreamFlowTvApp() {
|
|||
arguments = listOf(
|
||||
navArgument("slug") { type = NavType.StringType },
|
||||
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
||||
)
|
||||
),
|
||||
deepLinks = listOf(androidx.navigation.navDeepLink { uriPattern = "streamflow://player/{slug}/{episode}" })
|
||||
) { entry ->
|
||||
val slug = entry.arguments?.getString("slug")
|
||||
val episode = entry.arguments?.getInt("episode") ?: 1
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import java.util.concurrent.TimeUnit
|
|||
|
||||
object ApiClient {
|
||||
|
||||
private var _baseUrl: String = "https://nf.khoavo.myds.me/"
|
||||
// Default base URL for testing
|
||||
// Change this to your production API when ready
|
||||
// var baseUrl: String = "https://nf.khoavo.myds.me"
|
||||
private var _baseUrl: String = "http://10.0.2.2:3478/"
|
||||
|
||||
var baseUrl: String
|
||||
get() = _baseUrl
|
||||
|
|
|
|||
|
|
@ -59,6 +59,49 @@ fun PlayerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Wrap ExoPlayer to intercept next/previous UI clicks
|
||||
val forwardingPlayer = remember(exoPlayer, uiState.movie, uiState.currentEpisode) {
|
||||
object : androidx.media3.common.ForwardingPlayer(exoPlayer) {
|
||||
override fun getAvailableCommands(): androidx.media3.common.Player.Commands {
|
||||
return super.getAvailableCommands().buildUpon()
|
||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT)
|
||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun hasNextMediaItem(): Boolean {
|
||||
val eps = uiState.movie?.episodes ?: return false
|
||||
if (eps.isEmpty()) return false
|
||||
val maxEp = eps.maxOf { it.number }
|
||||
return uiState.currentEpisode < maxEp
|
||||
}
|
||||
override fun hasPreviousMediaItem(): Boolean {
|
||||
val eps = uiState.movie?.episodes ?: return false
|
||||
if (eps.isEmpty()) return false
|
||||
val minEp = eps.minOf { it.number }
|
||||
return uiState.currentEpisode > minEp
|
||||
}
|
||||
override fun seekToNextMediaItem() {
|
||||
if (hasNextMediaItem()) {
|
||||
viewModel.changeEpisode(uiState.currentEpisode + 1)
|
||||
}
|
||||
}
|
||||
override fun seekToNext() {
|
||||
seekToNextMediaItem()
|
||||
}
|
||||
override fun seekToPreviousMediaItem() {
|
||||
if (hasPreviousMediaItem()) {
|
||||
viewModel.changeEpisode(uiState.currentEpisode - 1)
|
||||
}
|
||||
}
|
||||
override fun seekToPrevious() {
|
||||
seekToPreviousMediaItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update player when source changes
|
||||
LaunchedEffect(uiState.source) {
|
||||
uiState.source?.let { source ->
|
||||
|
|
@ -132,6 +175,18 @@ fun PlayerScreen(
|
|||
playerView?.showController()
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||
if (forwardingPlayer.hasNextMediaItem()) {
|
||||
forwardingPlayer.seekToNextMediaItem()
|
||||
}
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||
if (forwardingPlayer.hasPreviousMediaItem()) {
|
||||
forwardingPlayer.seekToPreviousMediaItem()
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
} else false
|
||||
|
|
@ -159,13 +214,15 @@ fun PlayerScreen(
|
|||
}
|
||||
} else {
|
||||
// ExoPlayer View
|
||||
android.util.Log.e("StreamFlowPlayer", "Drawing AndroidView for Player")
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
android.util.Log.e("StreamFlowPlayer", "Creating PlayerView factory")
|
||||
PlayerView(ctx).apply {
|
||||
player = exoPlayer
|
||||
player = forwardingPlayer
|
||||
useController = true
|
||||
setShowNextButton(false)
|
||||
setShowPreviousButton(false)
|
||||
setShowNextButton(true)
|
||||
setShowPreviousButton(true)
|
||||
controllerAutoShow = true
|
||||
keepScreenOn = true // Prevent screen sleep during playback
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
|
|
|
|||
|
|
@ -71,19 +71,23 @@ class PlayerViewModel : ViewModel() {
|
|||
),
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
// Need to extract
|
||||
val targetUrl = ep?.url
|
||||
?: "https://phimmoichill.network/xem-phim/${movie.slug}/tap-$episode"
|
||||
|
||||
android.util.Log.e("PlayerViewModel", "Extracting from URL: $targetUrl")
|
||||
val source = repository.extractVideo(targetUrl)
|
||||
} else if (ep != null && ep.url.isNotEmpty()) {
|
||||
// Non-HLS URL — try to extract via backend
|
||||
android.util.Log.e("PlayerViewModel", "Extracting from URL: ${ep.url}")
|
||||
val source = repository.extractVideo(ep.url)
|
||||
android.util.Log.e("PlayerViewModel", "Extraction successful: $source")
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
source = source,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
// No valid episode URL found
|
||||
android.util.Log.e("PlayerViewModel", "No stream URL found for episode $episode")
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "No stream available for episode $episode"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("PlayerViewModel", "Error loading stream", e)
|
||||
|
|
|
|||
|
|
@ -32,12 +32,12 @@ func main() {
|
|||
|
||||
videoRepo := database.NewVideoRepository(database.DB)
|
||||
ophimService := scraper.NewOphimScraper()
|
||||
phimMoiService := scraper.NewPhimMoiChillScraper()
|
||||
phim30Service := scraper.NewPhim30Scraper()
|
||||
tmdbService := service.NewTMDBService()
|
||||
extractorService := service.NewVideoExtractor()
|
||||
imageService := service.NewImageService()
|
||||
|
||||
providers := []scraper.MovieProvider{ophimService, phimMoiService}
|
||||
providers := []scraper.MovieProvider{ophimService, phim30Service}
|
||||
|
||||
handler := api.NewHandler(videoRepo, providers, tmdbService, extractorService, imageService)
|
||||
|
||||
|
|
|
|||
|
|
@ -126,7 +126,16 @@ func (h *Handler) fetchAndMergeMovies(fetch movieFetcher) []models.RophimMovie {
|
|||
return []models.RophimMovie{}
|
||||
}
|
||||
|
||||
return h.mergeMovies(providerResults, maxLen)
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -304,8 +304,11 @@ func (s *OphimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error)
|
|||
if _, err := fmt.Sscanf(ep.Name, "Tap %d", &n); err == nil {
|
||||
epNum = n
|
||||
}
|
||||
// If still 0 (e.g. "Full", "Trailer"), skip — don't default to 1
|
||||
// as that would collide with real Episode 1 during dedup
|
||||
if strings.EqualFold(ep.Name, "Full") || strings.EqualFold(ep.Name, "Trailer") {
|
||||
epNum = 1 // single-movie or trailer as ep 1
|
||||
}
|
||||
|
||||
// If still 0, skip
|
||||
if epNum == 0 {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
191
backend/internal/scraper/phim30.go
Normal file
191
backend/internal/scraper/phim30.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
package scraper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
func parseEpisodeNumber(title string) int {
|
||||
// e.g "Tập 1", "Tập 01", "Full"
|
||||
t := strings.ToLower(strings.TrimSpace(title))
|
||||
if t == "full" {
|
||||
return 1
|
||||
}
|
||||
t = strings.ReplaceAll(t, "tập ", "")
|
||||
t = strings.ReplaceAll(t, "tap ", "")
|
||||
|
||||
// handle multi-spaces
|
||||
parts := strings.Fields(t)
|
||||
if len(parts) > 0 {
|
||||
num, err := strconv.Atoi(parts[0])
|
||||
if err == nil {
|
||||
return num
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
const Phim30BaseURL = "https://phim30.me"
|
||||
|
||||
type Phim30Scraper struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewPhim30Scraper() *Phim30Scraper {
|
||||
return &Phim30Scraper{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) Search(query string, page int) ([]models.RophimMovie, error) {
|
||||
searchURL := fmt.Sprintf("%s/tim-kiem?keyword=%s&page=%d", Phim30BaseURL, url.QueryEscape(query), page)
|
||||
return p.scrapeMovieList(searchURL)
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) {
|
||||
// e.g. https://phim30.me/the-loai/hanh-dong?page=1
|
||||
catURL := fmt.Sprintf("%s/the-loai/%s?page=%d", Phim30BaseURL, category, page)
|
||||
return p.scrapeMovieList(catURL)
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie, error) {
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var movies []models.RophimMovie
|
||||
|
||||
doc.Find("a[href^='https://phim30.me/phim/']").Each(func(i int, s *goquery.Selection) {
|
||||
href, _ := s.Attr("href")
|
||||
title, _ := s.Attr("title")
|
||||
|
||||
// Remove the base url to get the slug
|
||||
slug := strings.TrimPrefix(href, "https://phim30.me/phim/")
|
||||
|
||||
// Try to find an image child (check data-src for lazy-loaded images)
|
||||
thumb := ""
|
||||
s.Find("img").Each(func(j int, img *goquery.Selection) {
|
||||
src, _ := img.Attr("src")
|
||||
dataSrc, _ := img.Attr("data-src")
|
||||
lazySrc, _ := img.Attr("lazy-src")
|
||||
if dataSrc != "" {
|
||||
thumb = dataSrc
|
||||
} else if lazySrc != "" {
|
||||
thumb = lazySrc
|
||||
} else if src != "" && !strings.Contains(src, "data:image") {
|
||||
thumb = src
|
||||
}
|
||||
})
|
||||
|
||||
if title != "" && slug != "" {
|
||||
movies = append(movies, models.RophimMovie{
|
||||
ID: slug,
|
||||
Slug: slug,
|
||||
Title: title,
|
||||
OriginalTitle: title,
|
||||
Thumbnail: thumb,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Deduplicate movies because a search page might have multiple links to the same movie
|
||||
var uniqueMovies []models.RophimMovie
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range movies {
|
||||
if !seen[m.Slug] {
|
||||
seen[m.Slug] = true
|
||||
uniqueMovies = append(uniqueMovies, m)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueMovies, nil
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
||||
targetURL := fmt.Sprintf("%s/phim/%s", Phim30BaseURL, slug)
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
movie := &models.RophimMovie{
|
||||
ID: slug,
|
||||
Slug: slug,
|
||||
}
|
||||
|
||||
title := doc.Find("h1.movie-title").Text()
|
||||
if title == "" {
|
||||
title = doc.Find("title").Text()
|
||||
title = strings.Split(title, "–")[0]
|
||||
title = strings.TrimSpace(title)
|
||||
}
|
||||
movie.Title = title
|
||||
movie.OriginalTitle = title
|
||||
|
||||
var eps []models.Episode
|
||||
doc.Find("a[href*='/xem-phim/']").Each(func(i int, s *goquery.Selection) {
|
||||
href, _ := s.Attr("href")
|
||||
epName := strings.TrimSpace(s.Text())
|
||||
|
||||
if epName != "" && href != "" {
|
||||
if !strings.HasPrefix(href, "http") {
|
||||
href = Phim30BaseURL + href
|
||||
}
|
||||
eps = append(eps, models.Episode{
|
||||
ServerName: "Phim30",
|
||||
Title: epName,
|
||||
Number: parseEpisodeNumber(epName),
|
||||
URL: href,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if len(eps) > 0 {
|
||||
movie.Episodes = eps
|
||||
}
|
||||
|
||||
return movie, nil
|
||||
}
|
||||
|
|
@ -1,276 +0,0 @@
|
|||
package scraper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
const PhimMoiChillBaseURL = "https://phimmoichill.my"
|
||||
|
||||
type PhimMoiChillScraper struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewPhimMoiChillScraper() *PhimMoiChillScraper {
|
||||
return &PhimMoiChillScraper{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PhimMoiChillScraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) {
|
||||
// Map categories to URL paths
|
||||
// Home -> list/phim-moi-cap-nhat (or just use list/phim-le for now as default)
|
||||
// category "phim-le", "phim-bo" -> list/phim-le
|
||||
// others -> genre/category
|
||||
|
||||
var path string
|
||||
switch category {
|
||||
case "home", "":
|
||||
path = "list/phim-moi" // Better for home than phim-le
|
||||
case "phim-le", "phim-bo", "hoat-hinh", "tv-shows":
|
||||
path = fmt.Sprintf("list/%s", category)
|
||||
default:
|
||||
path = fmt.Sprintf("genre/phim-%s", category)
|
||||
}
|
||||
|
||||
targetURL := fmt.Sprintf("%s/%s", PhimMoiChillBaseURL, path)
|
||||
if page > 1 {
|
||||
targetURL = fmt.Sprintf("%s?page=%d", targetURL, page)
|
||||
}
|
||||
|
||||
return s.scrapeList(targetURL)
|
||||
}
|
||||
|
||||
func (s *PhimMoiChillScraper) Search(query string, page int) ([]models.RophimMovie, error) {
|
||||
encodedQuery := strings.ReplaceAll(query, " ", "+")
|
||||
targetURL := fmt.Sprintf("%s/tim-kiem/%s/", PhimMoiChillBaseURL, encodedQuery)
|
||||
// If page > 1, might need suffix. Let's append ?page= just in case
|
||||
if page > 1 {
|
||||
targetURL = fmt.Sprintf("%s/tim-kiem/%s/page/%d", PhimMoiChillBaseURL, encodedQuery, page)
|
||||
}
|
||||
return s.scrapeList(targetURL)
|
||||
}
|
||||
|
||||
func (s *PhimMoiChillScraper) scrapeList(targetURL string) ([]models.RophimMovie, error) {
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
|
||||
req.Header.Set("Accept-Language", "vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7")
|
||||
req.Header.Set("Referer", PhimMoiChillBaseURL)
|
||||
req.Header.Set("Connection", "keep-alive")
|
||||
|
||||
res, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("status code error: %d %s", res.StatusCode, res.Status)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var movies []models.RophimMovie
|
||||
|
||||
// Selectors based on inspection (list-film item is common)
|
||||
// Assuming structure similar to: <ul class="list-film"> <li class="item"> ...
|
||||
doc.Find(".list-film .item").Each(func(i int, s *goquery.Selection) {
|
||||
linkTag := s.Find("a").First()
|
||||
href, _ := linkTag.Attr("href")
|
||||
title := linkTag.AttrOr("title", "")
|
||||
|
||||
// Slug from href: https://phimmoichill.my/info/slug-pmID
|
||||
slug := ""
|
||||
if parts := strings.Split(href, "/info/"); len(parts) > 1 {
|
||||
slug = parts[1]
|
||||
}
|
||||
|
||||
// Image
|
||||
imgTag := s.Find("img").First()
|
||||
thumb := imgTag.AttrOr("src", "")
|
||||
if dataSrc, exists := imgTag.Attr("data-src"); exists && dataSrc != "" {
|
||||
thumb = dataSrc
|
||||
}
|
||||
|
||||
// Cleanup Name (Remove " - NameEN")
|
||||
name := title
|
||||
originName := ""
|
||||
if parts := strings.Split(title, " - "); len(parts) > 1 {
|
||||
name = parts[0]
|
||||
originName = parts[1]
|
||||
}
|
||||
|
||||
// Episode Label / Status
|
||||
label := strings.TrimSpace(s.Find(".label .status").Text())
|
||||
if label == "" {
|
||||
label = strings.TrimSpace(s.Find(".label").Text())
|
||||
}
|
||||
|
||||
movies = append(movies, models.RophimMovie{
|
||||
ID: slug,
|
||||
Slug: slug,
|
||||
Title: name,
|
||||
OriginalTitle: originName,
|
||||
Thumbnail: thumb,
|
||||
Quality: label,
|
||||
Category: "movies",
|
||||
Provider: "PhimMoiChill",
|
||||
})
|
||||
})
|
||||
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
func (s *PhimMoiChillScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
||||
// slug likely includes the ID suffix, e.g. "linh-truong-pm17080"
|
||||
targetURL := fmt.Sprintf("%s/info/%s", PhimMoiChillBaseURL, slug)
|
||||
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
|
||||
res, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("status code error: %d %s", res.StatusCode, res.Status)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
movie := &models.RophimMovie{
|
||||
ID: slug,
|
||||
Slug: slug,
|
||||
}
|
||||
|
||||
// Info
|
||||
// Selectors need guessing or checking. Assuming .entry-title, .name-real
|
||||
movie.Title = doc.Find("h1.entry-title, h1.title, h1").First().Text()
|
||||
movie.OriginalTitle = doc.Find(".name-real, h2.real-name").First().Text()
|
||||
movie.Description = doc.Find(".film-content, .entry-content, #info-film").Text()
|
||||
movie.Thumbnail = doc.Find(".film-info .poster img, .image img").AttrOr("src", "")
|
||||
|
||||
// Details
|
||||
doc.Find(".list-info li, .film-info li").Each(func(i int, s *goquery.Selection) {
|
||||
text := s.Text()
|
||||
if strings.Contains(text, "Quốc gia:") {
|
||||
movie.Country = strings.TrimSpace(strings.Replace(text, "Quốc gia:", "", 1))
|
||||
}
|
||||
if strings.Contains(text, "Đạo diễn:") {
|
||||
movie.Director = strings.TrimSpace(strings.Replace(text, "Đạo diễn:", "", 1))
|
||||
}
|
||||
if strings.Contains(text, "Thể loại:") {
|
||||
movie.Genre = strings.TrimSpace(strings.Replace(text, "Thể loại:", "", 1))
|
||||
}
|
||||
if strings.Contains(text, "Năm phát hành:") {
|
||||
yearStr := strings.TrimSpace(strings.Replace(text, "Năm phát hành:", "", 1))
|
||||
if y, err := strconv.Atoi(yearStr); err == nil {
|
||||
movie.Year = y
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Episodes
|
||||
// Look for latest-episode links
|
||||
var episodes []models.Episode
|
||||
epMap := make(map[int]int) // map[epNum]sliceIndex
|
||||
doc.Find(".latest-episode a").Each(func(i int, s *goquery.Selection) {
|
||||
epName := strings.TrimSpace(s.Text())
|
||||
href, _ := s.Attr("href")
|
||||
|
||||
epNum := 0
|
||||
if strings.EqualFold(epName, "Full") {
|
||||
// Single-movie "Full" — will be handled by the fallback below
|
||||
// Don't assign epNum=1 as it collides with real Episode 1 in series
|
||||
return
|
||||
}
|
||||
// Try "Tập 1", "Tập 2"
|
||||
fmt.Sscanf(epName, "Tập %d", &epNum)
|
||||
|
||||
if epNum == 0 {
|
||||
// Try plain number
|
||||
fmt.Sscanf(epName, "%d", &epNum)
|
||||
}
|
||||
|
||||
if epNum == 0 {
|
||||
epNum = i + 1
|
||||
}
|
||||
|
||||
if idx, exists := epMap[epNum]; exists {
|
||||
if episodes[idx].URL == "" && href != "" {
|
||||
episodes[idx].URL = href
|
||||
episodes[idx].Title = epName
|
||||
}
|
||||
} else {
|
||||
epMap[epNum] = len(episodes)
|
||||
episodes = append(episodes, models.Episode{
|
||||
Number: epNum,
|
||||
Title: epName,
|
||||
URL: href,
|
||||
ServerName: "PhimMoiChill",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback/Main: Find "Xem phim" button which is often Episode 1 for series,
|
||||
// or the only episode for movies. We always check this, not just when len(episodes)==0.
|
||||
watchBtn := doc.Find("a.btn-watch, a.btn-see, ul.btn-block a")
|
||||
var watchHref string
|
||||
if watchBtn.Length() > 0 {
|
||||
watchHref, _ = watchBtn.Attr("href")
|
||||
} else {
|
||||
doc.Find("a").Each(func(i int, s *goquery.Selection) {
|
||||
if strings.Contains(strings.ToLower(s.Text()), "xem phim") {
|
||||
href, _ := s.Attr("href")
|
||||
if strings.Contains(href, "/xem/") {
|
||||
watchHref = href
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if watchHref != "" && strings.Contains(watchHref, "/xem/") {
|
||||
// Only add if Episode 1 is not already present
|
||||
if _, exists := epMap[1]; !exists {
|
||||
epMap[1] = len(episodes)
|
||||
title := "Tập 1"
|
||||
if len(episodes) == 0 {
|
||||
title = "Full"
|
||||
}
|
||||
episodes = append(episodes, models.Episode{
|
||||
Number: 1,
|
||||
Title: title,
|
||||
URL: watchHref,
|
||||
ServerName: "PhimMoiChill",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
movie.Episodes = episodes
|
||||
|
||||
return movie, nil
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
package scraper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPhimMoiChillScraper(t *testing.T) {
|
||||
scraper := NewPhimMoiChillScraper()
|
||||
|
||||
// Test GetMoviesByCategory
|
||||
t.Run("GetMoviesByCategory", func(t *testing.T) {
|
||||
movies, err := scraper.GetMoviesByCategory("phim-le", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Error getting movies: %v", err)
|
||||
}
|
||||
if len(movies) == 0 {
|
||||
t.Fatal("No movies found")
|
||||
}
|
||||
fmt.Printf("GetMoviesByCategory found %d movies\n", len(movies))
|
||||
fmt.Printf("First movie: %+v\n", movies[0])
|
||||
})
|
||||
|
||||
// Test Search
|
||||
t.Run("Search", func(t *testing.T) {
|
||||
movies, err := scraper.Search("batman", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Error searching: %v", err)
|
||||
}
|
||||
if len(movies) == 0 {
|
||||
t.Log("No movies found for search 'batman' (might be possible if site changed)")
|
||||
} else {
|
||||
fmt.Printf("Search found %d movies\n", len(movies))
|
||||
fmt.Printf("First search result: %+v\n", movies[0])
|
||||
}
|
||||
})
|
||||
|
||||
// Test GetMovieDetail
|
||||
// Use a hardcoded known slug or pick from search
|
||||
t.Run("GetMovieDetail", func(t *testing.T) {
|
||||
// Try to search effectively first
|
||||
list, _ := scraper.GetMoviesByCategory("phim-le", 1)
|
||||
if len(list) > 0 {
|
||||
slug := list[0].Slug
|
||||
fmt.Printf("Testing detail for slug: %s\n", slug)
|
||||
movie, err := scraper.GetMovieDetail(slug)
|
||||
if err != nil {
|
||||
t.Fatalf("Error getting detail: %v", err)
|
||||
}
|
||||
if movie == nil {
|
||||
t.Fatal("Movie detail is nil")
|
||||
}
|
||||
fmt.Printf("Movie Detail: Title=%s, Episodes=%d\n", movie.Title, len(movie.Episodes))
|
||||
if len(movie.Episodes) > 0 {
|
||||
fmt.Printf("First Episode URL: %s\n", movie.Episodes[0].URL)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -4,13 +4,9 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -36,8 +32,9 @@ func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error)
|
|||
defer cancel()
|
||||
|
||||
// Check for custom extractors
|
||||
if strings.Contains(url, "phimmoichill") {
|
||||
return e.extractPhimMoiChill(url)
|
||||
if strings.Contains(url, "phim30.me") {
|
||||
// Currently returning the URL as-is, letting yt-dlp attempt extraction
|
||||
// or allowing the frontend iframe to handle it directly if it's embeddable
|
||||
}
|
||||
|
||||
// Build format selector
|
||||
|
|
@ -97,154 +94,3 @@ func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error)
|
|||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (e *VideoExtractor) extractPhimMoiChill(pageURL string) (*VideoInfo, error) {
|
||||
// 1. Fetch the watch page
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
req, err := http.NewRequest("GET", pageURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %v", err)
|
||||
}
|
||||
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("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9,vi;q=0.8")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch page: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("failed to fetch page: status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Capture cookies for the session
|
||||
cookies := resp.Cookies()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read page body: %v", err)
|
||||
}
|
||||
body := string(bodyBytes)
|
||||
|
||||
// 2. Extract IDs: filmId and episodeID
|
||||
// 2. Extract IDs: filmId and episodeID
|
||||
reID := regexp.MustCompile(`chillplay\("(\d+)"\)`)
|
||||
matchesID := reID.FindStringSubmatch(body)
|
||||
var episodeID string
|
||||
if len(matchesID) >= 2 {
|
||||
episodeID = matchesID[1]
|
||||
} else {
|
||||
// Fallback: extract from URL
|
||||
// URL format: ...-tap-X-pm(\d+) or ...-pm(\d+)
|
||||
reURL := regexp.MustCompile(`-pm(\d+)`)
|
||||
matchesURL := reURL.FindStringSubmatch(pageURL)
|
||||
if len(matchesURL) >= 2 {
|
||||
episodeID = matchesURL[1]
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to extract episode ID from page or URL")
|
||||
}
|
||||
}
|
||||
|
||||
reFilmID := regexp.MustCompile(`filmId\s*[:=]\s*(\d+)`)
|
||||
matchesFilm := reFilmID.FindStringSubmatch(body)
|
||||
filmID := ""
|
||||
if len(matchesFilm) > 1 {
|
||||
filmID = matchesFilm[1]
|
||||
}
|
||||
|
||||
// 2.1 Simulate movie_update_view AJAX session (handshake)
|
||||
if filmID != "" {
|
||||
updateURL := "https://phimmoichill.my/movie_update_view"
|
||||
updateData := url.Values{}
|
||||
updateData.Set("film_id", filmID)
|
||||
updateReq, _ := http.NewRequest("POST", updateURL, strings.NewReader(updateData.Encode()))
|
||||
updateReq.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
updateReq.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
updateReq.Header.Set("Referer", pageURL)
|
||||
updateReq.Header.Set("User-Agent", req.Header.Get("User-Agent"))
|
||||
for _, c := range cookies {
|
||||
updateReq.AddCookie(c)
|
||||
}
|
||||
updateResp, err := client.Do(updateReq)
|
||||
if err == nil {
|
||||
updateResp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// 3. POST to chillsplayer.php, trying multiple servers if needed
|
||||
playerURL := "https://phimmoichill.my/chillsplayer.php"
|
||||
var streamHash string
|
||||
|
||||
for sv := 0; sv <= 3; sv++ {
|
||||
data := url.Values{}
|
||||
data.Set("qcao", episodeID)
|
||||
if sv > 0 {
|
||||
data.Set("sv", fmt.Sprintf("%d", sv))
|
||||
}
|
||||
|
||||
reqPost, err := http.NewRequest("POST", playerURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
reqPost.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
reqPost.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")
|
||||
reqPost.Header.Set("Referer", pageURL)
|
||||
reqPost.Header.Set("Origin", "https://phimmoichill.my")
|
||||
reqPost.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
for _, c := range cookies {
|
||||
reqPost.AddCookie(c)
|
||||
}
|
||||
|
||||
respPost, err := client.Do(reqPost)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
playerBodyBytes, err := io.ReadAll(respPost.Body)
|
||||
respPost.Body.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
playerBody := string(playerBodyBytes)
|
||||
|
||||
// 4. Extract Hash or Playlist URL
|
||||
reHash := regexp.MustCompile(`src="https://pmc\.phimmoichill\.my/player/index\.php\?id=([^"&]+)"`)
|
||||
matchesHash := reHash.FindStringSubmatch(playerBody)
|
||||
if len(matchesHash) > 1 {
|
||||
streamHash = matchesHash[1]
|
||||
break
|
||||
}
|
||||
|
||||
reIni := regexp.MustCompile(`iniPlayers\("([^"]+)"`)
|
||||
matchesIni := reIni.FindStringSubmatch(playerBody)
|
||||
if len(matchesIni) > 1 {
|
||||
streamHash = matchesIni[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if streamHash == "" {
|
||||
return nil, fmt.Errorf("failed to extract stream hash from player response after trying all servers")
|
||||
}
|
||||
|
||||
// 5. Construct HLS URL
|
||||
streamURL := fmt.Sprintf("https://sotrim.topphimmoi.org/mpeg/%s/index.m3u8", streamHash)
|
||||
|
||||
title := "Unknown Title"
|
||||
reTitle := regexp.MustCompile(`<title>(.*?)</title>`)
|
||||
if match := reTitle.FindStringSubmatch(body); len(match) > 1 {
|
||||
title = strings.Replace(match[1], " - PhimMoiChill", "", -1)
|
||||
}
|
||||
|
||||
return &VideoInfo{
|
||||
Title: title,
|
||||
StreamURL: streamURL,
|
||||
FormatID: "hls",
|
||||
Resolution: "Auto",
|
||||
Ext: "m3u8",
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"streamflow-backend/internal/scraper"
|
||||
)
|
||||
|
||||
func main() {
|
||||
p := scraper.NewPhimMoiChillScraper()
|
||||
movie, err := p.GetMovieDetail("vu-tru-cua-doi-ta-pm17193")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
b, _ := json.MarshalIndent(movie.Episodes, "", " ")
|
||||
fmt.Println(string(b))
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"streamflow-backend/internal/scraper"
|
||||
)
|
||||
|
||||
func main() {
|
||||
p := scraper.NewOphimScraper()
|
||||
movie, err := p.GetMovieDetail("vu-tru-cua-doi-ta") // Ophim slugs don't have suffix
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
b, _ := json.MarshalIndent(movie.Episodes, "", " ")
|
||||
fmt.Println(string(b))
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"streamflow-backend/internal/scraper"
|
||||
)
|
||||
|
||||
func main() {
|
||||
p := scraper.NewPhimMoiChillScraper()
|
||||
movies, err := p.Search("vũ trụ của đôi ta", 1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
b, _ := json.MarshalIndent(movies, "", " ")
|
||||
fmt.Println(string(b))
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
resp, err := http.Get("http://localhost:8000/api/videos/vu-tru-cua-doi-ta-pm17193")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Size in bytes: %d\n", len(b))
|
||||
}
|
||||
Binary file not shown.
Loading…
Reference in a new issue