Compare commits

..

37 Commits

Author SHA1 Message Date
Salastil
5ea24f3b0b fix
All checks were successful
Build Release Binaries / build-and-release (push) Successful in 10m31s
2025-11-23 12:40:15 -05:00
Salastil
37bfd30a84 Unique filenames for builds
Some checks failed
Build Release Binaries / build-and-release (push) Failing after 10m29s
2025-11-23 05:42:55 -05:00
Salastil
1feedc4488 Update permissions on token
All checks were successful
Build Release Binaries / build-and-release (push) Successful in 10m22s
2025-11-23 05:16:13 -05:00
Salastil
cccbf4ab63 1
Some checks failed
Build Release Binaries / build-and-release (push) Failing after 5m42s
2025-11-23 05:00:11 -05:00
Salastil
0f7f0219aa Update Gitea workflow
Some checks failed
Build Release Binaries / build-and-release (push) Failing after 5m43s
2025-11-23 04:47:19 -05:00
Salastil
653326177c Github Workflow 2025-11-23 04:28:39 -05:00
Salastil
d661aee36c Enhance README with usage and build instructions
Expanded README to include detailed usage instructions, debugging tips, and building from source.
2025-11-23 02:17:56 -05:00
Salastil
d99e44e996 Bundle Puppeteer Dependency 2025-11-23 01:53:57 -05:00
Salastil
c524e2c990 Improve TUI clarity and status messaging 2025-11-23 01:32:36 -05:00
Salastil
1f14c3f4a1 Add help binding to footer shortcuts 2025-11-23 01:24:02 -05:00
Salastil
3ded284b57 Reposition admin stream notice in help panel 2025-11-23 01:21:08 -05:00
Salastil
369184a30c Improve popular viewer count mapping 2025-11-23 01:13:26 -05:00
Salastil
1f57c556a0 Show popular match viewer counts 2025-11-23 01:06:12 -05:00
Salastil
45d0979b01 Handle admin streams as browser-only 2025-11-23 00:57:23 -05:00
Salastil
49ac8c9e1b Add date separators in matches list 2025-11-23 00:46:20 -05:00
Salastil
5afa624af0 Filter admin streams from view 2025-11-23 00:38:09 -05:00
Salastil
661ce59d9c Expand streams column width further 2025-11-23 00:33:44 -05:00
Salastil
494e51508a Widen streams column and shrink matches 2025-11-23 00:29:06 -05:00
Salastil
feafdfe4cc Show viewer counts for streams 2025-11-23 00:26:12 -05:00
Salastil
4cb7bd5945 Align debug pane with columns 2025-11-23 00:16:24 -05:00
Salastil
6bf9f35f8d Align debug pane width with columns 2025-11-23 00:12:51 -05:00
Salastil
f6e1b6b350 Reorder debug log pane 2025-11-23 00:09:24 -05:00
Salastil
5eda333046 Add bottom debug log pane 2025-11-23 00:02:45 -05:00
Salastil
c02cabdd93 Constrain UI to 95 percent width 2025-11-22 23:53:30 -05:00
Salastil
dac214bb96 Prevent highlighted items from collapsing 2025-11-22 23:32:01 -05:00
Salastil
17f524332c Adjust column widths for emphasis on matches 2025-11-22 23:25:47 -05:00
Salastil
5e179e84a3 Complete Extractor 2025-11-22 23:11:12 -05:00
Salastil
989258573a Detach mpv for CLI extraction 2025-11-22 22:58:53 -05:00
Salastil
813929ed87 Speed up puppeteer navigation wait 2025-11-22 22:43:41 -05:00
Salastil
5a8fd6c328 Route puppeteer debug logs to stderr 2025-11-22 22:22:55 -05:00
Salastil
ffe4f3cc0d Resolve Node module lookup for puppeteer runner 2025-11-22 22:05:31 -05:00
Salastil
61dccecc24 Stop waiting for secondary navigation in puppeteer runner 2025-11-22 22:01:46 -05:00
Salastil
7511e0d8c1 Remove redundant stream utility helpers 2025-11-22 21:15:27 -05:00
Salastil
30a03db923 Follow nested m3u8 playlists in puppeteer runner 2025-11-22 21:00:59 -05:00
Salastil
2d9bd0b8a2 Improve puppeteer extractor debug visibility 2025-11-22 20:44:28 -05:00
Salastil
83417000f9 Make extractor CLI attach mpv output 2025-11-22 20:44:26 -05:00
Salastil
9df8c33e5e Remove firefox puppeteer fallback 2025-11-22 20:34:29 -05:00
16 changed files with 1448 additions and 296 deletions

