P-Stream (GrokNT Fork)

P-Stream compatible UserScript

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 (GrokNT Fork)
// @namespace    https://pstream.mov/
// @version      1.4.3
// @description  P-Stream compatible UserScript
// @author       Duplicake, P-Stream Team, groknt
// @icon         https://raw.githubusercontent.com/p-stream/p-stream/production/public/mstile-150x150.jpeg
// @match        *://pstream.mov/*
// @match        *://aether.mom/*
// @match        *://lordflix.club/*
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @run-at       document-start
// @connect      *
// ==/UserScript==

(function () {
  "use strict";

  const VERSION = "1.4.3";
  const LOG_PREFIX = "P-Stream:";

  const CORS_HEADERS = Object.freeze({
    "access-control-allow-origin": "*",
    "access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
    "access-control-allow-headers": "*",
  });

  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 STREAMING_EXTENSIONS_REGEX = /\.(m3u8|mpd)(?:\?|$)/i;
  const STREAMING_MIME_TYPES = ["mpegurl", "dash+xml"];

  const XHR_STATES = Object.freeze({
    UNSENT: 0,
    OPENED: 1,
    HEADERS_RECEIVED: 2,
    LOADING: 3,
    DONE: 4,
  });

  const XHR_EVENT_TYPES = Object.freeze([
    "readystatechange",
    "load",
    "error",
    "timeout",
    "abort",
    "loadend",
    "progress",
    "loadstart",
  ]);

  const globalContext =
    typeof unsafeWindow !== "undefined" ? unsafeWindow : window;

  const gmXmlHttpRequest =
    typeof GM_xmlhttpRequest === "function"
      ? GM_xmlhttpRequest
      : typeof GM?.xmlHttpRequest === "function"
        ? GM.xmlHttpRequest
        : null;

  const pageOrigin = (function () {
    try {
      const { origin, href } = globalContext.location;
      return origin !== "null" ? origin : new URL(href).origin;
    } catch {
      return "*";
    }
  })();

  const proxyRules = new Map();
  const blobUrlRegistry = new Set();
  const proxyCache = new Map();
  const regexCache = new Map();
  const patchStatus = { fetch: false, xhr: false, media: false };

  function logWarning(...args) {
    console.warn(LOG_PREFIX, ...args);
  }

  function logError(...args) {
    console.error(LOG_PREFIX, ...args);
  }

  function normalizeUrl(input, base = globalContext.location.href) {
    if (!input) return null;
    try {
      return new URL(input, base).href;
    } catch {
      return null;
    }
  }

  function parseUrl(input, base = globalContext.location.href) {
    if (!input) return null;
    try {
      return new URL(input, base);
    } catch {
      return null;
    }
  }

  function isSameOrigin(url) {
    try {
      return new URL(url).origin === pageOrigin;
    } catch {
      return false;
    }
  }

  function buildUrl(url, options = {}) {
    const { baseUrl = "", query = {} } = options;
    const base = baseUrl.endsWith("/") ? baseUrl : baseUrl && `${baseUrl}/`;
    const path = url.startsWith("/") ? url.slice(1) : url;
    const fullUrl = `${base}${path}`;

    if (!/^https?:\/\//i.test(fullUrl)) {
      throw new Error(`Invalid URL scheme: ${fullUrl}`);
    }

    const parsedUrl = new URL(fullUrl);
    for (const [key, value] of Object.entries(query)) {
      parsedUrl.searchParams.set(key, value);
    }
    return parsedUrl.href;
  }

  function parseResponseHeaders(rawHeaders) {
    const headers = {};
    if (!rawHeaders) return headers;

    for (const line of rawHeaders.split(/\r?\n/)) {
      const separatorIndex = line.indexOf(":");
      if (separatorIndex === -1) continue;

      const key = line.slice(0, separatorIndex).trim().toLowerCase();
      if (!key) continue;

      const value = line.slice(separatorIndex + 1).trim();
      headers[key] = headers[key] ? `${headers[key]}, ${value}` : value;
    }
    return headers;
  }

  function buildResponseHeaders(rawHeaders, ruleHeaders, includeCredentials) {
    const headers = {
      ...CORS_HEADERS,
      ...ruleHeaders,
      ...parseResponseHeaders(rawHeaders),
    };

    if (includeCredentials) {
      headers["access-control-allow-credentials"] = "true";
      if (
        !headers["access-control-allow-origin"] ||
        headers["access-control-allow-origin"] === "*"
      ) {
        headers["access-control-allow-origin"] = pageOrigin;
      }
    }

    return headers;
  }

  function shouldIncludeCredentials(
    url,
    credentialsMode,
    forceInclude = false,
  ) {
    if (forceInclude || credentialsMode === "include") return true;
    if (credentialsMode === "omit") return false;
    return isSameOrigin(url);
  }

  function normalizeRequestBody(body) {
    if (body == null) return undefined;
    if (
      typeof body === "string" ||
      body instanceof FormData ||
      body instanceof Blob ||
      body instanceof ArrayBuffer ||
      ArrayBuffer.isView(body)
    ) {
      return body;
    }
    if (body instanceof URLSearchParams) return body.toString();
    if (typeof body === "object") return JSON.stringify(body);
    return body;
  }

  function deserializeRequestBody(body, bodyType) {
    if (body == null) return undefined;

    switch (bodyType) {
      case "FormData": {
        const formData = new FormData();
        for (const [key, value] of body) {
          formData.append(key, value);
        }
        return formData;
      }
      case "URLSearchParams":
        return new URLSearchParams(body);
      case "object":
        return JSON.stringify(body);
      default:
        return body;
    }
  }

  function executeGmRequest(options) {
    return new Promise((resolve, reject) => {
      if (!gmXmlHttpRequest) {
        reject(new Error("GM_xmlhttpRequest unavailable"));
        return;
      }

      gmXmlHttpRequest({
        ...options,
        onload: resolve,
        onerror: (error) => reject(new Error(error?.error || "Network error")),
        ontimeout: () => reject(new Error("Request timeout")),
      });
    });
  }

  function responseToArrayBuffer(response) {
    return response.response instanceof ArrayBuffer
      ? response.response
      : new TextEncoder().encode(response.responseText || "");
  }

  function getCompiledRegex(pattern) {
    if (regexCache.has(pattern)) {
      return regexCache.get(pattern);
    }
    try {
      const regex = new RegExp(pattern);
      regexCache.set(pattern, regex);
      return regex;
    } catch {
      regexCache.set(pattern, null);
      return null;
    }
  }

  function findMatchingRule(url) {
    const parsedUrl = parseUrl(url);
    if (!parsedUrl) return null;

    const normalizedUrl = parsedUrl.href;
    const hostname = parsedUrl.hostname;

    for (const rule of proxyRules.values()) {
      if (rule.targetDomains?.length) {
        const domainMatches = rule.targetDomains.some(
          (domain) => hostname === domain || hostname.endsWith(`.${domain}`),
        );
        if (domainMatches) return rule;
      }

      if (rule.targetRegex) {
        const regex = getCompiledRegex(rule.targetRegex);
        if (regex && regex.test(normalizedUrl)) return rule;
      }
    }

    return null;
  }

  function isStreamingContent(contentType, url) {
    return (
      STREAMING_MIME_TYPES.some((mimeType) => contentType.includes(mimeType)) ||
      STREAMING_EXTENSIONS_REGEX.test(url)
    );
  }

  function createBlobUrl(data, contentType = "application/octet-stream") {
    const url = URL.createObjectURL(new Blob([data], { type: contentType }));
    blobUrlRegistry.add(url);
    return url;
  }

  function cleanupStreamData() {
    for (const blobUrl of blobUrlRegistry) {
      try {
        URL.revokeObjectURL(blobUrl);
      } catch {}
    }
    blobUrlRegistry.clear();
    proxyCache.clear();
  }

  function removeRuleCachedRegex(ruleId) {
    const existingRule = proxyRules.get(ruleId);
    if (existingRule?.targetRegex) {
      regexCache.delete(existingRule.targetRegex);
    }
  }

  function proxyMediaSource(url) {
    const normalizedUrl = normalizeUrl(url);
    if (!normalizedUrl) return Promise.resolve(null);

    const rule = findMatchingRule(normalizedUrl);
    if (!rule) return Promise.resolve(null);

    if (proxyCache.has(normalizedUrl)) {
      return proxyCache.get(normalizedUrl);
    }

    const proxyPromise = (async () => {
      try {
        const response = await executeGmRequest({
          url: normalizedUrl,
          method: "GET",
          headers: rule.requestHeaders,
          responseType: "arraybuffer",
          withCredentials: true,
        });

        const contentType =
          parseResponseHeaders(response.responseHeaders)["content-type"] || "";
        if (isStreamingContent(contentType, normalizedUrl)) return null;

        return createBlobUrl(responseToArrayBuffer(response), contentType);
      } catch (error) {
        logWarning("Media proxy failed:", error.message);
        return null;
      } finally {
        setTimeout(() => proxyCache.delete(normalizedUrl), 1000);
      }
    })();

    proxyCache.set(normalizedUrl, proxyPromise);
    return proxyPromise;
  }

  function patchFetch() {
    if (patchStatus.fetch) return;
    patchStatus.fetch = true;

    const nativeFetch = globalContext.fetch.bind(globalContext);

    globalContext.fetch = async function (input, init = {}) {
      const url = normalizeUrl(typeof input === "string" ? input : input?.url);
      const rule = url && findMatchingRule(url);

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

      const headers = {
        ...rule.requestHeaders,
        ...(init.headers instanceof Headers
          ? Object.fromEntries(init.headers)
          : init.headers),
      };

      const includeCredentials = shouldIncludeCredentials(
        url,
        init.credentials,
      );

      try {
        const response = await executeGmRequest({
          url,
          method: init.method || "GET",
          headers,
          data: normalizeRequestBody(init.body),
          responseType: "arraybuffer",
          withCredentials: includeCredentials,
        });

        return new Response(responseToArrayBuffer(response), {
          status: response.status,
          statusText: response.statusText || "",
          headers: buildResponseHeaders(
            response.responseHeaders,
            rule.responseHeaders,
            includeCredentials,
          ),
        });
      } catch (error) {
        logWarning("Fetch proxy failed:", error.message);
        return nativeFetch(input, init);
      }
    };
  }

  function patchXhr() {
    if (patchStatus.xhr) return;
    patchStatus.xhr = true;

    const NativeXMLHttpRequest = globalContext.XMLHttpRequest;

    class ProxyXMLHttpRequest {
      constructor() {
        this._nativeXhr = new NativeXMLHttpRequest();
        this._useNative = true;
        this._eventListeners = new Map();
        this._requestHeaders = {};
        this._responseHeaders = {};
        this._matchedRule = null;
        this._requestUrl = "";
        this._requestMethod = "GET";
        this._isAborted = false;
        this._timeoutId = null;
        this._overriddenMimeType = "";
        this._nativeEventsBound = false;

        this.readyState = XHR_STATES.UNSENT;
        this.status = 0;
        this.statusText = "";
        this.response = null;
        this.responseText = "";
        this.responseURL = "";
        this.responseType = "";
        this.withCredentials = false;
        this.timeout = 0;
        this.upload = this._nativeXhr.upload;

        this.onreadystatechange = null;
        this.onload = null;
        this.onerror = null;
        this.ontimeout = null;
        this.onabort = null;
        this.onloadend = null;
        this.onprogress = null;
        this.onloadstart = null;
      }

      _emitEvent(eventType, event = new Event(eventType)) {
        const handler = this[`on${eventType}`];
        if (handler) {
          try {
            handler.call(this, event);
          } catch (error) {
            logError("XHR handler error:", error);
          }
        }

        const listeners = this._eventListeners.get(eventType);
        if (listeners) {
          for (const listener of listeners) {
            try {
              listener.call(this, event);
            } catch (error) {
              logError("XHR listener error:", error);
            }
          }
        }
      }

      _syncFromNative() {
        if (!this._useNative) return;

        try {
          this.readyState = this._nativeXhr.readyState;

          if (this.readyState >= XHR_STATES.HEADERS_RECEIVED) {
            this.status = this._nativeXhr.status;
            this.statusText = this._nativeXhr.statusText;
          }

          if (this.readyState === XHR_STATES.DONE) {
            this.response = this._nativeXhr.response;
            this.responseURL = this._nativeXhr.responseURL;

            const responseType = this._nativeXhr.responseType;
            if (!responseType || responseType === "text") {
              this.responseText = this._nativeXhr.responseText;
            }
          }
        } catch {}
      }

      _bindNativeEvents() {
        if (this._nativeEventsBound) return;
        this._nativeEventsBound = true;

        for (const eventType of XHR_EVENT_TYPES) {
          this._nativeXhr.addEventListener(eventType, (event) => {
            this._syncFromNative();
            this._emitEvent(eventType, event);
          });
        }
      }

      _setResponseData(buffer) {
        const contentType =
          this.getResponseHeader("content-type") ||
          this._overriddenMimeType ||
          "application/octet-stream";

        switch (this.responseType) {
          case "arraybuffer":
            this.response = buffer;
            break;

          case "blob":
            this.response = new Blob([buffer], { type: contentType });
            break;

          case "json": {
            const text = new TextDecoder().decode(buffer);
            this.responseText = text;
            try {
              this.response = JSON.parse(text);
            } catch {
              this.response = null;
            }
            break;
          }

          case "document": {
            const text = new TextDecoder().decode(buffer);
            this.responseText = text;
            try {
              const parser = new DOMParser();
              const mimeType = contentType.includes("xml")
                ? "application/xml"
                : "text/html";
              this.response = parser.parseFromString(text, mimeType);
            } catch {
              this.response = null;
            }
            break;
          }

          default: {
            const text = new TextDecoder().decode(buffer);
            this.response = text;
            this.responseText = text;
          }
        }
      }

      addEventListener(eventType, callback) {
        if (!this._eventListeners.has(eventType)) {
          this._eventListeners.set(eventType, []);
        }
        this._eventListeners.get(eventType).push(callback);

        if (this._useNative) {
          this._nativeXhr.addEventListener(eventType, callback);
        }
      }

      removeEventListener(eventType, callback) {
        const listeners = this._eventListeners.get(eventType);
        if (listeners) {
          const index = listeners.indexOf(callback);
          if (index !== -1) listeners.splice(index, 1);
        }

        if (this._useNative) {
          this._nativeXhr.removeEventListener(eventType, callback);
        }
      }

      dispatchEvent(event) {
        if (this._useNative) {
          return this._nativeXhr.dispatchEvent(event);
        }
        this._emitEvent(event.type, event);
        return !event.defaultPrevented;
      }

      open(method, url, async = true, username, password) {
        this._requestMethod = method;
        this._requestUrl = normalizeUrl(url) || url;
        this._matchedRule = findMatchingRule(this._requestUrl);
        this._useNative = !this._matchedRule;

        if (this._useNative) {
          return this._nativeXhr.open(method, url, async, username, password);
        }

        this.readyState = XHR_STATES.OPENED;
        this._emitEvent("readystatechange");
      }

      setRequestHeader(name, value) {
        if (this._useNative) {
          return this._nativeXhr.setRequestHeader(name, value);
        }
        this._requestHeaders[name] = value;
      }

      getResponseHeader(name) {
        if (this._useNative) {
          return this._nativeXhr.getResponseHeader(name);
        }
        return this._responseHeaders[name?.toLowerCase()] ?? null;
      }

      getAllResponseHeaders() {
        if (this._useNative) {
          return this._nativeXhr.getAllResponseHeaders();
        }
        return Object.entries(this._responseHeaders)
          .map(([key, value]) => `${key}: ${value}`)
          .join("\r\n");
      }

      overrideMimeType(mimeType) {
        if (this._useNative) {
          return this._nativeXhr.overrideMimeType(mimeType);
        }
        this._overriddenMimeType = mimeType;
      }

      abort() {
        if (this._useNative) {
          return this._nativeXhr.abort();
        }

        if (this._timeoutId) {
          clearTimeout(this._timeoutId);
          this._timeoutId = null;
        }

        this._isAborted = true;
        this.readyState = XHR_STATES.UNSENT;
        this._emitEvent("abort");
      }

      async send(body = null) {
        if (this._useNative) {
          this._nativeXhr.withCredentials = this.withCredentials;
          this._nativeXhr.responseType = this.responseType;
          this._nativeXhr.timeout = this.timeout;
          this._bindNativeEvents();
          return this._nativeXhr.send(body);
        }

        const rule = this._matchedRule;
        const url = this._requestUrl;
        const method = this._requestMethod;

        const headers = { ...rule.requestHeaders, ...this._requestHeaders };
        const includeCredentials = shouldIncludeCredentials(
          url,
          this.withCredentials ? "include" : undefined,
          this.withCredentials,
        );
        const binaryResponse =
          this.responseType === "arraybuffer" || this.responseType === "blob";

        const requestPromise = executeGmRequest({
          url,
          method,
          headers,
          data: normalizeRequestBody(body),
          responseType: binaryResponse ? "arraybuffer" : "text",
          withCredentials: includeCredentials,
        });

        let timeoutPromise = null;
        if (this.timeout > 0) {
          timeoutPromise = new Promise((_, reject) => {
            this._timeoutId = setTimeout(
              () => reject(new Error("timeout")),
              this.timeout,
            );
          });
        }

        this._emitEvent("loadstart");

        try {
          const response = await (timeoutPromise
            ? Promise.race([requestPromise, timeoutPromise])
            : requestPromise);

          if (this._timeoutId) {
            clearTimeout(this._timeoutId);
            this._timeoutId = null;
          }

          if (this._isAborted) return;

          this._responseHeaders = buildResponseHeaders(
            response.responseHeaders,
            rule.responseHeaders,
            includeCredentials,
          );
          this.responseURL = response.finalUrl || url;
          this.status = response.status;
          this.statusText = response.statusText || "";

          const buffer = responseToArrayBuffer(response);

          this.readyState = XHR_STATES.HEADERS_RECEIVED;
          this._emitEvent("readystatechange");

          this.readyState = XHR_STATES.LOADING;
          this._emitEvent("readystatechange");

          this._setResponseData(buffer);

          this.readyState = XHR_STATES.DONE;
          this._emitEvent("readystatechange");
          this._emitEvent("load");
          this._emitEvent("loadend");
        } catch (error) {
          if (this._timeoutId) {
            clearTimeout(this._timeoutId);
            this._timeoutId = null;
          }

          if (this._isAborted) return;

          this.status = 0;
          this.statusText = error.message || "";
          this.readyState = XHR_STATES.DONE;

          this._emitEvent("readystatechange");
          this._emitEvent(error.message === "timeout" ? "timeout" : "error");
          this._emitEvent("loadend");
        }
      }
    }

    Object.assign(ProxyXMLHttpRequest, XHR_STATES);
    globalContext.XMLHttpRequest = ProxyXMLHttpRequest;
  }

  function patchMediaElements() {
    if (patchStatus.media) return;
    patchStatus.media = true;

    const mediaPrototype = globalContext.HTMLMediaElement.prototype;
    const srcDescriptor = Object.getOwnPropertyDescriptor(
      mediaPrototype,
      "src",
    );
    const nativeSetAttribute = mediaPrototype.setAttribute;

    if (srcDescriptor?.set) {
      const originalSetter = srcDescriptor.set;

      Object.defineProperty(mediaPrototype, "src", {
        ...srcDescriptor,
        set(value) {
          const element = this;

          if (typeof value !== "string") {
            originalSetter.call(element, value);
            return;
          }

          const normalizedValue = normalizeUrl(value);
          if (!normalizedValue) {
            originalSetter.call(element, value);
            return;
          }

          originalSetter.call(element, value);

          proxyMediaSource(value)
            .then((proxiedUrl) => {
              if (proxiedUrl && element.src === normalizedValue) {
                originalSetter.call(element, proxiedUrl);
              }
            })
            .catch(() => {});
        },
      });
    }

    mediaPrototype.setAttribute = function (name, value) {
      const element = this;

      if (name?.toLowerCase() !== "src" || typeof value !== "string") {
        return nativeSetAttribute.call(element, name, value);
      }

      const normalizedValue = normalizeUrl(value);
      if (!normalizedValue) {
        return nativeSetAttribute.call(element, "src", value);
      }

      nativeSetAttribute.call(element, "src", value);

      proxyMediaSource(value)
        .then((proxiedUrl) => {
          if (proxiedUrl && element.src === normalizedValue) {
            nativeSetAttribute.call(element, "src", proxiedUrl);
          }
        })
        .catch(() => {});
    };

    globalContext.addEventListener("beforeunload", () => {
      for (const url of blobUrlRegistry) {
        URL.revokeObjectURL(url);
      }
      blobUrlRegistry.clear();
    });
  }

  function installProxies() {
    patchFetch();
    patchXhr();
    patchMediaElements();
  }

  const messageHandlers = {
    hello() {
      return {
        success: true,
        version: VERSION,
        allowed: true,
        hasPermission: true,
      };
    },

    async makeRequest(body) {
      if (!body) throw new Error("Missing request body");

      const url = buildUrl(body.url, body);
      const includeCredentials = shouldIncludeCredentials(
        url,
        body.credentials,
        body.withCredentials,
      );

      const response = await executeGmRequest({
        url,
        method: body.method || "GET",
        headers: body.headers,
        data: deserializeRequestBody(body.body, body.bodyType),
        responseType: "arraybuffer",
        withCredentials: includeCredentials,
      });

      const headers = buildResponseHeaders(
        response.responseHeaders,
        null,
        includeCredentials,
      );
      const text = new TextDecoder().decode(responseToArrayBuffer(response));
      const contentType = headers["content-type"] || "";

      let parsedBody = text;
      if (contentType.includes("application/json")) {
        try {
          parsedBody = JSON.parse(text);
        } catch {}
      }

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

    async prepareStream(body) {
      if (!body) throw new Error("Missing request body");

      cleanupStreamData();
      removeRuleCachedRegex(body.ruleId);

      const responseHeaders = {};
      if (body.responseHeaders) {
        for (const [key, value] of Object.entries(body.responseHeaders)) {
          const normalizedKey = key.toLowerCase();
          if (MODIFIABLE_HEADERS.has(normalizedKey)) {
            responseHeaders[normalizedKey] = value;
          }
        }
      }

      proxyRules.set(body.ruleId, { ...body, responseHeaders });
      installProxies();

      return { success: true };
    },

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

  function setupMessageRelay(messageName, handler) {
    globalContext.addEventListener("message", async (event) => {
      if (
        event.source !== globalContext ||
        event.data?.name !== messageName ||
        event.data?.relayed
      ) {
        return;
      }

      const { instanceId, body } = event.data;

      try {
        const result = await handler(body);
        globalContext.postMessage(
          { name: messageName, instanceId, body: result, relayed: true },
          "/",
        );
      } catch (error) {
        logError(`${messageName} handler failed:`, error.message);
        globalContext.postMessage(
          {
            name: messageName,
            instanceId,
            body: { success: false, error: error.message || String(error) },
            relayed: true,
          },
          "/",
        );
      }
    });
  }

  for (const [name, handler] of Object.entries(messageHandlers)) {
    setupMessageRelay(name, handler);
  }
})();