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