package realdebrid import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" gourl "net/url" "path/filepath" "strconv" "strings" "sync" "time" "github.com/sirrobot01/decypharr/pkg/debrid/account" "github.com/sirrobot01/decypharr/pkg/debrid/types" "go.uber.org/ratelimit" "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 accountsManager *account.Manager DownloadUncached bool client *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, ratelimits map[string]ratelimit.Limiter) (*RealDebrid, error) { 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, accountsManager: account.NewManager(dc, ratelimits["download"], _log), DownloadUncached: dc.DownloadUncached, autoExpiresLinksAfter: autoExpiresLinksAfter, UnpackRar: dc.UnpackRar, client: request.New( request.WithHeaders(headers), request.WithRateLimiter(ratelimits["main"]), request.WithLogger(_log), request.WithMaxRetries(10), request.WithRetryableStatus(429, 502), request.WithProxy(dc.Proxy), ), repairClient: request.New( request.WithRateLimiter(ratelimits["repair"]), 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 } func (r *RealDebrid) handleRarFallback(t *types.Torrent, data torrentInfo) (map[string]types.File, error) { files := make(map[string]types.File) file := types.File{ TorrentId: t.Id, Id: "0", Name: t.Name + ".rar", Size: data.Bytes, IsRar: true, ByteRange: nil, Path: t.Name + ".rar", Link: data.Links[0], Generated: time.Now(), } files[file.Name] = 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. Falling back to single file representation.", t.Name) return r.handleRarFallback(t, data) } 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 { r.logger.Debug().Err(err).Msgf("Error getting download link for RAR file: %s. Falling back to single file representation.", t.Name) return r.handleRarFallback(t, data) } dlLink := downloadLinkObj.DownloadLink reader, err := rar.NewReader(dlLink) if err != nil { r.logger.Debug().Err(err).Msgf("Error creating RAR reader for %s. Falling back to single file representation.", t.Name) return r.handleRarFallback(t, data) } rarFiles, err := reader.GetFiles() if err != nil { r.logger.Debug().Err(err).Msgf("Error reading RAR files for %s. Falling back to single file representation.", t.Name) return r.handleRarFallback(t, data) } // 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()) } } if len(files) == 0 { r.logger.Warn().Msgf("No valid files found in RAR archive for torrent: %s", t.Name) return r.handleRarFallback(t, data) } r.logger.Info().Msgf("Unpacked RAR archive for torrent: %s with %d files", t.Name, len(files)) 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 } } defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, err } t.Id = data.Id t.Debrid = r.name t.MountPath = r.MountPath t.Added = time.Now().Format(time.RFC3339) 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 } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { // Handle multiple_downloads if resp.StatusCode == 509 { return nil, utils.TooManyActiveDownloadsError } bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return nil, fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes)) } if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { return nil, err } t.Id = data.Id t.Debrid = r.name t.MountPath = r.MountPath t.Added = time.Now().Format(time.RFC3339) 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() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) 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 if err = json.NewDecoder(resp.Body).Decode(&data); 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() if resp.StatusCode != http.StatusOK { if resp.StatusCode == http.StatusNotFound { return utils.TorrentNotFoundError } bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes)) } var data torrentInfo if err = json.NewDecoder(resp.Body).Decode(&data); 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.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 t.Added = data.Added if status == "waiting_files_selection" { t.Files = r.getTorrentFiles(t, data) if len(t.Files) == 0 { return t, fmt.Errorf("no valid 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.Empty() { 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 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(account *account.Account, file *types.File) (types.DownloadLink, error) { url := fmt.Sprintf("%s/unrestrict/link/", r.Host) emptyLink := types.DownloadLink{} _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 := account.Client().Do(req) if err != nil { return emptyLink, err } defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) if resp.StatusCode != http.StatusOK { // Read the response body to get the error message var data ErrorResponse if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { return emptyLink, fmt.Errorf("error unmarshalling %d || %s", resp.StatusCode, err) } switch data.ErrorCode { case 19, 24, 35: return emptyLink, utils.HosterUnavailableError // File has been removed case 23, 34, 36: return emptyLink, utils.TrafficExceededError default: return emptyLink, fmt.Errorf("realdebrid API error: Status: %d || Code: %d", resp.StatusCode, data.ErrorCode) } } var data UnrestrictResponse if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { return emptyLink, fmt.Errorf("realdebrid API error: Error unmarshalling response: %w", err) } if data.Download == "" { return emptyLink, fmt.Errorf("realdebrid API error: download link not found") } now := time.Now() dl := types.DownloadLink{ Token: account.Token, Filename: data.Filename, Size: data.Filesize, Link: data.Link, DownloadLink: data.Download, Generated: now, ExpiresAt: now.Add(r.autoExpiresLinksAfter), } // Store the link in the account account.StoreDownloadLink(dl) return dl, nil } func (r *RealDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (types.DownloadLink, error) { accounts := r.accountsManager.Active() for _, _account := range accounts { downloadLink, err := r.getDownloadLink(_account, 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 downloadLink, err } backOff := 1 * time.Second for retries > 0 { downloadLink, err = r.getDownloadLink(_account, file) if err == nil { return downloadLink, nil } if !errors.Is(err, utils.TrafficExceededError) { return downloadLink, err } // Add a delay before retrying time.Sleep(backOff) backOff *= 2 // Exponential backoff retries-- } } return types.DownloadLink{}, fmt.Errorf("realdebrid API error: used all active accounts") } 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() totalItems, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) var data []TorrentsResponse if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return 0, nil, fmt.Errorf("failed to decode response: %w", 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) RefreshDownloadLinks() error { accounts := r.accountsManager.All() for _, _account := range accounts { if _account == nil || _account.Token == "" { continue } offset := 0 limit := 1000 links := make(map[string]*types.DownloadLink) for { dl, err := r.getDownloadLinks(_account, 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) } _account.StoreDownloadLinks(links) } return nil } func (r *RealDebrid) getDownloadLinks(account *account.Account, 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 := account.Client().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{ Token: account.Token, 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) 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{ Name: r.name, 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) { req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/torrents/activeCount", r.Host), 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) AccountManager() *account.Manager { return r.accountsManager } func (r *RealDebrid) SyncAccounts() error { // Sync accounts with the current configuration if len(r.accountsManager.Active()) == 0 { return nil } for _, _account := range r.accountsManager.All() { if err := r.syncAccount(_account); err != nil { r.logger.Error().Err(err).Msgf("Error syncing account %s", _account.Username) continue // Skip this account and continue with the next } } return nil } func (r *RealDebrid) syncAccount(account *account.Account) error { if account.Token == "" { return fmt.Errorf("account %s has no token", account.Username) } req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/user", r.Host), nil) if err != nil { return fmt.Errorf("error creating request for account %s: %w", account.Username, err) } resp, err := account.Client().Do(req) if err != nil { return fmt.Errorf("error checking account %s: %w", account.Username, err) } if resp.StatusCode != http.StatusOK { resp.Body.Close() return fmt.Errorf("account %s is not valid, status code: %d", account.Username, resp.StatusCode) } defer resp.Body.Close() var profile profileResponse if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil { return fmt.Errorf("error decoding profile for account %s: %w", account.Username, err) } account.Username = profile.Username // Get traffic usage trafficReq, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/traffic/details", r.Host), nil) if err != nil { return fmt.Errorf("error creating request for traffic details for account %s: %w", account.Username, err) } trafficResp, err := account.Client().Do(trafficReq) if err != nil { return fmt.Errorf("error checking traffic for account %s: %w", account.Username, err) } if trafficResp.StatusCode != http.StatusOK { trafficResp.Body.Close() return fmt.Errorf("error checking traffic for account %s, status code: %d", account.Username, trafficResp.StatusCode) } defer trafficResp.Body.Close() var trafficData TrafficResponse if err := json.NewDecoder(trafficResp.Body).Decode(&trafficData); err != nil { // Skip logging traffic error account.TrafficUsed.Store(0) } else { today := time.Now().Format(time.DateOnly) if todayData, exists := trafficData[today]; exists { account.TrafficUsed.Store(todayData.Bytes) } } //r.accountsManager.Update(account) return nil }