Files
mal/api/playback/service_sources.go
2026-05-12 16:05:36 +02:00

225 lines
5.9 KiB
Go

package playback
import (
"context"
"errors"
"io"
"net/http"
"strings"
"sync"
)
// resolveModeSourceWithCache is like resolveModeSource but caches probe results.
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
}
selected.AvailableQualities = sources
return selected, nil
}
// choosePlaybackSource selects the best playable source from ranked candidates.
// priority: direct media > probed media > embed sources > ranked fallback.
func (s *Service) choosePlaybackSource(
ctx context.Context,
ranked []sourceScore,
probeFn func(context.Context, StreamSource) (bool, string),
) (StreamSource, string, error) {
if len(ranked) == 0 {
return StreamSource{}, "", errors.New("no ranked sources available")
}
embedCandidates := make([]StreamSource, 0, len(ranked))
for _, candidate := range ranked {
source := candidate.source
switch strings.ToLower(source.Type) {
case "mp4", "m3u8":
return source, "direct-media", nil // known playable types
case "embed":
embedCandidates = append(embedCandidates, source) // need probing
default:
// probe unknown types
if playable, contentType := probeFn(ctx, source); playable {
return normalizeSourceTypeFromProbe(source, contentType), "probed-media", nil
}
}
}
// check embed sources for playability
for _, embed := range embedCandidates {
if s.probeEmbedSource(ctx, embed) {
return embed, "embed-probed", nil
}
}
// fallback to first embed or first ranked
if len(embedCandidates) > 0 {
return embedCandidates[0], "embed-fallback", nil
}
return ranked[0].source, "ranked-fallback", nil
}
// choosePlaybackSourceWithCache wraps choosePlaybackSource with cached probing.
func (s *Service) choosePlaybackSourceWithCache(
ctx context.Context,
ranked []sourceScore,
probeCache map[string]directProbeResult,
probeCacheMu *sync.Mutex,
) (StreamSource, string, error) {
return s.choosePlaybackSource(ctx, ranked, func(ctx context.Context, source StreamSource) (bool, string) {
return s.probeDirectMediaCached(ctx, source, probeCache, probeCacheMu)
})
}
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
}
// probeDirectMedia checks if a direct media URL is playable.
// checks content-type header, reads prefix for magic bytes, falls back to URL extension.
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") // small range to detect playable content
resp, err := s.httpClient.Do(req)
if err != nil {
return false, ""
}
defer func() { _ = resp.Body.Close() }()
// check content-type header first
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
if strings.Contains(contentType, "video/") || strings.Contains(contentType, "mpegurl") {
return true, contentType
}
// check magic bytes in prefix
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"
}
}
// fallback to URL extension
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
}
// probeEmbedSource checks if an embed page is still available.
// returns false if the page contains deletion markers.
func (s *Service) probeEmbedSource(ctx context.Context, source StreamSource) bool {
ctx, cancel := context.WithTimeout(ctx, providerProbeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, 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 func() { _ = resp.Body.Close() }()
if resp.StatusCode >= http.StatusBadRequest {
return false
}
// check for common deletion messages
body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
if err != nil {
return false
}
content := strings.ToLower(string(body))
for _, marker := range []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",
} {
if strings.Contains(content, marker) {
return false
}
}
return true
}