diff --git a/go.mod b/go.mod index 9d046a6..f39f0bd 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,9 @@ module github.com/Salastil/streamed-tui go 1.24 require ( - github.com/charmbracelet/bubbles v0.18.0 - github.com/charmbracelet/bubbletea v0.26.6 - github.com/charmbracelet/lipgloss v0.13.0 - github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d - github.com/chromedp/chromedp v0.14.2 + github.com/charmbracelet/bubbles v0.18.0 + github.com/charmbracelet/bubbletea v0.26.6 + github.com/charmbracelet/lipgloss v0.13.0 ) require ( @@ -16,10 +14,9 @@ require ( github.com/charmbracelet/x/input v0.1.0 // indirect github.com/charmbracelet/x/term v0.1.1 // indirect github.com/charmbracelet/x/windows v0.1.0 // indirect - github.com/chromedp/sysutil v1.1.0 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect - github.com/gobwas/httphead v0.1.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect + github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect diff --git a/internal/app.go b/internal/app.go index 9869019..f4acced 100644 --- a/internal/app.go +++ b/internal/app.go @@ -107,13 +107,13 @@ type Model struct { // ENTRY POINT // ──────────────────────────────── -func Run() error { - p := tea.NewProgram(New(), tea.WithAltScreen()) +func Run(debug bool) error { + p := tea.NewProgram(New(debug), tea.WithAltScreen()) _, err := p.Run() return err } -func New() Model { +func New(debug bool) Model { base := BaseURLFromEnv() client := NewClient(base, 15*time.Second) styles := NewStyles() @@ -128,6 +128,10 @@ func New() Model { debugLines: []string{}, } + if debug { + m.currentView = viewDebug + } + m.sports = NewListColumn[Sport]("Sports", func(s Sport) string { return s.Name }) m.matches = NewListColumn[Match]("Popular Matches", func(mt Match) string { when := time.UnixMilli(mt.Date).Local().Format("Jan 2 15:04") @@ -452,7 +456,7 @@ func (m Model) runExtractor(st Stream) tea.Cmd { } } - logcb(fmt.Sprintf("[extractor] Starting Chrome-based extractor for %s", st.EmbedURL)) + logcb(fmt.Sprintf("[extractor] Starting puppeteer extractor for %s", st.EmbedURL)) m3u8, hdrs, err := extractM3U8Lite(st.EmbedURL, func(line string) { m.debugLines = append(m.debugLines, line) diff --git a/internal/columns.go b/internal/columns.go index 95960ea..a4032dd 100644 --- a/internal/columns.go +++ b/internal/columns.go @@ -22,8 +22,8 @@ type Styles struct { func NewStyles() Styles { border := lipgloss.RoundedBorder() return Styles{ - Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")), - Box: lipgloss.NewStyle().Border(border).Padding(0, 1).MarginRight(1), + Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")), + Box: lipgloss.NewStyle().Border(border).Padding(0, 1).MarginRight(1), Active: lipgloss.NewStyle(). Border(border). BorderForeground(lipgloss.Color("#FA8072")). // Not pink, its Salmon obviously @@ -71,7 +71,11 @@ func (c *ListColumn[T]) SetWidth(w int) { c.width = w - 4 } -func (c *ListColumn[T]) SetHeight(h int) { if h > 5 { c.height = h - 5 } } +func (c *ListColumn[T]) SetHeight(h int) { + if h > 5 { + c.height = h - 5 + } +} func (c *ListColumn[T]) CursorUp() { if c.selected > 0 { @@ -116,22 +120,22 @@ func (c *ListColumn[T]) View(styles Styles, focused bool) string { if end > len(c.items) { end = len(c.items) } - for i := start; i < end; i++ { - cursor := " " - lineText := c.render(c.items[i]) - if i == c.selected { - cursor = "▸ " - lineText = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FA8072")). // Not pink, its Salmon obviously - Bold(true). - Render(lineText) + for i := start; i < end; i++ { + cursor := " " + lineText := c.render(c.items[i]) + if i == c.selected { + cursor = "▸ " + lineText = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FA8072")). // Not pink, its Salmon obviously + Bold(true). + Render(lineText) + } + line := fmt.Sprintf("%s%s", cursor, lineText) + if len(line) > c.width && c.width > 3 { + line = line[:c.width-3] + "…" + } + lines = append(lines, line) } - line := fmt.Sprintf("%s%s", cursor, lineText) - if len(line) > c.width && c.width > 3 { - line = line[:c.width-3] + "…" - } - lines = append(lines, line) - } } // Fill remaining lines if fewer than height @@ -142,4 +146,4 @@ func (c *ListColumn[T]) View(styles Styles, focused bool) string { content := strings.Join(lines, "\n") // IMPORTANT: width = interior content width + 4 (border+padding) return box.Width(c.width + 4).Render(head + "\n" + content) -} \ No newline at end of file +} diff --git a/internal/extractor.go b/internal/extractor.go index e3b174c..47f0cba 100644 --- a/internal/extractor.go +++ b/internal/extractor.go @@ -1,118 +1,260 @@ package internal import ( - "context" + "bytes" + "encoding/json" "errors" "fmt" "os" "os/exec" + "path/filepath" "strings" "time" - - "github.com/chromedp/cdproto/network" - "github.com/chromedp/chromedp" ) -// extractM3U8Lite loads an embed page in headless Chrome via chromedp, -// runs any JavaScript, and extracts the final .m3u8 URL and its HTTP headers. -// It streams live log lines via the provided log callback. +type puppeteerResult struct { + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Browser string `json:"browser"` +} + +func ensurePuppeteerAvailable() error { + if _, err := exec.LookPath("node"); err != nil { + return fmt.Errorf("node executable not found: %w", err) + } + + // Verify both puppeteer-extra and the stealth plugin are available from the + // project directory so the temporary runner can load them relative to cwd. + requireScript := strings.Join([]string{ + "const { createRequire } = require('module');", + "const req = createRequire(process.cwd() + '/');", + "req.resolve('puppeteer-extra/package.json');", + "req.resolve('puppeteer-extra-plugin-stealth/package.json');", + }, "") + + check := exec.Command("node", "-e", requireScript) + if err := check.Run(); err != nil { + return fmt.Errorf("puppeteer-extra or stealth plugin missing. Run `npm install puppeteer-extra puppeteer-extra-plugin-stealth puppeteer` in the project directory: %w", err) + } + + return nil +} + +// extractM3U8Lite invokes a small Puppeteer runner that loads the embed page, +// watches for .m3u8 requests, and returns the first match plus its request +// headers. func extractM3U8Lite(embedURL string, log func(string)) (string, map[string]string, error) { if log == nil { log = func(string) {} } - ctx, cancel := chromedp.NewContext(context.Background()) - defer cancel() - - // Capture the first .m3u8 request Chrome makes - type capture struct { - URL string - Headers map[string]string + if strings.TrimSpace(embedURL) == "" { + return "", nil, errors.New("empty embed URL") } - found := make(chan capture, 1) - chromedp.ListenTarget(ctx, func(ev interface{}) { - if e, ok := ev.(*network.EventRequestWillBeSent); ok { - u := e.Request.URL - if strings.Contains(u, ".m3u8") { - h := make(map[string]string) - for k, v := range e.Request.Headers { - if s, ok := v.(string); ok { - h[k] = s - } - } - select { - case found <- capture{URL: u, Headers: h}: - default: - } - } - } - }) - - log(fmt.Sprintf("[chromedp] launching Chrome for %s", embedURL)) - - // Set reasonable headers for navigation - headers := network.Headers{ - "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/144.0", - } - ctxTimeout, cancelNav := context.WithTimeout(ctx, 30*time.Second) - defer cancelNav() - - if err := chromedp.Run(ctxTimeout, - network.Enable(), - network.SetExtraHTTPHeaders(headers), - chromedp.Navigate(embedURL), - chromedp.WaitReady("body", chromedp.ByQuery), - ); err != nil { - log(fmt.Sprintf("[chromedp] navigation error: %v", err)) + if err := ensurePuppeteerAvailable(); err != nil { return "", nil, err } - log("[chromedp] page loaded, waiting for .m3u8 network requests...") + runnerPath, err := writePuppeteerRunner() + if err != nil { + return "", nil, err + } + defer os.Remove(runnerPath) - select { - case cap := <-found: - log(fmt.Sprintf("[chromedp] ✅ found .m3u8 via network: %s", cap.URL)) - log(fmt.Sprintf("[chromedp] captured %d headers", len(cap.Headers))) - return cap.URL, cap.Headers, nil - case <-time.After(12 * time.Second): - log("[chromedp] timeout waiting for .m3u8 request, attempting DOM fallback...") + log(fmt.Sprintf("[puppeteer] launching chromium stealth runner for %s", embedURL)) + + cmd := exec.Command("node", runnerPath, embedURL) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + log(fmt.Sprintf("[puppeteer] runner error: %s", strings.TrimSpace(stderr.String()))) + return "", nil, fmt.Errorf("puppeteer runner failed: %w", err) } - // DOM fallback: look for