perf(playback): speed up watch data build
This commit is contained in:
@@ -14,16 +14,24 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
"mal/internal/jikan"
|
const (
|
||||||
|
showResolutionCacheTTL = 12 * time.Hour
|
||||||
|
playbackDataCacheTTL = 2 * time.Minute
|
||||||
|
providerProbeTimeout = 3 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
allAnimeClient *allAnimeClient
|
allAnimeClient *allAnimeClient
|
||||||
jikanClient *jikan.Client
|
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
db database.Querier
|
db database.Querier
|
||||||
|
|
||||||
|
cacheMu sync.RWMutex
|
||||||
|
showResolution map[int]showResolutionCacheItem
|
||||||
|
playbackDataCache map[string]playbackDataCacheItem
|
||||||
}
|
}
|
||||||
|
|
||||||
type sourceScore struct {
|
type sourceScore struct {
|
||||||
@@ -35,12 +43,53 @@ type sourceScore struct {
|
|||||||
refererScore int
|
refererScore int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(jikanClient *jikan.Client, db database.Querier) *Service {
|
type showResolutionCacheItem struct {
|
||||||
|
ShowID string
|
||||||
|
Title string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type playbackDataCacheItem struct {
|
||||||
|
Data playbackBaseData
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type playbackBaseData struct {
|
||||||
|
Title string
|
||||||
|
AvailableModes []string
|
||||||
|
ModeSources map[string]ModeSource
|
||||||
|
Segments []SkipSegment
|
||||||
|
}
|
||||||
|
|
||||||
|
type modeSourceResult struct {
|
||||||
|
Mode string
|
||||||
|
Source ModeSource
|
||||||
|
OK bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type searchModeResult struct {
|
||||||
|
Mode string
|
||||||
|
Results []searchResult
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type directProbeResult struct {
|
||||||
|
Playable bool
|
||||||
|
ContentType string
|
||||||
|
}
|
||||||
|
|
||||||
|
type userPlaybackState struct {
|
||||||
|
CurrentStatus string
|
||||||
|
StartTimeSeconds float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db database.Querier) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
allAnimeClient: newAllAnimeClient(),
|
allAnimeClient: newAllAnimeClient(),
|
||||||
jikanClient: jikanClient,
|
httpClient: &http.Client{Timeout: 12 * time.Second},
|
||||||
httpClient: &http.Client{Timeout: 12 * time.Second},
|
db: db,
|
||||||
db: db,
|
showResolution: make(map[int]showResolutionCacheItem),
|
||||||
|
playbackDataCache: make(map[string]playbackDataCacheItem),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,101 +108,293 @@ func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title strin
|
|||||||
normalizedEpisode = "1"
|
normalizedEpisode = "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
showID, resolvedTitle, err := s.resolveShow(ctx, malID, title)
|
userStateCh := s.fetchUserPlaybackStateAsync(ctx, userID, malID, normalizedEpisode)
|
||||||
if err != nil {
|
|
||||||
return WatchPageData{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
modeSources := make(map[string]ModeSource)
|
cacheKey := playbackDataCacheKey(malID, normalizedEpisode)
|
||||||
for _, sourceMode := range []string{"dub", "sub"} {
|
baseData, cacheHit := s.getPlaybackBaseDataCache(cacheKey)
|
||||||
resolved, resolveErr := s.resolveModeSource(ctx, showID, normalizedEpisode, sourceMode, "best")
|
if !cacheHit {
|
||||||
if resolveErr != nil {
|
showID, resolvedTitle, err := s.resolveShowCached(ctx, malID, title)
|
||||||
continue
|
if err != nil {
|
||||||
|
return WatchPageData{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.ToLower(resolved.Type) == "embed" {
|
modeSources, segments := s.fetchPlaybackSourcesAndSegments(ctx, showID, malID, normalizedEpisode)
|
||||||
continue
|
if len(modeSources) == 0 {
|
||||||
|
return WatchPageData{}, errors.New("no direct playable sources available")
|
||||||
}
|
}
|
||||||
|
|
||||||
modeSources[sourceMode] = ModeSource{
|
watchTitle := strings.TrimSpace(resolvedTitle)
|
||||||
URL: resolved.URL,
|
if watchTitle == "" {
|
||||||
Referer: resolved.Referer,
|
watchTitle = strings.TrimSpace(title)
|
||||||
Subtitles: toSubtitleItems(resolved),
|
|
||||||
}
|
}
|
||||||
|
if watchTitle == "" {
|
||||||
|
watchTitle = fmt.Sprintf("MAL #%d", malID)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseData = playbackBaseData{
|
||||||
|
Title: watchTitle,
|
||||||
|
AvailableModes: availableModes(modeSources),
|
||||||
|
ModeSources: modeSources,
|
||||||
|
Segments: segments,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.setPlaybackBaseDataCache(cacheKey, baseData)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(modeSources) == 0 {
|
initialMode := selectInitialMode(normalizedMode, baseData.ModeSources)
|
||||||
return WatchPageData{}, errors.New("no direct playable sources available")
|
|
||||||
|
userState := userPlaybackState{}
|
||||||
|
if userStateCh != nil {
|
||||||
|
userState = <-userStateCh
|
||||||
}
|
}
|
||||||
|
|
||||||
availableModes := availableModes(modeSources)
|
return WatchPageData{
|
||||||
initialMode := selectInitialMode(normalizedMode, modeSources)
|
MalID: malID,
|
||||||
|
Title: baseData.Title,
|
||||||
|
CurrentEpisode: normalizedEpisode,
|
||||||
|
StartTimeSeconds: userState.StartTimeSeconds,
|
||||||
|
CurrentStatus: userState.CurrentStatus,
|
||||||
|
InitialMode: initialMode,
|
||||||
|
AvailableModes: cloneStringSlice(baseData.AvailableModes),
|
||||||
|
ModeSources: cloneModeSources(baseData.ModeSources),
|
||||||
|
Segments: cloneSegments(baseData.Segments),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
episodes := s.fetchEpisodeList(ctx, malID)
|
func playbackDataCacheKey(malID int, episode string) string {
|
||||||
if len(episodes) == 0 {
|
return fmt.Sprintf("%d:%s", malID, episode)
|
||||||
episodeNumbers := s.fetchModeEpisodes(ctx, showID, initialMode)
|
}
|
||||||
episodes = fallbackEpisodeList(episodeNumbers)
|
|
||||||
|
func (s *Service) fetchUserPlaybackStateAsync(ctx context.Context, userID string, malID int, episode string) <-chan userPlaybackState {
|
||||||
|
if userID == "" || s.db == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
segments := s.fetchSkipSegments(ctx, malID, normalizedEpisode)
|
resultCh := make(chan userPlaybackState, 1)
|
||||||
|
go func() {
|
||||||
|
state := userPlaybackState{}
|
||||||
|
|
||||||
currentStatus := ""
|
|
||||||
startTimeSeconds := 0.0
|
|
||||||
if userID != "" && s.db != nil {
|
|
||||||
entry, err := s.db.GetWatchListEntry(ctx, database.GetWatchListEntryParams{
|
entry, err := s.db.GetWatchListEntry(ctx, database.GetWatchListEntryParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
AnimeID: int64(malID),
|
AnimeID: int64(malID),
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
currentStatus = entry.Status
|
state.CurrentStatus = entry.Status
|
||||||
if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == normalizedEpisode && entry.CurrentTimeSeconds > 0 {
|
if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode && entry.CurrentTimeSeconds > 0 {
|
||||||
startTimeSeconds = entry.CurrentTimeSeconds
|
state.StartTimeSeconds = entry.CurrentTimeSeconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if startTimeSeconds <= 0 {
|
if state.StartTimeSeconds <= 0 {
|
||||||
continueEntry, continueErr := s.db.GetContinueWatchingEntry(ctx, database.GetContinueWatchingEntryParams{
|
continueEntry, continueErr := s.db.GetContinueWatchingEntry(ctx, database.GetContinueWatchingEntryParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
AnimeID: int64(malID),
|
AnimeID: int64(malID),
|
||||||
})
|
})
|
||||||
if continueErr == nil && continueEntry.CurrentEpisode.Valid && strconv.FormatInt(continueEntry.CurrentEpisode.Int64, 10) == normalizedEpisode && continueEntry.CurrentTimeSeconds > 0 {
|
if continueErr == nil && continueEntry.CurrentEpisode.Valid && strconv.FormatInt(continueEntry.CurrentEpisode.Int64, 10) == episode && continueEntry.CurrentTimeSeconds > 0 {
|
||||||
startTimeSeconds = continueEntry.CurrentTimeSeconds
|
state.StartTimeSeconds = continueEntry.CurrentTimeSeconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resultCh <- state
|
||||||
|
}()
|
||||||
|
|
||||||
|
return resultCh
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) getPlaybackBaseDataCache(key string) (playbackBaseData, bool) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
s.cacheMu.RLock()
|
||||||
|
item, ok := s.playbackDataCache[key]
|
||||||
|
s.cacheMu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return playbackBaseData{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if now.After(item.ExpiresAt) {
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
current, exists := s.playbackDataCache[key]
|
||||||
|
if exists && time.Now().After(current.ExpiresAt) {
|
||||||
|
delete(s.playbackDataCache, key)
|
||||||
|
}
|
||||||
|
s.cacheMu.Unlock()
|
||||||
|
return playbackBaseData{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return clonePlaybackBaseData(item.Data), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) setPlaybackBaseDataCache(key string, data playbackBaseData) {
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
s.playbackDataCache[key] = playbackDataCacheItem{
|
||||||
|
Data: clonePlaybackBaseData(data),
|
||||||
|
ExpiresAt: time.Now().Add(playbackDataCacheTTL),
|
||||||
|
}
|
||||||
|
s.cacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveShowCached(ctx context.Context, malID int, title string) (string, string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
s.cacheMu.RLock()
|
||||||
|
item, ok := s.showResolution[malID]
|
||||||
|
s.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
if ok && now.Before(item.ExpiresAt) && strings.TrimSpace(item.ShowID) != "" {
|
||||||
|
return item.ShowID, item.Title, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
showID, resolvedTitle, err := s.resolveShow(ctx, malID, title)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.cacheMu.Lock()
|
||||||
|
s.showResolution[malID] = showResolutionCacheItem{
|
||||||
|
ShowID: showID,
|
||||||
|
Title: resolvedTitle,
|
||||||
|
ExpiresAt: time.Now().Add(showResolutionCacheTTL),
|
||||||
|
}
|
||||||
|
s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
return showID, resolvedTitle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) fetchPlaybackSourcesAndSegments(ctx context.Context, showID string, malID int, episode string) (map[string]ModeSource, []SkipSegment) {
|
||||||
|
modeCh := make(chan modeSourceResult, 2)
|
||||||
|
probeCache := make(map[string]directProbeResult)
|
||||||
|
probeCacheMu := sync.Mutex{}
|
||||||
|
|
||||||
|
for _, mode := range []string{"dub", "sub"} {
|
||||||
|
modeValue := mode
|
||||||
|
go func() {
|
||||||
|
resolved, err := s.resolveModeSourceWithCache(ctx, showID, episode, modeValue, "best", probeCache, &probeCacheMu)
|
||||||
|
if err != nil {
|
||||||
|
modeCh <- modeSourceResult{Mode: modeValue, OK: false}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ToLower(resolved.Type) == "embed" {
|
||||||
|
modeCh <- modeSourceResult{Mode: modeValue, OK: false}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modeCh <- modeSourceResult{
|
||||||
|
Mode: modeValue,
|
||||||
|
Source: ModeSource{
|
||||||
|
URL: resolved.URL,
|
||||||
|
Referer: resolved.Referer,
|
||||||
|
Subtitles: toSubtitleItems(resolved),
|
||||||
|
},
|
||||||
|
OK: true,
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
segmentsCh := make(chan []SkipSegment, 1)
|
||||||
|
go func() {
|
||||||
|
segmentsCh <- s.fetchSkipSegments(ctx, malID, episode)
|
||||||
|
}()
|
||||||
|
|
||||||
|
modeSources := make(map[string]ModeSource)
|
||||||
|
for range 2 {
|
||||||
|
result := <-modeCh
|
||||||
|
if !result.OK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
modeSources[result.Mode] = result.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
segments := <-segmentsCh
|
||||||
|
return modeSources, segments
|
||||||
|
}
|
||||||
|
|
||||||
|
func clonePlaybackBaseData(data playbackBaseData) playbackBaseData {
|
||||||
|
return playbackBaseData{
|
||||||
|
Title: data.Title,
|
||||||
|
AvailableModes: cloneStringSlice(data.AvailableModes),
|
||||||
|
ModeSources: cloneModeSources(data.ModeSources),
|
||||||
|
Segments: cloneSegments(data.Segments),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringSlice(items []string) []string {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cloned := make([]string, len(items))
|
||||||
|
copy(cloned, items)
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneModeSources(modeSources map[string]ModeSource) map[string]ModeSource {
|
||||||
|
if len(modeSources) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cloned := make(map[string]ModeSource, len(modeSources))
|
||||||
|
for mode, source := range modeSources {
|
||||||
|
cloned[mode] = ModeSource{
|
||||||
|
URL: source.URL,
|
||||||
|
Referer: source.Referer,
|
||||||
|
Subtitles: cloneSubtitleItems(source.Subtitles),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watchTitle := strings.TrimSpace(resolvedTitle)
|
return cloned
|
||||||
if watchTitle == "" {
|
}
|
||||||
watchTitle = strings.TrimSpace(title)
|
|
||||||
}
|
func cloneSubtitleItems(items []SubtitleItem) []SubtitleItem {
|
||||||
if watchTitle == "" {
|
if len(items) == 0 {
|
||||||
watchTitle = fmt.Sprintf("MAL #%d", malID)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return WatchPageData{
|
cloned := make([]SubtitleItem, len(items))
|
||||||
MalID: malID,
|
copy(cloned, items)
|
||||||
Title: watchTitle,
|
return cloned
|
||||||
CurrentEpisode: normalizedEpisode,
|
}
|
||||||
StartTimeSeconds: startTimeSeconds,
|
|
||||||
CurrentStatus: currentStatus,
|
func cloneSegments(segments []SkipSegment) []SkipSegment {
|
||||||
InitialMode: initialMode,
|
if len(segments) == 0 {
|
||||||
AvailableModes: availableModes,
|
return nil
|
||||||
ModeSources: modeSources,
|
}
|
||||||
Episodes: episodes,
|
|
||||||
Segments: segments,
|
cloned := make([]SkipSegment, len(segments))
|
||||||
}, nil
|
copy(cloned, segments)
|
||||||
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) resolveShow(ctx context.Context, malID int, title string) (string, string, error) {
|
func (s *Service) resolveShow(ctx context.Context, malID int, title string) (string, string, error) {
|
||||||
malText := strconv.Itoa(malID)
|
malText := strconv.Itoa(malID)
|
||||||
modeCandidates := []string{"sub", "dub"}
|
modeCandidates := []string{"sub", "dub"}
|
||||||
|
|
||||||
|
resultsByMode := make(map[string][]searchResult, len(modeCandidates))
|
||||||
|
searchCh := make(chan searchModeResult, len(modeCandidates))
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
for _, mode := range modeCandidates {
|
for _, mode := range modeCandidates {
|
||||||
results, err := s.allAnimeClient.Search(ctx, title, mode)
|
modeValue := mode
|
||||||
if err != nil {
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
results, err := s.allAnimeClient.Search(ctx, title, modeValue)
|
||||||
|
searchCh <- searchModeResult{Mode: modeValue, Results: results, Err: err}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(searchCh)
|
||||||
|
|
||||||
|
for result := range searchCh {
|
||||||
|
if result.Err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
resultsByMode[result.Mode] = result.Results
|
||||||
|
}
|
||||||
|
|
||||||
for _, result := range results {
|
for _, mode := range modeCandidates {
|
||||||
|
for _, result := range resultsByMode[mode] {
|
||||||
if strings.TrimSpace(result.MalID) == malText && strings.TrimSpace(result.ID) != "" {
|
if strings.TrimSpace(result.MalID) == malText && strings.TrimSpace(result.ID) != "" {
|
||||||
return result.ID, result.Name, nil
|
return result.ID, result.Name, nil
|
||||||
}
|
}
|
||||||
@@ -162,8 +403,8 @@ func (s *Service) resolveShow(ctx context.Context, malID int, title string) (str
|
|||||||
|
|
||||||
if strings.TrimSpace(title) != "" {
|
if strings.TrimSpace(title) != "" {
|
||||||
for _, mode := range modeCandidates {
|
for _, mode := range modeCandidates {
|
||||||
results, err := s.allAnimeClient.Search(ctx, title, mode)
|
results := resultsByMode[mode]
|
||||||
if err != nil || len(results) == 0 {
|
if len(results) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +437,33 @@ func (s *Service) resolveModeSource(ctx context.Context, showID string, episode
|
|||||||
return selected, nil
|
return selected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveModeSourceWithCache(
|
||||||
|
ctx context.Context,
|
||||||
|
showID string,
|
||||||
|
episode string,
|
||||||
|
mode string,
|
||||||
|
quality string,
|
||||||
|
probeCache map[string]directProbeResult,
|
||||||
|
probeCacheMu *sync.Mutex,
|
||||||
|
) (StreamSource, error) {
|
||||||
|
sources, err := s.allAnimeClient.GetEpisodeSources(ctx, showID, episode, mode)
|
||||||
|
if err != nil {
|
||||||
|
return StreamSource{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ranked, err := rankSources(sources, quality)
|
||||||
|
if err != nil {
|
||||||
|
return StreamSource{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
selected, _, err := s.choosePlaybackSourceWithCache(ctx, ranked, probeCache, probeCacheMu)
|
||||||
|
if err != nil {
|
||||||
|
return StreamSource{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) choosePlaybackSource(ctx context.Context, ranked []sourceScore) (StreamSource, string, error) {
|
func (s *Service) choosePlaybackSource(ctx context.Context, ranked []sourceScore) (StreamSource, string, error) {
|
||||||
if len(ranked) == 0 {
|
if len(ranked) == 0 {
|
||||||
return StreamSource{}, "", errors.New("no ranked sources available")
|
return StreamSource{}, "", errors.New("no ranked sources available")
|
||||||
@@ -232,8 +500,79 @@ func (s *Service) choosePlaybackSource(ctx context.Context, ranked []sourceScore
|
|||||||
return ranked[0].source, "ranked-fallback", nil
|
return ranked[0].source, "ranked-fallback", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) choosePlaybackSourceWithCache(
|
||||||
|
ctx context.Context,
|
||||||
|
ranked []sourceScore,
|
||||||
|
probeCache map[string]directProbeResult,
|
||||||
|
probeCacheMu *sync.Mutex,
|
||||||
|
) (StreamSource, string, error) {
|
||||||
|
if len(ranked) == 0 {
|
||||||
|
return StreamSource{}, "", errors.New("no ranked sources available")
|
||||||
|
}
|
||||||
|
|
||||||
|
embedCandidates := make([]StreamSource, 0)
|
||||||
|
for _, candidate := range ranked {
|
||||||
|
source := candidate.source
|
||||||
|
sourceType := strings.ToLower(source.Type)
|
||||||
|
|
||||||
|
switch sourceType {
|
||||||
|
case "mp4", "m3u8":
|
||||||
|
return source, "direct-media", nil
|
||||||
|
case "embed":
|
||||||
|
embedCandidates = append(embedCandidates, source)
|
||||||
|
default:
|
||||||
|
playable, contentType := s.probeDirectMediaCached(ctx, source, probeCache, probeCacheMu)
|
||||||
|
if playable {
|
||||||
|
return normalizeSourceTypeFromProbe(source, contentType), "probed-media", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, embed := range embedCandidates {
|
||||||
|
if s.probeEmbedSource(ctx, embed) {
|
||||||
|
return embed, "embed-probed", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(embedCandidates) > 0 {
|
||||||
|
return embedCandidates[0], "embed-fallback", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranked[0].source, "ranked-fallback", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) probeDirectMediaCached(
|
||||||
|
ctx context.Context,
|
||||||
|
source StreamSource,
|
||||||
|
probeCache map[string]directProbeResult,
|
||||||
|
probeCacheMu *sync.Mutex,
|
||||||
|
) (bool, string) {
|
||||||
|
cacheKey := strings.TrimSpace(source.URL)
|
||||||
|
if cacheKey == "" {
|
||||||
|
return s.probeDirectMedia(ctx, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
probeCacheMu.Lock()
|
||||||
|
cached, ok := probeCache[cacheKey]
|
||||||
|
probeCacheMu.Unlock()
|
||||||
|
if ok {
|
||||||
|
return cached.Playable, cached.ContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
playable, contentType := s.probeDirectMedia(ctx, source)
|
||||||
|
|
||||||
|
probeCacheMu.Lock()
|
||||||
|
probeCache[cacheKey] = directProbeResult{Playable: playable, ContentType: contentType}
|
||||||
|
probeCacheMu.Unlock()
|
||||||
|
|
||||||
|
return playable, contentType
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) probeDirectMedia(ctx context.Context, source StreamSource) (bool, string) {
|
func (s *Service) probeDirectMedia(ctx context.Context, source StreamSource) (bool, string) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, source.URL, nil)
|
probeCtx, cancel := context.WithTimeout(ctx, providerProbeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, source.URL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, ""
|
return false, ""
|
||||||
}
|
}
|
||||||
@@ -278,7 +617,10 @@ func (s *Service) probeDirectMedia(ctx context.Context, source StreamSource) (bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) probeEmbedSource(ctx context.Context, source StreamSource) bool {
|
func (s *Service) probeEmbedSource(ctx context.Context, source StreamSource) bool {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, source.URL, nil)
|
probeCtx, cancel := context.WithTimeout(ctx, providerProbeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, source.URL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -388,59 +730,6 @@ func (s *Service) fetchSkipSegments(ctx context.Context, malID int, episode stri
|
|||||||
return segments
|
return segments
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) fetchEpisodeList(ctx context.Context, malID int) []EpisodeListItem {
|
|
||||||
if malID <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]EpisodeListItem, 0)
|
|
||||||
for page := 1; page <= 20; page++ {
|
|
||||||
result, err := s.jikanClient.GetEpisodes(ctx, malID, page)
|
|
||||||
if err != nil {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, episode := range result.Data {
|
|
||||||
if episode.MalID <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
items = append(items, EpisodeListItem{
|
|
||||||
Number: strconv.Itoa(episode.MalID),
|
|
||||||
Title: strings.TrimSpace(episode.Title),
|
|
||||||
Filler: episode.Filler,
|
|
||||||
Recap: episode.Recap,
|
|
||||||
Order: episode.MalID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result.Pagination.HasNextPage {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) fetchModeEpisodes(ctx context.Context, showID string, mode string) []string {
|
|
||||||
episodes, err := s.allAnimeClient.GetEpisodes(ctx, showID, mode)
|
|
||||||
if err == nil && len(episodes) > 0 {
|
|
||||||
return episodes
|
|
||||||
}
|
|
||||||
|
|
||||||
fallbackMode := "sub"
|
|
||||||
if mode == "sub" {
|
|
||||||
fallbackMode = "dub"
|
|
||||||
}
|
|
||||||
|
|
||||||
fallbackEpisodes, fallbackErr := s.allAnimeClient.GetEpisodes(ctx, showID, fallbackMode)
|
|
||||||
if fallbackErr != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallbackEpisodes
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ProxyStream(ctx context.Context, targetURL string, referer string, rangeHeader string) (int, http.Header, []byte, io.ReadCloser, error) {
|
func (s *Service) ProxyStream(ctx context.Context, targetURL string, referer string, rangeHeader string) (int, http.Header, []byte, io.ReadCloser, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -481,26 +770,6 @@ func (s *Service) ProxyStream(ctx context.Context, targetURL string, referer str
|
|||||||
return resp.StatusCode, headers, nil, resp.Body, nil
|
return resp.StatusCode, headers, nil, resp.Body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fallbackEpisodeList(episodeNumbers []string) []EpisodeListItem {
|
|
||||||
items := make([]EpisodeListItem, 0, len(episodeNumbers))
|
|
||||||
for idx, number := range episodeNumbers {
|
|
||||||
trimmed := strings.TrimSpace(number)
|
|
||||||
if trimmed == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
items = append(items, EpisodeListItem{
|
|
||||||
Number: trimmed,
|
|
||||||
Title: "",
|
|
||||||
Filler: false,
|
|
||||||
Recap: false,
|
|
||||||
Order: idx + 1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeMode(raw string) string {
|
func normalizeMode(raw string) string {
|
||||||
lower := strings.ToLower(strings.TrimSpace(raw))
|
lower := strings.ToLower(strings.TrimSpace(raw))
|
||||||
if lower == "sub" || lower == "dub" {
|
if lower == "sub" || lower == "dub" {
|
||||||
|
|||||||
@@ -26,14 +26,6 @@ type SubtitleItem struct {
|
|||||||
Referer string `json:"referer"`
|
Referer string `json:"referer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EpisodeListItem struct {
|
|
||||||
Number string `json:"number"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Filler bool `json:"filler"`
|
|
||||||
Recap bool `json:"recap"`
|
|
||||||
Order int `json:"order"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SkipSegment struct {
|
type SkipSegment struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Start float64 `json:"start"`
|
Start float64 `json:"start"`
|
||||||
@@ -41,14 +33,13 @@ type SkipSegment struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WatchPageData struct {
|
type WatchPageData struct {
|
||||||
MalID int
|
MalID int
|
||||||
Title string
|
Title string
|
||||||
CurrentEpisode string
|
CurrentEpisode string
|
||||||
StartTimeSeconds float64
|
StartTimeSeconds float64
|
||||||
CurrentStatus string
|
CurrentStatus string
|
||||||
InitialMode string
|
InitialMode string
|
||||||
AvailableModes []string
|
AvailableModes []string
|
||||||
ModeSources map[string]ModeSource
|
ModeSources map[string]ModeSource
|
||||||
Episodes []EpisodeListItem
|
Segments []SkipSegment
|
||||||
Segments []SkipSegment
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func NewRouter(cfg Config) http.Handler {
|
|||||||
|
|
||||||
animeSvc := anime.NewService(cfg.JikanClient, cfg.DB)
|
animeSvc := anime.NewService(cfg.JikanClient, cfg.DB)
|
||||||
animeHandler := anime.NewHandler(animeSvc)
|
animeHandler := anime.NewHandler(animeSvc)
|
||||||
playbackSvc := playback.NewService(cfg.JikanClient, cfg.DB)
|
playbackSvc := playback.NewService(cfg.DB)
|
||||||
playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient)
|
playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient)
|
||||||
|
|
||||||
// Serve static files
|
// Serve static files
|
||||||
|
|||||||
Reference in New Issue
Block a user