Initial Exctractor work
This commit is contained in:
11
go.mod
11
go.mod
@@ -1,11 +1,13 @@
|
|||||||
module github.com/Salastil/streamed-tui
|
module github.com/Salastil/streamed-tui
|
||||||
|
|
||||||
go 1.22
|
go 1.24
|
||||||
|
|
||||||
require (
|
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 (
|
||||||
@@ -14,7 +16,12 @@ 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/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
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
@@ -25,6 +32,6 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sync v0.7.0 // indirect
|
golang.org/x/sync v0.7.0 // indirect
|
||||||
golang.org/x/sys v0.21.0 // indirect
|
golang.org/x/sys v0.34.0 // indirect
|
||||||
golang.org/x/text v0.3.8 // indirect
|
golang.org/x/text v0.3.8 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package internal
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,6 +12,10 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ────────────────────────────────
|
||||||
|
// KEYMAP
|
||||||
|
// ────────────────────────────────
|
||||||
|
|
||||||
type keyMap struct {
|
type keyMap struct {
|
||||||
Up, Down, Left, Right key.Binding
|
Up, Down, Left, Right key.Binding
|
||||||
Enter, Quit, Refresh key.Binding
|
Enter, Quit, Refresh key.Binding
|
||||||
@@ -40,6 +42,7 @@ func defaultKeys() keyMap {
|
|||||||
func (k keyMap) ShortHelp() []key.Binding {
|
func (k keyMap) ShortHelp() []key.Binding {
|
||||||
return []key.Binding{k.Up, k.Down, k.Left, k.Right, k.Enter, k.OpenBrowser, k.OpenMPV, k.Quit}
|
return []key.Binding{k.Up, k.Down, k.Left, k.Right, k.Enter, k.OpenBrowser, k.OpenMPV, k.Quit}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k keyMap) FullHelp() [][]key.Binding {
|
func (k keyMap) FullHelp() [][]key.Binding {
|
||||||
return [][]key.Binding{
|
return [][]key.Binding{
|
||||||
{k.Up, k.Down, k.Left, k.Right},
|
{k.Up, k.Down, k.Left, k.Right},
|
||||||
@@ -47,6 +50,10 @@ func (k keyMap) FullHelp() [][]key.Binding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────
|
||||||
|
// TYPES & CONSTANTS
|
||||||
|
// ────────────────────────────────
|
||||||
|
|
||||||
type (
|
type (
|
||||||
sportsLoadedMsg []Sport
|
sportsLoadedMsg []Sport
|
||||||
matchesLoadedMsg struct {
|
matchesLoadedMsg struct {
|
||||||
@@ -74,6 +81,10 @@ const (
|
|||||||
viewDebug
|
viewDebug
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ────────────────────────────────
|
||||||
|
// MODEL
|
||||||
|
// ────────────────────────────────
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
apiClient *Client
|
apiClient *Client
|
||||||
styles Styles
|
styles Styles
|
||||||
@@ -92,6 +103,10 @@ type Model struct {
|
|||||||
TerminalWidth int
|
TerminalWidth int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────
|
||||||
|
// ENTRY POINT
|
||||||
|
// ────────────────────────────────
|
||||||
|
|
||||||
func Run() error {
|
func Run() error {
|
||||||
p := tea.NewProgram(New(), tea.WithAltScreen())
|
p := tea.NewProgram(New(), tea.WithAltScreen())
|
||||||
_, err := p.Run()
|
_, err := p.Run()
|
||||||
@@ -134,6 +149,10 @@ func New() Model {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────
|
||||||
|
// VIEW MANAGEMENT
|
||||||
|
// ────────────────────────────────
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
return tea.Batch(m.fetchSports(), m.fetchPopularMatches())
|
return tea.Batch(m.fetchSports(), m.fetchPopularMatches())
|
||||||
}
|
}
|
||||||
@@ -212,6 +231,10 @@ func (m Model) renderDebugPanel() string {
|
|||||||
return panel
|
return panel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────
|
||||||
|
// UPDATE LOOP
|
||||||
|
// ────────────────────────────────
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
|
||||||
@@ -368,6 +391,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────
|
||||||
|
// FETCHERS
|
||||||
|
// ────────────────────────────────
|
||||||
|
|
||||||
func (m Model) fetchSports() tea.Cmd {
|
func (m Model) fetchSports() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
sports, err := m.apiClient.GetSports(context.Background())
|
sports, err := m.apiClient.GetSports(context.Background())
|
||||||
@@ -408,33 +435,52 @@ func (m Model) fetchStreamsForMatch(mt Match) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────
|
||||||
|
// EXTRACTOR (chromedp integration)
|
||||||
|
// ────────────────────────────────
|
||||||
|
|
||||||
func (m Model) runExtractor(st Stream) tea.Cmd {
|
func (m Model) runExtractor(st Stream) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if st.EmbedURL == "" {
|
if st.EmbedURL == "" {
|
||||||
return debugLogMsg("Extractor aborted: empty embed URL")
|
return debugLogMsg("Extractor aborted: empty embed URL")
|
||||||
}
|
}
|
||||||
m3u8, err := extractM3U8Lite(st.EmbedURL, func(line string) {
|
|
||||||
// each extractor log line will flow back to the UI
|
logcb := func(line string) {
|
||||||
return
|
m.debugLines = append(m.debugLines, line)
|
||||||
|
if len(m.debugLines) > 200 {
|
||||||
|
m.debugLines = m.debugLines[len(m.debugLines)-200:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logcb(fmt.Sprintf("[extractor] Starting Chrome-based extractor for %s", st.EmbedURL))
|
||||||
|
|
||||||
|
m3u8, hdrs, err := extractM3U8Lite(st.EmbedURL, func(line string) {
|
||||||
|
m.debugLines = append(m.debugLines, line)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logcb(fmt.Sprintf("[extractor] ❌ %v", err))
|
||||||
return debugLogMsg(fmt.Sprintf("Extractor failed: %v", err))
|
return debugLogMsg(fmt.Sprintf("Extractor failed: %v", err))
|
||||||
}
|
}
|
||||||
cmd := exec.Command("mpv",
|
|
||||||
"--no-terminal",
|
logcb(fmt.Sprintf("[extractor] ✅ Found M3U8: %s", m3u8))
|
||||||
"--really-quiet",
|
if len(hdrs) > 0 {
|
||||||
fmt.Sprintf("--http-header-fields=User-Agent: %s", "Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/144.0"),
|
logcb(fmt.Sprintf("[extractor] Captured %d headers", len(hdrs)))
|
||||||
fmt.Sprintf("--http-header-fields=Origin: %s", "https://embedsports.top"),
|
}
|
||||||
fmt.Sprintf("--http-header-fields=Referer: %s", "https://embedsports.top/"),
|
|
||||||
m3u8,
|
if err := LaunchMPVWithHeaders(m3u8, hdrs, logcb); err != nil {
|
||||||
)
|
logcb(fmt.Sprintf("[mpv] ❌ %v", err))
|
||||||
cmd.Stdout = os.Stdout
|
return debugLogMsg(fmt.Sprintf("MPV error: %v", err))
|
||||||
cmd.Stderr = os.Stderr
|
}
|
||||||
_ = cmd.Start()
|
|
||||||
return debugLogMsg(fmt.Sprintf("Extractor success, launched MPV: %s", m3u8))
|
logcb(fmt.Sprintf("[mpv] ▶ Streaming started for %s", st.EmbedURL))
|
||||||
|
return debugLogMsg("Extractor completed successfully")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────
|
||||||
|
// LOG TO UI
|
||||||
|
// ────────────────────────────────
|
||||||
|
|
||||||
func (m Model) logToUI(line string) tea.Cmd {
|
func (m Model) logToUI(line string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
return debugLogMsg(line)
|
return debugLogMsg(line)
|
||||||
|
|||||||
@@ -1,179 +1,153 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"os"
|
||||||
"regexp"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/chromedp/cdproto/network"
|
||||||
|
"github.com/chromedp/chromedp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// extractM3U8Lite performs static extraction like yt-dlp,
|
// extractM3U8Lite loads an embed page in headless Chrome via chromedp,
|
||||||
// logging each step to the provided debug callback (for the F12 panel).
|
// runs any JavaScript, and extracts the final .m3u8 URL and its HTTP headers.
|
||||||
func extractM3U8Lite(embedURL string, log func(string)) (string, error) {
|
// It streams live log lines via the provided log callback.
|
||||||
const origin = "https://embedsports.top"
|
func extractM3U8Lite(embedURL string, log func(string)) (string, map[string]string, error) {
|
||||||
const referer = "https://embedsports.top/"
|
if log == nil {
|
||||||
const ua = "Mozilla/5.0 (X11; Linux x86_64; rv:144.0) Gecko/20100101 Firefox/144.0"
|
log = func(string) {}
|
||||||
|
|
||||||
if log != nil {
|
|
||||||
log(fmt.Sprintf("[extractor] Fetching embed page: %s", embedURL))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := fetchHTML(embedURL, ua, origin, referer, 10*time.Second)
|
ctx, cancel := chromedp.NewContext(context.Background())
|
||||||
if err != nil {
|
defer cancel()
|
||||||
if log != nil {
|
|
||||||
log(fmt.Sprintf("[extractor] ❌ fetch failed: %v", err))
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("fetch embed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
k, i, s := parseEmbedVars(body)
|
// Capture the first .m3u8 request Chrome makes
|
||||||
if log != nil {
|
type capture struct {
|
||||||
log(fmt.Sprintf("[extractor] Parsed vars → k=%q i=%q s=%q", k, i, s))
|
URL string
|
||||||
|
Headers map[string]string
|
||||||
}
|
}
|
||||||
|
found := make(chan capture, 1)
|
||||||
|
|
||||||
if k == "" || i == "" || s == "" {
|
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||||
if log != nil {
|
if e, ok := ev.(*network.EventRequestWillBeSent); ok {
|
||||||
log("[extractor] ❌ missing vars (k,i,s) in embed HTML")
|
u := e.Request.URL
|
||||||
}
|
if strings.Contains(u, ".m3u8") {
|
||||||
return "", errors.New("missing vars (k,i,s) in embed HTML")
|
h := make(map[string]string)
|
||||||
}
|
for k, v := range e.Request.Headers {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
if log != nil {
|
h[k] = s
|
||||||
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
|
select {
|
||||||
}
|
case found <- capture{URL: u, Headers: h}:
|
||||||
}
|
default:
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
if m := regexp.MustCompile(`"([A-Za-z0-9]{24,})"\.split\(""\)\.reverse`).FindStringSubmatch(body); len(m) > 1 {
|
log("[chromedp] page loaded, waiting for .m3u8 network requests...")
|
||||||
if log != nil {
|
|
||||||
log("[extractor] Found reversed string token")
|
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...")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[chromedp] ❌ failed to find .m3u8 via network or DOM")
|
||||||
|
return "", nil, errors.New("m3u8 not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if log == nil {
|
||||||
|
log = func(string) {}
|
||||||
|
}
|
||||||
|
if m3u8 == "" {
|
||||||
|
return fmt.Errorf("empty m3u8 URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{"--no-terminal", "--really-quiet"}
|
||||||
|
|
||||||
|
for k, v := range hdrs {
|
||||||
|
if k == "" || v == "" {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
return reverseString(m[1]), true
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
if log != nil {
|
args = append(args, m3u8)
|
||||||
log("[extractor] No token found in JS")
|
log(fmt.Sprintf("[mpv] launching with %d headers: %s", len(hdrs), m3u8))
|
||||||
|
|
||||||
|
cmd := exec.Command("mpv", args...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
log(fmt.Sprintf("[mpv] launch error: %v", err))
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return "", false
|
|
||||||
|
log(fmt.Sprintf("[mpv] started (pid %d)", cmd.Process.Pid))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user