Remove firefox puppeteer fallback
This commit is contained in:
3
go.mod
3
go.mod
@@ -6,8 +6,6 @@ require (
|
|||||||
github.com/charmbracelet/bubbles v0.18.0
|
github.com/charmbracelet/bubbles v0.18.0
|
||||||
github.com/charmbracelet/bubbletea v0.26.6
|
github.com/charmbracelet/bubbletea v0.26.6
|
||||||
github.com/charmbracelet/lipgloss v0.13.0
|
github.com/charmbracelet/lipgloss v0.13.0
|
||||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
|
|
||||||
github.com/chromedp/chromedp v0.14.2
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -16,7 +14,6 @@ require (
|
|||||||
github.com/charmbracelet/x/input v0.1.0 // indirect
|
github.com/charmbracelet/x/input v0.1.0 // indirect
|
||||||
github.com/charmbracelet/x/term v0.1.1 // indirect
|
github.com/charmbracelet/x/term v0.1.1 // indirect
|
||||||
github.com/charmbracelet/x/windows v0.1.0 // 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
|
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
|
||||||
github.com/gobwas/httphead v0.1.0 // indirect
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
|
|||||||
@@ -107,13 +107,13 @@ type Model struct {
|
|||||||
// ENTRY POINT
|
// ENTRY POINT
|
||||||
// ────────────────────────────────
|
// ────────────────────────────────
|
||||||
|
|
||||||
func Run() error {
|
func Run(debug bool) error {
|
||||||
p := tea.NewProgram(New(), tea.WithAltScreen())
|
p := tea.NewProgram(New(debug), tea.WithAltScreen())
|
||||||
_, err := p.Run()
|
_, err := p.Run()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() Model {
|
func New(debug bool) Model {
|
||||||
base := BaseURLFromEnv()
|
base := BaseURLFromEnv()
|
||||||
client := NewClient(base, 15*time.Second)
|
client := NewClient(base, 15*time.Second)
|
||||||
styles := NewStyles()
|
styles := NewStyles()
|
||||||
@@ -128,6 +128,10 @@ func New() Model {
|
|||||||
debugLines: []string{},
|
debugLines: []string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if debug {
|
||||||
|
m.currentView = viewDebug
|
||||||
|
}
|
||||||
|
|
||||||
m.sports = NewListColumn[Sport]("Sports", func(s Sport) string { return s.Name })
|
m.sports = NewListColumn[Sport]("Sports", func(s Sport) string { return s.Name })
|
||||||
m.matches = NewListColumn[Match]("Popular Matches", func(mt Match) string {
|
m.matches = NewListColumn[Match]("Popular Matches", func(mt Match) string {
|
||||||
when := time.UnixMilli(mt.Date).Local().Format("Jan 2 15:04")
|
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) {
|
m3u8, hdrs, err := extractM3U8Lite(st.EmbedURL, func(line string) {
|
||||||
m.debugLines = append(m.debugLines, line)
|
m.debugLines = append(m.debugLines, line)
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ func (c *ListColumn[T]) SetWidth(w int) {
|
|||||||
c.width = w - 4
|
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() {
|
func (c *ListColumn[T]) CursorUp() {
|
||||||
if c.selected > 0 {
|
if c.selected > 0 {
|
||||||
|
|||||||
@@ -1,118 +1,260 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/network"
|
|
||||||
"github.com/chromedp/chromedp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// extractM3U8Lite loads an embed page in headless Chrome via chromedp,
|
type puppeteerResult struct {
|
||||||
// runs any JavaScript, and extracts the final .m3u8 URL and its HTTP headers.
|
URL string `json:"url"`
|
||||||
// It streams live log lines via the provided log callback.
|
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) {
|
func extractM3U8Lite(embedURL string, log func(string)) (string, map[string]string, error) {
|
||||||
if log == nil {
|
if log == nil {
|
||||||
log = func(string) {}
|
log = func(string) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := chromedp.NewContext(context.Background())
|
if strings.TrimSpace(embedURL) == "" {
|
||||||
defer cancel()
|
return "", nil, errors.New("empty embed URL")
|
||||||
|
}
|
||||||
|
|
||||||
// Capture the first .m3u8 request Chrome makes
|
if err := ensurePuppeteerAvailable(); err != nil {
|
||||||
type capture struct {
|
|
||||||
URL string
|
|
||||||
Headers map[string]string
|
|
||||||
}
|
|
||||||
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))
|
|
||||||
return "", nil, err
|
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 {
|
log(fmt.Sprintf("[puppeteer] launching chromium stealth runner for %s", embedURL))
|
||||||
case cap := <-found:
|
|
||||||
log(fmt.Sprintf("[chromedp] ✅ found .m3u8 via network: %s", cap.URL))
|
cmd := exec.Command("node", runnerPath, embedURL)
|
||||||
log(fmt.Sprintf("[chromedp] captured %d headers", len(cap.Headers)))
|
var stdout bytes.Buffer
|
||||||
return cap.URL, cap.Headers, nil
|
var stderr bytes.Buffer
|
||||||
case <-time.After(12 * time.Second):
|
cmd.Stdout = &stdout
|
||||||
log("[chromedp] timeout waiting for .m3u8 request, attempting DOM fallback...")
|
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 <video> src or inline JS with a URL
|
var res puppeteerResult
|
||||||
var candidate string
|
if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
|
||||||
if err := chromedp.Run(ctx,
|
log(fmt.Sprintf("[puppeteer] decode error: %v", err))
|
||||||
chromedp.EvaluateAsDevTools(`(function(){
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePuppeteerRunner materializes a temporary Node.js script that performs
|
||||||
|
// the actual page load and .m3u8 discovery with puppeteer-extra stealth
|
||||||
|
// protections.
|
||||||
|
func writePuppeteerRunner() (string, error) {
|
||||||
|
script := `const { createRequire } = require('module');
|
||||||
|
const requireFromCwd = createRequire(process.cwd() + '/');
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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();
|
||||||
|
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;
|
||||||
|
const capturePromise = new Promise(resolve => {
|
||||||
|
page.on('request', req => {
|
||||||
|
const url = req.url();
|
||||||
|
if (!captured && url.includes('.m3u8')) {
|
||||||
|
const headers = req.headers();
|
||||||
|
captured = { url, headers };
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const v = document.querySelector('video');
|
await page.goto(embedURL, { waitUntil: 'networkidle2', timeout: timeoutMs });
|
||||||
if(v){
|
await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: timeoutMs }).catch(() => {});
|
||||||
if(v.currentSrc) return v.currentSrc;
|
} catch (err) {
|
||||||
if(v.src) return v.src;
|
console.error('[puppeteer] navigation warning: ' + err.message);
|
||||||
const s = v.querySelector('source');
|
}
|
||||||
if(s && s.src) return s.src;
|
|
||||||
|
await Promise.race([
|
||||||
|
capturePromise,
|
||||||
|
new Promise(resolve => setTimeout(resolve, 20000)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!captured) {
|
||||||
|
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 html = document.documentElement.innerHTML;
|
||||||
const match = html.match(/https?:\/\/[^'"\s]+\.m3u8[^'"\s]*/i);
|
const match = html.match(/https?:\/\/[^'"\s]+\.m3u8[^'"\s]*/i);
|
||||||
if(match) return match[0];
|
if (match) return match[0];
|
||||||
}catch(e){}
|
} catch (e) {}
|
||||||
return '';
|
return '';
|
||||||
})()`, &candidate),
|
});
|
||||||
); err != nil {
|
if (candidate && candidate.includes('.m3u8')) {
|
||||||
log(fmt.Sprintf("[chromedp] DOM evaluation error: %v", err))
|
captured = { url: candidate, headers: {} };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate = strings.TrimSpace(candidate)
|
if (captured) {
|
||||||
if candidate != "" && strings.Contains(candidate, ".m3u8") {
|
// Enrich headers with cookies and referer if missing.
|
||||||
log(fmt.Sprintf("[chromedp] ✅ found .m3u8 via DOM: %s", candidate))
|
const cookies = await page.cookies();
|
||||||
return candidate, map[string]string{}, nil
|
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) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
log("[chromedp] ❌ failed to find .m3u8 via network or DOM")
|
await browser.close();
|
||||||
return "", nil, errors.New("m3u8 not found")
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// LaunchMPVWithHeaders spawns mpv to play the given M3U8 URL,
|
// lookupHeaderValue returns the first header value matching name, using a
|
||||||
// reusing all captured HTTP headers to mimic browser playback.
|
// 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).
|
||||||
// Logs are streamed via the provided callback.
|
// Logs are streamed via the provided callback.
|
||||||
func LaunchMPVWithHeaders(m3u8 string, hdrs map[string]string, log func(string)) error {
|
func LaunchMPVWithHeaders(m3u8 string, hdrs map[string]string, log func(string)) error {
|
||||||
if log == nil {
|
if log == nil {
|
||||||
@@ -124,20 +266,27 @@ func LaunchMPVWithHeaders(m3u8 string, hdrs map[string]string, log func(string))
|
|||||||
|
|
||||||
args := []string{"--no-terminal", "--really-quiet"}
|
args := []string{"--no-terminal", "--really-quiet"}
|
||||||
|
|
||||||
for k, v := range hdrs {
|
// Only forward the minimal headers mpv requires to mirror the working
|
||||||
if k == "" || v == "" {
|
// curl→mpv handoff: User-Agent, Origin, and Referer. Extra headers
|
||||||
continue
|
// 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"},
|
||||||
}
|
}
|
||||||
switch strings.ToLower(k) {
|
for _, hk := range headerKeys {
|
||||||
case "accept-encoding", "sec-fetch-site", "sec-fetch-mode", "sec-fetch-dest",
|
if v := lookupHeaderValue(hdrs, hk.lookup); v != "" {
|
||||||
"sec-ch-ua", "sec-ch-ua-platform", "sec-ch-ua-mobile":
|
args = append(args, fmt.Sprintf("--http-header-fields=%s: %s", hk.display, v))
|
||||||
continue // ignore internal Chromium headers
|
|
||||||
}
|
}
|
||||||
args = append(args, fmt.Sprintf("--http-header-fields=%s: %s", k, v))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
args = append(args, m3u8)
|
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", len(args)-3, m3u8))
|
||||||
|
|
||||||
cmd := exec.Command("mpv", args...)
|
cmd := exec.Command("mpv", args...)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
@@ -151,3 +300,37 @@ func LaunchMPVWithHeaders(m3u8 string, hdrs map[string]string, log func(string))
|
|||||||
log(fmt.Sprintf("[mpv] started (pid %d)", cmd.Process.Pid))
|
log(fmt.Sprintf("[mpv] started (pid %d)", cmd.Process.Pid))
|
||||||
return nil
|
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); err != nil {
|
||||||
|
fmt.Printf("[mpv] ❌ %v\n", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("[mpv] ▶ streaming started")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
15
main.go
15
main.go
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
@@ -8,7 +9,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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)
|
log.Println("error:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user