1175 lines
27 KiB
Go
1175 lines
27 KiB
Go
package playback
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"mal/internal/database"
|
||
"net/http"
|
||
"net/url"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
showResolutionCacheTTL = 12 * time.Hour
|
||
playbackDataCacheTTL = 2 * time.Minute
|
||
providerProbeTimeout = 3 * time.Second
|
||
)
|
||
|
||
type Service struct {
|
||
allAnimeClient *allAnimeClient
|
||
httpClient *http.Client
|
||
db database.Querier
|
||
|
||
cacheMu sync.RWMutex
|
||
showResolution map[int]showResolutionCacheItem
|
||
playbackDataCache map[string]playbackDataCacheItem
|
||
}
|
||
|
||
type sourceScore struct {
|
||
source StreamSource
|
||
total int
|
||
typeScore int
|
||
providerScore int
|
||
qualityScore int
|
||
refererScore int
|
||
}
|
||
|
||
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{
|
||
allAnimeClient: newAllAnimeClient(),
|
||
httpClient: &http.Client{Timeout: 12 * time.Second},
|
||
db: db,
|
||
showResolution: make(map[int]showResolutionCacheItem),
|
||
playbackDataCache: make(map[string]playbackDataCacheItem),
|
||
}
|
||
}
|
||
|
||
func (s *Service) BuildWatchPageData(ctx context.Context, malID int, titleCandidates []string, episode string, mode string, userID string) (WatchPageData, error) {
|
||
if malID <= 0 {
|
||
return WatchPageData{}, errors.New("invalid mal id")
|
||
}
|
||
|
||
normalizedMode := normalizeMode(mode)
|
||
if normalizedMode == "" {
|
||
normalizedMode = "dub"
|
||
}
|
||
|
||
normalizedEpisode := strings.TrimSpace(episode)
|
||
if normalizedEpisode == "" {
|
||
normalizedEpisode = "1"
|
||
}
|
||
|
||
userStateCh := s.fetchUserPlaybackStateAsync(ctx, userID, malID, normalizedEpisode)
|
||
|
||
cacheKey := playbackDataCacheKey(malID, normalizedEpisode)
|
||
baseData, cacheHit := s.getPlaybackBaseDataCache(cacheKey)
|
||
if !cacheHit {
|
||
showID, resolvedTitle, err := s.resolveShowCached(ctx, malID, titleCandidates)
|
||
if err != nil {
|
||
return WatchPageData{}, err
|
||
}
|
||
|
||
modeSources, segments := s.fetchPlaybackSourcesAndSegments(ctx, showID, malID, normalizedEpisode)
|
||
if len(modeSources) == 0 {
|
||
return WatchPageData{}, errors.New("no direct playable sources available")
|
||
}
|
||
|
||
watchTitle := strings.TrimSpace(resolvedTitle)
|
||
if watchTitle == "" {
|
||
watchTitle = firstNonEmptyTitle(titleCandidates)
|
||
}
|
||
if watchTitle == "" {
|
||
watchTitle = fmt.Sprintf("MAL #%d", malID)
|
||
}
|
||
|
||
baseData = playbackBaseData{
|
||
Title: watchTitle,
|
||
AvailableModes: availableModes(modeSources),
|
||
ModeSources: modeSources,
|
||
Segments: segments,
|
||
}
|
||
|
||
s.setPlaybackBaseDataCache(cacheKey, baseData)
|
||
}
|
||
|
||
initialMode := selectInitialMode(normalizedMode, baseData.ModeSources)
|
||
|
||
userState := userPlaybackState{}
|
||
if userStateCh != nil {
|
||
userState = <-userStateCh
|
||
}
|
||
|
||
return WatchPageData{
|
||
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
|
||
}
|
||
|
||
func playbackDataCacheKey(malID int, episode string) string {
|
||
return fmt.Sprintf("%d:%s", malID, episode)
|
||
}
|
||
|
||
func (s *Service) fetchUserPlaybackStateAsync(ctx context.Context, userID string, malID int, episode string) <-chan userPlaybackState {
|
||
if userID == "" || s.db == nil {
|
||
return nil
|
||
}
|
||
|
||
resultCh := make(chan userPlaybackState, 1)
|
||
go func() {
|
||
state := userPlaybackState{}
|
||
|
||
entry, err := s.db.GetWatchListEntry(ctx, database.GetWatchListEntryParams{
|
||
UserID: userID,
|
||
AnimeID: int64(malID),
|
||
})
|
||
if err == nil {
|
||
state.CurrentStatus = entry.Status
|
||
if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode && entry.CurrentTimeSeconds > 0 {
|
||
state.StartTimeSeconds = entry.CurrentTimeSeconds
|
||
}
|
||
}
|
||
|
||
if state.StartTimeSeconds <= 0 {
|
||
continueEntry, continueErr := s.db.GetContinueWatchingEntry(ctx, database.GetContinueWatchingEntryParams{
|
||
UserID: userID,
|
||
AnimeID: int64(malID),
|
||
})
|
||
if continueErr == nil && continueEntry.CurrentEpisode.Valid && strconv.FormatInt(continueEntry.CurrentEpisode.Int64, 10) == episode && continueEntry.CurrentTimeSeconds > 0 {
|
||
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, titleCandidates []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, titleCandidates)
|
||
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),
|
||
}
|
||
}
|
||
|
||
return cloned
|
||
}
|
||
|
||
func cloneSubtitleItems(items []SubtitleItem) []SubtitleItem {
|
||
if len(items) == 0 {
|
||
return nil
|
||
}
|
||
|
||
cloned := make([]SubtitleItem, len(items))
|
||
copy(cloned, items)
|
||
return cloned
|
||
}
|
||
|
||
func cloneSegments(segments []SkipSegment) []SkipSegment {
|
||
if len(segments) == 0 {
|
||
return nil
|
||
}
|
||
|
||
cloned := make([]SkipSegment, len(segments))
|
||
copy(cloned, segments)
|
||
return cloned
|
||
}
|
||
|
||
func (s *Service) resolveShow(ctx context.Context, malID int, titleCandidates []string) (string, string, error) {
|
||
malText := strconv.Itoa(malID)
|
||
modeCandidates := []string{"sub", "dub"}
|
||
queries := buildTitleSearchQueries(titleCandidates)
|
||
|
||
for _, query := range queries {
|
||
resultsByMode := s.searchShowResultsByMode(ctx, query, modeCandidates)
|
||
|
||
for _, mode := range modeCandidates {
|
||
for _, result := range resultsByMode[mode] {
|
||
if strings.TrimSpace(result.MalID) == malText && strings.TrimSpace(result.ID) != "" {
|
||
return result.ID, result.Name, nil
|
||
}
|
||
}
|
||
}
|
||
|
||
for _, mode := range modeCandidates {
|
||
results := resultsByMode[mode]
|
||
if len(results) == 0 {
|
||
continue
|
||
}
|
||
|
||
best := results[0]
|
||
if strings.TrimSpace(best.ID) != "" {
|
||
return best.ID, best.Name, nil
|
||
}
|
||
}
|
||
}
|
||
|
||
return "", "", errors.New("unable to resolve allanime show")
|
||
}
|
||
|
||
func (s *Service) searchShowResultsByMode(ctx context.Context, query string, modeCandidates []string) map[string][]searchResult {
|
||
resultsByMode := make(map[string][]searchResult, len(modeCandidates))
|
||
searchCh := make(chan searchModeResult, len(modeCandidates))
|
||
|
||
var wg sync.WaitGroup
|
||
for _, mode := range modeCandidates {
|
||
modeValue := mode
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
results, err := s.allAnimeClient.Search(ctx, query, modeValue)
|
||
searchCh <- searchModeResult{Mode: modeValue, Results: results, Err: err}
|
||
}()
|
||
}
|
||
|
||
wg.Wait()
|
||
close(searchCh)
|
||
|
||
for result := range searchCh {
|
||
if result.Err != nil {
|
||
continue
|
||
}
|
||
|
||
resultsByMode[result.Mode] = result.Results
|
||
}
|
||
|
||
return resultsByMode
|
||
}
|
||
|
||
func buildTitleSearchQueries(titleCandidates []string) []string {
|
||
queries := make([]string, 0, len(titleCandidates)*4)
|
||
seen := make(map[string]struct{})
|
||
|
||
add := func(raw string) {
|
||
normalized := normalizeSearchQuery(raw)
|
||
if normalized == "" {
|
||
return
|
||
}
|
||
|
||
key := strings.ToLower(normalized)
|
||
if _, exists := seen[key]; exists {
|
||
return
|
||
}
|
||
|
||
seen[key] = struct{}{}
|
||
queries = append(queries, normalized)
|
||
}
|
||
|
||
for _, candidate := range titleCandidates {
|
||
normalized := normalizeSearchQuery(candidate)
|
||
if normalized == "" {
|
||
continue
|
||
}
|
||
|
||
add(normalized)
|
||
add(strings.ReplaceAll(normalized, "+", " "))
|
||
|
||
withoutApostrophes := strings.NewReplacer("'", "", "’", "", "`", "").Replace(normalized)
|
||
add(withoutApostrophes)
|
||
add(strings.ReplaceAll(withoutApostrophes, "+", " "))
|
||
}
|
||
|
||
return queries
|
||
}
|
||
|
||
func normalizeSearchQuery(raw string) string {
|
||
return strings.Join(strings.Fields(strings.TrimSpace(raw)), " ")
|
||
}
|
||
|
||
func firstNonEmptyTitle(values []string) string {
|
||
for _, value := range values {
|
||
normalized := strings.TrimSpace(value)
|
||
if normalized != "" {
|
||
return normalized
|
||
}
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
func (s *Service) resolveModeSource(ctx context.Context, showID string, episode string, mode string, quality string) (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.choosePlaybackSource(ctx, ranked)
|
||
if err != nil {
|
||
return StreamSource{}, err
|
||
}
|
||
|
||
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) {
|
||
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.probeDirectMedia(ctx, source)
|
||
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) 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) {
|
||
probeCtx, cancel := context.WithTimeout(ctx, providerProbeTimeout)
|
||
defer cancel()
|
||
|
||
req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, source.URL, nil)
|
||
if err != nil {
|
||
return false, ""
|
||
}
|
||
|
||
if source.Referer != "" {
|
||
req.Header.Set("Referer", source.Referer)
|
||
}
|
||
req.Header.Set("User-Agent", defaultUserAgent)
|
||
req.Header.Set("Range", "bytes=0-4095")
|
||
|
||
resp, err := s.httpClient.Do(req)
|
||
if err != nil {
|
||
return false, ""
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
|
||
if strings.Contains(contentType, "video/") || strings.Contains(contentType, "mpegurl") {
|
||
return true, contentType
|
||
}
|
||
|
||
prefix, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||
if err == nil {
|
||
if isLikelyM3U8(prefix) {
|
||
return true, "application/vnd.apple.mpegurl"
|
||
}
|
||
if isLikelyMP4(prefix) {
|
||
return true, "video/mp4"
|
||
}
|
||
}
|
||
|
||
finalURL := ""
|
||
if resp.Request != nil && resp.Request.URL != nil {
|
||
finalURL = strings.ToLower(resp.Request.URL.String())
|
||
}
|
||
|
||
if strings.Contains(finalURL, ".mp4") || strings.Contains(finalURL, ".m3u8") {
|
||
return true, contentType
|
||
}
|
||
|
||
return false, contentType
|
||
}
|
||
|
||
func (s *Service) probeEmbedSource(ctx context.Context, source StreamSource) bool {
|
||
probeCtx, cancel := context.WithTimeout(ctx, providerProbeTimeout)
|
||
defer cancel()
|
||
|
||
req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, source.URL, nil)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
|
||
if source.Referer != "" {
|
||
req.Header.Set("Referer", source.Referer)
|
||
}
|
||
req.Header.Set("User-Agent", defaultUserAgent)
|
||
|
||
resp, err := s.httpClient.Do(req)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode >= http.StatusBadRequest {
|
||
return false
|
||
}
|
||
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
|
||
if err != nil {
|
||
return false
|
||
}
|
||
|
||
content := strings.ToLower(string(body))
|
||
markers := []string{
|
||
"file was deleted",
|
||
"file has been deleted",
|
||
"video was deleted",
|
||
"video has been deleted",
|
||
"video unavailable",
|
||
"file not found",
|
||
"this file does not exist",
|
||
"resource unavailable",
|
||
}
|
||
for _, marker := range markers {
|
||
if strings.Contains(content, marker) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
func (s *Service) fetchSkipSegments(ctx context.Context, malID int, episode string) []SkipSegment {
|
||
if malID <= 0 || strings.TrimSpace(episode) == "" {
|
||
return nil
|
||
}
|
||
|
||
endpoint := fmt.Sprintf("https://api.aniskip.com/v1/skip-times/%s/%s?types=op&types=ed", url.PathEscape(strconv.Itoa(malID)), url.PathEscape(episode))
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
req.Header.Set("User-Agent", defaultUserAgent)
|
||
|
||
resp, err := s.httpClient.Do(req)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil
|
||
}
|
||
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
|
||
type resultItem struct {
|
||
SkipType string `json:"skip_type"`
|
||
Interval struct {
|
||
StartTime float64 `json:"start_time"`
|
||
EndTime float64 `json:"end_time"`
|
||
} `json:"interval"`
|
||
}
|
||
type apiResponse struct {
|
||
Found bool `json:"found"`
|
||
Result []resultItem `json:"results"`
|
||
}
|
||
|
||
var parsed apiResponse
|
||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||
return nil
|
||
}
|
||
|
||
segments := make([]SkipSegment, 0, len(parsed.Result))
|
||
for _, item := range parsed.Result {
|
||
if item.Interval.EndTime <= item.Interval.StartTime {
|
||
continue
|
||
}
|
||
|
||
t := strings.ToLower(item.SkipType)
|
||
if t != "op" && t != "ed" {
|
||
continue
|
||
}
|
||
|
||
segments = append(segments, SkipSegment{
|
||
Type: t,
|
||
Start: item.Interval.StartTime,
|
||
End: item.Interval.EndTime,
|
||
})
|
||
}
|
||
|
||
return segments
|
||
}
|
||
|
||
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)
|
||
if err != nil {
|
||
return 0, nil, nil, nil, fmt.Errorf("invalid upstream url: %w", err)
|
||
}
|
||
|
||
if referer != "" {
|
||
req.Header.Set("Referer", referer)
|
||
}
|
||
req.Header.Set("User-Agent", defaultUserAgent)
|
||
if rangeHeader != "" {
|
||
req.Header.Set("Range", rangeHeader)
|
||
}
|
||
|
||
resp, err := s.httpClient.Do(req)
|
||
if err != nil {
|
||
return 0, nil, nil, nil, fmt.Errorf("upstream request failed: %w", err)
|
||
}
|
||
|
||
if isM3U8(targetURL, resp.Header.Get("Content-Type")) {
|
||
defer resp.Body.Close()
|
||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||
if readErr != nil {
|
||
return 0, nil, nil, nil, fmt.Errorf("read playlist failed: %w", readErr)
|
||
}
|
||
|
||
rewritten, rewriteErr := rewritePlaylist(string(body), targetURL, referer)
|
||
if rewriteErr != nil {
|
||
return 0, nil, nil, nil, fmt.Errorf("rewrite playlist failed: %w", rewriteErr)
|
||
}
|
||
|
||
headers := cloneHeaders(resp.Header)
|
||
headers.Set("Content-Type", "application/vnd.apple.mpegurl")
|
||
return resp.StatusCode, headers, []byte(rewritten), nil, nil
|
||
}
|
||
|
||
headers := cloneHeaders(resp.Header)
|
||
return resp.StatusCode, headers, nil, resp.Body, nil
|
||
}
|
||
|
||
func normalizeMode(raw string) string {
|
||
lower := strings.ToLower(strings.TrimSpace(raw))
|
||
if lower == "sub" || lower == "dub" {
|
||
return lower
|
||
}
|
||
|
||
return lower
|
||
}
|
||
|
||
func toSubtitleItems(source StreamSource) []SubtitleItem {
|
||
items := make([]SubtitleItem, 0, len(source.Subtitles))
|
||
for _, subtitle := range source.Subtitles {
|
||
targetURL := strings.TrimSpace(subtitle.URL)
|
||
if targetURL == "" {
|
||
continue
|
||
}
|
||
|
||
items = append(items, SubtitleItem{
|
||
Lang: strings.TrimSpace(subtitle.Lang),
|
||
URL: targetURL,
|
||
Referer: source.Referer,
|
||
})
|
||
}
|
||
|
||
return items
|
||
}
|
||
|
||
func availableModes(modeSources map[string]ModeSource) []string {
|
||
ordered := make([]string, 0, len(modeSources))
|
||
if _, ok := modeSources["dub"]; ok {
|
||
ordered = append(ordered, "dub")
|
||
}
|
||
if _, ok := modeSources["sub"]; ok {
|
||
ordered = append(ordered, "sub")
|
||
}
|
||
|
||
extra := make([]string, 0)
|
||
for mode := range modeSources {
|
||
if mode == "dub" || mode == "sub" {
|
||
continue
|
||
}
|
||
extra = append(extra, mode)
|
||
}
|
||
sort.Strings(extra)
|
||
|
||
return append(ordered, extra...)
|
||
}
|
||
|
||
func selectInitialMode(requestedMode string, modeSources map[string]ModeSource) string {
|
||
normalizedRequested := normalizeMode(requestedMode)
|
||
if normalizedRequested != "" {
|
||
if _, ok := modeSources[normalizedRequested]; ok {
|
||
return normalizedRequested
|
||
}
|
||
}
|
||
|
||
if _, ok := modeSources["dub"]; ok {
|
||
return "dub"
|
||
}
|
||
if _, ok := modeSources["sub"]; ok {
|
||
return "sub"
|
||
}
|
||
|
||
for mode := range modeSources {
|
||
return mode
|
||
}
|
||
|
||
return "dub"
|
||
}
|
||
|
||
func rankSources(sources []StreamSource, quality string) ([]sourceScore, error) {
|
||
filtered := make([]StreamSource, 0, len(sources))
|
||
seen := make(map[string]struct{})
|
||
|
||
for _, source := range sources {
|
||
if source.URL == "" {
|
||
continue
|
||
}
|
||
if _, exists := seen[source.URL]; exists {
|
||
continue
|
||
}
|
||
seen[source.URL] = struct{}{}
|
||
filtered = append(filtered, source)
|
||
}
|
||
|
||
if len(filtered) == 0 {
|
||
return nil, errors.New("no playable sources available")
|
||
}
|
||
|
||
targetQuality := normalizeQuality(quality)
|
||
scored := make([]sourceScore, 0, len(filtered))
|
||
for _, source := range filtered {
|
||
typeScore := sourceTypePriority(source.Type)
|
||
providerScore := providerPriority(source.Provider)
|
||
qualityScore := sourceQualityPriority(source.Quality, targetQuality)
|
||
refererScore := 0
|
||
if source.Referer != "" {
|
||
refererScore = 20
|
||
}
|
||
|
||
total := typeScore + providerScore + qualityScore + refererScore
|
||
scored = append(scored, sourceScore{
|
||
source: source,
|
||
total: total,
|
||
typeScore: typeScore,
|
||
providerScore: providerScore,
|
||
qualityScore: qualityScore,
|
||
refererScore: refererScore,
|
||
})
|
||
}
|
||
|
||
sort.SliceStable(scored, func(i int, j int) bool {
|
||
return scored[i].total > scored[j].total
|
||
})
|
||
|
||
return scored, nil
|
||
}
|
||
|
||
func normalizeQuality(quality string) string {
|
||
lower := strings.ToLower(strings.TrimSpace(quality))
|
||
if lower == "" {
|
||
return "best"
|
||
}
|
||
|
||
return lower
|
||
}
|
||
|
||
func sourceTypePriority(sourceType string) int {
|
||
switch strings.ToLower(sourceType) {
|
||
case "mp4":
|
||
return 500
|
||
case "m3u8":
|
||
return 450
|
||
case "unknown":
|
||
return 300
|
||
case "embed":
|
||
return 100
|
||
default:
|
||
return 200
|
||
}
|
||
}
|
||
|
||
func providerPriority(provider string) int {
|
||
switch strings.ToLower(provider) {
|
||
case "s-mp4":
|
||
return 120
|
||
case "default":
|
||
return 115
|
||
case "luf-mp4":
|
||
return 110
|
||
case "vid-mp4":
|
||
return 105
|
||
case "yt-mp4":
|
||
return 100
|
||
case "mp4":
|
||
return 95
|
||
case "uv-mp4":
|
||
return 90
|
||
case "hls":
|
||
return 80
|
||
case "sw":
|
||
return 40
|
||
case "ok":
|
||
return 35
|
||
case "ss-hls":
|
||
return 30
|
||
default:
|
||
return 60
|
||
}
|
||
}
|
||
|
||
func sourceQualityPriority(sourceQuality string, targetQuality string) int {
|
||
qualityValue := parseQualityValue(sourceQuality)
|
||
|
||
switch targetQuality {
|
||
case "best":
|
||
return qualityValue
|
||
case "worst":
|
||
return -qualityValue
|
||
default:
|
||
if qualityMatches(sourceQuality, targetQuality) {
|
||
return 2000 + qualityValue
|
||
}
|
||
|
||
return -300 + qualityValue
|
||
}
|
||
}
|
||
|
||
func parseQualityValue(rawQuality string) int {
|
||
lower := strings.ToLower(rawQuality)
|
||
var digits strings.Builder
|
||
|
||
for _, char := range lower {
|
||
if char >= '0' && char <= '9' {
|
||
digits.WriteRune(char)
|
||
continue
|
||
}
|
||
if digits.Len() > 0 {
|
||
break
|
||
}
|
||
}
|
||
|
||
if digits.Len() > 0 {
|
||
value, err := strconv.Atoi(digits.String())
|
||
if err == nil {
|
||
return value
|
||
}
|
||
}
|
||
|
||
if lower == "auto" {
|
||
return 240
|
||
}
|
||
|
||
return 0
|
||
}
|
||
|
||
func qualityMatches(sourceQuality string, targetQuality string) bool {
|
||
sourceLower := strings.ToLower(sourceQuality)
|
||
targetLower := strings.ToLower(targetQuality)
|
||
|
||
if sourceLower == "" {
|
||
return false
|
||
}
|
||
|
||
if strings.Contains(sourceLower, targetLower) {
|
||
return true
|
||
}
|
||
|
||
sourceDigits := extractDigits(sourceLower)
|
||
targetDigits := extractDigits(targetLower)
|
||
|
||
return sourceDigits != "" && sourceDigits == targetDigits
|
||
}
|
||
|
||
func extractDigits(value string) string {
|
||
var digits strings.Builder
|
||
for _, char := range value {
|
||
if char >= '0' && char <= '9' {
|
||
digits.WriteRune(char)
|
||
continue
|
||
}
|
||
if digits.Len() > 0 {
|
||
break
|
||
}
|
||
}
|
||
|
||
return digits.String()
|
||
}
|
||
|
||
func normalizeSourceTypeFromProbe(source StreamSource, contentType string) StreamSource {
|
||
lower := strings.ToLower(contentType)
|
||
if strings.Contains(lower, "video/mp4") {
|
||
source.Type = "mp4"
|
||
return source
|
||
}
|
||
|
||
if strings.Contains(lower, "mpegurl") {
|
||
source.Type = "m3u8"
|
||
return source
|
||
}
|
||
|
||
return source
|
||
}
|
||
|
||
func isLikelyMP4(payload []byte) bool {
|
||
if len(payload) < 12 {
|
||
return false
|
||
}
|
||
|
||
return bytes.Equal(payload[4:8], []byte("ftyp"))
|
||
}
|
||
|
||
func isLikelyM3U8(payload []byte) bool {
|
||
trimmed := strings.TrimSpace(string(payload))
|
||
return strings.HasPrefix(trimmed, "#EXTM3U")
|
||
}
|
||
|
||
func isM3U8(targetURL string, contentType string) bool {
|
||
lowerURL := strings.ToLower(targetURL)
|
||
lowerType := strings.ToLower(contentType)
|
||
if strings.Contains(lowerURL, ".m3u8") {
|
||
return true
|
||
}
|
||
|
||
return strings.Contains(lowerType, "application/vnd.apple.mpegurl") || strings.Contains(lowerType, "application/x-mpegurl")
|
||
}
|
||
|
||
func rewritePlaylist(content string, baseURL string, referer string) (string, error) {
|
||
base, err := url.Parse(baseURL)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
var out strings.Builder
|
||
scanner := bufio.NewScanner(strings.NewReader(content))
|
||
for scanner.Scan() {
|
||
line := scanner.Text()
|
||
trimmed := strings.TrimSpace(line)
|
||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||
out.WriteString(line)
|
||
out.WriteString("\n")
|
||
continue
|
||
}
|
||
|
||
relativeURL, parseErr := url.Parse(trimmed)
|
||
if parseErr != nil {
|
||
out.WriteString(line)
|
||
out.WriteString("\n")
|
||
continue
|
||
}
|
||
|
||
absolute := base.ResolveReference(relativeURL).String()
|
||
proxied := "/watch/proxy/segment?u=" + url.QueryEscape(absolute)
|
||
if referer != "" {
|
||
proxied += "&r=" + url.QueryEscape(referer)
|
||
}
|
||
|
||
out.WriteString(proxied)
|
||
out.WriteString("\n")
|
||
}
|
||
|
||
if err := scanner.Err(); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return out.String(), nil
|
||
}
|
||
|
||
func cloneHeaders(src http.Header) http.Header {
|
||
dst := make(http.Header)
|
||
for key, values := range src {
|
||
lower := strings.ToLower(key)
|
||
if lower == "connection" || lower == "transfer-encoding" || lower == "keep-alive" || lower == "proxy-authenticate" || lower == "proxy-authorization" || lower == "te" || lower == "trailers" || lower == "upgrade" {
|
||
continue
|
||
}
|
||
|
||
for _, value := range values {
|
||
dst.Add(key, value)
|
||
}
|
||
}
|
||
|
||
return dst
|
||
}
|