From 9f71ea7f071793f9024651c5f9fc4a3459f8160f Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:30:43 -0700 Subject: [PATCH 1/6] feat: add Real-Debrid API client for premium download speeds Add a debrid package with a Real-Debrid API client that can unrestrict file hosting links to get direct download URLs. Includes host detection to check if a URL is from a supported file host (Mega, RapidGator, etc). Adds debrid settings to the configuration (enabled, provider, API key) with a new Debrid category in the TUI settings. Wiring into the probe flow will follow in a separate PR. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/config/settings.go | 34 +++++-- internal/config/settings_test.go | 2 +- internal/debrid/realdebrid.go | 140 +++++++++++++++++++++++++++++ internal/debrid/realdebrid_test.go | 90 +++++++++++++++++++ 4 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 internal/debrid/realdebrid.go create mode 100644 internal/debrid/realdebrid_test.go diff --git a/internal/config/settings.go b/internal/config/settings.go index c87b5080..31fbe279 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -41,13 +41,21 @@ const ( // NetworkSettings contains network connection parameters. type NetworkSettings struct { - MaxConnectionsPerHost int `json:"max_connections_per_host"` - MaxConcurrentDownloads int `json:"max_concurrent_downloads"` - UserAgent string `json:"user_agent"` - ProxyURL string `json:"proxy_url"` - SequentialDownload bool `json:"sequential_download"` - MinChunkSize int64 `json:"min_chunk_size"` - WorkerBufferSize int `json:"worker_buffer_size"` + MaxConnectionsPerHost int `json:"max_connections_per_host"` + MaxConcurrentDownloads int `json:"max_concurrent_downloads"` + UserAgent string `json:"user_agent"` + ProxyURL string `json:"proxy_url"` + SequentialDownload bool `json:"sequential_download"` + MinChunkSize int64 `json:"min_chunk_size"` + WorkerBufferSize int `json:"worker_buffer_size"` + Debrid DebridSettings `json:"debrid"` +} + +// DebridSettings contains debrid service settings. +type DebridSettings struct { + Enabled bool `json:"enabled"` + Provider string `json:"provider"` + APIKey string `json:"api_key"` } // PerformanceSettings contains performance tuning parameters. @@ -102,12 +110,17 @@ func GetSettingsMetadata() map[string][]SettingMeta { {Key: "stall_timeout", Label: "Stall Timeout", Description: "Restart workers with no data for this duration (e.g., 5s).", Type: "duration"}, {Key: "speed_ema_alpha", Label: "Speed EMA Alpha", Description: "Exponential moving average smoothing factor (0.0-1.0).", Type: "float64"}, }, + "Debrid": { + {Key: "enabled", Label: "Enable Debrid", Description: "Route downloads through a debrid service for premium speeds from file hosts.", Type: "bool"}, + {Key: "provider", Label: "Provider", Description: "Debrid service provider (currently only real-debrid).", Type: "string"}, + {Key: "api_key", Label: "API Key", Description: "Your debrid service API key.", Type: "string"}, + }, } } // CategoryOrder returns the order of categories for UI tabs. func CategoryOrder() []string { - return []string{"General", "Network", "Performance", "Categories"} + return []string{"General", "Network", "Performance", "Debrid", "Categories"} } const ( @@ -142,6 +155,11 @@ func DefaultSettings() *Settings { SequentialDownload: false, MinChunkSize: 2 * MB, WorkerBufferSize: 512 * KB, + Debrid: DebridSettings{ + Enabled: false, + Provider: "real-debrid", + APIKey: "", + }, }, Performance: PerformanceSettings{ MaxTaskRetries: 3, diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index 74d7fb19..9e8b3b43 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -453,7 +453,7 @@ func TestCategoryOrder(t *testing.T) { } // Should have all expected categories - expectedCount := 4 // General, Network, Performance, Categories + expectedCount := 5 // General, Network, Performance, Debrid, Categories if len(order) != expectedCount { t.Errorf("Expected %d categories, got %d", expectedCount, len(order)) } diff --git a/internal/debrid/realdebrid.go b/internal/debrid/realdebrid.go new file mode 100644 index 00000000..dc4dc28c --- /dev/null +++ b/internal/debrid/realdebrid.go @@ -0,0 +1,140 @@ +package debrid + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Client is a Real-Debrid API client. +type Client struct { + apiKey string + httpClient *http.Client + baseURL string +} + +// NewClient creates a new Real-Debrid client with the given API key. +func NewClient(apiKey string) *Client { + return &Client{ + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + baseURL: "https://api.real-debrid.com/rest/1.0", + } +} + +// UnrestrictResult holds the response from unrestricting a link. +type UnrestrictResult struct { + ID string `json:"id"` + Filename string `json:"filename"` + FileSize int64 `json:"filesize"` + Link string `json:"link"` // Original link + Download string `json:"download"` // Unrestricted direct download URL + Host string `json:"host"` + MimeType string `json:"mimeType"` +} + +// apiError represents an error response from Real-Debrid. +type apiError struct { + ErrorCode int `json:"error_code"` + Error string `json:"error"` +} + +// Unrestrict takes a hosted file URL and returns a direct download URL. +func (c *Client) Unrestrict(link string) (*UnrestrictResult, error) { + if c.apiKey == "" { + return nil, fmt.Errorf("debrid: API key is required") + } + + data := url.Values{} + data.Set("link", link) + + req, err := http.NewRequest(http.MethodPost, c.baseURL+"/unrestrict/link", strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("debrid: failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("debrid: request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("debrid: failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var apiErr apiError + if json.Unmarshal(body, &apiErr) == nil && apiErr.Error != "" { + return nil, fmt.Errorf("debrid: API error %d: %s", apiErr.ErrorCode, apiErr.Error) + } + return nil, fmt.Errorf("debrid: unexpected status %d", resp.StatusCode) + } + + var result UnrestrictResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("debrid: failed to parse response: %w", err) + } + + if result.Download == "" { + return nil, fmt.Errorf("debrid: no download URL in response") + } + + return &result, nil +} + +// SupportedHosts returns the list of supported file hosting domains. +func (c *Client) SupportedHosts() ([]string, error) { + req, err := http.NewRequest(http.MethodGet, c.baseURL+"/hosts/domains", nil) + if err != nil { + return nil, fmt.Errorf("debrid: failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("debrid: request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("debrid: unexpected status %d", resp.StatusCode) + } + + var domains []string + if err := json.NewDecoder(resp.Body).Decode(&domains); err != nil { + return nil, fmt.Errorf("debrid: failed to parse hosts response: %w", err) + } + + return domains, nil +} + +// IsSupported checks if a URL's host is supported by Real-Debrid. +func (c *Client) IsSupported(rawURL string) bool { + parsed, err := url.Parse(rawURL) + if err != nil { + return false + } + host := strings.ToLower(parsed.Hostname()) + + hosts, err := c.SupportedHosts() + if err != nil { + return false + } + + for _, h := range hosts { + h = strings.ToLower(h) + if h == host || strings.HasSuffix(host, "."+h) { + return true + } + } + return false +} diff --git a/internal/debrid/realdebrid_test.go b/internal/debrid/realdebrid_test.go new file mode 100644 index 00000000..3ac295ff --- /dev/null +++ b/internal/debrid/realdebrid_test.go @@ -0,0 +1,90 @@ +package debrid + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUnrestrict_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/unrestrict/link", r.URL.Path) + assert.Contains(t, r.Header.Get("Authorization"), "Bearer") + + _ = json.NewEncoder(w).Encode(UnrestrictResult{ + ID: "test123", + Filename: "file.zip", + FileSize: 1024, + Download: "https://direct.example.com/file.zip", + Host: "mega.nz", + }) + })) + defer server.Close() + + client := NewClient("test-api-key") + client.baseURL = server.URL + + result, err := client.Unrestrict("https://mega.nz/file/abc") + require.NoError(t, err) + assert.Equal(t, "file.zip", result.Filename) + assert.Equal(t, "https://direct.example.com/file.zip", result.Download) +} + +func TestUnrestrict_NoAPIKey(t *testing.T) { + client := NewClient("") + _, err := client.Unrestrict("https://mega.nz/file/abc") + assert.Error(t, err) + assert.Contains(t, err.Error(), "API key is required") +} + +func TestUnrestrict_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(apiError{ + ErrorCode: 8, + Error: "Bad token", + }) + })) + defer server.Close() + + client := NewClient("bad-key") + client.baseURL = server.URL + + _, err := client.Unrestrict("https://mega.nz/file/abc") + assert.Error(t, err) + assert.Contains(t, err.Error(), "Bad token") +} + +func TestSupportedHosts(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/hosts/domains", r.URL.Path) + _ = json.NewEncoder(w).Encode([]string{"mega.nz", "rapidgator.net", "uploaded.net"}) + })) + defer server.Close() + + client := NewClient("test-key") + client.baseURL = server.URL + + hosts, err := client.SupportedHosts() + require.NoError(t, err) + assert.Contains(t, hosts, "mega.nz") +} + +func TestIsSupported(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]string{"mega.nz", "rapidgator.net"}) + })) + defer server.Close() + + client := NewClient("test-key") + client.baseURL = server.URL + + assert.True(t, client.IsSupported("https://mega.nz/file/abc")) + assert.True(t, client.IsSupported("https://www.mega.nz/file/abc")) + assert.False(t, client.IsSupported("https://example.com/file")) +} From cf6ad5cb1951de2dc97f637abbd146caa8e4b954 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:37:50 -0700 Subject: [PATCH 2/6] fix(debrid): cache supported hosts to avoid HTTP call on every IsSupported check --- internal/debrid/realdebrid.go | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/internal/debrid/realdebrid.go b/internal/debrid/realdebrid.go index dc4dc28c..77b85923 100644 --- a/internal/debrid/realdebrid.go +++ b/internal/debrid/realdebrid.go @@ -12,9 +12,11 @@ import ( // Client is a Real-Debrid API client. type Client struct { - apiKey string - httpClient *http.Client - baseURL string + apiKey string + httpClient *http.Client + baseURL string + cachedHosts []string + hostsCached time.Time } // NewClient creates a new Real-Debrid client with the given API key. @@ -117,6 +119,21 @@ func (c *Client) SupportedHosts() ([]string, error) { return domains, nil } +// supportedHostsCached returns the supported hosts list, caching for 1 hour +// to avoid making a live HTTP call on every invocation. +func (c *Client) supportedHostsCached() ([]string, error) { + if c.cachedHosts != nil && time.Since(c.hostsCached) < time.Hour { + return c.cachedHosts, nil + } + hosts, err := c.SupportedHosts() + if err != nil { + return nil, err + } + c.cachedHosts = hosts + c.hostsCached = time.Now() + return hosts, nil +} + // IsSupported checks if a URL's host is supported by Real-Debrid. func (c *Client) IsSupported(rawURL string) bool { parsed, err := url.Parse(rawURL) @@ -125,7 +142,7 @@ func (c *Client) IsSupported(rawURL string) bool { } host := strings.ToLower(parsed.Hostname()) - hosts, err := c.SupportedHosts() + hosts, err := c.supportedHostsCached() if err != nil { return false } From 6f1fa3af64c6e8b03e3be342deba50adbc2099e4 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:18:14 -0700 Subject: [PATCH 3/6] fix(debrid): add RWMutex to prevent data race on cached hosts Addresses greptile P1 finding: cachedHosts and hostsCached were accessed without synchronization. Uses double-checked locking pattern to minimize lock contention on the hot path. --- internal/debrid/realdebrid.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/debrid/realdebrid.go b/internal/debrid/realdebrid.go index 77b85923..1a4bc1fa 100644 --- a/internal/debrid/realdebrid.go +++ b/internal/debrid/realdebrid.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "strings" + "sync" "time" ) @@ -15,6 +16,7 @@ type Client struct { apiKey string httpClient *http.Client baseURL string + mu sync.RWMutex cachedHosts []string hostsCached time.Time } @@ -122,6 +124,17 @@ func (c *Client) SupportedHosts() ([]string, error) { // supportedHostsCached returns the supported hosts list, caching for 1 hour // to avoid making a live HTTP call on every invocation. func (c *Client) supportedHostsCached() ([]string, error) { + c.mu.RLock() + if c.cachedHosts != nil && time.Since(c.hostsCached) < time.Hour { + hosts := c.cachedHosts + c.mu.RUnlock() + return hosts, nil + } + c.mu.RUnlock() + + c.mu.Lock() + defer c.mu.Unlock() + // Double-check after acquiring write lock. if c.cachedHosts != nil && time.Since(c.hostsCached) < time.Hour { return c.cachedHosts, nil } From 09830d4481da1b1425d9b967e152b086bd468d80 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:05:04 -0700 Subject: [PATCH 4/6] style: remove redundant inline comments in realdebrid.go --- internal/debrid/realdebrid.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/debrid/realdebrid.go b/internal/debrid/realdebrid.go index 1a4bc1fa..b3dcdea0 100644 --- a/internal/debrid/realdebrid.go +++ b/internal/debrid/realdebrid.go @@ -37,8 +37,8 @@ type UnrestrictResult struct { ID string `json:"id"` Filename string `json:"filename"` FileSize int64 `json:"filesize"` - Link string `json:"link"` // Original link - Download string `json:"download"` // Unrestricted direct download URL + Link string `json:"link"` + Download string `json:"download"` Host string `json:"host"` MimeType string `json:"mimeType"` } From 2cb1c98f0a83d09c634381d400d4ef691589d8ee Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:48:46 -0700 Subject: [PATCH 5/6] fix: wire up Debrid settings tab in TUI Add getSettingsValues case for "Debrid" category and setDebridSetting method so the Debrid tab in settings UI is functional (was visible but non-interactive). Addresses greptile bot finding. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/tui/view_settings.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/tui/view_settings.go b/internal/tui/view_settings.go index 3b21b7c8..7e5a47ff 100644 --- a/internal/tui/view_settings.go +++ b/internal/tui/view_settings.go @@ -275,6 +275,10 @@ func (m RootModel) getSettingsValues(category string) map[string]interface{} { values["slow_worker_grace_period"] = m.Settings.Performance.SlowWorkerGracePeriod values["stall_timeout"] = m.Settings.Performance.StallTimeout values["speed_ema_alpha"] = m.Settings.Performance.SpeedEmaAlpha + case "Debrid": + values["enabled"] = m.Settings.Network.Debrid.Enabled + values["provider"] = m.Settings.Network.Debrid.Provider + values["api_key"] = m.Settings.Network.Debrid.APIKey case "Categories": values["category_enabled"] = m.Settings.General.CategoryEnabled } @@ -302,6 +306,8 @@ func (m *RootModel) setSettingValue(category, key, value string) error { return m.setNetworkSetting(key, value, meta.Type) case "Performance": return m.setPerformanceSetting(key, value, meta.Type) + case "Debrid": + return m.setDebridSetting(key, value, meta.Type) case "Categories": if key == "category_enabled" { m.Settings.General.CategoryEnabled = !m.Settings.General.CategoryEnabled @@ -465,6 +471,18 @@ func (m *RootModel) setPerformanceSetting(key, value, typ string) error { return nil } +func (m *RootModel) setDebridSetting(key, value, typ string) error { + switch key { + case "enabled": + m.Settings.Network.Debrid.Enabled = !m.Settings.Network.Debrid.Enabled + case "provider": + m.Settings.Network.Debrid.Provider = value + case "api_key": + m.Settings.Network.Debrid.APIKey = value + } + return nil +} + // getCurrentSettingKey returns the key of the currently selected setting func (m RootModel) getCurrentSettingKey() string { meta := m.getCurrentSettingMeta() From 491e35e42a91b042da6435067229acc74f5f0b9d Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:09:27 -0400 Subject: [PATCH 6/6] fix: reduce host cache TTL to 5min and use consistent time reference Address greptile P1 findings: - Reduce supportedHostsCached TTL from 1 hour to 5 minutes for fresher host availability checks - Capture time.Now() once and reuse for consistent TTL comparisons across the read-lock and write-lock paths Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/debrid/realdebrid.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/debrid/realdebrid.go b/internal/debrid/realdebrid.go index b3dcdea0..7fde2a34 100644 --- a/internal/debrid/realdebrid.go +++ b/internal/debrid/realdebrid.go @@ -121,11 +121,13 @@ func (c *Client) SupportedHosts() ([]string, error) { return domains, nil } -// supportedHostsCached returns the supported hosts list, caching for 1 hour -// to avoid making a live HTTP call on every invocation. +// supportedHostsCached returns the supported hosts list, caching briefly to +// avoid making a live HTTP call on every invocation. func (c *Client) supportedHostsCached() ([]string, error) { + now := time.Now() + c.mu.RLock() - if c.cachedHosts != nil && time.Since(c.hostsCached) < time.Hour { + if c.cachedHosts != nil && now.Sub(c.hostsCached) < 5*time.Minute { hosts := c.cachedHosts c.mu.RUnlock() return hosts, nil @@ -135,7 +137,8 @@ func (c *Client) supportedHostsCached() ([]string, error) { c.mu.Lock() defer c.mu.Unlock() // Double-check after acquiring write lock. - if c.cachedHosts != nil && time.Since(c.hostsCached) < time.Hour { + now = time.Now() + if c.cachedHosts != nil && now.Sub(c.hostsCached) < 5*time.Minute { return c.cachedHosts, nil } hosts, err := c.SupportedHosts() @@ -143,7 +146,7 @@ func (c *Client) supportedHostsCached() ([]string, error) { return nil, err } c.cachedHosts = hosts - c.hostsCached = time.Now() + c.hostsCached = now return hosts, nil }