mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
fix legacy library scan and empty pages
This commit is contained in:
@@ -40,7 +40,7 @@ MediaStationGo 是一个自托管媒体管理系统,面向 NAS、小主机、
|
|||||||
- **本地 + 网盘**:支持本地硬盘、下载目录、OpenList、CloudDrive2、WebDAV、STRMURL 和 302 反代播放。
|
- **本地 + 网盘**:支持本地硬盘、下载目录、OpenList、CloudDrive2、WebDAV、STRMURL 和 302 反代播放。
|
||||||
- **订阅下载入库**:连接 qBittorrent 后支持搜索、订阅、下载完成整理、刮削和入库通知。
|
- **订阅下载入库**:连接 qBittorrent 后支持搜索、订阅、下载完成整理、刮削和入库通知。
|
||||||
- **多用户与权限**:管理员/普通用户、有效期、成人内容开关、设备管理、注册码和 Telegram Bot 绑定。
|
- **多用户与权限**:管理员/普通用户、有效期、成人内容开关、设备管理、注册码和 Telegram Bot 绑定。
|
||||||
- **三挡部署**:按规模选择 PostgreSQL、Redis、OpenSearch,低配 NAS 到大库检索都能覆盖。
|
- **灵活部署**:单镜像 SQLite 一键起步,或按规模选择 PostgreSQL、Redis、OpenSearch,低配 NAS 到大库检索都能覆盖。
|
||||||
|
|
||||||
## 社区与友链
|
## 社区与友链
|
||||||
|
|
||||||
@@ -75,12 +75,15 @@ MediaStationGo 是一个自托管媒体管理系统,面向 NAS、小主机、
|
|||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
最推荐使用 Docker Compose。仓库提供三份独立完整模板,全部不依赖 `.env`。新手默认下载第一档,复制后按自己的 NAS 路径改 `volumes` 和路径环境变量即可。
|
最推荐使用 Docker Compose。仓库提供四份独立完整模板,全部不依赖 `.env`。想最省心就下载单镜像档(SQLite,只有一个镜像);需要多用户/高并发再选第一档起的 PostgreSQL 档位。复制后按自己的 NAS 路径改 `volumes` 和路径环境变量即可。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p MediaStationGo
|
mkdir -p MediaStationGo
|
||||||
cd MediaStationGo
|
cd MediaStationGo
|
||||||
curl -fsSL https://raw.githubusercontent.com/ShukeBta/MediaStationGo/main/docker-compose.yml -o docker-compose.yml
|
# 最省心:单镜像 + SQLite
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/ShukeBta/MediaStationGo/main/docker-compose.simple.yml -o docker-compose.yml
|
||||||
|
# 或第一档:PostgreSQL(多用户/高并发更稳)
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/ShukeBta/MediaStationGo/main/docker-compose.yml -o docker-compose.yml
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -105,16 +108,39 @@ GHCR:ghcr.io/shukebta/mediastation-go:latest
|
|||||||
Docker Hub 备用:shukbet/mediastationgo:latest
|
Docker Hub 备用:shukbet/mediastationgo:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## 三挡部署
|
## 部署档位
|
||||||
|
|
||||||
MediaStationGo 推荐按机器资源和用户规模选择部署档位。三份 Compose 文件都是完整文件,不需要再叠加多个 `-f`。三挡都使用 PostgreSQL 作为主数据库;Redis 和 OpenSearch 是增强组件,不替代 PostgreSQL。
|
MediaStationGo 推荐按机器资源和用户规模选择部署档位。每份 Compose 文件都是完整文件,不需要再叠加多个 `-f`。想一个镜像跑起来就选单镜像档(SQLite);需要多用户 / 高并发时再用 PostgreSQL 三档。Redis 和 OpenSearch 是增强组件,不替代 PostgreSQL。
|
||||||
|
|
||||||
| 档位 | 完整配置文件 | 组件 | 适合场景 |
|
| 档位 | 完整配置文件 | 组件 | 适合场景 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
| 单镜像档 | `docker-compose.simple.yml` | MediaStationGo + 内置 SQLite | 新手、单人使用、只想一个镜像跑起来的低配机器 |
|
||||||
| 第一档 | `docker-compose.yml` | MediaStationGo + PostgreSQL | 大多数 NAS、个人/家庭使用、低内存机器 |
|
| 第一档 | `docker-compose.yml` | MediaStationGo + PostgreSQL | 大多数 NAS、个人/家庭使用、低内存机器 |
|
||||||
| 第二档 | `docker-compose.standard.yml` | MediaStationGo + PostgreSQL + Redis | 多用户、Emby 客户端频繁刷新、首页/媒体列表访问较多 |
|
| 第二档 | `docker-compose.standard.yml` | MediaStationGo + PostgreSQL + Redis | 多用户、Emby 客户端频繁刷新、首页/媒体列表访问较多 |
|
||||||
| 第三档 | `docker-compose.search.yml` | MediaStationGo + PostgreSQL + Redis + OpenSearch | 超大媒体库、复杂全文搜索、后续需要独立搜索索引 |
|
| 第三档 | `docker-compose.search.yml` | MediaStationGo + PostgreSQL + Redis + OpenSearch | 超大媒体库、复杂全文搜索、后续需要独立搜索索引 |
|
||||||
|
|
||||||
|
### 单镜像档:SQLite(最省心)
|
||||||
|
|
||||||
|
只启动 MediaStationGo 一个镜像,主数据库用内置 SQLite,不需要 PostgreSQL / Redis。变量最少、资源占用最低,适合新手和单人使用。日后需要多用户或更高并发时,保留 `./data` 后切换到第一档的 PostgreSQL 即可。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p MediaStationGo
|
||||||
|
cd MediaStationGo
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/ShukeBta/MediaStationGo/main/docker-compose.simple.yml -o docker-compose.yml
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
关键数据目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
./data JWT 密钥、运行配置、SQLite 主数据库(mediastation.db)——必须备份
|
||||||
|
./cache 海报/临时缓存,可重建
|
||||||
|
./media 媒体库
|
||||||
|
./downloads 下载目录
|
||||||
|
```
|
||||||
|
|
||||||
|
> 单镜像模式请不要配置 `MEDIASTATION_DATABASE_DSN`;一旦填了 DSN 就会切回 PostgreSQL。
|
||||||
|
|
||||||
### 第一档:PostgreSQL
|
### 第一档:PostgreSQL
|
||||||
|
|
||||||
第一档是默认推荐部署。它只启动主服务和 PostgreSQL,资源占用最低,适合绝大多数 NAS。
|
第一档是默认推荐部署。它只启动主服务和 PostgreSQL,资源占用最低,适合绝大多数 NAS。
|
||||||
@@ -162,9 +188,10 @@ OpenSearch 数据目录是 `./opensearch`。搜索索引可重建,但重建大
|
|||||||
|
|
||||||
## 配置示例
|
## 配置示例
|
||||||
|
|
||||||
仓库内提供三份推荐 Compose 文件:
|
仓库内提供四份推荐 Compose 文件:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
docker-compose.simple.yml 单镜像档:MediaStationGo + 内置 SQLite
|
||||||
docker-compose.yml 第一档:MediaStationGo + PostgreSQL
|
docker-compose.yml 第一档:MediaStationGo + PostgreSQL
|
||||||
docker-compose.standard.yml 第二档:MediaStationGo + PostgreSQL + Redis
|
docker-compose.standard.yml 第二档:MediaStationGo + PostgreSQL + Redis
|
||||||
docker-compose.search.yml 第三档:MediaStationGo + PostgreSQL + Redis + OpenSearch
|
docker-compose.search.yml 第三档:MediaStationGo + PostgreSQL + Redis + OpenSearch
|
||||||
@@ -179,6 +206,7 @@ docker compose up -d
|
|||||||
如果保留原始文件名,也可以这样启动:
|
如果保留原始文件名,也可以这样启动:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
docker compose -f docker-compose.simple.yml up -d
|
||||||
docker compose -f docker-compose.standard.yml up -d
|
docker compose -f docker-compose.standard.yml up -d
|
||||||
docker compose -f docker-compose.search.yml up -d
|
docker compose -f docker-compose.search.yml up -d
|
||||||
```
|
```
|
||||||
|
|||||||
+3
-2
@@ -120,15 +120,16 @@ The repository `docker-compose.yml` is the lightweight recommended template: no
|
|||||||
|
|
||||||
If you already have an older `./data/mediastation.db`, the first start with the new compose file automatically imports it into PostgreSQL. Keep `./data`; it still stores the JWT secret, runtime data, and the old SQLite migration source.
|
If you already have an older `./data/mediastation.db`, the first start with the new compose file automatically imports it into PostgreSQL. Keep `./data`; it still stores the JWT secret, runtime data, and the old SQLite migration source.
|
||||||
|
|
||||||
### Three deployment modes
|
### Deployment modes
|
||||||
|
|
||||||
| Mode | Command | Best for |
|
| Mode | Command | Best for |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| Single image: SQLite | `docker compose -f docker-compose.simple.yml up -d` | Beginners and single-user setups that want one image only, no PostgreSQL/Redis |
|
||||||
| Lightweight: PG only | `docker compose up -d` | Most NAS devices, lowest resource use |
|
| Lightweight: PG only | `docker compose up -d` | Most NAS devices, lowest resource use |
|
||||||
| Standard: PG + Redis | `docker compose -f docker-compose.yml -f docker-compose.standard.yml up -d` | Multi-user use and frequent Emby client refreshes |
|
| Standard: PG + Redis | `docker compose -f docker-compose.yml -f docker-compose.standard.yml up -d` | Multi-user use and frequent Emby client refreshes |
|
||||||
| Search enhanced: PG + Redis + OpenSearch | `docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml up -d` | Huge libraries and future standalone search indexing |
|
| Search enhanced: PG + Redis + OpenSearch | `docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml up -d` | Huge libraries and future standalone search indexing |
|
||||||
|
|
||||||
Start with the lightweight mode. Redis and OpenSearch are enhancement layers, not source databases. Do not enable OpenSearch by default on low-memory NAS devices.
|
The single-image `docker-compose.simple.yml` runs only MediaStationGo with a built-in SQLite database — the simplest starting point. Do not set `MEDIASTATION_DATABASE_DSN` there, or it switches back to PostgreSQL. Move up to the PostgreSQL modes for multi-user or high-concurrency use (keep `./data` when you switch). Redis and OpenSearch are enhancement layers, not source databases. Do not enable OpenSearch by default on low-memory NAS devices.
|
||||||
|
|
||||||
### Database Choice And Disabling SQLite
|
### Database Choice And Disabling SQLite
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# MediaStationGo 单镜像 Docker Compose 部署文件(最省心)
|
||||||
|
#
|
||||||
|
# 组件:
|
||||||
|
# 只有 MediaStationGo 一个镜像 + 内置 SQLite,不需要 PostgreSQL / Redis。
|
||||||
|
#
|
||||||
|
# 适合:
|
||||||
|
# 新手、单人使用、低配 NAS / 软路由 / 小主机,想一个镜像跑起来就行。
|
||||||
|
# 多用户或 Emby 客户端频繁刷新时,再升级到 docker-compose.yml(PostgreSQL)。
|
||||||
|
#
|
||||||
|
# 使用方式二选一:
|
||||||
|
# 1. 保存为 docker-compose.yml 后执行:
|
||||||
|
# docker compose up -d
|
||||||
|
# 2. 保留本文件名时执行:
|
||||||
|
# docker compose -f docker-compose.simple.yml up -d
|
||||||
|
#
|
||||||
|
# 访问:
|
||||||
|
# http://服务器IP:18080
|
||||||
|
#
|
||||||
|
# 默认账号:
|
||||||
|
# admin / admin123
|
||||||
|
|
||||||
|
services:
|
||||||
|
mediastation-go:
|
||||||
|
# 镜像二选一:
|
||||||
|
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
|
||||||
|
image: ghcr.io/shukebta/mediastation-go:latest
|
||||||
|
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
|
||||||
|
# image: shukbet/mediastationgo:latest
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
|
||||||
|
# 浏览器访问端口。
|
||||||
|
# 如果 18080 被占用,可以改成 "19011:8080" 之类。
|
||||||
|
ports:
|
||||||
|
- "18080:8080"
|
||||||
|
|
||||||
|
# 让容器可以访问宿主机上的 qBittorrent。
|
||||||
|
# qB 地址通常可填:http://host.docker.internal:8085
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# 程序运行数据:JWT 密钥、运行配置、SQLite 主数据库、旧 SQLite 迁移源。
|
||||||
|
# 单镜像模式下主数据库就在 ./data,升级/备份时务必保留整个 ./data。
|
||||||
|
- ./data:/data
|
||||||
|
|
||||||
|
# 缓存目录:海报缓存、临时文件等。通常不用备份。
|
||||||
|
- ./cache:/cache
|
||||||
|
|
||||||
|
# 媒体库目录。
|
||||||
|
# 如果要使用自动整理/重命名/入库,这里必须保持读写,不能加 :ro。
|
||||||
|
# 新手可先把影片放到当前目录的 ./media。
|
||||||
|
# NAS 用户把左边改成真实路径,例如:
|
||||||
|
# - /vol1/1000/Media:/media
|
||||||
|
# Windows Docker Desktop 示例:
|
||||||
|
# - D:/Media:/media
|
||||||
|
- ./media:/media
|
||||||
|
|
||||||
|
# 下载目录。
|
||||||
|
# qB 下载目录、手动整理、自动整理会经常用到。
|
||||||
|
# NAS 示例:
|
||||||
|
# - /vol1/1000/Downloads:/downloads
|
||||||
|
# Windows Docker Desktop 示例:
|
||||||
|
# - D:/Downloads:/downloads
|
||||||
|
- ./downloads:/downloads
|
||||||
|
|
||||||
|
# 管理面板「系统更新」需要访问 Docker 引擎。
|
||||||
|
# 需要一键更新 Docker 镜像时取消下一行注释;如果提示权限不足,
|
||||||
|
# 请确认 PUID/PGID 对 /var/run/docker.sock 有读写权限。
|
||||||
|
# - /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
|
||||||
|
# Linux/NAS 用户权限。一般 1000 就可以。
|
||||||
|
# 如果写入文件权限不对,再改成宿主机实际用户的 uid/gid。
|
||||||
|
PUID: "1000"
|
||||||
|
PGID: "1000"
|
||||||
|
|
||||||
|
# 程序基础配置,通常不用改。
|
||||||
|
MEDIASTATION_APP_HOST: 0.0.0.0
|
||||||
|
MEDIASTATION_APP_PORT: 8080
|
||||||
|
MEDIASTATION_APP_WEB_DIR: /app/web/dist
|
||||||
|
MEDIASTATION_APP_DATA_DIR: /data
|
||||||
|
# 需要排查订阅/站点搜索/下载器问题时,临时把 level 改成 info 或 debug。
|
||||||
|
# 详细应用日志会保存在 ./data/logs/app.log,warn/error 也会拆分保存。
|
||||||
|
MEDIASTATION_LOGGING_LEVEL: warn
|
||||||
|
MEDIASTATION_LOGGING_FORMAT: console
|
||||||
|
MEDIASTATION_LOGGING_OUTPUT_PATH: /data/logs
|
||||||
|
|
||||||
|
# 单镜像模式:使用内置 SQLite,不连接 PostgreSQL。
|
||||||
|
# 不要在这里配置 MEDIASTATION_DATABASE_DSN;一旦填了 DSN 就会切回 PostgreSQL。
|
||||||
|
# 主数据库文件是 ./data/mediastation.db,升级/备份务必保留 ./data。
|
||||||
|
MEDIASTATION_DATABASE_TYPE: sqlite
|
||||||
|
MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db
|
||||||
|
|
||||||
|
# 单镜像模式不启用 Redis;缓存直接落在下面这个本地目录,丢失可自动重建。
|
||||||
|
MEDIASTATION_CACHE_CACHE_DIR: /cache
|
||||||
|
|
||||||
|
# 管理面板热更新默认拉取此镜像。
|
||||||
|
MEDIASTATION_UPDATE_IMAGE: ghcr.io/shukebta/mediastation-go:latest
|
||||||
|
|
||||||
|
# 路径换算配置。
|
||||||
|
# 如果上面 volumes 的 ./media 改成 /vol1/1000/Media,
|
||||||
|
# 这里也要改成同一个宿主机真实路径。
|
||||||
|
MEDIASTATION_MEDIA_DIR: ./media
|
||||||
|
MEDIASTATION_MEDIA_CONTAINER_DIR: /media
|
||||||
|
|
||||||
|
# 如果上面 volumes 的 ./downloads 改成 /vol1/1000/Downloads,
|
||||||
|
# 这里也要改成同一个宿主机真实路径。
|
||||||
|
MEDIASTATION_DOWNLOAD_DIR: ./downloads
|
||||||
|
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||||
|
|
||||||
|
# NAS 友好的低负载默认值。
|
||||||
|
MEDIASTATION_TRANSCODER_ENABLED: "true"
|
||||||
|
MEDIASTATION_TRANSCODER_HARDWARE_ACCEL: "false"
|
||||||
|
MEDIASTATION_TRANSCODER_REALTIME: "true"
|
||||||
|
MEDIASTATION_TRANSCODER_THREADS: "2"
|
||||||
|
MEDIASTATION_TRANSCODER_MAX_CONCURRENT: "1"
|
||||||
|
MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
# 限制 Docker 日志大小,避免长期运行把磁盘写满。
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
@@ -142,6 +142,9 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []model.Media{}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"items": items,
|
"items": items,
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -155,6 +158,9 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []service.MediaItem{}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"items": items,
|
"items": items,
|
||||||
"total": total,
|
"total": total,
|
||||||
|
|||||||
@@ -423,3 +423,58 @@ func requestLibrarySeriesEpisodes(t *testing.T, svc *service.Container, path, li
|
|||||||
}
|
}
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 空库的 media / series 列表必须返回 "items":[](而不是 Go nil slice 序列化出的 null)。
|
||||||
|
// 前端 [].concat(null) 会得到 [null],随后在渲染期解引用 null 崩溃,导致「空库点进去
|
||||||
|
// 报错且无法返回」。这条测试钉死该 JSON 契约,防止再退化。
|
||||||
|
func TestEmptyLibraryListsReturnEmptyArraysNotNull(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
movie := model.Library{Name: "空电影库", Path: "/media/empty-movie", Type: "movie", Enabled: true}
|
||||||
|
tv := model.Library{Name: "空剧集库", Path: "/media/empty-tv", Type: "tv", Enabled: true}
|
||||||
|
for _, lib := range []*model.Library{&movie, &tv} {
|
||||||
|
if err := repos.Library.Create(t.Context(), lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svc := &service.Container{
|
||||||
|
Repo: repos,
|
||||||
|
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
lib string
|
||||||
|
handler gin.HandlerFunc
|
||||||
|
}{
|
||||||
|
{"media", "/api/libraries/" + movie.ID + "/media", movie.ID, listMediaHandler(svc)},
|
||||||
|
{"series", "/api/libraries/" + tv.ID + "/series", tv.ID, listLibrarySeriesHandler(svc)},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Set(middleware.CtxUserID, "user-1")
|
||||||
|
c.Set(middleware.CtxUserRole, "user")
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: tc.lib}}
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||||
|
tc.handler(c)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s: status = %d body=%s", tc.name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if strings.Contains(body, `"items":null`) {
|
||||||
|
t.Fatalf("%s: empty library returned items:null (crashes frontend): %s", tc.name, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `"items":[]`) {
|
||||||
|
t.Fatalf("%s: expected items:[] for empty library, got %s", tc.name, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,8 +92,13 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
if end > len(items) {
|
if end > len(items) {
|
||||||
end = len(items)
|
end = len(items)
|
||||||
}
|
}
|
||||||
|
pageItems := items[start:end]
|
||||||
|
if pageItems == nil {
|
||||||
|
// 非 nil 空切片,避免空库返回 "items": null 触发前端崩溃。
|
||||||
|
pageItems = []service.SeriesCard{}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"items": items[start:end],
|
"items": pageItems,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": size,
|
"page_size": size,
|
||||||
|
|||||||
@@ -75,29 +75,49 @@ func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Contai
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r mediaDisplayLibraryResolver) DisplayLibraryForMedia(media model.Media) (model.Library, bool) {
|
func (r mediaDisplayLibraryResolver) DisplayLibraryForMedia(media model.Media) (model.Library, bool) {
|
||||||
|
// Issue #61: an auto-category assignment is authoritative. The media was explicitly
|
||||||
|
// categorized into this library even though its physical (cloud) path may still live
|
||||||
|
// under the source scan directory (e.g. cloud://cloud115/云下载/...). Resolving by path
|
||||||
|
// here would wrongly redirect the media back to the source cloud library, so resolve it
|
||||||
|
// from the owning library instead.
|
||||||
|
if own, ok := r.byID[media.LibraryID]; ok && CloudLibraryAutoCategory(own) {
|
||||||
|
return r.autoCategoryDisplayLibrary(own), true
|
||||||
|
}
|
||||||
if lib, ok := r.bestPathDisplayLibrary(media); ok {
|
if lib, ok := r.bestPathDisplayLibrary(media); ok {
|
||||||
return lib, true
|
return lib, true
|
||||||
}
|
}
|
||||||
if lib, ok := r.displayByID[media.LibraryID]; ok {
|
if lib, ok := r.displayByID[media.LibraryID]; ok {
|
||||||
return lib, true
|
return lib, true
|
||||||
}
|
}
|
||||||
own, hasOwn := r.byID[media.LibraryID]
|
if own, hasOwn := r.byID[media.LibraryID]; hasOwn {
|
||||||
if hasOwn {
|
|
||||||
if key, ok := CloudLibraryMergeKey(own); ok {
|
if key, ok := CloudLibraryMergeKey(own); ok {
|
||||||
if lib, exists := r.displayByMergeKey[key]; exists {
|
if lib, exists := r.displayByMergeKey[key]; exists {
|
||||||
return lib, true
|
return lib, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if CloudLibraryAutoCategory(own) {
|
|
||||||
if lib, ok := r.rootCloudDisplayLibraryForAutoCategory(own); ok {
|
|
||||||
return lib, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return own, true
|
return own, true
|
||||||
}
|
}
|
||||||
return model.Library{}, false
|
return model.Library{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autoCategoryDisplayLibrary resolves the visible library that should represent an
|
||||||
|
// auto-category library: the library itself when it is displayed standalone, otherwise
|
||||||
|
// the sibling it was merged into, otherwise the root cloud library it was split from.
|
||||||
|
func (r mediaDisplayLibraryResolver) autoCategoryDisplayLibrary(own model.Library) model.Library {
|
||||||
|
if lib, ok := r.displayByID[own.ID]; ok {
|
||||||
|
return lib
|
||||||
|
}
|
||||||
|
if key, ok := CloudLibraryMergeKey(own); ok {
|
||||||
|
if lib, exists := r.displayByMergeKey[key]; exists {
|
||||||
|
return lib
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lib, ok := r.rootCloudDisplayLibraryForAutoCategory(own); ok {
|
||||||
|
return lib
|
||||||
|
}
|
||||||
|
return own
|
||||||
|
}
|
||||||
|
|
||||||
func (r mediaDisplayLibraryResolver) rootCloudDisplayLibraryForAutoCategory(auto model.Library) (model.Library, bool) {
|
func (r mediaDisplayLibraryResolver) rootCloudDisplayLibraryForAutoCategory(auto model.Library) (model.Library, bool) {
|
||||||
info, ok := ParseCloudLibraryMount(auto.Path)
|
info, ok := ParseCloudLibraryMount(auto.Path)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAttachLibraryMetadataAutoCategoryKeepsOwnDisplayLibrary reproduces issue #61.
|
||||||
|
//
|
||||||
|
// A movie scanned from a source cloud library ("115 云下载") is auto-categorized into a
|
||||||
|
// separate auto-category library ("成人"): its library_id points to the auto-category
|
||||||
|
// library, but its physical cloud path still lives under the source scan directory
|
||||||
|
// (cloud://cloud115/云下载/...). Display resolution must attribute the media to the
|
||||||
|
// library it is actually browsed under (the auto-category "成人" library), not path-match
|
||||||
|
// it back to the source cloud library — otherwise the detail page "返回媒体库" button
|
||||||
|
// jumps to the wrong library.
|
||||||
|
func TestAttachLibraryMetadataAutoCategoryKeepsOwnDisplayLibrary(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
|
||||||
|
source := model.Library{Name: "115 云下载", Path: "cloud://cloud115/云下载", Type: "movie", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &source); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
autoPath := BuildCloudAutoCategoryLibraryPathWithScanDir("cloud115", "成人/成人", "成人")
|
||||||
|
adult := model.Library{Name: "成人", Path: autoPath, Type: "movie", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &adult); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !CloudLibraryAutoCategory(adult) {
|
||||||
|
t.Fatalf("adult library should be auto-category, got path %q", adult.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaPath := "cloud://cloud115/云下载/Some.Movie.2024/Some.Movie.2024.mp4"
|
||||||
|
if err := repos.Media.Upsert(t.Context(), &model.Media{
|
||||||
|
LibraryID: adult.ID,
|
||||||
|
Title: "Some Movie",
|
||||||
|
Path: mediaPath,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||||
|
items := []model.Media{{LibraryID: adult.ID, Title: "Some Movie", Path: mediaPath}}
|
||||||
|
svc.attachLibraryMetadata(t.Context(), items)
|
||||||
|
|
||||||
|
got := items[0]
|
||||||
|
if got.DisplayLibraryID != adult.ID {
|
||||||
|
t.Fatalf("display_library_id = %s (%s), want auto-category library %s (成人)",
|
||||||
|
got.DisplayLibraryID, got.DisplayLibraryName, adult.ID)
|
||||||
|
}
|
||||||
|
if got.DisplayLibraryName != "成人" {
|
||||||
|
t.Fatalf("display_library_name = %q, want 成人", got.DisplayLibraryName)
|
||||||
|
}
|
||||||
|
if got.LibraryName != "成人" {
|
||||||
|
t.Fatalf("library_name = %q, want 成人 (not the source cloud library)", got.LibraryName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,14 @@ func resolveAccessibleLibraryPath(path string) (string, error) {
|
|||||||
return filepath.Clean(candidate), nil
|
return filepath.Clean(candidate), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 兜底:旧库把宿主机绝对路径整段写进来时 /media 出现在路径中段(moviepilot 布局),
|
||||||
|
// 常规候选覆盖不到。只在这里按 isAccessibleDir 校验后采用,避免污染不校验存在性的
|
||||||
|
// 目的地解析(resolveMappedDestinationPath)。见 embeddedContainerMarkerCandidates。
|
||||||
|
for _, candidate := range embeddedContainerMarkerCandidates(input) {
|
||||||
|
if isAccessibleDir(candidate) {
|
||||||
|
return filepath.Clean(candidate), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
abs, err := filepath.Abs(input)
|
abs, err := filepath.Abs(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("invalid path: %w", err)
|
return "", fmt.Errorf("invalid path: %w", err)
|
||||||
@@ -46,6 +54,52 @@ func resolveAccessibleLibraryPath(path string) (string, error) {
|
|||||||
return "", fmt.Errorf("path is not an accessible directory: %s", abs)
|
return "", fmt.Errorf("path is not an accessible directory: %s", abs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// embeddedContainerMarkerCandidates 处理旧库把宿主机绝对路径整段写进来的情况:
|
||||||
|
// /media 或 /downloads 出现在路径中段(如 moviepilot 的 /vol1/.../media/电视剧/国产剧),
|
||||||
|
// 取最后一个该段之后的尾巴拼到容器目录(默认 /media、/downloads,可用 *_CONTAINER_DIR 覆盖)。
|
||||||
|
//
|
||||||
|
// 这个启发式偏激进,只能在**按存在性校验**的读/扫描解析里使用;绝不能并入
|
||||||
|
// mappedPathCandidates——resolveMappedDestinationPath 不校验存在性、会返回首个候选,
|
||||||
|
// 那样会把形如 <tmp>/media/... 的合法目的地错误重写到容器根,破坏整理/硬链接。
|
||||||
|
func embeddedContainerMarkerCandidates(input string) []string {
|
||||||
|
normalized := cleanPathForVolumeMapping(input)
|
||||||
|
markerPath := pathAfterWindowsDrivePrefix(normalized)
|
||||||
|
var candidates []string
|
||||||
|
for _, marker := range []struct {
|
||||||
|
part string
|
||||||
|
container string
|
||||||
|
}{
|
||||||
|
{part: "/media", container: envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")},
|
||||||
|
{part: "/downloads", container: envOrDefault("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", "/downloads")},
|
||||||
|
} {
|
||||||
|
part := strings.TrimRight(marker.part, "/")
|
||||||
|
container := strings.TrimRight(filepath.ToSlash(marker.container), "/")
|
||||||
|
if idx := strings.LastIndex(markerPath, part+"/"); idx > 0 {
|
||||||
|
candidates = append(candidates, filepath.Clean(filepath.FromSlash(container+markerPath[idx+len(part):])))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeUnresolvedLibraryPath 把「路径解析失败」变成可操作的诊断信息:列出尝试过的
|
||||||
|
// 候选路径与当前的宿主机→容器映射状态。旧库存的是宿主机路径,在新版/容器内常因为缺少
|
||||||
|
// MEDIASTATION_MEDIA_DIR 映射而扫不出媒体——这条诊断帮助用户直接定位到底哪一步断了。
|
||||||
|
// 仅用于日志与扫描错误提示,不改变解析逻辑本身。
|
||||||
|
func describeUnresolvedLibraryPath(rawPath string) string {
|
||||||
|
rawPath = strings.TrimSpace(rawPath)
|
||||||
|
candidates := mappedPathCandidates(rawPath)
|
||||||
|
mediaHost := strings.TrimSpace(os.Getenv("MEDIASTATION_MEDIA_DIR"))
|
||||||
|
mediaContainer := envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "媒体库路径无法解析为可访问目录:%s(已尝试候选:%s)", rawPath, strings.Join(candidates, " | "))
|
||||||
|
if mediaHost == "" {
|
||||||
|
b.WriteString(";未配置 MEDIASTATION_MEDIA_DIR / MEDIASTATION_MEDIA_CONTAINER_DIR。若为 Docker 部署且此库为旧宿主机路径,请设置这两个变量把宿主机路径映射到容器内路径,并确认对应 volume 已挂载。")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, ";当前映射 %s → %s。请确认该库路径位于此宿主机目录下,或补充对应的 volume 与路径映射。", mediaHost, mediaContainer)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
func resolveAccessibleMappedPath(path string) (string, os.FileInfo, error) {
|
func resolveAccessibleMappedPath(path string) (string, os.FileInfo, error) {
|
||||||
input := strings.TrimSpace(path)
|
input := strings.TrimSpace(path)
|
||||||
if input == "" {
|
if input == "" {
|
||||||
@@ -57,6 +111,13 @@ func resolveAccessibleMappedPath(path string) (string, os.FileInfo, error) {
|
|||||||
return filepath.Clean(candidate), info, nil
|
return filepath.Clean(candidate), info, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 兜底同 resolveAccessibleLibraryPath:中段 /media|/downloads 的旧宿主机路径,
|
||||||
|
// 仅在 os.Stat 校验存在后采用。
|
||||||
|
for _, candidate := range embeddedContainerMarkerCandidates(input) {
|
||||||
|
if info, err := os.Stat(candidate); err == nil {
|
||||||
|
return filepath.Clean(candidate), info, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
abs, err := filepath.Abs(input)
|
abs, err := filepath.Abs(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, fmt.Errorf("invalid path: %w", err)
|
return "", nil, fmt.Errorf("invalid path: %w", err)
|
||||||
|
|||||||
@@ -106,6 +106,56 @@ func TestResolveAccessibleLibraryPathMapsRelativeDockerMediaMarker(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 旧库把宿主机绝对路径整段写进来时(moviepilot 布局:/vol1/.../media/电视剧/国产剧),
|
||||||
|
// /media 出现在路径中段而非开头;解析必须能把最后一个 /media 段之后的尾巴映射到容器媒体
|
||||||
|
// 目录,否则新版容器只挂了 /media 就永远扫不出这类旧库。钉死该行为,防止再退化。
|
||||||
|
func TestResolveAccessibleLibraryPathMapsEmbeddedHostMediaMarker(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
containerRoot := filepath.Join(root, "container", "media")
|
||||||
|
containerLibrary := filepath.Join(containerRoot, "电视剧", "国产剧")
|
||||||
|
if err := os.MkdirAll(containerLibrary, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("MEDIASTATION_MEDIA_CONTAINER_DIR", containerRoot)
|
||||||
|
|
||||||
|
got, err := resolveAccessibleLibraryPath("/vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveAccessibleLibraryPath() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != filepath.Clean(containerLibrary) {
|
||||||
|
t.Fatalf("resolveAccessibleLibraryPath() = %q, want %q", got, filepath.Clean(containerLibrary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAccessibleMappedPathMapsEmbeddedHostDownloadMarker(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
containerDownloads := filepath.Join(root, "container", "downloads")
|
||||||
|
containerItem := filepath.Join(containerDownloads, "国产剧")
|
||||||
|
if err := os.MkdirAll(containerItem, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("MEDIASTATION_DOWNLOAD_CONTAINER_DIR", containerDownloads)
|
||||||
|
|
||||||
|
got, _, err := resolveAccessibleMappedPath("/vol1/1000/Docker/qbittorrent/downloads/国产剧")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveAccessibleMappedPath() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != filepath.Clean(containerItem) {
|
||||||
|
t.Fatalf("resolveAccessibleMappedPath() = %q, want %q", got, filepath.Clean(containerItem))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中段 marker 启发式绝不能污染目的地解析:resolveMappedDestinationPath 不校验存在性、
|
||||||
|
// 会返回首个候选,一旦启发式并进来,就会把形如 <tmp>/media/... 的合法整理目的地错误
|
||||||
|
// 重写到容器根 /media(曾导致 organizer 跨盘 hardlink 失败)。钉死该边界。
|
||||||
|
func TestResolveMappedDestinationPathIgnoresEmbeddedMediaMarker(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dst := filepath.Join(root, "001", "media", "电视剧", "国产剧", "Some Show")
|
||||||
|
if got := resolveMappedDestinationPath(dst); got != filepath.Clean(dst) {
|
||||||
|
t.Fatalf("resolveMappedDestinationPath() = %q, want %q (embedded /media must not remap destinations)", got, filepath.Clean(dst))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInferLibraryKindFromCategoryPathOverridesMovieDefault(t *testing.T) {
|
func TestInferLibraryKindFromCategoryPathOverridesMovieDefault(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ func normalizeGroupedMediaPage(page, pageSize int) (int, int) {
|
|||||||
func paginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem {
|
func paginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem {
|
||||||
page, pageSize = normalizeGroupedMediaPage(page, pageSize)
|
page, pageSize = normalizeGroupedMediaPage(page, pageSize)
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
// 返回非 nil 空切片:nil 会被 JSON 序列化成 "items": null,
|
||||||
|
// 前端 concat(null) 会得到 [null] 并在渲染期崩溃(空库进入白屏)。
|
||||||
|
return []MediaItem{}
|
||||||
}
|
}
|
||||||
start := (page - 1) * pageSize
|
start := (page - 1) * pageSize
|
||||||
if start >= len(items) {
|
if start >= len(items) {
|
||||||
|
|||||||
@@ -94,11 +94,18 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
|
|||||||
for i := range roots {
|
for i := range roots {
|
||||||
root := roots[i]
|
root := roots[i]
|
||||||
if err := s.resolveLocalLibraryRootPath(ctx, lib, &root); err != nil {
|
if err := s.resolveLocalLibraryRootPath(ctx, lib, &root); err != nil {
|
||||||
addScanError(res, root.Path, err)
|
// 优雅降级:路径不可达时只跳过该 root,并保留该库已入库的旧媒体(下方 prune 不会执行),
|
||||||
s.log.Warn("library root scan skipped",
|
// 同时输出可操作诊断(候选路径 + 宿主机/容器映射状态),方便定位旧库在容器内扫不出媒体的原因。
|
||||||
|
diag := describeUnresolvedLibraryPath(root.Path)
|
||||||
|
addScanError(res, "", errors.New(diag))
|
||||||
|
s.log.Warn("library root scan skipped; existing media preserved",
|
||||||
zap.String("library_id", lib.ID),
|
zap.String("library_id", lib.ID),
|
||||||
zap.String("root_id", root.ID),
|
zap.String("root_id", root.ID),
|
||||||
zap.String("path", root.Path),
|
zap.String("path", root.Path),
|
||||||
|
zap.Strings("candidates", mappedPathCandidates(root.Path)),
|
||||||
|
zap.String("media_dir_env", os.Getenv("MEDIASTATION_MEDIA_DIR")),
|
||||||
|
zap.String("media_container_env", envOrDefault("MEDIASTATION_MEDIA_CONTAINER_DIR", "/media")),
|
||||||
|
zap.String("diagnostic", diag),
|
||||||
zap.Error(err))
|
zap.Error(err))
|
||||||
if scanErr == nil {
|
if scanErr == nil {
|
||||||
scanErr = err
|
scanErr = err
|
||||||
|
|||||||
+15
-7
@@ -28,13 +28,21 @@ class AppErrorBoundary extends Component<{ children: ReactNode }, { hasError: bo
|
|||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
当前页面遇到异常,刷新后会重新加载资源和登录状态。
|
当前页面遇到异常,刷新后会重新加载资源和登录状态。
|
||||||
</p>
|
</p>
|
||||||
<button
|
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||||
type="button"
|
<a
|
||||||
onClick={() => window.location.reload()}
|
href="/"
|
||||||
className="mt-5 rounded-xl bg-gray-950 px-4 py-2 text-sm font-bold text-white hover:bg-gray-800"
|
className="rounded-xl bg-gray-950 px-4 py-2 text-sm font-bold text-white hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
刷新页面
|
返回首页
|
||||||
</button>
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="rounded-xl border border-gray-300 px-4 py-2 text-sm font-bold text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
刷新页面
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import clsx from 'clsx'
|
|||||||
|
|
||||||
import { AppFooter } from './AppFooter'
|
import { AppFooter } from './AppFooter'
|
||||||
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
||||||
|
import { RouteErrorBoundary } from './RouteErrorBoundary'
|
||||||
import type { useLayoutSidebar } from './useLayoutSidebar'
|
import type { useLayoutSidebar } from './useLayoutSidebar'
|
||||||
|
|
||||||
type LayoutSidebarState = ReturnType<typeof useLayoutSidebar>
|
type LayoutSidebarState = ReturnType<typeof useLayoutSidebar>
|
||||||
@@ -120,7 +121,9 @@ export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
|
|||||||
exit={{ opacity: 0, y: -6 }}
|
exit={{ opacity: 0, y: -6 }}
|
||||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<RouteErrorBoundary>
|
||||||
|
<Outlet />
|
||||||
|
</RouteErrorBoundary>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由级错误边界:只包住当前路由内容(Outlet),崩溃时侧边栏 / 顶栏仍然保留,
|
||||||
|
* 用户可以点「返回首页」或直接用侧边栏切换到别的页面(切换 pathname 会让父层
|
||||||
|
* 的 keyed motion.div 重新挂载本边界,从而自动恢复),避免整页只剩一个刷新按钮
|
||||||
|
* 导致「无法返回」。
|
||||||
|
*/
|
||||||
|
function RouteErrorFallback() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||||
|
<p className="text-lg font-bold text-ink-600">页面加载失败</p>
|
||||||
|
<p className="mt-2 max-w-md text-sm text-sand-500">
|
||||||
|
当前页面遇到异常。你可以返回首页继续使用,或刷新后重试;其他页面不受影响。
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="rounded-xl bg-gray-950 px-4 py-2 text-sm font-bold text-white hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="rounded-xl border border-gray-300 px-4 py-2 text-sm font-bold text-ink-600 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
刷新页面
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RouteErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
|
||||||
|
state = { hasError: false }
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return { hasError: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
console.error('MediaStationGo route crashed', error, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) return <RouteErrorFallback />
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,9 +154,11 @@ async function loadAllSeriesCards(
|
|||||||
let collected: SeriesCard[] = []
|
let collected: SeriesCard[] = []
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const data = await libraryAPI.listSeries(libraryID, page, pageSize)
|
const data = await libraryAPI.listSeries(libraryID, page, pageSize)
|
||||||
collected = collected.concat(data.items)
|
// 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。
|
||||||
onPage({ items: collected, total: data.total, firstPage: page === 1 })
|
const pageItems = data.items ?? []
|
||||||
if (collected.length >= data.total || data.items.length < pageSize) break
|
collected = collected.concat(pageItems)
|
||||||
|
onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 })
|
||||||
|
if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break
|
||||||
page += 1
|
page += 1
|
||||||
}
|
}
|
||||||
return { items: collected }
|
return { items: collected }
|
||||||
@@ -171,9 +173,11 @@ async function loadAllMedia(
|
|||||||
let collected: Media[] = []
|
let collected: Media[] = []
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const data = await libraryAPI.listMedia(libraryID, page, pageSize)
|
const data = await libraryAPI.listMedia(libraryID, page, pageSize)
|
||||||
collected = collected.concat(data.items)
|
// 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。
|
||||||
onPage({ items: collected, total: data.total, firstPage: page === 1 })
|
const pageItems = data.items ?? []
|
||||||
if (collected.length >= data.total || data.items.length < pageSize) break
|
collected = collected.concat(pageItems)
|
||||||
|
onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 })
|
||||||
|
if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break
|
||||||
page += 1
|
page += 1
|
||||||
}
|
}
|
||||||
return { items: collected }
|
return { items: collected }
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ function compactSeriesKey(raw: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isEpisodeLike(media: Media): boolean {
|
export function isEpisodeLike(media: Media): boolean {
|
||||||
|
if (!media) return false
|
||||||
return (media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0
|
return (media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user