Minor changes, beginning to do mpv m3u8 extraction

This commit is contained in:
Salastil
2025-10-20 13:48:55 -04:00
parent 25f3cdac64
commit 33796afa47
3 changed files with 252 additions and 29 deletions

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
@@ -22,28 +23,31 @@ import (
type keyMap struct {
Up, Down, Left, Right key.Binding
Enter, Quit, Refresh key.Binding
OpenBrowser, OpenMPV key.Binding
}
func defaultKeys() keyMap {
return keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "focus left")),
Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "focus right")),
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "focus left")),
Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "focus right")),
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
OpenBrowser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "open in browser")),
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
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 {
return [][]key.Binding{
{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 {
apiClient *Client
styles Styles
keys keyMap
help help.Model
focus focusCol
styles Styles
keys keyMap
help help.Model
focus focusCol
lastError error
sports *ListColumn[Sport]
matches *ListColumn[Match]
@@ -148,16 +153,53 @@ func (m Model) Init() tea.Cmd {
// 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 {
// 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(
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)
return lipgloss.JoinVertical(lipgloss.Left, cols, status, m.help.View(m.keys))
cols += " " // one-char right-edge buffer
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) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.sports.SetWidth(msg.Width / 3)
m.matches.SetWidth(msg.Width / 3)
m.streams.SetWidth(msg.Width / 3)
total := msg.Width
bordersAndPads := 4 // 2 border + 2 padding per column
// 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
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Left):
if m.focus > focusSports {
m.focus--
}
return m, nil
case key.Matches(msg, m.keys.Right):
if m.focus < focusStreams {
m.focus++
}
return m, nil
case key.Matches(msg, m.keys.Up):
switch m.focus {
case focusSports:
@@ -198,6 +259,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.streams.CursorUp()
}
return m, nil
case key.Matches(msg, m.keys.Down):
switch m.focus {
case focusSports:
@@ -208,6 +270,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.streams.CursorDown()
}
return m, nil
case key.Matches(msg, m.keys.Enter):
switch m.focus {
case focusSports:
@@ -226,6 +289,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
@@ -251,7 +337,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case errorMsg:
m.status = fmt.Sprintf("Error: %v", msg)
m.lastError = msg
return m, nil
}
return m, nil
@@ -316,3 +402,32 @@ func (m Model) launchMPV(st Stream) tea.Cmd {
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
}