+ .map((category, index) => {
+ const isDefault = isDefaultCategory(category.name);
+ return (
+
+ {isDefault && (
+
+ System Default (name cannot be changed)
+
+ )}
- {!isReadOnly && (
+ {!isReadOnly && !isDefault && (
- ))}
+ )})}
)}
diff --git a/frontend/src/components/queue/DragDropUpload.tsx b/frontend/src/components/queue/DragDropUpload.tsx
index 3f3120814..e53fb2d1d 100644
--- a/frontend/src/components/queue/DragDropUpload.tsx
+++ b/frontend/src/components/queue/DragDropUpload.tsx
@@ -2,6 +2,7 @@ import { AlertCircle, CheckCircle2, Download, FileIcon, Link, Upload, X } from "
import { useCallback, useState } from "react";
import { useToast } from "../../contexts/ToastContext";
import { useUploadNZBLnks, useUploadToQueue } from "../../hooks/useApi";
+import { useConfig } from "../../hooks/useConfig";
import { ErrorAlert } from "../ui/ErrorAlert";
interface UploadedFile {
@@ -32,6 +33,10 @@ export function DragDropUpload() {
const uploadMutation = useUploadToQueue();
const uploadLinksMutation = useUploadNZBLnks();
const { showToast } = useToast();
+ const { data: config } = useConfig();
+
+ // Get available categories from config
+ const categories = config?.sabnzbd?.categories ?? [];
const validateFile = useCallback((file: File): string | null => {
// Check file extension
@@ -407,13 +412,18 @@ export function DragDropUpload() {
{/* Category Input (shared) */}
diff --git a/internal/api/auth_handlers.go b/internal/api/auth_handlers.go
index fbb33349f..11c874778 100644
--- a/internal/api/auth_handlers.go
+++ b/internal/api/auth_handlers.go
@@ -318,6 +318,27 @@ func (s *Server) handleRegenerateAPIKey(c *fiber.Ctx) error {
return RespondInternalError(c, "Failed to regenerate API key", err.Error())
}
+ // If key_override is configured (has a value with 32 chars), update it with the new key
+ if s.configManager != nil {
+ cfg := s.configManager.GetConfig()
+ if cfg.API.KeyOverride != "" && len(cfg.API.KeyOverride) == 32 {
+ // Update the key_override in config to match the new key
+ newConfig := cfg.DeepCopy()
+ newConfig.API.KeyOverride = apiKey
+
+ if err := s.configManager.UpdateConfig(newConfig); err != nil {
+ slog.WarnContext(c.Context(), "Failed to update key_override in config", "error", err)
+ // Don't fail the request, just log the warning
+ } else {
+ if err := s.configManager.SaveConfig(); err != nil {
+ slog.WarnContext(c.Context(), "Failed to save config after updating key_override", "error", err)
+ } else {
+ slog.InfoContext(c.Context(), "Updated key_override in config with new API key")
+ }
+ }
+ }
+ }
+
response := fiber.Map{
"api_key": apiKey,
"message": "API key regenerated successfully",
diff --git a/internal/api/parsers.go b/internal/api/parsers.go
index c87259b4f..0032b1f90 100644
--- a/internal/api/parsers.go
+++ b/internal/api/parsers.go
@@ -104,7 +104,24 @@ func ParseTimeParamFiber(c *fiber.Ctx, param string) (*time.Time, error) {
}
// validateAPIKey validates the API key using AltMount's authentication system
+// First checks if there's a key_override in config (must be exactly 32 characters)
+// Then falls back to checking the database
func (s *Server) validateAPIKey(c *fiber.Ctx, apiKey string) bool {
+ if apiKey == "" {
+ return false
+ }
+
+ // Check config key_override first (must be exactly 32 characters to be valid)
+ if s.configManager != nil {
+ cfg := s.configManager.GetConfig()
+ if cfg.API.KeyOverride != "" && len(cfg.API.KeyOverride) == 32 {
+ if apiKey == cfg.API.KeyOverride {
+ return true
+ }
+ }
+ }
+
+ // Fall back to database validation
if s.userRepo == nil {
return false
}
diff --git a/internal/api/sabnzbd_handlers.go b/internal/api/sabnzbd_handlers.go
index f107987c1..b4274d67c 100644
--- a/internal/api/sabnzbd_handlers.go
+++ b/internal/api/sabnzbd_handlers.go
@@ -29,6 +29,25 @@ var defaultCategory = config.SABnzbdCategory{
Dir: "",
}
+// getDefaultCategory returns the Default category from config or a fallback
+func (s *Server) getDefaultCategory() config.SABnzbdCategory {
+ if s.configManager != nil {
+ cfg := s.configManager.GetConfig()
+ for _, cat := range cfg.SABnzbd.Categories {
+ if cat.Name == config.DefaultCategoryName {
+ return cat
+ }
+ }
+ }
+ // Fallback if not found in config
+ return config.SABnzbdCategory{
+ Name: config.DefaultCategoryName,
+ Order: 0,
+ Priority: 0,
+ Dir: config.DefaultCategoryDir,
+ }
+}
+
// handleSABnzbd is the main handler for SABnzbd API endpoints
func (s *Server) handleSABnzbd(c *fiber.Ctx) error {
// Check if SABnzbd API is enabled
@@ -890,30 +909,40 @@ func (s *Server) parseSABnzbdPriority(priority string) database.QueuePriority {
// buildCategoryPath builds the directory path for a category
func (s *Server) buildCategoryPath(category string) string {
- // Return empty for default category (no subdirectory)
- if category == "default" || category == "" {
- return ""
+ // Empty category uses Default category's Dir
+ if category == "" {
+ category = config.DefaultCategoryName
}
if s.configManager == nil {
- // No config manager, use category name as directory
+ // No config manager, use category name as directory (Default uses its default dir)
+ if category == config.DefaultCategoryName {
+ return config.DefaultCategoryDir
+ }
return category
}
- config := s.configManager.GetConfig()
+ cfg := s.configManager.GetConfig()
// If no categories are configured, use category name as directory
- if len(config.SABnzbd.Categories) == 0 {
+ if len(cfg.SABnzbd.Categories) == 0 {
+ if category == config.DefaultCategoryName {
+ return config.DefaultCategoryDir
+ }
return category
}
// Look for the category in configuration
- for _, configCategory := range config.SABnzbd.Categories {
+ for _, configCategory := range cfg.SABnzbd.Categories {
if configCategory.Name == category {
// Use configured Dir if available, otherwise use category name
if configCategory.Dir != "" {
return configCategory.Dir
}
+ // For Default category with empty Dir, return default dir
+ if category == config.DefaultCategoryName {
+ return config.DefaultCategoryDir
+ }
return category
}
}
@@ -924,6 +953,7 @@ func (s *Server) buildCategoryPath(category string) string {
// validateSABnzbdCategory validates and returns the category, or error if invalid
func (s *Server) validateSABnzbdCategory(category string) (string, error) {
+ defaultCategory := s.getDefaultCategory()
if category == "" {
return defaultCategory.Name, nil
}
diff --git a/internal/config/manager.go b/internal/config/manager.go
index 4e297a749..3db9116a4 100644
--- a/internal/config/manager.go
+++ b/internal/config/manager.go
@@ -60,7 +60,8 @@ type FuseConfig struct {
// APIConfig represents REST API configuration
type APIConfig struct {
- Prefix string `yaml:"prefix" mapstructure:"prefix" json:"prefix"`
+ Prefix string `yaml:"prefix" mapstructure:"prefix" json:"prefix"`
+ KeyOverride string `yaml:"key_override" mapstructure:"key_override" json:"key_override,omitempty"`
}
// AuthConfig represents authentication configuration
@@ -245,6 +246,42 @@ type SABnzbdCategory struct {
Type string `yaml:"type" mapstructure:"type" json:"type"` // "sonarr" or "radarr"
}
+// DefaultCategoryName is the name of the mandatory default category
+const DefaultCategoryName = "Default"
+
+// DefaultCategoryDir is the default directory for the Default category
+const DefaultCategoryDir = "complete"
+
+// EnsureDefaultCategory ensures that the Default category always exists in the configuration.
+// The Default category's name cannot be changed, but order, priority, and dir can be customized via YAML.
+// If not configured, uses sensible defaults (order=0, priority=0, dir="complete").
+func (c *Config) EnsureDefaultCategory() {
+ // Check if Default category already exists
+ for i, cat := range c.SABnzbd.Categories {
+ if strings.EqualFold(cat.Name, DefaultCategoryName) {
+ // Ensure the name is exactly "Default" (case-sensitive)
+ c.SABnzbd.Categories[i].Name = DefaultCategoryName
+ // Keep customized values from YAML, only set defaults if not configured
+ // Dir defaults to "complete" if empty
+ if c.SABnzbd.Categories[i].Dir == "" {
+ c.SABnzbd.Categories[i].Dir = DefaultCategoryDir
+ }
+ return
+ }
+ }
+
+ // Default category doesn't exist, add it at the beginning with default values
+ defaultCat := SABnzbdCategory{
+ Name: DefaultCategoryName,
+ Order: 0,
+ Priority: 0, // Normal priority
+ Dir: DefaultCategoryDir, // Default dir for files
+ }
+
+ // Prepend Default category to the list
+ c.SABnzbd.Categories = append([]SABnzbdCategory{defaultCat}, c.SABnzbd.Categories...)
+}
+
// ArrsConfig represents arrs configuration
type ArrsConfig struct {
Enabled *bool `yaml:"enabled" mapstructure:"enabled" json:"enabled"`
@@ -478,13 +515,24 @@ func (c *Config) Validate() error {
// Validate SABnzbd configuration
if c.SABnzbd.Enabled != nil && *c.SABnzbd.Enabled {
+ // CompleteDir is a virtual path relative to the mount point, not an absolute filesystem path
+ // It defaults to "/" (root of mount) if not specified
+ // Normalize: remove leading/trailing slashes for consistency, then ensure it starts with /
if c.SABnzbd.CompleteDir == "" {
- return fmt.Errorf("sabnzbd complete_dir cannot be empty when SABnzbd is enabled")
- }
- if !filepath.IsAbs(c.SABnzbd.CompleteDir) {
- return fmt.Errorf("sabnzbd complete_dir must be an absolute path")
+ c.SABnzbd.CompleteDir = "/"
+ } else {
+ // Normalize the path: ensure it starts with / and remove trailing /
+ cleanDir := strings.Trim(c.SABnzbd.CompleteDir, "/")
+ if cleanDir == "" {
+ c.SABnzbd.CompleteDir = "/"
+ } else {
+ c.SABnzbd.CompleteDir = "/" + cleanDir
+ }
}
+ // Ensure Default category always exists
+ c.EnsureDefaultCategory()
+
// Validate categories if provided
categoryNames := make(map[string]bool)
for i, category := range c.SABnzbd.Categories {
diff --git a/internal/importer/service.go b/internal/importer/service.go
index b10bf1c49..ed3743614 100644
--- a/internal/importer/service.go
+++ b/internal/importer/service.go
@@ -752,21 +752,32 @@ func (s *Service) ensurePersistentNzb(ctx context.Context, item *database.Import
// buildCategoryPath resolves a category name to its configured directory path.
// Returns the category's Dir if configured, otherwise falls back to the category name.
+// buildCategoryPath builds the directory path for a category.
+// For Default category, returns its configured Dir (defaults to "complete").
func (s *Service) buildCategoryPath(category string) string {
- if category == "" || category == "default" {
- return ""
+ // Empty category uses Default category
+ if category == "" {
+ category = config.DefaultCategoryName
}
cfg := s.configGetter()
if cfg == nil || len(cfg.SABnzbd.Categories) == 0 {
+ // No config, use default dir for Default category
+ if strings.EqualFold(category, config.DefaultCategoryName) {
+ return config.DefaultCategoryDir
+ }
return category
}
for _, cat := range cfg.SABnzbd.Categories {
- if cat.Name == category {
+ if strings.EqualFold(cat.Name, category) {
if cat.Dir != "" {
return cat.Dir
}
+ // For Default category with empty Dir, return default dir
+ if strings.EqualFold(category, config.DefaultCategoryName) {
+ return config.DefaultCategoryDir
+ }
return category
}
}
@@ -1060,12 +1071,12 @@ func (s *Service) calculateStrmFileSize(r io.Reader) (int64, error) {
fileSizeStr := u.Query().Get("file_size")
if fileSizeStr == "" {
- return 0, NewNonRetryableError("missing file_size parameter in NZG link", nil)
+ return 0, NewNonRetryableError("missing file_size parameter in NXG link", nil)
}
fileSize, err := strconv.ParseInt(fileSizeStr, 10, 64)
if err != nil {
- return 0, NewNonRetryableError("invalid file_size parameter in NZG link", err)
+ return 0, NewNonRetryableError("invalid file_size parameter in NXG link", err)
}
return fileSize, nil