diff --git a/android-tv/app/src/main/AndroidManifest.xml b/android-tv/app/src/main/AndroidManifest.xml
index 93c9385..bd82ea6 100644
--- a/android-tv/app/src/main/AndroidManifest.xml
+++ b/android-tv/app/src/main/AndroidManifest.xml
@@ -32,6 +32,12 @@
+
+
+
+
+
+
diff --git a/android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt b/android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt
index 0cdee94..f915bc2 100644
--- a/android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt
+++ b/android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt
@@ -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
diff --git a/android-tv/app/src/main/java/com/streamflow/tv/data/api/ApiClient.kt b/android-tv/app/src/main/java/com/streamflow/tv/data/api/ApiClient.kt
index 483214f..7313100 100644
--- a/android-tv/app/src/main/java/com/streamflow/tv/data/api/ApiClient.kt
+++ b/android-tv/app/src/main/java/com/streamflow/tv/data/api/ApiClient.kt
@@ -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
diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/PlayerScreen.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/PlayerScreen.kt
index b51062b..e1ed378 100644
--- a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/PlayerScreen.kt
+++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/PlayerScreen.kt
@@ -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(
diff --git a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/PlayerViewModel.kt b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/PlayerViewModel.kt
index d7a3d0a..6e552de 100644
--- a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/PlayerViewModel.kt
+++ b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/PlayerViewModel.kt
@@ -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)
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index aae3b43..61b61cf 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -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)
diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go
index fd43ff1..f2a67d5 100644
--- a/backend/internal/api/handlers.go
+++ b/backend/internal/api/handlers.go
@@ -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 {
diff --git a/backend/internal/scraper/ophim.go b/backend/internal/scraper/ophim.go
index 235f796..27ac668 100644
--- a/backend/internal/scraper/ophim.go
+++ b/backend/internal/scraper/ophim.go
@@ -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
}
diff --git a/backend/internal/scraper/phim30.go b/backend/internal/scraper/phim30.go
new file mode 100644
index 0000000..9ffbf3f
--- /dev/null
+++ b/backend/internal/scraper/phim30.go
@@ -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
+}
diff --git a/backend/internal/scraper/phimmoichill.go b/backend/internal/scraper/phimmoichill.go
deleted file mode 100644
index ddceb70..0000000
--- a/backend/internal/scraper/phimmoichill.go
+++ /dev/null
@@ -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:
- ...
- 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
-}
diff --git a/backend/internal/scraper/phimmoichill_test.go b/backend/internal/scraper/phimmoichill_test.go
deleted file mode 100644
index 20fea09..0000000
--- a/backend/internal/scraper/phimmoichill_test.go
+++ /dev/null
@@ -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)
- }
- }
- })
-}
diff --git a/backend/internal/service/extractor.go b/backend/internal/service/extractor.go
index f20f5fb..e1f608b 100644
--- a/backend/internal/service/extractor.go
+++ b/backend/internal/service/extractor.go
@@ -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(`(.*?)`)
- 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
-}
diff --git a/backend/test_movie.go b/backend/test_movie.go
deleted file mode 100644
index b8b1072..0000000
--- a/backend/test_movie.go
+++ /dev/null
@@ -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))
-}
diff --git a/backend/test_ophim.go b/backend/test_ophim.go
deleted file mode 100644
index 73f519e..0000000
--- a/backend/test_ophim.go
+++ /dev/null
@@ -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))
-}
diff --git a/backend/test_search.go b/backend/test_search.go
deleted file mode 100644
index 89802a0..0000000
--- a/backend/test_search.go
+++ /dev/null
@@ -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))
-}
diff --git a/backend/test_size.go b/backend/test_size.go
deleted file mode 100644
index 993181a..0000000
--- a/backend/test_size.go
+++ /dev/null
@@ -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))
-}
diff --git a/frontend-react/public/streamflow-tv.apk b/frontend-react/public/streamflow-tv.apk
index 5fc8f98..d32fdc4 100644
Binary files a/frontend-react/public/streamflow-tv.apk and b/frontend-react/public/streamflow-tv.apk differ