要求
我需要在线将 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:1234@example.com/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: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 付款,
|