From 700d00b802d0beda5dd25524f892b5fd9364582b Mon Sep 17 00:00:00 2001 From: Mukhtar Akere Date: Wed, 8 Oct 2025 08:13:13 +0100 Subject: [PATCH] - Fix issues with new setup - Fix arr setup getting thr wrong crendentials - Add file link invalidator - Other minor bug fixes --- go.mod | 2 +- internal/config/auth.go | 19 ++ internal/config/config.go | 5 +- internal/request/request.go | 48 +++- internal/utils/file.go | 51 ++++ pkg/arr/arr.go | 12 +- pkg/debrid/providers/realdebrid/realdebrid.go | 59 +---- pkg/debrid/store/cache.go | 23 +- pkg/debrid/store/download_link.go | 75 +++--- pkg/debrid/store/misc.go | 3 +- pkg/debrid/store/stream.go | 239 ++++++++++++++++++ pkg/debrid/store/worker.go | 1 + pkg/debrid/types/torrent.go | 11 - pkg/qbit/context.go | 89 ++----- pkg/rar/rarar.go | 16 -- pkg/web/middlewares.go | 15 +- pkg/web/routes.go | 37 +-- pkg/web/templates/config.html | 10 + pkg/web/templates/download.html | 10 + pkg/web/templates/index.html | 10 + pkg/web/templates/layout.html | 4 +- pkg/web/templates/register.html | 2 +- pkg/web/templates/repair.html | 10 + pkg/web/ui.go | 26 +- pkg/webdav/file.go | 192 ++------------ pkg/webdav/handler.go | 18 +- pkg/webdav/misc.go | 8 - pkg/webdav/webdav.go | 64 ++--- pkg/wire/torrent.go | 12 +- 29 files changed, 606 insertions(+), 465 deletions(-) create mode 100644 internal/config/auth.go create mode 100644 pkg/debrid/store/stream.go 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" }}
+ {{ if .NeedSetup }} + + {{ end }} +
diff --git a/pkg/web/templates/download.html b/pkg/web/templates/download.html index 184dcad..6fccac1 100644 --- a/pkg/web/templates/download.html +++ b/pkg/web/templates/download.html @@ -1,5 +1,15 @@ {{ define "download" }}
+ {{ if .NeedSetup }} + + {{ end }} +
diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index d539cbc..b5acb15 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -1,6 +1,16 @@ {{ define "index" }}
+ {{ if .NeedSetup }} + + {{ end }} +
diff --git a/pkg/web/templates/layout.html b/pkg/web/templates/layout.html index 4161feb..f3bdeb7 100644 --- a/pkg/web/templates/layout.html +++ b/pkg/web/templates/layout.html @@ -54,7 +54,7 @@
  • Repair
  • -
  • +
  • Settings
  • @@ -85,7 +85,7 @@
  • -
  • +
  • diff --git a/pkg/web/templates/register.html b/pkg/web/templates/register.html index 04ed40b..8fc0d8e 100644 --- a/pkg/web/templates/register.html +++ b/pkg/web/templates/register.html @@ -75,7 +75,7 @@ // Handle skip auth button skipAuthBtn.addEventListener('click', function() { - window.decypharrUtils.fetcher('/skip-auth', { method: 'GET' }) + window.decypharrUtils.fetcher('/skip-auth', { method: 'POST' }) .then(response => { if (response.ok) { window.location.href = window.decypharrUtils.joinURL(window.urlBase, '/'); diff --git a/pkg/web/templates/repair.html b/pkg/web/templates/repair.html index bbf5bb1..5754ce1 100644 --- a/pkg/web/templates/repair.html +++ b/pkg/web/templates/repair.html @@ -1,5 +1,15 @@ {{ define "repair" }}
    + {{ if .NeedSetup }} + + {{ end }} +

    diff --git a/pkg/web/ui.go b/pkg/web/ui.go index 35a003d..37eed23 100644 --- a/pkg/web/ui.go +++ b/pkg/web/ui.go @@ -114,9 +114,11 @@ func (wb *Web) RegisterHandler(w http.ResponseWriter, r *http.Request) { func (wb *Web) IndexHandler(w http.ResponseWriter, r *http.Request) { cfg := config.Get() data := map[string]interface{}{ - "URLBase": cfg.URLBase, - "Page": "index", - "Title": "Torrents", + "URLBase": cfg.URLBase, + "Page": "index", + "Title": "Torrents", + "NeedSetup": cfg.CheckSetup() != nil, + "SetupError": cfg.CheckSetup(), } _ = wb.templates.ExecuteTemplate(w, "layout", data) } @@ -134,6 +136,8 @@ func (wb *Web) DownloadHandler(w http.ResponseWriter, r *http.Request) { "Debrids": debrids, "HasMultiDebrid": len(debrids) > 1, "DownloadFolder": cfg.QBitTorrent.DownloadFolder, + "NeedSetup": cfg.CheckSetup() != nil, + "SetupError": cfg.CheckSetup(), } _ = wb.templates.ExecuteTemplate(w, "layout", data) } @@ -141,9 +145,11 @@ func (wb *Web) DownloadHandler(w http.ResponseWriter, r *http.Request) { func (wb *Web) RepairHandler(w http.ResponseWriter, r *http.Request) { cfg := config.Get() data := map[string]interface{}{ - "URLBase": cfg.URLBase, - "Page": "repair", - "Title": "Repair", + "URLBase": cfg.URLBase, + "Page": "repair", + "Title": "Repair", + "NeedSetup": cfg.CheckSetup() != nil, + "SetupError": cfg.CheckSetup(), } _ = wb.templates.ExecuteTemplate(w, "layout", data) } @@ -151,9 +157,11 @@ func (wb *Web) RepairHandler(w http.ResponseWriter, r *http.Request) { func (wb *Web) ConfigHandler(w http.ResponseWriter, r *http.Request) { cfg := config.Get() data := map[string]interface{}{ - "URLBase": cfg.URLBase, - "Page": "config", - "Title": "Config", + "URLBase": cfg.URLBase, + "Page": "config", + "Title": "Config", + "NeedSetup": cfg.CheckSetup() != nil, + "SetupError": cfg.CheckSetup(), } _ = wb.templates.ExecuteTemplate(w, "layout", data) } diff --git a/pkg/webdav/file.go b/pkg/webdav/file.go index e0ea316..2dbad06 100644 --- a/pkg/webdav/file.go +++ b/pkg/webdav/file.go @@ -5,22 +5,12 @@ import ( "io" "net/http" "os" - "strings" "time" - "github.com/sirrobot01/decypharr/internal/utils" "github.com/sirrobot01/decypharr/pkg/debrid/store" "github.com/sirrobot01/decypharr/pkg/debrid/types" ) -type retryAction int - -const ( - noRetry retryAction = iota - retryWithLimit - retryAlways -) - const ( MaxNetworkRetries = 3 MaxLinkRetries = 10 @@ -44,7 +34,6 @@ type File struct { name string torrentName string link string - downloadLink types.DownloadLink size int64 isDir bool fileId string @@ -70,17 +59,12 @@ func (f *File) Close() error { // This is just to satisfy the os.File interface f.content = nil f.children = nil - f.downloadLink = types.DownloadLink{} f.readOffset = 0 return nil } func (f *File) getDownloadLink() (types.DownloadLink, error) { // Check if we already have a final URL cached - - if f.downloadLink.Valid() == nil { - return f.downloadLink, nil - } downloadLink, err := f.cache.GetDownloadLink(f.torrentName, f.name, f.link) if err != nil { return downloadLink, err @@ -89,7 +73,6 @@ func (f *File) getDownloadLink() (types.DownloadLink, error) { if err != nil { return types.DownloadLink{}, err } - f.downloadLink = downloadLink return downloadLink, nil } @@ -137,163 +120,44 @@ func (f *File) StreamResponse(w http.ResponseWriter, r *http.Request) error { if f.content != nil { return f.servePreloadedContent(w, r) } + _logger := f.cache.Logger() - return f.streamWithRetry(w, r, 0, 0) + start, end := f.getRange(r) + + resp, err := f.cache.Stream(r.Context(), start, end, f.getDownloadLink) + if err != nil { + _logger.Error().Err(err).Str("file", f.name).Msg("Failed to stream with initial link") + return &streamError{Err: err, StatusCode: http.StatusRequestedRangeNotSatisfiable} + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + return f.handleSuccessfulResponse(w, resp, start, end) } -func (f *File) streamWithRetry(w http.ResponseWriter, r *http.Request, networkRetries, recoverableRetries int) error { - - _log := f.cache.Logger() - - downloadLink, err := f.getDownloadLink() - if err != nil { - return &streamError{Err: err, StatusCode: http.StatusPreconditionFailed} - } - - upstreamReq, err := http.NewRequest("GET", downloadLink.DownloadLink, nil) - if err != nil { - return &streamError{Err: err, StatusCode: http.StatusInternalServerError} - } - - isRangeRequest := f.handleRangeRequest(upstreamReq, r, w) - if isRangeRequest == -1 { - return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable} - } - - resp, err := f.cache.Download(upstreamReq) - if err != nil { - // Network error - retry with limit - if networkRetries < MaxNetworkRetries { - _log.Debug(). - Int("network_retries", networkRetries+1). - Err(err). - Msg("Network error, retrying") - return f.streamWithRetry(w, r, networkRetries+1, recoverableRetries) - } - return &streamError{Err: err, StatusCode: http.StatusServiceUnavailable} - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { - retryType, retryErr := f.handleUpstreamError(downloadLink, resp) - - switch retryType { - case retryAlways: - if recoverableRetries >= MaxLinkRetries { - return &streamError{ - Err: fmt.Errorf("max link retries exceeded (%d)", MaxLinkRetries), - StatusCode: http.StatusServiceUnavailable, - } - } - - _log.Debug(). - Int("recoverable_retries", recoverableRetries+1). - Str("file", f.name). - Msg("Recoverable error, retrying") - return f.streamWithRetry(w, r, 0, recoverableRetries+1) // Reset network retries - - case retryWithLimit: - if networkRetries < MaxNetworkRetries { - _log.Debug(). - Int("network_retries", networkRetries+1). - Str("file", f.name). - Msg("Network error, retrying") - return f.streamWithRetry(w, r, networkRetries+1, recoverableRetries) - } - fallthrough - - case noRetry: - if retryErr != nil { - return retryErr - } - return &streamError{ - Err: fmt.Errorf("non-retryable error: status %d", resp.StatusCode), - StatusCode: http.StatusBadGateway, - } - } - } - - // Success - stream the response +func (f *File) handleSuccessfulResponse(w http.ResponseWriter, resp *http.Response, start, end int64) error { statusCode := http.StatusOK - if isRangeRequest == 1 { + if start > 0 || end > 0 { statusCode = http.StatusPartialContent } + // Copy relevant headers if contentLength := resp.Header.Get("Content-Length"); contentLength != "" { w.Header().Set("Content-Length", contentLength) } - if contentRange := resp.Header.Get("Content-Range"); contentRange != "" && isRangeRequest == 1 { + if contentRange := resp.Header.Get("Content-Range"); contentRange != "" && statusCode == http.StatusPartialContent { w.Header().Set("Content-Range", contentRange) } + // Copy other important headers + if contentType := resp.Header.Get("Content-Type"); contentType != "" { + w.Header().Set("Content-Type", contentType) + } + return f.streamBuffer(w, resp.Body, statusCode) } -func (f *File) handleUpstreamError(downloadLink types.DownloadLink, resp *http.Response) (retryAction, error) { - _log := f.cache.Logger() - - cleanupResp := func(resp *http.Response) { - if resp.Body != nil { - _, _ = io.Copy(io.Discard, resp.Body) - resp.Body.Close() - } - } - - switch resp.StatusCode { - case http.StatusServiceUnavailable: - body, readErr := io.ReadAll(resp.Body) - cleanupResp(resp) - - if readErr != nil { - _log.Error().Err(readErr).Msg("Failed to read response body") - return retryWithLimit, nil - } - - bodyStr := string(body) - if strings.Contains(bodyStr, "you have exceeded your traffic") { - _log.Debug(). - Str("token", utils.Mask(downloadLink.Token)). - Str("file", f.name). - Msg("Bandwidth exceeded for account, invalidating link") - - f.cache.MarkDownloadLinkAsInvalid(f.downloadLink, "bandwidth_exceeded") - f.downloadLink = types.DownloadLink{} - return retryAlways, nil - } - - return noRetry, &streamError{ - Err: fmt.Errorf("service unavailable: %s", bodyStr), - StatusCode: http.StatusServiceUnavailable, - } - - case http.StatusNotFound: - cleanupResp(resp) - _log.Debug(). - Str("file", f.name). - Msg("Link not found, invalidating and regenerating") - - f.cache.MarkDownloadLinkAsInvalid(f.downloadLink, "link_not_found") - f.downloadLink = types.DownloadLink{} - return retryAlways, nil - - default: - body, _ := io.ReadAll(resp.Body) - cleanupResp(resp) - - _log.Error(). - Int("status_code", resp.StatusCode). - Str("file", f.name). - Str("response_body", string(body)). - Msg("Unexpected upstream error") - - return retryWithLimit, &streamError{ - Err: fmt.Errorf("upstream error %d: %s", resp.StatusCode, string(body)), - StatusCode: http.StatusBadGateway, - } - } -} - func (f *File) streamBuffer(w http.ResponseWriter, src io.Reader, statusCode int) error { flusher, ok := w.(http.Flusher) if !ok { @@ -342,21 +206,21 @@ func (f *File) streamBuffer(w http.ResponseWriter, src io.Reader, statusCode int } } -func (f *File) handleRangeRequest(upstreamReq *http.Request, r *http.Request, w http.ResponseWriter) int { +func (f *File) getRange(r *http.Request) (int64, int64) { rangeHeader := r.Header.Get("Range") if rangeHeader == "" { // For video files, apply byte range if exists if byteRange, _ := f.getDownloadByteRange(); byteRange != nil { - upstreamReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", byteRange[0], byteRange[1])) + return byteRange[0], byteRange[1] } - return 0 // No range request + return 0, 0 } // Parse range request ranges, err := parseRange(rangeHeader, f.size) if err != nil || len(ranges) != 1 { - w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", f.size)) - return -1 // Invalid range + // Invalid range, return full content + return 0, 0 } // Apply byte range offset if exists @@ -367,9 +231,7 @@ func (f *File) handleRangeRequest(upstreamReq *http.Request, r *http.Request, w start += byteRange[0] end += byteRange[0] } - - upstreamReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end)) - return 1 // Valid range request + return start, end } /* diff --git a/pkg/webdav/handler.go b/pkg/webdav/handler.go index cb2c301..140df92 100644 --- a/pkg/webdav/handler.go +++ b/pkg/webdav/handler.go @@ -101,18 +101,13 @@ func (h *Handler) RemoveAll(ctx context.Context, name string) error { if len(parts) >= 2 { if utils.Contains(h.getParentItems(), parts[0]) { torrentName := parts[1] - cached := h.cache.GetTorrentByName(torrentName) - if cached != nil && len(parts) >= 3 { - filename := filepath.Clean(path.Join(parts[2:]...)) - if file, ok := cached.GetFile(filename); ok { - if err := h.cache.RemoveFile(cached.Id, file.Name); err != nil { - h.logger.Error().Err(err).Msgf("Failed to remove file %s from torrent %s", file.Name, torrentName) - return err - } - // If the file was successfully removed, we can return nil - return nil - } + filename := filepath.Clean(path.Join(parts[2:]...)) + if err := h.cache.RemoveFile(torrentName, filename); err != nil { + h.logger.Error().Err(err).Msgf("Failed to remove file %s from torrent %s", filename, torrentName) + return err } + // If the file was successfully removed, we can return nil + return nil } } @@ -489,7 +484,6 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) { } return } - return } func (h *Handler) handleHead(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/webdav/misc.go b/pkg/webdav/misc.go index 9de243d..4d76726 100644 --- a/pkg/webdav/misc.go +++ b/pkg/webdav/misc.go @@ -128,14 +128,6 @@ func writeXml(w http.ResponseWriter, status int, buf stringbuf.StringBuf) { _, _ = w.Write(buf.Bytes()) } -func hasHeadersWritten(w http.ResponseWriter) bool { - // Most ResponseWriter implementations support this - if hw, ok := w.(interface{ Written() bool }); ok { - return hw.Written() - } - return false -} - func isClientDisconnection(err error) bool { if err == nil { return false diff --git a/pkg/webdav/webdav.go b/pkg/webdav/webdav.go index 7dcb094..57c9934 100644 --- a/pkg/webdav/webdav.go +++ b/pkg/webdav/webdav.go @@ -4,10 +4,6 @@ import ( "context" "embed" "fmt" - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/sirrobot01/decypharr/internal/config" - "github.com/sirrobot01/decypharr/pkg/wire" "html/template" "net/http" "net/url" @@ -16,6 +12,12 @@ import ( "strings" "sync" "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/sirrobot01/decypharr/internal/config" + "github.com/sirrobot01/decypharr/internal/utils" + "github.com/sirrobot01/decypharr/pkg/wire" ) //go:embed templates/* @@ -33,42 +35,8 @@ var ( } return strings.Join(segments, "/") }, - "formatSize": func(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) - }, - "hasSuffix": strings.HasSuffix, + "formatSize": utils.FormatSize, + "hasSuffix": strings.HasSuffix, } tplRoot = template.Must(template.ParseFS(templatesFS, "templates/root.html")) tplDirectory = template.Must(template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/directory.html")) @@ -108,6 +76,7 @@ func (wd *WebDav) Routes() http.Handler { wr := chi.NewRouter() wr.Use(middleware.StripSlashes) wr.Use(wd.commonMiddleware) + // wr.Use(wd.authMiddleware) Disable auth for now wd.setupRootHandler(wr) wd.mountHandlers(wr) @@ -178,6 +147,21 @@ func (wd *WebDav) commonMiddleware(next http.Handler) http.Handler { }) } +func (wd *WebDav) authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cfg := config.Get() + if cfg.UseAuth && cfg.EnableWebdavAuth { + username, password, ok := r.BasicAuth() + if !ok || !config.VerifyAuth(username, password) { + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + } + next.ServeHTTP(w, r) + }) +} + func (wd *WebDav) handleGetRoot() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/pkg/wire/torrent.go b/pkg/wire/torrent.go index 5a71252..a31fd68 100644 --- a/pkg/wire/torrent.go +++ b/pkg/wire/torrent.go @@ -91,12 +91,11 @@ func (s *Store) processFiles(torrent *Torrent, debridTorrent *types.Torrent, imp if debridTorrent.Status == "downloaded" || !utils.Contains(downloadingStatuses, debridTorrent.Status) { break } - select { - case <-backoff.C: - // Increase interval gradually, cap at max - nextInterval := min(s.refreshInterval*2, 30*time.Second) - backoff.Reset(nextInterval) - } + + <-backoff.C + // Reset the backoff timer + nextInterval := min(s.refreshInterval*2, 30*time.Second) + backoff.Reset(nextInterval) } var torrentSymlinkPath, torrentRclonePath string debridTorrent.Arr = _arr @@ -113,7 +112,6 @@ func (s *Store) processFiles(torrent *Torrent, debridTorrent *types.Torrent, imp }() s.logger.Error().Err(err).Msgf("Error occured while processing torrent %s", debridTorrent.Name) importReq.markAsFailed(err, torrent, debridTorrent) - return } onSuccess := func(torrentSymlinkPath string) {