From 9503908b6b6ead5e9791d2df27b1ee8fc4db2b00 Mon Sep 17 00:00:00 2001 From: drondeseries Date: Mon, 20 Apr 2026 09:12:50 -0400 Subject: [PATCH 1/6] fix(arrs): respect pre-configured categories during auto-registration --- internal/arrs/instances/manager.go | 40 ++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/internal/arrs/instances/manager.go b/internal/arrs/instances/manager.go index 904731559..7ae46eb26 100644 --- a/internal/arrs/instances/manager.go +++ b/internal/arrs/instances/manager.go @@ -154,19 +154,33 @@ func (m *Manager) RegisterInstance(ctx context.Context, arrURL, apiKey string) ( // Determine category based on ARR type var category string - switch arrType { - case "radarr": - category = "movies" - case "sonarr": - category = "tv" - case "lidarr": - category = "music" - case "readarr": - category = "books" - case "whisparr": - category = "movies" - default: - return false, fmt.Errorf("unsupported ARR type: %s", arrType) + + // Check if instance already exists in config to respect pre-configured category + existingInstances := m.GetAllInstances() + found := false + for _, inst := range existingInstances { + if normalizeURL(inst.URL) == normalizeURL(arrURL) { + category = inst.Category + found = true + break + } + } + + if !found { + switch arrType { + case "radarr": + category = "movies" + case "sonarr": + category = "tv" + case "lidarr": + category = "music" + case "readarr": + category = "books" + case "whisparr": + category = "movies" + default: + return false, fmt.Errorf("unsupported ARR type: %s", arrType) + } } // Generate instance name From a3dbb69f3f76066a8405130c51ca652415706bd5 Mon Sep 17 00:00:00 2001 From: drondeseries Date: Mon, 20 Apr 2026 09:41:10 -0400 Subject: [PATCH 2/6] refactor(arrs): improve download client registration logging --- internal/arrs/registrar/manager.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/arrs/registrar/manager.go b/internal/arrs/registrar/manager.go index 170529126..edec36785 100644 --- a/internal/arrs/registrar/manager.go +++ b/internal/arrs/registrar/manager.go @@ -470,9 +470,9 @@ func (m *Manager) EnsureDownloadClientRegistration(ctx context.Context, altmount } _, err := client.AddDownloadClientContext(ctx, dc) if err != nil { - slog.ErrorContext(ctx, "Failed to add Radarr download client", "instance", instance.Name, "error", err) + slog.ErrorContext(ctx, "Failed to add download client to "+instance.Type, "instance", instance.Name, "error", err) } else { - slog.InfoContext(ctx, "Added AltMount download client to Radarr", "instance", instance.Name) + slog.InfoContext(ctx, "Added AltMount download client to "+instance.Type, "instance", instance.Name, "category", category) } } From 83459b9adc7b507f155d1f93476f0e29cddde9e2 Mon Sep 17 00:00:00 2001 From: drondeseries Date: Mon, 20 Apr 2026 09:49:28 -0400 Subject: [PATCH 3/6] refactor(arrs): remove hardcoded category fallbacks in download client registration --- internal/arrs/registrar/manager.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/arrs/registrar/manager.go b/internal/arrs/registrar/manager.go index edec36785..5f0a37e4e 100644 --- a/internal/arrs/registrar/manager.go +++ b/internal/arrs/registrar/manager.go @@ -419,7 +419,7 @@ func (m *Manager) EnsureDownloadClientRegistration(ctx context.Context, altmount slog.InfoContext(ctx, "Updating Radarr download client API key/Host", "instance", instance.Name) category := instance.Category if category == "" { - category = "movies" + slog.WarnContext(ctx, "No category found in configuration for instance, using empty string", "instance", instance.Name) } dc := &radarr.DownloadClientInput{ ID: existing.ID, @@ -442,13 +442,13 @@ func (m *Manager) EnsureDownloadClientRegistration(ctx context.Context, altmount } _, err := client.UpdateDownloadClientContext(ctx, dc, true) if err != nil { - slog.ErrorContext(ctx, "Failed to update Radarr download client", "instance", instance.Name, "error", err) + slog.ErrorContext(ctx, "Failed to update download client", "instance", instance.Name, "error", err) } } } else { category := instance.Category if category == "" { - category = "movies" + slog.WarnContext(ctx, "No category found in configuration for instance, using empty string", "instance", instance.Name) } dc := &radarr.DownloadClientInput{ Name: clientName, @@ -514,7 +514,7 @@ func (m *Manager) EnsureDownloadClientRegistration(ctx context.Context, altmount slog.InfoContext(ctx, "Updating Sonarr download client API key/Host", "instance", instance.Name) category := instance.Category if category == "" { - category = "tv" + slog.WarnContext(ctx, "No category found in configuration for instance, using empty string", "instance", instance.Name) } dc := &sonarr.DownloadClientInput{ ID: existing.ID, @@ -537,13 +537,13 @@ func (m *Manager) EnsureDownloadClientRegistration(ctx context.Context, altmount } _, err := client.UpdateDownloadClientContext(ctx, dc, true) if err != nil { - slog.ErrorContext(ctx, "Failed to update Sonarr download client", "instance", instance.Name, "error", err) + slog.ErrorContext(ctx, "Failed to update download client", "instance", instance.Name, "error", err) } } } else { category := instance.Category if category == "" { - category = "tv" + slog.WarnContext(ctx, "No category found in configuration for instance, using empty string", "instance", instance.Name) } dc := &sonarr.DownloadClientInput{ Name: clientName, From fc9283e24a4144260512dcfb8fc1c01f6a3903dd Mon Sep 17 00:00:00 2001 From: drondeseries Date: Mon, 20 Apr 2026 15:16:24 -0400 Subject: [PATCH 4/6] feat(metadata): enhance BackupWorker reliability and scope --- internal/metadata/backup_worker.go | 158 ++++++++++++++++++++--------- 1 file changed, 109 insertions(+), 49 deletions(-) diff --git a/internal/metadata/backup_worker.go b/internal/metadata/backup_worker.go index 85dd2056e..788f80011 100644 --- a/internal/metadata/backup_worker.go +++ b/internal/metadata/backup_worker.go @@ -47,6 +47,11 @@ func (w *BackupWorker) Start(ctx context.Context) error { w.workerCtx, w.workerCancel = context.WithCancel(ctx) + // Catch-up logic + if w.shouldTriggerImmediateBackup(cfg.Metadata.Backup.Path) { + go w.performBackup() + } + w.cronRunner = cron.New(cron.WithLocation(time.UTC)) if _, err := w.cronRunner.AddFunc(cfg.Metadata.Backup.Schedule, w.performBackup); err != nil { w.workerCancel() @@ -62,6 +67,27 @@ func (w *BackupWorker) Start(ctx context.Context) error { return nil } +func (w *BackupWorker) shouldTriggerImmediateBackup(backupRoot string) bool { + files, err := os.ReadDir(backupRoot) + if err != nil { + return false + } + + var latestModTime time.Time + for _, f := range files { + if f.IsDir() { + info, err := f.Info() + if err == nil { + if info.ModTime().After(latestModTime) { + latestModTime = info.ModTime() + } + } + } + } + + return time.Since(latestModTime) > 24*time.Hour +} + func (w *BackupWorker) Stop(ctx context.Context) { w.workerMu.Lock() defer w.workerMu.Unlock() @@ -94,53 +120,69 @@ func (w *BackupWorker) performBackup() { slog.InfoContext(w.workerCtx, "Starting metadata backup (copy)", "destination", backupDir) count := 0 - err := filepath.Walk(metadataDir, func(path string, info os.FileInfo, err error) error { - if w.workerCtx != nil { - select { - case <-w.workerCtx.Done(): - return w.workerCtx.Err() - default: + + // Paths to back up + pathsToBackup := []string{metadataDir} + if cfg.Health.LibraryDir != nil && *cfg.Health.LibraryDir != "" { + pathsToBackup = append(pathsToBackup, *cfg.Health.LibraryDir) + } + + for _, root := range pathsToBackup { + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if w.workerCtx != nil { + select { + case <-w.workerCtx.Done(): + return w.workerCtx.Err() + default: + } } - } - if err != nil { - return err - } + if err != nil { + return err + } - if info.IsDir() { - return nil - } + if info.IsDir() { + return nil + } - if !strings.HasSuffix(info.Name(), ".meta") { - return nil - } + if !strings.HasSuffix(info.Name(), ".meta") { + return nil + } - relPath, err := filepath.Rel(metadataDir, path) - if err != nil { - return err - } + relPath, err := filepath.Rel(root, path) + if err != nil { + return err + } - destPath := filepath.Join(backupDir, relPath) - if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { - return err - } + // Add subdir prefix to avoid collisions + var destPath string + if root == metadataDir { + destPath = filepath.Join(backupDir, relPath) + } else { + destPath = filepath.Join(backupDir, filepath.Base(root), relPath) + } - if err := w.copyFile(path, destPath); err != nil { - return err - } - count++ - return nil - }) + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return err + } - if err != nil { - if errors.Is(err, context.Canceled) { - slog.InfoContext(w.workerCtx, "Metadata backup canceled") - } else { - slog.ErrorContext(w.workerCtx, "Failed to complete metadata backup", "error", err) + if err := w.copyFile(path, destPath); err != nil { + return err + } + count++ + return nil + }) + + if err != nil { + if errors.Is(err, context.Canceled) { + slog.InfoContext(w.workerCtx, "Metadata backup canceled") + } else { + slog.ErrorContext(w.workerCtx, "Failed to complete metadata backup", "error", err) + } + // Cleanup failed partial backup + os.RemoveAll(backupDir) + return } - // Cleanup failed partial backup - os.RemoveAll(backupDir) - return } slog.InfoContext(w.workerCtx, "Metadata backup completed successfully", "files_copied", count) @@ -149,20 +191,38 @@ func (w *BackupWorker) performBackup() { } func (w *BackupWorker) copyFile(src, dst string) error { - sourceFile, err := os.Open(src) - if err != nil { - return err - } - defer sourceFile.Close() + const maxRetries = 3 + var lastErr error - destFile, err := os.Create(dst) - if err != nil { - return err + for i := 0; i < maxRetries; i++ { + sourceFile, err := os.Open(src) + if err != nil { + lastErr = err + time.Sleep(time.Duration(i+1) * time.Second) + continue + } + + destFile, err := os.Create(dst) + if err != nil { + sourceFile.Close() + lastErr = err + time.Sleep(time.Duration(i+1) * time.Second) + continue + } + + _, err = io.Copy(destFile, sourceFile) + sourceFile.Close() + destFile.Close() + + if err == nil { + return nil + } + + lastErr = err + time.Sleep(time.Duration(i+1) * time.Second) } - defer destFile.Close() - _, err = io.Copy(destFile, sourceFile) - return err + return fmt.Errorf("failed to copy file after %d attempts: %w", maxRetries, lastErr) } func (w *BackupWorker) cleanupOldBackups(backupRoot string, keep int) { From 64b0df6afefc5c2e18c12fb7841ed7835a5b1984 Mon Sep 17 00:00:00 2001 From: drondeseries Date: Sat, 18 Apr 2026 12:29:34 -0400 Subject: [PATCH 5/6] fix(safety): implement hard protection for root mount points and symlinks --- internal/api/arrs_handlers.go | 10 +++++-- internal/database/health_repository.go | 2 +- internal/health/library_sync.go | 37 ++++++++++++++++++++++++++ internal/metadata/service.go | 6 +++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/internal/api/arrs_handlers.go b/internal/api/arrs_handlers.go index 5f914372d..b262b6efa 100644 --- a/internal/api/arrs_handlers.go +++ b/internal/api/arrs_handlers.go @@ -337,17 +337,23 @@ func (s *Server) handleArrsWebhook(c *fiber.Ctx) error { } // Redundant Deletion Guard: ensure the file is gone from the local mount - if s.configManager != nil { + if s.configManager != nil && metadataPath != "" && metadataPath != "." && metadataPath != "/" { cfg := s.configManager.GetConfig() if cfg.MountPath != "" { localPath := filepath.Join(cfg.MountPath, metadataPath) + + // HARD SAFETY: Never delete the mount root or critical system paths + cleanLocal := filepath.Clean(localPath) + if cleanLocal == "/" || cleanLocal == "." || cleanLocal == filepath.Clean(cfg.MountPath) { + slog.WarnContext(c.Context(), "Safety Guard: Blocked attempt to delete root mount path", "path", cleanLocal) + continue + } if _, err := os.Stat(localPath); err == nil { slog.InfoContext(c.Context(), "Redundant Deletion Guard: Manual removal of ghost file from mount", "path", localPath) _ = os.Remove(localPath) } } } - } // Process Directory Deletions diff --git a/internal/database/health_repository.go b/internal/database/health_repository.go index 0d3de34eb..c35f1d9d0 100644 --- a/internal/database/health_repository.go +++ b/internal/database/health_repository.go @@ -206,7 +206,7 @@ func (r *HealthRepository) GetUnhealthyFiles(ctx context.Context, limit int, str LIMIT ? ` - // Build the library directory prefix filter (e.g. /mnt/usenet-rclone/%) + // Build the library directory prefix filter (e.g. /my/library/path/%) libraryPrefix := libraryDir if !strings.HasSuffix(libraryPrefix, "/") { libraryPrefix += "/" diff --git a/internal/health/library_sync.go b/internal/health/library_sync.go index 00bc9ceff..e0f46ebb3 100644 --- a/internal/health/library_sync.go +++ b/internal/health/library_sync.go @@ -929,6 +929,22 @@ func (lsw *LibrarySyncWorker) SyncLibrary(ctx context.Context, dryRun bool) *Dry } } + // HARD SAFETY: Never delete the entire mount root or library root + cleanFile := filepath.Clean(file) + cleanMount := "" + if cfg.MountPath != "" { + cleanMount = filepath.Clean(cfg.MountPath) + } + cleanLibDir := "" + if cfg.Health.LibraryDir != nil && *cfg.Health.LibraryDir != "" { + cleanLibDir = filepath.Clean(*cfg.Health.LibraryDir) + } + + if cleanFile == cleanMount || cleanFile == cleanLibDir || cleanFile == "/" || cleanFile == "." { + slog.WarnContext(ctx, "Nuclear Guard: Blocked attempt to delete protected path in sync", "path", cleanFile) + continue + } + err = os.Remove(file) if err != nil { if !os.IsNotExist(err) { @@ -1148,6 +1164,12 @@ func updateSymlinkForMountChange( // Create new target path newTarget := filepath.Join(newMountPath, relativePath) + // HARD SAFETY: Never delete protected paths + cleanSymlink := filepath.Clean(symlinkPath) + if cleanSymlink == "/" || cleanSymlink == "." { + return currentTarget, false, fmt.Errorf("safety block: refusing to remove protected symlink path: %s", cleanSymlink) + } + // Remove old symlink if err := os.Remove(symlinkPath); err != nil { slog.WarnContext(ctx, "Failed to remove old symlink during mount path update", @@ -1579,6 +1601,21 @@ func (lsw *LibrarySyncWorker) removeEmptyDirectories(ctx context.Context) (int, default: } + // HARD SAFETY: Never delete protected paths + cleanDir := filepath.Clean(dir) + cleanMount := "" + if cfg.MountPath != "" { + cleanMount = filepath.Clean(cfg.MountPath) + } + cleanLibDir := "" + if cfg.Health.LibraryDir != nil && *cfg.Health.LibraryDir != "" { + cleanLibDir = filepath.Clean(*cfg.Health.LibraryDir) + } + + if cleanDir == cleanMount || cleanDir == cleanLibDir || cleanDir == "/" || cleanDir == "." { + continue + } + // Try to remove the directory (will fail if not empty) if err := os.Remove(dir); err != nil { continue diff --git a/internal/metadata/service.go b/internal/metadata/service.go index 474335544..9b3556244 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -445,6 +445,12 @@ func (ms *MetadataService) DeleteDirectory(virtualPath string) error { metadataDir := filepath.Join(ms.rootPath, virtualPath) + // HARD SAFETY: Never delete the root metadata path + cleanMetadataDir := filepath.Clean(metadataDir) + if cleanMetadataDir == filepath.Clean(ms.rootPath) || cleanMetadataDir == "/" || cleanMetadataDir == "." { + return fmt.Errorf("safety block: refusing to remove root metadata directory: %s", cleanMetadataDir) + } + err := os.RemoveAll(metadataDir) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to delete metadata directory: %w", err) From ec450dd4615bfce2816c210f14430ede2af424f0 Mon Sep 17 00:00:00 2001 From: drondeseries Date: Tue, 21 Apr 2026 09:06:16 -0400 Subject: [PATCH 6/6] fix(importer): correctly strip .nzb and .nzb.gz extensions from release names Introduced nzbtrim utility to handle case-insensitive removal of both .nzb and .nzb.gz extensions, preventing redundant .nzb suffixes in symlink folders and filenames. --- internal/api/nzb_stremio_handlers.go | 3 ++- internal/importer/archive/rar/aggregator.go | 5 +++-- .../importer/archive/sevenzip/aggregator.go | 5 +++-- internal/importer/filesystem/utils.go | 3 ++- internal/importer/parser/fileinfo/fileinfo.go | 3 ++- internal/importer/parser/parser.go | 3 ++- internal/importer/processor.go | 5 +++-- internal/importer/service.go | 7 ++++--- internal/importer/utils/nzbtrim/nzb_trim.go | 19 +++++++++++++++++++ 9 files changed, 40 insertions(+), 13 deletions(-) create mode 100644 internal/importer/utils/nzbtrim/nzb_trim.go diff --git a/internal/api/nzb_stremio_handlers.go b/internal/api/nzb_stremio_handlers.go index abc80d65c..78f3c4d88 100644 --- a/internal/api/nzb_stremio_handlers.go +++ b/internal/api/nzb_stremio_handlers.go @@ -12,6 +12,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/javi11/altmount/internal/database" + "github.com/javi11/altmount/internal/importer/utils/nzbtrim" ) // mediaExtensions lists common video/media file extensions for Stremio stream filtering. @@ -120,7 +121,7 @@ func (s *Server) handleNzbStreams(c *fiber.Ctx) error { // --- Derive stable names before touching the filesystem --- uploadDir := filepath.Join(os.TempDir(), "altmount-uploads") safeFilename := filepath.Base(file.Filename) - nzbName := strings.TrimSuffix(safeFilename, filepath.Ext(safeFilename)) + nzbName := nzbtrim.TrimNzbExtension(safeFilename) tempPath := filepath.Join(uploadDir, safeFilename) // --- Short-circuit: return cached streams if NZB was already processed --- diff --git a/internal/importer/archive/rar/aggregator.go b/internal/importer/archive/rar/aggregator.go index fbce35039..52f8d4871 100644 --- a/internal/importer/archive/rar/aggregator.go +++ b/internal/importer/archive/rar/aggregator.go @@ -20,6 +20,7 @@ import ( "github.com/javi11/altmount/internal/importer/filesystem" "github.com/javi11/altmount/internal/importer/parser" "github.com/javi11/altmount/internal/importer/utils" + "github.com/javi11/altmount/internal/importer/utils/nzbtrim" "github.com/javi11/altmount/internal/importer/validation" "github.com/javi11/altmount/internal/metadata" metapb "github.com/javi11/altmount/internal/metadata/proto" @@ -240,7 +241,7 @@ func ProcessArchive(ctx context.Context, opts ProcessArchiveOptions) error { } nzbName := filepath.Base(nzbPath) - releaseName := strings.TrimSuffix(nzbName, filepath.Ext(nzbName)) + releaseName := nzbtrim.TrimNzbExtension(nzbName) shouldNormalizeName := renameToNzbName && mediaFilesCount == 1 // Count ISO-expanded files so single-file ISOs omit the index suffix. @@ -570,7 +571,7 @@ func GroupArchivesByBaseName(files []parser.ParsedFile) [][]parser.ParsedFile { // normalizeArchiveReleaseFilename aligns the filename to the NZB basename while keeping the original extension. func normalizeArchiveReleaseFilename(nzbFilename, originalFilename string) string { - releaseName := strings.TrimSuffix(nzbFilename, filepath.Ext(nzbFilename)) + releaseName := nzbtrim.TrimNzbExtension(nzbFilename) fileExt := filepath.Ext(originalFilename) if fileExt == "" { diff --git a/internal/importer/archive/sevenzip/aggregator.go b/internal/importer/archive/sevenzip/aggregator.go index 5adf9097c..f0214a294 100644 --- a/internal/importer/archive/sevenzip/aggregator.go +++ b/internal/importer/archive/sevenzip/aggregator.go @@ -18,6 +18,7 @@ import ( "github.com/javi11/altmount/internal/importer/filesystem" "github.com/javi11/altmount/internal/importer/parser" "github.com/javi11/altmount/internal/importer/utils" + "github.com/javi11/altmount/internal/importer/utils/nzbtrim" "github.com/javi11/altmount/internal/importer/validation" "github.com/javi11/altmount/internal/metadata" metapb "github.com/javi11/altmount/internal/metadata/proto" @@ -217,7 +218,7 @@ func ProcessArchive(ctx context.Context, opts ProcessArchiveOptions) error { } nzbName := filepath.Base(nzbPath) - releaseName := strings.TrimSuffix(nzbName, filepath.Ext(nzbName)) + releaseName := nzbtrim.TrimNzbExtension(nzbName) shouldNormalizeName := renameToNzbName && mediaFilesCount == 1 // Count ISO-expanded files so single-file ISOs omit the index suffix. @@ -521,7 +522,7 @@ func expandISOContents( // normalizeArchiveReleaseFilename aligns the filename to the NZB basename while keeping the original extension. func normalizeArchiveReleaseFilename(nzbFilename, originalFilename string) string { - releaseName := strings.TrimSuffix(nzbFilename, filepath.Ext(nzbFilename)) + releaseName := nzbtrim.TrimNzbExtension(nzbFilename) fileExt := filepath.Ext(originalFilename) if fileExt == "" { diff --git a/internal/importer/filesystem/utils.go b/internal/importer/filesystem/utils.go index 0552bfec3..dbaa14a7d 100644 --- a/internal/importer/filesystem/utils.go +++ b/internal/importer/filesystem/utils.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/javi11/altmount/internal/importer/parser" + "github.com/javi11/altmount/internal/importer/utils/nzbtrim" "github.com/javi11/altmount/internal/metadata" ) @@ -117,7 +118,7 @@ func EnsureDirectoryExists(virtualDir string, metadataService *metadata.Metadata // CreateNzbFolder creates a folder named after the NZB file func CreateNzbFolder(virtualDir, nzbFilename string, metadataService *metadata.MetadataService) (string, error) { - nzbBaseName := strings.TrimSuffix(nzbFilename, filepath.Ext(nzbFilename)) + nzbBaseName := nzbtrim.TrimNzbExtension(nzbFilename) // Now, also strip the media file extension if it exists // Common media extensions: .mkv, .mp4, .avi, .flv, .wmv, .mov, .webm // This is not exhaustive, but covers common cases. diff --git a/internal/importer/parser/fileinfo/fileinfo.go b/internal/importer/parser/fileinfo/fileinfo.go index d6a6da2b4..9f074dc3f 100644 --- a/internal/importer/parser/fileinfo/fileinfo.go +++ b/internal/importer/parser/fileinfo/fileinfo.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/javi11/altmount/internal/importer/parser/par2" + "github.com/javi11/altmount/internal/importer/utils/nzbtrim" ) var ( @@ -24,7 +25,7 @@ func GetFileInfos( nzbFilename string, ) []*FileInfo { // Strip .nzb extension for use as last-resort filename stem - nzbStem := strings.TrimSuffix(nzbFilename, filepath.Ext(nzbFilename)) + nzbStem := nzbtrim.TrimNzbExtension(nzbFilename) fileInfos := make([]*FileInfo, 0, len(files)) for _, file := range files { diff --git a/internal/importer/parser/parser.go b/internal/importer/parser/parser.go index 8b8124bae..0da4c849b 100644 --- a/internal/importer/parser/parser.go +++ b/internal/importer/parser/parser.go @@ -21,6 +21,7 @@ import ( "github.com/javi11/altmount/internal/errors" "github.com/javi11/altmount/internal/importer/parser/fileinfo" "github.com/javi11/altmount/internal/importer/parser/par2" + "github.com/javi11/altmount/internal/importer/utils/nzbtrim" metapb "github.com/javi11/altmount/internal/metadata/proto" "github.com/javi11/altmount/internal/pool" "github.com/javi11/altmount/internal/progress" @@ -354,7 +355,7 @@ func (p *Parser) parseFile(ctx context.Context, meta map[string]string, nzbFilen if metaFilename, ok := meta["file_name"]; ok && metaFilename != "" { if fSize, ok := meta["file_size"]; ok { // This is a usenet-drive nzb with one file - metaFilename = strings.TrimSuffix(nzbFilename, filepath.Ext(nzbFilename)) + metaFilename = nzbtrim.TrimNzbExtension(nzbFilename) if fe, ok := meta["file_extension"]; ok { metaFilename = metaFilename + fe diff --git a/internal/importer/processor.go b/internal/importer/processor.go index 97bb30f20..bf101da96 100644 --- a/internal/importer/processor.go +++ b/internal/importer/processor.go @@ -20,6 +20,7 @@ import ( "github.com/javi11/altmount/internal/importer/multifile" "github.com/javi11/altmount/internal/importer/parser" "github.com/javi11/altmount/internal/importer/singlefile" + "github.com/javi11/altmount/internal/importer/utils/nzbtrim" "github.com/javi11/altmount/internal/metadata" "github.com/javi11/altmount/internal/pool" "github.com/javi11/altmount/internal/progress" @@ -330,7 +331,7 @@ func (proc *Processor) processSingleFile( // Normalize virtualDir only for synthetic duplicate folders; skip if the NZB actually lives inside a // real directory named like the release (e.g. .../Season 01//.nzb). nzbName := proc.getCleanNzbName(nzbPath, queueID) - releaseName := strings.TrimSuffix(nzbName, filepath.Ext(nzbName)) + releaseName := nzbtrim.TrimNzbExtension(nzbName) nzbDirBase := filepath.Base(filepath.Dir(nzbPath)) fileDir := filepath.Dir(regularFiles[0].Filename) if fileDir == "." || fileDir == "" { @@ -816,7 +817,7 @@ func applyNzbRename(renameToNzbName bool, nzbName string, files []parser.ParsedF // normalizeReleaseFilename aligns the filename to the NZB basename while keeping the original extension. // It avoids generating duplicate extensions like ".mp4.mp4" when the NZB name already contains the suffix. func normalizeReleaseFilename(nzbFilename, originalFilename string) string { - releaseName := strings.TrimSuffix(nzbFilename, filepath.Ext(nzbFilename)) + releaseName := nzbtrim.TrimNzbExtension(nzbFilename) fileExt := filepath.Ext(originalFilename) if fileExt == "" { diff --git a/internal/importer/service.go b/internal/importer/service.go index a6847dc10..df05d44c5 100644 --- a/internal/importer/service.go +++ b/internal/importer/service.go @@ -24,6 +24,7 @@ import ( "github.com/javi11/altmount/internal/importer/postprocessor" "github.com/javi11/altmount/internal/importer/queue" "github.com/javi11/altmount/internal/importer/scanner" + "github.com/javi11/altmount/internal/importer/utils/nzbtrim" "github.com/javi11/altmount/internal/metadata" "github.com/javi11/altmount/internal/pool" "github.com/javi11/altmount/internal/progress" @@ -119,7 +120,7 @@ func isFileAlreadyProcessed(metadataService *metadata.MetadataService, filePath // Normalize filename (remove .nzb extension) fileName := filepath.Base(filePath) - baseName := strings.TrimSuffix(fileName, filepath.Ext(fileName)) + baseName := nzbtrim.TrimNzbExtension(fileName) // Check if a directory exists with the release name releaseDir := filepath.Join(virtualPath, baseName) @@ -850,7 +851,7 @@ func (s *Service) ensurePersistentNzb(ctx context.Context, item *database.Import filename := filepath.Base(item.NzbPath) newFilename := sanitizeFilename(filename) if !strings.HasSuffix(strings.ToLower(newFilename), nzbGzExtension) { - newFilename = strings.TrimSuffix(newFilename, filepath.Ext(newFilename)) + nzbGzExtension + newFilename = nzbtrim.TrimNzbExtension(newFilename) + nzbGzExtension } newPath := filepath.Join(nzbDir, newFilename) @@ -1339,7 +1340,7 @@ func (s *Service) RegenerateMetadata(ctx context.Context, mountRelativePath stri if releaseName == "" { // Fallback: use the filename without extension releaseName = filepath.Base(mountRelativePath) - releaseName = strings.TrimSuffix(releaseName, filepath.Ext(releaseName)) + releaseName = nzbtrim.TrimNzbExtension(releaseName) } s.log.InfoContext(ctx, "Attempting to regenerate metadata", "path", mountRelativePath, "release_name", releaseName) diff --git a/internal/importer/utils/nzbtrim/nzb_trim.go b/internal/importer/utils/nzbtrim/nzb_trim.go new file mode 100644 index 000000000..24a11c249 --- /dev/null +++ b/internal/importer/utils/nzbtrim/nzb_trim.go @@ -0,0 +1,19 @@ +package nzbtrim + +import ( + "path/filepath" + "strings" +) + +// TrimNzbExtension removes .nzb or .nzb.gz from a filename (case-insensitive) +func TrimNzbExtension(filename string) string { + lower := strings.ToLower(filename) + if strings.HasSuffix(lower, ".nzb.gz") { + return filename[:len(filename)-7] + } + if strings.HasSuffix(lower, ".nzb") { + return filename[:len(filename)-4] + } + // Fallback to standard extension removal if it's not a known NZB extension + return strings.TrimSuffix(filename, filepath.Ext(filename)) +}