Node.js implementation of the Ployan embed → HLS handshake. Zero npm runtime dependencies.
Example embed page: flixhqz.com/movie/scream-7-1630860919/
- Introduction
- What gets decrypted
- How decryption works
- How the stream is resolved
- Why it cannot play directly in the browser
- How the proxy works
- Stack
- Code map
- REST API
- Disclaimer
A watch page like https://flixhqz.com/movie/scream-7-1630860919/ is not the stream. It is a shell — title, layout, mediaId, and JavaScript that mounts the Ployan player. The HLS playlist URL never appears in the HTML. The player fetches it from a separate API host after passing an encrypted session token.
Three origins are involved:
| Layer | Role |
|---|---|
Embed site (flixhqz.com) |
Serves HTML, exposes mediaId |
Player API (from plyURL) |
Validates token, returns info |
| CDN (inside M3U8 responses) | Serves .ts HLS segments |
flowchart LR
A[Embed URL] --> B[Scrape HTML]
B --> C[Decode plyURL]
B --> D[Read mediaId]
C --> E[Seal token]
D --> E
E --> F["GET /get/{token}"]
F --> G[master.m3u8 URL]
G --> H[VLC / MPV / Proxy]
This repository reproduces that chain server-side and exposes it via GET /api/stream, GET /api/proxy, and a browser UI.
Ployan hides two values. Both get called “decryption” in writeups — they are different operations.
plyURL |
Session token | |
|---|---|---|
| Transform | Base64 decode | AES-256-GCM seal |
| Hidden value | Player API hostname | {mediaId}+{episode}+1+{timestamp} |
| Direction | Obfuscated string → https://… origin |
Plaintext → hex blob in /get/{token} |
| Who reverses it | Anyone with the HTML | Ployan server decrypts; this repo re-seals |
Not decrypted anywhere in this project: /get JSON responses, M3U8 playlist text, or video segments.
The player API host is stored as base64 in page JavaScript:
const plyURL = "aHR0cHM6Ly9wbGF5ZXIuZXhhbXBsZS8=";Obfuscation only — no key, no salt.
const PLY_RX = /const\s+plyURL\s*=\s*["']([A-Za-z0-9+/=]+)["']/;Match → pad → Buffer.from(…, "base64") → strip trailing slash → origin.
The Ployan client encrypts a payload before GET {origin}/get/{token}. This repo runs the same seal in src/ployan/hls.js.
Plaintext
{mediaId}+{episode}+1+{unixTimestamp}
| Field | Value |
|---|---|
episode |
Defaults to 1 |
middle 1 |
Hardcoded server slot |
unixTimestamp |
Math.floor(Date.now() / 1000) — stale tokens are rejected |
PBKDF2-SHA256
| Parameter | Value |
|---|---|
| Password | "player" |
| Salt | 8 random bytes |
| Iterations | 1000 |
| Key length | 32 bytes |
AES-256-GCM
| Parameter | Value |
|---|---|
| IV | 12 random bytes |
| Auth tag | 16 bytes, appended to ciphertext |
Wire format: {saltHex}-{ivHex}-{ciphertextHex}{tagHex}
function seal(plain) {
const salt = randomBytes(8);
const key = pbkdf2Sync("player", salt, 1000, 32, "sha256");
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const body = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
return `${salt.toString("hex")}-${iv.toString("hex")}-${body.toString("hex")}${cipher.getAuthTag().toString("hex")}`;
}
function token(mediaId, episode) {
return seal(`${mediaId}+${episode}+1+${Math.floor(Date.now() / 1000)}`);
}The Ployan server splits on -, re-derives the key from salt + "player", decrypts, verifies the GCM tag, and validates the fields. Generate a new token per resolve.
src/route/stream.js orchestrates:
scrape(url) → hls({ origin, mediaId, episode }) → { title, url, mode }
Scrape (src/embed/scrape.js) — one GET on the embed URL with Referer: {page-origin}/ and headers from src/env.js:
| Field | Source |
|---|---|
mediaId |
#mid / #watch-block data-id, or URL -{digits} suffix |
origin |
Decoded plyURL |
title |
<title> before | |
/get (src/ployan/hls.js) — sealed token from the previous section:
GET {origin}/get/{token}
Referer: {origin}/
M3U8 URL — when response has mode: "direct" and info:
{origin}/hls/{info}/master.m3u8
From there, standard HLS: master.m3u8 → media playlist → .ts segments on a CDN. VLC, MPV, and Safari handle that chain natively. This repo stops at the master URL. Other mode values return no playlist (url: null).
The resolved M3U8 URL works in VLC and MPV without this server. In-browser playback does not.
HLS support — only Safari plays HLS natively. Chrome and Firefox need hls.js (public/ui.js).
Cross-origin — M3U8 and .ts segments live on the player CDN, not on the UI origin. Browsers block or restrict those fetches (CORS, missing upstream headers).
src/route/proxy.js serves GET /api/proxy?url={absolute-url}.
- Fetch upstream with
Referer: {upstream-origin}/. - If response is M3U8 (
Content-Type,.m3u8path, or#EXTM3Uheader):- Rewrite media lines and
URI="…"tags to{host}/api/proxy?url={encoded-upstream}.
- Rewrite media lines and
- Otherwise pass binary bytes through unchanged (
.ts,.m4s, keys).
The browser only talks to your host. The UI plays through the proxied URL; external players can use the direct M3U8 from /api/stream instead.
| Role | Technology |
|---|---|
plyURL decode |
Buffer base64 |
| Token seal | node:crypto — randomBytes, pbkdf2Sync, createCipheriv("aes-256-gcm") |
| Runtime | Node.js ≥ 22, ES modules |
| HTTP | node:http, native fetch |
| Browser HLS (UI) | hls.js 1.5.20 from jsDelivr |
| Variable | Default | Purpose |
|---|---|---|
PORT |
3000 |
Listen port |
HOST |
all interfaces | Bind when set |
USER_AGENT |
Chrome Android mobile | Upstream identity |
npm start
| File | Export |
|---|---|
src/server.js |
HTTP entry |
src/env.js |
headers(), port |
src/net/fetch.js |
text(), json(), bytes() |
src/embed/scrape.js |
scrape() |
src/ployan/hls.js |
hls() |
src/route/stream.js |
onStream() |
src/route/proxy.js |
onProxy() |
public/ui.js |
UI playback |
| Query | Required | Description |
|---|---|---|
url |
yes | Embed page URL |
episode |
no | Token payload episode (default 1) |
200
{
"title": "Movie Title",
"url": "https://player.example/hls/abc123/master.m3u8",
"mode": "direct"
}Missing url query → 400. Upstream failure → 502 { "error": "…" }. CORS: *.
| Query | Required | Description |
|---|---|---|
url |
yes | Absolute upstream URL (M3U8 or segment) |
Returns rewritten M3U8 or raw bytes.
This project does not host, store, or distribute media. Embed sites and Ployan player hosts are independent services. The resolver reads public embed HTML and calls their APIs the same way a browser would.
You are responsible for complying with copyright law, site terms of service, and local regulations. No warranty. Use only on content you have the right to access.