- 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
512 lines
15 KiB
Go
512 lines
15 KiB
Go
package playerconfig
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// PlayerConfig is returned by the /api/streams/{id}/player-config endpoint.
|
|
type PlayerConfig struct {
|
|
Type string `json:"type"`
|
|
HLSURL string `json:"hls_url,omitempty"`
|
|
AuthToken string `json:"auth_token,omitempty"`
|
|
ChannelKey string `json:"channel_key,omitempty"`
|
|
ChannelSalt string `json:"channel_salt,omitempty"`
|
|
Timestamp string `json:"timestamp,omitempty"`
|
|
AuthModURL string `json:"auth_mod_url,omitempty"`
|
|
ServerKey string `json:"server_key,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type cacheEntry struct {
|
|
config *PlayerConfig
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// Service handles stream type detection and DaddyLive config extraction.
|
|
type Service struct {
|
|
client *http.Client
|
|
mu sync.RWMutex
|
|
cache map[string]*cacheEntry
|
|
}
|
|
|
|
// New creates a new playerconfig Service.
|
|
func New() *Service {
|
|
return &Service{
|
|
client: &http.Client{
|
|
Timeout: 15 * time.Second,
|
|
},
|
|
cache: make(map[string]*cacheEntry),
|
|
}
|
|
}
|
|
|
|
// DetectStreamType returns "hls", "daddylive", "vipleague", or "proxy" based on the URL.
|
|
func DetectStreamType(rawURL string) string {
|
|
lower := strings.ToLower(rawURL)
|
|
|
|
if strings.HasSuffix(strings.SplitN(lower, "?", 2)[0], ".m3u8") {
|
|
return "hls"
|
|
}
|
|
|
|
daddyPatterns := []string{"dlhd.link", "dlhd.sx", "dlhd.dad", "daddylive", "ksohls.ru"}
|
|
for _, p := range daddyPatterns {
|
|
if strings.Contains(lower, p) {
|
|
return "daddylive"
|
|
}
|
|
}
|
|
|
|
vipPatterns := []string{"vipleague.io", "vipleague.im", "vipleague.cc", "casthill.net"}
|
|
for _, p := range vipPatterns {
|
|
if strings.Contains(lower, p) {
|
|
return "vipleague"
|
|
}
|
|
}
|
|
|
|
return "proxy"
|
|
}
|
|
|
|
// GetConfig returns a PlayerConfig for the given stream URL.
|
|
func (s *Service) GetConfig(ctx context.Context, rawURL string) *PlayerConfig {
|
|
streamType := DetectStreamType(rawURL)
|
|
|
|
switch streamType {
|
|
case "hls":
|
|
encoded := base64.RawURLEncoding.EncodeToString([]byte(rawURL))
|
|
return &PlayerConfig{
|
|
Type: "hls",
|
|
HLSURL: "/hls/" + encoded,
|
|
}
|
|
|
|
case "daddylive":
|
|
return s.getDaddyLiveConfig(ctx, rawURL)
|
|
|
|
case "vipleague":
|
|
return s.getVIPLeagueConfig(ctx, rawURL)
|
|
|
|
default:
|
|
return &PlayerConfig{Type: "proxy"}
|
|
}
|
|
}
|
|
|
|
// Channel ID extraction patterns
|
|
var channelIDPatterns = []*regexp.Regexp{
|
|
regexp.MustCompile(`stream-(\d+)\.php`),
|
|
regexp.MustCompile(`[?&]id=(\d+)`),
|
|
regexp.MustCompile(`/(\d+)\.php`),
|
|
}
|
|
|
|
// Page content extraction patterns
|
|
var (
|
|
iframeRe = regexp.MustCompile(`<iframe[^>]*src=["'](https?://[^"']*ksohls\.ru[^"']*)["']`)
|
|
authTokenRe = regexp.MustCompile(`authToken\s*[:=]\s*['"]([^'"]+)['"]`)
|
|
channelKeyRe = regexp.MustCompile(`channelKey\s*[:=]\s*['"]([^'"]+)['"]`)
|
|
channelSaltRe = regexp.MustCompile(`channelSalt\s*[:=]\s*['"]([^'"]+)['"]`)
|
|
timestampRe = regexp.MustCompile(`timestamp\s*[:=]\s*['"]?(\d+)['"]?`)
|
|
authModRe = regexp.MustCompile(`<script[^>]*src=["'](https?://[^"']*aiaged\.fun[^"']*obfuscated[^"']*)["']`)
|
|
)
|
|
|
|
func (s *Service) getDaddyLiveConfig(ctx context.Context, rawURL string) *PlayerConfig {
|
|
// Check cache
|
|
s.mu.RLock()
|
|
if entry, ok := s.cache[rawURL]; ok && time.Now().Before(entry.expiresAt) {
|
|
s.mu.RUnlock()
|
|
return entry.config
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
config := s.fetchDaddyLiveConfig(ctx, rawURL)
|
|
|
|
// Cache the result (even errors, to avoid hammering)
|
|
s.mu.Lock()
|
|
s.cache[rawURL] = &cacheEntry{
|
|
config: config,
|
|
expiresAt: time.Now().Add(1 * time.Hour),
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
return config
|
|
}
|
|
|
|
func (s *Service) fetchDaddyLiveConfig(ctx context.Context, rawURL string) *PlayerConfig {
|
|
// Step 1: Extract channel ID from URL
|
|
channelID := ""
|
|
for _, re := range channelIDPatterns {
|
|
if m := re.FindStringSubmatch(rawURL); len(m) > 1 {
|
|
channelID = m[1]
|
|
break
|
|
}
|
|
}
|
|
if channelID == "" {
|
|
return &PlayerConfig{Type: "proxy", Error: "could not extract channel ID"}
|
|
}
|
|
|
|
log.Printf("playerconfig: DaddyLive channel=%s from %s", channelID, rawURL)
|
|
return s.fetchDaddyLiveConfigByID(ctx, channelID)
|
|
}
|
|
|
|
func (s *Service) fetchDaddyLiveConfigByID(ctx context.Context, channelID string) *PlayerConfig {
|
|
// Step 2: Fetch the cast page to find the ksohls iframe
|
|
castURL := fmt.Sprintf("https://dlhd.link/cast/stream-%s.php", channelID)
|
|
castBody, err := s.fetchPage(ctx, castURL, "https://dlhd.link/")
|
|
if err != nil {
|
|
log.Printf("playerconfig: failed to fetch cast page: %v", err)
|
|
return &PlayerConfig{Type: "proxy", Error: "failed to fetch cast page"}
|
|
}
|
|
|
|
// Step 3: Extract ksohls iframe URL
|
|
iframeMatch := iframeRe.FindStringSubmatch(castBody)
|
|
if iframeMatch == nil {
|
|
log.Printf("playerconfig: no ksohls iframe found in cast page")
|
|
return &PlayerConfig{Type: "proxy", Error: "no ksohls iframe found"}
|
|
}
|
|
ksohURL := iframeMatch[1]
|
|
|
|
// Step 4: Fetch the ksohls page
|
|
referer := fmt.Sprintf("https://dlhd.link/stream/stream-%s.php", channelID)
|
|
ksohBody, err := s.fetchPage(ctx, ksohURL, referer)
|
|
if err != nil {
|
|
log.Printf("playerconfig: failed to fetch ksohls page: %v", err)
|
|
return &PlayerConfig{Type: "proxy", Error: "failed to fetch ksohls page"}
|
|
}
|
|
|
|
// Step 5: Extract auth params from ksohls page
|
|
config := &PlayerConfig{Type: "daddylive"}
|
|
|
|
if m := authTokenRe.FindStringSubmatch(ksohBody); len(m) > 1 {
|
|
config.AuthToken = m[1]
|
|
}
|
|
if m := channelKeyRe.FindStringSubmatch(ksohBody); len(m) > 1 {
|
|
config.ChannelKey = m[1]
|
|
}
|
|
if m := channelSaltRe.FindStringSubmatch(ksohBody); len(m) > 1 {
|
|
config.ChannelSalt = m[1]
|
|
}
|
|
if m := timestampRe.FindStringSubmatch(ksohBody); len(m) > 1 {
|
|
config.Timestamp = m[1]
|
|
}
|
|
if m := authModRe.FindStringSubmatch(ksohBody); len(m) > 1 {
|
|
config.AuthModURL = m[1]
|
|
}
|
|
|
|
if config.ChannelKey == "" {
|
|
log.Printf("playerconfig: no channelKey found in ksohls page")
|
|
return &PlayerConfig{Type: "proxy", Error: "no channelKey found"}
|
|
}
|
|
|
|
// Step 6: Server lookup
|
|
lookupURL := fmt.Sprintf("https://chevy.soyspace.cyou/server_lookup?channel_id=%s", config.ChannelKey)
|
|
lookupBody, err := s.fetchPage(ctx, lookupURL, "")
|
|
if err != nil {
|
|
log.Printf("playerconfig: server lookup failed: %v", err)
|
|
return &PlayerConfig{Type: "proxy", Error: "server lookup failed"}
|
|
}
|
|
|
|
var lookupResp struct {
|
|
ServerKey string `json:"server_key"`
|
|
}
|
|
if err := json.Unmarshal([]byte(lookupBody), &lookupResp); err != nil || lookupResp.ServerKey == "" {
|
|
log.Printf("playerconfig: failed to parse server lookup: %v body=%s", err, lookupBody)
|
|
return &PlayerConfig{Type: "proxy", Error: "server lookup parse failed"}
|
|
}
|
|
config.ServerKey = lookupResp.ServerKey
|
|
|
|
// Step 7: Build m3u8 URL
|
|
m3u8URL := fmt.Sprintf("https://chevy.soyspace.cyou/proxy/%s/%s/mono.m3u8",
|
|
config.ServerKey, config.ChannelKey)
|
|
encoded := base64.RawURLEncoding.EncodeToString([]byte(m3u8URL))
|
|
config.HLSURL = "/hls/" + encoded
|
|
|
|
log.Printf("playerconfig: DaddyLive config ready channel=%s server=%s", config.ChannelKey, config.ServerKey)
|
|
return config
|
|
}
|
|
|
|
// VIPLeague/casthill resolution
|
|
|
|
var zmidRe = regexp.MustCompile(`(?:const|var|let)\s+zmid\s*=\s*["']([^"']+)["']`)
|
|
var casthillVRe = regexp.MustCompile(`[?&]v=([^&]+)`)
|
|
|
|
func (s *Service) getVIPLeagueConfig(ctx context.Context, rawURL string) *PlayerConfig {
|
|
// Check cache using normalized URL
|
|
s.mu.RLock()
|
|
if entry, ok := s.cache[rawURL]; ok && time.Now().Before(entry.expiresAt) {
|
|
s.mu.RUnlock()
|
|
return entry.config
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
config := s.fetchVIPLeagueConfig(ctx, rawURL)
|
|
|
|
s.mu.Lock()
|
|
s.cache[rawURL] = &cacheEntry{
|
|
config: config,
|
|
expiresAt: time.Now().Add(1 * time.Hour),
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
return config
|
|
}
|
|
|
|
func (s *Service) fetchVIPLeagueConfig(ctx context.Context, rawURL string) *PlayerConfig {
|
|
lower := strings.ToLower(rawURL)
|
|
|
|
var zmid string
|
|
|
|
if strings.Contains(lower, "casthill.net") {
|
|
// Extract zmid from casthill URL query param ?v=...
|
|
if m := casthillVRe.FindStringSubmatch(rawURL); len(m) > 1 {
|
|
zmid = m[1]
|
|
}
|
|
}
|
|
|
|
if zmid == "" {
|
|
// Try to fetch VIPLeague page and extract zmid from JavaScript
|
|
body, err := s.fetchPage(ctx, rawURL, "")
|
|
if err != nil {
|
|
log.Printf("playerconfig: failed to fetch VIPLeague page: %v, trying URL-based extraction", err)
|
|
} else {
|
|
if m := zmidRe.FindStringSubmatch(body); len(m) > 1 {
|
|
zmid = m[1]
|
|
}
|
|
}
|
|
}
|
|
|
|
if zmid == "" {
|
|
// Fallback: extract slug from URL path and use it directly for channel matching
|
|
// e.g. /f-1/sky-sports-f1-streaming → "sky sports f1"
|
|
zmid = extractSlugFromURL(rawURL)
|
|
if zmid != "" {
|
|
log.Printf("playerconfig: extracted slug %q from URL path", zmid)
|
|
}
|
|
}
|
|
|
|
if zmid == "" {
|
|
log.Printf("playerconfig: no zmid found for VIPLeague URL %s", rawURL)
|
|
return &PlayerConfig{Type: "proxy", Error: "no zmid found in VIPLeague page"}
|
|
}
|
|
|
|
log.Printf("playerconfig: VIPLeague zmid=%q from %s", zmid, rawURL)
|
|
|
|
channelID, err := s.resolveChannelID(ctx, zmid)
|
|
if err != nil {
|
|
log.Printf("playerconfig: failed to resolve zmid %q: %v", zmid, err)
|
|
return &PlayerConfig{Type: "proxy", Error: fmt.Sprintf("failed to resolve zmid: %v", err)}
|
|
}
|
|
|
|
log.Printf("playerconfig: resolved zmid=%q to DaddyLive channel=%s", zmid, channelID)
|
|
return s.fetchDaddyLiveConfigByID(ctx, channelID)
|
|
}
|
|
|
|
// extractSlugFromURL extracts a channel-matching slug from a VIPLeague URL path.
|
|
// e.g. "https://vipleague.io/f-1/sky-sports-f1-streaming" → "sky sports f1"
|
|
// Strips common suffixes like "-streaming", "-live-stream", "-live", etc.
|
|
func extractSlugFromURL(rawURL string) string {
|
|
// Get the last path segment
|
|
path := rawURL
|
|
if idx := strings.Index(path, "?"); idx != -1 {
|
|
path = path[:idx]
|
|
}
|
|
path = strings.TrimRight(path, "/")
|
|
lastSlash := strings.LastIndex(path, "/")
|
|
if lastSlash == -1 {
|
|
return ""
|
|
}
|
|
slug := path[lastSlash+1:]
|
|
|
|
// Strip common suffixes
|
|
for _, suffix := range []string{"-streaming", "-live-stream", "-stream", "-live", "-online", "-free"} {
|
|
slug = strings.TrimSuffix(slug, suffix)
|
|
}
|
|
|
|
// Replace hyphens with spaces for matching against channel names
|
|
slug = strings.ReplaceAll(slug, "-", " ")
|
|
slug = strings.TrimSpace(slug)
|
|
|
|
if slug == "" || len(slug) < 3 {
|
|
return ""
|
|
}
|
|
return slug
|
|
}
|
|
|
|
var channelLinkRe = regexp.MustCompile(`<a[^>]*href=["'][^"']*watch\.php\?id=(\d+)["'][^>]*data-title=["']([^"']+)["']`)
|
|
var channelLinkRe2 = regexp.MustCompile(`<a[^>]*data-title=["']([^"']+)["'][^>]*href=["'][^"']*watch\.php\?id=(\d+)["']`)
|
|
|
|
func (s *Service) resolveChannelID(ctx context.Context, zmid string) (string, error) {
|
|
channels, err := s.getChannelIndex(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
zmidLower := strings.ToLower(zmid)
|
|
|
|
// Build tokens: if zmid contains spaces, split on spaces; otherwise use tokenizer
|
|
var tokens []string
|
|
if strings.Contains(zmidLower, " ") {
|
|
for _, word := range strings.Fields(zmidLower) {
|
|
if len(word) >= 2 {
|
|
tokens = append(tokens, word)
|
|
}
|
|
}
|
|
} else {
|
|
tokens = tokenize(zmidLower)
|
|
}
|
|
|
|
bestID := ""
|
|
bestScore := 0
|
|
bestNameLen := 0
|
|
|
|
for id, name := range channels {
|
|
score := 0
|
|
for _, tok := range tokens {
|
|
if strings.Contains(name, tok) {
|
|
score++
|
|
}
|
|
}
|
|
// Tiebreaker: prefer shorter names (more specific match) and
|
|
// English/UK channels which tend to have shorter names
|
|
if score > bestScore || (score == bestScore && score > 0 && len(name) < bestNameLen) {
|
|
bestScore = score
|
|
bestID = id
|
|
bestNameLen = len(name)
|
|
}
|
|
}
|
|
|
|
if bestID == "" || bestScore == 0 {
|
|
return "", fmt.Errorf("no channel matched zmid %q (tried %d channels)", zmid, len(channels))
|
|
}
|
|
|
|
log.Printf("playerconfig: zmid=%q matched channel %s (%s) with score %d/%d",
|
|
zmid, bestID, channels[bestID], bestScore, len(tokens))
|
|
return bestID, nil
|
|
}
|
|
|
|
func (s *Service) getChannelIndex(ctx context.Context) (map[string]string, error) {
|
|
const cacheKey = "__channel_index__"
|
|
|
|
s.mu.RLock()
|
|
if entry, ok := s.cache[cacheKey]; ok && time.Now().Before(entry.expiresAt) {
|
|
s.mu.RUnlock()
|
|
// Decode from the Error field (ab)used as storage
|
|
var idx map[string]string
|
|
if err := json.Unmarshal([]byte(entry.config.Error), &idx); err == nil {
|
|
return idx, nil
|
|
}
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
body, err := s.fetchPage(ctx, "https://dlhd.link/24-7-channels.php", "https://dlhd.link/")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch channel index: %w", err)
|
|
}
|
|
|
|
channels := make(map[string]string)
|
|
|
|
// Try both attribute orderings
|
|
for _, m := range channelLinkRe.FindAllStringSubmatch(body, -1) {
|
|
channels[m[1]] = strings.ToLower(strings.TrimSpace(m[2]))
|
|
}
|
|
for _, m := range channelLinkRe2.FindAllStringSubmatch(body, -1) {
|
|
channels[m[2]] = strings.ToLower(strings.TrimSpace(m[1]))
|
|
}
|
|
|
|
if len(channels) == 0 {
|
|
return nil, fmt.Errorf("no channels found in 24/7 page (%d bytes)", len(body))
|
|
}
|
|
|
|
log.Printf("playerconfig: loaded %d channels from DaddyLive 24/7 page", len(channels))
|
|
|
|
// Cache as JSON in a fake PlayerConfig entry
|
|
encoded, _ := json.Marshal(channels)
|
|
s.mu.Lock()
|
|
s.cache[cacheKey] = &cacheEntry{
|
|
config: &PlayerConfig{Error: string(encoded)},
|
|
expiresAt: time.Now().Add(6 * time.Hour),
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
return channels, nil
|
|
}
|
|
|
|
// tokenize splits a zmid slug into meaningful tokens.
|
|
// e.g. "skyf1" -> ["sky", "f1"], "daznf1" -> ["dazn", "f1"]
|
|
func tokenize(zmid string) []string {
|
|
// Common known prefixes/suffixes in sports streaming slugs
|
|
knownTokens := []string{
|
|
"sky", "sports", "f1", "dazn", "espn", "fox", "bein", "bt",
|
|
"star", "nbc", "cbs", "tnt", "abc", "tsn", "supersport",
|
|
"canal", "rtl", "viaplay", "premier", "main", "event",
|
|
"arena", "action", "cricket", "football", "tennis", "golf",
|
|
"racing", "news", "extra", "max", "hd", "uhd",
|
|
}
|
|
|
|
var tokens []string
|
|
remaining := zmid
|
|
|
|
for len(remaining) > 0 {
|
|
matched := false
|
|
for _, tok := range knownTokens {
|
|
if strings.HasPrefix(remaining, tok) {
|
|
tokens = append(tokens, tok)
|
|
remaining = remaining[len(tok):]
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
// Try numeric suffix (like channel numbers)
|
|
i := 0
|
|
for i < len(remaining) && remaining[i] >= '0' && remaining[i] <= '9' {
|
|
i++
|
|
}
|
|
if i > 0 {
|
|
tokens = append(tokens, remaining[:i])
|
|
remaining = remaining[i:]
|
|
} else {
|
|
// Skip single character and try again
|
|
remaining = remaining[1:]
|
|
}
|
|
}
|
|
}
|
|
|
|
// If tokenization produced nothing useful, use the whole zmid as a single token
|
|
if len(tokens) == 0 {
|
|
tokens = []string{zmid}
|
|
}
|
|
|
|
return tokens
|
|
}
|
|
|
|
func (s *Service) fetchPage(ctx context.Context, pageURL, referer string) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
|
if referer != "" {
|
|
req.Header.Set("Referer", referer)
|
|
}
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("status %d from %s", resp.StatusCode, pageURL)
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) // 2MB max
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(body), nil
|
|
}
|