Complete Extractor

This commit is contained in:
Salastil
2025-11-22 23:11:12 -05:00
committed by GitHub
7 changed files with 539 additions and 229 deletions

15
go.mod
View File

@@ -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

View File

@@ -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)
@@ -467,7 +471,7 @@ func (m Model) runExtractor(st Stream) tea.Cmd {
logcb(fmt.Sprintf("[extractor] Captured %d headers", len(hdrs)))
}
if err := LaunchMPVWithHeaders(m3u8, hdrs, logcb); err != nil {
if err := LaunchMPVWithHeaders(m3u8, hdrs, logcb, false); err != nil {
logcb(fmt.Sprintf("[mpv] ❌ %v", err))
return debugLogMsg(fmt.Sprintf("MPV error: %v", err))
}

14
internal/browser.go Normal file
View File

@@ -0,0 +1,14 @@
package internal
import (
"errors"
"os/exec"
)
// openBrowser tries to open the embed URL in the system browser.
func openBrowser(link string) error {
if link == "" {
return errors.New("empty URL")
}
return exec.Command("xdg-open", link).Start()
}

View File

@@ -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)
}
}

View File

@@ -1,120 +1,422 @@
package internal
import (
"context"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"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"`
}
type logBuffer struct {
buf *bytes.Buffer
log func(string)
prefix string
}
// findNodeModuleBase attempts to locate a directory containing the required
// Puppeteer dependencies, starting from the current working directory and the
// executable's directory, walking up parent paths until a node_modules match is
// found. This allows the binary to resolve Node packages even when launched via
// a .desktop file or from another directory.
func findNodeModuleBase() (string, error) {
starts := []string{}
if wd, err := os.Getwd(); err == nil {
starts = append(starts, wd)
}
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
if exeDir != "" {
starts = append(starts, exeDir)
}
}
seen := map[string]struct{}{}
for _, start := range starts {
dir := filepath.Clean(start)
for {
if _, ok := seen[dir]; ok {
break
}
seen[dir] = struct{}{}
if dir == "" || dir == string(filepath.Separator) {
break
}
candidate := filepath.Join(dir, "node_modules", "puppeteer-extra", "package.json")
if _, err := os.Stat(candidate); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
}
return "", errors.New("puppeteer-extra not found; install dependencies with npm in the project directory")
}
func (l *logBuffer) Write(p []byte) (int, error) {
if l.buf == nil {
l.buf = &bytes.Buffer{}
}
n, err := l.buf.Write(p)
if l.log != nil {
for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
l.log(l.prefix + trimmed)
}
}
return n, err
}
func (l *logBuffer) Bytes() []byte {
if l.buf == nil {
l.buf = &bytes.Buffer{}
}
return l.buf.Bytes()
}
func (l *logBuffer) String() string {
return string(l.Bytes())
}
func (l *logBuffer) Len() int {
return len(l.Bytes())
}
func (l *logBuffer) WriteTo(w io.Writer) (int64, error) {
if l.buf == nil {
return 0, nil
}
return l.buf.WriteTo(w)
}
func ensurePuppeteerAvailable(baseDir string) 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
// discovered base directory so the temporary runner can load them reliably
// even when the binary is launched outside the repo (e.g., .desktop file).
requireScript := strings.Join([]string{
"const { createRequire } = require('module');",
"const base = process.env.STREAMED_TUI_NODE_BASE || process.cwd();",
"const req = createRequire(base.endsWith('/') ? base : base + '/');",
"req.resolve('puppeteer-extra/package.json');",
"req.resolve('puppeteer-extra-plugin-stealth/package.json');",
}, "")
check := exec.Command("node", "-e", requireScript)
check.Dir = baseDir
check.Env = append(os.Environ(), fmt.Sprintf("STREAMED_TUI_NODE_BASE=%s", baseDir))
if err := check.Run(); err != nil {
return fmt.Errorf("puppeteer-extra or stealth plugin missing in %s. Run `npm install puppeteer-extra puppeteer-extra-plugin-stealth puppeteer` there: %w", baseDir, 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))
baseDir, err := findNodeModuleBase()
if err != nil {
return "", nil, err
}
log("[chromedp] page loaded, waiting for .m3u8 network requests...")
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...")
if err := ensurePuppeteerAvailable(baseDir); err != nil {
return "", nil, err
}
// DOM fallback: look for <video> src or inline JS with a URL
var candidate string
if err := chromedp.Run(ctx,
chromedp.EvaluateAsDevTools(`(function(){
try {
const v = document.querySelector('video');
if(v){
if(v.currentSrc) return v.currentSrc;
if(v.src) return v.src;
const s = v.querySelector('source');
if(s && s.src) return s.src;
}
const html = document.documentElement.innerHTML;
const match = html.match(/https?:\/\/[^'"\s]+\.m3u8[^'"\s]*/i);
if(match) return match[0];
}catch(e){}
return '';
})()`, &candidate),
); err != nil {
log(fmt.Sprintf("[chromedp] DOM evaluation error: %v", err))
runnerPath, err := writePuppeteerRunner(baseDir)
if err != nil {
return "", nil, err
}
defer os.Remove(runnerPath)
log(fmt.Sprintf("[puppeteer] launching chromium stealth runner for %s", embedURL))
cmd := exec.Command("node", runnerPath, embedURL)
cmd.Dir = baseDir
cmd.Env = append(os.Environ(), fmt.Sprintf("STREAMED_TUI_NODE_BASE=%s", baseDir))
stdout := &logBuffer{buf: &bytes.Buffer{}, log: func(line string) { log(line) }, prefix: "[puppeteer stdout] "}
stderr := &logBuffer{buf: &bytes.Buffer{}, log: func(line string) { log(line) }, prefix: "[puppeteer stderr] "}
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)
}
candidate = strings.TrimSpace(candidate)
if candidate != "" && strings.Contains(candidate, ".m3u8") {
log(fmt.Sprintf("[chromedp] ✅ found .m3u8 via DOM: %s", candidate))
return candidate, map[string]string{}, nil
var res puppeteerResult
if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
log(fmt.Sprintf("[puppeteer] decode error: %v", err))
return "", nil, err
}
log("[chromedp] ❌ failed to find .m3u8 via network or DOM")
return "", nil, errors.New("m3u8 not found")
if res.URL == "" {
if stderr.Len() > 0 {
log(strings.TrimSpace(stderr.String()))
}
return "", nil, errors.New("m3u8 not found")
}
log(fmt.Sprintf("[puppeteer] ✅ found .m3u8 via %s: %s", res.Browser, res.URL))
return res.URL, res.Headers, nil
}
// LaunchMPVWithHeaders spawns mpv to play the given M3U8 URL,
// reusing all captured HTTP headers to mimic browser playback.
// Logs are streamed via the provided callback.
func LaunchMPVWithHeaders(m3u8 string, hdrs map[string]string, log func(string)) error {
// writePuppeteerRunner materializes a temporary Node.js script that performs
// the actual page load and .m3u8 discovery with puppeteer-extra stealth
// protections.
func writePuppeteerRunner(baseDir string) (string, error) {
script := `const { createRequire } = require('module');
const base = process.env.STREAMED_TUI_NODE_BASE || process.cwd();
const requireFromCwd = createRequire(base.endsWith('/') ? base : base + '/');
let puppeteer;
let StealthPlugin;
try {
puppeteer = requireFromCwd('puppeteer-extra');
StealthPlugin = requireFromCwd('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
} catch (err) {
console.error('[puppeteer] required packages missing. install with "npm install puppeteer-extra puppeteer-extra-plugin-stealth puppeteer" in the project directory.');
process.exit(1);
}
const embedURL = process.argv[2];
const timeoutMs = 45000;
const log = (...args) => console.error(...args);
if (!embedURL) {
console.error('missing embed URL');
process.exit(1);
}
const viewport = { width: 1280, height: 720 };
const launchArgs = ['--disable-blink-features=AutomationControlled', '--no-sandbox', '--disable-web-security', '--window-size=1920,1080'];
const userAgent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
async function launchBrowser() {
const chromiumOptions = {
headless: 'new',
args: launchArgs,
defaultViewport: viewport,
};
const browser = await puppeteer.launch(chromiumOptions);
return { browser, flavor: 'chromium' };
}
function installTouchAndWindowSpoofing(page) {
return page.evaluateOnNewDocument(() => {
const { width, height } = window.screen || { width: 1920, height: 1080 };
Object.defineProperty(navigator, 'maxTouchPoints', { get: () => 1 });
Object.defineProperty(navigator, 'platform', { get: () => 'Linux x86_64' });
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
Object.defineProperty(window, 'outerWidth', { get: () => width });
Object.defineProperty(window, 'outerHeight', { get: () => height });
});
}
(async () => {
const { browser, flavor } = await launchBrowser();
log('[puppeteer] launched ' + flavor + ' (headless new)');
const page = await browser.newPage();
await installTouchAndWindowSpoofing(page);
await page.setUserAgent(userAgent);
await page.setViewport(viewport);
await page.setExtraHTTPHeaders({
'accept-language': 'en-US,en;q=0.9',
'sec-fetch-site': 'same-origin',
'sec-fetch-mode': 'navigate',
'sec-fetch-user': '?1',
'sec-fetch-dest': 'document',
'sec-ch-ua': '"Chromium";v="124", "Not=A?Brand";v="99", "Google Chrome";v="124"',
'sec-ch-ua-platform': 'Linux',
'sec-ch-ua-mobile': '?0',
});
let captured = null;
let resolveCapture;
const capturePromise = new Promise(resolve => {
resolveCapture = resolve;
});
function findNestedPlaylist(body, baseUrl) {
if (!body) return '';
const lines = body.split(/\r?\n/);
for (const rawLine of lines) {
const line = (rawLine || '').trim();
if (!line || line.startsWith('#')) continue;
if (line.toLowerCase().includes('.m3u8')) {
try {
return new URL(line, baseUrl).toString();
} catch (_) {
return line;
}
}
}
return '';
}
async function handleM3U8Response(res) {
const url = res.url();
const headers = res.request().headers();
let body = '';
try {
body = await res.text();
} catch (err) {
log('[puppeteer] failed to read m3u8 body for ' + url + ': ' + err.message);
}
const hasExtinf = body && body.includes('#EXTINF');
const nested = findNestedPlaylist(body, url);
let finalUrl = url;
let reason = 'first seen';
if (hasExtinf) {
reason = 'contains #EXTINF segments';
} else if (nested) {
finalUrl = nested;
reason = 'nested m3u8 discovered in response body';
}
if (!captured || hasExtinf) {
captured = { url: finalUrl, headers, hasExtinf };
log('[puppeteer] captured .m3u8 (' + reason + '): ' + finalUrl);
if (resolveCapture) resolveCapture();
}
}
page.on('response', res => {
if (!res.url().includes('.m3u8')) return;
handleM3U8Response(res);
});
try {
log('[puppeteer] navigating to ' + embedURL);
await page.goto(embedURL, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
log('[puppeteer] primary navigation reached domcontentloaded');
} catch (err) {
console.error('[puppeteer] navigation warning: ' + err.message);
}
await Promise.race([
capturePromise,
new Promise(resolve => setTimeout(resolve, 20000)),
]);
if (!captured) {
log('[puppeteer] no .m3u8 request observed, scanning DOM for fallback');
const candidate = await page.evaluate(() => {
try {
const video = document.querySelector('video');
if (video) {
if (video.currentSrc) return video.currentSrc;
if (video.src) return video.src;
const source = video.querySelector('source');
if (source && source.src) return source.src;
}
const html = document.documentElement.innerHTML;
const match = html.match(/https?:\/\/[^'"\s]+\.m3u8[^'"\s]*/i);
if (match) return match[0];
} catch (e) {}
return '';
});
if (candidate && candidate.includes('.m3u8')) {
captured = { url: candidate, headers: {} };
}
}
if (captured) {
// Enrich headers with cookies and referer if missing.
const cookies = await page.cookies();
log('[puppeteer] collected ' + cookies.length + ' cookies during session');
if (cookies && cookies.length > 0) {
const cookieHeader = cookies.map(c => c.name + '=' + c.value).join('; ');
if (!captured.headers) captured.headers = {};
captured.headers['cookie'] = captured.headers['cookie'] || cookieHeader;
}
captured.headers = captured.headers || {};
captured.headers['user-agent'] = userAgent;
captured.headers['referer'] = captured.headers['referer'] || embedURL;
try {
const origin = new URL(embedURL).origin;
captured.headers['origin'] = captured.headers['origin'] || origin;
} catch (e) {}
}
await browser.close();
const output = captured || { url: '', headers: {} };
output.browser = flavor;
console.log(JSON.stringify(output));
})().catch(err => {
console.error(err.stack || err.message);
process.exit(1);
});
`
dir := os.TempDir()
path := filepath.Join(dir, fmt.Sprintf("puppeteer-runner-%d.js", time.Now().UnixNano()))
if err := os.WriteFile(path, []byte(script), 0o600); err != nil {
return "", err
}
return path, nil
}
// lookupHeaderValue returns the first header value matching name, using a
// case-insensitive comparison for keys sourced from the Puppeteer request map.
func lookupHeaderValue(hdrs map[string]string, name string) string {
for k, v := range hdrs {
if strings.EqualFold(k, name) {
return v
}
}
return ""
}
// LaunchMPVWithHeaders spawns mpv to play the given M3U8 URL using the minimal
// header set required for successful playback (User-Agent, Origin, Referer).
// When attachOutput is true, mpv stays attached to the current terminal and the
// call blocks until the player exits; otherwise mpv is started quietly and
// detached so closing the terminal will not terminate playback. Logs are
// streamed via the provided callback.
func LaunchMPVWithHeaders(m3u8 string, hdrs map[string]string, log func(string), attachOutput bool) error {
if log == nil {
log = func(string) {}
}
@@ -122,32 +424,102 @@ func LaunchMPVWithHeaders(m3u8 string, hdrs map[string]string, log func(string))
return fmt.Errorf("empty m3u8 URL")
}
args := []string{"--no-terminal", "--really-quiet"}
args := []string{}
if !attachOutput {
args = append(args, "--no-terminal", "--really-quiet")
}
for k, v := range hdrs {
if k == "" || v == "" {
continue
// Only forward the minimal headers mpv requires to mirror the working
// curl→mpv handoff: User-Agent, Origin, and Referer. Extra headers
// captured in the browser session can cause mpv to reject the request
// or send malformed values when duplicated, so we constrain the set
// explicitly and tolerate case-insensitive keys from Puppeteer.
headerKeys := []struct {
lookup string
display string
}{
{lookup: "user-agent", display: "User-Agent"},
{lookup: "origin", display: "Origin"},
{lookup: "referer", display: "Referer"},
}
headerCount := 0
for _, hk := range headerKeys {
if v := lookupHeaderValue(hdrs, hk.lookup); v != "" {
args = append(args, fmt.Sprintf("--http-header-fields=%s: %s", hk.display, v))
headerCount++
}
switch strings.ToLower(k) {
case "accept-encoding", "sec-fetch-site", "sec-fetch-mode", "sec-fetch-dest",
"sec-ch-ua", "sec-ch-ua-platform", "sec-ch-ua-mobile":
continue // ignore internal Chromium headers
}
args = append(args, fmt.Sprintf("--http-header-fields=%s: %s", k, v))
}
args = append(args, m3u8)
log(fmt.Sprintf("[mpv] launching with %d headers: %s", len(hdrs), m3u8))
log(fmt.Sprintf("[mpv] launching with %d headers: %s", headerCount, m3u8))
cmd := exec.Command("mpv", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if attachOutput {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
} else {
// Detach from the current terminal so closing it will not send
// SIGHUP to mpv. Discard stdio to avoid keeping the tty open.
devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("open devnull: %w", err)
}
cmd.Stdin = devNull
cmd.Stdout = devNull
cmd.Stderr = devNull
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}
if err := cmd.Start(); err != nil {
log(fmt.Sprintf("[mpv] launch error: %v", err))
return err
}
if attachOutput {
log("[mpv] started (attached)")
if err := cmd.Wait(); err != nil {
log(fmt.Sprintf("[mpv] exited with error: %v", err))
return err
}
log("[mpv] exited")
return nil
}
log(fmt.Sprintf("[mpv] started (pid %d)", cmd.Process.Pid))
return nil
}
// RunExtractorCLI provides a non-TUI entry point to run the extractor directly
// from the command line ("-e <embedURL>"). When debug is true, verbose output
// from the Puppeteer runner and mpv launch is printed to stdout.
func RunExtractorCLI(embedURL string, debug bool) error {
if strings.TrimSpace(embedURL) == "" {
return errors.New("missing embed URL")
}
logger := func(string) {}
if debug {
logger = func(line string) { fmt.Println(line) }
}
fmt.Printf("[extractor] starting for %s\n", embedURL)
m3u8, hdrs, err := extractM3U8Lite(embedURL, logger)
if err != nil {
fmt.Printf("[extractor] ❌ %v\n", err)
return err
}
fmt.Printf("[extractor] ✅ found M3U8: %s\n", m3u8)
if len(hdrs) > 0 && debug {
fmt.Printf("[extractor] captured %d headers\n", len(hdrs))
}
if err := LaunchMPVWithHeaders(m3u8, hdrs, logger, false); err != nil {
fmt.Printf("[mpv] ❌ %v\n", err)
return err
}
fmt.Println("[mpv] ▶ streaming started (detached)")
return nil
}

View File

@@ -1,94 +0,0 @@
package internal
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os/exec"
"regexp"
"strings"
"time"
)
// openBrowser tries to open the embed URL in the system browser.
func openBrowser(link string) error {
if link == "" {
return errors.New("empty URL")
}
return exec.Command("xdg-open", link).Start()
}
// deriveHeaders guesses Origin, Referer, and User-Agent based on known embed domains.
func deriveHeaders(embed string) (origin, referer, ua string, err error) {
if embed == "" {
return "", "", "", errors.New("empty embed url")
}
u, err := url.Parse(embed)
if err != nil {
return "", "", "", fmt.Errorf("parse url: %w", err)
}
host := u.Host
if strings.Contains(host, "embedsports") {
origin = "https://embedsports.top"
referer = "https://embedsports.top/"
} else {
origin = fmt.Sprintf("https://%s", host)
referer = fmt.Sprintf("https://%s/", host)
}
ua = "Mozilla/5.0 (X11; Linux x86_64; rv:144.0) Gecko/20100101 Firefox/144.0"
return origin, referer, ua, nil
}
// fetchHTML performs a GET request with proper headers and returns body text.
func fetchHTML(embed, ua, origin, referer string, timeout time.Duration) (string, error) {
client := &http.Client{Timeout: timeout}
req, err := http.NewRequest("GET", embed, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", ua)
req.Header.Set("Origin", origin)
req.Header.Set("Referer", referer)
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}
// extractM3U8 uses regex to find an .m3u8 playlist link from an embed page.
func extractM3U8(html string) string {
re := regexp.MustCompile(`https?://[^\s'"]+\.m3u8[^\s'"]*`)
m := re.FindString(html)
return strings.TrimSpace(m)
}
// launchMPV executes mpv with all the necessary HTTP headers.
func launchMPV(m3u8, ua, origin, referer string) error {
args := []string{
"--no-terminal",
"--really-quiet",
fmt.Sprintf(`--http-header-fields=User-Agent: %s`, ua),
fmt.Sprintf(`--http-header-fields=Origin: %s`, origin),
fmt.Sprintf(`--http-header-fields=Referer: %s`, referer),
m3u8,
}
cmd := exec.Command("mpv", args...)
return cmd.Start()
}

15
main.go
View File

@@ -1,6 +1,7 @@
package main
import (
"flag"
"log"
"os"
@@ -8,7 +9,19 @@ import (
)
func main() {
if err := internal.Run(); err != nil {
embedURL := flag.String("e", "", "extract a single embed URL and launch mpv")
debug := flag.Bool("debug", false, "enable verbose extractor/debug output")
flag.Parse()
if *embedURL != "" {
if err := internal.RunExtractorCLI(*embedURL, *debug); err != nil {
log.Println("error:", err)
os.Exit(1)
}
return
}
if err := internal.Run(*debug); err != nil {
log.Println("error:", err)
os.Exit(1)
}