- Add HLS proxy (hlsproxy) for rewriting m3u8 playlists and proxying segments with correct Referer/Origin headers (uses ?domain= param) - Add playerconfig service for detecting stream types (VIPLeague, DaddyLive, HLS) and extracting auth params from ksohls pages - Add VIPLeague URL resolution: extract slug from URL path, match against DaddyLive 24/7 channel index with token-based scoring - Replace Clappr with direct HLS.js player for better compatibility - Add CryptoJS CDN for DaddyLive auth module support - Disable CrowdSec on f1-stream ingress to prevent false positives - Bump image to v1.3.1
209 lines
5.7 KiB
Go
209 lines
5.7 KiB
Go
package hlsproxy
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/base64"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// NewHandler returns an http.Handler for /hls/{base64url_encoded_full_url}.
|
|
// It proxies HLS playlists and segments, rewriting m3u8 URLs to route
|
|
// through the proxy and forwarding X-Hls-Forward-* headers upstream.
|
|
func NewHandler() http.Handler {
|
|
client := &http.Client{
|
|
Timeout: 30_000_000_000, // 30s
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 5 {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodOptions {
|
|
setCORS(w)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
// Parse: /hls/{base64url_encoded_full_url}
|
|
trimmed := strings.TrimPrefix(r.URL.Path, "/hls/")
|
|
if trimmed == "" || trimmed == r.URL.Path {
|
|
http.Error(w, "bad hls proxy URL", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Decode the full upstream URL from base64url
|
|
upstreamURL, err := base64.RawURLEncoding.DecodeString(trimmed)
|
|
if err != nil {
|
|
http.Error(w, "invalid base64url", http.StatusBadRequest)
|
|
return
|
|
}
|
|
target := string(upstreamURL)
|
|
|
|
parsed, err := url.Parse(target)
|
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
|
http.Error(w, "invalid upstream URL", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
log.Printf("hlsproxy: %s -> %s", r.URL.Path, target)
|
|
|
|
upReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
|
|
if err != nil {
|
|
http.Error(w, "failed to create request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Set Referer and Origin. If the URL has a ?domain= param (CDN segments),
|
|
// use that domain as the origin so the CDN accepts the request.
|
|
refererOrigin := parsed.Scheme + "://" + parsed.Host
|
|
if domainParam := parsed.Query().Get("domain"); domainParam != "" {
|
|
refererOrigin = "https://" + domainParam
|
|
}
|
|
upReq.Header.Set("Referer", refererOrigin+"/")
|
|
upReq.Header.Set("Origin", refererOrigin)
|
|
upReq.Header.Set("User-Agent", r.Header.Get("User-Agent"))
|
|
|
|
// Forward X-Hls-Forward-* headers (strip prefix)
|
|
for key, vals := range r.Header {
|
|
if strings.HasPrefix(key, "X-Hls-Forward-") {
|
|
realKey := strings.TrimPrefix(key, "X-Hls-Forward-")
|
|
for _, v := range vals {
|
|
upReq.Header.Set(realKey, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
resp, err := client.Do(upReq)
|
|
if err != nil {
|
|
log.Printf("hlsproxy: upstream fetch failed: %v", err)
|
|
http.Error(w, "upstream fetch failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
log.Printf("hlsproxy: %s <- %d (%s)", truncPath(r.URL.Path, 60), resp.StatusCode, resp.Header.Get("Content-Type"))
|
|
|
|
setCORS(w)
|
|
|
|
ct := resp.Header.Get("Content-Type")
|
|
isM3U8 := strings.Contains(ct, "mpegurl") ||
|
|
strings.Contains(ct, "x-mpegURL") ||
|
|
strings.HasSuffix(parsed.Path, ".m3u8")
|
|
|
|
if isM3U8 {
|
|
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
|
w.WriteHeader(resp.StatusCode)
|
|
rewriteM3U8(w, resp.Body, target)
|
|
return
|
|
}
|
|
|
|
// Stream segment or other content directly
|
|
for key, vals := range resp.Header {
|
|
lk := strings.ToLower(key)
|
|
if lk == "content-type" || lk == "content-length" || lk == "cache-control" || lk == "accept-ranges" {
|
|
for _, v := range vals {
|
|
w.Header().Add(key, v)
|
|
}
|
|
}
|
|
}
|
|
w.WriteHeader(resp.StatusCode)
|
|
io.Copy(w, resp.Body)
|
|
})
|
|
}
|
|
|
|
// rewriteM3U8 reads an m3u8 playlist from r, rewrites segment/playlist URLs
|
|
// to route through /hls/{b64}, and writes the result to w.
|
|
func rewriteM3U8(w io.Writer, r io.Reader, playlistURL string) {
|
|
base, err := url.Parse(playlistURL)
|
|
if err != nil {
|
|
io.Copy(w, r)
|
|
return
|
|
}
|
|
|
|
scanner := bufio.NewScanner(r)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
|
|
if strings.HasPrefix(line, "#") {
|
|
// Rewrite URI="..." in directives like #EXT-X-KEY, #EXT-X-MAP
|
|
rewritten := rewriteURIAttribute(line, base)
|
|
w.Write([]byte(rewritten))
|
|
w.Write([]byte("\n"))
|
|
continue
|
|
}
|
|
|
|
// Non-comment, non-empty lines are URLs
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
w.Write([]byte("\n"))
|
|
continue
|
|
}
|
|
|
|
resolved := resolveURL(base, trimmed)
|
|
encoded := encodeHLSURL(resolved)
|
|
w.Write([]byte(encoded))
|
|
w.Write([]byte("\n"))
|
|
}
|
|
}
|
|
|
|
// rewriteURIAttribute rewrites URI="..." attributes in HLS directives.
|
|
func rewriteURIAttribute(line string, base *url.URL) string {
|
|
// Look for URI="..." (case insensitive)
|
|
uriIdx := strings.Index(strings.ToUpper(line), "URI=\"")
|
|
if uriIdx == -1 {
|
|
return line
|
|
}
|
|
|
|
// Find the actual position (preserving original case)
|
|
prefix := line[:uriIdx+5] // everything up to and including URI="
|
|
rest := line[uriIdx+5:]
|
|
endQuote := strings.Index(rest, "\"")
|
|
if endQuote == -1 {
|
|
return line
|
|
}
|
|
|
|
uri := rest[:endQuote]
|
|
suffix := rest[endQuote:] // closing quote and anything after
|
|
|
|
resolved := resolveURL(base, uri)
|
|
encoded := encodeHLSURL(resolved)
|
|
|
|
return prefix + encoded + suffix
|
|
}
|
|
|
|
// resolveURL resolves a potentially relative URL against a base URL.
|
|
func resolveURL(base *url.URL, ref string) string {
|
|
refURL, err := url.Parse(ref)
|
|
if err != nil {
|
|
return ref
|
|
}
|
|
return base.ResolveReference(refURL).String()
|
|
}
|
|
|
|
// encodeHLSURL encodes a full URL into /hls/{base64url} format.
|
|
func encodeHLSURL(fullURL string) string {
|
|
encoded := base64.RawURLEncoding.EncodeToString([]byte(fullURL))
|
|
return "/hls/" + encoded
|
|
}
|
|
|
|
func setCORS(w http.ResponseWriter) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "*")
|
|
}
|
|
|
|
func truncPath(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
}
|