前段时间研究了搭建了网络电台,但其实听得很少,那不是很浪费吗?收听的时间再推送音频流,没人收听就不推流,基本也就不占用流量,主机资源占用也非常少。
以下的代码基于TinyIce,它是基于golang做的一个网络电台程序。说起来用Icecast也差不多。
先运行 tinyice.exe -config .\tinyice.json 启动电台服务,再运行本程序。
当访问 /ease 时,它会启动ffmpeg推流到tinyice,然后将流量转发到客户端;当用户退出时,ffmpeg推流中止,继续等待访问。
package main
import (
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// ===================== 配置 =====================
// Config 从 config.json 读取的配置
type Config struct {
ListenAddr string `json:"listenAddr"`
TinyiceAddr string `json:"tinyiceAddr"`
FFmpegPushURL string `json:"ffmpegPushURL"`
MusicDir string `json:"musicDir"`
AudioExts []string `json:"audioExts"`
// 以下字段自动从 MusicDir 派生,无需手动配置
FFmpegInput string
PositionFile string
AudioExtMap map[string]bool
TinyIceURL *url.URL
}
var cfg Config
// loadConfig 从 config.json 加载配置
func loadConfig() error {
data, err := os.ReadFile("config.json")
if err != nil {
return fmt.Errorf("无法读取 config.json: %w", err)
}
if err := json.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("解析 config.json 失败: %w", err)
}
// 派生字段
cfg.FFmpegInput = filepath.Join(cfg.MusicDir, "list.txt")
cfg.PositionFile = filepath.Join(cfg.MusicDir, "position.txt")
cfg.AudioExtMap = make(map[string]bool)
for _, ext := range cfg.AudioExts {
cfg.AudioExtMap[strings.ToLower(ext)] = true
}
cfg.TinyIceURL, err = url.Parse("http://" + cfg.TinyiceAddr)
if err != nil {
return fmt.Errorf("解析 TinyIce 地址失败: %w", err)
}
return nil
}
var (
ffmpegLock sync.Mutex
ffmpegRunning bool
listenerCount int
lastStop time.Time
playStart time.Time // 当前 ffmpeg 会话启动时间
)
func main() {
// 加载配置
if err := loadConfig(); err != nil {
fmt.Printf("❌ 加载配置失败: %v\n", err)
return
}
fmt.Println("✅ 配置已加载")
// 检查 ffmpeg 是否可用
if err := checkFFmpeg(); err != nil {
fmt.Printf("❌ FFmpeg 检查失败: %v\n", err)
return
}
fmt.Println("✅ FFmpeg 可用")
// 初始生成播放列表并验证
if err := generatePlaylist(); err != nil {
fmt.Printf("❌ 生成播放列表失败: %v\n", err)
return
}
if err := buildResumePlaylist(); err != nil {
fmt.Printf("⚠️ 恢复播放列表失败,将从头播放: %v\n", err)
}
if err := validatePlaylist(); err != nil {
fmt.Printf("❌ 播放列表验证失败: %v\n", err)
return
}
fmt.Printf("✅ 监听播放地址:http://IP%s/ease\n", cfg.ListenAddr[strings.Index(cfg.ListenAddr, ":"):])
fmt.Println("✅ 无人自动停推,有人自动推流")
http.HandleFunc("/ease", handleStream)
http.ListenAndServe(cfg.ListenAddr, nil)
}
// checkFFmpeg 检查 ffmpeg 是否已安装且可用
func checkFFmpeg() error {
cmd := exec.Command("ffmpeg", "-version")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg 未安装或不可执行: %w", err)
}
// 检查是否支持 libmp3lame 编码
if !strings.Contains(string(output), "--enable-libmp3lame") {
return fmt.Errorf("ffmpeg 未编译 libmp3lame 支持,无法编码 MP3")
}
return nil
}
// generatePlaylist 扫描音乐目录,生成 ffmpeg concat 格式的播放列表
func generatePlaylist() error {
entries, err := os.ReadDir(cfg.MusicDir)
if err != nil {
return fmt.Errorf("无法读取音乐目录 %s: %w", cfg.MusicDir, err)
}
var lines []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
if !cfg.AudioExtMap[ext] {
continue
}
// 使用单引号包裹文件名(ffmpeg concat 格式)
lines = append(lines, fmt.Sprintf("file '%s'", entry.Name()))
}
if len(lines) == 0 {
return fmt.Errorf("音乐目录 %s 中没有找到音频文件", cfg.MusicDir)
}
content := strings.Join(lines, "\n") + "\n"
if err := os.WriteFile(cfg.FFmpegInput, []byte(content), 0644); err != nil {
return fmt.Errorf("无法写入播放列表 %s: %w", cfg.FFmpegInput, err)
}
fmt.Printf("✅ 已生成播放列表,共 %d 首歌曲\n", len(lines))
return nil
}
// validatePlaylist 验证播放列表能否被 ffmpeg 正常解码
func validatePlaylist() error {
// 只解码前 3 秒来验证
cmd := exec.Command("ffmpeg",
"-t", "3",
"-f", "concat", "-safe", "0", "-i", cfg.FFmpegInput,
"-f", "null", "-",
)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg 无法播放列表: %w\n输出: %s", err, string(output))
}
fmt.Println("✅ 播放列表验证通过,FFmpeg 可正常播放")
return nil
}
// readPosition 读取已播放的累计秒数
func readPosition() float64 {
data, err := os.ReadFile(cfg.PositionFile)
if err != nil {
return 0
}
pos, err := strconv.ParseFloat(strings.TrimSpace(string(data)), 64)
if err != nil {
return 0
}
return pos
}
// savePosition 保存当前累计播放位置
func savePosition() {
if playStart.IsZero() {
return
}
elapsed := time.Since(playStart).Seconds()
total := readPosition() + elapsed
os.WriteFile(cfg.PositionFile, []byte(fmt.Sprintf("%.2f", total)), 0644)
fmt.Printf("📝 已保存播放位置:累计 %.0f 秒\n", total)
}
// getTrackDuration 用 ffprobe 获取音频文件时长(秒)
func getTrackDuration(filePath string) (float64, error) {
cmd := exec.Command("ffprobe", "-v", "quiet",
"-show_entries", "format=duration",
"-of", "csv=p=0", filePath)
output, err := cmd.Output()
if err != nil {
return 0, fmt.Errorf("ffprobe 失败: %w", err)
}
dur, err := strconv.ParseFloat(strings.TrimSpace(string(output)), 64)
if err != nil {
return 0, fmt.Errorf("解析时长失败: %w", err)
}
return dur, nil
}
// buildResumePlaylist 根据已保存的播放位置,生成恢复播放列表
func buildResumePlaylist() error {
savedPos := readPosition()
if savedPos <= 0 {
fmt.Println("ℹ️ 无历史播放记录,从头开始播放")
return nil
}
// 获取音频文件列表(与 generatePlaylist 顺序一致)
entries, err := os.ReadDir(cfg.MusicDir)
if err != nil {
return fmt.Errorf("无法读取音乐目录: %w", err)
}
var audioFiles []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
if cfg.AudioExtMap[ext] {
audioFiles = append(audioFiles, entry.Name())
}
}
if len(audioFiles) == 0 {
return nil
}
// 获取每首歌的时长
durations := make([]float64, len(audioFiles))
var totalDuration float64
for i, name := range audioFiles {
dur, err := getTrackDuration(filepath.Join(cfg.MusicDir, name))
if err != nil {
fmt.Printf("⚠️ 无法获取 %s 时长: %v\n", name, err)
durations[i] = 0
continue
}
durations[i] = dur
totalDuration += dur
}
if totalDuration <= 0 {
return nil
}
// 取余数,支持循环播放
pos := math.Mod(savedPos, totalDuration)
// 找到恢复播放的起始歌曲和偏移
var cumDur float64
startIndex := 0
startOffset := 0.0
for i, dur := range durations {
if dur <= 0 {
continue
}
if cumDur+dur > pos {
startIndex = i
startOffset = pos - cumDur
break
}
cumDur += dur
}
// 生成恢复播放列表(使用 inpoint 跳过已播放的部分)
var lines []string
for i := startIndex; i < len(audioFiles); i++ {
lines = append(lines, fmt.Sprintf("file '%s'", audioFiles[i]))
if i == startIndex && startOffset > 1 {
lines = append(lines, fmt.Sprintf("inpoint %.2f", startOffset))
}
}
content := strings.Join(lines, "\n") + "\n"
if err := os.WriteFile(cfg.FFmpegInput, []byte(content), 0644); err != nil {
return fmt.Errorf("无法写入恢复播放列表: %w", err)
}
fmt.Printf("✅ 从第 %d 首「%s」偏移 %.1f 秒处恢复播放\n", startIndex+1, audioFiles[startIndex], startOffset)
return nil
}
func handleStream(w http.ResponseWriter, r *http.Request) {
if time.Since(lastStop) < 3*time.Second {
http.Error(w, "Please retry later", 503)
return
}
ffmpegLock.Lock()
listenerCount++
if !ffmpegRunning {
startFFmpeg()
ffmpegRunning = true
// FFmpeg 刚启动,等待 TinyIce 流就绪后再代理
ffmpegLock.Unlock()
if !waitTinyIceReady(10 * time.Second) {
ffmpegLock.Lock()
listenerCount--
if listenerCount == 0 && ffmpegRunning {
stopFFmpeg()
ffmpegRunning = false
lastStop = time.Now()
}
ffmpegLock.Unlock()
http.Error(w, "Stream not ready, please retry", 503)
return
}
} else {
ffmpegLock.Unlock()
}
defer func() {
ffmpegLock.Lock()
listenerCount--
if listenerCount == 0 && ffmpegRunning {
stopFFmpeg()
ffmpegRunning = false
lastStop = time.Now()
}
ffmpegLock.Unlock()
}()
proxyToTinyIce(w, r)
}
func startFFmpeg() {
// 每次启动前重新生成播放列表,根据已保存位置恢复
if err := generatePlaylist(); err != nil {
fmt.Printf("❌ 生成播放列表失败: %v\n", err)
return
}
if err := buildResumePlaylist(); err != nil {
fmt.Printf("⚠️ 恢复播放列表失败,从头播放: %v\n", err)
}
cmd := exec.Command("ffmpeg",
"-re",
"-f", "concat", "-safe", "0", "-i", cfg.FFmpegInput,
"-ar", "44100", "-ac", "2",
"-c:a", "libmp3lame", "-b:a", "128k",
"-filter:a", "loudnorm=I=-16:LRA=11:TP=-1.5",
"-f", "mp3",
cfg.FFmpegPushURL,
)
cmd.Stdout = nil
cmd.Stderr = nil
err := cmd.Start()
if err == nil {
playStart = time.Now()
println("🟢 FFmpeg 已启动(有人收听)")
}
}
func stopFFmpeg() {
savePosition()
playStart = time.Time{}
exec.Command("taskkill", "/F", "/IM", "ffmpeg.exe").Run()
println("🔴 FFmpeg 已停止(无人收听)")
}
// waitTinyIceReady 轮询 TinyIce 直到流就绪或超时
func waitTinyIceReady(timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
checkURL := "http://" + cfg.TinyiceAddr + "/ease"
for time.Now().Before(deadline) {
resp, err := http.Get(checkURL)
if err == nil {
resp.Body.Close()
if resp.StatusCode == 200 {
println("✅ TinyIce 流已就绪")
return true
}
}
time.Sleep(500 * time.Millisecond)
}
println("❌ 等待 TinyIce 就绪超时")
return false
}
func proxyToTinyIce(w http.ResponseWriter, r *http.Request) {
proxy := httputil.NewSingleHostReverseProxy(cfg.TinyIceURL)
// FlushInterval = -1 表示每次写入后立即刷新,确保音频流实时推送给客户端
proxy.FlushInterval = -1
proxy.ServeHTTP(w, r)
}
{
"listenAddr": "0.0.0.0:8000",
"tinyiceAddr": "127.0.0.1:9911",
"ffmpegPushURL": "icecast://:123123@127.0.0.1:9911/ease",
"musicDir": "e:/music",
"audioExts": [".mp3", ".flac", ".wav", ".ape", ".ogg", ".aac", ".m4a", ".wma", ".opus"]
}
打赏