P-Stream Userscript

Userscript replacement for the P-Stream extension

Versión del día 16/12/2025. Echa un vistazo a la versión más reciente.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         P-Stream Userscript
// @namespace    https://pstream.mov/
// @version      1.4.1
// @description  Userscript replacement for the P-Stream extension
// @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/*
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @run-at       document-start
// @connect      *
// ==/UserScript==

(function () {
  "use strict";

  const VERSION = "1.4.1";
  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 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 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();
      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 findMatchingRule(url) {
    const normalizedUrl = normalizeUrl(url);
    if (!normalizedUrl) return null;

    const hostname = new URL(normalizedUrl).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) {
        try {
          if (new RegExp(rule.targetRegex).test(normalizedUrl)) return rule;
        } catch (error) {
          logWarning("Invalid rule regex:", error.message);
        }
      }
    }

    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 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;
      }

      _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;
          }

          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);
        }
      }

      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;
          }

          originalSetter.call(element, value);

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

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

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

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

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

    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();

      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);
  }
})();