fix: avoid repeated category organization

This commit is contained in:
ShukeBta
2026-05-29 02:55:46 +08:00
parent 1b86de7c26
commit fc0dc70bad
5 changed files with 98 additions and 36 deletions
+11 -11
View File
@@ -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
```
+10 -10
View File
@@ -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
```
+1 -1
View File
@@ -12,7 +12,7 @@
#
# 镜像版本:
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.6
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.7
#
# 路径映射总览:
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
+20 -14
View File
@@ -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":
+56
View File
@@ -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)
}
}