285 lines
8.8 KiB
Go
285 lines
8.8 KiB
Go
package util
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"net/http"
|
|
url2 "net/url"
|
|
"seanime/internal/util"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Eyevinn/hls-m3u8/m3u8"
|
|
"github.com/goccy/go-json"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
var proxyUA = util.GetRandomUserAgent()
|
|
|
|
var videoProxyClient = &http.Client{
|
|
Transport: &http.Transport{
|
|
MaxIdleConns: 100,
|
|
MaxIdleConnsPerHost: 10,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
ForceAttemptHTTP2: false, // Fixes issues on Linux
|
|
},
|
|
Timeout: 60 * time.Second,
|
|
}
|
|
|
|
func VideoProxy(c echo.Context) (err error) {
|
|
defer util.HandlePanicInModuleWithError("util/VideoProxy", &err)
|
|
|
|
url := c.QueryParam("url")
|
|
headers := c.QueryParam("headers")
|
|
|
|
// Always use GET request internally, even for HEAD requests
|
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("proxy: Error creating request")
|
|
return echo.NewHTTPError(http.StatusInternalServerError)
|
|
}
|
|
|
|
var headerMap map[string]string
|
|
if headers != "" {
|
|
if err := json.Unmarshal([]byte(headers), &headerMap); err != nil {
|
|
log.Error().Err(err).Msg("proxy: Error unmarshalling headers")
|
|
return echo.NewHTTPError(http.StatusInternalServerError)
|
|
}
|
|
for key, value := range headerMap {
|
|
req.Header.Set(key, value)
|
|
}
|
|
}
|
|
|
|
req.Header.Set("User-Agent", proxyUA)
|
|
req.Header.Set("Accept", "*/*")
|
|
if rangeHeader := c.Request().Header.Get("Range"); rangeHeader != "" {
|
|
req.Header.Set("Range", rangeHeader)
|
|
}
|
|
|
|
resp, err := videoProxyClient.Do(req)
|
|
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("proxy: Error sending request")
|
|
return echo.NewHTTPError(http.StatusInternalServerError)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Copy response headers
|
|
for k, vs := range resp.Header {
|
|
for _, v := range vs {
|
|
if !strings.EqualFold(k, "Content-Length") { // Skip Content-Length header, fixes net::ERR_CONTENT_LENGTH_MISMATCH
|
|
c.Response().Header().Set(k, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set CORS headers
|
|
c.Response().Header().Set("Access-Control-Allow-Origin", "*")
|
|
c.Response().Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
c.Response().Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
|
|
|
// For HEAD requests, return only headers
|
|
if c.Request().Method == http.MethodHead {
|
|
return c.NoContent(http.StatusOK)
|
|
}
|
|
|
|
isHlsPlaylist := strings.HasSuffix(url, ".m3u8") || strings.Contains(resp.Header.Get("Content-Type"), "mpegurl")
|
|
|
|
if !isHlsPlaylist {
|
|
return c.Stream(resp.StatusCode, c.Response().Header().Get("Content-Type"), resp.Body)
|
|
}
|
|
|
|
// HLS Playlist
|
|
//log.Debug().Str("url", url).Msg("proxy: Processing HLS playlist")
|
|
|
|
bodyBytes, readErr := io.ReadAll(resp.Body)
|
|
if readErr != nil {
|
|
log.Error().Err(readErr).Str("url", url).Msg("proxy: Error reading HLS response body")
|
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to read HLS playlist")
|
|
}
|
|
|
|
buffer := bytes.NewBuffer(bodyBytes)
|
|
playlist, listType, decodeErr := m3u8.Decode(*buffer, true)
|
|
if decodeErr != nil {
|
|
// Playlist might be valid but not decodable by the library, or simply corrupted.
|
|
// Option 1: Proxy as-is (might be preferred if decoding fails unexpectedly)
|
|
log.Warn().Err(decodeErr).Str("url", url).Msg("proxy: Failed to decode M3U8 playlist, proxying raw content")
|
|
c.Response().Header().Set(echo.HeaderContentType, resp.Header.Get("Content-Type")) // Use original Content-Type
|
|
c.Response().Header().Set(echo.HeaderContentLength, strconv.Itoa(len(bodyBytes)))
|
|
c.Response().WriteHeader(resp.StatusCode)
|
|
_, writeErr := c.Response().Writer.Write(bodyBytes)
|
|
return writeErr
|
|
}
|
|
|
|
var modifiedPlaylistBytes []byte
|
|
needsRewrite := false // Flag to check if we actually need to rewrite
|
|
|
|
if listType == m3u8.MEDIA {
|
|
mediaPl := playlist.(*m3u8.MediaPlaylist)
|
|
baseURL, _ := url2.Parse(url) // Base URL for resolving relative paths
|
|
|
|
for _, segment := range mediaPl.Segments {
|
|
if segment != nil {
|
|
// Rewrite Segment URI
|
|
if !isAlreadyProxied(segment.URI) {
|
|
if segment.URI != "" {
|
|
if !strings.HasPrefix(segment.URI, "http") {
|
|
segment.URI = resolveURL(baseURL, segment.URI)
|
|
}
|
|
segment.URI = rewriteProxyURL(segment.URI, headerMap)
|
|
needsRewrite = true
|
|
}
|
|
}
|
|
|
|
// Rewrite encryption key URIs
|
|
for i, key := range segment.Keys {
|
|
if key.URI != "" {
|
|
if !isAlreadyProxied(key.URI) {
|
|
keyURI := key.URI
|
|
if !strings.HasPrefix(key.URI, "http") {
|
|
keyURI = resolveURL(baseURL, key.URI)
|
|
}
|
|
segment.Keys[i].URI = rewriteProxyURL(keyURI, headerMap)
|
|
needsRewrite = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rewrite playlist-level encryption key URIs
|
|
for i, key := range mediaPl.Keys {
|
|
if key.URI != "" {
|
|
if !isAlreadyProxied(key.URI) {
|
|
keyURI := key.URI
|
|
if !strings.HasPrefix(key.URI, "http") {
|
|
keyURI = resolveURL(baseURL, key.URI)
|
|
}
|
|
mediaPl.Keys[i].URI = rewriteProxyURL(keyURI, headerMap)
|
|
needsRewrite = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Encode the modified media playlist
|
|
buffer := mediaPl.Encode()
|
|
modifiedPlaylistBytes = buffer.Bytes()
|
|
|
|
} else if listType == m3u8.MASTER {
|
|
// Rewrite URIs in Master playlists
|
|
masterPl := playlist.(*m3u8.MasterPlaylist)
|
|
baseURL, _ := url2.Parse(url) // Base URL for resolving relative paths
|
|
|
|
for _, variant := range masterPl.Variants {
|
|
if variant != nil && variant.URI != "" {
|
|
if !isAlreadyProxied(variant.URI) {
|
|
variantURI := variant.URI
|
|
if !strings.HasPrefix(variant.URI, "http") {
|
|
variantURI = resolveURL(baseURL, variant.URI)
|
|
}
|
|
variant.URI = rewriteProxyURL(variantURI, headerMap)
|
|
needsRewrite = true
|
|
}
|
|
}
|
|
|
|
// Handle alternative media groups (audio, subtitles, etc.) for each variant
|
|
if variant != nil {
|
|
for _, alternative := range variant.Alternatives {
|
|
if alternative != nil && alternative.URI != "" {
|
|
if !isAlreadyProxied(alternative.URI) {
|
|
alternativeURI := alternative.URI
|
|
if !strings.HasPrefix(alternative.URI, "http") {
|
|
alternativeURI = resolveURL(baseURL, alternative.URI)
|
|
}
|
|
alternative.URI = rewriteProxyURL(alternativeURI, headerMap)
|
|
needsRewrite = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
allAlternatives := masterPl.GetAllAlternatives()
|
|
for _, alternative := range allAlternatives {
|
|
if alternative != nil && alternative.URI != "" {
|
|
if !isAlreadyProxied(alternative.URI) {
|
|
alternativeURI := alternative.URI
|
|
if !strings.HasPrefix(alternative.URI, "http") {
|
|
alternativeURI = resolveURL(baseURL, alternative.URI)
|
|
}
|
|
alternative.URI = rewriteProxyURL(alternativeURI, headerMap)
|
|
needsRewrite = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rewrite session key URIs
|
|
for i, sessionKey := range masterPl.SessionKeys {
|
|
if sessionKey.URI != "" {
|
|
if !isAlreadyProxied(sessionKey.URI) {
|
|
sessionKeyURI := sessionKey.URI
|
|
if !strings.HasPrefix(sessionKey.URI, "http") {
|
|
sessionKeyURI = resolveURL(baseURL, sessionKey.URI)
|
|
}
|
|
masterPl.SessionKeys[i].URI = rewriteProxyURL(sessionKeyURI, headerMap)
|
|
needsRewrite = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Encode the modified master playlist
|
|
buffer := masterPl.Encode()
|
|
modifiedPlaylistBytes = buffer.Bytes()
|
|
|
|
} else {
|
|
// Unknown type, pass through
|
|
modifiedPlaylistBytes = bodyBytes
|
|
}
|
|
|
|
// Set headers *after* potential modification
|
|
contentType := "application/vnd.apple.mpegurl"
|
|
c.Response().Header().Set(echo.HeaderContentType, contentType)
|
|
// Set Content-Length based on the *modified* playlist
|
|
c.Response().Header().Set(echo.HeaderContentLength, strconv.Itoa(len(modifiedPlaylistBytes)))
|
|
|
|
// Set Cache-Control headers appropriate for playlists (often no-cache for live)
|
|
if resp.Header.Get("Cache-Control") == "" {
|
|
c.Response().Header().Set("Cache-Control", "no-cache")
|
|
}
|
|
|
|
log.Debug().Bool("rewritten", needsRewrite).Str("url", url).Msg("proxy: Sending modified HLS playlist")
|
|
c.Response().WriteHeader(resp.StatusCode)
|
|
|
|
return c.Blob(http.StatusOK, c.Response().Header().Get("Content-Type"), modifiedPlaylistBytes)
|
|
}
|
|
|
|
func resolveURL(base *url2.URL, relativeURI string) string {
|
|
if base == nil {
|
|
return relativeURI // Cannot resolve without a base
|
|
}
|
|
relativeURL, err := url2.Parse(relativeURI)
|
|
if err != nil {
|
|
return relativeURI // Invalid relative URI
|
|
}
|
|
return base.ResolveReference(relativeURL).String()
|
|
}
|
|
|
|
func rewriteProxyURL(targetMediaURL string, headerMap map[string]string) string {
|
|
proxyURL := "/api/v1/proxy?url=" + url2.QueryEscape(targetMediaURL)
|
|
if len(headerMap) > 0 {
|
|
headersStrB, err := json.Marshal(headerMap)
|
|
// Ignore marshalling errors here? Or log them? For simplicity, ignoring now.
|
|
if err == nil && len(headersStrB) > 2 { // Check > 2 for "{}" empty map
|
|
proxyURL += "&headers=" + url2.QueryEscape(string(headersStrB))
|
|
}
|
|
}
|
|
return proxyURL
|
|
}
|
|
|
|
func isAlreadyProxied(url string) bool {
|
|
// Check if the URL contains the proxy pattern
|
|
return strings.Contains(url, "/api/v1/proxy?url=") || strings.Contains(url, url2.QueryEscape("/api/v1/proxy?url="))
|
|
}
|