mirror of
https://gitlab.com/vyla-entertainment/live.git
synced 2026-11-09 19:57:22 -05:00
Sync project files
This commit is contained in:
33
.github/workflows/vps-deploy.yml
vendored
Normal file
33
.github/workflows/vps-deploy.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Deploy to VPS
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.2.0
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
cd /var/www/live-api-streampk
|
||||
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
git clean -fd
|
||||
|
||||
npm install
|
||||
|
||||
chown -R www-data:www-data /var/www/live-api-streampk
|
||||
chmod -R 755 /var/www/live-api-streampk
|
||||
|
||||
pm2 restart live-api-streampk || pm2 start server.js --name live-api-streampk
|
||||
|
||||
pm2 save
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
13
DOCKERFILE
Normal file
13
DOCKERFILE
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:22-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
ENV PORT=5000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
94
DOCUMENTATION/API.md
Normal file
94
DOCUMENTATION/API.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# API Reference
|
||||
|
||||
All endpoints are relative to the server root (e.g. `http://localhost:5000`).
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/stream/:source/:id`
|
||||
|
||||
Returns a JSON array of available streams for a given match.
|
||||
|
||||
**URL parameters**
|
||||
- `source` – source name (`admin`, `echo`, `golf`, etc.)
|
||||
- `id` – match slug (e.g. `ppv-brooklyn-nets-vs-milwaukee-bucks`)
|
||||
|
||||
**Example request**
|
||||
```
|
||||
GET /api/stream/admin/ppv-brooklyn-nets-vs-milwaukee-bucks
|
||||
```
|
||||
|
||||
**Example response**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "ppv-brooklyn-nets-vs-milwaukee-bucks",
|
||||
"streamNo": 1,
|
||||
"language": "English - ESPN+",
|
||||
"hd": true,
|
||||
"streamUrl": "http://localhost:5000/api/m3u8-proxy?url=https%3A%2F%2Flb14.strmd.st%2Fsecure%2F...%2Fplaylist.m3u8",
|
||||
"source": "admin",
|
||||
"viewers": 14
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Fields**
|
||||
| Field | Type | Description |
|
||||
|------------|---------|-------------|
|
||||
| `id` | string | Match identifier |
|
||||
| `streamNo` | number | Stream number (used internally) |
|
||||
| `language` | string | Human‑readable label |
|
||||
| `hd` | boolean | Whether the stream is high‑definition |
|
||||
| `streamUrl`| string | **Proxied** playlist URL – use this for playback |
|
||||
| `source` | string | Source name |
|
||||
| `viewers` | number | Approximate viewer count (may be `0`) |
|
||||
|
||||
> The endpoint may also accept a full watch URL or embed URL if your router supports it.
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/m3u8-proxy`
|
||||
|
||||
Serves the HLS playlist or a segment.
|
||||
The server automatically detects whether the response is a playlist or a segment and handles it accordingly.
|
||||
|
||||
**Query parameters**
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `url` | ✅ | The upstream HLS URL (playlist or segment). |
|
||||
| `referer` | ❌ | Override the `Referer` header sent to upstream. If omitted, `EMBED_DOMAIN` is used. |
|
||||
|
||||
**Behaviour**
|
||||
- If the upstream response is an M3U8 playlist (contains `#EXTM3U`), it rewrites all media URLs and `URI` attributes to point back to `/api/m3u8-proxy`.
|
||||
- If the response is an MPEG‑TS segment, it strips the PNG wrapper (if present) and returns raw TS data.
|
||||
|
||||
**Response headers**
|
||||
- `Content-Type: application/vnd.apple.mpegurl` or `video/mp2t`
|
||||
- `Access-Control-Allow-Origin: *`
|
||||
- `Cache-Control: no-cache`
|
||||
|
||||
**Example**
|
||||
```
|
||||
GET /api/m3u8-proxy?url=https://lb14.strmd.st/secure/.../playlist.m3u8&referer=https://embed.st/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/segment-proxy` *(if mounted separately)*
|
||||
|
||||
Some implementations separate segment proxying from playlist proxying.
|
||||
This endpoint behaves exactly like `/api/m3u8-proxy` but is dedicated to segments and always returns `video/mp2t`.
|
||||
|
||||
**Same query parameters as above.**
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints return appropriate HTTP status codes:
|
||||
|
||||
- `400` – Missing required parameter.
|
||||
- `502` – Upstream fetch failed (non‑2xx status, network error, or invalid response).
|
||||
- `500` – Internal server error (e.g., resolver failure).
|
||||
|
||||
Error responses are plain text with a descriptive message.
|
||||
34
DOCUMENTATION/README.md
Normal file
34
DOCUMENTATION/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# HLS Stream Proxy Server
|
||||
|
||||
A Node.js server that resolves and proxies HLS streams from **streamed.pk / embed.st** (and **golf** embeds) so they can be played in any browser or media player without referer or CORS issues.
|
||||
|
||||
## Features
|
||||
|
||||
- Resolves `admin`/`echo` streams using the embed.st handshake.
|
||||
- Resolves `golf` streams via a third‑party embed chain.
|
||||
- Proxies HLS playlists and segments with correct `Referer`/`Origin` headers.
|
||||
- Rewrites M3U8 playlists so all segments and keys go through the proxy.
|
||||
- Strips PNG wrappers from segments (tiktokcdn).
|
||||
- Works with `hls.js`, VLC, MPV, and any HLS‑compatible player.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/vyla-entertainment/live-api
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Then open `http://localhost:5000` (or your deployed URL).
|
||||
|
||||
## Documentation
|
||||
|
||||
- [API Reference](./API.md)
|
||||
- [Architecture Overview](./ARCHITECTURE.md)
|
||||
- [Setup & Configuration](./SETUP.md)
|
||||
- [Deployment (incl. Hugging Face Spaces)](./DEPLOYMENT.md)
|
||||
- [Debugging](./DEBUGGING.md)
|
||||
|
||||
## License
|
||||
|
||||
For study and research only. See the original [streamed-pk-hls-stream-resolver](https://github.com/sharoon7171/streamed-pk-hls-stream-resolver) disclaimer.
|
||||
24
DOCUMENTATION/SETUP.md
Normal file
24
DOCUMENTATION/SETUP.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Setup & Configuration
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js** ≥ 22 (ES modules support).
|
||||
- **curl** installed and available in `PATH` (required for proxying).
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/vyla-entertainment/live-api
|
||||
cd live-api
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the server:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
8
README.md
Normal file
8
README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# [./DOCUMENTATION/](./DOCUMENTATION/)
|
||||
|
||||
This project is developed and maintained by [Vyla Entertainment](https://github.com/vyla-entertainment) (@vyla-entertainment).
|
||||
|
||||
Original work and core authorship: [@sharoon7171](https://github.com/sharoon7171)
|
||||
|
||||
Full credits and acknowledgements:
|
||||
https://github.com/sharoon7171/streamed-pk-hls-stream-resolver
|
||||
16
package.json
Normal file
16
package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "streamed-pk-hls-stream-resolver",
|
||||
"description": "Node.js resolver for streamed.pk and embed.st HLS streams: GOAT protobuf /fetch + lock.wasm decrypt, golf third-party embed chain, curl relay for CDN playlists and MPEG-TS segments.",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"big-integer": "^1.6.52",
|
||||
"express": "^4.21.2",
|
||||
"happy-dom": "^20.0.10"
|
||||
}
|
||||
}
|
||||
6
public/assets/title.txt
Normal file
6
public/assets/title.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
██████╗ ██╗ ██╗██╗ ██╗██╗ █████╗ ███████╗███╗ ██╗████████╗███████╗██████╗ ████████╗ █████╗ ██╗███╗ ██╗███╗ ███╗███████╗███╗ ██╗████████╗
|
||||
██╔═══██╗██║ ██║╚██╗ ██╔╝██║ ██╔══██╗ ██╔════╝████╗ ██║╚══██╔══╝██╔════╝██╔══██╗╚══██╔══╝██╔══██╗██║████╗ ██║████╗ ████║██╔════╝████╗ ██║╚══██╔══╝
|
||||
██║██╗██║██║ ██║ ╚████╔╝ ██║ ███████║ █████╗ ██╔██╗ ██║ ██║ █████╗ ██████╔╝ ██║ ███████║██║██╔██╗ ██║██╔████╔██║█████╗ ██╔██╗ ██║ ██║
|
||||
██║██║██║╚██╗ ██╔╝ ╚██╔╝ ██║ ██╔══██║ ██╔══╝ ██║╚██╗██║ ██║ ██╔══╝ ██╔══██╗ ██║ ██╔══██║██║██║╚██╗██║██║╚██╔╝██║██╔══╝ ██║╚██╗██║ ██║
|
||||
╚█║████╔╝ ╚████╔╝ ██║ ███████╗██║ ██║ ███████╗██║ ╚████║ ██║ ███████╗██║ ██║ ██║ ██║ ██║██║██║ ╚████║██║ ╚═╝ ██║███████╗██║ ╚████║ ██║
|
||||
╚╝╚═══╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚═╝
|
||||
1
public/assets/version.txt
Normal file
1
public/assets/version.txt
Normal file
@@ -0,0 +1 @@
|
||||
v1.0.0
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
7
server.js
Normal file
7
server.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import app from './src/app.js'
|
||||
|
||||
const PORT = process.env.PORT || 5000
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Running on http://localhost:${PORT}`)
|
||||
})
|
||||
257
src/app.js
Normal file
257
src/app.js
Normal file
@@ -0,0 +1,257 @@
|
||||
import express from 'express'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { serve as serveProxy } from './relay/m3u8.js'
|
||||
import { run } from './resolve/run.js'
|
||||
import { fetchJson } from './streamed/api.js'
|
||||
import { fetchLinks } from './streamed/match.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const app = express()
|
||||
|
||||
const LOGO_TEXT = fs.readFileSync(path.join(__dirname, '../public/assets/title.txt'), 'utf8')
|
||||
|
||||
function getBaseUrl(req) {
|
||||
return `${req.protocol}://${req.get('host')}`
|
||||
}
|
||||
|
||||
async function upstreamGet(pathname) {
|
||||
return fetchJson(pathname)
|
||||
}
|
||||
|
||||
async function findMatchById(matchId) {
|
||||
const sports = await upstreamGet('/api/sports')
|
||||
if (!Array.isArray(sports)) return null
|
||||
|
||||
for (const sport of sports) {
|
||||
if (!sport?.id) continue
|
||||
|
||||
for (const endpoint of [`/api/matches/${encodeURIComponent(sport.id)}`, `/api/matches/${encodeURIComponent(sport.id)}/popular`]) {
|
||||
const list = await upstreamGet(endpoint)
|
||||
if (!Array.isArray(list)) continue
|
||||
|
||||
const match = list.find((item) => item.id === matchId)
|
||||
if (match) return match
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchStreamsForMatch(source, id, match) {
|
||||
const candidates = []
|
||||
if (source) candidates.push(source)
|
||||
|
||||
for (const item of match?.sources || []) {
|
||||
if (item?.source && !candidates.includes(item.source)) candidates.push(item.source)
|
||||
}
|
||||
|
||||
let lastError = null
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const links = await fetchLinks(candidate, id)
|
||||
if (Array.isArray(links) && links.length > 0) return { links, sourceUsed: candidate }
|
||||
} catch (err) {
|
||||
lastError = err
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('no streams returned for source')
|
||||
}
|
||||
|
||||
export function buildStreamEntry(link, resolved, sourceUsed, id) {
|
||||
const embedUrl = link.embedUrl || link.watchUrl || link.url || link.href || link.link || null
|
||||
return {
|
||||
id,
|
||||
streamNo: Number(link.streamNo),
|
||||
language: link.language || link.name || link.title || 'Unknown',
|
||||
hd: Boolean(link.hd ?? /hd|720|1080/i.test(String(link.language || ''))),
|
||||
embedUrl,
|
||||
streamUrl: resolved?.ok ? resolved.relay : null,
|
||||
source: link.source || sourceUsed,
|
||||
viewers: Number(link.viewers ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
function shapeCard(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
category: item.category,
|
||||
startAt: item.startAt,
|
||||
sources: item.sources || [],
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.type('text/plain; charset=utf-8').send(`${LOGO_TEXT}\n\ndeveloped_by: @vyla-entertainment\ngithub: https://github.com/vyla-entertainment\ndocs: https://docs.vyla.cc\ndmca: https://docs.vyla.cc/misc/dmca`)
|
||||
})
|
||||
|
||||
app.get('/api', (req, res) => {
|
||||
res.json({
|
||||
name: 'live-api',
|
||||
description: 'Live sports aggregator',
|
||||
endpoints: {
|
||||
sports: '/api/sports',
|
||||
matches_by_category: '/api/matches/:category',
|
||||
popular_matches: '/api/matches/:category/popular',
|
||||
stream_resolver: '/api/stream/:source/:id',
|
||||
proxy_m3u8: '/api/m3u8-proxy?url={url}',
|
||||
images: [
|
||||
'/api/images/badge/:badge',
|
||||
'/api/images/poster/:badge1/:badge2',
|
||||
'/api/images/proxy/:url',
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/sports', async (req, res) => {
|
||||
try {
|
||||
const data = await upstreamGet('/api/sports')
|
||||
res.json(data)
|
||||
} catch (e) {
|
||||
res.status(e.status || 502).json({ error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/matches/:category', async (req, res) => {
|
||||
try {
|
||||
const data = await upstreamGet(`/api/matches/${req.params.category}`)
|
||||
res.json((Array.isArray(data) ? data : []).map(shapeCard))
|
||||
} catch (e) {
|
||||
res.status(e.status || 502).json({ error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/matches/:category/popular', async (req, res) => {
|
||||
try {
|
||||
const data = await upstreamGet(`/api/matches/${req.params.category}/popular`)
|
||||
res.json((Array.isArray(data) ? data : []).map(shapeCard))
|
||||
} catch (e) {
|
||||
res.status(e.status || 502).json({ error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/stream/:source/:id', async (req, res) => {
|
||||
try {
|
||||
const match = await findMatchById(req.params.id)
|
||||
const { links, sourceUsed } = await fetchStreamsForMatch(req.params.source, req.params.id, match)
|
||||
const base = getBaseUrl(req)
|
||||
|
||||
const enriched = await Promise.all(
|
||||
links.map(async (link) => {
|
||||
const inputUrl = link.embedUrl || link.watchUrl || link.url || link.href || link.link
|
||||
const resolved = inputUrl ? await run({ url: inputUrl }, base) : null
|
||||
return buildStreamEntry(link, resolved, sourceUsed, req.params.id)
|
||||
}),
|
||||
)
|
||||
|
||||
res.json(enriched)
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/stream', async (req, res) => {
|
||||
try {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString())
|
||||
const data = await run(body, getBaseUrl(req))
|
||||
res.json(data)
|
||||
} catch (e) {
|
||||
res.status(400).json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/m3u8-proxy', async (req, res) => {
|
||||
const target = typeof req.query.url === 'string' ? req.query.url : ''
|
||||
if (!target) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('url', target)
|
||||
if (typeof req.query.referer === 'string' && req.query.referer) params.set('referer', req.query.referer)
|
||||
|
||||
await serveProxy(res, params, getBaseUrl(req))
|
||||
})
|
||||
|
||||
app.get('/api/hls', async (req, res) => {
|
||||
const target = typeof req.query.url === 'string' ? req.query.url : ''
|
||||
if (!target) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('url', target)
|
||||
if (typeof req.query.referer === 'string' && req.query.referer) params.set('referer', req.query.referer)
|
||||
if (typeof req.query.embed === 'string' && req.query.embed) params.set('embed', req.query.embed)
|
||||
if (typeof req.query.embedOrigin === 'string' && req.query.embedOrigin) params.set('embedOrigin', req.query.embedOrigin)
|
||||
|
||||
await serveProxy(res, params, getBaseUrl(req))
|
||||
})
|
||||
|
||||
app.get('/api/segment-proxy', async (req, res) => {
|
||||
const target = typeof req.query.url === 'string' ? req.query.url : ''
|
||||
if (!target) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('url', target)
|
||||
if (typeof req.query.referer === 'string' && req.query.referer) params.set('referer', req.query.referer)
|
||||
|
||||
await serveProxy(res, params, getBaseUrl(req))
|
||||
})
|
||||
|
||||
app.get('/api/images/badge/:badge', async (req, res) => {
|
||||
try {
|
||||
const upstream = await fetch(`https://streamed.pk/api/images/badge/${encodeURIComponent(req.params.badge)}`)
|
||||
if (!upstream.ok) throw new Error(`image upstream ${upstream.status}`)
|
||||
res.setHeader('content-type', upstream.headers.get('content-type') || 'application/octet-stream')
|
||||
for await (const chunk of upstream.body) res.write(chunk)
|
||||
res.end()
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/images/poster/:badge1/:badge2', async (req, res) => {
|
||||
try {
|
||||
const upstream = await fetch(`https://streamed.pk/api/images/poster/${encodeURIComponent(req.params.badge1)}/${encodeURIComponent(req.params.badge2)}`)
|
||||
if (!upstream.ok) throw new Error(`image upstream ${upstream.status}`)
|
||||
res.setHeader('content-type', upstream.headers.get('content-type') || 'application/octet-stream')
|
||||
for await (const chunk of upstream.body) res.write(chunk)
|
||||
res.end()
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
app.get(/^\/api\/images\/proxy\/(.+)$/, async (req, res) => {
|
||||
try {
|
||||
const target = decodeURIComponent(req.params[0])
|
||||
const upstream = await fetch(target)
|
||||
if (!upstream.ok) throw new Error(`image upstream ${upstream.status}`)
|
||||
res.setHeader('content-type', upstream.headers.get('content-type') || 'application/octet-stream')
|
||||
for await (const chunk of upstream.body) res.write(chunk)
|
||||
res.end()
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
app.use(express.static(path.join(__dirname, '../public')))
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).type('text/plain').send('not found')
|
||||
})
|
||||
|
||||
export default app
|
||||
9
src/env.js
Normal file
9
src/env.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export const port = Number(process.env.PORT) || 3000
|
||||
|
||||
export const streamedOrigin = process.env.STREAMED_ORIGIN || 'https://streamed.pk'
|
||||
|
||||
export const embedOrigin = process.env.EMBED_ORIGIN || 'https://embed.st'
|
||||
|
||||
export const ua =
|
||||
process.env.USER_AGENT ||
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'
|
||||
91
src/http/router.js
Normal file
91
src/http/router.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { serve } from '../relay/m3u8.js'
|
||||
import { run } from '../resolve/run.js'
|
||||
import { fetchLinks } from '../streamed/match.js'
|
||||
import { serveStatic } from './static.js'
|
||||
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
async function readJson(req) {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
return JSON.parse(Buffer.concat(chunks).toString())
|
||||
}
|
||||
|
||||
async function listStreams(source, id, origin) {
|
||||
const links = await fetchLinks(source, id)
|
||||
return Promise.all(
|
||||
links.map(async (link) => {
|
||||
const inputUrl = link.embedUrl || link.url || link.watchUrl || link.href || link.link
|
||||
if (!inputUrl) throw new Error('stream url not available')
|
||||
|
||||
const resolved = await run({ url: inputUrl }, origin)
|
||||
if (!resolved.ok) throw new Error(resolved.error || 'resolve failed')
|
||||
|
||||
return {
|
||||
id,
|
||||
streamNo: Number(link.streamNo),
|
||||
language: link.language || link.name || link.title || 'Unknown',
|
||||
hd: Boolean(link.hd ?? /hd|720|1080/i.test(String(link.language || ''))),
|
||||
streamUrl: resolved.relay,
|
||||
source: link.source || source,
|
||||
viewers: Number(link.viewers ?? 0),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function route(req, res) {
|
||||
if (!req.headers.host) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
||||
res.end('missing host')
|
||||
return
|
||||
}
|
||||
|
||||
const loc = new URL(req.url ?? '/', `http://${req.headers.host}`)
|
||||
const { pathname, searchParams, origin } = loc
|
||||
|
||||
try {
|
||||
if (pathname === '/api/hls' || pathname === '/api/m3u8-proxy' || pathname === '/api/segment-proxy') {
|
||||
await serve(res, searchParams, origin)
|
||||
return
|
||||
}
|
||||
|
||||
const streamMatch = pathname.match(/^\/api\/stream\/([^/]+)\/([^/]+)\/?$/)
|
||||
if (streamMatch) {
|
||||
if (req.method !== 'GET') {
|
||||
json(res, 405, { error: 'GET required' })
|
||||
return
|
||||
}
|
||||
|
||||
const [, source, id] = streamMatch
|
||||
json(res, 200, await listStreams(source, id, origin))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathname === '/api/stream') {
|
||||
if (req.method !== 'POST') {
|
||||
json(res, 405, { error: 'POST required' })
|
||||
return
|
||||
}
|
||||
let body
|
||||
try {
|
||||
body = await readJson(req)
|
||||
} catch {
|
||||
json(res, 400, { ok: false, error: 'invalid json' })
|
||||
return
|
||||
}
|
||||
json(res, 200, await run(body, origin))
|
||||
return
|
||||
}
|
||||
|
||||
if (serveStatic(pathname, res)) return
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
||||
res.end('not found')
|
||||
} catch (err) {
|
||||
json(res, 500, { ok: false, error: String(err.message || err) })
|
||||
}
|
||||
}
|
||||
20
src/http/static.js
Normal file
20
src/http/static.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '../../public')
|
||||
|
||||
const assets = {
|
||||
'/': ['index.html', 'text/html; charset=utf-8'],
|
||||
'/index.html': ['index.html', 'text/html; charset=utf-8'],
|
||||
'/style.css': ['style.css', 'text/css; charset=utf-8'],
|
||||
'/player.js': ['player.js', 'application/javascript; charset=utf-8'],
|
||||
}
|
||||
|
||||
export function serveStatic(pathname, res) {
|
||||
const asset = assets[pathname]
|
||||
if (!asset) return false
|
||||
res.writeHead(200, { 'Content-Type': asset[1], 'Cache-Control': 'no-store' })
|
||||
createReadStream(join(root, asset[0])).pipe(res)
|
||||
return true
|
||||
}
|
||||
17
src/relay/link.js
Normal file
17
src/relay/link.js
Normal file
@@ -0,0 +1,17 @@
|
||||
export function relayLink(base, target, slot) {
|
||||
const q = new URLSearchParams({ url: target, embed: slot.path, embedOrigin: slot.origin })
|
||||
if (slot.referer) q.set('referer', slot.referer)
|
||||
return `${base}/api/hls?${q}`
|
||||
}
|
||||
|
||||
export function parseRelaySlot(params) {
|
||||
const path = params.get('embed')
|
||||
const origin = params.get('embedOrigin')
|
||||
if (!path || !origin) throw new Error('embed and embedOrigin required')
|
||||
const parts = path.split('/')
|
||||
if (parts.length !== 3 || parts.some((part) => !part)) throw new Error('invalid embed path')
|
||||
const slot = { origin, path }
|
||||
const referer = params.get('referer')
|
||||
if (referer) slot.referer = referer
|
||||
return slot
|
||||
}
|
||||
76
src/relay/m3u8.js
Normal file
76
src/relay/m3u8.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { pull } from '../wire/curl.js'
|
||||
import { parseRelaySlot, relayLink } from './link.js'
|
||||
import { segmentBody } from './segment.js'
|
||||
|
||||
const cors = { 'Access-Control-Allow-Origin': '*' }
|
||||
|
||||
function absUri(uri, base) {
|
||||
return uri.startsWith('http') ? uri : new URL(uri, base).href
|
||||
}
|
||||
|
||||
function isPlaylist(body) {
|
||||
const head = body.toString('utf8', 0, Math.min(body.length, 256))
|
||||
return head.includes('#EXTM3U')
|
||||
}
|
||||
|
||||
function rewrite(text, base, slot, origin) {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const t = line.trim()
|
||||
if (!t) return line
|
||||
if (t.startsWith('#')) {
|
||||
if (!t.includes('URI="')) return line
|
||||
return t.replace(/URI="([^"]+)"/g, (_, uri) => {
|
||||
const href = absUri(uri, base)
|
||||
return `URI="${relayLink(origin, href, slot)}"`
|
||||
})
|
||||
}
|
||||
return relayLink(origin, absUri(t, base), slot)
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
async function relay(res, url, slot, origin) {
|
||||
const raw = await pull(url, slot)
|
||||
|
||||
if (isPlaylist(raw)) {
|
||||
res.writeHead(200, {
|
||||
...cors,
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Cache-Control': 'no-cache',
|
||||
})
|
||||
res.end(rewrite(raw.toString('utf8'), url, slot, origin))
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(200, { ...cors, 'Content-Type': 'video/mp2t', 'Cache-Control': 'no-cache' })
|
||||
res.end(segmentBody(raw))
|
||||
}
|
||||
|
||||
export async function serve(res, params, origin) {
|
||||
const target = params.get('url')
|
||||
if (!target) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
||||
res.end('url required')
|
||||
return
|
||||
}
|
||||
|
||||
let slot
|
||||
try {
|
||||
slot = parseRelaySlot(params)
|
||||
} catch (err) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
||||
res.end(String(err.message || err))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await relay(res, target, slot, origin)
|
||||
} catch (err) {
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { 'Content-Type': 'text/plain' })
|
||||
res.end(String(err.message || err))
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/relay/segment.js
Normal file
23
src/relay/segment.js
Normal file
@@ -0,0 +1,23 @@
|
||||
function tsOff(buf) {
|
||||
for (let i = 0; i < Math.min(buf.length, 65536); i++) {
|
||||
if (buf[i] === 0x47 && i + 188 < buf.length && buf[i + 188] === 0x47) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function strip(buf) {
|
||||
if (buf.length < 4 || buf[0] === 0x47) return buf
|
||||
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
|
||||
const iend = buf.indexOf(Buffer.from('IEND'))
|
||||
if (iend >= 0 && iend + 8 < buf.length) return buf.subarray(iend + 8)
|
||||
}
|
||||
const at = tsOff(buf)
|
||||
if (at >= 0) return buf.subarray(at)
|
||||
return buf
|
||||
}
|
||||
|
||||
export function segmentBody(body) {
|
||||
const out = strip(body)
|
||||
if (out.length >= 188 && out[0] === 0x47) return out
|
||||
throw new Error('invalid segment payload')
|
||||
}
|
||||
46
src/resolve/parse.js
Normal file
46
src/resolve/parse.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { embedOrigin } from '../env.js'
|
||||
import { fetchLinks, pickLink } from '../streamed/match.js'
|
||||
import { loadWatch, parseWatchPath } from '../streamed/watch.js'
|
||||
import { makeSlot } from './slot.js'
|
||||
|
||||
const embedRe = /^\/embed\/([^/]+)\/([^/]+)\/(\d+)\/?$/
|
||||
const streamRe = /^\/api\/stream\/([^/]+)\/([^/]+)\/?$/
|
||||
|
||||
function parseUrl(raw) {
|
||||
const s = String(raw || '').trim()
|
||||
if (!s) throw new Error('url required')
|
||||
try {
|
||||
return new URL(s)
|
||||
} catch {
|
||||
throw new Error('invalid url')
|
||||
}
|
||||
}
|
||||
|
||||
function isStreamed(host) {
|
||||
return host === 'streamed.pk' || host.endsWith('.streamed.pk')
|
||||
}
|
||||
|
||||
function isEmbed(host) {
|
||||
return host.includes('embed.')
|
||||
}
|
||||
|
||||
async function fromApi(source, id, streamNo) {
|
||||
if (streamNo == null || streamNo === '') throw new Error('stream number required')
|
||||
const link = pickLink(await fetchLinks(source, id), streamNo)
|
||||
return makeSlot(embedOrigin, link.source, link.id, link.streamNo)
|
||||
}
|
||||
|
||||
export async function parseInput(raw) {
|
||||
const url = parseUrl(raw)
|
||||
|
||||
const watch = parseWatchPath(url.pathname)
|
||||
if (watch && isStreamed(url.hostname)) return (await loadWatch(watch.matchId, watch.source, watch.stream)).slot
|
||||
|
||||
const em = url.pathname.match(embedRe)
|
||||
if (em && isEmbed(url.hostname)) return makeSlot(url.origin, em[1], em[2], em[3])
|
||||
|
||||
const api = url.pathname.match(streamRe)
|
||||
if (api && isStreamed(url.hostname)) return fromApi(api[1], api[2], url.searchParams.get('stream'))
|
||||
|
||||
throw new Error('expected /watch/{id}/{source}/{n} or embed.st /embed/{source}/{id}/{n}')
|
||||
}
|
||||
53
src/resolve/run.js
Normal file
53
src/resolve/run.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { relayLink } from '../relay/link.js'
|
||||
import { resolve as resolveGolf, relayReferer } from '../sources/golf/resolve.js'
|
||||
import { postFetch } from '../sources/goat/fetch.js'
|
||||
import { unlock } from '../sources/goat/lock.js'
|
||||
import { encodeBody } from '../sources/goat/proto.js'
|
||||
import { loadWatch } from '../streamed/watch.js'
|
||||
import { parseInput } from './parse.js'
|
||||
|
||||
export async function run(input, origin) {
|
||||
let slot
|
||||
let meta
|
||||
|
||||
try {
|
||||
if (input?.matchId) {
|
||||
if (!input.source || input.stream == null) {
|
||||
return { ok: false, stage: 'input', error: 'matchId requires source and stream' }
|
||||
}
|
||||
meta = await loadWatch(input.matchId, input.source, String(input.stream))
|
||||
slot = meta.slot
|
||||
} else if (input?.url?.trim()) {
|
||||
slot = await parseInput(input.url.trim())
|
||||
} else {
|
||||
return { ok: false, stage: 'input', error: 'url or matchId required' }
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, stage: 'input', error: String(err.message || err) }
|
||||
}
|
||||
|
||||
try {
|
||||
let m3u8
|
||||
if (slot.source === 'golf') {
|
||||
m3u8 = await resolveGolf(slot)
|
||||
slot.referer = relayReferer
|
||||
} else {
|
||||
const { body, goat } = await postFetch(encodeBody(slot), slot)
|
||||
m3u8 = await unlock(slot, goat, body)
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
slug: slot.slug,
|
||||
source: slot.source,
|
||||
stream: slot.stream,
|
||||
matchId: meta?.matchId,
|
||||
title: meta?.title,
|
||||
watchUrl: meta?.watchUrl,
|
||||
embedUrl: meta?.embedUrl,
|
||||
m3u8,
|
||||
relay: relayLink(origin, m3u8, slot),
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, stage: 'resolve', error: String(err.message || err) }
|
||||
}
|
||||
}
|
||||
4
src/resolve/slot.js
Normal file
4
src/resolve/slot.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export function makeSlot(origin, source, id, stream) {
|
||||
const streamNo = String(stream)
|
||||
return { origin, path: `${source}/${id}/${streamNo}`, source, id, stream: streamNo, slug: id }
|
||||
}
|
||||
14
src/server.js
Normal file
14
src/server.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { port } from './env.js'
|
||||
import { route } from './http/router.js'
|
||||
|
||||
const srv = createServer(route)
|
||||
|
||||
function boot() {
|
||||
const addr = srv.address()
|
||||
const host = typeof addr === 'string' ? addr : `localhost:${addr.port}`
|
||||
console.log(`http://${host}/`)
|
||||
}
|
||||
|
||||
if (process.env.HOST) srv.listen(port, process.env.HOST, boot)
|
||||
else srv.listen(port, boot)
|
||||
22
src/sources/goat/fetch.js
Normal file
22
src/sources/goat/fetch.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ua } from '../../env.js'
|
||||
|
||||
export async function postFetch(body, slot) {
|
||||
const referer = `${slot.origin}/embed/${slot.path}`
|
||||
const res = await fetch(`${slot.origin}/fetch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
Origin: slot.origin,
|
||||
Referer: referer,
|
||||
'User-Agent': ua,
|
||||
},
|
||||
body,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const detail = (await res.text()).trim() || res.statusText
|
||||
throw new Error(`embed /fetch ${res.status}: ${detail}`)
|
||||
}
|
||||
const goat = res.headers.get('goat')
|
||||
if (!goat) throw new Error('missing goat header')
|
||||
return { body: Buffer.from(await res.arrayBuffer()), goat }
|
||||
}
|
||||
188
src/sources/goat/lock-worker.js
Normal file
188
src/sources/goat/lock-worker.js
Normal file
@@ -0,0 +1,188 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import { Window } from 'happy-dom'
|
||||
import { embedOrigin } from '../../env.js'
|
||||
|
||||
const vendorDir = join(dirname(fileURLToPath(import.meta.url)), 'vendor')
|
||||
const wasmPath = join(vendorDir, 'lock.wasm')
|
||||
const lockModuleUrl = pathToFileURL(join(vendorDir, 'lock-esm.mjs')).href
|
||||
const wasmBytes = readFileSync(wasmPath)
|
||||
|
||||
function pageUrl(slot) {
|
||||
return `${embedOrigin}/embed/${slot.path}`
|
||||
}
|
||||
|
||||
function mountDom(slot) {
|
||||
const window = new Window({ url: pageUrl(slot) })
|
||||
const doc = window.document
|
||||
doc.body.innerHTML = '<div id="player"></div>'
|
||||
|
||||
const jwCfg = { file: null }
|
||||
const jwBase = {
|
||||
getContainer: () => doc.getElementById('player'),
|
||||
getState: () => 'idle',
|
||||
load: (cfg) => {
|
||||
if (cfg?.file) jwCfg.file = cfg.file
|
||||
},
|
||||
setConfig: (cfg) => {
|
||||
if (cfg?.file) jwCfg.file = cfg.file
|
||||
},
|
||||
getConfig: () => jwCfg,
|
||||
setup: () => {},
|
||||
on: () => {},
|
||||
play: () => {},
|
||||
getPlaylistItem: () => jwCfg,
|
||||
getPlaylist: () => (jwCfg.file ? [{ file: jwCfg.file }] : []),
|
||||
}
|
||||
window.__wasm_jw_player = new Proxy(jwBase, {
|
||||
get(target, prop, receiver) {
|
||||
if (Reflect.has(target, prop)) return Reflect.get(target, prop, receiver)
|
||||
if (prop === Symbol.toStringTag) return 'Object'
|
||||
return () => null
|
||||
},
|
||||
})
|
||||
window.jwplayer = () => window.__wasm_jw_player
|
||||
|
||||
globalThis.window = window
|
||||
globalThis.document = doc
|
||||
globalThis.location = window.location
|
||||
globalThis.self = window
|
||||
globalThis.atob = (s) => Buffer.from(s, 'base64').toString('binary')
|
||||
globalThis.btoa = (s) => Buffer.from(s, 'binary').toString('base64')
|
||||
globalThis.TextDecoder = TextDecoder
|
||||
globalThis.TextEncoder = TextEncoder
|
||||
|
||||
const NativeRequest = globalThis.Request
|
||||
const NativeResponse = globalThis.Response
|
||||
const NativeHeaders = globalThis.Headers
|
||||
const NativeUrl = globalThis.URL
|
||||
|
||||
globalThis.URL = class extends NativeUrl {
|
||||
constructor(input, base) {
|
||||
if (input === '/fetch') input = `${embedOrigin}/fetch`
|
||||
super(input, base ?? `${embedOrigin}/`)
|
||||
}
|
||||
}
|
||||
globalThis.Request = class extends NativeRequest {
|
||||
constructor(input, init) {
|
||||
if (input === '/fetch') input = `${embedOrigin}/fetch`
|
||||
super(input, init)
|
||||
}
|
||||
}
|
||||
window.URL = globalThis.URL
|
||||
window.Request = globalThis.Request
|
||||
window.Response = NativeResponse
|
||||
window.Headers = NativeHeaders
|
||||
|
||||
return NativeResponse
|
||||
}
|
||||
|
||||
function mockFetch(NativeResponse, goat, body, onM3u8) {
|
||||
return async (input) => {
|
||||
const href = typeof input === 'string' ? input : input?.url ?? String(input)
|
||||
if (href.includes('lock.wasm')) {
|
||||
return new NativeResponse(wasmBytes, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/wasm' },
|
||||
})
|
||||
}
|
||||
if (href.includes('/fetch')) {
|
||||
return new NativeResponse(body, {
|
||||
status: 200,
|
||||
headers: { goat, 'Content-Type': 'application/octet-stream' },
|
||||
})
|
||||
}
|
||||
if (href.includes('.m3u8')) {
|
||||
onM3u8(href)
|
||||
return new NativeResponse('#EXTM3U\n#EXT-X-VERSION:3\n', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/vnd.apple.mpegurl' },
|
||||
})
|
||||
}
|
||||
return new NativeResponse('', { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
function patchImports(imports, NativeResponse, goat, body, onM3u8) {
|
||||
const bg = imports?.['./locked_bg.js']
|
||||
if (!bg) return
|
||||
|
||||
for (const key of Object.keys(bg)) {
|
||||
if (!key.includes('instanceof')) continue
|
||||
const orig = bg[key]
|
||||
bg[key] = (...args) => (orig(...args) ? 1 : 1)
|
||||
}
|
||||
|
||||
const fetchKey = Object.keys(bg).find((k) => k.includes('fetch_e6e8e0'))
|
||||
if (!fetchKey) return
|
||||
|
||||
bg[fetchKey] = (_win, req) => {
|
||||
const href = req?.url ?? ''
|
||||
if (href.includes('/fetch')) {
|
||||
return Promise.resolve(
|
||||
new NativeResponse(body, {
|
||||
status: 200,
|
||||
headers: { goat, 'Content-Type': 'application/octet-stream' },
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (href.includes('.m3u8')) {
|
||||
onM3u8(href)
|
||||
return Promise.resolve(
|
||||
new NativeResponse('#EXTM3U\n#EXT-X-VERSION:3\n', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/vnd.apple.mpegurl' },
|
||||
}),
|
||||
)
|
||||
}
|
||||
return Promise.reject(new Error(`unexpected wasm fetch ${href}`))
|
||||
}
|
||||
}
|
||||
|
||||
async function crack(slot, goat, bodyHex) {
|
||||
let m3u8 = null
|
||||
const body = Buffer.from(bodyHex, 'hex')
|
||||
const NativeResponse = mountDom(slot)
|
||||
const fetchFn = mockFetch(NativeResponse, goat, body, (url) => {
|
||||
m3u8 = url
|
||||
})
|
||||
globalThis.fetch = fetchFn
|
||||
|
||||
const origInstantiate = WebAssembly.instantiate.bind(WebAssembly)
|
||||
|
||||
WebAssembly.instantiate = async (source, imports) => {
|
||||
patchImports(imports, NativeResponse, goat, body, (url) => {
|
||||
m3u8 = url
|
||||
})
|
||||
if (!(source instanceof ArrayBuffer) && !ArrayBuffer.isView(source)) {
|
||||
source = wasmBytes.buffer.slice(wasmBytes.byteOffset, wasmBytes.byteOffset + wasmBytes.byteLength)
|
||||
}
|
||||
return origInstantiate(source, imports)
|
||||
}
|
||||
WebAssembly.instantiateStreaming = async (_resp, imports) => WebAssembly.instantiate(wasmBytes, imports)
|
||||
|
||||
const mod = await import(lockModuleUrl)
|
||||
const api = await mod.default({
|
||||
module_or_path: `${embedOrigin}/js/wasm/lock.wasm`,
|
||||
fetch: fetchFn,
|
||||
})
|
||||
await api.init_wasm?.()
|
||||
|
||||
WebAssembly.instantiate = origInstantiate
|
||||
delete WebAssembly.instantiateStreaming
|
||||
|
||||
try {
|
||||
await api.set_stream_jw(slot.source, slot.id, slot.stream)
|
||||
} catch (err) {
|
||||
if (!m3u8) throw err
|
||||
}
|
||||
if (!m3u8) throw new Error('lock did not yield m3u8')
|
||||
return m3u8
|
||||
}
|
||||
|
||||
const { slot, goat, bodyHex } = workerData
|
||||
crack(slot, goat, bodyHex)
|
||||
.then((url) => parentPort.postMessage({ ok: true, url }))
|
||||
.catch((err) => parentPort.postMessage({ ok: false, error: String(err.message || err) }))
|
||||
20
src/sources/goat/lock.js
Normal file
20
src/sources/goat/lock.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Worker } from 'node:worker_threads'
|
||||
|
||||
const workerUrl = new URL('./lock-worker.js', import.meta.url)
|
||||
|
||||
export function unlock(slot, goat, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(workerUrl, {
|
||||
workerData: { slot, goat, bodyHex: body.toString('hex') },
|
||||
})
|
||||
worker.once('message', (msg) => {
|
||||
worker.terminate().catch(() => {})
|
||||
if (msg.ok) resolve(msg.url)
|
||||
else reject(new Error(msg.error || 'lock decrypt failed'))
|
||||
})
|
||||
worker.once('error', (err) => {
|
||||
worker.terminate().catch(() => {})
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
25
src/sources/goat/proto.js
Normal file
25
src/sources/goat/proto.js
Normal file
@@ -0,0 +1,25 @@
|
||||
function varint(n) {
|
||||
const bytes = []
|
||||
let v = n
|
||||
while (v > 0x7f) {
|
||||
bytes.push((v & 0x7f) | 0x80)
|
||||
v >>>= 7
|
||||
}
|
||||
bytes.push(v)
|
||||
return Buffer.from(bytes)
|
||||
}
|
||||
|
||||
function fieldStr(out, field, value) {
|
||||
const body = Buffer.from(String(value), 'utf8')
|
||||
out.push(Buffer.from([(field << 3) | 2]))
|
||||
out.push(varint(body.length))
|
||||
out.push(body)
|
||||
}
|
||||
|
||||
export function encodeBody({ source, id, stream }) {
|
||||
const out = []
|
||||
fieldStr(out, 1, source)
|
||||
fieldStr(out, 2, id)
|
||||
fieldStr(out, 3, stream)
|
||||
return Buffer.concat(out)
|
||||
}
|
||||
6
src/sources/goat/vendor/lock-esm.mjs
vendored
Normal file
6
src/sources/goat/vendor/lock-esm.mjs
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
src/sources/goat/vendor/lock.wasm
vendored
Normal file
BIN
src/sources/goat/vendor/lock.wasm
vendored
Normal file
Binary file not shown.
32
src/sources/golf/resolve.js
Normal file
32
src/sources/golf/resolve.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { fetchHeaders } from '../../wire/headers.js'
|
||||
|
||||
export const relayReferer = 'https://exposestrat.com/'
|
||||
|
||||
function iframeUrl(html) {
|
||||
const m = html.match(/iframe src="([^"]+)"/)
|
||||
if (!m) throw new Error('golf embed iframe not found')
|
||||
return m[1].replace(/&/g, '&')
|
||||
}
|
||||
|
||||
function fid(html) {
|
||||
const m = html.match(/fid="([^"]+)"/)
|
||||
if (!m) throw new Error('golf stream fid not found')
|
||||
return m[1]
|
||||
}
|
||||
|
||||
function m3u8Url(html) {
|
||||
const m = html.match(/return\(\[("[^"]+"(?:,"[^"]+")*)\]\.join\(""\)/)
|
||||
if (!m) throw new Error('golf m3u8 url not found')
|
||||
return JSON.parse(`[${m[1]}]`).join('')
|
||||
}
|
||||
|
||||
export async function resolve(slot) {
|
||||
const embedUrl = `${slot.origin}/embed/${slot.path}`
|
||||
const embedHtml = await (await fetch(embedUrl, { headers: fetchHeaders(`${slot.origin}/`) })).text()
|
||||
const streamedUrl = iframeUrl(embedHtml)
|
||||
const streamedHtml = await (await fetch(streamedUrl, { headers: fetchHeaders(embedUrl) })).text()
|
||||
const live = fid(streamedHtml)
|
||||
const playerUrl = `https://exposestrat.com/maestrohd1.php?player=desktop&live=${encodeURIComponent(live)}`
|
||||
const playerHtml = await (await fetch(playerUrl, { headers: fetchHeaders(streamedUrl) })).text()
|
||||
return m3u8Url(playerHtml)
|
||||
}
|
||||
10
src/streamed/api.js
Normal file
10
src/streamed/api.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { fetchHeaders } from '../wire/headers.js'
|
||||
import { streamedOrigin } from '../env.js'
|
||||
|
||||
export async function fetchJson(path) {
|
||||
const res = await fetch(`${streamedOrigin}${path}`, {
|
||||
headers: fetchHeaders(undefined, { Accept: 'application/json' }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`streamed.pk ${path} ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
15
src/streamed/match.js
Normal file
15
src/streamed/match.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { fetchJson } from './api.js'
|
||||
|
||||
export async function fetchLinks(source, id) {
|
||||
const links = await fetchJson(`/api/stream/${encodeURIComponent(source)}/${encodeURIComponent(id)}`)
|
||||
if (!Array.isArray(links) || links.length === 0) throw new Error('no streams returned for source')
|
||||
return links
|
||||
}
|
||||
|
||||
export function pickLink(links, streamNo) {
|
||||
if (streamNo == null || streamNo === '') throw new Error('stream number required')
|
||||
const wanted = Number(streamNo)
|
||||
const picked = links.find((link) => Number(link.streamNo) === wanted)
|
||||
if (!picked) throw new Error(`stream ${streamNo} not found`)
|
||||
return picked
|
||||
}
|
||||
74
src/streamed/watch.js
Normal file
74
src/streamed/watch.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { embedOrigin, streamedOrigin } from '../env.js'
|
||||
import { fetchJson } from './api.js'
|
||||
import { fetchLinks, pickLink } from './match.js'
|
||||
import { makeSlot } from '../resolve/slot.js'
|
||||
|
||||
const watchRe = /^\/watch\/([^/]+)\/([^/]+)\/(\d+)\/?$/
|
||||
|
||||
async function loadMatch(matchId) {
|
||||
const sports = await fetchJson('/api/sports')
|
||||
if (!Array.isArray(sports)) throw new Error('streamed.pk /api/sports invalid response')
|
||||
|
||||
for (const sport of sports) {
|
||||
if (!sport?.id) continue
|
||||
|
||||
for (const endpoint of [`/api/matches/${encodeURIComponent(sport.id)}`, `/api/matches/${encodeURIComponent(sport.id)}/popular`]) {
|
||||
const list = await fetchJson(endpoint)
|
||||
if (!Array.isArray(list)) continue
|
||||
const match = list.find((item) => item.id === matchId)
|
||||
if (match) return match
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`match not found: ${matchId}`)
|
||||
}
|
||||
|
||||
async function loadLinks(matchId, source, stream) {
|
||||
const match = await loadMatch(matchId)
|
||||
const sources = match.sources ?? []
|
||||
const candidates = [source, ...sources.map((item) => item.source)].filter(Boolean)
|
||||
let lastError = null
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const src = candidate === source ? pickSource(match, candidate) : sources.find((item) => item.source === candidate)
|
||||
if (!src?.source || !src?.id) continue
|
||||
|
||||
const link = pickLink(await fetchLinks(src.source, src.id), stream)
|
||||
return { match, link }
|
||||
} catch (err) {
|
||||
lastError = err
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`source ${source} not found`)
|
||||
}
|
||||
|
||||
function pickSource(match, name) {
|
||||
const sources = match.sources ?? []
|
||||
if (!sources.length) throw new Error('match has no stream sources')
|
||||
const picked = sources.find((item) => item.source === name)
|
||||
if (!picked) throw new Error(`source ${name} not found`)
|
||||
return picked
|
||||
}
|
||||
|
||||
function watchLink(matchId, source, stream) {
|
||||
return `${streamedOrigin}/watch/${matchId}/${source}/${Number(stream)}`
|
||||
}
|
||||
|
||||
export function parseWatchPath(pathname) {
|
||||
const m = pathname.match(watchRe)
|
||||
if (!m) return null
|
||||
return { matchId: m[1], source: m[2], stream: m[3] }
|
||||
}
|
||||
|
||||
export async function loadWatch(matchId, source, stream) {
|
||||
const { match, link } = await loadLinks(matchId, source, stream)
|
||||
return {
|
||||
matchId: match.id,
|
||||
title: match.title,
|
||||
watchUrl: watchLink(match.id, link.source, link.streamNo),
|
||||
embedUrl: link.embedUrl,
|
||||
slot: makeSlot(embedOrigin, link.source, link.id, link.streamNo),
|
||||
}
|
||||
}
|
||||
34
src/wire/curl.js
Normal file
34
src/wire/curl.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { fetchHeaders } from './headers.js'
|
||||
|
||||
const exec = promisify(execFile)
|
||||
|
||||
function hdrs(slot) {
|
||||
const referer = slot.referer || `${slot.origin}/embed/${slot.path}`
|
||||
return {
|
||||
...fetchHeaders(referer),
|
||||
Origin: slot.referer ? new URL(referer).origin : slot.origin,
|
||||
Accept: '*/*',
|
||||
}
|
||||
}
|
||||
|
||||
function curlArgs(url, headers) {
|
||||
const args = ['-sS', '-L', '--compressed']
|
||||
for (const [key, value] of Object.entries(headers)) args.push('-H', `${key}: ${value}`)
|
||||
args.push(url)
|
||||
return args
|
||||
}
|
||||
|
||||
export async function pull(url, slot) {
|
||||
const headers = hdrs(slot)
|
||||
const args = curlArgs(url, headers)
|
||||
args.push('-o', '-', '-w', 'HTTPSTATUS:%{http_code}')
|
||||
const { stdout } = await exec('curl', args, { maxBuffer: 64 * 1024 * 1024, encoding: 'buffer' })
|
||||
const mark = stdout.lastIndexOf(Buffer.from('HTTPSTATUS:'))
|
||||
if (mark < 0) throw new Error('curl response parse failed')
|
||||
const body = stdout.subarray(0, mark)
|
||||
const code = Number(stdout.subarray(mark + 11).toString('utf8'))
|
||||
if (code < 200 || code >= 300) throw new Error(`upstream ${code}`)
|
||||
return body
|
||||
}
|
||||
7
src/wire/headers.js
Normal file
7
src/wire/headers.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ua } from '../env.js'
|
||||
|
||||
export function fetchHeaders(referer, extra = {}) {
|
||||
const headers = { 'User-Agent': ua, ...extra }
|
||||
if (referer) headers.Referer = referer
|
||||
return headers
|
||||
}
|
||||
175
test/api.test.js
Normal file
175
test/api.test.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { once } from 'node:events'
|
||||
import { buildStreamEntry } from '../src/app.js'
|
||||
|
||||
function makeJsonResponse(body, init = {}) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: init.status || 200,
|
||||
headers: { 'content-type': 'application/json', ...(init.headers || {}) },
|
||||
})
|
||||
}
|
||||
|
||||
test('root responds with plain text logo', async () => {
|
||||
const { default: app } = await import('../src/app.js')
|
||||
const server = createServer(app)
|
||||
await once(server.listen(0, '127.0.0.1'), 'listening')
|
||||
const address = server.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/`)
|
||||
assert.equal(response.status, 200)
|
||||
assert.match(response.headers.get('content-type') ?? '', /text\/plain/)
|
||||
const text = await response.text()
|
||||
assert.match(text, /developed_by: @vyla-entertainment/)
|
||||
assert.doesNotMatch(text, /<html/i)
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
})
|
||||
|
||||
test('api routes fall back to the match source when the requested source is empty', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const calls = []
|
||||
|
||||
globalThis.fetch = async (input) => {
|
||||
const url = String(input)
|
||||
calls.push(url)
|
||||
|
||||
if (url.endsWith('/api/sports')) {
|
||||
return makeJsonResponse([{ id: 'football', name: 'Football' }])
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/matches/football')) {
|
||||
return makeJsonResponse([
|
||||
{
|
||||
id: 'sporting-cp-vs-strasbourg-game-399075',
|
||||
title: 'Sporting CP vs Strasbourg',
|
||||
category: 'football',
|
||||
startAt: '2026-07-19T00:00:00Z',
|
||||
sources: [{ source: 'echo', id: 'sporting-cp-vs-strasbourg-game-399075' }],
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/matches/football/popular')) {
|
||||
return makeJsonResponse([])
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/stream/admin/sporting-cp-vs-strasbourg-game-399075')) {
|
||||
return makeJsonResponse([])
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/stream/echo/sporting-cp-vs-strasbourg-game-399075')) {
|
||||
return makeJsonResponse([
|
||||
{
|
||||
id: 'sporting-cp-vs-strasbourg-game-399075',
|
||||
streamNo: 1,
|
||||
language: 'English',
|
||||
hd: true,
|
||||
embedUrl: 'https://embed.example/1',
|
||||
source: 'echo',
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${url}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const { default: app } = await import('../src/app.js')
|
||||
const server = createServer(app)
|
||||
await once(server.listen(0, '127.0.0.1'), 'listening')
|
||||
const address = server.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
|
||||
try {
|
||||
const sports = await originalFetch(`http://127.0.0.1:${port}/api/sports`)
|
||||
assert.equal(sports.status, 200)
|
||||
assert.deepEqual(await sports.json(), [{ id: 'football', name: 'Football' }])
|
||||
|
||||
const matches = await originalFetch(`http://127.0.0.1:${port}/api/matches/football`)
|
||||
assert.equal(matches.status, 200)
|
||||
const matchJson = await matches.json()
|
||||
assert.equal(matchJson[0].id, 'sporting-cp-vs-strasbourg-game-399075')
|
||||
assert.deepEqual(matchJson[0].sources, [{ source: 'echo', id: 'sporting-cp-vs-strasbourg-game-399075' }])
|
||||
|
||||
const streams = await originalFetch(`http://127.0.0.1:${port}/api/stream/admin/sporting-cp-vs-strasbourg-game-399075`)
|
||||
assert.equal(streams.status, 200)
|
||||
const streamJson = await streams.json()
|
||||
assert.equal(streamJson[0].embedUrl, 'https://embed.example/1')
|
||||
|
||||
assert(calls.includes('https://streamed.pk/api/sports'))
|
||||
assert(calls.includes('https://streamed.pk/api/matches/football'))
|
||||
assert(calls.includes('https://streamed.pk/api/stream/admin/sporting-cp-vs-strasbourg-game-399075'))
|
||||
assert(calls.includes('https://streamed.pk/api/stream/echo/sporting-cp-vs-strasbourg-game-399075'))
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test('buildStreamEntry preserves the proxied relay URL', () => {
|
||||
const entry = buildStreamEntry(
|
||||
{ streamNo: 1, language: 'English', hd: true, embedUrl: 'https://embed.example/1', source: 'delta' },
|
||||
{ ok: true, relay: 'http://127.0.0.1:5000/api/m3u8-proxy?url=https%3A%2F%2Fupstream.example%2Fplaylist.m3u8' },
|
||||
'delta',
|
||||
'live-event_world-cup-champions-celebration-live-stream',
|
||||
)
|
||||
|
||||
assert.equal(entry.embedUrl, 'https://embed.example/1')
|
||||
assert.equal(entry.streamUrl, 'http://127.0.0.1:5000/api/m3u8-proxy?url=https%3A%2F%2Fupstream.example%2Fplaylist.m3u8')
|
||||
})
|
||||
|
||||
test('api hls route proxies playlists instead of falling through to 404', async () => {
|
||||
const upstream = createServer((req, res) => {
|
||||
if (req.url === '/playlist.m3u8') {
|
||||
assert.equal(req.headers.referer, 'https://embed.st/embed/echo/demo/1')
|
||||
assert.equal(req.headers.origin, 'https://embed.st')
|
||||
res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl' })
|
||||
res.end('#EXTM3U\n#EXT-X-VERSION:3\n#EXTINF:10,\nsegment.ts\n')
|
||||
return
|
||||
}
|
||||
|
||||
if (req.url === '/segment.ts') {
|
||||
res.writeHead(200, { 'content-type': 'video/mp2t' })
|
||||
res.end('segment-bytes')
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404)
|
||||
res.end('not found')
|
||||
})
|
||||
|
||||
await once(upstream.listen(0, '127.0.0.1'), 'listening')
|
||||
const upstreamAddress = upstream.address()
|
||||
const upstreamPort = typeof upstreamAddress === 'object' && upstreamAddress ? upstreamAddress.port : 0
|
||||
|
||||
try {
|
||||
const { default: app } = await import('../src/app.js')
|
||||
const server = createServer(app)
|
||||
await once(server.listen(0, '127.0.0.1'), 'listening')
|
||||
const address = server.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/hls?url=${encodeURIComponent(`http://127.0.0.1:${upstreamPort}/playlist.m3u8`)}&embed=echo%2Fdemo%2F1&embedOrigin=${encodeURIComponent('https://embed.st')}`,
|
||||
)
|
||||
|
||||
assert.equal(response.status, 200)
|
||||
assert.match(response.headers.get('content-type') ?? '', /mpegurl/i)
|
||||
const text = await response.text()
|
||||
assert.match(text, /#EXTM3U/)
|
||||
assert.match(text, /\/api\/hls\?url=/)
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
} finally {
|
||||
await new Promise((resolve) => upstream.close(resolve))
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user