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
+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)
}
}