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..7fde2a34 --- /dev/null +++ b/internal/debrid/realdebrid.go @@ -0,0 +1,173 @@ +package debrid + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// Client is a Real-Debrid API client. +type Client struct { + apiKey string + httpClient *http.Client + baseURL string + mu sync.RWMutex + cachedHosts []string + hostsCached time.Time +} + +// 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"` + Download string `json:"download"` + 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 +} + +// 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 && now.Sub(c.hostsCached) < 5*time.Minute { + 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. + now = time.Now() + if c.cachedHosts != nil && now.Sub(c.hostsCached) < 5*time.Minute { + return c.cachedHosts, nil + } + hosts, err := c.SupportedHosts() + if err != nil { + return nil, err + } + c.cachedHosts = hosts + c.hostsCached = 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) + if err != nil { + return false + } + host := strings.ToLower(parsed.Hostname()) + + hosts, err := c.supportedHostsCached() + 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")) +} 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()