Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

93 changes: 86 additions & 7 deletions internal/api/arrs_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

"github.com/gofiber/fiber/v2"
"github.com/javi11/altmount/internal/arrs/model"
"github.com/javi11/altmount/internal/database"
)

Expand Down Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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 {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you saving all the hook request metadata? can we select just some fields? does it make sense better to save it as JSON in the database to perform queries?

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
}
}
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions internal/api/health_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions internal/arrs/model/metadata.go
Original file line number Diff line number Diff line change
@@ -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"`
}
188 changes: 188 additions & 0 deletions internal/arrs/scanner/discovery.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading