Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"}
}
Comment on lines 122 to 124
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Debrid TUI tab is visible but non-functional

CategoryOrder() now includes "Debrid", so the tab appears in the settings UI. However, view_settings.go was not updated: getSettingValues has no case "Debrid" (all three fields will display as blank), and setSettingValue also has no case "Debrid" (edits will be silently discarded). The PR description explicitly promises a working TUI settings tab, but the wiring is missing.

view_settings.go needs two additions:

// in getSettingValues
case "Debrid":
    values["enabled"] = m.Settings.Network.Debrid.Enabled
    values["provider"] = m.Settings.Network.Debrid.Provider
    values["api_key"] = m.Settings.Network.Debrid.APIKey
// in setSettingValue switch
case "Debrid":
    return m.setDebridSetting(key, value, meta.Type)

…plus a corresponding setDebridSetting method.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/config/settings.go
Line: 122-124

Comment:
**Debrid TUI tab is visible but non-functional**

`CategoryOrder()` now includes `"Debrid"`, so the tab appears in the settings UI. However, `view_settings.go` was not updated: `getSettingValues` has no `case "Debrid"` (all three fields will display as blank), and `setSettingValue` also has no `case "Debrid"` (edits will be silently discarded). The PR description explicitly promises a working TUI settings tab, but the wiring is missing.

`view_settings.go` needs two additions:

```go
// in getSettingValues
case "Debrid":
    values["enabled"] = m.Settings.Network.Debrid.Enabled
    values["provider"] = m.Settings.Network.Debrid.Provider
    values["api_key"] = m.Settings.Network.Debrid.APIKey
```

```go
// in setSettingValue switch
case "Debrid":
    return m.setDebridSetting(key, value, meta.Type)
```

…plus a corresponding `setDebridSetting` method.

How can I resolve this? If you propose a fix, please make it concise.


const (
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion internal/config/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
173 changes: 173 additions & 0 deletions internal/debrid/realdebrid.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// 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
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
90 changes: 90 additions & 0 deletions internal/debrid/realdebrid_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
18 changes: 18 additions & 0 deletions internal/tui/view_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
)

// viewSettings renders the Btop-style settings page
func (m RootModel) viewSettings() string {

Check failure on line 18 in internal/tui/view_settings.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 38 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=SurgeDM_Surge&issues=AZ2siq1FhIxN7G5tMq4H&open=AZ2siq1FhIxN7G5tMq4H&pullRequest=325
if m.width <= 0 || m.height <= 0 {
return ""
}
Expand Down Expand Up @@ -275,6 +275,10 @@
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
}
Expand Down Expand Up @@ -302,6 +306,8 @@
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
Expand All @@ -326,7 +332,7 @@
return nil
}

func (m *RootModel) setGeneralSetting(key, value, typ string) error {

Check failure on line 335 in internal/tui/view_settings.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=SurgeDM_Surge&issues=AZ2siq1FhIxN7G5tMq4I&open=AZ2siq1FhIxN7G5tMq4I&pullRequest=325
switch key {
case "default_download_dir":
m.Settings.General.DefaultDownloadDir = value
Expand Down Expand Up @@ -378,7 +384,7 @@
return nil
}

func (m *RootModel) setNetworkSetting(key, value, typ string) error {

Check failure on line 387 in internal/tui/view_settings.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=SurgeDM_Surge&issues=AZ2siq1FhIxN7G5tMq4J&open=AZ2siq1FhIxN7G5tMq4J&pullRequest=325
switch key {
case "max_connections_per_host":
if v, err := strconv.Atoi(value); err == nil {
Expand Down Expand Up @@ -419,7 +425,7 @@
return nil
}

func (m *RootModel) setPerformanceSetting(key, value, typ string) error {

Check failure on line 428 in internal/tui/view_settings.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 23 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=SurgeDM_Surge&issues=AZ2siq1FhIxN7G5tMq4K&open=AZ2siq1FhIxN7G5tMq4K&pullRequest=325
switch key {
case "max_task_retries":
if v, err := strconv.Atoi(value); err == nil {
Expand Down Expand Up @@ -465,6 +471,18 @@
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()
Expand Down Expand Up @@ -571,7 +589,7 @@
}

// formatSettingValue formats a setting value for display
func formatSettingValue(value interface{}, typ string) string {

Check failure on line 592 in internal/tui/view_settings.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=SurgeDM_Surge&issues=AZ2siq1FhIxN7G5tMq4L&open=AZ2siq1FhIxN7G5tMq4L&pullRequest=325
if value == nil {
return "-"
}
Expand Down