feat(playback): add watch backend
This commit is contained in:
816
internal/features/playback/service.go
Normal file
816
internal/features/playback/service.go
Normal file
@@ -0,0 +1,816 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mal/internal/jikan"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
allAnimeClient *allAnimeClient
|
||||
jikanClient *jikan.Client
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type sourceScore struct {
|
||||
source StreamSource
|
||||
total int
|
||||
typeScore int
|
||||
providerScore int
|
||||
qualityScore int
|
||||
refererScore int
|
||||
}
|
||||
|
||||
func NewService(jikanClient *jikan.Client) *Service {
|
||||
return &Service{
|
||||
allAnimeClient: newAllAnimeClient(),
|
||||
jikanClient: jikanClient,
|
||||
httpClient: &http.Client{Timeout: 12 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title string, episode string, mode 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"
|
||||
}
|
||||
|
||||
showID, resolvedTitle, err := s.resolveShow(ctx, malID, title)
|
||||
if err != nil {
|
||||
return WatchPageData{}, err
|
||||
}
|
||||
|
||||
modeSources := make(map[string]ModeSource)
|
||||
for _, sourceMode := range []string{"dub", "sub"} {
|
||||
resolved, resolveErr := s.resolveModeSource(ctx, showID, normalizedEpisode, sourceMode, "best")
|
||||
if resolveErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.ToLower(resolved.Type) == "embed" {
|
||||
continue
|
||||
}
|
||||
|
||||
modeSources[sourceMode] = ModeSource{
|
||||
URL: resolved.URL,
|
||||
Referer: resolved.Referer,
|
||||
Subtitles: toSubtitleItems(resolved),
|
||||
}
|
||||
}
|
||||
|
||||
if len(modeSources) == 0 {
|
||||
return WatchPageData{}, errors.New("no direct playable sources available")
|
||||
}
|
||||
|
||||
availableModes := availableModes(modeSources)
|
||||
initialMode := selectInitialMode(normalizedMode, modeSources)
|
||||
|
||||
episodes := s.fetchEpisodeList(ctx, malID)
|
||||
if len(episodes) == 0 {
|
||||
episodeNumbers := s.fetchModeEpisodes(ctx, showID, initialMode)
|
||||
episodes = fallbackEpisodeList(episodeNumbers)
|
||||
}
|
||||
|
||||
segments := s.fetchSkipSegments(ctx, malID, normalizedEpisode)
|
||||
|
||||
watchTitle := strings.TrimSpace(resolvedTitle)
|
||||
if watchTitle == "" {
|
||||
watchTitle = strings.TrimSpace(title)
|
||||
}
|
||||
if watchTitle == "" {
|
||||
watchTitle = fmt.Sprintf("MAL #%d", malID)
|
||||
}
|
||||
|
||||
return WatchPageData{
|
||||
MalID: malID,
|
||||
Title: watchTitle,
|
||||
CurrentEpisode: normalizedEpisode,
|
||||
InitialMode: initialMode,
|
||||
AvailableModes: availableModes,
|
||||
ModeSources: modeSources,
|
||||
Episodes: episodes,
|
||||
Segments: segments,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveShow(ctx context.Context, malID int, title string) (string, string, error) {
|
||||
malText := strconv.Itoa(malID)
|
||||
modeCandidates := []string{"sub", "dub"}
|
||||
for _, mode := range modeCandidates {
|
||||
results, err := s.allAnimeClient.Search(ctx, title, mode)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
if strings.TrimSpace(result.MalID) == malText && strings.TrimSpace(result.ID) != "" {
|
||||
return result.ID, result.Name, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(title) != "" {
|
||||
for _, mode := range modeCandidates {
|
||||
results, err := s.allAnimeClient.Search(ctx, title, mode)
|
||||
if err != nil || 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) 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) 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) probeDirectMedia(ctx context.Context, source StreamSource) (bool, string) {
|
||||
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)
|
||||
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 {
|
||||
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 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) 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) {
|
||||
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 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 {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user