Files
MeBox/internal/handler/reader_routes.go
T
truewhile 9b8ca33279 bug处理
2026-11-01 09:23:58 +08:00

2093 lines
78 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package handler — 阅读(legado 书源兼容)子系统路由。
package handler
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
readerrule "github.com/truewhile/MeBox/internal/service/reader/rule"
)
func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
g := authed.Group("/reader")
// 书源管理
g.GET("/sources", readerListSourcesHandler(svc))
g.POST("/sources/import", readerImportSourcesHandler(svc))
g.PATCH("/sources/:id", readerUpdateSourceHandler(svc))
g.DELETE("/sources/:id", readerDeleteSourceHandler(svc))
g.POST("/sources/:id/debug", readerDebugSourceHandler(svc))
// 书源登录与源变量(登录类书源必需)
g.GET("/sources/:id/login", readerSourceLoginInfoHandler(svc))
g.POST("/sources/:id/login", readerSourceLoginActionHandler(svc))
g.DELETE("/sources/:id/login", readerSourceLogoutHandler(svc))
g.PUT("/sources/:id/variable", readerSetSourceVariableHandler(svc))
g.PUT("/sources/:id/login-info", readerSetSourceLoginInfoHandler(svc))
// 书源 JS 的宿主浏览器(java.startBrowser / startBrowserAwait):
// 前端轮询待办 → 在 iframe 里承载页面 → 用户点 √ 回传 DOM。
g.GET("/browser/pending", readerBrowserPendingHandler(svc))
g.POST("/browser/result", readerBrowserResultHandler(svc))
// 页面内的 fetch/XHR 经此转发(iframe 是不透明源,请求带不上书源 Cookie)
g.POST("/browser/xhr", readerBrowserXHRHandler(svc))
// 段评:用宿主浏览器打开评论地址(带书源 Cookie/登录态,对应书源的 showCmt)
g.POST("/comments/open", readerOpenCommentHandler(svc))
// 搜索(多源聚合)
g.POST("/search", readerSearchHandler(svc))
// 详情 / 目录 / 正文
g.GET("/book-info", readerBookInfoHandler(svc))
g.GET("/toc", readerTocHandler(svc))
g.GET("/content", readerContentHandler(svc))
// 书架
g.GET("/books", readerListBooksHandler(svc))
g.POST("/books", readerAddBookHandler(svc))
// 本地书籍(TXT / EPUB 上传导入)
g.POST("/local/books", readerImportLocalBookHandler(svc))
// 服务器已有文件/目录导入:原地引用不复制,仅管理员(会读取允许根目录内的文件)
g.POST("/local/books/from-path", middleware.AdminRequired(), readerImportLocalBookFromPathHandler(svc))
g.POST("/local/audiobooks", middleware.AdminRequired(), readerImportLocalAudioDirHandler(svc))
g.DELETE("/books/:id", readerRemoveBookHandler(svc))
g.PUT("/books/:id/progress", readerSaveProgressHandler(svc))
// 更新目录:重抓书架里全部网络书籍的目录,刷新章节缓存与「最近更新」时间
g.POST("/shelf/refresh-toc", readerRefreshBooksTocHandler(svc))
// 阅读器偏好(每个用户一条):主题 / 排版 / 听书 / 书架展示设置,跨设备同步
g.GET("/profile", readerGetProfileHandler(svc))
g.PUT("/profile", readerSaveProfileHandler(svc))
// 书架分组(每个用户一份):组名 → 书籍 ID,仿影视模块的媒体库标签
g.GET("/book-groups", readerGetBookGroupsHandler(svc))
g.PUT("/book-groups", readerSetBookGroupsHandler(svc))
// 换源:把书架里的书切到另一个书源(保留阅读进度,目录缓存按新源重建)
g.POST("/books/:id/origin", readerSwitchOriginHandler(svc))
g.PUT("/books/:id/audio-config", readerSaveAudioConfigHandler(svc))
g.GET("/books/:id/chapters", readerListChaptersHandler(svc))
g.POST("/books/:id/chapters", readerReplaceChaptersHandler(svc))
g.GET("/books/:id/content", readerBookContentHandler(svc))
// 批量取正文(离线缓存协议,对应 legado CacheBook):返回每章内容与命中统计
g.POST("/books/:id/content-batch", readerBookContentBatchHandler(svc))
// 替换净化规则
g.GET("/replace-rules", readerListReplaceRulesHandler(svc))
g.POST("/replace-rules", readerCreateReplaceRuleHandler(svc))
g.PATCH("/replace-rules/:id", readerUpdateReplaceRuleHandler(svc))
g.DELETE("/replace-rules/:id", readerDeleteReplaceRuleHandler(svc))
}
func readerListSourcesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
sources, err := svc.Reader.ListSources(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"sources": sources})
}
}
func readerImportSourcesHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Text string `json:"text" binding:"required"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
imported, err := svc.Reader.ImportSources(c.Request.Context(), userID, body.Text)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"imported": imported})
}
}
func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Enabled *bool `json:"enabled"`
// MediaDirect 媒体直连开关:允许客户端自行下载音频/漫画图片字节。
// 可单独提交,也可与其他字段一起提交(只更新出现的字段)。
MediaDirect *bool `json:"media_direct"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil || (body.Enabled == nil && body.MediaDirect == nil) {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 或 media_direct 字段必填"})
return
}
userID := c.GetString(middleware.CtxUserID)
if body.Enabled != nil {
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), userID, c.Param("id"), *body.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if body.MediaDirect != nil {
if err := svc.Reader.SetSourceMediaDirect(c.Request.Context(), userID, c.Param("id"), *body.MediaDirect); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.DeleteSource(c.Request.Context(), userID, c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var body struct {
Key string `json:"key" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
logs, err := svc.Reader.Debug(c.Request.Context(), userID, c.Param("id"), body.Key)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"logs": logs})
}
}
func readerSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
info, err := svc.Reader.GetSourceLogin(c.Request.Context(), userID, c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, info)
}
}
func readerSourceLoginActionHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
// Action 为 loginUi 里按钮的 action;留空表示执行 login()(确认登录)。
Action string `json:"action"`
Fields map[string]string `json:"fields"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
res, err := svc.Reader.RunLoginAction(c.Request.Context(), userID, c.Param("id"), body.Action, body.Fields)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
// readerBrowserPendingHandler 前端轮询:该用户在某书源下待用户完成的页面。
// 书源的 java.startBrowserAwait 会阻塞在服务端,前端据此把页面呈现出来。
func readerBrowserPendingHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
pages := svc.Reader.PendingBrowserPages(userID, c.Query("source_id"))
c.JSON(http.StatusOK, gin.H{"pages": pages})
}
}
// readerBrowserResultHandler 用户完成页面后回传 DOM(或取消),解除服务端阻塞。
func readerBrowserResultHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
ID string `json:"id" binding:"required"`
Body string `json:"body"`
URL string `json:"url"`
Cancelled bool `json:"cancelled"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.ResolveBrowser(body.ID, userID, body.Body, body.URL, body.Cancelled); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// readerBrowserXHRHandler 转发承载页面内的接口请求。
//
// 页面在 iframe 里是不透明源,自己的 XHR 既带不上书源 Cookie 也会被 CORS 拦,
// 所以由父窗口(持 JWT)把请求转交到这里,服务端补上书源凭据再发。
func readerBrowserXHRHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
ID string `json:"id" binding:"required"`
URL string `json:"url" binding:"required"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
res, err := svc.Reader.ProxyBrowserXHR(
c.Request.Context(), body.ID, body.Method, body.URL, body.Headers, body.Body)
if err != nil {
// 交给页面自己处理失败,别把 4xx 泄成框架错误
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
// readerOpenCommentHandler 打开一条段评:复用书源登录态在宿主浏览器里承载评论页。
// 书源的 showCmt 内部就是「java.ajax 取评论页 → java.showBrowser 展示」,
// 这里用承载登录页的同一套机制等价实现(见 reader.OpenContentComment)。
func readerOpenCommentHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
BookID string `json:"book_id" binding:"required"`
URL string `json:"url" binding:"required"`
Title string `json:"title"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
page, err := svc.Reader.OpenContentComment(c.Request.Context(), userID, body.BookID, body.URL, body.Title)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, page)
}
}
// readerBrowserPageHandler 承载待办页面本体。//
// 鉴权走 HMAC 签名而非 JWT:这个地址要填进 <iframe src>,而 iframe 的请求
// 带不上 Authorization 头。签名绑定待办 ID,链接随待办一起过期。
func readerBrowserPageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
snap, err := svc.Reader.VerifyBrowserPage(c.Query("id"), c.Query("s"))
if err != nil {
c.String(http.StatusForbidden, "%s", err.Error())
return
}
c.Header("Cache-Control", "no-store")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(snap.HTML))
}
}
// readerBrowserAssetHandler 页面资源/表单/站内链接的同源代理。
// 服务端补上书源 Cookie 与请求头,使「用户后台」这类页面在 iframe 里保持登录态。
func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
target, _, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s"))
if err != nil {
c.String(http.StatusForbidden, "%s", err.Error())
return
}
contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), c.Query("id"), target)
if err != nil {
c.String(http.StatusBadGateway, "资源加载失败: %s", err.Error())
return
}
if strings.Contains(strings.ToLower(contentType), "text/css") {
data = []byte(svc.Reader.RewriteBrowserCSS(string(data), target, c.Query("id")))
}
c.Header("Cache-Control", "no-store")
c.Data(status, contentType, data)
}
}
func readerSourceLogoutHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.ClearSourceLogin(c.Request.Context(), userID, c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerSetSourceVariableHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Variable string `json:"variable"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SetSourceVariable(c.Request.Context(), userID, c.Param("id"), body.Variable); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Fields map[string]string `json:"fields"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), userID, c.Param("id"), body.Fields); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Key string `json:"key" binding:"required"`
// SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。
SourceIDs []string `json:"source_ids"`
// Page 页码(从 1 开始):书源 searchUrl 支持 {{page}} 时,前端滚到底逐页取。
Page int `json:"page"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
books, skipped, err := svc.Reader.Search(c.Request.Context(), userID, body.Key, body.SourceIDs, body.Page)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"books": books, "skipped": skipped})
}
}
func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
info, err := svc.Reader.GetBookInfo(
c.Request.Context(),
c.Query("source_id"), c.Query("source_url"), c.Query("book_url"),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 声明了封面解密的书源:详情封面也要走解密代理(尚未入库,按书源 URL 签)。
if info != nil {
// 顺手修复书架里被上游限流写坏的封面(存成接口地址的那种),先按原始
// 地址写回,再做下发时的解密代理改写。
userID := c.GetString(middleware.CtxUserID)
svc.Reader.RepairShelfCover(c.Request.Context(), userID, c.Query("source_url"), c.Query("book_url"), info.CoverURL)
info.CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), "", c.Query("source_url"), info.CoverURL)
}
c.JSON(http.StatusOK, info)
}
}
func readerTocHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
chapters, err := svc.Reader.GetToc(
c.Request.Context(),
userID, c.Query("source_id"), c.Query("source_url"), c.Query("book_url"), c.Query("toc_url"),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// nil 切片会被编码成 null,前端拿到 null 再取 .length 就是一句
// 「Cannot read properties of null」。空目录统一给 []。
if chapters == nil {
chapters = []reader.TocChapter{}
}
c.JSON(http.StatusOK, gin.H{"chapters": chapters})
}
}
func readerContentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
content, err := svc.Reader.GetContent(
c.Request.Context(),
c.Query("source_id"), c.Query("source_url"), c.Query("book_url"), c.Query("chapter_url"),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, content)
}
}
func readerListBooksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
books, err := svc.Reader.ListBooks(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 封面解密代理只在下发时改写:库里仍存原始地址,换源/换签名密钥后不会失效。
for i := range books {
books[i].CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), books[i].ID, books[i].Origin, books[i].CoverURL)
}
c.JSON(http.StatusOK, gin.H{"books": books})
}
}
func readerAddBookHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Origin reader.SearchOrigin `json:"origin" binding:"required"`
Name string `json:"name" binding:"required"`
Author string `json:"author"`
CoverURL string `json:"cover_url"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
book, err := svc.Reader.AddBook(c.Request.Context(), userID, body.Origin, body.Name, body.Author, body.CoverURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 后台补目录缓存,让书架能显示未读章数;失败不影响加入书架本身。
svc.Reader.WarmUpBookChaptersAsync(c.Request.Context(), userID, book)
c.JSON(http.StatusOK, book)
}
}
// readerSwitchOriginHandler 换源:把书架里的书切到另一个书源。
// 阅读进度保留;旧源目录缓存清空后按新源后台重新预热。
func readerSwitchOriginHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Origin reader.SearchOrigin `json:"origin" binding:"required"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
book, err := svc.Reader.SwitchOrigin(c.Request.Context(), userID, c.Param("id"), body.Origin)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
svc.Reader.WarmUpBookChaptersAsync(c.Request.Context(), userID, book)
c.JSON(http.StatusOK, book)
}
}
// readerImportLocalBookHandler 上传本地书籍(TXT / EPUB)并加入书架。
// 正文落盘到 data/reader/local,目录切分后与网络书籍共用阅读器链路。
func readerImportLocalBookHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
// 先卡住请求体大小,避免超大文件把内存打满(多给 1MB 放 multipart 头)
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, int64(reader.LocalBookMaxBytes)+(1<<20))
header, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少上传文件(表单字段 file),或文件超过大小上限"})
return
}
f, err := header.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取上传文件失败: " + err.Error()})
return
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, int64(reader.LocalBookMaxBytes)+1))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取上传文件失败: " + err.Error()})
return
}
if len(data) > reader.LocalBookMaxBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "文件超过大小上限"})
return
}
userID := c.GetString(middleware.CtxUserID)
book, err := svc.Reader.ImportLocalBook(c.Request.Context(), userID, header.Filename, data)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, book)
}
}
// readerImportLocalBookFromPathHandler 从服务器已有文件导入书籍(TXT / EPUB),原地引用。
// 仅管理员:会读取服务器上允许根目录内的任意文件。
func readerImportLocalBookFromPathHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Path string `json:"path" binding:"required"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
abs, err := svc.FileManager.ResolvePath(body.Path)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
book, err := svc.Reader.ImportLocalBookFromPath(c.Request.Context(), userID, abs)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, book)
}
}
// readerImportLocalAudioDirHandler 把一个服务器目录导入为一本有声书,原地引用。
// 仅管理员;目录下的音频文件与 .strm 播放指针按相对路径排序成为章节。
func readerImportLocalAudioDirHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Path string `json:"path" binding:"required"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
abs, err := svc.FileManager.ResolvePath(body.Path)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
book, err := svc.Reader.ImportLocalAudioDir(c.Request.Context(), userID, abs)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, book)
}
}
// readerRefreshBooksTocHandler 更新目录:重抓书架里全部网络书籍的目录。
// 本地书籍与没有书源信息的书籍跳过;单本失败只计数,不影响其它书。
func readerRefreshBooksTocHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
res, err := svc.Reader.RefreshBooksToc(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func readerRemoveBookHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.RemoveBook(c.Request.Context(), userID, c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// readerGetProfileHandler 读当前用户的阅读器偏好。
// 没保存过时返回 {"profile": null},前端据此用本地值播种。
func readerGetProfileHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
settings, err := svc.Reader.GetReaderSettings(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"profile": settings})
}
}
// readerSaveProfileHandler 覆盖保存当前用户的阅读器偏好(服务端做范围收敛)。
func readerSaveProfileHandler(svc *service.Container) gin.HandlerFunc {
var body reader.ReaderSettings
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
saved, err := svc.Reader.SaveReaderSettings(c.Request.Context(), userID, body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"profile": saved})
}
}
// readerGetBookGroupsHandler 读当前用户的书架分组。
// 没有分组时返回空数组(不是 null),前端可以直接遍历。
func readerGetBookGroupsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
groups, err := svc.Reader.GetBookGroups(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"groups": groups})
}
}
// readerSetBookGroupsHandler 覆盖保存当前用户的书架分组(整份替换)。
func readerSetBookGroupsHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Groups []model.BookGroupSet `json:"groups"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
groups, err := svc.Reader.SetBookGroups(c.Request.Context(), userID, body.Groups)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"groups": groups})
}
}
// maxReaderChapterPos 进度上限:秒(听书)/ 页码(文本)/ 图片序号(漫画)都远小于它,
// 只用来挡住异常大的浮点数转 int 时溢出。约 115 天,足够覆盖任何单章。
const maxReaderChapterPos = 10_000_000
func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
ChapterIndex int `json:"chapter_index"`
// Pos 用 float64 接:听书的进度是 audio.currentTime(秒,天然带小数),
// 漫画是图片序号、文本是页码(都是整数)。用 int 接小数会让整个请求
// 400,而前端是 fire-and-forget,音频进度就被静默丢掉了。
Pos float64 `json:"pos"`
ChapterTitle string `json:"chapter_title"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// dur_chapter_pos 落库是 int,截断成秒;负数没有意义,夹到 0。
posFloat := body.Pos
if !(posFloat > 0) { // 同时挡住 0 与负数(JSON 不会给出 NaN)
posFloat = 0
}
if posFloat > maxReaderChapterPos {
posFloat = maxReaderChapterPos
}
pos := int(posFloat)
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SaveProgress(c.Request.Context(), userID, c.Param("id"), body.ChapterIndex, pos, body.ChapterTitle); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// readerSaveAudioConfigHandler 保存听书片头/片尾跳过秒数(0 为不跳过)。
func readerSaveAudioConfigHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
OpenCredits int `json:"open_credits"`
CloseCredits int `json:"close_credits"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SaveAudioConfig(
c.Request.Context(), userID, c.Param("id"), body.OpenCredits, body.CloseCredits); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerListChaptersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
chapters, err := svc.Reader.ListChapters(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"chapters": chapters})
}
}
func readerReplaceChaptersHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Chapters []reader.ChapterInput `json:"chapters"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Reader.SaveChapters(c.Request.Context(), c.Param("id"), body.Chapters); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerListReplaceRulesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
rules, err := svc.Reader.ListReplaceRules(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"rules": rules})
}
}
func readerCreateReplaceRuleHandler(svc *service.Container) gin.HandlerFunc {
var body reader.ReplaceRuleInput
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
rule, err := svc.Reader.CreateReplaceRule(c.Request.Context(), userID, body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rule)
}
}
func readerUpdateReplaceRuleHandler(svc *service.Container) gin.HandlerFunc {
var body reader.ReplaceRuleInput
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.UpdateReplaceRule(c.Request.Context(), userID, c.Param("id"), body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerDeleteReplaceRuleHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.DeleteReplaceRule(c.Request.Context(), userID, c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// readerMediaProxyHandler 音频流/漫画图片签名代理:
// 校验 HMAC 签名 → 携书源防盗链头拉取 → Range 透传(音频拖动)/ m3u8 重写。
func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
bookID := c.Query("b")
rawURL, err := svc.Reader.VerifyProxyURL(bookID, c.Query("u"), c.Query("s"))
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 http(s) 媒体地址"})
return
}
// 封面解密代理(d=cover):签名主体可能是书源 URL(搜索结果尚未入库)。
// 封面不读取书籍正文,因此不需要书籍记录;统一整段读取后解密下发。
if c.Query("d") == "cover" {
resp, fetchErr := svc.Reader.FetchCover(c.Request.Context(), bookID, rawURL)
if fetchErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "封面拉取失败: " + fetchErr.Error()})
return
}
defer resp.Body.Close()
data, readErr := io.ReadAll(io.LimitReader(resp.Body, readerImageDecodeMaxBytes+1))
if readErr != nil || len(data) > readerImageDecodeMaxBytes {
c.JSON(http.StatusBadGateway, gin.H{"error": "封面过大或读取失败"})
return
}
decoded := svc.Reader.DecodeCoverBytes(c.Request.Context(), bookID, rawURL, data)
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = http.DetectContentType(decoded)
}
c.Data(resp.StatusCode, contentType, decoded)
return
}
book, err := svc.Reader.GetBook(c.Request.Context(), bookID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "书籍不存在"})
return
}
// 图片地址可自带 ",{headers:{...}}" 选项段:拆分后 headers 逐图应用,
// 代理地址里只保留不带选项的地址(见 ProxyURL)。
rawURL, mediaOptions, _ := readerrule.ParseMediaOptions(rawURL)
// 远端音轨:整段落盘缓存,命中后不再回源。
//
// 听书源的音频直链签在腾讯音乐 CDN 上,跨境链路会间歇性返回空 401
// 或直接 RST 连接。旧实现每次都把抖动原样交给播放器:要么是 502 的
// JSON,要么是截断的 206,浏览器一律判成 MEDIA_ERR_SRC_NOT_SUPPORTED
// (前端据此提示「格式不支持,请检查 ffmpeg」——其实完全无关)。
// 缓存后只要首播成功一次,后续拖动进度、续播、重试都走本地文件。
// 缓存目录不可用时退回旧的流式透传,不让配置问题直接变成「播不了」。
if c.Request.Method == http.MethodGet &&
reader.IsAudioMediaURL(rawURL, book.Type) &&
svc.Reader.RemoteAudioCachePath(bookID, rawURL) != "" {
handleCachedReaderAudio(c, svc, book, bookID, rawURL, mediaOptions.Headers)
return
}
// 只有 200 的 GET 需要嗅探响应体(判播放列表 / 判错误页);
// 206 的体是二进制分片、HEAD 没有体,读了反而要额外拼回去。
probeBody := c.Request.Method == http.MethodGet
media, err := fetchReaderMedia(c.Request.Context(), svc, book, rawURL, c.GetHeader("Range"), mediaOptions.Headers, probeBody)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()})
return
}
resp := media.resp
defer resp.Body.Close()
// 换过票时用新地址:m3u8 里的相对分片要按新地址解析。
rawURL = media.url
ct := resp.Header.Get("Content-Type")
// 书源声明了 imageDecode 时,图片字节需要服务端二次解密。
// 这类响应必须整段读取后处理,不再支持 Range(解密后长度会变)。
if decodeJS := svc.Reader.ImageDecodeRule(c.Request.Context(), book); decodeJS != "" && !isMediaPlaylistRequest(resp) {
sniffed, readErr := io.ReadAll(io.LimitReader(resp.Body, readerImageDecodeMaxBytes+1))
if readErr != nil || len(sniffed) > readerImageDecodeMaxBytes {
c.JSON(http.StatusBadGateway, gin.H{"error": "图片过大或读取失败"})
return
}
sniffed = append(append([]byte(nil), media.head...), sniffed...)
decoded, decErr := svc.Reader.DecodeImageBytes(c.Request.Context(), book, rawURL, false, sniffed)
if decErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "图片解密失败: " + decErr.Error()})
return
}
contentType := ct
if contentType == "" {
contentType = http.DetectContentType(decoded)
}
c.Data(resp.StatusCode, contentType, decoded)
return
}
// 200 的 GET:media.head 已经读过一小段,先用它判播放列表与错误页。
//
// m3u8 判定不能只看 Content-Type 或 URL 后缀:上游经常把播放列表标成
// application/octet-stream,或者地址是 /index.m3u8?token=...(后缀判断不命中),
// 所以以 #EXTM3U 标记为准。
if probeBody && resp.StatusCode == http.StatusOK {
head := media.head
if reader.BodyIsPlaylist(head, ct) {
rest, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(head)+string(rest))
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
return
}
// 走到这里说明 head 不是错误页(fetchReaderMedia 已经把错误页换票重试过了),
// 原样拼回去继续流式透传。Content-Length 因为已读过一部分会失配,
// 直接省略交给 net/http 按 chunked 处理,避免少写/多写导致截断。
for _, h := range []string{"Content-Type", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(h); v != "" {
c.Header(h, v)
}
}
c.Status(resp.StatusCode)
if len(head) > 0 {
_, _ = c.Writer.Write(head)
}
streamReaderMediaBody(c.Writer, resp.Body)
return
}
// 流式透传(含 206 Partial Content,支持音频拖动进度)
for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(h); v != "" {
c.Header(h, v)
}
}
c.Status(resp.StatusCode)
streamReaderMediaBody(c.Writer, resp.Body)
}
}
// streamReaderMediaBody 把上游响应体写到客户端,并在上游中途断开时**主动掐断**连接。
//
// 这是修复「播放器拿到半首歌」的关键:以前这里 `_, _ = io.Copy(...)` 把错误直接
// 丢掉,上游(腾讯 CDN 边缘)在跨境链路上 RST 连接时,客户端收到的是一个正常
// 结束、但字节数不足的响应 —— 播放器无从判断,只能报
// ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED(看起来像「格式不支持」,实际是被截断)。
//
// 现在改成:上游没读完就中止这个响应,客户端因此拿到「传输不完整」而不是「正常结束」,
// 播放器可以按常规的重试/重连逻辑处理,而不是误判成格式问题。
func streamReaderMediaBody(w io.Writer, body io.Reader) {
if _, err := io.Copy(w, body); err != nil {
// panic(http.ErrAbortHandler) 是 net/http 约定的「异常中止」:不再补写
// chunked 结尾,直接关闭连接,客户端据此判定响应不完整。
panic(http.ErrAbortHandler)
}
}
// ─── 远端音轨落盘缓存 ──────────────────────────────────────────────────────
const (
// readerAudioCacheAttempts 首次落盘的最多尝试次数(每次内部都会走「失败换票」)。
// 续传只补没下完的部分,所以这里的次数比过去更可用:每次失败都能推进进度。
readerAudioCacheAttempts = 4
// readerMediaReissueAttempts 换票重试次数:跨境 CDN 的「空 401」是概率性的,
// 一张票不行往往换一张就好;旧实现只换一次,第二次失败就把 502 交给播放器。
readerMediaReissueAttempts = 3
readerMediaReissueDelay = 300 * time.Millisecond
)
// readerAudioFetchGroup 同一音轨并发请求(播放器 + 预取 + 预热)只下载一次。
var readerAudioFetchGroup singleflight.Group
// readerAudioStreamLocks 首播直通按缓存键串行化。
//
// 直通模式下两个并发请求会同时写同一个 .part 文件(播放器 + 预热器各自开一条
// 连接很常见),交错写入必然把文件写坏。这里让后到者排队等前一个下完,
// 拿到锁后再查一次缓存直接下发——语义与原来 singleflight 等价,只是领导者
// 是在流式外发的,追随者不需要重复下载。
var readerAudioStreamLocks = newKeyedMutex()
// keyedMutex 按字符串键串行化,锁释放后回收条目,避免 map 无限增长。
type keyedMutex struct {
mu sync.Mutex
locks map[string]*keyedMutexEntry
}
type keyedMutexEntry struct {
mu sync.Mutex
refs int
}
func newKeyedMutex() *keyedMutex {
return &keyedMutex{locks: map[string]*keyedMutexEntry{}}
}
// Lock 取得该键的独占锁,返回解锁函数(必须调用)。
func (k *keyedMutex) Lock(key string) func() {
k.mu.Lock()
entry := k.locks[key]
if entry == nil {
entry = &keyedMutexEntry{}
k.locks[key] = entry
}
entry.refs++
k.mu.Unlock()
entry.mu.Lock()
return func() {
entry.mu.Unlock()
k.mu.Lock()
entry.refs--
if entry.refs == 0 {
delete(k.locks, key)
}
k.mu.Unlock()
}
}
// keyedMutexPollInterval 等锁的重试间隔。只用于「已有写入者时短暂等待」,
// 不值得为它引入条件变量。
const keyedMutexPollInterval = 20 * time.Millisecond
// LockWait 最多等 timeout 就放弃。
//
// 音频缓存的写入者可能是「首播直通」,它会一直持锁到客户端读完整章;让后来的
// 请求排队等它,播放器的重试就会干等几十秒(用户看到的就是「一直转圈」)。
// 等不到锁的请求改走纯透传,字节照样能出声,只是这次不落盘。
func (k *keyedMutex) LockWait(key string, timeout time.Duration) (func(), bool) {
deadline := time.Now().Add(timeout)
for {
if unlock, ok := k.acquire(key); ok {
return unlock, true
}
if time.Now().After(deadline) {
return nil, false
}
time.Sleep(keyedMutexPollInterval)
}
}
// acquire 取一次锁,取不到立刻返回。引用计数保证条目在最后一个持有者归还后回收。
func (k *keyedMutex) acquire(key string) (func(), bool) {
k.mu.Lock()
entry := k.locks[key]
if entry == nil {
entry = &keyedMutexEntry{}
k.locks[key] = entry
}
entry.refs++
k.mu.Unlock()
if !entry.mu.TryLock() {
k.release(key, entry)
return nil, false
}
return func() {
entry.mu.Unlock()
k.release(key, entry)
}, true
}
// release 归还一次引用,无人持有时回收条目。
func (k *keyedMutex) release(key string, entry *keyedMutexEntry) {
k.mu.Lock()
entry.refs--
if entry.refs == 0 {
delete(k.locks, key)
}
k.mu.Unlock()
}
// readerAudioLockWait 单条音轨的写锁最多等多久;等不到就降级成纯透传。
const readerAudioLockWait = 500 * time.Millisecond
// handleCachedReaderAudio 音频直链的统一入口。
//
// 四条路径,按代价从低到高:
// 1. 命中完整缓存 → http.ServeContent 按 Range 下发(拖动、续播都读本地);
// 2. 带 Range 的冷缓存 → 区间直通(serveReaderAudioRange):客户端要哪一段就
// 从上游边下边发哪一段,同时补全分片。**播放器嗅探完容器一定会带 Range 重开
// 一次**(media3 的固有行为),所以这条才是真正的起播路径。旧实现要求整章
// 下完才能按区间下发,每章起播都要等 1–4MB 跨境回源,实测 1.5–35 秒;
// 3. 冷启动整段请求 → 边下边发给客户端,同时落盘(streamReaderAudioInto):
// 播放器嗅探容器的那次 200 请求,几百毫秒就能出声,落盘照旧;
// 4. 已有 .part 分片的整段请求 → 走整段下载:分片意味着上次已经下到一半,
// 同步续传剩余字节比重新直通更省流量,下完再一次性下发。
//
// 同一条音轨同时只允许一个写入者。等不到写锁的请求降级成纯透传(不排队、不
// 落盘):首播直通会一直持锁到客户端读完整章,让它排队等于让播放器的重试干等。
func handleCachedReaderAudio(
c *gin.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
) {
if f, info, ok := svc.Reader.OpenCachedRemoteAudio(bookID, rawURL); ok {
defer f.Close()
serveCachedReaderAudio(c, f, info, rawURL)
return
}
cacheKey := svc.Reader.RemoteAudioCachePath(bookID, rawURL)
if cacheKey == "" {
c.String(http.StatusBadGateway, "音频缓存不可用")
return
}
rangeHeader := strings.TrimSpace(c.GetHeader("Range"))
unlock, locked := readerAudioStreamLocks.LockWait(cacheKey, readerAudioLockWait)
if locked {
defer unlock()
// 排队期间前一个请求可能已经把整章下完:直接下发本地文件,不重复回源。
if f, info, ok := svc.Reader.OpenCachedRemoteAudio(bookID, rawURL); ok {
defer f.Close()
serveCachedReaderAudio(c, f, info, rawURL)
return
}
}
if rangeHeader != "" && serveReaderAudioRange(c, svc, book, bookID, rawURL, headers, rangeHeader, locked) {
return
}
if !locked {
proxyReaderAudio(c, svc, book, bookID, rawURL, headers, rangeHeader)
return
}
// 播放器拖进度时会带 Range。已缓存的文件才谈得上 Range;未缓存时直通
// 只能从 0 发,否则客户端拿到的区间与它请求的对不上。
canStream := c.Request.Method == http.MethodGet &&
rangeHeader == "" &&
!svc.Reader.HasPartialRemoteAudio(bookID, rawURL)
if canStream {
streamReaderAudioInto(c, svc, book, bookID, rawURL, headers)
return
}
cachePath, err := ensureCachedRemoteAudio(c.Request.Context(), svc, book, bookID, rawURL, headers)
if err != nil {
if svc.Log != nil {
svc.Log.Warn("reader 音频拉取失败", zap.String("book", bookID), zap.Error(err))
}
c.String(http.StatusBadGateway, "音频拉取失败: %s", err.Error())
return
}
f, err := os.Open(cachePath) // #nosec G304 -- 路径由服务端哈希生成
if err != nil {
c.String(http.StatusBadGateway, "音频缓存不可用: %s", err.Error())
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
c.String(http.StatusBadGateway, "音频缓存不可用: %s", err.Error())
return
}
serveCachedReaderAudio(c, f, info, rawURL)
}
// serveReaderAudioRange 区间直通:把客户端请求的那一段从上游边下边发。
//
// persist 表示调用方已拿到写锁,可以顺手把字节补进分片(落盘后下次拖动/重播
// 直接读本地)。返回 false 表示这次没法按区间处理(多区间、后缀区间、上游不认
// Range 且总长未知等),调用方应退回整段路径。
func serveReaderAudioRange(
c *gin.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
rangeHeader string,
persist bool,
) bool {
start, end, ok := parseReaderAudioRange(rangeHeader)
if !ok {
return false
}
// 分片里已经躺着 [0, partLen) 的字节:起点在分片内就先把这段发出去,再从
// partLen 处回源续下。起点比分片还靠后说明中间有空洞,拼不上,只能从起点
// 重新取,并且不落盘(否则分片会被写成带洞的文件)。
var partFile *os.File
partLen := int64(0)
upstreamStart := start
if f, size, okPart := svc.Reader.PartialRemoteAudio(bookID, rawURL); okPart {
partFile = f
partLen = size
if partLen > start {
upstreamStart = partLen
}
}
// 收尾时关掉分片句柄:Windows 下「文件还开着就 rename」会被共享冲突挡住,
// 提升分片为正式缓存之前必须先关。
defer func() {
if partFile != nil {
_ = partFile.Close()
}
}()
out, err := fetchReaderMediaResilient(c.Request.Context(), svc, book, rawURL,
fmt.Sprintf("bytes=%d-", upstreamStart), headers, false)
if err != nil || out == nil || out.resp == nil {
if svc.Log != nil && err != nil {
svc.Log.Warn("reader 音频区间直通失败", zap.String("book", bookID), zap.Error(err))
}
return false
}
resp := out.resp
total := int64(-1)
switch resp.StatusCode {
case http.StatusPartialContent:
crStart, crTotal, crOK := reader.ParseContentRange(resp.Header.Get("Content-Range"))
if !crOK || crTotal <= 0 || crStart != upstreamStart {
drainReaderMediaBody(resp)
return false
}
total = crTotal
case http.StatusOK:
// 上游忽略了 Range,整段从头给:把前缀读掉丢弃,后面的字节与分片仍然
// 对得上(前缀正好也是 [0, upstreamStart)),所以落盘可以照旧。
if resp.ContentLength <= 0 {
drainReaderMediaBody(resp)
return false
}
if upstreamStart > 0 {
if _, copyErr := io.CopyN(io.Discard, resp.Body, upstreamStart); copyErr != nil {
drainReaderMediaBody(resp)
return false
}
}
total = resp.ContentLength
case http.StatusRequestedRangeNotSatisfiable:
// 分片比对象还长(对象换过):丢掉分片走整段路径重来。
drainReaderMediaBody(resp)
return false
default:
drainReaderMediaBody(resp)
return false
}
if total <= 0 || start >= total {
drainReaderMediaBody(resp)
if total > 0 {
c.Header("Content-Range", fmt.Sprintf("bytes */%d", total))
c.Status(http.StatusRequestedRangeNotSatisfiable)
return true
}
return false
}
serveEnd := end
if serveEnd < 0 || serveEnd >= total {
serveEnd = total - 1
}
wantLen := serveEnd - start + 1
prefixLen := int64(0)
if partFile != nil && partLen > start {
prefixLen = partLen - start
if prefixLen > wantLen {
prefixLen = wantLen
}
}
// 响应头必须一次写全:播放器据此决定后面要不要等更多字节。
contentType := readerAudioContentType(rawURL)
if upstreamType := strings.TrimSpace(resp.Header.Get("Content-Type")); strings.HasPrefix(strings.ToLower(upstreamType), "audio/") {
contentType = upstreamType
}
writer := c.Writer
writer.Header().Set("Content-Type", contentType)
writer.Header().Set("Accept-Ranges", "bytes")
writer.Header().Set("Cache-Control", "private, max-age=604800")
writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, serveEnd, total))
writer.Header().Set("Content-Length", strconv.FormatInt(wantLen, 10))
writer.WriteHeader(http.StatusPartialContent)
writer.Flush()
written := int64(0)
if prefixLen > 0 {
n, copyErr := io.CopyN(writer, io.NewSectionReader(partFile, start, prefixLen), prefixLen)
written += n
if copyErr != nil {
drainReaderMediaBody(resp)
if svc.Log != nil {
svc.Log.Warn("reader 音频区间下发失败", zap.String("book", bookID), zap.Error(copyErr))
}
panic(http.ErrAbortHandler)
}
}
// 前缀发完就不需要读句柄了。必须先关掉:Windows 下文件还开着时 rename
// 会被共享冲突挡住,下面的「提升分片为正式缓存」就会静默失败。
_ = partFile.Close()
partFile = nil
if written >= wantLen {
// 客户端要的区间完全落在本地分片里:这一轮不必再回源。
drainReaderMediaBody(resp)
return true
}
// 剩余字节边下边发;拿得到写锁时同时补进分片,落满后转成完整缓存。
var partWriter *os.File
if persist && partLen >= start {
if f, size, appendErr := svc.Reader.AppendRemoteAudioPartial(bookID, rawURL); appendErr == nil {
if size == partLen {
partWriter = f
} else {
_ = f.Close()
}
}
}
sink := io.Writer(writer)
if partWriter != nil {
sink = io.MultiWriter(writer, partWriter)
}
copied, copyErr := io.CopyBuffer(sink, io.LimitReader(resp.Body, wantLen-written), make([]byte, 32<<10))
written += copied
_ = resp.Body.Close()
if partWriter != nil {
_ = partWriter.Close()
if copyErr == nil {
// 分片正好落满整章时提升为正式缓存(长度对不上时内部会拒绝)。
svc.Reader.PromoteRemoteAudioPartial(bookID, rawURL, total)
}
}
if copyErr == nil && written >= wantLen {
return true
}
if svc.Log != nil {
svc.Log.Warn("reader 音频区间中断",
zap.String("book", bookID),
zap.Int64("written", written),
zap.Int64("want", wantLen),
zap.Error(copyErr))
}
// 已经发出去的字节收不回来:只能中止响应让播放器判「不完整」,分片留给下次续传。
panic(http.ErrAbortHandler)
}
// proxyReaderAudio 纯透传:不落盘、不排队,把上游字节原样转给播放器。
//
// 用在「同一条音轨已经有别的请求在写分片」时:旧实现让第二个请求排队等前一个
// 下载结束,而首播直通会一直持锁到客户端读完整章,播放器重试因此可能干等几十秒。
func proxyReaderAudio(
c *gin.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
rangeHeader string,
) {
out, err := fetchReaderMediaResilient(c.Request.Context(), svc, book, rawURL,
rangeHeader, headers, rangeHeader == "")
if err != nil || out == nil || out.resp == nil {
if svc.Log != nil && err != nil {
svc.Log.Warn("reader 音频透传失败", zap.String("book", bookID), zap.Error(err))
}
c.String(http.StatusBadGateway, "音频拉取失败")
return
}
resp := out.resp
defer func() { _ = resp.Body.Close() }()
contentType := readerAudioContentType(rawURL)
if upstreamType := strings.TrimSpace(resp.Header.Get("Content-Type")); strings.HasPrefix(strings.ToLower(upstreamType), "audio/") {
contentType = upstreamType
}
writer := c.Writer
writer.Header().Set("Content-Type", contentType)
writer.Header().Set("Accept-Ranges", "bytes")
writer.Header().Set("Cache-Control", "private, max-age=604800")
if v := resp.Header.Get("Content-Range"); v != "" {
writer.Header().Set("Content-Range", v)
}
if resp.ContentLength > 0 {
writer.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
}
writer.WriteHeader(resp.StatusCode)
if len(out.head) > 0 {
if _, writeErr := writer.Write(out.head); writeErr != nil {
panic(http.ErrAbortHandler)
}
}
streamReaderMediaBody(writer, resp.Body)
}
// drainReaderMediaBody 丢掉剩余响应体并关闭连接(让上游把连接收回去)。
func drainReaderMediaBody(resp *http.Response) {
if resp == nil || resp.Body == nil {
return
}
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
_ = resp.Body.Close()
}
// parseReaderAudioRange 解析客户端的单区间 Range 头("bytes=0-" / "bytes=100-199")。
//
// 多区间与后缀区间("bytes=-500" 要总长才能换算)不在这里处理:播放器不会这么
// 发,真遇到就退回磁盘路径,由 http.ServeContent 负责。
func parseReaderAudioRange(header string) (start, end int64, ok bool) {
header = strings.TrimSpace(header)
const prefix = "bytes="
if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
return 0, 0, false
}
spec := strings.TrimSpace(header[len(prefix):])
if spec == "" || strings.Contains(spec, ",") {
return 0, 0, false
}
dash := strings.IndexByte(spec, '-')
if dash <= 0 {
return 0, 0, false
}
start, err := strconv.ParseInt(strings.TrimSpace(spec[:dash]), 10, 64)
if err != nil || start < 0 {
return 0, 0, false
}
endPart := strings.TrimSpace(spec[dash+1:])
if endPart == "" {
return start, -1, true
}
end, err = strconv.ParseInt(endPart, 10, 64)
if err != nil || end < start {
return 0, 0, false
}
return start, end, true
}
// streamReaderAudioInto 首播直通:一边落盘一边把字节写给客户端(调用方已持写锁)。
//
// 直接写 c.Writer 而不是先落盘再 ServeContent,是为了让第一个字节尽早到达。
// 下载失败时客户端已经收到部分字节,只能中止响应(见下面的 panic),
// 不能改成 502 —— 状态码早就发出去了。
func streamReaderAudioInto(
c *gin.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
) {
writer := c.Writer
// started 表示响应头是否已发出。没发出时失败还能改成 502;已经发出就只能
// 中止连接——状态码收不回来了。
started := false
_, err := svc.Reader.DownloadRemoteAudioStreaming(
c.Request.Context(), bookID, rawURL, readerAudioCacheAttempts,
readerAudioFetchFunc(c.Request.Context(), svc, book, bookID, rawURL, headers),
func(total int64, upstreamType string) {
contentType := readerAudioContentType(rawURL)
// 上游给了明确类型时优先用它(个别源把 m4a 标成 audio/x-m4a)。
if strings.HasPrefix(strings.ToLower(upstreamType), "audio/") {
contentType = upstreamType
}
writer.Header().Set("Content-Type", contentType)
writer.Header().Set("Cache-Control", "private, max-age=604800")
if total > 0 {
writer.Header().Set("Content-Length", strconv.FormatInt(total, 10))
}
writer.WriteHeader(http.StatusOK)
started = true
// 立刻把已写好的头刷给客户端:gin 的 ResponseWriter 有缓冲,
// 不 Flush 的话播放器要等到第一批音频字节才看到响应,白等一轮 RTT。
writer.Flush()
},
writer,
)
if err == nil {
return
}
if svc.Log != nil {
svc.Log.Warn("reader 音频直通失败", zap.String("book", bookID), zap.Error(err))
}
if !started {
// 一个字节都还没发出去(首拉就失败):保持与旧实现一致的错误语义。
c.String(http.StatusBadGateway, "音频拉取失败: %s", err.Error())
return
}
// 响应头已经发出:只能中止连接,让客户端判定「传输不完整」并自己重试。
// 保留 .part 分片,下一次请求会走续传路径。
panic(http.ErrAbortHandler)
}
// readerAudioFetchFunc 把「带换票重试的媒体拉取」适配成下载器需要的 fetcher。
func readerAudioFetchFunc(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
) reader.AudioRangeFetcher {
return func(rangeHeader string) (*http.Response, []byte, error) {
// 只有整段请求才需要嗅探响应体(判 HLS/错误页);续传拿到的是 206
// 二进制分片,读了还得再补写一次,没必要。
out, fetchErr := fetchReaderMediaResilient(ctx, svc, book, rawURL, rangeHeader, headers, rangeHeader == "")
if fetchErr != nil {
if isReaderMediaNoRetry(fetchErr) {
return nil, nil, &reader.NoRetryError{Err: fetchErr}
}
return nil, nil, fetchErr
}
return out.resp, out.head, nil
}
}
// serveCachedReaderAudio 用 http.ServeContent 下发本地音轨(自带 Range / 206 语义)。
func serveCachedReaderAudio(c *gin.Context, f *os.File, info os.FileInfo, rawURL string) {
c.Header("Content-Type", readerAudioContentType(rawURL))
c.Header("Cache-Control", "private, max-age=604800")
http.ServeContent(c.Writer, c.Request, "audio"+readerAudioExt(rawURL), info.ModTime(), f)
}
// ensureCachedRemoteAudio 单飞下载:并发命中同一章时只回源一次。
func ensureCachedRemoteAudio(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
) (string, error) {
key := svc.Reader.RemoteAudioCachePath(bookID, rawURL)
if key == "" {
return "", fmt.Errorf("音频缓存目录不可用")
}
v, err, _ := readerAudioFetchGroup.Do(key, func() (any, error) {
if svc.Reader.HasCachedRemoteAudio(bookID, rawURL) {
return key, nil
}
if err := downloadReaderAudioToCache(ctx, svc, book, bookID, rawURL, headers); err != nil {
return nil, err
}
return key, nil
})
if err != nil {
return "", err
}
path, _ := v.(string)
if path == "" {
return "", fmt.Errorf("音频缓存不可用")
}
return path, nil
}
// downloadReaderAudioToCache 整段拉取并落盘,支持断点续传:
// 上游 RST/超时后用 Range 从已写入的位置接着下,不再整段重下;
// 只有字节数与上游声明的总长一致时才会生成正式缓存文件。
func downloadReaderAudioToCache(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
) error {
_, err := svc.Reader.DownloadRemoteAudio(ctx, bookID, rawURL, readerAudioCacheAttempts,
readerAudioFetchFunc(ctx, svc, book, bookID, rawURL, headers))
return err
}
// isReaderMediaNoRetry 判断这次失败是否「换个花样重试也没用」。
// 用于让外层下载循环立刻收手,而不是把同一张被拒的票再打四遍。
func isReaderMediaNoRetry(err error) bool {
if errors.Is(err, errReaderMediaStaleTicket) {
return true
}
var rejected *readerMediaRejectedError
return errors.As(err, &rejected)
}
// readerAudioExt 取音频地址的后缀(用于推断 Content-Type)。
func readerAudioExt(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
return strings.ToLower(path.Ext(u.Path))
}
// readerAudioContentType 按后缀给出 Content-Type:缓存文件统一存成 .audio,
// 不能依赖 http.ServeContent 的嗅探,直接在响应头里写死。
func readerAudioContentType(rawURL string) string {
switch readerAudioExt(rawURL) {
case ".mp3":
return "audio/mpeg"
case ".aac":
return "audio/aac"
case ".flac":
return "audio/flac"
case ".wav":
return "audio/wav"
case ".ogg", ".oga", ".opus":
return "audio/ogg"
default:
// .m4a / .m4b / .wma / .ape 以及无后缀地址:腾讯的听书直链都是 m4a。
return "audio/mp4"
}
}
// readerImageDecodeMaxBytes 需要服务端解密的单张图片上限。
const readerImageDecodeMaxBytes = 32 << 20
// readerMediaProbeBytes 媒体代理嗅探上游响应体用的前缀长度。
//
// 这个前缀同时用于三件事:判定 HLS 播放列表(#EXTM3U)、判定「其实是错误页」、
// 以及把已读出的字节原样拼回响应体继续流式透传。
const readerMediaProbeBytes = 64 << 10
// readerMediaResult 一次媒体拉取的结果。
type readerMediaResult struct {
resp *http.Response
// url 是本次真正请求的媒体地址(换过票时是新票),m3u8 重写要用它做 base。
url string
// head 是已从响应体读出的前缀(仅 200 的 GET 才有),调用方写响应时要先写它。
head []byte
// rejected 表示 200 的响应体其实是错误页(HTML/JSON 报错),不能喂给播放器。
rejected bool
}
// fetchReaderMedia 拉取媒体,并在「签名票失效」时换票重试一次。
//
// 听书源的播放地址是第三方中转现签的 CDN 直链(vkey/uid 带时效),票被上游判掉时
// 有两种表现,这里都要兜住:
//
// ① 空的 401:没有 Content-Type、没有 body(腾讯 CDN 对无效 vkey/uid 的固定回答);
// ② 200 + HTML/JSON 错误页:中转自己出错时把它的报错页当成媒体回给我们。
//
// 旧行为是两种都原样透传给播放器:① 变成连续多个 401(播放器重试的是同一个
// 必然失败的地址),② 变成 ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED(19 个
// extractor 一个都不认)。两者的根因都是「这张票已经不能用了」,所以统一处理成
// 回源换一张新票再拉:
//
// - 换票成功 → 用新地址重拉,新票同时写回章节缓存,后续请求自然有效;
// - 地址不属于可换票的音轨(漫画图片防盗链的 403 等)→ 原样透传上游状态,
// 不改变原有行为;
// - 该换票但换不到、或换票后依然不可用 → 返回错误,由调用方给出明确文案,
// 而不是让播放器对着必然失败的地址反复重试。
//
// readerMediaRejectedError 上游拒绝了这张票,继续走同一条重试路径不会有进展。
//
// status —— 上游状态码(401/403/410)
// refreshed —— 这次拒绝发生在「已经换过一张新票」之后
// message —— 对外文案(留空时按状态码生成)
//
// 上游的拒绝常常成批出现(几分钟一波,随后自己恢复):换票拿到的是新票,但
// 同一时刻仍被同一个状态码挡回。连续两轮都这样时立刻停止重试——继续重试只会
// 让播放器多等十几秒,结果并不会更好。
type readerMediaRejectedError struct {
status int
refreshed bool
message string
}
func (e *readerMediaRejectedError) Error() string {
if e.message != "" {
return e.message
}
return fmt.Sprintf("音源暂时被上游拒绝(HTTP %d),请稍后重试或换源", e.status)
}
// errReaderMediaStaleTicket 换票没有拿到新票(书源还是给回同一张、或干脆给不出),
// 再重试同一个书源不会有不同结果。具体原因由外层用 %w 串上。
var errReaderMediaStaleTicket = errors.New("音源签名失效且重新取票失败")
func fetchReaderMedia(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
rawURL, rangeHeader string,
options map[string]string,
probeBody bool,
) (*readerMediaResult, error) {
// 上游 CDN 在并发/跨境链路上偶发 reset 连接(实测同一章重试即可成功),
// 探测阶段读到一半断开不能直接判死,先按退避重试几次。
out, refreshable, err := fetchReaderMediaWithRetry(ctx, svc, book, rawURL, rangeHeader, options, probeBody)
if err != nil || !refreshable {
return out, err
}
fresh, canRefresh, refreshErr := svc.Reader.RefreshMediaURL(ctx, book, rawURL)
if !canRefresh {
// 换不动(漫画图片防盗链的 403 等):原样透传上游状态,保持既有行为。
// 但有两类失败例外——它们透传给播放器毫无意义(播放器只会一直重试
// 同一个必然失败的地址,或者把网页当成音频),改成明确错误让前端提示。
switch {
case out.rejected:
_ = out.resp.Body.Close()
return nil, fmt.Errorf("音源返回了非媒体内容(%s),请稍后重试或换源",
strings.TrimSpace(out.resp.Header.Get("Content-Type")))
case out.resp.StatusCode == http.StatusUnauthorized:
_, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16))
_ = out.resp.Body.Close()
return nil, &readerMediaRejectedError{
status: http.StatusUnauthorized,
message: "音源签名失效(上游 401 拒绝),请稍后重试或换源",
}
}
return out, nil
}
// 换得动就必须先释放这张失败响应的连接,再发新请求。
_, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16))
_ = out.resp.Body.Close()
if refreshErr != nil {
return nil, fmt.Errorf("%w: %v", errReaderMediaStaleTicket, refreshErr)
}
retried, _, retryErr := fetchReaderMediaOnce(ctx, svc, book, fresh, rangeHeader, options, probeBody)
if retryErr != nil {
return nil, retryErr
}
if retried.rejected {
_ = retried.resp.Body.Close()
return nil, &readerMediaRejectedError{
status: retried.resp.StatusCode,
refreshed: true,
message: "音源换票后仍返回非媒体内容,请稍后重试或换源",
}
}
if reader.MediaNeedsRefresh(retried.resp.StatusCode) {
status := retried.resp.StatusCode
_ = retried.resp.Body.Close()
return nil, &readerMediaRejectedError{status: status, refreshed: true}
}
return retried, nil
}
// fetchReaderMediaResilient 在 fetchReaderMedia(已含一次换票)之上再包一层。
//
// 跨境 CDN 判掉签名票是概率性的:换一张票往往就能取到,而旧实现只换一次,
// 第二次失败直接把 502 交给播放器。这里最多再重来 readerMediaReissueAttempts 轮,
// 每轮都会作废章节缓存、重新回源签票。
//
// 但"没有进展"的失败会提前收手,否则用户要对着转圈干等三十多秒:
// - 换票拿不到新票(errReaderMediaStaleTicket)→ 立即结束;
// - 换过票仍是同一个拒绝状态码 → 两轮即结束(见 readerMediaRejectedError)。
func fetchReaderMediaResilient(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
rawURL, rangeHeader string,
options map[string]string,
probeBody bool,
) (*readerMediaResult, error) {
var lastErr error
lastRejected := 0
for attempt := 1; attempt <= readerMediaReissueAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(readerMediaReissueDelay * time.Duration(attempt-1)):
}
}
out, err := fetchReaderMedia(ctx, svc, book, rawURL, rangeHeader, options, probeBody)
if err == nil {
if out == nil || out.resp == nil {
lastErr = fmt.Errorf("音源响应为空")
continue
}
if !out.rejected && !reader.MediaNeedsRefresh(out.resp.StatusCode) {
return out, nil
}
status := out.resp.StatusCode
_ = out.resp.Body.Close()
lastErr = &readerMediaRejectedError{status: status, refreshed: true}
} else {
lastErr = err
if ctx.Err() != nil {
return nil, err
}
if errors.Is(err, errReaderMediaStaleTicket) {
return nil, err
}
}
var rejected *readerMediaRejectedError
if errors.As(lastErr, &rejected) {
// 没有换票空间的拒绝:原样重试同一张票没有意义。
if !rejected.refreshed {
return nil, lastErr
}
// 换过票还是同一个状态码:上游这几分钟就是不放行。
if lastRejected == rejected.status {
return nil, lastErr
}
lastRejected = rejected.status
}
}
return nil, lastErr
}
// readerMediaProbeAttempts 探测阶段遇到连接中断(reset/EOF)的最大尝试次数。
//
// 上游 CDN(腾讯 CDN 的跨境边缘)在并发或长传输时会直接 RST 连接,同一章
// 重试一次往往就好。以前这里读 body 失败就直接回 502,把一次可恢复的抖动
// 变成了「这本书播不了」。
const readerMediaProbeAttempts = 3
// readerMediaProbeRetryDelay 探测重试的退避基数。
const readerMediaProbeRetryDelay = 300 * time.Millisecond
// readerMediaProbeError 探测阶段的可重试错误(连接被 reset / 提前 EOF)。
type readerMediaProbeError struct{ err error }
func (e *readerMediaProbeError) Error() string { return e.err.Error() }
func (e *readerMediaProbeError) Unwrap() error { return e.err }
// fetchReaderMediaWithRetry 探测阶段按退避重试,只对「连接中断」类错误重试:
// 上游明确回了 4xx 时重试同一张票没有意义(那是换票流程的事)。
func fetchReaderMediaWithRetry(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
rawURL, rangeHeader string,
options map[string]string,
probeBody bool,
) (*readerMediaResult, bool, error) {
var lastErr error
for attempt := 1; attempt <= readerMediaProbeAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return nil, false, ctx.Err()
case <-time.After(readerMediaProbeRetryDelay * time.Duration(attempt-1)):
}
}
out, refreshable, err := fetchReaderMediaOnce(ctx, svc, book, rawURL, rangeHeader, options, probeBody)
if err == nil {
return out, refreshable, nil
}
var probeErr *readerMediaProbeError
if !errors.As(err, &probeErr) {
return nil, false, err
}
lastErr = err
}
return nil, false, lastErr
}
// fetchReaderMediaOnce 发一次媒体请求并做可用性判定。
//
// 返回的 refreshable 表示「这次失败值得换一张签名票再试」:
// 上游 401/403/410,或 200 但响应体其实是错误页(详见 fetchReaderMedia 的说明)。
// rejected 表示「200 但内容不是媒体」——调用方据此在无法换票时报明确错误,
// 而不是把网页喂给播放器。
func fetchReaderMediaOnce(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
rawURL, rangeHeader string,
options map[string]string,
probeBody bool,
) (*readerMediaResult, bool, error) {
resp, err := svc.Reader.FetchMediaWithOptions(ctx, book, rawURL, rangeHeader, options)
if err != nil {
return nil, false, err
}
if reader.MediaNeedsRefresh(resp.StatusCode) {
return &readerMediaResult{resp: resp, url: rawURL}, true, nil
}
if !probeBody || resp.StatusCode != http.StatusOK {
return &readerMediaResult{resp: resp, url: rawURL}, false, nil
}
// 先读一小段判断是不是错误页/播放列表;不是就原样拼回去继续流式透传。
head, readErr := io.ReadAll(io.LimitReader(resp.Body, readerMediaProbeBytes))
if readErr != nil {
_ = resp.Body.Close()
// 连接被 reset / 提前 EOF 属于上游抖动,交给上层退避重试。
return nil, false, &readerMediaProbeError{err: fmt.Errorf("读取媒体响应失败: %w", readErr)}
}
rejected := readerMediaPayloadRejected(head, resp.Header.Get("Content-Type"))
return &readerMediaResult{resp: resp, url: rawURL, head: head, rejected: rejected}, rejected, nil
}
// readerMediaPayloadRejected 判断上游 200 响应体其实是错误页而不是媒体。
//
// 直链失效的另一个变种:CDN/中转不返回 4xx,而是回一段 HTML 错误页或 JSON
// 报错(Content-Type 是 text/html、application/json)。这类字节喂给播放器,
// ExoPlayer 的 19 个 extractor 全都不认,报出来的是
// ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED —— 看起来像「格式不支持」,
// 实际是「拿到了一张网页」。这里按 Content-Type + 首字符识别出来,直接判失败。
func readerMediaPayloadRejected(head []byte, contentType string) bool {
if len(head) == 0 {
return false
}
lower := strings.ToLower(contentType)
errish := strings.Contains(lower, "html") ||
strings.Contains(lower, "json") ||
strings.HasPrefix(lower, "text/")
if !errish {
return false
}
trimmed := bytes.TrimSpace(head)
if len(trimmed) == 0 {
return false
}
switch trimmed[0] {
case '<', '{', '[':
return true
default:
return false
}
}
// isMediaPlaylistRequest 判断响应是否可能已是 HLS 播放列表。
// 播放列表太小、不该走图片解密分支(书源的 imageDecode 只会用于图片)。
func isMediaPlaylistRequest(resp *http.Response) bool {
ct := strings.ToLower(resp.Header.Get("Content-Type"))
return strings.Contains(ct, "mpegurl")
}
// readerBodyIsPlaylist 判断上游响应体是否是 HLS 播放列表。
// 以 #EXTM3U 标记为准:Content-Type 只是辅助(上游常标成 octet-stream)。
func readerBodyIsPlaylist(head []byte, contentType string) bool {
return reader.BodyIsPlaylist(head, contentType)
}
// readerLocalAssetHandler 本地书籍内嵌资源(EPUB 图片等):
// 鉴权走 HMAC 签名(<img src> 带不上 JWT),与 /reader/media 同一套做法。
func readerLocalAssetHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
bookID := c.Query("b")
entry, err := svc.Reader.VerifyLocalAssetURL(bookID, c.Query("p"), c.Query("s"))
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
data, contentType, err := svc.Reader.ReadLocalAsset(c.Request.Context(), bookID, entry)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
// 同一本书的图片不会变,可长缓存
c.Header("Cache-Control", "private, max-age=604800")
c.Data(http.StatusOK, contentType, data)
}
}
// readerAudioTranscodeHandler 需要转码的有声书音轨:签名鉴权(<audio src> 带不上 JWT),
// 首次请求跑 ffmpeg 转成 mp3 落缓存,之后按 Range 下发,播放器可以拖动进度。
func readerAudioTranscodeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
bookID := c.Query("b")
source, err := svc.Reader.VerifyAudioTranscodeURL(bookID, c.Query("u"), c.Query("s"))
if err != nil {
c.String(http.StatusForbidden, "%s", err.Error())
return
}
path, err := svc.Reader.EnsureTranscodedAudio(c.Request.Context(), bookID, source)
if err != nil {
c.String(http.StatusBadGateway, "%s", err.Error())
return
}
f, err := os.Open(path) // #nosec G304 -- 路径由签名校验 + 缓存目录哈希生成
if err != nil {
c.String(http.StatusNotFound, "转码结果已丢失")
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
c.String(http.StatusNotFound, "转码结果不可用")
return
}
c.Header("Cache-Control", "private, max-age=604800")
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), f)
}
}
// readerLocalAudioHandler 本地有声书音频流:签名鉴权(<audio src> 带不上 JWT),
// 交给 http.ServeContent 处理 Range,播放器才能拖动进度。
func readerLocalAudioHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
path, err := svc.Reader.VerifyLocalAudioURL(c.Query("b"), c.Query("p"), c.Query("s"))
if err != nil {
c.String(http.StatusForbidden, "%s", err.Error())
return
}
f, info, err := svc.Reader.OpenLocalAudio(path)
if err != nil {
c.String(http.StatusNotFound, "%s", err.Error())
return
}
defer f.Close()
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), f)
}
}
// readerContentOptions 从查询参数解析正文下发的可选项。
//
// 目前只有 `media` 一个维度:
// - 不传(网页端):媒体全部走服务端签名代理,与历史行为一致;
// - `media=direct`:书源开启直连时保留上游地址,由客户端自取;
// - `media=proxy`:本次强制代理,给客户端直连失败时的退路。
func readerContentOptions(c *gin.Context) reader.ContentOptions {
switch strings.ToLower(strings.TrimSpace(c.Query("media"))) {
case "direct":
return reader.ContentOptions{MediaDirect: true}
case "proxy":
return reader.ContentOptions{ForceProxy: true}
default:
return reader.ContentOptions{}
}
}
// readerBookContentHandler 书架维度正文(服务端应用替换净化规则)。
//
// media=direct 是客户端(Android App)专用的可选能力:媒体地址保留上游原始
// 地址并由客户端自行下载,服务端只下发所需请求头。不传该参数(网页端)时
// 行为与历史版本完全一致。
func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
chapter, err := strconv.Atoi(c.DefaultQuery("chapter", "0"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "chapter 参数需为整数"})
return
}
userID := c.GetString(middleware.CtxUserID)
opts := readerContentOptions(c)
content, err := svc.Reader.GetContentForBook(c.Request.Context(), userID, c.Param("id"), chapter, opts)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, content)
}
}
// readerBookContentBatchHandler 批量取正文(对应 legado CacheBook / getContentBatch)。
//
// 请求 {chapter_indexes:[...], apply_replace:bool}:单章失败不中断整批,
// 返回每章内容与 hit/miss/failed 统计,供前端窗口预取与后续离线缓存。
// 媒体通道同样按 media=direct 查询参数选择,默认(网页端)走服务端代理。
func readerBookContentBatchHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var body struct {
ChapterIndexes []int `json:"chapter_indexes" binding:"required"`
// ApplyReplace 是否在服务端应用用户替换净化规则(默认 false,与单章接口一致)。
ApplyReplace bool `json:"apply_replace"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "chapter_indexes 不能为空"})
return
}
if len(body.ChapterIndexes) > 64 {
c.JSON(http.StatusBadRequest, gin.H{"error": "一次最多请求 64 章"})
return
}
userID := c.GetString(middleware.CtxUserID)
result, err := svc.Reader.GetContentBatch(c.Request.Context(), userID, c.Param("id"), body.ChapterIndexes, body.ApplyReplace, readerContentOptions(c))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
}