diff --git a/internal/api/arrs_handlers.go b/internal/api/arrs_handlers.go index 0db3a8f9d..ed7bdaa57 100644 --- a/internal/api/arrs_handlers.go +++ b/internal/api/arrs_handlers.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/url" "os" + "path/filepath" "strconv" "strings" "time" @@ -322,6 +323,18 @@ func (s *Server) handleArrsWebhook(c *fiber.Ctx) error { var releaseDate *time.Time var sourceNzb *string + // Handle Rename specifically: try to find and re-link old record + if req.EventType == "Rename" { + fileName := filepath.Base(normalizedPath) + // Try to find a record with the same filename but currently under /complete/ + // or with a NULL library_path + if err := s.healthRepo.RelinkFileByFilename(c.Context(), fileName, normalizedPath, path); err == nil { + slog.InfoContext(c.Context(), "Successfully re-linked health record during Rename webhook", + "filename", fileName, "new_library_path", path) + continue // Successfully re-linked, no need to add new + } + } + // Try to read metadata to get release date if s.metadataService != nil { meta, err := s.metadataService.ReadFileMetadata(normalizedPath) diff --git a/internal/database/health_repository.go b/internal/database/health_repository.go index 492963120..a00d85be1 100644 --- a/internal/database/health_repository.go +++ b/internal/database/health_repository.go @@ -149,8 +149,75 @@ func (r *HealthRepository) GetUnhealthyFiles(ctx context.Context, limit int) ([] return nil, fmt.Errorf("failed to iterate unhealthy files: %w", err) } - return files, nil -} + return files, nil + + + + } + + + + // RelinkFileByFilename attempts to find a health record with the same filename and update its paths + + func (r *HealthRepository) RelinkFileByFilename(ctx context.Context, fileName, newVirtualPath, newLibraryPath string) error { + + newVirtualPath = strings.TrimPrefix(newVirtualPath, "/") + + + + // We look for a record that: + + // 1. Ends with the same filename + + // 2. Is NOT already at the correct path + + // 3. Either has a NULL library_path or is in /complete/ + + query := ` + + UPDATE file_health + + SET file_path = ?, + + library_path = ?, + + updated_at = datetime('now') + + WHERE (file_path LIKE '%' || ? OR library_path LIKE '%' || ?) + + AND file_path != ? + + AND (library_path IS NULL OR library_path LIKE '%/complete/%' OR library_path LIKE 'complete/%') + + ` + + + + result, err := r.db.ExecContext(ctx, query, newVirtualPath, newLibraryPath, fileName, fileName, newVirtualPath) + + if err != nil { + + return err + + } + + + + rows, _ := result.RowsAffected() + + if rows == 0 { + + return fmt.Errorf("no matching record found to re-link") + + } + + + + return nil + + } + + // SetPriority sets the priority for a file health record func (r *HealthRepository) SetPriority(ctx context.Context, id int64, priority HealthPriority) error { @@ -1442,14 +1509,21 @@ func (r *HealthRepository) RenameHealthRecord(ctx context.Context, oldPath, newP query := ` UPDATE file_health SET file_path = ?, + library_path = CASE + WHEN library_path = ? THEN ? + ELSE library_path + END, updated_at = datetime('now') WHERE file_path = ? ` - _, err := r.db.ExecContext(ctx, query, newPath, oldPath) - if err != nil { - return fmt.Errorf("failed to rename health record: %w", err) - } + _, err := r.db.ExecContext(ctx, query, newPath, oldPath, newPath, oldPath) + if err != nil { + return fmt.Errorf("failed to rename health record: %w", err) + } - return nil -} + return nil + + } + + diff --git a/internal/health/library_sync.go b/internal/health/library_sync.go index b15640c6c..de08d2e52 100644 --- a/internal/health/library_sync.go +++ b/internal/health/library_sync.go @@ -619,11 +619,37 @@ func (lsw *LibrarySyncWorker) SyncLibrary(ctx context.Context, dryRun bool) *Dry path := mountRelativePath p.Go(func() { - // Check if needs to be added - if it, exists := dbPathSet[path]; !exists || it.LibraryPath == nil { - // Look up library path from our map - libraryPath := lsw.getLibraryPath(path, filesInUse) + // Check if needs to be added or repaired + recordExists := false + var existingRecord *database.AutomaticHealthCheckRecord + if it, exists := dbPathSet[path]; exists { + recordExists = true + existingRecord = &it + } + // Look up library path from our map + libraryPath := lsw.getLibraryPath(path, filesInUse) + + // Fast Path Recovery: If record exists but has no library path, + // try to see if it's at the expected location even if not in filesInUse + if recordExists && existingRecord.LibraryPath == nil && libraryPath == nil { + expectedPath := filepath.Join(cfg.MountPath, path) + if _, err := os.Stat(expectedPath); err == nil { + // Found it at the expected location! + libStr := expectedPath + libraryPath = &libStr + slog.InfoContext(ctx, "Recovered library path for record", + "path", path, "recovered_location", expectedPath) + + // Update DB immediately for this recovered path + if err := lsw.healthRepo.UpdateLibraryPath(ctx, path, expectedPath); err != nil { + slog.ErrorContext(ctx, "Failed to update recovered library path", + "path", path, "error", err) + } + } + } + + if !recordExists || (existingRecord.LibraryPath == nil && libraryPath != nil) { record, err := lsw.processMetadataForSync(ctx, path, libraryPath) if err != nil { slog.ErrorContext(ctx, "Failed to read metadata", @@ -656,8 +682,8 @@ func (lsw *LibrarySyncWorker) SyncLibrary(ctx context.Context, dryRun bool) *Dry // Wait for all workers to complete p.Wait() - // Additional cleanup of orphaned metadata files if enabled - metadataDeletedCount := 0 + // Additional cleanup of orphaned database records if enabled + // We no longer delete metadata files here for safety. if shouldCleanup { // We already have libraryFiles from earlier in the function for relativeMountPath := range metaFileSet { @@ -670,25 +696,17 @@ func (lsw *LibrarySyncWorker) SyncLibrary(ctx context.Context, dryRun bool) *Dry libraryPath := lsw.getLibraryPath(relativeMountPath, filesInUse) if libraryPath == nil { - if !dryRun { - deleteSourceNzb := false - if cfg.Metadata.DeleteSourceNzbOnRemoval != nil { - deleteSourceNzb = *cfg.Metadata.DeleteSourceNzbOnRemoval - } - err := lsw.metadataService.DeleteFileMetadataWithSourceNzb(ctx, relativeMountPath, deleteSourceNzb) - if err != nil { - slog.ErrorContext(ctx, "Failed to delete metadata file", "error", err) - continue - } - // Remove from our set so the database cleanup step below knows it's gone - delete(metaFileSet, relativeMountPath) - } - metadataDeletedCount++ + // We used to delete metadata here, but it's removed for safety. + // If a file is missing from the library, we just let the database cleanup handle it later + // or keep the metadata so it can be re-linked if found. + slog.DebugContext(ctx, "File missing from library, but preserving metadata for safety", + "path", relativeMountPath) } } } // Cleanup orphaned library files (symlinks and STRM files without metadata) + metadataDeletedCount := 0 libraryFilesDeletedCount := 0 libraryDirsDeletedCount := 0 diff --git a/internal/nzbfilesystem/metadata_remote_file.go b/internal/nzbfilesystem/metadata_remote_file.go index 551255dc1..4c6d4f505 100644 --- a/internal/nzbfilesystem/metadata_remote_file.go +++ b/internal/nzbfilesystem/metadata_remote_file.go @@ -297,6 +297,7 @@ func (mrf *MetadataRemoteFile) RenameFile(ctx context.Context, oldName, newName if err := os.Rename(oldDirPath, newDirPath); err != nil { return false, fmt.Errorf("failed to rename directory: %w", err) } + return true, nil }