diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index 655eb3e..11101a3 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -848,6 +848,17 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool, } } + // Cross-library move detection: the file may have been moved into a + // different library of the same type (e.g. reorganizing ebooks into a + // new library). If identical content is registered elsewhere and the old + // file is gone from disk, repoint that row so reading history follows + // the book instead of creating a duplicate. + if hashInfo.FileSHA256 != "" { + if handled := s.detectCrossLibraryMove(ctx, hashInfo.FileSHA256, libraryID, path, info.Size()); handled { + return false, nil + } + } + // REMOVED: Comic metadata extraction now handled by mergeMetadata() // This avoids duplicate extraction and ensures smart merging happens @@ -1741,6 +1752,136 @@ func (s *MediaScanner) DetectFixedLayoutEPUB(epubPath string) (bool, error) { return false, nil } +// moveCandidate is the subset of a cross-library hash-match row the scanner +// needs to decide whether a file is a move or a deliberate copy. +type moveCandidate struct { + ID pgtype.UUID + LibraryID pgtype.UUID + FilePath string + Title string + Archived bool + MissingScans int32 +} + +// selectMoveCandidate returns the first candidate whose file has vanished +// from its library folders. Candidates arrive pre-ordered (archived first, +// then most missing scans, then oldest), so the first missing one is the +// best repoint target. Candidates whose file still exists are deliberate +// copies in another library and are left untouched. Returns ok=false when +// every candidate is still on disk. +func selectMoveCandidate(candidates []moveCandidate, fileExists func(moveCandidate) bool) (moveCandidate, bool) { + for _, candidate := range candidates { + if !fileExists(candidate) { + return candidate, true + } + } + return moveCandidate{}, false +} + +// detectCrossLibraryMove repoints an existing row from another same-type +// library when this file is that row's content at a new location and the old +// file is gone from disk. Returns true when the file was fully handled as a +// cross-library move (or rejected as type-incompatible) and must not be +// imported again by the caller. +func (s *MediaScanner) detectCrossLibraryMove(ctx context.Context, sha string, libraryID pgtype.UUID, path string, size int64) bool { + rows, err := s.db.GetMediaItemsBySHA256AndSameLibraryType(ctx, database.GetMediaItemsBySHA256AndSameLibraryTypeParams{ + FileSha256: pgtype.Text{String: sha, Valid: true}, + LibraryID: libraryID, + }) + if err != nil { + if !errors.Is(err, pgx.ErrNoRows) { + fmt.Printf("Warning: cross-library SHA lookup failed for %s: %v\n", path, err) + } + return false + } + if len(rows) == 0 { + return false + } + + candidates := make([]moveCandidate, 0, len(rows)) + for _, row := range rows { + candidates = append(candidates, moveCandidate{ + ID: row.ID, + LibraryID: row.LibraryID, + FilePath: row.FilePath, + Title: row.Title, + Archived: row.ArchivedAt.Valid, + MissingScans: row.MissingScanCount, + }) + } + + candidate, ok := selectMoveCandidate(candidates, func(c moveCandidate) bool { + return s.mediaItemFileExists(ctx, c.LibraryID, c.FilePath) + }) + if !ok { + // Every registered copy of this content still lives in its own + // library: a deliberate multi-library import, not a move. + return false + } + + return s.repointCrossLibraryMove(ctx, candidate, libraryID, path, size) +} + +// mediaItemFileExists resolves a media item's relative path against its own +// library's folders and reports whether the file is present on disk. +func (s *MediaScanner) mediaItemFileExists(ctx context.Context, libraryID pgtype.UUID, relativePath string) bool { + folders, err := s.db.GetLibraryFolders(ctx, libraryID) + if err != nil { + return false + } + for _, folder := range folders { + if _, statErr := os.Stat(filepath.Join(folder.FolderPath, relativePath)); statErr == nil { + return true + } + } + return false +} + +// repointCrossLibraryMove validates the target library's type rules, then +// moves the candidate row into the target library at its new path. Reading +// history, annotations and collections follow automatically because the row +// keeps its ID. Returns true when the file was handled (moved or rejected) +// and must not be imported again. +func (s *MediaScanner) repointCrossLibraryMove(ctx context.Context, candidate moveCandidate, libraryID pgtype.UUID, path string, size int64) bool { + item, err := s.db.GetMediaItem(ctx, candidate.ID) + if err != nil { + fmt.Printf("Warning: failed to load cross-library move candidate %q: %v\n", candidate.Title, err) + return false + } + + targetLib, err := s.db.GetLibraryWithType(ctx, libraryID) + if err != nil { + fmt.Printf("Warning: failed to load target library for cross-library move of %q: %v\n", candidate.Title, err) + return false + } + + // Respect the target library's type rules: a reflowable EPUB must not + // silently land in a manga library, mirroring import validation. + if issue := s.ValidateMediaItemForLibrary(ctx, item, targetLib); issue != nil { + fmt.Printf("[MOVE] Rejected cross-library move of %q: %s\n", candidate.Title, *issue) + s.logger.LogDelete(fmt.Sprintf("[MOVE] Rejected moving item '%s' into library %s: %s", candidate.Title, libraryID, *issue)) + if logErr := s.LogProcessingIssue(ctx, uuid.UUID(candidate.ID.Bytes), uuid.UUID(libraryID.Bytes), + "library_type_mismatch", *issue, "warning"); logErr != nil { + fmt.Printf("Warning: failed to record processing issue for %q: %v\n", candidate.Title, logErr) + } + return true + } + + if err := s.db.MoveMediaItemToLibrary(ctx, database.MoveMediaItemToLibraryParams{ + ID: candidate.ID, + LibraryID: libraryID, + FilePath: s.getRelativePath(path), + FileSize: pgtype.Int8{Int64: size, Valid: true}, + }); err != nil { + fmt.Printf("Warning: failed to repoint cross-library move %q -> %s: %v\n", candidate.FilePath, path, err) + return false + } + + fmt.Printf("[MOVE] Cross-library move: %q -> %s (history preserved)\n", candidate.FilePath, path) + s.logger.LogDelete(fmt.Sprintf("[MOVE] Repointed item '%s' from %q to %s (library changed)", candidate.Title, candidate.FilePath, path)) + return true +} + // ValidateMediaItemForLibrary checks if item matches library type expectations // and returns issue description if validation fails, nil if valid func (s *MediaScanner) ValidateMediaItemForLibrary( diff --git a/internal/services/media_scanner_move_test.go b/internal/services/media_scanner_move_test.go new file mode 100644 index 0000000..849cb65 --- /dev/null +++ b/internal/services/media_scanner_move_test.go @@ -0,0 +1,67 @@ +package services + +import ( + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +func mustUUID(t *testing.T, raw string) pgtype.UUID { + t.Helper() + parsed, err := uuid.Parse(raw) + if err != nil { + t.Fatalf("invalid test uuid %q: %v", raw, err) + } + return pgtype.UUID{Bytes: parsed, Valid: true} +} + +// alwaysExists simulates every candidate's file still being on disk: +// deliberate copies across libraries must never be repointed. +func TestSelectMoveCandidateAllFilesExist(t *testing.T) { + candidates := []moveCandidate{ + {ID: mustUUID(t, "00000000-0000-0000-0000-000000000001"), FilePath: "a/book.epub"}, + {ID: mustUUID(t, "00000000-0000-0000-0000-000000000002"), FilePath: "b/book.epub"}, + } + + if _, ok := selectMoveCandidate(candidates, func(moveCandidate) bool { return true }); ok { + t.Fatal("expected no move candidate when every file still exists") + } +} + +// Input arrives pre-ordered (archived first, then most missing scans, then +// oldest), so the first missing candidate must win. +func TestSelectMoveCandidatePicksFirstMissing(t *testing.T) { + onDisk := moveCandidate{ID: mustUUID(t, "00000000-0000-0000-0000-000000000001"), FilePath: "a/book.epub"} + gone := moveCandidate{ID: mustUUID(t, "00000000-0000-0000-0000-000000000002"), FilePath: "b/book.epub"} + candidates := []moveCandidate{onDisk, gone} + + got, ok := selectMoveCandidate(candidates, func(c moveCandidate) bool { + return c.ID == onDisk.ID + }) + if !ok { + t.Fatal("expected a move candidate") + } + if got.ID != gone.ID { + t.Fatalf("expected candidate %v, got %v", gone.ID, got.ID) + } +} + +func TestSelectMoveCandidateOnlyMissingQualifies(t *testing.T) { + gone := moveCandidate{ID: mustUUID(t, "00000000-0000-0000-0000-000000000001"), FilePath: "old/book.epub"} + candidates := []moveCandidate{gone} + + got, ok := selectMoveCandidate(candidates, func(moveCandidate) bool { return false }) + if !ok { + t.Fatal("expected the missing candidate to qualify") + } + if got.ID != gone.ID { + t.Fatalf("expected candidate %v, got %v", gone.ID, got.ID) + } +} + +func TestSelectMoveCandidateEmptyList(t *testing.T) { + if _, ok := selectMoveCandidate(nil, func(moveCandidate) bool { return false }); ok { + t.Fatal("expected no candidate for an empty list") + } +}