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

102 天前
 AaronLee

要求

我需要在线将 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 付款,

1705 次点击
所在节点    外包
15 条回复
proxytoworld
102 天前
两百块谁给你做
donaldturinglee
102 天前
我没看错的话,你这个还要带部署的吗?
Chaidu
102 天前
让 AI 帮你弄,没必要花这冤枉钱
LiuJiang
102 天前
200 块钱,哈哈哈哈
chaoschick
102 天前
转成 mp3 后,就不能流式传输数据了吧,比方说 ffmpeg 刚转完 10 秒的音频 然后写入 a.mp3 这时候如果 a.mp3 被访问
那边音频就只能播放 10 秒 然后就停止吧,就是不能流式的播放音频
chaoschick
102 天前
安卓平台 google play

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

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

没看懂你的应用场景,直接上 oss 加上鉴权不就完事儿了。
NGGTI
102 天前
python 有原生 ffmpeg 库。设置一下缓冲区应该就能解决内存泄露了。
NGGTI
102 天前
你播客还得支持 mp3 流式播放才行。不然搞了也没用
lgpqdwjh
102 天前
别笑,搞不好真有叼毛给他做!
mikawang
101 天前
200 电脑都懒得打开
hhhanako
101 天前
200 去找个专业的抖 S 骂
l3m3lq
101 天前
怎么好意思的???!!
duguyihou
101 天前
大概是我太菜了,估计一小时搞不出来
StrangerA
100 天前
都 AI 了建议自己接着写完吧。你可以提出两百块咨询费问方案哪里有问题,但是两百块要完整方案的话怕不是大学生作业都不够做的。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://ex.noerr.eu.org/t/1120457

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX