From 9919ae264badc115fe4bd70aa308bf59145373f9 Mon Sep 17 00:00:00 2001 From: drondeseries Date: Thu, 23 Apr 2026 20:19:50 -0400 Subject: [PATCH] feat(health): high-precision ID-based surgical repair and metadata discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This comprehensive update transitions AltMount's repair system from fragile path-based string matching to a high-precision, ID-based surgical workflow. By capturing and persisting unique ARR database IDs, AltMount can now guarantee 100% accuracy during repairs, even if files or folders have been renamed or reorganized. Key Technical Components: 1. Database & Schema: - Adds a 'metadata' JSON column to the 'file_health' table (Migration 026). - Updates all repository queries to persist and retrieve rich ARR IDs. - Ensures metadata is preserved and updated during file re-linking/renames. 2. Rich Metadata Capture: - Expands the Webhook handler to extract InstanceName, SeriesID, MovieID, and EpisodeFileID directly from Sonarr/Radarr payloads. - Implements detailed logging for webhook events, providing immediate visibility into captured IDs and library paths. 3. Automated Metadata Discovery Service (Three-Layer Engine): - Priority 1 (Show & Scene Match): Directly searches ARR libraries by title extracted from Release Name (NZB), matching against the permanent 'sceneName' field—the gold standard for ID precision. - Priority 2 (Global Discovery): Automatically polls all enabled ARR instances if the managing instance cannot be determined by path alone. - Priority 3 (Fuzzy Fallback): Safety mechanism using dots-to-spaces normalization and fuzzy title matching to identify legacy items. 4. Health Worker Integration: - Auto-Discovery: Proactively backfills IDs for healthy files during background check cycles. - Emergency Discovery: Attempts a final high-precision ID lookup immediately before a corruption repair strike if metadata is missing. - Surgical Strike: Utilizes captured IDs to execute targeted 'Fail and Replace' commands via the starr API, ensuring zero false-positive deletions. This creates a self-healing, ID-aware media management layer that provides production-grade reliability for large-scale library repairs. --- README.md | 1 + internal/api/arrs_handlers.go | 93 +++- internal/api/health_handlers.go | 4 +- internal/arrs/model/metadata.go | 30 ++ internal/arrs/scanner/discovery.go | 188 +++++++++ internal/arrs/scanner/manager.go | 396 ++++++++++++------ internal/arrs/service.go | 9 +- internal/database/health_repository.go | 49 ++- internal/database/health_repository_test.go | 7 +- .../026_add_metadata_to_file_health.sql | 9 + .../026_add_metadata_to_file_health.sql | 14 + .../026_add_metadata_to_file_health.sql | 9 + internal/database/models.go | 1 + internal/health/library_sync_test.go | 1 + internal/health/repair_e2e_test.go | 8 +- internal/health/resilience_test.go | 1 + internal/health/worker.go | 73 +++- internal/health/worker_test.go | 1 + internal/nzbfilesystem/types.go | 2 +- 19 files changed, 730 insertions(+), 166 deletions(-) create mode 100644 internal/arrs/model/metadata.go create mode 100644 internal/arrs/scanner/discovery.go create mode 100644 internal/database/migrations/026_add_metadata_to_file_health.sql create mode 100644 internal/database/migrations/postgres/026_add_metadata_to_file_health.sql create mode 100644 internal/database/migrations/sqlite/026_add_metadata_to_file_health.sql diff --git a/README.md b/README.md index 9d5b0242f..1bbea52f7 100644 --- a/README.md +++ b/README.md @@ -69,3 +69,4 @@ See the [Development Guide](https://altmount.kipsilabs.top/docs/Development/setu ## License This project is licensed under the terms specified in the [LICENSE](LICENSE) file. + diff --git a/internal/api/arrs_handlers.go b/internal/api/arrs_handlers.go index b262b6efa..35d262122 100644 --- a/internal/api/arrs_handlers.go +++ b/internal/api/arrs_handlers.go @@ -13,6 +13,7 @@ import ( "time" "github.com/gofiber/fiber/v2" + "github.com/javi11/altmount/internal/arrs/model" "github.com/javi11/altmount/internal/database" ) @@ -43,23 +44,69 @@ type ArrsWebhookRequest struct { } `json:"book"` EventType string `json:"eventType"` + InstanceName string `json:"instanceName,omitempty"` FilePath string `json:"filePath,omitempty"` // For upgrades/renames, the file path might be in other fields or need to be inferred Movie struct { + Id int64 `json:"id"` + TmdbId int64 `json:"tmdbId"` FolderPath string `json:"folderPath"` } `json:"movie"` MovieFile struct { - Path string `json:"path"` + Id int64 `json:"id"` + SceneName string `json:"sceneName"` + Path string `json:"path"` } `json:"movieFile"` Series struct { - Path string `json:"path"` + Id int64 `json:"id"` + TvdbId int64 `json:"tvdbId"` + Path string `json:"path"` } `json:"series"` EpisodeFile struct { - Path string `json:"path"` + Id int64 `json:"id"` + SceneName string `json:"sceneName"` + Path string `json:"path"` } `json:"episodeFile"` DeletedFiles ArrsDeletedFiles `json:"deletedFiles,omitempty"` } +func (req ArrsWebhookRequest) ToMetadata() model.WebhookMetadata { + meta := model.WebhookMetadata{ + EventType: req.EventType, + InstanceName: req.InstanceName, + } + + if req.Movie.Id > 0 || req.Movie.TmdbId > 0 { + meta.Movie = &model.MovieMetadata{ + Id: req.Movie.Id, + TmdbId: req.Movie.TmdbId, + } + } + + if req.MovieFile.Id > 0 || req.MovieFile.SceneName != "" { + meta.MovieFile = &model.MovieFileMetadata{ + Id: req.MovieFile.Id, + SceneName: req.MovieFile.SceneName, + } + } + + if req.Series.Id > 0 || req.Series.TvdbId > 0 { + meta.Series = &model.SeriesMetadata{ + Id: req.Series.Id, + TvdbId: req.Series.TvdbId, + } + } + + if req.EpisodeFile.Id > 0 || req.EpisodeFile.SceneName != "" { + meta.EpisodeFile = &model.EpisodeFileMetadata{ + Id: req.EpisodeFile.Id, + SceneName: req.EpisodeFile.SceneName, + } + } + + return meta +} + type ArrsDeletedFile struct { Path string `json:"path"` } @@ -424,9 +471,34 @@ func (s *Server) handleArrsWebhook(c *fiber.Ctx) error { fileName := filepath.Base(normalizedPath) // Try to find a record with the same filename but currently under /complete/ // or with a NULL library_path - if relinked, err := s.healthRepo.RelinkFileByFilename(c.Context(), fileName, normalizedPath, path); err == nil && relinked { - slog.InfoContext(c.Context(), "Successfully re-linked health record during webhook", - "event", req.EventType, "filename", fileName, "new_library_path", path) + var metadataStr *string + metaBytes, err := json.Marshal(req.ToMetadata()) + if err == nil { + str := string(metaBytes) + metadataStr = &str + } + + if relinked, err := s.healthRepo.RelinkFileByFilename(c.Context(), fileName, normalizedPath, path, metadataStr); err == nil && relinked { + attrs := []any{ + "event", req.EventType, + "instance", req.InstanceName, + "filename", fileName, + "new_library_path", path, + } + if req.Series.Id > 0 { + attrs = append(attrs, "series_id", req.Series.Id) + } + if req.EpisodeFile.Id > 0 { + attrs = append(attrs, "episode_file_id", req.EpisodeFile.Id) + } + if req.Movie.Id > 0 { + attrs = append(attrs, "movie_id", req.Movie.Id) + } + if req.MovieFile.Id > 0 { + attrs = append(attrs, "movie_file_id", req.MovieFile.Id) + } + + slog.InfoContext(c.Context(), "Successfully re-linked health record during webhook with rich metadata", attrs...) continue // Successfully re-linked, no need to add new } } @@ -453,9 +525,16 @@ func (s *Server) handleArrsWebhook(c *fiber.Ctx) error { } } + var metadataStr *string + metaBytes, err := json.Marshal(req.ToMetadata()) + if err == nil { + str := string(metaBytes) + metadataStr = &str + } + // Add to health check (pending status) with high priority (Next) to ensure it's processed right away cfg := s.configManager.GetConfigGetter()() - err := s.healthRepo.AddFileToHealthCheckWithMetadata(c.Context(), normalizedPath, &path, cfg.GetMaxRetries(), cfg.GetMaxRepairRetries(), sourceNzb, database.HealthPriorityNext, releaseDate) + err = s.healthRepo.AddFileToHealthCheckWithMetadata(c.Context(), normalizedPath, &path, cfg.GetMaxRetries(), cfg.GetMaxRepairRetries(), sourceNzb, database.HealthPriorityNext, releaseDate, metadataStr) if err != nil { slog.ErrorContext(c.Context(), "Failed to add webhook file to health check", "path", normalizedPath, "error", err) } else { diff --git a/internal/api/health_handlers.go b/internal/api/health_handlers.go index a547940f0..5b9b98cff 100644 --- a/internal/api/health_handlers.go +++ b/internal/api/health_handlers.go @@ -439,7 +439,7 @@ func (s *Server) handleRepairHealth(c *fiber.Ctx) error { } // Trigger rescan with the resolved path - err = s.arrsService.TriggerFileRescan(ctx, pathForRescan, item.FilePath) + err = s.arrsService.TriggerFileRescan(ctx, pathForRescan, item.FilePath, item.Metadata) if err != nil { // Check if this is a "no ARR instance found" error if strings.Contains(err.Error(), "no ARR instance found") { @@ -546,7 +546,7 @@ func (s *Server) handleRepairHealthBulk(c *fiber.Ctx) error { } // Trigger rescan - err = s.arrsService.TriggerFileRescan(ctx, pathForRescan, item.FilePath) + err = s.arrsService.TriggerFileRescan(ctx, pathForRescan, item.FilePath, item.Metadata) if err != nil { failedCount++ errors[filePath] = fmt.Sprintf("Failed to trigger repair: %v", err) diff --git a/internal/arrs/model/metadata.go b/internal/arrs/model/metadata.go new file mode 100644 index 000000000..59992ef73 --- /dev/null +++ b/internal/arrs/model/metadata.go @@ -0,0 +1,30 @@ +package model + +type MovieMetadata struct { + Id int64 `json:"id,omitempty"` + TmdbId int64 `json:"tmdbId,omitempty"` +} + +type MovieFileMetadata struct { + Id int64 `json:"id,omitempty"` + SceneName string `json:"sceneName,omitempty"` +} + +type SeriesMetadata struct { + Id int64 `json:"id,omitempty"` + TvdbId int64 `json:"tvdbId,omitempty"` +} + +type EpisodeFileMetadata struct { + Id int64 `json:"id,omitempty"` + SceneName string `json:"sceneName,omitempty"` +} + +type WebhookMetadata struct { + EventType string `json:"eventType,omitempty"` + InstanceName string `json:"instanceName,omitempty"` + Movie *MovieMetadata `json:"movie,omitempty"` + MovieFile *MovieFileMetadata `json:"movieFile,omitempty"` + Series *SeriesMetadata `json:"series,omitempty"` + EpisodeFile *EpisodeFileMetadata `json:"episodeFile,omitempty"` +} diff --git a/internal/arrs/scanner/discovery.go b/internal/arrs/scanner/discovery.go new file mode 100644 index 000000000..972ac96c8 --- /dev/null +++ b/internal/arrs/scanner/discovery.go @@ -0,0 +1,188 @@ +package scanner + +import ( + "context" + "fmt" + "log/slog" + "regexp" + "strings" + + "github.com/javi11/altmount/internal/arrs/model" + "golift.io/starr" +) + +var ( + tvSeasonPattern = regexp.MustCompile(`(?i)S\d{1,4}E\d{1,4}`) + tvDatePattern = regexp.MustCompile(`(?i)\d{4}.\d{2}.\d{2}`) +) + +// DiscoverFileMetadata attempts to find the rich metadata for a file by searching ARR instances. +// This implementation is 100% STRICT and performs zero fuzzy guessing. +func (m *Manager) DiscoverFileMetadata(ctx context.Context, filePath, relativePath, nzbName, libraryPath string) (*model.WebhookMetadata, error) { + allInstances := m.instances.GetAllInstances() + cleanNzbName := strings.TrimSuffix(nzbName, ".nzb") + + // Determine preferred type based on patterns (S01E01, etc.) + isTV := tvSeasonPattern.MatchString(cleanNzbName) || tvSeasonPattern.MatchString(filePath) || + tvDatePattern.MatchString(cleanNzbName) || tvDatePattern.MatchString(filePath) + + preferredType := "radarr" + if isTV { + preferredType = "sonarr" + } + + slog.DebugContext(ctx, "Strict Discovery: Starting", "nzb", cleanNzbName, "preferred_type", preferredType) + + // Strategy 1: Strict Global ID Lock (Primary Type First) + if cleanNzbName != "" { + // Pass 1: Preferred type + for _, inst := range allInstances { + if !inst.Enabled || inst.Type != preferredType { + continue + } + + if meta, err := m.runStrictDiscovery(ctx, inst, filePath, cleanNzbName, libraryPath); err == nil && meta != nil { + slog.InfoContext(ctx, "Strict Discovery Success: Exact ID lock", "instance", inst.Name, "nzb", cleanNzbName, "type", inst.Type) + return meta, nil + } + } + + // Pass 2: Fallback to other types (Only if preferred failed) + for _, inst := range allInstances { + if !inst.Enabled || inst.Type == preferredType { + continue + } + + if meta, err := m.runStrictDiscovery(ctx, inst, filePath, cleanNzbName, libraryPath); err == nil && meta != nil { + slog.InfoContext(ctx, "Strict Discovery Success (Fallback Type): Exact ID lock", "instance", inst.Name, "nzb", cleanNzbName, "type", inst.Type) + return meta, nil + } + } + } + + return nil, fmt.Errorf("strict discovery failed: no exact match found for %s", filePath) +} + +func (m *Manager) runStrictDiscovery(ctx context.Context, inst *model.ConfigInstance, filePath, cleanNzbName, libraryPath string) (*model.WebhookMetadata, error) { + if inst.Type == "radarr" { + return m.discoverRadarrStrict(ctx, filePath, cleanNzbName, libraryPath, inst.Name) + } else if inst.Type == "sonarr" { + return m.discoverSonarrStrict(ctx, filePath, cleanNzbName, libraryPath, inst.Name) + } + return nil, fmt.Errorf("unsupported type") +} + +func (m *Manager) discoverRadarrStrict(ctx context.Context, filePath, cleanNzbName, libraryPath, instanceName string) (*model.WebhookMetadata, error) { + instanceConfig, _ := m.instances.FindConfigInstance("radarr", instanceName) + client, _ := m.clients.GetOrCreateRadarrClient(instanceName, instanceConfig.URL, instanceConfig.APIKey) + + // 1. Check Library (Active Files) + movies, err := m.data.GetMovies(ctx, client, instanceName) + if err == nil { + for _, movie := range movies { + if movie.HasFile && movie.MovieFile != nil { + // Strict Match Conditions: + // - Library Path matches + // - OR Scene Name matches (NZB Name) + if (libraryPath != "" && movie.MovieFile.Path == libraryPath) || + (cleanNzbName != "" && strings.EqualFold(movie.MovieFile.SceneName, cleanNzbName)) { + metadata := &model.WebhookMetadata{ + EventType: "StrictDiscovery", + InstanceName: instanceName, + Movie: &model.MovieMetadata{ + Id: movie.ID, + TmdbId: movie.TmdbID, + }, + MovieFile: &model.MovieFileMetadata{ + Id: movie.MovieFile.ID, + SceneName: movie.MovieFile.SceneName, + }, + } + return metadata, nil + } + } + } + } + + // 2. Check History (Exact Release Name match) + req := &starr.PageReq{PageSize: 100, SortKey: "date", SortDir: starr.SortDescend} + history, err := client.GetHistoryPageContext(ctx, req) + if err == nil { + for _, record := range history.Records { + if strings.EqualFold(record.SourceTitle, cleanNzbName) { + metadata := &model.WebhookMetadata{ + EventType: "StrictHistoryDiscovery", + InstanceName: instanceName, + Movie: &model.MovieMetadata{ + Id: record.MovieID, + }, + } + return metadata, nil + } + } + } + + return nil, fmt.Errorf("not found") +} + +func (m *Manager) discoverSonarrStrict(ctx context.Context, filePath, cleanNzbName, libraryPath, instanceName string) (*model.WebhookMetadata, error) { + instanceConfig, _ := m.instances.FindConfigInstance("sonarr", instanceName) + client, _ := m.clients.GetOrCreateSonarrClient(instanceName, instanceConfig.URL, instanceConfig.APIKey) + + // 1. Check Library (Active Files) + series, err := m.data.GetSeries(ctx, client, instanceName) + if err == nil { + for _, show := range series { + // Optimization: only pull files for shows that might contain this file + if !strings.Contains(strings.ToLower(cleanNzbName), strings.ToLower(show.CleanTitle)) && + !strings.Contains(strings.ToLower(filePath), strings.ToLower(show.Path)) && + (libraryPath == "" || !strings.Contains(strings.ToLower(libraryPath), strings.ToLower(show.Path))) { + continue + } + + episodeFiles, err := m.data.GetEpisodeFiles(ctx, client, instanceName, show.ID) + if err != nil { + continue + } + for _, ef := range episodeFiles { + if (libraryPath != "" && ef.Path == libraryPath) || + (cleanNzbName != "" && strings.EqualFold(ef.SceneName, cleanNzbName)) || + (ef.Path == filePath) { + metadata := &model.WebhookMetadata{ + EventType: "StrictDiscovery", + InstanceName: instanceName, + Series: &model.SeriesMetadata{ + Id: show.ID, + TvdbId: show.TvdbID, + }, + EpisodeFile: &model.EpisodeFileMetadata{ + Id: ef.ID, + SceneName: ef.SceneName, + }, + } + return metadata, nil + } + } + } + } + + // 2. Check History (Exact Release Name match) + req := &starr.PageReq{PageSize: 100, SortKey: "date", SortDir: starr.SortDescend} + history, err := client.GetHistoryPageContext(ctx, req) + if err == nil { + for _, record := range history.Records { + if strings.EqualFold(record.SourceTitle, cleanNzbName) { + metadata := &model.WebhookMetadata{ + EventType: "StrictHistoryDiscovery", + InstanceName: instanceName, + Series: &model.SeriesMetadata{ + Id: record.SeriesID, + }, + } + return metadata, nil + } + } + } + + return nil, fmt.Errorf("not found") +} diff --git a/internal/arrs/scanner/manager.go b/internal/arrs/scanner/manager.go index f7288dca4..c7ab014cc 100644 --- a/internal/arrs/scanner/manager.go +++ b/internal/arrs/scanner/manager.go @@ -2,6 +2,7 @@ package scanner import ( "context" + "encoding/json" "fmt" "log/slog" "path/filepath" @@ -297,14 +298,44 @@ func (m *Manager) sonarrHasFile(ctx context.Context, client *sonarr.Sonarr, inst } // TriggerFileRescan triggers a rescan for a specific file path through the appropriate ARR instance -func (m *Manager) TriggerFileRescan(ctx context.Context, pathForRescan string, relativePath string) error { +func (m *Manager) TriggerFileRescan(ctx context.Context, pathForRescan string, relativePath string, metadataStr *string) error { res, err, _ := m.sf.Do(fmt.Sprintf("rescan:%s", pathForRescan), func() (interface{}, error) { slog.InfoContext(ctx, "Triggering ARR rescan", "path", pathForRescan, "relative_path", relativePath) - // Find which ARR instance manages this file path - instanceType, instanceName, err := m.findInstanceForFilePath(ctx, pathForRescan, relativePath) - if err != nil { - return nil, fmt.Errorf("failed to find ARR instance for file path %s: %w", pathForRescan, err) + var metadata *model.WebhookMetadata + if metadataStr != nil && *metadataStr != "" { + var parsedMetadata model.WebhookMetadata + if err := json.Unmarshal([]byte(*metadataStr), &parsedMetadata); err == nil { + metadata = &parsedMetadata + } else { + slog.WarnContext(ctx, "Failed to parse metadata string, falling back to path-based repair", "error", err, "path", pathForRescan) + } + } + + var instanceType, instanceName string + var err error + + // Try fast path: use instance from metadata + if metadata != nil && metadata.InstanceName != "" { + // Find if the instance exists in config + instances := m.instances.GetAllInstances() + for _, inst := range instances { + if inst.Name == metadata.InstanceName { + instanceType = inst.Type + instanceName = inst.Name + slog.InfoContext(ctx, "Fast path: Found instance from metadata", "instance", instanceName, "type", instanceType) + break + } + } + } + + // Fallback to path-based logic if instance not found from metadata + if instanceName == "" { + slog.DebugContext(ctx, "Instance not found from metadata, falling back to path-based detection", "path", pathForRescan) + instanceType, instanceName, err = m.findInstanceForFilePath(ctx, pathForRescan, relativePath) + if err != nil { + return nil, fmt.Errorf("failed to find ARR instance for file path %s: %w", pathForRescan, err) + } } // Find the instance configuration @@ -325,14 +356,14 @@ func (m *Manager) TriggerFileRescan(ctx context.Context, pathForRescan string, r if err != nil { return nil, fmt.Errorf("failed to create Radarr client: %w", err) } - return nil, m.triggerRadarrRescanByPath(ctx, client, pathForRescan, relativePath, instanceName) + return nil, m.triggerRadarrRescanByPath(ctx, client, pathForRescan, relativePath, instanceName, metadata) case "sonarr": client, err := m.clients.GetOrCreateSonarrClient(instanceName, instanceConfig.URL, instanceConfig.APIKey) if err != nil { return nil, fmt.Errorf("failed to create Sonarr client: %w", err) } - return nil, m.triggerSonarrRescanByPath(ctx, client, pathForRescan, relativePath, instanceName) + return nil, m.triggerSonarrRescanByPath(ctx, client, pathForRescan, relativePath, instanceName, metadata) case "lidarr", "readarr", "whisparr": // For now, we only support RefreshMonitoredDownloads for these @@ -492,65 +523,106 @@ func (m *Manager) TriggerDownloadScan(ctx context.Context, instanceType string) } // triggerRadarrRescanByPath triggers a rescan in Radarr for the given file path -func (m *Manager) triggerRadarrRescanByPath(ctx context.Context, client *radarr.Radarr, filePath, relativePath, instanceName string) error { +func (m *Manager) triggerRadarrRescanByPath(ctx context.Context, client *radarr.Radarr, filePath, relativePath, instanceName string, metadata *model.WebhookMetadata) error { slog.InfoContext(ctx, "Searching Radarr for matching movie", "instance", instanceName, "file_path", filePath, "relative_path", relativePath) - // Get all movies to find the one with matching file path - movies, err := m.data.GetMovies(ctx, client, instanceName) - if err != nil { - return fmt.Errorf("failed to get movies from Radarr: %w", err) - } - var targetMovie *radarr.Movie - for _, movie := range movies { - // Try match by filename (the most robust way if paths differ) - requestFileName := filepath.Base(filePath) - - if movie.HasFile && movie.MovieFile != nil { - // Try exact match - if movie.MovieFile.Path == filePath { + var targetMovieFileID int64 + var err error + + // ID-Based Precision: If we have the exact movie ID and file ID from metadata, use them directly + if metadata != nil && metadata.Movie.Id > 0 && metadata.MovieFile.Id > 0 { + slog.InfoContext(ctx, "ID-Based Precision: Using metadata IDs for Radarr repair", + "movie_id", metadata.Movie.Id, + "movie_file_id", metadata.MovieFile.Id) + + movies, err := m.data.GetMovies(ctx, client, instanceName) + if err != nil { + return fmt.Errorf("failed to get movies from Radarr for ID lookup: %w", err) + } + for _, movie := range movies { + if movie.ID == metadata.Movie.Id { targetMovie = movie + + // Smart Repair Guard: Check if the movie already has a newer/different healthy file + if movie.HasFile && movie.MovieFile != nil { + if movie.MovieFile.ID != metadata.MovieFile.Id { + slog.InfoContext(ctx, "Smart Repair Guard: Movie already has a different healthy file (likely upgraded). Skipping repair.", + "movie", movie.Title, + "old_file_id", metadata.MovieFile.Id, + "new_file_id", movie.MovieFile.ID) + return model.ErrEpisodeAlreadySatisfied + } + targetMovieFileID = movie.MovieFile.ID + } break } + } + } - movieFileName := filepath.Base(movie.MovieFile.Path) - if movieFileName == requestFileName { - slog.InfoContext(ctx, "Found Radarr movie match by filename", - "movie", movie.Title, - "path", movie.MovieFile.Path) - targetMovie = movie - break - } + // Fallback to path-based guessing if ID-based precision failed or metadata was missing + if targetMovie == nil { + slog.DebugContext(ctx, "Falling back to path-based guessing for Radarr", "file_path", filePath) + // Get all movies to find the one with matching file path + movies, err := m.data.GetMovies(ctx, client, instanceName) + if err != nil { + return fmt.Errorf("failed to get movies from Radarr: %w", err) + } - // Try match without .strm extension if filePath is a .strm file - if before, ok := strings.CutSuffix(filePath, ".strm"); ok { - strippedPath := before - // Check if movie file path (without its own extension) matches stripped filePath - if strings.TrimSuffix(movie.MovieFile.Path, filepath.Ext(movie.MovieFile.Path)) == strippedPath { + for _, movie := range movies { + // Try match by filename (the most robust way if paths differ) + requestFileName := filepath.Base(filePath) + + if movie.HasFile && movie.MovieFile != nil { + // Try exact match + if movie.MovieFile.Path == filePath { targetMovie = movie + targetMovieFileID = movie.MovieFile.ID break } - } - // Try suffix match with relative path if provided - if relativePath != "" { - strippedRelative := strings.TrimSuffix(relativePath, ".strm") - if strings.HasSuffix(movie.MovieFile.Path, relativePath) || - strings.HasSuffix(strings.TrimSuffix(movie.MovieFile.Path, filepath.Ext(movie.MovieFile.Path)), strippedRelative) { - slog.InfoContext(ctx, "Found Radarr movie match by relative path suffix", - "radarr_path", movie.MovieFile.Path, - "relative_path", relativePath) + + movieFileName := filepath.Base(movie.MovieFile.Path) + if movieFileName == requestFileName { + slog.InfoContext(ctx, "Found Radarr movie match by filename", + "movie", movie.Title, + "path", movie.MovieFile.Path) targetMovie = movie + targetMovieFileID = movie.MovieFile.ID break } + + // Try match without .strm extension if filePath is a .strm file + if before, ok := strings.CutSuffix(filePath, ".strm"); ok { + strippedPath := before + // Check if movie file path (without its own extension) matches stripped filePath + if strings.TrimSuffix(movie.MovieFile.Path, filepath.Ext(movie.MovieFile.Path)) == strippedPath { + targetMovie = movie + targetMovieFileID = movie.MovieFile.ID + break + } + } + // Try suffix match with relative path if provided + if relativePath != "" { + strippedRelative := strings.TrimSuffix(relativePath, ".strm") + if strings.HasSuffix(movie.MovieFile.Path, relativePath) || + strings.HasSuffix(strings.TrimSuffix(movie.MovieFile.Path, filepath.Ext(movie.MovieFile.Path)), strippedRelative) { + slog.InfoContext(ctx, "Found Radarr movie match by relative path suffix", + "radarr_path", movie.MovieFile.Path, + "relative_path", relativePath) + targetMovie = movie + targetMovieFileID = movie.MovieFile.ID + break + } + } } } } if targetMovie == nil { - slog.WarnContext(ctx, "No movie found with matching file path in Radarr library, attempting queue-based failure", + slog.WarnContext(ctx, "No movie found with matching file path or ID in Radarr library, attempting queue-based failure", "instance", instanceName, "file_path", filePath) @@ -569,25 +641,24 @@ func (m *Manager) triggerRadarrRescanByPath(ctx context.Context, client *radarr. "movie_path", targetMovie.Path, "file_path", filePath) - // If we found the movie but it has no file (or different file), we can't blocklist the specific file ID - // But we can still trigger search - if targetMovie.HasFile && targetMovie.MovieFile != nil { + // If we found the movie and have a file ID, try to blocklist and delete the file + if targetMovieFileID > 0 { // Try to blocklist the release associated with this file - if err := m.blocklistRadarrMovieFile(ctx, client, targetMovie.ID, targetMovie.MovieFile.ID); err != nil { + if err := m.blocklistRadarrMovieFile(ctx, client, targetMovie.ID, targetMovieFileID); err != nil { slog.WarnContext(ctx, "Failed to blocklist Radarr release", "error", err) } // Delete the existing file from Radarr database - err = client.DeleteMovieFilesContext(ctx, targetMovie.MovieFile.ID) + err = client.DeleteMovieFilesContext(ctx, targetMovieFileID) if err != nil { slog.WarnContext(ctx, "Failed to delete movie file from Radarr, continuing with search", "instance", instanceName, "movie_id", targetMovie.ID, - "file_id", targetMovie.MovieFile.ID, + "file_id", targetMovieFileID, "error", err) } } else { - slog.InfoContext(ctx, "Movie has no file linked in Radarr, skipping blocklist/delete and proceeding to search", + slog.InfoContext(ctx, "Movie has no specific file ID linked in Radarr, skipping blocklist/delete and proceeding to search", "movie", targetMovie.Title) } @@ -611,149 +682,198 @@ func (m *Manager) triggerRadarrRescanByPath(ctx context.Context, client *radarr. } // triggerSonarrRescanByPath triggers a rescan in Sonarr for the given file path -func (m *Manager) triggerSonarrRescanByPath(ctx context.Context, client *sonarr.Sonarr, filePath, relativePath, instanceName string) error { - cfg := m.configGetter() - - // Get library directory from health config - libraryDir := m.configGetter().MountPath - if cfg.Health.LibraryDir != nil && *cfg.Health.LibraryDir != "" { - libraryDir = *cfg.Health.LibraryDir - } - +func (m *Manager) triggerSonarrRescanByPath(ctx context.Context, client *sonarr.Sonarr, filePath, relativePath, instanceName string, metadata *model.WebhookMetadata) error { slog.InfoContext(ctx, "Searching Sonarr for matching series", "instance", instanceName, "file_path", filePath, - "relative_path", relativePath, - "library_dir", libraryDir) + "relative_path", relativePath) - // Get all series to find the one that contains this file path - series, err := m.data.GetSeries(ctx, client, instanceName) - if err != nil { - return fmt.Errorf("failed to get series from Sonarr: %w", err) + var targetSeriesID int64 + var targetSeriesTitle string + var targetEpisodeFileID int64 + var err error + + // ID-Based Precision: If we have exact IDs from metadata, use them + if metadata != nil && metadata.Series.Id > 0 && metadata.EpisodeFile.Id > 0 { + slog.InfoContext(ctx, "ID-Based Precision: Using metadata IDs for Sonarr repair", + "series_id", metadata.Series.Id, + "episode_file_id", metadata.EpisodeFile.Id) + + targetSeriesID = metadata.Series.Id + targetEpisodeFileID = metadata.EpisodeFile.Id + targetSeriesTitle = "Known Series (ID Based)" // Title is just for logging } - // Find the series that contains this file path - var targetSeries *sonarr.Series - for _, show := range series { - if strings.Contains(filePath, show.Path) { - targetSeries = show - break + // Fallback to path-based guessing if ID-based precision failed or metadata was missing + if targetSeriesID == 0 { + slog.DebugContext(ctx, "Falling back to path-based guessing for Sonarr", "file_path", filePath) + + // Get library directory from health config + libraryDir := m.configGetter().MountPath + cfg := m.configGetter() + if cfg.Health.LibraryDir != nil && *cfg.Health.LibraryDir != "" { + libraryDir = *cfg.Health.LibraryDir + } + + slog.DebugContext(ctx, "Searching Sonarr for matching series by path", + "library_dir", libraryDir) + + // Get all series to find the one that contains this file path + series, err := m.data.GetSeries(ctx, client, instanceName) + if err != nil { + return fmt.Errorf("failed to get series from Sonarr: %w", err) } - } - if targetSeries == nil { - // Fallback search for series using relative path + // Find the series that contains this file path + var targetSeries *sonarr.Series for _, show := range series { - showFolderName := filepath.Base(show.Path) - if strings.Contains(relativePath, showFolderName) { - slog.InfoContext(ctx, "Found series match by folder name", "series", show.Title, "folder", showFolderName) + if strings.Contains(filePath, show.Path) { targetSeries = show break } } - } - if targetSeries == nil { - slog.WarnContext(ctx, "No series found in Sonarr matching file path in library, attempting queue-based failure", - "instance", instanceName, - "file_path", filePath) - - // Fallback: search in Sonarr download queue for active/stuck imports - if err := m.failSonarrQueueItemByPath(ctx, client, filePath); err == nil { - return nil + if targetSeries == nil { + // Fallback search for series using relative path + for _, show := range series { + showFolderName := filepath.Base(show.Path) + if strings.Contains(relativePath, showFolderName) { + slog.InfoContext(ctx, "Found series match by folder name", "series", show.Title, "folder", showFolderName) + targetSeries = show + break + } + } } - return fmt.Errorf("no series found containing file path in library or queue: %s", filePath) - } + if targetSeries == nil { + slog.WarnContext(ctx, "No series found in Sonarr matching file path in library, attempting queue-based failure", + "instance", instanceName, + "file_path", filePath) - slog.InfoContext(ctx, "Found matching series, searching for episode file", - "series_title", targetSeries.Title, - "series_path", targetSeries.Path) + // Fallback: search in Sonarr download queue for active/stuck imports + if err := m.failSonarrQueueItemByPath(ctx, client, filePath); err == nil { + return nil + } - // Get all episodes for this specific series - episodes, err := client.GetSeriesEpisodesContext(ctx, &sonarr.GetEpisode{ - SeriesID: targetSeries.ID, - }) - if err != nil { - return fmt.Errorf("failed to get episodes for series %s: %w", targetSeries.Title, err) - } + return fmt.Errorf("no series found containing file path in library or queue: %s: %w", filePath, model.ErrPathMatchFailed) + } - // Get episode files for this series to find the matching file - episodeFiles, err := m.data.GetEpisodeFiles(ctx, client, instanceName, targetSeries.ID) - if err != nil { - return fmt.Errorf("failed to get episode files for series %s: %w", targetSeries.Title, err) - } + targetSeriesID = targetSeries.ID + targetSeriesTitle = targetSeries.Title - // Find the episode file with matching path - var targetEpisodeFile *sonarr.EpisodeFile - for _, episodeFile := range episodeFiles { - if episodeFile.Path == filePath { - targetEpisodeFile = episodeFile - break - } + slog.InfoContext(ctx, "Found matching series, searching for episode file", + "series_title", targetSeriesTitle, + "series_path", targetSeries.Path) - // Try match by filename - if filepath.Base(episodeFile.Path) == filepath.Base(filePath) { - slog.InfoContext(ctx, "Found Sonarr episode match by filename", "path", episodeFile.Path) - targetEpisodeFile = episodeFile - break + // Get episode files for this series to find the matching file + episodeFiles, err := m.data.GetEpisodeFiles(ctx, client, instanceName, targetSeriesID) + if err != nil { + return fmt.Errorf("failed to get episode files for series %s: %w", targetSeriesTitle, err) } - // Try match without .strm extension - if before, ok := strings.CutSuffix(filePath, ".strm"); ok { - strippedPath := before - if strings.TrimSuffix(episodeFile.Path, filepath.Ext(episodeFile.Path)) == strippedPath { + // Find the episode file with matching path + var targetEpisodeFile *sonarr.EpisodeFile + for _, episodeFile := range episodeFiles { + if episodeFile.Path == filePath { targetEpisodeFile = episodeFile break } - } - // Try match with relative path - if relativePath != "" { - strippedRelative := strings.TrimSuffix(relativePath, ".strm") - if strings.HasSuffix(episodeFile.Path, relativePath) || - strings.HasSuffix(strings.TrimSuffix(episodeFile.Path, filepath.Ext(episodeFile.Path)), strippedRelative) { - slog.InfoContext(ctx, "Found Sonarr episode match by relative path suffix", - "sonarr_path", episodeFile.Path, - "relative_path", relativePath) + // Try match by filename + if filepath.Base(episodeFile.Path) == filepath.Base(filePath) { + slog.InfoContext(ctx, "Found Sonarr episode match by filename", "path", episodeFile.Path) targetEpisodeFile = episodeFile break } + + // Try match without .strm extension + if before, ok := strings.CutSuffix(filePath, ".strm"); ok { + strippedPath := before + if strings.TrimSuffix(episodeFile.Path, filepath.Ext(episodeFile.Path)) == strippedPath { + targetEpisodeFile = episodeFile + break + } + } + + // Try match with relative path + if relativePath != "" { + strippedRelative := strings.TrimSuffix(relativePath, ".strm") + if strings.HasSuffix(episodeFile.Path, relativePath) || + strings.HasSuffix(strings.TrimSuffix(episodeFile.Path, filepath.Ext(episodeFile.Path)), strippedRelative) { + slog.InfoContext(ctx, "Found Sonarr episode match by relative path suffix", + "sonarr_path", episodeFile.Path, + "relative_path", relativePath) + targetEpisodeFile = episodeFile + break + } + } + } + + if targetEpisodeFile != nil { + targetEpisodeFileID = targetEpisodeFile.ID } } var episodeIDs []int64 - if targetEpisodeFile != nil { + // Get all episodes for this specific series + episodes, err := client.GetSeriesEpisodesContext(ctx, &sonarr.GetEpisode{ + SeriesID: targetSeriesID, + }) + if err != nil { + return fmt.Errorf("failed to get episodes for series %s: %w", targetSeriesTitle, err) + } + + if targetEpisodeFileID > 0 { // Found the file record - get episodes linked to it for _, episode := range episodes { - if episode.HasFile && episode.EpisodeFileID == targetEpisodeFile.ID { + if episode.EpisodeFileID == targetEpisodeFileID { episodeIDs = append(episodeIDs, episode.ID) } } + // Smart Repair Guard: If we had a file ID but it's no longer found linked to any episode, + // it's likely been upgraded or deleted. + if len(episodeIDs) == 0 { + slog.InfoContext(ctx, "Smart Repair Guard: Episode file ID is no longer active. Checking for newer replacements.", + "old_file_id", targetEpisodeFileID) + + // Try to find if any episode currently has a different file at the same path or with same scene name + episodeFiles, err := m.data.GetEpisodeFiles(ctx, client, instanceName, targetSeriesID) + if err == nil { + for _, ef := range episodeFiles { + if ef.Path == filePath || (metadata != nil && ef.SceneName == metadata.EpisodeFile.SceneName) { + slog.InfoContext(ctx, "Smart Repair Guard: Episode already has a different healthy file (likely upgraded). Skipping repair.", + "old_file_id", targetEpisodeFileID, + "new_file_id", ef.ID) + return model.ErrEpisodeAlreadySatisfied + } + } + } + } + if len(episodeIDs) > 0 { slog.DebugContext(ctx, "Found matching episodes by file ID", "episode_count", len(episodeIDs), - "episode_file_id", targetEpisodeFile.ID) + "episode_file_id", targetEpisodeFileID) // Try to blocklist the release associated with this file - if err := m.blocklistSonarrEpisodeFile(ctx, client, targetSeries.ID, targetEpisodeFile.ID); err != nil { + if err := m.blocklistSonarrEpisodeFile(ctx, client, targetSeriesID, targetEpisodeFileID); err != nil { slog.WarnContext(ctx, "Failed to blocklist Sonarr release", "error", err) } // Delete the existing episode file from Sonarr database - err = client.DeleteEpisodeFileContext(ctx, targetEpisodeFile.ID) + err := client.DeleteEpisodeFileContext(ctx, targetEpisodeFileID) if err != nil { slog.WarnContext(ctx, "Failed to delete episode file from Sonarr, continuing with search", "instance", instanceName, - "episode_file_id", targetEpisodeFile.ID, + "episode_file_id", targetEpisodeFileID, "error", err) } } } else { - slog.WarnContext(ctx, "Series found but no matching episode file found in Sonarr library, attempting queue-based failure", - "series", targetSeries.Title, + slog.WarnContext(ctx, "Series found but no matching episode file ID found, attempting queue-based failure", + "series", targetSeriesTitle, "file_path", filePath) // Fallback: search in Sonarr download queue @@ -779,7 +899,7 @@ func (m *Manager) triggerSonarrRescanByPath(ctx context.Context, client *sonarr. slog.InfoContext(ctx, "Successfully triggered Sonarr targeted episode search for re-download", "instance", instanceName, - "series_title", targetSeries.Title, + "series_title", targetSeriesTitle, "episode_ids", episodeIDs, "command_id", response.ID) diff --git a/internal/arrs/service.go b/internal/arrs/service.go index a02c38e12..29f13ebb6 100644 --- a/internal/arrs/service.go +++ b/internal/arrs/service.go @@ -173,8 +173,13 @@ func (s *Service) CleanupQueue(ctx context.Context) error { } // TriggerFileRescan triggers a rescan for a specific file path through the appropriate ARR instance -func (s *Service) TriggerFileRescan(ctx context.Context, pathForRescan string, relativePath string) error { - return s.scanner.TriggerFileRescan(ctx, pathForRescan, relativePath) +func (s *Service) TriggerFileRescan(ctx context.Context, pathForRescan string, relativePath string, metadataStr *string) error { + return s.scanner.TriggerFileRescan(ctx, pathForRescan, relativePath, metadataStr) +} + +// DiscoverFileMetadata attempts to discover the rich metadata for a file through the appropriate ARR instance +func (s *Service) DiscoverFileMetadata(ctx context.Context, filePath, relativePath, nzbName, libraryPath string) (*model.WebhookMetadata, error) { + return s.scanner.DiscoverFileMetadata(ctx, filePath, relativePath, nzbName, libraryPath) } // TriggerScanForFile finds the ARR instance managing the file and triggers a download scan on it. diff --git a/internal/database/health_repository.go b/internal/database/health_repository.go index c35f1d9d0..f1828f943 100644 --- a/internal/database/health_repository.go +++ b/internal/database/health_repository.go @@ -87,6 +87,7 @@ func (r *HealthRepository) GetFileHealth(ctx context.Context, filePath string) ( repair_retry_count, max_repair_retries, source_nzb_path, error_details, created_at, updated_at, release_date, priority, streaming_failure_count, is_masked + , metadata FROM file_health WHERE file_path = ? ` @@ -99,6 +100,7 @@ func (r *HealthRepository) GetFileHealth(ctx context.Context, filePath string) ( &health.SourceNzbPath, &health.ErrorDetails, &health.CreatedAt, &health.UpdatedAt, &health.ReleaseDate, &health.Priority, &health.StreamingFailureCount, &health.IsMasked, + &health.Metadata, ) if err != nil { if err == sql.ErrNoRows { @@ -117,6 +119,7 @@ func (r *HealthRepository) GetFileHealthByID(ctx context.Context, id int64) (*Fi repair_retry_count, max_repair_retries, source_nzb_path, error_details, created_at, updated_at, release_date, priority, streaming_failure_count, is_masked + , metadata FROM file_health WHERE id = ? ` @@ -129,6 +132,7 @@ func (r *HealthRepository) GetFileHealthByID(ctx context.Context, id int64) (*Fi &health.SourceNzbPath, &health.ErrorDetails, &health.CreatedAt, &health.UpdatedAt, &health.ReleaseDate, &health.Priority, &health.StreamingFailureCount, &health.IsMasked, + &health.Metadata, ) if err != nil { if err == sql.ErrNoRows { @@ -189,6 +193,7 @@ func (r *HealthRepository) GetUnhealthyFiles(ctx context.Context, limit int, str repair_retry_count, max_repair_retries, source_nzb_path, error_details, created_at, updated_at, release_date, scheduled_check_at, library_path, priority, streaming_failure_count, is_masked + , metadata FROM file_health WHERE scheduled_check_at IS NOT NULL AND scheduled_check_at <= datetime('now') @@ -233,6 +238,7 @@ func (r *HealthRepository) GetUnhealthyFiles(ctx context.Context, limit int, str &health.Priority, &health.StreamingFailureCount, &health.IsMasked, + &health.Metadata, ) if err != nil { return nil, fmt.Errorf("failed to scan file health: %w", err) @@ -270,6 +276,7 @@ func (r *HealthRepository) GetFilesForRepairNotification(ctx context.Context, li SELECT id, file_path, status, last_checked, last_error, retry_count, max_retries, repair_retry_count, max_repair_retries, source_nzb_path, error_details, created_at, updated_at + , metadata FROM file_health WHERE status = 'repair_triggered' AND repair_retry_count < max_repair_retries @@ -292,7 +299,7 @@ func (r *HealthRepository) GetFilesForRepairNotification(ctx context.Context, li &health.LastError, &health.RetryCount, &health.MaxRetries, &health.RepairRetryCount, &health.MaxRepairRetries, &health.SourceNzbPath, &health.ErrorDetails, - &health.CreatedAt, &health.UpdatedAt, + &health.CreatedAt, &health.UpdatedAt, &health.Metadata, ) if err != nil { return nil, fmt.Errorf("failed to scan file health for repair notification: %w", err) @@ -682,19 +689,19 @@ func (r *HealthRepository) RegisterCorruptedFile(ctx context.Context, filePath s // AddFileToHealthCheck adds a file to the health database for checking func (r *HealthRepository) AddFileToHealthCheck(ctx context.Context, filePath string, libraryPath *string, maxRetries int, maxRepairRetries int, sourceNzbPath *string, priority HealthPriority) error { - return r.AddFileToHealthCheckWithMetadata(ctx, filePath, libraryPath, maxRetries, maxRepairRetries, sourceNzbPath, priority, nil) + return r.AddFileToHealthCheckWithMetadata(ctx, filePath, libraryPath, maxRetries, maxRepairRetries, sourceNzbPath, priority, nil, nil) } // AddFileToHealthCheckWithMetadata adds a file to the health database for checking with metadata -func (r *HealthRepository) AddFileToHealthCheckWithMetadata(ctx context.Context, filePath string, libraryPath *string, maxRetries int, maxRepairRetries int, sourceNzbPath *string, priority HealthPriority, releaseDate *time.Time) error { +func (r *HealthRepository) AddFileToHealthCheckWithMetadata(ctx context.Context, filePath string, libraryPath *string, maxRetries int, maxRepairRetries int, sourceNzbPath *string, priority HealthPriority, releaseDate *time.Time, metadata *string) error { var releaseDateStr any = nil if releaseDate != nil { releaseDateStr = releaseDate.UTC().Format("2006-01-02 15:04:05") } query := ` - INSERT INTO file_health (file_path, library_path, status, last_checked, retry_count, max_retries, repair_retry_count, max_repair_retries, source_nzb_path, priority, release_date, created_at, updated_at, scheduled_check_at) - VALUES (?, ?, ?, datetime('now'), 0, ?, 0, ?, ?, ?, ?, datetime('now'), datetime('now'), datetime('now')) + INSERT INTO file_health (file_path, library_path, status, last_checked, retry_count, max_retries, repair_retry_count, max_repair_retries, source_nzb_path, priority, release_date, metadata, created_at, updated_at, scheduled_check_at) + VALUES (?, ?, ?, datetime('now'), 0, ?, 0, ?, ?, ?, ?, ?, datetime('now'), datetime('now'), datetime('now')) ON CONFLICT(file_path) DO UPDATE SET library_path = COALESCE(excluded.library_path, library_path), @@ -708,11 +715,12 @@ func (r *HealthRepository) AddFileToHealthCheckWithMetadata(ctx context.Context, source_nzb_path = COALESCE(excluded.source_nzb_path, source_nzb_path), priority = excluded.priority, release_date = COALESCE(excluded.release_date, release_date), + metadata = COALESCE(excluded.metadata, metadata), updated_at = datetime('now'), scheduled_check_at = datetime('now') ` - _, err := r.db.ExecContext(ctx, query, filePath, libraryPath, HealthStatusPending, maxRetries, maxRepairRetries, sourceNzbPath, priority, releaseDateStr) + _, err := r.db.ExecContext(ctx, query, filePath, libraryPath, HealthStatusPending, maxRetries, maxRepairRetries, sourceNzbPath, priority, releaseDateStr, metadata) if err != nil { return fmt.Errorf("failed to add file to health check: %w", err) @@ -750,6 +758,7 @@ func (r *HealthRepository) ListHealthItems(ctx context.Context, statusFilter *He repair_retry_count, max_repair_retries, source_nzb_path, error_details, created_at, updated_at, scheduled_check_at, library_path, streaming_failure_count, is_masked + , metadata FROM file_health WHERE (? IS NULL OR status = ?) AND (? IS NULL OR created_at >= ?) @@ -795,6 +804,7 @@ func (r *HealthRepository) ListHealthItems(ctx context.Context, statusFilter *He &health.SourceNzbPath, &health.ErrorDetails, &health.CreatedAt, &health.UpdatedAt, &health.ScheduledCheckAt, &health.LibraryPath, &health.StreamingFailureCount, &health.IsMasked, + &health.Metadata, ) if err != nil { return nil, fmt.Errorf("failed to scan health item: %w", err) @@ -1249,6 +1259,7 @@ type HealthStatusUpdate struct { type BackfillRecord struct { ID int64 FilePath string + Metadata *string } // BackfillUpdate represents an update for release date backfilling @@ -1343,6 +1354,7 @@ func (r *HealthRepository) GetAllHealthCheckRecords(ctx context.Context) ([]Auto func (r *HealthRepository) GetFilesMissingReleaseDate(ctx context.Context, limit int) ([]BackfillRecord, error) { query := ` SELECT id, file_path + , metadata FROM file_health WHERE release_date IS NULL LIMIT ? @@ -1357,7 +1369,7 @@ func (r *HealthRepository) GetFilesMissingReleaseDate(ctx context.Context, limit var records []BackfillRecord for rows.Next() { var rec BackfillRecord - if err := rows.Scan(&rec.ID, &rec.FilePath); err != nil { + if err := rows.Scan(&rec.ID, &rec.FilePath, &rec.Metadata); err != nil { return nil, err } records = append(records, rec) @@ -1533,6 +1545,7 @@ func (r *HealthRepository) GetFilesWithoutLibraryPath(ctx context.Context) ([]*F SELECT id, file_path, library_path, status, last_checked, last_error, retry_count, max_retries, repair_retry_count, max_repair_retries, source_nzb_path, error_details, created_at, updated_at, release_date, priority + , metadata FROM file_health WHERE library_path IS NULL ORDER BY file_path ASC @@ -1553,6 +1566,7 @@ func (r *HealthRepository) GetFilesWithoutLibraryPath(ctx context.Context) ([]*F &health.RepairRetryCount, &health.MaxRepairRetries, &health.SourceNzbPath, &health.ErrorDetails, &health.CreatedAt, &health.UpdatedAt, &health.ReleaseDate, &health.Priority, + &health.Metadata, ) if err != nil { return nil, fmt.Errorf("failed to scan file health: %w", err) @@ -1629,7 +1643,7 @@ func (r *HealthRepository) RenameHealthRecord(ctx context.Context, oldPath, newP // RelinkFileByFilename updates the file_path and library_path for a record that matches by filename. // This is typically called by webhooks during renames or downloads to provide a definitive library path. -func (r *HealthRepository) RelinkFileByFilename(ctx context.Context, filename, filePath, libraryPath string) (bool, error) { +func (r *HealthRepository) RelinkFileByFilename(ctx context.Context, filename, filePath, libraryPath string, metadataStr *string) (bool, error) { filePath = strings.TrimPrefix(filePath, "/") query := ` UPDATE file_health @@ -1638,13 +1652,14 @@ func (r *HealthRepository) RelinkFileByFilename(ctx context.Context, filename, f status = 'pending', last_error = NULL, error_details = NULL, + metadata = COALESCE(?, metadata), updated_at = datetime('now'), scheduled_check_at = datetime('now') WHERE (file_path LIKE ? OR file_path = ? OR library_path LIKE ? OR library_path = ?) ` likePattern := "%/" + filename - res, err := r.db.ExecContext(ctx, query, filePath, libraryPath, likePattern, filename, likePattern, libraryPath) + res, err := r.db.ExecContext(ctx, query, filePath, libraryPath, metadataStr, likePattern, filename, likePattern, libraryPath) if err != nil { return false, fmt.Errorf("failed to relink file by filename: %w", err) } @@ -1705,6 +1720,7 @@ func (r *HealthRepository) GetFilesByPaths(ctx context.Context, filePaths []stri SELECT id, file_path, library_path, status, last_checked, last_error, retry_count, max_retries, repair_retry_count, max_repair_retries, source_nzb_path, error_details, created_at, updated_at, release_date, priority + , metadata FROM file_health WHERE file_path IN (%s) ORDER BY file_path ASC @@ -1724,6 +1740,7 @@ func (r *HealthRepository) GetFilesByPaths(ctx context.Context, filePaths []stri &health.LastError, &health.RetryCount, &health.MaxRetries, &health.RepairRetryCount, &health.MaxRepairRetries, &health.SourceNzbPath, &health.ErrorDetails, &health.CreatedAt, &health.UpdatedAt, &health.ReleaseDate, &health.Priority, + &health.Metadata, ) if err != nil { return nil, fmt.Errorf("failed to scan file health: %w", err) @@ -1740,6 +1757,7 @@ func (r *HealthRepository) GetFilesForLibrarySync(ctx context.Context) ([]*FileH SELECT id, file_path, library_path, status, last_checked, last_error, retry_count, max_retries, repair_retry_count, max_repair_retries, source_nzb_path, error_details, created_at, updated_at, release_date, priority + , metadata FROM file_health ORDER BY file_path ASC ` @@ -1759,6 +1777,7 @@ func (r *HealthRepository) GetFilesForLibrarySync(ctx context.Context) ([]*FileH &health.RepairRetryCount, &health.MaxRepairRetries, &health.SourceNzbPath, &health.ErrorDetails, &health.CreatedAt, &health.UpdatedAt, &health.ReleaseDate, &health.Priority, + &health.Metadata, ) if err != nil { return nil, fmt.Errorf("failed to scan file health: %w", err) @@ -1788,3 +1807,15 @@ func (r *HealthRepository) HasImportHistoryForPath(ctx context.Context, virtualP } return true, nil } + +// UpdateFileMetadata updates the metadata column for a health record +func (r *HealthRepository) UpdateFileMetadata(ctx context.Context, id int64, metadata string) error { + query := ` + UPDATE file_health + SET metadata = ?, + updated_at = datetime('now') + WHERE id = ? + ` + _, err := r.db.ExecContext(ctx, query, metadata, id) + return err +} diff --git a/internal/database/health_repository_test.go b/internal/database/health_repository_test.go index 7683c9ab3..2dc15dad1 100644 --- a/internal/database/health_repository_test.go +++ b/internal/database/health_repository_test.go @@ -29,6 +29,7 @@ func setupTestDB(t *testing.T) *HealthRepository { max_repair_retries INTEGER DEFAULT 3, source_nzb_path TEXT, error_details TEXT, + metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, release_date DATETIME, @@ -269,7 +270,7 @@ func TestRelinkFileByFilename_UpdatesAnyStatus(t *testing.T) { require.NoError(t, err) // 2. Perform Relink - relinked, err := repo.RelinkFileByFilename(ctx, fileName, newPath, libPath) + relinked, err := repo.RelinkFileByFilename(ctx, fileName, newPath, libPath, nil) require.NoError(t, err) assert.True(t, relinked, "Should have relinked the healthy record") @@ -290,7 +291,7 @@ func TestRelinkFileByFilename_UpdatesAnyStatus(t *testing.T) { err = repo.UpdateFileHealth(ctx, corruptedFile, HealthStatusCorrupted, nil, nil, nil, false) require.NoError(t, err) - relinked, err = repo.RelinkFileByFilename(ctx, "Matrix.mkv", corruptedFile, "/lib/Matrix.mkv") + relinked, err = repo.RelinkFileByFilename(ctx, "Matrix.mkv", corruptedFile, "/lib/Matrix.mkv", nil) require.NoError(t, err) assert.True(t, relinked) } @@ -306,7 +307,7 @@ func TestAddFileToHealthCheckWithMetadata_StoresLibraryPath(t *testing.T) { sourceNzb := "Dune.nzb" // Add the file - err := repo.AddFileToHealthCheckWithMetadata(ctx, filePath, &libraryPath, 3, 3, &sourceNzb, HealthPriorityNormal, nil) + err := repo.AddFileToHealthCheckWithMetadata(ctx, filePath, &libraryPath, 3, 3, &sourceNzb, HealthPriorityNormal, nil, nil) require.NoError(t, err) // Verify it was stored diff --git a/internal/database/migrations/026_add_metadata_to_file_health.sql b/internal/database/migrations/026_add_metadata_to_file_health.sql new file mode 100644 index 000000000..b7e2bcc48 --- /dev/null +++ b/internal/database/migrations/026_add_metadata_to_file_health.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE file_health ADD COLUMN metadata TEXT DEFAULT NULL; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE file_health DROP COLUMN metadata; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/database/migrations/postgres/026_add_metadata_to_file_health.sql b/internal/database/migrations/postgres/026_add_metadata_to_file_health.sql new file mode 100644 index 000000000..d515748d7 --- /dev/null +++ b/internal/database/migrations/postgres/026_add_metadata_to_file_health.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- +goose StatementBegin +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='file_health' AND column_name='metadata') THEN + ALTER TABLE file_health ADD COLUMN metadata TEXT DEFAULT NULL; + END IF; +END $$; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE file_health DROP COLUMN IF EXISTS metadata; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/database/migrations/sqlite/026_add_metadata_to_file_health.sql b/internal/database/migrations/sqlite/026_add_metadata_to_file_health.sql new file mode 100644 index 000000000..b7e2bcc48 --- /dev/null +++ b/internal/database/migrations/sqlite/026_add_metadata_to_file_health.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE file_health ADD COLUMN metadata TEXT DEFAULT NULL; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE file_health DROP COLUMN metadata; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/database/models.go b/internal/database/models.go index e9dfa9a86..02920344e 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -102,6 +102,7 @@ type FileHealth struct { MaxRepairRetries int `db:"max_repair_retries"` // Max repair retries SourceNzbPath *string `db:"source_nzb_path"` ErrorDetails *string `db:"error_details"` // JSON error details + Metadata *string `db:"metadata"` // JSON metadata CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` // Health check scheduling fields diff --git a/internal/health/library_sync_test.go b/internal/health/library_sync_test.go index e64ade86f..b1cd3f6e9 100644 --- a/internal/health/library_sync_test.go +++ b/internal/health/library_sync_test.go @@ -54,6 +54,7 @@ func TestSyncLibrary_WorkerPool(t *testing.T) { max_repair_retries INTEGER DEFAULT 3, source_nzb_path TEXT, error_details TEXT, + metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, release_date DATETIME, diff --git a/internal/health/repair_e2e_test.go b/internal/health/repair_e2e_test.go index 888b1dda5..99721f7f3 100644 --- a/internal/health/repair_e2e_test.go +++ b/internal/health/repair_e2e_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/javi11/altmount/internal/arrs" + "github.com/javi11/altmount/internal/arrs/model" "github.com/javi11/altmount/internal/config" "github.com/javi11/altmount/internal/database" "github.com/javi11/altmount/internal/importer" @@ -55,13 +56,17 @@ type triggerCall struct { relativePath string } -func (m *mockARRsService) TriggerFileRescan(_ context.Context, pathForRescan string, relativePath string) error { +func (m *mockARRsService) TriggerFileRescan(_ context.Context, pathForRescan string, relativePath string, _ *string) error { m.mu.Lock() defer m.mu.Unlock() m.calls = append(m.calls, triggerCall{pathForRescan: pathForRescan, relativePath: relativePath}) return m.returnErr } +func (m *mockARRsService) DiscoverFileMetadata(_ context.Context, _, _, _, _ string) (*model.WebhookMetadata, error) { + return nil, nil +} + // mockImportService implements importer.ImportService for testing. type mockImportService struct { importer.ImportService @@ -102,6 +107,7 @@ func newRepairTestEnv(t *testing.T, tempDir string, arrsErr error) *repairTestEn max_repair_retries INTEGER DEFAULT 3, source_nzb_path TEXT, error_details TEXT, + metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, release_date DATETIME, diff --git a/internal/health/resilience_test.go b/internal/health/resilience_test.go index 54fb9ef53..bf1d1e50e 100644 --- a/internal/health/resilience_test.go +++ b/internal/health/resilience_test.go @@ -39,6 +39,7 @@ func newResilienceDB(t *testing.T) *sql.DB { max_repair_retries INTEGER DEFAULT 3, source_nzb_path TEXT, error_details TEXT, + metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, release_date DATETIME, diff --git a/internal/health/worker.go b/internal/health/worker.go index d273d3a41..d9571cf5d 100644 --- a/internal/health/worker.go +++ b/internal/health/worker.go @@ -2,6 +2,7 @@ package health import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -12,6 +13,7 @@ import ( "time" "github.com/javi11/altmount/internal/arrs" + "github.com/javi11/altmount/internal/arrs/model" "github.com/javi11/altmount/internal/config" "github.com/javi11/altmount/internal/database" "github.com/javi11/altmount/internal/importer" @@ -24,7 +26,8 @@ import ( // ARRsRepairService abstracts the ARR repair operations needed by HealthWorker. type ARRsRepairService interface { - TriggerFileRescan(ctx context.Context, pathForRescan string, relativePath string) error + TriggerFileRescan(ctx context.Context, pathForRescan string, relativePath string, metadataStr *string) error + DiscoverFileMetadata(ctx context.Context, filePath, relativePath, nzbName, libraryPath string) (*model.WebhookMetadata, error) } // WorkerStatus represents the current status of the health worker @@ -421,6 +424,34 @@ func (hw *HealthWorker) prepareUpdateForResult(ctx context.Context, fh *database sideEffect = func() error { slog.InfoContext(ctx, "File is healthy", "file_path", fh.FilePath) + + // Discovery: If the file is healthy but missing rich metadata (IDs), attempt to discover it now. + // This gradually backfills your library as health checks occur. + if fh.Metadata == nil || *fh.Metadata == "" { + slog.DebugContext(ctx, "Missing metadata for healthy file, attempting discovery", "file_path", fh.FilePath) + relativePath := strings.TrimPrefix(fh.FilePath, "complete/") + nzbName := "" + if fh.SourceNzbPath != nil { + nzbName = filepath.Base(*fh.SourceNzbPath) + } + libPath := "" + if fh.LibraryPath != nil { + libPath = *fh.LibraryPath + } + metadata, err := hw.arrsService.DiscoverFileMetadata(ctx, fh.FilePath, relativePath, nzbName, libPath) + if err == nil && metadata != nil { + metaBytes, err := json.Marshal(metadata) + if err == nil { + slog.InfoContext(ctx, "Successfully discovered metadata during health check", + "file_path", fh.FilePath, + "instance", metadata.InstanceName) + if err := hw.healthRepo.UpdateFileMetadata(ctx, fh.ID, string(metaBytes)); err != nil { + slog.ErrorContext(ctx, "Failed to save discovered metadata", "error", err) + } + } + } + } + return hw.metadataService.UpdateFileStatus(fh.FilePath, metapb.FileStatus_FILE_STATUS_HEALTHY) } @@ -960,8 +991,9 @@ func (hw *HealthWorker) triggerFileRepair(ctx context.Context, item *database.Fi slog.InfoContext(ctx, "Triggering file repair using direct ARR API approach", "file_path", filePath) pathForRescan := hw.resolvePathForRescan(item) + metadataStr := hw.ensureMetadata(ctx, item) - err := hw.arrsService.TriggerFileRescan(ctx, pathForRescan, filePath) + err := hw.arrsService.TriggerFileRescan(ctx, pathForRescan, filePath, metadataStr) if err != nil { if errors.Is(err, arrs.ErrEpisodeAlreadySatisfied) || errors.Is(err, arrs.ErrPathMatchFailed) { slog.WarnContext(ctx, "File no longer tracked by ARR, removing from AltMount", @@ -1007,10 +1039,11 @@ func (hw *HealthWorker) triggerFileRepair(ctx context.Context, item *database.Fi func (hw *HealthWorker) retriggerFileRepair(ctx context.Context, item *database.FileHealth) (repairOutcome, error) { filePath := item.FilePath pathForRescan := hw.resolvePathForRescan(item) + metadataStr := hw.ensureMetadata(ctx, item) slog.InfoContext(ctx, "Re-triggering ARR rescan for file in repair", "file_path", filePath, "path_for_rescan", pathForRescan) - err := hw.arrsService.TriggerFileRescan(ctx, pathForRescan, filePath) + err := hw.arrsService.TriggerFileRescan(ctx, pathForRescan, filePath, metadataStr) if err != nil { if errors.Is(err, arrs.ErrEpisodeAlreadySatisfied) || errors.Is(err, arrs.ErrPathMatchFailed) { slog.WarnContext(ctx, "File no longer tracked by ARR during re-trigger, removing from AltMount", "file_path", filePath) @@ -1025,3 +1058,37 @@ func (hw *HealthWorker) retriggerFileRepair(ctx context.Context, item *database. slog.InfoContext(ctx, "Successfully re-triggered ARR rescan", "file_path", filePath) return repairOutcomeTriggered, nil } + +func (hw *HealthWorker) ensureMetadata(ctx context.Context, item *database.FileHealth) *string { + if item.Metadata != nil && *item.Metadata != "" { + return item.Metadata + } + + slog.InfoContext(ctx, "Emergency ID discovery: Missing metadata for corrupted file, attempting discovery before repair", "file_path", item.FilePath) + relativePath := strings.TrimPrefix(item.FilePath, "complete/") + nzbName := "" + if item.SourceNzbPath != nil { + nzbName = filepath.Base(*item.SourceNzbPath) + } + libPath := "" + if item.LibraryPath != nil { + libPath = *item.LibraryPath + } + metadata, err := hw.arrsService.DiscoverFileMetadata(ctx, item.FilePath, relativePath, nzbName, libPath) + if err == nil && metadata != nil { + metaBytes, err := json.Marshal(metadata) + if err == nil { + str := string(metaBytes) + slog.InfoContext(ctx, "Successfully discovered metadata during emergency discovery", + "file_path", item.FilePath, + "instance", metadata.InstanceName) + if err := hw.healthRepo.UpdateFileMetadata(ctx, item.ID, str); err != nil { + slog.ErrorContext(ctx, "Failed to save discovered metadata during emergency discovery", "error", err) + } + return &str + } + } + + slog.WarnContext(ctx, "Emergency ID discovery failed, falling back to path-based repair", "file_path", item.FilePath) + return nil +} diff --git a/internal/health/worker_test.go b/internal/health/worker_test.go index 1f86907fd..657a1beb8 100644 --- a/internal/health/worker_test.go +++ b/internal/health/worker_test.go @@ -36,6 +36,7 @@ func setupWorkerTestDB(t *testing.T) (*database.HealthRepository, *sql.DB) { max_repair_retries INTEGER DEFAULT 3, source_nzb_path TEXT, error_details TEXT, + metadata TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, release_date DATETIME, diff --git a/internal/nzbfilesystem/types.go b/internal/nzbfilesystem/types.go index bc43f0eed..5b4c61c61 100644 --- a/internal/nzbfilesystem/types.go +++ b/internal/nzbfilesystem/types.go @@ -32,7 +32,7 @@ type ActiveStream struct { // ARRsRepairService abstracts the ARR repair operations needed by the filesystem. type ARRsRepairService interface { - TriggerFileRescan(ctx context.Context, pathForRescan string, relativePath string) error + TriggerFileRescan(ctx context.Context, pathForRescan string, relativePath string, metadataStr *string) error } // StreamTracker interface for tracking active streams