diff --git a/go.mod b/go.mod index 7ecc1cd..e7126c0 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.16 require ( github.com/anacrolix/torrent v1.29.1 - github.com/gdm85/go-libdeluge v0.5.4 + github.com/gdm85/go-libdeluge v0.5.5 github.com/go-chi/chi v1.5.4 github.com/gorilla/sessions v1.2.1 github.com/gorilla/websocket v1.4.2 diff --git a/go.sum b/go.sum index 331ba15..c6ae222 100644 --- a/go.sum +++ b/go.sum @@ -222,8 +222,12 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/gdm85/go-libdeluge v0.5.4 h1:Y2vV6wGwvR5skrFrlntTYAXxaWRzg2HDqrcWyVzuUgo= github.com/gdm85/go-libdeluge v0.5.4/go.mod h1:Fxm576GtD2fTcSUCSPqJINBZRkY8WrtGf9JfYVRtmD0= +github.com/gdm85/go-libdeluge v0.5.5 h1:vQ2wuphJVU8UaVS46gFH6VgpRZjyg9qSsXhxSkWoGcQ= +github.com/gdm85/go-libdeluge v0.5.5/go.mod h1:RYJ0orON2gNxGfh3VOpLDv7HFz3a0/14Rh1fCYNowT0= github.com/gdm85/go-rencode v0.1.6 h1:JbSv//2Og8aeSMUBMDTRNA6JW55iZbLMJU8bp9GqULY= github.com/gdm85/go-rencode v0.1.6/go.mod h1:0dr3BuaKzeseY1of6o1KRTGB/Oo7eio+YEyz8KDp5+s= +github.com/gdm85/go-rencode v0.1.8 h1:7+qxwoQWU1b1nMGcESOyoUR5dzPtRA6yLQpKn7uXmnI= +github.com/gdm85/go-rencode v0.1.8/go.mod h1:0dr3BuaKzeseY1of6o1KRTGB/Oo7eio+YEyz8KDp5+s= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= diff --git a/internal/action/deluge.go b/internal/action/deluge.go index 60796e8..a270124 100644 --- a/internal/action/deluge.go +++ b/internal/action/deluge.go @@ -13,14 +13,14 @@ import ( ) func (s *service) deluge(action domain.Action, torrentFile string) error { - log.Trace().Msgf("action DELUGE: %v", torrentFile) + log.Debug().Msgf("action Deluge: %v", action.Name) var err error // get client for action client, err := s.clientSvc.FindByID(action.ClientID) if err != nil { - log.Error().Err(err).Msgf("error finding client: %v", action.ClientID) + log.Error().Stack().Err(err).Msgf("error finding client: %v", action.ClientID) return err } @@ -39,23 +39,109 @@ func (s *service) deluge(action domain.Action, torrentFile string) error { switch client.Type { case "DELUGE_V1": - err = delugeV1(settings, action, torrentFile) + if err = delugeV1(client, settings, action, torrentFile); err != nil { + return err + } case "DELUGE_V2": - err = delugeV2(settings, action, torrentFile) + if err = delugeV2(client, settings, action, torrentFile); err != nil { + return err + } } - return err + return nil } -func delugeV1(settings delugeClient.Settings, action domain.Action, torrentFile string) error { +func (s *service) delugeCheckRulesCanDownload(action domain.Action) (bool, error) { + log.Trace().Msgf("action Deluge: %v check rules", action.Name) + + // get client for action + client, err := s.clientSvc.FindByID(action.ClientID) + if err != nil { + log.Error().Stack().Err(err).Msgf("error finding client: %v ID %v", action.Name, action.ClientID) + return false, err + } + + if client == nil { + return false, errors.New("no client found") + } + + settings := delugeClient.Settings{ + Hostname: client.Host, + Port: uint(client.Port), + Login: client.Username, + Password: client.Password, + DebugServerResponses: true, + ReadWriteTimeout: time.Second * 20, + } + var deluge delugeClient.DelugeClient + + switch client.Type { + case "DELUGE_V1": + deluge = delugeClient.NewV1(settings) + + case "DELUGE_V2": + deluge = delugeClient.NewV2(settings) + } + + // perform connection to Deluge server + err = deluge.Connect() + if err != nil { + log.Error().Stack().Err(err).Msgf("error logging into client: %v %v", client.Name, client.Host) + return false, err + } + + defer deluge.Close() + + // check for active downloads and other rules + if client.Settings.Rules.Enabled && !action.IgnoreRules { + activeDownloads, err := deluge.TorrentsStatus(delugeClient.StateDownloading, nil) + if err != nil { + log.Error().Stack().Err(err).Msg("Deluge - could not fetch downloading torrents") + return false, err + } + + // make sure it's not set to 0 by default + if client.Settings.Rules.MaxActiveDownloads > 0 { + + // if max active downloads reached, check speed and if lower than threshold add anyways + if len(activeDownloads) >= client.Settings.Rules.MaxActiveDownloads { + log.Debug().Msg("max active downloads reached, skipping") + return false, nil + + // // TODO handle ignore slow torrents + //if client.Settings.Rules.IgnoreSlowTorrents { + // + // // get session state + // // gives type conversion errors + // state, err := deluge.GetSessionStatus() + // if err != nil { + // log.Error().Err(err).Msg("could not get session state") + // return err + // } + // + // if int64(state.DownloadRate)*1024 >= client.Settings.Rules.DownloadSpeedThreshold { + // log.Trace().Msg("max active downloads reached, skip adding") + // return nil + // } + // + // log.Trace().Msg("active downloads are slower than set limit, lets add it") + //} + } + } + } + + return true, nil +} + +func delugeV1(client *domain.DownloadClient, settings delugeClient.Settings, action domain.Action, torrentFile string) error { deluge := delugeClient.NewV1(settings) // perform connection to Deluge server err := deluge.Connect() if err != nil { - log.Error().Err(err).Msgf("error logging into client: %v", settings.Hostname) + log.Error().Stack().Err(err).Msgf("error logging into client: %v %v", client.Name, client.Host) return err } @@ -63,14 +149,14 @@ func delugeV1(settings delugeClient.Settings, action domain.Action, torrentFile t, err := ioutil.ReadFile(torrentFile) if err != nil { - log.Error().Err(err).Msgf("could not read torrent file: %v", torrentFile) + log.Error().Stack().Err(err).Msgf("could not read torrent file: %v", torrentFile) return err } // encode file to base64 before sending to deluge encodedFile := base64.StdEncoding.EncodeToString(t) if encodedFile == "" { - log.Error().Err(err).Msgf("could not encode torrent file: %v", torrentFile) + log.Error().Stack().Err(err).Msgf("could not encode torrent file: %v", torrentFile) return err } @@ -92,17 +178,18 @@ func delugeV1(settings delugeClient.Settings, action domain.Action, torrentFile options.MaxUploadSpeed = &maxUL } + log.Trace().Msgf("action Deluge options: %+v", options) + torrentHash, err := deluge.AddTorrentFile(torrentFile, encodedFile, &options) if err != nil { - log.Error().Err(err).Msgf("could not add torrent to client: %v", torrentFile) + log.Error().Stack().Err(err).Msgf("could not add torrent %v to client: %v", torrentFile, client.Name) return err } if action.Label != "" { - p, err := deluge.LabelPlugin() if err != nil { - log.Error().Err(err).Msgf("could not load label plugin: %v", torrentFile) + log.Error().Stack().Err(err).Msgf("could not load label plugin: %v", client.Name) return err } @@ -110,25 +197,25 @@ func delugeV1(settings delugeClient.Settings, action domain.Action, torrentFile // TODO first check if label exists, if not, add it, otherwise set err = p.SetTorrentLabel(torrentHash, action.Label) if err != nil { - log.Error().Err(err).Msgf("could not set label: %v", torrentFile) + log.Error().Stack().Err(err).Msgf("could not set label: %v on client: %v", action.Label, client.Name) return err } } } - log.Trace().Msgf("deluge: torrent successfully added! hash: %v", torrentHash) + log.Info().Msgf("torrent with hash %v successfully added to client: '%v'", torrentHash, client.Name) return nil } -func delugeV2(settings delugeClient.Settings, action domain.Action, torrentFile string) error { +func delugeV2(client *domain.DownloadClient, settings delugeClient.Settings, action domain.Action, torrentFile string) error { deluge := delugeClient.NewV2(settings) // perform connection to Deluge server err := deluge.Connect() if err != nil { - log.Error().Err(err).Msgf("error logging into client: %v", settings.Hostname) + log.Error().Stack().Err(err).Msgf("error logging into client: %v %v", client.Name, client.Host) return err } @@ -136,14 +223,14 @@ func delugeV2(settings delugeClient.Settings, action domain.Action, torrentFile t, err := ioutil.ReadFile(torrentFile) if err != nil { - log.Error().Err(err).Msgf("could not read torrent file: %v", torrentFile) + log.Error().Stack().Err(err).Msgf("could not read torrent file: %v", torrentFile) return err } // encode file to base64 before sending to deluge encodedFile := base64.StdEncoding.EncodeToString(t) if encodedFile == "" { - log.Error().Err(err).Msgf("could not encode torrent file: %v", torrentFile) + log.Error().Stack().Err(err).Msgf("could not encode torrent file: %v", torrentFile) return err } @@ -165,17 +252,18 @@ func delugeV2(settings delugeClient.Settings, action domain.Action, torrentFile options.MaxUploadSpeed = &maxUL } + log.Trace().Msgf("action Deluge options: %+v", options) + torrentHash, err := deluge.AddTorrentFile(torrentFile, encodedFile, &options) if err != nil { - log.Error().Err(err).Msgf("could not add torrent to client: %v", torrentFile) + log.Error().Stack().Err(err).Msgf("could not add torrent %v to client: %v", torrentFile, client.Name) return err } if action.Label != "" { - p, err := deluge.LabelPlugin() if err != nil { - log.Error().Err(err).Msgf("could not load label plugin: %v", torrentFile) + log.Error().Stack().Err(err).Msgf("could not load label plugin: %v", client.Name) return err } @@ -183,13 +271,13 @@ func delugeV2(settings delugeClient.Settings, action domain.Action, torrentFile // TODO first check if label exists, if not, add it, otherwise set err = p.SetTorrentLabel(torrentHash, action.Label) if err != nil { - log.Error().Err(err).Msgf("could not set label: %v", torrentFile) + log.Error().Stack().Err(err).Msgf("could not set label: %v on client: %v", action.Label, client.Name) return err } } } - log.Trace().Msgf("deluge: torrent successfully added! hash: %v", torrentHash) + log.Info().Msgf("torrent with hash %v successfully added to client: '%v'", torrentHash, client.Name) return nil } diff --git a/internal/action/exec.go b/internal/action/exec.go index 2580f08..68131bf 100644 --- a/internal/action/exec.go +++ b/internal/action/exec.go @@ -11,12 +11,12 @@ import ( ) func (s *service) execCmd(announce domain.Announce, action domain.Action, torrentFile string) { - log.Trace().Msgf("action EXEC: release: %v", announce.TorrentName) + log.Debug().Msgf("action exec: %v release: %v", action.Name, announce.TorrentName) // check if program exists cmd, err := exec.LookPath(action.ExecCmd) if err != nil { - log.Error().Err(err).Msgf("exec failed, could not find program: %v", action.ExecCmd) + log.Error().Stack().Err(err).Msgf("exec failed, could not find program: %v", action.ExecCmd) return } @@ -30,7 +30,7 @@ func (s *service) execCmd(announce domain.Announce, action domain.Action, torren // parse and replace values in argument string before continuing parsedArgs, err := m.Parse(action.ExecArgs) if err != nil { - log.Error().Err(err).Msgf("exec failed, could not parse arguments: %v", action.ExecCmd) + log.Error().Stack().Err(err).Msgf("exec failed, could not parse arguments: %v", action.ExecCmd) return } @@ -46,7 +46,7 @@ func (s *service) execCmd(announce domain.Announce, action domain.Action, torren output, err := command.CombinedOutput() if err != nil { // everything other than exit 0 is considered an error - log.Error().Err(err).Msgf("command: %v args: %v failed, torrent: %v", cmd, parsedArgs, torrentFile) + log.Error().Stack().Err(err).Msgf("command: %v args: %v failed, torrent: %v", cmd, parsedArgs, torrentFile) } log.Trace().Msgf("executed command: '%v'", string(output)) diff --git a/internal/action/qbittorrent.go b/internal/action/qbittorrent.go index 4d6c4e8..989c03d 100644 --- a/internal/action/qbittorrent.go +++ b/internal/action/qbittorrent.go @@ -9,13 +9,16 @@ import ( "github.com/rs/zerolog/log" ) +const REANNOUNCE_MAX_ATTEMPTS = 30 +const REANNOUNCE_INTERVAL = 7000 + func (s *service) qbittorrent(action domain.Action, hash string, torrentFile string) error { - log.Trace().Msgf("action QBITTORRENT: %v", torrentFile) + log.Debug().Msgf("action qBittorrent: %v", action.Name) // get client for action client, err := s.clientSvc.FindByID(action.ClientID) if err != nil { - log.Error().Err(err).Msgf("error finding client: %v", action.ClientID) + log.Error().Stack().Err(err).Msgf("error finding client: %v ID %v", action.Name, action.ClientID) return err } @@ -35,12 +38,10 @@ func (s *service) qbittorrent(action domain.Action, hash string, torrentFile str // save cookies? err = qbt.Login() if err != nil { - log.Error().Err(err).Msgf("error logging into client: %v", action.ClientID) + log.Error().Stack().Err(err).Msgf("error logging into client: %v %v", client.Name, client.Host) return err } - // TODO check for active downloads and other rules - options := map[string]string{} if action.Paused { @@ -63,67 +64,144 @@ func (s *service) qbittorrent(action domain.Action, hash string, torrentFile str options["dlLimit"] = strconv.FormatInt(action.LimitDownloadSpeed, 10) } + log.Trace().Msgf("action qBittorrent options: %+v", options) + err = qbt.AddTorrentFromFile(torrentFile, options) if err != nil { - log.Error().Err(err).Msgf("error sending to client: %v", action.ClientID) + log.Error().Stack().Err(err).Msgf("could not add torrent %v to client: %v", torrentFile, client.Name) return err } if !action.Paused && hash != "" { err = checkTrackerStatus(*qbt, hash) if err != nil { - log.Error().Err(err).Msgf("could not get tracker status for torrent: %v", hash) + log.Error().Stack().Err(err).Msgf("could not get tracker status for torrent: %v", hash) return err } } - log.Debug().Msgf("torrent %v successfully added to: %v", hash, client.Name) + log.Info().Msgf("torrent with hash %v successfully added to client: '%v'", hash, client.Name) return nil } +func (s *service) qbittorrentCheckRulesCanDownload(action domain.Action) (bool, error) { + log.Trace().Msgf("action qBittorrent: %v check rules", action.Name) + + // get client for action + client, err := s.clientSvc.FindByID(action.ClientID) + if err != nil { + log.Error().Stack().Err(err).Msgf("error finding client: %v", action.ClientID) + return false, err + } + + if client == nil { + return false, err + } + + qbtSettings := qbittorrent.Settings{ + Hostname: client.Host, + Port: uint(client.Port), + Username: client.Username, + Password: client.Password, + SSL: client.SSL, + } + + qbt := qbittorrent.NewClient(qbtSettings) + // save cookies? + err = qbt.Login() + if err != nil { + log.Error().Stack().Err(err).Msgf("error logging into client: %v", client.Host) + return false, err + } + + // check for active downloads and other rules + if client.Settings.Rules.Enabled && !action.IgnoreRules { + activeDownloads, err := qbt.GetTorrentsFilter(qbittorrent.TorrentFilterDownloading) + if err != nil { + log.Error().Stack().Err(err).Msg("could not fetch downloading torrents") + return false, err + } + + // make sure it's not set to 0 by default + if client.Settings.Rules.MaxActiveDownloads > 0 { + + // if max active downloads reached, check speed and if lower than threshold add anyways + if len(activeDownloads) >= client.Settings.Rules.MaxActiveDownloads { + if client.Settings.Rules.IgnoreSlowTorrents { + // check speeds of downloads + info, err := qbt.GetTransferInfo() + if err != nil { + log.Error().Err(err).Msg("could not get transfer info") + return false, err + } + + // if current transfer speed is more than threshold return out and skip + // DlInfoSpeed is in bytes so lets convert to KB to match DownloadSpeedThreshold + if info.DlInfoSpeed/1024 >= client.Settings.Rules.DownloadSpeedThreshold { + log.Debug().Msg("max active downloads reached, skipping") + return false, nil + } + + log.Debug().Msg("active downloads are slower than set limit, lets add it") + } + } + } + } + + return true, nil +} + func checkTrackerStatus(qb qbittorrent.Client, hash string) error { announceOK := false attempts := 0 - for attempts < REANNOUNCE_MAX_ATTEMPTS { - log.Debug().Msgf("RE-ANNOUNCE %v attempt: %v", hash, attempts) + // initial sleep to give tracker a head start + time.Sleep(2 * time.Second) - // initial sleep to give tracker a head start - time.Sleep(REANNOUNCE_INTERVAL * time.Millisecond) + for attempts < REANNOUNCE_MAX_ATTEMPTS { + log.Debug().Msgf("qBittorrent - run re-announce %v attempt: %v", hash, attempts) trackers, err := qb.GetTorrentTrackers(hash) if err != nil { - log.Error().Err(err).Msgf("could not get trackers of torrent: %v", hash) + log.Error().Err(err).Msgf("qBittorrent - could not get trackers for torrent: %v", hash) return err } // check if status not working or something else - _, working := findTrackerStatus(trackers, qbittorrent.TrackerStatusOK) + working := findTrackerStatus(trackers, qbittorrent.TrackerStatusOK) if !working { + log.Trace().Msgf("qBittorrent - not working yet, lets re-announce %v attempt: %v", hash, attempts) err = qb.ReAnnounceTorrents([]string{hash}) if err != nil { - log.Error().Err(err).Msgf("could not get re-announce torrent: %v", hash) + log.Error().Err(err).Msgf("qBittorrent - could not get re-announce torrent: %v", hash) return err } attempts++ + + // add delay for next run + time.Sleep(REANNOUNCE_INTERVAL * time.Millisecond) + continue } else { - log.Debug().Msgf("RE-ANNOUNCE %v OK", hash) + log.Debug().Msgf("qBittorrent - re-announce for %v OK", hash) announceOK = true break } } + // add extra delay before delete + time.Sleep(30 * time.Second) + if !announceOK { - log.Debug().Msgf("RE-ANNOUNCE %v took too long, deleting torrent", hash) + log.Debug().Msgf("qBittorrent - re-announce for %v took too long, deleting torrent", hash) err := qb.DeleteTorrents([]string{hash}, false) if err != nil { - log.Error().Err(err).Msgf("could not delete torrent: %v", hash) + log.Error().Stack().Err(err).Msgf("qBittorrent - could not delete torrent: %v", hash) return err } } @@ -138,11 +216,14 @@ func checkTrackerStatus(qb qbittorrent.Client, hash string) error { // 2 Tracker has been contacted and is working // 3 Tracker is updating // 4 Tracker has been contacted, but it is not working (or doesn't send proper replies) -func findTrackerStatus(slice []qbittorrent.TorrentTracker, status qbittorrent.TrackerStatus) (int, bool) { - for i, item := range slice { - if item.Status == status { - return i, true +func findTrackerStatus(slice []qbittorrent.TorrentTracker, status qbittorrent.TrackerStatus) bool { + for _, item := range slice { + // if updating skip and give some more time + if item.Status == qbittorrent.TrackerStatusUpdating { + return false + } else if item.Status == status { + return true } } - return -1, false + return false } diff --git a/internal/action/run.go b/internal/action/run.go new file mode 100644 index 0000000..53a88b0 --- /dev/null +++ b/internal/action/run.go @@ -0,0 +1,201 @@ +package action + +import ( + "io" + "os" + "path" + + "github.com/anacrolix/torrent/metainfo" + "github.com/rs/zerolog/log" + + "github.com/autobrr/autobrr/internal/client" + "github.com/autobrr/autobrr/internal/domain" +) + +func (s *service) RunActions(actions []domain.Action, announce domain.Announce) error { + + var err error + var tmpFile string + var hash string + + for _, action := range actions { + if !action.Enabled { + // only run active actions + continue + } + + log.Debug().Msgf("process action: %v", action.Name) + + switch action.Type { + case domain.ActionTypeTest: + s.test(action.Name) + + case domain.ActionTypeExec: + if tmpFile == "" { + tmpFile, hash, err = downloadFile(announce.TorrentUrl) + if err != nil { + log.Error().Stack().Err(err) + return err + } + } + go func(announce domain.Announce, action domain.Action, tmpFile string) { + s.execCmd(announce, action, tmpFile) + }(announce, action, tmpFile) + + case domain.ActionTypeWatchFolder: + if tmpFile == "" { + tmpFile, hash, err = downloadFile(announce.TorrentUrl) + if err != nil { + log.Error().Stack().Err(err) + return err + } + } + s.watchFolder(action.WatchFolder, tmpFile) + + case domain.ActionTypeDelugeV1, domain.ActionTypeDelugeV2: + canDownload, err := s.delugeCheckRulesCanDownload(action) + if err != nil { + log.Error().Stack().Err(err).Msgf("error checking client rules: %v", action.Name) + continue + } + if canDownload { + if tmpFile == "" { + tmpFile, hash, err = downloadFile(announce.TorrentUrl) + if err != nil { + log.Error().Stack().Err(err) + return err + } + } + + go func(action domain.Action, tmpFile string) { + err = s.deluge(action, tmpFile) + if err != nil { + log.Error().Stack().Err(err).Msg("error sending torrent to Deluge") + } + }(action, tmpFile) + } + + case domain.ActionTypeQbittorrent: + canDownload, err := s.qbittorrentCheckRulesCanDownload(action) + if err != nil { + log.Error().Stack().Err(err).Msgf("error checking client rules: %v", action.Name) + continue + } + if canDownload { + if tmpFile == "" { + tmpFile, hash, err = downloadFile(announce.TorrentUrl) + if err != nil { + log.Error().Stack().Err(err) + return err + } + } + + go func(action domain.Action, hash string, tmpFile string) { + err = s.qbittorrent(action, hash, tmpFile) + if err != nil { + log.Error().Stack().Err(err).Msg("error sending torrent to qBittorrent") + } + }(action, hash, tmpFile) + } + + case domain.ActionTypeRadarr: + go func(announce domain.Announce, action domain.Action) { + err = s.radarr(announce, action) + if err != nil { + log.Error().Stack().Err(err).Msg("error sending torrent to radarr") + //continue + } + }(announce, action) + + case domain.ActionTypeSonarr: + go func(announce domain.Announce, action domain.Action) { + err = s.sonarr(announce, action) + if err != nil { + log.Error().Stack().Err(err).Msg("error sending torrent to sonarr") + //continue + } + }(announce, action) + + case domain.ActionTypeLidarr: + go func(announce domain.Announce, action domain.Action) { + err = s.lidarr(announce, action) + if err != nil { + log.Error().Stack().Err(err).Msg("error sending torrent to lidarr") + //continue + } + }(announce, action) + + default: + log.Warn().Msgf("unsupported action: %v type: %v", action.Name, action.Type) + } + } + + // safe to delete tmp file + + return nil +} + +// downloadFile returns tmpFile, hash, error +func downloadFile(url string) (string, string, error) { + // create http client + c := client.NewHttpClient() + + // download torrent file + // TODO check extra headers, cookie + res, err := c.DownloadFile(url, nil) + if err != nil { + log.Error().Stack().Err(err).Msgf("could not download file: %v", url) + return "", "", err + } + + // match more filters like torrent size + + // Get meta info from file to find out the hash for later use + meta, err := metainfo.LoadFromFile(res.FileName) + //meta, err := metainfo.Load(res.Body) + if err != nil { + log.Error().Stack().Err(err).Msgf("metainfo could not open file: %v", res.FileName) + return "", "", err + } + + // torrent info hash used for re-announce + hash := meta.HashInfoBytes().String() + + return res.FileName, hash, nil +} + +func (s *service) test(name string) { + log.Info().Msgf("action TEST: %v", name) +} + +func (s *service) watchFolder(dir string, torrentFile string) { + log.Trace().Msgf("action WATCH_FOLDER: %v file: %v", dir, torrentFile) + + // Open original file + original, err := os.Open(torrentFile) + if err != nil { + log.Error().Stack().Err(err).Msgf("could not open temp file '%v'", torrentFile) + return + } + defer original.Close() + + _, tmpFileName := path.Split(torrentFile) + fullFileName := path.Join(dir, tmpFileName) + + // Create new file + newFile, err := os.Create(fullFileName) + if err != nil { + log.Error().Stack().Err(err).Msgf("could not create new temp file '%v'", fullFileName) + return + } + defer newFile.Close() + + // Copy file + _, err = io.Copy(newFile, original) + if err != nil { + log.Error().Stack().Err(err).Msgf("could not copy file %v to watch folder", fullFileName) + return + } + + log.Info().Msgf("saved file to watch folder: %v", fullFileName) +} diff --git a/internal/action/service.go b/internal/action/service.go index 20074e0..5416777 100644 --- a/internal/action/service.go +++ b/internal/action/service.go @@ -1,24 +1,17 @@ package action import ( - "io" - "os" - "path" - "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/internal/download_client" - "github.com/rs/zerolog/log" ) -const REANNOUNCE_MAX_ATTEMPTS = 30 -const REANNOUNCE_INTERVAL = 7000 - type Service interface { - RunActions(torrentFile string, hash string, filter domain.Filter, announce domain.Announce) error Store(action domain.Action) (*domain.Action, error) Fetch() ([]domain.Action, error) Delete(actionID int) error ToggleEnabled(actionID int) error + + RunActions(actions []domain.Action, announce domain.Announce) error } type service struct { @@ -30,72 +23,6 @@ func NewService(repo domain.ActionRepo, clientSvc download_client.Service) Servi return &service{repo: repo, clientSvc: clientSvc} } -func (s *service) RunActions(torrentFile string, hash string, filter domain.Filter, announce domain.Announce) error { - for _, action := range filter.Actions { - if !action.Enabled { - // only run active actions - continue - } - - log.Trace().Msgf("process action: %v", action.Name) - - switch action.Type { - case domain.ActionTypeTest: - go s.test(torrentFile) - - case domain.ActionTypeWatchFolder: - go s.watchFolder(action.WatchFolder, torrentFile) - - case domain.ActionTypeQbittorrent: - go func() { - err := s.qbittorrent(action, hash, torrentFile) - if err != nil { - log.Error().Err(err).Msg("error sending torrent to client") - } - }() - - case domain.ActionTypeExec: - go s.execCmd(announce, action, torrentFile) - - case domain.ActionTypeDelugeV1, domain.ActionTypeDelugeV2: - go func() { - err := s.deluge(action, torrentFile) - if err != nil { - log.Error().Err(err).Msg("error sending torrent to client") - } - }() - - case domain.ActionTypeRadarr: - go func() { - err := s.radarr(announce, action) - if err != nil { - log.Error().Err(err).Msg("error sending torrent to radarr") - } - }() - - case domain.ActionTypeSonarr: - go func() { - err := s.sonarr(announce, action) - if err != nil { - log.Error().Err(err).Msg("error sending torrent to sonarr") - } - }() - case domain.ActionTypeLidarr: - go func() { - err := s.lidarr(announce, action) - if err != nil { - log.Error().Err(err).Msg("error sending torrent to lidarr") - } - }() - - default: - log.Warn().Msgf("unsupported action: %v type: %v", action.Name, action.Type) - } - } - - return nil -} - func (s *service) Store(action domain.Action) (*domain.Action, error) { // validate data @@ -131,36 +58,3 @@ func (s *service) ToggleEnabled(actionID int) error { return nil } - -func (s *service) test(torrentFile string) { - log.Info().Msgf("action TEST: %v", torrentFile) -} - -func (s *service) watchFolder(dir string, torrentFile string) { - log.Trace().Msgf("action WATCH_FOLDER: %v file: %v", dir, torrentFile) - - // Open original file - original, err := os.Open(torrentFile) - if err != nil { - log.Fatal().Err(err) - } - defer original.Close() - - _, tmpFileName := path.Split(torrentFile) - fullFileName := path.Join(dir, tmpFileName) - - // Create new file - newFile, err := os.Create(fullFileName) - if err != nil { - log.Fatal().Err(err) - } - defer newFile.Close() - - // Copy file - _, err = io.Copy(newFile, original) - if err != nil { - log.Fatal().Err(err) - } - - log.Info().Msgf("saved file to watch folder: %v", fullFileName) -} diff --git a/internal/announce/service.go b/internal/announce/service.go index 3171836..47ee0c1 100644 --- a/internal/announce/service.go +++ b/internal/announce/service.go @@ -75,7 +75,7 @@ func (s *service) Parse(announceID string, msg string) error { log.Trace().Msgf("announce: %+v", announce) - log.Info().Msgf("Matched %v (%v) for %v", announce.TorrentName, announce.Filter.Name, announce.Site) + log.Info().Msgf("Matched '%v' (%v) for %v", announce.TorrentName, announce.Filter.Name, announce.Site) // match release diff --git a/internal/client/http.go b/internal/client/http.go index a42d997..b3e497a 100644 --- a/internal/client/http.go +++ b/internal/client/http.go @@ -3,6 +3,7 @@ package client import ( "crypto/md5" "encoding/hex" + "errors" "fmt" "io" "net/http" @@ -40,8 +41,6 @@ func (c *HttpClient) DownloadFile(url string, opts map[string]string) (*Download hashString := hex.EncodeToString(hash[:]) tmpFileName := fmt.Sprintf("/tmp/%v", hashString) - log.Debug().Msgf("tmpFileName: %v", tmpFileName) - // Create the file out, err := os.Create(tmpFileName) if err != nil { @@ -61,8 +60,6 @@ func (c *HttpClient) DownloadFile(url string, opts map[string]string) (*Download // retry logic - log.Trace().Msgf("downloaded file response: %v - status: %v", resp.Status, resp.StatusCode) - if resp.StatusCode != 200 { log.Error().Stack().Err(err).Msgf("error downloading file: %v - bad status: %d", tmpFileName, resp.StatusCode) return nil, err @@ -82,7 +79,12 @@ func (c *HttpClient) DownloadFile(url string, opts map[string]string) (*Download FileName: tmpFileName, } - log.Trace().Msgf("successfully downloaded file: %v", tmpFileName) + if res.FileName == "" || res.Body == nil { + log.Error().Stack().Err(err).Msgf("tmp file error - empty body: %v", url) + return nil, errors.New("error downloading file, no tmp file") + } + + log.Debug().Msgf("successfully downloaded file: %v", tmpFileName) return &res, nil } diff --git a/internal/database/download_client.go b/internal/database/download_client.go index 3861eb5..cca9f5d 100644 --- a/internal/database/download_client.go +++ b/internal/database/download_client.go @@ -91,6 +91,7 @@ func (r *DownloadClientRepo) Store(client domain.DownloadClient) (*domain.Downlo settings := domain.DownloadClientSettings{ APIKey: client.Settings.APIKey, Basic: client.Settings.Basic, + Rules: client.Settings.Rules, } settingsJson, err := json.Marshal(&settings) @@ -162,6 +163,8 @@ func (r *DownloadClientRepo) Store(client domain.DownloadClient) (*domain.Downlo resId, _ := res.LastInsertId() client.ID = int(resId) + + log.Trace().Msgf("download_client: store new record %d", client.ID) } log.Info().Msgf("store download client: %v", client.Name) diff --git a/internal/domain/client.go b/internal/domain/client.go index f4f3908..b58703e 100644 --- a/internal/domain/client.go +++ b/internal/domain/client.go @@ -22,8 +22,16 @@ type DownloadClient struct { } type DownloadClientSettings struct { - APIKey string `json:"apikey,omitempty"` - Basic BasicAuth `json:"basic,omitempty"` + APIKey string `json:"apikey,omitempty"` + Basic BasicAuth `json:"basic,omitempty"` + Rules DownloadClientRules `json:"rules,omitempty"` +} + +type DownloadClientRules struct { + Enabled bool `json:"enabled"` + MaxActiveDownloads int `json:"max_active_downloads"` + IgnoreSlowTorrents bool `json:"ignore_slow_torrents"` + DownloadSpeedThreshold int64 `json:"download_speed_threshold"` } type BasicAuth struct { diff --git a/internal/download_client/service.go b/internal/download_client/service.go index 59cef0a..43d5ae6 100644 --- a/internal/download_client/service.go +++ b/internal/download_client/service.go @@ -46,8 +46,6 @@ func (s *service) Store(client domain.DownloadClient) (*domain.DownloadClient, e // validate data if client.Host == "" { return nil, errors.New("validation error: no host") - } else if client.Port == 0 { - return nil, errors.New("validation error: no port") } else if client.Type == "" { return nil, errors.New("validation error: no type") } @@ -75,8 +73,6 @@ func (s *service) Test(client domain.DownloadClient) error { // basic validation of client if client.Host == "" { return errors.New("validation error: no host") - } else if client.Port == 0 { - return errors.New("validation error: no port") } else if client.Type == "" { return errors.New("validation error: no type") } diff --git a/internal/filter/service.go b/internal/filter/service.go index 4279800..b8f6cf8 100644 --- a/internal/filter/service.go +++ b/internal/filter/service.go @@ -98,8 +98,8 @@ func (s *service) FindByIndexerIdentifier(announce domain.Announce) (*domain.Fil // if match, return the filter matchedFilter := s.checkFilter(filter, announce) if matchedFilter { - log.Trace().Msgf("found filter: %+v", &filter) - log.Debug().Msgf("found filter: %+v", &filter.Name) + log.Trace().Msgf("found matching filter: %+v", &filter) + log.Debug().Msgf("found matching filter: %v", &filter.Name) // find actions and attach actions, err := s.actionRepo.FindByFilterID(filter.ID) diff --git a/internal/release/process.go b/internal/release/process.go index 602ce72..9c04446 100644 --- a/internal/release/process.go +++ b/internal/release/process.go @@ -1,14 +1,11 @@ package release import ( - "errors" "fmt" - "github.com/anacrolix/torrent/metainfo" "github.com/rs/zerolog/log" "github.com/autobrr/autobrr/internal/action" - "github.com/autobrr/autobrr/internal/client" "github.com/autobrr/autobrr/internal/domain" ) @@ -31,54 +28,14 @@ func (s *service) Process(announce domain.Announce) error { return fmt.Errorf("no actions for filter: %v", announce.Filter.Name) } - // check can download // smart episode? - // check against rules like active downloading torrents - // create http client - c := client.NewHttpClient() - - // download torrent file - // TODO check extra headers, cookie - res, err := c.DownloadFile(announce.TorrentUrl, nil) - if err != nil { - log.Error().Stack().Err(err).Msgf("could not download file: %v", announce.TorrentName) - return err - } - - if res.FileName == "" { - return errors.New("error downloading file, no tmp file") - } - - if res.Body == nil { - log.Error().Stack().Err(err).Msgf("tmp file error - empty body: %v", announce.TorrentName) - return errors.New("empty body") - } - - //log.Debug().Msgf("downloaded torrent file: %v", res.FileName) - - // onTorrentDownloaded - - // match more filters like torrent size - - // Get meta info from file to find out the hash for later use - meta, err := metainfo.LoadFromFile(res.FileName) - if err != nil { - log.Error().Err(err).Msgf("metainfo could not open file: %v", res.FileName) - return err - } - - // torrent info hash used for re-announce - hash := meta.HashInfoBytes().String() - - // take action (watchFolder, test, runProgram, qBittorrent, Deluge etc) - err = s.actionSvc.RunActions(res.FileName, hash, *announce.Filter, announce) + // run actions (watchFolder, test, exec, qBittorrent, Deluge etc.) + err := s.actionSvc.RunActions(announce.Filter.Actions, announce) if err != nil { log.Error().Stack().Err(err).Msgf("error running actions for filter: %v", announce.Filter.Name) return err } - // safe to delete tmp file - return nil } diff --git a/pkg/qbittorrent/client.go b/pkg/qbittorrent/client.go index 32b58f1..a5c65d9 100644 --- a/pkg/qbittorrent/client.go +++ b/pkg/qbittorrent/client.go @@ -13,7 +13,6 @@ import ( "time" "github.com/rs/zerolog/log" - "golang.org/x/net/publicsuffix" ) diff --git a/pkg/qbittorrent/domain.go b/pkg/qbittorrent/domain.go index 1152564..c13a6e3 100644 --- a/pkg/qbittorrent/domain.go +++ b/pkg/qbittorrent/domain.go @@ -177,3 +177,42 @@ const ( // 4 Tracker has been contacted, but it is not working (or doesn't send proper replies) TrackerStatusNotWorking TrackerStatus = 4 ) + +type ConnectionStatus string + +const ( + ConnectionStatusConnected = "connected" + ConnectionStatusFirewalled = "firewalled" + ConnectionStatusDisconnected = "disconnected" +) + +// TransferInfo +// +// https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-global-transfer-info +// +// dl_info_speed integer Global download rate (bytes/s) +// +// dl_info_data integer Data downloaded this session (bytes) +// +// up_info_speed integer Global upload rate (bytes/s) +// +// up_info_data integer Data uploaded this session (bytes) +// +// dl_rate_limit integer Download rate limit (bytes/s) +// +// up_rate_limit integer Upload rate limit (bytes/s) +// +// dht_nodes integer DHT nodes connected to +// +// connection_status string Connection status. See possible values here below +// +type TransferInfo struct { + ConnectionStatus ConnectionStatus `json:"connection_status"` + DHTNodes int64 `json:"dht_nodes"` + DlInfoData int64 `json:"dl_info_data"` + DlInfoSpeed int64 `json:"dl_info_speed"` + DlRateLimit int64 `json:"dl_rate_limit"` + UpInfoData int64 `json:"up_info_data"` + UpInfoSpeed int64 `json:"up_info_speed"` + UpRateLimit int64 `json:"up_rate_limit"` +} diff --git a/pkg/qbittorrent/methods.go b/pkg/qbittorrent/methods.go index f572285..f59c710 100644 --- a/pkg/qbittorrent/methods.go +++ b/pkg/qbittorrent/methods.go @@ -27,8 +27,8 @@ func (c *Client) Login() error { return err } else if resp.StatusCode != http.StatusOK { // check for correct status code - log.Error().Err(err).Msg("login bad status error") - return err + log.Error().Err(err).Msgf("login bad status %v error", resp.StatusCode) + return errors.New("qbittorrent login bad status") } defer resp.Body.Close() @@ -220,3 +220,29 @@ func (c *Client) ReAnnounceTorrents(hashes []string) error { return nil } + +func (c *Client) GetTransferInfo() (*TransferInfo, error) { + var info TransferInfo + + resp, err := c.get("transfer/info", nil) + if err != nil { + log.Error().Err(err).Msg("get torrents error") + return nil, err + } + + defer resp.Body.Close() + + body, readErr := ioutil.ReadAll(resp.Body) + if readErr != nil { + log.Error().Err(err).Msg("get torrents read error") + return nil, readErr + } + + err = json.Unmarshal(body, &info) + if err != nil { + log.Error().Err(err).Msg("get torrents unmarshal error") + return nil, err + } + + return &info, nil +} diff --git a/web/src/components/FilterActionList.tsx b/web/src/components/FilterActionList.tsx index e4f4b32..b03faa8 100644 --- a/web/src/components/FilterActionList.tsx +++ b/web/src/components/FilterActionList.tsx @@ -10,7 +10,7 @@ import { import { useToggle } from "../hooks/hooks"; import { useMutation } from "react-query"; import { Field, Form } from "react-final-form"; -import { TextField } from "./inputs"; +import { SwitchGroup, TextField } from "./inputs"; import { NumberField, SelectField } from "./inputs/compact"; import DEBUG from "./debug"; import APIClient from "../api/APIClient"; @@ -281,6 +281,12 @@ function ListItem({ action, clients, filterID, idx }: ListItemProps) { label="Limit upload speed (KB/s)" /> + +
{help}
+ )}+ Manage max downloads. +
++ Manage max downloads etc. +
+