Files
MeBox/internal/handler/reader_routes.go
T
truewhile db516ded61 优化
2026-10-29 00:09:00 +08:00

1300 lines
50 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"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"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"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil || body.Enabled == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"})
return
}
userID := c.GetString(middleware.CtxUserID)
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
}
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 {
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)
// 只有 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)
}
}
// 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 等)→ 原样透传上游状态,
// 不改变原有行为;
// - 该换票但换不到、或换票后依然不可用 → 返回错误,由调用方给出明确文案,
// 而不是让播放器对着必然失败的地址反复重试。
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, fmt.Errorf("音源鉴权被拒(上游 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", 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, fmt.Errorf("音源换票后仍返回非媒体内容,请稍后重试或换源")
}
if reader.MediaNeedsRefresh(retried.resp.StatusCode) {
return nil, fmt.Errorf("音源换票后仍被拒绝(上游 %d),请稍后重试或换源", retried.resp.StatusCode)
}
return retried, nil
}
// 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)
}
}
// readerBookContentHandler 书架维度正文(服务端应用替换净化规则)。
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)
content, err := svc.Reader.GetContentForBook(c.Request.Context(), userID, c.Param("id"), chapter)
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 统计,供前端窗口预取与后续离线缓存。
func readerBookContentBatchHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
ChapterIndexes []int `json:"chapter_indexes" binding:"required"`
// ApplyReplace 是否在服务端应用用户替换净化规则(默认 false,与单章接口一致)。
ApplyReplace bool `json:"apply_replace"`
}
return func(c *gin.Context) {
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)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
}