From 07576cd5236eb4232ed3fc3ffd8e27cb822996d7 Mon Sep 17 00:00:00 2001 From: Struchkov Mark Date: Thu, 4 Dec 2025 21:10:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20Telegram-=D0=B1=D0=BE=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D0=B5=D0=B3=D0=BE=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20chat=20ID=20=D0=B8=20=D1=83=D0=BB?= =?UTF-8?q?=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D1=81=D0=BE=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B8=D0=B9.=20=D0=94=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20?= =?UTF-8?q?=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BE=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D0=B8=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=B7=D0=B0=D1=89=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=20=D0=BE=D1=82=20=D0=BF=D1=80=D0=B5=D0=B2=D1=8B?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20API-=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=BE?= =?UTF-8?q?=D0=B2.=20=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B3=D1=83=D0=BB=D1=8F=D1=80=D0=BD=D1=8B=D1=85=20=D0=B2?= =?UTF-8?q?=D1=8B=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9=20=D1=81=20?= =?UTF-8?q?=D0=BA=D1=8D=D1=88=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=D0=BC=20=D0=B8=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B1=D1=83=D1=84=D0=B5=D1=80=D0=B0=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/bot/main.go | 12 +--- internal/bot/bot.go | 106 +++++++++++++++++++++---------- internal/bot/handlers.go | 108 +++++++++++++++++++++++++++----- internal/bot/helpers.go | 48 +++++++++++--- internal/formatter/formatter.go | 2 +- internal/monitor/monitor.go | 14 +++++ internal/transmission/client.go | 3 +- 7 files changed, 229 insertions(+), 64 deletions(-) diff --git a/cmd/bot/main.go b/cmd/bot/main.go index e2f459d..3d5872f 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -75,20 +75,14 @@ func main() { // Initialize monitor mon := monitor.NewMonitor(cachedClient, log, config.DefaultPollInterval) - // chatID will be set when user sends first message - var chatID int64 - // Create bot instance first - telegramBot := bot.NewBot(bot, cachedClient, cfg, log, updates) + telegramBot := bot.NewBot(bot, cachedClient, cfg, log, updates, mon) // Set up completion callback - chatID will be set by bot when user sends first message - var chatID int64 mon.SetOnComplete(func(torrent *transmission.Torrent) { msg := fmt.Sprintf("✅ Completed: %s", torrent.Name) - if chatID != 0 { - // Send via bot helper - telegramBot.SendMessage(chatID, msg, false) - } + // Send via bot helper - bot will handle chatID internally + telegramBot.SendCompletionNotification(msg) }) // Start monitoring diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 580ef80..9d09596 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -3,10 +3,12 @@ package bot import ( "context" "strings" + "sync" tgbotapi "gopkg.in/telegram-bot-api.v4" "transmission-telegram/internal/config" "transmission-telegram/internal/logger" + "transmission-telegram/internal/monitor" transmissionClient "transmission-telegram/internal/transmission" ) @@ -17,16 +19,20 @@ type Bot struct { cfg *config.Config logger *logger.Logger updates <-chan tgbotapi.Update + monitor *monitor.Monitor + chatID int64 + chatMu sync.RWMutex } // NewBot creates a new bot instance -func NewBot(api *tgbotapi.BotAPI, client transmissionClient.Client, cfg *config.Config, log *logger.Logger, updates <-chan tgbotapi.Update) *Bot { +func NewBot(api *tgbotapi.BotAPI, client transmissionClient.Client, cfg *config.Config, log *logger.Logger, updates <-chan tgbotapi.Update, mon *monitor.Monitor) *Bot { return &Bot{ api: api, client: client, cfg: cfg, logger: log, updates: updates, + monitor: mon, } } @@ -47,6 +53,27 @@ func (b *Bot) SendMessage(chatID int64, text string, markdown bool) int { return sendMessage(b.api, chatID, text, markdown) } +// SendCompletionNotification sends a completion notification to the registered chat +func (b *Bot) SendCompletionNotification(msg string) { + b.chatMu.RLock() + chatID := b.chatID + b.chatMu.RUnlock() + + if chatID != 0 { + b.SendMessage(chatID, msg, false) + } +} + +// safeHandler wraps a handler function with panic recovery +func (b *Bot) safeHandler(handler func()) { + defer func() { + if r := recover(); r != nil { + b.logger.Printf("[ERROR] Panic recovered in handler: %v", r) + } + }() + handler() +} + // handleUpdate processes a Telegram update func (b *Bot) handleUpdate(update tgbotapi.Update) { if update.Message == nil { @@ -59,6 +86,21 @@ func (b *Bot) handleUpdate(update tgbotapi.Update) { return } + // Update chatID for completion notifications + b.chatMu.Lock() + if b.chatID != update.Message.Chat.ID { + b.chatID = update.Message.Chat.ID + if b.monitor != nil { + b.monitor.SetChatID(update.Message.Chat.ID) + } + } + b.chatMu.Unlock() + + // Validate message text + if update.Message.Text == "" { + return + } + // Tokenize the update tokens := strings.Split(update.Message.Text, " ") @@ -69,70 +111,70 @@ func (b *Bot) handleUpdate(update tgbotapi.Update) { command := strings.ToLower(tokens[0]) - // Route to appropriate handler + // Route to appropriate handler with panic recovery switch command { case "list", "/list", "li", "/li", "/ls", "ls": - go b.handleList(update, tokens[1:]) + go b.safeHandler(func() { b.handleList(update, tokens[1:]) }) case "head", "/head", "he", "/he": - go b.handleHead(update, tokens[1:]) + go b.safeHandler(func() { b.handleHead(update, tokens[1:]) }) case "tail", "/tail", "ta", "/ta": - go b.handleTail(update, tokens[1:]) + go b.safeHandler(func() { b.handleTail(update, tokens[1:]) }) case "downs", "/downs", "dg", "/dg": - go b.handleDowns(update) + go b.safeHandler(func() { b.handleDowns(update) }) case "seeding", "/seeding", "sd", "/sd": - go b.handleSeeding(update) + go b.safeHandler(func() { b.handleSeeding(update) }) case "paused", "/paused", "pa", "/pa": - go b.handlePaused(update) + go b.safeHandler(func() { b.handlePaused(update) }) case "checking", "/checking", "ch", "/ch": - go b.handleChecking(update) + go b.safeHandler(func() { b.handleChecking(update) }) case "active", "/active", "ac", "/ac": - go b.handleActive(update) + go b.safeHandler(func() { b.handleActive(update) }) case "errors", "/errors", "er", "/er": - go b.handleErrors(update) + go b.safeHandler(func() { b.handleErrors(update) }) case "sort", "/sort", "so", "/so": - go b.handleSort(update, tokens[1:]) + go b.safeHandler(func() { b.handleSort(update, tokens[1:]) }) case "trackers", "/trackers", "tr", "/tr": - go b.handleTrackers(update) + go b.safeHandler(func() { b.handleTrackers(update) }) case "downloaddir", "dd": - go b.handleDownloadDir(update, tokens[1:]) + go b.safeHandler(func() { b.handleDownloadDir(update, tokens[1:]) }) case "add", "/add", "ad", "/ad": - go b.handleAdd(update, tokens[1:]) + go b.safeHandler(func() { b.handleAdd(update, tokens[1:]) }) case "search", "/search", "se", "/se": - go b.handleSearch(update, tokens[1:]) + go b.safeHandler(func() { b.handleSearch(update, tokens[1:]) }) case "latest", "/latest", "la", "/la": - go b.handleLatest(update, tokens[1:]) + go b.safeHandler(func() { b.handleLatest(update, tokens[1:]) }) case "info", "/info", "in", "/in": - go b.handleInfo(update, tokens[1:]) + go b.safeHandler(func() { b.handleInfo(update, tokens[1:]) }) case "stop", "/stop", "sp", "/sp": - go b.handleStop(update, tokens[1:]) + go b.safeHandler(func() { b.handleStop(update, tokens[1:]) }) case "start", "/start", "st", "/st": - go b.handleStart(update, tokens[1:]) + go b.safeHandler(func() { b.handleStart(update, tokens[1:]) }) case "check", "/check", "ck", "/ck": - go b.handleCheck(update, tokens[1:]) + go b.safeHandler(func() { b.handleCheck(update, tokens[1:]) }) case "stats", "/stats", "sa", "/sa": - go b.handleStats(update) + go b.safeHandler(func() { b.handleStats(update) }) case "downlimit", "dl": - go b.handleDownLimit(update, tokens[1:]) + go b.safeHandler(func() { b.handleDownLimit(update, tokens[1:]) }) case "uplimit", "ul": - go b.handleUpLimit(update, tokens[1:]) + go b.safeHandler(func() { b.handleUpLimit(update, tokens[1:]) }) case "speed", "/speed", "ss", "/ss": - go b.handleSpeed(update) + go b.safeHandler(func() { b.handleSpeed(update) }) case "count", "/count", "co", "/co": - go b.handleCount(update) + go b.safeHandler(func() { b.handleCount(update) }) case "del", "/del", "rm", "/rm": - go b.handleDel(update, tokens[1:]) + go b.safeHandler(func() { b.handleDel(update, tokens[1:]) }) case "deldata", "/deldata": - go b.handleDelData(update, tokens[1:]) + go b.safeHandler(func() { b.handleDelData(update, tokens[1:]) }) case "help", "/help": - go b.sendMessage(update.Message.Chat.ID, HelpText, true) + go b.safeHandler(func() { b.sendMessage(update.Message.Chat.ID, HelpText, true) }) case "version", "/version", "ver", "/ver": - go b.handleVersion(update) + go b.safeHandler(func() { b.handleVersion(update) }) case "": // might be a file received - go b.handleReceiveTorrent(update) + go b.safeHandler(func() { b.handleReceiveTorrent(update) }) default: // no such command - go b.sendMessage(update.Message.Chat.ID, "No such command, try /help", false) + go b.safeHandler(func() { b.sendMessage(update.Message.Chat.ID, "No such command, try /help", false) }) } } diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 55d81ab..e2bafe8 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -6,6 +6,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" "github.com/dustin/go-humanize" @@ -16,7 +17,48 @@ import ( "transmission-telegram/pkg/utils" ) -var trackerRegex = regexp.MustCompile(`[https?|udp]://([^:/]*)`) +var ( + trackerRegex = regexp.MustCompile(`[https?|udp]://([^:/]*)`) + // bufferPool is a pool for reusing bytes.Buffer instances + bufferPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, + } + // regexCache caches compiled regular expressions + regexCache = sync.Map{} +) + +// getCompiledRegex gets or compiles a regex pattern with caching +func getCompiledRegex(pattern string) (*regexp.Regexp, error) { + // Check cache first + if cached, ok := regexCache.Load(pattern); ok { + return cached.(*regexp.Regexp), nil + } + + // Compile and cache + regx, err := regexp.Compile("(?i)" + pattern) + if err != nil { + return nil, err + } + + regexCache.Store(pattern, regx) + return regx, nil +} + +// getBuffer gets a buffer from the pool +func getBuffer() *bytes.Buffer { + return bufferPool.Get().(*bytes.Buffer) +} + +// putBuffer returns a buffer to the pool after resetting it +func putBuffer(buf *bytes.Buffer) { + buf.Reset() + bufferPool.Put(buf) +} + +// rateLimitWait waits for the next available API call slot +// This function is defined in helpers.go to access the rate limiter // Handler methods for bot commands - these are placeholders that need full implementation // For now, they provide basic functionality @@ -30,7 +72,7 @@ func (b *Bot) handleList(update tgbotapi.Update, tokens []string) { buf := new(bytes.Buffer) if len(tokens) != 0 { - regx, err := regexp.Compile("(?i)" + tokens[0]) + regx, err := getCompiledRegex(tokens[0]) if err != nil { b.sendMessage(update.Message.Chat.ID, "*list:* "+err.Error(), false) return @@ -395,7 +437,7 @@ func (b *Bot) handleSearch(update tgbotapi.Update, tokens []string) { } query := strings.Join(tokens, " ") - regx, err := regexp.Compile("(?i)" + query) + regx, err := getCompiledRegex(query) if err != nil { b.sendMessage(update.Message.Chat.ID, "*search:* "+err.Error(), false) return @@ -795,14 +837,21 @@ func (b *Bot) liveUpdateTorrents(chatID int64, msgID int, filter func(transmissi } filtered := filter(torrents) - buf := new(bytes.Buffer) + buf := getBuffer() + defer putBuffer(buf) + for i := range filtered { buf.WriteString(formatter(&filtered[i])) } + // Rate limit before sending + rateLimitWait() editConf := tgbotapi.NewEditMessageText(chatID, msgID, buf.String()) editConf.ParseMode = tgbotapi.ModeMarkdown - b.api.Send(editConf) + if _, err := b.api.Send(editConf); err != nil { + b.logger.Printf("[ERROR] Failed to update message: %s", err) + return + } } } @@ -814,30 +863,43 @@ func (b *Bot) liveUpdateActive(chatID int64, msgID int) { continue } - buf := new(bytes.Buffer) + buf := getBuffer() for i := range torrents { if torrents[i].RateDownload > 0 || torrents[i].RateUpload > 0 { buf.WriteString(formatter.FormatTorrentDetailed(&torrents[i])) } } + // Rate limit before sending + rateLimitWait() editConf := tgbotapi.NewEditMessageText(chatID, msgID, buf.String()) editConf.ParseMode = tgbotapi.ModeMarkdown - b.api.Send(editConf) + if _, err := b.api.Send(editConf); err != nil { + b.logger.Printf("[ERROR] Failed to update message: %s", err) + putBuffer(buf) + return + } + putBuffer(buf) } time.Sleep(b.cfg.Interval) torrents, _ := b.client.GetTorrents() - buf := new(bytes.Buffer) + buf := getBuffer() + defer putBuffer(buf) + for i := range torrents { if torrents[i].RateDownload > 0 || torrents[i].RateUpload > 0 { buf.WriteString(formatter.FormatTorrentActiveStopped(&torrents[i])) } } + // Rate limit before sending + rateLimitWait() editConf := tgbotapi.NewEditMessageText(chatID, msgID, buf.String()) editConf.ParseMode = tgbotapi.ModeMarkdown - b.api.Send(editConf) + if _, err := b.api.Send(editConf); err != nil { + b.logger.Printf("[ERROR] Failed to update message: %s", err) + } } func (b *Bot) liveUpdateInfo(chatID int64, msgID int, torrentID int, trackers string) { @@ -849,17 +911,26 @@ func (b *Bot) liveUpdateInfo(chatID int64, msgID int, torrentID int, trackers st } info := formatter.FormatTorrentInfo(torrent, trackers) + // Rate limit before sending + rateLimitWait() editConf := tgbotapi.NewEditMessageText(chatID, msgID, info) editConf.ParseMode = tgbotapi.ModeMarkdown - b.api.Send(editConf) + if _, err := b.api.Send(editConf); err != nil { + b.logger.Printf("[ERROR] Failed to update message: %s", err) + return + } } time.Sleep(b.cfg.Interval) torrent, _ := b.client.GetTorrent(torrentID) info := formatter.FormatTorrentInfoStopped(torrent, trackers) + // Rate limit before sending + rateLimitWait() editConf := tgbotapi.NewEditMessageText(chatID, msgID, info) editConf.ParseMode = tgbotapi.ModeMarkdown - b.api.Send(editConf) + if _, err := b.api.Send(editConf); err != nil { + b.logger.Printf("[ERROR] Failed to update message: %s", err) + } } func (b *Bot) liveUpdateSpeed(chatID int64, msgID int) { @@ -871,13 +942,22 @@ func (b *Bot) liveUpdateSpeed(chatID int64, msgID int) { } msg := fmt.Sprintf("↓ %s ↑ %s", humanize.Bytes(stats.DownloadSpeed), humanize.Bytes(stats.UploadSpeed)) + // Rate limit before sending + rateLimitWait() editConf := tgbotapi.NewEditMessageText(chatID, msgID, msg) - b.api.Send(editConf) + if _, err := b.api.Send(editConf); err != nil { + b.logger.Printf("[ERROR] Failed to update message: %s", err) + return + } time.Sleep(b.cfg.Interval) } time.Sleep(b.cfg.Interval) - editConf := tgbotapi.NewEditMessageText(chatID, msgID, "↓ - B ↑ - B") - b.api.Send(editConf) + // Rate limit before sending + rateLimitWait() + editConf := tgbotapi.NewEditMessageText(chatID, msgID, "↓ - ↑ -") + if _, err := b.api.Send(editConf); err != nil { + b.logger.Printf("[ERROR] Failed to update message: %s", err) + } } diff --git a/internal/bot/helpers.go b/internal/bot/helpers.go index 65a325a..91574df 100644 --- a/internal/bot/helpers.go +++ b/internal/bot/helpers.go @@ -4,6 +4,8 @@ import ( "bytes" "fmt" "strconv" + "sync" + "time" "unicode/utf8" "github.com/pyed/transmission" @@ -11,8 +13,36 @@ import ( "transmission-telegram/internal/config" ) +// rateLimiter limits API calls to Telegram (30 messages per second) +type rateLimiter struct { + ticker *time.Ticker + mu sync.Mutex +} + +var ( + // telegramRateLimiter limits API calls to 30 per second + telegramRateLimiter = &rateLimiter{ + ticker: time.NewTicker(time.Second / 30), // 30 calls per second + } +) + +// wait waits for the next available slot +func (rl *rateLimiter) wait() { + rl.mu.Lock() + defer rl.mu.Unlock() + <-rl.ticker.C +} + +// rateLimitWait waits for the next available API call slot +func rateLimitWait() { + telegramRateLimiter.wait() +} + // sendMessage sends a message to Telegram, splitting if necessary func sendMessage(bot *tgbotapi.BotAPI, chatID int64, text string, markdown bool) int { + // Rate limit: wait before sending + telegramRateLimiter.wait() + // Set typing action action := tgbotapi.NewChatAction(chatID, tgbotapi.ChatTyping) bot.Send(action) @@ -22,12 +52,15 @@ func sendMessage(bot *tgbotapi.BotAPI, chatID int64, text string, markdown bool) var lastMsgID int + // Convert to runes for proper UTF-8 handling + runes := []rune(text) + // Split message if too long - for msgRuneCount > config.TelegramMaxMessageLength { + for len(runes) > config.TelegramMaxMessageLength { stop := config.TelegramMaxMessageLength - 1 // Find the last newline before the limit - for stop > 0 && text[stop] != '\n' { + for stop > 0 && runes[stop] != '\n' { stop-- } @@ -36,10 +69,10 @@ func sendMessage(bot *tgbotapi.BotAPI, chatID int64, text string, markdown bool) stop = config.TelegramMaxMessageLength - 1 } - chunk := text[:stop] - text = text[stop:] - msgRuneCount = utf8.RuneCountInString(text) + chunk := string(runes[:stop+1]) + runes = runes[stop+1:] + telegramRateLimiter.wait() msg := tgbotapi.NewMessage(chatID, chunk) msg.DisableWebPagePreview = true if markdown { @@ -52,8 +85,9 @@ func sendMessage(bot *tgbotapi.BotAPI, chatID int64, text string, markdown bool) } // Send remaining text - if len(text) > 0 { - msg := tgbotapi.NewMessage(chatID, text) + if len(runes) > 0 { + telegramRateLimiter.wait() + msg := tgbotapi.NewMessage(chatID, string(runes)) msg.DisableWebPagePreview = true if markdown { msg.ParseMode = tgbotapi.ModeMarkdown diff --git a/internal/formatter/formatter.go b/internal/formatter/formatter.go index 4df96b4..5b1224a 100644 --- a/internal/formatter/formatter.go +++ b/internal/formatter/formatter.go @@ -66,7 +66,7 @@ func FormatTorrentInfo(torrent *transmission.Torrent, trackers string) string { // FormatTorrentInfoStopped formats torrent info when live updates are stopped func FormatTorrentInfoStopped(torrent *transmission.Torrent, trackers string) string { torrentName := utils.EscapeMarkdown(torrent.Name) - return fmt.Sprintf("`<%d>` *%s*\n%s *%s* of *%s* (*%.1f%%*) ↓ *- B* ↑ *- B* R: *%s*\nDL: *%s* UP: *%s*\nAdded: *%s*, ETA: *-*\nTrackers: `%s`", + return fmt.Sprintf("`<%d>` *%s*\n%s *%s* of *%s* (*%.1f%%*) ↓ *-* ↑ *-* R: *%s*\nDL: *%s* UP: *%s*\nAdded: *%s*, ETA: *-*\nTrackers: `%s`", torrent.ID, torrentName, torrent.TorrentStatus(), humanize.Bytes(torrent.Have()), humanize.Bytes(torrent.SizeWhenDone), diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index f9dbc16..61bf4d8 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -86,6 +86,13 @@ func (m *Monitor) checkCompletions() { m.statesMutex.Lock() defer m.statesMutex.Unlock() + // Create a set of current torrent IDs + currentTorrentIDs := make(map[int]bool, len(torrents)) + for _, torrent := range torrents { + currentTorrentIDs[torrent.ID] = true + } + + // Check each torrent for completion and update states for _, torrent := range torrents { prevState, exists := m.states[torrent.ID] @@ -121,5 +128,12 @@ func (m *Monitor) checkCompletions() { delete(m.states, torrent.ID) } } + + // Clean up states for torrents that no longer exist + for torrentID := range m.states { + if !currentTorrentIDs[torrentID] { + delete(m.states, torrentID) + } + } } diff --git a/internal/transmission/client.go b/internal/transmission/client.go index 839dfbc..1a4bc05 100644 --- a/internal/transmission/client.go +++ b/internal/transmission/client.go @@ -182,9 +182,10 @@ func (c *CachedClient) ExecuteAddCommand(cmd transmission.AddCommand) (*transmis return torrent, err } -// SetSort sets sort type +// SetSort sets sort type and invalidates cache since it changes the order func (c *CachedClient) SetSort(sort transmission.SortType) { c.client.SetSort(sort) + c.InvalidateCache() } // Version returns transmission version