Files
MeBox/internal/service/organizer_transfer_test.go
T
soldosluka857 b9b7b7052a feat(organize/scan): transfer modes, seeding-safe relocation, inode dedup, incremental scanning
- Organizer: add move/copy/hardlink/symlink transfer modes (default move),
  per-request target_path/transfer_mode overrides, and honor organize.target_dir
  / organize.transfer_mode settings.
- keep_seeding (default on): escalate move->hardlink (cross-device->copy) so the
  qBittorrent source stays in place and continues seeding after organize.
- qBittorrent SetLocation + POST /downloads/relocate to migrate whole torrents
  while keeping them seeding.
- Scanner: FileID (device:inode) hardlink dedup to avoid duplicate recognition
  and double-counted storage; extract single-file ingest.
- Watcher: recursive watch + incremental per-file ingest/remove instead of full
  re-scan; periodic full library scan now gated behind scan.periodic_enabled
  (default off) to reduce disk wear.
- Telegram bot: handle callback_query in polling, declare allowed_updates in
  webhook, answer callbacks.
- Frontend: settings for transfer mode / keep_seeding / periodic scan; organize
  panel target dir + transfer mode overrides.
- Tests for transfer modes, organizer resolution, SetLocation, inode dedup,
  incremental ingest/remove.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 12:01:46 +08:00

147 lines
4.6 KiB
Go

package service
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func newOrganizerTestRepo(t *testing.T) *repository.Container {
t.Helper()
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)
}
return repository.New(db)
}
func TestOrganizeMediaHonorsTargetDirAndCopyMode(t *testing.T) {
root := t.TempDir()
srcDir := filepath.Join(root, "downloads")
if err := os.MkdirAll(srcDir, 0o755); err != nil {
t.Fatal(err)
}
source := filepath.Join(srcDir, "Some Movie.mkv")
if err := os.WriteFile(source, []byte("movie"), 0o644); err != nil {
t.Fatal(err)
}
target := filepath.Join(root, "custom-library")
repos := newOrganizerTestRepo(t)
if err := repos.Setting.Set(t.Context(), "organize.target_dir", target); err != nil {
t.Fatal(err)
}
lib := model.Library{Name: "Movies", Path: filepath.Join(root, "lib"), Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{LibraryID: lib.ID, Title: "Some Movie", Path: source, Year: 2020, Container: "mkv", ScrapeStatus: "matched"}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
dst, err := org.OrganizeMediaWithOptions(t.Context(), media.ID, OrganizeOptions{TransferMode: TransferCopy})
if err != nil {
t.Fatalf("organize: %v", err)
}
if !strings.HasPrefix(dst, target) {
t.Fatalf("dst %q should be under target dir %q", dst, target)
}
if _, err := os.Stat(source); err != nil {
t.Fatalf("copy mode must keep source: %v", err)
}
if _, err := os.Stat(dst); err != nil {
t.Fatalf("organized file missing: %v", err)
}
}
func TestOrganizeMediaKeepSeedingUpgradesMoveToHardlink(t *testing.T) {
root := t.TempDir()
srcDir := filepath.Join(root, "downloads")
if err := os.MkdirAll(srcDir, 0o755); err != nil {
t.Fatal(err)
}
source := filepath.Join(srcDir, "Some Movie.mkv")
if err := os.WriteFile(source, []byte("movie"), 0o644); err != nil {
t.Fatal(err)
}
repos := newOrganizerTestRepo(t)
// 默认转移方式为 move,做种保种默认开启 → 应升级为硬链接(保留源文件)。
lib := model.Library{Name: "Movies", Path: filepath.Join(root, "lib"), Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{LibraryID: lib.ID, Title: "Some Movie", Path: source, Year: 2021, Container: "mkv", ScrapeStatus: "matched"}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
dst, err := org.OrganizeMedia(t.Context(), media.ID)
if err != nil {
t.Fatalf("organize: %v", err)
}
si, err := os.Stat(source)
if err != nil {
t.Fatalf("keep_seeding default should keep source for seeding: %v", err)
}
di, err := os.Stat(dst)
if err != nil {
t.Fatalf("organized file missing: %v", err)
}
if !os.SameFile(si, di) {
t.Fatal("organized file should be a hardlink of the source (same inode)")
}
}
func TestOrganizeMediaKeepSeedingOffPerformsTrueMove(t *testing.T) {
root := t.TempDir()
srcDir := filepath.Join(root, "downloads")
if err := os.MkdirAll(srcDir, 0o755); err != nil {
t.Fatal(err)
}
source := filepath.Join(srcDir, "Some Movie.mkv")
if err := os.WriteFile(source, []byte("movie"), 0o644); err != nil {
t.Fatal(err)
}
repos := newOrganizerTestRepo(t)
if err := repos.Setting.Set(t.Context(), "organize.keep_seeding", "false"); err != nil {
t.Fatal(err)
}
lib := model.Library{Name: "Movies", Path: filepath.Join(root, "lib"), Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
media := model.Media{LibraryID: lib.ID, Title: "Some Movie", Path: source, Year: 2022, Container: "mkv", ScrapeStatus: "matched"}
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
t.Fatal(err)
}
org := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
dst, err := org.OrganizeMedia(t.Context(), media.ID)
if err != nil {
t.Fatalf("organize: %v", err)
}
if _, err := os.Stat(source); !os.IsNotExist(err) {
t.Fatalf("keep_seeding off should remove source, stat err = %v", err)
}
if _, err := os.Stat(dst); err != nil {
t.Fatalf("organized file missing: %v", err)
}
}