Added add endpoint
This commit is contained in:
@@ -26,7 +26,22 @@ type TorrentInfo struct {
|
|||||||
Availability float64
|
Availability float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TorrentFile struct {
|
||||||
|
Filename string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type FindOptions struct {
|
||||||
|
Hash string
|
||||||
|
Name string
|
||||||
|
Category string
|
||||||
|
Tag string
|
||||||
|
State string
|
||||||
|
}
|
||||||
|
|
||||||
type TorrentClient interface {
|
type TorrentClient interface {
|
||||||
Login(username string, password string) (string, error)
|
Login(username string, password string) (string, error)
|
||||||
List() ([]TorrentInfo, error)
|
List() ([]TorrentInfo, error)
|
||||||
|
Find(opts FindOptions) ([]TorrentInfo, error)
|
||||||
|
Add(file TorrentFile) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package torrent
|
package torrent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -75,34 +77,63 @@ func (c *QbittorrentClient) Login(username string, password string) (string, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *QbittorrentClient) List() ([]TorrentInfo, error) {
|
func (c *QbittorrentClient) List() ([]TorrentInfo, error) {
|
||||||
log.Trace().Msg("qbittorrent listing torrents")
|
return c.Find(FindOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", c.baseURL+"/api/v2/torrents/info", nil)
|
func (c *QbittorrentClient) Find(opts FindOptions) ([]TorrentInfo, error) {
|
||||||
|
log.Trace().
|
||||||
|
Str("hash", opts.Hash).
|
||||||
|
Str("name", opts.Name).
|
||||||
|
Str("category", opts.Category).
|
||||||
|
Str("tag", opts.Tag).
|
||||||
|
Str("state", opts.State).
|
||||||
|
Msg("qbittorrent finding torrents")
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
if opts.Hash != "" {
|
||||||
|
params.Set("hashes", opts.Hash)
|
||||||
|
}
|
||||||
|
if opts.Category != "" {
|
||||||
|
params.Set("category", opts.Category)
|
||||||
|
}
|
||||||
|
if opts.Tag != "" {
|
||||||
|
params.Set("tag", opts.Tag)
|
||||||
|
}
|
||||||
|
if opts.State != "" {
|
||||||
|
params.Set("filter", opts.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqURL := c.baseURL + "/api/v2/torrents/info"
|
||||||
|
if len(params) > 0 {
|
||||||
|
reqURL += "?" + params.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("qbittorrent creating list request failed")
|
log.Error().Err(err).Msg("qbittorrent creating find request failed")
|
||||||
return nil, fmt.Errorf("creating list request: %w", err)
|
return nil, fmt.Errorf("creating find request: %w", err)
|
||||||
}
|
}
|
||||||
req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid})
|
req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid})
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("qbittorrent list request failed")
|
log.Error().Err(err).Msg("qbittorrent find request failed")
|
||||||
return nil, fmt.Errorf("list request failed: %w", err)
|
return nil, fmt.Errorf("find request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent list response")
|
log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent find response")
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
log.Error().Int("status", resp.StatusCode).Msg("qbittorrent list returned non-OK status")
|
log.Error().Int("status", resp.StatusCode).Msg("qbittorrent find returned non-OK status")
|
||||||
return nil, fmt.Errorf("list request returned status %d", resp.StatusCode)
|
return nil, fmt.Errorf("find request returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
var items []QbittorrentListItem
|
var items []QbittorrentListItem
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
|
||||||
log.Error().Err(err).Msg("qbittorrent decoding list response failed")
|
log.Error().Err(err).Msg("qbittorrent decoding find response failed")
|
||||||
return nil, fmt.Errorf("decoding list response: %w", err)
|
return nil, fmt.Errorf("decoding find response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
torrents := make([]TorrentInfo, len(items))
|
torrents := make([]TorrentInfo, len(items))
|
||||||
@@ -110,11 +141,93 @@ func (c *QbittorrentClient) List() ([]TorrentInfo, error) {
|
|||||||
torrents[i] = item.toTorrentInfo()
|
torrents[i] = item.toTorrentInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debug().Int("count", len(torrents)).Msg("qbittorrent torrents listed")
|
torrents = filterLocally(torrents, opts)
|
||||||
|
|
||||||
|
log.Debug().Int("count", len(torrents)).Msg("qbittorrent find results")
|
||||||
|
|
||||||
return torrents, nil
|
return torrents, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterLocally(torrents []TorrentInfo, opts FindOptions) []TorrentInfo {
|
||||||
|
var result []TorrentInfo
|
||||||
|
|
||||||
|
for _, t := range torrents {
|
||||||
|
if opts.Name != "" && !strings.Contains(strings.ToLower(t.Name), strings.ToLower(opts.Name)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if opts.Hash != "" && !strings.EqualFold(t.Hash, opts.Hash) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if opts.Category != "" && !strings.EqualFold(t.Category, opts.Category) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if opts.Tag != "" && !strings.Contains(strings.ToLower(t.Tags), strings.ToLower(opts.Tag)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if opts.State != "" && !strings.EqualFold(t.State, opts.State) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *QbittorrentClient) Add(file TorrentFile) error {
|
||||||
|
log.Trace().Str("filename", file.Filename).Int("size", len(file.Data)).Msg("qbittorrent adding torrent")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&buf)
|
||||||
|
|
||||||
|
part, err := writer.CreateFormFile("torrents", file.Filename)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("qbittorrent creating multipart form failed")
|
||||||
|
return fmt.Errorf("creating multipart form: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := part.Write(file.Data); err != nil {
|
||||||
|
log.Error().Err(err).Msg("qbittorrent writing torrent data failed")
|
||||||
|
return fmt.Errorf("writing torrent data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
return fmt.Errorf("closing multipart writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", c.baseURL+"/api/v2/torrents/add", &buf)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("qbittorrent creating add request failed")
|
||||||
|
return fmt.Errorf("creating add request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid})
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("qbittorrent add request failed")
|
||||||
|
return fmt.Errorf("add request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("qbittorrent reading add response failed")
|
||||||
|
return fmt.Errorf("reading add response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent add response")
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK || string(body) != "Ok." {
|
||||||
|
log.Error().Int("status", resp.StatusCode).Str("body", string(body)).Msg("qbittorrent add torrent failed")
|
||||||
|
return fmt.Errorf("add torrent failed: status %d, body: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Str("filename", file.Filename).Msg("qbittorrent torrent added")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type QbittorrentListItem struct {
|
type QbittorrentListItem struct {
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -29,6 +29,18 @@ func (server *TorrentServer) List(ctx context.Context, req *pb.ListRequest) (*pb
|
|||||||
return server.service.List(req)
|
return server.service.List(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (server *TorrentServer) Add(ctx context.Context, req *pb.AddRequest) (*pb.AddResponse, error) {
|
||||||
|
log.Debug().Str("download_url", req.GetDownloadUrl()).Str("filename", req.GetFilename()).Msg("add torrent requested")
|
||||||
|
|
||||||
|
resp, err := server.service.Add(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("add torrent failed")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *TorrentServer) Register(server *grpc.Server) {
|
func (s *TorrentServer) Register(server *grpc.Server) {
|
||||||
pb.RegisterTorrentServiceServer(server, s)
|
pb.RegisterTorrentServiceServer(server, s)
|
||||||
}
|
}
|
||||||
|
|||||||
+168
-27
@@ -2,6 +2,11 @@ package torrent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
@@ -43,34 +48,170 @@ func (service *TorrentService) List(req *pb.ListRequest) (*pb.ListResponse, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]*pb.ListItem, len(torrents))
|
return &pb.ListResponse{Items: toProtoItems(torrents)}, nil
|
||||||
for i, t := range torrents {
|
}
|
||||||
items[i] = &pb.ListItem{
|
|
||||||
Hash: t.Hash,
|
func (service *TorrentService) Add(req *pb.AddRequest) (*pb.AddResponse, error) {
|
||||||
Name: t.Name,
|
var file TorrentFile
|
||||||
Size: t.Size,
|
|
||||||
Progress: t.Progress,
|
if len(req.GetTorrentData()) > 0 {
|
||||||
Dlspeed: t.DlSpeed,
|
file = TorrentFile{
|
||||||
Upspeed: t.UpSpeed,
|
Filename: req.GetFilename(),
|
||||||
NumSeeds: t.NumSeeds,
|
Data: req.GetTorrentData(),
|
||||||
NumLeechs: t.NumLeechs,
|
|
||||||
State: t.State,
|
|
||||||
Eta: t.ETA,
|
|
||||||
Ratio: t.Ratio,
|
|
||||||
Category: t.Category,
|
|
||||||
Tags: t.Tags,
|
|
||||||
AddedOn: t.AddedOn,
|
|
||||||
CompletionOn: t.CompletionOn,
|
|
||||||
SavePath: t.SavePath,
|
|
||||||
ContentPath: t.ContentPath,
|
|
||||||
Downloaded: t.Downloaded,
|
|
||||||
Uploaded: t.Uploaded,
|
|
||||||
Tracker: t.Tracker,
|
|
||||||
SeedingTime: t.SeedingTime,
|
|
||||||
AmountLeft: t.AmountLeft,
|
|
||||||
Availability: t.Availability,
|
|
||||||
}
|
}
|
||||||
|
} else if req.GetDownloadUrl() != "" {
|
||||||
|
downloaded, err := downloadTorrentFile(req.GetDownloadUrl())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
file = *downloaded
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("either torrent_data or download_url must be provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &pb.ListResponse{Items: items}, nil
|
if err := service.client.Add(file); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
searchName := strings.TrimSuffix(file.Filename, ".torrent")
|
||||||
|
torrents, err := service.client.Find(FindOptions{Name: searchName})
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Msg("torrent added but failed to find it afterwards")
|
||||||
|
return &pb.AddResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(torrents) == 0 {
|
||||||
|
log.Warn().Str("filename", file.Filename).Msg("torrent added but not found in client")
|
||||||
|
return &pb.AddResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.AddResponse{Item: toProtoItem(torrents[0])}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProtoItems(torrents []TorrentInfo) []*pb.ListItem {
|
||||||
|
items := make([]*pb.ListItem, len(torrents))
|
||||||
|
for i, t := range torrents {
|
||||||
|
items[i] = toProtoItem(t)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProtoItem(t TorrentInfo) *pb.ListItem {
|
||||||
|
return &pb.ListItem{
|
||||||
|
Hash: t.Hash,
|
||||||
|
Name: t.Name,
|
||||||
|
Size: t.Size,
|
||||||
|
Progress: t.Progress,
|
||||||
|
Dlspeed: t.DlSpeed,
|
||||||
|
Upspeed: t.UpSpeed,
|
||||||
|
NumSeeds: t.NumSeeds,
|
||||||
|
NumLeechs: t.NumLeechs,
|
||||||
|
State: t.State,
|
||||||
|
Eta: t.ETA,
|
||||||
|
Ratio: t.Ratio,
|
||||||
|
Category: t.Category,
|
||||||
|
Tags: t.Tags,
|
||||||
|
AddedOn: t.AddedOn,
|
||||||
|
CompletionOn: t.CompletionOn,
|
||||||
|
SavePath: t.SavePath,
|
||||||
|
ContentPath: t.ContentPath,
|
||||||
|
Downloaded: t.Downloaded,
|
||||||
|
Uploaded: t.Uploaded,
|
||||||
|
Tracker: t.Tracker,
|
||||||
|
SeedingTime: t.SeedingTime,
|
||||||
|
AmountLeft: t.AmountLeft,
|
||||||
|
Availability: t.Availability,
|
||||||
|
|
||||||
|
SizeFormatted: formatBytes(t.Size),
|
||||||
|
ProgressFormatted: fmt.Sprintf("%.1f%%", t.Progress*100),
|
||||||
|
DlspeedFormatted: formatSpeed(t.DlSpeed),
|
||||||
|
UpspeedFormatted: formatSpeed(t.UpSpeed),
|
||||||
|
AddedOnFormatted: formatTimestamp(t.AddedOn),
|
||||||
|
CompletionOnFormatted: formatTimestamp(t.CompletionOn),
|
||||||
|
DownloadedFormatted: formatBytes(t.Downloaded),
|
||||||
|
UploadedFormatted: formatBytes(t.Uploaded),
|
||||||
|
AmountLeftFormatted: formatBytes(t.AmountLeft),
|
||||||
|
AvailabilityFormatted: fmt.Sprintf("%.2f", t.Availability),
|
||||||
|
EtaFormatted: formatETA(t.ETA),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBytes(b int64) string {
|
||||||
|
switch {
|
||||||
|
case b >= 1<<30:
|
||||||
|
return fmt.Sprintf("%.2f GB", float64(b)/float64(1<<30))
|
||||||
|
case b >= 1<<20:
|
||||||
|
return fmt.Sprintf("%.1f MB", float64(b)/float64(1<<20))
|
||||||
|
case b >= 1<<10:
|
||||||
|
return fmt.Sprintf("%.0f KB", float64(b)/float64(1<<10))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d B", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSpeed(bytesPerSec int64) string {
|
||||||
|
if bytesPerSec == 0 {
|
||||||
|
return "0 B/s"
|
||||||
|
}
|
||||||
|
return formatBytes(bytesPerSec) + "/s"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTimestamp(ts int64) string {
|
||||||
|
if ts <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return time.Unix(ts, 0).Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatETA(seconds int64) string {
|
||||||
|
if seconds <= 0 || seconds >= 8640000 {
|
||||||
|
return "∞"
|
||||||
|
}
|
||||||
|
d := time.Duration(seconds) * time.Second
|
||||||
|
h := int(d.Hours())
|
||||||
|
m := int(d.Minutes()) % 60
|
||||||
|
s := int(d.Seconds()) % 60
|
||||||
|
if h > 0 {
|
||||||
|
return fmt.Sprintf("%dh %dm %ds", h, m, s)
|
||||||
|
}
|
||||||
|
if m > 0 {
|
||||||
|
return fmt.Sprintf("%dm %ds", m, s)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%ds", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadTorrentFile(url string) (*TorrentFile, error) {
|
||||||
|
log.Trace().Str("url", url).Msg("downloading torrent file")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("url", url).Msg("downloading torrent file failed")
|
||||||
|
return nil, fmt.Errorf("downloading torrent file: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
log.Error().Int("status", resp.StatusCode).Str("url", url).Msg("torrent download returned non-OK status")
|
||||||
|
return nil, fmt.Errorf("torrent download returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading torrent file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := path.Base(resp.Request.URL.Path)
|
||||||
|
if !strings.HasSuffix(strings.ToLower(filename), ".torrent") {
|
||||||
|
filename += ".torrent"
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Str("filename", filename).Int("size", len(data)).Msg("torrent file downloaded")
|
||||||
|
|
||||||
|
return &TorrentFile{
|
||||||
|
Filename: filename,
|
||||||
|
Data: data,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ option go_package = "homelab.lan/music-agregator/gen/music_agregator/v1/torrent"
|
|||||||
|
|
||||||
service TorrentService {
|
service TorrentService {
|
||||||
rpc List(ListRequest) returns (ListResponse) {}
|
rpc List(ListRequest) returns (ListResponse) {}
|
||||||
|
rpc Add(AddRequest) returns (AddResponse) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListRequest {
|
message ListRequest {
|
||||||
@@ -45,4 +46,27 @@ message ListItem {
|
|||||||
int64 seeding_time = 21;
|
int64 seeding_time = 21;
|
||||||
int64 amount_left = 22;
|
int64 amount_left = 22;
|
||||||
double availability = 23;
|
double availability = 23;
|
||||||
|
|
||||||
|
string size_formatted = 100;
|
||||||
|
string progress_formatted = 101;
|
||||||
|
string dlspeed_formatted = 102;
|
||||||
|
string upspeed_formatted = 103;
|
||||||
|
string added_on_formatted = 104;
|
||||||
|
string completion_on_formatted = 105;
|
||||||
|
string downloaded_formatted = 106;
|
||||||
|
string uploaded_formatted = 107;
|
||||||
|
string amount_left_formatted = 108;
|
||||||
|
string availability_formatted = 109;
|
||||||
|
string eta_formatted = 110;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddRequest {
|
||||||
|
string download_url = 1;
|
||||||
|
string filename = 2;
|
||||||
|
bytes torrent_data = 3;
|
||||||
|
string save_path = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddResponse {
|
||||||
|
ListItem item = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user