diff --git a/README.md b/README.md index 20d799b..88747f8 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,7 @@ vim docker-compose.yml # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.7 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.8 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 @@ -342,7 +342,9 @@ services: # NAS / 飞牛等系统请写绝对路径,例如: # MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media # 不要写 ./vol1/...,否则会变成当前部署目录下的 vol1 子目录。 - - ${MEDIASTATION_MEDIA_DIR:-./media}:/media:ro + # 默认映射到容器 /media;如需在网页中直接填写宿主机绝对路径,可将 + # MEDIASTATION_MEDIA_CONTAINER_DIR 设置为同一个 /vol1/... 路径。 + - ${MEDIASTATION_MEDIA_DIR:-./media}:${MEDIASTATION_MEDIA_CONTAINER_DIR:-/media}:ro # 下载保存目录。订阅/站点下载的保存路径建议使用: # /downloads/Movies @@ -353,7 +355,9 @@ services: # NAS / 飞牛等系统请写绝对路径,例如: # MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads # 不要写 ./vol1/...,否则下载目录会被映射到当前部署目录下面。 - - ${MEDIASTATION_DOWNLOAD_DIR:-./downloads}:/downloads + # 默认映射到容器 /downloads;如需下载器和应用都使用宿主机绝对路径,可将 + # MEDIASTATION_DOWNLOAD_CONTAINER_DIR 设置为同一个 /vol1/... 路径。 + - ${MEDIASTATION_DOWNLOAD_DIR:-./downloads}:${MEDIASTATION_DOWNLOAD_CONTAINER_DIR:-/downloads} environment: # Web 服务监听配置。容器内固定监听 8080,对外端口由上方 ports 控制。 @@ -370,9 +374,9 @@ services: # /vol1/1000/Docker/moviepilot-v2/media/电视剧 -> /media/电视剧 # /vol1/1000/qBittorrent/downloads/国产剧 -> /downloads/国产剧 MEDIASTATION_MEDIA_DIR: ${MEDIASTATION_MEDIA_DIR:-./media} - MEDIASTATION_MEDIA_CONTAINER_DIR: /media + MEDIASTATION_MEDIA_CONTAINER_DIR: ${MEDIASTATION_MEDIA_CONTAINER_DIR:-/media} MEDIASTATION_DOWNLOAD_DIR: ${MEDIASTATION_DOWNLOAD_DIR:-./downloads} - MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads + MEDIASTATION_DOWNLOAD_CONTAINER_DIR: ${MEDIASTATION_DOWNLOAD_CONTAINER_DIR:-/downloads} # 日志级别:debug / info / warn / error。 MEDIASTATION_LOGGING_LEVEL: ${MEDIASTATION_LOGGING_LEVEL:-info} @@ -470,7 +474,7 @@ docker compose up -d ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.7 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.8 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_MEDIA_DIR=/mnt/nas/media MEDIASTATION_DOWNLOAD_DIR=/mnt/nas/downloads @@ -498,6 +502,26 @@ Docker Compose 左侧是宿主机真实目录,右侧是容器内目录。宿 > 如果你在添加媒体库时填写了 `/vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧` 并提示不可访问,原因是应用在容器内运行,默认只能看到 `/media/电视剧/国产剧`。新版 compose 会把宿主机路径作为映射提示传入容器,尽量自动纠正;但最推荐、最稳定的填写方式仍然是容器路径 `/media/...`。 +如果你更希望“页面里直接填写 NAS 绝对路径”,可以把宿主机路径和容器路径设置成完全一致,这不会复制文件,也不会多占空间: + +```env +MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media +MEDIASTATION_MEDIA_CONTAINER_DIR=/vol1/1000/Docker/moviepilot-v2/media +MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads +MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/vol1/1000/qBittorrent/downloads +``` + +对应 compose 实际效果等价于: + +```yaml +- /vol1/1000/Docker/moviepilot-v2/media:/vol1/1000/Docker/moviepilot-v2/media:ro +- /vol1/1000/qBittorrent/downloads:/vol1/1000/qBittorrent/downloads +``` + +这样添加媒体库时就可以直接填写 `/vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧`。 + +> 安全策略:扫描和播放只读取原目录;“整理整个库”不会再搬动已经位于媒体库目录内的文件,避免本地 NFO、海报、字幕等元数据被迁移后丢失。下载完成后的自动整理仍然默认关闭,只有你手动打开后才会移动下载目录中的新文件。 + 如果 compose 中这样挂载: ```yaml @@ -582,7 +606,9 @@ qBittorrent 容器:/downloads | `MEDIASTATION_DATA_DIR` | `./data` | 数据持久化目录 | | `MEDIASTATION_CACHE_DIR` | `./cache` | 图片和转码缓存目录 | | `MEDIASTATION_MEDIA_DIR` | `./media` | 媒体库宿主机目录;NAS 建议写 `/vol1/1000/Docker/moviepilot-v2/media` 这种绝对路径 | +| `MEDIASTATION_MEDIA_CONTAINER_DIR` | `/media` | 容器内媒体路径;如想页面直接填写 `/vol1/...`,可设置成与 `MEDIASTATION_MEDIA_DIR` 相同 | | `MEDIASTATION_DOWNLOAD_DIR` | `./downloads` | 下载保存宿主机目录;NAS 建议写 `/vol1/1000/qBittorrent/downloads` 这种绝对路径 | +| `MEDIASTATION_DOWNLOAD_CONTAINER_DIR` | `/downloads` | 容器内下载路径;如想下载器保存路径直接使用 `/vol1/...`,可设置成与 `MEDIASTATION_DOWNLOAD_DIR` 相同 | | `PUID` / `PGID` | `1000` / `1000` | Linux/NAS 文件权限映射 | | `TZ` | `Asia/Shanghai` | 容器时区 | @@ -650,26 +676,26 @@ cd MediaStationGo | 平台 | 包名示例 | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.7-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.7-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.7-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.7-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.7-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.8-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.8-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.8-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.8-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.8-darwin-arm64.tar.gz` | 部署步骤: ```bash # Linux 示例 -tar -xzf MediaStationGo-v0.0.7-linux-amd64.tar.gz -cd MediaStationGo-v0.0.7-linux-amd64 +tar -xzf MediaStationGo-v0.0.8-linux-amd64.tar.gz +cd MediaStationGo-v0.0.8-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows: ```powershell -Expand-Archive .\MediaStationGo-v0.0.7-windows-amd64.zip -cd .\MediaStationGo-v0.0.7-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.8-windows-amd64.zip +cd .\MediaStationGo-v0.0.8-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` diff --git a/README_EN.md b/README_EN.md index 21a9c48..6520028 100644 --- a/README_EN.md +++ b/README_EN.md @@ -320,7 +320,7 @@ For production, pin a specific release tag instead of using `latest`: ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.7 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.8 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_MEDIA_DIR=/mnt/nas/media MEDIASTATION_DOWNLOAD_DIR=/mnt/nas/downloads @@ -348,6 +348,26 @@ Inside the app, use only `/media` and `/downloads`. If you see `/vol1/1000/Docke > If adding `/vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧` as a library reports inaccessible, the app is running inside the container and normally sees `/media/电视剧/国产剧`. The updated compose passes host-path hints so the backend can auto-convert common mistakes, but `/media/...` remains the recommended and most stable input. +If you prefer entering the NAS absolute path directly in the web UI, map the host path to the exact same container path. This does not copy files or use extra disk space: + +```env +MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media +MEDIASTATION_MEDIA_CONTAINER_DIR=/vol1/1000/Docker/moviepilot-v2/media +MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads +MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/vol1/1000/qBittorrent/downloads +``` + +Equivalent compose mounts: + +```yaml +- /vol1/1000/Docker/moviepilot-v2/media:/vol1/1000/Docker/moviepilot-v2/media:ro +- /vol1/1000/qBittorrent/downloads:/vol1/1000/qBittorrent/downloads +``` + +With this mode, add libraries using paths such as `/vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧`. + +> Safety policy: scanning and playback read the original directory. “Organize entire library” no longer moves files already inside the library root, protecting local NFO, posters, subtitles, and other sidecar metadata. Auto-organize after downloads remains disabled by default. + If your compose mounts are: ```yaml @@ -432,7 +452,9 @@ The repository includes a heavily commented `docker-compose.yml`. Common variabl | `MEDIASTATION_DATA_DIR` | `./data` | Persistent data directory | | `MEDIASTATION_CACHE_DIR` | `./cache` | Image and transcoding cache | | `MEDIASTATION_MEDIA_DIR` | `./media` | Host media library root; on NAS use an absolute path such as `/vol1/1000/Docker/moviepilot-v2/media` | +| `MEDIASTATION_MEDIA_CONTAINER_DIR` | `/media` | Container media path; set it equal to `MEDIASTATION_MEDIA_DIR` if you want to enter `/vol1/...` directly in the web UI | | `MEDIASTATION_DOWNLOAD_DIR` | `./downloads` | Host download target; on NAS use an absolute path such as `/vol1/1000/qBittorrent/downloads` | +| `MEDIASTATION_DOWNLOAD_CONTAINER_DIR` | `/downloads` | Container download path; set it equal to `MEDIASTATION_DOWNLOAD_DIR` if your downloader save path should also be `/vol1/...` | | `PUID` / `PGID` | `1000` / `1000` | Linux/NAS file permission mapping | | `TZ` | `Asia/Shanghai` | Container timezone | @@ -500,25 +522,25 @@ Each release provides multi-platform archives: | Platform | Package example | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.7-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.7-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.7-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.7-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.7-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.8-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.8-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.8-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.8-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.8-darwin-arm64.tar.gz` | Linux example: ```bash -tar -xzf MediaStationGo-v0.0.7-linux-amd64.tar.gz -cd MediaStationGo-v0.0.7-linux-amd64 +tar -xzf MediaStationGo-v0.0.8-linux-amd64.tar.gz +cd MediaStationGo-v0.0.8-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows example: ```powershell -Expand-Archive .\MediaStationGo-v0.0.7-windows-amd64.zip -cd .\MediaStationGo-v0.0.7-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.8-windows-amd64.zip +cd .\MediaStationGo-v0.0.8-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` diff --git a/docker-compose.yml b/docker-compose.yml index 13d41ec..9ce5848 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.7 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.8 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 @@ -67,7 +67,9 @@ services: # NAS / 飞牛等系统请写绝对路径,例如: # MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media # 不要写 ./vol1/...,否则会变成当前部署目录下的 vol1 子目录。 - - ${MEDIASTATION_MEDIA_DIR:-./media}:/media:ro + # 默认映射到容器 /media;如需在网页中直接填写宿主机绝对路径,可将 + # MEDIASTATION_MEDIA_CONTAINER_DIR 设置为同一个 /vol1/... 路径。 + - ${MEDIASTATION_MEDIA_DIR:-./media}:${MEDIASTATION_MEDIA_CONTAINER_DIR:-/media}:ro # 下载保存目录。订阅/站点下载的保存路径建议使用: # /downloads/Movies @@ -78,7 +80,9 @@ services: # NAS / 飞牛等系统请写绝对路径,例如: # MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads # 不要写 ./vol1/...,否则下载目录会被映射到当前部署目录下面。 - - ${MEDIASTATION_DOWNLOAD_DIR:-./downloads}:/downloads + # 默认映射到容器 /downloads;如需下载器和应用都使用宿主机绝对路径,可将 + # MEDIASTATION_DOWNLOAD_CONTAINER_DIR 设置为同一个 /vol1/... 路径。 + - ${MEDIASTATION_DOWNLOAD_DIR:-./downloads}:${MEDIASTATION_DOWNLOAD_CONTAINER_DIR:-/downloads} environment: # Web 服务监听配置。容器内固定监听 8080,对外端口由上方 ports 控制。 @@ -95,9 +99,9 @@ services: # /vol1/1000/Docker/moviepilot-v2/media/电视剧 -> /media/电视剧 # /vol1/1000/qBittorrent/downloads/国产剧 -> /downloads/国产剧 MEDIASTATION_MEDIA_DIR: ${MEDIASTATION_MEDIA_DIR:-./media} - MEDIASTATION_MEDIA_CONTAINER_DIR: /media + MEDIASTATION_MEDIA_CONTAINER_DIR: ${MEDIASTATION_MEDIA_CONTAINER_DIR:-/media} MEDIASTATION_DOWNLOAD_DIR: ${MEDIASTATION_DOWNLOAD_DIR:-./downloads} - MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads + MEDIASTATION_DOWNLOAD_CONTAINER_DIR: ${MEDIASTATION_DOWNLOAD_CONTAINER_DIR:-/downloads} # 日志级别:debug / info / warn / error。 MEDIASTATION_LOGGING_LEVEL: ${MEDIASTATION_LOGGING_LEVEL:-info} diff --git a/internal/handler/strm.go b/internal/handler/strm.go index 7e804a9..9bfb445 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -84,7 +84,7 @@ func importSTRMHandler(svc *service.Container) gin.HandlerFunc { STRMURL: req.URL, Container: "strm", } - if err := svc.Repo.DB.WithContext(c.Request.Context()).Create(m).Error; err != nil { + if err := svc.Repo.Media.Upsert(c.Request.Context(), m); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 0845573..bef5201 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -276,6 +276,9 @@ func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error { if m.EpisodeNum > 0 && existing.EpisodeNum != m.EpisodeNum { updates["episode_num"] = m.EpisodeNum } + if m.STRMURL != "" { + updates["strm_url"] = m.STRMURL + } if err := r.db.WithContext(ctx).Unscoped().Model(&model.Media{}). Where("id = ?", existing.ID).Updates(updates).Error; err != nil { diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 56ff07c..6db105e 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -150,6 +150,10 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s // OrganizeLibrary organizes every media row in a library whose file is // not already in the expected path structure. func (o *OrganizerService) OrganizeLibrary(ctx context.Context, libraryID string) (*OrganizeResult, error) { + lib, err := o.repo.Library.FindByID(ctx, libraryID) + if err != nil || lib == nil { + return nil, errors.New("library not found") + } var rows []model.Media if err := o.repo.DB.WithContext(ctx). Where("library_id = ? AND deleted_at IS NULL", libraryID). @@ -158,6 +162,10 @@ func (o *OrganizerService) OrganizeLibrary(ctx context.Context, libraryID string } res := &OrganizeResult{} for i := range rows { + if pathWithin(rows[i].Path, lib.Path) { + res.Skipped++ + continue + } dst, err := o.OrganizeMedia(ctx, rows[i].ID) if err != nil { res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", rows[i].Title, err.Error())) @@ -290,6 +298,19 @@ func categoryRoot(root, category string) string { return filepath.Join(root, category) } +func pathWithin(path, root string) bool { + cleanPath := filepath.Clean(path) + cleanRoot := filepath.Clean(root) + if strings.EqualFold(cleanPath, cleanRoot) { + return true + } + rel, err := filepath.Rel(cleanRoot, cleanPath) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + func mediaTypeRootDir(mediaType string) string { switch normalizeMediaType(mediaType, "", "") { case "movie": diff --git a/internal/service/organizer_test.go b/internal/service/organizer_test.go index ccdf232..a42cf17 100644 --- a/internal/service/organizer_test.go +++ b/internal/service/organizer_test.go @@ -232,3 +232,64 @@ func TestOrganizeMediaDoesNotRepeatCategoryWhenLibraryIsCategoryRoot(t *testing. t.Fatalf("repeated category path exists or stat failed unexpectedly: %v", err) } } + +func TestOrganizeLibrarySkipsFilesAlreadyInsideLibrary(t *testing.T) { + root := t.TempDir() + libraryRoot := filepath.Join(root, "media", "电视剧", "国产剧") + sourceDir := filepath.Join(libraryRoot, "Existing Show") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatal(err) + } + source := filepath.Join(sourceDir, "Existing Show S01E02.mkv") + if err := os.WriteFile(source, []byte("episode"), 0o644); err != nil { + t.Fatal(err) + } + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + if err := repos.Setting.Set(t.Context(), "organizer.smart_classify", "true"); err != nil { + t.Fatal(err) + } + lib := model.Library{Name: "国产剧", Path: libraryRoot, Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + media := model.Media{ + LibraryID: lib.ID, + Title: "Existing Show", + Path: source, + Container: "mkv", + Countries: "CN", + SeasonNum: 1, + EpisodeNum: 2, + ScrapeStatus: "matched", + } + if err := repos.Media.Upsert(t.Context(), &media); err != nil { + t.Fatal(err) + } + + organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + result, err := organizer.OrganizeLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatal(err) + } + if result.Organized != 0 || result.Skipped != 1 { + t.Fatalf("result = %+v, want organized=0 skipped=1", result) + } + if _, err := os.Stat(source); err != nil { + t.Fatalf("source should remain untouched: %v", err) + } + var refreshed model.Media + if err := db.First(&refreshed, "id = ?", media.ID).Error; err != nil { + t.Fatal(err) + } + if refreshed.Path != source { + t.Fatalf("media path = %q, want unchanged %q", refreshed.Path, source) + } +}