From b647bc8272b967bcadece47bea79e7d4fddb7005 Mon Sep 17 00:00:00 2001 From: vndangkhoa Date: Sat, 28 Feb 2026 18:45:48 +0700 Subject: [PATCH] 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 --- android-tv/app/src/main/AndroidManifest.xml | 6 + .../java/com/streamflow/tv/MainActivity.kt | 7 +- .../com/streamflow/tv/data/api/ApiClient.kt | 5 +- .../streamflow/tv/ui/screens/PlayerScreen.kt | 63 +++- .../tv/viewmodel/PlayerViewModel.kt | 18 +- backend/cmd/server/main.go | 4 +- backend/internal/api/handlers.go | 11 +- backend/internal/scraper/ophim.go | 7 +- backend/internal/scraper/phim30.go | 191 ++++++++++++ backend/internal/scraper/phimmoichill.go | 276 ------------------ backend/internal/scraper/phimmoichill_test.go | 59 ---- backend/internal/service/extractor.go | 160 +--------- backend/test_movie.go | 19 -- backend/test_ophim.go | 19 -- backend/test_search.go | 19 -- backend/test_size.go | 19 -- frontend-react/public/streamflow-tv.apk | Bin 23214858 -> 23349075 bytes 17 files changed, 296 insertions(+), 587 deletions(-) create mode 100644 backend/internal/scraper/phim30.go delete mode 100644 backend/internal/scraper/phimmoichill.go delete mode 100644 backend/internal/scraper/phimmoichill_test.go delete mode 100644 backend/test_movie.go delete mode 100644 backend/test_ophim.go delete mode 100644 backend/test_search.go delete mode 100644 backend/test_size.go 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: