Minor changes, beginning to do mpv m3u8 extraction
This commit is contained in:
153
internal/app.go
153
internal/app.go
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/help"
|
"github.com/charmbracelet/bubbles/help"
|
||||||
@@ -22,28 +23,31 @@ import (
|
|||||||
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
|
||||||
|
OpenBrowser, OpenMPV key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultKeys() keyMap {
|
func defaultKeys() keyMap {
|
||||||
return keyMap{
|
return keyMap{
|
||||||
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
|
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
|
||||||
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
||||||
Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "focus left")),
|
Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "focus left")),
|
||||||
Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "focus right")),
|
Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "focus right")),
|
||||||
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
|
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
|
||||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
OpenBrowser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "open in browser")),
|
||||||
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
|
OpenMPV: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "open in mpv")),
|
||||||
|
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||||
|
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// implement help.KeyMap interface
|
// implement help.KeyMap interface
|
||||||
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.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},
|
||||||
{k.Enter, k.Refresh, k.Quit},
|
{k.Enter, k.OpenBrowser, k.OpenMPV, k.Refresh, k.Quit},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,10 +85,11 @@ const (
|
|||||||
type Model struct {
|
type Model struct {
|
||||||
apiClient *Client
|
apiClient *Client
|
||||||
|
|
||||||
styles Styles
|
styles Styles
|
||||||
keys keyMap
|
keys keyMap
|
||||||
help help.Model
|
help help.Model
|
||||||
focus focusCol
|
focus focusCol
|
||||||
|
lastError error
|
||||||
|
|
||||||
sports *ListColumn[Sport]
|
sports *ListColumn[Sport]
|
||||||
matches *ListColumn[Match]
|
matches *ListColumn[Match]
|
||||||
@@ -148,16 +153,53 @@ func (m Model) Init() tea.Cmd {
|
|||||||
// VIEW
|
// VIEW
|
||||||
// ────────────────────────────────
|
// ────────────────────────────────
|
||||||
//
|
//
|
||||||
|
func padToHeight(s string, height int) string {
|
||||||
|
lines := strings.Split(s, "\n")
|
||||||
|
for len(lines) < height {
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
return strings.Join(lines[:height], "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) dynamicHelp() string {
|
||||||
|
switch m.focus {
|
||||||
|
case focusSports, focusMatches:
|
||||||
|
return m.help.View(keyMap{
|
||||||
|
Up: m.keys.Up, Down: m.keys.Down, Left: m.keys.Left, Right: m.keys.Right,
|
||||||
|
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
|
||||||
|
OpenBrowser: m.keys.OpenBrowser, OpenMPV: m.keys.OpenMPV,
|
||||||
|
Quit: m.keys.Quit, Refresh: m.keys.Refresh,
|
||||||
|
})
|
||||||
|
case focusStreams:
|
||||||
|
return m.help.View(keyMap{
|
||||||
|
Up: m.keys.Up, Down: m.keys.Down, Left: m.keys.Left, Right: m.keys.Right,
|
||||||
|
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "mpv / browser")),
|
||||||
|
OpenBrowser: m.keys.OpenBrowser, OpenMPV: m.keys.OpenMPV,
|
||||||
|
Quit: m.keys.Quit, Refresh: m.keys.Refresh,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return m.help.View(m.keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) View() string {
|
func (m Model) View() string {
|
||||||
|
// Copy styles so we can tweak the rightmost margin to 0
|
||||||
|
right := m.styles
|
||||||
|
right.Box = right.Box.MarginRight(0)
|
||||||
|
right.Active = right.Active.MarginRight(0)
|
||||||
|
|
||||||
cols := lipgloss.JoinHorizontal(
|
cols := lipgloss.JoinHorizontal(
|
||||||
lipgloss.Top,
|
lipgloss.Top,
|
||||||
m.sports.View(m.styles, m.focus == focusSports),
|
m.sports.View(m.styles, m.focus == focusSports),
|
||||||
m.matches.View(m.styles, m.focus == focusMatches),
|
m.matches.View(m.styles, m.focus == focusMatches),
|
||||||
m.streams.View(m.styles, m.focus == focusStreams),
|
m.streams.View(m.styles, m.focus == focusStreams),
|
||||||
)
|
)
|
||||||
status := m.styles.Status.Render(m.status)
|
cols += " " // one-char right-edge buffer
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, cols, status, m.help.View(m.keys))
|
status := m.styles.Status.Render(m.status)
|
||||||
|
if m.lastError != nil {
|
||||||
|
status = m.styles.Error.Render(fmt.Sprintf("⚠️ %v", m.lastError))
|
||||||
|
}
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, cols, status, m.dynamicHelp())
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -169,25 +211,44 @@ func (m Model) View() string {
|
|||||||
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) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.sports.SetWidth(msg.Width / 3)
|
total := msg.Width
|
||||||
m.matches.SetWidth(msg.Width / 3)
|
bordersAndPads := 4 // 2 border + 2 padding per column
|
||||||
m.streams.SetWidth(msg.Width / 3)
|
|
||||||
|
// Compute equal thirds, but reserve space for borders/pads
|
||||||
|
colWidth := (total / 3) - (bordersAndPads / 3)
|
||||||
|
|
||||||
|
// Give the rightmost column any leftover width to avoid clipping
|
||||||
|
remainder := total - (colWidth * 3)
|
||||||
|
rightWidth := colWidth + remainder - 1 // leave 1-char breathing room
|
||||||
|
|
||||||
|
usableHeight := int(float64(msg.Height) * 0.9)
|
||||||
|
|
||||||
|
m.sports.SetWidth(colWidth)
|
||||||
|
m.matches.SetWidth(colWidth)
|
||||||
|
m.streams.SetWidth(rightWidth)
|
||||||
|
|
||||||
|
m.sports.SetHeight(usableHeight)
|
||||||
|
m.matches.SetHeight(usableHeight)
|
||||||
|
m.streams.SetHeight(usableHeight)
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, m.keys.Quit):
|
case key.Matches(msg, m.keys.Quit):
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Left):
|
case key.Matches(msg, m.keys.Left):
|
||||||
if m.focus > focusSports {
|
if m.focus > focusSports {
|
||||||
m.focus--
|
m.focus--
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Right):
|
case key.Matches(msg, m.keys.Right):
|
||||||
if m.focus < focusStreams {
|
if m.focus < focusStreams {
|
||||||
m.focus++
|
m.focus++
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Up):
|
case key.Matches(msg, m.keys.Up):
|
||||||
switch m.focus {
|
switch m.focus {
|
||||||
case focusSports:
|
case focusSports:
|
||||||
@@ -198,6 +259,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.streams.CursorUp()
|
m.streams.CursorUp()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Down):
|
case key.Matches(msg, m.keys.Down):
|
||||||
switch m.focus {
|
switch m.focus {
|
||||||
case focusSports:
|
case focusSports:
|
||||||
@@ -208,6 +270,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.streams.CursorDown()
|
m.streams.CursorDown()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Enter):
|
case key.Matches(msg, m.keys.Enter):
|
||||||
switch m.focus {
|
switch m.focus {
|
||||||
case focusSports:
|
case focusSports:
|
||||||
@@ -226,6 +289,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.launchMPV(st)
|
return m, m.launchMPV(st)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.OpenBrowser):
|
||||||
|
if m.focus == focusStreams {
|
||||||
|
if st, ok := m.streams.Selected(); ok && st.EmbedURL != "" {
|
||||||
|
_ = openBrowser(st.EmbedURL)
|
||||||
|
m.status = fmt.Sprintf("🌐 Opened in browser: %s", st.EmbedURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.OpenMPV):
|
||||||
|
if m.focus == focusStreams {
|
||||||
|
if st, ok := m.streams.Selected(); ok {
|
||||||
|
go func(st Stream) {
|
||||||
|
if err := m.forceMPVLaunch(st); err != nil {
|
||||||
|
m.lastError = err
|
||||||
|
}
|
||||||
|
}(st)
|
||||||
|
m.status = fmt.Sprintf("🎞️ Attempting mpv: %s", st.EmbedURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -251,7 +337,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case errorMsg:
|
case errorMsg:
|
||||||
m.status = fmt.Sprintf("Error: %v", msg)
|
m.lastError = msg
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -316,3 +402,32 @@ func (m Model) launchMPV(st Stream) tea.Cmd {
|
|||||||
return launchStreamMsg{URL: url}
|
return launchStreamMsg{URL: url}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// forceMPVLaunch attempts to extract .m3u8 and open it in mpv directly.
|
||||||
|
func (m Model) forceMPVLaunch(st Stream) error {
|
||||||
|
embed := strings.TrimSpace(st.EmbedURL)
|
||||||
|
if embed == "" {
|
||||||
|
return fmt.Errorf("no embed URL for stream %s", st.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
origin, referer, ua, err := deriveHeaders(embed)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bad embed URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := fetchHTML(embed, ua, origin, referer, 12*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m3u8 := extractM3U8(body)
|
||||||
|
if m3u8 == "" {
|
||||||
|
return fmt.Errorf("no .m3u8 found in embed page")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := launchMPV(m3u8, ua, origin, referer); err != nil {
|
||||||
|
return fmt.Errorf("mpv launch failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,16 +16,19 @@ type Styles struct {
|
|||||||
Box lipgloss.Style
|
Box lipgloss.Style
|
||||||
Active lipgloss.Style
|
Active lipgloss.Style
|
||||||
Status lipgloss.Style
|
Status lipgloss.Style
|
||||||
|
Error lipgloss.Style // NEW: for red bold error lines
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStyles() Styles {
|
func NewStyles() Styles {
|
||||||
border := lipgloss.RoundedBorder()
|
border := lipgloss.RoundedBorder()
|
||||||
return Styles{
|
return Styles{
|
||||||
Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")),
|
Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")),
|
||||||
Box: lipgloss.NewStyle().Border(border).Padding(0, 1).MarginRight(1),
|
// REMOVE MarginRight(1) from both Box and Active:
|
||||||
Active: lipgloss.NewStyle().Border(border).BorderForeground(lipgloss.Color("10")).Padding(0, 1).MarginRight(1),
|
Box: lipgloss.NewStyle().Border(border).Padding(0, 1),
|
||||||
Status: lipgloss.NewStyle().Foreground(lipgloss.Color("8")).MarginTop(1),
|
Active: lipgloss.NewStyle().Border(border).BorderForeground(lipgloss.Color("10")).Padding(0, 1),
|
||||||
}
|
Status: lipgloss.NewStyle().Foreground(lipgloss.Color("8")).MarginTop(1),
|
||||||
|
Error: lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────
|
// ────────────────────────────────
|
||||||
@@ -55,7 +58,17 @@ func (c *ListColumn[T]) SetItems(items []T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *ListColumn[T]) SetTitle(title string) { c.title = title }
|
func (c *ListColumn[T]) SetTitle(title string) { c.title = title }
|
||||||
func (c *ListColumn[T]) SetWidth(w int) { if w > 20 { c.width = w - 2 } }
|
|
||||||
|
func (c *ListColumn[T]) SetWidth(w int) {
|
||||||
|
// w is the total width the app wants to allocate to the box.
|
||||||
|
// Subtract 4 for border (2) + padding (2) to get interior content width.
|
||||||
|
if w < 4 {
|
||||||
|
c.width = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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() {
|
||||||
@@ -120,5 +133,6 @@ func (c *ListColumn[T]) View(styles Styles, focused bool) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
content := strings.Join(lines, "\n")
|
content := strings.Join(lines, "\n")
|
||||||
return box.Width(c.width + 2).Render(head + "\n" + content)
|
// IMPORTANT: width = interior content width + 4 (border+padding)
|
||||||
}
|
return box.Width(c.width + 4).Render(head + "\n" + content)
|
||||||
|
}
|
||||||
94
internal/stream_utils.go
Normal file
94
internal/stream_utils.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user