From 0493823b999e1645cf82427e0f208f4cea8fc53f Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:51:33 +0800 Subject: [PATCH] fix legacy library scan and empty pages --- README.md | 40 +++++- README_EN.md | 5 +- docker-compose.simple.yml | 135 ++++++++++++++++++ internal/handler/media.go | 6 + internal/handler/media_test.go | 55 +++++++ internal/handler/series.go | 7 +- internal/service/media_display_library.go | 34 ++++- .../service/media_display_library_test.go | 64 +++++++++ internal/service/media_paths.go | 61 ++++++++ internal/service/media_test.go | 50 +++++++ internal/service/media_versions.go | 4 +- internal/service/scanner_scan.go | 11 +- web/src/App.tsx | 22 ++- web/src/components/LayoutSections.tsx | 5 +- web/src/components/RouteErrorBoundary.tsx | 53 +++++++ web/src/pages/useLibraryData.ts | 16 ++- web/src/utils/groupSeries.ts | 1 + 17 files changed, 536 insertions(+), 33 deletions(-) create mode 100644 docker-compose.simple.yml create mode 100644 internal/service/media_display_library_test.go create mode 100644 web/src/components/RouteErrorBoundary.tsx diff --git a/README.md b/README.md index 4e2455c..0b90b2c 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ MediaStationGo 是一个自托管媒体管理系统,面向 NAS、小主机、 - **本地 + 网盘**:支持本地硬盘、下载目录、OpenList、CloudDrive2、WebDAV、STRMURL 和 302 反代播放。 - **订阅下载入库**:连接 qBittorrent 后支持搜索、订阅、下载完成整理、刮削和入库通知。 - **多用户与权限**:管理员/普通用户、有效期、成人内容开关、设备管理、注册码和 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 mkdir -p 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 ``` @@ -105,16 +108,39 @@ GHCR:ghcr.io/shukebta/mediastation-go: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.standard.yml` | MediaStationGo + PostgreSQL + Redis | 多用户、Emby 客户端频繁刷新、首页/媒体列表访问较多 | | 第三档 | `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,资源占用最低,适合绝大多数 NAS。 @@ -162,9 +188,10 @@ OpenSearch 数据目录是 `./opensearch`。搜索索引可重建,但重建大 ## 配置示例 -仓库内提供三份推荐 Compose 文件: +仓库内提供四份推荐 Compose 文件: ```text +docker-compose.simple.yml 单镜像档:MediaStationGo + 内置 SQLite docker-compose.yml 第一档:MediaStationGo + PostgreSQL docker-compose.standard.yml 第二档:MediaStationGo + PostgreSQL + Redis docker-compose.search.yml 第三档:MediaStationGo + PostgreSQL + Redis + OpenSearch @@ -179,6 +206,7 @@ docker compose up -d 如果保留原始文件名,也可以这样启动: ```bash +docker compose -f docker-compose.simple.yml up -d docker compose -f docker-compose.standard.yml up -d docker compose -f docker-compose.search.yml up -d ``` diff --git a/README_EN.md b/README_EN.md index 45e95c2..d131833 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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. -### Three deployment modes +### Deployment modes | 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 | | 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 | -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 diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml new file mode 100644 index 0000000..018618c --- /dev/null +++ b/docker-compose.simple.yml @@ -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" diff --git a/internal/handler/media.go b/internal/handler/media.go index d9e8cfd..629f665 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -142,6 +142,9 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc { writeInternalOrCanceled(c, err) return } + if items == nil { + items = []model.Media{} + } c.JSON(http.StatusOK, gin.H{ "items": items, "total": total, @@ -155,6 +158,9 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc { writeInternalOrCanceled(c, err) return } + if items == nil { + items = []service.MediaItem{} + } c.JSON(http.StatusOK, gin.H{ "items": items, "total": total, diff --git a/internal/handler/media_test.go b/internal/handler/media_test.go index 6827e80..2b5de0d 100644 --- a/internal/handler/media_test.go +++ b/internal/handler/media_test.go @@ -423,3 +423,58 @@ func requestLibrarySeriesEpisodes(t *testing.T, svc *service.Container, path, li } 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) + } + } +} diff --git a/internal/handler/series.go b/internal/handler/series.go index b74c614..defc226 100644 --- a/internal/handler/series.go +++ b/internal/handler/series.go @@ -92,8 +92,13 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc { if 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{ - "items": items[start:end], + "items": pageItems, "total": total, "page": page, "page_size": size, diff --git a/internal/service/media_display_library.go b/internal/service/media_display_library.go index 1e09800..2af44cf 100644 --- a/internal/service/media_display_library.go +++ b/internal/service/media_display_library.go @@ -75,29 +75,49 @@ func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Contai } 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 { return lib, true } if lib, ok := r.displayByID[media.LibraryID]; ok { return lib, true } - own, hasOwn := r.byID[media.LibraryID] - if hasOwn { + if own, hasOwn := r.byID[media.LibraryID]; hasOwn { if key, ok := CloudLibraryMergeKey(own); ok { if lib, exists := r.displayByMergeKey[key]; exists { return lib, true } } - if CloudLibraryAutoCategory(own) { - if lib, ok := r.rootCloudDisplayLibraryForAutoCategory(own); ok { - return lib, true - } - } return own, true } 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) { info, ok := ParseCloudLibraryMount(auto.Path) if !ok { diff --git a/internal/service/media_display_library_test.go b/internal/service/media_display_library_test.go new file mode 100644 index 0000000..526e710 --- /dev/null +++ b/internal/service/media_display_library_test.go @@ -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) + } +} diff --git a/internal/service/media_paths.go b/internal/service/media_paths.go index de07078..d8644bc 100644 --- a/internal/service/media_paths.go +++ b/internal/service/media_paths.go @@ -39,6 +39,14 @@ func resolveAccessibleLibraryPath(path string) (string, error) { 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) if err != nil { 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) } +// embeddedContainerMarkerCandidates 处理旧库把宿主机绝对路径整段写进来的情况: +// /media 或 /downloads 出现在路径中段(如 moviepilot 的 /vol1/.../media/电视剧/国产剧), +// 取最后一个该段之后的尾巴拼到容器目录(默认 /media、/downloads,可用 *_CONTAINER_DIR 覆盖)。 +// +// 这个启发式偏激进,只能在**按存在性校验**的读/扫描解析里使用;绝不能并入 +// mappedPathCandidates——resolveMappedDestinationPath 不校验存在性、会返回首个候选, +// 那样会把形如 /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) { input := strings.TrimSpace(path) if input == "" { @@ -57,6 +111,13 @@ func resolveAccessibleMappedPath(path string) (string, os.FileInfo, error) { 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) if err != nil { return "", nil, fmt.Errorf("invalid path: %w", err) diff --git a/internal/service/media_test.go b/internal/service/media_test.go index 99887df..48a4349 100644 --- a/internal/service/media_test.go +++ b/internal/service/media_test.go @@ -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 不校验存在性、 +// 会返回首个候选,一旦启发式并进来,就会把形如 /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) { for _, tc := range []struct { name string diff --git a/internal/service/media_versions.go b/internal/service/media_versions.go index 4bab068..aee7ab3 100644 --- a/internal/service/media_versions.go +++ b/internal/service/media_versions.go @@ -29,7 +29,9 @@ func normalizeGroupedMediaPage(page, pageSize int) (int, int) { func paginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem { page, pageSize = normalizeGroupedMediaPage(page, pageSize) if len(items) == 0 { - return nil + // 返回非 nil 空切片:nil 会被 JSON 序列化成 "items": null, + // 前端 concat(null) 会得到 [null] 并在渲染期崩溃(空库进入白屏)。 + return []MediaItem{} } start := (page - 1) * pageSize if start >= len(items) { diff --git a/internal/service/scanner_scan.go b/internal/service/scanner_scan.go index bbfa99d..8b0b030 100644 --- a/internal/service/scanner_scan.go +++ b/internal/service/scanner_scan.go @@ -94,11 +94,18 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto for i := range roots { root := roots[i] if err := s.resolveLocalLibraryRootPath(ctx, lib, &root); err != nil { - addScanError(res, root.Path, err) - s.log.Warn("library root scan skipped", + // 优雅降级:路径不可达时只跳过该 root,并保留该库已入库的旧媒体(下方 prune 不会执行), + // 同时输出可操作诊断(候选路径 + 宿主机/容器映射状态),方便定位旧库在容器内扫不出媒体的原因。 + 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("root_id", root.ID), 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)) if scanErr == nil { scanErr = err diff --git a/web/src/App.tsx b/web/src/App.tsx index 707e3c1..448bc60 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -28,13 +28,21 @@ class AppErrorBoundary extends Component<{ children: ReactNode }, { hasError: bo

