AaronLee
V2EX  ›  外包

[悬赏]200 元人民币,希望有个带用户认证的可以将 m3u8 音频转换为在线 mp3 播放的解决方法

  •  
  •   AaronLee · Mar 23, 2025 · 2010 views
    This topic created in 506 days ago, the information mentioned may be changed or developed.

    要求

    我需要在线将 m3u 格式的广播音频转换为可以在线播放的 MP3 音频,以便可以在播客中收听广播,一般的播客 app 无法播放 m3u8 音频,有些网站提供广播在线 MP3 播放链接,但会经常失效,或者根本不提供 MP3 播放链接,因此需要用ffmpeg将 m3u8 转为 mp3 。

    m3u8 音频链接

    中国之声: http://ngcdn001.cnr.cn/live/zgzs/index.m3u8

    测试链接 1: http://ngcdn002.cnr.cn/live/jjzs/index.m3u8

    测试链接 2: http://ngcdn003.cnr.cn/live/yyzs/index.m3u8

    测试链接 3: http://ngcdn010.cnr.cn/live/wyzs/index.m3u8

    具体要求

    1. 可以进行用户认证,设置变量FM_USER: "fm",FM_PASSWORD: "1234"FM_ACCESS_KEY: "5555",则可以通过https://fm:[email protected]/1.mp3https://example.com/1.mp3?key=5555访问,如果未进行用户认证则可以直接访问。

    2. 当有用户访问时可以立即进行 ffmpeg 转换,当访问断开时等待 10 秒或更长时间未访问链接可以关闭 ffmpeg 转换已节省内存,我尝试用 AI 解决此问题,但都遇到启动慢,或内存泄漏问题,无法在在结束访问时及时关闭或开始访问时无法及时启动。

    3. 根据环境变量设置链接别名

      services:
        fm-proxy:
          build:
            context: .
          image: fm-proxy
          container_name: fm-proxy
          environment:
            FM_USER: "fm"
            FM_PASSWORD: "1234"
            FM_ACCESS_KEY: "5555"
            FM_M3U8_URL_1: "http://ngcdn001.cnr.cn/live/zgzs/index.m3u8"
            FM_MP3_NAME_1: "cnr1.mp3"
            FM_MP3_PATH_1: "cnr1"
            FM_M3U8_URL_2: "http://ngcdn002.cnr.cn/live/jjzs/index.m3u8"
            FM_MP3_NAME_2: "cnr2.mp3"
            FM_MP3_PATH_2: "cnr2"
            FM_M3U8_URL_18: "http://ngcdn003.cnr.cn/live/yyzs/index.m3u8"
            FM_MP3_NAME_18: "cnr3.mp3"
            FM_MP3_PATH_18: "cnr3"
            FM_M3U8_URL_4: "http://ngcdn010.cnr.cn/live/wyzs/index.m3u8"
            FM_MP3_NAME_4: "cnr4.mp3"
            FM_MP3_PATH_4: "cnr4"
          ports:
            - "8000:8000"
      

      链接为

      https://fm:[email protected]/cnr1/cnr1.mp3
      https://fm:[email protected]/cnr2/cnr2.mp3
      https://fm:[email protected]/cnr3/cnr3.mp3
      或
      https://example.com/cnr1/cnr1.mp3?key=5555
      https://example.com/cnr2/cnr2.mp3?key=5555
      https://example.com/cnr3/cnr3.mp3?key=5555
      https://example.com/cnr4/cnr4.mp3?key=5555
      

      AI 给的解决方案

      python 版

      Dockerfile

      # 使用更小的基础镜像
      FROM python:3.9-alpine
      
      # 安装构建 psutil 所需的依赖
      RUN apk add --no-cache gcc python3-dev musl-dev linux-headers ffmpeg
      
      # 设置工作目录
      WORKDIR /app
      
      # 复制依赖文件并安装
      COPY config/requirements.txt .
      RUN pip install --no-cache-dir -r requirements.txt
      
      # 复制应用代码
      COPY config/app.py  .
      
      # 暴露端口
      EXPOSE 8000
      
      # 启动应用,优化参数
      CMD ["gunicorn", "-w", "3", "-k", "gthread", "-t", "60", "--bind", "0.0.0.0:8000", "app:app"]
      

      config/app.py

      from flask import Flask, Response, stream_with_context, request, abort
      import subprocess
      import os
      import psutil
      import threading
      import time
      import logging
      
      app = Flask(__name__)
      
      # 设置日志配置
      logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
      
      # 定义内存报告函数
      def report_memory_usage():
          while True:
              process = psutil.Process()
              memory_info = process.memory_info()
              logging.info(f"Memory Usage: {memory_info.rss / (1024 * 1024):.2f} MB")
              time.sleep(300)
      
      # 启动后台线程
      threading.Thread(target=report_memory_usage, daemon=True).start()
      
      # 用于存储当前活动的 ffmpeg 进程
      active_processes = {}
      
      def check_auth(username, password):
          return username == os.getenv('FM_USER') and password == os.getenv('FM_PASSWORD')
      
      def authenticate():
          return Response(
              '请提供用户名和密码!',
              401,
              {'WWW-Authenticate': 'Basic realm="Login Required"'})
      
      @app.route('/<path:path_value>/<filename>')
      def stream(path_value, filename):
          stream_id = None
      
          for key in os.environ:
              if key.startswith('FM_MP3_NAME_'):
                  i = key.split('_')[-1]
                  if filename == os.getenv(key) and path_value == os.getenv(f'FM_MP3_PATH_{i}'):
                      stream_id = i
                      break
      
          # 如果流 ID 无效,直接返回,不进行认证
          if stream_id is None:
              logging.error("请求的文件名或路径不匹配。")
              return "请求的文件名或路径不匹配。", 404
      
          # 检查是否设置了 FM_ACCESS_KEY
          access_key = request.args.get('key')
          if os.getenv('FM_ACCESS_KEY') and access_key == os.getenv('FM_ACCESS_KEY'):
              logging.info("通过 FM_ACCESS_KEY 认证访问。")
              # 认证成功后,不再使用 access_key 参数
          else:
              # 进行用户认证,仅在环境变量不为空时
              if os.getenv('FM_USER') and os.getenv('FM_PASSWORD'):
                  auth = request.authorization
                  if not auth or not check_auth(auth.username, auth.password):
                      return authenticate()
      
          m3u8_url = os.getenv(f'FM_M3U8_URL_{stream_id}')
          
          if not m3u8_url:
              logging.error(f"FM_M3U8_URL_{stream_id} 环境变量未设置。")
              return f"FM_M3U8_URL_{stream_id} 环境变量未设置。", 400
      
          # 如果流已经在运行,直接返回
          if stream_id in active_processes:
              logging.info(f"流 {stream_id} 已在运行,返回现有流。")
              process = active_processes[stream_id]
          else:
              command = ['ffmpeg', '-i', m3u8_url, '-f', 'mp3', '-']
              process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
              active_processes[stream_id] = process
              logging.info(f"开始流式传输: {m3u8_url},流 ID: {stream_id}")
      
          def generate():
              try:
                  while True:
                      data = process.stdout.read(1024)
                      if not data:
                          break
                      yield data
              except BrokenPipeError:
                  logging.warning(f"流 {stream_id} 的 Broken pipe error: 客户端可能已关闭连接。")
              except GeneratorExit:
                  logging.info(f"流 {stream_id} 的生成器被关闭,准备终止 ffmpeg 进程。")
              finally:
                  # 关闭 ffmpeg 进程
                  process.terminate()
                  process.wait()
                  del active_processes[stream_id]  # 从活动进程中移除
                  logging.info(f"ffmpeg 进程已终止,流 {stream_id} 被关闭。")
      
          return Response(stream_with_context(generate()), content_type='audio/mpeg')
      
      if __name__ == '__main__':
          app.run(host='0.0.0.0', port=8000)
      

      config/requirements.txt

      Flask==2.2.2
      Werkzeug==2.2.2
      gunicorn==20.1.0
      psutil
      

      Golang 版

      Dockerfile

      # 使用 Go 的官方镜像
      FROM golang:1.20-alpine
      
      # 安装 ffmpeg
      RUN apk add --no-cache ffmpeg
      
      # 设置工作目录
      WORKDIR /app
      
      # 复制 go.mod 和 go.sum 文件
      COPY go.mod go.sum ./
      
      # 下载依赖
      RUN go mod download
      
      # 复制应用代码
      COPY main.go .
      
      # 编译应用
      RUN go build -o fm-proxy .
      
      # 暴露端口
      EXPOSE 8000
      
      # 启动应用
      CMD ["./fm-proxy"]
      

      go.mod

      module fm-proxy
      
      go 1.20
      

      go.sum

      # This file is a placeholder and will be generated by Go.
      

      main.go

      package main
      
      import (
          "log"
          "net/http"
          "os"
          "os/exec"
          "sync"
      )
      
      var (
          activeProcesses = make(map[string]*exec.Cmd)
          mu             sync.Mutex
      )
      
      func streamHandler(w http.ResponseWriter, r *http.Request) {
          streamID := r.URL.Query().Get("id") // 假设流 ID 从 URL 查询参数中获取
          m3u8URL := "http://ngcdn002.cnr.cn/live/jjzs/index.m3u8" // 示例 m3u8 URL
      
          mu.Lock()
          cmd, exists := activeProcesses[streamID]
          if !exists {
              // 检查响应写入器状态
              if w == nil || r.Context().Err() != nil {
                  log.Println("响应写入器无效,无法处理请求。")
                  mu.Unlock()
                  return
              }
      
              // 使用更适合流媒体的 FFmpeg 命令
              cmd = exec.Command("ffmpeg", "-i", m3u8URL, "-f", "mp3", "-b:a", "128k", "-")
              cmd.Stdout = w
              cmd.Stderr = os.Stderr
      
              err := cmd.Start()
              if err != nil {
                  log.Println("启动 ffmpeg 失败:", err)
                  mu.Unlock()
                  http.Error(w, "启动流失败,请稍后重试。", http.StatusInternalServerError)
                  return
              }
      
              activeProcesses[streamID] = cmd
              log.Printf("开始流式传输: %s ,流 ID: %s\n", m3u8URL, streamID)
          }
          mu.Unlock()
      
          // 等待 FFmpeg 进程结束
          go func() {
              if err := cmd.Wait(); err != nil {
                  log.Println("ffmpeg 进程遇到错误:", err)
                  mu.Lock()
                  delete(activeProcesses, streamID)
                  mu.Unlock()
              }
          }()
      }
      
      func handleRequest(w http.ResponseWriter, r *http.Request) {
          // 处理流
          streamHandler(w, r)
      
          // 检查请求的上下文
          select {
          case <-r.Context().Done():
              log.Println("请求已取消或超时,终止 FFmpeg 进程")
              mu.Lock()
              if cmd, exists := activeProcesses[r.URL.Query().Get("id")]; exists {
                  cmd.Process.Kill() // 优雅地终止 FFmpeg 进程
                  delete(activeProcesses, r.URL.Query().Get("id"))
              }
              mu.Unlock()
          }
      }
      
      func main() {
          http.HandleFunc("/stream", handleRequest)
          log.Println("服务器启动,监听 :8080")
          if err := http.ListenAndServe(":8080", nil); err != nil {
              log.Fatal("服务器启动失败:", err)
          }
      }
      
      

    在 Vercel 上部署播客的 RSS 源,可以设置用户认证

    文件格式

    fm-rss/
      -/api/display-feed.js
      -/files/fm.feed
      -/images/guonei.jpg
      -/pages/index.js
      -vercel.json
    

    api/display-feed.js

    // api/display-feed.js
    import path from 'path';
    import fs from 'fs';
    
    const USERNAME = process.env.AUTH_USERNAME; // 从环境变量获取用户名
    const PASSWORD = process.env.AUTH_PASSWORD; // 从环境变量获取密码
    const ACCESS_KEY = process.env.ACCESS_KEY; // 新增环境变量
    
    export default function handler(req, res) {
        const authHeader = req.headers['authorization'];
        const { query } = req;
        const accessKey = query.key; // 从查询参数中获取 ACCESS_KEY
    
        // 检查是否设置了 AUTH_USERNAME 和 AUTH_PASSWORD
        const authRequired = USERNAME && PASSWORD;
    
        // 如果设置了 ACCESS_KEY ,检查是否提供了正确的 key
        if (ACCESS_KEY && accessKey === ACCESS_KEY) {
            // ACCESS_KEY 正确,允许访问
            return serveFile(req, res);
        }
    
        // 如果需要认证,检查用户名和密码
        if (authRequired) {
            if (!authHeader) {
                return res.setHeader('WWW-Authenticate', 'Basic').status(401).send('需要认证。');
            }
    
            const base64Credentials = authHeader.split(' ')[1];
            const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
            const [username, password] = credentials.split(':');
    
            // 检查用户名和密码
            if (username === USERNAME && password === PASSWORD) {
                // 用户认证成功,允许访问
                return serveFile(req, res);
            } else {
                return res.setHeader('WWW-Authenticate', 'Basic').status(401).send('需要认证。');
            }
        }
    
        // 如果没有设置 AUTH_USERNAME 和 AUTH_PASSWORD ,直接允许访问
        return serveFile(req, res);
    }
    
    function serveFile(req, res) {
        const { query } = req;
        const fileName = query.file; // 从查询参数中获取文件名
    
        if (!fileName) {
            return res.status(400).json({ message: '文件名是必需的' });
        }
    
        // 构造文件路径
        const filePath = path.resolve(process.cwd(), 'files', fileName);
    
        fs.readFile(filePath, 'utf-8', (err, data) => {
            if (err) {
                console.error(err); // 打印错误信息到控制台
                res.status(500).json({ message: '读取文件时出错' });
            } else {
                res.setHeader('Content-Type', 'application/xml; charset=utf-8');
                res.status(200).send(data);
            }
        });
    }
    

    /files/fm.feed

    <?xml version="1.0" encoding="UTF-8"?>
    <rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
      <channel>
        <title>国内广播</title>
        <link></link>
        <atom:link href="https://xxxxx/guonei.feed" rel="self" type="application/rss+xml"></atom:link>
        <description>用于在线收听国内广播</description>
        <generator>手工编写</generator>
        <webMaster>[email protected]</webMaster>
        <itunes:author>无</itunes:author>
        <itunes:category text="Music"></itunes:category>
        <itunes:explicit>false</itunes:explicit>
        <language>zh</language>
        <image>
          <url>https://xxxxx/images/guonei.jpg</url>
          <title>国内广播</title>
          <link>https://xxxxx/</link>
        </image>
        <lastBuildDate>Mon, 06 Jan 2025 14:32:19 GMT</lastBuildDate>
        <ttl>120</ttl>
        
        <item>
          <title>环球资讯广播</title>
          <description>环球资讯广播在线收听</description>
          <link>https://newsradio.cri.cn/</link>
          <guid isPermaLink="false">https://newsradio.cri.cn/</guid>
          <pubDate>Wed, 28 Sep 2005 00:00:00 GMT</pubDate>
          <author>中国中央人民广播电台</author>
          <itunes:image href="https://cnvod.cnr.cn/audio2017/ondemand/img/1100/20200306/1583464064428.jpg"></itunes:image>
          <enclosure url="" type="audio/mpeg"></enclosure>
          <itunes:duration>0:00:00</itunes:duration>
        </item>
        
        <item>
          <title>江苏经典流行音乐广播</title>
          <description>江苏经典流行音乐广播在线收听</description>
          <link>https://www.vojs.cn/2014new/c/g/</link>
          <guid isPermaLink="false">https://www.vojs.cn/2014new/c/g/</guid>
          <pubDate>Sun, 23 May 1993 00:00:00 GMT</pubDate>
          <author>江苏省广播电视总台</author>
          <itunes:image href="https://pic.qtfm.cn/2017/0518/20170518065929.jpeg"></itunes:image>
          <enclosure url="" type="audio/mpeg"></enclosure>
          <itunes:duration>0:00:00</itunes:duration>
        </item>
    
        <item>
          <title>中国之声</title>
          <description>中国之声广播在线收听</description>
          <link>https://china.cnr.cn/</link>
          <guid isPermaLink="false">https://china.cnr.cn/</guid>
          <pubDate>Mon, 30 Dec 1940 00:00:00 GMT</pubDate>
          <author>中国中央人民广播电台</author>
          <itunes:image href="https://cnvod.cnr.cn/audio2017/ondemand/media/1100/20210425/1619317448858.jpg"></itunes:image>
          <enclosure url="" type="audio/mpeg"></enclosure>
          <itunes:duration>0:00:00</itunes:duration>
        </item>
        
        <item>
          <title>音乐之声</title>
          <description>音乐之声广播在线收听</description>
          <link>https://www.cnr.cn/#country-radio0207</link>
          <guid isPermaLink="false">https://www.cnr.cn/#country-radio0207</guid>
          <pubDate>Mon, 02 Dec 2002 00:00:00 GMT</pubDate>
          <author>中国中央人民广播电台</author>
          <itunes:image href="https://cnvod.cnr.cn/audio2017/ondemand/img/1100/20191224/1577155745280.png"></itunes:image>
          <enclosure url=" type="audio/mpeg"></enclosure>
          <itunes:duration>0:00:00</itunes:duration>
        </item>
    
        <!-- 可以根据需要添加更多的 <item> 元素 -->
        
      </channel>
    </rss>
    

    pages/index.js

    // pages/index.js
    import React from 'react';
    
    const Home = () => {
        const handleViewFeed = () => {
            const username = process.env.NEXT_PUBLIC_AUTH_USERNAME; // 前端环境变量
            const password = process.env.NEXT_PUBLIC_AUTH_PASSWORD; // 前端环境变量
            const url = `/api/display-feed`;
    
            const headers = new Headers();
            headers.append('Authorization', 'Basic ' + btoa(`${username}:${password}`)); // 创建基本认证头
    
            fetch(url, {
                method: 'GET',
                headers: headers
            })
            .then(response => {
                if (response.ok) {
                    window.open(url, '_blank');
                } else {
                    alert('未授权访问');
                }
            });
        };
    
        return (
            <div>
                <h1>欢迎来到我的 Feed 展示页面</h1>
                <button onClick={handleViewFeed}>查看 fm.feed 内容</button>
            </div>
        );
    };
    
    export default Home;
    

    vercel.json

    {
      "headers": [
        {
          "source": "/(.*\\.feed)",
          "headers": [
            {
              "key": "Content-Type",
              "value": "application/xml; charset=utf-8"
            }
          ]
        }
      ]
    }
    

    总结

    可以是在 GitHub 上的项目,可以支付宝或微信付款,或国外 paypal 付款,

    15 replies    2025-03-25 12:34:27 +08:00
    proxytoworld
        1
    proxytoworld  
       Mar 23, 2025
    两百块谁给你做
    donaldturinglee
        2
    donaldturinglee  
       Mar 23, 2025 via Android
    我没看错的话,你这个还要带部署的吗?
    Chaidu
        3
    Chaidu  
       Mar 23, 2025
    让 AI 帮你弄,没必要花这冤枉钱
    jchencode
        4
    jchencode  
       Mar 23, 2025
    200 块钱,哈哈哈哈
    chaoschick
        5
    chaoschick  
       Mar 23, 2025
    转成 mp3 后,就不能流式传输数据了吧,比方说 ffmpeg 刚转完 10 秒的音频 然后写入 a.mp3 这时候如果 a.mp3 被访问
    那边音频就只能播放 10 秒 然后就停止吧,就是不能流式的播放音频
    chaoschick
        6
    chaoschick  
       Mar 23, 2025
    安卓平台 google play

    https://play.google.com/store/apps/details?id=org.videolan.vlc

    这个应用可以播放 m3u8 流
    cs3230524
        7
    cs3230524  
       Mar 23, 2025
    200 块只能买个技术咨询服务。

    没看懂你的应用场景,直接上 oss 加上鉴权不就完事儿了。
    NGGTI
        8
    NGGTI  
    PRO
       Mar 23, 2025
    python 有原生 ffmpeg 库。设置一下缓冲区应该就能解决内存泄露了。
    NGGTI
        9
    NGGTI  
    PRO
       Mar 23, 2025
    你播客还得支持 mp3 流式播放才行。不然搞了也没用
    RealLiuSha
        10
    RealLiuSha  
       Mar 24, 2025
    别笑,搞不好真有叼毛给他做!
    mikawang
        11
    mikawang  
       Mar 24, 2025
    200 电脑都懒得打开
    hhhanako
        12
    hhhanako  
       Mar 24, 2025
    200 去找个专业的抖 S 骂
    l3m3lq
        13
    l3m3lq  
       Mar 24, 2025
    怎么好意思的???!!
    duguyihou
        14
    duguyihou  
       Mar 25, 2025 via iPhone
    大概是我太菜了,估计一小时搞不出来
    StrangerA
        15
    StrangerA  
       Mar 25, 2025
    都 AI 了建议自己接着写完吧。你可以提出两百块咨询费问方案哪里有问题,但是两百块要完整方案的话怕不是大学生作业都不够做的。
    About   ·   Help   ·   Advertise   ·   Blog   ·   API   ·   FAQ   ·   Solana   ·   903 Online   Highest 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 46ms · UTC 21:17 · PVG 05:17 · LAX 14:17 · JFK 17:17
    ♥ Do have faith in what you're doing.