feat: Add episode progress tracking and fix image URLs
Some checks failed
StreamFlow CI/CD / Backend Tests (push) Failing after 23s
StreamFlow CI/CD / Backend Lint (push) Failing after 2s
StreamFlow CI/CD / Frontend Tests (push) Failing after 3s
StreamFlow CI/CD / Android TV Build (push) Failing after 1s
StreamFlow CI/CD / Docker Build (push) Has been skipped
StreamFlow CI/CD / Docker Publish (push) Failing after 1m55s
Some checks failed
StreamFlow CI/CD / Backend Tests (push) Failing after 23s
StreamFlow CI/CD / Backend Lint (push) Failing after 2s
StreamFlow CI/CD / Frontend Tests (push) Failing after 3s
StreamFlow CI/CD / Android TV Build (push) Failing after 1s
StreamFlow CI/CD / Docker Build (push) Has been skipped
StreamFlow CI/CD / Docker Publish (push) Failing after 1m55s
- Add useWatchProgress hook for saving watch progress - Auto-save progress every 5 seconds and on pause - Seek to saved position (minus 20s) when returning - Add Continue Watching section with progress bars - Fix ophim image URLs (img.ophim.live) - Remove broken wsrv.nl proxy dependency - Add episode badge and progress bar to MovieCard
This commit is contained in:
parent
3009f94fe9
commit
0819a1beca
8 changed files with 656 additions and 400 deletions
|
|
@ -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, ", ")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<div className="space-y-4 relative z-10 mb-12">
|
||||
{/* Continue Watching Row */}
|
||||
{watchHistory.length > 0 && (
|
||||
<MovieRow title="Tiếp tục xem" movies={watchHistory} />
|
||||
{continueWatching.length > 0 && (
|
||||
<MovieRow title="Tiếp tục xem" movies={continueWatching} />
|
||||
)}
|
||||
|
||||
{/* My List Row */}
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Episode Badge for Continue Watching */}
|
||||
{movie.currentEpisode && (
|
||||
<div className="absolute top-2 left-2 mt-7">
|
||||
<div className="bg-cyan-500/90 backdrop-blur-md px-1.5 py-0.5 rounded text-[9px] font-bold text-black border border-cyan-400/20">
|
||||
Tập {movie.currentEpisode}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top-Right Tags (Quality & Lang) */}
|
||||
<div className="absolute top-2 right-2 flex flex-col gap-1.5 items-end">
|
||||
{movie.quality && (
|
||||
|
|
@ -87,6 +101,16 @@ export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCa
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Bar for Continue Watching */}
|
||||
{progressPercent > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-600/50">
|
||||
<div
|
||||
className="h-full bg-cyan-500 transition-all duration-300"
|
||||
style={{ width: `${Math.min(progressPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Info Section */}
|
||||
|
|
|
|||
|
|
@ -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<HTMLVideoElement>(null);
|
||||
|
|
@ -8,6 +9,40 @@ export const useWatchMovie = (slug: string | undefined, episode: string | undefi
|
|||
const [source, setSource] = useState<VideoSource | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentEpisode, setCurrentEpisode] = useState(parseInt(episode || '1'));
|
||||
const { getProgress, saveProgress, clearProgress } = useWatchProgress();
|
||||
const saveIntervalRef = useRef<ReturnType<typeof setInterval> | 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(() => {
|
||||
|
|
|
|||
118
frontend-react/src/hooks/useWatchProgress.ts
Normal file
118
frontend-react/src/hooks/useWatchProgress.ts
Normal file
|
|
@ -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<StoredProgress>(() => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue