// 本地/远端有声书的按需转码:源文件是浏览器解不了的格式(典型是 WMA/ASF)时, // 用 ffmpeg 转成 mp3 落到缓存目录,再按 Range 下发。 // // 为什么落盘而不是实时流式输出:有声书必须能拖动进度,而 http.ServeContent 的 // Range 支持要求可随机读取的文件;实时管道没有长度信息,一旦 seek 就废掉。 // 一章通常几 MB,转一次几秒,之后同一章秒开。 package reader import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "net/url" "os" "os/exec" "path/filepath" "sort" "strings" "sync" "time" "golang.org/x/sync/singleflight" "github.com/truewhile/MeBox/internal/helper" ) // audioTranscodeFlight 同一章节并发命中时只跑一次 ffmpeg(按缓存路径去重)。 var audioTranscodeFlight singleflight.Group // 转码互斥表:singleflight 只保证同一 key 不重复执行,prune 与写文件仍需串行。 var audioTranscodeMu sync.Mutex const ( // audioTranscodeDirName 缓存目录名,位于 cache.cache_dir 之下。 audioTranscodeDirName = "reader-audio" // maxAudioTranscodeCacheBytes 转码缓存上限,超出按修改时间淘汰最旧的。 maxAudioTranscodeCacheBytes = 4 << 30 // audioTranscodeKeepRatio 触发淘汰后回落到上限的比例,避免每次写入都淘汰。 audioTranscodeKeepRatio = 0.9 // maxAudioTranscodeDuration 单章转码超时。 maxAudioTranscodeDuration = 15 * time.Minute // audioTranscodeBitrate 语音内容 96k 足够,体积约为原 WMA 的两倍以内。 audioTranscodeBitrate = "96k" ) // needsTranscodeAudioExt 明确需要转码的容器/编码(浏览器都无法直接解码)。 // 只列已知有问题的:未列出的格式维持原样直出,避免把本来能播的流也拖去转码。 var needsTranscodeAudioExt = map[string]bool{ ".wma": true, ".asf": true, ".wmv": true, ".ape": true, ".wv": true, ".ac3": true, ".dts": true, ".amr": true, ".tta": true, ".dsf": true, ".dff": true, } // audioSourceExt 取音频地址的扩展名(小写带点)。 // // 既要认本地路径(D:\x\a.wma),也要认带 query 的远端地址 // (…/video.wma?acct=…)——后者直接 filepath.Ext 会把 query 一起算进去。 func audioSourceExt(source string) string { raw := strings.TrimSpace(source) if raw == "" { return "" } // scheme 长度 >1 才算真 URL:Windows 盘符(D:\…)会被 url.Parse 当成单字符 scheme if u, err := url.Parse(raw); err == nil && len(u.Scheme) > 1 { return strings.ToLower(filepath.Ext(u.Path)) } if i := strings.IndexAny(raw, "?#"); i >= 0 { raw = raw[:i] } return strings.ToLower(filepath.Ext(raw)) } // needsAudioTranscode 判断该音频是否必须转码后才能给浏览器播放。 func needsAudioTranscode(source string) bool { return needsTranscodeAudioExt[audioSourceExt(source)] } // ffmpegBinary 解析可用的 ffmpeg 可执行文件;找不到返回空串。 func (s *ReaderService) ffmpegBinary() string { path := strings.TrimSpace(s.cfg.App.FFmpegPath) if path == "" { path = "ffmpeg" } resolved, err := exec.LookPath(path) if err != nil { return "" } return resolved } // ErrAudioTranscodeUnavailable 服务器没有 ffmpeg,无法转码该格式。 var ErrAudioTranscodeUnavailable = errors.New("服务器未安装 ffmpeg") // transcodeMissingFFmpegError 给前端一条能直接照做的提示。 func transcodeMissingFFmpegError(source string) error { ext := strings.TrimPrefix(audioSourceExt(source), ".") if ext == "" { ext = "该" } return fmt.Errorf("这个音频是 %s 格式,浏览器无法直接播放,需要服务器转码;%w,请在设置里配置 app.ffmpeg_path 或安装 ffmpeg", strings.ToUpper(ext), ErrAudioTranscodeUnavailable) } // ─── 签名(与本地音频同一套: