Start from the beginning

This commit is contained in:
Alexander
2026-04-29 17:31:07 +02:00
parent 945aab82c2
commit 8067cd93c5
27 changed files with 0 additions and 5108 deletions
-49
View File
@@ -1,49 +0,0 @@
package torrent
import (
"context"
"errors"
)
var (
ErrNotConnected = errors.New("not connected")
ErrAuthFailed = errors.New("authentication failed")
ErrTorrentNotFound = errors.New("torrent not found")
ErrInvalidRequest = errors.New("invalid request")
ErrConnectionFailed = errors.New("connection failed")
)
type TorrentState string
const (
StateDownloading TorrentState = "downloading"
StateSeeding TorrentState = "seeding"
StatePaused TorrentState = "paused"
StateQueued TorrentState = "queued"
StateChecking TorrentState = "checking"
StateError TorrentState = "error"
StateUnknown TorrentState = "unknown"
)
type TorrentInfo struct {
Hash string `json:"hash"`
Name string `json:"name"`
Size uint64 `json:"size"`
Progress float64 `json:"progress"`
DownloadSpeed uint64 `json:"download_speed"`
UploadSpeed uint64 `json:"upload_speed"`
State TorrentState `json:"state"`
SavePath string `json:"save_path"`
}
type Client interface {
Connect(ctx context.Context) error
Disconnect(ctx context.Context) error
ListTorrents(ctx context.Context) ([]TorrentInfo, error)
GetTorrent(ctx context.Context, hash string) (*TorrentInfo, error)
AddTorrentURL(ctx context.Context, url string, savePath *string) error
AddTorrentFile(ctx context.Context, data []byte, savePath *string) error
RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error
PauseTorrent(ctx context.Context, hash string) error
ResumeTorrent(ctx context.Context, hash string) error
}
-365
View File
@@ -1,365 +0,0 @@
package torrent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"sync"
"github.com/rs/zerolog/log"
)
type QBittorrentClient struct {
baseURL string
username string
password string
client *http.Client
connected bool
mu sync.RWMutex
}
type qbTorrent struct {
Hash string `json:"hash"`
Name string `json:"name"`
Size int64 `json:"size"`
Progress float64 `json:"progress"`
DLSpeed int64 `json:"dlspeed"`
UPSpeed int64 `json:"upspeed"`
State string `json:"state"`
SavePath string `json:"save_path"`
}
func NewQBittorrentClient(baseURL, username, password string) (*QBittorrentClient, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
return &QBittorrentClient{
baseURL: strings.TrimRight(baseURL, "/"),
username: username,
password: password,
client: &http.Client{Jar: jar},
}, nil
}
func (c *QBittorrentClient) apiURL(path string) string {
return fmt.Sprintf("%s/api/v2%s", c.baseURL, path)
}
func (c *QBittorrentClient) mapState(state string) TorrentState {
switch state {
case "downloading", "forcedDL", "metaDL", "allocating", "stalledDL":
return StateDownloading
case "uploading", "forcedUP", "stalledUP":
return StateSeeding
case "pausedDL", "pausedUP":
return StatePaused
case "queuedDL", "queuedUP":
return StateQueued
case "checkingDL", "checkingUP", "checkingResumeData":
return StateChecking
case "error", "missingFiles":
return StateError
default:
return StateUnknown
}
}
func (c *QBittorrentClient) mapTorrent(t qbTorrent) TorrentInfo {
size := uint64(0)
if t.Size > 0 {
size = uint64(t.Size)
}
dlSpeed := uint64(0)
if t.DLSpeed > 0 {
dlSpeed = uint64(t.DLSpeed)
}
upSpeed := uint64(0)
if t.UPSpeed > 0 {
upSpeed = uint64(t.UPSpeed)
}
return TorrentInfo{
Hash: t.Hash,
Name: t.Name,
Size: size,
Progress: t.Progress,
DownloadSpeed: dlSpeed,
UploadSpeed: upSpeed,
State: c.mapState(t.State),
SavePath: t.SavePath,
}
}
func (c *QBittorrentClient) ensureConnected() error {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.connected {
return ErrNotConnected
}
return nil
}
func (c *QBittorrentClient) Connect(ctx context.Context) error {
data := url.Values{}
data.Set("username", c.username)
data.Set("password", c.password)
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/auth/login"), strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("%w: %v", ErrConnectionFailed, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if string(body) == "Ok." {
c.mu.Lock()
c.connected = true
c.mu.Unlock()
return nil
}
return ErrAuthFailed
}
func (c *QBittorrentClient) Disconnect(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/auth/logout"), nil)
if err != nil {
return err
}
c.client.Do(req)
c.mu.Lock()
c.connected = false
c.mu.Unlock()
return nil
}
func (c *QBittorrentClient) ListTorrents(ctx context.Context) ([]TorrentInfo, error) {
if err := c.ensureConnected(); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "GET", c.apiURL("/torrents/info"), nil)
if err != nil {
return nil, err
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var torrents []qbTorrent
if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil {
return nil, err
}
result := make([]TorrentInfo, len(torrents))
for i, t := range torrents {
result[i] = c.mapTorrent(t)
}
return result, nil
}
func (c *QBittorrentClient) GetTorrent(ctx context.Context, hash string) (*TorrentInfo, error) {
if err := c.ensureConnected(); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "GET", c.apiURL("/torrents/info")+"?hashes="+hash, nil)
if err != nil {
return nil, err
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var torrents []qbTorrent
if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil {
return nil, err
}
if len(torrents) == 0 {
return nil, ErrTorrentNotFound
}
info := c.mapTorrent(torrents[0])
return &info, nil
}
func (c *QBittorrentClient) AddTorrentURL(ctx context.Context, torrentURL string, savePath *string) error {
if err := c.ensureConnected(); err != nil {
return err
}
log.Debug().Str("url", torrentURL).Msg("[QBITTORRENT] adding torrent URL")
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
w.WriteField("urls", torrentURL)
if savePath != nil {
w.WriteField("savepath", *savePath)
}
w.Close()
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/add"), &buf)
if err != nil {
return err
}
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := c.client.Do(req)
if err != nil {
log.Error().Err(err).Msg("[QBITTORRENT] request failed")
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
bodyStr := strings.TrimSpace(string(body))
log.Debug().Int("status", resp.StatusCode).Str("body", bodyStr).Msg("[QBITTORRENT] add torrent response")
if !statusOK(resp.StatusCode) {
log.Error().Int("status", resp.StatusCode).Str("body", bodyStr).Msg("[QBITTORRENT] add torrent failed")
return fmt.Errorf("%w: %s", ErrInvalidRequest, bodyStr)
}
if bodyStr == "Fails." {
log.Error().Str("url", torrentURL).Msg("[QBITTORRENT] torrent add rejected")
return fmt.Errorf("qBittorrent rejected torrent: %s", torrentURL)
}
log.Info().Msg("[QBITTORRENT] torrent added successfully")
return nil
}
func (c *QBittorrentClient) AddTorrentFile(ctx context.Context, data []byte, savePath *string) error {
if err := c.ensureConnected(); err != nil {
return err
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, err := w.CreateFormFile("torrents", "torrent.torrent")
if err != nil {
return err
}
part.Write(data)
if savePath != nil {
w.WriteField("savepath", *savePath)
}
w.Close()
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/add"), &buf)
if err != nil {
return err
}
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if !statusOK(resp.StatusCode) {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("%w: %s", ErrInvalidRequest, string(body))
}
return nil
}
func (c *QBittorrentClient) RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error {
if err := c.ensureConnected(); err != nil {
return err
}
data := url.Values{}
data.Set("hashes", hash)
if deleteFiles {
data.Set("deleteFiles", "true")
} else {
data.Set("deleteFiles", "false")
}
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/delete"), strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if !statusOK(resp.StatusCode) {
return ErrTorrentNotFound
}
return nil
}
func (c *QBittorrentClient) PauseTorrent(ctx context.Context, hash string) error {
if err := c.ensureConnected(); err != nil {
return err
}
data := url.Values{}
data.Set("hashes", hash)
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/pause"), strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
c.client.Do(req)
return nil
}
func (c *QBittorrentClient) ResumeTorrent(ctx context.Context, hash string) error {
if err := c.ensureConnected(); err != nil {
return err
}
data := url.Values{}
data.Set("hashes", hash)
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/resume"), strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
c.client.Do(req)
return nil
}
func statusOK(code int) bool {
return code >= 200 && code < 300
}
-227
View File
@@ -1,227 +0,0 @@
package torrent
import (
"context"
"crypto/sha1"
"encoding/hex"
"fmt"
"os"
"sync"
"time"
)
type StubClient struct {
logPath string
savePath string
mu sync.RWMutex
logMu sync.Mutex
torrents map[string]*TorrentInfo
}
func NewStubClient(logPath, savePath string) *StubClient {
return &StubClient{
logPath: logPath,
savePath: savePath,
torrents: make(map[string]*TorrentInfo),
}
}
func (c *StubClient) log(format string, args ...any) {
if c.logPath == "" {
return
}
c.logMu.Lock()
defer c.logMu.Unlock()
f, err := os.OpenFile(c.logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
timestamp := time.Now().Format(time.RFC3339)
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(f, "[%s] %s\n", timestamp, msg)
}
func (c *StubClient) Connect(ctx context.Context) error {
c.log("CONNECT")
return nil
}
func (c *StubClient) Disconnect(ctx context.Context) error {
c.log("DISCONNECT")
return nil
}
func (c *StubClient) ListTorrents(ctx context.Context) ([]TorrentInfo, error) {
c.mu.RLock()
defer c.mu.RUnlock()
c.log("LIST_TORRENTS count=%d", len(c.torrents))
result := make([]TorrentInfo, 0, len(c.torrents))
for _, t := range c.torrents {
result = append(result, *t)
}
return result, nil
}
func (c *StubClient) GetTorrent(ctx context.Context, hash string) (*TorrentInfo, error) {
c.mu.RLock()
defer c.mu.RUnlock()
c.log("GET_TORRENT hash=%s", hash)
t, ok := c.torrents[hash]
if !ok {
return nil, ErrTorrentNotFound
}
return t, nil
}
func (c *StubClient) AddTorrentURL(ctx context.Context, url string, savePath *string) error {
path := c.savePath
if savePath != nil {
path = *savePath
}
hash := generateHashFromURL(url)
name := "Torrent-" + hash[:8]
c.mu.Lock()
c.torrents[hash] = &TorrentInfo{
Hash: hash,
Name: name,
Size: 500 * 1024 * 1024,
Progress: 0,
DownloadSpeed: 0,
UploadSpeed: 0,
State: StateQueued,
SavePath: path,
}
c.mu.Unlock()
c.log("ADD_TORRENT_URL url=%s hash=%s save_path=%s", url, hash, path)
return nil
}
func (c *StubClient) AddTorrentFile(ctx context.Context, data []byte, savePath *string) error {
path := c.savePath
if savePath != nil {
path = *savePath
}
hash := generateHashFromData(data)
name := "Torrent-" + hash[:8]
c.mu.Lock()
c.torrents[hash] = &TorrentInfo{
Hash: hash,
Name: name,
Size: uint64(len(data) * 100),
Progress: 0,
DownloadSpeed: 0,
UploadSpeed: 0,
State: StateQueued,
SavePath: path,
}
c.mu.Unlock()
c.log("ADD_TORRENT_FILE size=%d hash=%s save_path=%s", len(data), hash, path)
return nil
}
func (c *StubClient) RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error {
c.mu.Lock()
delete(c.torrents, hash)
c.mu.Unlock()
c.log("REMOVE_TORRENT hash=%s delete_files=%t", hash, deleteFiles)
return nil
}
func (c *StubClient) PauseTorrent(ctx context.Context, hash string) error {
c.mu.Lock()
if t, ok := c.torrents[hash]; ok {
t.State = StatePaused
t.DownloadSpeed = 0
}
c.mu.Unlock()
c.log("PAUSE_TORRENT hash=%s", hash)
return nil
}
func (c *StubClient) ResumeTorrent(ctx context.Context, hash string) error {
c.mu.Lock()
if t, ok := c.torrents[hash]; ok {
if t.Progress < 1.0 {
t.State = StateDownloading
} else {
t.State = StateSeeding
}
}
c.mu.Unlock()
c.log("RESUME_TORRENT hash=%s", hash)
return nil
}
func (c *StubClient) SetTorrentState(hash string, state TorrentState, progress float64) {
c.mu.Lock()
defer c.mu.Unlock()
if t, ok := c.torrents[hash]; ok {
t.State = state
t.Progress = progress
if state == StateSeeding {
t.Progress = 1.0
}
}
}
func (c *StubClient) SetTorrentName(hash, name string) {
c.mu.Lock()
defer c.mu.Unlock()
if t, ok := c.torrents[hash]; ok {
t.Name = name
}
}
func (c *StubClient) AddTorrentDirect(info TorrentInfo) {
c.mu.Lock()
defer c.mu.Unlock()
c.torrents[info.Hash] = &info
}
func (c *StubClient) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.torrents = make(map[string]*TorrentInfo)
}
func (c *StubClient) GetAllTorrents() map[string]*TorrentInfo {
c.mu.RLock()
defer c.mu.RUnlock()
result := make(map[string]*TorrentInfo, len(c.torrents))
for k, v := range c.torrents {
copy := *v
result[k] = &copy
}
return result
}
func generateHashFromURL(url string) string {
h := sha1.New()
h.Write([]byte(url))
return hex.EncodeToString(h.Sum(nil))
}
func generateHashFromData(data []byte) string {
h := sha1.New()
h.Write(data)
return hex.EncodeToString(h.Sum(nil))
}