添加新功能,完善项目
@@ -1,190 +0,0 @@
|
||||
# 【开源推荐】MeBox:把 NAS / 网盘 / 远程 Emby 统一家里的观影入口,Docker 一键部署
|
||||
|
||||
> 配图已托管在 GitHub 仓库(`raw.githubusercontent.com` 直链),发帖时可直接引用,或下载 `docs/tutorial-screenshots/` 后作为附件上传。
|
||||
|
||||
---
|
||||
|
||||
## 写在前面
|
||||
|
||||
给论坛的朋友们推荐一个我维护的开源项目 —— **MeBox**,一个面向 NAS 与家庭影音场景的**自托管私人媒体中心**(GPL-3.0,Go + React)。
|
||||
|
||||
GitHub:https://github.com/truewhile/MeBox
|
||||
|
||||
一句话介绍:**部署一个服务,同时获得媒体库后台、网盘 STRM 整理、Emby 客户端协议网关三件套。** 内置完整 Emby/Jellyfin 服务端协议实现——手机、电视、平板上的 Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端直接「添加 Emby 服务器」就能连,一套账号体系全搞定,Emby 老用户零学习成本。
|
||||
|
||||
项目 fork 自 MediaStationGo 并持续二开,围绕网盘播放、任务队列、远程挂载和权限体系做了大量增强。
|
||||
|
||||
---
|
||||
|
||||
## 它能解决什么问题?
|
||||
|
||||
家里看电影电视的痛点,MeBox 基本一把梭:
|
||||
|
||||
| 痛点 | MeBox 的解法 |
|
||||
| --- | --- |
|
||||
| 硬盘散落各处,海报墙乱七八糟 | 多根目录媒体库 + TMDb/Bangumi/Douban 自动刮削,海报墙、继续观看、多季剧集一应俱全 |
|
||||
| 网盘资源看一部下一部太麻烦 | OpenList / CloudDrive2 / 115 / WebDAV 接入,STRM 同步 + 直链/302 播放,不占本地空间 |
|
||||
| 已经有一台 Emby,出门还得开 App | **远程 Emby 挂载**:把远程 Emby 的媒体库直接挂进 MeBox 界面统一浏览 |
|
||||
| 家人乱动设置、小孩看不该看的 | 多用户 + 有效期 + 成人内容开关 + 播放配置 PIN,细粒度权限 |
|
||||
| 每个设备装一套专属 App 太折腾 | **完整兼容 Emby/Jellyfin 客户端**:Infuse、SenPlayer、Fileball、官方客户端按「添加 Emby 服务器」填地址 + MeBox 账号即可,海报墙、观看进度、多用户直接同步 |
|
||||
|
||||
---
|
||||
|
||||
## 特点一览
|
||||
|
||||
**1. 现代化 Web UI,海报墙开箱即用**
|
||||
|
||||

|
||||
|
||||
深色系登录页,默认账号 `admin / admin123`(首次登录请立即改密)。
|
||||
|
||||

|
||||
|
||||
首页自带焦点推荐轮播 + 媒体库入口卡片,继续观看、最近添加直接呈现。
|
||||
|
||||
**2. 媒体库与刮削**
|
||||
|
||||

|
||||
|
||||
20 个媒体库、1600+ 条目一眼尽收:每库自带封面拼贴、条目数统计,支持「全库修复+重刮」「刮削队列」批量处理。
|
||||
|
||||

|
||||
|
||||
库内海报墙带评分、集数角标,支持按最后集添加日期排序,点开即看。
|
||||
|
||||
**3. 详情页与多季管理**
|
||||
|
||||

|
||||
|
||||
剧情简介、类型标签、多季分集(特别篇/第 1-N 季)、每集缩略图与时长;一键立即播放、调用外部播放器、加入收藏。
|
||||
|
||||
**4. Emby/Jellyfin 客户端无缝兼容**
|
||||
|
||||
这是我最想强调的一点:**MeBox 内置了完整的 Emby 服务端协议实现**。手机、电视、平板上的 Infuse、SenPlayer、Fileball,甚至 Emby/Jellyfin 官方客户端,都不需要任何插件或改造——按「添加 Emby 服务器」填入 `http://服务器IP:18080`,用 MeBox 账号登录,海报墙、观看进度、收藏、多用户权限全部无缝衔接。已经习惯 Emby 生态的朋友可以零成本迁移,家人只用电视端 App 也完全无感。
|
||||
|
||||
**5. 网页播放器 + 弹幕自动匹配**
|
||||
|
||||

|
||||
|
||||
内置网页播放器支持 HLS 转码、字幕、播放配置档;**弹幕按剧名自动匹配全季分集**(截图中自动匹配到《一拳超人》39 集),屏幕占比/透明度/字号随意调,追新番体验直接拉满。
|
||||
|
||||
**6. 网盘 STRM:网盘当本地盘用**
|
||||
|
||||

|
||||
|
||||
添加网盘账号(**115 支持二维码扫码登录**)→ 添加同步目录 → 系统把网盘/本地目录里的视频生成 `.strm` 文件,元数据经下载/上传队列双向同步,播放走直链/302 不落盘。
|
||||
|
||||
**7. 远程 Emby 挂载(特色功能)**
|
||||
|
||||

|
||||
|
||||
已有远程 Emby 服务器?填一次账号,按需勾选要挂载的媒体库(支持同服务器多线路自动切换、直连开关、排序),远程库直接出现在 MeBox 首页,不必再开 Emby 客户端。
|
||||
|
||||
**8. 任务队列统一管理**
|
||||
|
||||

|
||||
|
||||
刮削 / 下载 / 上传三类任务统一看板,排队中、进行中、已匹配、失败分类计数,支持搜索与批量清理。
|
||||
|
||||
**9. 下载与自动整理**
|
||||
|
||||

|
||||
|
||||
配合任意下载器(qBittorrent、Transmission 等下载到本地目录即可),MeBox 定时自动整理入媒体库:智能分类子库、自动注册目的地媒体库、复制/移动/硬链/软链多种整理方式,命名规则可配。
|
||||
|
||||
**10. 多用户与权限**
|
||||
|
||||

|
||||
|
||||
管理员/普通用户分级、单实例用户数上限、账号有效期、成人内容开关、播放配置 PIN——给家人开号放心给。
|
||||
|
||||
**11. 运维省心**
|
||||
|
||||

