diff --git a/internal/config/load.go b/internal/config/load.go index 10139bd..1283c1a 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -26,8 +26,7 @@ type ConfigInterface interface { } func LoadZurgConfig(filename string) (ConfigInterface, error) { - rlog := logutil.NewLogger() - log := rlog.Named("config") + log := logutil.NewLogger().Named("config") log.Debug("Loading config file ", filename) content, err := os.ReadFile(filename) diff --git a/internal/config/v1.go b/internal/config/v1.go index a199854..85fbfb2 100644 --- a/internal/config/v1.go +++ b/internal/config/v1.go @@ -33,8 +33,7 @@ func (z *ZurgConfigV1) GetDirectories() []string { } func (z *ZurgConfigV1) GetGroupMap() map[string][]string { - rlog := logutil.NewLogger() - log := rlog.Named("config") + log := logutil.NewLogger().Named("config") var groupMap = make(map[string][]string) var groupOrderMap = make(map[string]int) // To store GroupOrder for each directory diff --git a/internal/dav/listing.go b/internal/dav/listing.go index d2653e6..805017f 100644 --- a/internal/dav/listing.go +++ b/internal/dav/listing.go @@ -15,8 +15,7 @@ import ( ) func HandlePropfindRequest(w http.ResponseWriter, r *http.Request, t *torrent.TorrentManager, c config.ConfigInterface, cache *expirable.LRU[string, string]) { - rlog := logutil.NewLogger() - log := rlog.Named("dav") + log := logutil.NewLogger().Named("dav") requestPath := path.Clean(r.URL.Path) requestPath = strings.Trim(requestPath, "/") diff --git a/internal/dav/response.go b/internal/dav/response.go index 6098b6f..c89d623 100644 --- a/internal/dav/response.go +++ b/internal/dav/response.go @@ -18,12 +18,12 @@ func createMultiTorrentResponse(basePath string, torrents []torrent.Torrent) (*d if item.Progress != 100 { continue } - if _, exists := seen[item.Name]; exists { + if _, exists := seen[item.AccessKey]; exists { continue } - seen[item.Name] = true + seen[item.AccessKey] = true - path := filepath.Join(basePath, item.Name) + path := filepath.Join(basePath, item.AccessKey) responses = append(responses, dav.Directory(path)) } @@ -39,7 +39,7 @@ func createSingleTorrentResponse(basePath string, torrents []torrent.Torrent) (* var responses []dav.Response // initial response is the directory itself - currentPath := filepath.Join(basePath, torrents[0].Name) + currentPath := filepath.Join(basePath, torrents[0].AccessKey) responses = append(responses, dav.Directory(currentPath)) finalName := make(map[string]bool) diff --git a/internal/http/listing.go b/internal/http/listing.go index f641062..24746a3 100644 --- a/internal/http/listing.go +++ b/internal/http/listing.go @@ -14,8 +14,7 @@ import ( ) func HandleDirectoryListing(w http.ResponseWriter, r *http.Request, t *torrent.TorrentManager, c config.ConfigInterface, cache *expirable.LRU[string, string]) { - rlog := logutil.NewLogger() - log := rlog.Named("http") + log := logutil.NewLogger().Named("http") requestPath := path.Clean(r.URL.Path) diff --git a/internal/http/response.go b/internal/http/response.go index f488257..13e0ec9 100644 --- a/internal/http/response.go +++ b/internal/http/response.go @@ -18,13 +18,13 @@ func createMultiTorrentResponse(basePath string, torrents []torrent.Torrent) (st if item.Progress != 100 { continue } - if _, exists := seen[item.Name]; exists { + if _, exists := seen[item.AccessKey]; exists { continue } - seen[item.Name] = true + seen[item.AccessKey] = true - path := filepath.Join(basePath, url.PathEscape(item.Name)) - htmlDoc += fmt.Sprintf("
  • %s
  • ", path, item.Name) + path := filepath.Join(basePath, url.PathEscape(item.AccessKey)) + htmlDoc += fmt.Sprintf("
  • %s
  • ", path, item.AccessKey) } return htmlDoc, nil diff --git a/internal/net/router.go b/internal/net/router.go index 1c5f4fc..08cd26b 100644 --- a/internal/net/router.go +++ b/internal/net/router.go @@ -16,8 +16,7 @@ import ( // Router creates a WebDAV router func Router(mux *http.ServeMux, c config.ConfigInterface, t *torrent.TorrentManager, cache *expirable.LRU[string, string]) { - rlog := logutil.NewLogger() - log := rlog.Named("net") + log := logutil.NewLogger().Named("net") mux.HandleFunc("/http/", func(w http.ResponseWriter, r *http.Request) { switch r.Method { diff --git a/internal/torrent/hooks.go b/internal/torrent/hooks.go index 9015f24..d149f07 100644 --- a/internal/torrent/hooks.go +++ b/internal/torrent/hooks.go @@ -30,8 +30,7 @@ func (se *ScriptExecutor) Execute() (string, error) { } func OnLibraryUpdateHook(config config.ConfigInterface) { - rlog := logutil.NewLogger() - log := rlog.Named("hooks") + log := logutil.NewLogger().Named("hooks") executor := &ScriptExecutor{ Script: config.GetOnLibraryUpdate(), diff --git a/internal/torrent/manager.go b/internal/torrent/manager.go index 3b3b2aa..e6b0780 100644 --- a/internal/torrent/manager.go +++ b/internal/torrent/manager.go @@ -18,7 +18,9 @@ import ( type TorrentManager struct { requiredVersion string + rd *realdebrid.RealDebrid torrents []Torrent + torrentMap map[string]*Torrent inProgress []string checksum string config config.ConfigInterface @@ -35,7 +37,9 @@ type TorrentManager struct { // and store them in-memory; it is called only once at startup func NewTorrentManager(config config.ConfigInterface, cache *expirable.LRU[string, string]) *TorrentManager { t := &TorrentManager{ - requiredVersion: fmt.Sprintf("4.11.2023 - retain:%v", config.EnableRetainFolderNameExtension()), + requiredVersion: fmt.Sprintf("8.11.2023 - retain:%v", config.EnableRetainFolderNameExtension()), + rd: realdebrid.NewRealDebrid(config.GetToken(), logutil.NewLogger().Named("realdebrid")), + torrentMap: make(map[string]*Torrent), config: config, cache: cache, workerPool: make(chan bool, config.GetNumOfWorkers()), @@ -45,32 +49,49 @@ func NewTorrentManager(config config.ConfigInterface, cache *expirable.LRU[strin log: logutil.NewLogger().Named("manager"), } - // Initialize torrents for the first time + // start with a clean slate t.mu.Lock() - t.torrents = t.getFreshListFromAPI() - t.checksum = t.getChecksum() - t.mu.Unlock() + t.cache.Purge() + t.torrents = nil - // log.Println("First checksum", t.checksum) + newTorrents, _, err := t.rd.GetTorrents(0) + if err != nil { + t.log.Fatalf("Cannot get torrents: %v\n", err) + } + torrentsChan := make(chan *Torrent, len(newTorrents)) var wg sync.WaitGroup - - for i := range t.torrents { + for i := range newTorrents { wg.Add(1) go func(idx int) { defer wg.Done() t.workerPool <- true - t.addMoreInfo(&t.torrents[idx]) + torrentsChan <- t.getMoreInfo(newTorrents[idx]) <-t.workerPool }(i) } + wg.Wait() + close(torrentsChan) + for newTorrent := range torrentsChan { + if newTorrent == nil { + continue + } + t.torrents = append(t.torrents, *newTorrent) + if _, exists := t.torrentMap[newTorrent.AccessKey]; exists { + t.torrentMap[newTorrent.AccessKey].Files = newTorrent.Files + t.torrentMap[newTorrent.AccessKey].Links = newTorrent.Links + t.torrentMap[newTorrent.AccessKey].SelectedFiles = newTorrent.SelectedFiles + t.torrentMap[newTorrent.AccessKey].ForRepair = newTorrent.ForRepair + } else { + t.torrentMap[newTorrent.AccessKey] = newTorrent + } + } + t.checksum = t.getChecksum() + t.mu.Unlock() if t.config.EnableRepair() { - go t.repairAll(&wg) + go t.repairAll() } - - wg.Wait() - t.mapToDirectories() go t.startRefreshJob() return t @@ -80,7 +101,7 @@ func NewTorrentManager(config config.ConfigInterface, cache *expirable.LRU[strin func (t *TorrentManager) GetByDirectory(directory string) []Torrent { var torrents []Torrent for i := range t.torrents { - for _, dir := range t.directoryMap[t.torrents[i].Name] { + for _, dir := range t.directoryMap[t.torrents[i].AccessKey] { if dir == directory { torrents = append(torrents, t.torrents[i]) } @@ -100,13 +121,18 @@ func (t *TorrentManager) FindAllTorrentsWithName(directory, torrentName string) var matchingTorrents []Torrent torrents := t.GetByDirectory(directory) for i := range torrents { - if torrents[i].Name == torrentName || strings.HasPrefix(torrents[i].Name, torrentName) { + if torrents[i].AccessKey == torrentName || strings.Contains(torrents[i].AccessKey, torrentName) { matchingTorrents = append(matchingTorrents, torrents[i]) } } return matchingTorrents } +// proxy +func (t *TorrentManager) UnrestrictUntilOk(link string) *realdebrid.UnrestrictResponse { + return t.rd.UnrestrictUntilOk(link) +} + // findAllDownloadedFilesFromHash finds all files that were with a given hash func (t *TorrentManager) findAllDownloadedFilesFromHash(hash string) []File { var files []File @@ -134,7 +160,7 @@ func (t *TorrentManager) getChecksum() string { // GetTorrents request go func() { - torrents, totalCount, err := realdebrid.GetTorrents(t.config.GetToken(), 1) + torrents, totalCount, err := t.rd.GetTorrents(1) if err != nil { errChan <- err return @@ -144,7 +170,7 @@ func (t *TorrentManager) getChecksum() string { // GetActiveTorrentCount request go func() { - count, err := realdebrid.GetActiveTorrentCount(t.config.GetToken()) + count, err := t.rd.GetActiveTorrentCount() if err != nil { errChan <- err return @@ -186,92 +212,79 @@ func (t *TorrentManager) startRefreshJob() { if checksum == t.checksum { continue } + + t.mu.Lock() t.cache.Purge() + t.torrents = nil - newTorrents := t.getFreshListFromAPI() + newTorrents, _, err := t.rd.GetTorrents(0) + if err != nil { + t.log.Errorf("Cannot get torrents: %v\n", err) + continue + } + + torrentsChan := make(chan *Torrent) var wg sync.WaitGroup - for i := range newTorrents { wg.Add(1) go func(idx int) { defer wg.Done() + t.log.Debug(newTorrents[idx].ID) t.workerPool <- true - t.addMoreInfo(&newTorrents[idx]) + torrentsChan <- t.getMoreInfo(newTorrents[idx]) <-t.workerPool }(i) } wg.Wait() - - // apply side effects - t.mu.Lock() - t.torrents = newTorrents + close(torrentsChan) + for newTorrent := range torrentsChan { + if newTorrent == nil { + continue + } + t.torrents = append(t.torrents, *newTorrent) + if _, exists := t.torrentMap[newTorrent.AccessKey]; exists { + t.torrentMap[newTorrent.AccessKey].Files = newTorrent.Files + t.torrentMap[newTorrent.AccessKey].Links = newTorrent.Links + t.torrentMap[newTorrent.AccessKey].SelectedFiles = newTorrent.SelectedFiles + t.torrentMap[newTorrent.AccessKey].ForRepair = newTorrent.ForRepair + } else { + t.torrentMap[newTorrent.AccessKey] = newTorrent + } + } t.checksum = t.getChecksum() t.mu.Unlock() - // log.Println("Checksum changed", t.checksum) if t.config.EnableRepair() { - go t.repairAll(&wg) + go t.repairAll() } - go t.mapToDirectories() go OnLibraryUpdateHook(t.config) } } -// getFreshListFromAPI returns all torrents -func (t *TorrentManager) getFreshListFromAPI() []Torrent { - torrents, _, err := realdebrid.GetTorrents(t.config.GetToken(), 0) - if err != nil { - t.log.Errorf("Cannot get torrents: %v\n", err) - return nil - } - - // convert to own internal type without SelectedFiles yet - // populate inProgress - var torrentsV2 []Torrent - t.inProgress = t.inProgress[:0] // reset - for _, torrent := range torrents { - torrent.Name = strings.TrimSuffix(torrent.Name, "/") - torrentV2 := Torrent{ - Torrent: torrent, - SelectedFiles: nil, - ForRepair: false, - lock: &sync.Mutex{}, - } - torrentsV2 = append(torrentsV2, torrentV2) - - if torrent.Progress != 100 { - t.inProgress = append(t.inProgress, torrent.Hash) - } - } - - t.log.Infof("Fetched %d torrents", len(torrentsV2)) - return torrentsV2 -} - -// addMoreInfo updates the selected files for a torrent -func (t *TorrentManager) addMoreInfo(torrent *Torrent) { +// getMoreInfo updates the selected files for a torrent +func (t *TorrentManager) getMoreInfo(rdTorrent realdebrid.Torrent) *Torrent { + t.log.Info("Getting more info for", rdTorrent.ID) // file cache - torrentFromFile := t.readFromFile(torrent.ID) + torrentFromFile := t.readFromFile(rdTorrent.ID) if torrentFromFile != nil { // see if api data and file data still match // then it means data is still usable - if len(torrentFromFile.Links) == len(torrent.Links) { - torrent.Name = t.getName(torrentFromFile) - torrent.ForRepair = torrentFromFile.ForRepair - torrent.SelectedFiles = torrentFromFile.SelectedFiles[:] - return + if len(torrentFromFile.Links) == len(rdTorrent.Links) { + return torrentFromFile } } - // no file data yet as it is still downloading - if torrent.Progress != 100 { - return + t.log.Debug("Getting info for", rdTorrent.ID) + info, err := t.rd.GetTorrentInfo(rdTorrent.ID) + if err != nil { + t.log.Errorf("Cannot get info for id=%s: %v\n", rdTorrent.ID, err) + return nil } - // t.log.Println("Getting info for", torrent.ID) - info, err := realdebrid.GetTorrentInfo(t.config.GetToken(), torrent.ID) - if err != nil { - t.log.Errorf("Cannot get info: %v\n", err) - return + torrent := Torrent{ + Version: t.requiredVersion, + Torrent: *info, + SelectedFiles: nil, + ForRepair: false, } // SelectedFiles is a subset of Files with only the selected ones @@ -321,23 +334,25 @@ func (t *TorrentManager) addMoreInfo(torrent *Torrent) { selectedFiles[i].Link = link } } + + torrent.ForRepair = forRepair + torrent.SelectedFiles = selectedFiles + torrent.AccessKey = t.getName(info.Name, info.OriginalName) + // update file cache - torrent.OriginalName = info.OriginalName - torrent.Name = t.getName(torrent) if len(selectedFiles) > 0 { - // update the torrent with more data! - torrent.SelectedFiles = selectedFiles - torrent.ForRepair = forRepair - t.writeToFile(torrent) + t.writeToFile(&torrent) } + t.log.Debugf("Got info for %s %s", torrent.ID, torrent.AccessKey) + return &torrent } -func (t *TorrentManager) getName(torrent *Torrent) string { +func (t *TorrentManager) getName(name, originalName string) string { // drop the extension from the name - if t.config.EnableRetainFolderNameExtension() && strings.Contains(torrent.Name, torrent.OriginalName) { - return torrent.Name + if t.config.EnableRetainFolderNameExtension() && strings.Contains(name, originalName) { + return name } else { - ret := strings.TrimSuffix(torrent.OriginalName, ".mp4") + ret := strings.TrimSuffix(originalName, ".mp4") ret = strings.TrimSuffix(ret, ".mkv") return ret } @@ -357,7 +372,7 @@ func (t *TorrentManager) mapToDirectories() { for i := range t.torrents { // don't process torrents that are already mapped if it is not the first run alreadyMappedToGroup := false - for _, mappedGroup := range t.processedTorrents[t.torrents[i].Name] { + for _, mappedGroup := range t.processedTorrents[t.torrents[i].AccessKey] { if mappedGroup == group { alreadyMappedToGroup = true } @@ -371,10 +386,10 @@ func (t *TorrentManager) mapToDirectories() { for _, file := range t.torrents[i].SelectedFiles { filenames = append(filenames, file.Path) } - if configV1.MeetsConditions(directory, t.torrents[i].ID, t.torrents[i].Name, filenames) { + if configV1.MeetsConditions(directory, t.torrents[i].ID, t.torrents[i].AccessKey, filenames) { found := false // check if it is already mapped to this directory - for _, dir := range t.directoryMap[t.torrents[i].Name] { + for _, dir := range t.directoryMap[t.torrents[i].AccessKey] { if dir == directory { found = true break // it is already mapped to this directory @@ -383,14 +398,14 @@ func (t *TorrentManager) mapToDirectories() { if !found { counter[directory]++ t.mu.Lock() - t.directoryMap[t.torrents[i].Name] = append(t.directoryMap[t.torrents[i].Name], directory) + t.directoryMap[t.torrents[i].AccessKey] = append(t.directoryMap[t.torrents[i].AccessKey], directory) t.mu.Unlock() break // we found a directory for this torrent, so we can stop looking for more } } } t.mu.Lock() - t.processedTorrents[t.torrents[i].Name] = append(t.processedTorrents[t.torrents[i].Name], group) + t.processedTorrents[t.torrents[i].AccessKey] = append(t.processedTorrents[t.torrents[i].AccessKey], group) t.mu.Unlock() } sum := 0 @@ -408,6 +423,33 @@ func (t *TorrentManager) mapToDirectories() { } } +func (t *TorrentManager) getDirectories(torrent *Torrent) []string { + var ret []string + // Map torrents to directories + switch t.config.GetVersion() { + case "v1": + configV1 := t.config.(*config.ZurgConfigV1) + groupMap := configV1.GetGroupMap() + // for every group, iterate over every torrent + // and then sprinkle/distribute the torrents to the directories of the group + for _, directories := range groupMap { + for _, directory := range directories { + var filenames []string + for _, file := range torrent.SelectedFiles { + filenames = append(filenames, file.Path) + } + if configV1.MeetsConditions(directory, torrent.ID, torrent.AccessKey, filenames) { + ret = append(ret, directory) + break // we found a directory for this torrent for this group, so we can stop looking for more + } + } + } + default: + t.log.Error("Unknown config version") + } + return ret +} + // getByID returns a torrent by its ID func (t *TorrentManager) getByID(torrentID string) *Torrent { for i := range t.torrents { @@ -463,19 +505,18 @@ func (t *TorrentManager) readFromFile(torrentID string) *Torrent { return &torrent } -func (t *TorrentManager) repairAll(wg *sync.WaitGroup) { - wg.Wait() +func (t *TorrentManager) repairAll() { for _, torrent := range t.torrents { if torrent.ForRepair { - t.log.Infof("Issues were detected on %s %s; fixing...", torrent.ID, torrent.Name) + t.log.Infof("There were less links than was expected on %s %s; fixing...", torrent.ID, torrent.AccessKey) t.repair(torrent.ID, torrent.SelectedFiles, true) } if len(torrent.Links) == 0 && torrent.Progress == 100 { // If the torrent has no links // and already processing repair // delete it! - t.log.Infof("Deleting broken torrent %s %s as it doesn't contain any files", torrent.ID, torrent.Name) - realdebrid.DeleteTorrent(t.config.GetToken(), torrent.ID) + t.log.Infof("Deleting broken torrent %s %s as it doesn't contain any files", torrent.ID, torrent.AccessKey) + t.rd.DeleteTorrent(torrent.ID) } } } @@ -518,12 +559,12 @@ func (t *TorrentManager) repair(torrentID string, selectedFiles []File, tryReins } } if len(missingFiles) == 0 { - t.log.Infof("Torrent %s %s is already repaired", torrent.ID, torrent.Name) + t.log.Infof("Torrent %s %s is already repaired", torrent.ID, torrent.AccessKey) return } // then we repair it! - t.log.Infof("Repairing torrent %s %s", torrent.ID, torrent.Name) + t.log.Infof("Repairing torrent %s %s", torrent.ID, torrent.AccessKey) // check if we can still add more downloads proceed := t.canCapacityHandle() if !proceed { @@ -573,7 +614,7 @@ func (t *TorrentManager) repair(torrentID string, selectedFiles []File, tryReins t.log.Info("No other missing files left to reinsert") } } else { - t.log.Infof("Torrent %s %s is unfixable as the only link cached in RD is already broken", torrent.ID, torrent.Name) + t.log.Infof("Torrent %s %s is unfixable as the only link cached in RD is already broken", torrent.ID, torrent.AccessKey) t.log.Debugf("You can try fixing it yourself magnet:?xt=urn:btih:%s", torrent.Hash) return } @@ -584,7 +625,7 @@ func (t *TorrentManager) repair(torrentID string, selectedFiles []File, tryReins func (t *TorrentManager) reinsertTorrent(torrent *Torrent, missingFiles string, deleteIfFailed bool) bool { // if missingFiles is not provided, look for missing files if missingFiles == "" { - t.log.Info("Redownloading whole torrent", torrent.Name) + t.log.Info("Redownloading whole torrent", torrent.AccessKey) var selection string for _, file := range torrent.SelectedFiles { selection += fmt.Sprintf("%d,", file.ID) @@ -598,29 +639,29 @@ func (t *TorrentManager) reinsertTorrent(torrent *Torrent, missingFiles string, } // redownload torrent - resp, err := realdebrid.AddMagnetHash(t.config.GetToken(), torrent.Hash) + resp, err := t.rd.AddMagnetHash(torrent.Hash) if err != nil { t.log.Errorf("Cannot redownload torrent: %v", err) return false } newTorrentID := resp.ID - err = realdebrid.SelectTorrentFiles(t.config.GetToken(), newTorrentID, missingFiles) + err = t.rd.SelectTorrentFiles(newTorrentID, missingFiles) if err != nil { t.log.Errorf("Cannot start redownloading: %v", err) } if deleteIfFailed { if err != nil { - realdebrid.DeleteTorrent(t.config.GetToken(), newTorrentID) + t.rd.DeleteTorrent(newTorrentID) return false } time.Sleep(1 * time.Second) // see if the torrent is ready - info, err := realdebrid.GetTorrentInfo(t.config.GetToken(), newTorrentID) + info, err := t.rd.GetTorrentInfo(newTorrentID) if err != nil { t.log.Errorf("Cannot get info on redownloaded torrent: %v", err) if deleteIfFailed { - realdebrid.DeleteTorrent(t.config.GetToken(), newTorrentID) + t.rd.DeleteTorrent(newTorrentID) } return false } @@ -628,17 +669,17 @@ func (t *TorrentManager) reinsertTorrent(torrent *Torrent, missingFiles string, if info.Progress != 100 { t.log.Infof("Torrent is not cached anymore so we have to wait until completion, currently %d%%", info.Progress) - realdebrid.DeleteTorrent(t.config.GetToken(), newTorrentID) + t.rd.DeleteTorrent(newTorrentID) return false } if len(info.Links) != len(torrent.SelectedFiles) { t.log.Infof("It didn't fix the issue, only got %d files but we need %d, undoing", len(info.Links), len(torrent.SelectedFiles)) - realdebrid.DeleteTorrent(t.config.GetToken(), newTorrentID) + t.rd.DeleteTorrent(newTorrentID) return false } t.log.Info("Redownload successful, deleting old torrent") - realdebrid.DeleteTorrent(t.config.GetToken(), torrent.ID) + t.rd.DeleteTorrent(torrent.ID) } return true } @@ -652,43 +693,43 @@ func (t *TorrentManager) organizeChaos(info *realdebrid.Torrent, selectedFiles [ var wg sync.WaitGroup // Limit concurrency - sem := make(chan struct{}, t.config.GetNumOfWorkers()) + sem := make(chan bool, t.config.GetNumOfWorkers()) for _, link := range info.Links { wg.Add(1) - sem <- struct{}{} + sem <- true go func(lnk string) { defer wg.Done() defer func() { <-sem }() - - unrestrictFn := func() (*realdebrid.UnrestrictResponse, error) { - return realdebrid.UnrestrictCheck(t.config.GetToken(), lnk) - } - resp := realdebrid.RetryUntilOk(unrestrictFn) - if resp != nil { - resultsChan <- Result{Response: resp} - } + resp := t.rd.UnrestrictUntilOk(lnk) + resultsChan <- Result{Response: resp} }(link) } go func() { + t.log.Debugf("Checking %d link(s) for problematic torrent id=%s", len(info.Links), info.ID) wg.Wait() close(sem) close(resultsChan) + t.log.Debugf("Closing results channel for torrent id=%s, checking...", info.ID) }() isChaotic := false for result := range resultsChan { + if result.Response == nil { + continue + } found := false for i := range selectedFiles { - if strings.HasSuffix(selectedFiles[i].Path, result.Response.Filename) { + if strings.Contains(selectedFiles[i].Path, result.Response.Filename) { + t.log.Debugf("Found a file that is in the selection for torrent id=%s: %s", info.ID, result.Response.Filename) selectedFiles[i].Link = result.Response.Link found = true } } if !found { - // "chaos" file, we don't know where it belongs - isChaotic = !isStreamable(result.Response.Filename) + isChaotic = result.Response.Streamable == 0 + t.log.Debugf("Found a file that is not in the selection for torrent id=%s: %s %v", info.ID, result.Response.Filename, result.Response.Streamable) selectedFiles = append(selectedFiles, File{ File: realdebrid.File{ Path: result.Response.Filename, @@ -710,7 +751,7 @@ func (t *TorrentManager) canCapacityHandle() bool { const maxDelay = 60 * time.Second retryCount := 0 for { - count, err := realdebrid.GetActiveTorrentCount(t.config.GetToken()) + count, err := t.rd.GetActiveTorrentCount() if err != nil { t.log.Errorf("Cannot get active downloads count: %v", err) if retryCount >= maxRetries { diff --git a/internal/torrent/types.go b/internal/torrent/types.go index 37b0156..b5dfb42 100644 --- a/internal/torrent/types.go +++ b/internal/torrent/types.go @@ -1,17 +1,15 @@ package torrent import ( - "sync" - "github.com/debridmediamanager.com/zurg/pkg/realdebrid" ) type Torrent struct { - Version string + AccessKey string + Version string realdebrid.Torrent SelectedFiles []File ForRepair bool - lock *sync.Mutex } type File struct { diff --git a/internal/universal/get.go b/internal/universal/get.go index 62f52ae..d7524ba 100644 --- a/internal/universal/get.go +++ b/internal/universal/get.go @@ -14,15 +14,13 @@ import ( intHttp "github.com/debridmediamanager.com/zurg/internal/http" "github.com/debridmediamanager.com/zurg/internal/torrent" "github.com/debridmediamanager.com/zurg/pkg/logutil" - "github.com/debridmediamanager.com/zurg/pkg/realdebrid" "github.com/hashicorp/golang-lru/v2/expirable" "go.uber.org/zap" ) // HandleGetRequest handles a GET request universally for both WebDAV and HTTP func HandleGetRequest(w http.ResponseWriter, r *http.Request, t *torrent.TorrentManager, c config.ConfigInterface, cache *expirable.LRU[string, string]) { - rlog := logutil.NewLogger() - log := rlog.Named("uniget") + log := logutil.NewLogger().Named("uniget") requestPath := path.Clean(r.URL.Path) isDav := true @@ -44,7 +42,7 @@ func HandleGetRequest(w http.ResponseWriter, r *http.Request, t *torrent.Torrent return } if data, exists := cache.Get(requestPath); exists { - streamFileToResponse(data, w, r, c, log) + streamFileToResponse(data, w, r, t, c, log) return } @@ -68,43 +66,40 @@ func HandleGetRequest(w http.ResponseWriter, r *http.Request, t *torrent.Torrent if file.Link == "" { // This is a dead file, serve an alternate file log.Errorf("File %s is no longer available", filename) - streamErrorVideo("https://www.youtube.com/watch?v=bGTqwt6vdcY", w, r, c, log) + streamErrorVideo("https://www.youtube.com/watch?v=bGTqwt6vdcY", w, r, t, c, log) return } link := file.Link - unrestrictFn := func() (*realdebrid.UnrestrictResponse, error) { - return realdebrid.UnrestrictLink(c.GetToken(), link) - } - resp := realdebrid.RetryUntilOk(unrestrictFn) + resp := t.UnrestrictUntilOk(link) if resp == nil { if !file.Unavailable { log.Errorf("Cannot unrestrict file %s %s", filename, link) t.HideTheFile(torrent, file) } - streamErrorVideo("https://www.youtube.com/watch?v=gea_FJrtFVA", w, r, c, log) + streamErrorVideo("https://www.youtube.com/watch?v=gea_FJrtFVA", w, r, t, c, log) return } else if resp.Filename != filename { actualExt := filepath.Ext(resp.Filename) expectedExt := filepath.Ext(filename) if actualExt != expectedExt && resp.Streamable != 1 { log.Errorf("File extension mismatch: %s and %s", filename, resp.Filename) - streamErrorVideo("https://www.youtube.com/watch?v=t9VgOriBHwE", w, r, c, log) + streamErrorVideo("https://www.youtube.com/watch?v=t9VgOriBHwE", w, r, t, c, log) return } else { log.Errorf("Filename mismatch: %s and %s", filename, resp.Filename) } } cache.Add(requestPath, resp.Download) - streamFileToResponse(resp.Download, w, r, c, log) + streamFileToResponse(resp.Download, w, r, t, c, log) } -func streamFileToResponse(url string, w http.ResponseWriter, r *http.Request, c config.ConfigInterface, log *zap.SugaredLogger) { +func streamFileToResponse(url string, w http.ResponseWriter, r *http.Request, t *torrent.TorrentManager, c config.ConfigInterface, log *zap.SugaredLogger) { // Create a new request for the file download. req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { log.Errorf("Error creating new request: %v", err) - streamErrorVideo("https://www.youtube.com/watch?v=H3NSrObyAxM", w, r, c, log) + streamErrorVideo("https://www.youtube.com/watch?v=H3NSrObyAxM", w, r, t, c, log) return } @@ -154,19 +149,16 @@ func streamFileToResponse(url string, w http.ResponseWriter, r *http.Request, c // Perform the operation with the retry policy. if err := backoff.Retry(operation, retryPolicy); err != nil { log.Errorf("Failed after retries: %v", err) - streamErrorVideo("https://www.youtube.com/watch?v=FSSd8cponAA", w, r, c, log) + streamErrorVideo("https://www.youtube.com/watch?v=FSSd8cponAA", w, r, t, c, log) return } } -func streamErrorVideo(link string, w http.ResponseWriter, r *http.Request, c config.ConfigInterface, log *zap.SugaredLogger) { - unrestrictFn := func() (*realdebrid.UnrestrictResponse, error) { - return realdebrid.UnrestrictLink(c.GetToken(), link) - } - resp := realdebrid.RetryUntilOk(unrestrictFn) +func streamErrorVideo(link string, w http.ResponseWriter, r *http.Request, t *torrent.TorrentManager, c config.ConfigInterface, log *zap.SugaredLogger) { + resp := t.UnrestrictUntilOk(link) if resp == nil { http.Error(w, "REAL-DEBRID IS DOWN", http.StatusInternalServerError) return } - streamFileToResponse(resp.Download, w, r, c, log) + streamFileToResponse(resp.Download, w, r, t, c, log) } diff --git a/internal/universal/head.go b/internal/universal/head.go index 28d3803..65211fe 100644 --- a/internal/universal/head.go +++ b/internal/universal/head.go @@ -14,8 +14,7 @@ import ( ) func HandleHeadRequest(w http.ResponseWriter, r *http.Request, t *torrent.TorrentManager, c config.ConfigInterface, cache *expirable.LRU[string, string]) { - rlog := logutil.NewLogger() - log := rlog.Named("unihead") + log := logutil.NewLogger().Named("unihead") requestPath := path.Clean(r.URL.Path) requestPath = strings.Replace(requestPath, "/http", "", 1) diff --git a/pkg/http/client.go b/pkg/http/client.go new file mode 100644 index 0000000..50b65ad --- /dev/null +++ b/pkg/http/client.go @@ -0,0 +1,51 @@ +package http + +import ( + "net/http" + "time" +) + +type HTTPClient struct { + Client *http.Client + MaxRetries int + Backoff func(attempt int) time.Duration + CheckRespStatus func(resp *http.Response, err error) bool + BearerToken string +} + +func (r *HTTPClient) Do(req *http.Request) (*http.Response, error) { + if r.BearerToken != "" { + req.Header.Set("Authorization", "Bearer "+r.BearerToken) + } + var resp *http.Response + var err error + for attempt := 0; attempt < r.MaxRetries; attempt++ { + resp, err = r.Client.Do(req) + if !r.CheckRespStatus(resp, err) { + return resp, err + } + time.Sleep(r.Backoff(attempt)) + } + return resp, err +} + +func NewHTTPClient(token string, maxRetries int, timeout time.Duration) *HTTPClient { + return &HTTPClient{ + BearerToken: token, + Client: &http.Client{Timeout: timeout}, + MaxRetries: maxRetries, + Backoff: func(attempt int) time.Duration { + return time.Duration(attempt) * time.Second + }, + CheckRespStatus: func(resp *http.Response, err error) bool { + if err != nil { + return true + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return true + } + // no need to retry because the status code is 2XX + return false + }, + } +} diff --git a/pkg/realdebrid/api.go b/pkg/realdebrid/api.go index 3d765b2..f808f64 100644 --- a/pkg/realdebrid/api.go +++ b/pkg/realdebrid/api.go @@ -3,94 +3,77 @@ package realdebrid import ( "bytes" "encoding/json" - "errors" "fmt" "io" "net/http" "net/url" "strconv" + "strings" + "time" + + zurghttp "github.com/debridmediamanager.com/zurg/pkg/http" + "go.uber.org/zap" ) -func UnrestrictCheck(accessToken, link string) (*UnrestrictResponse, error) { +type RealDebrid struct { + log *zap.SugaredLogger + client *zurghttp.HTTPClient +} + +func NewRealDebrid(accessToken string, log *zap.SugaredLogger) *RealDebrid { + maxRetries := 10 + timeout := 10 * time.Second + client := zurghttp.NewHTTPClient(accessToken, maxRetries, timeout) + log.Debugf("Created an HTTP client with %d max retries and %s timeout", maxRetries, timeout) + return &RealDebrid{ + log: log, + client: client, + } +} + +func (rd *RealDebrid) UnrestrictCheck(link string) (*UnrestrictResponse, error) { data := url.Values{} data.Set("link", link) req, err := http.NewRequest("POST", "https://api.real-debrid.com/rest/1.0/unrestrict/check", bytes.NewBufferString(data.Encode())) if err != nil { + rd.log.Errorf("Error when creating a unrestrict check request: %v", err) return nil, err } - - req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - client := &http.Client{} - resp, err := client.Do(req) + resp, err := rd.client.Do(req) if err != nil { + rd.log.Errorf("Error when executing the unrestrict check request: %v", err) return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { + rd.log.Errorf("Error when reading the body of unrestrict check response: %v", err) return nil, err } - if resp.StatusCode != http.StatusOK { + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + rd.log.Errorf("Received a %s from unrestrict check endpoint", resp.Status) return nil, fmt.Errorf("HTTP error: %s", resp.Status) } var response UnrestrictResponse err = json.Unmarshal(body, &response) if err != nil { + rd.log.Errorf("Error when decoding unrestrict check JSON: %v", err) return nil, err } - return &response, nil -} - -func UnrestrictLink(accessToken, link string) (*UnrestrictResponse, error) { - data := url.Values{} - data.Set("link", link) - - req, err := http.NewRequest("POST", "https://api.real-debrid.com/rest/1.0/unrestrict/link", bytes.NewBufferString(data.Encode())) - if err != nil { - return nil, err - } - - req.Header.Set("Authorization", "Bearer "+accessToken) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP error: %s", resp.Status) - } - - var response UnrestrictResponse - err = json.Unmarshal(body, &response) - if err != nil { - return nil, err - } - - if !canFetchFirstByte(response.Download) { - return nil, fmt.Errorf("can't fetch first byte") - } + rd.log.Info("Link %s is streamable? %v", response.Streamable) return &response, nil } // GetTorrents returns all torrents, paginated // if customLimit is 0, the default limit of 2500 is used -func GetTorrents(accessToken string, customLimit int) ([]Torrent, int, error) { +func (rd *RealDebrid) GetTorrents(customLimit int) ([]Torrent, int, error) { baseURL := "https://api.real-debrid.com/rest/1.0/torrents" var allTorrents []Torrent page := 1 @@ -110,19 +93,20 @@ func GetTorrents(accessToken string, customLimit int) ([]Torrent, int, error) { req, err := http.NewRequest("GET", reqURL, nil) if err != nil { + rd.log.Errorf("Error when creating a get torrents request: %v", err) return nil, 0, err } - req.Header.Set("Authorization", "Bearer "+accessToken) - - client := &http.Client{} - resp, err := client.Do(req) + resp, err := rd.client.Do(req) if err != nil { + rd.log.Errorf("Error when executing the get torrents request: %v", err) return nil, 0, err } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { + // if status code is not 2xx, return error + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + rd.log.Errorf("Received a %s from get torrents endpoint", resp.Status) return nil, 0, fmt.Errorf("HTTP error: %s", resp.Status) } @@ -130,6 +114,7 @@ func GetTorrents(accessToken string, customLimit int) ([]Torrent, int, error) { decoder := json.NewDecoder(resp.Body) err = decoder.Decode(&torrents) if err != nil { + rd.log.Errorf("Error when decoding get torrents JSON: %v", err) return nil, 0, err } @@ -147,126 +132,106 @@ func GetTorrents(accessToken string, customLimit int) ([]Torrent, int, error) { page++ } - return allTorrents, totalCount, nil } -func GetTorrentInfo(accessToken, id string) (*Torrent, error) { +func (rd *RealDebrid) GetTorrentInfo(id string) (*Torrent, error) { url := "https://api.real-debrid.com/rest/1.0/torrents/info/" + id req, err := http.NewRequest("GET", url, nil) if err != nil { + rd.log.Errorf("Error when creating a get info request: %v", err) return nil, err } - req.Header.Set("Authorization", "Bearer "+accessToken) - - client := &http.Client{} - resp, err := client.Do(req) + resp, err := rd.client.Do(req) if err != nil { + rd.log.Errorf("Error when executing the get info request: %v", err) return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { + rd.log.Errorf("Error when reading the body of get info response: %v", err) return nil, err } - if resp.StatusCode != http.StatusOK { + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + rd.log.Errorf("Received a %s from get info endpoint", resp.Status) return nil, fmt.Errorf("HTTP error: %s", resp.Status) } var response Torrent err = json.Unmarshal(body, &response) if err != nil { + rd.log.Errorf("Error when : %v", err) return nil, err } + rd.log.Debugf("Fetched info for torrent id=%s", response.ID) return &response, nil } // SelectTorrentFiles selects files of a torrent to start it. -func SelectTorrentFiles(accessToken string, id string, files string) error { - // Prepare request data +func (rd *RealDebrid) SelectTorrentFiles(id string, files string) error { data := url.Values{} data.Set("files", files) - // Construct request URL reqURL := fmt.Sprintf("https://api.real-debrid.com/rest/1.0/torrents/selectFiles/%s", id) req, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(data.Encode())) if err != nil { + rd.log.Errorf("Error when creating a select files request: %v", err) return err } - // Set request headers - req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - // Send the request - client := &http.Client{} - resp, err := client.Do(req) + resp, err := rd.client.Do(req) if err != nil { + rd.log.Errorf("Error when executing the select files request: %v", err) return err } defer resp.Body.Close() - // Handle response status codes - switch resp.StatusCode { - case http.StatusOK, http.StatusNoContent: - return nil // Success - case http.StatusAccepted: - return errors.New("action already done") - case http.StatusBadRequest: - return errors.New("bad request") - case http.StatusUnauthorized: - return errors.New("bad token (expired or invalid)") - case http.StatusForbidden: - return errors.New("permission denied (account locked or not premium)") - case http.StatusNotFound: - return errors.New("wrong parameter (invalid file id(s)) or unknown resource (invalid id)") - default: - return fmt.Errorf("unexpected HTTP error: %s", resp.Status) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + rd.log.Errorf("Received a %s from select files endpoint", resp.Status) + return fmt.Errorf("HTTP error: %s", resp.Status) } + + rd.log.Debugf("Selected files %s for torrent id=%s", len(strings.Split(files, ",")), id) + return nil } // DeleteTorrent deletes a torrent from the torrents list. -func DeleteTorrent(accessToken string, id string) error { +func (rd *RealDebrid) DeleteTorrent(id string) error { // Construct request URL reqURL := fmt.Sprintf("https://api.real-debrid.com/rest/1.0/torrents/delete/%s", id) req, err := http.NewRequest("DELETE", reqURL, nil) if err != nil { + rd.log.Errorf("Error when creating a delete torrent request: %v", err) return err } - // Set request headers - req.Header.Set("Authorization", "Bearer "+accessToken) - // Send the request - client := &http.Client{} - resp, err := client.Do(req) + resp, err := rd.client.Do(req) if err != nil { + rd.log.Errorf("Error when executing the delete torrent request: %v", err) return err } defer resp.Body.Close() - // Handle response status codes - switch resp.StatusCode { - case http.StatusNoContent: - return nil // Success - case http.StatusUnauthorized: - return errors.New("bad token (expired or invalid)") - case http.StatusForbidden: - return errors.New("permission denied (account locked)") - case http.StatusNotFound: - return errors.New("unknown resource") - default: - return fmt.Errorf("unexpected HTTP error: %s", resp.Status) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + rd.log.Errorf("Received a %s from delete torrent endpoint", resp.Status) + return fmt.Errorf("HTTP error: %s", resp.Status) } + + rd.log.Debugf("Deleted torrent with id=%s", id) + return nil } // AddMagnetHash adds a magnet link to download. -func AddMagnetHash(accessToken, magnet string) (*MagnetResponse, error) { +func (rd *RealDebrid) AddMagnetHash(magnet string) (*MagnetResponse, error) { // Prepare request data data := url.Values{} data.Set("magnet", fmt.Sprintf("magnet:?xt=urn:btih:%s", magnet)) @@ -275,77 +240,110 @@ func AddMagnetHash(accessToken, magnet string) (*MagnetResponse, error) { reqURL := "https://api.real-debrid.com/rest/1.0/torrents/addMagnet" req, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(data.Encode())) if err != nil { + rd.log.Errorf("Error when creating an add magnet request: %v", err) return nil, err } - // Set request headers - req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // Send the request - client := &http.Client{} - resp, err := client.Do(req) + resp, err := rd.client.Do(req) if err != nil { + rd.log.Errorf("Error when executing the add magnet request: %v", err) return nil, err } defer resp.Body.Close() - // Handle response status codes - switch resp.StatusCode { - case http.StatusCreated: - var response MagnetResponse - err := json.NewDecoder(resp.Body).Decode(&response) - if err != nil { - return nil, err - } - return &response, nil - case http.StatusBadRequest: - return nil, errors.New("bad request") - case http.StatusUnauthorized: - return nil, errors.New("bad token (expired or invalid)") - case http.StatusForbidden: - return nil, errors.New("permission denied (account locked or not premium)") - case http.StatusServiceUnavailable: - return nil, errors.New("service unavailable") - default: - return nil, fmt.Errorf("unexpected HTTP error: %s", resp.Status) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + rd.log.Errorf("Received a %s from add magnet endpoint", resp.Status) + return nil, fmt.Errorf("HTTP error: %s", resp.Status) } + + var response MagnetResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + rd.log.Errorf("Error when decoding add magnet JSON: %v", err) + return nil, err + } + + rd.log.Debugf("Added magnet %s with id=%s", magnet, response.ID) + return &response, nil } // GetActiveTorrentCount gets the number of currently active torrents and the current maximum limit. -func GetActiveTorrentCount(accessToken string) (*ActiveTorrentCountResponse, error) { +func (rd *RealDebrid) GetActiveTorrentCount() (*ActiveTorrentCountResponse, error) { // Construct request URL reqURL := "https://api.real-debrid.com/rest/1.0/torrents/activeCount" req, err := http.NewRequest("GET", reqURL, nil) if err != nil { + rd.log.Errorf("Error when creating a active torrents request: %v", err) return nil, err } - // Set request headers - req.Header.Set("Authorization", "Bearer "+accessToken) - // Send the request - client := &http.Client{} - resp, err := client.Do(req) + resp, err := rd.client.Do(req) if err != nil { + rd.log.Errorf("Error when executing the active torrents request: %v", err) return nil, err } defer resp.Body.Close() - // Handle response status codes - switch resp.StatusCode { - case http.StatusOK: - var response ActiveTorrentCountResponse - err := json.NewDecoder(resp.Body).Decode(&response) - if err != nil { - return nil, err - } - return &response, nil - case http.StatusUnauthorized: - return nil, errors.New("bad token (expired or invalid)") - case http.StatusForbidden: - return nil, errors.New("permission denied (account locked)") - default: - return nil, fmt.Errorf("unexpected HTTP error: %s", resp.Status) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + rd.log.Errorf("Received a %s from active torrents endpoint", resp.Status) + return nil, fmt.Errorf("HTTP error: %s", resp.Status) } + + var response ActiveTorrentCountResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + rd.log.Errorf("Error when decoding active torrents JSON: %v", err) + return nil, err + } + return &response, nil +} + +func (rd *RealDebrid) UnrestrictLink(link string) (*UnrestrictResponse, error) { + data := url.Values{} + data.Set("link", link) + + req, err := http.NewRequest("POST", "https://api.real-debrid.com/rest/1.0/unrestrict/link", bytes.NewBufferString(data.Encode())) + if err != nil { + rd.log.Errorf("Error when creating a unrestrict link request: %v", err) + return nil, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + rd.log.Errorf("Error when executing the unrestrict link request: %v", err) + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + rd.log.Errorf("Error when reading the body of unrestrict link response: %v", err) + return nil, err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + rd.log.Errorf("Received a %s from unrestrict link endpoint", resp.Status) + return nil, fmt.Errorf("HTTP error: %s", resp.Status) + } + + var response UnrestrictResponse + err = json.Unmarshal(body, &response) + if err != nil { + rd.log.Errorf("Error when decoding unrestrict link JSON: %v", err) + return nil, err + } + + if !canFetchFirstByte(response.Download) { + return nil, fmt.Errorf("can't fetch first byte") + } + + rd.log.Debugf("Unrestricted link %s into %s", link, response.Download) + return &response, nil } diff --git a/pkg/realdebrid/types.go b/pkg/realdebrid/types.go index ca08b21..7f441b2 100644 --- a/pkg/realdebrid/types.go +++ b/pkg/realdebrid/types.go @@ -20,15 +20,16 @@ type UnrestrictResponse struct { } type Torrent struct { - ID string `json:"id"` - Name string `json:"filename"` - OriginalName string `json:"original_filename"` - Hash string `json:"hash"` - Progress int `json:"-"` - Added string `json:"added"` - Bytes int64 `json:"bytes"` - Links []string `json:"links"` - Files []File `json:"files,omitempty"` + ID string `json:"id"` + Name string `json:"filename"` + OriginalName string `json:"original_filename"` + Hash string `json:"hash"` + Progress int `json:"-"` + Added string `json:"added"` + Bytes int64 `json:"bytes"` + OriginalBytes int64 `json:"original_bytes"` + Links []string `json:"links"` + Files []File `json:"files,omitempty"` } func (t *Torrent) UnmarshalJSON(data []byte) error { diff --git a/pkg/realdebrid/util.go b/pkg/realdebrid/unrestrict.go similarity index 85% rename from pkg/realdebrid/util.go rename to pkg/realdebrid/unrestrict.go index e1b2f65..56f0d43 100644 --- a/pkg/realdebrid/util.go +++ b/pkg/realdebrid/unrestrict.go @@ -7,7 +7,14 @@ import ( "time" ) -func RetryUntilOk[T any](fn func() (T, error)) T { +func (rd *RealDebrid) UnrestrictUntilOk(link string) *UnrestrictResponse { + unrestrictFn := func() (*UnrestrictResponse, error) { + return rd.UnrestrictLink(link) + } + return retryUntilOk(unrestrictFn) +} + +func retryUntilOk[T any](fn func() (T, error)) T { const initialDelay = 1 * time.Second const maxDelay = 128 * time.Second for i := 0; ; i++ {