node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,61 @@
package qbittorrent_application
import (
"github.com/rs/zerolog"
"net/http"
qbittorrent_model "seanime/internal/torrent_clients/qbittorrent/model"
qbittorrent_util "seanime/internal/torrent_clients/qbittorrent/util"
)
type Client struct {
BaseUrl string
Client *http.Client
Logger *zerolog.Logger
}
func (c Client) GetAppVersion() (string, error) {
var res string
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/version", nil); err != nil {
return "", err
}
return res, nil
}
func (c Client) GetAPIVersion() (string, error) {
var res string
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/webapiVersion", nil); err != nil {
return "", err
}
return res, nil
}
func (c Client) GetBuildInfo() (*qbittorrent_model.BuildInfo, error) {
var res qbittorrent_model.BuildInfo
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/buildInfo", nil); err != nil {
return nil, err
}
return &res, nil
}
func (c Client) GetAppPreferences() (*qbittorrent_model.Preferences, error) {
var res qbittorrent_model.Preferences
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/preferences", nil); err != nil {
return nil, err
}
return &res, nil
}
func (c Client) SetAppPreferences(p *qbittorrent_model.Preferences) error {
if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/setPreferences", p); err != nil {
return err
}
return nil
}
func (c Client) GetDefaultSavePath() (string, error) {
var res string
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/defaultSavePath", nil); err != nil {
return "", err
}
return res, nil
}

View File

@@ -0,0 +1,162 @@
package qbittorrent
import (
"fmt"
"github.com/rs/zerolog"
"net/http"
"net/http/cookiejar"
"net/url"
"seanime/internal/torrent_clients/qbittorrent/application"
"seanime/internal/torrent_clients/qbittorrent/log"
"seanime/internal/torrent_clients/qbittorrent/rss"
"seanime/internal/torrent_clients/qbittorrent/search"
"seanime/internal/torrent_clients/qbittorrent/sync"
"seanime/internal/torrent_clients/qbittorrent/torrent"
"seanime/internal/torrent_clients/qbittorrent/transfer"
"strings"
"golang.org/x/net/publicsuffix"
)
type Client struct {
baseURL string
logger *zerolog.Logger
client *http.Client
Username string
Password string
Port int
Host string
Path string
DisableBinaryUse bool
Tags string
Application qbittorrent_application.Client
Log qbittorrent_log.Client
RSS qbittorrent_rss.Client
Search qbittorrent_search.Client
Sync qbittorrent_sync.Client
Torrent qbittorrent_torrent.Client
Transfer qbittorrent_transfer.Client
}
type NewClientOptions struct {
Logger *zerolog.Logger
Username string
Password string
Port int
Host string
Path string
DisableBinaryUse bool
Tags string
}
func NewClient(opts *NewClientOptions) *Client {
baseURL := fmt.Sprintf("http://%s:%d/api/v2", opts.Host, opts.Port)
if strings.HasPrefix(opts.Host, "https://") {
opts.Host = strings.TrimPrefix(opts.Host, "https://")
baseURL = fmt.Sprintf("https://%s:%d/api/v2", opts.Host, opts.Port)
}
client := &http.Client{}
return &Client{
baseURL: baseURL,
logger: opts.Logger,
client: client,
Username: opts.Username,
Password: opts.Password,
Port: opts.Port,
Path: opts.Path,
DisableBinaryUse: opts.DisableBinaryUse,
Host: opts.Host,
Tags: opts.Tags,
Application: qbittorrent_application.Client{
BaseUrl: baseURL + "/app",
Client: client,
Logger: opts.Logger,
},
Log: qbittorrent_log.Client{
BaseUrl: baseURL + "/log",
Client: client,
Logger: opts.Logger,
},
RSS: qbittorrent_rss.Client{
BaseUrl: baseURL + "/rss",
Client: client,
Logger: opts.Logger,
},
Search: qbittorrent_search.Client{
BaseUrl: baseURL + "/search",
Client: client,
Logger: opts.Logger,
},
Sync: qbittorrent_sync.Client{
BaseUrl: baseURL + "/sync",
Client: client,
Logger: opts.Logger,
},
Torrent: qbittorrent_torrent.Client{
BaseUrl: baseURL + "/torrents",
Client: client,
Logger: opts.Logger,
},
Transfer: qbittorrent_transfer.Client{
BaseUrl: baseURL + "/transfer",
Client: client,
Logger: opts.Logger,
},
}
}
func (c *Client) Login() error {
endpoint := c.baseURL + "/auth/login"
data := url.Values{}
data.Add("username", c.Username)
data.Add("password", c.Password)
request, err := http.NewRequest("POST", endpoint, strings.NewReader(data.Encode()))
if err != nil {
return err
}
request.Header.Add("content-type", "application/x-www-form-urlencoded")
resp, err := c.client.Do(request)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
c.logger.Err(err).Msg("failed to close login response body")
}
}()
if resp.StatusCode != 200 {
return fmt.Errorf("invalid status %s", resp.Status)
}
if len(resp.Cookies()) < 1 {
return fmt.Errorf("no cookies in login response")
}
apiURL, err := url.Parse(c.baseURL)
if err != nil {
return err
}
jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
if err != nil {
return err
}
jar.SetCookies(apiURL, []*http.Cookie{resp.Cookies()[0]})
c.client.Jar = jar
return nil
}
func (c *Client) Logout() error {
endpoint := c.baseURL + "/auth/logout"
request, err := http.NewRequest("POST", endpoint, nil)
if err != nil {
return err
}
resp, err := c.client.Do(request)
if err != nil {
return err
}
if resp.StatusCode != 200 {
return fmt.Errorf("invalid status %s", resp.Status)
}
return nil
}

View File

@@ -0,0 +1,91 @@
package qbittorrent
import (
"github.com/stretchr/testify/require"
"seanime/internal/test_utils"
"seanime/internal/torrent_clients/qbittorrent/model"
"seanime/internal/util"
"testing"
)
func TestGetList(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.TorrentClient())
client := NewClient(&NewClientOptions{
Logger: util.NewLogger(),
Username: test_utils.ConfigData.Provider.QbittorrentUsername,
Password: test_utils.ConfigData.Provider.QbittorrentPassword,
Port: test_utils.ConfigData.Provider.QbittorrentPort,
Host: test_utils.ConfigData.Provider.QbittorrentHost,
Path: test_utils.ConfigData.Provider.QbittorrentPath,
})
res, err := client.Torrent.GetList(&qbittorrent_model.GetTorrentListOptions{
Filter: "",
Category: nil,
Sort: "",
Reverse: false,
Limit: 0,
Offset: 0,
Hashes: "",
})
require.NoError(t, err)
for _, torrent := range res {
t.Logf("%+v", torrent)
}
}
func TestGetMainDataList(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.TorrentClient())
client := NewClient(&NewClientOptions{
Logger: util.NewLogger(),
Username: test_utils.ConfigData.Provider.QbittorrentUsername,
Password: test_utils.ConfigData.Provider.QbittorrentPassword,
Port: test_utils.ConfigData.Provider.QbittorrentPort,
Host: test_utils.ConfigData.Provider.QbittorrentHost,
Path: test_utils.ConfigData.Provider.QbittorrentPath,
})
res, err := client.Sync.GetMainData(0)
require.NoError(t, err)
for _, torrent := range res.Torrents {
t.Logf("%+v", torrent)
}
res2, err := client.Sync.GetMainData(res.RID)
require.NoError(t, err)
require.Equal(t, 0, len(res2.Torrents))
for _, torrent := range res2.Torrents {
t.Logf("%+v", torrent)
}
}
func TestGetActiveTorrents(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.TorrentClient())
client := NewClient(&NewClientOptions{
Logger: util.NewLogger(),
Username: test_utils.ConfigData.Provider.QbittorrentUsername,
Password: test_utils.ConfigData.Provider.QbittorrentPassword,
Port: test_utils.ConfigData.Provider.QbittorrentPort,
Host: test_utils.ConfigData.Provider.QbittorrentHost,
Path: test_utils.ConfigData.Provider.QbittorrentPath,
})
res, err := client.Torrent.GetList(&qbittorrent_model.GetTorrentListOptions{
Filter: "active",
})
require.NoError(t, err)
for _, torrent := range res {
t.Logf("%+v", torrent.Name)
}
}

View File

@@ -0,0 +1,44 @@
package qbittorrent_log
import (
"github.com/google/go-querystring/query"
"github.com/rs/zerolog"
"net/http"
"net/url"
"seanime/internal/torrent_clients/qbittorrent/model"
"seanime/internal/torrent_clients/qbittorrent/util"
"strconv"
)
type Client struct {
BaseUrl string
Client *http.Client
Logger *zerolog.Logger
}
func (c Client) GetLog(options *qbittorrent_model.GetLogOptions) ([]*qbittorrent_model.LogEntry, error) {
endpoint := c.BaseUrl + "/main"
if options != nil {
params, err := query.Values(options)
if err != nil {
return nil, err
}
endpoint += "?" + params.Encode()
}
var res []*qbittorrent_model.LogEntry
if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil {
return nil, err
}
return res, nil
}
func (c Client) GetPeerLog(lastKnownID int) ([]*qbittorrent_model.PeerLogEntry, error) {
params := url.Values{}
params.Add("last_known_id", strconv.Itoa(lastKnownID))
endpoint := c.BaseUrl + "/peers?" + params.Encode()
var res []*qbittorrent_model.PeerLogEntry
if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil {
return nil, err
}
return res, nil
}

View File

@@ -0,0 +1,30 @@
package qbittorrent_model
type AddTorrentsOptions struct {
// Download folder
Savepath string `json:"savepath,omitempty"`
// Cookie sent to download the .torrent file
Cookie string `json:"cookie,omitempty"`
// Category for the torrent
Category string `json:"category,omitempty"`
// Skip hash checking.
SkipChecking bool `json:"skip_checking,omitempty"`
// Add torrents in the paused state.
Paused string `json:"paused,omitempty"`
// Create the root folder. Possible values are true, false, unset (default)
RootFolder string `json:"root_folder,omitempty"`
// Rename torrent
Rename string `json:"rename,omitempty"`
// Set torrent upload speed limit. Unit in bytes/second
UpLimit int `json:"upLimit,omitempty"`
// Set torrent download speed limit. Unit in bytes/second
DlLimit int `json:"dlLimit,omitempty"`
// Whether Automatic Torrent Management should be used
UseAutoTMM bool `json:"useAutoTMM,omitempty"`
// Enable sequential download. Possible values are true, false (default)
SequentialDownload bool `json:"sequentialDownload,omitempty"`
// Prioritize download first last piece. Possible values are true, false (default)
FirstLastPiecePrio bool `json:"firstLastPiecePrio,omitempty"`
// Tags for the torrent, split by ','
Tags string `json:"tags,omitempty"`
}

View File

@@ -0,0 +1,9 @@
package qbittorrent_model
type BuildInfo struct {
QT string `json:"qt"`
LibTorrent string `json:"libtorrent"`
Boost string `json:"boost"`
OpenSSL string `json:"openssl"`
Bitness string `json:"bitness"`
}

View File

@@ -0,0 +1,6 @@
package qbittorrent_model
type Category struct {
Name string `json:"name"`
SavePath string `json:"savePath"`
}

View File

@@ -0,0 +1,9 @@
package qbittorrent_model
type GetLogOptions struct {
Normal bool `url:"normal"`
Info bool `url:"info"`
Warning bool `url:"warning"`
Critical bool `url:"critical"`
LastKnownID int `url:"lastKnownId"`
}

View File

@@ -0,0 +1,23 @@
package qbittorrent_model
type GetTorrentListOptions struct {
Filter TorrentListFilter `url:"filter,omitempty"`
Category *string `url:"category,omitempty"`
Sort string `url:"sort,omitempty"`
Reverse bool `url:"reverse,omitempty"`
Limit int `url:"limit,omitempty"`
Offset int `url:"offset,omitempty"`
Hashes string `url:"hashes,omitempty"`
}
type TorrentListFilter string
const (
FilterAll TorrentListFilter = "all"
FilterDownloading = "downloading"
FilterCompleted = "completed"
FilterPaused = "paused"
FilterActive = "active"
FilterInactive = "inactive"
FilterResumed = "resumed"
)

View File

