Files
mal/internal/features/playback/service.go

1175 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}