IPTV CF Worker 代理视频流,直连播放 pixman 直播源

stlyx · 2024年06月12日 · 最后由 ccf 回复于 2024年10月17日 · 4014 次阅读

基于之前有人做的 xiaoya CF worker 里代理直播流的代码,进行了一波魔改,实现直连播放视频流。

使用方法: 创建 CF Worker,粘贴下面代码,修改 pixman服务所在域名 CFWorker路由域名 你自己的YouTube播放列表 等变量。

聚合 m3u URL: https://{CFWorker路由域名}/

直连 m3u URL: https://{CFWorker路由域名}/noproxy

原理: https://{CFWorker路由域名}/proxy/{被代理的URL} 这个路由代理一切,并把返回的 m3u8 链接也替换掉。

效果: Youtube、4gtv 非常丝滑。但是 mytvsuper 在 tvbox 里还是有点卡,可能是码率比较高,可以在视频流里选低一点的分辨率。看看有没有大神继续优化一下这个代码。

// 聚合以下所有源,mod:获取后对每个源进行修改(代理视频流等),filter:过滤获取到的源
const SRC = [
  {
    name: '央视频',
    url: 'http://pixman服务所在域名:5000/ysp.m3u'
  },
  {
    name: 'MyTVSuper',
    url: 'http://pixman服务所在域名:5000/mytvsuper-tivimate.m3u',
    mod: (noproxy) => noproxy ? identity : proxify
  },
  {
    name: '四季',
    url: 'http://pixman服务所在域名:5000/4gtv.m3u',
    mod: (noproxy) => noproxy ? identity : proxify
  },
  {
    name: '油管',
    url: 'http://pixman服务所在域名:5000/youtube/list/你自己的YouTube播放列表',
    mod: (noproxy) => noproxy ? identity : proxify
  }
]

// 要代理的域名
const PROXY_DOMAINS = [
  'pixman服务所在域名',
  '[^/]+\.hinet\.net',
  '[^/]+\.googlevideo\.com',
  '[^/]+\.tvb.com(:\d+)?'
];

// 不修改
function identity(it) {return it;}

// 替换要代理的域名为 Worker 代理 URL
function proxify(it) {
  for (const dom of PROXY_DOMAINS) {
    it = it.replace(new RegExp('https?://' + dom, 'g'), 'https://CFWorker路由域名/proxy/$&');
  }
  return it;
}


import { connect } from "cloudflare:sockets";

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname.startsWith('/proxy')) {
      return handleProxy(request, env, ctx);
    } else {
      return handleList(request, env, ctx);
    }
  }
};