@@ -0,0 +1,44 @@
package qbittorrent_model
import (
"encoding/json"
"time"
)
type LogEntry struct {
ID int `json:"id"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
Type LogType `json:"type"`
}
func (l *LogEntry) UnmarshalJSON(data []byte) error {
var raw rawLogEntry
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
t := time.Unix(0, int64(raw.Timestamp)*int64(time.Millisecond))
*l = LogEntry{
ID: raw.ID,
Message: raw.Message,
Timestamp: t,
Type: raw.Type,
}
return nil
}
type LogType int
const (
TypeNormal LogType = iota << 1
TypeInfo
TypeWarning
TypeCritical
)
type rawLogEntry struct {
ID int `json:"id"`
Message string `json:"message"`
Timestamp int `json:"timestamp"`
Type LogType `json:"type"`
}

View File

@@ -0,0 +1,19 @@
package qbittorrent_model
type Peer struct {
Client string `json:"client"`
Connection string `json:"connection"`
Country string `json:"country"`
CountryCode string `json:"country_code"`
DLSpeed int `json:"dlSpeed"`
Downloaded int `json:"downloaded"`
Files string `json:"files"`
Flags string `json:"flags"`
FlagsDescription string `json:"flags_desc"`
IP string `json:"ip"`
Port int `json:"port"`
Progress float64 `json:"progress"`
Relevance int `json:"relevance"`
ULSpeed int `json:"up_speed"`
Uploaded int `json:"uploaded"`
}

View File

@@ -0,0 +1,38 @@
package qbittorrent_model
import (
"encoding/json"
"time"
)
type PeerLogEntry struct {
ID int `json:"id"`
IP string `json:"ip"`
Timestamp time.Time `json:"timestamp"`
Blocked bool `json:"blocked"`
Reason string `json:"reason"`
}
func (l *PeerLogEntry) UnmarshalJSON(data []byte) error {
var raw rawPeerLogEntry
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
t := time.Unix(0, int64(raw.Timestamp)*int64(time.Millisecond))
*l = PeerLogEntry{
ID: raw.ID,
IP: raw.IP,
Timestamp: t,
Blocked: raw.Blocked,
Reason: raw.Reason,
}
return nil
}
type rawPeerLogEntry struct {
ID int `json:"id"`
IP string `json:"ip"`
Timestamp int `json:"timestamp"`
Blocked bool `json:"blocked"`
Reason string `json:"reason"`
}

View File

@@ -0,0 +1,213 @@
package qbittorrent_model
type Preferences struct {
// Currently selected language (e.g. en_GB for English)
Locale string `json:"locale"`
// True if a subfolder should be created when adding a torrent
CreateSubfolderEnabled bool `json:"create_subfolder_enabled"`
// True if torrents should be added in a Paused state
StartPausedEnabled bool `json:"start_paused_enabled"`
// No documentation provided
AutoDeleteMode int `json:"auto_delete_mode"`
// True if disk space should be pre-allocated for all files
PreallocateAll bool `json:"preallocate_all"`
// True if ".!qB" should be appended to incomplete files
IncompleteFilesExt bool `json:"incomplete_files_ext"`
// True if Automatic Torrent Management is enabled by default
AutoTmmEnabled bool `json:"auto_tmm_enabled"`
// True if torrent should be relocated when its Category changes
TorrentChangedTmmEnabled bool `json:"torrent_changed_tmm_enabled"`
// True if torrent should be relocated when the default save path changes
SavePathChangedTmmEnabled bool `json:"save_path_changed_tmm_enabled"`
// True if torrent should be relocated when its Category's save path changes
CategoryChangedTmmEnabled bool `json:"category_changed_tmm_enabled"`
// Default save path for torrents, separated by slashes
SavePath string `json:"save_path"`
// True if folder for incomplete torrents is enabled
TempPathEnabled bool `json:"temp_path_enabled"`
// Path for incomplete torrents, separated by slashes
TempPath string `json:"temp_path"`
// Property: directory to watch for torrent files, value: where torrents loaded from this directory should be downloaded to (see list of possible values below). Slashes are used as path separators; multiple key/value pairs can be specified
ScanDirs map[string]interface{} `json:"scan_dirs"`
// Path to directory to copy .torrent files to. Slashes are used as path separators
ExportDir string `json:"export_dir"`
// Path to directory to copy .torrent files of completed downloads to. Slashes are used as path separators
ExportDirFin string `json:"export_dir_fin"`
// True if e-mail notification should be enabled
MailNotificationEnabled bool `json:"mail_notification_enabled"`
// e-mail where notifications should originate from
MailNotificationSender string `json:"mail_notification_sender"`
// e-mail to send notifications to
MailNotificationEmail string `json:"mail_notification_email"`
// smtp server for e-mail notifications
MailNotificationSmtp string `json:"mail_notification_smtp"`
// True if smtp server requires SSL connection
MailNotificationSslEnabled bool `json:"mail_notification_ssl_enabled"`
// True if smtp server requires authentication
MailNotificationAuthEnabled bool `json:"mail_notification_auth_enabled"`
// Username for smtp authentication
MailNotificationUsername string `json:"mail_notification_username"`
// Password for smtp authentication
MailNotificationPassword string `json:"mail_notification_password"`
// True if external program should be run after torrent has finished downloading
AutorunEnabled bool `json:"autorun_enabled"`
// Program path/name/arguments to run if autorun_enabled is enabled; path is separated by slashes; you can use %f and %n arguments, which will be expanded by qBittorent as path_to_torrent_file and torrent_name (from the GUI; not the .torrent file name) respectively
AutorunProgram string `json:"autorun_program"`
// True if torrent queuing is enabled
QueueingEnabled bool `json:"queueing_enabled"`
// Maximum number of active simultaneous downloads
MaxActiveDownloads int `json:"max_active_downloads"`
// Maximum number of active simultaneous downloads and uploads
MaxActiveTorrents int `json:"max_active_torrents"`
// Maximum number of active simultaneous uploads
MaxActiveUploads int `json:"max_active_uploads"`
// If true torrents w/o any activity (stalled ones) will not be counted towards max_active_* limits; see dont_count_slow_torrents for more information
DontCountSlowTorrents bool `json:"dont_count_slow_torrents"`
// Download rate in KiB/s for a torrent to be considered "slow"
SlowTorrentDlRateThreshold int `json:"slow_torrent_dl_rate_threshold"`
// Upload rate in KiB/s for a torrent to be considered "slow"
SlowTorrentUlRateThreshold int `json:"slow_torrent_ul_rate_threshold"`
// Seconds a torrent should be inactive before considered "slow"
SlowTorrentInactiveTimer int `json:"slow_torrent_inactive_timer"`
// True if share ratio limit is enabled
MaxRatioEnabled bool `json:"max_ratio_enabled"`
// Get the global share ratio limit
MaxRatio float64 `json:"max_ratio"`
// Action performed when a torrent reaches the maximum share ratio. See list of possible values here below.
MaxRatioAct MaxRatioAction `json:"max_ratio_act"`
// Port for incoming connections
ListenPort int `json:"listen_port"`
// True if UPnP/NAT-PMP is enabled
Upnp bool `json:"upnp"`
// True if the port is randomly selected
RandomPort bool `json:"random_port"`
// Global download speed limit in KiB/s; -1 means no limit is applied
DlLimit int `json:"dl_limit"`
// Global upload speed limit in KiB/s; -1 means no limit is applied
UpLimit int `json:"up_limit"`
// Maximum global number of simultaneous connections
MaxConnec int `json:"max_connec"`
// Maximum number of simultaneous connections per torrent
MaxConnecPerTorrent int `json:"max_connec_per_torrent"`
// Maximum number of upload slots
MaxUploads int `json:"max_uploads"`
// Maximum number of upload slots per torrent
MaxUploadsPerTorrent int `json:"max_uploads_per_torrent"`
// True if uTP protocol should be enabled; this option is only available in qBittorent built against libtorrent version 0.16.X and higher
EnableUtp bool `json:"enable_utp"`
// True if [du]l_limit should be applied to uTP connections; this option is only available in qBittorent built against libtorrent version 0.16.X and higher
LimitUtpRate bool `json:"limit_utp_rate"`
// True if [du]l_limit should be applied to estimated TCP overhead (service data: e.g. packet headers)
LimitTcpOverhead bool `json:"limit_tcp_overhead"`
// True if [du]l_limit should be applied to peers on the LAN
LimitLanPeers bool `json:"limit_lan_peers"`
// Alternative global download speed limit in KiB/s
AltDlLimit int `json:"alt_dl_limit"`
// Alternative global upload speed limit in KiB/s
AltUpLimit int `json:"alt_up_limit"`
// True if alternative limits should be applied according to schedule
SchedulerEnabled bool `json:"scheduler_enabled"`
// Scheduler starting hour
ScheduleFromHour int `json:"schedule_from_hour"`
// Scheduler starting minute
ScheduleFromMin int `json:"schedule_from_min"`
// Scheduler ending hour
ScheduleToHour int `json:"schedule_to_hour"`
// Scheduler ending minute
ScheduleToMin int `json:"schedule_to_min"`
// Scheduler days. See possible values here below
SchedulerDays int `json:"scheduler_days"`
// True if DHT is enabled
Dht bool `json:"dht"`
// True if DHT port should match TCP port
DhtSameAsBT bool `json:"dhtSameAsBT"`
// DHT port if dhtSameAsBT is false
DhtPort int `json:"dht_port"`
// True if PeX is enabled
Pex bool `json:"pex"`
// True if LSD is enabled
Lsd bool `json:"lsd"`
// See list of possible values here below
Encryption int `json:"encryption"`
// If true anonymous mode will be enabled; read more here; this option is only available in qBittorent built against libtorrent version 0.16.X and higher
AnonymousMode bool `json:"anonymous_mode"`
// See list of possible values here below
ProxyType int `json:"proxy_type"`
// Proxy IP address or domain name
ProxyIp string `json:"proxy_ip"`
// Proxy port
ProxyPort int `json:"proxy_port"`
// True if peer and web seed connections should be proxified; this option will have any effect only in qBittorent built against libtorrent version 0.16.X and higher
ProxyPeerConnections bool `json:"proxy_peer_connections"`
// True if the connections not supported by the proxy are disabled
ForceProxy bool `json:"force_proxy"`
// True proxy requires authentication; doesn't apply to SOCKS4 proxies
ProxyAuthEnabled bool `json:"proxy_auth_enabled"`
// Username for proxy authentication
ProxyUsername string `json:"proxy_username"`
// Password for proxy authentication
ProxyPassword string `json:"proxy_password"`
// True if external IP filter should be enabled
IpFilterEnabled bool `json:"ip_filter_enabled"`
// Path to IP filter file (.dat, .p2p, .p2b files are supported); path is separated by slashes
IpFilterPath string `json:"ip_filter_path"`
// True if IP filters are applied to trackers
IpFilterTrackers bool `json:"ip_filter_trackers"`
// Comma-separated list of domains to accept when performing Host header validation
WebUiDomainList string `json:"web_ui_domain_list"`
// IP address to use for the WebUI
WebUiAddress string `json:"web_ui_address"`
// WebUI port
WebUiPort int `json:"web_ui_port"`
// True if UPnP is used for the WebUI port
WebUiUpnp bool `json:"web_ui_upnp"`
// WebUI username
WebUiUsername string `json:"web_ui_username"`
// For API ≥ v2.3.0: Plaintext WebUI password, not readable, write-only. For API < v2.3.0: MD5 hash of WebUI password, hash is generated from the following string: username:Web UI Access:plain_text_web_ui_password
WebUiPassword string `json:"web_ui_password"`
// True if WebUI CSRF protection is enabled
WebUiCsrfProtectionEnabled bool `json:"web_ui_csrf_protection_enabled"`
// True if WebUI clickjacking protection is enabled
WebUiClickjackingProtectionEnabled bool `json:"web_ui_clickjacking_protection_enabled"`
// True if authentication challenge for loopback address (127.0.0.1) should be disabled
BypassLocalAuth bool `json:"bypass_local_auth"`
// True if webui authentication should be bypassed for clients whose ip resides within (at least) one of the subnets on the whitelist
BypassAuthSubnetWhitelistEnabled bool `json:"bypass_auth_subnet_whitelist_enabled"`
// (White)list of ipv4/ipv6 subnets for which webui authentication should be bypassed; list entries are separated by commas
BypassAuthSubnetWhitelist string `json:"bypass_auth_subnet_whitelist"`
// True if an alternative WebUI should be used
AlternativeWebuiEnabled bool `json:"alternative_webui_enabled"`
// File path to the alternative WebUI
AlternativeWebuiPath string `json:"alternative_webui_path"`
// True if WebUI HTTPS access is enabled
UseHttps bool `json:"use_https"`
// SSL keyfile contents (this is a not a path)
SslKey string `json:"ssl_key"`
// SSL certificate contents (this is a not a path)
SslCert string `json:"ssl_cert"`
// True if server DNS should be updated dynamically
DyndnsEnabled bool `json:"dyndns_enabled"`
// See list of possible values here below
DyndnsService int `json:"dyndns_service"`
// Username for DDNS service
DyndnsUsername string `json:"dyndns_username"`
// Password for DDNS service
DyndnsPassword string `json:"dyndns_password"`
// Your DDNS domain name
DyndnsDomain string `json:"dyndns_domain"`
// RSS refresh interval
RssRefreshInterval int `json:"rss_refresh_interval"`
// Max stored articles per RSS feed
RssMaxArticlesPerFeed int `json:"rss_max_articles_per_feed"`
// Enable processing of RSS feeds
RssProcessingEnabled bool `json:"rss_processing_enabled"`
// Enable auto-downloading of torrents from the RSS feeds
RssAutoDownloadingEnabled bool `json:"rss_auto_downloading_enabled"`
}
type MaxRatioAction int
const (
ActionPause MaxRatioAction = 0
ActionRemove = 1
)

View File

@@ -0,0 +1,30 @@
package qbittorrent_model
type RuleDefinition struct {
// Whether the rule is enabled
Enabled bool `json:"enabled"`
// The substring that the torrent name must contain
MustContain string `json:"mustContain"`
// The substring that the torrent name must not contain
MustNotContain string `json:"mustNotContain"`
// Enable regex mode in "mustContain" and "mustNotContain"
UseRegex bool `json:"useRegex"`
// Episode filter definition
EpisodeFilter string `json:"episodeFilter"`
// Enable smart episode filter
SmartFilter bool `json:"smartFilter"`
// The list of episode IDs already matched by smart filter
PreviouslyMatchedEpisodes []string `json:"previouslyMatchedEpisodes"`
// The feed URLs the rule applied to
AffectedFeeds []string `json:"affectedFeeds"`
// Ignore sunsequent rule matches
IgnoreDays int `json:"ignoreDays"`
// The rule last match time
LastMatch string `json:"lastMatch"`
// Add matched torrent in paused mode
AddPaused bool `json:"addPaused"`
// Assign category to the torrent
AssignedCategory string `json:"assignedCategory"`
// Save torrent to the given directory
SavePath string `json:"savePath"`
}

View File

@@ -0,0 +1,10 @@
package qbittorrent_model
type SearchPlugin struct {
Enabled bool `json:"enabled"`
FullName string `json:"fullName"`
Name string `json:"name"`
SupportedCategories []string `json:"supportedCategories"`
URL string `json:"url"`
Version string `json:"version"`
}

View File

@@ -0,0 +1,18 @@
package qbittorrent_model
type SearchResult struct {
// URL of the torrent's description page
DescriptionLink string `json:"descrLink"`
// Name of the file
FileName string `json:"fileName"`
// Size of the file in Bytes
FileSize int `json:"fileSize"`
// Torrent download link (usually either .torrent file or magnet link)
FileUrl string `json:"fileUrl"`
// Number of leechers
NumLeechers int `json:"nbLeechers"`
// int of seeders
NumSeeders int `json:"nbSeeders"`
// URL of the torrent site
SiteUrl string `json:"siteUrl"`
}

View File

@@ -0,0 +1,7 @@
package qbittorrent_model
type SearchResultsPaging struct {
Results []SearchResult `json:"results"`
Status string `json:"status"`
Total int `json:"total"`
}

View File

@@ -0,0 +1,7 @@
package qbittorrent_model
type SearchStatus struct {
ID int `json:"id"`
Status string `json:"status"`
Total int `json:"total"`
}

View File

@@ -0,0 +1,80 @@
package qbittorrent_model
import (
"encoding/json"
"strconv"
)
type ServerState struct {
TransferInfo
AlltimeDl int `json:"alltime_dl"`
AlltimeUl int `json:"alltime_ul"`
AverageTimeQueue int `json:"average_time_queue"`
FreeSpaceOnDisk int `json:"free_space_on_disk"`
GlobalRatio float64 `json:"global_ratio"`
QueuedIoJobs int `json:"queued_io_jobs"`
ReadCacheHits float64 `json:"read_cache_hits"`
ReadCacheOverload float64 `json:"read_cache_overload"`
TotalBuffersSize int `json:"total_buffers_size"`
TotalPeerConnections int `json:"total_peer_connections"`
TotalQueuedSize int `json:"total_queued_size"`
TotalWastedSession int `json:"total_wasted_session"`
WriteCacheOverload float64 `json:"write_cache_overload"`
}
func (s *ServerState) UnmarshalJSON(data []byte) error {
var raw rawServerState
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
globalRatio, err := strconv.ParseFloat(raw.GlobalRatio, 64)
if err != nil {
return err
}
readCacheHits, err := strconv.ParseFloat(raw.ReadCacheHits, 64)
if err != nil {
return err
}
readCacheOverload, err := strconv.ParseFloat(raw.ReadCacheOverload, 64)
if err != nil {
return err
}
writeCacheOverload, err := strconv.ParseFloat(raw.WriteCacheOverload, 64)
if err != nil {
return err
}
*s = ServerState{
TransferInfo: raw.TransferInfo,
AlltimeDl: raw.AlltimeDl,
AlltimeUl: raw.AlltimeUl,
AverageTimeQueue: raw.AverageTimeQueue,
FreeSpaceOnDisk: raw.FreeSpaceOnDisk,
GlobalRatio: globalRatio,
QueuedIoJobs: raw.QueuedIoJobs,
ReadCacheHits: readCacheHits,
ReadCacheOverload: readCacheOverload,
TotalBuffersSize: raw.TotalBuffersSize,
TotalPeerConnections: raw.TotalPeerConnections,
TotalQueuedSize: raw.TotalQueuedSize,
TotalWastedSession: raw.TotalWastedSession,
WriteCacheOverload: writeCacheOverload,
}
return nil
}
type rawServerState struct {
TransferInfo
AlltimeDl int `json:"alltime_dl"`
AlltimeUl int `json:"alltime_ul"`
AverageTimeQueue int `json:"average_time_queue"`
FreeSpaceOnDisk int `json:"free_space_on_disk"`
GlobalRatio string `json:"global_ratio"`
QueuedIoJobs int `json:"queued_io_jobs"`
ReadCacheHits string `json:"read_cache_hits"`
ReadCacheOverload string `json:"read_cache_overload"`
TotalBuffersSize int `json:"total_buffers_size"`
TotalPeerConnections int `json:"total_peer_connections"`
TotalQueuedSize int `json:"total_queued_size"`
TotalWastedSession int `json:"total_wasted_session"`
WriteCacheOverload string `json:"write_cache_overload"`
}

View File

@@ -0,0 +1,12 @@
package qbittorrent_model
type SyncMainData struct {
RID int `json:"rid"`
FullUpdate bool `json:"full_update"`
Torrents map[string]*Torrent `json:"torrents"`
TorrentsRemoved []string `json:"torrents_removed"`
Categories map[string]Category `json:"categories"`
CategoriesRemoved map[string]Category `json:"categories_removed"`
Queueing bool `json:"queueing"`
ServerState ServerState `json:"server_state"`
}

View File

@@ -0,0 +1,8 @@
package qbittorrent_model
type SyncPeersData struct {
FullUpdate bool `json:"full_update"`
Peers map[string]Peer `json:"peers"`
RID int `json:"rid"`
ShowFlags bool `json:"show_flags"`
}

View File

@@ -0,0 +1,119 @@
package qbittorrent_model
type Torrent struct {
// Torrent hash
Hash string `json:"hash"`
// Torrent name
Name string `json:"name"`
// Total size (bytes) of files selected for download
Size int `json:"size"`
// Torrent progress (percentage/100)
Progress float64 `json:"progress"`
// Torrent download speed (bytes/s)
Dlspeed int `json:"dlspeed"`
// Torrent upload speed (bytes/s)
Upspeed int `json:"upspeed"`
// Torrent priority. Returns -1 if queuing is disabled or torrent is in seed mode
Priority int `json:"priority"`
// Number of seeds connected to
NumSeeds int `json:"num_seeds"`
// Number of seeds in the swarm
NumComplete int `json:"num_complete"`
// Number of leechers connected to
NumLeechs int `json:"num_leechs"`
// Number of leechers in the swarm
NumIncomplete int `json:"num_incomplete"`
// Torrent share ratio. Max ratio value: 9999.
Ratio float64 `json:"ratio"`
// Torrent ETA (seconds)
Eta int `json:"eta"`
// Torrent state. See table here below for the possible values
State TorrentState `json:"state"`
// True if sequential download is enabled
SeqDl bool `json:"seq_dl"`
// True if first last piece are prioritized
FLPiecePrio bool `json:"f_l_piece_prio"`
// Category of the torrent
Category string `json:"category"`
// True if super seeding is enabled
SuperSeeding bool `json:"super_seeding"`
// True if force start is enabled for this torrent
ForceStart bool `json:"force_start"`
// New added fields
AddedOn int `json:"added_on"`
AmountLeft int `json:"amount_left"`
AutoTmm bool `json:"auto_tmm"`
Availability float64 `json:"availability"`
Completed int64 `json:"completed"`
CompletionOn int `json:"completion_on"`
ContentPath string `json:"content_path"`
DlLimit int `json:"dl_limit"`
DownloadPath string `json:"download_path"`
Downloaded int64 `json:"downloaded"`
DownloadedSession int `json:"downloaded_session"`
InfohashV1 string `json:"infohash_v1"`
InfohashV2 string `json:"infohash_v2"`
LastActivity int `json:"last_activity"`
MagnetUri string `json:"magnet_uri"`
MaxRatio int `json:"max_ratio"`
MaxSeedingTime int `json:"max_seeding_time"`
RatioLimit int `json:"ratio_limit"`
SavePath string `json:"save_path"`
SeedingTime int `json:"seeding_time"`
SeedingTimeLimit int `json:"seeding_time_limit"`
SeenComplete int `json:"seen_complete"`
Tags string `json:"tags"`
TimeActive int `json:"time_active"`
TotalSize int64 `json:"total_size"`
Tracker string `json:"tracker"`
TrackersCount int `json:"trackers_count"`
UpLimit int `json:"up_limit"`
Uploaded int64 `json:"uploaded"`
UploadedSession int64 `json:"uploaded_session"`
}
type TorrentState string
const (
// Some error occurred, applies to paused torrents
StateError TorrentState = "error"
// Torrent data files is missing
StateMissingFiles TorrentState = "missingFiles"
// Torrent is being seeded and data is being transferred
StateUploading TorrentState = "uploading"
// Torrent is paused and has finished downloading
StatePausedUP TorrentState = "pausedUP"
StateStoppedUP TorrentState = "stoppedUP"
// Queuing is enabled and torrent is queued for upload
StateQueuedUP TorrentState = "queuedUP"
// Torrent is being seeded, but no connection were made
StateStalledUP TorrentState = "stalledUP"
// Torrent has finished downloading and is being checked
StateCheckingUP TorrentState = "checkingUP"
// Torrent is forced to uploading and ignore queue limit
StateForcedUP TorrentState = "forcedUP"
// Torrent is allocating disk space for download
StateAllocating TorrentState = "allocating"
// Torrent is being downloaded and data is being transferred
StateDownloading TorrentState = "downloading"
// Torrent has just started downloading and is fetching metadata
StateMetaDL TorrentState = "metaDL"
// Torrent is paused and has NOT finished downloading
StatePausedDL TorrentState = "pausedDL"
StateStoppedDL TorrentState = "stoppedDL"
// Queuing is enabled and torrent is queued for download
StateQueuedDL TorrentState = "queuedDL"
// Torrent is being downloaded, but no connection were made
StateStalledDL TorrentState = "stalledDL"
// Same as checkingUP, but torrent has NOT finished downloading
StateCheckingDL TorrentState = "checkingDL"
// Torrent is forced to downloading to ignore queue limit
StateForceDL TorrentState = "forceDL"
// Checking resume data on qBt startup
StateCheckingResumeData TorrentState = "checkingResumeData"
// Torrent is moving to another location
StateMoving TorrentState = "moving"
// Unknown status
StateUnknown TorrentState = "unknown"
)

View File

@@ -0,0 +1,27 @@
package qbittorrent_model
type TorrentContent struct {
// File name (including relative path)
Name string `json:" name"`
// File size (bytes)
Size int `json:" size"`
// File progress (percentage/100)
Progress float64 `json:" progress"`
// File priority. See possible values here below
Priority TorrentPriority `json:" priority"`
// True if file is seeding/complete
IsSeed bool `json:" is_seed"`
// The first number is the starting piece index and the second number is the ending piece index (inclusive)
PieceRange []int `json:" piece_range"`
// Percentage of file pieces currently available
Availability float64 `json:" availability"`
}
type TorrentPriority int
const (
PriorityDoNotDownload TorrentPriority = 0
PriorityNormal = 1
PriorityHigh = 6
PriorityMaximum = 7
)

View File

@@ -0,0 +1,9 @@
package qbittorrent_model
type TorrentPieceState int
const (
PieceStateNotDownloaded TorrentPieceState = iota
PieceStateDownloading
PieceStateDownloaded
)

View File

@@ -0,0 +1,194 @@
package qbittorrent_model
import (
"encoding/json"
"time"
)
type TorrentProperties struct {
// Torrent save path
SavePath string `json:"save_path"`
// Torrent creation date (Unix timestamp)
CreationDate time.Time `json:"creation_date"`
// Torrent piece size (bytes)
PieceSize int `json:"piece_size"`
// Torrent comment
Comment string `json:"comment"`
// Total data wasted for torrent (bytes)
TotalWasted int `json:"total_wasted"`
// Total data uploaded for torrent (bytes)
TotalUploaded int `json:"total_uploaded"`
// Total data uploaded this session (bytes)
TotalUploadedSession int `json:"total_uploaded_session"`
// Total data downloaded for torrent (bytes)
TotalDownloaded int `json:"total_downloaded"`
// Total data downloaded this session (bytes)
TotalDownloadedSession int `json:"total_downloaded_session"`
// Torrent upload limit (bytes/s)
UpLimit int `json:"up_limit"`
// Torrent download limit (bytes/s)
DlLimit int `json:"dl_limit"`
// Torrent elapsed time (seconds)
TimeElapsed int `json:"time_elapsed"`
// Torrent elapsed time while complete (seconds)
SeedingTime time.Duration `json:"seeding_time"`
// Torrent connection count
NbConnections int `json:"nb_connections"`
// Torrent connection count limit
NbConnectionsLimit int `json:"nb_connections_limit"`
// Torrent share ratio
ShareRatio float64 `json:"share_ratio"`
// When this torrent was added (unix timestamp)
AdditionDate time.Time `json:"addition_date"`
// Torrent completion date (unix timestamp)
CompletionDate time.Time `json:"completion_date"`
// Torrent creator
CreatedBy string `json:"created_by"`
// Torrent average download speed (bytes/second)
DlSpeedAvg int `json:"dl_speed_avg"`
// Torrent download speed (bytes/second)
DlSpeed int `json:"dl_speed"`
// Torrent ETA (seconds)
Eta time.Duration `json:"eta"`
// Last seen complete date (unix timestamp)
LastSeen time.Time `json:"last_seen"`
// Number of peers connected to
Peers int `json:"peers"`
// Number of peers in the swarm
PeersTotal int `json:"peers_total"`
// Number of pieces owned
PiecesHave int `json:"pieces_have"`
// Number of pieces of the torrent
PiecesNum int `json:"pieces_num"`
// Number of seconds until the next announce
Reannounce time.Duration `json:"reannounce"`
// Number of seeds connected to
Seeds int `json:"seeds"`
// Number of seeds in the swarm
SeedsTotal int `json:"seeds_total"`
// Torrent total size (bytes)
TotalSize int `json:"total_size"`
// Torrent average upload speed (bytes/second)
UpSpeedAvg int `json:"up_speed_avg"`
// Torrent upload speed (bytes/second)
UpSpeed int `json:"up_speed"`
}
func (p *TorrentProperties) UnmarshalJSON(data []byte) error {
var raw rawTorrentProperties
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
creationDate := time.Unix(int64(raw.CreationDate), 0)
seedingTime := time.Duration(raw.SeedingTime) * time.Second
additionDate := time.Unix(int64(raw.AdditionDate), 0)
completionDate := time.Unix(int64(raw.CompletionDate), 0)
eta := time.Duration(raw.Eta) * time.Second
lastSeen := time.Unix(int64(raw.LastSeen), 0)
reannounce := time.Duration(raw.Reannounce) * time.Second
*p = TorrentProperties{
SavePath: raw.SavePath,
CreationDate: creationDate,
PieceSize: raw.PieceSize,
Comment: raw.Comment,
TotalWasted: raw.TotalWasted,
TotalUploaded: raw.TotalUploaded,
TotalUploadedSession: raw.TotalUploadedSession,
TotalDownloaded: raw.TotalDownloaded,
TotalDownloadedSession: raw.TotalDownloadedSession,
UpLimit: raw.UpLimit,
DlLimit: raw.DlLimit,
TimeElapsed: raw.TimeElapsed,
SeedingTime: seedingTime,
NbConnections: raw.NbConnections,
NbConnectionsLimit: raw.NbConnectionsLimit,
ShareRatio: raw.ShareRatio,
AdditionDate: additionDate,
CompletionDate: completionDate,
CreatedBy: raw.CreatedBy,
DlSpeedAvg: raw.DlSpeedAvg,
DlSpeed: raw.DlSpeed,
Eta: eta,
LastSeen: lastSeen,
Peers: raw.Peers,
PeersTotal: raw.PeersTotal,
PiecesHave: raw.PiecesHave,
PiecesNum: raw.PiecesNum,
Reannounce: reannounce,
Seeds: raw.Seeds,
SeedsTotal: raw.SeedsTotal,
TotalSize: raw.TotalSize,
UpSpeedAvg: raw.UpSpeedAvg,
UpSpeed: raw.UpSpeed,
}
return nil
}
type rawTorrentProperties struct {
// Torrent save path
SavePath string `json:"save_path"`
// Torrent creation date (Unix timestamp)
CreationDate int `json:"creation_date"`
// Torrent piece size (bytes)
PieceSize int `json:"piece_size"`
// Torrent comment
Comment string `json:"comment"`
// Total data wasted for torrent (bytes)
TotalWasted int `json:"total_wasted"`
// Total data uploaded for torrent (bytes)
TotalUploaded int `json:"total_uploaded"`
// Total data uploaded this session (bytes)
TotalUploadedSession int `json:"total_uploaded_session"`
// Total data downloaded for torrent (bytes)
TotalDownloaded int `json:"total_downloaded"`
// Total data downloaded this session (bytes)
TotalDownloadedSession int `json:"total_downloaded_session"`
// Torrent upload limit (bytes/s)
UpLimit int `json:"up_limit"`
// Torrent download limit (bytes/s)
DlLimit int `json:"dl_limit"`
// Torrent elapsed time (seconds)
TimeElapsed int `json:"time_elapsed"`
// Torrent elapsed time while complete (seconds)
SeedingTime int `json:"seeding_time"`
// Torrent connection count
NbConnections int `json:"nb_connections"`
// Torrent connection count limit
NbConnectionsLimit int `json:"nb_connections_limit"`
// Torrent share ratio
ShareRatio float64 `json:"share_ratio"`
// When this torrent was added (unix timestamp)
AdditionDate int `json:"addition_date"`
// Torrent completion date (unix timestamp)
CompletionDate int `json:"completion_date"`
// Torrent creator
CreatedBy string `json:"created_by"`
// Torrent average download speed (bytes/second)
DlSpeedAvg int `json:"dl_speed_avg"`
// Torrent download speed (bytes/second)
DlSpeed int `json:"dl_speed"`
// Torrent ETA (seconds)
Eta int `json:"eta"`
// Last seen complete date (unix timestamp)
LastSeen int `json:"last_seen"`
// Number of peers connected to
Peers int `json:"peers"`
// Number of peers in the swarm
PeersTotal int `json:"peers_total"`
// Number of pieces owned
PiecesHave int `json:"pieces_have"`
// Number of pieces of the torrent
PiecesNum int `json:"pieces_num"`
// Number of seconds until the next announce
Reannounce int `json:"reannounce"`
// Number of seeds connected to
Seeds int `json:"seeds"`
// Number of seeds in the swarm
SeedsTotal int `json:"seeds_total"`
// Torrent total size (bytes)
TotalSize int `json:"total_size"`
// Torrent average upload speed (bytes/second)
UpSpeedAvg int `json:"up_speed_avg"`
// Torrent upload speed (bytes/second)
UpSpeed int `json:"up_speed"`
}

View File

@@ -0,0 +1,22 @@
package qbittorrent_model
type TorrentTracker struct {
URL string `json:"url"`
Status TrackerStatus `json:"status"`
Tier int `json:"tier"`
NumPeers int `json:"num_peers"`
NumSeeds int `json:"num_seeds"`
NumLeeches int `json:"num_leeches"`
NumDownloaded int `json:"num_downloaded"`
Message string `json:"msg"`
}
type TrackerStatus int
const (
TrackerStatusDisabled TrackerStatus = iota
TrackerStatusNotContacted
TrackerStatusWorking
TrackerStatusUpdating
TrackerStatusNotWorking
)

View File

@@ -0,0 +1,23 @@
package qbittorrent_model
type TransferInfo struct {
ConnectionStatus ConnectionStatus `json:"connection_status"`
DhtNodes int `json:"dht_nodes"`
DlInfoData int `json:"dl_info_data"`
DlInfoSpeed int `json:"dl_info_speed"`
DlRateLimit int `json:"dl_rate_limit"`
UpInfoData int `json:"up_info_data"`
UpInfoSpeed int `json:"up_info_speed"`
UpRateLimit int `json:"up_rate_limit"`
UseAltSpeedLimits bool `json:"use_alt_speed_limits"`
Queueing bool `json:"queueing"`
RefreshInterval int `json:"refresh_interval"`
}
type ConnectionStatus string
const (
StatusConnected ConnectionStatus = "connected"
StatusFirewalled = "firewalled"
StatusDisconnected = "disconnected"
)

View File

@@ -0,0 +1,97 @@
package qbittorrent_rss
import (
"encoding/json"
"github.com/rs/zerolog"
"net/http"
"net/url"
qbittorrent_model "seanime/internal/torrent_clients/qbittorrent/model"
qbittorrent_util "seanime/internal/torrent_clients/qbittorrent/util"
)
type Client struct {
BaseUrl string
Client *http.Client
Logger *zerolog.Logger
}
func (c Client) AddFolder(folder string) error {
params := url.Values{}
params.Add("path", folder)
if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/addFolder?"+params.Encode(), nil); err != nil {
return err
}
return nil
}
func (c Client) AddFeed(link string, folder string) error {
params := url.Values{}
params.Add("path", folder)
if folder != "" {
params.Add("path", folder)
}
if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/addFeed?"+params.Encode(), nil); err != nil {
return err
}
return nil
}
func (c Client) RemoveItem(folder string) error {
params := url.Values{}
params.Add("path", folder)
if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/removeItem?"+params.Encode(), nil); err != nil {
return err
}
return nil
}
func (c Client) MoveItem(currentFolder, destinationFolder string) error {
params := url.Values{}
params.Add("itemPath", currentFolder)
params.Add("destPath", destinationFolder)
if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/moveItem?"+params.Encode(), nil); err != nil {
return err
}
return nil
}
func (c Client) AddRule(name string, def qbittorrent_model.RuleDefinition) error {
params := url.Values{}
b, err := json.Marshal(def)
if err != nil {
return err
}
params.Add("ruleName", name)
params.Add("ruleDef", string(b))
if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/setRule?"+params.Encode(), nil); err != nil {
return err
}
return nil
}
func (c Client) RenameRule(old, new string) error {
params := url.Values{}
params.Add("ruleName", old)
params.Add("newRuleName", new)
if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/renameRule?"+params.Encode(), nil); err != nil {
return err
}
return nil
}
func (c Client) RemoveRule(name string) error {
params := url.Values{}
params.Add("ruleName", name)
if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/removeRule?"+params.Encode(), nil); err != nil {
return err
}
return nil
}
func (c Client) GetRules() (map[string]qbittorrent_model.RuleDefinition, error) {
var res map[string]qbittorrent_model.RuleDefinition
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/rules", nil); err != nil {
return nil, err
}
return res, nil
}

View File

@@ -0,0 +1,140 @@
package qbittorrent_search
import (
"fmt"
"github.com/rs/zerolog"
"net/http"
"net/url"
"seanime/internal/torrent_clients/qbittorrent/model"
"seanime/internal/torrent_clients/qbittorrent/util"
"strconv"
"strings"
)
type Client struct {
BaseUrl string
Client *http.Client
Logger *zerolog.Logger
}
func (c Client) Start(pattern string, plugins, categories []string) (int, error) {
params := url.Values{}
params.Add("pattern", pattern)
params.Add("plugins", strings.Join(plugins, "|"))
params.Add("category", strings.Join(categories, "|"))
var res struct {
ID int `json:"id"`
}
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/start?"+params.Encode(), nil); err != nil {
return 0, err
}
return res.ID, nil
}
func (c Client) Stop(id int) error {
params := url.Values{}
params.Add("id", strconv.Itoa(id))
if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/stop?"+params.Encode(), nil); err != nil {
return err
}
return nil
}
func (c Client) GetStatus(id int) (*qbittorrent_model.SearchStatus, error) {
params := url.Values{}
params.Add("id", strconv.Itoa(id))
var res []*qbittorrent_model.SearchStatus
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/status?"+params.Encode(), nil); err != nil {
return nil, err
}
if len(res) < 1 {
return nil, fmt.Errorf("response did not contain any statuses")
}
return res[0], nil
}
func (c Client) GetStatuses() ([]*qbittorrent_model.SearchStatus, error) {
var res []*qbittorrent_model.SearchStatus
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/status", nil); err != nil {
return nil, err
}
return res, nil
}
func (c Client) GetResults(id, limit, offset int) (*qbittorrent_model.SearchResultsPaging, error) {
params := url.Values{}
params.Add("id", strconv.Itoa(id))
params.Add("limit", strconv.Itoa(limit))
params.Add("offset", strconv.Itoa(offset))
var res qbittorrent_model.SearchResultsPaging
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/results?"+params.Encode(), nil); err != nil {
return nil, err
}
return &res, nil
}
func (c Client) Delete(id int) error {
params := url.Values{}
params.Add("id", strconv.Itoa(id))
if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/delete?"+params.Encode(), nil); err != nil {
return err
}
return nil
}
func (c Client) GetCategories(plugins []string) ([]string, error) {
endpoint := c.BaseUrl + "/categories"
if plugins != nil {
params := url.Values{}
params.Add("pluginName", strings.Join(plugins, "|"))
endpoint += "?" + params.Encode()
}
var res []string
if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil {
return nil, err
}
return res, nil
}
func (c Client) GetPlugins() ([]qbittorrent_model.SearchPlugin, error) {
var res []qbittorrent_model.SearchPlugin
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/plugins", nil); err != nil {
return nil, err
}
return res, nil
}
func (c Client) InstallPlugins(sources []string) error {
params := url.Values{}
params.Add("sources", strings.Join(sources, "|"))
if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/installPlugin?"+params.Encode(), nil); err != nil {
return err
}
return nil
}
func (c Client) UninstallPlugins(plugins []string) error {
params := url.Values{}
params.Add("names", strings.Join(plugins, "|"))
if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/uninstallPlugin?"+params.Encode(), nil); err != nil {
return err
}
return nil
}
func (c Client) EnablePlugins(plugins []string, enable bool) error {
params := url.Values{}
params.Add("names", strings.Join(plugins, "|"))
params.Add("enable", fmt.Sprintf("%v", enable))
if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/enablePlugin?"+params.Encode(), nil); err != nil {
return err
}
return nil
}
func (c Client) updatePlugins() error {
if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/updatePlugins", nil); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,90 @@
package qbittorrent
import (
"errors"
"runtime"
"seanime/internal/util"
"time"
)
func (c *Client) getExecutableName() string {
switch runtime.GOOS {
case "windows":
return "qbittorrent.exe"
default:
return "qbittorrent"
}
}
func (c *Client) getExecutablePath() string {
if len(c.Path) > 0 {
return c.Path
}
switch runtime.GOOS {
case "windows":
return "C:/Program Files/qBittorrent/qbittorrent.exe"
case "linux":
return "/usr/bin/qbittorrent" // Default path for Client on most Linux distributions
case "darwin":
return "/Applications/qbittorrent.app/Contents/MacOS/qbittorrent" // Default path for Client on macOS
default:
return "C:/Program Files/qBittorrent/qbittorrent.exe"
}
}
func (c *Client) Start() error {
// If the path is empty, do not check if qBittorrent is running
if c.Path == "" {
return nil
}
name := c.getExecutableName()
if util.ProgramIsRunning(name) {
return nil
}
exe := c.getExecutablePath()
cmd := util.NewCmd(exe)
err := cmd.Start()
if err != nil {
return errors.New("failed to start qBittorrent")
}
time.Sleep(1 * time.Second)
return nil
}
func (c *Client) CheckStart() bool {
if c == nil {
return false
}
// If the path is empty, assume it's running
if c.Path == "" {
return true
}
_, err := c.Application.GetAppVersion()
if err == nil {
return true
}
err = c.Start()
timeout := time.After(30 * time.Second)
ticker := time.Tick(1 * time.Second)
for {
select {
case <-ticker:
_, err = c.Application.GetAppVersion()
if err == nil {
return true
}
case <-timeout:
return false
}
}
}

View File

@@ -0,0 +1,41 @@
package qbittorrent
import (
"github.com/stretchr/testify/assert"
"seanime/internal/test_utils"
"seanime/internal/util"
"testing"
)
func TestClient_Start(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.TorrentClient())
client := NewClient(&NewClientOptions{
Logger: util.NewLogger(),
Username: test_utils.ConfigData.Provider.QbittorrentUsername,
Password: test_utils.ConfigData.Provider.QbittorrentPassword,
Port: test_utils.ConfigData.Provider.QbittorrentPort,
Host: test_utils.ConfigData.Provider.QbittorrentHost,
Path: test_utils.ConfigData.Provider.QbittorrentPath,
})
err := client.Start()
assert.Nil(t, err)
}
func TestClient_CheckStart(t *testing.T) {
client := NewClient(&NewClientOptions{
Logger: util.NewLogger(),
Username: test_utils.ConfigData.Provider.QbittorrentUsername,
Password: test_utils.ConfigData.Provider.QbittorrentPassword,
Port: test_utils.ConfigData.Provider.QbittorrentPort,
Host: test_utils.ConfigData.Provider.QbittorrentHost,
Path: test_utils.ConfigData.Provider.QbittorrentPath,
})
started := client.CheckStart()
assert.True(t, started)
}

View File

@@ -0,0 +1,39 @@
package qbittorrent_sync
import (
"github.com/rs/zerolog"
"net/http"
"net/url"
"seanime/internal/torrent_clients/qbittorrent/model"
"seanime/internal/torrent_clients/qbittorrent/util"
"strconv"
)
type Client struct {
BaseUrl string
Client *http.Client
Logger *zerolog.Logger
}
func (c Client) GetMainData(rid int) (*qbittorrent_model.SyncMainData, error) {
params := url.Values{}
params.Add("rid", strconv.Itoa(rid))
endpoint := c.BaseUrl + "/maindata?" + params.Encode()
var res qbittorrent_model.SyncMainData
if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil {
return nil, err
}
return &res, nil
}
func (c Client) GetTorrentPeersData(hash string, rid int) (*qbittorrent_model.SyncPeersData, error) {
params := url.Values{}
params.Add("hash", hash)
params.Add("rid", strconv.Itoa(rid))
endpoint := c.BaseUrl + "/torrentPeers?" + params.Encode()
var res qbittorrent_model.SyncPeersData
if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil {
return nil, err
}
return &res, nil
}

View File

@@ -0,0 +1,451 @@
package qbittorrent_torrent
import (
"fmt"
"github.com/rs/zerolog"
"net/http"
"net/url"
qbittorrent_model "seanime/internal/torrent_clients/qbittorrent/model"
qbittorrent_util "seanime/internal/torrent_clients/qbittorrent/util"
"strconv"
"strings"
"github.com/google/go-querystring/query"
)
type Client struct {
BaseUrl string
Client *http.Client
Logger *zerolog.Logger
}
func (c Client) GetList(options *qbittorrent_model.GetTorrentListOptions) ([]*qbittorrent_model.Torrent, error) {
endpoint := c.BaseUrl + "/info"
if options != nil {
params, err := query.Values(options)
if err != nil {
return nil, err
}
endpoint += "?" + params.Encode()
}
var res []*qbittorrent_model.Torrent
if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil {
return nil, err
}
return res, nil
}
func (c Client) GetProperties(hash string) (*qbittorrent_model.TorrentProperties, error) {
params := url.Values{}
params.Add("hash", hash)
endpoint := c.BaseUrl + "/properties?" + params.Encode()
var res qbittorrent_model.TorrentProperties
if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil {
return nil, err
}
return &res, nil
}
func (c Client) GetTrackers(hash string) ([]*qbittorrent_model.TorrentTracker, error) {
params := url.Values{}
params.Add("hash", hash)
endpoint := c.BaseUrl + "/trackers?" + params.Encode()
var res []*qbittorrent_model.TorrentTracker
if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil {
return nil, err
}
return res, nil
}
func (c Client) GetWebSeeds(hash string) ([]string, error) {
params := url.Values{}
params.Add("hash", hash)
endpoint := c.BaseUrl + "/trackers?" + params.Encode()
var res []struct {
URL string `json:"url"`
}
if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil {
return nil, err
}
var seeds []string
for _, seed := range res {
seeds = append(seeds, seed.URL)
}
return seeds, nil
}
func (c Client) GetContents(hash string) ([]*qbittorrent_model.TorrentContent, error) {
params := url.Values{}
params.Add("hash", hash)
endpoint := c.BaseUrl + "/files?" + params.Encode()
var res []*qbittorrent_model.TorrentContent
if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil {
return nil, err
}
return res, nil
}
func (c Client) GetPieceStates(hash string) ([]qbittorrent_model.TorrentPieceState, error) {
params := url.Values{}
params.Add("hash", hash)
endpoint := c.BaseUrl + "/pieceStates?" + params.Encode()
var res []qbittorrent_model.TorrentPieceState
if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil {
return nil, err
}
return res, nil
}
func (c Client) GetPieceHashes(hash string) ([]string, error) {
params := url.Values{}
params.Add("hash", hash)
endpoint := c.BaseUrl + "/pieceHashes?" + params.Encode()
var res []string
if err := qbittorrent_util.GetInto(c.Client, &res, endpoint, nil); err != nil {
return nil, err
}
return res, nil
}
func (c Client) StopTorrents(hashes []string) error {
value := strings.Join(hashes, "|")
params := url.Values{}
params.Add("hashes", value)
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/pause", strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/stop", strings.NewReader(params.Encode()), "application/x-www-form-urlencoded")
}
return nil
}
func (c Client) ResumeTorrents(hashes []string) error {
value := strings.Join(hashes, "|")
params := url.Values{}
params.Add("hashes", value)
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/resume", strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/start", strings.NewReader(params.Encode()), "application/x-www-form-urlencoded")
}
return nil
}
func (c Client) DeleteTorrents(hashes []string, deleteFiles bool) error {
value := strings.Join(hashes, "|")
params := url.Values{}
params.Add("deleteFiles", fmt.Sprintf("%v", deleteFiles))
params.Add("hashes", value)
//endpoint := c.BaseUrl + "/delete?" + params.Encode()
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/delete", strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) RecheckTorrents(hashes []string) error {
value := strings.Join(hashes, "|")
params := url.Values{}
params.Add("hashes", value)
endpoint := c.BaseUrl + "/recheck?" + params.Encode()
if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil {
return err
}
return nil
}
func (c Client) ReannounceTorrents(hashes []string) error {
value := strings.Join(hashes, "|")
params := url.Values{}
params.Add("hashes", value)
endpoint := c.BaseUrl + "/reannounce?" + params.Encode()
if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil {
return err
}
return nil
}
func (c Client) AddURLs(urls []string, options *qbittorrent_model.AddTorrentsOptions) error {
if err := qbittorrent_util.PostMultipartLinks(c.Client, c.BaseUrl+"/add", options, urls); err != nil {
return err
}
return nil
}
func (c Client) AddFiles(files map[string][]byte, options *qbittorrent_model.AddTorrentsOptions) error {
if err := qbittorrent_util.PostMultipartFiles(c.Client, c.BaseUrl+"/add", options, files); err != nil {
return err
}
return nil
}
func (c Client) AddTrackers(hash string, trackerURLs []string) error {
params := url.Values{}
params.Add("hash", hash)
params.Add("urls", strings.Join(trackerURLs, "\n"))
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/addTrackers",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) EditTrackers(hash, old, new string) error {
params := url.Values{}
params.Add("hash", hash)
params.Add("origUrl", old)
params.Add("newUrl", new)
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/editTracker",
strings.NewReader(params.Encode()),
"application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) RemoveTrackers(hash string, trackerURLs []string) error {
params := url.Values{}
params.Add("hash", hash)
params.Add("urls", strings.Join(trackerURLs, "|"))
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/removeTrackers",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) IncreasePriority(hashes []string) error {
value := strings.Join(hashes, "|")
params := url.Values{}
params.Add("hashes", value)
endpoint := c.BaseUrl + "/increasePrio?" + params.Encode()
if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil {
return err
}
return nil
}
func (c Client) DecreasePriority(hashes []string) error {
value := strings.Join(hashes, "|")
params := url.Values{}
params.Add("hashes", value)
endpoint := c.BaseUrl + "/decreasePrio?" + params.Encode()
if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil {
return err
}
return nil
}
func (c Client) SetMaximumPriority(hashes []string) error {
value := strings.Join(hashes, "|")
params := url.Values{}
params.Add("hashes", value)
endpoint := c.BaseUrl + "/topPrio?" + params.Encode()
if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil {
return err
}
return nil
}
func (c Client) SetMinimumPriority(hashes []string) error {
value := strings.Join(hashes, "|")
params := url.Values{}
params.Add("hashes", value)
endpoint := c.BaseUrl + "/bottomPrio?" + params.Encode()
if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil {
return err
}
return nil
}
func (c Client) SetFilePriorities(hash string, ids []string, priority qbittorrent_model.TorrentPriority) error {
params := url.Values{}
params.Add("hash", hash)
params.Add("id", strings.Join(ids, "|"))
params.Add("priority", strconv.Itoa(int(priority)))
//endpoint := c.BaseUrl + "/filePrio?" + params.Encode()
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/filePrio", strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) GetDownloadLimits(hashes []string) (map[string]int, error) {
params := url.Values{}
params.Add("hashes", strings.Join(hashes, "|"))
var res map[string]int
if err := qbittorrent_util.GetIntoWithContentType(c.Client, &res, c.BaseUrl+"/downloadLimit",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return nil, err
}
return res, nil
}
func (c Client) SetDownloadLimits(hashes []string, limit int) error {
params := url.Values{}
params.Add("hashes", strings.Join(hashes, "|"))
params.Add("limit", strconv.Itoa(limit))
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setDownloadLimit",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) SetShareLimits(hashes []string, ratioLimit float64, seedingTimeLimit int) error {
params := url.Values{}
params.Add("hashes", strings.Join(hashes, "|"))
params.Add("ratioLimit", strconv.FormatFloat(ratioLimit, 'f', -1, 64))
params.Add("seedingTimeLimit", strconv.Itoa(seedingTimeLimit))
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setShareLimits",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) GetUploadLimits(hashes []string) (map[string]int, error) {
params := url.Values{}
params.Add("hashes", strings.Join(hashes, "|"))
var res map[string]int
if err := qbittorrent_util.GetIntoWithContentType(c.Client, &res, c.BaseUrl+"/uploadLimit",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return nil, err
}
return res, nil
}
func (c Client) SetUploadLimits(hashes []string, limit int) error {
params := url.Values{}
params.Add("hashes", strings.Join(hashes, "|"))
params.Add("limit", strconv.Itoa(limit))
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setUploadLimit",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) SetLocations(hashes []string, location string) error {
params := url.Values{}
params.Add("hashes", strings.Join(hashes, "|"))
params.Add("location", location)
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setLocation",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) SetName(hash string, name string) error {
params := url.Values{}
params.Add("hash", hash)
params.Add("name", name)
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/rename",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) SetCategories(hashes []string, category string) error {
params := url.Values{}
params.Add("hashes", strings.Join(hashes, "|"))
params.Add("category", category)
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setCategory",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) GetCategories() (map[string]*qbittorrent_model.Category, error) {
var res map[string]*qbittorrent_model.Category
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/categories", nil); err != nil {
return nil, err
}
return res, nil
}
func (c Client) AddCategory(category string, savePath string) error {
params := url.Values{}
params.Add("category", category)
params.Add("savePath", savePath)
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/createCategory",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) EditCategory(category string, savePath string) error {
params := url.Values{}
params.Add("category", category)
params.Add("savePath", savePath)
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/editCategory",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) RemoveCategory(categories []string) error {
params := url.Values{}
params.Add("categories", strings.Join(categories, "\n"))
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/removeCategories",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) SetAutomaticManagement(hashes []string, enable bool) error {
params := url.Values{}
params.Add("hashes", strings.Join(hashes, "|"))
params.Add("enable", fmt.Sprintf("%v", enable))
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setAutoManagement",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) ToggleSequentialDownload(hashes []string) error {
value := strings.Join(hashes, "|")
params := url.Values{}
params.Add("hashes", value)
endpoint := c.BaseUrl + "/toggleSequentialDownload?" + params.Encode()
if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil {
return err
}
return nil
}
func (c Client) ToggleFirstLastPiecePriority(hashes []string) error {
value := strings.Join(hashes, "|")
params := url.Values{}
params.Add("hashes", value)
endpoint := c.BaseUrl + "/toggleFirstLastPiecePrio?" + params.Encode()
if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil {
return err
}
return nil
}
func (c Client) SetForceStart(hashes []string, enable bool) error {
params := url.Values{}
params.Add("hashes", strings.Join(hashes, "|"))
params.Add("value", fmt.Sprintf("%v", enable))
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setForceStart",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}
func (c Client) SetSuperSeeding(hashes []string, enable bool) error {
params := url.Values{}
params.Add("hashes", strings.Join(hashes, "|"))
params.Add("value", fmt.Sprintf("%v", enable))
if err := qbittorrent_util.PostWithContentType(c.Client, c.BaseUrl+"/setSuperSeeding",
strings.NewReader(params.Encode()), "application/x-www-form-urlencoded"); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,75 @@
package qbittorrent_transfer
import (
"github.com/rs/zerolog"
"net/http"
"net/url"
"seanime/internal/torrent_clients/qbittorrent/model"
"seanime/internal/torrent_clients/qbittorrent/util"
"strconv"
)
type Client struct {
BaseUrl string
Client *http.Client
Logger *zerolog.Logger
}
func (c Client) GetTransferInfo() (*qbittorrent_model.TransferInfo, error) {
var res qbittorrent_model.TransferInfo
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/info", nil); err != nil {
return nil, err
}
return &res, nil
}
func (c Client) AlternativeSpeedLimitsEnabled() (bool, error) {
var res int
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/speedLimitsMode", nil); err != nil {
return false, err
}
return res == 1, nil
}
func (c Client) ToggleAlternativeSpeedLimits() error {
if err := qbittorrent_util.Post(c.Client, c.BaseUrl+"/toggleSpeedLimitsMode", nil); err != nil {
return err
}
return nil
}
func (c Client) GetGlobalDownloadLimit() (int, error) {
var res int
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/downloadLimit", nil); err != nil {
return 0, err
}
return res, nil
}
func (c Client) SetGlobalDownloadLimit(limit int) error {
params := url.Values{}
params.Add("limit", strconv.Itoa(limit))
endpoint := c.BaseUrl + "/setDownloadLimit?" + params.Encode()
if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil {
return err
}
return nil
}
func (c Client) GetGlobalUploadLimit() (int, error) {
var res int
if err := qbittorrent_util.GetInto(c.Client, &res, c.BaseUrl+"/uploadLimit", nil); err != nil {
return 0, err
}
return res, nil
}
func (c Client) SetGlobalUploadLimit(limit int) error {
params := url.Values{}
params.Add("limit", strconv.Itoa(limit))
endpoint := c.BaseUrl + "/setUploadLimit?" + params.Encode()
if err := qbittorrent_util.Post(c.Client, endpoint, nil); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,228 @@
package qbittorrent_util
import (
"bytes"
"fmt"
"github.com/goccy/go-json"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"seanime/internal/torrent_clients/qbittorrent/model"
"strings"
)
func GetInto(client *http.Client, target interface{}, url string, body interface{}) (err error) {
var buffer bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buffer).Encode(body); err != nil {
return err
}
}
r, err := http.NewRequest("GET", url, &buffer)
if err != nil {
return err
}
resp, err := client.Do(r)
if err != nil {
return err
}
defer func() {
if err2 := resp.Body.Close(); err2 != nil {
err = err2
}
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("invalid response status %s", resp.Status)
}
buf, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if err := json.NewDecoder(bytes.NewReader(buf)).Decode(target); err != nil {
if err2 := json.NewDecoder(strings.NewReader(`"` + string(buf) + `"`)).Decode(target); err2 != nil {
return err
}
}
return nil
}
func Post(client *http.Client, url string, body interface{}) (err error) {
var buffer bytes.Buffer
if err := json.NewEncoder(&buffer).Encode(body); err != nil {
return err
}
r, err := http.NewRequest("POST", url, &buffer)
if err != nil {
return err
}
resp, err := client.Do(r)
if err != nil {
return err
}
defer func() {
if err2 := resp.Body.Close(); err2 != nil {
err = err2
}
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("invalid status %s", resp.Status)
}
return nil
}
func createFormFileWithHeader(writer *multipart.Writer, name, filename string, headers map[string]string) (io.Writer, error) {
header := textproto.MIMEHeader{}
header.Add("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, name, filename))
for key, value := range headers {
header.Add(key, value)
}
return writer.CreatePart(header)
}
func PostMultipartLinks(client *http.Client, url string, options *qbittorrent_model.AddTorrentsOptions, links []string) (err error) {
var o map[string]interface{}
if options != nil {
b, err := json.Marshal(options)
if err != nil {
return err
}
if err := json.Unmarshal(b, &o); err != nil {
return err
}
}
buf := bytes.Buffer{}
form := multipart.NewWriter(&buf)
if err := form.WriteField("urls", strings.Join(links, "\n")); err != nil {
return err
}
for key, value := range o {
if err := form.WriteField(key, fmt.Sprintf("%v", value)); err != nil {
return err
}
}
if err := form.Close(); err != nil {
return err
}
req, err := http.NewRequest("POST", url, &buf)
if err != nil {
return err
}
req.Header.Add("content-type", "multipart/form-data; boundary="+form.Boundary())
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() {
if err2 := resp.Body.Close(); err2 != nil {
err = err2
}
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("invalid status %s", resp.Status)
}
return nil
}
func PostMultipartFiles(client *http.Client, url string, options *qbittorrent_model.AddTorrentsOptions, files map[string][]byte) (err error) {
var o map[string]interface{}
if options != nil {
b, err := json.Marshal(options)
if err != nil {
return err
}
if err := json.Unmarshal(b, &o); err != nil {
return err
}
}
buf := bytes.Buffer{}
form := multipart.NewWriter(&buf)
for filename, file := range files {
writer, err := createFormFileWithHeader(form, "torrents", filename, map[string]string{
"content-type": "application/x-bittorrent",
})
if err != nil {
return err
}
if _, err := writer.Write(file); err != nil {
return err
}
}
for key, value := range o {
if err := form.WriteField(key, fmt.Sprintf("%v", value)); err != nil {
return err
}
}
if err := form.Close(); err != nil {
return err
}
req, err := http.NewRequest("POST", url, &buf)
if err != nil {
return err
}
req.Header.Add("content-type", "multipart/form-data; boundary="+form.Boundary())
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() {
if err2 := resp.Body.Close(); err2 != nil {
err = err2
}
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("invalid status %s", resp.Status)
}
return nil
}
func PostWithContentType(client *http.Client, url string, body io.Reader, contentType string) (err error) {
r, err := http.NewRequest("POST", url, body)
if err != nil {
return err
}
r.Header.Add("content-type", contentType)
resp, err := client.Do(r)
if err != nil {
return err
}
defer func() {
if err2 := resp.Body.Close(); err2 != nil {
err = err2
}
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("invalid status %s", resp.Status)
}
return nil
}
func GetIntoWithContentType(client *http.Client, target interface{}, url string, body io.Reader, contentType string) (err error) {
r, err := http.NewRequest("GET", url, body)
if err != nil {
return err
}
r.Header.Add("content-type", contentType)
resp, err := client.Do(r)
if err != nil {
return err
}
defer func() {
if err2 := resp.Body.Close(); err2 != nil {
err = err2
}
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("invalid response status %s", resp.Status)
}
buf, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if err := json.NewDecoder(bytes.NewReader(buf)).Decode(target); err != nil {
if err2 := json.NewDecoder(strings.NewReader(`"` + string(buf) + `"`)).Decode(target); err2 != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1 @@
package torrent_client

View File

@@ -0,0 +1,420 @@
package torrent_client
import (
"context"
"errors"
"github.com/hekmon/transmissionrpc/v3"
"github.com/rs/zerolog"
"seanime/internal/api/metadata"
"seanime/internal/events"
"seanime/internal/torrent_clients/qbittorrent"
"seanime/internal/torrent_clients/qbittorrent/model"
"seanime/internal/torrent_clients/transmission"
"seanime/internal/torrents/torrent"
"strconv"
"time"
)
const (
QbittorrentClient = "qbittorrent"
TransmissionClient = "transmission"
NoneClient = "none"
)
type (
Repository struct {
logger *zerolog.Logger
qBittorrentClient *qbittorrent.Client
transmission *transmission.Transmission
torrentRepository *torrent.Repository
provider string
metadataProvider metadata.Provider
activeTorrentCountCtxCancel context.CancelFunc
activeTorrentCount *ActiveCount
}
NewRepositoryOptions struct {
Logger *zerolog.Logger
QbittorrentClient *qbittorrent.Client
Transmission *transmission.Transmission
TorrentRepository *torrent.Repository
Provider string
MetadataProvider metadata.Provider
}
ActiveCount struct {
Downloading int `json:"downloading"`
Seeding int `json:"seeding"`
Paused int `json:"paused"`
}
)
func NewRepository(opts *NewRepositoryOptions) *Repository {
if opts.Provider == "" {
opts.Provider = QbittorrentClient
}
return &Repository{
logger: opts.Logger,
qBittorrentClient: opts.QbittorrentClient,
transmission: opts.Transmission,
torrentRepository: opts.TorrentRepository,
provider: opts.Provider,
metadataProvider: opts.MetadataProvider,
activeTorrentCount: &ActiveCount{},
}
}
func (r *Repository) Shutdown() {
if r.activeTorrentCountCtxCancel != nil {
r.activeTorrentCountCtxCancel()
r.activeTorrentCountCtxCancel = nil
}
}
func (r *Repository) InitActiveTorrentCount(enabled bool, wsEventManager events.WSEventManagerInterface) {
if r.activeTorrentCountCtxCancel != nil {
r.activeTorrentCountCtxCancel()
}
if !enabled {
return
}
var ctx context.Context
ctx, r.activeTorrentCountCtxCancel = context.WithCancel(context.Background())
go func(ctx context.Context) {
ticker := time.NewTicker(time.Second * 5)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.GetActiveCount(r.activeTorrentCount)
wsEventManager.SendEvent(events.ActiveTorrentCountUpdated, r.activeTorrentCount)
}
}
}(ctx)
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (r *Repository) GetProvider() string {
return r.provider
}
func (r *Repository) Start() bool {
switch r.provider {
case QbittorrentClient:
return r.qBittorrentClient.CheckStart()
case TransmissionClient:
return r.transmission.CheckStart()
case NoneClient:
return true
default:
return false
}
}
func (r *Repository) TorrentExists(hash string) bool {
switch r.provider {
case QbittorrentClient:
p, err := r.qBittorrentClient.Torrent.GetProperties(hash)
return err == nil && p != nil
case TransmissionClient:
torrents, err := r.transmission.Client.TorrentGetAllForHashes(context.Background(), []string{hash})
return err == nil && len(torrents) > 0
default:
return false
}
}
// GetList will return all torrents from the torrent client.
func (r *Repository) GetList() ([]*Torrent, error) {
switch r.provider {
case QbittorrentClient:
torrents, err := r.qBittorrentClient.Torrent.GetList(&qbittorrent_model.GetTorrentListOptions{Filter: "all"})
if err != nil {
r.logger.Err(err).Msg("torrent client: Error while getting torrent list (qBittorrent)")
return nil, err
}
return r.FromQbitTorrents(torrents), nil
case TransmissionClient:
torrents, err := r.transmission.Client.TorrentGetAll(context.Background())
if err != nil {
r.logger.Err(err).Msg("torrent client: Error while getting torrent list (Transmission)")
return nil, err
}
return r.FromTransmissionTorrents(torrents), nil
default:
return nil, errors.New("torrent client: No torrent client provider found")
}
}
// GetActiveCount will return the count of active torrents (downloading, seeding, paused).
func (r *Repository) GetActiveCount(ret *ActiveCount) {
ret.Seeding = 0
ret.Downloading = 0
ret.Paused = 0
switch r.provider {
case QbittorrentClient:
torrents, err := r.qBittorrentClient.Torrent.GetList(&qbittorrent_model.GetTorrentListOptions{Filter: "downloading"})
if err != nil {
return
}
torrents2, err := r.qBittorrentClient.Torrent.GetList(&qbittorrent_model.GetTorrentListOptions{Filter: "seeding"})
if err != nil {
return
}
torrents = append(torrents, torrents2...)
for _, t := range torrents {
switch fromQbitTorrentStatus(t.State) {
case TorrentStatusDownloading:
ret.Downloading++
case TorrentStatusSeeding:
ret.Seeding++
case TorrentStatusPaused:
ret.Paused++
}
}
case TransmissionClient:
torrents, err := r.transmission.Client.TorrentGet(context.Background(), []string{"id", "status", "isFinished"}, nil)
if err != nil {
return
}
for _, t := range torrents {
if t.Status == nil || t.IsFinished == nil {
continue
}
switch fromTransmissionTorrentStatus(*t.Status, *t.IsFinished) {
case TorrentStatusDownloading:
ret.Downloading++
case TorrentStatusSeeding:
ret.Seeding++
case TorrentStatusPaused:
ret.Paused++
}
}
return
default:
return
}
}
// GetActiveTorrents will return all torrents that are currently downloading, paused or seeding.
func (r *Repository) GetActiveTorrents() ([]*Torrent, error) {
torrents, err := r.GetList()
if err != nil {
return nil, err
}
var active []*Torrent
for _, t := range torrents {
if t.Status == TorrentStatusDownloading || t.Status == TorrentStatusSeeding || t.Status == TorrentStatusPaused {
active = append(active, t)
}
}
return active, nil
}
func (r *Repository) AddMagnets(magnets []string, dest string) error {
r.logger.Trace().Any("magnets", magnets).Msg("torrent client: Adding magnets")
if len(magnets) == 0 {
r.logger.Debug().Msg("torrent client: No magnets to add")
return nil
}
var err error
switch r.provider {
case QbittorrentClient:
err = r.qBittorrentClient.Torrent.AddURLs(magnets, &qbittorrent_model.AddTorrentsOptions{
Savepath: dest,
Tags: r.qBittorrentClient.Tags,
})
case TransmissionClient:
for _, magnet := range magnets {
_, err = r.transmission.Client.TorrentAdd(context.Background(), transmissionrpc.TorrentAddPayload{
Filename: &magnet,
DownloadDir: &dest,
})
if err != nil {
r.logger.Err(err).Msg("torrent client: Error while adding magnets (Transmission)")
break
}
}
case NoneClient:
return errors.New("torrent client: No torrent client selected")
}
if err != nil {
r.logger.Err(err).Msg("torrent client: Error while adding magnets")
return err
}
r.logger.Debug().Msg("torrent client: Added torrents")
return nil
}
func (r *Repository) RemoveTorrents(hashes []string) error {
r.logger.Trace().Msg("torrent client: Removing torrents")
var err error
switch r.provider {
case QbittorrentClient:
err = r.qBittorrentClient.Torrent.DeleteTorrents(hashes, true)
case TransmissionClient:
torrents, err := r.transmission.Client.TorrentGetAllForHashes(context.Background(), hashes)
if err != nil {
r.logger.Err(err).Msg("torrent client: Error while fetching torrents (Transmission)")
return err
}
ids := make([]int64, len(torrents))
for i, t := range torrents {
ids[i] = *t.ID
}
err = r.transmission.Client.TorrentRemove(context.Background(), transmissionrpc.TorrentRemovePayload{
IDs: ids,
DeleteLocalData: true,
})
if err != nil {
r.logger.Err(err).Msg("torrent client: Error while removing torrents (Transmission)")
return err
}
}
if err != nil {
r.logger.Err(err).Msg("torrent client: Error while removing torrents")
return err
}
r.logger.Debug().Any("hashes", hashes).Msg("torrent client: Removed torrents")
return nil
}
func (r *Repository) PauseTorrents(hashes []string) error {
r.logger.Trace().Msg("torrent client: Pausing torrents")
var err error
switch r.provider {
case QbittorrentClient:
err = r.qBittorrentClient.Torrent.StopTorrents(hashes)
case TransmissionClient:
err = r.transmission.Client.TorrentStopHashes(context.Background(), hashes)
}
if err != nil {
r.logger.Err(err).Msg("torrent client: Error while pausing torrents")
return err
}
r.logger.Debug().Any("hashes", hashes).Msg("torrent client: Paused torrents")
return nil
}
func (r *Repository) ResumeTorrents(hashes []string) error {
r.logger.Trace().Msg("torrent client: Resuming torrents")
var err error
switch r.provider {
case QbittorrentClient:
err = r.qBittorrentClient.Torrent.ResumeTorrents(hashes)
case TransmissionClient:
err = r.transmission.Client.TorrentStartHashes(context.Background(), hashes)
}
if err != nil {
r.logger.Err(err).Msg("torrent client: Error while resuming torrents")
return err
}
r.logger.Debug().Any("hashes", hashes).Msg("torrent client: Resumed torrents")
return nil
}
func (r *Repository) DeselectFiles(hash string, indices []int) error {
var err error
switch r.provider {
case QbittorrentClient:
strIndices := make([]string, len(indices), len(indices))
for i, v := range indices {
strIndices[i] = strconv.Itoa(v)
}
err = r.qBittorrentClient.Torrent.SetFilePriorities(hash, strIndices, 0)
case TransmissionClient:
torrents, err := r.transmission.Client.TorrentGetAllForHashes(context.Background(), []string{hash})
if err != nil || torrents[0].ID == nil {
r.logger.Err(err).Msg("torrent client: Error while deselecting files (Transmission)")
return err
}
id := *torrents[0].ID
ind := make([]int64, len(indices), len(indices))
for i, v := range indices {
ind[i] = int64(v)
}
err = r.transmission.Client.TorrentSet(context.Background(), transmissionrpc.TorrentSetPayload{
FilesUnwanted: ind,
IDs: []int64{id},
})
}
if err != nil {
r.logger.Err(err).Msg("torrent client: Error while deselecting files")
return err
}
r.logger.Debug().Str("hash", hash).Any("indices", indices).Msg("torrent client: Deselected torrent files")
return nil
}
// GetFiles blocks until the files are retrieved, or until timeout.
func (r *Repository) GetFiles(hash string) (filenames []string, err error) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
filenames = make([]string, 0)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
done := make(chan struct{})
go func() {
r.logger.Debug().Str("hash", hash).Msg("torrent client: Getting torrent files")
defer close(done)
for {
select {
case <-ctx.Done():
err = errors.New("torrent client: Unable to retrieve torrent files (timeout)")
return
case <-ticker.C:
switch r.provider {
case QbittorrentClient:
qbitFiles, err := r.qBittorrentClient.Torrent.GetContents(hash)
if err == nil && qbitFiles != nil && len(qbitFiles) > 0 {
r.logger.Debug().Str("hash", hash).Int("count", len(qbitFiles)).Msg("torrent client: Retrieved torrent files")
for _, f := range qbitFiles {
filenames = append(filenames, f.Name)
}
return
}
case TransmissionClient:
torrents, err := r.transmission.Client.TorrentGetAllForHashes(context.Background(), []string{hash})
if err == nil && len(torrents) > 0 && torrents[0].Files != nil && len(torrents[0].Files) > 0 {
transmissionFiles := torrents[0].Files
r.logger.Debug().Str("hash", hash).Int("count", len(transmissionFiles)).Msg("torrent client: Retrieved torrent files")
for _, f := range transmissionFiles {
filenames = append(filenames, f.Name)
}
return
}
}
}
}
}()
<-done // wait for the files to be retrieved
return
}

View File

@@ -0,0 +1,154 @@
package torrent_client
import (
"errors"
"fmt"
"seanime/internal/api/anilist"
"seanime/internal/platforms/platform"
"seanime/internal/torrents/analyzer"
"time"
hibiketorrent "seanime/internal/extension/hibike/torrent"
)
type (
SmartSelectParams struct {
Torrent *hibiketorrent.AnimeTorrent
EpisodeNumbers []int
Media *anilist.CompleteAnime
Destination string
ShouldAddTorrent bool
Platform platform.Platform
}
)
// SmartSelect will automatically the provided episode files from the torrent.
// If the torrent has not been added yet, set SmartSelect.ShouldAddTorrent to true.
// The torrent will NOT be removed if the selection fails.
func (r *Repository) SmartSelect(p *SmartSelectParams) error {
if p.Media == nil || p.Platform == nil || r.torrentRepository == nil {
r.logger.Error().Msg("torrent client: media or platform is nil (smart select)")
return errors.New("media or anilist client wrapper is nil")
}
providerExtension, ok := r.torrentRepository.GetAnimeProviderExtension(p.Torrent.Provider)
if !ok {
r.logger.Error().Str("provider", p.Torrent.Provider).Msg("torrent client: provider extension not found (smart select)")
return errors.New("provider extension not found")
}
if p.Media.IsMovieOrSingleEpisode() {
return errors.New("smart select is not supported for movies or single-episode series")
}
if len(p.EpisodeNumbers) == 0 {
r.logger.Error().Msg("torrent client: no episode numbers provided (smart select)")
return errors.New("no episode numbers provided")
}
if p.ShouldAddTorrent {
r.logger.Info().Msg("torrent client: adding torrent (smart select)")
// Get magnet
magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(p.Torrent)
if err != nil {
return err
}
// Add the torrent
err = r.AddMagnets([]string{magnet}, p.Destination)
if err != nil {
return err
}
}
filepaths, err := r.GetFiles(p.Torrent.InfoHash)
if err != nil {
r.logger.Err(err).Msg("torrent client: error getting files (smart select)")
_ = r.RemoveTorrents([]string{p.Torrent.InfoHash})
return fmt.Errorf("error getting files, torrent still added: %w", err)
}
// Pause the torrent
err = r.PauseTorrents([]string{p.Torrent.InfoHash})
if err != nil {
r.logger.Err(err).Msg("torrent client: error while pausing torrent (smart select)")
_ = r.RemoveTorrents([]string{p.Torrent.InfoHash})
return fmt.Errorf("error while selecting files: %w", err)
}
// AnalyzeTorrentFiles the torrent files
analyzer := torrent_analyzer.NewAnalyzer(&torrent_analyzer.NewAnalyzerOptions{
Logger: r.logger,
Filepaths: filepaths,
Media: p.Media,
Platform: p.Platform,
MetadataProvider: r.metadataProvider,
})
r.logger.Debug().Msg("torrent client: analyzing torrent files (smart select)")
analysis, err := analyzer.AnalyzeTorrentFiles()
if err != nil {
r.logger.Err(err).Msg("torrent client: error while analyzing torrent files (smart select)")
_ = r.RemoveTorrents([]string{p.Torrent.InfoHash})
return fmt.Errorf("error while analyzing torrent files: %w", err)
}
r.logger.Debug().Msg("torrent client: finished analyzing torrent files (smart select)")
mainFiles := analysis.GetCorrespondingMainFiles()
// find episode number duplicates
dup := make(map[int]int) // map[episodeNumber]count
for _, f := range mainFiles {
if _, ok := dup[f.GetLocalFile().GetEpisodeNumber()]; ok {
dup[f.GetLocalFile().GetEpisodeNumber()]++
} else {
dup[f.GetLocalFile().GetEpisodeNumber()] = 1
}
}
dupCount := 0
for _, count := range dup {
if count > 1 {
dupCount++
}
}
if dupCount > 2 {
_ = r.RemoveTorrents([]string{p.Torrent.InfoHash})
return errors.New("failed to select files, can't tell seasons apart")
}
selectedFiles := make(map[int]*torrent_analyzer.File)
selectedCount := 0
for idx, f := range mainFiles {
for _, ep := range p.EpisodeNumbers {
if f.GetLocalFile().GetEpisodeNumber() == ep {
selectedCount++
selectedFiles[idx] = f
}
}
}
if selectedCount == 0 || selectedCount < len(p.EpisodeNumbers) {
_ = r.RemoveTorrents([]string{p.Torrent.InfoHash})
return errors.New("failed to select files, could not find the right season files")
}
indicesToRemove := analysis.GetUnselectedIndices(selectedFiles)
if len(indicesToRemove) > 0 {
// Deselect files
err = r.DeselectFiles(p.Torrent.InfoHash, indicesToRemove)
if err != nil {
r.logger.Err(err).Msg("torrent client: error while deselecting files (smart select)")
_ = r.RemoveTorrents([]string{p.Torrent.InfoHash})
return fmt.Errorf("error while deselecting files: %w", err)
}
}
time.Sleep(1 * time.Second)
// Resume the torrent
_ = r.ResumeTorrents([]string{p.Torrent.InfoHash})
return nil
}

View File

@@ -0,0 +1,80 @@
package torrent_client
//func TestSmartSelect(t *testing.T) {
// t.Skip("Refactor test")
// test_utils.InitTestProvider(t, test_utils.TorrentClient())
//
// _ = t.TempDir()
//
// anilistClient := anilist.TestGetMockAnilistClient()
// _ = anilist_platform.NewAnilistPlatform(anilistClient, util.NewLogger())
//
// // get repo
//
// tests := []struct {
// name string
// mediaId int
// url string
// selectedEpisodes []int
// client string
// }{
// {
// name: "Kakegurui xx (Season 2)",
// mediaId: 100876,
// url: "https://nyaa.si/view/1553978", // kakegurui season 1 + season 2
// selectedEpisodes: []int{10, 11, 12}, // should select 10, 11, 12 in season 2
// client: QbittorrentClient,
// },
// {
// name: "Spy x Family",
// mediaId: 140960,
// url: "https://nyaa.si/view/1661695", // spy x family (01-25)
// selectedEpisodes: []int{10, 11, 12}, // should select 10, 11, 12
// client: QbittorrentClient,
// },
// {
// name: "Spy x Family Part 2",
// mediaId: 142838,
// url: "https://nyaa.si/view/1661695", // spy x family (01-25)
// selectedEpisodes: []int{10, 11, 12, 13}, // should select 22, 23, 24, 25
// client: QbittorrentClient,
// },
// {
// name: "Kakegurui xx (Season 2)",
// mediaId: 100876,
// url: "https://nyaa.si/view/1553978", // kakegurui season 1 + season 2
// selectedEpisodes: []int{10, 11, 12}, // should select 10, 11, 12 in season 2
// client: TransmissionClient,
// },
// {
// name: "Spy x Family",
// mediaId: 140960,
// url: "https://nyaa.si/view/1661695", // spy x family (01-25)
// selectedEpisodes: []int{10, 11, 12}, // should select 10, 11, 12
// client: TransmissionClient,
// },
// {
// name: "Spy x Family Part 2",
// mediaId: 142838,
// url: "https://nyaa.si/view/1661695", // spy x family (01-25)
// selectedEpisodes: []int{10, 11, 12, 13}, // should select 22, 23, 24, 25
// client: TransmissionClient,
// },
// }
//
// for _, tt := range tests {
//
// t.Run(tt.name, func(t *testing.T) {
//
// repo := getTestRepo(t, tt.client)
//
// ok := repo.Start()
// if !assert.True(t, ok) {
// return
// }
//
// })
//
// }
//
//}

View File

@@ -0,0 +1,164 @@
package torrent_client
import (
"seanime/internal/torrent_clients/qbittorrent/model"
"seanime/internal/util"
"github.com/hekmon/transmissionrpc/v3"
)
const (
TorrentStatusDownloading TorrentStatus = "downloading"
TorrentStatusSeeding TorrentStatus = "seeding"
TorrentStatusPaused TorrentStatus = "paused"
TorrentStatusOther TorrentStatus = "other"
TorrentStatusStopped TorrentStatus = "stopped"
)
type (
Torrent struct {
Name string `json:"name"`
Hash string `json:"hash"`
Seeds int `json:"seeds"`
UpSpeed string `json:"upSpeed"`
DownSpeed string `json:"downSpeed"`
Progress float64 `json:"progress"`
Size string `json:"size"`
Eta string `json:"eta"`
Status TorrentStatus `json:"status"`
ContentPath string `json:"contentPath"`
}
TorrentStatus string
)
//var torrentPool = util.NewPool[*Torrent](func() *Torrent {
// return &Torrent{}
//})
func (r *Repository) FromTransmissionTorrents(t []transmissionrpc.Torrent) []*Torrent {
ret := make([]*Torrent, 0, len(t))
for _, t := range t {
ret = append(ret, r.FromTransmissionTorrent(&t))
}
return ret
}
func (r *Repository) FromTransmissionTorrent(t *transmissionrpc.Torrent) *Torrent {
torrent := &Torrent{}
torrent.Name = "N/A"
if t.Name != nil {
torrent.Name = *t.Name
}
torrent.Hash = "N/A"
if t.HashString != nil {
torrent.Hash = *t.HashString
}
torrent.Seeds = 0
if t.PeersSendingToUs != nil {
torrent.Seeds = int(*t.PeersSendingToUs)
}
torrent.UpSpeed = "0 KB/s"
if t.RateUpload != nil {
torrent.UpSpeed = util.ToHumanReadableSpeed(int(*t.RateUpload))
}
torrent.DownSpeed = "0 KB/s"
if t.RateDownload != nil {
torrent.DownSpeed = util.ToHumanReadableSpeed(int(*t.RateDownload))
}
torrent.Progress = 0.0
if t.PercentDone != nil {
torrent.Progress = *t.PercentDone
}
torrent.Size = "N/A"
if t.TotalSize != nil {
torrent.Size = util.Bytes(uint64(*t.TotalSize))
}
torrent.Eta = "???"
if t.ETA != nil {
torrent.Eta = util.FormatETA(int(*t.ETA))
}
torrent.ContentPath = ""
if t.DownloadDir != nil {
torrent.ContentPath = *t.DownloadDir
}
torrent.Status = TorrentStatusOther
if t.Status != nil && t.IsFinished != nil {
torrent.Status = fromTransmissionTorrentStatus(*t.Status, *t.IsFinished)
}
return torrent
}
// fromTransmissionTorrentStatus returns a normalized status for the torrent.
func fromTransmissionTorrentStatus(st transmissionrpc.TorrentStatus, isFinished bool) TorrentStatus {
if st == transmissionrpc.TorrentStatusSeed || st == transmissionrpc.TorrentStatusSeedWait {
return TorrentStatusSeeding
} else if st == transmissionrpc.TorrentStatusStopped && isFinished {
return TorrentStatusStopped
} else if st == transmissionrpc.TorrentStatusStopped && !isFinished {
return TorrentStatusPaused
} else if st == transmissionrpc.TorrentStatusDownload || st == transmissionrpc.TorrentStatusDownloadWait {
return TorrentStatusDownloading
} else {
return TorrentStatusOther
}
}
func (r *Repository) FromQbitTorrents(t []*qbittorrent_model.Torrent) []*Torrent {
ret := make([]*Torrent, 0, len(t))
for _, t := range t {
ret = append(ret, r.FromQbitTorrent(t))
}
return ret
}
func (r *Repository) FromQbitTorrent(t *qbittorrent_model.Torrent) *Torrent {
torrent := &Torrent{}
torrent.Name = t.Name
torrent.Hash = t.Hash
torrent.Seeds = t.NumSeeds
torrent.UpSpeed = util.ToHumanReadableSpeed(t.Upspeed)
torrent.DownSpeed = util.ToHumanReadableSpeed(t.Dlspeed)
torrent.Progress = t.Progress
torrent.Size = util.Bytes(uint64(t.Size))
torrent.Eta = util.FormatETA(t.Eta)
torrent.ContentPath = t.ContentPath
torrent.Status = fromQbitTorrentStatus(t.State)
return torrent
}
// fromQbitTorrentStatus returns a normalized status for the torrent.
func fromQbitTorrentStatus(st qbittorrent_model.TorrentState) TorrentStatus {
if st == qbittorrent_model.StateQueuedUP ||
st == qbittorrent_model.StateStalledUP ||
st == qbittorrent_model.StateForcedUP ||
st == qbittorrent_model.StateCheckingUP ||
st == qbittorrent_model.StateUploading {
return TorrentStatusSeeding
} else if st == qbittorrent_model.StatePausedDL || st == qbittorrent_model.StateStoppedDL {
return TorrentStatusPaused
} else if st == qbittorrent_model.StateDownloading ||
st == qbittorrent_model.StateCheckingDL ||
st == qbittorrent_model.StateStalledDL ||
st == qbittorrent_model.StateQueuedDL ||
st == qbittorrent_model.StateMetaDL ||
st == qbittorrent_model.StateAllocating ||
st == qbittorrent_model.StateForceDL {
return TorrentStatusDownloading
} else if st == qbittorrent_model.StatePausedUP || st == qbittorrent_model.StateStoppedUP {
return TorrentStatusStopped
} else {
return TorrentStatusOther
}
}

View File

@@ -0,0 +1,92 @@
package transmission
import (
"context"
"errors"
"runtime"
"seanime/internal/util"
"time"
)
func (c *Transmission) getExecutableName() string {
switch runtime.GOOS {
case "windows":
return "transmission-qt.exe"
default:
return "transmission-qt"
}
}
func (c *Transmission) getExecutablePath() string {
if len(c.Path) > 0 {
return c.Path
}
switch runtime.GOOS {
case "windows":
return "C:/Program Files/Transmission/transmission-qt.exe"
case "linux":
return "/usr/bin/transmission-qt" // Default path for Transmission on most Linux distributions
case "darwin":
return "/Applications/Transmission.app/Contents/MacOS/transmission-qt"
// Default path for Transmission on macOS
default:
return "C:/Program Files/Transmission/transmission-qt.exe"
}
}
func (c *Transmission) Start() error {
// If the path is empty, do not check if Transmission is running
if c.Path == "" {
return nil
}
name := c.getExecutableName()
if util.ProgramIsRunning(name) {
return nil
}
exe := c.getExecutablePath()
cmd := util.NewCmd(exe)
err := cmd.Start()
if err != nil {
return errors.New("failed to start Transmission")
}
time.Sleep(1 * time.Second)
return nil
}
func (c *Transmission) CheckStart() bool {
if c == nil {
return false
}
// If the path is empty, assume it's running
if c.Path == "" {
return true
}
_, _, _, err := c.Client.RPCVersion(context.Background())
if err == nil {
return true
}
err = c.Start()
timeout := time.After(30 * time.Second)
ticker := time.Tick(1 * time.Second)
for {
select {
case <-ticker:
_, _, _, err := c.Client.RPCVersion(context.Background())
if err == nil {
return true
}
case <-timeout:
return false
}
}
}

View File

@@ -0,0 +1,62 @@
package transmission
import (
"fmt"
"github.com/hekmon/transmissionrpc/v3"
"github.com/rs/zerolog"
"net/url"
"strings"
)
type (
Transmission struct {
Client *transmissionrpc.Client
Path string
Logger *zerolog.Logger
}
NewTransmissionOptions struct {
Path string
Logger *zerolog.Logger
Username string
Password string
Host string // Default: 127.0.0.1
Port int
}
)
func New(options *NewTransmissionOptions) (*Transmission, error) {
// Set default host
if options.Host == "" {
options.Host = "127.0.0.1"
}
baseUrl := fmt.Sprintf("http://%s:%s@%s:%d/transmission/rpc",
options.Username,
url.QueryEscape(options.Password),
options.Host,
options.Port,
)
if strings.HasPrefix(options.Host, "https://") {
options.Host = strings.TrimPrefix(options.Host, "https://")
baseUrl = fmt.Sprintf("https://%s:%s@%s:%d/transmission/rpc",
options.Username,
url.QueryEscape(options.Password),
options.Host,
options.Port,
)
}
_url, err := url.Parse(baseUrl)
if err != nil {
return nil, err
}
client, _ := transmissionrpc.New(_url, nil)
return &Transmission{
Client: client,
Path: options.Path,
Logger: options.Logger,
}, nil
}

View File

@@ -0,0 +1,106 @@
package transmission
import (
"context"
"github.com/davecgh/go-spew/spew"
"github.com/hekmon/transmissionrpc/v3"
"github.com/stretchr/testify/assert"
"seanime/internal/test_utils"
"seanime/internal/util"
"testing"
"time"
)
//func TestGetActiveTorrents(t *testing.T) {
// t.Skip("Provide magnets")
// test_utils.InitTestProvider(t, test_utils.TorrentClient())
//
// trans, err := New(&NewTransmissionOptions{
// Host: test_utils.ConfigData.Provider.TransmissionHost,
// Path: test_utils.ConfigData.Provider.TransmissionPath,
// Port: test_utils.ConfigData.Provider.TransmissionPort,
// Username: test_utils.ConfigData.Provider.TransmissionUsername,
// Password: test_utils.ConfigData.Provider.TransmissionPassword,
// Logger: util.NewLogger(),
// })
// if err != nil {
// t.Fatal(err)
// }
//
//}
func TestGetFiles(t *testing.T) {
t.Skip("Provide magnets")
test_utils.InitTestProvider(t, test_utils.TorrentClient())
tempDir := t.TempDir()
tests := []struct {
name string
url string
magnet string
mediaId int
expectedNbFiles int
}{
{
name: "[EMBER] Demon Slayer (2023) (Season 3)",
url: "https://animetosho.org/view/ember-demon-slayer-2023-season-3-bdrip-1080p.n1778316",
magnet: "",
mediaId: 145139,
expectedNbFiles: 11,
},
{
name: "[Tenrai-Sensei] Kakegurui (Season 1-2 + OVAs)",
url: "https://nyaa.si/view/1553978",
magnet: "",
mediaId: 98314,
expectedNbFiles: 27,
},
}
trans, err := New(&NewTransmissionOptions{
Host: test_utils.ConfigData.Provider.TransmissionHost,
Path: test_utils.ConfigData.Provider.TransmissionPath,
Port: test_utils.ConfigData.Provider.TransmissionPort,
Username: test_utils.ConfigData.Provider.TransmissionUsername,
Password: test_utils.ConfigData.Provider.TransmissionPassword,
Logger: util.NewLogger(),
})
if err != nil {
t.Fatal(err)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
to, err := trans.Client.TorrentAdd(context.Background(), transmissionrpc.TorrentAddPayload{
Filename: &tt.magnet,
DownloadDir: &tempDir,
})
if assert.NoError(t, err) {
time.Sleep(20 * time.Second)
// Get files
torrents, err := trans.Client.TorrentGetAllFor(context.Background(), []int64{*to.ID})
to = torrents[0]
spew.Dump(to.Files)
// Remove torrent
err = trans.Client.TorrentRemove(context.Background(), transmissionrpc.TorrentRemovePayload{
IDs: []int64{*to.ID},
DeleteLocalData: true,
})
assert.NoError(t, err)
}
})
}
}