From 692b7fb9df1c12faebfec360588a40c6afc7bd5c Mon Sep 17 00:00:00 2001 From: yebuwudong <1573876229@qq.com> Date: Thu, 18 Jun 2026 00:05:38 +0800 Subject: [PATCH] Improve Telegram notifications --- internal/service/downloads.go | 101 +++++++++-- internal/service/downloads_test.go | 38 ++++ internal/service/notify_channels.go | 36 +++- internal/service/notify_channels_test.go | 54 ++++++ internal/service/notify_telegram.go | 221 +++++++++++++++++++++-- internal/service/subscription.go | 20 +- 6 files changed, 435 insertions(+), 35 deletions(-) diff --git a/internal/service/downloads.go b/internal/service/downloads.go index a7eeb59..ade9197 100644 --- a/internal/service/downloads.go +++ b/internal/service/downloads.go @@ -931,8 +931,10 @@ func (d *DownloadService) processDownloadSnapshot(ctx context.Context, live []QB stateKey := completedTorrentQueueKey(torrent) complete := torrent.Progress >= 1.0 matchedTask, hasTask := findMatchingTaskByTorrentIdentity(torrent.Name, taskByKey) + autoOrganize := d.downloadAutoOrganizeEnabled(ctx) catchupRecorded := hasTask && d.completedTorrentCatchupRecorded(ctx, torrent) taskNeedsOrganize := hasTask && !catchupRecorded && + autoOrganize && (downloadTaskNeedsCompletion(matchedTask) || recentlyCompletedTorrent(torrent, time.Now())) d.syncDownloadTaskProgress(ctx, torrent, taskByKey) if stateKey == "" { @@ -1060,6 +1062,20 @@ func (d *DownloadService) markCompletedTorrentOrganizeDone(torrent QBitTorrent) const completedTorrentCatchupWindow = 24 * time.Hour const completedTorrentCatchupSettingPrefix = "download.auto_organized." +const completedTorrentNotifySettingPrefix = "download.completed_notified." + +func (d *DownloadService) downloadAutoOrganizeEnabled(ctx context.Context) bool { + if d == nil || d.repo == nil || d.repo.Setting == nil { + return false + } + if v, err := d.repo.Setting.Get(ctx, "organizer.auto_after_download"); err == nil && parseBoolSetting(v, false) { + return true + } + if v, err := d.repo.Setting.Get(ctx, "organize.auto"); err == nil && parseBoolSetting(v, false) { + return true + } + return false +} // recentlyCompletedTorrent 报告该种子是否在补整理时间窗内完成。 // qBittorrent 未提供 completion_on 时保守地返回 false。 @@ -1111,6 +1127,46 @@ func completedTorrentCatchupSettingKey(torrent QBitTorrent) string { return completedTorrentCatchupSettingPrefix + fmt.Sprintf("%x", sum[:]) } +func (d *DownloadService) completedTorrentNotified(ctx context.Context, torrent QBitTorrent) bool { + if d == nil || d.repo == nil || d.repo.Setting == nil { + return false + } + key := completedTorrentNotifySettingKey(torrent) + if key == "" { + return false + } + value, err := d.repo.Setting.Get(ctx, key) + if err != nil { + return false + } + return parseBoolSetting(value, false) +} + +func (d *DownloadService) markCompletedTorrentNotified(ctx context.Context, torrent QBitTorrent) { + if d == nil || d.repo == nil || d.repo.Setting == nil { + return + } + key := completedTorrentNotifySettingKey(torrent) + if key == "" { + return + } + if err := d.repo.Setting.Set(ctx, key, "true"); err != nil && d.log != nil { + d.log.Debug("mark completed torrent notification failed", + zap.String("hash", torrent.Hash), + zap.String("name", torrent.Name), + zap.Error(err)) + } +} + +func completedTorrentNotifySettingKey(torrent QBitTorrent) string { + key := completedTorrentQueueKey(torrent) + if key == "" { + return "" + } + sum := sha1.Sum([]byte(key)) + return completedTorrentNotifySettingPrefix + fmt.Sprintf("%x", sum[:]) +} + func completedTorrentQueueKey(torrent QBitTorrent) string { hash := strings.ToLower(strings.TrimSpace(torrent.Hash)) if hash != "" { @@ -1221,22 +1277,15 @@ func downloadTaskNeedsCompletion(task model.DownloadTask) bool { // Media rows is too late for freshly-downloaded files: they usually have not // been scanned into the library yet. func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTorrent) { - d.notifyDownloadComplete(torrent) + taskRow, hasTask := d.completedTorrentTask(ctx, torrent) + d.notifyDownloadComplete(ctx, torrent, taskRow) if d.organizer == nil { return } // 仅当显式开启 organizer.auto_after_download / organize.auto 时才在下载完成后整理。 // 之前的代码错误地把 organizer.smart_classify 也当成"自动整理"开关, // 让操作员只想启用"分类子目录"就被动触发了文件 move。 - autoOrganize := false - if v, err := d.repo.Setting.Get(ctx, "organizer.auto_after_download"); err == nil { - autoOrganize = v == "true" || v == "1" || v == "on" - } - if !autoOrganize { - if v, err := d.repo.Setting.Get(ctx, "organize.auto"); err == nil { - autoOrganize = v == "true" || v == "1" || v == "on" - } - } + autoOrganize := d.downloadAutoOrganizeEnabled(ctx) if !autoOrganize { d.log.Info("download completed, auto-organize disabled", zap.String("hash", torrent.Hash)) return @@ -1250,7 +1299,6 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTor zap.String("content_path", torrent.ContentPath)) return } - taskRow, hasTask := d.completedTorrentTask(ctx, torrent) allowReplace := hasTask && taskRow.AllowExistingLibrary d.log.Info("download completed, triggering directory organize", zap.String("hash", torrent.Hash), @@ -1289,22 +1337,49 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTor zap.Int("errors", len(res.Errors))) } -func (d *DownloadService) notifyDownloadComplete(torrent QBitTorrent) { +func (d *DownloadService) notifyDownloadComplete(ctx context.Context, torrent QBitTorrent, task *model.DownloadTask) { if d == nil || d.notify == nil { return } + if d.completedTorrentNotified(ctx, torrent) { + return + } + d.markCompletedTorrentNotified(ctx, torrent) name := strings.TrimSpace(torrent.Name) if name == "" { name = strings.TrimSpace(filepath.Base(torrent.ContentPath)) } + if task != nil && strings.TrimSpace(task.Title) != "" { + name = strings.TrimSpace(task.Title) + } if name == "" { name = "下载任务" } body := fmt.Sprintf("任务:%s\n保存路径:%s\nHash:%s", name, firstNonEmpty(torrent.ContentPath, torrent.SavePath), torrent.Hash) + data := map[string]interface{}{} + if task != nil { + if strings.TrimSpace(task.PosterURL) != "" { + data["poster_url"] = task.PosterURL + } + if strings.TrimSpace(task.BackdropURL) != "" { + data["backdrop_url"] = task.BackdropURL + } + if strings.TrimSpace(task.MediaType) != "" { + data["media_type"] = task.MediaType + } + if strings.TrimSpace(task.MediaCategory) != "" { + data["media_category"] = task.MediaCategory + } + } go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - d.notify.Broadcast(ctx, "MediaStationGo 下载完成", body, EventDownloadComplete) + d.notify.BroadcastEvent(ctx, NotifyEvent{ + Type: EventDownloadComplete, + Title: "MediaStationGo 下载完成", + Message: body, + Data: data, + }) }() } diff --git a/internal/service/downloads_test.go b/internal/service/downloads_test.go index e8d9f11..9529121 100644 --- a/internal/service/downloads_test.go +++ b/internal/service/downloads_test.go @@ -145,6 +145,9 @@ func TestProcessDownloadSnapshotQueuesCompletedPendingTaskOnFirstSnapshot(t *tes if err := repos.Download.Create(t.Context(), task); err != nil { t.Fatal(err) } + if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { + t.Fatal(err) + } svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{ @@ -444,6 +447,9 @@ func TestDownloadPollCatchesUpRecentlyCompletedTorrents(t *testing.T) { if err := repos.Download.Create(t.Context(), task); err != nil { t.Fatal(err) } + if err := repos.Setting.Set(t.Context(), "organizer.auto_after_download", "true"); err != nil { + t.Fatal(err) + } svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) svc.processDownloadSnapshot(t.Context(), []QBitTorrent{ @@ -458,6 +464,38 @@ func TestDownloadPollCatchesUpRecentlyCompletedTorrents(t *testing.T) { } } +func TestDownloadPollDoesNotCatchUpWhenAutoOrganizeDisabled(t *testing.T) { + repos := newOrganizerTestRepo(t) + if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil { + t.Fatal(err) + } + task := &model.DownloadTask{ + Source: "qbittorrent", + URL: "magnet:?xt=urn:btih:fresh", + Title: "Fresh Complete S01E01", + SavePath: "/downloads", + Status: "queued", + Progress: 0, + } + if err := repos.Download.Create(t.Context(), task); err != nil { + t.Fatal(err) + } + svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil) + torrent := QBitTorrent{ + Hash: "fresh-complete", + Name: "Fresh Complete S01E01", + Progress: 1, + CompletionOn: time.Now().Add(-time.Hour).Unix(), + } + + svc.processDownloadSnapshot(t.Context(), []QBitTorrent{torrent}, tasksByTorrentIdentity([]model.DownloadTask{*task})) + svc.processDownloadSnapshot(t.Context(), []QBitTorrent{torrent}, tasksByTorrentIdentity([]model.DownloadTask{*task})) + + if got := len(svc.organizeQueue); got != 0 { + t.Fatalf("auto-organize disabled queued %d completed jobs, want 0", got) + } +} + func TestDownloadPollSkipsRecordedCompletedTorrentCatchup(t *testing.T) { repos := newOrganizerTestRepo(t) torrent := QBitTorrent{ diff --git a/internal/service/notify_channels.go b/internal/service/notify_channels.go index 8ba3e78..46e93e0 100644 --- a/internal/service/notify_channels.go +++ b/internal/service/notify_channels.go @@ -192,16 +192,27 @@ const ( // NotifyEventNone sentinel means the channel stays enabled but receives no // event push. func (s *NotifyChannelService) Broadcast(ctx context.Context, title, body, event string) { + s.BroadcastEvent(ctx, NotifyEvent{ + Type: event, + Title: title, + Message: body, + }) +} + +// BroadcastEvent sends one structured event to every subscribed enabled +// channel. Rich channels such as Telegram can use Data fields for artwork and +// cleaner formatting while simpler channels keep receiving title/body text. +func (s *NotifyChannelService) BroadcastEvent(ctx context.Context, event NotifyEvent) { rows, err := s.repo.NotifyChannel.ListEnabled(ctx) if err != nil { s.log.Warn("notify list failed", zap.Error(err)) return } for _, r := range rows { - if !channelSubscribes(r, event) { + if !channelSubscribes(r, event.Type) { continue } - if err := s.dispatchOne(ctx, r, title, body); err != nil { + if err := s.dispatchOneEvent(ctx, r, event); err != nil { s.log.Warn("notify dispatch failed", zap.String("channel", r.Name), zap.Error(err)) } } @@ -237,8 +248,14 @@ func channelSubscribes(n model.NotifyChannel, event string) bool { // dispatchOne is the inner dispatcher; the channel type drives which // HTTP request gets built. func (s *NotifyChannelService) dispatchOne(ctx context.Context, n model.NotifyChannel, title, body string) error { + return s.dispatchOneEvent(ctx, n, NotifyEvent{Title: title, Message: body}) +} + +func (s *NotifyChannelService) dispatchOneEvent(ctx context.Context, n model.NotifyChannel, event NotifyEvent) error { cfg := map[string]any{} _ = json.Unmarshal([]byte(n.Config), &cfg) + title := event.Title + body := event.Message switch n.Type { case "telegram": @@ -248,9 +265,22 @@ func (s *NotifyChannelService) dispatchOne(ctx context.Context, n model.NotifyCh if token == "" || len(chats) == 0 { return errors.New("telegram missing bot_token / group_chat_id / channel_chat_id") } - text := fmt.Sprintf("%s\n\n%s", escapeHTML(title), escapeHTML(body)) + text := formatTelegramNotification(event) + photoURL := telegramEventPhotoURL(event) var firstErr error for _, chat := range chats { + if photoURL != "" && len(text) <= 1024 { + form := url.Values{} + form.Set("chat_id", chat) + form.Set("photo", photoURL) + form.Set("caption", text) + form.Set("parse_mode", "HTML") + if err := telegramPostForm(ctx, telegramCfg, "sendPhoto", form, 15*time.Second); err == nil { + continue + } else if firstErr == nil { + firstErr = err + } + } form := url.Values{} form.Set("chat_id", chat) form.Set("text", text) diff --git a/internal/service/notify_channels_test.go b/internal/service/notify_channels_test.go index 2580f9f..aa267cc 100644 --- a/internal/service/notify_channels_test.go +++ b/internal/service/notify_channels_test.go @@ -1,9 +1,13 @@ package service import ( + "net/http" + "net/http/httptest" + "strings" "testing" "github.com/ShukeBta/MediaStationGo/internal/model" + "go.uber.org/zap" ) func TestChannelSubscribesCanDisableAllEvents(t *testing.T) { @@ -37,3 +41,53 @@ func TestChannelSubscribesSupportsExplicitAllAndSpecificEvents(t *testing.T) { t.Fatal("unlisted event should not be subscribed") } } + +func TestTelegramDispatchUsesPhotoAndFormattedCaption(t *testing.T) { + var gotPath string + var gotForm map[string]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + gotForm = map[string]string{} + for key := range r.Form { + gotForm[key] = r.Form.Get(key) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + svc := NewNotifyChannelService(zap.NewNop(), nil) + channel := model.NotifyChannel{ + Type: "telegram", + Config: `{ + "bot_token":"123456:ABC", + "group_chat_id":"-10001", + "api_base_url":"` + server.URL + `" + }`, + } + err := svc.dispatchOneEvent(t.Context(), channel, NotifyEvent{ + Type: EventDownloadComplete, + Title: "MediaStationGo 下载完成", + Message: "任务:示例影片\n保存路径:/downloads/movie\nHash:abcdef", + Data: map[string]interface{}{ + "poster_url": server.URL + "/poster.jpg", + }, + }) + if err != nil { + t.Fatal(err) + } + if gotPath != "/bot123456:ABC/sendPhoto" { + t.Fatalf("path = %q, want sendPhoto", gotPath) + } + if gotForm["chat_id"] != "-10001" || gotForm["photo"] == "" { + t.Fatalf("telegram form = %#v", gotForm) + } + caption := gotForm["caption"] + for _, want := range []string{"MediaStationGo", "下载完成", "任务: 示例影片", "/downloads/movie", "abcdef"} { + if !strings.Contains(caption, want) { + t.Fatalf("caption missing %q: %s", want, caption) + } + } +} diff --git a/internal/service/notify_telegram.go b/internal/service/notify_telegram.go index 5847c8e..2c4c55e 100644 --- a/internal/service/notify_telegram.go +++ b/internal/service/notify_telegram.go @@ -4,6 +4,8 @@ package service import ( "context" "fmt" + "net/url" + "sort" "strings" "time" ) @@ -25,9 +27,23 @@ func (p *TelegramProvider) Send(ctx context.Context, cfg map[string]string, even } text := formatTelegramMessage(event, parseMode) + photoURL := telegramEventPhotoURL(event) var firstErr error for _, chatID := range chatIDs { + if photoURL != "" && len(text) <= 1024 { + payload := map[string]string{ + "chat_id": chatID, + "photo": photoURL, + "caption": text, + "parse_mode": parseMode, + } + if err := telegramPostJSON(ctx, cfg, "sendPhoto", payload, 15*time.Second); err == nil { + continue + } else if firstErr == nil { + firstErr = err + } + } payload := map[string]string{ "chat_id": chatID, "text": text, @@ -53,27 +69,196 @@ func (p *TelegramProvider) ValidateConfig(cfg map[string]string) error { // formatTelegramMessage 格式化消息内容。 func formatTelegramMessage(event NotifyEvent, parseMode string) string { - var sb strings.Builder - sb.WriteString(fmt.Sprintf("%s\n\n", escapeHTML(event.Title))) - sb.WriteString(escapeHTML(event.Message)) + text := formatTelegramNotification(event) + if parseMode == "HTML" || parseMode == "" { + return text + } + result := text + result = strings.ReplaceAll(result, "", "**") + result = strings.ReplaceAll(result, "", "**") + result = strings.ReplaceAll(result, "", "`") + result = strings.ReplaceAll(result, "", "`") + result = strings.ReplaceAll(result, "<", "<") + result = strings.ReplaceAll(result, ">", ">") + result = strings.ReplaceAll(result, "&", "&") + return result +} - if len(event.Data) > 0 { +func formatTelegramNotification(event NotifyEvent) string { + var sb strings.Builder + title := strings.TrimSpace(event.Title) + if strings.HasPrefix(title, "MediaStationGo ") { + sb.WriteString("MediaStationGo\n") + sb.WriteString(fmt.Sprintf("%s", escapeHTML(strings.TrimSpace(strings.TrimPrefix(title, "MediaStationGo "))))) + } else if title != "" { + sb.WriteString(fmt.Sprintf("%s", escapeHTML(title))) + } else { + sb.WriteString("MediaStationGo") + } + + message := strings.TrimSpace(event.Message) + if message != "" { sb.WriteString("\n\n") - for k, v := range event.Data { - sb.WriteString(fmt.Sprintf("• %s: %v\n", escapeHTML(k), v)) + sb.WriteString(formatTelegramBody(message)) + } + + fields := telegramDisplayData(event.Data) + if len(fields) > 0 { + if message != "" { + sb.WriteString("\n") + } + sb.WriteString("\n") + for _, field := range fields { + sb.WriteString(formatTelegramField(field.key, field.value)) + sb.WriteString("\n") } } - if parseMode != "HTML" { - // Markdown 模式 - result := sb.String() - result = strings.ReplaceAll(result, "", "**") - result = strings.ReplaceAll(result, "", "**") - result = strings.ReplaceAll(result, "<", "<") - result = strings.ReplaceAll(result, ">", ">") - result = strings.ReplaceAll(result, "&", "&") - return result - } - - return sb.String() + return strings.TrimSpace(sb.String()) +} + +func formatTelegramBody(message string) string { + lines := strings.Split(message, "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + out = append(out, "") + continue + } + if strings.HasPrefix(line, "- ") { + out = append(out, "- "+escapeHTML(strings.TrimSpace(strings.TrimPrefix(line, "- ")))) + continue + } + if key, value, ok := splitTelegramField(line); ok { + out = append(out, formatTelegramField(key, value)) + continue + } + out = append(out, escapeHTML(line)) + } + return strings.TrimSpace(strings.Join(out, "\n")) +} + +func splitTelegramField(line string) (string, string, bool) { + idx := strings.Index(line, ":") + sepLen := len(":") + if idx < 0 { + idx = strings.Index(line, ":") + sepLen = len(":") + } + if idx <= 0 { + return "", "", false + } + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+sepLen:]) + if key == "" || value == "" || len([]rune(key)) > 16 { + return "", "", false + } + return key, value, true +} + +func formatTelegramField(key, value string) string { + key = telegramFieldLabel(key) + escapedValue := escapeHTML(strings.TrimSpace(value)) + if telegramCodeField(key) { + escapedValue = "" + escapedValue + "" + } + return fmt.Sprintf("%s: %s", escapeHTML(key), escapedValue) +} + +func telegramCodeField(key string) bool { + key = strings.ToLower(strings.TrimSpace(key)) + return strings.Contains(key, "hash") || + strings.Contains(key, "路径") || + strings.Contains(key, "path") || + strings.Contains(key, "id") +} + +type telegramDataField struct { + key string + value string +} + +func telegramDisplayData(data map[string]interface{}) []telegramDataField { + if len(data) == 0 { + return nil + } + keys := make([]string, 0, len(data)) + for key := range data { + if telegramHiddenDataKey(key) { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + fields := make([]telegramDataField, 0, len(keys)) + for _, key := range keys { + value := strings.TrimSpace(fmt.Sprint(data[key])) + if value == "" || value == "" { + continue + } + fields = append(fields, telegramDataField{key: key, value: value}) + } + return fields +} + +func telegramHiddenDataKey(key string) bool { + switch strings.ToLower(strings.TrimSpace(key)) { + case "photo_url", "poster_url", "poster", "image_url", "backdrop_url": + return true + default: + return false + } +} + +func telegramFieldLabel(key string) string { + switch strings.ToLower(strings.TrimSpace(key)) { + case "title", "name": + return "标题" + case "save_path": + return "保存路径" + case "hash": + return "Hash" + case "media_type": + return "媒体类型" + case "media_category": + return "分类" + case "subscription": + return "订阅" + case "queued": + return "新增资源" + default: + return strings.TrimSpace(key) + } +} + +func telegramEventPhotoURL(event NotifyEvent) string { + for _, key := range []string{"photo_url", "poster_url", "poster", "image_url", "backdrop_url"} { + value := telegramDataString(event.Data, key) + if isTelegramRemotePhotoURL(value) { + return value + } + } + return "" +} + +func telegramDataString(data map[string]interface{}, key string) string { + if len(data) == 0 { + return "" + } + for k, value := range data { + if strings.EqualFold(strings.TrimSpace(k), key) { + return strings.TrimSpace(fmt.Sprint(value)) + } + } + return "" +} + +func isTelegramRemotePhotoURL(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" { + return false + } + u, err := url.Parse(raw) + return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" } diff --git a/internal/service/subscription.go b/internal/service/subscription.go index 09aeaa9..349a951 100644 --- a/internal/service/subscription.go +++ b/internal/service/subscription.go @@ -421,7 +421,25 @@ func (s *SubscriptionService) notifySubscriptionHit(sub *model.Subscription, que go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - s.notify.Broadcast(ctx, "MediaStationGo 订阅命中新资源", body, EventSubscriptionHit) + data := map[string]interface{}{} + if strings.TrimSpace(sub.PosterURL) != "" { + data["poster_url"] = sub.PosterURL + } + if strings.TrimSpace(sub.BackdropURL) != "" { + data["backdrop_url"] = sub.BackdropURL + } + if strings.TrimSpace(sub.MediaType) != "" { + data["media_type"] = sub.MediaType + } + if strings.TrimSpace(sub.MediaCategory) != "" { + data["media_category"] = sub.MediaCategory + } + s.notify.BroadcastEvent(ctx, NotifyEvent{ + Type: EventSubscriptionHit, + Title: "MediaStationGo 订阅命中新资源", + Message: body, + Data: data, + }) }() }