- Fix issues with new setup

- Fix arr setup getting thr wrong crendentials
- Add file link invalidator
- Other minor bug fixes
This commit is contained in:
Mukhtar Akere
2025-10-08 08:13:13 +01:00
parent 22dae9efad
commit 700d00b802
29 changed files with 606 additions and 465 deletions
+1 -1
View File
@@ -11,6 +11,7 @@ require (
github.com/go-co-op/gocron/v2 v2.16.1 github.com/go-co-op/gocron/v2 v2.16.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.4.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/robfig/cron/v3 v3.0.1
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.33.0
github.com/stanNthe5/stringbuf v0.0.3 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-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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 github.com/rogpeppe/go-internal v1.14.1 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect
) )
+19
View File
@@ -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
}
+3 -2
View File
@@ -152,6 +152,7 @@ type Config struct {
DiscordWebhook string `json:"discord_webhook_url,omitempty"` DiscordWebhook string `json:"discord_webhook_url,omitempty"`
RemoveStalledAfter string `json:"remove_stalled_after,omitzero"` RemoveStalledAfter string `json:"remove_stalled_after,omitzero"`
CallbackURL string `json:"callback_url,omitempty"` CallbackURL string `json:"callback_url,omitempty"`
EnableWebdavAuth bool `json:"enable_webdav_auth,omitempty"`
} }
func (c *Config) JsonFile() string { func (c *Config) JsonFile() string {
@@ -337,12 +338,12 @@ func (c *Config) SaveAuth(auth *Auth) error {
return os.WriteFile(c.AuthFile(), data, 0644) return os.WriteFile(c.AuthFile(), data, 0644)
} }
func (c *Config) NeedsSetup() error { func (c *Config) CheckSetup() error {
return ValidateConfig(c) return ValidateConfig(c)
} }
func (c *Config) NeedsAuth() bool { 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 { func (c *Config) updateDebrid(d Debrid) Debrid {
+44 -4
View File
@@ -7,10 +7,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/rs/zerolog"
"github.com/sirrobot01/decypharr/internal/logger"
"go.uber.org/ratelimit"
"golang.org/x/net/proxy"
"io" "io"
"math/rand" "math/rand"
"net" "net"
@@ -20,6 +16,11 @@ import (
"strings" "strings"
"sync" "sync"
"time" "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) { func JoinURL(base string, paths ...string) (string, error) {
@@ -422,3 +423,42 @@ func SetProxy(transport *http.Transport, proxyURL string) {
} }
return 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)
}
+51
View File
@@ -84,3 +84,54 @@ func readSmallChunks(file *os.File, startPos int64, totalToRead int, chunkSize i
} }
return nil 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)
}
+5 -1
View File
@@ -39,6 +39,7 @@ type Arr struct {
Name string `json:"name"` Name string `json:"name"`
Host string `json:"host"` Host string `json:"host"`
Token string `json:"token"` Token string `json:"token"`
Type Type `json:"type"` Type Type `json:"type"`
Cleanup bool `json:"cleanup"` Cleanup bool `json:"cleanup"`
SkipRepair bool `json:"skip_repair"` 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 { func (a *Arr) Validate() error {
if a.Token == "" || a.Host == "" { 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) resp, err := a.Request("GET", "/api/v3/health", nil)
if err != nil { if err != nil {
+14 -45
View File
@@ -347,18 +347,11 @@ func (r *RealDebrid) addTorrent(t *types.Torrent) (*types.Torrent, error) {
if resp.StatusCode == 509 { if resp.StatusCode == 509 {
return nil, utils.TooManyActiveDownloadsError 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) { defer func(Body io.ReadCloser) {
_ = Body.Close() _ = Body.Close()
}(resp.Body) }(resp.Body)
bodyBytes, err := io.ReadAll(resp.Body) if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}
if err = json.Unmarshal(bodyBytes, &data); err != nil {
return nil, err return nil, err
} }
t.Id = data.Id t.Id = data.Id
@@ -379,6 +372,7 @@ func (r *RealDebrid) addMagnet(t *types.Torrent) (*types.Torrent, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
// Handle multiple_downloads // Handle multiple_downloads
@@ -386,15 +380,10 @@ func (r *RealDebrid) addMagnet(t *types.Torrent) (*types.Torrent, error) {
return nil, utils.TooManyActiveDownloadsError 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)) return nil, fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes))
} }
defer resp.Body.Close() if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
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 {
return nil, err return nil, err
} }
t.Id = data.Id t.Id = data.Id
@@ -412,19 +401,15 @@ func (r *RealDebrid) GetTorrent(torrentId string) (*types.Torrent, error) {
return nil, err return nil, err
} }
defer resp.Body.Close() 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 { if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
return nil, utils.TorrentNotFoundError 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 var data torrentInfo
err = json.Unmarshal(bodyBytes, &data) if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
if err != nil {
return nil, err return nil, err
} }
t := &types.Torrent{ t := &types.Torrent{
@@ -455,19 +440,15 @@ func (r *RealDebrid) UpdateTorrent(t *types.Torrent) error {
return err return err
} }
defer resp.Body.Close() 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.StatusOK {
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
return utils.TorrentNotFoundError 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)) return fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes))
} }
var data torrentInfo var data torrentInfo
err = json.Unmarshal(bodyBytes, &data) if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
if err != nil {
return err return err
} }
t.Name = data.Filename t.Name = data.Filename
@@ -657,13 +638,9 @@ func (r *RealDebrid) getDownloadLink(account *account.Account, file *types.File)
}(resp.Body) }(resp.Body)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
// Read the response body to get the error message // Read the response body to get the error message
b, err := io.ReadAll(resp.Body)
if err != nil {
return emptyLink, err
}
var data ErrorResponse var data ErrorResponse
if err = json.Unmarshal(b, &data); err != nil { if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
return emptyLink, fmt.Errorf("error unmarshalling %d || %s \n %s", resp.StatusCode, err, string(b)) return emptyLink, fmt.Errorf("error unmarshalling %d || %s", resp.StatusCode, err)
} }
switch data.ErrorCode { switch data.ErrorCode {
case 19, 24, 35: 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) 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 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) return emptyLink, fmt.Errorf("realdebrid API error: Error unmarshalling response: %w", err)
} }
if data.Download == "" { if data.Download == "" {
@@ -758,14 +731,10 @@ func (r *RealDebrid) getTorrents(offset int, limit int) (int, []*types.Torrent,
} }
defer resp.Body.Close() 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")) totalItems, _ := strconv.Atoi(resp.Header.Get("X-Total-Count"))
var data []TorrentsResponse var data []TorrentsResponse
if err = json.Unmarshal(body, &data); err != nil { if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return 0, torrents, err return 0, nil, fmt.Errorf("failed to decode response: %w", err)
} }
filenames := map[string]struct{}{} filenames := map[string]struct{}{}
for _, t := range data { for _, t := range data {
+13 -10
View File
@@ -23,6 +23,7 @@ import (
"github.com/sirrobot01/decypharr/pkg/rclone" "github.com/sirrobot01/decypharr/pkg/rclone"
"github.com/sirrobot01/decypharr/pkg/debrid/types" "github.com/sirrobot01/decypharr/pkg/debrid/types"
"golang.org/x/sync/singleflight"
"encoding/json" "encoding/json"
_ "time/tzdata" _ "time/tzdata"
@@ -88,6 +89,7 @@ type Cache struct {
invalidDownloadLinks *xsync.Map[string, string] invalidDownloadLinks *xsync.Map[string, string]
repairRequest *xsync.Map[string, *reInsertRequest] repairRequest *xsync.Map[string, *reInsertRequest]
failedToReinsert *xsync.Map[string, struct{}] failedToReinsert *xsync.Map[string, struct{}]
failedLinksCounter *xsync.Map[string, *atomic.Int32] // link -> counter
// repair // repair
repairChan chan RepairRequest repairChan chan RepairRequest
@@ -112,7 +114,8 @@ type Cache struct {
config config.Debrid config config.Debrid
customFolders []string customFolders []string
mounter *rclone.Mount mounter *rclone.Mount
httpClient *http.Client downloadSG singleflight.Group
streamClient *http.Client
} }
func NewDebridCache(dc config.Debrid, client common.Client, mounter *rclone.Mount) *Cache { 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())) _log := logger.New(fmt.Sprintf("%s-webdav", client.Name()))
transport := &http.Transport{ transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
TLSHandshakeTimeout: 10 * time.Second, TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 30 * time.Second, ResponseHeaderTimeout: 60 * time.Second,
MaxIdleConns: 10, MaxIdleConns: 100,
MaxIdleConnsPerHost: 2, MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: false,
} }
httpClient := &http.Client{ httpClient := &http.Client{
Transport: transport, Transport: transport,
@@ -189,10 +195,11 @@ func NewDebridCache(dc config.Debrid, client common.Client, mounter *rclone.Moun
mounter: mounter, mounter: mounter,
ready: make(chan struct{}), ready: make(chan struct{}),
httpClient: httpClient,
invalidDownloadLinks: xsync.NewMap[string, string](), invalidDownloadLinks: xsync.NewMap[string, string](),
repairRequest: xsync.NewMap[string, *reInsertRequest](), repairRequest: xsync.NewMap[string, *reInsertRequest](),
failedToReinsert: xsync.NewMap[string, struct{}](), 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 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 { func (c *Cache) GetConfig() config.Debrid {
return c.config return c.config
} }
func (c *Cache) Download(req *http.Request) (*http.Response, error) {
return c.httpClient.Do(req)
}
+32 -27
View File
@@ -3,50 +3,50 @@ package store
import ( import (
"errors" "errors"
"fmt" "fmt"
"sync/atomic"
"github.com/sirrobot01/decypharr/internal/utils" "github.com/sirrobot01/decypharr/internal/utils"
"github.com/sirrobot01/decypharr/pkg/debrid/types" "github.com/sirrobot01/decypharr/pkg/debrid/types"
) )
type downloadLinkRequest struct { const (
result string MaxLinkFailures = 10
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
}
func (c *Cache) GetDownloadLink(torrentName, filename, fileLink string) (types.DownloadLink, error) { func (c *Cache) GetDownloadLink(torrentName, filename, fileLink string) (types.DownloadLink, error) {
// Check link cache // 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())
}
// 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() { if dl, err := c.checkDownloadLink(fileLink); err == nil && !dl.Empty() {
return dl, nil return dl, nil
} }
// Fetch the download link
dl, err := c.fetchDownloadLink(torrentName, filename, fileLink) dl, err := c.fetchDownloadLink(torrentName, filename, fileLink)
if err != nil { if err != nil {
c.downloadSG.Forget(fileLink)
return types.DownloadLink{}, err return types.DownloadLink{}, err
} }
if dl.Empty() { if dl.Empty() {
c.downloadSG.Forget(fileLink)
err = fmt.Errorf("download link is empty for %s in torrent %s", filename, torrentName) err = fmt.Errorf("download link is empty for %s in torrent %s", filename, torrentName)
return types.DownloadLink{}, err return types.DownloadLink{}, err
} }
return dl, err
return dl, nil
})
if err != nil {
return types.DownloadLink{}, err
}
return v.(types.DownloadLink), nil
} }
func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (types.DownloadLink, error) { 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) 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) c.invalidDownloadLinks.Store(downloadLink.DownloadLink, reason)
// Remove the download api key from active // Remove the download api key from active
if reason == "bandwidth_exceeded" { if reason == "bandwidth_exceeded" {
@@ -166,8 +172,7 @@ func (c *Cache) MarkDownloadLinkAsInvalid(downloadLink types.DownloadLink, reaso
} }
func (c *Cache) downloadLinkIsInvalid(downloadLink string) bool { func (c *Cache) downloadLinkIsInvalid(downloadLink string) bool {
if reason, ok := c.invalidDownloadLinks.Load(downloadLink); ok { if _, ok := c.invalidDownloadLinks.Load(downloadLink); ok {
c.logger.Debug().Msgf("Download link %s is invalid: %s", downloadLink, reason)
return true return true
} }
return false return false
+2 -1
View File
@@ -1,8 +1,9 @@
package store package store
import ( import (
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"sort" "sort"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
) )
// MergeFiles merges the files from multiple torrents into a single map. // MergeFiles merges the files from multiple torrents into a single map.
+239
View File
@@ -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,
}
}
}
+1
View File
@@ -2,6 +2,7 @@ package store
import ( import (
"context" "context"
"github.com/go-co-op/gocron/v2" "github.com/go-co-op/gocron/v2"
"github.com/sirrobot01/decypharr/internal/utils" "github.com/sirrobot01/decypharr/internal/utils"
) )
-11
View File
@@ -2,7 +2,6 @@ package types
import ( import (
"fmt" "fmt"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
@@ -182,20 +181,10 @@ type DownloadLink struct {
ExpiresAt time.Time 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 { func (dl *DownloadLink) Valid() error {
if dl.Empty() { if dl.Empty() {
return EmptyDownloadLinkError return EmptyDownloadLinkError
} }
// Check if the link is actually a valid URL
if !isValidURL(dl.DownloadLink) {
return ErrDownloadLinkNotFound
}
return nil return nil
} }
+14 -65
View File
@@ -6,14 +6,12 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strings" "strings"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/sirrobot01/decypharr/internal/config" "github.com/sirrobot01/decypharr/internal/config"
"github.com/sirrobot01/decypharr/pkg/arr" "github.com/sirrobot01/decypharr/pkg/arr"
"github.com/sirrobot01/decypharr/pkg/wire" "github.com/sirrobot01/decypharr/pkg/wire"
"golang.org/x/crypto/bcrypt"
) )
type contextKey string type contextKey string
@@ -24,45 +22,6 @@ const (
arrKey contextKey = "arr" 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 { func getCategory(ctx context.Context) string {
if category, ok := ctx.Value(categoryKey).(string); ok { if category, ok := ctx.Value(categoryKey).(string); ok {
return category return category
@@ -187,21 +146,27 @@ func (q *QBit) authenticate(category, username, password string) (*arr.Arr, erro
} }
a.Host = username a.Host = username
a.Token = password a.Token = password
if cfg.UseAuth { arrValidated := false // This is a flag to indicate if arr validation was successful
if a.Host == "" || a.Token == "" { if a.Host == "" || a.Token == "" && cfg.UseAuth {
return nil, fmt.Errorf("unauthorized: Host and token are required for authentication(you've enabled authentication)") 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 err := a.Validate(); err == nil {
// If this failed, try to use user auth validation arrValidated = true
if !verifyAuth(username, password) { }
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") return nil, fmt.Errorf("unauthorized: invalid credentials")
} }
} }
} if arrValidated {
// Only update the arr if arr validation was successful
a.Source = "auto" a.Source = "auto"
arrs.AddOrUpdate(a) arrs.AddOrUpdate(a)
}
return a, nil return a, nil
} }
@@ -264,19 +229,3 @@ func hashesContext(next http.Handler) http.Handler {
next.ServeHTTP(w, r.WithContext(ctx)) 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
}
-16
View File
@@ -191,7 +191,6 @@ func (f *HttpFile) ReadAt(p []byte, off int64) (n int, err error) {
bytesRead, err := io.ReadFull(resp.Body, p) bytesRead, err := io.ReadFull(resp.Body, p)
return bytesRead, err return bytesRead, err
case http.StatusOK: case http.StatusOK:
// Some servers return the full content instead of partial
fullData, err := io.ReadAll(resp.Body) fullData, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return 0, fmt.Errorf("%w: %v", ErrNetworkError, err) 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)) 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
}
+9 -6
View File
@@ -12,16 +12,16 @@ import (
func (wb *Web) setupMiddleware(next http.Handler) http.Handler { func (wb *Web) setupMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg := config.Get() cfg := config.Get()
needsAuth := cfg.NeedsSetup() needsSetup := cfg.CheckSetup()
if needsAuth != nil && r.URL.Path != "/config" && r.URL.Path != "/api/config" { if needsSetup != nil && r.URL.Path != "/settings" && r.URL.Path != "/api/config" {
http.Redirect(w, r, fmt.Sprintf("/config?inco=%s", needsAuth.Error()), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/settings?inco=%s", needsSetup.Error()), http.StatusSeeOther)
return return
} }
// strip inco from URL // 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 // 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) 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) { func (wb *Web) sendJSONError(w http.ResponseWriter, message string, statusCode int) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode) w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(map[string]interface{}{ err := json.NewEncoder(w).Encode(map[string]interface{}{
"error": message, "error": message,
"status": statusCode, "status": statusCode,
}) })
if err != nil {
return
}
} }
+21 -16
View File
@@ -1,53 +1,58 @@
package web package web
import ( import (
"github.com/go-chi/chi/v5"
"io/fs" "io/fs"
"net/http" "net/http"
"github.com/go-chi/chi/v5"
) )
func (wb *Web) Routes() http.Handler { func (wb *Web) Routes() http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
// Load static files from embedded filesystem // Static assets - always public
staticFS, err := fs.Sub(assetsEmbed, "assets/build") staticFS, _ := fs.Sub(assetsEmbed, "assets/build")
if err != nil { imagesFS, _ := fs.Sub(imagesEmbed, "assets/images")
panic(err)
}
imagesFS, err := fs.Sub(imagesEmbed, "assets/images")
if err != nil {
panic(err)
}
r.Handle("/assets/*", http.StripPrefix("/assets/", http.FileServer(http.FS(staticFS)))) r.Handle("/assets/*", http.StripPrefix("/assets/", http.FileServer(http.FS(staticFS))))
r.Handle("/images/*", http.StripPrefix("/images/", http.FileServer(http.FS(imagesFS)))) 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.Get("/login", wb.LoginHandler)
r.Post("/login", wb.LoginHandler) r.Post("/login", wb.LoginHandler)
r.Get("/register", wb.RegisterHandler) r.Get("/register", wb.RegisterHandler)
r.Post("/register", wb.RegisterHandler) r.Post("/register", wb.RegisterHandler)
r.Get("/skip-auth", wb.skipAuthHandler) r.Post("/skip-auth", wb.skipAuthHandler)
r.Get("/version", wb.handleGetVersion)
// Protected routes - require auth
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(wb.authMiddleware) r.Use(wb.authMiddleware)
r.Use(wb.setupMiddleware) // Web pages
r.Get("/", wb.IndexHandler) r.Get("/", wb.IndexHandler)
r.Get("/download", wb.DownloadHandler) r.Get("/download", wb.DownloadHandler)
r.Get("/repair", wb.RepairHandler) r.Get("/repair", wb.RepairHandler)
r.Get("/stats", wb.StatsHandler) r.Get("/stats", wb.StatsHandler)
r.Get("/config", wb.ConfigHandler) r.Get("/settings", wb.ConfigHandler)
// API routes
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
// Arr management
r.Get("/arrs", wb.handleGetArrs) r.Get("/arrs", wb.handleGetArrs)
r.Post("/add", wb.handleAddContent) r.Post("/add", wb.handleAddContent)
// Repair operations
r.Post("/repair", wb.handleRepairMedia) r.Post("/repair", wb.handleRepairMedia)
r.Get("/repair/jobs", wb.handleGetRepairJobs) r.Get("/repair/jobs", wb.handleGetRepairJobs)
r.Post("/repair/jobs/{id}/process", wb.handleProcessRepairJob) r.Post("/repair/jobs/{id}/process", wb.handleProcessRepairJob)
r.Post("/repair/jobs/{id}/stop", wb.handleStopRepairJob) r.Post("/repair/jobs/{id}/stop", wb.handleStopRepairJob)
r.Delete("/repair/jobs", wb.handleDeleteRepairJob) r.Delete("/repair/jobs", wb.handleDeleteRepairJob)
// Torrent management
r.Get("/torrents", wb.handleGetTorrents) r.Get("/torrents", wb.handleGetTorrents)
r.Delete("/torrents/{category}/{hash}", wb.handleDeleteTorrent) 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.Get("/config", wb.handleGetConfig)
r.Post("/config", wb.handleUpdateConfig) r.Post("/config", wb.handleUpdateConfig)
r.Post("/refresh-token", wb.handleRefreshAPIToken) r.Post("/refresh-token", wb.handleRefreshAPIToken)
+10
View File
@@ -1,5 +1,15 @@
{{ define "config" }} {{ define "config" }}
<div class="space-y-6"> <div class="space-y-6">
{{ if .NeedSetup }}
<div role="alert" class="alert alert-warning">
<i class="bi bi-exclamation-triangle text-xl"></i>
<div>
<h3 class="font-bold">Configuration Required</h3>
<div class="text-sm">Your configuration is incomplete. Please complete the setup below.</div>
</div>
</div>
{{ end }}
<form id="configForm" class="space-y-6"> <form id="configForm" class="space-y-6">
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body"> <div class="card-body">
+10
View File
@@ -1,5 +1,15 @@
{{ define "download" }} {{ define "download" }}
<div class="space-y-6"> <div class="space-y-6">
{{ if .NeedSetup }}
<div role="alert" class="alert alert-warning">
<i class="bi bi-exclamation-triangle text-xl"></i>
<div>
<h3 class="font-bold">Configuration Required</h3>
<div class="text-sm">Your configuration is incomplete. Please complete the setup in the <a href="{{.URLBase}}settings" class="link link-hover font-semibold">Settings page</a>.</div>
</div>
</div>
{{ end }}
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body"> <div class="card-body">
<form id="downloadForm" enctype="multipart/form-data" class="space-y-3"> <form id="downloadForm" enctype="multipart/form-data" class="space-y-3">
+10
View File
@@ -1,6 +1,16 @@
{{ define "index" }} {{ define "index" }}
<div class="space-y-6"> <div class="space-y-6">
{{ if .NeedSetup }}
<div role="alert" class="alert alert-warning">
<i class="bi bi-exclamation-triangle text-xl"></i>
<div>
<h3 class="font-bold">Configuration Required</h3>
<div class="text-sm">Your configuration is incomplete. Please complete the setup in the <a href="{{.URLBase}}settings" class="link link-hover font-semibold">Settings page</a>.</div>
</div>
</div>
{{ end }}
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body"> <div class="card-body">
<div class="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4"> <div class="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
+2 -2
View File
@@ -54,7 +54,7 @@
<li><a href="{{.URLBase}}repair" class="{{if eq .Page "repair"}}active{{end}}"> <li><a href="{{.URLBase}}repair" class="{{if eq .Page "repair"}}active{{end}}">
<i class="bi bi-wrench-adjustable text-accent"></i>Repair <i class="bi bi-wrench-adjustable text-accent"></i>Repair
</a></li> </a></li>
<li><a href="{{.URLBase}}config" class="{{if eq .Page "config"}}active{{end}}"> <li><a href="{{.URLBase}}settings" class="{{if eq .Page "config"}}active{{end}}">
<i class="bi bi-gear text-info"></i>Settings <i class="bi bi-gear text-info"></i>Settings
</a></li> </a></li>
<li><a href="{{.URLBase}}webdav" target="_blank"> <li><a href="{{.URLBase}}webdav" target="_blank">
@@ -85,7 +85,7 @@
<i class="bi bi-wrench-adjustable"></i> <i class="bi bi-wrench-adjustable"></i>
<span class="hidden xl:inline">Repair</span> <span class="hidden xl:inline">Repair</span>
</a></li> </a></li>
<li><a href="{{.URLBase}}config" class="{{if eq .Page "config"}}active{{end}} tooltip tooltip-bottom" data-tip="Settings"> <li><a href="{{.URLBase}}settings" class="{{if eq .Page "config"}}active{{end}} tooltip tooltip-bottom" data-tip="Settings">
<i class="bi bi-gear"></i> <i class="bi bi-gear"></i>
<span class="hidden xl:inline">Settings</span> <span class="hidden xl:inline">Settings</span>
</a></li> </a></li>
+1 -1
View File
@@ -75,7 +75,7 @@
// Handle skip auth button // Handle skip auth button
skipAuthBtn.addEventListener('click', function() { skipAuthBtn.addEventListener('click', function() {
window.decypharrUtils.fetcher('/skip-auth', { method: 'GET' }) window.decypharrUtils.fetcher('/skip-auth', { method: 'POST' })
.then(response => { .then(response => {
if (response.ok) { if (response.ok) {
window.location.href = window.decypharrUtils.joinURL(window.urlBase, '/'); window.location.href = window.decypharrUtils.joinURL(window.urlBase, '/');
+10
View File
@@ -1,5 +1,15 @@
{{ define "repair" }} {{ define "repair" }}
<div class="space-y-6"> <div class="space-y-6">
{{ if .NeedSetup }}
<div role="alert" class="alert alert-warning">
<i class="bi bi-exclamation-triangle text-xl"></i>
<div>
<h3 class="font-bold">Configuration Required</h3>
<div class="text-sm">Your configuration is incomplete. Please complete the setup in the <a href="{{.URLBase}}settings" class="link link-hover font-semibold">Settings page</a>.</div>
</div>
</div>
{{ end }}
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-2xl mb-6"> <h2 class="card-title text-2xl mb-6">
+8
View File
@@ -117,6 +117,8 @@ func (wb *Web) IndexHandler(w http.ResponseWriter, r *http.Request) {
"URLBase": cfg.URLBase, "URLBase": cfg.URLBase,
"Page": "index", "Page": "index",
"Title": "Torrents", "Title": "Torrents",
"NeedSetup": cfg.CheckSetup() != nil,
"SetupError": cfg.CheckSetup(),
} }
_ = wb.templates.ExecuteTemplate(w, "layout", data) _ = wb.templates.ExecuteTemplate(w, "layout", data)
} }
@@ -134,6 +136,8 @@ func (wb *Web) DownloadHandler(w http.ResponseWriter, r *http.Request) {
"Debrids": debrids, "Debrids": debrids,
"HasMultiDebrid": len(debrids) > 1, "HasMultiDebrid": len(debrids) > 1,
"DownloadFolder": cfg.QBitTorrent.DownloadFolder, "DownloadFolder": cfg.QBitTorrent.DownloadFolder,
"NeedSetup": cfg.CheckSetup() != nil,
"SetupError": cfg.CheckSetup(),
} }
_ = wb.templates.ExecuteTemplate(w, "layout", data) _ = wb.templates.ExecuteTemplate(w, "layout", data)
} }
@@ -144,6 +148,8 @@ func (wb *Web) RepairHandler(w http.ResponseWriter, r *http.Request) {
"URLBase": cfg.URLBase, "URLBase": cfg.URLBase,
"Page": "repair", "Page": "repair",
"Title": "Repair", "Title": "Repair",
"NeedSetup": cfg.CheckSetup() != nil,
"SetupError": cfg.CheckSetup(),
} }
_ = wb.templates.ExecuteTemplate(w, "layout", data) _ = wb.templates.ExecuteTemplate(w, "layout", data)
} }
@@ -154,6 +160,8 @@ func (wb *Web) ConfigHandler(w http.ResponseWriter, r *http.Request) {
"URLBase": cfg.URLBase, "URLBase": cfg.URLBase,
"Page": "config", "Page": "config",
"Title": "Config", "Title": "Config",
"NeedSetup": cfg.CheckSetup() != nil,
"SetupError": cfg.CheckSetup(),
} }
_ = wb.templates.ExecuteTemplate(w, "layout", data) _ = wb.templates.ExecuteTemplate(w, "layout", data)
} }
+27 -165
View File
@@ -5,22 +5,12 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"strings"
"time" "time"
"github.com/sirrobot01/decypharr/internal/utils"
"github.com/sirrobot01/decypharr/pkg/debrid/store" "github.com/sirrobot01/decypharr/pkg/debrid/store"
"github.com/sirrobot01/decypharr/pkg/debrid/types" "github.com/sirrobot01/decypharr/pkg/debrid/types"
) )
type retryAction int
const (
noRetry retryAction = iota
retryWithLimit
retryAlways
)
const ( const (
MaxNetworkRetries = 3 MaxNetworkRetries = 3
MaxLinkRetries = 10 MaxLinkRetries = 10
@@ -44,7 +34,6 @@ type File struct {
name string name string
torrentName string torrentName string
link string link string
downloadLink types.DownloadLink
size int64 size int64
isDir bool isDir bool
fileId string fileId string
@@ -70,17 +59,12 @@ func (f *File) Close() error {
// This is just to satisfy the os.File interface // This is just to satisfy the os.File interface
f.content = nil f.content = nil
f.children = nil f.children = nil
f.downloadLink = types.DownloadLink{}
f.readOffset = 0 f.readOffset = 0
return nil return nil
} }
func (f *File) getDownloadLink() (types.DownloadLink, error) { func (f *File) getDownloadLink() (types.DownloadLink, error) {
// Check if we already have a final URL cached // 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) downloadLink, err := f.cache.GetDownloadLink(f.torrentName, f.name, f.link)
if err != nil { if err != nil {
return downloadLink, err return downloadLink, err
@@ -89,7 +73,6 @@ func (f *File) getDownloadLink() (types.DownloadLink, error) {
if err != nil { if err != nil {
return types.DownloadLink{}, err return types.DownloadLink{}, err
} }
f.downloadLink = downloadLink
return downloadLink, nil return downloadLink, nil
} }
@@ -137,163 +120,44 @@ func (f *File) StreamResponse(w http.ResponseWriter, r *http.Request) error {
if f.content != nil { if f.content != nil {
return f.servePreloadedContent(w, r) 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 { func (f *File) handleSuccessfulResponse(w http.ResponseWriter, resp *http.Response, start, end int64) 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
statusCode := http.StatusOK statusCode := http.StatusOK
if isRangeRequest == 1 { if start > 0 || end > 0 {
statusCode = http.StatusPartialContent statusCode = http.StatusPartialContent
} }
// Copy relevant headers
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" { if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
w.Header().Set("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) 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) 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 { func (f *File) streamBuffer(w http.ResponseWriter, src io.Reader, statusCode int) error {
flusher, ok := w.(http.Flusher) flusher, ok := w.(http.Flusher)
if !ok { 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") rangeHeader := r.Header.Get("Range")
if rangeHeader == "" { if rangeHeader == "" {
// For video files, apply byte range if exists // For video files, apply byte range if exists
if byteRange, _ := f.getDownloadByteRange(); byteRange != nil { 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 // Parse range request
ranges, err := parseRange(rangeHeader, f.size) ranges, err := parseRange(rangeHeader, f.size)
if err != nil || len(ranges) != 1 { if err != nil || len(ranges) != 1 {
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", f.size)) // Invalid range, return full content
return -1 // Invalid range return 0, 0
} }
// Apply byte range offset if exists // Apply byte range offset if exists
@@ -367,9 +231,7 @@ func (f *File) handleRangeRequest(upstreamReq *http.Request, r *http.Request, w
start += byteRange[0] start += byteRange[0]
end += byteRange[0] end += byteRange[0]
} }
return start, end
upstreamReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
return 1 // Valid range request
} }
/* /*
+2 -8
View File
@@ -101,20 +101,15 @@ func (h *Handler) RemoveAll(ctx context.Context, name string) error {
if len(parts) >= 2 { if len(parts) >= 2 {
if utils.Contains(h.getParentItems(), parts[0]) { if utils.Contains(h.getParentItems(), parts[0]) {
torrentName := parts[1] torrentName := parts[1]
cached := h.cache.GetTorrentByName(torrentName)
if cached != nil && len(parts) >= 3 {
filename := filepath.Clean(path.Join(parts[2:]...)) filename := filepath.Clean(path.Join(parts[2:]...))
if file, ok := cached.GetFile(filename); ok { if err := h.cache.RemoveFile(torrentName, filename); err != nil {
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", filename, torrentName)
h.logger.Error().Err(err).Msgf("Failed to remove file %s from torrent %s", file.Name, torrentName)
return err return err
} }
// If the file was successfully removed, we can return nil // If the file was successfully removed, we can return nil
return nil return nil
} }
} }
}
}
return nil return nil
} }
@@ -489,7 +484,6 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
} }
return return
} }
return
} }
func (h *Handler) handleHead(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleHead(w http.ResponseWriter, r *http.Request) {
-8
View File
@@ -128,14 +128,6 @@ func writeXml(w http.ResponseWriter, status int, buf stringbuf.StringBuf) {
_, _ = w.Write(buf.Bytes()) _, _ = 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 { func isClientDisconnection(err error) bool {
if err == nil { if err == nil {
return false return false
+23 -39
View File
@@ -4,10 +4,6 @@ import (
"context" "context"
"embed" "embed"
"fmt" "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" "html/template"
"net/http" "net/http"
"net/url" "net/url"
@@ -16,6 +12,12 @@ import (
"strings" "strings"
"sync" "sync"
"time" "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/* //go:embed templates/*
@@ -33,41 +35,7 @@ var (
} }
return strings.Join(segments, "/") return strings.Join(segments, "/")
}, },
"formatSize": func(bytes int64) string { "formatSize": utils.FormatSize,
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, "hasSuffix": strings.HasSuffix,
} }
tplRoot = template.Must(template.ParseFS(templatesFS, "templates/root.html")) tplRoot = template.Must(template.ParseFS(templatesFS, "templates/root.html"))
@@ -108,6 +76,7 @@ func (wd *WebDav) Routes() http.Handler {
wr := chi.NewRouter() wr := chi.NewRouter()
wr.Use(middleware.StripSlashes) wr.Use(middleware.StripSlashes)
wr.Use(wd.commonMiddleware) wr.Use(wd.commonMiddleware)
// wr.Use(wd.authMiddleware) Disable auth for now
wd.setupRootHandler(wr) wd.setupRootHandler(wr)
wd.mountHandlers(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 { func (wd *WebDav) handleGetRoot() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
+3 -5
View File
@@ -91,13 +91,12 @@ func (s *Store) processFiles(torrent *Torrent, debridTorrent *types.Torrent, imp
if debridTorrent.Status == "downloaded" || !utils.Contains(downloadingStatuses, debridTorrent.Status) { if debridTorrent.Status == "downloaded" || !utils.Contains(downloadingStatuses, debridTorrent.Status) {
break break
} }
select {
case <-backoff.C: <-backoff.C
// Increase interval gradually, cap at max // Reset the backoff timer
nextInterval := min(s.refreshInterval*2, 30*time.Second) nextInterval := min(s.refreshInterval*2, 30*time.Second)
backoff.Reset(nextInterval) backoff.Reset(nextInterval)
} }
}
var torrentSymlinkPath, torrentRclonePath string var torrentSymlinkPath, torrentRclonePath string
debridTorrent.Arr = _arr 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) s.logger.Error().Err(err).Msgf("Error occured while processing torrent %s", debridTorrent.Name)
importReq.markAsFailed(err, torrent, debridTorrent) importReq.markAsFailed(err, torrent, debridTorrent)
return
} }
onSuccess := func(torrentSymlinkPath string) { onSuccess := func(torrentSymlinkPath string) {