View File

@@ -0,0 +1,147 @@
name: Build Release Binaries
on:
push:
tags:
- "v*"
permissions:
contents: write
env:
BINARY_NAME: streamed-tui
API_BASE: https://git.salastil.com/api/v1
GITEA_ENV: gitea.env
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
#######################################################
# 1. Bundle puppeteer-extra + stealth plugin
#######################################################
- name: Bundle extractor node_modules
run: |
chmod +x ./scripts/build_node_modules.sh
./scripts/build_node_modules.sh
#######################################################
# 2. Build all platform binaries
#######################################################
- name: Build binaries
run: |
mkdir -p dist
build() {
GOOS="$1" GOARCH="$2"
OUT="${BINARY_NAME}_${1}_${2}"
echo "Building $1 $2 => $OUT"
env \
GOOS="$1" \
GOARCH="$2" \
CGO_ENABLED=0 \
go build -o "dist/${OUT}" .
}
build linux amd64
build linux arm64
build darwin amd64
build darwin arm64
#######################################################
# 3. Check if release exists → create if needed
#######################################################
- name: Ensure release exists
id: ensure_release
env:
GITEA_TOKEN: ${{ secrets.CI_TOKEN }}
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
set -e
echo "Checking if release exists: $TAG"
# Query
STATUS=$(curl -s -o resp.json -w "%{http_code}" \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API_BASE}/repos/${REPO}/releases/tags/${TAG}")
if [ "$STATUS" = "200" ]; then
echo "Release already exists."
RELEASE_ID=$(jq -r '.id' resp.json)
else
echo "Release does not exist — creating"
CREATE=$(curl -s -o create.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"${TAG}\",
\"name\": \"${TAG}\",
\"body\": \"Automated release ${TAG}\",
\"draft\": false,
\"prerelease\": false
}" \
"${API_BASE}/repos/${REPO}/releases")
echo "Gitea API Response Code: $CREATE"
RELEASE_ID=$(jq -r '.id' create.json)
echo "New release id: $RELEASE_ID"
fi
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
echo "ERROR: Could not determine release ID"
exit 1
fi
# Write to environment file (Gitea-compatible)
echo "RELEASE_ID=${RELEASE_ID}" >> "$GITEA_ENV"
#######################################################
# 4. Load RELEASE_ID into environment (Gitea-compatible)
#######################################################
- name: Load RELEASE_ID
run: |
if [ -f "$GITEA_ENV" ]; then
cat "$GITEA_ENV" >> "$GITHUB_ENV"
echo "Loaded RELEASE_ID=$RELEASE_ID"
else
echo "ERROR: gitea.env missing"
exit 1
fi
#######################################################
# 5. Upload all binaries as release assets
#######################################################
- name: Upload binaries to Gitea Release
env:
GITEA_TOKEN: ${{ secrets.CI_TOKEN }}
REPO: ${{ github.repository }}
RELEASE_ID: ${{ env.RELEASE_ID }}
run: |
echo "Uploading binaries..."
cd dist
for file in *; do
echo "Uploading $file..."
curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${file}" \
"${API_BASE}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${file}"
done
echo "Upload complete."

