diff --git a/internal/app.go b/internal/app.go index 8606250..5ec5b17 100644 --- a/internal/app.go +++ b/internal/app.go @@ -52,6 +52,17 @@ func MainApp(configPath string) { os.Exit(1) } + repo := http.NewIPRepository(log.Named("network_test")) + repoClient := http.NewHTTPClient( + "", + 0, + 1, + true, + config, + log.Named("network_test"), + ) + repo.NetworkTest(repoClient, false) + apiClient := http.NewHTTPClient( config.GetToken(), config.GetRetriesUntilFailed(), // default retries = 2 @@ -74,7 +85,7 @@ func MainApp(configPath string) { "", // no token required for download client config.GetRetriesUntilFailed(), // config.GetDownloadTimeoutSecs(), // - true, // download client supports ipv6 + true, // set as download client config, log.Named("download_client"), ) diff --git a/internal/torrent/manager.go b/internal/torrent/manager.go index 1b3dcf5..f5dbe8d 100644 --- a/internal/torrent/manager.go +++ b/internal/torrent/manager.go @@ -258,9 +258,6 @@ func (t *TorrentManager) applyMediaInfoDetails(torrent *Torrent) { func (t *TorrentManager) readTorrentFromFile(filePath string) *Torrent { file, err := os.Open(filePath) if err != nil { - if os.IsNotExist(err) { - return nil - } return nil } defer file.Close() @@ -318,9 +315,6 @@ func (t *TorrentManager) readInfoFromFile(torrentID string) *realdebrid.TorrentI filePath := "data/info/" + torrentID + ".zurginfo" file, err := os.Open(filePath) if err != nil { - if os.IsNotExist(err) { - return nil - } return nil } defer file.Close() diff --git a/pkg/http/client.go b/pkg/http/client.go index cfcaf9a..08e7cb7 100644 --- a/pkg/http/client.go +++ b/pkg/http/client.go @@ -24,23 +24,18 @@ import ( ) type HTTPClient struct { - client *http.Client - maxRetries int - timeoutSecs int - backoff func(attempt int) time.Duration - bearerToken string - supportIPv6 bool - cfg config.ConfigInterface - ipv6 cmap.ConcurrentMap[string, string] - ipv6Hosts []string - log *logutil.Logger + client *http.Client + maxRetries int + timeoutSecs int + backoff func(attempt int) time.Duration + bearerToken string + isDownloadClient bool + cfg config.ConfigInterface + ipv6 cmap.ConcurrentMap[string, string] + ipv6Hosts []string + log *logutil.Logger } -// { -// "error": "infringing_file", -// "error_code": 35 -// } - type ApiErrorResponse struct { Message string `json:"error"` Code int `json:"error_code"` @@ -54,20 +49,20 @@ func NewHTTPClient( token string, maxRetries int, timeoutSecs int, - supportIPv6 bool, + isDownloadClient bool, cfg config.ConfigInterface, log *logutil.Logger, ) *HTTPClient { client := HTTPClient{ - bearerToken: token, - client: &http.Client{}, - maxRetries: maxRetries, - timeoutSecs: timeoutSecs, - backoff: backoffFunc, - supportIPv6: supportIPv6, - cfg: cfg, - ipv6: cmap.New[string](), - log: log, + bearerToken: token, + client: &http.Client{}, + maxRetries: maxRetries, + timeoutSecs: timeoutSecs, + backoff: backoffFunc, + isDownloadClient: isDownloadClient, + cfg: cfg, + ipv6: cmap.New[string](), + log: log, } var dialer proxy.Dialer = &net.Dialer{ @@ -217,7 +212,7 @@ func (r *HTTPClient) Do(req *http.Request) (*http.Response, error) { func (r *HTTPClient) replaceWithIPv6Host(req *http.Request) { // don't replace host if IPv6 is not supported or not forced - if !r.supportIPv6 || !r.cfg.ShouldForceIPv6() { + if !r.isDownloadClient || !r.cfg.ShouldForceIPv6() { return } // this host should be replaced diff --git a/pkg/http/ip.go b/pkg/http/ip.go new file mode 100644 index 0000000..b77356e --- /dev/null +++ b/pkg/http/ip.go @@ -0,0 +1,202 @@ +package http + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math/rand" + "net" + "net/http" + "os" + "time" + + "github.com/debridmediamanager/zurg/pkg/logutil" +) + +type IPRepository struct { + ipv4 []string + ipv6 []string + latencyMap map[string]float64 + log *logutil.Logger +} + +func NewIPRepository(log *logutil.Logger) *IPRepository { + repo := &IPRepository{ + ipv4: []string{}, + ipv6: []string{}, + latencyMap: make(map[string]float64), + log: log, + } + + repo.lookupDomains() + + return repo +} + +func (r *IPRepository) NetworkTest(downloadClient *HTTPClient, forceRun bool) { + latencyFile := "data/latency.json" + if !forceRun { + latencyData := r.readLatencyFile(latencyFile) + if latencyData != nil { + r.latencyMap = *latencyData + return + } + } + + r.log.Info("Network test will start now. Note that it will only run once and record the latency of each domain for future use.") + r.latencyTest(downloadClient) + r.log.Infof("Network test completed. Saving the results to %s", latencyFile) + r.writeLatencyFile(latencyFile) +} + +func (r *IPRepository) lookupDomains() { + limit := 99 + increment := 10 + start := 0 + for { + lastDomainWorked := false + for i := start; i <= limit; i++ { + domain := fmt.Sprintf("%d.download.real-debrid.com", i) + ips, err := net.LookupIP(domain) + if err == nil && len(ips) > 0 { + hasIPv6 := false + for _, ip := range ips { + if ip.To4() == nil { + hasIPv6 = true + } + } + // assume it always has ipv4 + r.ipv4 = append(r.ipv4, domain) + if hasIPv6 { + r.ipv6 = append(r.ipv6, domain) + } + if i == limit { + lastDomainWorked = true + } + } + + domain2 := fmt.Sprintf("%d.download.real-debrid.cloud", i) + ips2, err := net.LookupIP(domain2) + if err == nil && len(ips2) > 0 { + hasIPv6 := false + for _, ip := range ips { + if ip.To4() == nil { + hasIPv6 = true + } + } + r.ipv4 = append(r.ipv4, domain2) + if hasIPv6 { + r.ipv6 = append(r.ipv6, domain2) + } + if i == limit { + lastDomainWorked = true + } + } + } + if lastDomainWorked { + start = limit + 1 + limit += increment + } else { + break + } + } + + r.log.Infof("Found %d IPv4 domains and %d IPv6 domains", len(r.ipv4), len(r.ipv6)) +} + +func (r *IPRepository) latencyTest(downloadClient *HTTPClient) { + const testFileSize = 1 + const iterations = 3 + + for _, domain := range r.ipv4 { + url := fmt.Sprintf("https://%s/speedtest/test.rar/%f", domain, rand.Float64()) + + var totalDuration float64 + hasError := false + for i := 0; i < iterations; i++ { + ctx, cancel := context.WithTimeout(context.Background(), iterations*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + r.log.Warnf("Failed to create request for %s: %v", domain, err) + hasError = true + break + } + + headers := make(http.Header) + headers.Set("Range", fmt.Sprintf("bytes=0-%d", testFileSize-1)) + req.Header = headers + + start := time.Now() + resp, err := downloadClient.Do(req) + if err != nil { + r.log.Warnf("Failed to download from %s: %v", domain, err) + hasError = true + break + } + + limitedReader := io.LimitReader(resp.Body, testFileSize) + + _, err = io.Copy(io.Discard, limitedReader) + resp.Body.Close() + + if err != nil && err != io.EOF { + r.log.Warnf("Failed to read from %s: %v", domain, err) + hasError = true + break + } + + duration := time.Since(start).Seconds() + totalDuration += duration + } + if hasError { + continue + } + + r.latencyMap[domain] = totalDuration / 3 + + r.log.Debugf("Latency from %s: %.5f seconds", domain, r.latencyMap[domain]) + } +} + +func (r *IPRepository) readLatencyFile(latencyFile string) *map[string]float64 { + if _, err := os.Stat(latencyFile); err == nil { + file, err := os.Open(latencyFile) + if err != nil { + return nil + } + defer file.Close() + jsonData, err := io.ReadAll(file) + if err != nil { + return nil + } + var latencyMap map[string]float64 + if err := json.Unmarshal(jsonData, &latencyMap); err != nil { + return nil + } + return &latencyMap + } + return nil +} + +func (r *IPRepository) writeLatencyFile(latencyFile string) { + file, err := os.Create(latencyFile) + if err != nil { + r.log.Warnf("Cannot create latency file %s: %v", latencyFile, err) + return + } + defer file.Close() + + jsonData, err := json.Marshal(r.latencyMap) + if err != nil { + r.log.Warnf("Cannot marshal latency map: %v", err) + return + } + + if _, err := file.Write(jsonData); err != nil { + r.log.Warnf("Cannot write to latency file %s: %v", latencyFile, err) + return + } +}