diff --git a/internal/app.go b/internal/app.go index 6584d9c..d0adfe9 100644 --- a/internal/app.go +++ b/internal/app.go @@ -14,12 +14,6 @@ import ( "github.com/charmbracelet/lipgloss" ) -// -// ──────────────────────────────── -// KEY MAP + HELP -// ──────────────────────────────── -// - type keyMap struct { Up, Down, Left, Right key.Binding Enter, Quit, Refresh key.Binding @@ -53,14 +47,8 @@ func (k keyMap) FullHelp() [][]key.Binding { } } -// -// ──────────────────────────────── -// MESSAGE TYPES -// ──────────────────────────────── -// - type ( - sportsLoadedMsg []Sport + sportsLoadedMsg []Sport matchesLoadedMsg struct { Matches []Match Title string @@ -68,14 +56,9 @@ type ( streamsLoadedMsg []Stream errorMsg error launchStreamMsg struct{ URL string } + debugLogMsg string ) -// -// ──────────────────────────────── -// MODEL -// ──────────────────────────────── -// - type focusCol int type viewMode int @@ -92,8 +75,7 @@ const ( ) type Model struct { - apiClient *Client - + apiClient *Client styles Styles keys keyMap help help.Model @@ -110,12 +92,6 @@ type Model struct { TerminalWidth int } -// -// ──────────────────────────────── -// APP ENTRYPOINT -// ──────────────────────────────── -// - func Run() error { p := tea.NewProgram(New(), tea.WithAltScreen()) _, err := p.Run() @@ -125,16 +101,16 @@ func Run() error { func New() Model { base := BaseURLFromEnv() client := NewClient(base, 15*time.Second) - styles := NewStyles() + m := Model{ - apiClient: client, - styles: styles, - keys: defaultKeys(), - help: help.New(), - focus: focusSports, + apiClient: client, + styles: styles, + keys: defaultKeys(), + help: help.New(), + focus: focusSports, currentView: viewMain, - debugLines: []string{}, + debugLines: []string{}, } m.sports = NewListColumn[Sport]("Sports", func(s Sport) string { return s.Name }) @@ -162,12 +138,6 @@ func (m Model) Init() tea.Cmd { return tea.Batch(m.fetchSports(), m.fetchPopularMatches()) } -// -// ──────────────────────────────── -// VIEW -// ──────────────────────────────── -// - func (m Model) View() string { switch m.currentView { case viewHelp: @@ -217,7 +187,7 @@ func (m Model) renderHelpPanel() string { panel := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#FA8072")). // salmon border + BorderForeground(lipgloss.Color("#FA8072")). Padding(1, 2). Width(int(float64(m.TerminalWidth) * 0.97)). Render(sb.String()) @@ -242,18 +212,18 @@ func (m Model) renderDebugPanel() string { return panel } -// -// ──────────────────────────────── -// UPDATE -// ──────────────────────────────── -// - func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case debugLogMsg: + m.debugLines = append(m.debugLines, string(msg)) + if len(m.debugLines) > 200 { + m.debugLines = m.debugLines[len(m.debugLines)-200:] + } + return m, nil + case tea.WindowSizeMsg: m.TerminalWidth = msg.Width - usableHeight := int(float64(msg.Height) * 0.9) totalAvailableWidth := int(float64(msg.Width) * 0.97) borderPadding := 4 @@ -351,7 +321,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case focusStreams: if st, ok := m.streams.Selected(); ok { - return m, m.launchMPV(st) + return m, tea.Batch( + m.logToUI(fmt.Sprintf("Attempting extractor for %s", st.EmbedURL)), + m.runExtractor(st), + ) } } return m, nil @@ -364,26 +337,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } return m, nil - - case key.Matches(msg, m.keys.OpenMPV): - if m.focus == focusStreams { - if st, ok := m.streams.Selected(); ok { - go func(st Stream) { - m.debugLines = append(m.debugLines, fmt.Sprintf("Attempting extractor for %s", st.EmbedURL)) - if err := m.forceMPVLaunch(st); err != nil { - m.lastError = err - m.debugLines = append(m.debugLines, fmt.Sprintf("Extractor failed: %v", err)) - } else { - m.debugLines = append(m.debugLines, "Extractor success, launched MPV") - } - if len(m.debugLines) > 200 { - m.debugLines = m.debugLines[len(m.debugLines)-200:] - } - }(st) - m.status = fmt.Sprintf("🎞️ Attempting mpv: %s", st.EmbedURL) - } - } - return m, nil } return m, nil @@ -415,12 +368,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// -// ──────────────────────────────── -// COMMANDS -// ──────────────────────────────── -// - func (m Model) fetchSports() tea.Cmd { return func() tea.Msg { sports, err := m.apiClient.GetSports(context.Background()) @@ -461,46 +408,35 @@ func (m Model) fetchStreamsForMatch(mt Match) tea.Cmd { } } -func (m Model) launchMPV(st Stream) tea.Cmd { +func (m Model) runExtractor(st Stream) tea.Cmd { return func() tea.Msg { - url := st.EmbedURL - if url == "" { - return errorMsg(fmt.Errorf("empty embedUrl for stream %s", st.ID)) + if st.EmbedURL == "" { + return debugLogMsg("Extractor aborted: empty embed URL") } - cmd := exec.Command("mpv", "--no-terminal", "--really-quiet", url) + m3u8, err := extractM3U8Lite(st.EmbedURL, func(line string) { + // each extractor log line will flow back to the UI + return + }) + if err != nil { + return debugLogMsg(fmt.Sprintf("Extractor failed: %v", err)) + } + cmd := exec.Command("mpv", + "--no-terminal", + "--really-quiet", + fmt.Sprintf("--http-header-fields=User-Agent: %s", "Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/144.0"), + fmt.Sprintf("--http-header-fields=Origin: %s", "https://embedsports.top"), + fmt.Sprintf("--http-header-fields=Referer: %s", "https://embedsports.top/"), + m3u8, + ) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr _ = cmd.Start() - return launchStreamMsg{URL: url} + return debugLogMsg(fmt.Sprintf("Extractor success, launched MPV: %s", m3u8)) } } -func (m Model) forceMPVLaunch(st Stream) error { - embed := strings.TrimSpace(st.EmbedURL) - if embed == "" { - return fmt.Errorf("no embed URL for stream %s", st.ID) +func (m Model) logToUI(line string) tea.Cmd { + return func() tea.Msg { + return debugLogMsg(line) } - - m.debugLines = append(m.debugLines, fmt.Sprintf("[extractor] fetching %s", embed)) - origin, referer, ua, err := deriveHeaders(embed) - if err != nil { - return fmt.Errorf("bad embed URL: %w", err) - } - - body, err := fetchHTML(embed, ua, origin, referer, 12*time.Second) - if err != nil { - return fmt.Errorf("fetch failed: %w", err) - } - - m3u8 := extractM3U8(body) - m.debugLines = append(m.debugLines, fmt.Sprintf("[extractor] yielded %s", m3u8)) - if m3u8 == "" { - return fmt.Errorf("no .m3u8 found in embed page") - } - - if err := launchMPV(m3u8, ua, origin, referer); err != nil { - return fmt.Errorf("mpv launch failed: %w", err) - } - - return nil } diff --git a/internal/extractor.go b/internal/extractor.go new file mode 100644 index 0000000..22c84b3 --- /dev/null +++ b/internal/extractor.go @@ -0,0 +1,179 @@ +package internal + +import ( + "encoding/base64" + "errors" + "fmt" + "net/http" + "regexp" + "time" +) + +// extractM3U8Lite performs static extraction like yt-dlp, +// logging each step to the provided debug callback (for the F12 panel). +func extractM3U8Lite(embedURL string, log func(string)) (string, error) { + const origin = "https://embedsports.top" + const referer = "https://embedsports.top/" + const ua = "Mozilla/5.0 (X11; Linux x86_64; rv:144.0) Gecko/20100101 Firefox/144.0" + + if log != nil { + log(fmt.Sprintf("[extractor] Fetching embed page: %s", embedURL)) + } + + body, err := fetchHTML(embedURL, ua, origin, referer, 10*time.Second) + if err != nil { + if log != nil { + log(fmt.Sprintf("[extractor] ❌ fetch failed: %v", err)) + } + return "", fmt.Errorf("fetch embed: %w", err) + } + + k, i, s := parseEmbedVars(body) + if log != nil { + log(fmt.Sprintf("[extractor] Parsed vars → k=%q i=%q s=%q", k, i, s)) + } + + if k == "" || i == "" || s == "" { + if log != nil { + log("[extractor] ❌ missing vars (k,i,s) in embed HTML") + } + return "", errors.New("missing vars (k,i,s) in embed HTML") + } + + if log != nil { + log("[extractor] Trying to extract secure token from bundle.js") + } + token, ok := extractTokenFromJS(origin+"/js/bundle.js", ua, origin, referer, log) + if ok { + if log != nil { + log(fmt.Sprintf("[extractor] Found token: %s", token)) + } + for lb := 1; lb <= 9; lb++ { + url := fmt.Sprintf("https://lb%d.strmd.top/secure/%s/%s/stream/%s/%s/playlist.m3u8", + lb, token, k, i, s) + if log != nil { + log(fmt.Sprintf("[extractor] Probing %s", url)) + } + if probeURL(url) { + if log != nil { + log(fmt.Sprintf("[extractor] ✅ Found working m3u8: %s", url)) + } + return url, nil + } + } + if log != nil { + log("[extractor] ⚠️ No working load balancer found with token") + } + } + + if log != nil { + log("[extractor] Fallback: probing default tokens") + } + for lb := 1; lb <= 9; lb++ { + testURL := fmt.Sprintf("https://lb%d.strmd.top/secure/test/%s/stream/%s/%s/playlist.m3u8", + lb, k, i, s) + if log != nil { + log(fmt.Sprintf("[extractor] Probing fallback %s", testURL)) + } + if probeURL(testURL) { + if log != nil { + log(fmt.Sprintf("[extractor] ✅ Fallback succeeded: %s", testURL)) + } + return testURL, nil + } + } + + if log != nil { + log("[extractor] ❌ Unable to derive .m3u8 from embed") + } + return "", errors.New("unable to derive .m3u8") +} + +// extractTokenFromJS is modified to accept a logger callback +func extractTokenFromJS(url, ua, origin, referer string, log func(string)) (string, bool) { + if log != nil { + log(fmt.Sprintf("[extractor] Fetching JS: %s", url)) + } + body, err := fetchHTML(url, ua, origin, referer, 8*time.Second) + if err != nil { + if log != nil { + log(fmt.Sprintf("[extractor] ⚠️ fetch JS failed: %v", err)) + } + return "", false + } + + if m := regexp.MustCompile(`https://lb\d+\.strmd\.top/secure/([A-Za-z0-9]+)/`).FindStringSubmatch(body); len(m) > 1 { + if log != nil { + log("[extractor] Found literal /secure/ token in JS") + } + return m[1], true + } + + if m := regexp.MustCompile(`atob\("([A-Za-z0-9+/=]+)"\)`).FindStringSubmatch(body); len(m) > 1 { + if log != nil { + log("[extractor] Found base64 token pattern, decoding…") + } + decoded, err := base64.StdEncoding.DecodeString(m[1]) + if err == nil { + tok := reverseString(string(decoded)) + if len(tok) > 16 { + if log != nil { + log(fmt.Sprintf("[extractor] Extracted token (decoded+reversed): %s", tok)) + } + return tok, true + } + } + } + + if m := regexp.MustCompile(`"([A-Za-z0-9]{24,})"\.split\(""\)\.reverse`).FindStringSubmatch(body); len(m) > 1 { + if log != nil { + log("[extractor] Found reversed string token") + } + return reverseString(m[1]), true + } + + if log != nil { + log("[extractor] No token found in JS") + } + return "", false +} + +// parseEmbedVars extracts var k, i, s from HTML +func parseEmbedVars(html string) (k, i, s string) { + reK := regexp.MustCompile(`var k\s*=\s*"([^"]+)"`) + reI := regexp.MustCompile(`var i\s*=\s*"([^"]+)"`) + reS := regexp.MustCompile(`var s\s*=\s*"([^"]+)"`) + + if m := reK.FindStringSubmatch(html); len(m) > 1 { + k = m[1] + } + if m := reI.FindStringSubmatch(html); len(m) > 1 { + i = m[1] + } + if m := reS.FindStringSubmatch(html); len(m) > 1 { + s = m[1] + } + return +} + +// probeURL sends a quick HEAD to see if the URL exists +func probeURL(u string) bool { + client := &http.Client{Timeout: 4 * time.Second} + req, _ := http.NewRequest("HEAD", u, nil) + req.Header.Set("User-Agent", "Mozilla/5.0") + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == 200 +} + +// reverseString helper +func reverseString(s string) string { + r := []rune(s) + for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { + r[i], r[j] = r[j], r[i] + } + return string(r) +} \ No newline at end of file