diff --git a/backend/internal/scraper/ophim.go b/backend/internal/scraper/ophim.go index fbbebc6..c66346a 100644 --- a/backend/internal/scraper/ophim.go +++ b/backend/internal/scraper/ophim.go @@ -1,368 +1,368 @@ -package scraper - -import ( - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - "time" - - "streamflow-backend/internal/models" -) - -const OphimBaseURL = "https://ophim1.com" - -type OphimScraper struct { - client *http.Client -} - -func NewOphimScraper() *OphimScraper { - return &OphimScraper{ - client: &http.Client{ - Timeout: 30 * time.Second, - }, - } -} - -// Response structs for Ophim API - -type OphimResponse struct { - Items []OphimItem `json:"items"` - Data struct { - Items []OphimItem `json:"items"` - Item OphimMovie `json:"item"` - Episodes []OphimEpisodeServer `json:"episodes,omitempty"` // Sometimes here? - } `json:"data"` - Movie OphimMovie `json:"movie"` - Episodes []OphimEpisodeServer `json:"episodes"` - Pagination struct { - TotalItems int `json:"totalItems"` - TotalItemsPerPage int `json:"totalItemsPerPage"` - CurrentPage int `json:"currentPage"` - TotalPages int `json:"totalPages"` - } `json:"pagination"` -} - -type OphimItem struct { - Name string `json:"name"` - OriginName string `json:"origin_name"` - Slug string `json:"slug"` - ThumbURL string `json:"thumb_url"` - PosterURL string `json:"poster_url"` - Year int `json:"year"` - Time string `json:"time"` - Quality string `json:"quality"` - Lang string `json:"lang"` -} - -type OphimMovie struct { - ID string `json:"_id"` - Name string `json:"name"` - OriginName string `json:"origin_name"` - Slug string `json:"slug"` - Content string `json:"content"` - ThumbURL string `json:"thumb_url"` - PosterURL string `json:"poster_url"` - Year int `json:"year"` - Time string `json:"time"` - Quality string `json:"quality"` - Lang string `json:"lang"` - Director []string `json:"director"` - Category []struct { - Name string `json:"name"` - } `json:"category"` - Country []struct { - Name string `json:"name"` - } `json:"country"` - Episodes []OphimEpisodeServer `json:"episodes,omitempty"` // Nested episodes? - TrailerURL string `json:"trailer_url"` -} - -type OphimEpisodeServer struct { - ServerName string `json:"server_name"` - ServerData []OphimEpisodeData `json:"server_data"` -} - -type OphimEpisodeData struct { - Name string `json:"name"` - Slug string `json:"slug"` - Filename string `json:"filename"` - LinkEmbed string `json:"link_embed"` - LinkM3U8 string `json:"link_m3u8"` -} - -func (s *OphimScraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) { - // Logic to distinguish between "Lists" (danh-sach) and "Genres" (the-loai) - // Known lists: phim-le, phim-bo, hoat-hinh, tv-shows, phim-sap-chieu, phim-dang-chieu - var path string - switch category { - case "home", "": - path = "danh-sach/phim-moi-cap-nhat" - case "phim-le", "phim-bo", "hoat-hinh", "tv-shows", "phim-sap-chieu", "phim-dang-chieu": - path = fmt.Sprintf("danh-sach/%s", category) - default: - // Assume everything else is a Genre (e.g., hanh-dong, tinh-cam, co-trang) - // Ophim uses "the-loai" for these. - path = fmt.Sprintf("the-loai/%s", category) - } - - // Important: The upstream API endpoints are: - // - v1/api/danh-sach/{slug} - // - v1/api/the-loai/{slug} - // The getList function appends prefix if not present? - // s.getList adds "v1/api" prefix? No, currently getList takes full path suffix. - // Wait, loop at getList: url := fmt.Sprintf("%s/%s?page=%d", OphimBaseURL, path, page) - // So we need to include "v1/api/" in our path variable constructed above. - - finalPath := fmt.Sprintf("v1/api/%s", path) - return s.getList(finalPath, page) -} - -func (s *OphimScraper) GetHomepageMovies(page int) ([]models.RophimMovie, error) { - return s.GetMoviesByCategory("home", page) -} - -func (s *OphimScraper) Search(query string, page int) ([]models.RophimMovie, error) { - encodedQuery := url.QueryEscape(query) - url := fmt.Sprintf("%s/v1/api/tim-kiem?keyword=%s&page=%d", OphimBaseURL, encodedQuery, page) - return s.fetchAndParseList(url) -} - -func (s *OphimScraper) GetGenres() ([]models.Category, error) { - return s.fetchCategories("v1/api/the-loai") -} - -func (s *OphimScraper) GetCountries() ([]models.Category, error) { - return s.fetchCategories("v1/api/quoc-gia") -} - -func (s *OphimScraper) fetchCategories(path string) ([]models.Category, error) { - url := fmt.Sprintf("%s/%s", OphimBaseURL, path) - resp, err := s.client.Get(url) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var result struct { - Data struct { - Items []struct { - Name string `json:"name"` - Slug string `json:"slug"` - } `json:"items"` - } `json:"data"` - } - - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - var categories []models.Category - for _, item := range result.Data.Items { - categories = append(categories, models.Category{ - Name: item.Name, - Slug: item.Slug, - }) - } - return categories, nil -} - -func (s *OphimScraper) getList(path string, page int) ([]models.RophimMovie, error) { - url := fmt.Sprintf("%s/%s?page=%d", OphimBaseURL, path, page) - return s.fetchAndParseList(url) -} - -func (s *OphimScraper) fetchAndParseList(url string) ([]models.RophimMovie, error) { - resp, err := s.client.Get(url) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status) - } - - var result OphimResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - // API usually returns items in "items" (homepage/list) or "data" sometimes? - // The struct OphimResponse has "items". - // Search API structure verification: - // My previous curl showed "data": { "items": [...] } structure for search? - // Wait, checking the curled output from Step 256. - // Output: `{"status":true,"msg":"","data":{"seoOnPage":...,"breadCrumb":...,"titlePage":...,"items":[...]` - // So Search returns data -> items. - // My OphimResponse struct has "Items []OphimItem" at top level. - // I need to adjust struct to handle "data" wrapper if present, or "items" if direct. - // The homepage returns "items" directly? - // Let's check homepage struct. I previously assumed it was directly status, items. - // If search has "data", generic parsing might need adjustment. - - // Let's look at the previous successful homepage request. - // If it worked, then homepage returns "items" at top level. - // If Search returns "data" -> "items", I need a wrapper struct. - - var movies []models.RophimMovie - items := result.Items - - // If top level items is empty, try checking if there is a Data field with items - // I need to update OphimResponse struct first to include Data field. - - if len(items) == 0 && len(result.Data.Items) > 0 { - items = result.Data.Items - } - - for _, item := range items { - thumb := item.ThumbURL - if !strings.HasPrefix(thumb, "http") { - // Search API might return relative paths too - thumb = "https://img.ophim1.com/uploads/movies/" + thumb - } - - backdrop := item.PosterURL - if !strings.HasPrefix(backdrop, "http") { - backdrop = "https://img.ophim1.com/uploads/movies/" + backdrop - } - - movies = append(movies, models.RophimMovie{ - ID: item.Slug, - Title: item.Name, - OriginalTitle: item.OriginName, - Slug: item.Slug, - Thumbnail: thumb, - Backdrop: backdrop, - Year: item.Year, - Category: "movies", - Provider: "Ophim", - Time: item.Time, - Quality: item.Quality, - Lang: item.Lang, - }) - } - - return movies, nil -} - -func (s *OphimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) { - // Correct API endpoint is v1/api/phim/{slug} - url := fmt.Sprintf("%s/v1/api/phim/%s", OphimBaseURL, slug) - resp, err := s.client.Get(url) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status) - } - - var result OphimResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - // Try to get movie from Top Level or Data.Item - movie := result.Movie - if movie.Slug == "" { - movie = result.Data.Item - } - - thumb := movie.ThumbURL - if !strings.HasPrefix(thumb, "http") { - thumb = "https://img.ophim1.com/uploads/movies/" + thumb - } - - backdrop := movie.PosterURL - if !strings.HasPrefix(backdrop, "http") { - backdrop = "https://img.ophim1.com/uploads/movies/" + backdrop - } - - var episodes []models.Episode - // Try Top Level Episodes, then Data.Episodes, then Movie.Episodes? - rawEpisodes := result.Episodes - if len(rawEpisodes) == 0 { - // New API might put episodes inside "item.episodes" or "data.episodes" - // Based on typical Ophim structures: - if len(result.Data.Episodes) > 0 { - rawEpisodes = result.Data.Episodes - } else if len(movie.Episodes) > 0 { - rawEpisodes = movie.Episodes - } - } - - epMap := make(map[string]int) // map[epNum-serverName]sliceIndex - for _, server := range rawEpisodes { - for _, ep := range server.ServerData { - epNum := 0 - fmt.Sscanf(ep.Name, "%d", &epNum) - if epNum == 0 { - var n int - if _, err := fmt.Sscanf(ep.Name, "Tap %d", &n); err == nil { - epNum = n - } - 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 - } - } - - serverKey := fmt.Sprintf("%d-%s", epNum, server.ServerName) - if idx, exists := epMap[serverKey]; exists { - // If existing is empty, replace with this one - if episodes[idx].URL == "" && ep.LinkM3U8 != "" { - episodes[idx].URL = ep.LinkM3U8 - episodes[idx].Title = ep.Name - } - } else { - if ep.LinkM3U8 == "" && ep.LinkEmbed == "" { - continue - } - - epMap[serverKey] = len(episodes) - episodes = append(episodes, models.Episode{ - Number: epNum, - Title: ep.Name, - URL: ep.LinkM3U8, - ServerName: server.ServerName, - }) - } - } - } - - return &models.RophimMovie{ - ID: movie.Slug, - Title: movie.Name, - OriginalTitle: movie.OriginName, - Slug: movie.Slug, - Thumbnail: thumb, - Backdrop: backdrop, - Description: movie.Content, - Year: movie.Year, - Quality: movie.Quality, - Duration: 0, // String parse needed if we want "90 phut" - Category: "movies", - Episodes: episodes, - Country: safeGetName(movie.Country), - Director: strings.Join(movie.Director, ", "), - Genre: safeGetName(movie.Category), - TrailerURL: movie.TrailerURL, - }, nil -} - -func safeGetName(items []struct { - Name string `json:"name"` -}) string { - var names []string - for _, i := range items { - names = append(names, i.Name) - } - return strings.Join(names, ", ") -} +package scraper + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "streamflow-backend/internal/models" +) + +const OphimBaseURL = "https://ophim1.com" + +type OphimScraper struct { + client *http.Client +} + +func NewOphimScraper() *OphimScraper { + return &OphimScraper{ + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Response structs for Ophim API + +type OphimResponse struct { + Items []OphimItem `json:"items"` + Data struct { + Items []OphimItem `json:"items"` + Item OphimMovie `json:"item"` + Episodes []OphimEpisodeServer `json:"episodes,omitempty"` // Sometimes here? + } `json:"data"` + Movie OphimMovie `json:"movie"` + Episodes []OphimEpisodeServer `json:"episodes"` + Pagination struct { + TotalItems int `json:"totalItems"` + TotalItemsPerPage int `json:"totalItemsPerPage"` + CurrentPage int `json:"currentPage"` + TotalPages int `json:"totalPages"` + } `json:"pagination"` +} + +type OphimItem struct { + Name string `json:"name"` + OriginName string `json:"origin_name"` + Slug string `json:"slug"` + ThumbURL string `json:"thumb_url"` + PosterURL string `json:"poster_url"` + Year int `json:"year"` + Time string `json:"time"` + Quality string `json:"quality"` + Lang string `json:"lang"` +} + +type OphimMovie struct { + ID string `json:"_id"` + Name string `json:"name"` + OriginName string `json:"origin_name"` + Slug string `json:"slug"` + Content string `json:"content"` + ThumbURL string `json:"thumb_url"` + PosterURL string `json:"poster_url"` + Year int `json:"year"` + Time string `json:"time"` + Quality string `json:"quality"` + Lang string `json:"lang"` + Director []string `json:"director"` + Category []struct { + Name string `json:"name"` + } `json:"category"` + Country []struct { + Name string `json:"name"` + } `json:"country"` + Episodes []OphimEpisodeServer `json:"episodes,omitempty"` // Nested episodes? + TrailerURL string `json:"trailer_url"` +} + +type OphimEpisodeServer struct { + ServerName string `json:"server_name"` + ServerData []OphimEpisodeData `json:"server_data"` +} + +type OphimEpisodeData struct { + Name string `json:"name"` + Slug string `json:"slug"` + Filename string `json:"filename"` + LinkEmbed string `json:"link_embed"` + LinkM3U8 string `json:"link_m3u8"` +} + +func (s *OphimScraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) { + // Logic to distinguish between "Lists" (danh-sach) and "Genres" (the-loai) + // Known lists: phim-le, phim-bo, hoat-hinh, tv-shows, phim-sap-chieu, phim-dang-chieu + var path string + switch category { + case "home", "": + path = "danh-sach/phim-moi-cap-nhat" + case "phim-le", "phim-bo", "hoat-hinh", "tv-shows", "phim-sap-chieu", "phim-dang-chieu": + path = fmt.Sprintf("danh-sach/%s", category) + default: + // Assume everything else is a Genre (e.g., hanh-dong, tinh-cam, co-trang) + // Ophim uses "the-loai" for these. + path = fmt.Sprintf("the-loai/%s", category) + } + + // Important: The upstream API endpoints are: + // - v1/api/danh-sach/{slug} + // - v1/api/the-loai/{slug} + // The getList function appends prefix if not present? + // s.getList adds "v1/api" prefix? No, currently getList takes full path suffix. + // Wait, loop at getList: url := fmt.Sprintf("%s/%s?page=%d", OphimBaseURL, path, page) + // So we need to include "v1/api/" in our path variable constructed above. + + finalPath := fmt.Sprintf("v1/api/%s", path) + return s.getList(finalPath, page) +} + +func (s *OphimScraper) GetHomepageMovies(page int) ([]models.RophimMovie, error) { + return s.GetMoviesByCategory("home", page) +} + +func (s *OphimScraper) Search(query string, page int) ([]models.RophimMovie, error) { + encodedQuery := url.QueryEscape(query) + url := fmt.Sprintf("%s/v1/api/tim-kiem?keyword=%s&page=%d", OphimBaseURL, encodedQuery, page) + return s.fetchAndParseList(url) +} + +func (s *OphimScraper) GetGenres() ([]models.Category, error) { + return s.fetchCategories("v1/api/the-loai") +} + +func (s *OphimScraper) GetCountries() ([]models.Category, error) { + return s.fetchCategories("v1/api/quoc-gia") +} + +func (s *OphimScraper) fetchCategories(path string) ([]models.Category, error) { + url := fmt.Sprintf("%s/%s", OphimBaseURL, path) + resp, err := s.client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Data struct { + Items []struct { + Name string `json:"name"` + Slug string `json:"slug"` + } `json:"items"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + var categories []models.Category + for _, item := range result.Data.Items { + categories = append(categories, models.Category{ + Name: item.Name, + Slug: item.Slug, + }) + } + return categories, nil +} + +func (s *OphimScraper) getList(path string, page int) ([]models.RophimMovie, error) { + url := fmt.Sprintf("%s/%s?page=%d", OphimBaseURL, path, page) + return s.fetchAndParseList(url) +} + +func (s *OphimScraper) fetchAndParseList(url string) ([]models.RophimMovie, error) { + resp, err := s.client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status) + } + + var result OphimResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + // API usually returns items in "items" (homepage/list) or "data" sometimes? + // The struct OphimResponse has "items". + // Search API structure verification: + // My previous curl showed "data": { "items": [...] } structure for search? + // Wait, checking the curled output from Step 256. + // Output: `{"status":true,"msg":"","data":{"seoOnPage":...,"breadCrumb":...,"titlePage":...,"items":[...]` + // So Search returns data -> items. + // My OphimResponse struct has "Items []OphimItem" at top level. + // I need to adjust struct to handle "data" wrapper if present, or "items" if direct. + // The homepage returns "items" directly? + // Let's check homepage struct. I previously assumed it was directly status, items. + // If search has "data", generic parsing might need adjustment. + + // Let's look at the previous successful homepage request. + // If it worked, then homepage returns "items" at top level. + // If Search returns "data" -> "items", I need a wrapper struct. + + var movies []models.RophimMovie + items := result.Items + + // If top level items is empty, try checking if there is a Data field with items + // I need to update OphimResponse struct first to include Data field. + + if len(items) == 0 && len(result.Data.Items) > 0 { + items = result.Data.Items + } + + for _, item := range items { + thumb := item.ThumbURL + if !strings.HasPrefix(thumb, "http") { + // Search API might return relative paths too + thumb = "https://img.ophim.live/uploads/movies/" + thumb + } + + backdrop := item.PosterURL + if !strings.HasPrefix(backdrop, "http") { + backdrop = "https://img.ophim.live/uploads/movies/" + backdrop + } + + movies = append(movies, models.RophimMovie{ + ID: item.Slug, + Title: item.Name, + OriginalTitle: item.OriginName, + Slug: item.Slug, + Thumbnail: thumb, + Backdrop: backdrop, + Year: item.Year, + Category: "movies", + Provider: "Ophim", + Time: item.Time, + Quality: item.Quality, + Lang: item.Lang, + }) + } + + return movies, nil +} + +func (s *OphimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) { + // Correct API endpoint is v1/api/phim/{slug} + url := fmt.Sprintf("%s/v1/api/phim/%s", OphimBaseURL, slug) + resp, err := s.client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status) + } + + var result OphimResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + // Try to get movie from Top Level or Data.Item + movie := result.Movie + if movie.Slug == "" { + movie = result.Data.Item + } + + thumb := movie.ThumbURL + if !strings.HasPrefix(thumb, "http") { + thumb = "https://img.ophim.live/uploads/movies/" + thumb + } + + backdrop := movie.PosterURL + if !strings.HasPrefix(backdrop, "http") { + backdrop = "https://img.ophim.live/uploads/movies/" + backdrop + } + + var episodes []models.Episode + // Try Top Level Episodes, then Data.Episodes, then Movie.Episodes? + rawEpisodes := result.Episodes + if len(rawEpisodes) == 0 { + // New API might put episodes inside "item.episodes" or "data.episodes" + // Based on typical Ophim structures: + if len(result.Data.Episodes) > 0 { + rawEpisodes = result.Data.Episodes + } else if len(movie.Episodes) > 0 { + rawEpisodes = movie.Episodes + } + } + + epMap := make(map[string]int) // map[epNum-serverName]sliceIndex + for _, server := range rawEpisodes { + for _, ep := range server.ServerData { + epNum := 0 + fmt.Sscanf(ep.Name, "%d", &epNum) + if epNum == 0 { + var n int + if _, err := fmt.Sscanf(ep.Name, "Tap %d", &n); err == nil { + epNum = n + } + 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 + } + } + + serverKey := fmt.Sprintf("%d-%s", epNum, server.ServerName) + if idx, exists := epMap[serverKey]; exists { + // If existing is empty, replace with this one + if episodes[idx].URL == "" && ep.LinkM3U8 != "" { + episodes[idx].URL = ep.LinkM3U8 + episodes[idx].Title = ep.Name + } + } else { + if ep.LinkM3U8 == "" && ep.LinkEmbed == "" { + continue + } + + epMap[serverKey] = len(episodes) + episodes = append(episodes, models.Episode{ + Number: epNum, + Title: ep.Name, + URL: ep.LinkM3U8, + ServerName: server.ServerName, + }) + } + } + } + + return &models.RophimMovie{ + ID: movie.Slug, + Title: movie.Name, + OriginalTitle: movie.OriginName, + Slug: movie.Slug, + Thumbnail: thumb, + Backdrop: backdrop, + Description: movie.Content, + Year: movie.Year, + Quality: movie.Quality, + Duration: 0, // String parse needed if we want "90 phut" + Category: "movies", + Episodes: episodes, + Country: safeGetName(movie.Country), + Director: strings.Join(movie.Director, ", "), + Genre: safeGetName(movie.Category), + TrailerURL: movie.TrailerURL, + }, nil +} + +func safeGetName(items []struct { + Name string `json:"name"` +}) string { + var names []string + for _, i := range items { + names = append(names, i.Name) + } + return strings.Join(names, ", ") +} diff --git a/frontend-react/src/components/Hero.tsx b/frontend-react/src/components/Hero.tsx index 77c1b9a..766f4cb 100644 --- a/frontend-react/src/components/Hero.tsx +++ b/frontend-react/src/components/Hero.tsx @@ -34,8 +34,13 @@ export const Hero = ({ movies, variant = 'default' }: HeroProps) => { // Helper to generate robust image URLs const getImageUrl = (url: string | undefined, width: number, blur: number = 0) => { if (!url) return ''; - // Unified logic: Simple encoding like Card.tsx, relying on wsrv.nl's robust handling - return `https://wsrv.nl/?url=${encodeURIComponent(url)}&w=${width}&output=webp${blur ? `&blur=${blur}` : ''}&fit=cover`; + let cleanUrl = url; + if (url.startsWith('//')) { + cleanUrl = `https:${url}`; + } else if (!url.startsWith('http')) { + cleanUrl = `https://${url}`; + } + return cleanUrl; }; // --- Variant-Specific Styles --- diff --git a/frontend-react/src/components/HomeContent.tsx b/frontend-react/src/components/HomeContent.tsx index 0c40179..d8a13c3 100644 --- a/frontend-react/src/components/HomeContent.tsx +++ b/frontend-react/src/components/HomeContent.tsx @@ -7,6 +7,7 @@ import { CATEGORIES } from '../constants'; import { useMyList } from '../hooks/useMyList'; import { useSmartRecommendations } from '../hooks/useSmartRecommendations'; +import { useWatchProgress } from '../hooks/useWatchProgress'; interface HomeContentProps { topPadding?: string; @@ -20,6 +21,8 @@ export const HomeContent = ({ topPadding = "pt-24" }: HomeContentProps) => { const [hasMore, setHasMore] = useState(true); const { watchHistory, savedMovies } = useMyList(); // Access History and MyList + const { getContinueWatchingMovies } = useWatchProgress(); + const continueWatching = getContinueWatchingMovies(); const [searchParams] = useSearchParams(); const query = searchParams.get('q'); const category = searchParams.get('category'); @@ -124,8 +127,8 @@ export const HomeContent = ({ topPadding = "pt-24" }: HomeContentProps) => { {showRows && (
{/* Continue Watching Row */} - {watchHistory.length > 0 && ( - + {continueWatching.length > 0 && ( + )} {/* My List Row */} diff --git a/frontend-react/src/components/MovieCard.tsx b/frontend-react/src/components/MovieCard.tsx index 1eb5cb5..0de6cd8 100644 --- a/frontend-react/src/components/MovieCard.tsx +++ b/frontend-react/src/components/MovieCard.tsx @@ -12,15 +12,20 @@ interface MovieCardProps { export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCardProps) => { const [imgError, setImgError] = useState(false); + // Calculate progress percentage + const progressPercent = movie.watchedTimestamp && movie.duration + ? (movie.watchedTimestamp / movie.duration) * 100 + : 0; + const getImageUrl = (url: string, width: number) => { if (!url) return ''; let cleanUrl = url; - if (url.includes('img.ophim1.com')) { - cleanUrl = url.replace('img.ophim1.com', 'ssl:img.ophim1.com'); - } else if (url.startsWith('//')) { + if (url.startsWith('//')) { cleanUrl = `https:${url}`; + } else if (!url.startsWith('http')) { + cleanUrl = `https://${url}`; } - return `https://wsrv.nl/?url=${encodeURIComponent(cleanUrl.replace(/^https?:\/\//, ''))}&w=${width}&output=webp`; + return cleanUrl; }; return ( @@ -64,6 +69,15 @@ export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCa
)} + {/* Episode Badge for Continue Watching */} + {movie.currentEpisode && ( +
+
+ Tập {movie.currentEpisode} +
+
+ )} + {/* Top-Right Tags (Quality & Lang) */}
{movie.quality && ( @@ -87,6 +101,16 @@ export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCa
)} + + {/* Progress Bar for Continue Watching */} + {progressPercent > 0 && ( +
+
+
+ )} {/* Info Section */} diff --git a/frontend-react/src/hooks/useWatchMovie.ts b/frontend-react/src/hooks/useWatchMovie.ts index 0cff125..b50789b 100644 --- a/frontend-react/src/hooks/useWatchMovie.ts +++ b/frontend-react/src/hooks/useWatchMovie.ts @@ -1,6 +1,7 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import Hls from 'hls.js'; import type { MovieDetail, VideoSource } from '../types'; +import { useWatchProgress } from './useWatchProgress'; export const useWatchMovie = (slug: string | undefined, episode: string | undefined) => { const videoRef = useRef(null); @@ -8,6 +9,40 @@ export const useWatchMovie = (slug: string | undefined, episode: string | undefi const [source, setSource] = useState(null); const [loading, setLoading] = useState(true); const [currentEpisode, setCurrentEpisode] = useState(parseInt(episode || '1')); + const { getProgress, saveProgress, clearProgress } = useWatchProgress(); + const saveIntervalRef = useRef | null>(null); + + // Refs to avoid effect re-running when these functions change + const getProgressRef = useRef(getProgress); + const saveProgressRef = useRef(saveProgress); + const clearProgressRef = useRef(clearProgress); + const movieRef = useRef(movie); + + // Update refs when values change + useEffect(() => { + getProgressRef.current = getProgress; + }, [getProgress]); + + useEffect(() => { + saveProgressRef.current = saveProgress; + }, [saveProgress]); + + useEffect(() => { + clearProgressRef.current = clearProgress; + }, [clearProgress]); + + useEffect(() => { + movieRef.current = movie; + }, [movie]); + + // Load saved progress on mount + useEffect(() => { + if (!slug) return; + const progress = getProgress(slug); + if (progress) { + setCurrentEpisode(progress.episode); + } + }, [slug, getProgress]); useEffect(() => { if (!slug) return; @@ -24,6 +59,16 @@ export const useWatchMovie = (slug: string | undefined, episode: string | undefi fetchDetails(); }, [slug]); + // Save progress when episode changes + useEffect(() => { + if (!slug) return; + const progress = getProgress(slug); + if (progress && progress.episode !== currentEpisode) { + // Clear old progress when switching episodes + clearProgress(slug); + } + }, [currentEpisode, slug, getProgress, clearProgress]); + useEffect(() => { if (!movie) return; @@ -56,7 +101,7 @@ export const useWatchMovie = (slug: string | undefined, episode: string | undefi const res = await fetch(`/api/extract`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: targetUrl }) // Changed to JSON payload + body: JSON.stringify({ url: targetUrl }) }); if (!res.ok) throw new Error('Failed to extract'); @@ -75,31 +120,83 @@ export const useWatchMovie = (slug: string | undefined, episode: string | undefi }; fetchStream(); - }, [movie, currentEpisode, slug]); + }, [movie, currentEpisode, slug]); + // Save progress periodically and seek to saved position useEffect(() => { - if (source && videoRef.current) { - console.log("Initializing player with source:", source); - const isHls = source.stream_url.includes('.m3u8') || source.format_id === 'hls'; - console.log("Is HLS:", isHls, "Stream URL:", source.stream_url); + if (!source || !videoRef.current || !slug) return; - if (isHls && Hls.isSupported()) { - const hls = new Hls(); - hls.loadSource(source.stream_url); - hls.attachMedia(videoRef.current); - hls.on(Hls.Events.MANIFEST_PARSED, () => { - videoRef.current?.play().catch(() => { }); - }); - return () => { - hls.destroy(); - }; - } else { - // MP4 or Native HLS (Safari) - videoRef.current.src = source.stream_url; - videoRef.current.play().catch(() => { }); + const video = videoRef.current; + let hls: Hls | null = null; + + const saveCurrentProgress = () => { + if (video && slug && movieRef.current) { + const currentTime = video.currentTime; + const duration = video.duration; + if (duration > 0) { + saveProgressRef.current(slug, currentEpisode, currentTime, duration, { + title: movieRef.current.title, + thumbnail: movieRef.current.thumbnail, + backdrop: movieRef.current.backdrop, + year: movieRef.current.year, + category: movieRef.current.category, + }); + } } + }; + + const onLoadedMetadata = () => { + // Seek to saved position (minus 20s) if available + const progress = getProgressRef.current(slug); + if (progress && progress.episode === currentEpisode && progress.timestamp > 0) { + // Rewind 20 seconds so user doesn't miss the exact moment + video.currentTime = Math.max(0, progress.timestamp - 20); + } + }; + + const onPause = () => { + saveCurrentProgress(); + }; + + const onEnded = () => { + // Clear progress when video ends + clearProgressRef.current(slug); + }; + + const isHls = source.stream_url.includes('.m3u8') || source.format_id === 'hls'; + + if (isHls && Hls.isSupported()) { + hls = new Hls(); + hls.loadSource(source.stream_url); + hls.attachMedia(video); + hls.on(Hls.Events.MANIFEST_PARSED, () => { + video.play().catch(() => { }); + }); + } else { + video.src = source.stream_url; + video.play().catch(() => { }); } - }, [source]); + + video.addEventListener('loadedmetadata', onLoadedMetadata); + video.addEventListener('pause', onPause); + video.addEventListener('ended', onEnded); + + // Save progress every 5 seconds + saveIntervalRef.current = setInterval(saveCurrentProgress, 5000); + + return () => { + if (hls) hls.destroy(); + video.removeEventListener('loadedmetadata', onLoadedMetadata); + video.removeEventListener('pause', onPause); + video.removeEventListener('ended', onEnded); + if (saveIntervalRef.current) { + clearInterval(saveIntervalRef.current); + saveIntervalRef.current = null; + } + // Save final progress on unmount + saveCurrentProgress(); + }; + }, [source, slug, currentEpisode]); // Wake Lock Logic (Prevent Screen Sleep) useEffect(() => { diff --git a/frontend-react/src/hooks/useWatchProgress.ts b/frontend-react/src/hooks/useWatchProgress.ts new file mode 100644 index 0000000..f6837d9 --- /dev/null +++ b/frontend-react/src/hooks/useWatchProgress.ts @@ -0,0 +1,118 @@ +import { useState, useEffect, useCallback } from 'react'; +import type { Movie } from '../types'; + +const STORAGE_KEY = 'streamflow_watch_progress'; + +interface ProgressData { + episode: number; + timestamp: number; + duration: number; + updatedAt: string; +} + +interface StoredProgress { + [slug: string]: ProgressData; +} + +export interface WatchProgress extends ProgressData { + slug: string; + movieTitle?: string; + movieThumbnail?: string; + movieBackdrop?: string; + movieYear?: number; + movieCategory?: string; +} + +export const useWatchProgress = () => { + const [progressMap, setProgressMap] = useState(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : {}; + } catch { + return {}; + } + }); + + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(progressMap)); + } catch (e) { + console.error('Failed to save watch progress:', e); + } + }, [progressMap]); + + const getProgress = useCallback((slug: string): ProgressData | null => { + return progressMap[slug] || null; + }, [progressMap]); + + const saveProgress = useCallback((slug: string, episode: number, timestamp: number, duration: number, movieInfo?: { + title?: string; + thumbnail?: string; + backdrop?: string; + year?: number; + category?: string; + }) => { + setProgressMap(prev => ({ + ...prev, + [slug]: { + episode, + timestamp, + duration, + updatedAt: new Date().toISOString(), + movieTitle: movieInfo?.title, + movieThumbnail: movieInfo?.thumbnail, + movieBackdrop: movieInfo?.backdrop, + movieYear: movieInfo?.year, + movieCategory: movieInfo?.category, + } + })); + }, []); + + const getAllProgress = useCallback((): WatchProgress[] => { + return Object.entries(progressMap) + .map(([slug, data]) => ({ + slug, + ...data, + })) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + }, [progressMap]); + + const getContinueWatchingMovies = useCallback((): Movie[] => { + return Object.entries(progressMap) + .map(([slug, data]) => ({ + id: slug, + title: data.movieTitle || slug, + slug: slug, + thumbnail: data.movieThumbnail || '', + backdrop: data.movieBackdrop || undefined, + year: data.movieYear || undefined, + category: data.movieCategory || 'movies', + // Add progress info for display + currentEpisode: data.episode, + watchedTimestamp: data.timestamp, + duration: data.duration, + } as Movie)) + .sort((a, b) => new Date(progressMap[b.slug!].updatedAt).getTime() - new Date(progressMap[a.slug!].updatedAt).getTime()); + }, [progressMap]); + + const clearProgress = useCallback((slug: string) => { + setProgressMap(prev => { + const newMap = { ...prev }; + delete newMap[slug]; + return newMap; + }); + }, []); + + const clearAllProgress = useCallback(() => { + setProgressMap({}); + }, []); + + return { + getProgress, + saveProgress, + getAllProgress, + getContinueWatchingMovies, + clearProgress, + clearAllProgress, + }; +}; diff --git a/frontend-react/src/themes/default/WatchPage.tsx b/frontend-react/src/themes/default/WatchPage.tsx index 2461364..82a322d 100644 --- a/frontend-react/src/themes/default/WatchPage.tsx +++ b/frontend-react/src/themes/default/WatchPage.tsx @@ -22,8 +22,13 @@ export const WatchPage = ({ slug, episode }: { slug: string, episode: string }) // Helper for URL safety (same as Hero) const getImageUrl = (url: string | undefined, width: number) => { if (!url) return ''; - const cleanUrl = url.replace('img.ophim1.com', 'ssl:img.ophim1.com'); - return `https://wsrv.nl/?url=${encodeURIComponent(cleanUrl)}&w=${width}&output=webp`; + let cleanUrl = url; + if (url.startsWith('//')) { + cleanUrl = `https:${url}`; + } else if (!url.startsWith('http')) { + cleanUrl = `https://${url}`; + } + return cleanUrl; }; const episodesByServer = movie?.episodes?.reduce((acc, ep) => { const server = ep.server_name || 'Default'; diff --git a/frontend-react/src/types/index.ts b/frontend-react/src/types/index.ts index c44f697..fb6ee1c 100644 --- a/frontend-react/src/types/index.ts +++ b/frontend-react/src/types/index.ts @@ -13,6 +13,10 @@ export interface Movie { provider?: string; director?: string; cast?: string[]; + // Progress tracking + currentEpisode?: number; + watchedTimestamp?: number; + duration?: number; } export interface MovieDetail extends Movie {