feat(scanner): repoint rows on cross-library moves of identical content
Previously the scanner's SHA-256 dedup was library-scoped: moving a book between libraries created a duplicate row (new ID) while the old row went missing and archived after two scans, orphaning reading progress and annotations from the file that users still see. processMediaFile now falls through to cross-library detection when the same-library hash lookup misses: - GetMediaItemsBySHA256AndSameLibraryType returns candidates in other same-type libraries; each candidate's file is stat'd through ITS OWN library's folders (the old existence check stat'd against the current scan's folder tree, which is meaningless across libraries). - File gone -> it is a move: the candidate must pass the target library's type rules (ValidateMediaItemForLibrary), then the row is repointed via MoveMediaItemToLibrary with the recomputed relative path. Reading history, annotations, and collections follow automatically because the row keeps its ID. Incompatible formats (e.g. a reflowable EPUB into a manga library) are rejected with a processing issue instead of being force-imported; the upsert on (media_item_id, issue_type) keeps repeated scans from spamming duplicates. - File still present -> deliberate multi-library copy: fall through to normal import so both libraries keep independent rows. Candidate selection is factored into selectMoveCandidate (pure function, unit-tested in media_scanner_move_test.go): input arrives pre-ordered (archived first, then most missing scans, then oldest) and only candidates whose file is verifiably gone qualify, so deliberate copies are never repointed.
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user