要求

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

举报· 412 次点击
登录 注册 站外分享
3 条回复  
proxytoworld 初学 3 天前
两百块谁给你做
donaldturinglee 小成 3 天前
我没看错的话,你这个还要带部署的吗?
Chaidu 小成 3 天前
让 AI 帮你弄,没必要花这冤枉钱
返回顶部