Compare commits
30 Commits
e5d769d59b
...
d99e44e996
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d99e44e996 | ||
|
|
c524e2c990 | ||
|
|
1f14c3f4a1 | ||
|
|
3ded284b57 | ||
|
|
369184a30c | ||
|
|
1f57c556a0 | ||
|
|
45d0979b01 | ||
|
|
49ac8c9e1b | ||
|
|
5afa624af0 | ||
|
|
661ce59d9c | ||
|
|
494e51508a | ||
|
|
feafdfe4cc | ||
|
|
4cb7bd5945 | ||
|
|
6bf9f35f8d | ||
|
|
f6e1b6b350 | ||
|
|
5eda333046 | ||
|
|
c02cabdd93 | ||
|
|
dac214bb96 | ||
|
|
17f524332c | ||
|
|
5e179e84a3 | ||
|
|
989258573a | ||
|
|
813929ed87 | ||
|
|
5a8fd6c328 | ||
|
|
ffe4f3cc0d | ||
|
|
61dccecc24 | ||
|
|
7511e0d8c1 | ||
|
|
30a03db923 | ||
|
|
2d9bd0b8a2 | ||
|
|
83417000f9 | ||
|
|
9df8c33e5e |
15
README.md
15
README.md
@@ -1,2 +1,17 @@
|
||||
# streamed-tui
|
||||
TUI Application for launching streamed.pk feeds
|
||||
|
||||
## Bundled Puppeteer dependencies
|
||||
|
||||
The extractor relies on `puppeteer-extra`, `puppeteer-extra-plugin-stealth`, and `puppeteer`. These Node.js packages are
|
||||
bundled into the final binary via `internal/assets/node_modules.tar.gz`. To refresh the archive (for example after updating
|
||||
dependency versions), run:
|
||||
|
||||
```
|
||||
scripts/build_node_modules.sh
|
||||
```
|
||||
|
||||
The script installs the dependencies into a temporary directory and regenerates the tarball so the Go binary can extract
|
||||
them at runtime without requiring `npm install` on the target system. When the binary starts it will automatically unpack the
|
||||
archive into the user's cache directory (or `$TMPDIR` fallback) and point Puppeteer at that cached `node_modules` tree, so the
|
||||
program can run as a single self-contained executable even when no dependencies exist alongside it.
|
||||
|
||||
15
go.mod
15
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
|
||||
|
||||
303
internal/app.go
303
internal/app.go
@@ -20,7 +20,12 @@ type keyMap struct {
|
||||
Up, Down, Left, Right key.Binding
|
||||
Enter, Quit, Refresh key.Binding
|
||||
OpenBrowser, OpenMPV key.Binding
|
||||
Help, Debug key.Binding
|
||||
Help key.Binding
|
||||
}
|
||||
|
||||
type helpKeyMap struct {
|
||||
base keyMap
|
||||
showMPV bool
|
||||
}
|
||||
|
||||
func defaultKeys() keyMap {
|
||||
@@ -35,7 +40,6 @@ func defaultKeys() keyMap {
|
||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
|
||||
Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "toggle help")),
|
||||
Debug: key.NewBinding(key.WithKeys("f12"), key.WithHelp("F12", "debug panel")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +50,29 @@ func (k keyMap) ShortHelp() []key.Binding {
|
||||
func (k keyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{k.Up, k.Down, k.Left, k.Right},
|
||||
{k.Enter, k.OpenBrowser, k.OpenMPV, k.Refresh, k.Help, k.Debug, k.Quit},
|
||||
{k.Enter, k.OpenBrowser, k.OpenMPV, k.Refresh, k.Help, k.Quit},
|
||||
}
|
||||
}
|
||||
|
||||
func (h helpKeyMap) ShortHelp() []key.Binding {
|
||||
bindings := []key.Binding{h.base.Up, h.base.Down, h.base.Left, h.base.Right, h.base.Enter, h.base.OpenBrowser}
|
||||
if h.showMPV {
|
||||
bindings = append(bindings, h.base.OpenMPV)
|
||||
}
|
||||
bindings = append(bindings, h.base.Help, h.base.Quit)
|
||||
return bindings
|
||||
}
|
||||
|
||||
func (h helpKeyMap) FullHelp() [][]key.Binding {
|
||||
row2 := []key.Binding{h.base.Enter, h.base.OpenBrowser}
|
||||
if h.showMPV {
|
||||
row2 = append(row2, h.base.OpenMPV)
|
||||
}
|
||||
row2 = append(row2, h.base.Refresh, h.base.Help, h.base.Quit)
|
||||
|
||||
return [][]key.Binding{
|
||||
{h.base.Up, h.base.Down, h.base.Left, h.base.Right},
|
||||
row2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,9 +104,45 @@ const (
|
||||
const (
|
||||
viewMain viewMode = iota
|
||||
viewHelp
|
||||
viewDebug
|
||||
)
|
||||
|
||||
func formatViewerCount(count int) string {
|
||||
if count >= 1_000_000 {
|
||||
value := float64(count) / 1_000_000
|
||||
formatted := fmt.Sprintf("%.1f", value)
|
||||
formatted = strings.TrimSuffix(formatted, ".0")
|
||||
return formatted + "m"
|
||||
}
|
||||
|
||||
if count >= 1000 {
|
||||
value := float64(count) / 1000
|
||||
formatted := fmt.Sprintf("%.1f", value)
|
||||
formatted = strings.TrimSuffix(formatted, ".0")
|
||||
return formatted + "k"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d", count)
|
||||
}
|
||||
|
||||
func reorderStreams(streams []Stream) []Stream {
|
||||
if len(streams) == 0 {
|
||||
return streams
|
||||
}
|
||||
|
||||
regular := make([]Stream, 0, len(streams))
|
||||
admin := make([]Stream, 0)
|
||||
|
||||
for _, st := range streams {
|
||||
if strings.EqualFold(st.Source, "admin") {
|
||||
admin = append(admin, st)
|
||||
continue
|
||||
}
|
||||
regular = append(regular, st)
|
||||
}
|
||||
|
||||
return append(regular, admin...)
|
||||
}
|
||||
|
||||
// ────────────────────────────────
|
||||
// MODEL
|
||||
// ────────────────────────────────
|
||||
@@ -107,13 +169,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 +190,10 @@ func New() Model {
|
||||
debugLines: []string{},
|
||||
}
|
||||
|
||||
if debug {
|
||||
m.debugLines = append(m.debugLines, "(debug logging enabled)")
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -135,17 +201,44 @@ func New() Model {
|
||||
if mt.Teams != nil && mt.Teams.Home != nil && mt.Teams.Away != nil {
|
||||
title = fmt.Sprintf("%s vs %s", mt.Teams.Home.Name, mt.Teams.Away.Name)
|
||||
}
|
||||
return fmt.Sprintf("%s %s (%s)", when, title, mt.Category)
|
||||
|
||||
viewers := ""
|
||||
if mt.Viewers > 0 {
|
||||
viewers = fmt.Sprintf(" (%s viewers)", formatViewerCount(mt.Viewers))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s %s%s (%s)", when, title, viewers, mt.Category)
|
||||
})
|
||||
m.matches.SetSeparator(func(prev, curr Match) (string, bool) {
|
||||
currDay := time.UnixMilli(curr.Date).Local().Format("Jan 2")
|
||||
prevDay := ""
|
||||
if prev.Date != 0 {
|
||||
prevDay = time.UnixMilli(prev.Date).Local().Format("Jan 2")
|
||||
}
|
||||
|
||||
if prevDay == "" || prevDay != currDay {
|
||||
return currDay, true
|
||||
}
|
||||
return "", false
|
||||
})
|
||||
m.streams = NewListColumn[Stream]("Streams", func(st Stream) string {
|
||||
quality := "SD"
|
||||
if st.HD {
|
||||
quality = "HD"
|
||||
}
|
||||
return fmt.Sprintf("#%d %s (%s) – %s", st.StreamNo, st.Language, quality, st.Source)
|
||||
viewers := formatViewerCount(st.Viewers)
|
||||
return fmt.Sprintf("#%d %s (%s) – %s — (%s viewers)", st.StreamNo, st.Language, quality, st.Source, viewers)
|
||||
})
|
||||
m.streams.SetSeparator(func(prev, curr Stream) (string, bool) {
|
||||
isAdmin := strings.EqualFold(curr.Source, "admin")
|
||||
wasAdmin := strings.EqualFold(prev.Source, "admin")
|
||||
if isAdmin && !wasAdmin {
|
||||
return "Browser Only", true
|
||||
}
|
||||
return "", false
|
||||
})
|
||||
|
||||
m.status = fmt.Sprintf("Using API %s | Loading…", base)
|
||||
m.status = fmt.Sprintf("Using API %s | Loading sports and matches…", base)
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -161,25 +254,52 @@ func (m Model) View() string {
|
||||
switch m.currentView {
|
||||
case viewHelp:
|
||||
return m.renderHelpPanel()
|
||||
case viewDebug:
|
||||
return m.renderDebugPanel()
|
||||
default:
|
||||
return m.renderMainView()
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderMainView() string {
|
||||
cols := lipgloss.JoinHorizontal(
|
||||
lipgloss.Top,
|
||||
m.sports.View(m.styles, m.focus == focusSports),
|
||||
m.matches.View(m.styles, m.focus == focusMatches),
|
||||
m.streams.View(m.styles, m.focus == focusStreams),
|
||||
)
|
||||
status := m.styles.Status.Render(m.status)
|
||||
if m.lastError != nil {
|
||||
status = m.styles.Error.Render(fmt.Sprintf("⚠️ %v", m.lastError))
|
||||
gap := lipgloss.NewStyle().MarginRight(1)
|
||||
sportsCol := gap.Render(m.sports.View(m.styles, m.focus == focusSports))
|
||||
matchesCol := gap.Render(m.matches.View(m.styles, m.focus == focusMatches))
|
||||
streamsCol := m.streams.View(m.styles, m.focus == focusStreams)
|
||||
|
||||
cols := lipgloss.JoinHorizontal(lipgloss.Top, sportsCol, matchesCol, streamsCol)
|
||||
colsWidth := lipgloss.Width(cols)
|
||||
debugPane := m.renderDebugPane(colsWidth)
|
||||
status := m.renderStatusLine()
|
||||
keys := helpKeyMap{base: m.keys, showMPV: m.canUseMPVShortcut()}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, cols, debugPane, status, m.help.View(keys))
|
||||
}
|
||||
|
||||
func (m Model) canUseMPVShortcut() bool {
|
||||
if st, ok := m.streams.Selected(); ok {
|
||||
return !strings.EqualFold(st.Source, "admin")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m Model) renderStatusLine() string {
|
||||
focusLabel := m.currentFocusLabel()
|
||||
statusText := fmt.Sprintf("%s | Focus: %s (←/→)", m.status, focusLabel)
|
||||
if m.lastError != nil {
|
||||
return m.styles.Error.Render(fmt.Sprintf("⚠️ %v | Focus: %s (Esc to dismiss)", m.lastError, focusLabel))
|
||||
}
|
||||
return m.styles.Status.Render(statusText)
|
||||
}
|
||||
|
||||
func (m Model) currentFocusLabel() string {
|
||||
switch m.focus {
|
||||
case focusSports:
|
||||
return "Sports"
|
||||
case focusMatches:
|
||||
return "Matches"
|
||||
case focusStreams:
|
||||
return "Streams"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, cols, status, m.help.View(m.keys))
|
||||
}
|
||||
|
||||
func (m Model) renderHelpPanel() string {
|
||||
@@ -193,7 +313,6 @@ func (m Model) renderHelpPanel() string {
|
||||
{"R", "Refresh"},
|
||||
{"Q", "Quit"},
|
||||
{"F1 / ?", "Toggle this help"},
|
||||
{"F12", "Show debug panel"},
|
||||
{"Esc", "Return to main view"},
|
||||
}
|
||||
|
||||
@@ -202,33 +321,49 @@ func (m Model) renderHelpPanel() string {
|
||||
for _, b := range bindings {
|
||||
sb.WriteString(fmt.Sprintf("%-18s %s\n", b[0], b[1]))
|
||||
}
|
||||
sb.WriteString("\nPress Esc to return.")
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("Admin streams can only be opened in the browser because STREAMED obfuscates them\n\n")
|
||||
sb.WriteString("Press Esc to return.")
|
||||
|
||||
panel := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("#FA8072")).
|
||||
Padding(1, 2).
|
||||
Width(int(float64(m.TerminalWidth) * 0.97)).
|
||||
Width(int(float64(m.TerminalWidth) * 0.95)).
|
||||
Render(sb.String())
|
||||
|
||||
return panel
|
||||
}
|
||||
|
||||
func (m Model) renderDebugPanel() string {
|
||||
header := m.styles.Title.Render("Debug Output (F12 / Esc to close)")
|
||||
func (m Model) renderDebugPane(widthHint int) string {
|
||||
header := m.styles.Title.Render("Debug log")
|
||||
visibleLines := 4
|
||||
if len(m.debugLines) == 0 {
|
||||
m.debugLines = append(m.debugLines, "(no debug output yet)")
|
||||
m.debugLines = append(m.debugLines, "(debug log empty)")
|
||||
}
|
||||
start := len(m.debugLines) - visibleLines
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
lines := m.debugLines[start:]
|
||||
for len(lines) < visibleLines {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
content := strings.Join(m.debugLines, "\n")
|
||||
|
||||
panel := lipgloss.NewStyle().
|
||||
content := strings.Join(lines, "\n")
|
||||
width := widthHint
|
||||
if width == 0 {
|
||||
width = int(float64(m.TerminalWidth) * 0.95)
|
||||
if width == 0 {
|
||||
width = 80
|
||||
}
|
||||
}
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("#FA8072")).
|
||||
Padding(1, 2).
|
||||
Width(int(float64(m.TerminalWidth) * 0.97)).
|
||||
Render(header + "\n\n" + content)
|
||||
|
||||
return panel
|
||||
Padding(0, 1).
|
||||
Render(header + "\n" + content)
|
||||
}
|
||||
|
||||
// ────────────────────────────────
|
||||
@@ -247,17 +382,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.TerminalWidth = msg.Width
|
||||
usableHeight := int(float64(msg.Height) * 0.9)
|
||||
totalAvailableWidth := int(float64(msg.Width) * 0.97)
|
||||
debugPaneHeight := 7
|
||||
statusHeight := 1
|
||||
helpHeight := 2
|
||||
reservedHeight := debugPaneHeight + statusHeight + helpHeight
|
||||
usableHeight := msg.Height - reservedHeight
|
||||
if usableHeight < 5 {
|
||||
usableHeight = 5
|
||||
}
|
||||
totalAvailableWidth := int(float64(msg.Width) * 0.95)
|
||||
borderPadding := 4
|
||||
totalBorderSpace := borderPadding * 3
|
||||
availableWidth := totalAvailableWidth - totalBorderSpace
|
||||
colWidth := availableWidth / 3
|
||||
remainder := availableWidth % 3
|
||||
|
||||
m.sports.SetWidth(colWidth + borderPadding)
|
||||
m.matches.SetWidth(colWidth + borderPadding)
|
||||
m.streams.SetWidth(colWidth + remainder + borderPadding)
|
||||
// Allocate widths with weights: Sports=3, Matches=10, Streams=5 (18 total)
|
||||
// Streams gain an additional ~20% width by borrowing space from Matches.
|
||||
weightTotal := 18
|
||||
unit := availableWidth / weightTotal
|
||||
remainder := availableWidth - (unit * weightTotal)
|
||||
|
||||
sportsWidth := unit * 3
|
||||
matchesWidth := unit * 10
|
||||
streamsWidth := unit * 5
|
||||
|
||||
// Assign any leftover pixels to the widest column (matches) to keep alignment.
|
||||
matchesWidth += remainder
|
||||
|
||||
m.sports.SetWidth(sportsWidth + borderPadding)
|
||||
m.matches.SetWidth(matchesWidth + borderPadding)
|
||||
m.streams.SetWidth(streamsWidth + borderPadding)
|
||||
|
||||
m.sports.SetHeight(usableHeight)
|
||||
m.matches.SetHeight(usableHeight)
|
||||
@@ -277,14 +430,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.currentView = viewHelp
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Debug):
|
||||
if m.currentView == viewDebug {
|
||||
m.currentView = viewMain
|
||||
} else {
|
||||
m.currentView = viewDebug
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.currentView != viewMain {
|
||||
@@ -333,17 +478,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m.focus {
|
||||
case focusSports:
|
||||
if sport, ok := m.sports.Selected(); ok {
|
||||
m.lastError = nil
|
||||
m.status = fmt.Sprintf("Loading matches for %s…", sport.Name)
|
||||
m.streams.SetItems(nil)
|
||||
return m, m.fetchMatchesForSport(sport)
|
||||
}
|
||||
case focusMatches:
|
||||
if mt, ok := m.matches.Selected(); ok {
|
||||
m.lastError = nil
|
||||
m.status = fmt.Sprintf("Loading streams for %s…", mt.Title)
|
||||
return m, m.fetchStreamsForMatch(mt)
|
||||
}
|
||||
case focusStreams:
|
||||
if st, ok := m.streams.Selected(); ok {
|
||||
if strings.EqualFold(st.Source, "admin") {
|
||||
if st.EmbedURL != "" {
|
||||
_ = openBrowser(st.EmbedURL)
|
||||
m.lastError = nil
|
||||
m.status = fmt.Sprintf("🌐 Opened in browser: %s", st.EmbedURL)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Batch(
|
||||
m.logToUI(fmt.Sprintf("Attempting extractor for %s", st.EmbedURL)),
|
||||
m.runExtractor(st),
|
||||
@@ -356,6 +511,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.focus == focusStreams {
|
||||
if st, ok := m.streams.Selected(); ok && st.EmbedURL != "" {
|
||||
_ = openBrowser(st.EmbedURL)
|
||||
m.lastError = nil
|
||||
m.status = fmt.Sprintf("🌐 Opened in browser: %s", st.EmbedURL)
|
||||
}
|
||||
}
|
||||
@@ -364,28 +520,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
|
||||
case sportsLoadedMsg:
|
||||
m.sports.SetItems(msg)
|
||||
m.status = fmt.Sprintf("Loaded %d sports", len(msg))
|
||||
sports := prependPopularSport(msg)
|
||||
m.sports.SetItems(sports)
|
||||
m.lastError = nil
|
||||
m.status = fmt.Sprintf("Loaded %d sports – pick one with Enter or stay on Popular Matches", len(sports))
|
||||
return m, nil
|
||||
|
||||
case matchesLoadedMsg:
|
||||
m.matches.SetTitle(msg.Title)
|
||||
m.matches.SetItems(msg.Matches)
|
||||
m.status = fmt.Sprintf("Loaded %d matches", len(msg.Matches))
|
||||
m.lastError = nil
|
||||
m.status = fmt.Sprintf("Loaded %d matches – choose one to load streams", len(msg.Matches))
|
||||
return m, nil
|
||||
|
||||
case streamsLoadedMsg:
|
||||
m.streams.SetItems(msg)
|
||||
m.status = fmt.Sprintf("Loaded %d streams", len(msg))
|
||||
m.lastError = nil
|
||||
m.status = fmt.Sprintf("Loaded %d streams – Enter to launch mpv, o to open in browser", len(msg))
|
||||
m.focus = focusStreams
|
||||
return m, nil
|
||||
|
||||
case launchStreamMsg:
|
||||
m.lastError = nil
|
||||
m.status = fmt.Sprintf("🎥 Launched mpv: %s", msg.URL)
|
||||
return m, nil
|
||||
|
||||
case errorMsg:
|
||||
m.lastError = msg
|
||||
m.status = "Encountered an error while contacting the API"
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
@@ -417,21 +579,42 @@ func (m Model) fetchPopularMatches() tea.Cmd {
|
||||
|
||||
func (m Model) fetchMatchesForSport(s Sport) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
matches, err := m.apiClient.GetMatchesBySport(context.Background(), s.ID)
|
||||
get := func() ([]Match, error) {
|
||||
if strings.EqualFold(s.ID, "popular") {
|
||||
return m.apiClient.GetPopularMatches(context.Background())
|
||||
}
|
||||
return m.apiClient.GetMatchesBySport(context.Background(), s.ID)
|
||||
}
|
||||
|
||||
matches, err := get()
|
||||
if err != nil {
|
||||
return errorMsg(err)
|
||||
}
|
||||
return matchesLoadedMsg{Matches: matches, Title: fmt.Sprintf("Matches (%s)", s.Name)}
|
||||
title := fmt.Sprintf("Matches (%s)", s.Name)
|
||||
if strings.EqualFold(s.ID, "popular") {
|
||||
title = "Popular Matches"
|
||||
}
|
||||
return matchesLoadedMsg{Matches: matches, Title: title}
|
||||
}
|
||||
}
|
||||
|
||||
func prependPopularSport(sports []Sport) []Sport {
|
||||
for _, s := range sports {
|
||||
if strings.EqualFold(s.ID, "popular") || strings.EqualFold(s.Name, "popular") {
|
||||
return sports
|
||||
}
|
||||
}
|
||||
popular := Sport{ID: "popular", Name: "Popular"}
|
||||
return append([]Sport{popular}, sports...)
|
||||
}
|
||||
|
||||
func (m Model) fetchStreamsForMatch(mt Match) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
streams, err := m.apiClient.GetStreamsForMatch(context.Background(), mt)
|
||||
if err != nil {
|
||||
return errorMsg(err)
|
||||
}
|
||||
return streamsLoadedMsg(streams)
|
||||
return streamsLoadedMsg(reorderStreams(streams))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +635,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 +650,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))
|
||||
}
|
||||
|
||||
BIN
internal/assets/node_modules.tar.gz
Normal file
BIN
internal/assets/node_modules.tar.gz
Normal file
Binary file not shown.
14
internal/browser.go
Normal file
14
internal/browser.go
Normal 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()
|
||||
}
|
||||
@@ -62,6 +62,8 @@ type Match struct {
|
||||
Source string `json:"source"`
|
||||
ID string `json:"id"`
|
||||
} `json:"sources"`
|
||||
|
||||
Viewers int `json:"viewers"`
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
@@ -71,6 +73,7 @@ type Stream struct {
|
||||
HD bool `json:"hd"`
|
||||
EmbedURL string `json:"embedUrl"`
|
||||
Source string `json:"source"`
|
||||
Viewers int `json:"viewers"`
|
||||
}
|
||||
|
||||
// ────────────────────────────────
|
||||
@@ -88,7 +91,33 @@ func (c *Client) GetSports(ctx context.Context) ([]Sport, error) {
|
||||
|
||||
func (c *Client) GetPopularMatches(ctx context.Context) ([]Match, error) {
|
||||
url := c.base + "/api/matches/all/popular"
|
||||
return c.getMatches(ctx, url)
|
||||
matches, err := c.getMatches(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
viewCounts, err := c.GetPopularViewCounts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range matches {
|
||||
// Prefer a direct match on the match ID.
|
||||
if viewers, ok := viewCounts.ByMatchID[matches[i].ID]; ok {
|
||||
matches[i].Viewers = viewers
|
||||
continue
|
||||
}
|
||||
|
||||
// Fallback: some IDs can differ between endpoints, so also try source IDs.
|
||||
for _, src := range matches[i].Sources {
|
||||
if viewers, ok := viewCounts.BySourceID[src.ID]; ok {
|
||||
matches[i].Viewers = viewers
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMatchesBySport(ctx context.Context, sportID string) ([]Match, error) {
|
||||
@@ -96,6 +125,41 @@ func (c *Client) GetMatchesBySport(ctx context.Context, sportID string) ([]Match
|
||||
return c.getMatches(ctx, url)
|
||||
}
|
||||
|
||||
type PopularViewCounts struct {
|
||||
ByMatchID map[string]int
|
||||
BySourceID map[string]int
|
||||
}
|
||||
|
||||
func (c *Client) GetPopularViewCounts(ctx context.Context) (PopularViewCounts, error) {
|
||||
url := "https://streami.su/api/matches/live/popular-viewcount"
|
||||
|
||||
var payload []struct {
|
||||
ID string `json:"id"`
|
||||
Viewers int `json:"viewers"`
|
||||
Sources []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
|
||||
if err := c.get(ctx, url, &payload); err != nil {
|
||||
return PopularViewCounts{}, err
|
||||
}
|
||||
|
||||
matchMap := make(map[string]int, len(payload))
|
||||
sourceMap := make(map[string]int, len(payload))
|
||||
for _, item := range payload {
|
||||
matchMap[item.ID] = item.Viewers
|
||||
for _, src := range item.Sources {
|
||||
if src.ID == "" {
|
||||
continue
|
||||
}
|
||||
sourceMap[src.ID] = item.Viewers
|
||||
}
|
||||
}
|
||||
|
||||
return PopularViewCounts{ByMatchID: matchMap, BySourceID: sourceMap}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetStreamsForMatch(ctx context.Context, mt Match) ([]Stream, error) {
|
||||
var all []Stream
|
||||
for _, src := range mt.Sources {
|
||||
|
||||
@@ -17,19 +17,21 @@ type Styles struct {
|
||||
Active lipgloss.Style
|
||||
Status lipgloss.Style
|
||||
Error lipgloss.Style // NEW: for red bold error lines
|
||||
Subtle lipgloss.Style
|
||||
}
|
||||
|
||||
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),
|
||||
Active: lipgloss.NewStyle().
|
||||
Border(border).
|
||||
BorderForeground(lipgloss.Color("#FA8072")). // Not pink, its Salmon obviously
|
||||
Padding(0, 1).
|
||||
MarginRight(1),
|
||||
Padding(0, 1),
|
||||
Status: lipgloss.NewStyle().Foreground(lipgloss.Color("8")).MarginTop(1),
|
||||
Error: lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true),
|
||||
Subtle: lipgloss.NewStyle().Foreground(lipgloss.Color("243")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,12 +49,57 @@ type ListColumn[T any] struct {
|
||||
width int
|
||||
height int
|
||||
render renderer[T]
|
||||
|
||||
separator func(prev, curr T) (string, bool)
|
||||
}
|
||||
|
||||
func NewListColumn[T any](title string, r renderer[T]) *ListColumn[T] {
|
||||
return &ListColumn[T]{title: title, render: r, width: 30, height: 20}
|
||||
}
|
||||
|
||||
func (c *ListColumn[T]) SetSeparator(sep func(prev, curr T) (string, bool)) {
|
||||
c.separator = sep
|
||||
}
|
||||
|
||||
func truncateToWidth(text string, width int) string {
|
||||
if width <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if lipgloss.Width(text) <= width {
|
||||
return text
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
total := 0
|
||||
for i, r := range runes {
|
||||
rWidth := lipgloss.Width(string(r))
|
||||
if total+rWidth > width {
|
||||
return string(runes[:i])
|
||||
}
|
||||
total += rWidth
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
func buildSeparatorLine(label string, width int) string {
|
||||
if width <= 0 {
|
||||
return label
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(label)
|
||||
padded := fmt.Sprintf(" %s ", trimmed)
|
||||
remaining := width - lipgloss.Width(padded)
|
||||
if remaining <= 0 {
|
||||
return truncateToWidth(padded, width)
|
||||
}
|
||||
|
||||
left := remaining / 2
|
||||
right := remaining - left
|
||||
return strings.Repeat("─", left) + padded + strings.Repeat("─", right)
|
||||
}
|
||||
|
||||
func (c *ListColumn[T]) SetItems(items []T) {
|
||||
c.items = items
|
||||
c.selected = 0
|
||||
@@ -71,24 +118,24 @@ 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 > 6 {
|
||||
c.height = h - 6
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ListColumn[T]) CursorUp() {
|
||||
if c.selected > 0 {
|
||||
c.selected--
|
||||
}
|
||||
if c.selected < c.scroll {
|
||||
c.scroll = c.selected
|
||||
}
|
||||
c.ensureSelectedVisible()
|
||||
}
|
||||
|
||||
func (c *ListColumn[T]) CursorDown() {
|
||||
if c.selected < len(c.items)-1 {
|
||||
c.selected++
|
||||
}
|
||||
if c.selected >= c.scroll+c.height {
|
||||
c.scroll = c.selected - c.height + 1
|
||||
}
|
||||
c.ensureSelectedVisible()
|
||||
}
|
||||
|
||||
func (c *ListColumn[T]) Selected() (T, bool) {
|
||||
@@ -99,39 +146,149 @@ func (c *ListColumn[T]) Selected() (T, bool) {
|
||||
return c.items[c.selected], true
|
||||
}
|
||||
|
||||
type listRow[T any] struct {
|
||||
text string
|
||||
isSeparator bool
|
||||
itemIndex int
|
||||
}
|
||||
|
||||
func (c *ListColumn[T]) buildRows() []listRow[T] {
|
||||
rows := make([]listRow[T], 0, len(c.items))
|
||||
var prev T
|
||||
|
||||
for i, item := range c.items {
|
||||
if c.separator != nil {
|
||||
if sepText, ok := c.separator(prev, item); ok {
|
||||
rows = append(rows, listRow[T]{text: sepText, isSeparator: true, itemIndex: -1})
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, listRow[T]{text: c.render(item), itemIndex: i})
|
||||
prev = item
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (c *ListColumn[T]) clampScroll(totalRows int) {
|
||||
if c.height <= 0 {
|
||||
c.scroll = 0
|
||||
return
|
||||
}
|
||||
|
||||
maxScroll := totalRows - c.height
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
if c.scroll > maxScroll {
|
||||
c.scroll = maxScroll
|
||||
}
|
||||
if c.scroll < 0 {
|
||||
c.scroll = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ListColumn[T]) ensureSelectedVisible() {
|
||||
if len(c.items) == 0 {
|
||||
c.scroll = 0
|
||||
return
|
||||
}
|
||||
|
||||
rows := c.buildRows()
|
||||
selRow := 0
|
||||
for idx, row := range rows {
|
||||
if row.isSeparator {
|
||||
continue
|
||||
}
|
||||
if row.itemIndex == c.selected {
|
||||
selRow = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if c.height <= 0 {
|
||||
c.scroll = selRow
|
||||
return
|
||||
}
|
||||
|
||||
if selRow < c.scroll {
|
||||
c.scroll = selRow
|
||||
}
|
||||
if selRow >= c.scroll+c.height {
|
||||
c.scroll = selRow - c.height + 1
|
||||
}
|
||||
|
||||
c.clampScroll(len(rows))
|
||||
}
|
||||
|
||||
func (c *ListColumn[T]) View(styles Styles, focused bool) string {
|
||||
box := styles.Box
|
||||
if focused {
|
||||
box = styles.Active
|
||||
}
|
||||
|
||||
head := styles.Title.Render(c.title)
|
||||
titleText := fmt.Sprintf("%s (%d)", c.title, len(c.items))
|
||||
if focused {
|
||||
titleText = fmt.Sprintf("▶ %s", titleText)
|
||||
}
|
||||
head := styles.Title.Render(titleText)
|
||||
meta := styles.Subtle.Render("Waiting for data…")
|
||||
lines := []string{}
|
||||
|
||||
if len(c.items) == 0 {
|
||||
lines = append(lines, "(no items)")
|
||||
} else {
|
||||
rows := c.buildRows()
|
||||
c.clampScroll(len(rows))
|
||||
|
||||
start := c.scroll
|
||||
end := start + c.height
|
||||
if end > len(c.items) {
|
||||
end = len(c.items)
|
||||
if end > len(rows) {
|
||||
end = len(rows)
|
||||
}
|
||||
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)
|
||||
|
||||
startItem, endItem := -1, -1
|
||||
|
||||
for i := start; i < end; i++ {
|
||||
row := rows[i]
|
||||
cursor := " "
|
||||
lineText := row.text
|
||||
|
||||
contentWidth := c.width - lipgloss.Width(cursor)
|
||||
|
||||
if row.isSeparator {
|
||||
lineText = buildSeparatorLine(lineText, contentWidth)
|
||||
lineText = styles.Subtle.Render(lineText)
|
||||
} else {
|
||||
if contentWidth > 1 && lipgloss.Width(lineText) > contentWidth {
|
||||
lineText = fmt.Sprintf("%s…", truncateToWidth(lineText, contentWidth-1))
|
||||
}
|
||||
|
||||
if startItem == -1 {
|
||||
startItem = row.itemIndex
|
||||
}
|
||||
endItem = row.itemIndex
|
||||
|
||||
if row.itemIndex == 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)
|
||||
lines = append(lines, line)
|
||||
}
|
||||
line := fmt.Sprintf("%s%s", cursor, lineText)
|
||||
if len(line) > c.width && c.width > 3 {
|
||||
line = line[:c.width-3] + "…"
|
||||
|
||||
if startItem == -1 {
|
||||
startItem = 0
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if endItem == -1 {
|
||||
endItem = startItem
|
||||
}
|
||||
|
||||
meta = styles.Subtle.Render(fmt.Sprintf("Showing %d–%d of %d", startItem+1, endItem+1, len(c.items)))
|
||||
}
|
||||
|
||||
// Fill remaining lines if fewer than height
|
||||
@@ -141,5 +298,5 @@ 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)
|
||||
}
|
||||
return box.Width(c.width + 4).Render(head + "\n" + meta + "\n" + content)
|
||||
}
|
||||
|
||||
104
internal/dependencies.go
Normal file
104
internal/dependencies.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed assets/node_modules.tar.gz
|
||||
var embeddedNodeModules []byte
|
||||
|
||||
// ensureEmbeddedNodeModules extracts the bundled Node.js dependencies into a
|
||||
// deterministic cache directory derived from the archive hash and returns the
|
||||
// path that contains the resulting node_modules directory.
|
||||
func ensureEmbeddedNodeModules() (string, error) {
|
||||
if len(embeddedNodeModules) == 0 {
|
||||
return "", errors.New("no embedded node modules archive available")
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(embeddedNodeModules)
|
||||
hashPrefix := hex.EncodeToString(sum[:8])
|
||||
|
||||
cacheRoot, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
cacheRoot = os.TempDir()
|
||||
}
|
||||
baseDir := filepath.Join(cacheRoot, "streamed-tui", "node_modules", hashPrefix)
|
||||
|
||||
marker := filepath.Join(baseDir, ".complete")
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
return baseDir, nil
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(baseDir); err != nil {
|
||||
return "", fmt.Errorf("failed to clear embedded node cache: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(baseDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("failed to create embedded node cache: %w", err)
|
||||
}
|
||||
|
||||
if err := untarGzip(bytes.NewReader(embeddedNodeModules), baseDir); err != nil {
|
||||
return "", fmt.Errorf("failed to extract embedded node modules: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(marker, []byte(time.Now().Format(time.RFC3339)), 0o644); err != nil {
|
||||
return "", fmt.Errorf("failed to mark embedded node modules ready: %w", err)
|
||||
}
|
||||
|
||||
return baseDir, nil
|
||||
}
|
||||
|
||||
func untarGzip(r io.Reader, dest string) error {
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target := filepath.Join(dest, hdr.Name)
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, os.FileMode(hdr.Mode)); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// Ignore unsupported entries to keep extraction simple.
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,120 +1,430 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if extracted, err := ensureEmbeddedNodeModules(); err == nil {
|
||||
return extracted, nil
|
||||
}
|
||||
|
||||
return "", errors.New("puppeteer-extra not found; install dependencies with npm in the project directory or rebuild the embedded archive")
|
||||
}
|
||||
|
||||
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 {
|
||||
if embedded, embErr := ensureEmbeddedNodeModules(); embErr == nil && embedded != baseDir {
|
||||
return ensurePuppeteerAvailable(embedded)
|
||||
}
|
||||
|
||||
return fmt.Errorf("puppeteer-extra or stealth plugin missing in %s. Run `npm install puppeteer-extra puppeteer-extra-plugin-stealth puppeteer` there or rebuild the embedded archive with scripts/build_node_modules.sh: %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 +432,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
|
||||
}
|
||||
|
||||
@@ -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
15
main.go
@@ -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)
|
||||
}
|
||||
|
||||
22
scripts/build_node_modules.sh
Executable file
22
scripts/build_node_modules.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ASSETS_DIR="$ROOT_DIR/internal/assets"
|
||||
ARCHIVE="$ASSETS_DIR/node_modules.tar.gz"
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "npm is required to bundle node_modules" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
pushd "$TMP_DIR" >/dev/null
|
||||
npm install puppeteer-extra puppeteer-extra-plugin-stealth puppeteer
|
||||
mkdir -p "$ASSETS_DIR"
|
||||
tar -czf "$ARCHIVE" node_modules
|
||||
popd >/dev/null
|
||||
|
||||
echo "Bundled node_modules into $ARCHIVE"
|
||||
Reference in New Issue
Block a user