From b5ce4646cf4b303139c08fa3dcc55d0ae7dccce6 Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Tue, 22 Sep 2026 16:22:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=96=B0=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E5=AE=8C=E5=96=84=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/forum-post.md | 190 ----- docs/reading-module-design.md | 743 ------------------ docs/tutorial-screenshots/01-login.png | Bin 635361 -> 0 bytes docs/tutorial-screenshots/02-home.png | Bin 760677 -> 0 bytes docs/tutorial-screenshots/03-libraries.png | Bin 811801 -> 0 bytes .../04-library-posters.png | Bin 1171285 -> 0 bytes docs/tutorial-screenshots/05-media-detail.png | Bin 626766 -> 0 bytes docs/tutorial-screenshots/06-player-danmu.png | Bin 86658 -> 0 bytes docs/tutorial-screenshots/07-strm-cloud.png | Bin 253077 -> 0 bytes docs/tutorial-screenshots/08-emby-mount.png | Bin 384491 -> 0 bytes docs/tutorial-screenshots/09-task-queue.png | Bin 291518 -> 0 bytes docs/tutorial-screenshots/10-settings.png | Bin 327096 -> 0 bytes docs/tutorial-screenshots/11-file-manager.png | Bin 313516 -> 0 bytes docs/tutorial-screenshots/12-user-admin.png | Bin 270966 -> 0 bytes docs/tutorial-screenshots/13-poster-wall.png | Bin 1122313 -> 0 bytes internal/handler/admin_settings.go | 22 + internal/handler/devices.go | 173 ++++ internal/handler/devices_test.go | 232 ++++++ internal/handler/emby_discovery.go | 87 ++ internal/handler/emby_discovery_test.go | 244 ++++++ internal/handler/emby_routes.go | 8 +- internal/handler/emby_routes_lowercase.go | 8 +- internal/handler/library_discovery.go | 55 ++ internal/handler/library_filters_test.go | 283 +++++++ internal/handler/media.go | 92 ++- internal/handler/routes_admin.go | 5 + internal/handler/routes_authenticated_core.go | 12 + .../handler/routes_authenticated_extras.go | 2 +- internal/handler/series.go | 4 +- internal/handler/telegram_me.go | 89 +++ internal/handler/watch_history.go | 200 ++++- internal/handler/watch_history_stats_test.go | 349 ++++++++ internal/model/user.go | 3 + internal/model/user_telegram_test.go | 24 + internal/repository/media_filter_test.go | 174 ++++ internal/repository/media_repository.go | 116 +++ internal/service/device_listing.go | 16 +- internal/service/device_notify_test.go | 155 ++++ internal/service/device_service.go | 28 +- internal/service/emby_compat.go | 24 + internal/service/emby_compat_test.go | 132 ++++ internal/service/emby_discovery.go | 178 +++++ internal/service/media_cache.go | 17 + internal/service/media_discovery.go | 570 ++++++++++++++ .../service/media_discovery_nextup_test.go | 246 ++++++ .../service/media_discovery_similar_test.go | 167 ++++ internal/service/media_discovery_test.go | 136 ++++ internal/service/media_facets.go | 152 ++++ internal/service/media_filters_test.go | 88 +++ internal/service/media_listing.go | 85 +- internal/service/media_series.go | 92 +++ internal/service/scheduler.go | 14 + internal/service/scheduler_local_jobs.go | 8 + internal/service/service.go | 8 + internal/service/service_builder.go | 24 + internal/service/task_tracker.go | 46 ++ internal/service/telegram.go | 513 ++++++++++++ internal/service/telegram_alerts_test.go | 211 +++++ internal/service/telegram_expiry.go | 117 +++ internal/service/telegram_test.go | 210 +++++ web/src/api/client.ts | 4 + web/src/api/devices.ts | 49 ++ web/src/api/library.ts | 49 +- web/src/api/stats.ts | 23 + web/src/api/telegram.ts | 27 + web/src/appRoutes.tsx | 4 + web/src/components/MyDevicesPanel.tsx | 164 ++++ web/src/components/TelegramBindPanel.tsx | 189 +++++ web/src/components/layoutNavigation.ts | 4 + web/src/pages/ActiveUsersStrip.tsx | 71 ++ web/src/pages/AdminUserDevicesDialog.tsx | 195 +++++ web/src/pages/AdminUsersPanel.tsx | 12 + web/src/pages/AdminUsersTable.tsx | 12 +- web/src/pages/LibraryFilterBar.tsx | 237 ++++++ web/src/pages/LibraryPage.tsx | 80 +- web/src/pages/ProfilePage.tsx | 6 + web/src/pages/SettingsPage.tsx | 2 + web/src/pages/TelegramNotifyPanel.tsx | 67 ++ web/src/pages/WatchHistoryPage.tsx | 9 +- web/src/pages/WatchStatsPage.tsx | 281 +++++++ web/src/pages/settingsGroupDeviceNotify.ts | 76 ++ web/src/pages/settingsGroups.ts | 2 + web/src/pages/useLibraryData.ts | 31 +- web/src/types/history.ts | 20 + web/src/utils/libraryFilters.ts | 119 +++ 85 files changed, 7103 insertions(+), 982 deletions(-) delete mode 100644 docs/forum-post.md delete mode 100644 docs/reading-module-design.md delete mode 100644 docs/tutorial-screenshots/01-login.png delete mode 100644 docs/tutorial-screenshots/02-home.png delete mode 100644 docs/tutorial-screenshots/03-libraries.png delete mode 100644 docs/tutorial-screenshots/04-library-posters.png delete mode 100644 docs/tutorial-screenshots/05-media-detail.png delete mode 100644 docs/tutorial-screenshots/06-player-danmu.png delete mode 100644 docs/tutorial-screenshots/07-strm-cloud.png delete mode 100644 docs/tutorial-screenshots/08-emby-mount.png delete mode 100644 docs/tutorial-screenshots/09-task-queue.png delete mode 100644 docs/tutorial-screenshots/10-settings.png delete mode 100644 docs/tutorial-screenshots/11-file-manager.png delete mode 100644 docs/tutorial-screenshots/12-user-admin.png delete mode 100644 docs/tutorial-screenshots/13-poster-wall.png create mode 100644 internal/handler/devices.go create mode 100644 internal/handler/devices_test.go create mode 100644 internal/handler/emby_discovery.go create mode 100644 internal/handler/emby_discovery_test.go create mode 100644 internal/handler/library_discovery.go create mode 100644 internal/handler/library_filters_test.go create mode 100644 internal/handler/telegram_me.go create mode 100644 internal/handler/watch_history_stats_test.go create mode 100644 internal/model/user_telegram_test.go create mode 100644 internal/repository/media_filter_test.go create mode 100644 internal/service/device_notify_test.go create mode 100644 internal/service/emby_discovery.go create mode 100644 internal/service/media_discovery.go create mode 100644 internal/service/media_discovery_nextup_test.go create mode 100644 internal/service/media_discovery_similar_test.go create mode 100644 internal/service/media_discovery_test.go create mode 100644 internal/service/media_facets.go create mode 100644 internal/service/media_filters_test.go create mode 100644 internal/service/telegram.go create mode 100644 internal/service/telegram_alerts_test.go create mode 100644 internal/service/telegram_expiry.go create mode 100644 internal/service/telegram_test.go create mode 100644 web/src/api/devices.ts create mode 100644 web/src/api/stats.ts create mode 100644 web/src/api/telegram.ts create mode 100644 web/src/components/MyDevicesPanel.tsx create mode 100644 web/src/components/TelegramBindPanel.tsx create mode 100644 web/src/pages/ActiveUsersStrip.tsx create mode 100644 web/src/pages/AdminUserDevicesDialog.tsx create mode 100644 web/src/pages/LibraryFilterBar.tsx create mode 100644 web/src/pages/TelegramNotifyPanel.tsx create mode 100644 web/src/pages/WatchStatsPage.tsx create mode 100644 web/src/pages/settingsGroupDeviceNotify.ts create mode 100644 web/src/utils/libraryFilters.ts diff --git a/docs/forum-post.md b/docs/forum-post.md deleted file mode 100644 index fd27de9..0000000 --- a/docs/forum-post.md +++ /dev/null @@ -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 ⭐,也欢迎论坛里的朋友反馈使用体验,我长期维护。 diff --git a/docs/reading-module-design.md b/docs/reading-module-design.md deleted file mode 100644 index d77138a..0000000 --- a/docs/reading-module-design.md +++ /dev/null @@ -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 的 `