async function handleList(request, env, ctx) {
  let text = `#EXTM3U
#EXTM3U x-tvg-url="https://assets.livednow.com/epg.xml"

`;
  const REQ = SRC.map(src => ({
    ...src,
    response: fetchOverTcp(new Request(src.url))
  }));
  for (const src of REQ) {
    const respText = await (await src.response).text();
    let channels = respText.split(/^#EXT/gm).map(it => '#EXT' + it).filter(it => it.startsWith('#EXTINF'));
    if (src.filter) {
      const beforeLen = channels.length;
      channels = channels.filter(src.filter);
      const afterLen = channels.length;
      console.log(`filter ${src.name} ${beforeLen} -> ${afterLen}`);
    }
    if (src.mod) {
      const noproxy = request.url.indexOf('noproxy') > -1;
      channels = channels.map(src.mod(noproxy));
    }
    for (const chan of channels) {
      text += chan;
    }
  }

  return new Response(text);
}

async function handleProxy(request, env, ctx) {
  let target = new URL(request.url.split('/proxy/')[1]);
  console.log('proxying', target);
  let resp = await fetchOverTcp(new Request(target, {redirect: 'manual'}));
  if ([301,302,303,307,308].includes(resp.status)) {
    target = new URL(resp.headers.get('location'), target);
    const newHeader = new Headers(resp.headers);
    newHeader.set('location', proxify(target.href));
    const redirectResp = new Response(undefined, {
      status: resp.status,
      statusText: resp.statusText,
      headers: newHeader
    })
    return redirectResp;
  }
  if (resp.headers.get('content-type') === 'application/vnd.apple.mpegurl') {
    let respText = await resp.text();
    respText = proxify(respText);
    return new Response(respText);
  }
  return resp;
}

async function fetchOverTcp(request) {
  let url = new URL(request.url);
  let req = new Request(url, request);
  let port_string = url.port;
  if (!port_string) {
    port_string = url.protocol === "http:" ? "80" : "443";
  }
  let port = parseInt(port_string);

  if (
    (url.protocol === "https:" && port === 443) ||
    (url.protocol === "http:" && port === 80)
  ) {
    // CF标准的反代不支持IP地址,所以IPV6要走TCP代理
    if (!isIP(url.host)) {
      console.log('fetch natively');
      return await fetch(req);
    }
  }

  // 创建 TCP 连接
  let tcpSocket = connect(
    {
      hostname: url.hostname,
      port: port,
    },
    JSON.parse('{"secureTransport": "starttls"}')
  );

  if (url.protocol === "https:") {
    tcpSocket = tcpSocket.startTls();
  }

  try {
    const writer = tcpSocket.writable.getWriter();

    // 构造请求头部
    let headersString = "";
    let bodyString = "";

    for (let [name, value] of req.headers) {
      if (
        name === "connection" ||
        name === "host" ||
        name === "accept-encoding"
      ) {
        continue;
      }
      headersString += `${name}: ${value}\r\n`;
    }
    headersString += `connection: close\r\n`;
    headersString += `accept-encoding: identity\r\n`;

    let fullpath = url.pathname;

    // 如果有查询参数,将其添加到路径
    if (url.search) {
      fullpath += url.search.replace(/%3F/g, "?");
    }

    const body = await req.text();
    bodyString = `${body}`;

    // 发送请求
    await writer.write(
      new TextEncoder().encode(
        `${req.method} ${fullpath} HTTP/1.0\r\nHost: ${url.hostname}:${port}\r\n${headersString}\r\n${bodyString}`
      )
    );
    writer.releaseLock();

    // 获取响应
    const response = await constructHttpResponse(tcpSocket);

    console.log(
      "fetchOverTcp response headers",
      JSON.parse(
        JSON.stringify(
          Object.fromEntries(Array.from(response.headers.entries()))
        )
      )
    );

    return response;
  } catch (error) {
    console.log("fetchOverTcp Exception", error);
    tcpSocket.close();
    return new Response(error.stack, { status: 500 });
  }
}

async function constructHttpResponse(tcpSocket, timeout) {
  const reader = tcpSocket.readable.getReader();
  let remainingData = new Uint8Array(0);
  try {
    // 读取响应数据
    while (true) {
      const { value, done } = await reader.read();
      const newData = new Uint8Array(remainingData.length + value.length);
      newData.set(remainingData);
      newData.set(value, remainingData.length);
      remainingData = newData;
      const index = indexOfDoubleCRLF(remainingData);
      if (index !== -1) {
        reader.releaseLock();
        const headerBytes = remainingData.subarray(0, index);
        const bodyBytes = remainingData.subarray(index + 4);

        const header = new TextDecoder().decode(headerBytes);
        const [statusLine, ...headers] = header.split("\r\n");
        const [httpVersion, statusCode, ...tmpStatusText] =
          statusLine.split(" ");
        let statusText = tmpStatusText.join(" ");

        // 构造 Response 对象
        let responseHeaders = JSON.parse("{}");
        headers.forEach((header) => {
          const [name, value] = header.split(": ");
          responseHeaders[name.toLowerCase()] = value;
        });

        responseHeaders = JSON.parse(JSON.stringify(responseHeaders));
        console.log("orginal responseHeaders", responseHeaders);

        const responseInit = {
          status: parseInt(statusCode),
          statusText,
          headers: new Headers(responseHeaders),
        };

        console.log("statusCode", statusCode);

        let readable = null;
        let writable = null;
        let stream = null;
        if (responseHeaders["content-length"]) {
          stream = new FixedLengthStream(
            parseInt(responseHeaders["content-length"])
          );
        } else {
          stream = new TransformStream();
        }
        readable = stream.readable;
        writable = stream.writable;

        //规避CF问题,延迟1ms执行
        function delayedExecution() {
          setTimeout(() => {
            let writer = writable.getWriter();
            writer.write(bodyBytes);
            writer.releaseLock();
            tcpSocket.readable.pipeTo(writable);
          }, 1);
        }
        delayedExecution();

        return new Response(readable, responseInit);
      }
      if (done) {
        tcpSocket.close();
        break;
      }
    }

    console.log("Response Done!");
    return new Response();
  } catch (error) {
    console.log("Construct Response Exception", error);
    tcpSocket.close();
  }
}

function indexOfDoubleCRLF(data) {
  if (data.length < 4) {
    return -1;
  }
  for (let i = 0; i < data.length - 3; i++) {
    if (
      data[i] === 13 &&
      data[i + 1] === 10 &&
      data[i + 2] === 13 &&
      data[i + 3] === 10
    ) {
      return i;
    }
  }
  return -1;
}

function isIPv4(str) {
  // IPv4正则表达式
  const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
  return ipv4Regex.test(str);
}

function isIPv6(str) {
  let new_str = str.replace(/\[|\]/g, "");
  const ipv6Regex =
    /^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$/;
  return ipv6Regex.test(new_str);
}

function isIP(str) {
  // 判断是IPv4还是IPv6
  if (str.includes("[") && str.includes("]")) {
    return isIPv6(str);
  } else {
    return isIPv4(str);
  }
}

pixman 服务所在域是指 docker 所在服务器吗?

不错,越来越多高质量的帖子了,群策群力,众人拾柴火焰高

1101,不敢造次了,上次轉發 4gtv 廢了一個號,這次還是一樣的提示,趕緊刪了保號。

谢谢分享

大神,请教改了 5 处成为自己的域名,一处 cf 的域名部署后没反应,如何处理?

学习了,谢谢!

搞了一个上午,能出节目表,但也要翻才流畅

请教一下,我央视频填国内服务器,其他填境外的,那么 proxy 那里选哪个?

arthur 回复

不用写,没写 mod: 的就是不修改直连。

stlyx 回复

这个项目可以做到 4gtv 这些需要代理的,通过 cf worker 代理直连吗?

arthur 回复

就是这个目的

以实现完美观看,赞一个 可以支持自己在添加多个 youtube 列表么?

16 楼 已删除
mengzehe 回复

最后那个修改的网址是自定义域地址吗?还有 js 代码提示出错,无法部署

Jordankobe 回复

cf 给你提示了?

必须全局才能看么

如何让 cf 代理生效?

"pixman 服务所在域名"这个必须是域名吗?填本地的 IP 地址不行

cd1977 回复

填 ip 地址可以,填本地的 ip 地址就不行,cf worker 又不在你本地的内网里。

可以添加外部链接么?比如https://live.fanmingming.com/tv/m3u/ipv6.m3u这样的连接可以添加进去么? 我主要是想把几个直播链接自动合并在一起,整合成一个链接输出

还有 2 个问题:

  1. tivimate 直接填:https://{CFWorker 路由域名}/,提示错误。点击域名在浏览器是没问题的,m3u 内容完整。
  2. 4gtv 的代理如何实现,修改代码以后,里面的 m3u 内容,还是我的 pixman 服务域名,不翻墙还是看不了。

能出频道列表,不翻墙看不了,国外的 vps

小白软弱问一句,这个弄了后,最后的可用链接是个啥样子呢?比如: https://worker.dev/example.m3u? 是这种格式吗?谢谢各位大佬!

这个非常厉害。希望持续更新。

mengzehe 回复

请问楼主,此功能能实现吗?或者更新帮忙增加一下,感谢感谢

Cannot use import statement outside a module at worker.js: 哪里不对吗

在 worker 部署前,在哪里得到“CFWorker 路由域名”?还是说部署了才有?谢谢大佬。

发现运行后 4gtv 没加代理,加了 4gtv 和 litv 的标签后就可以都转了,试了下不代理可以看但有点卡

请教下,用这个 worker.js 部署后看不卡,最新服务器重新部署更换成 AlmaLinux 8 64 Bit 了,重新搭建 pixman 和 cf worker 之后播放卡得很,挂梯子播放才不卡。请问有解决方法么

部署后打不开,这是什么情况

牛人越来越多了

现在还好使么,好些放不出来了

需要 登录 后方可回复, 如果你还没有账号请 注册新账号