From ee32d584b2ae460a5b29241ca456ea11e97cf682 Mon Sep 17 00:00:00 2001 From: javi11 Date: Sun, 19 Apr 2026 19:35:45 +0200 Subject: [PATCH 01/13] fix(parser): don't propagate IsRarArchive to non-archive sidecars (.txt etc) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The propagation loop was marking every non-PAR2 file as IsRarArchive=true when the NZB type was RarArchive, even files with no RAR magic bytes and no RAR extension (.txt, .nfo sidecars). These files were then routed to ProcessArchive and failed the import. Gate propagation on the file already being detected as an archive via the existing fileinfo.IsRarFile / Is7zFile functions or magic-byte detection (f.IsRarArchive / f.Is7zArchive). Extract into propagateArchiveType for testability. Reproducer: Fresh.Off.the.Boat.S01E12...playWEB.nzb — .txt sidecar was arriving at ProcessArchive. --- internal/importer/parser/parser.go | 46 ++++++++++++++++--------- internal/importer/parser/parser_test.go | 35 +++++++++++++++++++ 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/internal/importer/parser/parser.go b/internal/importer/parser/parser.go index 147e38a7e..8b8124bae 100644 --- a/internal/importer/parser/parser.go +++ b/internal/importer/parser/parser.go @@ -235,26 +235,13 @@ func (p *Parser) ParseFile(ctx context.Context, r io.Reader, nzbPath string, pro // Determine NZB type based on content analysis parsed.Type = p.determineNzbType(parsed.Files) - // Propagate archive type to all non-par2 files. + // Propagate archive type to confirmed archive parts only. // For split archives only the first volume contains the magic-byte header, so // Is7zArchive / IsRarArchive may be false on subsequent parts even though they // are archive parts. Correct that now that we know the NZB type. - switch parsed.Type { - case NzbType7zArchive: - for i := range parsed.Files { - f := &parsed.Files[i] - if !f.IsPar2Archive && !fileinfo.IsPar2File(f.Filename) { - f.Is7zArchive = true - } - } - case NzbTypeRarArchive: - for i := range parsed.Files { - f := &parsed.Files[i] - if !f.IsPar2Archive && !fileinfo.IsPar2File(f.Filename) { - f.IsRarArchive = true - } - } - } + // Propagation is gated on existing detection (magic bytes or extension) so that + // non-archive sidecars (.txt, .nfo, etc.) are never wrongly classified. + p.propagateArchiveType(parsed) return parsed, nil } @@ -875,6 +862,31 @@ func (p *Parser) determineNzbType(files []ParsedFile) NzbType { return NzbTypeMultiFile } +// propagateArchiveType sets the archive-type flag on non-PAR2 files that are +// confirmed archive parts. Propagation is gated on the file already being +// detected as an archive (via magic bytes or extension), preventing non-archive +// sidecars (.txt, .nfo, etc.) from being wrongly classified. +func (p *Parser) propagateArchiveType(parsed *ParsedNzb) { + switch parsed.Type { + case NzbType7zArchive: + for i := range parsed.Files { + f := &parsed.Files[i] + if !f.IsPar2Archive && !fileinfo.IsPar2File(f.Filename) && + (f.Is7zArchive || fileinfo.Is7zFile(f.Filename)) { + f.Is7zArchive = true + } + } + case NzbTypeRarArchive: + for i := range parsed.Files { + f := &parsed.Files[i] + if !f.IsPar2Archive && !fileinfo.IsPar2File(f.Filename) && + (f.IsRarArchive || fileinfo.IsRarFile(f.Filename)) { + f.IsRarArchive = true + } + } + } +} + // GetMetadata extracts metadata from the NZB head section func (p *Parser) GetMetadata(nzbXML *nzbparser.Nzb) map[string]string { metadata := make(map[string]string) diff --git a/internal/importer/parser/parser_test.go b/internal/importer/parser/parser_test.go index 9dd655df9..d1ae939b0 100644 --- a/internal/importer/parser/parser_test.go +++ b/internal/importer/parser/parser_test.go @@ -155,3 +155,38 @@ func TestDetermineNzbType_ExcludesPar2Files(t *testing.T) { }) } } + +// TestPropagateArchiveType_SkipsTxtSidecar is the regression test for +// Fresh.Off.the.Boat.S01E12 where a .txt sidecar was incorrectly marked +// IsRarArchive=true by the archive-type propagation loop. +// +// Post-PAR2 state modelled here: all RAR volumes already have real names and +// IsRarArchive=true; the .txt sidecar has IsRarArchive=false and must not be +// touched by propagation. +func TestPropagateArchiveType_SkipsTxtSidecar(t *testing.T) { + release := "Fresh.Off.the.Boat.S01E12.Dribbling.Tiger.Bounce.Pass.Dragon.1080p.DSNP.WEB-DL.DD5.1.H.264-playWEB" + parsed := &ParsedNzb{ + Type: NzbTypeRarArchive, + Files: []ParsedFile{ + {Filename: release + ".part01.rar", IsRarArchive: true}, + {Filename: release + ".part02.rar", IsRarArchive: true}, + {Filename: release + ".part03.rar", IsRarArchive: true}, + {Filename: "5a3ae665828fe76b0bb904e41d4d2429.txt", IsRarArchive: false}, + {Filename: "5a3ae665828fe76b0bb904e41d4d2429.par2", IsPar2Archive: true}, + }, + } + + p := &Parser{} + p.propagateArchiveType(parsed) + + for _, f := range parsed.Files { + switch { + case strings.HasSuffix(f.Filename, ".rar"): + assert.True(t, f.IsRarArchive, "%s must stay IsRarArchive=true", f.Filename) + case strings.HasSuffix(f.Filename, ".txt"): + assert.False(t, f.IsRarArchive, ".txt sidecar must NOT be marked IsRarArchive=true") + case strings.HasSuffix(f.Filename, ".par2"): + assert.False(t, f.IsRarArchive, "PAR2 file must NOT be marked IsRarArchive=true") + } + } +} From 129dbe4b6389911ff848042254cad23c12e6f936 Mon Sep 17 00:00:00 2001 From: javi11 Date: Sun, 19 Apr 2026 20:45:58 +0200 Subject: [PATCH 02/13] feat(api): add skip_arr_notification to ManualImportRequest --- internal/api/types.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/api/types.go b/internal/api/types.go index 705fb779a..3aace5c07 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -899,8 +899,9 @@ type ScanStatusResponse struct { // ManualImportRequest represents a request to manually import a file by path type ManualImportRequest struct { - FilePath string `json:"file_path"` - RelativePath *string `json:"relative_path,omitempty"` + FilePath string `json:"file_path"` + RelativePath *string `json:"relative_path,omitempty"` + SkipArrNotification bool `json:"skip_arr_notification,omitempty"` } // ManualImportResponse represents the response from manually importing a file From e98c4da7a1e1942571d6f93806d211c4aa440834 Mon Sep 17 00:00:00 2001 From: javi11 Date: Sun, 19 Apr 2026 20:48:45 +0200 Subject: [PATCH 03/13] feat(api): encode skip_arr_notification flag into queue item metadata --- internal/api/import_handlers.go | 14 +++++ internal/api/import_handlers_skip_arr_test.go | 58 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 internal/api/import_handlers_skip_arr_test.go diff --git a/internal/api/import_handlers.go b/internal/api/import_handlers.go index 01511d89c..9ba43acde 100644 --- a/internal/api/import_handlers.go +++ b/internal/api/import_handlers.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "fmt" "log/slog" "os" @@ -274,6 +275,19 @@ func (s *Server) handleManualImportFile(c *fiber.Ctx) error { TargetPath: targetPath, } + if req.SkipArrNotification { + type importMeta struct { + SkipARRNotification bool `json:"skip_arr_notification"` + } + b, err := json.Marshal(importMeta{SkipARRNotification: true}) + if err != nil { + slog.WarnContext(c.Context(), "Failed to marshal skip_arr_notification metadata", "error", err) + } else { + meta := string(b) + item.Metadata = &meta + } + } + slog.DebugContext(c.Context(), "Adding file to queue", "file", req.FilePath, "relative_path", req.RelativePath, "target_path", targetPath) err = s.queueRepo.AddToQueue(c.Context(), item) diff --git a/internal/api/import_handlers_skip_arr_test.go b/internal/api/import_handlers_skip_arr_test.go new file mode 100644 index 000000000..047a39901 --- /dev/null +++ b/internal/api/import_handlers_skip_arr_test.go @@ -0,0 +1,58 @@ +package api + +import ( + "encoding/json" + "testing" + + "github.com/javi11/altmount/internal/database" +) + +func TestManualImportRequest_SkipArrNotificationEncoding(t *testing.T) { + req := ManualImportRequest{ + SkipArrNotification: true, + } + + // Simulate what the handler does: encode into metadata when flag is true + type importMeta struct { + SkipARRNotification bool `json:"skip_arr_notification"` + } + + var item database.ImportQueueItem + if req.SkipArrNotification { + b, err := json.Marshal(importMeta{SkipARRNotification: true}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + s := string(b) + item.Metadata = &s + } + + if item.Metadata == nil { + t.Fatal("expected Metadata to be set") + } + + var got importMeta + if err := json.Unmarshal([]byte(*item.Metadata), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !got.SkipARRNotification { + t.Error("expected skip_arr_notification to be true") + } +} + +func TestManualImportRequest_SkipArrNotification_FalseByDefault(t *testing.T) { + req := ManualImportRequest{} + + var item database.ImportQueueItem + if req.SkipArrNotification { + b, _ := json.Marshal(struct { + SkipARRNotification bool `json:"skip_arr_notification"` + }{SkipARRNotification: true}) + s := string(b) + item.Metadata = &s + } + + if item.Metadata != nil { + t.Error("expected Metadata to be nil when SkipArrNotification is false") + } +} From 21c43708257788097f423d2d1718951729b8a2f4 Mon Sep 17 00:00:00 2001 From: javi11 Date: Sun, 19 Apr 2026 20:54:10 +0200 Subject: [PATCH 04/13] feat(importer): skip ARR notification when skip_arr_notification metadata flag is set --- .../importer/postprocessor/coordinator.go | 22 +++- .../coordinator_skip_arr_test.go | 118 ++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 internal/importer/postprocessor/coordinator_skip_arr_test.go diff --git a/internal/importer/postprocessor/coordinator.go b/internal/importer/postprocessor/coordinator.go index 62f213a7e..f365c556d 100644 --- a/internal/importer/postprocessor/coordinator.go +++ b/internal/importer/postprocessor/coordinator.go @@ -5,6 +5,7 @@ package postprocessor import ( "context" + "encoding/json" "log/slog" "sync" "time" @@ -134,7 +135,11 @@ func (c *Coordinator) HandleSuccess(ctx context.Context, item *database.ImportQu } // 6. Notify ARR applications - if err := c.notifyARRWith(ctx, arrsService, item, resultingPath); err != nil { + if shouldSkipARRNotification(item) { + c.log.DebugContext(ctx, "ARR notification skipped (requested by caller)", + "queue_id", item.ID, + "path", resultingPath) + } else if err := c.notifyARRWith(ctx, arrsService, item, resultingPath); err != nil { c.log.DebugContext(ctx, "ARR notification not sent", "path", resultingPath, "error", err) @@ -157,3 +162,18 @@ func (c *Coordinator) HandleFailure(ctx context.Context, item *database.ImportQu return errors.ErrFallbackNotConfigured } + +// shouldSkipARRNotification decodes the item metadata and returns true when +// the caller explicitly requested that ARR notifications be suppressed. +func shouldSkipARRNotification(item *database.ImportQueueItem) bool { + if item.Metadata == nil || *item.Metadata == "" { + return false + } + var meta struct { + SkipARRNotification bool `json:"skip_arr_notification"` + } + if err := json.Unmarshal([]byte(*item.Metadata), &meta); err != nil { + return false + } + return meta.SkipARRNotification +} diff --git a/internal/importer/postprocessor/coordinator_skip_arr_test.go b/internal/importer/postprocessor/coordinator_skip_arr_test.go new file mode 100644 index 000000000..a4086e6c5 --- /dev/null +++ b/internal/importer/postprocessor/coordinator_skip_arr_test.go @@ -0,0 +1,118 @@ +package postprocessor + +import ( + "context" + "encoding/json" + "testing" + + "github.com/javi11/altmount/internal/config" + "github.com/javi11/altmount/internal/database" + "github.com/javi11/altmount/internal/metadata" +) + +// TestSkipARRNotificationFromMetadata verifies the metadata decode logic in isolation. +func TestSkipARRNotificationFromMetadata(t *testing.T) { + otherMeta := `{"nzbdav_id":"abc123"}` + otherMetaWithFlag := `{"nzbdav_id":"abc123","skip_arr_notification":true}` + + tests := []struct { + name string + metadata *string + want bool + }{ + { + name: "nil metadata → do not skip", + metadata: nil, + want: false, + }, + { + name: "empty string → do not skip", + metadata: new(string), + want: false, + }, + { + name: "flag false → do not skip", + metadata: jsonMeta(struct { + SkipARRNotification bool `json:"skip_arr_notification"` + }{SkipARRNotification: false}), + want: false, + }, + { + name: "flag true → skip", + metadata: jsonMeta(struct { + SkipARRNotification bool `json:"skip_arr_notification"` + }{SkipARRNotification: true}), + want: true, + }, + { + name: "other metadata fields, no flag → do not skip", + metadata: &otherMeta, + want: false, + }, + { + name: "other metadata fields + flag true → skip", + metadata: &otherMetaWithFlag, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + item := &database.ImportQueueItem{Metadata: tt.metadata} + got := shouldSkipARRNotification(item) + if got != tt.want { + t.Errorf("shouldSkipARRNotification() = %v, want %v", got, tt.want) + } + }) + } +} + +func jsonMeta(v any) *string { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + s := string(b) + return &s +} + +// TestCoordinator_HandleSuccess_SkipsARRNotification verifies that HandleSuccess +// runs without error and leaves ARRNotified=false when skip_arr_notification is set. +// +// Note: arrs.Service requires concrete DB dependencies so it is left nil here. +// Because notifyARRWith guards on nil arrsService, ARRNotified would be false +// regardless of the skip check. Call-site regression safety (i.e. that the +// shouldSkipARRNotification guard is actually reached) is covered by +// TestSkipARRNotificationFromMetadata, which tests the helper directly. +// +// This test runs in ~1s due to the FUSE mount propagation delay in HandleSuccess. +func TestCoordinator_HandleSuccess_SkipsARRNotification(t *testing.T) { + meta := `{"skip_arr_notification":true}` + item := &database.ImportQueueItem{ + ID: 1, + Metadata: &meta, + } + + // MountTypeNone → notifyVFSWith returns early. + // ImportStrategy "" → CreateSymlinks and CreateStrmFiles return early. + cfg := &config.Config{MountType: config.MountTypeNone} + configGetter := func() *config.Config { return cfg } + + // t.TempDir() gives a real directory so metadata walks succeed without panic. + // HealthRepo nil → ScheduleHealthCheck is skipped. + metaSvc := metadata.NewMetadataService(t.TempDir()) + + coord := NewCoordinator(Config{ + ConfigGetter: configGetter, + MetadataService: metaSvc, + }) + + result, err := coord.HandleSuccess(context.Background(), item, "/some/path") + if err != nil { + t.Fatalf("HandleSuccess returned unexpected error: %v", err) + } + if result.ARRNotified { + t.Error("expected ARRNotified to be false when skip_arr_notification is set, got true") + } +} + From dcf70219d587d46eaf527d9e3d02fbce5cd33b86 Mon Sep 17 00:00:00 2001 From: javi11 Date: Sun, 19 Apr 2026 21:13:26 +0200 Subject: [PATCH 05/13] refactor(api): use database.ImportQueueMetadata type instead of inline structs --- internal/api/import_handlers.go | 5 +---- internal/api/import_handlers_skip_arr_test.go | 14 ++++---------- internal/database/models.go | 7 +++++++ internal/importer/postprocessor/coordinator.go | 4 +--- .../postprocessor/coordinator_skip_arr_test.go | 12 ++++-------- 5 files changed, 17 insertions(+), 25 deletions(-) diff --git a/internal/api/import_handlers.go b/internal/api/import_handlers.go index 9ba43acde..46037463d 100644 --- a/internal/api/import_handlers.go +++ b/internal/api/import_handlers.go @@ -276,10 +276,7 @@ func (s *Server) handleManualImportFile(c *fiber.Ctx) error { } if req.SkipArrNotification { - type importMeta struct { - SkipARRNotification bool `json:"skip_arr_notification"` - } - b, err := json.Marshal(importMeta{SkipARRNotification: true}) + b, err := json.Marshal(database.ImportQueueMetadata{SkipARRNotification: true}) if err != nil { slog.WarnContext(c.Context(), "Failed to marshal skip_arr_notification metadata", "error", err) } else { diff --git a/internal/api/import_handlers_skip_arr_test.go b/internal/api/import_handlers_skip_arr_test.go index 047a39901..70c63b0b6 100644 --- a/internal/api/import_handlers_skip_arr_test.go +++ b/internal/api/import_handlers_skip_arr_test.go @@ -7,19 +7,15 @@ import ( "github.com/javi11/altmount/internal/database" ) + func TestManualImportRequest_SkipArrNotificationEncoding(t *testing.T) { req := ManualImportRequest{ SkipArrNotification: true, } - // Simulate what the handler does: encode into metadata when flag is true - type importMeta struct { - SkipARRNotification bool `json:"skip_arr_notification"` - } - var item database.ImportQueueItem if req.SkipArrNotification { - b, err := json.Marshal(importMeta{SkipARRNotification: true}) + b, err := json.Marshal(database.ImportQueueMetadata{SkipARRNotification: true}) if err != nil { t.Fatalf("marshal: %v", err) } @@ -31,7 +27,7 @@ func TestManualImportRequest_SkipArrNotificationEncoding(t *testing.T) { t.Fatal("expected Metadata to be set") } - var got importMeta + var got database.ImportQueueMetadata if err := json.Unmarshal([]byte(*item.Metadata), &got); err != nil { t.Fatalf("unmarshal: %v", err) } @@ -45,9 +41,7 @@ func TestManualImportRequest_SkipArrNotification_FalseByDefault(t *testing.T) { var item database.ImportQueueItem if req.SkipArrNotification { - b, _ := json.Marshal(struct { - SkipARRNotification bool `json:"skip_arr_notification"` - }{SkipARRNotification: true}) + b, _ := json.Marshal(database.ImportQueueMetadata{SkipARRNotification: true}) s := string(b) item.Metadata = &s } diff --git a/internal/database/models.go b/internal/database/models.go index be5e56ba9..6cfd58775 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -167,3 +167,10 @@ type ImportHistory struct { Metadata *string `db:"metadata"` CompletedAt time.Time `db:"completed_at"` } + +// ImportQueueMetadata is the JSON payload stored in ImportQueueItem.Metadata. +// Fields are omitempty so partial writes don't clobber unrelated keys. +type ImportQueueMetadata struct { + NzbdavID string `json:"nzbdav_id,omitempty"` + SkipARRNotification bool `json:"skip_arr_notification,omitempty"` +} diff --git a/internal/importer/postprocessor/coordinator.go b/internal/importer/postprocessor/coordinator.go index f365c556d..3666d3348 100644 --- a/internal/importer/postprocessor/coordinator.go +++ b/internal/importer/postprocessor/coordinator.go @@ -169,9 +169,7 @@ func shouldSkipARRNotification(item *database.ImportQueueItem) bool { if item.Metadata == nil || *item.Metadata == "" { return false } - var meta struct { - SkipARRNotification bool `json:"skip_arr_notification"` - } + var meta database.ImportQueueMetadata if err := json.Unmarshal([]byte(*item.Metadata), &meta); err != nil { return false } diff --git a/internal/importer/postprocessor/coordinator_skip_arr_test.go b/internal/importer/postprocessor/coordinator_skip_arr_test.go index a4086e6c5..d7a5aad5d 100644 --- a/internal/importer/postprocessor/coordinator_skip_arr_test.go +++ b/internal/importer/postprocessor/coordinator_skip_arr_test.go @@ -32,17 +32,13 @@ func TestSkipARRNotificationFromMetadata(t *testing.T) { }, { name: "flag false → do not skip", - metadata: jsonMeta(struct { - SkipARRNotification bool `json:"skip_arr_notification"` - }{SkipARRNotification: false}), - want: false, + metadata: jsonMeta(database.ImportQueueMetadata{SkipARRNotification: false}), + want: false, }, { name: "flag true → skip", - metadata: jsonMeta(struct { - SkipARRNotification bool `json:"skip_arr_notification"` - }{SkipARRNotification: true}), - want: true, + metadata: jsonMeta(database.ImportQueueMetadata{SkipARRNotification: true}), + want: true, }, { name: "other metadata fields, no flag → do not skip", From d351c9942235abe96552e16c9ad2fd76531fb418 Mon Sep 17 00:00:00 2001 From: javi11 Date: Sun, 19 Apr 2026 21:24:04 +0200 Subject: [PATCH 06/13] feat(db): add skip_arr_notification column to import_queue --- .../postgres/023_add_skip_arr_notification.sql | 9 +++++++++ .../migrations/sqlite/023_add_skip_arr_notification.sql | 9 +++++++++ 2 files changed, 18 insertions(+) create mode 100644 internal/database/migrations/postgres/023_add_skip_arr_notification.sql create mode 100644 internal/database/migrations/sqlite/023_add_skip_arr_notification.sql diff --git a/internal/database/migrations/postgres/023_add_skip_arr_notification.sql b/internal/database/migrations/postgres/023_add_skip_arr_notification.sql new file mode 100644 index 000000000..90b86f3e7 --- /dev/null +++ b/internal/database/migrations/postgres/023_add_skip_arr_notification.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE import_queue ADD COLUMN skip_arr_notification BOOLEAN NOT NULL DEFAULT FALSE; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE import_queue DROP COLUMN IF EXISTS skip_arr_notification; +-- +goose StatementEnd diff --git a/internal/database/migrations/sqlite/023_add_skip_arr_notification.sql b/internal/database/migrations/sqlite/023_add_skip_arr_notification.sql new file mode 100644 index 000000000..8defadd66 --- /dev/null +++ b/internal/database/migrations/sqlite/023_add_skip_arr_notification.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE import_queue ADD COLUMN skip_arr_notification BOOLEAN NOT NULL DEFAULT FALSE; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- SQLite does not support DROP COLUMN in older versions; intentional no-op +-- +goose StatementEnd From c9df44e341f5c5b7f2e9032ab9f676dbe07c16df Mon Sep 17 00:00:00 2001 From: javi11 Date: Sun, 19 Apr 2026 21:30:19 +0200 Subject: [PATCH 07/13] feat(database): add SkipArrNotification field to ImportQueueItem --- internal/database/models.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/database/models.go b/internal/database/models.go index 6cfd58775..3af2cb5d2 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -44,8 +44,9 @@ type ImportQueueItem struct { ErrorMessage *string `db:"error_message"` BatchID *string `db:"batch_id"` Metadata *string `db:"metadata"` // JSON metadata - FileSize *int64 `db:"file_size"` // Total size in bytes calculated from segments - TargetPath *string `db:"target_path"` // Optional forced symlink destination path + FileSize *int64 `db:"file_size"` // Total size in bytes calculated from segments + TargetPath *string `db:"target_path"` // Optional forced symlink destination path + SkipArrNotification bool `db:"skip_arr_notification"` } // BulkOperationResult represents the result of a bulk queue operation @@ -171,6 +172,5 @@ type ImportHistory struct { // ImportQueueMetadata is the JSON payload stored in ImportQueueItem.Metadata. // Fields are omitempty so partial writes don't clobber unrelated keys. type ImportQueueMetadata struct { - NzbdavID string `json:"nzbdav_id,omitempty"` - SkipARRNotification bool `json:"skip_arr_notification,omitempty"` + NzbdavID string `json:"nzbdav_id,omitempty"` } From 2d003a21a5693d3b4f0d34500edfbbfef24c43ea Mon Sep 17 00:00:00 2001 From: javi11 Date: Sun, 19 Apr 2026 23:29:56 +0200 Subject: [PATCH 08/13] feat(database): wire skip_arr_notification through all queue repository queries --- internal/database/queue_repository.go | 28 +++++++++++++-------------- internal/database/testing.go | 1 + 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/internal/database/queue_repository.go b/internal/database/queue_repository.go index 3172377fd..f827ce581 100644 --- a/internal/database/queue_repository.go +++ b/internal/database/queue_repository.go @@ -110,8 +110,8 @@ func (r *QueueRepository) RestartQueueItemsBulk(ctx context.Context, ids []int64 // AddToQueue adds a new NZB file to the import queue func (r *QueueRepository) AddToQueue(ctx context.Context, item *ImportQueueItem) error { query := ` - INSERT INTO import_queue (download_id, nzb_path, relative_path, category, priority, status, retry_count, max_retries, batch_id, metadata, file_size, target_path, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + INSERT INTO import_queue (download_id, nzb_path, relative_path, category, priority, status, retry_count, max_retries, batch_id, metadata, file_size, target_path, skip_arr_notification, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) ON CONFLICT(nzb_path) DO UPDATE SET download_id = COALESCE(excluded.download_id, import_queue.download_id), priority = CASE WHEN excluded.priority < priority THEN excluded.priority ELSE priority END, @@ -129,7 +129,7 @@ func (r *QueueRepository) AddToQueue(ctx context.Context, item *ImportQueueItem) ` args := []any{item.DownloadID, item.NzbPath, item.RelativePath, item.Category, item.Priority, item.Status, - item.RetryCount, item.MaxRetries, item.BatchID, item.Metadata, item.FileSize, item.TargetPath} + item.RetryCount, item.MaxRetries, item.BatchID, item.Metadata, item.FileSize, item.TargetPath, item.SkipArrNotification} if r.dialect.IsPostgres() { err := r.db.QueryRowContext(ctx, query+" RETURNING id", args...).Scan(&item.ID) @@ -232,7 +232,7 @@ func (r *QueueRepository) ClaimNextQueueItem(ctx context.Context) (*ImportQueueI // Get the complete claimed item data getQuery := ` SELECT id, download_id, nzb_path, relative_path, category, priority, status, created_at, updated_at, - started_at, completed_at, retry_count, max_retries, error_message, batch_id, metadata, file_size, target_path + started_at, completed_at, retry_count, max_retries, error_message, batch_id, metadata, file_size, target_path, skip_arr_notification FROM import_queue WHERE id = ? ` @@ -241,7 +241,7 @@ func (r *QueueRepository) ClaimNextQueueItem(ctx context.Context) (*ImportQueueI err = txRepo.db.QueryRowContext(ctx, getQuery, itemID).Scan( &item.ID, &item.DownloadID, &item.NzbPath, &item.RelativePath, &item.Category, &item.Priority, &item.Status, &item.CreatedAt, &item.UpdatedAt, &item.StartedAt, &item.CompletedAt, - &item.RetryCount, &item.MaxRetries, &item.ErrorMessage, &item.BatchID, &item.Metadata, &item.FileSize, &item.TargetPath, + &item.RetryCount, &item.MaxRetries, &item.ErrorMessage, &item.BatchID, &item.Metadata, &item.FileSize, &item.TargetPath, &item.SkipArrNotification, ) if err != nil { return fmt.Errorf("failed to get claimed item: %w", err) @@ -546,7 +546,7 @@ func (r *QueueRepository) UpdateQueueItemNzbPath(ctx context.Context, id int64, func (r *QueueRepository) GetQueueItemByNzbPath(ctx context.Context, nzbPath string) (*ImportQueueItem, error) { query := ` SELECT id, download_id, nzb_path, relative_path, category, priority, status, created_at, updated_at, - started_at, completed_at, retry_count, max_retries, error_message, batch_id, metadata, file_size, storage_path, target_path + started_at, completed_at, retry_count, max_retries, error_message, batch_id, metadata, file_size, storage_path, target_path, skip_arr_notification FROM import_queue WHERE nzb_path = ? LIMIT 1 ` @@ -554,7 +554,7 @@ func (r *QueueRepository) GetQueueItemByNzbPath(ctx context.Context, nzbPath str err := r.db.QueryRowContext(ctx, query, nzbPath).Scan( &item.ID, &item.DownloadID, &item.NzbPath, &item.RelativePath, &item.Category, &item.Priority, &item.Status, &item.CreatedAt, &item.UpdatedAt, &item.StartedAt, &item.CompletedAt, - &item.RetryCount, &item.MaxRetries, &item.ErrorMessage, &item.BatchID, &item.Metadata, &item.FileSize, &item.StoragePath, &item.TargetPath, + &item.RetryCount, &item.MaxRetries, &item.ErrorMessage, &item.BatchID, &item.Metadata, &item.FileSize, &item.StoragePath, &item.TargetPath, &item.SkipArrNotification, ) if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -633,8 +633,8 @@ func (r *QueueRepository) AddBatchToQueue(ctx context.Context, items []*ImportQu return r.withQueueTransaction(ctx, func(txRepo *QueueRepository) error { // Prepare batch insert statement query := ` - INSERT INTO import_queue (download_id, nzb_path, relative_path, category, priority, status, retry_count, max_retries, batch_id, metadata, file_size, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + INSERT INTO import_queue (download_id, nzb_path, relative_path, category, priority, status, retry_count, max_retries, batch_id, metadata, file_size, skip_arr_notification, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) ON CONFLICT(nzb_path) DO UPDATE SET download_id = COALESCE(excluded.download_id, import_queue.download_id), priority = CASE WHEN excluded.priority < priority THEN excluded.priority ELSE priority END, @@ -649,7 +649,7 @@ func (r *QueueRepository) AddBatchToQueue(ctx context.Context, items []*ImportQu now := time.Now() for _, item := range items { args := []any{item.DownloadID, item.NzbPath, item.RelativePath, item.Category, item.Priority, item.Status, - item.RetryCount, item.MaxRetries, item.BatchID, item.Metadata, item.FileSize} + item.RetryCount, item.MaxRetries, item.BatchID, item.Metadata, item.FileSize, item.SkipArrNotification} if txRepo.dialect.IsPostgres() { err := txRepo.db.QueryRowContext(ctx, query+" RETURNING id", args...).Scan(&item.ID) @@ -678,7 +678,7 @@ func (r *QueueRepository) AddBatchToQueue(ctx context.Context, items []*ImportQu func (r *QueueRepository) GetQueueItem(ctx context.Context, id int64) (*ImportQueueItem, error) { query := ` SELECT id, download_id, nzb_path, relative_path, category, priority, status, created_at, updated_at, - started_at, completed_at, retry_count, max_retries, error_message, batch_id, metadata, file_size, storage_path, target_path + started_at, completed_at, retry_count, max_retries, error_message, batch_id, metadata, file_size, storage_path, target_path, skip_arr_notification FROM import_queue WHERE id = ? ` @@ -686,7 +686,7 @@ func (r *QueueRepository) GetQueueItem(ctx context.Context, id int64) (*ImportQu err := r.db.QueryRowContext(ctx, query, id).Scan( &item.ID, &item.DownloadID, &item.NzbPath, &item.RelativePath, &item.Category, &item.Priority, &item.Status, &item.CreatedAt, &item.UpdatedAt, &item.StartedAt, &item.CompletedAt, - &item.RetryCount, &item.MaxRetries, &item.ErrorMessage, &item.BatchID, &item.Metadata, &item.FileSize, &item.StoragePath, &item.TargetPath, + &item.RetryCount, &item.MaxRetries, &item.ErrorMessage, &item.BatchID, &item.Metadata, &item.FileSize, &item.StoragePath, &item.TargetPath, &item.SkipArrNotification, ) if err != nil { if err == sql.ErrNoRows { @@ -736,7 +736,7 @@ func (r *QueueRepository) DeleteFailedItemsOlderThan(ctx context.Context, olderT err := r.withQueueTransaction(ctx, func(txRepo *QueueRepository) error { // Select failed items older than the threshold selectQuery := `SELECT id, download_id, nzb_path, relative_path, category, priority, status, created_at, updated_at, - started_at, completed_at, retry_count, max_retries, error_message, batch_id, metadata, file_size, storage_path, target_path + started_at, completed_at, retry_count, max_retries, error_message, batch_id, metadata, file_size, storage_path, target_path, skip_arr_notification FROM import_queue WHERE status = 'failed' AND updated_at < ?` rows, err := txRepo.db.QueryContext(ctx, selectQuery, olderThan) @@ -750,7 +750,7 @@ func (r *QueueRepository) DeleteFailedItemsOlderThan(ctx context.Context, olderT if err := rows.Scan( &item.ID, &item.DownloadID, &item.NzbPath, &item.RelativePath, &item.Category, &item.Priority, &item.Status, &item.CreatedAt, &item.UpdatedAt, &item.StartedAt, &item.CompletedAt, - &item.RetryCount, &item.MaxRetries, &item.ErrorMessage, &item.BatchID, &item.Metadata, &item.FileSize, &item.StoragePath, &item.TargetPath, + &item.RetryCount, &item.MaxRetries, &item.ErrorMessage, &item.BatchID, &item.Metadata, &item.FileSize, &item.StoragePath, &item.TargetPath, &item.SkipArrNotification, ); err != nil { return fmt.Errorf("failed to scan failed queue item: %w", err) } diff --git a/internal/database/testing.go b/internal/database/testing.go index 3486db95f..7ee2726be 100644 --- a/internal/database/testing.go +++ b/internal/database/testing.go @@ -32,6 +32,7 @@ func setupQueueSchema(t *testing.T, db *sql.DB) { category TEXT DEFAULT NULL, file_size BIGINT DEFAULT NULL, target_path TEXT DEFAULT NULL, + skip_arr_notification BOOLEAN NOT NULL DEFAULT FALSE, UNIQUE(nzb_path) ); From b7449292d1214aa46103ec94a4a9fcc3ad222da5 Mon Sep 17 00:00:00 2001 From: javi11 Date: Sun, 19 Apr 2026 23:31:16 +0200 Subject: [PATCH 09/13] feat(api): set skip_arr_notification directly on queue item --- internal/api/import_handlers.go | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/internal/api/import_handlers.go b/internal/api/import_handlers.go index 46037463d..c9a5df3a6 100644 --- a/internal/api/import_handlers.go +++ b/internal/api/import_handlers.go @@ -1,7 +1,6 @@ package api import ( - "encoding/json" "fmt" "log/slog" "os" @@ -265,24 +264,15 @@ func (s *Server) handleManualImportFile(c *fiber.Ctx) error { // Add the file to the processing queue item := &database.ImportQueueItem{ - NzbPath: req.FilePath, - Priority: database.QueuePriorityNormal, - Status: database.QueueStatusPending, - RetryCount: 0, - MaxRetries: 3, - CreatedAt: time.Now(), - RelativePath: req.RelativePath, - TargetPath: targetPath, - } - - if req.SkipArrNotification { - b, err := json.Marshal(database.ImportQueueMetadata{SkipARRNotification: true}) - if err != nil { - slog.WarnContext(c.Context(), "Failed to marshal skip_arr_notification metadata", "error", err) - } else { - meta := string(b) - item.Metadata = &meta - } + NzbPath: req.FilePath, + Priority: database.QueuePriorityNormal, + Status: database.QueueStatusPending, + RetryCount: 0, + MaxRetries: 3, + CreatedAt: time.Now(), + RelativePath: req.RelativePath, + TargetPath: targetPath, + SkipArrNotification: req.SkipArrNotification, } slog.DebugContext(c.Context(), "Adding file to queue", "file", req.FilePath, "relative_path", req.RelativePath, "target_path", targetPath) From 63be5e5ad046ab2034dd9df6c22a643dbb97748d Mon Sep 17 00:00:00 2001 From: javi11 Date: Sun, 19 Apr 2026 23:31:17 +0200 Subject: [PATCH 10/13] refactor(postprocessor): read skip_arr_notification from DB field directly --- internal/importer/postprocessor/coordinator.go | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/internal/importer/postprocessor/coordinator.go b/internal/importer/postprocessor/coordinator.go index 3666d3348..fb01afe30 100644 --- a/internal/importer/postprocessor/coordinator.go +++ b/internal/importer/postprocessor/coordinator.go @@ -5,7 +5,6 @@ package postprocessor import ( "context" - "encoding/json" "log/slog" "sync" "time" @@ -163,15 +162,8 @@ func (c *Coordinator) HandleFailure(ctx context.Context, item *database.ImportQu return errors.ErrFallbackNotConfigured } -// shouldSkipARRNotification decodes the item metadata and returns true when -// the caller explicitly requested that ARR notifications be suppressed. +// shouldSkipARRNotification returns true when the caller explicitly requested +// that ARR notifications be suppressed. func shouldSkipARRNotification(item *database.ImportQueueItem) bool { - if item.Metadata == nil || *item.Metadata == "" { - return false - } - var meta database.ImportQueueMetadata - if err := json.Unmarshal([]byte(*item.Metadata), &meta); err != nil { - return false - } - return meta.SkipARRNotification + return item.SkipArrNotification } From 07fbb546d01e7ca420178cfd03f4270a3bb24b7c Mon Sep 17 00:00:00 2001 From: javi11 Date: Sun, 19 Apr 2026 23:31:17 +0200 Subject: [PATCH 11/13] test: update skip_arr_notification tests to use DB field directly --- internal/api/import_handlers_skip_arr_test.go | 47 ++--------- .../coordinator_skip_arr_test.go | 83 +++---------------- 2 files changed, 17 insertions(+), 113 deletions(-) diff --git a/internal/api/import_handlers_skip_arr_test.go b/internal/api/import_handlers_skip_arr_test.go index 70c63b0b6..5f8c275f2 100644 --- a/internal/api/import_handlers_skip_arr_test.go +++ b/internal/api/import_handlers_skip_arr_test.go @@ -1,52 +1,17 @@ package api -import ( - "encoding/json" - "testing" +import "testing" - "github.com/javi11/altmount/internal/database" -) - - -func TestManualImportRequest_SkipArrNotificationEncoding(t *testing.T) { - req := ManualImportRequest{ - SkipArrNotification: true, - } - - var item database.ImportQueueItem - if req.SkipArrNotification { - b, err := json.Marshal(database.ImportQueueMetadata{SkipARRNotification: true}) - if err != nil { - t.Fatalf("marshal: %v", err) - } - s := string(b) - item.Metadata = &s - } - - if item.Metadata == nil { - t.Fatal("expected Metadata to be set") - } - - var got database.ImportQueueMetadata - if err := json.Unmarshal([]byte(*item.Metadata), &got); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if !got.SkipARRNotification { - t.Error("expected skip_arr_notification to be true") +func TestManualImportRequest_SkipArrNotification_True(t *testing.T) { + req := ManualImportRequest{SkipArrNotification: true} + if !req.SkipArrNotification { + t.Error("expected SkipArrNotification to be true") } } func TestManualImportRequest_SkipArrNotification_FalseByDefault(t *testing.T) { req := ManualImportRequest{} - - var item database.ImportQueueItem if req.SkipArrNotification { - b, _ := json.Marshal(database.ImportQueueMetadata{SkipARRNotification: true}) - s := string(b) - item.Metadata = &s - } - - if item.Metadata != nil { - t.Error("expected Metadata to be nil when SkipArrNotification is false") + t.Error("expected SkipArrNotification to be false by default") } } diff --git a/internal/importer/postprocessor/coordinator_skip_arr_test.go b/internal/importer/postprocessor/coordinator_skip_arr_test.go index d7a5aad5d..2491571bc 100644 --- a/internal/importer/postprocessor/coordinator_skip_arr_test.go +++ b/internal/importer/postprocessor/coordinator_skip_arr_test.go @@ -2,7 +2,6 @@ package postprocessor import ( "context" - "encoding/json" "testing" "github.com/javi11/altmount/internal/config" @@ -10,92 +9,33 @@ import ( "github.com/javi11/altmount/internal/metadata" ) -// TestSkipARRNotificationFromMetadata verifies the metadata decode logic in isolation. -func TestSkipARRNotificationFromMetadata(t *testing.T) { - otherMeta := `{"nzbdav_id":"abc123"}` - otherMetaWithFlag := `{"nzbdav_id":"abc123","skip_arr_notification":true}` - +func TestSkipARRNotificationFromField(t *testing.T) { tests := []struct { - name string - metadata *string - want bool + name string + flag bool + want bool }{ - { - name: "nil metadata → do not skip", - metadata: nil, - want: false, - }, - { - name: "empty string → do not skip", - metadata: new(string), - want: false, - }, - { - name: "flag false → do not skip", - metadata: jsonMeta(database.ImportQueueMetadata{SkipARRNotification: false}), - want: false, - }, - { - name: "flag true → skip", - metadata: jsonMeta(database.ImportQueueMetadata{SkipARRNotification: true}), - want: true, - }, - { - name: "other metadata fields, no flag → do not skip", - metadata: &otherMeta, - want: false, - }, - { - name: "other metadata fields + flag true → skip", - metadata: &otherMetaWithFlag, - want: true, - }, + {"false → do not skip", false, false}, + {"true → skip", true, true}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - item := &database.ImportQueueItem{Metadata: tt.metadata} - got := shouldSkipARRNotification(item) - if got != tt.want { + item := &database.ImportQueueItem{SkipArrNotification: tt.flag} + if got := shouldSkipARRNotification(item); got != tt.want { t.Errorf("shouldSkipARRNotification() = %v, want %v", got, tt.want) } }) } } -func jsonMeta(v any) *string { - b, err := json.Marshal(v) - if err != nil { - panic(err) - } - s := string(b) - return &s -} - -// TestCoordinator_HandleSuccess_SkipsARRNotification verifies that HandleSuccess -// runs without error and leaves ARRNotified=false when skip_arr_notification is set. -// -// Note: arrs.Service requires concrete DB dependencies so it is left nil here. -// Because notifyARRWith guards on nil arrsService, ARRNotified would be false -// regardless of the skip check. Call-site regression safety (i.e. that the -// shouldSkipARRNotification guard is actually reached) is covered by -// TestSkipARRNotificationFromMetadata, which tests the helper directly. -// -// This test runs in ~1s due to the FUSE mount propagation delay in HandleSuccess. func TestCoordinator_HandleSuccess_SkipsARRNotification(t *testing.T) { - meta := `{"skip_arr_notification":true}` item := &database.ImportQueueItem{ - ID: 1, - Metadata: &meta, + ID: 1, + SkipArrNotification: true, } - // MountTypeNone → notifyVFSWith returns early. - // ImportStrategy "" → CreateSymlinks and CreateStrmFiles return early. cfg := &config.Config{MountType: config.MountTypeNone} configGetter := func() *config.Config { return cfg } - - // t.TempDir() gives a real directory so metadata walks succeed without panic. - // HealthRepo nil → ScheduleHealthCheck is skipped. metaSvc := metadata.NewMetadataService(t.TempDir()) coord := NewCoordinator(Config{ @@ -108,7 +48,6 @@ func TestCoordinator_HandleSuccess_SkipsARRNotification(t *testing.T) { t.Fatalf("HandleSuccess returned unexpected error: %v", err) } if result.ARRNotified { - t.Error("expected ARRNotified to be false when skip_arr_notification is set, got true") + t.Error("expected ARRNotified to be false when SkipArrNotification is set, got true") } } - From bab8397e11069b2b7794cb6dc401b93157bd448f Mon Sep 17 00:00:00 2001 From: javi11 Date: Mon, 20 Apr 2026 09:37:07 +0200 Subject: [PATCH 12/13] feat(sabnzbd): hide skip_arr_notification items from queue and history responses --- internal/api/sabnzbd_handlers.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/api/sabnzbd_handlers.go b/internal/api/sabnzbd_handlers.go index ba6cf6713..c92afd1cc 100644 --- a/internal/api/sabnzbd_handlers.go +++ b/internal/api/sabnzbd_handlers.go @@ -606,7 +606,7 @@ func (s *Server) handleSABnzbdQueue(c *fiber.Ctx) error { var totalMbLeft float64 for i, item := range items { - if item.Status == database.QueueStatusFallback { + if item.Status == database.QueueStatusFallback || item.SkipArrNotification { continue } @@ -762,6 +762,9 @@ func (s *Server) handleSABnzbdHistory(c *fiber.Ctx) error { finalItems := make([]*database.ImportQueueItem, 0) for _, item := range completedQueueItems { + if item.SkipArrNotification { + continue + } name := filepath.Base(item.NzbPath) // Filter by nzo_ids if requested (check both integer ID and DownloadID) if len(nzoIDs) > 0 { @@ -821,6 +824,9 @@ func (s *Server) handleSABnzbdHistory(c *fiber.Ctx) error { // Combine failed items for noofslots calculation for _, item := range failed { + if item.SkipArrNotification { + continue + } name := filepath.Base(item.NzbPath) // Filter by nzo_ids if requested if len(nzoIDs) > 0 { From f873915faea6be5f22a1ed0a38660c654adcb1ab Mon Sep 17 00:00:00 2001 From: javi11 Date: Mon, 20 Apr 2026 09:42:25 +0200 Subject: [PATCH 13/13] refactor(database): remove unused ImportQueueMetadata type --- internal/database/models.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/database/models.go b/internal/database/models.go index 3af2cb5d2..3171fc44e 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -169,8 +169,3 @@ type ImportHistory struct { CompletedAt time.Time `db:"completed_at"` } -// ImportQueueMetadata is the JSON payload stored in ImportQueueItem.Metadata. -// Fields are omitempty so partial writes don't clobber unrelated keys. -type ImportQueueMetadata struct { - NzbdavID string `json:"nzbdav_id,omitempty"` -}