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=")) }