Implementing a streaming setup with Usenet
This commit is contained in:
171
pkg/sabnzbd/config.go
Normal file
171
pkg/sabnzbd/config.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package sabnzbd
|
||||
|
||||
// ConfigResponse represents configuration response
|
||||
type ConfigResponse struct {
|
||||
Config *Config `json:"config"`
|
||||
}
|
||||
|
||||
type ConfigNewzbin struct {
|
||||
Username string `json:"username"`
|
||||
BookmarkRate int `json:"bookmark_rate"`
|
||||
Url string `json:"url"`
|
||||
Bookmarks int `json:"bookmarks"`
|
||||
Password string `json:"password"`
|
||||
Unbookmark int `json:"unbookmark"`
|
||||
}
|
||||
|
||||
// Category represents a SABnzbd category
|
||||
type Category struct {
|
||||
Name string `json:"name"`
|
||||
Order int `json:"order"`
|
||||
Pp string `json:"pp"`
|
||||
Script string `json:"script"`
|
||||
Dir string `json:"dir"`
|
||||
NewzBin string `json:"newzbin"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
// Server represents a usenet server
|
||||
type Server struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Connections int `json:"connections"`
|
||||
Retention int `json:"retention"`
|
||||
Priority int `json:"priority"`
|
||||
SSL bool `json:"ssl"`
|
||||
Optional bool `json:"optional"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Misc MiscConfig `json:"misc"`
|
||||
Categories []Category `json:"categories"`
|
||||
Servers []Server `json:"servers"`
|
||||
}
|
||||
|
||||
type MiscConfig struct {
|
||||
// Directory Configuration
|
||||
CompleteDir string `json:"complete_dir"`
|
||||
DownloadDir string `json:"download_dir"`
|
||||
AdminDir string `json:"admin_dir"`
|
||||
NzbBackupDir string `json:"nzb_backup_dir"`
|
||||
ScriptDir string `json:"script_dir"`
|
||||
EmailDir string `json:"email_dir"`
|
||||
WebDir string `json:"web_dir"`
|
||||
|
||||
// Processing Options
|
||||
ParOption string `json:"par_option"`
|
||||
ParOptionConvert string `json:"par_option_convert"`
|
||||
ParOptionDuplicate string `json:"par_option_duplicate"`
|
||||
DirectUnpack string `json:"direct_unpack"`
|
||||
FlatUnpack string `json:"flat_unpack"`
|
||||
EnableRecursiveUnpack string `json:"enable_recursive_unpack"`
|
||||
OverwriteFiles string `json:"overwrite_files"`
|
||||
IgnoreWrongUnrar string `json:"ignore_wrong_unrar"`
|
||||
IgnoreUnrarDates string `json:"ignore_unrar_dates"`
|
||||
PreCheck string `json:"pre_check"`
|
||||
|
||||
// File Handling
|
||||
Permissions string `json:"permissions"`
|
||||
FolderRename string `json:"folder_rename"`
|
||||
FileRename string `json:"file_rename"`
|
||||
ReplaceIllegal string `json:"replace_illegal"`
|
||||
ReplaceDots string `json:"replace_dots"`
|
||||
ReplaceSpaces string `json:"replace_spaces"`
|
||||
SanitizeSafe string `json:"sanitize_safe"`
|
||||
IgnoreSamples string `json:"ignore_samples"`
|
||||
UnwantedExtensions []string `json:"unwanted_extensions"`
|
||||
ActionOnUnwanted string `json:"action_on_unwanted"`
|
||||
ActionOnDuplicate string `json:"action_on_duplicate"`
|
||||
BackupForDuplicates string `json:"backup_for_duplicates"`
|
||||
CleanupList []string `json:"cleanup_list"`
|
||||
DeobfuscateFinalFilenames string `json:"deobfuscate_final_filenames"`
|
||||
|
||||
// Scripts and Processing
|
||||
PreScript string `json:"pre_script"`
|
||||
PostScript string `json:"post_script"`
|
||||
EmptyPostproc string `json:"empty_postproc"`
|
||||
PauseOnPostProcessing string `json:"pause_on_post_processing"`
|
||||
|
||||
// System Resources
|
||||
Nice string `json:"nice"`
|
||||
NiceUnpack string `json:"nice_unpack"`
|
||||
Ionice string `json:"ionice"`
|
||||
Fsync string `json:"fsync"`
|
||||
|
||||
// Bandwidth and Performance
|
||||
BandwidthMax string `json:"bandwidth_max"`
|
||||
BandwidthPerc string `json:"bandwidth_perc"`
|
||||
RefreshRate string `json:"refresh_rate"`
|
||||
DirscanSpeed string `json:"dirscan_speed"`
|
||||
FolderMaxLength string `json:"folder_max_length"`
|
||||
PropagationDelay string `json:"propagation_delay"`
|
||||
|
||||
// Storage Management
|
||||
DownloadFree string `json:"download_free"`
|
||||
CompleteFree string `json:"complete_free"`
|
||||
|
||||
// Queue Management
|
||||
QueueComplete string `json:"queue_complete"`
|
||||
QueueCompletePers string `json:"queue_complete_pers"`
|
||||
AutoSort string `json:"auto_sort"`
|
||||
NewNzbOnFailure string `json:"new_nzb_on_failure"`
|
||||
PauseOnPwrar string `json:"pause_on_pwrar"`
|
||||
WarnedOldQueue string `json:"warned_old_queue"`
|
||||
|
||||
// Web Interface
|
||||
WebHost string `json:"web_host"`
|
||||
WebPort string `json:"web_port"`
|
||||
WebUsername string `json:"web_username"`
|
||||
WebPassword string `json:"web_password"`
|
||||
WebColor string `json:"web_color"`
|
||||
WebColor2 string `json:"web_color2"`
|
||||
AutoBrowser string `json:"auto_browser"`
|
||||
Autobrowser string `json:"autobrowser"` // Duplicate field - may need to resolve
|
||||
|
||||
// HTTPS Configuration
|
||||
EnableHTTPS string `json:"enable_https"`
|
||||
EnableHTTPSVerification string `json:"enable_https_verification"`
|
||||
HTTPSPort string `json:"https_port"`
|
||||
HTTPSCert string `json:"https_cert"`
|
||||
HTTPSKey string `json:"https_key"`
|
||||
HTTPSChain string `json:"https_chain"`
|
||||
|
||||
// Security and API
|
||||
APIKey string `json:"api_key"`
|
||||
NzbKey string `json:"nzb_key"`
|
||||
HostWhitelist string `json:"host_whitelist"`
|
||||
LocalRanges []string `json:"local_ranges"`
|
||||
InetExposure string `json:"inet_exposure"`
|
||||
APILogging string `json:"api_logging"`
|
||||
APIWarnings string `json:"api_warnings"`
|
||||
|
||||
// Logging
|
||||
LogLevel string `json:"log_level"`
|
||||
LogSize string `json:"log_size"`
|
||||
MaxLogSize string `json:"max_log_size"`
|
||||
LogBackups string `json:"log_backups"`
|
||||
LogNew string `json:"log_new"`
|
||||
|
||||
// Notifications
|
||||
MatrixUsername string `json:"matrix_username"`
|
||||
MatrixPassword string `json:"matrix_password"`
|
||||
MatrixServer string `json:"matrix_server"`
|
||||
MatrixRoom string `json:"matrix_room"`
|
||||
|
||||
// Miscellaneous
|
||||
ConfigLock string `json:"config_lock"`
|
||||
Language string `json:"language"`
|
||||
CheckNewRel string `json:"check_new_rel"`
|
||||
RSSFilenames string `json:"rss_filenames"`
|
||||
IPv6Hosting string `json:"ipv6_hosting"`
|
||||
EnableBonjour string `json:"enable_bonjour"`
|
||||
Cherryhost string `json:"cherryhost"`
|
||||
WinMenu string `json:"win_menu"`
|
||||
AMPM string `json:"ampm"`
|
||||
NotifiedNewSkin string `json:"notified_new_skin"`
|
||||
HelpURI string `json:"helpuri"`
|
||||
SSDURI string `json:"ssduri"`
|
||||
}
|
||||
121
pkg/sabnzbd/context.go
Normal file
121
pkg/sabnzbd/context.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package sabnzbd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/store"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
apiKeyKey contextKey = "apikey"
|
||||
modeKey contextKey = "mode"
|
||||
arrKey contextKey = "arr"
|
||||
categoryKey contextKey = "category"
|
||||
)
|
||||
|
||||
func getMode(ctx context.Context) string {
|
||||
if mode, ok := ctx.Value(modeKey).(string); ok {
|
||||
return mode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *SABnzbd) categoryContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
category := r.URL.Query().Get("category")
|
||||
if category == "" {
|
||||
// Check form data
|
||||
_ = r.ParseForm()
|
||||
category = r.Form.Get("category")
|
||||
}
|
||||
if category == "" {
|
||||
category = r.FormValue("category")
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), categoryKey, strings.TrimSpace(category))
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func getArrFromContext(ctx context.Context) *arr.Arr {
|
||||
if a, ok := ctx.Value(arrKey).(*arr.Arr); ok {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getCategory(ctx context.Context) string {
|
||||
if category, ok := ctx.Value(categoryKey).(string); ok {
|
||||
return category
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// modeContext extracts the mode parameter from the request
|
||||
func (s *SABnzbd) modeContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mode := r.URL.Query().Get("mode")
|
||||
if mode == "" {
|
||||
// Check form data
|
||||
_ = r.ParseForm()
|
||||
mode = r.Form.Get("mode")
|
||||
}
|
||||
|
||||
// Extract category for Arr integration
|
||||
category := r.URL.Query().Get("cat")
|
||||
if category == "" {
|
||||
category = r.Form.Get("cat")
|
||||
}
|
||||
|
||||
// Create a default Arr instance for the category
|
||||
downloadUncached := false
|
||||
a := arr.New(category, "", "", false, false, &downloadUncached, "", "auto")
|
||||
|
||||
ctx := context.WithValue(r.Context(), modeKey, strings.TrimSpace(mode))
|
||||
ctx = context.WithValue(ctx, arrKey, a)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// authContext creates a middleware that extracts the Arr host and token from the Authorization header
|
||||
// and adds it to the request context.
|
||||
// This is used to identify the Arr instance for the request.
|
||||
// Only a valid host and token will be added to the context/config. The rest are manual
|
||||
func (s *SABnzbd) authContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.FormValue("ma_username")
|
||||
token := r.FormValue("ma_password")
|
||||
category := getCategory(r.Context())
|
||||
arrs := store.Get().Arr()
|
||||
// Check if arr exists
|
||||
a := arrs.Get(category)
|
||||
if a == nil {
|
||||
// Arr is not configured, create a new one
|
||||
downloadUncached := false
|
||||
a = arr.New(category, "", "", false, false, &downloadUncached, "", "auto")
|
||||
}
|
||||
host = strings.TrimSpace(host)
|
||||
if host != "" {
|
||||
a.Host = host
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token != "" {
|
||||
a.Token = token
|
||||
}
|
||||
a.Source = "auto"
|
||||
if err := utils.ValidateServiceURL(a.Host); err != nil {
|
||||
// Return silently, no need to raise a problem. Just do not add the Arr to the context/config.json
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
arrs.AddOrUpdate(a)
|
||||
ctx := context.WithValue(r.Context(), arrKey, a)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
476
pkg/sabnzbd/handlers.go
Normal file
476
pkg/sabnzbd/handlers.go
Normal file
@@ -0,0 +1,476 @@
|
||||
package sabnzbd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/usenet"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleAPI is the main handler for all SABnzbd API requests
|
||||
func (s *SABnzbd) handleAPI(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
mode := getMode(ctx)
|
||||
|
||||
switch mode {
|
||||
case ModeQueue:
|
||||
s.handleQueue(w, r)
|
||||
case ModeHistory:
|
||||
s.handleHistory(w, r)
|
||||
case ModeConfig:
|
||||
s.handleConfig(w, r)
|
||||
case ModeStatus, ModeFullStatus:
|
||||
s.handleStatus(w, r)
|
||||
case ModeGetConfig:
|
||||
s.handleConfig(w, r)
|
||||
case ModeAddURL:
|
||||
s.handleAddURL(w, r)
|
||||
case ModeAddFile:
|
||||
s.handleAddFile(w, r)
|
||||
case ModeVersion:
|
||||
s.handleVersion(w, r)
|
||||
case ModeGetCats:
|
||||
s.handleGetCategories(w, r)
|
||||
case ModeGetScripts:
|
||||
s.handleGetScripts(w, r)
|
||||
case ModeGetFiles:
|
||||
s.handleGetFiles(w, r)
|
||||
default:
|
||||
// Default to queue if no mode specified
|
||||
s.logger.Warn().Str("mode", mode).Msg("Unknown API mode, returning 404")
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SABnzbd) handleQueue(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
s.handleListQueue(w, r)
|
||||
return
|
||||
}
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
switch name {
|
||||
case "delete":
|
||||
s.handleQueueDelete(w, r)
|
||||
case "pause":
|
||||
s.handleQueuePause(w, r)
|
||||
case "resume":
|
||||
s.handleQueueResume(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// handleResume handles resume operations
|
||||
func (s *SABnzbd) handleQueueResume(w http.ResponseWriter, r *http.Request) {
|
||||
response := StatusResponse{Status: true}
|
||||
request.JSONResponse(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// handleDelete handles delete operations
|
||||
func (s *SABnzbd) handleQueueDelete(w http.ResponseWriter, r *http.Request) {
|
||||
nzoIDs := r.FormValue("value")
|
||||
if nzoIDs == "" {
|
||||
s.writeError(w, "No NZB IDs provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var successCount int
|
||||
var errors []string
|
||||
|
||||
for _, nzoID := range strings.Split(nzoIDs, ",") {
|
||||
nzoID = strings.TrimSpace(nzoID)
|
||||
if nzoID == "" {
|
||||
continue // Skip empty IDs
|
||||
}
|
||||
|
||||
s.logger.Info().Str("nzo_id", nzoID).Msg("Deleting NZB")
|
||||
|
||||
// Use atomic delete operation
|
||||
if err := s.usenet.Store().AtomicDelete(nzoID); err != nil {
|
||||
s.logger.Error().
|
||||
Err(err).
|
||||
Str("nzo_id", nzoID).
|
||||
Msg("Failed to delete NZB")
|
||||
errors = append(errors, fmt.Sprintf("Failed to delete %s: %v", nzoID, err))
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Return response with success/error information
|
||||
if len(errors) > 0 {
|
||||
if successCount == 0 {
|
||||
// All deletions failed
|
||||
s.writeError(w, fmt.Sprintf("All deletions failed: %s", strings.Join(errors, "; ")), http.StatusInternalServerError)
|
||||
return
|
||||
} else {
|
||||
// Partial success
|
||||
s.logger.Warn().
|
||||
Int("success_count", successCount).
|
||||
Int("error_count", len(errors)).
|
||||
Strs("errors", errors).
|
||||
Msg("Partial success in queue deletion")
|
||||
}
|
||||
}
|
||||
|
||||
response := StatusResponse{
|
||||
Status: true,
|
||||
Error: "", // Could add error details here if needed
|
||||
}
|
||||
request.JSONResponse(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// handlePause handles pause operations
|
||||
func (s *SABnzbd) handleQueuePause(w http.ResponseWriter, r *http.Request) {
|
||||
response := StatusResponse{Status: true}
|
||||
request.JSONResponse(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// handleQueue returns the current download queue
|
||||
func (s *SABnzbd) handleListQueue(w http.ResponseWriter, r *http.Request) {
|
||||
nzbs := s.usenet.Store().GetQueue()
|
||||
|
||||
queue := Queue{
|
||||
Version: Version,
|
||||
Slots: []QueueSlot{},
|
||||
}
|
||||
|
||||
// Convert NZBs to queue slots
|
||||
for _, nzb := range nzbs {
|
||||
if nzb.ETA <= 0 {
|
||||
nzb.ETA = 0 // Ensure ETA is non-negative
|
||||
}
|
||||
var timeLeft string
|
||||
if nzb.ETA == 0 {
|
||||
timeLeft = "00:00:00" // If ETA is 0, set TimeLeft to "00:00:00"
|
||||
} else {
|
||||
// Convert ETA from seconds to "HH:MM:SS" format
|
||||
duration := time.Duration(nzb.ETA) * time.Second
|
||||
timeLeft = duration.String()
|
||||
}
|
||||
slot := QueueSlot{
|
||||
Status: s.mapNZBStatus(nzb.Status),
|
||||
Mb: nzb.TotalSize,
|
||||
Filename: nzb.Name,
|
||||
Cat: nzb.Category,
|
||||
MBLeft: 0,
|
||||
Percentage: nzb.Percentage,
|
||||
NzoId: nzb.ID,
|
||||
Size: nzb.TotalSize,
|
||||
TimeLeft: timeLeft, // This is in "00:00:00" format
|
||||
}
|
||||
queue.Slots = append(queue.Slots, slot)
|
||||
}
|
||||
|
||||
response := QueueResponse{
|
||||
Queue: queue,
|
||||
Status: true,
|
||||
Version: Version,
|
||||
}
|
||||
|
||||
request.JSONResponse(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// handleHistory returns the download history
|
||||
func (s *SABnzbd) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
limitStr := r.FormValue("limit")
|
||||
if limitStr == "" {
|
||||
limitStr = "0"
|
||||
}
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("Invalid limit parameter for history")
|
||||
s.writeError(w, "Invalid limit parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
history := s.getHistory(r.Context(), limit)
|
||||
|
||||
response := HistoryResponse{
|
||||
History: history,
|
||||
}
|
||||
|
||||
request.JSONResponse(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// handleConfig returns the configuration
|
||||
func (s *SABnzbd) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
response := ConfigResponse{
|
||||
Config: s.config,
|
||||
}
|
||||
|
||||
request.JSONResponse(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// handleAddURL handles adding NZB by URL
|
||||
func (s *SABnzbd) handleAddURL(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
_arr := getArrFromContext(ctx)
|
||||
cat := getCategory(ctx)
|
||||
|
||||
if _arr == nil {
|
||||
// If Arr is not in context, create a new one with default values
|
||||
_arr = arr.New(cat, "", "", false, false, nil, "", "")
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
s.logger.Warn().Str("method", r.Method).Msg("Invalid method")
|
||||
s.writeError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
url := r.FormValue("name")
|
||||
action := r.FormValue("action")
|
||||
downloadDir := r.FormValue("download_dir")
|
||||
if action == "" {
|
||||
action = "symlink"
|
||||
}
|
||||
if downloadDir == "" {
|
||||
downloadDir = s.config.Misc.DownloadDir
|
||||
}
|
||||
|
||||
if url == "" {
|
||||
s.writeError(w, "URL is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
nzoID, err := s.addNZBURL(ctx, url, _arr, action, downloadDir)
|
||||
if err != nil {
|
||||
s.writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if nzoID == "" {
|
||||
s.writeError(w, "Failed to add NZB", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := AddNZBResponse{
|
||||
Status: true,
|
||||
NzoIds: []string{nzoID},
|
||||
}
|
||||
|
||||
request.JSONResponse(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// handleAddFile handles NZB file uploads
|
||||
func (s *SABnzbd) handleAddFile(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
_arr := getArrFromContext(ctx)
|
||||
cat := getCategory(ctx)
|
||||
|
||||
if _arr == nil {
|
||||
// If Arr is not in context, create a new one with default values
|
||||
_arr = arr.New(cat, "", "", false, false, nil, "", "")
|
||||
}
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
s.writeError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse multipart form
|
||||
err := r.ParseMultipartForm(32 << 20) // 32 MB limit
|
||||
if err != nil {
|
||||
s.writeError(w, "Failed to parse multipart form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("name")
|
||||
if err != nil {
|
||||
s.writeError(w, "No file uploaded", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read file content
|
||||
content, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
s.writeError(w, "Failed to read file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
action := r.FormValue("action")
|
||||
downloadDir := r.FormValue("download_dir")
|
||||
if action == "" {
|
||||
action = "symlink"
|
||||
}
|
||||
if downloadDir == "" {
|
||||
downloadDir = s.config.Misc.DownloadDir
|
||||
}
|
||||
|
||||
// Process NZB file
|
||||
nzbID, err := s.addNZBFile(ctx, content, header.Filename, _arr, action, downloadDir)
|
||||
if err != nil {
|
||||
s.writeError(w, fmt.Sprintf("Failed to add NZB file: %s", err.Error()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if nzbID == "" {
|
||||
s.writeError(w, "Failed to add NZB file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := AddNZBResponse{
|
||||
Status: true,
|
||||
NzoIds: []string{nzbID},
|
||||
}
|
||||
|
||||
request.JSONResponse(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// handleVersion returns version information
|
||||
func (s *SABnzbd) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
response := VersionResponse{
|
||||
Version: Version,
|
||||
}
|
||||
request.JSONResponse(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// handleGetCategories returns available categories
|
||||
func (s *SABnzbd) handleGetCategories(w http.ResponseWriter, r *http.Request) {
|
||||
categories := s.getCategories()
|
||||
request.JSONResponse(w, categories, http.StatusOK)
|
||||
}
|
||||
|
||||
// handleGetScripts returns available scripts
|
||||
func (s *SABnzbd) handleGetScripts(w http.ResponseWriter, r *http.Request) {
|
||||
scripts := []string{"None"}
|
||||
request.JSONResponse(w, scripts, http.StatusOK)
|
||||
}
|
||||
|
||||
// handleGetFiles returns files for a specific NZB
|
||||
func (s *SABnzbd) handleGetFiles(w http.ResponseWriter, r *http.Request) {
|
||||
nzoID := r.FormValue("value")
|
||||
var files []string
|
||||
|
||||
if nzoID != "" {
|
||||
nzb := s.usenet.Store().Get(nzoID)
|
||||
if nzb != nil {
|
||||
for _, file := range nzb.Files {
|
||||
files = append(files, file.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request.JSONResponse(w, files, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *SABnzbd) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
type status struct {
|
||||
CompletedDir string `json:"completed_dir"`
|
||||
}
|
||||
response := struct {
|
||||
Status status `json:"status"`
|
||||
}{
|
||||
Status: status{
|
||||
CompletedDir: s.config.Misc.DownloadDir,
|
||||
},
|
||||
}
|
||||
request.JSONResponse(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
func (s *SABnzbd) getHistory(ctx context.Context, limit int) History {
|
||||
cat := getCategory(ctx)
|
||||
items := s.usenet.Store().GetHistory(cat, limit)
|
||||
slots := make([]HistorySlot, 0, len(items))
|
||||
history := History{
|
||||
Version: Version,
|
||||
Paused: false,
|
||||
}
|
||||
for _, item := range items {
|
||||
slot := HistorySlot{
|
||||
Status: s.mapNZBStatus(item.Status),
|
||||
Name: item.Name,
|
||||
NZBName: item.Name,
|
||||
NzoId: item.ID,
|
||||
Category: item.Category,
|
||||
FailMessage: item.FailMessage,
|
||||
Bytes: item.TotalSize,
|
||||
Storage: item.Storage,
|
||||
}
|
||||
slots = append(slots, slot)
|
||||
}
|
||||
history.Slots = slots
|
||||
return history
|
||||
}
|
||||
|
||||
func (s *SABnzbd) writeError(w http.ResponseWriter, message string, status int) {
|
||||
response := StatusResponse{
|
||||
Status: false,
|
||||
Error: message,
|
||||
}
|
||||
request.JSONResponse(w, response, status)
|
||||
}
|
||||
|
||||
func (s *SABnzbd) mapNZBStatus(status string) string {
|
||||
switch status {
|
||||
case "downloading":
|
||||
return StatusDownloading
|
||||
case "completed":
|
||||
return StatusCompleted
|
||||
case "paused":
|
||||
return StatusPaused
|
||||
case "error", "failed":
|
||||
return StatusFailed
|
||||
case "processing":
|
||||
return StatusProcessing
|
||||
case "verifying":
|
||||
return StatusVerifying
|
||||
case "repairing":
|
||||
return StatusRepairing
|
||||
case "extracting":
|
||||
return StatusExtracting
|
||||
case "moving":
|
||||
return StatusMoving
|
||||
case "running":
|
||||
return StatusRunning
|
||||
default:
|
||||
return StatusQueued
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SABnzbd) addNZBURL(ctx context.Context, url string, arr *arr.Arr, action, downloadDir string) (string, error) {
|
||||
if url == "" {
|
||||
return "", fmt.Errorf("URL is required")
|
||||
}
|
||||
// Download NZB content
|
||||
filename, content, err := utils.DownloadFile(url)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Str("url", url).Msg("Failed to download NZB from URL")
|
||||
return "", fmt.Errorf("failed to download NZB from URL: %w", err)
|
||||
}
|
||||
|
||||
if len(content) == 0 {
|
||||
s.logger.Warn().Str("url", url).Msg("Downloaded content is empty")
|
||||
return "", fmt.Errorf("downloaded content is empty")
|
||||
}
|
||||
return s.addNZBFile(ctx, content, filename, arr, action, downloadDir)
|
||||
}
|
||||
|
||||
func (s *SABnzbd) addNZBFile(ctx context.Context, content []byte, filename string, arr *arr.Arr, action, downloadDir string) (string, error) {
|
||||
if s.usenet == nil {
|
||||
return "", fmt.Errorf("store not initialized")
|
||||
}
|
||||
req := &usenet.ProcessRequest{
|
||||
NZBContent: content,
|
||||
Name: filename,
|
||||
Arr: arr,
|
||||
Action: action,
|
||||
DownloadDir: downloadDir,
|
||||
}
|
||||
nzb, err := s.usenet.ProcessNZB(ctx, req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to process NZB: %w", err)
|
||||
}
|
||||
return nzb.ID, nil
|
||||
}
|
||||
24
pkg/sabnzbd/routes.go
Normal file
24
pkg/sabnzbd/routes.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package sabnzbd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func (s *SABnzbd) Routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(s.categoryContext)
|
||||
r.Use(s.authContext)
|
||||
|
||||
// SABnzbd API endpoints - all under /api with mode parameter
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(s.modeContext)
|
||||
|
||||
// Queue operations
|
||||
r.Get("/", s.handleAPI)
|
||||
r.Post("/", s.handleAPI)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
116
pkg/sabnzbd/sabnzbd.go
Normal file
116
pkg/sabnzbd/sabnzbd.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package sabnzbd
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/pkg/store"
|
||||
"github.com/sirrobot01/decypharr/pkg/usenet"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type SABnzbd struct {
|
||||
downloadFolder string
|
||||
config *Config
|
||||
refreshInterval int
|
||||
logger zerolog.Logger
|
||||
usenet usenet.Usenet
|
||||
defaultCategories []string
|
||||
}
|
||||
|
||||
func New(usenetClient usenet.Usenet) *SABnzbd {
|
||||
_cfg := config.Get()
|
||||
cfg := _cfg.SABnzbd
|
||||
var defaultCategories []string
|
||||
for _, cat := range _cfg.SABnzbd.Categories {
|
||||
if cat != "" {
|
||||
defaultCategories = append(defaultCategories, cat)
|
||||
}
|
||||
}
|
||||
sb := &SABnzbd{
|
||||
downloadFolder: cfg.DownloadFolder,
|
||||
refreshInterval: cfg.RefreshInterval,
|
||||
logger: logger.New("sabnzbd"),
|
||||
usenet: usenetClient,
|
||||
defaultCategories: defaultCategories,
|
||||
}
|
||||
sb.SetConfig(_cfg)
|
||||
return sb
|
||||
}
|
||||
|
||||
func (s *SABnzbd) SetConfig(cfg *config.Config) {
|
||||
sabnzbdConfig := &Config{
|
||||
Misc: MiscConfig{
|
||||
CompleteDir: s.downloadFolder,
|
||||
DownloadDir: s.downloadFolder,
|
||||
AdminDir: s.downloadFolder,
|
||||
WebPort: cfg.Port,
|
||||
Language: "en",
|
||||
RefreshRate: "1",
|
||||
QueueComplete: "0",
|
||||
ConfigLock: "0",
|
||||
Autobrowser: "1",
|
||||
CheckNewRel: "1",
|
||||
},
|
||||
Categories: s.getCategories(),
|
||||
}
|
||||
if cfg.Usenet != nil || len(cfg.Usenet.Providers) == 0 {
|
||||
for _, provider := range cfg.Usenet.Providers {
|
||||
if provider.Host == "" || provider.Port == 0 {
|
||||
continue
|
||||
}
|
||||
sabnzbdConfig.Servers = append(sabnzbdConfig.Servers, Server{
|
||||
Name: provider.Name,
|
||||
Host: provider.Host,
|
||||
Port: provider.Port,
|
||||
Username: provider.Username,
|
||||
Password: provider.Password,
|
||||
Connections: provider.Connections,
|
||||
SSL: provider.SSL,
|
||||
})
|
||||
}
|
||||
}
|
||||
s.config = sabnzbdConfig
|
||||
}
|
||||
|
||||
func (s *SABnzbd) getCategories() []Category {
|
||||
_store := store.Get()
|
||||
arrs := _store.Arr().GetAll()
|
||||
categories := make([]Category, 0, len(arrs))
|
||||
added := map[string]struct{}{}
|
||||
|
||||
for i, a := range arrs {
|
||||
if _, ok := added[a.Name]; ok {
|
||||
continue // Skip if category already added
|
||||
}
|
||||
categories = append(categories, Category{
|
||||
Name: a.Name,
|
||||
Order: i + 1,
|
||||
Pp: "3",
|
||||
Script: "None",
|
||||
Dir: filepath.Join(s.downloadFolder, a.Name),
|
||||
Priority: PriorityNormal,
|
||||
})
|
||||
}
|
||||
|
||||
// Add default categories if not already present
|
||||
for _, defaultCat := range s.defaultCategories {
|
||||
if _, ok := added[defaultCat]; ok {
|
||||
continue // Skip if default category already added
|
||||
}
|
||||
categories = append(categories, Category{
|
||||
Name: defaultCat,
|
||||
Order: len(categories) + 1,
|
||||
Pp: "3",
|
||||
Script: "None",
|
||||
Dir: filepath.Join(s.downloadFolder, defaultCat),
|
||||
Priority: PriorityNormal,
|
||||
})
|
||||
added[defaultCat] = struct{}{}
|
||||
}
|
||||
|
||||
return categories
|
||||
}
|
||||
|
||||
func (s *SABnzbd) Reset() {
|
||||
}
|
||||
150
pkg/sabnzbd/types.go
Normal file
150
pkg/sabnzbd/types.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package sabnzbd
|
||||
|
||||
// SABnzbd API response types based on official documentation
|
||||
|
||||
var (
|
||||
Version = "4.5.0"
|
||||
)
|
||||
|
||||
// QueueResponse represents the queue status response
|
||||
type QueueResponse struct {
|
||||
Queue Queue `json:"queue"`
|
||||
Status bool `json:"status"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// Queue represents the download queue
|
||||
type Queue struct {
|
||||
Version string `json:"version"`
|
||||
Slots []QueueSlot `json:"slots"`
|
||||
}
|
||||
|
||||
// QueueSlot represents a download in the queue
|
||||
type QueueSlot struct {
|
||||
Status string `json:"status"`
|
||||
TimeLeft string `json:"timeleft"`
|
||||
Mb int64 `json:"mb"`
|
||||
Filename string `json:"filename"`
|
||||
Priority string `json:"priority"`
|
||||
Cat string `json:"cat"`
|
||||
MBLeft int64 `json:"mbleft"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
NzoId string `json:"nzo_id"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// HistoryResponse represents the history response
|
||||
type HistoryResponse struct {
|
||||
History History `json:"history"`
|
||||
}
|
||||
|
||||
// History represents the download history
|
||||
type History struct {
|
||||
Version string `json:"version"`
|
||||
Paused bool `json:"paused"`
|
||||
Slots []HistorySlot `json:"slots"`
|
||||
}
|
||||
|
||||
// HistorySlot represents a completed download
|
||||
type HistorySlot struct {
|
||||
Status string `json:"status"`
|
||||
Name string `json:"name"`
|
||||
NZBName string `json:"nzb_name"`
|
||||
NzoId string `json:"nzo_id"`
|
||||
Category string `json:"category"`
|
||||
FailMessage string `json:"fail_message"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Storage string `json:"storage"`
|
||||
}
|
||||
|
||||
// StageLog represents processing stages
|
||||
type StageLog struct {
|
||||
Name string `json:"name"`
|
||||
Actions []string `json:"actions"`
|
||||
}
|
||||
|
||||
// VersionResponse represents version information
|
||||
type VersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// StatusResponse represents general status
|
||||
type StatusResponse struct {
|
||||
Status bool `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// FullStatusResponse represents the full status response with queue and history
|
||||
type FullStatusResponse struct {
|
||||
Queue Queue `json:"queue"`
|
||||
History History `json:"history"`
|
||||
Status bool `json:"status"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// AddNZBRequest represents the request to add an NZB
|
||||
type AddNZBRequest struct {
|
||||
Name string `json:"name"`
|
||||
Cat string `json:"cat"`
|
||||
Script string `json:"script"`
|
||||
Priority string `json:"priority"`
|
||||
PP string `json:"pp"`
|
||||
Password string `json:"password"`
|
||||
NZBData []byte `json:"nzb_data"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// AddNZBResponse represents the response when adding an NZB
|
||||
type AddNZBResponse struct {
|
||||
Status bool `json:"status"`
|
||||
NzoIds []string `json:"nzo_ids"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// API Mode constants
|
||||
const (
|
||||
ModeQueue = "queue"
|
||||
ModeHistory = "history"
|
||||
ModeConfig = "config"
|
||||
ModeGetConfig = "get_config"
|
||||
ModeAddURL = "addurl"
|
||||
ModeAddFile = "addfile"
|
||||
ModeVersion = "version"
|
||||
ModePause = "pause"
|
||||
ModeResume = "resume"
|
||||
ModeDelete = "delete"
|
||||
ModeShutdown = "shutdown"
|
||||
ModeRestart = "restart"
|
||||
ModeGetCats = "get_cats"
|
||||
ModeGetScripts = "get_scripts"
|
||||
ModeGetFiles = "get_files"
|
||||
ModeRetry = "retry"
|
||||
ModeStatus = "status"
|
||||
ModeFullStatus = "fullstatus"
|
||||
)
|
||||
|
||||
// Status constants
|
||||
const (
|
||||
StatusQueued = "Queued"
|
||||
StatusPaused = "Paused"
|
||||
StatusDownloading = "downloading"
|
||||
StatusProcessing = "Processing"
|
||||
StatusCompleted = "Completed"
|
||||
StatusFailed = "Failed"
|
||||
StatusGrabbing = "Grabbing"
|
||||
StatusPropagating = "Propagating"
|
||||
StatusVerifying = "Verifying"
|
||||
StatusRepairing = "Repairing"
|
||||
StatusExtracting = "Extracting"
|
||||
StatusMoving = "Moving"
|
||||
StatusRunning = "Running"
|
||||
)
|
||||
|
||||
// Priority constants
|
||||
const (
|
||||
PriorityForced = "2"
|
||||
PriorityHigh = "1"
|
||||
PriorityNormal = "0"
|
||||
PriorityLow = "-1"
|
||||
PriorityStop = "-2"
|
||||
)
|
||||
Reference in New Issue
Block a user