P-Stream Userscript

A P-Stream compatible userscript

As of 2026-04-26. See the latest version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         P-Stream Userscript
// @namespace    groknt
// @version      2.0.1
// @description  A P-Stream compatible userscript
// @author       groknt
// @license      MIT
// @match        *://pstream.net/*
// @match        *://aether.mom/*
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @run-at       document-start
// @connect      *
// ==/UserScript==

(function (GM) {
  "use strict";

  const CONFIG = {
    extensionVersion: "1.5.0",
    logPrefix: "P-Stream:",
    maxMediaSize: {
      enabled: false,
      value: 100 * 1024 * 1024,
    },
  };

  const win = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
  if (!GM) console.warn(`${CONFIG.logPrefix} GM_xmlhttpRequest missing - proxy will fail`);

  const pageOrigin = win.location.origin !== "null" ? win.location.origin : new URL(win.location.href).origin || "*";
  const rules = new Map();
  const mediaBlobs = new WeakMap();
  const activeMediaReqs = new WeakMap();
  let patchStatus = false;

  const RE_STREAM_MANIFEST = /\.(m3u8|mpd|ism|isml)([\?\/#]|$)/i;
  const RE_STREAM_MEDIA = /\.(mp4|m4v|m4a|webm|mkv|mov|avi|wmv|mp3|ogg|ogv|flac|wav)([\?\/#]|$)/i;
  const RE_CONTENT_MEDIA = /video|audio/i;

  const MODIFIABLE_HEADERS = new Set([
    "access-control-allow-origin",
    "access-control-allow-methods",
    "access-control-allow-headers",
    "content-security-policy",
    "content-security-policy-report-only",
    "content-disposition",
  ]);

  const normalizeUrl = (url) => {
    try {
      return new URL(url, win.location.href).href;
    } catch {
      return String(url);
    }
  };

  const getRule = (url) => {
    if (!url) return null;
    let u;
    try {
      u = new URL(url, win.location.href);
    } catch {
      return null;
    }

    const hostname = u.hostname;
    const href = u.href;

    for (const rule of rules.values()) {
      if (rule.targetDomains) {
        for (let i = 0; i < rule.targetDomains.length; i++) {
          const d = rule.targetDomains[i];
          if (hostname === d || hostname.endsWith(`.${d}`)) return rule;
        }
      }
      if (rule._compiledRegex && rule._compiledRegex.test(href)) return rule;
    }
    return null;
  };

  const parseHeaders = (raw) => {
    const h = new Headers();
    if (!raw) return h;
    const lines = raw.split("\n");
    for (let i = 0; i < lines.length; i++) {
      const idx = lines[i].indexOf(":");
      if (idx > 0) h.append(lines[i].slice(0, idx).trim(), lines[i].slice(idx + 1).trim());
    }
    return h;
  };

  const shouldIncludeCredentials = (url, mode, force) => {
    if (force || mode === "include") return true;
    if (mode === "omit") return false;
    try {
      return new URL(url).origin === pageOrigin;
    } catch {
      return false;
    }
  };

  const isMediaStream = (url, headersObj) => {
    if (RE_STREAM_MANIFEST.test(url)) return false;

    for (const k in headersObj) {
      if (k.toLowerCase() === "range" && headersObj[k].startsWith("bytes=")) return false;
    }

    if (RE_STREAM_MEDIA.test(url)) return true;

    return false;
  };

  const gmFetch = (opts) =>
    new Promise((resolve, reject) => {
      opts.headers = opts.headers || {};
      let hasReferer = false;
      let hasOrigin = false;

      for (const k in opts.headers) {
        const lowerK = k.toLowerCase();
        if (lowerK === "referer") hasReferer = true;
        if (lowerK === "origin") hasOrigin = true;
      }

      if (!hasReferer) opts.headers["Referer"] = win.location.href;
      if (!hasOrigin) opts.headers["Origin"] = pageOrigin;

      const { signal, ...gmOpts } = opts;
      let req;
      let abortHandler;

      const cleanup = () => {
        if (signal && abortHandler) {
          signal.removeEventListener("abort", abortHandler);
        }
        req = null;
      };

      if (signal && signal.aborted) {
        return reject(new Error("Aborted"));
      }

      req = GM({
        ...gmOpts,
        onload: (res) => {
          cleanup();
          resolve(res);
        },
        onerror: (e) => {
          cleanup();
          reject(new Error(e?.error || e?.message || "Network Error"));
        },
        ontimeout: () => {
          cleanup();
          reject(new Error("Timeout"));
        },
        onabort: () => {
          cleanup();
          reject(new Error("Aborted"));
        },
      });

      if (signal) {
        abortHandler = () => {
          if (req && typeof req.abort === "function") req.abort();
          cleanup();
          reject(new Error("Aborted"));
        };
        signal.addEventListener("abort", abortHandler);
      }
    });

  const patchFetch = () => {
    const origFetch = win.fetch;
    win.fetch = async (input, init = {}) => {
      const isReq = typeof input === "object" && input instanceof Request;
      const rawUrl = isReq ? input.url : input instanceof URL ? input.href : String(input);
      const urlStr = normalizeUrl(rawUrl);
      const rule = getRule(urlStr);

      if (!rule) return origFetch(input, init);

      const method = init.method || (isReq ? input.method : "GET");
      const reqHeaders = new Headers();

      if (isReq && input.headers) {
        input.headers.forEach((v, k) => reqHeaders.set(k, v));
      }
      if (init.headers) {
        new Headers(init.headers).forEach((v, k) => reqHeaders.set(k, v));
      }

      if (rule.requestHeaders) {
        for (const k in rule.requestHeaders) reqHeaders.set(k, rule.requestHeaders[k]);
      }

      const gmHeaders = Object.fromEntries(reqHeaders.entries());
      if (isMediaStream(urlStr, gmHeaders)) return origFetch(input, { ...init, headers: gmHeaders });

      let body = init.body;
      if (body !== undefined && typeof ReadableStream !== "undefined" && body instanceof ReadableStream) {
        try {
          body = await new Response(body).arrayBuffer();
        } catch {}
      } else if (body === undefined && isReq && method !== "GET" && method !== "HEAD") {
        try {
          body = await input.clone().arrayBuffer();
        } catch {}
      }

      const credMode = init.credentials || (isReq ? input.credentials : undefined);

      try {
        const res = await gmFetch({
          method,
          url: urlStr,
          headers: gmHeaders,
          data: body,
          responseType: "arraybuffer",
          withCredentials: shouldIncludeCredentials(urlStr, credMode),
          signal: init.signal || (isReq ? input.signal : undefined),
        });

        const resHeaders = new Headers();
        const parsedResHeaders = parseHeaders(res.responseHeaders);
        parsedResHeaders.forEach((v, k) => {
          if (!MODIFIABLE_HEADERS.has(k)) resHeaders.set(k, v);
        });

        if (rule.responseHeaders) {
          for (const k in rule.responseHeaders) resHeaders.set(k, rule.responseHeaders[k]);
        }
        resHeaders.set("access-control-allow-origin", "*");

        return new Response(res.response, { status: res.status, statusText: res.statusText, headers: resHeaders });
      } catch (e) {
        if (e.message !== "Aborted") console.warn(`${CONFIG.logPrefix} Fetch proxy failed`, e);
        return origFetch(input, init);
      }
    };
  };

  const patchXhr = () => {
    const OrigXHR = win.XMLHttpRequest;
    win.XMLHttpRequest = class XhrProxy extends OrigXHR {
      constructor() {
        super();
        this._st = null;
      }
      open(method, url, ...args) {
        const urlStr = normalizeUrl(url instanceof URL ? url.href : String(url));
        const rule = getRule(urlStr);
        if (rule) {
          this._st = {
            rule,
            method,
            url: urlStr,
            reqHeaders: {},
            resHeaders: new Headers(),
            readyState: 1,
            status: 0,
            statusText: "",
            response: null,
            responseText: "",
            responseURL: urlStr,
            controller: new AbortController(),
          };
          this.dispatchEvent(new Event("readystatechange"));
        } else {
          this._st = null;
          super.open(method, url, ...args);
        }
      }
      setRequestHeader(name, value) {
        if (this._st) this._st.reqHeaders[name] = value;
        else super.setRequestHeader(name, value);
      }
      abort() {
        if (this._st) {
          this._st.controller.abort();
          if (this._st.readyState > 0 && this._st.readyState < 4) {
            this._st.readyState = 0;
            this._st.status = 0;
            this.dispatchEvent(new Event("readystatechange"));
            this.dispatchEvent(new Event("abort"));
            this.dispatchEvent(new ProgressEvent("loadend", { lengthComputable: false, loaded: 0, total: 0 }));
          }
        }
        super.abort();
      }
      send(body) {
        if (!this._st) return super.send(body);

        const mergedHeaders = { ...this._st.rule.requestHeaders, ...this._st.reqHeaders };
        if (isMediaStream(this._st.url, mergedHeaders)) {
          this._st = null;
          return super.send(body);
        }

        const isBinary = this.responseType === "arraybuffer" || this.responseType === "blob";

        gmFetch({
          method: this._st.method,
          url: this._st.url,
          headers: mergedHeaders,
          data: body,
          responseType: isBinary ? "arraybuffer" : "text",
          timeout: this.timeout,
          withCredentials: shouldIncludeCredentials(this._st.url, this.withCredentials ? "include" : undefined, this.withCredentials),
          signal: this._st.controller.signal,
        })
          .then((res) => {
            if (!this._st || this._st.controller.signal.aborted) return;

            const st = this._st;
            st.resHeaders = parseHeaders(res.responseHeaders);
            st.status = res.status;
            st.statusText = res.statusText || "OK";
            st.responseURL = res.finalUrl || st.url;

            let data = res.response;
            if (this.responseType === "json") {
              try {
                data = JSON.parse(res.responseText);
              } catch {
                data = null;
              }
            } else if (this.responseType === "blob") {
              data = new Blob([res.response], { type: st.resHeaders.get("content-type") || "" });
            } else if (!isBinary) {
              data = res.responseText;
              st.responseText = data;
            }
            st.response = data;

            st.readyState = 2;
            this.dispatchEvent(new Event("readystatechange"));
            if (!this._st || this._st.readyState !== 2) return;

            st.readyState = 3;
            this.dispatchEvent(new Event("readystatechange"));
            if (!this._st || this._st.readyState !== 3) return;

            this.dispatchEvent(new ProgressEvent("progress", { lengthComputable: true, loaded: 1, total: 1 }));
            if (!this._st) return;

            st.readyState = 4;
            this.dispatchEvent(new Event("readystatechange"));
            this.dispatchEvent(new ProgressEvent("load", { lengthComputable: true, loaded: 1, total: 1 }));
            this.dispatchEvent(new ProgressEvent("loadend", { lengthComputable: true, loaded: 1, total: 1 }));
          })
          .catch((err) => {
            if (this._st && this._st.controller.signal.aborted) return;
            if (this._st) {
              this._st.readyState = 4;
              this._st.status = 0;
              this._st.statusText = "";
            }
            this.dispatchEvent(new Event("readystatechange"));
            this.dispatchEvent(new Event("error"));
            this.dispatchEvent(new ProgressEvent("loadend", { lengthComputable: false, loaded: 0, total: 0 }));
          });
      }

      get readyState() {
        return this._st ? this._st.readyState : super.readyState;
      }
      get status() {
        return this._st ? this._st.status : super.status;
      }
      get statusText() {
        return this._st ? this._st.statusText : super.statusText;
      }
      get response() {
        return this._st ? this._st.response : super.response;
      }
      get responseText() {
        return this._st ? this._st.responseText : super.responseText;
      }
      get responseURL() {
        return this._st ? this._st.responseURL : super.responseURL;
      }
      get responseXML() {
        if (!this._st) return super.responseXML;
        if (this.responseType !== "" && this.responseType !== "document") return null;
        if (this._st.responseXML !== undefined) return this._st.responseXML;
        try {
          const parser = new DOMParser();
          const contentType = this.getResponseHeader("content-type") || "";
          const mime = contentType.includes("xml") ? "application/xml" : "text/html";
          this._st.responseXML = parser.parseFromString(this._st.responseText, mime);
        } catch {
          this._st.responseXML = null;
        }
        return this._st.responseXML;
      }
      getAllResponseHeaders() {
        if (!this._st) return super.getAllResponseHeaders();
        const headers = [];
        this._st.resHeaders.forEach((v, k) => headers.push(`${k}: ${v}`));
        return headers.join("\r\n");
      }
      getResponseHeader(name) {
        if (!this._st) return super.getResponseHeader(name);
        return this._st.resHeaders.get(name);
      }
    };
  };

  const patchMedia = () => {
    const proto = win.HTMLMediaElement.prototype;
    const origSet = Object.getOwnPropertyDescriptor(proto, "src")?.set;
    const origSetAttr = proto.setAttribute;

    const cleanupElement = (el) => {
      if (mediaBlobs.has(el)) {
        URL.revokeObjectURL(mediaBlobs.get(el));
        mediaBlobs.delete(el);
      }
      if (activeMediaReqs.has(el)) {
        activeMediaReqs.get(el).abort();
        activeMediaReqs.delete(el);
      }
    };

    const intercept = async (el, val, setter) => {
      if (mediaBlobs.has(el) && mediaBlobs.get(el) === val) return;

      cleanupElement(el);
      setter.call(el, val);

      const urlStr = normalizeUrl(val);
      const rule = getRule(urlStr);
      if (!rule) return;

      const controller = new AbortController();
      activeMediaReqs.set(el, controller);

      try {
        if (!RE_STREAM_MANIFEST.test(urlStr)) {
          const head = await gmFetch({ method: "HEAD", url: urlStr, headers: rule.requestHeaders, signal: controller.signal });
          const map = parseHeaders(head.responseHeaders);
          const len = parseInt(map.get("content-length") || "0", 10);
          const contentType = map.get("content-type");
          const acceptRanges = map.get("accept-ranges");
          if (
            contentType &&
            RE_CONTENT_MEDIA.test(contentType) &&
            (acceptRanges?.includes("bytes") || (CONFIG.maxMediaSize.enabled && len > CONFIG.maxMediaSize.value) || isNaN(len))
          )
            return;
        }

        const res = await gmFetch({
          method: "GET",
          url: urlStr,
          headers: rule.requestHeaders,
          responseType: "arraybuffer",
          signal: controller.signal,
        });
        if (!res || el.src !== urlStr) return;

        const blob = new Blob([res.response], {
          type: parseHeaders(res.responseHeaders).get("content-type") || "application/octet-stream",
        });
        const url = URL.createObjectURL(blob);

        if (mediaBlobs.has(el)) URL.revokeObjectURL(mediaBlobs.get(el));
        mediaBlobs.set(el, url);
        setter.call(el, url);
      } catch (e) {
        if (e.message !== "Aborted") console.warn(`${CONFIG.logPrefix} Media proxy failed`, e);
      }
    };

    if (origSet) {
      Object.defineProperty(proto, "src", {
        set(v) {
          typeof v === "string" ? intercept(this, v, origSet) : origSet.call(this, v);
        },
      });
    }

    proto.setAttribute = function (n, v) {
      if (n.toLowerCase() === "src" && typeof v === "string") intercept(this, v, (u) => origSetAttr.call(this, "src", u));
      else origSetAttr.call(this, n, v);
    };

    const origRemoveAttr = proto.removeAttribute;
    proto.removeAttribute = function (n) {
      if (n.toLowerCase() === "src") cleanupElement(this);
      origRemoveAttr.call(this, n);
    };

    new MutationObserver((muts) => {
      for (let i = 0; i < muts.length; i++) {
        const removed = muts[i].removedNodes;
        for (let j = 0; j < removed.length; j++) {
          const n = removed[j];
          if (n instanceof HTMLMediaElement) {
            cleanupElement(n);
          } else if (n.nodeType === 1) {
            const children = n.querySelectorAll?.("video, audio");
            if (children) {
              for (let k = 0; k < children.length; k++) {
                cleanupElement(children[k]);
              }
            }
          }
        }
      }
    }).observe(document, { childList: true, subtree: true });
  };

  const handlers = {
    hello: () => ({
      success: true,
      version: CONFIG.extensionVersion,
      allowed: true,
      hasPermission: true,
    }),

    async makeRequest(body) {
      const url = new URL(body.url, body.baseUrl || win.location.href);
      if (body.query) {
        for (const k in body.query) url.searchParams.set(k, body.query[k]);
      }

      let data = body.body;
      const reqHeaders = { ...body.headers };

      if (body.bodyType === "FormData" && Array.isArray(data)) {
        const fd = new FormData();
        for (let i = 0; i < data.length; i++) fd.append(data[i][0], data[i][1]);
        data = fd;

        const ctKey = Object.keys(reqHeaders).find((k) => k.toLowerCase() === "content-type");
        if (ctKey) delete reqHeaders[ctKey];
      } else if (body.bodyType === "URLSearchParams") {
        data = new URLSearchParams(data);
      } else if (body.bodyType === "object") {
        data = JSON.stringify(data);
      }

      const res = await gmFetch({
        method: body.method || "GET",
        url: url.href,
        headers: reqHeaders,
        data,
        responseType: "arraybuffer",
        withCredentials: shouldIncludeCredentials(url.href, body.credentials, body.withCredentials),
      });

      const h = parseHeaders(res.responseHeaders);
      const headers = {};
      h.forEach((v, k) => (headers[k] = v));

      let resBody = new TextDecoder().decode(res.response);
      if (headers["content-type"]?.includes("json")) {
        try {
          resBody = JSON.parse(resBody);
        } catch {}
      }

      return { success: true, response: { statusCode: res.status, headers, finalUrl: res.finalUrl || url.href, body: resBody } };
    },

    async prepareStream(body) {
      const responseHeaders = {};
      if (body.responseHeaders) {
        for (const k in body.responseHeaders) {
          const lowerK = k.toLowerCase();
          if (MODIFIABLE_HEADERS.has(lowerK)) responseHeaders[lowerK] = body.responseHeaders[k];
        }
      }

      const rule = { ...body, responseHeaders };
      if (rule.targetRegex) {
        try {
          rule._compiledRegex = new RegExp(rule.targetRegex);
        } catch (e) {
          console.warn(`${CONFIG.logPrefix} Invalid rule regex`, rule.targetRegex);
        }
      }
      rules.set(body.ruleId, rule);

      if (!patchStatus) {
        patchFetch();
        patchXhr();
        patchMedia();
        patchStatus = true;
      }
      return { success: true };
    },

    openPage(body) {
      if (body?.redirectUrl) win.location.href = body.redirectUrl;
      return { success: true };
    },
  };

  win.addEventListener("message", async (e) => {
    const data = e.data;
    if (e.source !== win || !data || data.relayed || !handlers[data.name]) return;

    try {
      const result = await handlers[data.name](data.body);
      win.postMessage({ name: data.name, instanceId: data.instanceId, body: result, relayed: true }, pageOrigin);
    } catch (err) {
      win.postMessage(
        { name: data.name, instanceId: data.instanceId, body: { success: false, error: err.message }, relayed: true },
        pageOrigin,
      );
    }
  });
})(typeof GM_xmlhttpRequest !== "undefined" ? GM_xmlhttpRequest : GM?.xmlHttpRequest);