82
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,82 @@
name: Build & Release
on:
push:
tags:
- "v*"
permissions:
contents: write
env:
BINARY_NAME: streamed-tui
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# -------------------------------------------------
# Build node_modules bundle for puppeteer-extra
# -------------------------------------------------
- name: Build node_modules archive
run: |
chmod +x scripts/build_node_modules.sh
./scripts/build_node_modules.sh
# -------------------------------------------------
# Compile all platform binaries
# -------------------------------------------------
- name: Build binaries
run: |
mkdir -p out
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o out/${BINARY_NAME}_linux_amd64 .
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o out/${BINARY_NAME}_linux_arm64 .
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -o out/${BINARY_NAME}_darwin_amd64 .
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o out/${BINARY_NAME}_darwin_arm64 .
# -------------------------------------------------
# Create source code bundle for this version
# -------------------------------------------------
- name: Create source tarball
run: |
mkdir -p release
tar -czf release/${BINARY_NAME}_${GITHUB_REF_NAME}_source.tar.gz .
# -------------------------------------------------
# Install GitHub CLI
# -------------------------------------------------
- name: Install GitHub CLI
run: sudo apt-get install -y gh
# -------------------------------------------------
# Create Release + upload binaries and source
# -------------------------------------------------
- name: Create Release and Upload Assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${GITHUB_REF_NAME}"
# Remove old release if re-tagged
gh release delete "$TAG" --yes --cleanup-tag || true
# Create release and upload all assets
gh release create "$TAG" \
out/${BINARY_NAME}_linux_amd64 \
out/${BINARY_NAME}_linux_arm64 \
out/${BINARY_NAME}_darwin_amd64 \
out/${BINARY_NAME}_darwin_arm64 \
release/${BINARY_NAME}_${TAG}_source.tar.gz \
--title "$TAG" \
--notes "Release $TAG" \
--latest

1
.gitignore vendored
View File

@@ -30,5 +30,4 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/
go.sum
streamed-tui

View File

@@ -1,2 +1,43 @@
# streamed-tui
TUI Application for launching streamed.pk feeds
Terminal UI for browsing streamed.pk sports streams, opening the selected embed URL in your browser or passing it straight to mpv.
<img width="1910" height="1038" alt="20251123_015918" src="https://github.com/user-attachments/assets/d0bf328b-9139-44ef-b764-25f1a53c7da7" />
## How it works
The client talks to the https://streamed.pk/ API to load sports, popular matches, and per-match streams in a three column layout: sports on the left, matches in the middle, and available streams on the right. Focus moves with the arrow keys or vim keys hjkl, and selecting a match triggers a stream lookup for that event. Press `o` to open the highlighted stream in your default browser, `p` or enter to pipe the embed URL to mpv. You can also bypass the TUI entirely with `-e <embed-url>` to extract and launch a single stream from the command line if you know the url from embed.top
**Debugging** Start the app with `--debug` to append verbose extractor logs into the debug panel at the bottom of the UI, useful when diagnosing failed stream loads, best used with the -e flag so it will not render the TUI and the debug log is placed in stdout.
**Admin Streams** - Streams flagged as Admin are not capable of being forwarded to mpv. These streams have heavier obsfucation and the typical m3u8 extraction method does not work as the javascript on these pages continously issue new m3u8 rather than the typical follow-along type on other streams. These streams can only be watched in the browser, hitting 'o' on the stream will open your browser as set by $XDG_OPEN.
## Building from source
1. Install Go 1.24+ (matching the module version) and ensure your `$GOPATH/bin` is on `PATH`.
2. Refresh bundled Node.js dependencies (only needed if you change extractor packages):
```bash
scripts/build_node_modules.sh
```
This repacks the `puppeteer-extra` toolchain into `internal/assets/node_modules.tar.gz` so the Go binary can unpack it at runtime without requiring npm on the target machine.
3. Compile the TUI:
```bash
go build -o streamed-tui .
```
4. Run it:
```bash
./streamed-tui # launches the full TUI
./streamed-tui -e URL # extracts and launches a single embed URL
./streamed-tui --debug # shows extractor debug log in the footer
```
## 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.

7
go.mod
View File

@@ -6,8 +6,6 @@ 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
)
require (
@@ -16,12 +14,7 @@ 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/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/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect

47
go.sum Normal file
View File

@@ -0,0 +1,47 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s=
github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk=
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM=
github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ=
github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28=
github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4=
github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

View File

@@ -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))
}

Binary file not shown.

14
internal/browser.go Normal file
View 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()
}

View File

@@ -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 {

View File

@@ -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
View 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
}

View File

@@ -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
}

View File

@@ -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
View File

@@ -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
View 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"