diff --git a/README.md b/README.md index 2da5561..20d799b 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,7 @@ vim docker-compose.yml # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.6 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.7 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 @@ -470,7 +470,7 @@ docker compose up -d ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.6 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.7 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_MEDIA_DIR=/mnt/nas/media MEDIASTATION_DOWNLOAD_DIR=/mnt/nas/downloads @@ -650,26 +650,26 @@ cd MediaStationGo | 平台 | 包名示例 | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.6-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.6-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.6-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.6-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.6-darwin-arm64.tar.gz` | +| 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` | 部署步骤: ```bash # Linux 示例 -tar -xzf MediaStationGo-v0.0.6-linux-amd64.tar.gz -cd MediaStationGo-v0.0.6-linux-amd64 +tar -xzf MediaStationGo-v0.0.7-linux-amd64.tar.gz +cd MediaStationGo-v0.0.7-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows: ```powershell -Expand-Archive .\MediaStationGo-v0.0.6-windows-amd64.zip -cd .\MediaStationGo-v0.0.6-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.7-windows-amd64.zip +cd .\MediaStationGo-v0.0.7-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` diff --git a/README_EN.md b/README_EN.md index 5a108e4..21a9c48 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.6 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.7 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_MEDIA_DIR=/mnt/nas/media MEDIASTATION_DOWNLOAD_DIR=/mnt/nas/downloads @@ -500,25 +500,25 @@ Each release provides multi-platform archives: | Platform | Package example | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.6-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.6-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.6-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.6-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.6-darwin-arm64.tar.gz` | +| 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 example: ```bash -tar -xzf MediaStationGo-v0.0.6-linux-amd64.tar.gz -cd MediaStationGo-v0.0.6-linux-amd64 +tar -xzf MediaStationGo-v0.0.7-linux-amd64.tar.gz +cd MediaStationGo-v0.0.7-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows example: ```powershell -Expand-Archive .\MediaStationGo-v0.0.6-windows-amd64.zip -cd .\MediaStationGo-v0.0.6-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.7-windows-amd64.zip +cd .\MediaStationGo-v0.0.7-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` diff --git a/docker-compose.yml b/docker-compose.yml index 2145c7b..13d41ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.6 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.7 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 84d467b..56ff07c 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -7,15 +7,17 @@ // movie: {Title} ({Year})/{Title} ({Year}).{Ext} // tv: {Title}/Season {Season:02d}/{Title} - S{Season:02d}E{Episode:02d}.{Ext} // -// We do NOT delete the source after move — the operator can turn that on -// via a config flag. This mirrors MediaStation's "organize after download" -// workflow. +// Organizing moves the source file. On the same filesystem this is an +// os.Rename; across filesystems it falls back to streaming copy + source +// removal, so operators should keep downloads and media on the same volume +// when they want instant moves and no temporary duplicate disk usage. package service import ( "context" "errors" "fmt" + "io" "os" "path/filepath" "strings" @@ -79,10 +81,7 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s season := fmt.Sprintf("Season %02d", m.SeasonNum) epTag := fmt.Sprintf("S%02dE%02d", m.SeasonNum, m.EpisodeNum) root := o.organizeRoot(lib.Path, lib.Type, category) - dir := filepath.Join(root, category, title, season) - if category == "" { - dir = filepath.Join(root, title, season) - } + dir := filepath.Join(categoryRoot(root, category), title, season) dst = filepath.Join(dir, fmt.Sprintf("%s - %s%s", title, epTag, ext)) } else { // Movie: {lib.Path}/[分类]/{Title} ({Year})/{Title} ({Year}).ext @@ -91,10 +90,7 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s folder = fmt.Sprintf("%s (%d)", title, m.Year) } root := o.organizeRoot(lib.Path, lib.Type, category) - dir := filepath.Join(root, category, folder) - if category == "" { - dir = filepath.Join(root, folder) - } + dir := filepath.Join(categoryRoot(root, category), folder) dst = filepath.Join(dir, folder+ext) } @@ -219,17 +215,20 @@ func moveFile(src, dst string) error { if err := os.Rename(src, dst); err == nil { return nil } - // Cross-device: read → write → remove. - data, err := os.ReadFile(src) + // Cross-device: stream copy → remove. This can temporarily consume the + // destination file size while copying, but the source is removed after the + // copy succeeds. + in, err := os.Open(src) if err != nil { return err } + defer in.Close() // O_EXCL 保证不会覆盖已存在的目标。 f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { return err } - if _, werr := f.Write(data); werr != nil { + if _, werr := io.Copy(f, in); werr != nil { f.Close() os.Remove(dst) return werr @@ -284,6 +283,13 @@ func (o *OrganizerService) organizeRoot(libraryPath, mediaType, category string) return libraryPath } +func categoryRoot(root, category string) string { + if strings.TrimSpace(category) == "" || pathAlreadyEndsWith(root, category) { + return root + } + return filepath.Join(root, category) +} + 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 74713ea..ccdf232 100644 --- a/internal/service/organizer_test.go +++ b/internal/service/organizer_test.go @@ -176,3 +176,59 @@ func TestOrganizeMediaAddsMoviePilotStyleTypeRootForGenericMediaRoot(t *testing. t.Fatalf("organized file missing: %v", err) } } + +func TestOrganizeMediaDoesNotRepeatCategoryWhenLibraryIsCategoryRoot(t *testing.T) { + root := t.TempDir() + libraryRoot := filepath.Join(root, "media", "电视剧", "国产剧") + sourceDir := filepath.Join(root, "downloads", "国产剧") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatal(err) + } + source := filepath.Join(sourceDir, "Some 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: "Some 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) + dst, err := organizer.OrganizeMedia(t.Context(), media.ID) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(libraryRoot, "Some Show", "Season 01", "Some Show - S01E02.mkv") + if dst != want { + t.Fatalf("dst = %q, want %q", dst, want) + } + repeated := filepath.Join(libraryRoot, "国产剧", "Some Show", "Season 01", "Some Show - S01E02.mkv") + if _, err := os.Stat(repeated); !os.IsNotExist(err) { + t.Fatalf("repeated category path exists or stat failed unexpectedly: %v", err) + } +}