- 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:
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, '/');
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user