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.LEANBACK_LAUNCHER" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,9 @@ fun StreamFlowTvApp() {
|
||||||
try {
|
try {
|
||||||
currentTheme = userRepo.theme.first()
|
currentTheme = userRepo.theme.first()
|
||||||
val serverUrl = userRepo.serverUrl.first()
|
val serverUrl = userRepo.serverUrl.first()
|
||||||
if (serverUrl.isNotBlank()) {
|
/*if (serverUrl.isNotBlank()) {
|
||||||
ApiClient.baseUrl = serverUrl
|
ApiClient.baseUrl = serverUrl
|
||||||
}
|
}*/
|
||||||
Log.d("StreamFlowTvApp", "Settings loaded: theme=$currentTheme, url=$serverUrl")
|
Log.d("StreamFlowTvApp", "Settings loaded: theme=$currentTheme, url=$serverUrl")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("StreamFlowTvApp", "Error loading settings", e)
|
Log.e("StreamFlowTvApp", "Error loading settings", e)
|
||||||
|
|
@ -128,7 +128,8 @@ fun StreamFlowTvApp() {
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
navArgument("slug") { type = NavType.StringType },
|
navArgument("slug") { type = NavType.StringType },
|
||||||
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
||||||
)
|
),
|
||||||
|
deepLinks = listOf(androidx.navigation.navDeepLink { uriPattern = "streamflow://player/{slug}/{episode}" })
|
||||||
) { entry ->
|
) { entry ->
|
||||||
val slug = entry.arguments?.getString("slug")
|
val slug = entry.arguments?.getString("slug")
|
||||||
val episode = entry.arguments?.getInt("episode") ?: 1
|
val episode = entry.arguments?.getInt("episode") ?: 1
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
object ApiClient {
|
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
|
var baseUrl: String
|
||||||
get() = _baseUrl
|
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
|
// Update player when source changes
|
||||||
LaunchedEffect(uiState.source) {
|
LaunchedEffect(uiState.source) {
|
||||||
uiState.source?.let { source ->
|
uiState.source?.let { source ->
|
||||||
|
|
@ -132,6 +175,18 @@ fun PlayerScreen(
|
||||||
playerView?.showController()
|
playerView?.showController()
|
||||||
true
|
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
|
||||||
}
|
}
|
||||||
} else false
|
} else false
|
||||||
|
|
@ -159,13 +214,15 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ExoPlayer View
|
// ExoPlayer View
|
||||||
|
android.util.Log.e("StreamFlowPlayer", "Drawing AndroidView for Player")
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
|
android.util.Log.e("StreamFlowPlayer", "Creating PlayerView factory")
|
||||||
PlayerView(ctx).apply {
|
PlayerView(ctx).apply {
|
||||||
player = exoPlayer
|
player = forwardingPlayer
|
||||||
useController = true
|
useController = true
|
||||||
setShowNextButton(false)
|
setShowNextButton(true)
|
||||||
setShowPreviousButton(false)
|
setShowPreviousButton(true)
|
||||||
controllerAutoShow = true
|
controllerAutoShow = true
|
||||||
keepScreenOn = true // Prevent screen sleep during playback
|
keepScreenOn = true // Prevent screen sleep during playback
|
||||||
layoutParams = FrameLayout.LayoutParams(
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
|
|
|
||||||
|
|
@ -71,19 +71,23 @@ class PlayerViewModel : ViewModel() {
|
||||||
),
|
),
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
} else {
|
} else if (ep != null && ep.url.isNotEmpty()) {
|
||||||
// Need to extract
|
// Non-HLS URL — try to extract via backend
|
||||||
val targetUrl = ep?.url
|
android.util.Log.e("PlayerViewModel", "Extracting from URL: ${ep.url}")
|
||||||
?: "https://phimmoichill.network/xem-phim/${movie.slug}/tap-$episode"
|
val source = repository.extractVideo(ep.url)
|
||||||
|
|
||||||
android.util.Log.e("PlayerViewModel", "Extracting from URL: $targetUrl")
|
|
||||||
val source = repository.extractVideo(targetUrl)
|
|
||||||
android.util.Log.e("PlayerViewModel", "Extraction successful: $source")
|
android.util.Log.e("PlayerViewModel", "Extraction successful: $source")
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
source = source,
|
source = source,
|
||||||
isLoading = false
|
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) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("PlayerViewModel", "Error loading stream", e)
|
android.util.Log.e("PlayerViewModel", "Error loading stream", e)
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,12 @@ func main() {
|
||||||
|
|
||||||
videoRepo := database.NewVideoRepository(database.DB)
|
videoRepo := database.NewVideoRepository(database.DB)
|
||||||
ophimService := scraper.NewOphimScraper()
|
ophimService := scraper.NewOphimScraper()
|
||||||
phimMoiService := scraper.NewPhimMoiChillScraper()
|
phim30Service := scraper.NewPhim30Scraper()
|
||||||
tmdbService := service.NewTMDBService()
|
tmdbService := service.NewTMDBService()
|
||||||
extractorService := service.NewVideoExtractor()
|
extractorService := service.NewVideoExtractor()
|
||||||
imageService := service.NewImageService()
|
imageService := service.NewImageService()
|
||||||
|
|
||||||
providers := []scraper.MovieProvider{ophimService, phimMoiService}
|
providers := []scraper.MovieProvider{ophimService, phim30Service}
|
||||||
|
|
||||||
handler := api.NewHandler(videoRepo, providers, tmdbService, extractorService, imageService)
|
handler := api.NewHandler(videoRepo, providers, tmdbService, extractorService, imageService)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,16 @@ func (h *Handler) fetchAndMergeMovies(fetch movieFetcher) []models.RophimMovie {
|
||||||
return []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 {
|
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 {
|
if _, err := fmt.Sscanf(ep.Name, "Tap %d", &n); err == nil {
|
||||||
epNum = n
|
epNum = n
|
||||||
}
|
}
|
||||||
// If still 0 (e.g. "Full", "Trailer"), skip — don't default to 1
|
if strings.EqualFold(ep.Name, "Full") || strings.EqualFold(ep.Name, "Trailer") {
|
||||||
// as that would collide with real Episode 1 during dedup
|
epNum = 1 // single-movie or trailer as ep 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still 0, skip
|
||||||
if epNum == 0 {
|
if epNum == 0 {
|
||||||
continue
|
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"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -36,8 +32,9 @@ func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Check for custom extractors
|
// Check for custom extractors
|
||||||
if strings.Contains(url, "phimmoichill") {
|
if strings.Contains(url, "phim30.me") {
|
||||||
return e.extractPhimMoiChill(url)
|
// 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
|
// Build format selector
|
||||||
|
|
@ -97,154 +94,3 @@ func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error)
|
||||||
|
|
||||||
return &info, nil
|
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