From 249b1dfe9338a43e6af849e953a975c580de5938 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 13 Sep 2026 12:00:19 -0400 Subject: [PATCH] feat(scanner): treat reappeared content as a move, not a duplicate When the SHA-256 dedup found identical content already in the library, the scan skipped the file as a duplicate - and after files moved between folders the old row kept its stale path, cycled missing -> archived, and the new path never took. The archive feature turned the old destructive move behavior into a stuck move instead. Now the dedup branch stats the old location: if it is gone, the book was MOVED, so the row is repointed (file_path, file_size) with archive state cleared and reading history intact. 'Skip as duplicate' only applies when the old path still exists (a true copy). Verified live: a moved EPUB kept its single row, followed the file, and never entered the missing/archive cycle. --- internal/services/media_scanner.go | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index de60791..6bf8fc9 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -795,6 +795,39 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool, LibraryID: libraryID, }) if err == nil && existingByHash.ID.Valid { + // Same content at a different path. If the old location is gone, + // the book was MOVED: repoint the row so history follows it and + // the archive pass doesn't cycle it into missing/archived. Only + // treat it as a duplicate copy when the old path still exists. + oldPathStillExists := false + for _, folder := range s.folders { + if _, statErr := os.Stat(filepath.Join(folder, existingByHash.FilePath)); statErr == nil { + oldPathStillExists = true + break + } + } + + if !oldPathStillExists { + if err := s.db.MoveMediaItemFilePath(ctx, database.MoveMediaItemFilePathParams{ + ID: existingByHash.ID, + FilePath: s.getRelativePath(path), + FileSize: pgtype.Int8{Int64: info.Size(), Valid: true}, + }); err != nil { + fmt.Printf("Warning: failed to repoint moved media item %s -> %s: %v\n", existingByHash.FilePath, path, err) + } else { + fmt.Printf("[MOVE] Item content moved: %q -> %s (history preserved)\n", existingByHash.FilePath, path) + s.logger.LogDelete(fmt.Sprintf("[MOVE] Repointed item '%s' from %q to %s", existingByHash.Title, existingByHash.FilePath, path)) + } + if s.forceRescan { + moved := existingByHash + moved.FilePath = s.getRelativePath(path) + moved.ArchivedAt = pgtype.Timestamptz{} + moved.MissingScanCount = 0 + _ = s.updateMediaItem(ctx, moved, path) + } + return false, nil + } + fmt.Printf("Media item with same SHA-256 already exists in library (path %q), skipping duplicate: %s\n", existingByHash.FilePath, path) // Content returned (possibly at a new path): restore archived rows.