Repository: AlistGo/alist (language: go)

Issue:
能否支持transmission作为离线下载工具
### Please make sure of the following things

- [ ] I have read the [documentation](https://alist.nn.ci).
- [ ] I'm sure there are no duplicate issues or discussions.
- [ ] I'm sure this feature is not implemented.
- [ ] I'm sure it's a reasonable and popular requirement.

### Description of the feature / 需求描述

建议新增支持transmission作为离线下载工具，trasmission比qBittorent要稳定些

### Suggested solution / 实现思路

trasmission也有web端接口，实现起来应该和qBittorent差不多

### Additional context / 附件

_No response_


Discussion / hints:
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

Hello @, this issue was closed due to inactive more than 52 days. You can reopen or recreate it if you think it should continue.

Fix patch:
diff --git a/go.mod b/go.mod
index 94e10ca1c42..9b9d859d3fe 100644
--- a/go.mod
+++ b/go.mod
@@ -33,6 +33,7 @@ require (
 	github.com/golang-jwt/jwt/v4 v4.5.0
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.3
+	github.com/hekmon/transmissionrpc/v3 v3.0.0
 	github.com/hirochachacha/go-smb2 v1.1.0
 	github.com/ipfs/go-ipfs-api v0.7.0
 	github.com/jlaffaye/ftp v0.2.0
@@ -82,6 +83,8 @@ require (
 	github.com/cloudwego/base64x v0.1.4 // indirect
 	github.com/cloudwego/iasm v0.2.0 // indirect
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+	github.com/hekmon/cunits/v2 v2.1.0 // indirect
 	github.com/ipfs/boxo v0.12.0 // indirect
 	github.com/jackc/puddle/v2 v2.2.1 // indirect
 )
diff --git a/go.sum b/go.sum
index 346a2d45125..f4699bc20b9 100644
--- a/go.sum
+++ b/go.sum
@@ -240,11 +240,17 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
 github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
 github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
 github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
 github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
 github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
 github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
+github.com/hekmon/cunits/v2 v2.1.0 h1:k6wIjc4PlacNOHwKEMBgWV2/c8jyD4eRMs5mR1BBhI0=
+github.com/hekmon/cunits/v2 v2.1.0/go.mod h1:9r1TycXYXaTmEWlAIfFV8JT+Xo59U96yUJAYHxzii2M=
+github.com/hekmon/transmissionrpc/v3 v3.0.0 h1:0Fb11qE0IBh4V4GlOwHNYpqpjcYDp5GouolwrpmcUDQ=
+github.com/hekmon/transmissionrpc/v3 v3.0.0/go.mod h1:38SlNhFzinVUuY87wGj3acOmRxeYZAZfrj6Re7UgCDg=
 github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI=
 github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
diff --git a/internal/conf/const.go b/internal/conf/const.go
index 2d53702e91a..13787b5e2ac 100644
--- a/internal/conf/const.go
+++ b/internal/conf/const.go
@@ -54,11 +54,15 @@ const (
 	Aria2Uri    = "aria2_uri"
 	Aria2Secret = "aria2_secret"
 
+	// transmission
+	TransmissionUri      = "transmission_uri"
+	TransmissionSeedtime = "transmission_seedtime"
+
 	// single
 	Token         = "token"
 	IndexProgress = "index_progress"
 
-	//SSO
+	// SSO
 	SSOClientId          = "sso_client_id"
 	SSOClientSecret      = "sso_client_secret"
 	SSOLoginEnabled      = "sso_login_enabled"
@@ -73,7 +77,7 @@ const (
 	SSODefaultPermission = "sso_default_permission"
 	SSOCompatibilityMode = "sso_compatibility_mode"
 
-	//ldap
+	// ldap
 	LdapLoginEnabled      = "ldap_login_enabled"
 	LdapServer            = "ldap_server"
 	LdapManagerDN         = "ldap_manager_dn"
@@ -84,7 +88,7 @@ const (
 	LdapDefaultDir        = "ldap_default_dir"
 	LdapLoginTips         = "ldap_login_tips"
 
-	//s3
+	// s3
 	S3Buckets         = "s3_buckets"
 	S3AccessKeyId     = "s3_access_key_id"
 	S3SecretAccessKey = "s3_secret_access_key"
@@ -97,7 +101,7 @@ const (
 const (
 	UNKNOWN = iota
 	FOLDER
-	//OFFICE
+	// OFFICE
 	VIDEO
 	AUDIO
 	TEXT
diff --git a/internal/offline_download/all.go b/internal/offline_download/all.go
index ee80b5a0b8f..6682155dec8 100644
--- a/internal/offline_download/all.go
+++ b/internal/offline_download/all.go
@@ -6,4 +6,5 @@ import (
 	_ "github.com/alist-org/alist/v3/internal/offline_download/http"
 	_ "github.com/alist-org/alist/v3/internal/offline_download/pikpak"
 	_ "github.com/alist-org/alist/v3/internal/offline_download/qbit"
+	_ "github.com/alist-org/alist/v3/internal/offline_download/transmission"
 )
diff --git a/internal/offline_download/tool/download.go b/internal/offline_download/tool/download.go
index 4cc86a26124..ef9ceabfc8a 100644
--- a/internal/offline_download/tool/download.go
+++ b/internal/offline_download/tool/download.go
@@ -101,6 +101,19 @@ outer:
 			}
 		}
 	}
+
+	if t.tool.Name() == "transmission" {
+		// hack for transmission
+		seedTime := setting.GetInt(conf.TransmissionSeedtime, 0)
+		if seedTime >= 0 {
+			t.Status = "offline download completed, waiting for seeding"
+			<-time.After(time.Minute * time.Duration(seedTime))
+			err := t.tool.Remove(t)
+			if err != nil {
+				log.Errorln(err.Error())
+			}
+		}
+	}
 	return nil
 }
 
diff --git a/internal/offline_download/transmission/client.go b/internal/offline_download/transmission/client.go
new file mode 100644
index 00000000000..a6075414814
--- /dev/null
+++ b/internal/offline_download/transmission/client.go
@@ -0,0 +1,176 @@
+package transmission
+
+import (
+	"bytes"
+	"context"
+	"encoding/base64"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strconv"
+
+	"github.com/alist-org/alist/v3/internal/conf"
+	"github.com/alist-org/alist/v3/internal/errs"
+	"github.com/alist-org/alist/v3/internal/model"
+	"github.com/alist-org/alist/v3/internal/offline_download/tool"
+	"github.com/alist-org/alist/v3/internal/setting"
+	"github.com/hekmon/transmissionrpc/v3"
+	"github.com/pkg/errors"
+	log "github.com/sirupsen/logrus"
+)
+
+type Transmission struct {
+	client *transmissionrpc.Client
+}
+
+func (t *Transmission) Run(task *tool.DownloadTask) error {
+	return errs.NotSupport
+}
+
+func (t *Transmission) Name() string {
+	return "transmission"
+}
+
+func (t *Transmission) Items() []model.SettingItem {
+	// transmission settings
+	return []model.SettingItem{
+		{Key: conf.TransmissionUri, Value: "http://localhost:9091/transmission/rpc", Type: conf.TypeString, Group: model.OFFLINE_DOWNLOAD, Flag: model.PRIVATE},
+		{Key: conf.TransmissionSeedtime, Value: "0", Type: conf.TypeNumber, Group: model.OFFLINE_DOWNLOAD, Flag: model.PRIVATE},
+	}
+}
+
+func (t *Transmission) Init() (string, error) {
+	t.client = nil
+	uri := setting.GetStr(conf.TransmissionUri)
+	endpoint, err := url.Parse(uri)
+	if err != nil {
+		return "", errors.Wrap(err, "failed to init transmission client")
+	}
+	c, err := transmissionrpc.New(endpoint, nil)
+	if err != nil {
+		return "", errors.Wrap(err, "failed to init transmission client")
+	}
+
+	ok, serverVersion, serverMinimumVersion, err := c.RPCVersion(context.Background())
+	if err != nil {
+		return "", errors.Wrapf(err, "failed get transmission version")
+	}
+
+	if !ok {
+		return "", fmt.Errorf("remote transmission RPC version (v%d) is incompatible with the transmission library (v%d): remote needs at least v%d",
+			serverVersion, transmissionrpc.RPCVersion, serverMinimumVersion)
+	}
+
+	t.client = c
+	log.Infof("remote transmission RPC version (v%d) is compatible with our transmissionrpc library (v%d)\n",
+		serverVersion, transmissionrpc.RPCVersion)
+	log.Infof("using transmission version: %d", serverVersion)
+	return fmt.Sprintf("transmission version: %d", serverVersion), nil
+}
+
+func (t *Transmission) IsReady() bool {
+	return t.client != nil
+}
+
+func (t *Transmission) AddURL(args *tool.AddUrlArgs) (string, error) {
+	endpoint, err := url.Parse(args.Url)
+	if err != nil {
+		return "", errors.Wrap(err, "failed to parse transmission uri")
+	}
+
+	rpcPayload := transmissionrpc.TorrentAddPayload{
+		DownloadDir: &args.TempDir,
+	}
+	// http url for .torrent file
+	if endpoint.Scheme == "http" || endpoint.Scheme == "https" {
+		resp, err := http.Get(args.Url)
+		if err != nil {
+			return "", errors.Wrap(err, "failed to get .torrent file")
+		}
+		defer resp.Body.Close()
+		buffer := new(bytes.Buffer)
+		encoder := base64.NewEncoder(base64.StdEncoding, buffer)
+		// Stream file to the encoder
+		if _, err = io.Copy(encoder, resp.Body); err != nil {
+			return "", errors.Wrap(err, "can't copy file content into the base64 encoder")
+		}
+		// Flush last bytes
+		if err = encoder.Close(); err != nil {
+			return "", errors.Wrap(err, "can't flush last bytes of the base64 encoder")
+		}
+		// Get the string form
+		b64 := buffer.String()
+		rpcPayload.MetaInfo = &b64
+	} else { // magnet uri
+		rpcPayload.Filename = &args.Url
+	}
+
+	torrent, err := t.client.TorrentAdd(context.TODO(), rpcPayload)
+	if err != nil {
+		return "", err
+	}
+
+	if torrent.ID == nil {
+		return "", fmt.Errorf("failed get torrent ID")
+	}
+	gid := strconv.FormatInt(*torrent.ID, 10)
+	return gid, nil
+}
+
+func (t *Transmission) Remove(task *tool.DownloadTask) error {
+	gid, err := strconv.ParseInt(task.GID, 10, 64)
+	if err != nil {
+		return err
+	}
+	err = t.client.TorrentRemove(context.TODO(), transmissionrpc.TorrentRemovePayload{
+		IDs:             []int64{gid},
+		DeleteLocalData: false,
+	})
+	return err
+}
+
+func (t *Transmission) Status(task *tool.DownloadTask) (*tool.Status, error) {
+	gid, err := strconv.ParseInt(task.GID, 10, 64)
+	if err != nil {
+		return nil, err
+	}
+	infos, err := t.client.TorrentGetAllFor(context.TODO(), []int64{gid})
+	if err != nil {
+		return nil, err
+	}
+
+	if len(infos) < 1 {
+		return nil, fmt.Errorf("failed get status, wrong gid: %s", task.GID)
+	}
+	info := infos[0]
+
+	s := &tool.Status{
+		Completed: *info.IsFinished,
+		Err:       err,
+	}
+	s.Progress = *info.PercentDone * 100
+
+	switch *info.Status {
+	case transmissionrpc.TorrentStatusCheckWait,
+		transmissionrpc.TorrentStatusDownloadWait,
+		transmissionrpc.TorrentStatusCheck,
+		transmissionrpc.TorrentStatusDownload,
+		transmissionrpc.TorrentStatusIsolated:
+		s.Status = "[transmission] " + info.Status.String()
+	case transmissionrpc.TorrentStatusSeedWait,
+		transmissionrpc.TorrentStatusSeed:
+		s.Completed = true
+	case transmissionrpc.TorrentStatusStopped:
+		s.Err = errors.Errorf("[transmission] failed to download %s, status: %s, error: %s", task.GID, info.Status.String(), *info.ErrorString)
+	default:
+		s.Err = errors.Errorf("[transmission] unknown status occurred downloading %s, err: %s", task.GID, *info.ErrorString)
+	}
+	return s, nil
+}
+
+var _ tool.Tool = (*Transmission)(nil)
+
+func init() {
+	tool.Tools.Add(&Transmission{})
+}
diff --git a/server/handles/offline_download.go b/server/handles/offline_download.go
index 0b019e9e48c..1c5f95557ff 100644
--- a/server/handles/offline_download.go
+++ b/server/handles/offline_download.go
@@ -30,6 +30,10 @@ func SetAria2(c *gin.Context) {
 		return
 	}
 	_tool, err := tool.Tools.Get("aria2")
+	if err != nil {
+		common.ErrorResp(c, err, 500)
+		return
+	}
 	version, err := _tool.Init()
 	if err != nil {
 		common.ErrorResp(c, err, 500)
@@ -74,6 +78,37 @@ func OfflineDownloadTools(c *gin.Context) {
 	common.SuccessResp(c, tools)
 }
 
+type SetTransmissionReq struct {
+	Uri      string `json:"uri" form:"uri"`
+	Seedtime string `json:"seedtime" form:"seedtime"`
+}
+
+func SetTransmission(c *gin.Context) {
+	var req SetTransmissionReq
+	if err := c.ShouldBind(&req); err != nil {
+		common.ErrorResp(c, err, 400)
+		return
+	}
+	items := []model.SettingItem{
+		{Key: conf.TransmissionUri, Value: req.Uri, Type: conf.TypeString, Group: model.OFFLINE_DOWNLOAD, Flag: model.PRIVATE},
+		{Key: conf.TransmissionSeedtime, Value: req.Seedtime, Type: conf.TypeNumber, Group: model.OFFLINE_DOWNLOAD, Flag: model.PRIVATE},
+	}
+	if err := op.SaveSettingItems(items); err != nil {
+		common.ErrorResp(c, err, 500)
+		return
+	}
+	_tool, err := tool.Tools.Get("transmission")
+	if err != nil {
+		common.ErrorResp(c, err, 500)
+		return
+	}
+	if _, err := _tool.Init(); err != nil {
+		common.ErrorResp(c, err, 500)
+		return
+	}
+	common.SuccessResp(c, "ok")
+}
+
 type AddOfflineDownloadReq struct {
 	Urls         []string `json:"urls"`
 	Path         string   `json:"path"`
diff --git a/server/router.go b/server/router.go
index 5be593f7497..07423f923cd 100644
--- a/server/router.go
+++ b/server/router.go
@@ -62,7 +62,7 @@ func Init(e *gin.Engine) {
 	api.GET("/auth/get_sso_id", handles.SSOLoginCallback)
 	api.GET("/auth/sso_get_token", handles.SSOLoginCallback)
 
-	//webauthn
+	// webauthn
 	webauthn.GET("/webauthn_begin_registration", handles.BeginAuthnRegistration)
 	webauthn.POST("/webauthn_finish_registration", handles.FinishAuthnRegistration)
 	webauthn.GET("/webauthn_begin_login", handles.BeginAuthnLogin)
@@ -125,6 +125,7 @@ func admin(g *gin.RouterGroup) {
 	setting.POST("/reset_token", handles.ResetToken)
 	setting.POST("/set_aria2", handles.SetAria2)
 	setting.POST("/set_qbit", handles.SetQbittorrent)
+	setting.POST("/set_transmission", handles.SetTransmission)
 
 	task := g.Group("/task")
 	handles.SetupTaskRoute(task)
@@ -159,14 +160,15 @@ func _fs(g *gin.RouterGroup) {
 	g.PUT("/put", middlewares.FsUp, handles.FsStream)
 	g.PUT("/form", middlewares.FsUp, handles.FsForm)
 	g.POST("/link", middlewares.AuthAdmin, handles.Link)
-	//g.POST("/add_aria2", handles.AddOfflineDownload)
-	//g.POST("/add_qbit", handles.AddQbittorrent)
+	// g.POST("/add_aria2", handles.AddOfflineDownload)
+	// g.POST("/add_qbit", handles.AddQbittorrent)
+	// g.POST("/add_transmission", handles.SetTransmission)
 	g.POST("/add_offline_download", handles.AddOfflineDownload)
 }
 
 func Cors(r *gin.Engine) {
 	config := cors.DefaultConfig()
-	//config.AllowAllOrigins = true
+	// config.AllowAllOrigins = true
 	config.AllowOrigins = conf.Conf.Cors.AllowOrigins
 	config.AllowHeaders = conf.Conf.Cors.AllowHeaders
 	config.AllowMethods = conf.Conf.Cors.AllowMethods


Test patch: