V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
AaronLee
V2EX  ›  外包

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

  •  
  •   AaronLee · 8 天前 · 1271 次点击

    要求

    我需要在线将 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:1234@example.com/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:1234@example.com/cnr1/cnr1.mp3
      https://fm:1234@example.com/cnr2/cnr2.mp3
      https://fm:1234@example.com/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>contact@example.com</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 条回复    2025-03-25 12:34:27 +08:00
    proxytoworld
        1
    proxytoworld  
       8 天前
    两百块谁给你做
    donaldturinglee
        2
    donaldturinglee  
       8 天前 via Android
    我没看错的话,你这个还要带部署的吗?
    Chaidu
        3
    Chaidu  
       8 天前
    让 AI 帮你弄,没必要花这冤枉钱
    LiuJiang
        4
    LiuJiang  
       8 天前
    200 块钱,哈哈哈哈
    chaoschick
        5
    chaoschick  
       8 天前
    转成 mp3 后,就不能流式传输数据了吧,比方说 ffmpeg 刚转完 10 秒的音频 然后写入 a.mp3 这时候如果 a.mp3 被访问
    那边音频就只能播放 10 秒 然后就停止吧,就是不能流式的播放音频
    chaoschick
        6
    chaoschick  
       8 天前
    安卓平台 google play

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

    这个应用可以播放 m3u8 流
    cs3230524
        7
    cs3230524  
       8 天前
    200 块只能买个技术咨询服务。

    没看懂你的应用场景,直接上 oss 加上鉴权不就完事儿了。
    NGGTI
        8
    NGGTI  
       8 天前
    python 有原生 ffmpeg 库。设置一下缓冲区应该就能解决内存泄露了。
    NGGTI
        9
    NGGTI  
       8 天前
    你播客还得支持 mp3 流式播放才行。不然搞了也没用
    lgpqdwjh
        10
    lgpqdwjh  
       7 天前
    别笑,搞不好真有叼毛给他做!
    mikawang
        11
    mikawang  
       7 天前
    200 电脑都懒得打开
    hhhanako
        12
    hhhanako  
       7 天前
    200 去找个专业的抖 S 骂
    l3m3lq
        13
    l3m3lq  
       7 天前
    怎么好意思的???!!
    duguyihou
        14
    duguyihou  
       6 天前 via iPhone
    大概是我太菜了,估计一小时搞不出来
    StrangerA
        15
    StrangerA  
       6 天前
    都 AI 了建议自己接着写完吧。你可以提出两百块咨询费问方案哪里有问题,但是两百块要完整方案的话怕不是大学生作业都不够做的。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   969 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 23ms · UTC 20:36 · PVG 04:36 · LAX 13:36 · JFK 16:36
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.