要求
我需要在线将 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
具体要求
-
可以进行用户认证,设置变量
FM_USER: "fm",FM_PASSWORD: "1234"和FM_ACCESS_KEY: "5555",则可以通过https://fm:[email protected]/1.mp3或https://example.com/1.mp3?key=5555访问,如果未进行用户认证则可以直接访问。 -
当有用户访问时可以立即进行 ffmpeg 转换,当访问断开时等待 10 秒或更长时间未访问链接可以关闭 ffmpeg 转换已节省内存,我尝试用 AI 解决此问题,但都遇到启动慢,或内存泄漏问题,无法在在结束访问时及时关闭或开始访问时无法及时启动。
-
根据环境变量设置链接别名
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=5555AI 给的解决方案
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 psutilGolang 版
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.20go.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 付款,
