Files
MeBox/internal/service/scanner_incremental_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

136 lines
3.9 KiB
Go

package service
import (
"os"
"path/filepath"
"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 newScannerTestEnv(t *testing.T) (*ScannerService, *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)
}
repos := repository.New(db)
sc := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
return sc, repos
}
func countMedia(t *testing.T, repos *repository.Container) int64 {
t.Helper()
var n int64
if err := repos.DB.Model(&model.Media{}).Count(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
func TestIngestPathAddsSingleFile(t *testing.T) {
sc, repos := newScannerTestEnv(t)
root := t.TempDir()
lib := model.Library{Name: "Movies", Path: root, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
file := filepath.Join(root, "Some Movie (2021).mkv")
if err := os.WriteFile(file, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
added, err := sc.IngestPath(t.Context(), lib.ID, file)
if err != nil {
t.Fatalf("ingest: %v", err)
}
if !added {
t.Fatal("expected file to be added")
}
if got := countMedia(t, repos); got != 1 {
t.Fatalf("media count = %d, want 1", got)
}
// Non-video file is ignored.
other := filepath.Join(root, "notes.txt")
if err := os.WriteFile(other, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if added, _ := sc.IngestPath(t.Context(), lib.ID, other); added {
t.Fatal("non-video file should not be ingested")
}
}
func TestRemovePathDeletesVanishedMedia(t *testing.T) {
sc, repos := newScannerTestEnv(t)
root := t.TempDir()
lib := model.Library{Name: "Movies", Path: root, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
file := filepath.Join(root, "Gone (2020).mkv")
if err := os.WriteFile(file, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := sc.IngestPath(t.Context(), lib.ID, file); err != nil {
t.Fatal(err)
}
if countMedia(t, repos) != 1 {
t.Fatal("expected 1 media before removal")
}
// A still-present file is not removed.
if removed, _ := sc.RemovePath(t.Context(), file); removed != 0 {
t.Fatalf("present file should not be removed, got %d", removed)
}
if err := os.Remove(file); err != nil {
t.Fatal(err)
}
removed, err := sc.RemovePath(t.Context(), file)
if err != nil {
t.Fatalf("remove: %v", err)
}
if removed != 1 {
t.Fatalf("removed = %d, want 1", removed)
}
if countMedia(t, repos) != 0 {
t.Fatal("expected 0 media after removal")
}
}
// TestScanSkipsHardlinkDuplicate verifies that a hardlink (same inode) kept
// for seeding is not imported as a second media item, preventing duplicate
// recognition and double-counted storage.
func TestScanSkipsHardlinkDuplicate(t *testing.T) {
sc, repos := newScannerTestEnv(t)
root := t.TempDir()
lib := model.Library{Name: "Movies", Path: root, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
orig := filepath.Join(root, "Movie (2019).mkv")
if err := os.WriteFile(orig, []byte("payload"), 0o644); err != nil {
t.Fatal(err)
}
linked := filepath.Join(root, "Movie (2019) [organized].mkv")
if err := os.Link(orig, linked); err != nil {
t.Skipf("hardlinks unsupported on this fs: %v", err)
}
res, err := sc.ScanLibrary(t.Context(), lib.ID)
if err != nil {
t.Fatalf("scan: %v", err)
}
if got := countMedia(t, repos); got != 1 {
t.Fatalf("media count = %d, want 1 (hardlink should be deduped)", got)
}
if res.Skipped != 1 {
t.Fatalf("Skipped = %d, want 1", res.Skipped)
}
}