From fae1d481fa1a31b1065f2c47e3f7bc3d603b7f39 Mon Sep 17 00:00:00 2001 From: "Pah." <121189493+Nubuki-all@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:48:35 +0100 Subject: [PATCH 1/2] Bug fixes (#1) --- client.go | 64 ++++- errors/exitstatus.go | 112 +++++++++ .../exitstatus_string.go | 2 +- errors/json.go | 13 + exitstatus.go | 73 +++--- go.mod | 6 +- go.sum | 16 +- helpers.go | 130 ++++++++++ internal/pkg/jsonrpc/jsonrpc.go | 231 ++++++++++++++++++ status.go | 10 +- 10 files changed, 600 insertions(+), 57 deletions(-) create mode 100644 errors/exitstatus.go rename exitstatus_string.go => errors/exitstatus_string.go (99%) create mode 100644 errors/json.go create mode 100644 helpers.go create mode 100644 internal/pkg/jsonrpc/jsonrpc.go diff --git a/client.go b/client.go index 2f95161..879f5a2 100644 --- a/client.go +++ b/client.go @@ -8,9 +8,9 @@ import ( "net/http" "os" - "github.com/cenkalti/rpc2" - "github.com/cenkalti/rpc2/jsonrpc" "github.com/gorilla/websocket" + "github.com/nubuki-all/rpc2" + "github.com/siku2/arigo/internal/pkg/jsonrpc" "github.com/siku2/arigo/internal/pkg/wsrpc" "github.com/siku2/arigo/pkg/aria2proto" ) @@ -109,22 +109,27 @@ func (c *Client) onDownloadStart(_ *rpc2.Client, event *DownloadEvent, _ *interf c.evtTarget.Dispatch(StartEvent, event) return nil } + func (c *Client) onDownloadPause(_ *rpc2.Client, event *DownloadEvent, _ *interface{}) error { c.evtTarget.Dispatch(PauseEvent, event) return nil } + func (c *Client) onDownloadStop(_ *rpc2.Client, event *DownloadEvent, _ *interface{}) error { c.evtTarget.Dispatch(StopEvent, event) return nil } + func (c *Client) onDownloadComplete(_ *rpc2.Client, event *DownloadEvent, _ *interface{}) error { c.evtTarget.Dispatch(CompleteEvent, event) return nil } + func (c *Client) onDownloadError(_ *rpc2.Client, event *DownloadEvent, _ *interface{}) error { c.evtTarget.Dispatch(ErrorEvent, event) return nil } + func (c *Client) onBTDownloadComplete(_ *rpc2.Client, event *DownloadEvent, _ *interface{}) error { c.evtTarget.Dispatch(BTCompleteEvent, event) return nil @@ -196,6 +201,51 @@ func (c *Client) DownloadWithContext(ctx context.Context, uris []string, options return } +// GetDownloads retrieves a list of statuses for the given gids +// if no gid is given, return all the downloads. +func (c *Client) GetDownloads(gids ...string) []Status { + var statuses []Status + if len(gids) != 0 { + for _, gid := range gids { + s, _ := c.TellStatus(gid) + statuses = append(statuses, s) + } + } else { + s, _ := c.TellActive() + statuses = append(statuses, s...) + s, _ = c.TellWaiting(0, 100) + statuses = append(statuses, s...) + s, _ = c.TellStopped(0, 100) + statuses = append(statuses, s...) + } + return statuses +} + +// DeleteDownloads removes the downloads denoted by status and deletes all corresponding files. +// This is not an aria2 method. +func (c *Client) DeleteDownloads(statuses []Status, force, files, clean bool) { + for _, status := range statuses { + switch status.Status { + case StatusCompleted, StatusRemoved, StatusError: + _ = c.RemoveDownloadResult(status.GID) + default: + if force { + _ = c.ForceRemove(status.GID) + } else { + _ = c.Remove(status.GID) + } + _ = c.RemoveDownloadResult(status.GID) + } + if files { + RemoveFiles(status.Files) + removeRootDir(status) + } + if clean { + _ = DeleteControlFile(status) + } + } +} + // Delete removes the download denoted by gid and deletes all corresponding files. // This is not an aria2 method. func (c *Client) Delete(gid string) (err error) { @@ -471,6 +521,10 @@ func (c *Client) GetServers(gid string) ([]FileServers, error) { // keys does the same as in the TellStatus() method. func (c *Client) TellActive(keys ...string) ([]Status, error) { var reply []Status + + if len(keys) == 0 { + keys = make([]string, 0) + } err := c.rpcClient.Call(aria2proto.TellActive, c.getArgs(keys), &reply) return reply, err @@ -488,6 +542,9 @@ func (c *Client) TellActive(keys ...string) ([]Status, error) { // If specified, the returned Statuses only contain the keys passed to the method. func (c *Client) TellWaiting(offset int, num uint, keys ...string) ([]Status, error) { var reply []Status + if len(keys) == 0 { + keys = make([]string, 0) + } err := c.rpcClient.Call(aria2proto.TellWaiting, c.getArgs(offset, num, keys), &reply) return reply, err @@ -505,6 +562,9 @@ func (c *Client) TellWaiting(offset int, num uint, keys ...string) ([]Status, er // If specified, the returned Statuses only contain the keys passed to the method. func (c *Client) TellStopped(offset int, num uint, keys ...string) ([]Status, error) { var reply []Status + if len(keys) == 0 { + keys = make([]string, 0) + } err := c.rpcClient.Call(aria2proto.TellStopped, c.getArgs(offset, num, keys), &reply) return reply, err diff --git a/errors/exitstatus.go b/errors/exitstatus.go new file mode 100644 index 0000000..3fb2b56 --- /dev/null +++ b/errors/exitstatus.go @@ -0,0 +1,112 @@ +package errors + +//go:generate stringer -type=ExitStatus + +// ExitStatus is an integer returned by aria2 for downloads which describes why a download exited. +// Please see https://aria2.github.io/manual/en/html/aria2c.html#exit-status +type ExitStatus uint8 + +const ( + // Success indicates that all downloads were successful. + Success ExitStatus = iota + + // UnknownError indicates that an unknown error occurred. + UnknownError + + // Timeout indicates that a timeout occurred. + Timeout + + // ResourceNotFound indicates that a resource was not found. + ResourceNotFound + + // ResourceNotFoundReached indicates that aria2 saw the specified number of “resource not found” error. + // See the --max-file-not-found option. + ResourceNotFoundReached + + // DownloadSpeedTooSlow indicates that a download aborted because download speed was too slow. + // See --lowest-speed-limit option. + DownloadSpeedTooSlow + + // NetworkError indicates that a network problem occurred. + NetworkError + + // UnfinishedDownloads indicates that there were unfinished downloads. + // This error is only reported if all finished downloads were successful and there were unfinished + // downloads in a queue when aria2 exited by pressing Ctrl-C by an user or sending TERM or INT signal. + UnfinishedDownloads + + // RemoteNoResume indicates that the remote server did not support resume when resume was required to complete download. + RemoteNoResume + + // NotEnoughDiskSpace indicates that there was not enough disk space available. + NotEnoughDiskSpace + + // PieceLengthMismatch indicates that the piece length was different from one in .aria2 control file. + // See --allow-piece-length-change option. + PieceLengthMismatch + + // SameFileBeingDownloaded indicates that aria2 was downloading same file at that moment. + SameFileBeingDownloaded + + // SameInfoHashBeingDownloaded indicates that aria2 was downloading same info hash torrent at that moment. + SameInfoHashBeingDownloaded + + // FileAlreadyExists indicates that the file already existed. See --allow-overwrite option. + FileAlreadyExists + + //RenamingFailed indicates that renaming the file failed. See --auto-file-renaming option. + RenamingFailed + + // CouldNotOpenExistingFile indicates that aria2 could not open existing file. + CouldNotOpenExistingFile + + // CouldNotCreateNewFile indicates that aria2 could not create new file or truncate existing file. + CouldNotCreateNewFile + + // FileIOError indicates that a file I/O error occurred. + FileIOError + + // CouldNotCreateDirectory indicates that aria2 could not create directory. + CouldNotCreateDirectory + + // NameResolutionFailed indicates that the name resolution failed. + NameResolutionFailed + + // MetalinkParsingFailed indicates that aria2 could not parse Metalink document. + MetalinkParsingFailed + + // FTPCommandFailed indicates that the FTP command failed. + FTPCommandFailed + + // HTTPResponseHeaderBad indicates that the HTTP response header was bad or unexpected. + HTTPResponseHeaderBad + + // TooManyRedirects indicates that too many redirects occurred. + TooManyRedirects + + // HTTPAuthorizationFailed indicates that HTTP authorization failed. + HTTPAuthorizationFailed + + // BencodedFileParseError indicates that aria2 could not parse bencoded file (usually “.torrent” file). + BencodedFileParseError + + // TorrentFileCorrupt indicates that the “.torrent” file was corrupted or missing information that aria2 needed. + TorrentFileCorrupt + + // MagnetURIBad indicates that the magnet URI was bad. + MagnetURIBad + + // RemoteServerHandleRequestError indicates that the remote server was unable to handle the request due to a + // temporary overloading or maintenance. + RemoteServerHandleRequestError + + // JSONRPCParseError indicates that aria2 could not parse JSON-RPC request. + JSONRPCParseError + + // Reserved is a reserved value. If you get this exit status then the library is probably out-of-date, + // or the universe is breaking down. + Reserved + + // ChecksumValidationFailed indicates that the checksum validation failed. + ChecksumValidationFailed +) diff --git a/exitstatus_string.go b/errors/exitstatus_string.go similarity index 99% rename from exitstatus_string.go rename to errors/exitstatus_string.go index 80e0fab..4571c6c 100644 --- a/exitstatus_string.go +++ b/errors/exitstatus_string.go @@ -1,6 +1,6 @@ // Code generated by "stringer -type=ExitStatus"; DO NOT EDIT. -package arigo +package errors import "strconv" diff --git a/errors/json.go b/errors/json.go new file mode 100644 index 0000000..1efe58d --- /dev/null +++ b/errors/json.go @@ -0,0 +1,13 @@ +package errors + +import "fmt" + +type JSONRPCError struct { + Code ExitStatus `json:"code"` // error code + Message string `json:"message"` // The human readable error message associated to Code +} + +func (e *JSONRPCError) Error() string { + return fmt.Sprintf("code=%d, message=%s", e.Code, e.Message) +} + diff --git a/exitstatus.go b/exitstatus.go index ec382f8..5f421bc 100644 --- a/exitstatus.go +++ b/exitstatus.go @@ -1,112 +1,111 @@ package arigo -//go:generate stringer -type=ExitStatus +import errs "github.com/siku2/arigo/errors" -// ExitStatus is an integer returned by aria2 for downloads which describes why a download exited. -// Please see https://aria2.github.io/manual/en/html/aria2c.html#exit-status -type ExitStatus uint8 +// Re-export the ExitStatus type so callers can still use arigo.ExitStatus +type ExitStatus = errs.ExitStatus const ( // Success indicates that all downloads were successful. - Success ExitStatus = iota + Success = errs.Success // UnknownError indicates that an unknown error occurred. - UnknownError + UnknownError = errs.UnknownError // Timeout indicates that a timeout occurred. - Timeout + Timeout = errs.Timeout // ResourceNotFound indicates that a resource was not found. - ResourceNotFound + ResourceNotFound = errs.ResourceNotFound // ResourceNotFoundReached indicates that aria2 saw the specified number of “resource not found” error. // See the --max-file-not-found option. - ResourceNotFoundReached + ResourceNotFoundReached = errs.ResourceNotFoundReached // DownloadSpeedTooSlow indicates that a download aborted because download speed was too slow. // See --lowest-speed-limit option. - DownloadSpeedTooSlow + DownloadSpeedTooSlow = errs.DownloadSpeedTooSlow // NetworkError indicates that a network problem occurred. - NetworkError + NetworkError = errs.NetworkError // UnfinishedDownloads indicates that there were unfinished downloads. // This error is only reported if all finished downloads were successful and there were unfinished // downloads in a queue when aria2 exited by pressing Ctrl-C by an user or sending TERM or INT signal. - UnfinishedDownloads + UnfinishedDownloads = errs.UnfinishedDownloads // RemoteNoResume indicates that the remote server did not support resume when resume was required to complete download. - RemoteNoResume + RemoteNoResume = errs.RemoteNoResume // NotEnoughDiskSpace indicates that there was not enough disk space available. - NotEnoughDiskSpace + NotEnoughDiskSpace = errs.NotEnoughDiskSpace // PieceLengthMismatch indicates that the piece length was different from one in .aria2 control file. // See --allow-piece-length-change option. - PieceLengthMismatch + PieceLengthMismatch = errs.PieceLengthMismatch // SameFileBeingDownloaded indicates that aria2 was downloading same file at that moment. - SameFileBeingDownloaded + SameFileBeingDownloaded = errs.SameFileBeingDownloaded // SameInfoHashBeingDownloaded indicates that aria2 was downloading same info hash torrent at that moment. - SameInfoHashBeingDownloaded + SameInfoHashBeingDownloaded = errs.SameInfoHashBeingDownloaded // FileAlreadyExists indicates that the file already existed. See --allow-overwrite option. - FileAlreadyExists + FileAlreadyExists = errs.FileAlreadyExists - //RenamingFailed indicates that renaming the file failed. See --auto-file-renaming option. - RenamingFailed + // RenamingFailed indicates that renaming the file failed. See --auto-file-renaming option. + RenamingFailed = errs.RenamingFailed // CouldNotOpenExistingFile indicates that aria2 could not open existing file. - CouldNotOpenExistingFile + CouldNotOpenExistingFile = errs.CouldNotOpenExistingFile // CouldNotCreateNewFile indicates that aria2 could not create new file or truncate existing file. - CouldNotCreateNewFile + CouldNotCreateNewFile = errs.CouldNotCreateNewFile // FileIOError indicates that a file I/O error occurred. - FileIOError + FileIOError = errs.FileIOError // CouldNotCreateDirectory indicates that aria2 could not create directory. - CouldNotCreateDirectory + CouldNotCreateDirectory = errs.CouldNotCreateDirectory // NameResolutionFailed indicates that the name resolution failed. - NameResolutionFailed + NameResolutionFailed = errs.NameResolutionFailed // MetalinkParsingFailed indicates that aria2 could not parse Metalink document. - MetalinkParsingFailed + MetalinkParsingFailed = errs.MetalinkParsingFailed // FTPCommandFailed indicates that the FTP command failed. - FTPCommandFailed + FTPCommandFailed = errs.FTPCommandFailed // HTTPResponseHeaderBad indicates that the HTTP response header was bad or unexpected. - HTTPResponseHeaderBad + HTTPResponseHeaderBad = errs.HTTPResponseHeaderBad // TooManyRedirects indicates that too many redirects occurred. - TooManyRedirects + TooManyRedirects = errs.TooManyRedirects // HTTPAuthorizationFailed indicates that HTTP authorization failed. - HTTPAuthorizationFailed + HTTPAuthorizationFailed = errs.HTTPAuthorizationFailed // BencodedFileParseError indicates that aria2 could not parse bencoded file (usually “.torrent” file). - BencodedFileParseError + BencodedFileParseError = errs.BencodedFileParseError // TorrentFileCorrupt indicates that the “.torrent” file was corrupted or missing information that aria2 needed. - TorrentFileCorrupt + TorrentFileCorrupt = errs.TorrentFileCorrupt // MagnetURIBad indicates that the magnet URI was bad. - MagnetURIBad + MagnetURIBad = errs.MagnetURIBad // RemoteServerHandleRequestError indicates that the remote server was unable to handle the request due to a // temporary overloading or maintenance. - RemoteServerHandleRequestError + RemoteServerHandleRequestError = errs.RemoteServerHandleRequestError // JSONRPCParseError indicates that aria2 could not parse JSON-RPC request. - JSONRPCParseError + JSONRPCParseError = errs.JSONRPCParseError // Reserved is a reserved value. If you get this exit status then the library is probably out-of-date, // or the universe is breaking down. - Reserved + Reserved = errs.Reserved // ChecksumValidationFailed indicates that the checksum validation failed. - ChecksumValidationFailed + ChecksumValidationFailed = errs.ChecksumValidationFailed ) diff --git a/go.mod b/go.mod index a8ed677..59cde75 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,7 @@ module github.com/siku2/arigo go 1.12 require ( - github.com/cenk/hub v1.0.1 // indirect - github.com/cenkalti/hub v1.0.1-0.20160527103212-11382a9960d3 // indirect - github.com/cenkalti/rpc2 v0.0.0-20180727162946-9642ea02d0aa - github.com/gorilla/websocket v1.4.1 + github.com/gorilla/websocket v1.5.3 + github.com/nubuki-all/rpc2 v1.0.0 github.com/stretchr/testify v1.3.0 ) diff --git a/go.sum b/go.sum index c96a317..ea243ab 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,13 @@ -github.com/cenk/hub v1.0.1 h1:RBwXNOF4a8KjD8BJ08XqN8KbrqaGiQLDrgvUGJSHuPA= -github.com/cenk/hub v1.0.1/go.mod h1:rJM1LNAW0ppT8FMMuPK6c2NP/R2nH/UthtuRySSaf6Y= -github.com/cenkalti/hub v1.0.1-0.20160527103212-11382a9960d3 h1:JoNNeZqjMj74cMtMUi456vOlL/4Kwk1C3sU6e62caJA= -github.com/cenkalti/hub v1.0.1-0.20160527103212-11382a9960d3/go.mod h1:tcYwtS3a2d9NO/0xDXVJWx3IedurUjYCqFCmpi0lpHs= -github.com/cenkalti/rpc2 v0.0.0-20180727162946-9642ea02d0aa h1:t+iWhuJE2aropY4uxKMVbyP+IJ29o422f7YAd73aTjg= -github.com/cenkalti/rpc2 v0.0.0-20180727162946-9642ea02d0aa/go.mod h1:v2npkhrXyk5BCnkNIiPdRI23Uq6uWPUQGL2hnRcRr/M= +github.com/cenkalti/hub v1.0.2 h1:Nqv9TNaA9boeO2wQFW8o87BY3zKthtnzXmWGmJqhAV8= +github.com/cenkalti/hub v1.0.2/go.mod h1:8LAFAZcCasb83vfxatMUnZHRoQcffho2ELpHb+kaTJU= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= -github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/nubuki-all/rpc2 v0.0.0-20260123134553-73acb79c44e6 h1:iNeX7DT3qj8ffSctuC9qMYp/Z4NDZwCHzpcabaXhxck= +github.com/nubuki-all/rpc2 v0.0.0-20260123134553-73acb79c44e6/go.mod h1:+MrQdTszT+WuqIkY7p+gbFdFQ9QfegL7HzjoXJ5i+Bw= +github.com/nubuki-all/rpc2 v1.0.0 h1:9I2yZTHqxpfYSQHPK9IzVcIuNFyeZF+kjKP4H7pjzUQ= +github.com/nubuki-all/rpc2 v1.0.0/go.mod h1:+MrQdTszT+WuqIkY7p+gbFdFQ9QfegL7HzjoXJ5i+Bw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..c2c0f0a --- /dev/null +++ b/helpers.go @@ -0,0 +1,130 @@ +package arigo + +import ( + "os" + "path/filepath" + "strings" +) + +func DeleteControlFile(status Status) error { + name, err := GetDownloadName(status) + if err != nil { + return err + } + ctrlFile := filepath.Join(status.Dir, name+".aria2") + return os.Remove(ctrlFile) // error can be ignored +} + +func GetDownloadName(status Status) (string, error) { + var name string + name = status.BitTorrent.Info.Name + if name != "" { + return name, nil + } + files := status.Files + if len(files) == 0 { + return name, nil + } + file := files[0] + name = file.Path + if strings.HasPrefix(name, "[METADATA]") { + return name, nil + } + if strings.HasPrefix(name, status.Dir) { + name = filepath.Base(name) + } else { + if uris := file.URIs; len(uris) > 0 { + tempStr := strings.Split(uris[0].URI, "/") + if len(tempStr) > 0 { + name = tempStr[len(tempStr)-1] + } + } + } + return name, nil +} + +func RemoveFiles(files []File) { + for _, file := range files { + _ = os.Remove(file.Path) + } +} + +func removeRootDir(status Status) { + name, _ := GetDownloadName(status) + if strings.HasPrefix(name, "[METADATA]") { + return + } + path := filepath.Join(status.Dir, name) + _, _ = RemoveEmptyDirs(path, true) +} + +func RemoveUnselectedFiles(status Status) { + for _, file := range status.Files { + if !file.Selected { + _ = os.Remove(file.Path) + } + } + name, _ := GetDownloadName(status) + _, _ = RemoveEmptyDirs(filepath.Join(status.Dir, name), false) +} + +// RemoveEmptyDirs walks root recursively and removes any directories that are empty. +// It returns the number of directories removed and the first non-nil error encountered (if any). +// Symlinked directories are skipped +func RemoveEmptyDirs(root string, removeRoot bool) (int, error) { + var removed int + var walk func(path string) (bool, error) + walk = func(path string) (bool, error) { + entries, err := os.ReadDir(path) + if err != nil { + return false, err + } + + // iterate children first + for _, e := range entries { + if e.IsDir() { + // skip symlinked directories + info, err := e.Info() + if err != nil { + return false, err + } + if info.Mode()&os.ModeSymlink != 0 { + // don't follow symlinks + continue + } + childPath := filepath.Join(path, e.Name()) + empty, err := walk(childPath) + if err != nil { + return false, err + } + if empty { + if err := os.Remove(childPath); err != nil { + // if remove fails, we treat dir as non-empty for parent + return false, err + } + removed++ + } + } + } + + entries, err = os.ReadDir(path) + if err != nil { + return false, err + } + return len(entries) == 0, nil + } + + isEmpty, err := walk(root) + if err != nil { + return removed, err + } + if isEmpty && removeRoot { + if err := os.Remove(root); err == nil { + removed++ + } else { + return removed, err + } + } + return removed, nil +} + diff --git a/internal/pkg/jsonrpc/jsonrpc.go b/internal/pkg/jsonrpc/jsonrpc.go new file mode 100644 index 0000000..d068871 --- /dev/null +++ b/internal/pkg/jsonrpc/jsonrpc.go @@ -0,0 +1,231 @@ +// Package jsonrpc implements a JSON-RPC ClientCodec and ServerCodec for the rpc2 package. +// +// Beside struct types, JSONCodec allows using positional arguments. +// Use []interface{} as the type of argument when sending and receiving methods. +// +// Positional arguments example: +// +// server.Handle("add", func(client *rpc2.Client, args []interface{}, result *float64) error { +// *result = args[0].(float64) + args[1].(float64) +// return nil +// }) +// +// var result float64 +// client.Call("add", []interface{}{1, 2}, &result) +// +// from https://github.com/nubuki-all/rpc2 modified to avoid disconnected client on any error +package jsonrpc + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "sync" + + "github.com/nubuki-all/rpc2" + errs "github.com/siku2/arigo/errors" +) + +type jsonCodec struct { + dec *json.Decoder // for reading JSON values + enc *json.Encoder // for writing JSON values + c io.Closer + + // temporary work space + msg message + serverRequest serverRequest + clientResponse clientResponse + + // JSON-RPC clients can use arbitrary json values as request IDs. + // Package rpc expects uint64 request IDs. + // We assign uint64 sequence numbers to incoming requests + // but save the original request ID in the pending map. + // When rpc responds, we use the sequence number in + // the response to find the original request ID. + mutex sync.Mutex // protects seq, pending + pending map[uint64]*json.RawMessage + seq uint64 +} + +// NewJSONCodec returns a new rpc2.Codec using JSON-RPC on conn. +func NewJSONCodec(conn io.ReadWriteCloser) rpc2.Codec { + return &jsonCodec{ + dec: json.NewDecoder(conn), + enc: json.NewEncoder(conn), + c: conn, + pending: make(map[uint64]*json.RawMessage), + } +} + +// serverRequest and clientResponse combined +type message struct { + Method string `json:"method"` + Params *json.RawMessage `json:"params"` + Id *json.RawMessage `json:"id"` + Result *json.RawMessage `json:"result"` + Error *json.RawMessage `json:"error"` + // Error interface{} `json:"error"` +} + +// Unmarshal to +type serverRequest struct { + Method string `json:"method"` + Params *json.RawMessage `json:"params"` + Id *json.RawMessage `json:"id"` +} +type clientResponse struct { + Id uint64 `json:"id"` + Result *json.RawMessage `json:"result"` + Error *json.RawMessage `json:"error"` + // Error interface{} `json:"error"` +} + +// to Marshal +type serverResponse struct { + Id *json.RawMessage `json:"id"` + Result interface{} `json:"result"` + Error interface{} `json:"error"` +} +type clientRequest struct { + Method string `json:"method"` + Params interface{} `json:"params"` + Id *uint64 `json:"id"` +} + +func (c *jsonCodec) ReadHeader(req *rpc2.Request, resp *rpc2.Response) error { + c.msg = message{} + if err := c.dec.Decode(&c.msg); err != nil { + return err + } + + if c.msg.Method != "" { + // request comes to server + c.serverRequest.Id = c.msg.Id + c.serverRequest.Method = c.msg.Method + c.serverRequest.Params = c.msg.Params + + req.Method = c.serverRequest.Method + + // JSON request id can be any JSON value; + // RPC package expects uint64. Translate to + // internal uint64 and save JSON on the side. + if c.serverRequest.Id == nil { + // Notification + } else { + c.mutex.Lock() + c.seq++ + c.pending[c.seq] = c.serverRequest.Id + c.serverRequest.Id = nil + req.Seq = c.seq + c.mutex.Unlock() + } + } else { + // response comes to client + err := json.Unmarshal(*c.msg.Id, &c.clientResponse.Id) + if err != nil { + return err + } + c.clientResponse.Result = c.msg.Result + c.clientResponse.Error = c.msg.Error + + resp.Error = "" + resp.Seq = c.clientResponse.Id + if c.clientResponse.Error != nil || c.clientResponse.Result == nil { + var x errs.JSONRPCError + err := json.Unmarshal(*c.clientResponse.Error, &x) + if err != nil { + var data interface{} + _ = json.Unmarshal(*c.clientResponse.Error, &data) + return fmt.Errorf("invalid error %v", data) + } + resp.Err = &x + } + } + return nil +} + +var errMissingParams = errors.New("jsonrpc: request body missing params") + +func (c *jsonCodec) ReadRequestBody(x interface{}) error { + if x == nil { + return nil + } + if c.serverRequest.Params == nil { + return errMissingParams + } + + var err error + + // Check if x points to a slice of any kind + rt := reflect.TypeOf(x) + if rt.Kind() == reflect.Ptr && rt.Elem().Kind() == reflect.Slice { + // If it's a slice, unmarshal as is + err = json.Unmarshal(*c.serverRequest.Params, x) + } else { + // Anything else unmarshal into a slice containing x + params := &[]interface{}{x} + err = json.Unmarshal(*c.serverRequest.Params, params) + } + + return err +} + +func (c *jsonCodec) ReadResponseBody(x interface{}) error { + if x == nil { + return nil + } + return json.Unmarshal(*c.clientResponse.Result, x) +} + +func (c *jsonCodec) WriteRequest(r *rpc2.Request, param interface{}) error { + req := &clientRequest{Method: r.Method} + + // Check if param is a slice of any kind + if param != nil && reflect.TypeOf(param).Kind() == reflect.Slice { + // If it's a slice, leave as is + req.Params = param + } else { + // Put anything else into a slice + req.Params = []interface{}{param} + } + + if r.Seq == 0 { + // Notification + req.Id = nil + } else { + seq := r.Seq + req.Id = &seq + } + return c.enc.Encode(req) +} + +var null = json.RawMessage([]byte("null")) + +func (c *jsonCodec) WriteResponse(r *rpc2.Response, x interface{}) error { + c.mutex.Lock() + b, ok := c.pending[r.Seq] + if !ok { + c.mutex.Unlock() + return errors.New("invalid sequence number in response") + } + delete(c.pending, r.Seq) + c.mutex.Unlock() + + if b == nil { + // Invalid request so no id. Use JSON null. + b = &null + } + resp := serverResponse{Id: b} + if r.Error == "" { + resp.Result = x + } else { + resp.Error = r.Error + } + return c.enc.Encode(resp) +} + +func (c *jsonCodec) Close() error { + return c.c.Close() +} diff --git a/status.go b/status.go index 363e2b3..e3859a6 100644 --- a/status.go +++ b/status.go @@ -118,11 +118,11 @@ type BitTorrentStatus struct { // List of lists of announce URIs. // If the torrent contains announce and no announce-list, // announce is converted to the announce-list format - AnnounceList [][]string `json:"announceList"` // List of lists of announce URIs. - Comment string `json:"comment"` // The comment of the torrent - CreationDate UNIXTime `json:"creationDate,string"` // The creation time of the torrent - Mode TorrentMode `json:"mode"` // File mode of the torrent - Info BitTorrentStatusInfo `json:"info"` // Information from the info dictionary + AnnounceList [][]string `json:"announceList"` // List of lists of announce URIs. + Comment string `json:"comment"` // The comment of the torrent + CreationDate UNIXTime `json:"creationDate"` // The creation time of the torrent + Mode TorrentMode `json:"mode"` // File mode of the torrent + Info BitTorrentStatusInfo `json:"info"` // Information from the info dictionary } // A BitTorrentStatusInfo holds information from the info dictionary. From 3d4710020493973b1b9328cbc285d74ace475acb Mon Sep 17 00:00:00 2001 From: Nubuki-all <121189493+Nubuki-all@users.noreply.github.com> Date: Sun, 25 Jan 2026 11:34:00 +0100 Subject: [PATCH 2/2] fix github actions --- .github/workflows/build.yml | 2 +- client.go | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b28150a..aa298a1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,7 @@ jobs: strategy: matrix: include: - - os: ubuntu-20.04 + - os: ubuntu-latest - os: windows-2022 runs-on: ${{ matrix.os }} steps: diff --git a/client.go b/client.go index 879f5a2..078f15a 100644 --- a/client.go +++ b/client.go @@ -202,7 +202,8 @@ func (c *Client) DownloadWithContext(ctx context.Context, uris []string, options } // GetDownloads retrieves a list of statuses for the given gids -// if no gid is given, return all the downloads. +// if no gid is given, return all active downloads and a thousand (1000) +// of each of most recent waiting and stopped downloads. func (c *Client) GetDownloads(gids ...string) []Status { var statuses []Status if len(gids) != 0 { @@ -213,9 +214,9 @@ func (c *Client) GetDownloads(gids ...string) []Status { } else { s, _ := c.TellActive() statuses = append(statuses, s...) - s, _ = c.TellWaiting(0, 100) + s, _ = c.TellWaiting(0, 1000) statuses = append(statuses, s...) - s, _ = c.TellStopped(0, 100) + s, _ = c.TellStopped(0, 1000) statuses = append(statuses, s...) } return statuses