Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
157 changes: 157 additions & 0 deletions internal/debrid/realdebrid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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
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"` // Original link
Download string `json:"download"` // Unrestricted direct download URL
Host string `json:"host"`
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
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 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)
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"))
}
Loading