fix legacy library scan and empty pages

This commit is contained in:
ShukeBta
2026-07-01 22:51:33 +08:00
parent e18f73ed3c
commit 0493823b99
17 changed files with 536 additions and 33 deletions
+34 -6
View File
@@ -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
View File
@@ -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
+135
View File
@@ -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"
+6
View File
@@ -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,
+55
View File
@@ -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)
}
}
}
+6 -1
View File
@@ -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,
+27 -7
View File
@@ -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)
}
}
+61
View File
@@ -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)
+50
View File
@@ -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
+3 -1
View File
@@ -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) {
+9 -2
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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>
+53
View File
@@ -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
}
}
+10 -6
View File
@@ -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 }
+1
View File
@@ -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
} }