mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
2093 lines
78 KiB
Go
2093 lines
78 KiB
Go
// 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)
|
||
}
|
||
}
|