diff --git a/go.mod b/go.mod index 7c064df..1b13b1d 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/go-co-op/gocron/v2 v2.16.1 github.com/google/uuid v1.6.0 github.com/gorilla/sessions v1.4.0 + github.com/puzpuzpuz/xsync/v4 v4.1.0 github.com/robfig/cron/v3 v3.0.1 github.com/rs/zerolog v1.33.0 github.com/stanNthe5/stringbuf v0.0.3 @@ -34,7 +35,6 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/puzpuzpuz/xsync/v4 v4.1.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect golang.org/x/sys v0.33.0 // indirect ) diff --git a/internal/config/auth.go b/internal/config/auth.go new file mode 100644 index 0000000..eaccbb0 --- /dev/null +++ b/internal/config/auth.go @@ -0,0 +1,19 @@ +package config + +import "golang.org/x/crypto/bcrypt" + +func VerifyAuth(username, password string) bool { + // If you're storing hashed password, use bcrypt to compare + if username == "" { + return false + } + auth := Get().GetAuth() + if auth == nil { + return false + } + if username != auth.Username { + return false + } + err := bcrypt.CompareHashAndPassword([]byte(auth.Password), []byte(password)) + return err == nil +} diff --git a/internal/config/config.go b/internal/config/config.go index ebffc83..4ccf501 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -152,6 +152,7 @@ type Config struct { DiscordWebhook string `json:"discord_webhook_url,omitempty"` RemoveStalledAfter string `json:"remove_stalled_after,omitzero"` CallbackURL string `json:"callback_url,omitempty"` + EnableWebdavAuth bool `json:"enable_webdav_auth,omitempty"` } func (c *Config) JsonFile() string { @@ -337,12 +338,12 @@ func (c *Config) SaveAuth(auth *Auth) error { return os.WriteFile(c.AuthFile(), data, 0644) } -func (c *Config) NeedsSetup() error { +func (c *Config) CheckSetup() error { return ValidateConfig(c) } func (c *Config) NeedsAuth() bool { - return !c.UseAuth && c.GetAuth().Username == "" + return c.UseAuth && (c.Auth == nil || c.Auth.Username == "" || c.Auth.Password == "") } func (c *Config) updateDebrid(d Debrid) Debrid { diff --git a/internal/request/request.go b/internal/request/request.go index ab14c7c..79ba3d3 100644 --- a/internal/request/request.go +++ b/internal/request/request.go @@ -7,10 +7,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/rs/zerolog" - "github.com/sirrobot01/decypharr/internal/logger" - "go.uber.org/ratelimit" - "golang.org/x/net/proxy" "io" "math/rand" "net" @@ -20,6 +16,11 @@ import ( "strings" "sync" "time" + + "github.com/rs/zerolog" + "github.com/sirrobot01/decypharr/internal/logger" + "go.uber.org/ratelimit" + "golang.org/x/net/proxy" ) func JoinURL(base string, paths ...string) (string, error) { @@ -422,3 +423,42 @@ func SetProxy(transport *http.Transport, proxyURL string) { } return } + +func ValidateURL(urlStr string) error { + if urlStr == "" { + return fmt.Errorf("URL cannot be empty") + } + + // Try parsing as full URL first + u, err := url.Parse(urlStr) + if err == nil && u.Scheme != "" && u.Host != "" { + // It's a full URL, validate scheme + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("URL scheme must be http or https") + } + return nil + } + + // Check if it's a host:port format (no scheme) + if strings.Contains(urlStr, ":") && !strings.Contains(urlStr, "://") { + // Try parsing with http:// prefix + testURL := "http://" + urlStr + u, err := url.Parse(testURL) + if err != nil { + return fmt.Errorf("invalid host:port format: %w", err) + } + + if u.Host == "" { + return fmt.Errorf("host is required in host:port format") + } + + // Validate port number + if u.Port() == "" { + return fmt.Errorf("port is required in host:port format") + } + + return nil + } + + return fmt.Errorf("invalid URL format: %s", urlStr) +} diff --git a/internal/utils/file.go b/internal/utils/file.go index adc8a2d..2b9e52b 100644 --- a/internal/utils/file.go +++ b/internal/utils/file.go @@ -84,3 +84,54 @@ func readSmallChunks(file *os.File, startPos int64, totalToRead int, chunkSize i } return nil } + +func EnsureDir(dirPath string) error { + if dirPath == "" { + return fmt.Errorf("directory path is empty") + } + _, err := os.Stat(dirPath) + if os.IsNotExist(err) { + // Directory does not exist, create it + if err := os.MkdirAll(dirPath, 0755); err != nil { + return fmt.Errorf("failed to create directory: %v", err) + } + return nil + } + return err +} + +func FormatSize(bytes int64) string { + const ( + KB = 1024 + MB = 1024 * KB + GB = 1024 * MB + TB = 1024 * GB + ) + + var size float64 + var unit string + + switch { + case bytes >= TB: + size = float64(bytes) / TB + unit = "TB" + case bytes >= GB: + size = float64(bytes) / GB + unit = "GB" + case bytes >= MB: + size = float64(bytes) / MB + unit = "MB" + case bytes >= KB: + size = float64(bytes) / KB + unit = "KB" + default: + size = float64(bytes) + unit = "bytes" + } + + // Format to 2 decimal places for larger units, no decimals for bytes + if unit == "bytes" { + return fmt.Sprintf("%.0f %s", size, unit) + } + return fmt.Sprintf("%.2f %s", size, unit) +} diff --git a/pkg/arr/arr.go b/pkg/arr/arr.go index 21c549c..aa4b68d 100644 --- a/pkg/arr/arr.go +++ b/pkg/arr/arr.go @@ -36,9 +36,10 @@ const ( ) type Arr struct { - Name string `json:"name"` - Host string `json:"host"` - Token string `json:"token"` + Name string `json:"name"` + Host string `json:"host"` + Token string `json:"token"` + Type Type `json:"type"` Cleanup bool `json:"cleanup"` SkipRepair bool `json:"skip_repair"` @@ -110,7 +111,10 @@ func (a *Arr) Request(method, endpoint string, payload interface{}) (*http.Respo func (a *Arr) Validate() error { if a.Token == "" || a.Host == "" { - return nil + return fmt.Errorf("arr not configured") + } + if request.ValidateURL(a.Host) != nil { + return fmt.Errorf("invalid arr host URL") } resp, err := a.Request("GET", "/api/v3/health", nil) if err != nil { diff --git a/pkg/debrid/providers/realdebrid/realdebrid.go b/pkg/debrid/providers/realdebrid/realdebrid.go index c8bc41b..034e525 100644 --- a/pkg/debrid/providers/realdebrid/realdebrid.go +++ b/pkg/debrid/providers/realdebrid/realdebrid.go @@ -347,18 +347,11 @@ func (r *RealDebrid) addTorrent(t *types.Torrent) (*types.Torrent, error) { if resp.StatusCode == 509 { return nil, utils.TooManyActiveDownloadsError } - - bodyBytes, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes)) } defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading response body: %w", err) - } - if err = json.Unmarshal(bodyBytes, &data); err != nil { + if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, err } t.Id = data.Id @@ -379,6 +372,7 @@ func (r *RealDebrid) addMagnet(t *types.Torrent) (*types.Torrent, error) { if err != nil { return nil, err } + defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { // Handle multiple_downloads @@ -386,15 +380,10 @@ func (r *RealDebrid) addMagnet(t *types.Torrent) (*types.Torrent, error) { return nil, utils.TooManyActiveDownloadsError } - bodyBytes, _ := io.ReadAll(resp.Body) + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return nil, fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes)) } - defer resp.Body.Close() - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading response body: %w", err) - } - if err = json.Unmarshal(bodyBytes, &data); err != nil { + if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, err } t.Id = data.Id @@ -412,19 +401,15 @@ func (r *RealDebrid) GetTorrent(torrentId string) (*types.Torrent, error) { return nil, err } defer resp.Body.Close() - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading response body: %w", err) - } if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) if resp.StatusCode == http.StatusNotFound { return nil, utils.TorrentNotFoundError } - return nil, fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes)) + return nil, fmt.Errorf("realdebrid API error: Status: %d || Body %s", resp.StatusCode, string(bodyBytes)) } var data torrentInfo - err = json.Unmarshal(bodyBytes, &data) - if err != nil { + if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, err } t := &types.Torrent{ @@ -455,19 +440,15 @@ func (r *RealDebrid) UpdateTorrent(t *types.Torrent) error { return err } defer resp.Body.Close() - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("reading response body: %w", err) - } if resp.StatusCode != http.StatusOK { if resp.StatusCode == http.StatusNotFound { return utils.TorrentNotFoundError } + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes)) } var data torrentInfo - err = json.Unmarshal(bodyBytes, &data) - if err != nil { + if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { return err } t.Name = data.Filename @@ -657,13 +638,9 @@ func (r *RealDebrid) getDownloadLink(account *account.Account, file *types.File) }(resp.Body) if resp.StatusCode != http.StatusOK { // Read the response body to get the error message - b, err := io.ReadAll(resp.Body) - if err != nil { - return emptyLink, err - } var data ErrorResponse - if err = json.Unmarshal(b, &data); err != nil { - return emptyLink, fmt.Errorf("error unmarshalling %d || %s \n %s", resp.StatusCode, err, string(b)) + if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { + return emptyLink, fmt.Errorf("error unmarshalling %d || %s", resp.StatusCode, err) } switch data.ErrorCode { case 19, 24, 35: @@ -674,12 +651,8 @@ func (r *RealDebrid) getDownloadLink(account *account.Account, file *types.File) return emptyLink, fmt.Errorf("realdebrid API error: Status: %d || Code: %d", resp.StatusCode, data.ErrorCode) } } - b, err := io.ReadAll(resp.Body) - if err != nil { - return emptyLink, err - } var data UnrestrictResponse - if err = json.Unmarshal(b, &data); err != nil { + if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { return emptyLink, fmt.Errorf("realdebrid API error: Error unmarshalling response: %w", err) } if data.Download == "" { @@ -758,14 +731,10 @@ func (r *RealDebrid) getTorrents(offset int, limit int) (int, []*types.Torrent, } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return 0, torrents, err - } totalItems, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) var data []TorrentsResponse - if err = json.Unmarshal(body, &data); err != nil { - return 0, torrents, err + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return 0, nil, fmt.Errorf("failed to decode response: %w", err) } filenames := map[string]struct{}{} for _, t := range data { diff --git a/pkg/debrid/store/cache.go b/pkg/debrid/store/cache.go index 15fce6d..5c46750 100644 --- a/pkg/debrid/store/cache.go +++ b/pkg/debrid/store/cache.go @@ -23,6 +23,7 @@ import ( "github.com/sirrobot01/decypharr/pkg/rclone" "github.com/sirrobot01/decypharr/pkg/debrid/types" + "golang.org/x/sync/singleflight" "encoding/json" _ "time/tzdata" @@ -88,6 +89,7 @@ type Cache struct { invalidDownloadLinks *xsync.Map[string, string] repairRequest *xsync.Map[string, *reInsertRequest] failedToReinsert *xsync.Map[string, struct{}] + failedLinksCounter *xsync.Map[string, *atomic.Int32] // link -> counter // repair repairChan chan RepairRequest @@ -112,7 +114,8 @@ type Cache struct { config config.Debrid customFolders []string mounter *rclone.Mount - httpClient *http.Client + downloadSG singleflight.Group + streamClient *http.Client } func NewDebridCache(dc config.Debrid, client common.Client, mounter *rclone.Mount) *Cache { @@ -160,10 +163,13 @@ func NewDebridCache(dc config.Debrid, client common.Client, mounter *rclone.Moun _log := logger.New(fmt.Sprintf("%s-webdav", client.Name())) transport := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - TLSHandshakeTimeout: 10 * time.Second, - ResponseHeaderTimeout: 30 * time.Second, - MaxIdleConns: 10, - MaxIdleConnsPerHost: 2, + TLSHandshakeTimeout: 30 * time.Second, + ResponseHeaderTimeout: 60 * time.Second, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 20, + IdleConnTimeout: 90 * time.Second, + DisableKeepAlives: false, + ForceAttemptHTTP2: false, } httpClient := &http.Client{ Transport: transport, @@ -189,10 +195,11 @@ func NewDebridCache(dc config.Debrid, client common.Client, mounter *rclone.Moun mounter: mounter, ready: make(chan struct{}), - httpClient: httpClient, invalidDownloadLinks: xsync.NewMap[string, string](), repairRequest: xsync.NewMap[string, *reInsertRequest](), failedToReinsert: xsync.NewMap[string, struct{}](), + failedLinksCounter: xsync.NewMap[string, *atomic.Int32](), + streamClient: httpClient, repairChan: make(chan RepairRequest, 100), // Initialize the repair channel, max 100 requests buffered } @@ -924,7 +931,3 @@ func (c *Cache) Logger() zerolog.Logger { func (c *Cache) GetConfig() config.Debrid { return c.config } - -func (c *Cache) Download(req *http.Request) (*http.Response, error) { - return c.httpClient.Do(req) -} diff --git a/pkg/debrid/store/download_link.go b/pkg/debrid/store/download_link.go index 3c6eddc..dcbdfce 100644 --- a/pkg/debrid/store/download_link.go +++ b/pkg/debrid/store/download_link.go @@ -3,50 +3,50 @@ package store import ( "errors" "fmt" + "sync/atomic" "github.com/sirrobot01/decypharr/internal/utils" "github.com/sirrobot01/decypharr/pkg/debrid/types" ) -type downloadLinkRequest struct { - result string - err error - done chan struct{} -} - -func newDownloadLinkRequest() *downloadLinkRequest { - return &downloadLinkRequest{ - done: make(chan struct{}), - } -} - -func (r *downloadLinkRequest) Complete(result string, err error) { - r.result = result - r.err = err - close(r.done) -} - -func (r *downloadLinkRequest) Wait() (string, error) { - <-r.done - return r.result, r.err -} +const ( + MaxLinkFailures = 10 +) func (c *Cache) GetDownloadLink(torrentName, filename, fileLink string) (types.DownloadLink, error) { - // Check link cache - if dl, err := c.checkDownloadLink(fileLink); err == nil && !dl.Empty() { - return dl, nil + // Check + counter, ok := c.failedLinksCounter.Load(fileLink) + if ok && counter.Load() >= MaxLinkFailures { + return types.DownloadLink{}, fmt.Errorf("file link %s has failed %d times, not retrying", fileLink, counter.Load()) } - dl, err := c.fetchDownloadLink(torrentName, filename, fileLink) + // Use singleflight to deduplicate concurrent requests + v, err, _ := c.downloadSG.Do(fileLink, func() (interface{}, error) { + // Double-check cache inside singleflight (another goroutine might have filled it) + if dl, err := c.checkDownloadLink(fileLink); err == nil && !dl.Empty() { + return dl, nil + } + + // Fetch the download link + dl, err := c.fetchDownloadLink(torrentName, filename, fileLink) + if err != nil { + c.downloadSG.Forget(fileLink) + return types.DownloadLink{}, err + } + + if dl.Empty() { + c.downloadSG.Forget(fileLink) + err = fmt.Errorf("download link is empty for %s in torrent %s", filename, torrentName) + return types.DownloadLink{}, err + } + + return dl, nil + }) + if err != nil { return types.DownloadLink{}, err } - - if dl.Empty() { - err = fmt.Errorf("download link is empty for %s in torrent %s", filename, torrentName) - return types.DownloadLink{}, err - } - return dl, err + return v.(types.DownloadLink), nil } func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (types.DownloadLink, error) { @@ -146,7 +146,13 @@ func (c *Cache) checkDownloadLink(link string) (types.DownloadLink, error) { return types.DownloadLink{}, fmt.Errorf("download link not found for %s", link) } -func (c *Cache) MarkDownloadLinkAsInvalid(downloadLink types.DownloadLink, reason string) { +func (c *Cache) MarkLinkAsInvalid(downloadLink types.DownloadLink, reason string) { + // Increment file link error counter + counter, _ := c.failedLinksCounter.LoadOrCompute(downloadLink.Link, func() (*atomic.Int32, bool) { + return &atomic.Int32{}, true + }) + counter.Add(1) + c.invalidDownloadLinks.Store(downloadLink.DownloadLink, reason) // Remove the download api key from active if reason == "bandwidth_exceeded" { @@ -166,8 +172,7 @@ func (c *Cache) MarkDownloadLinkAsInvalid(downloadLink types.DownloadLink, reaso } func (c *Cache) downloadLinkIsInvalid(downloadLink string) bool { - if reason, ok := c.invalidDownloadLinks.Load(downloadLink); ok { - c.logger.Debug().Msgf("Download link %s is invalid: %s", downloadLink, reason) + if _, ok := c.invalidDownloadLinks.Load(downloadLink); ok { return true } return false diff --git a/pkg/debrid/store/misc.go b/pkg/debrid/store/misc.go index 7908187..5bb9f33 100644 --- a/pkg/debrid/store/misc.go +++ b/pkg/debrid/store/misc.go @@ -1,8 +1,9 @@ package store import ( - "github.com/sirrobot01/decypharr/pkg/debrid/types" "sort" + + "github.com/sirrobot01/decypharr/pkg/debrid/types" ) // MergeFiles merges the files from multiple torrents into a single map. diff --git a/pkg/debrid/store/stream.go b/pkg/debrid/store/stream.go new file mode 100644 index 0000000..fcff288 --- /dev/null +++ b/pkg/debrid/store/stream.go @@ -0,0 +1,239 @@ +package store + +import ( + "context" + "errors" + "fmt" + "io" + "math/rand" + "net" + "net/http" + "strings" + "time" + + "github.com/sirrobot01/decypharr/pkg/debrid/types" +) + +const ( + MaxNetworkRetries = 5 + MaxLinkRetries = 10 +) + +type StreamError struct { + Err error + Retryable bool + LinkError bool // true if we should try a new link +} + +func (e StreamError) Error() string { + return e.Err.Error() +} + +// isConnectionError checks if the error is related to connection issues +func (c *Cache) isConnectionError(err error) bool { + if err == nil { + return false + } + + errStr := err.Error() + // Check for common connection errors + if strings.Contains(errStr, "EOF") || + strings.Contains(errStr, "connection reset by peer") || + strings.Contains(errStr, "broken pipe") || + strings.Contains(errStr, "connection refused") { + return true + } + + // Check for net.Error types + var netErr net.Error + return errors.As(err, &netErr) +} + +func (c *Cache) Stream(ctx context.Context, start, end int64, linkFunc func() (types.DownloadLink, error)) (*http.Response, error) { + + var lastErr error + + downloadLink, err := linkFunc() + if err != nil { + return nil, fmt.Errorf("failed to get download link: %w", err) + } + + // Outer loop: Link retries + for retry := 0; retry < MaxLinkRetries; retry++ { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + resp, err := c.doRequest(ctx, downloadLink.DownloadLink, start, end) + if err != nil { + // Network/connection error + lastErr = err + c.logger.Trace(). + Int("retries", retry). + Err(err). + Msg("Network request failed, retrying") + + // Backoff and continue network retry + if retry < MaxLinkRetries { + backoff := time.Duration(retry+1) * time.Second + jitter := time.Duration(rand.Intn(1000)) * time.Millisecond + select { + case <-time.After(backoff + jitter): + case <-ctx.Done(): + return nil, ctx.Err() + } + continue + } else { + return nil, fmt.Errorf("network request failed after retries: %w", lastErr) + } + } + + // Got response - check status + if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent { + return resp, nil + } + + // Bad status code - handle error + streamErr := c.handleHTTPError(resp, downloadLink) + resp.Body.Close() + + if !streamErr.Retryable { + return nil, streamErr // Fatal error + } + + if streamErr.LinkError { + c.logger.Trace(). + Int("retries", retry). + Msg("Link error, getting fresh link") + lastErr = streamErr + // Try new link + downloadLink, err = linkFunc() + if err != nil { + return nil, fmt.Errorf("failed to get download link: %w", err) + } + continue + } + + // Retryable HTTP error (429, 503, etc.) - retry network + lastErr = streamErr + c.logger.Trace(). + Err(lastErr). + Str("downloadLink", downloadLink.DownloadLink). + Str("link", downloadLink.Link). + Int("retries", retry). + Int("statusCode", resp.StatusCode). + Msg("HTTP error, retrying") + + if retry < MaxNetworkRetries-1 { + backoff := time.Duration(retry+1) * time.Second + jitter := time.Duration(rand.Intn(1000)) * time.Millisecond + select { + case <-time.After(backoff + jitter): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + } + + return nil, fmt.Errorf("stream failed after %d link retries: %w", MaxLinkRetries, lastErr) +} + +func (c *Cache) StreamReader(ctx context.Context, start, end int64, linkFunc func() (types.DownloadLink, error)) (io.ReadCloser, error) { + resp, err := c.Stream(ctx, start, end, linkFunc) + if err != nil { + return nil, err + } + + // Validate we got the expected content + if resp.ContentLength == 0 { + resp.Body.Close() + return nil, fmt.Errorf("received empty response") + } + + return resp.Body, nil +} + +func (c *Cache) doRequest(ctx context.Context, url string, start, end int64) (*http.Response, error) { + var lastErr error + // Retry loop specifically for connection-level failures (EOF, reset, etc.) + for connRetry := 0; connRetry < 3; connRetry++ { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, StreamError{Err: err, Retryable: false} + } + + // Set range header + if start > 0 || end > 0 { + rangeHeader := fmt.Sprintf("bytes=%d-", start) + if end > 0 { + rangeHeader = fmt.Sprintf("bytes=%d-%d", start, end) + } + req.Header.Set("Range", rangeHeader) + } + + // Set optimized headers for streaming + req.Header.Set("Connection", "keep-alive") + req.Header.Set("Accept-Encoding", "identity") // Disable compression for streaming + req.Header.Set("Cache-Control", "no-cache") + + resp, err := c.streamClient.Do(req) + if err != nil { + lastErr = err + + // Check if it's a connection error that we should retry + if c.isConnectionError(err) && connRetry < 2 { + // Brief backoff before retrying with fresh connection + time.Sleep(time.Duration(connRetry+1) * 100 * time.Millisecond) + continue + } + + return nil, StreamError{Err: err, Retryable: true} + } + return resp, nil + } + + return nil, StreamError{Err: fmt.Errorf("connection retry exhausted: %w", lastErr), Retryable: true} +} + +func (c *Cache) handleHTTPError(resp *http.Response, downloadLink types.DownloadLink) StreamError { + body, _ := io.ReadAll(resp.Body) + bodyStr := strings.ToLower(string(body)) + + switch resp.StatusCode { + case http.StatusNotFound: + c.MarkLinkAsInvalid(downloadLink, "link_not_found") + return StreamError{ + Err: errors.New("download link not found"), + Retryable: true, + LinkError: true, + } + + case http.StatusServiceUnavailable: + if strings.Contains(bodyStr, "bandwidth") || strings.Contains(bodyStr, "traffic") { + c.MarkLinkAsInvalid(downloadLink, "bandwidth_exceeded") + return StreamError{ + Err: errors.New("bandwidth limit exceeded"), + Retryable: true, + LinkError: true, + } + } + fallthrough + + case http.StatusTooManyRequests: + return StreamError{ + Err: fmt.Errorf("HTTP %d: rate limited", resp.StatusCode), + Retryable: true, + LinkError: false, + } + + default: + retryable := resp.StatusCode >= 500 + return StreamError{ + Err: fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)), + Retryable: retryable, + LinkError: false, + } + } +} diff --git a/pkg/debrid/store/worker.go b/pkg/debrid/store/worker.go index 28f0710..70675ce 100644 --- a/pkg/debrid/store/worker.go +++ b/pkg/debrid/store/worker.go @@ -2,6 +2,7 @@ package store import ( "context" + "github.com/go-co-op/gocron/v2" "github.com/sirrobot01/decypharr/internal/utils" ) diff --git a/pkg/debrid/types/torrent.go b/pkg/debrid/types/torrent.go index 8190feb..3600ca6 100644 --- a/pkg/debrid/types/torrent.go +++ b/pkg/debrid/types/torrent.go @@ -2,7 +2,6 @@ package types import ( "fmt" - "net/url" "os" "path/filepath" "sync" @@ -182,20 +181,10 @@ type DownloadLink struct { ExpiresAt time.Time } -func isValidURL(str string) bool { - u, err := url.Parse(str) - // A valid URL should parse without error, and have a non-empty scheme and host. - return err == nil && u.Scheme != "" && u.Host != "" -} - func (dl *DownloadLink) Valid() error { if dl.Empty() { return EmptyDownloadLinkError } - // Check if the link is actually a valid URL - if !isValidURL(dl.DownloadLink) { - return ErrDownloadLinkNotFound - } return nil } diff --git a/pkg/qbit/context.go b/pkg/qbit/context.go index 0778e77..fb2c71f 100644 --- a/pkg/qbit/context.go +++ b/pkg/qbit/context.go @@ -6,14 +6,12 @@ import ( "encoding/base64" "fmt" "net/http" - "net/url" "strings" "github.com/go-chi/chi/v5" "github.com/sirrobot01/decypharr/internal/config" "github.com/sirrobot01/decypharr/pkg/arr" "github.com/sirrobot01/decypharr/pkg/wire" - "golang.org/x/crypto/bcrypt" ) type contextKey string @@ -24,45 +22,6 @@ const ( arrKey contextKey = "arr" ) -func validateServiceURL(urlStr string) error { - if urlStr == "" { - return fmt.Errorf("URL cannot be empty") - } - - // Try parsing as full URL first - u, err := url.Parse(urlStr) - if err == nil && u.Scheme != "" && u.Host != "" { - // It's a full URL, validate scheme - if u.Scheme != "http" && u.Scheme != "https" { - return fmt.Errorf("URL scheme must be http or https") - } - return nil - } - - // Check if it's a host:port format (no scheme) - if strings.Contains(urlStr, ":") && !strings.Contains(urlStr, "://") { - // Try parsing with http:// prefix - testURL := "http://" + urlStr - u, err := url.Parse(testURL) - if err != nil { - return fmt.Errorf("invalid host:port format: %w", err) - } - - if u.Host == "" { - return fmt.Errorf("host is required in host:port format") - } - - // Validate port number - if u.Port() == "" { - return fmt.Errorf("port is required in host:port format") - } - - return nil - } - - return fmt.Errorf("invalid URL format: %s", urlStr) -} - func getCategory(ctx context.Context) string { if category, ok := ctx.Value(categoryKey).(string); ok { return category @@ -187,21 +146,27 @@ func (q *QBit) authenticate(category, username, password string) (*arr.Arr, erro } a.Host = username a.Token = password - if cfg.UseAuth { - if a.Host == "" || a.Token == "" { - return nil, fmt.Errorf("unauthorized: Host and token are required for authentication(you've enabled authentication)") - } - // try to use either Arr validate, or user auth validation - if err := a.Validate(); err != nil { - // If this failed, try to use user auth validation - if !verifyAuth(username, password) { - return nil, fmt.Errorf("unauthorized: invalid credentials") - } - } + arrValidated := false // This is a flag to indicate if arr validation was successful + if a.Host == "" || a.Token == "" && cfg.UseAuth { + return nil, fmt.Errorf("unauthorized: Host and token are required for authentication(you've enabled authentication)") + } + + if err := a.Validate(); err == nil { + arrValidated = true + } + + if !arrValidated && cfg.UseAuth { + // If arr validation failed, try to use user auth validation + if !config.VerifyAuth(username, password) { + return nil, fmt.Errorf("unauthorized: invalid credentials") + } + } + if arrValidated { + // Only update the arr if arr validation was successful + a.Source = "auto" + arrs.AddOrUpdate(a) } - a.Source = "auto" - arrs.AddOrUpdate(a) return a, nil } @@ -264,19 +229,3 @@ func hashesContext(next http.Handler) http.Handler { next.ServeHTTP(w, r.WithContext(ctx)) }) } - -func verifyAuth(username, password string) bool { - // If you're storing hashed password, use bcrypt to compare - if username == "" { - return false - } - auth := config.Get().GetAuth() - if auth == nil { - return false - } - if username != auth.Username { - return false - } - err := bcrypt.CompareHashAndPassword([]byte(auth.Password), []byte(password)) - return err == nil -} diff --git a/pkg/rar/rarar.go b/pkg/rar/rarar.go index ae9f4b5..deb1b8b 100644 --- a/pkg/rar/rarar.go +++ b/pkg/rar/rarar.go @@ -191,7 +191,6 @@ func (f *HttpFile) ReadAt(p []byte, off int64) (n int, err error) { bytesRead, err := io.ReadFull(resp.Body, p) return bytesRead, err case http.StatusOK: - // Some servers return the full content instead of partial fullData, err := io.ReadAll(resp.Body) if err != nil { return 0, fmt.Errorf("%w: %v", ErrNetworkError, err) @@ -684,18 +683,3 @@ func (r *Reader) ExtractFile(file *File) ([]byte, error) { return r.readBytes(file.DataOffset, int(file.CompressedSize)) } - -// Helper functions -func min(a, b int) int { - if a < b { - return a - } - return b -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/pkg/web/middlewares.go b/pkg/web/middlewares.go index afe8f67..35891b0 100644 --- a/pkg/web/middlewares.go +++ b/pkg/web/middlewares.go @@ -12,16 +12,16 @@ import ( func (wb *Web) setupMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cfg := config.Get() - needsAuth := cfg.NeedsSetup() - if needsAuth != nil && r.URL.Path != "/config" && r.URL.Path != "/api/config" { - http.Redirect(w, r, fmt.Sprintf("/config?inco=%s", needsAuth.Error()), http.StatusSeeOther) + needsSetup := cfg.CheckSetup() + if needsSetup != nil && r.URL.Path != "/settings" && r.URL.Path != "/api/config" { + http.Redirect(w, r, fmt.Sprintf("/settings?inco=%s", needsSetup.Error()), http.StatusSeeOther) return } // strip inco from URL - if inco := r.URL.Query().Get("inco"); inco != "" && needsAuth == nil && r.URL.Path == "/config" { + if inco := r.URL.Query().Get("inco"); inco != "" && needsSetup == nil && r.URL.Path == "/settings" { // redirect to the same URL without the inco parameter - http.Redirect(w, r, "/config", http.StatusSeeOther) + http.Redirect(w, r, "/settings", http.StatusSeeOther) } next.ServeHTTP(w, r) }) @@ -79,8 +79,11 @@ func (wb *Web) isAPIRequest(r *http.Request) bool { func (wb *Web) sendJSONError(w http.ResponseWriter, message string, statusCode int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) - json.NewEncoder(w).Encode(map[string]interface{}{ + err := json.NewEncoder(w).Encode(map[string]interface{}{ "error": message, "status": statusCode, }) + if err != nil { + return + } } diff --git a/pkg/web/routes.go b/pkg/web/routes.go index e3e7b38..0192bce 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -1,53 +1,58 @@ package web import ( - "github.com/go-chi/chi/v5" "io/fs" "net/http" + + "github.com/go-chi/chi/v5" ) func (wb *Web) Routes() http.Handler { r := chi.NewRouter() - // Load static files from embedded filesystem - staticFS, err := fs.Sub(assetsEmbed, "assets/build") - if err != nil { - panic(err) - } - imagesFS, err := fs.Sub(imagesEmbed, "assets/images") - if err != nil { - panic(err) - } - + // Static assets - always public + staticFS, _ := fs.Sub(assetsEmbed, "assets/build") + imagesFS, _ := fs.Sub(imagesEmbed, "assets/images") r.Handle("/assets/*", http.StripPrefix("/assets/", http.FileServer(http.FS(staticFS)))) r.Handle("/images/*", http.StripPrefix("/images/", http.FileServer(http.FS(imagesFS)))) + // Public routes - no auth needed + r.Get("/version", wb.handleGetVersion) r.Get("/login", wb.LoginHandler) r.Post("/login", wb.LoginHandler) r.Get("/register", wb.RegisterHandler) r.Post("/register", wb.RegisterHandler) - r.Get("/skip-auth", wb.skipAuthHandler) - r.Get("/version", wb.handleGetVersion) + r.Post("/skip-auth", wb.skipAuthHandler) + // Protected routes - require auth r.Group(func(r chi.Router) { r.Use(wb.authMiddleware) - r.Use(wb.setupMiddleware) + // Web pages r.Get("/", wb.IndexHandler) r.Get("/download", wb.DownloadHandler) r.Get("/repair", wb.RepairHandler) r.Get("/stats", wb.StatsHandler) - r.Get("/config", wb.ConfigHandler) + r.Get("/settings", wb.ConfigHandler) + + // API routes r.Route("/api", func(r chi.Router) { + // Arr management r.Get("/arrs", wb.handleGetArrs) r.Post("/add", wb.handleAddContent) + + // Repair operations r.Post("/repair", wb.handleRepairMedia) r.Get("/repair/jobs", wb.handleGetRepairJobs) r.Post("/repair/jobs/{id}/process", wb.handleProcessRepairJob) r.Post("/repair/jobs/{id}/stop", wb.handleStopRepairJob) r.Delete("/repair/jobs", wb.handleDeleteRepairJob) + + // Torrent management r.Get("/torrents", wb.handleGetTorrents) r.Delete("/torrents/{category}/{hash}", wb.handleDeleteTorrent) - r.Delete("/torrents/", wb.handleDeleteTorrents) + r.Delete("/torrents", wb.handleDeleteTorrents) // Fixed trailing slash + + // Config/Auth r.Get("/config", wb.handleGetConfig) r.Post("/config", wb.handleUpdateConfig) r.Post("/refresh-token", wb.handleRefreshAPIToken) diff --git a/pkg/web/templates/config.html b/pkg/web/templates/config.html index fb8a7f6..b067d49 100644 --- a/pkg/web/templates/config.html +++ b/pkg/web/templates/config.html @@ -1,5 +1,15 @@ {{ define "config" }}