(原) 按需播放的网络电台

原创文章,请后转载,并注明出处。

前段时间研究了搭建了网络电台,但其实听得很少,那不是很浪费吗?收听的时间再推送音频流,没人收听就不推流,基本也就不占用流量,主机资源占用也非常少。

以下的代码基于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"]
}

相关文章