|
||||
|
||||
FFmpeg/FFprobe 一键下载安装、转码与硬件加速开关、TMDb 语言、识别词、弹幕、Adult/NSFW 开关全在设置页分组管理;另有 DLNA 投屏、存储统计、海报墙聚合视图:
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 使用教程:从零到海报墙只要 5 步
|
||||
|
||||
### 第 1 步:Docker 一键部署
|
||||
|
||||
推荐 Docker Compose(仓库提供 4 份互相独立的完整模板,无需 `.env`):
|
||||
|
||||
```bash
|
||||
mkdir -p MeBox && cd MeBox
|
||||
|
||||
# 最省心:单镜像 + 内置 SQLite
|
||||
curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.simple.yml -o docker-compose.yml
|
||||
|
||||
# 多用户/大数据量可选 PostgreSQL 档、Redis 档、OpenSearch 档,见仓库 README「部署档位」
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
浏览器访问 `http://服务器IP:18080`,镜像:`ghcr.io/truewhile/mebox:latest`(amd64 / arm64 都有,也提供 Windows/Linux/macOS 单文件可执行程序,不想装 Docker 直接下载跑)。
|
||||
|
||||
### 第 2 步:登录并修改密码
|
||||
|
||||
默认账号 `admin / admin123`,登录后右上角头像 → 个人资料修改密码。
|
||||
|
||||
### 第 3 步:创建媒体库 + 扫库
|
||||
|
||||
后台 → 媒体库 → 管理媒体库,添加本地路径(Docker 部署记得填**容器内**路径,如 `/media/电影`,`volumes` 左侧挂宿主机真实目录)→ 执行扫库。
|
||||
|
||||
### 第 4 步:配置元数据刮削
|
||||
|
||||
系统设置 → 外部 API,填入 TMDb / Bangumi / Douban 等 API Key;媒体库页可对单个库「全库修复+重刮」,刮削进度在任务队列实时可见。
|
||||
|
||||
### 第 5 步(可选但强烈推荐):
|
||||
|
||||
- **网盘用户**:STRM 管理 → 添加网盘账号(115 可扫码)→ 添加同步目录 → 生成 STRM 后直链播放;
|
||||
- **已有 Emby**:Emby 挂载 → 添加 Emby 账号 → 勾选要挂载的媒体库;
|
||||
- **第三方播放器(Emby 客户端全兼容)**:Infuse / SenPlayer / Fileball / Emby、Jellyfin 官方客户端,按「添加 Emby 服务器」填 `http://服务器IP:18080`,用 MeBox 账号登录即可,原有使用习惯完全不变;
|
||||
- **下载党**:qBittorrent 等任意下载器把视频下到下载目录,在文件管理里把它设为整理源,下完自动分类入库。
|
||||
|
||||
### 路径映射小抄(Docker 最常见坑)
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /vol1/1000/Media:/media # 左:宿主机真实路径;右:容器内路径(网页里填这个)
|
||||
environment:
|
||||
MEBOX_MEDIA_DIR: /vol1/1000/Media
|
||||
MEBOX_MEDIA_CONTAINER_DIR: /media
|
||||
```
|
||||
|
||||
硬链接要求同一文件系统/子卷,跨盘请改复制或软链。
|
||||
|
||||
---
|
||||
|
||||
## 部署档位怎么选?
|
||||
|
||||
| 档位 | 文件 | 组件 | 适合 |
|
||||
| --- | --- | --- | --- |
|
||||
| 极简 | `docker-compose.simple.yml` | 单镜像 + SQLite | 个人使用、低配设备 |
|
||||
| 标准 | `docker-compose.yml` | + PostgreSQL | 多用户家庭共享 |
|
||||
| 增强 | `docker-compose.standard.yml` | + Redis | 大媒体库高频访问 |
|
||||
| 搜索 | `docker-compose.search.yml` | + OpenSearch | 超大库全文搜索 |
|
||||
|
||||
---
|
||||
|
||||
## 技术栈与致谢
|
||||
|
||||
- 后端:Go · Gin · GORM · SQLite/PostgreSQL · 可选 Redis / OpenSearch
|
||||
- 前端:React 18 · Vite · TypeScript · Tailwind CSS · Zustand
|
||||
- 部署:Docker Compose 多档模板,amd64/arm64 镜像 + 单文件可执行
|
||||
|
||||
感谢上游 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的奠基,网盘同步/STRM/整理部分参考了 [qmediasync](https://github.com/qicfan/qmediasync) 的思路。
|
||||
|
||||
---
|
||||
|
||||
## 链接
|
||||
|
||||
- GitHub:https://github.com/truewhile/MeBox
|
||||
- Issue / PR:欢迎提 bug(附部署方式+复现步骤+日志)与功能建议
|
||||
- License:GPL-3.0
|
||||
|
||||
觉得有用的话求个 Star ⭐,也欢迎论坛里的朋友反馈使用体验,我长期维护。
|
||||
@@ -1,743 +0,0 @@
|
||||
# 阅读模块(Reading Module)设计与实施方案
|
||||
|
||||
> 状态:设计稿,待评审
|
||||
> 目标版本:v0.2.0(分期落地,见第 9 节)
|
||||
> 关联现有子系统:媒体库 / 网盘存储 / 权限体系 / 任务队列
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
### 1.1 已确认的产品决策
|
||||
|
||||
| 维度 | 决策 |
|
||||
| --- | --- |
|
||||
| 内容类型 | **电子书 + 漫画统一书架**(EPUB / TXT / PDF / MOBI 与 CBZ / CBR / 图片文件夹) |
|
||||
| 书源 | **独立书库**(不复用影视媒体库)+ **网盘直链阅读** |
|
||||
| 首版范围 | **完整版**:多用户书库权限 + 阅读统计 |
|
||||
| 阅读形态 | **滚动流式与分页翻页双模式**,用户可切换并持久化偏好 |
|
||||
|
||||
### 1.2 明确的非目标
|
||||
|
||||
- **不接入 Emby/Jellyfin 协议。** Emby 的 `Items` / `Views` / `PlaybackInfo` 语义围绕音视频构建,没有书籍章节与阅读进度的对应概念。强行映射会污染 `internal/service/emby_*.go` 与 `internal/handler/emby_*.go` 的既有兼容层,收益极低。阅读能力只通过 MeBox 自己的 Web UI 提供。
|
||||
- **不复用 `model.Library` / `LibraryRoot`。** `Library.Type` 的取值域是 `movie/tv/anime/music`,且被海报墙轮播(`CarouselEnabled`)、自动整理管线、Emby 视图、首页预览等链路消费。把书库塞进去会导致这些链路需要到处加 `type != "book"` 判断。
|
||||
- 首版不做:听书 TTS、在线书源(笔趣阁类)、社交分享、跨设备同步批注冲突合并。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体架构
|
||||
|
||||
### 2.1 分层落位
|
||||
|
||||
完全沿用现有分层,不引入新模式:
|
||||
|
||||
```
|
||||
web/src/pages/Books*.tsx ← 页面
|
||||
web/src/components/Book*.tsx ← 阅读器与书架组件
|
||||
web/src/api/books.ts ← axios 封装(仿 web/src/api/library.ts)
|
||||
↓ /api/books/*
|
||||
internal/handler/books*.go ← 反序列化 + 权限校验 + 响应
|
||||
internal/service/book_*.go ← 业务策略(扫描、解析、进度、统计)
|
||||
internal/repository/book_*.go ← 纯持久化
|
||||
internal/model/book.go ← GORM 模型,注册进 model.AllModels()
|
||||
```
|
||||
|
||||
新增路由注册走 `internal/handler/routes_authenticated_features.go` 的既有范式,新增一个 `registerAuthedBookRoutes(authed, svc)`,在 `registerAuthenticatedRoutes` 链上挂载。`service.Container` 与 `repository.Container` 各追加一个字段。
|
||||
|
||||
### 2.2 与现有能力的复用点
|
||||
|
||||
| 现有部件 | 复用方式 |
|
||||
| --- | --- |
|
||||
| `service.StreamService.ServeFile`(`internal/service/stream_file.go`) | 已用 `http.ServeContent` 处理 HEAD / Range / If-Modified-Since,**PDF 与原始文件流直接照搬这条路径** |
|
||||
| `cloud.Provider.Resolve(ctx, fileRef) (*DirectLink, error)`(`internal/service/cloud/cloud.go`) | 网盘书源的直链解析入口,`DirectLink.Proxy` 决定 302 还是反代 |
|
||||
| `model.StorageConfig`(`internal/model/storage_assistant.go`) | 直接复用为网盘书源的账号凭据载体,**不新建凭据表** |
|
||||
| `service.ImageProxy`(`internal/service/image_proxy*.go`) | 漫画页与封面的磁盘缓存 + 远程拉取 + 缩放,复用其缓存目录与命名思路 |
|
||||
| `service.PruneImageCache` / `PruneImageCachePools`(`internal/service/cache_cleanup.go`) | 现成的「按池做 LRU 淘汰 + 按保留时长淘汰」助手,书籍缓存淘汰直接复用它 |
|
||||
| `service/scheduler_local_jobs.go` | 本地定时任务的挂载点,书籍缓存清理与每日统计汇总都注册在这里 |
|
||||
| `config.CacheConfig`(`internal/config/types.go`) | 已有 `CacheDir` / `ImagesMaxSizeMB` / `ImagesOriginalsMaxSizeMB` / `ImagesOriginalsTTLHours` / `MemoryMaxSizeMB`,书籍缓存容量配置直接挂进去 |
|
||||
| `service.FileManager`(`internal/service/filemanager.go`) | 本地书源目录浏览,前端复用 `LocalDirBrowserDialog.tsx` |
|
||||
| `service.Scheduler` | 书库定时扫描(默认关闭,管理员可开) |
|
||||
| `model.UserPermission` | 新增阅读权限位,见第 7 节 |
|
||||
| `helper.Go` / `Container.stopCtx` | 后台扫描任务的生命周期管理 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
新增文件 `internal/model/book.go`,并在 `internal/model/model.go` 的 `AllModels()` 中追加。所有表继承 `model.Base`(UUID 主键 + 时间戳 + 软删除)。
|
||||
|
||||
**表名约定**:`internal/model` 全包**没有任何 `TableName()` 覆盖**,一律使用 GORM 默认复数化(例如 `PlaybackHistory` → `playback_histories`,可从 `internal/database/schema_migration.go` 的裸 SQL 印证)。新表沿用该约定,不引入例外。因此模型命名要保证复数化结果干净:
|
||||
|
||||
| 模型 | 表名 |
|
||||
| --- | --- |
|
||||
| `Book` | `books` |
|
||||
| `BookLibrary` | `book_libraries` |
|
||||
| `BookSource` | `book_sources` |
|
||||
| `BookChapter` | `book_chapters` |
|
||||
| `BookProgress` | `book_progresses` |
|
||||
| `BookAnnotation` | `book_annotations` |
|
||||
| `BookFavorite` | `book_favorites` |
|
||||
| `BookReadingSession` | `book_reading_sessions` |
|
||||
| `BookDailyStat` | `book_daily_stats` |
|
||||
|
||||
(刻意用 `BookDailyStat` 而不是 `BookStatDaily`——后者复数化会得到 `book_stat_dailies`。)
|
||||
|
||||
### 3.1 书库与书源
|
||||
|
||||
```go
|
||||
// BookLibrary 是独立于影视媒体库的书库。
|
||||
type BookLibrary struct {
|
||||
Base
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Kind string `gorm:"size:16;not null;default:mixed" json:"kind"` // ebook / comic / mixed
|
||||
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
SortOrder int `gorm:"index;default:0" json:"sort_order"`
|
||||
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
|
||||
ScanStatus string `gorm:"size:16;default:idle" json:"scan_status"` // idle / scanning / error
|
||||
ScanMessage string `gorm:"size:512" json:"scan_message,omitempty"`
|
||||
}
|
||||
|
||||
// BookSource 是书库下的一条挂载来源:本地目录或网盘路径。
|
||||
type BookSource struct {
|
||||
Base
|
||||
LibraryID string `gorm:"index;size:36;not null" json:"library_id"`
|
||||
Name string `gorm:"size:128" json:"name,omitempty"`
|
||||
StorageKind string `gorm:"size:16;not null;default:local" json:"storage_kind"` // local / cloud
|
||||
Path string `gorm:"size:1024;not null" json:"path"` // 本地绝对路径 / 网盘内路径
|
||||
StorageConfigID string `gorm:"index;size:36" json:"storage_config_id,omitempty"` // 复用 model.StorageConfig
|
||||
Depth int `gorm:"default:3" json:"depth"` // 扫描递归深度上限
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
}
|
||||
```
|
||||
|
||||
`StorageKind = cloud` 时,`StorageConfigID` 指向一条 `StorageConfig`(`Type` ∈ `cloud115 / clouddrive2 / openlist / emby_remote`)。凭据解密沿用 `service.CryptoService`。
|
||||
|
||||
### 3.2 书籍与章节
|
||||
|
||||
```go
|
||||
type Book struct {
|
||||
Base
|
||||
LibraryID string `gorm:"index;size:36;not null" json:"library_id"`
|
||||
SourceID string `gorm:"uniqueIndex:uniq_book_source_path,priority:1;index;size:36;not null" json:"source_id"`
|
||||
// SourcePath 在本地源是绝对路径,在网盘源是「网盘内路径」,两者都用
|
||||
// (source_id, source_path) 做唯一键,天然隔离两个 ID 空间。
|
||||
SourcePath string `gorm:"uniqueIndex:uniq_book_source_path,priority:2;size:1024;not null" json:"source_path"`
|
||||
SourceRef string `gorm:"size:256" json:"source_ref,omitempty"` // 网盘 file id / pickcode
|
||||
Title string `gorm:"size:512;not null" json:"title"`
|
||||
Author string `gorm:"size:256;index" json:"author,omitempty"`
|
||||
SeriesName string `gorm:"size:256;index" json:"series_name,omitempty"`
|
||||
Volume int `json:"volume"`
|
||||
Format string `gorm:"size:16;not null" json:"format"` // epub/txt/pdf/mobi/cbz/cbr/folder
|
||||
MediaKind string `gorm:"size:16;not null;default:ebook" json:"media_kind"` // ebook / comic
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
FileHash string `gorm:"index;size:64" json:"file_hash,omitempty"` // 大小+首尾采样,去重
|
||||
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
|
||||
Description string `gorm:"type:text" json:"description,omitempty"`
|
||||
Language string `gorm:"size:32" json:"language,omitempty"`
|
||||
Tags string `gorm:"type:text" json:"tags,omitempty"` // 逗号分隔
|
||||
ChapterCount int `json:"chapter_count"`
|
||||
WordCount int64 `json:"word_count"`
|
||||
PageCount int `json:"page_count"` // 漫画总页数 / PDF 页数
|
||||
ParseStatus string `gorm:"size:16;default:pending" json:"parse_status"` // pending/ok/failed
|
||||
ParseMessage string `gorm:"size:512" json:"parse_message,omitempty"`
|
||||
NSFW bool `gorm:"default:false" json:"nsfw"`
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
}
|
||||
```
|
||||
|
||||
**唯一键说明**:`SourcePath` 上的 `uniqueIndex` 需与 `SourceID` 组成复合键(`uniq_book_source_path`,priority 1 = `source_id`)。同一本书被两个书源包含时允许重复入库,这是符合预期的(用户可能故意如此)。
|
||||
|
||||
```go
|
||||
// BookChapter 只存索引,不存正文(见 3.4 的取舍)。
|
||||
type BookChapter struct {
|
||||
Base
|
||||
BookID string `gorm:"index:idx_book_chapter,priority:1;size:36;not null" json:"book_id"`
|
||||
Index int `gorm:"index:idx_book_chapter,priority:2" json:"index"`
|
||||
Title string `gorm:"size:512" json:"title"`
|
||||
Level int `gorm:"default:1" json:"level"` // 目录嵌套层级,1 = 顶级
|
||||
// 电子书定位:二选一
|
||||
Href string `gorm:"size:1024" json:"href,omitempty"` // EPUB zip 内条目路径
|
||||
StartOffset int64 `json:"start_offset"` // TXT 字节区间
|
||||
EndOffset int64 `json:"end_offset"`
|
||||
// 漫画/PDF 定位
|
||||
PageStart int `json:"page_start"`
|
||||
PageEnd int `json:"page_end"`
|
||||
CharCount int `json:"char_count"`
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 进度、批注、收藏、统计
|
||||
|
||||
```go
|
||||
// BookProgress 每个用户每本书一行(复合唯一键,仿 model.PlaybackHistory 的 uniq_user_history 模式)。
|
||||
type BookProgress struct {
|
||||
Base
|
||||
UserID string `gorm:"uniqueIndex:uniq_user_book,priority:1;size:36;not null" json:"user_id"`
|
||||
BookID string `gorm:"uniqueIndex:uniq_user_book,priority:2;size:36;not null" json:"book_id"`
|
||||
ChapterIndex int `gorm:"default:0" json:"chapter_index"`
|
||||
ChapterTitle string `gorm:"size:512" json:"chapter_title,omitempty"`
|
||||
CharOffset int `json:"char_offset"` // 章内字符偏移(电子书)
|
||||
PageIndex int `json:"page_index"` // 页码(漫画 / PDF)
|
||||
Percent float64 `json:"percent"` // 全书百分比,书架进度条展示用
|
||||
ScrollRatio float64 `json:"scroll_ratio"` // 章内滚动比例,跨端还原更精确
|
||||
ReaderMode string `gorm:"size:16;default:scroll" json:"reader_mode"` // scroll / paged
|
||||
Finished bool `gorm:"default:false" json:"finished"`
|
||||
TotalSeconds int64 `json:"total_seconds"`
|
||||
LastReadAt time.Time `gorm:"index" json:"last_read_at"`
|
||||
}
|
||||
|
||||
type BookAnnotation struct {
|
||||
Base
|
||||
UserID string `gorm:"index:idx_book_anno,priority:1;size:36;not null" json:"user_id"`
|
||||
BookID string `gorm:"index:idx_book_anno,priority:2;size:36;not null" json:"book_id"`
|
||||
ChapterIndex int `json:"chapter_index"`
|
||||
Type string `gorm:"size:16;not null" json:"type"` // bookmark / highlight / note
|
||||
StartOffset int `json:"start_offset"`
|
||||
EndOffset int `json:"end_offset"`
|
||||
SelectedText string `gorm:"size:2048" json:"selected_text,omitempty"`
|
||||
Note string `gorm:"type:text" json:"note,omitempty"`
|
||||
Color string `gorm:"size:16" json:"color,omitempty"`
|
||||
}
|
||||
|
||||
type BookFavorite struct {
|
||||
Base
|
||||
UserID string `gorm:"uniqueIndex:uniq_user_book_fav,priority:1;size:36;not null" json:"user_id"`
|
||||
BookID string `gorm:"uniqueIndex:uniq_user_book_fav,priority:2;size:36;not null" json:"book_id"`
|
||||
}
|
||||
|
||||
// BookReadingSession 由前端心跳驱动,服务端按小时聚合,避免行数爆炸。
|
||||
type BookReadingSession struct {
|
||||
Base
|
||||
UserID string `gorm:"index:idx_book_stat,priority:1;size:36;not null" json:"user_id"`
|
||||
BookID string `gorm:"index;size:36;not null" json:"book_id"`
|
||||
BucketStart time.Time `gorm:"index:idx_book_stat,priority:2" json:"bucket_start"` // 截断到小时
|
||||
Seconds int64 `json:"seconds"`
|
||||
CharsRead int64 `json:"chars_read"`
|
||||
PagesRead int `json:"pages_read"`
|
||||
}
|
||||
|
||||
// BookDailyStat 每日汇总,供热力图与「年度阅读报告」查询,避免实时扫 session 表。
|
||||
type BookDailyStat struct {
|
||||
Base
|
||||
UserID string `gorm:"uniqueIndex:uniq_user_book_daily,priority:1;size:36;not null" json:"user_id"`
|
||||
Day string `gorm:"uniqueIndex:uniq_user_book_daily,priority:2;size:10;not null" json:"day"` // YYYY-MM-DD
|
||||
Seconds int64 `json:"seconds"`
|
||||
Chars int64 `json:"chars"`
|
||||
Pages int `json:"pages"`
|
||||
Books int `json:"books"` // 当日有阅读记录的书数
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 关键取舍:正文不入库
|
||||
|
||||
**决策:DB 只存章节索引(偏移量 / zip 内路径 / 页码区间),正文按需从源文件读取。**
|
||||
|
||||
理由:
|
||||
1. 网文 TXT 常见 5–50MB,漫画单册 100–800MB。入库会让 SQLite 单文件膨胀到数十 GB,直接冲击 `docker-compose.simple.yml` 的「单文件数据库好备份」定位,也会拖慢全库 VACUUM / 备份 / 数据库迁移(`internal/service/database_admin.go`)。
|
||||
2. 源文件本来就是权威副本,重复存储没有收益。
|
||||
3. EPUB 与 CBZ 本质上都是 zip,**随机读取 zip 内单个条目成本极低**(读中央目录 + 解压目标条目),不需要把整本解压落盘。
|
||||
|
||||
代价是每次打开章节都要读源文件。缓解手段:
|
||||
- 本地源:`os.Open` + `io.SectionReader`,代价可忽略。
|
||||
- 网盘源:见 4.3 的本地缓存策略,且对已缓存的章节走本地。
|
||||
|
||||
### 3.5 用户级字段(挂在 `model.User` 上)
|
||||
|
||||
沿用 `AllowedLibraryIDs` 的 JSON-in-text 模式(见 `internal/model/user.go`),**不复用影视库字段**,避免两个 ID 空间交叉:
|
||||
|
||||
```go
|
||||
// 追加到 model.User
|
||||
ReaderSettings string `gorm:"type:text" json:"-"` // 阅读器偏好 JSON
|
||||
AllowedBookLibraryIDs string `gorm:"type:text" json:"-"` // 空 = 不限制
|
||||
AllowedBookLibraryList []string `gorm:"-" json:"allowed_book_library_ids,omitempty"`
|
||||
```
|
||||
|
||||
`ReaderSettings` 结构(前端读写,服务端仅透传与长度校验):
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "scroll|paged",
|
||||
"fontSize": 18,
|
||||
"lineHeight": 1.8,
|
||||
"fontFamily": "serif|sans|custom",
|
||||
"contentWidth": 720,
|
||||
"theme": "light|sepia|dark|black",
|
||||
"pageAnimation": "slide|fade|none",
|
||||
"comicLayout": "single|double|auto",
|
||||
"comicDirection": "ltr|rtl",
|
||||
"hideScrollbar": true
|
||||
}
|
||||
```
|
||||
|
||||
放在 `User` 行内(而非新表)的理由:与 `PlayerVolume` / `DanmakuFontSize` 等既有播放器偏好一致,读取时随用户信息一并返回,无需额外查询。
|
||||
|
||||
---
|
||||
|
||||
## 4. 书源与内容读取管线
|
||||
|
||||
### 4.1 扫描流程
|
||||
|
||||
```
|
||||
POST /api/books/libraries/:id/scan
|
||||
→ BookScannerService.ScanLibrary(ctx, libraryID)
|
||||
1. 置 ScanStatus=scanning,通过 SSEHub 广播进度(复用 service.SSEHub)
|
||||
2. 遍历启用的 BookSource
|
||||
- local: filepath.WalkDir,按扩展名白名单过滤,超过 Depth 停止递归
|
||||
- cloud: cloud.New(cfg.Type, cfg, client).List(ctx, dirID) 递归列目录
|
||||
3. 对每个候选文件调 BookParser.ParseMeta(reader) 拿元信息 + 目录
|
||||
4. Upsert 到 books / book_chapters(source_id + source_path 为幂等键)
|
||||
5. 源上已消失的书标记软删除(与影视库扫描语义保持一致)
|
||||
6. 置 ScanStatus=idle,记录 LastScanAt
|
||||
```
|
||||
|
||||
扩展名白名单:`.epub .txt .pdf .mobi .azw3 .cbz .cbr .zip .rar`(`.zip/.rar` 仅当目录内全是图片时按漫画处理,否则跳过,防止误吞压缩包)。
|
||||
|
||||
并发:复用 `internal/service` 现有的 worker 池写法,默认 2–4 并发解析(解析要读文件,IO 密集)。
|
||||
|
||||
### 4.2 各格式解析策略
|
||||
|
||||
| 格式 | 元信息 | 章节 / 页 | 正文读取 |
|
||||
| --- | --- | --- | --- |
|
||||
| **EPUB** | zip → `META-INF/container.xml` → OPF → `dc:title/dc:creator/dc:language/dc:description`;封面取 OPF `meta[name=cover]` 指向项,退化到 `guide` | 按 spine 顺序,标题取每个 XHTML 的 `<title>` 或首个 `h1..h3`;`Level` 由 nav/ncx 的嵌套深度推断 | `archive/zip` 定位 `Href` 条目,读出 XHTML → 服务端清洗后返回 |
|
||||
| **TXT** | 文件名(`书名 - 作者.txt` 模式)+ 编码探测 | 正则切分:`第[一二三四五六七八九十百千零两0-9]+[章节卷回篇]`、`Chapter\s+\d+`、`^\s*\d+\s*$`;命中不足 3 个则按固定字节窗口切片 | `io.SectionReader` 读 `[StartOffset, EndOffset)` → 按探测到的编码转 UTF-8 |
|
||||
| **PDF** | 首页/元数据(页数、标题);封面渲染首屏,失败则留空 | 单章「正文」,`PageStart/PageEnd` = 全书页 | 原始文件流(Range),前端 pdf.js 自己解析 |
|
||||
| **CBZ / CBR** | zip/rar 条目自然排序,第一张图做封面 | 单章,页区间 = 图片条目序号 | 按页解压单条目,走图片响应路径 |
|
||||
| **图片文件夹** | 目录名 | 单章,页区间 = 排序后图片序号 | 直接读本地文件 |
|
||||
| **MOBI / AZW3** | PalmDOC / KF8 头 | 首版**只入库展示、不支持在线阅读**,详情页给出「下载原文件」入口 | — |
|
||||
|
||||
实现细节提示:
|
||||
- 编码探测用 `golang.org/x/text`(已是 `go.mod` 间接依赖)。GBK/Big5/UTF-16LE 都要覆盖,中文网文 TXT 大量是 GBK。
|
||||
- CBR 需要 RAR 解压。建议引入纯 Go 的 `github.com/nwaples/rardecode`;若不接受新依赖,首版把 CBR 归入「只入库、不可读」。
|
||||
- EPUB XHTML 清洗**必须在服务端做**:剔除 `<script>`、`on*` 事件属性、`<iframe>`、外部 `http(s)` 资源引用,把 `src/href` 重写为 `/api/books/:id/res/*`。前端再叠一层 DOMPurify 作为纵深防御。
|
||||
|
||||
### 4.3 网盘书籍的读取策略
|
||||
|
||||
网盘直链的核心约束:**EPUB / CBZ 的解析必须能读到文件尾部**(zip 中央目录在末尾),但 `cloud.Provider.Resolve` 返回的是短时效 URL,且 115 直链依赖 UA/Cookie(`DirectLink.Headers`),浏览器无法直接携带。
|
||||
|
||||
因此分两条路径:
|
||||
|
||||
**A. 解析阶段 —— 完整拉取到缓存目录**
|
||||
|
||||
```
|
||||
<CacheDir>/books/<sourceID>/<hash>.<ext>
|
||||
```
|
||||
|
||||
`BookParser` 通过 `DirectLink` 拉全量文件到缓存后再解析。缓存目录复用 `config.CacheConfig.CacheDir`(默认 `<DataDir>/cache`,容器里是 `/cache`),容量上限新加一项 `CacheConfig.BooksMaxSizeMB`(默认 2GB),走 LRU 淘汰。缓存命中的书后续正文读取也直接走本地,不再回网盘。
|
||||
|
||||
**B. 阅读阶段 —— 优先本地缓存,未命中走代理流**
|
||||
|
||||
未缓存时由服务端反代目标 URL(`DirectLink.Proxy=true` 时同样反代),并把 `Content-Type: image/*` 或 `application/pdf` 透传给前端。反代实现直接参照 `internal/service/cloud115_hls_proxy.go` 的响应头透传白名单(`Content-Type/Content-Length/Content-Range/Accept-Ranges/ETag/Last-Modified`)。
|
||||
|
||||
**C. 阅读进度与文件解耦** —— 代码里区分「源」「位置」:
|
||||
|
||||
```go
|
||||
type BookLocator struct {
|
||||
Kind string `json:"kind"` // local / cloud
|
||||
LocalPath string `json:"local_path,omitempty"`
|
||||
CloudConfig string `json:"cloud_config,omitempty"`
|
||||
CloudRef string `json:"cloud_ref,omitempty"`
|
||||
Href string `json:"href,omitempty"` // zip 内条目
|
||||
StartOffset int64 `json:"start_offset,omitempty"`
|
||||
EndOffset int64 `json:"end_offset,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
被缓存的书 `Kind` 仍报 `cloud`(进度不绑物理位置),这样缓存被淘汰后进度依然有效。这是不把 `Book.Path` 直接存成本地缓存路径的原因。
|
||||
|
||||
### 4.4 磁盘与容器
|
||||
|
||||
书籍目录需要在 compose 里挂载,并在 README 的部署档位表补充说明。新缓存目录复用现有 `MEBOX_CACHE_CACHE_DIR`(`docker-compose.simple.yml` 中为 `/cache`),无需新增环境变量。
|
||||
|
||||
---
|
||||
|
||||
## 5. HTTP API 设计
|
||||
|
||||
全部挂在 `/api/books/*`,注册在 `registerAuthedBookRoutes`。响应统一走 `internal/handler/response.go` 的既有助手。
|
||||
|
||||
### 5.1 书库与扫描(管理端)
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/books/libraries` | `can_read_books` | 列表,按 `AllowedBookLibraryIDs` 过滤可见性 |
|
||||
| POST | `/api/books/libraries` | `can_manage_book_library` | 新建/更新书库 |
|
||||
| DELETE | `/api/books/libraries/:id` | `can_manage_book_library` | 删除(含级联软删 books) |
|
||||
| GET | `/api/books/libraries/:id/sources` | `can_manage_book_library` | 书源列表 |
|
||||
| POST | `/api/books/libraries/:id/sources` | `can_manage_book_library` | 新增书源(本地目录 / 网盘路径) |
|
||||
| POST | `/api/books/libraries/:id/scan` | `can_manage_book_library` | 触发扫描,返回 task id |
|
||||
| GET | `/api/books/scan/status` | `can_manage_book_library` | 扫描进度轮询 |
|
||||
| GET | `/api/books/browse` | `can_manage_book_library` | 网盘路径浏览(复用 cloud Provider.List) |
|
||||
|
||||
### 5.2 书架与详情
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/books` | 书架列表。参数:`library_id`、`keyword`、`media_kind`、`format`、`tag`、`sort`(`title/added_at/last_read/progress`)、`page/page_size` |
|
||||
| GET | `/api/books/continue-reading` | 最近在读,首页「继续阅读」区块用 |
|
||||
| GET | `/api/books/:id` | 详情(元信息 + 目录树 + 当前用户进度 + 收藏态) |
|
||||
| GET | `/api/books/:id/cover` | 封面。走 `ImageProxy` 的缓存与缩放,参数 `w` |
|
||||
| GET | `/api/books/:id/chapters/:index` | 章节正文。电子书返回 `text/html`(已清洗)或 `application/json` 结构化段落 |
|
||||
| GET | `/api/books/:id/res/*path` | EPUB 内部资源(图片/字体/CSS),路径参数为 zip 内条目 |
|
||||
| GET | `/api/books/:id/pages/:index` | 漫画单页图片,`Content-Type: image/*` + 长效缓存头 |
|
||||
| GET | `/api/books/:id/file` | 原始文件流(Range),pdf.js 与「下载原文件」共用 |
|
||||
| POST | `/api/books/:id/favorite` | 收藏 / 取消收藏 |
|
||||
| DELETE | `/api/books/:id` | 删除(`can_manage_books`) |
|
||||
|
||||
**章节响应格式(推荐 JSON 而非裸 HTML)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"index": 12,
|
||||
"title": "第十二章 雨夜",
|
||||
"char_count": 3820,
|
||||
"blocks": [
|
||||
{ "type": "p", "text": "……" },
|
||||
{ "type": "img", "src": "/api/books/xxx/res/images/1.png" }
|
||||
],
|
||||
"next_index": 13,
|
||||
"prev_index": 11
|
||||
}
|
||||
```
|
||||
|
||||
用结构化 blocks 而非 HTML 的理由:
|
||||
1. 前端可安全渲染,不必 `dangerouslySetInnerHTML`,彻底绕开 XSS 面。
|
||||
2. 分页模式需要按节点测量高度做分栏,结构化的段落数组比操作 DOM 简单得多。
|
||||
3. 字号/行距/主题切换只需重渲染,不碰 HTML 字符串。
|
||||
|
||||
保底方案:`?format=html` 仍返回清洗后的 HTML,供 EPUB 中复杂排版(表格、脚注、双向文字)回退。
|
||||
|
||||
### 5.3 进度、批注、统计
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/books/:id/progress` | 当前用户进度 |
|
||||
| PUT | `/api/books/:id/progress` | 保存进度。前端**节流 5s + 页面卸载时 `sendBeacon`** |
|
||||
| GET | `/api/books/:id/annotations` | 批注列表 |
|
||||
| POST | `/api/books/:id/annotations` | 新建 |
|
||||
| PATCH | `/api/books/:id/annotations/:aid` | 修改 note / color |
|
||||
| DELETE | `/api/books/:id/annotations/:aid` | 删除 |
|
||||
| POST | `/api/books/:id/heartbeat` | 阅读心跳,body 为 `{seconds, chars, pages}`,落 `book_reading_sessions` 小时桶 |
|
||||
| GET | `/api/books/reader-settings` | 读取当前用户的阅读器偏好(`model.User.ReaderSettings`) |
|
||||
| PUT | `/api/books/reader-settings` | 保存阅读器偏好(服务端只做长度与枚举校验后原样存储) |
|
||||
| GET | `/api/books/stats` | 个人统计:累计时长、已读书数、在读、近 30 天热力图 |
|
||||
| GET | `/api/books/stats/overview` | 管理员视角:全站阅读统计(`can_access_settings`) |
|
||||
|
||||
进度写入的并发安全:`uniq_user_book` 复合唯一键 + 先 `Updates` 再 `Create` 的 upsert 模式。**参照 `internal/database/schema_migration.go` 里 `dedupePlaybackHistories` 的前车之鉴**——`PlaybackHistory` 曾因 read-then-write 产生重复行导致唯一索引进不去,新表直接写 upsert,不要复制那个 bug。
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端设计
|
||||
|
||||
### 6.1 路由与导航
|
||||
|
||||
`web/src/appRoutes.tsx` 新增懒加载路由:
|
||||
|
||||
```tsx
|
||||
const BookshelfPage = lazy(() => import('./pages/BookshelfPage').then(m => ({ default: m.BookshelfPage })))
|
||||
const BookDetailPage = lazy(() => import('./pages/BookDetailPage').then(m => ({ default: m.BookDetailPage })))
|
||||
const BookReaderPage = lazy(() => import('./pages/BookReaderPage').then(m => ({ default: m.BookReaderPage })))
|
||||
const BookStatsPage = lazy(() => import('./pages/BookStatsPage').then(m => ({ default: m.BookStatsPage })))
|
||||
const BookLibraryPage = lazy(() => import('./pages/BookLibraryPage').then(m => ({ default: m.BookLibraryPage })))
|
||||
```
|
||||
|
||||
```
|
||||
/books 书架
|
||||
/books/:id 书籍详情(目录、元信息、开始阅读)
|
||||
/books/:id/read 阅读器(全屏,隐藏底栏)
|
||||
/books/stats 阅读统计
|
||||
/books/library 书库管理(adminOnly)
|
||||
```
|
||||
|
||||
`web/src/components/layoutNavigation.ts` 的改动:
|
||||
|
||||
- `MEDIA_NAV_ITEMS` 与 `MOBILE_BOTTOM_NAV_ITEMS` 加「阅读」项(`BookOpen` 图标),普通用户可见。
|
||||
- 新增 `isBookReaderRoute(pathname)`,并在 `shouldShowMobileBottomNav` 中排除 `/books/:id/read`,与 `isPlayerRoute` 的处理一致。
|
||||
- `resolveHeaderBack` 补 `/books/...` 的返回链。
|
||||
- `LAYOUT_NAV_ITEMS` 加「书库管理」条目,`adminOnly: true`。
|
||||
|
||||
### 6.2 页面组成
|
||||
|
||||
```
|
||||
web/src/pages/
|
||||
BookshelfPage.tsx 书架:筛选栏 + 网格/列表双视图 + 继续阅读横滑
|
||||
BookDetailPage.tsx 详情:封面、元信息、目录树、进度、开始/继续阅读
|
||||
BookReaderPage.tsx 阅读器外壳:顶栏 + 内容区 + 底部工具条 + 设置抽屉
|
||||
BookStatsPage.tsx 统计:热力图 + 概览卡片
|
||||
BookLibraryPage.tsx 书库管理:书库 CRUD + 书源 CRUD + 扫描触发与进度
|
||||
web/src/components/
|
||||
book/ReaderCore.tsx 渲染内核分发(按 media_kind + format)
|
||||
book/ScrollReader.tsx 滚动流式
|
||||
book/PagedReader.tsx 分页翻页
|
||||
book/ComicReader.tsx 漫画(单页/双页/右开本/预加载)
|
||||
book/PdfReader.tsx PDF(pdf.js)
|
||||
book/ReaderToolbar.tsx 顶栏 + 底栏(章节、进度、目录、批注、设置)
|
||||
book/ReaderSettingsPanel.tsx 阅读设置
|
||||
book/ChapterTocDrawer.tsx 目录抽屉
|
||||
book/AnnotationList.tsx 书签笔记列表
|
||||
book/ReaderProgressBar.tsx 进度条(可拖拽跳章)
|
||||
web/src/api/books.ts 接口封装
|
||||
```
|
||||
|
||||
对于 4 类内容,`ReaderCore` 的分发是第一层决策:
|
||||
|
||||
| `media_kind` | `format` | 内核 |
|
||||
| --- | --- | --- |
|
||||
| ebook | epub / txt | `ScrollReader` 或 `PagedReader`(按 `settings.mode`) |
|
||||
| ebook | pdf | `PdfReader` |
|
||||
| comic | cbz / cbr / folder | `ComicReader` |
|
||||
| ebook | mobi / azw3 | 不提供阅读,仅详情页 |
|
||||
|
||||
### 6.3 渲染内核选型(关键决策)
|
||||
|
||||
**结论:自研内核,不引入 epub.js / foliate-js。**
|
||||
|
||||
对比:
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| `epub.js` | 成熟、CFI 精确定位、多列分页开箱即用 | 维护停滞;CFI 定位难以与自研进度模型(`charOffset` / `percent`)对齐;PDF/漫画仍需另做两个内核 |
|
||||
| `foliate-js` | 一套 API 覆盖 EPUB/MOBI/CBZ/PDF,排版质量高 | 生态小、文档薄、非稳定 API,需要 vendored 一份并自行承担升级风险 |
|
||||
| **自研(推荐)** | 进度模型完全可控、跨端一致;零新增重依赖;与既有 Tailwind 主题体系天然统一 | 需要自己实现分页测量、脏 HTML 清洗、资源重写 |
|
||||
|
||||
自研方案的可行性依据:分页的本质是「CSS multi-column 布局 + `transform: translateX` 平移」,foliate-js 也是这么做的,核心约 200 行;滚动模式的虚拟化可以直接复用已有的 `react-virtuoso`(已在 `web/package.json`,用于 `VirtualMediaGrid`)。
|
||||
|
||||
自研必须做好的三件事:
|
||||
|
||||
1. **HTML 清洗**:服务端为主(见 4.2),前端用 `dompurify` 兜底。这是新增的唯一运行时依赖。
|
||||
2. **资源重写**:EPUB 内部图片/字体/CSS 的 `src`、`href`、`url()` 全部重写到 `/api/books/:id/res/`,否则相对路径会 404。
|
||||
3. **分页测量与重排**:容器尺寸变化(窗口 resize、字号切换、横竖屏)后必须重新分页,并把「当前段落 + 段内比例」作为锚点恢复位置,不能让用户跳回章首。
|
||||
|
||||
### 6.4 双模式实现
|
||||
|
||||
**滚动模式(`ScrollReader`)**
|
||||
- 章内虚拟化:单章文本通常 2k–10k 字,直接整章渲染即可;跨章用「当前章 + 前后各一章」的窗口,滚动到边界时无缝追加。
|
||||
- 进度:`IntersectionObserver` 观测可视段落,映射为 `charOffset`;`scroll_ratio` 同时上报。
|
||||
- 优势:移动端体验好,实现简单,长段落无分页误差。
|
||||
|
||||
**分页模式(`PagedReader`)**
|
||||
- 章内:容器设为多列(`column-width: <contentWidth>`),`overflow: hidden`,通过 `translateX` 翻页;总页数由 `scrollWidth / containerWidth` 得出。
|
||||
- 跨章:翻到本章末尾自动加载下一章首页;反向同理。章首/章尾需处理「残页合并」,避免出现半屏空白页。
|
||||
- 输入:左右方向键、空格、点击左右热区、滑动手势(移动端)。`comicDirection`/`pageAnimation` 控制方向与动画。
|
||||
- 进度:`chapter_index` + `page_index` 映射回 `charOffset`。
|
||||
|
||||
两种模式共享 `BookProgress`,切换模式时用「章 + 比率」换算,不丢位置。
|
||||
|
||||
### 6.5 状态与持久化
|
||||
|
||||
- 阅读器设置来自 `authStore` 的用户信息(`ReaderSettings` 反序列化),改动后 `PUT /api/books/reader-settings` 持久化 + 本地 `localStorage` 兜底(首屏渲染不等接口)。
|
||||
- 进度本地先写 `localStorage`(key `mebook:book:<id>:pos`),再节流同步服务端;页面隐藏/卸载用 `navigator.sendBeacon` 保证不丢。
|
||||
- 新增 `web/src/stores/readerSettings.ts`(zustand),与既有 `playProfile.ts` 组织方式一致。
|
||||
|
||||
---
|
||||
|
||||
## 7. 权限与多用户
|
||||
|
||||
`model.UserPermission` 新增 4 位(当前 18 个字段,加后 22 位):
|
||||
|
||||
| 权限位 | 默认 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `can_read_books` | `true` | 书架、阅读、进度、批注 |
|
||||
| `can_manage_book_library` | `false` | 书库 / 书源 CRUD、触发扫描、网盘浏览 |
|
||||
| `can_manage_books` | `false` | 编辑书籍元信息、删除书、手动重新解析 |
|
||||
| `can_view_book_stats` | `false` | 查看全站阅读统计 |
|
||||
|
||||
同步改动清单(**漏一处就会出现「后端有权限、前端不显示开关」的静默 bug**):
|
||||
|
||||
1. `internal/model/permission.go` — 字段、`NewDefaultPermission()`、`PermissionMap()`,并更新文件头注释里的数量描述(注释目前写「19项」,实际 18 个字段,顺手修正)。
|
||||
2. `web/src/types/auth.ts` — `PermissionFlags` 接口加 4 个字段。
|
||||
3. `web/src/stores/permissions.ts` — 默认值对象、中文标签映射、权限分组数组。
|
||||
4. `web/src/hooks/usePermission.ts` — 若其中有分组注释需同步。
|
||||
5. `internal/handler/permissions.go` — 权限矩阵响应(若有枚举)。
|
||||
6. `web/src/pages/AdminUsersForm.tsx` / 权限勾选 UI — 若按分组硬编码了列表。
|
||||
|
||||
书库可见性:
|
||||
|
||||
- 管理员在用户管理页勾选该用户可访问的书库,写入 `User.AllowedBookLibraryIDs`。
|
||||
- 空值 = 不限制(与影视库语义一致)。
|
||||
- 过滤集中在一个 `bookVisibility` 助手,与现有的 `internal/handler/visibility.go` 并列(该文件就是影视库可见性的集中判定点,并且会与 `PlayProfile.AllowedLibraryIDs` 求交集)。**阅读模块首版不接播放配置档**——`PlayProfile` 是影视播放器概念(音量、转码参数、PIN),与阅读无关;但判定入口要与它放在同一层,将来若要按配置档限制书库才不用重构。
|
||||
- **服务端强制**:`GET /api/books/:id`、章节、页面、资源(`/res/*`)、封面、原始文件流,**所有**按 ID 取内容的接口都要校验 `book.LibraryID ∈ 用户可见书库`,不能只靠书架列表过滤。这是最容易漏的越权点:`/api/books/:id/res/*path` 会直接吐出书籍内部的原始资源,漏校验等于开放全库文件读取。
|
||||
- 用户被取消书库授权后,其 `BookProgress` / `BookAnnotation` 保留不删(授权恢复即恢复),但接口一律按当前可见性判定,不因历史数据放行。
|
||||
|
||||
---
|
||||
|
||||
## 8. 阅读统计
|
||||
|
||||
- **采集**:阅读器每 30s 发一次 `heartbeat`,卸载时补发一次;服务端按 `(user_id, book_id, 小时桶)` 累加,行数上限 = 用户数 × 书数 × 阅读小时数,可控。
|
||||
- **汇总**:`Scheduler` 每日 03:00 把昨天之前的 session 滚进 `BookDailyStat`(复用 `service.Scheduler` 的既有定时任务注册方式)。
|
||||
- **展示**:
|
||||
- 个人页「阅读统计」:累计时长、读完本数、在读本数、近 30 天热力图(仿 GitHub 贡献图)、阅读类型分布(电子书 / 漫画)。
|
||||
- 首页新增「继续阅读」横滑区块(参照 `HomePageSections.tsx` 里既有区块的写法)。
|
||||
- 管理员视图:全站活跃度、热门书籍 Top 20(需 `can_view_book_stats`)。
|
||||
|
||||
隐私:统计仅对本人与管理员可见;管理员视图只出聚合数据,不暴露单个用户的阅读内容。
|
||||
|
||||
---
|
||||
|
||||
## 9. 分期实施计划
|
||||
|
||||
### P0 — 端到端可用(本地书库 / EPUB + TXT 电子书)
|
||||
|
||||
目标:能扫库、能在网页上把一本书读完、关掉浏览器再打开能续读。
|
||||
|
||||
| # | 交付物 |
|
||||
| --- | --- |
|
||||
| 1 | `internal/model/book.go` 九张表 + `AllModels()` 注册 + 迁移验证(SQLite 与 PostgreSQL 各跑一次升级) |
|
||||
| 2 | `BookLibrary` / `BookSource` / `Book` / `BookChapter` / `BookProgress` 的 repository |
|
||||
| 3 | `BookParser`:EPUB 与 TXT 解析(含 GBK 编码探测、章节正则切分、封面提取) |
|
||||
| 4 | `BookScannerService`:本地目录扫描 + upsert + 进度广播 |
|
||||
| 5 | API:书库 CRUD、书源 CRUD、扫描、书架列表、详情、章节正文、封面、进度读写、阅读器偏好读写 |
|
||||
| 6 | 前端:`BookshelfPage`、`BookDetailPage`、`BookReaderPage`(仅滚动模式)、目录抽屉、阅读设置面板 |
|
||||
| 7 | 权限:4 个权限位 + `AllowedBookLibraryIDs` 全链路(含服务端越权校验) |
|
||||
|
||||
**验收标准**
|
||||
- 一个含 50 本 EPUB 与 20 本 GBK 编码 TXT 的目录,扫描后书架正确列出,标题/作者/封面/章节目录无误。
|
||||
- 任意一本书可连续阅读 3 章以上,刷新页面后回到原位置(误差 < 1 段)。
|
||||
- 权限为 `can_read_books=false` 的账号访问 `/api/books` 返回 403;直接请求他人书库的 `/api/books/:id/chapters/0`、`/api/books/:id/res/*`、`/api/books/:id/file` 同样被拒。
|
||||
- SQLite 单文件档与 PostgreSQL 档都能从旧版本升级启动,无迁移报错。
|
||||
|
||||
### P1 — 漫画 + 分页模式 + 网盘直链
|
||||
|
||||
| # | 交付物 |
|
||||
| --- | --- |
|
||||
| 1 | `ComicReader`:CBZ 解析、单页/双页、右开本、相邻页预加载 |
|
||||
| 2 | `PagedReader`:分页测量、resize 重排、跨章衔接、键鼠与手势输入 |
|
||||
| 3 | 网盘书源:`StorageKind=cloud` 的书源配置、`cloud.Provider` 接入、本地缓存目录 + LRU 淘汰 |
|
||||
| 4 | 网盘书籍的索引拉取与阅读反代(含 `Content-Range` 透传) |
|
||||
| 5 | PDF:`PdfReader`(pdf.js)+ Range 文件流接口 |
|
||||
| 6 | 图片文件夹型漫画 |
|
||||
|
||||
**验收标准**
|
||||
- CBZ 单册 300 页可流畅翻阅,双页模式断页处理正确(避免跨章错配)。
|
||||
- 分页模式下切换字号、resize 窗口、手机横竖屏切换后,位置不跳、不出现空白页。
|
||||
- 挂在 OpenList 与 115 上的 EPUB 能正常入库并在线阅读,缓存目录达到上限后按 LRU 淘汰且不影响已有进度。
|
||||
- 20MB 以上 PDF 可跳页、可缩放。
|
||||
|
||||
### P2 — 批注、统计与体验打磨
|
||||
|
||||
| # | 交付物 |
|
||||
| --- | --- |
|
||||
| 1 | 划线 / 书签 / 笔记:`BookAnnotation` 接口与 UI,批注列表与跳转 |
|
||||
| 2 | 阅读统计:心跳采集、每日汇总任务、个人统计页、首页「继续阅读」区块 |
|
||||
| 3 | 管理员统计视图 + 热门书籍排行 |
|
||||
| 4 | 书库定时扫描(`Scheduler` 接入,默认关闭) |
|
||||
| 5 | 书架高级筛选与排序、合集(系列)聚合视图 |
|
||||
| 6 | MOBI/AZW3 元信息解析(仍不做在线阅读,仅提供下载) |
|
||||
| 7 | 部署文档与 compose 注释更新(书籍目录挂载说明) |
|
||||
|
||||
### P3 — 可选增强
|
||||
听书 TTS、跨设备批注冲突合并、书源自动整理(仿 `OrganizerService`)、EPUB 阅读器内注释锚点高亮。
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险与待拍板项
|
||||
|
||||
### 10.1 需要你拍板的两点
|
||||
|
||||
**① 网盘书籍的缓存策略**
|
||||
- 选项 A(本方案):索引时完整下载到缓存目录,阅读时优先本地。省流量、体验好,但全新书首次打开有等待,且占用磁盘(默认 2GB 上限)。
|
||||
- 选项 B:完全不落盘,每次按 Range/整文件从网盘拉。省磁盘,但每次打开都要重新下载,网盘限速时体验很差。
|
||||
- 选项 C:折中——只对 EPUB/CBZ 缓存(解析必须读全文),漫画原图与 PDF 走流式。
|
||||
|
||||
我的建议是 **C**,因为它把「必须落盘」和「可以不落盘」分开了。
|
||||
|
||||
**② 章节正文的返回格式**
|
||||
- JSON blocks(本方案推荐):安全、便于分页测量,但复杂 EPUB 排版(表格、脚注、竖排)会降级。
|
||||
- 清洗后 HTML:保真度高,但前端要 `dangerouslySetInnerHTML`,XSS 面更大。
|
||||
- 我的建议是 **JSON blocks 为主 + `?format=html` 回退**,两者都实现,前端在遇到 `type: "html-block"` 时回退渲染。
|
||||
|
||||
### 10.2 技术风险
|
||||
|
||||
| 风险 | 影响 | 缓解 |
|
||||
| --- | --- | --- |
|
||||
| 自研分页内核的边界情况多(残页、跨章、RTL、竖排) | P1 可能超期 | P0 先只做滚动模式;分页单独立项,配套 `playerPageModel.test.ts` 那样的单测 |
|
||||
| TXT 章节正则对网文变体覆盖不足 | 目录错乱 | 提供「手动重新切分」入口,规则可配(仿 `RecognitionWordsPanel` 的可配置词表模式) |
|
||||
| 网盘直链失效 / 限速 / 防盗链 | 阅读中断 | 复用现有 115 换链与 `url_cache.go` 的缓存机制;失败时前端降级为「下载原文件」 |
|
||||
| 大 TXT(>50MB)章节表行数过多 | SQLite 写入慢 | 章节超过阈值(如 5000 章)时按固定窗口粗切,或改为「按需切分 + 缓存到章节表」的惰性策略 |
|
||||
| 缓存目录膨胀 | 磁盘打满 | 容量上限 + 复用 `service.PruneImageCache` 的 LRU 清理 + 系统设置页可见 |
|
||||
| 数据库迁移对老库不兼容 | 升级失败 | 新表全部是纯新增,无列变更;不触碰 `ensurePostgresColumnCompatibility` 的既有语句 |
|
||||
|
||||
### 10.3 不引入的新依赖清单
|
||||
|
||||
| 依赖 | 用途 | 取舍 |
|
||||
| --- | --- | --- |
|
||||
| `dompurify` | 前端 HTML 清洗兜底 | **建议引入**(前端必需) |
|
||||
| `pdfjs-dist` | PDF 渲染 | **建议引入**(P1) |
|
||||
| `github.com/nwaples/rardecode` | CBR 解压 | 可选;不接受则 CBR 首版只入库 |
|
||||
| `epub.js` / `foliate-js` | EPUB 渲染 | **不引入**,见 6.3 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 测试策略
|
||||
|
||||
与项目现有测试密度对齐(`internal/service` 下大量 `_test.go`,前端有 `*.test.ts`):
|
||||
|
||||
**后端**
|
||||
- `book_parser_test.go`:EPUB / TXT 各准备 fixture(`testdata/` 下小体积样本),断言元信息、章节数、章节边界字节偏移、GBK 转码正确性。
|
||||
- `book_scanner_test.go`:临时目录扫描 + 重复扫描幂等 + 源文件删除后软删。
|
||||
- `book_progress_test.go`:并发 upsert 不产生重复行(直接复现 `dedupePlaybackHistories` 防的那类 bug)。
|
||||
- `book_permission_test.go`:越权矩阵,逐接口断言非可见书库返回 403/404。
|
||||
- Handler 层:仿 `internal/handler/media_test.go` 起的 `httptest` + 真实内存 SQLite。
|
||||
|
||||
**前端**
|
||||
- `readerModel.test.ts`:模式切换时的位置换算(`scroll ↔ paged`、`charOffset ↔ pageIndex`)、百分比计算、跨章边界。
|
||||
- 分页计算的纯函数抽出单测(不含 DOM),参照 `web/src/pages/playerPageModel.test.ts` 的做法——把逻辑从组件里拔出来测,是项目已有的好传统。
|
||||
|
||||
---
|
||||
|
||||
## 12. 附:改动文件清单
|
||||
|
||||
**后端新增**
|
||||
```
|
||||
internal/model/book.go
|
||||
internal/repository/book_repository.go
|
||||
internal/service/book_parser.go EPUB / TXT / CBZ 解析
|
||||
internal/service/book_parser_epub.go
|
||||
internal/service/book_parser_txt.go
|
||||
internal/service/book_parser_comic.go
|
||||
internal/service/book_scanner.go
|
||||
internal/service/book_reader.go 章节 / 页面 / 资源的读取与清洗
|
||||
internal/service/book_progress.go
|
||||
internal/service/book_stats.go
|
||||
internal/service/book_cache.go 网盘缓存与 LRU
|
||||
internal/handler/books.go
|
||||
internal/handler/books_library.go
|
||||
internal/handler/books_reader.go
|
||||
internal/handler/routes_books.go
|
||||
```
|
||||
|
||||
**后端修改**
|
||||
```
|
||||
internal/model/model.go AllModels() 追加 9 张表
|
||||
internal/model/permission.go 4 个权限位
|
||||
internal/model/user.go ReaderSettings / AllowedBookLibraryIDs
|
||||
internal/repository/repository.go Container 加字段
|
||||
internal/service/service.go Container 加字段 + Boot() 启动扫描
|
||||
internal/handler/routes_authenticated.go 挂载 registerAuthedBookRoutes
|
||||
internal/service/scheduler_local_jobs.go 书籍缓存清理 + 每日阅读统计汇总
|
||||
internal/config/types.go CacheConfig 加 BooksMaxSizeMB;新增 BookConfig(扫描并发等)
|
||||
docker-compose*.yml 书籍目录挂载注释
|
||||
README.md / README_EN.md 能力表新增「阅读」
|
||||
```
|
||||
|
||||
**前端新增**
|
||||
```
|
||||
web/src/api/books.ts
|
||||
web/src/stores/readerSettings.ts
|
||||
web/src/pages/BookshelfPage.tsx
|
||||
web/src/pages/BookDetailPage.tsx
|
||||
web/src/pages/BookReaderPage.tsx
|
||||
web/src/pages/BookStatsPage.tsx
|
||||
web/src/pages/BookLibraryPage.tsx
|
||||
web/src/components/book/*.tsx
|
||||
```
|
||||
**前端修改**
|
||||
```
|
||||
web/src/appRoutes.tsx 4 条路由
|
||||
web/src/components/layoutNavigation.ts 导航项、阅读器路由判定、返回链
|
||||
web/src/types/auth.ts 权限位
|
||||
web/src/stores/permissions.ts 权限位默认值 / 标签 / 分组
|
||||
web/src/pages/HomePageSections.tsx 「继续阅读」区块
|
||||
web/src/pages/settingsGroupBooks.ts (新增)阅读设置分组
|
||||
web/src/pages/settingsGroups.ts 把 settingsGroupBooks 加入 GROUPS 数组
|
||||
```
|
||||
|
Before Width: | Height: | Size: 620 KiB |
|
Before Width: | Height: | Size: 743 KiB |
|
Before Width: | Height: | Size: 793 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 612 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |