v3.9: Add Next/Prev episode buttons, replace PhimMoiChill with Phim30 scraper, filter blank thumbnails
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

- 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:
vndangkhoa 2026-02-28 18:45:48 +07:00
parent 46818cced5
commit b647bc8272
17 changed files with 296 additions and 587 deletions

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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)

View file

@ -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)

View file

@ -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 {

View file

@ -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
}

View 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
}

View file

@ -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
}

View file

@ -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)
}
}
})
}

View file

@ -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
}

View file

@ -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))
}

View file

@ -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))
}

View file

@ -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))
}

View file

@ -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))
}