当前页面遇到异常,刷新后会重新加载资源和登录状态。

- +
+ + 返回首页 + + +
) diff --git a/web/src/components/LayoutSections.tsx b/web/src/components/LayoutSections.tsx index a092e28..d369e9f 100644 --- a/web/src/components/LayoutSections.tsx +++ b/web/src/components/LayoutSections.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx' import { AppFooter } from './AppFooter' import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent' +import { RouteErrorBoundary } from './RouteErrorBoundary' import type { useLayoutSidebar } from './useLayoutSidebar' type LayoutSidebarState = ReturnType @@ -120,7 +121,9 @@ export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) { exit={{ opacity: 0, y: -6 }} transition={{ duration: 0.25, ease: 'easeOut' }} > - + + + diff --git a/web/src/components/RouteErrorBoundary.tsx b/web/src/components/RouteErrorBoundary.tsx new file mode 100644 index 0000000..27b0f78 --- /dev/null +++ b/web/src/components/RouteErrorBoundary.tsx @@ -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 ( +
+

页面加载失败

+

+ 当前页面遇到异常。你可以返回首页继续使用,或刷新后重试;其他页面不受影响。 +

+
+ + +
+
+ ) +} + +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 + return this.props.children + } +} diff --git a/web/src/pages/useLibraryData.ts b/web/src/pages/useLibraryData.ts index fb136ca..df0c587 100644 --- a/web/src/pages/useLibraryData.ts +++ b/web/src/pages/useLibraryData.ts @@ -154,9 +154,11 @@ async function loadAllSeriesCards( let collected: SeriesCard[] = [] for (;;) { const data = await libraryAPI.listSeries(libraryID, page, pageSize) - collected = collected.concat(data.items) - onPage({ items: collected, total: data.total, firstPage: page === 1 }) - if (collected.length >= data.total || data.items.length < pageSize) break + // 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。 + const pageItems = data.items ?? [] + 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 } return { items: collected } @@ -171,9 +173,11 @@ async function loadAllMedia( let collected: Media[] = [] for (;;) { const data = await libraryAPI.listMedia(libraryID, page, pageSize) - collected = collected.concat(data.items) - onPage({ items: collected, total: data.total, firstPage: page === 1 }) - if (collected.length >= data.total || data.items.length < pageSize) break + // 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。 + const pageItems = data.items ?? [] + 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 } return { items: collected } diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index 2b7c354..b185822 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -74,6 +74,7 @@ function compactSeriesKey(raw: string): string { } export function isEpisodeLike(media: Media): boolean { + if (!media) return false return (media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0 }