Compare commits
37 Commits
e5d769d59b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ea24f3b0b | ||
|
|
37bfd30a84 | ||
|
|
1feedc4488 | ||
|
|
cccbf4ab63 | ||
|
|
0f7f0219aa | ||
|
|
653326177c | ||
|
|
d661aee36c | ||
|
|
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 |
147
.gitea/workflows/release.yml
Normal file
147
.gitea/workflows/release.yml
Normal 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
82
.github/workflows/release.yml
vendored
Normal 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
1
.gitignore
vendored
@@ -30,5 +30,4 @@ go.work.sum
|
||||
# Editor/IDE
|
||||
# .idea/
|
||||
# .vscode/
|
||||
go.sum
|
||||
streamed-tui
|
||||
|
||||
43
README.md
43
README.md
@@ -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
7
go.mod
@@ -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
47
go.sum
Normal 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=
|
||||
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