package realdebrid import ( "bytes" "cmp" "encoding/json" "errors" "fmt" "github.com/sirrobot01/decypharr/pkg/debrid/types" "io" "net/http" gourl "net/url" "path/filepath" "strconv" "strings" "sync" "time" "github.com/rs/zerolog" "github.com/sirrobot01/decypharr/internal/config" "github.com/sirrobot01/decypharr/internal/logger" "github.com/sirrobot01/decypharr/internal/request" "github.com/sirrobot01/decypharr/internal/utils" "github.com/sirrobot01/decypharr/pkg/rar" ) type RealDebrid struct { name string Host string `json:"host"` APIKey string accounts *types.Accounts DownloadUncached bool client *request.Client downloadClient *request.Client repairClient *request.Client autoExpiresLinksAfter time.Duration MountPath string logger zerolog.Logger UnpackRar bool rarSemaphore chan struct{} checkCached bool addSamples bool Profile *types.Profile minimumFreeSlot int // Minimum number of active pots to maintain (used for cached stuffs, etc.) limit int } func New(dc config.Debrid) (*RealDebrid, error) { rl := request.ParseRateLimit(dc.RateLimit) repairRl := request.ParseRateLimit(cmp.Or(dc.RepairRateLimit, dc.RateLimit)) downloadRl := request.ParseRateLimit(cmp.Or(dc.DownloadRateLimit, dc.RateLimit)) headers := map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", dc.APIKey), } _log := logger.New(dc.Name) autoExpiresLinksAfter, err := time.ParseDuration(dc.AutoExpireLinksAfter) if autoExpiresLinksAfter == 0 || err != nil { autoExpiresLinksAfter = 48 * time.Hour } r := &RealDebrid{ name: "realdebrid", Host: "https://api.real-debrid.com/rest/1.0", APIKey: dc.APIKey, accounts: types.NewAccounts(dc), DownloadUncached: dc.DownloadUncached, autoExpiresLinksAfter: autoExpiresLinksAfter, UnpackRar: dc.UnpackRar, client: request.New( request.WithHeaders(headers), request.WithRateLimiter(rl), request.WithLogger(_log), request.WithMaxRetries(10), request.WithRetryableStatus(429, 502), request.WithProxy(dc.Proxy), ), downloadClient: request.New( request.WithRateLimiter(downloadRl), request.WithLogger(_log), request.WithMaxRetries(10), request.WithRetryableStatus(429, 447, 502), request.WithProxy(dc.Proxy), ), repairClient: request.New( request.WithRateLimiter(repairRl), request.WithHeaders(headers), request.WithLogger(_log), request.WithMaxRetries(4), request.WithRetryableStatus(429, 502), request.WithProxy(dc.Proxy), ), MountPath: dc.Folder, logger: logger.New(dc.Name), rarSemaphore: make(chan struct{}, 2), checkCached: dc.CheckCached, addSamples: dc.AddSamples, minimumFreeSlot: dc.MinimumFreeSlot, limit: dc.Limit, } if _, err := r.GetProfile(); err != nil { return nil, err } else { return r, nil } } func (r *RealDebrid) Name() string { return r.name } func (r *RealDebrid) Logger() zerolog.Logger { return r.logger } func (r *RealDebrid) getSelectedFiles(t *types.Torrent, data torrentInfo) (map[string]types.File, error) { files := make(map[string]types.File) selectedFiles := make([]types.File, 0) for _, f := range data.Files { if f.Selected == 1 { selectedFiles = append(selectedFiles, types.File{ TorrentId: t.Id, Name: filepath.Base(f.Path), Path: filepath.Base(f.Path), Size: f.Bytes, Id: strconv.Itoa(f.ID), }) } } if len(selectedFiles) == 0 { return files, nil } // Handle RARed torrents (single link, multiple files) if len(data.Links) == 1 && len(selectedFiles) > 1 { return r.handleRarArchive(t, data, selectedFiles) } // Standard case - map files to links if len(selectedFiles) > len(data.Links) { r.logger.Warn().Msgf("More files than links available: %d files, %d links for %s", len(selectedFiles), len(data.Links), t.Name) } for i, f := range selectedFiles { if i < len(data.Links) { f.Link = data.Links[i] files[f.Name] = f } else { r.logger.Warn().Str("file", f.Name).Msg("No link available for file") } } return files, nil } // handleRarArchive processes RAR archives with multiple files func (r *RealDebrid) handleRarArchive(t *types.Torrent, data torrentInfo, selectedFiles []types.File) (map[string]types.File, error) { // This will block if 2 RAR operations are already in progress r.rarSemaphore <- struct{}{} defer func() { <-r.rarSemaphore }() files := make(map[string]types.File) if !r.UnpackRar { r.logger.Debug().Msgf("RAR file detected, but unpacking is disabled: %s", t.Name) // Create a single file representing the RAR archive file := types.File{ TorrentId: t.Id, Id: "0", Name: t.Name + ".rar", Size: 0, IsRar: true, ByteRange: nil, Path: t.Name + ".rar", Link: data.Links[0], Generated: time.Now(), } files[file.Name] = file return files, nil } r.logger.Info().Msgf("RAR file detected, unpacking: %s", t.Name) linkFile := &types.File{TorrentId: t.Id, Link: data.Links[0]} downloadLinkObj, err := r.GetDownloadLink(t, linkFile) if err != nil { return nil, fmt.Errorf("failed to get download link for RAR file: %w", err) } dlLink := downloadLinkObj.DownloadLink reader, err := rar.NewReader(dlLink) if err != nil { return nil, fmt.Errorf("failed to create RAR reader: %w", err) } rarFiles, err := reader.GetFiles() if err != nil { return nil, fmt.Errorf("failed to read RAR files: %w", err) } // Create lookup map for faster matching fileMap := make(map[string]*types.File) for i := range selectedFiles { // RD converts special chars to '_' for RAR file paths // @TODO: there might be more special chars to replace safeName := strings.NewReplacer("|", "_", "\"", "_", "\\", "_", "?", "_", "*", "_", ":", "_", "<", "_", ">", "_").Replace(selectedFiles[i].Name) fileMap[safeName] = &selectedFiles[i] } now := time.Now() for _, rarFile := range rarFiles { if file, exists := fileMap[rarFile.Name()]; exists { file.IsRar = true file.ByteRange = rarFile.ByteRange() file.Link = data.Links[0] file.Generated = now files[file.Name] = *file } else if !rarFile.IsDirectory { r.logger.Warn().Msgf("RAR file %s not found in torrent files", rarFile.Name()) } } return files, nil } // getTorrentFiles returns a list of torrent files from the torrent info // validate is used to determine if the files should be validated // if validate is false, selected files will be returned func (r *RealDebrid) getTorrentFiles(t *types.Torrent, data torrentInfo) map[string]types.File { files := make(map[string]types.File) cfg := config.Get() idx := 0 for _, f := range data.Files { name := filepath.Base(f.Path) if !r.addSamples && utils.IsSampleFile(f.Path) { // Skip sample files continue } if !cfg.IsAllowedFile(name) { continue } if !cfg.IsSizeAllowed(f.Bytes) { continue } file := types.File{ TorrentId: t.Id, Name: name, Path: name, Size: f.Bytes, Id: strconv.Itoa(f.ID), } files[name] = file idx++ } return files } func (r *RealDebrid) IsAvailable(hashes []string) map[string]bool { // Check if the infohashes are available in the local cache result := make(map[string]bool) // Divide hashes into groups of 100 for i := 0; i < len(hashes); i += 200 { end := i + 200 if end > len(hashes) { end = len(hashes) } // Filter out empty strings validHashes := make([]string, 0, end-i) for _, hash := range hashes[i:end] { if hash != "" { validHashes = append(validHashes, hash) } } // If no valid hashes in this batch, continue to the next batch if len(validHashes) == 0 { continue } hashStr := strings.Join(validHashes, "/") url := fmt.Sprintf("%s/torrents/instantAvailability/%s", r.Host, hashStr) req, _ := http.NewRequest(http.MethodGet, url, nil) resp, err := r.client.MakeRequest(req) if err != nil { r.logger.Error().Err(err).Msgf("Error checking availability") return result } var data AvailabilityResponse err = json.Unmarshal(resp, &data) if err != nil { r.logger.Error().Err(err).Msgf("Error marshalling availability") return result } for _, h := range hashes[i:end] { hosters, exists := data[strings.ToLower(h)] if exists && len(hosters.Rd) > 0 { result[h] = true } } } return result } func (r *RealDebrid) SubmitMagnet(t *types.Torrent) (*types.Torrent, error) { if t.Magnet.IsTorrent() { return r.addTorrent(t) } return r.addMagnet(t) } func (r *RealDebrid) addTorrent(t *types.Torrent) (*types.Torrent, error) { url := fmt.Sprintf("%s/torrents/addTorrent", r.Host) var data AddMagnetSchema req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(t.Magnet.File)) if err != nil { return nil, err } req.Header.Add("Content-Type", "application/x-bittorrent") resp, err := r.client.Do(req) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { // Handle multiple_downloads if resp.StatusCode == 509 { return nil, utils.TooManyActiveDownloadsError } bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes)) } defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading response body: %w", err) } if err = json.Unmarshal(bodyBytes, &data); err != nil { return nil, err } t.Id = data.Id t.Debrid = r.name t.MountPath = r.MountPath return t, nil } func (r *RealDebrid) addMagnet(t *types.Torrent) (*types.Torrent, error) { url := fmt.Sprintf("%s/torrents/addMagnet", r.Host) payload := gourl.Values{ "magnet": {t.Magnet.Link}, } var data AddMagnetSchema req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode())) resp, err := r.client.Do(req) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { // Handle multiple_downloads if resp.StatusCode == 509 { return nil, utils.TooManyActiveDownloadsError } bodyBytes, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes)) } defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading response body: %w", err) } if err = json.Unmarshal(bodyBytes, &data); err != nil { return nil, err } t.Id = data.Id t.Debrid = r.name t.MountPath = r.MountPath return t, nil } func (r *RealDebrid) GetTorrent(torrentId string) (*types.Torrent, error) { url := fmt.Sprintf("%s/torrents/info/%s", r.Host, torrentId) req, _ := http.NewRequest(http.MethodGet, url, nil) resp, err := r.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading response body: %w", err) } if resp.StatusCode != http.StatusOK { if resp.StatusCode == http.StatusNotFound { return nil, utils.TorrentNotFoundError } return nil, fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes)) } var data torrentInfo err = json.Unmarshal(bodyBytes, &data) if err != nil { return nil, err } t := &types.Torrent{ Id: data.ID, Name: data.Filename, Bytes: data.Bytes, Folder: data.OriginalFilename, Progress: data.Progress, Speed: data.Speed, Seeders: data.Seeders, Added: data.Added, Status: data.Status, Filename: data.Filename, OriginalFilename: data.OriginalFilename, Links: data.Links, Debrid: r.name, MountPath: r.MountPath, } t.Files = r.getTorrentFiles(t, data) // Get selected files return t, nil } func (r *RealDebrid) UpdateTorrent(t *types.Torrent) error { url := fmt.Sprintf("%s/torrents/info/%s", r.Host, t.Id) req, _ := http.NewRequest(http.MethodGet, url, nil) resp, err := r.client.Do(req) if err != nil { return err } defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("reading response body: %w", err) } if resp.StatusCode != http.StatusOK { if resp.StatusCode == http.StatusNotFound { return utils.TorrentNotFoundError } return fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes)) } var data torrentInfo err = json.Unmarshal(bodyBytes, &data) if err != nil { return err } t.Name = data.Filename t.Bytes = data.Bytes t.Folder = data.OriginalFilename t.Progress = data.Progress t.Status = data.Status t.Speed = data.Speed t.Seeders = data.Seeders t.Filename = data.Filename t.OriginalFilename = data.OriginalFilename t.Links = data.Links t.MountPath = r.MountPath t.Debrid = r.name t.Added = data.Added t.Files, _ = r.getSelectedFiles(t, data) // Get selected files return nil } func (r *RealDebrid) CheckStatus(t *types.Torrent) (*types.Torrent, error) { url := fmt.Sprintf("%s/torrents/info/%s", r.Host, t.Id) req, _ := http.NewRequest(http.MethodGet, url, nil) for { resp, err := r.client.MakeRequest(req) if err != nil { r.logger.Info().Msgf("ERROR Checking file: %v", err) return t, err } var data torrentInfo if err = json.Unmarshal(resp, &data); err != nil { return t, err } status := data.Status t.Name = data.Filename // Important because some magnet changes the name t.Folder = data.OriginalFilename t.Filename = data.Filename t.OriginalFilename = data.OriginalFilename t.Bytes = data.Bytes t.Progress = data.Progress t.Speed = data.Speed t.Seeders = data.Seeders t.Links = data.Links t.Status = status t.Debrid = r.name t.MountPath = r.MountPath if status == "waiting_files_selection" { t.Files = r.getTorrentFiles(t, data) if len(t.Files) == 0 { return t, fmt.Errorf("no video files found") } filesId := make([]string, 0) for _, f := range t.Files { filesId = append(filesId, f.Id) } p := gourl.Values{ "files": {strings.Join(filesId, ",")}, } payload := strings.NewReader(p.Encode()) req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/torrents/selectFiles/%s", r.Host, t.Id), payload) res, err := r.client.Do(req) if err != nil { return t, err } if res.StatusCode != http.StatusNoContent { if res.StatusCode == 509 { return nil, utils.TooManyActiveDownloadsError } return t, fmt.Errorf("realdebrid API error: Status: %d", res.StatusCode) } } else if status == "downloaded" { t.Files, err = r.getSelectedFiles(t, data) // Get selected files if err != nil { return t, err } r.logger.Info().Msgf("Torrent: %s downloaded to RD", t.Name) return t, nil } else if utils.Contains(r.GetDownloadingStatus(), status) { if !t.DownloadUncached { return t, fmt.Errorf("torrent: %s not cached", t.Name) } return t, nil } else { return t, fmt.Errorf("torrent: %s has error: %s", t.Name, status) } } } func (r *RealDebrid) DeleteTorrent(torrentId string) error { url := fmt.Sprintf("%s/torrents/delete/%s", r.Host, torrentId) req, _ := http.NewRequest(http.MethodDelete, url, nil) if _, err := r.client.MakeRequest(req); err != nil { return err } r.logger.Info().Msgf("Torrent: %s deleted from RD", torrentId) return nil } func (r *RealDebrid) GetFileDownloadLinks(t *types.Torrent) error { var wg sync.WaitGroup var mu sync.Mutex var firstErr error files := make(map[string]types.File) links := make(map[string]*types.DownloadLink) _files := t.GetFiles() wg.Add(len(_files)) for _, f := range _files { go func(file types.File) { defer wg.Done() link, err := r.GetDownloadLink(t, &file) if err != nil { mu.Lock() if firstErr == nil { firstErr = err } mu.Unlock() return } if link == nil { mu.Lock() if firstErr == nil { firstErr = fmt.Errorf("realdebrid API error: download link not found for file %s", file.Name) } mu.Unlock() return } file.DownloadLink = link mu.Lock() files[file.Name] = file links[link.Link] = link mu.Unlock() }(f) } wg.Wait() if firstErr != nil { return firstErr } // Add links to cache r.accounts.SetDownloadLinks(links) t.Files = files return nil } func (r *RealDebrid) CheckLink(link string) error { url := fmt.Sprintf("%s/unrestrict/check", r.Host) payload := gourl.Values{ "link": {link}, } req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode())) resp, err := r.repairClient.Do(req) if err != nil { return err } if resp.StatusCode == http.StatusNotFound { return utils.HosterUnavailableError // File has been removed } return nil } func (r *RealDebrid) _getDownloadLink(file *types.File) (*types.DownloadLink, error) { url := fmt.Sprintf("%s/unrestrict/link/", r.Host) _link := file.Link if strings.HasPrefix(file.Link, "https://real-debrid.com/d/") && len(file.Link) > 39 { _link = file.Link[0:39] } payload := gourl.Values{ "link": {_link}, } req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode())) resp, err := r.downloadClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { // Read the response body to get the error message b, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var data ErrorResponse if err = json.Unmarshal(b, &data); err != nil { return nil, fmt.Errorf("error unmarshalling %d || %s \n %s", resp.StatusCode, err, string(b)) } switch data.ErrorCode { case 19: return nil, utils.HosterUnavailableError // File has been removed case 23: return nil, utils.TrafficExceededError case 24: return nil, utils.HosterUnavailableError // Link has been nerfed case 34: return nil, utils.TrafficExceededError // traffic exceeded case 35: return nil, utils.HosterUnavailableError case 36: return nil, utils.TrafficExceededError // traffic exceeded default: return nil, fmt.Errorf("realdebrid API error: Status: %d || Code: %d", resp.StatusCode, data.ErrorCode) } } b, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var data UnrestrictResponse if err = json.Unmarshal(b, &data); err != nil { return nil, fmt.Errorf("realdebrid API error: Error unmarshalling response: %w", err) } if data.Download == "" { return nil, fmt.Errorf("realdebrid API error: download link not found") } now := time.Now() return &types.DownloadLink{ Filename: data.Filename, Size: data.Filesize, Link: data.Link, DownloadLink: data.Download, Generated: now, ExpiresAt: now.Add(r.autoExpiresLinksAfter), }, nil } func (r *RealDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, error) { accounts := r.accounts.All() for _, account := range accounts { r.downloadClient.SetHeader("Authorization", fmt.Sprintf("Bearer %s", account.Token)) downloadLink, err := r._getDownloadLink(file) if err == nil { return downloadLink, nil } retries := 0 if errors.Is(err, utils.TrafficExceededError) { // Retries generating retries = 5 } else { // If the error is not traffic exceeded, return the error return nil, err } backOff := 1 * time.Second for retries > 0 { downloadLink, err = r._getDownloadLink(file) if err == nil { return downloadLink, nil } if !errors.Is(err, utils.TrafficExceededError) { return nil, err } // Add a delay before retrying time.Sleep(backOff) backOff *= 2 // Exponential backoff retries-- } } return nil, fmt.Errorf("realdebrid API error: download link not found") } func (r *RealDebrid) getTorrents(offset int, limit int) (int, []*types.Torrent, error) { url := fmt.Sprintf("%s/torrents?limit=%d", r.Host, limit) torrents := make([]*types.Torrent, 0) if offset > 0 { url = fmt.Sprintf("%s&offset=%d", url, offset) } req, _ := http.NewRequest(http.MethodGet, url, nil) resp, err := r.client.Do(req) if err != nil { return 0, torrents, err } if resp.StatusCode == http.StatusNoContent { return 0, torrents, nil } if resp.StatusCode != http.StatusOK { resp.Body.Close() return 0, torrents, fmt.Errorf("realdebrid API error: %d", resp.StatusCode) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return 0, torrents, err } totalItems, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) var data []TorrentsResponse if err = json.Unmarshal(body, &data); err != nil { return 0, torrents, err } filenames := map[string]struct{}{} for _, t := range data { if t.Status != "downloaded" { continue } torrents = append(torrents, &types.Torrent{ Id: t.Id, Name: t.Filename, Bytes: t.Bytes, Progress: t.Progress, Status: t.Status, Filename: t.Filename, OriginalFilename: t.Filename, Links: t.Links, Files: make(map[string]types.File), InfoHash: t.Hash, Debrid: r.name, MountPath: r.MountPath, Added: t.Added.Format(time.RFC3339), }) filenames[t.Filename] = struct{}{} } return totalItems, torrents, nil } func (r *RealDebrid) GetTorrents() ([]*types.Torrent, error) { limit := 5000 if r.limit != 0 { limit = r.limit } hardLimit := r.limit // Get first batch and total count allTorrents := make([]*types.Torrent, 0) var fetchError error offset := 0 for { // Fetch next batch of torrents _, torrents, err := r.getTorrents(offset, limit) if err != nil { fetchError = err break } totalTorrents := len(torrents) if totalTorrents == 0 { break } allTorrents = append(allTorrents, torrents...) offset += totalTorrents if hardLimit != 0 && len(allTorrents) >= hardLimit { // If hard limit is set, stop fetching more torrents break } } if fetchError != nil { return nil, fetchError } return allTorrents, nil } func (r *RealDebrid) GetDownloadLinks() (map[string]*types.DownloadLink, error) { links := make(map[string]*types.DownloadLink) offset := 0 limit := 1000 accounts := r.accounts.All() if len(accounts) < 1 { // No active download keys. It's likely that the key has reached bandwidth limit return links, fmt.Errorf("no active download keys") } activeAccount := accounts[0] r.downloadClient.SetHeader("Authorization", fmt.Sprintf("Bearer %s", activeAccount.Token)) for { dl, err := r._getDownloads(offset, limit) if err != nil { break } if len(dl) == 0 { break } for _, d := range dl { if _, exists := links[d.Link]; exists { // This is ordered by date, so we can skip the rest continue } links[d.Link] = &d } offset += len(dl) } return links, nil } func (r *RealDebrid) _getDownloads(offset int, limit int) ([]types.DownloadLink, error) { url := fmt.Sprintf("%s/downloads?limit=%d", r.Host, limit) if offset > 0 { url = fmt.Sprintf("%s&offset=%d", url, offset) } req, _ := http.NewRequest(http.MethodGet, url, nil) resp, err := r.downloadClient.MakeRequest(req) if err != nil { return nil, err } var data []DownloadsResponse if err = json.Unmarshal(resp, &data); err != nil { return nil, err } links := make([]types.DownloadLink, 0) for _, d := range data { links = append(links, types.DownloadLink{ Filename: d.Filename, Size: d.Filesize, Link: d.Link, DownloadLink: d.Download, Generated: d.Generated, ExpiresAt: d.Generated.Add(r.autoExpiresLinksAfter), Id: d.Id, }) } return links, nil } func (r *RealDebrid) GetDownloadingStatus() []string { return []string{"downloading", "magnet_conversion", "queued", "compressing", "uploading"} } func (r *RealDebrid) GetDownloadUncached() bool { return r.DownloadUncached } func (r *RealDebrid) GetMountPath() string { return r.MountPath } func (r *RealDebrid) DeleteDownloadLink(linkId string) error { url := fmt.Sprintf("%s/downloads/delete/%s", r.Host, linkId) req, _ := http.NewRequest(http.MethodDelete, url, nil) if _, err := r.downloadClient.MakeRequest(req); err != nil { return err } return nil } func (r *RealDebrid) GetProfile() (*types.Profile, error) { if r.Profile != nil { return r.Profile, nil } url := fmt.Sprintf("%s/user", r.Host) req, _ := http.NewRequest(http.MethodGet, url, nil) resp, err := r.client.MakeRequest(req) if err != nil { return nil, err } var data profileResponse if json.Unmarshal(resp, &data) != nil { return nil, err } profile := &types.Profile{ Id: data.Id, Username: data.Username, Email: data.Email, Points: data.Points, Premium: data.Premium, Expiration: data.Expiration, Type: data.Type, } r.Profile = profile return profile, nil } func (r *RealDebrid) GetAvailableSlots() (int, error) { url := fmt.Sprintf("%s/torrents/activeCount", r.Host) req, _ := http.NewRequest(http.MethodGet, url, nil) resp, err := r.client.MakeRequest(req) if err != nil { return 0, nil } var data AvailableSlotsResponse if json.Unmarshal(resp, &data) != nil { return 0, fmt.Errorf("error unmarshalling available slots response: %w", err) } return data.TotalSlots - data.ActiveSlots - r.minimumFreeSlot, nil // Ensure we maintain minimum active pots } func (r *RealDebrid) Accounts() *types.Accounts { return r.accounts }