Fix organize metadata matching and Telegram group privacy

This commit is contained in:
ShukeBta
2026-06-14 01:12:55 +08:00
parent c76c72966a
commit 4a3ed77395
7 changed files with 353 additions and 13 deletions
+9 -4
View File
@@ -31,10 +31,11 @@ import (
// OrganizerService moves/renames files into library structures.
type OrganizerService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
probe *FFprobeService // optional; used for 洗版 resolution comparison
cfg *config.Config
log *zap.Logger
repo *repository.Container
probe *FFprobeService // optional; used for 洗版 resolution comparison
scraper *ScraperService // optional; used to identify metadata before rename
}
// NewOrganizerService is the constructor.
@@ -47,6 +48,10 @@ func NewOrganizerService(cfg *config.Config, log *zap.Logger, repo *repository.C
// Optional: when nil the organizer falls back to filename resolution tokens.
func (o *OrganizerService) SetProbe(p *FFprobeService) { o.probe = p }
// SetScraper wires the scraper so directory organize can resolve TMDb/Bangumi
// metadata before it decides the final folder and filename.
func (o *OrganizerService) SetScraper(s *ScraperService) { o.scraper = s }
// OrganizeResult reports what happened.
type OrganizeResult struct {
Organized int `json:"organized"`
+98 -3
View File
@@ -138,6 +138,7 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO
}
mode := o.resolveTransferMode(ctx, opts.TransferMode)
res := &OrganizeResult{SourcePath: source, DestPath: dest, DryRun: opts.DryRun}
metadataCache := map[string]*Match{}
if !info.IsDir() {
ext := strings.ToLower(filepath.Ext(source))
if _, ok := videoExtensions[ext]; !ok {
@@ -157,7 +158,7 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO
)
return res, nil
}
if err := o.organizeSourceFile(ctx, source, filepath.Dir(source), dest, mode, opts.MediaType, opts.MediaCategory, opts.DryRun, opts.AllowReplaceExisting, res); err != nil {
if err := o.organizeSourceFile(ctx, source, filepath.Dir(source), dest, mode, opts.MediaType, opts.MediaCategory, opts.DryRun, opts.AllowReplaceExisting, metadataCache, res); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", filepath.Base(source), err.Error()))
res.Items = append(res.Items, OrganizePreviewItem{Source: source, Action: "error", Reason: err.Error()})
}
@@ -185,7 +186,7 @@ func (o *OrganizerService) OrganizeDirectory(ctx context.Context, opts OrganizeO
res.Items = append(res.Items, OrganizePreviewItem{Source: path, Action: "skip", Reason: reason})
return nil
}
if err := o.organizeSourceFile(ctx, path, source, dest, mode, opts.MediaType, opts.MediaCategory, opts.DryRun, opts.AllowReplaceExisting, res); err != nil {
if err := o.organizeSourceFile(ctx, path, source, dest, mode, opts.MediaType, opts.MediaCategory, opts.DryRun, opts.AllowReplaceExisting, metadataCache, res); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("%s: %s", filepath.Base(path), err.Error()))
res.Items = append(res.Items, OrganizePreviewItem{Source: path, Action: "error", Reason: err.Error()})
}
@@ -239,7 +240,7 @@ type organizeDirectoryLayout struct {
// organizeSourceFile organizes a single video file from the source directory
// into destRoot, applying dedup + 洗版.
func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRoot, destRoot string, mode TransferMode, mediaTypeOverride, mediaCategoryOverride string, dryRun bool, allowReplaceExisting bool, res *OrganizeResult) error {
func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRoot, destRoot string, mode TransferMode, mediaTypeOverride, mediaCategoryOverride string, dryRun bool, allowReplaceExisting bool, metadataCache map[string]*Match, res *OrganizeResult) error {
ext := filepath.Ext(src)
title, year := CleanQuery(src)
if title == "" {
@@ -263,6 +264,15 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
if layout.MediaType == "" {
layout.MediaType = o.inferMediaTypeForSourceFile(src, title, season, episode)
}
if match := o.lookupOrganizeMetadata(ctx, src, sourceRoot, layout.MediaType, title, year, season, episode, metadataCache); match != nil {
if matchedTitle := sanitizeFilename(strings.TrimSpace(match.Title)); matchedTitle != "" {
title = matchedTitle
parsedTitle = strings.TrimSpace(match.Title)
}
if match.Year > 0 {
year = match.Year
}
}
if category := strings.TrimSpace(mediaCategoryOverride); category != "" {
layout.Category = sanitizeFilename(category)
} else if layout.Category == "" {
@@ -445,6 +455,91 @@ func (o *OrganizerService) inferMediaTypeForSourceFile(src, title string, season
return normalizeMediaType("", title, src)
}
func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sourceRoot, mediaType, title string, year, season, episode int, cache map[string]*Match) *Match {
seriesLike := isSeriesLibraryType(mediaType) || season > 0 || episode > 0
if local, err := ReadLocalMetadata(src, sourceRoot, seriesLike); err == nil && local != nil {
if match := organizeMatchFromLocalMetadata(local); match != nil {
return match
}
} else if err != nil && o.log != nil {
o.log.Debug("organize read local metadata before rename failed", zap.String("path", src), zap.Error(err))
}
if o == nil || o.scraper == nil || !o.scraper.AnyEnabled() {
return nil
}
libType := normalizeOrganizeMediaType(mediaType)
if libType == "" {
libType = organizeLibraryModelType(mediaType)
}
lib := &model.Library{Path: sourceRoot, Type: libType, Enabled: true}
media := &model.Media{
Title: title,
Year: year,
Path: src,
SeasonNum: season,
EpisodeNum: episode,
}
for _, candidate := range scrapeQueryCandidates(media, lib) {
key := organizeMetadataCacheKey(lib.Type, candidate, year)
if cache != nil {
if cached, ok := cache[key]; ok {
if cached != nil {
return cached
}
continue
}
}
match := o.scraper.lookup(ctx, lib, candidate, year)
if cache != nil {
cache[key] = match
}
if match != nil && strings.TrimSpace(match.Title) != "" {
if o.log != nil {
o.log.Info("organize metadata matched before rename",
zap.String("source", src),
zap.String("query", candidate),
zap.String("title", match.Title),
zap.Int("year", match.Year),
zap.Int("tmdb_id", match.TMDbID),
zap.Int("bangumi_id", match.BangumiID))
}
return match
}
}
return nil
}
func organizeMatchFromLocalMetadata(local *LocalMetadata) *Match {
if local == nil || strings.TrimSpace(local.Title) == "" {
return nil
}
match := &Match{
Title: strings.TrimSpace(local.Title),
OriginalName: strings.TrimSpace(local.OriginalName),
Overview: local.Overview,
PosterURL: local.PosterURL,
BackdropURL: local.BackdropURL,
Year: local.Year,
Rating: local.Rating,
TMDbID: local.TMDbID,
NSFW: local.NSFW,
}
if local.Genres != "" {
match.Genres = splitNFOList(local.Genres)
}
if local.Countries != "" {
match.Countries = splitNFOList(local.Countries)
}
if local.Languages != "" {
match.Languages = splitNFOList(local.Languages)
}
return match
}
func organizeMetadataCacheKey(mediaType, query string, year int) string {
return strings.ToLower(strings.TrimSpace(mediaType)) + "|" + fmt.Sprint(year) + "|" + strings.ToLower(strings.TrimSpace(query))
}
func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sourceRoot, mediaType, title, parsedTitle string) string {
if o == nil || !o.isSmartClassifyEnabled(ctx) {
return ""
+33
View File
@@ -69,6 +69,39 @@ func TestOrganizeDirectoryScanAndScrapeAfter(t *testing.T) {
}
}
func TestOrganizeDirectoryUsesScraperMatchBeforeRename(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
root := t.TempDir()
src := filepath.Join(root, "downloads")
dest := filepath.Join(root, "media")
sourceFile := filepath.Join(src, "Spy.x.Family.S01E01.2022.1080p.mkv")
writeOrgFile(t, sourceFile, "episode")
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
organizer.SetScraper(scraper)
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
SourcePath: src,
DestPath: dest,
TransferMode: TransferCopy,
MediaType: "tv",
})
if err != nil {
t.Fatalf("organize directory: %v", err)
}
if res.Organized != 1 {
t.Fatalf("organized = %d, want 1", res.Organized)
}
want := filepath.Join(dest, "电视剧", "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Fatalf("organized file should use matched metadata path %q: %v; items=%#v", want, err, res.Items)
}
if len(res.Items) != 1 || res.Items[0].Target != want || res.Items[0].Title != "间谍过家家" {
t.Fatalf("organize preview did not use scraper metadata: %#v", res.Items)
}
}
func TestOrganizeScanAndScrapeRetriesNoMatchRows(t *testing.T) {
scraper, repos, closeServer := newTestScraper(t)
defer closeServer()
+1
View File
@@ -101,6 +101,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
scraper := NewScraperService(cfg, log, repos, tmdb, bangumi, thetvdb, fanart, hub, adult)
organizer := NewOrganizerService(cfg, log, repos)
organizer.SetProbe(probe)
organizer.SetScraper(scraper)
discover := NewDiscoverService(log, tmdb)
transcoder := NewTranscoderService(cfg, log, repos, hub)
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
+146
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -11,6 +12,7 @@ import (
"time"
"github.com/ShukeBta/MediaStationGo/internal/model"
"go.uber.org/zap"
)
func TestTelegramMethodURLUsesCustomAPIBase(t *testing.T) {
@@ -249,6 +251,150 @@ func TestTelegramReplyAutoDeletesSentMessage(t *testing.T) {
waitForTelegramMethod(t, requests, "deleteMessage")
}
func TestTelegramGroupCommandSendsPanelPrivately(t *testing.T) {
var payloads []struct {
ChatID any `json:"chat_id"`
Text string `json:"text"`
ReplyMarkup map[string]any `json:"reply_markup"`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/sendMessage") {
http.NotFound(w, r)
return
}
var payload struct {
ChatID any `json:"chat_id"`
Text string `json:"text"`
ReplyMarkup map[string]any `json:"reply_markup"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode sendMessage: %v", err)
}
payloads = append(payloads, payload)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":777}}`))
}))
defer server.Close()
repos, bot := newBotTestService(t)
cfg, _ := json.Marshal(map[string]string{
"bot_token": "123456:ABC-def",
"api_base_url": server.URL,
"group_chat_id": "-100123",
"auto_delete_seconds": "-1",
})
if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)}).Error; err != nil {
t.Fatalf("create channel: %v", err)
}
update, _ := json.Marshal(TelegramUpdate{
UpdateID: 1,
Message: &TelegramMessage{
MessageID: 55,
From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"},
Chat: TelegramChat{ID: -100123, Type: "supergroup"},
Text: "/menu",
},
})
if err := bot.HandleWebhook(t.Context(), update); err != nil {
t.Fatalf("handle webhook: %v", err)
}
if len(payloads) != 2 {
t.Fatalf("sendMessage count = %d, payloads=%#v", len(payloads), payloads)
}
if got := fmt.Sprint(payloads[0].ChatID); got != "9002" {
t.Fatalf("first message should be private to requester, chat_id=%s payload=%#v", got, payloads[0])
}
if payloads[0].ReplyMarkup == nil {
t.Fatalf("private panel should include inline keyboard: %#v", payloads[0])
}
if got := fmt.Sprint(payloads[1].ChatID); got != "-100123" {
t.Fatalf("second message should be group ack, chat_id=%s payload=%#v", got, payloads[1])
}
if payloads[1].ReplyMarkup != nil {
t.Fatalf("group ack must not expose buttons: %#v", payloads[1])
}
if !strings.Contains(payloads[1].Text, "私聊") {
t.Fatalf("group ack should explain private delivery, got %q", payloads[1].Text)
}
}
func TestTelegramGroupCallbackIsRejected(t *testing.T) {
var callbackPayloads []struct {
CallbackID string `json:"callback_query_id"`
Text string `json:"text"`
ShowAlert bool `json:"show_alert"`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/answerCallbackQuery") {
http.NotFound(w, r)
return
}
var payload struct {
CallbackID string `json:"callback_query_id"`
Text string `json:"text"`
ShowAlert bool `json:"show_alert"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode answerCallbackQuery: %v", err)
}
callbackPayloads = append(callbackPayloads, payload)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":true}`))
}))
defer server.Close()
ctx := t.Context()
repos, auth, _, _ := newAuthTestServices(t)
user, _, err := auth.Register(ctx, "viewer", "secret-pass")
if err != nil {
t.Fatalf("register user: %v", err)
}
if err := repos.DB.AutoMigrate(&model.NotifyChannel{}); err != nil {
t.Fatalf("migrate notify channel: %v", err)
}
if err := repos.DB.Create(&model.TelegramBinding{
TelegramUserID: 9002,
TelegramName: "@viewer",
ChatID: 9002,
UserID: user.ID,
}).Error; err != nil {
t.Fatalf("create binding: %v", err)
}
cfg, _ := json.Marshal(map[string]string{
"bot_token": "123456:ABC-def",
"api_base_url": server.URL,
"group_chat_id": "-100123",
"auto_delete_seconds": "-1",
})
if err := repos.DB.Create(&model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: string(cfg)}).Error; err != nil {
t.Fatalf("create channel: %v", err)
}
before, _ := repos.User.FindByID(ctx, user.ID)
bot := NewTelegramBotService(zap.NewNop(), repos, nil, auth)
update, _ := json.Marshal(TelegramUpdate{
UpdateID: 2,
CallbackQuery: &TelegramCallbackQuery{
ID: "cb-group",
From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"},
Message: &TelegramMessage{MessageID: 56, Chat: TelegramChat{ID: -100123, Type: "supergroup"}},
Data: "adult_toggle",
},
})
if err := bot.HandleWebhook(ctx, update); err != nil {
t.Fatalf("handle webhook: %v", err)
}
if len(callbackPayloads) != 1 {
t.Fatalf("answerCallbackQuery count = %d", len(callbackPayloads))
}
if !callbackPayloads[0].ShowAlert || !strings.Contains(callbackPayloads[0].Text, "群组内按钮面板已禁用") {
t.Fatalf("unexpected callback answer: %#v", callbackPayloads[0])
}
after, _ := repos.User.FindByID(ctx, user.ID)
if before == nil || after == nil || before.HideAdult != after.HideAdult {
t.Fatalf("group callback should not mutate user adult visibility: before=%#v after=%#v", before, after)
}
}
func waitForTelegramMethod(t *testing.T, requests <-chan string, want string) {
t.Helper()
deadline := time.After(2 * time.Second)
+62 -4
View File
@@ -217,13 +217,13 @@ func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) err
reply, err := s.executeCommand(ctx, channel, msg, text)
if err != nil {
s.log.Error("command failed", zap.Error(err))
_ = s.reply(ctx, channel, msg.Chat.ID, telegramCommandReply{Text: "命令执行失败: " + err.Error()})
_ = s.replyForMessage(ctx, channel, msg, telegramCommandReply{Text: "命令执行失败: " + err.Error()})
s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID)
return nil
}
if reply.Text != "" {
if err := s.reply(ctx, channel, msg.Chat.ID, reply); err != nil {
if err := s.replyForMessage(ctx, channel, msg, reply); err != nil {
s.log.Error("reply failed", zap.Error(err))
}
s.deleteTelegramSourceMessage(channel, msg.Chat.ID, msg.MessageID)
@@ -255,6 +255,15 @@ func telegramIsGroupChat(chatType string) bool {
return chatType != "" && chatType != "private"
}
func telegramPrivateMessageForUser(msg *TelegramMessage) *TelegramMessage {
if msg == nil || !telegramIsGroupChat(msg.Chat.Type) {
return msg
}
copied := *msg
copied.Chat = TelegramChat{ID: msg.From.ID, Type: "private"}
return &copied
}
func telegramGroupPrivateAdminHint() string {
return "管理命令请私聊 Bot 使用 <code>/menu</code> 或对应管理员命令,避免在群组公开管理面板。"
}
@@ -267,6 +276,14 @@ func telegramGroupPrivateUserHint(action string) string {
return action + "包含账号凭据或敏感信息,请私聊 Bot 操作;群组内仅开放账号状态、签到、设备与成人目录开关。"
}
func telegramGroupPrivateDeliverySentHint() string {
return "已把你的 Bot 面板/执行结果私聊发送给你。若没收到,请先私聊 Bot 发送 <code>/start</code>。"
}
func telegramGroupPrivateDeliveryFailedHint() string {
return "无法私聊发送给你。请先打开 Bot 私聊窗口发送 <code>/start</code>,再回群里使用命令。"
}
// cmdStart 处理 /start 命令。
func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
name := msg.From.FirstName
@@ -930,6 +947,29 @@ func (s *TelegramBotService) reply(ctx context.Context, channel *model.NotifyCha
return nil
}
func (s *TelegramBotService) replyForMessage(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, reply telegramCommandReply) error {
if msg == nil {
return nil
}
if strings.TrimSpace(reply.Text) == "" {
return nil
}
if !telegramIsGroupChat(msg.Chat.Type) {
return s.reply(ctx, channel, msg.Chat.ID, reply)
}
if err := s.reply(ctx, channel, msg.From.ID, reply); err != nil {
if s.log != nil {
s.log.Warn("telegram private reply from group failed",
zap.Int("group_chat_id", msg.Chat.ID),
zap.Int("telegram_user_id", msg.From.ID),
zap.Error(sanitizeTelegramError(err)),
)
}
return s.reply(ctx, channel, msg.Chat.ID, telegramCommandReply{Text: telegramGroupPrivateDeliveryFailedHint()})
}
return s.reply(ctx, channel, msg.Chat.ID, telegramCommandReply{Text: telegramGroupPrivateDeliverySentHint()})
}
func (s *TelegramBotService) deleteTelegramSourceMessage(channel *model.NotifyChannel, chatID, messageID int) {
if messageID <= 0 {
return
@@ -1057,6 +1097,15 @@ func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCal
channel = s.findChannelByChatID(ctx, cb.Message.Chat.ID)
}
// 立即应答回调,关闭按钮上的加载状态,避免客户端长时间转圈。
if telegramIsGroupChat(cb.Message.Chat.Type) {
s.answerCallbackWithText(ctx, channel, cb.ID, "为了隐私,群组内按钮面板已禁用。请私聊 Bot 或在群里发送 /menu,我会把面板私聊给你。", true)
s.deleteTelegramSourceMessage(channel, cb.Message.Chat.ID, cb.Message.MessageID)
return nil
}
if cb.Message.Chat.Type == "private" && cb.Message.Chat.ID != cb.From.ID {
s.answerCallbackWithText(ctx, channel, cb.ID, "这个面板不属于你,请发送 /menu 打开自己的面板。", true)
return nil
}
s.answerCallback(ctx, channel, cb.ID)
data := strings.TrimSpace(cb.Data)
if data == "adult_toggle" {
@@ -1080,6 +1129,10 @@ func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCal
// answerCallback 应答 Telegram 回调查询,关闭按钮上的加载提示。
func (s *TelegramBotService) answerCallback(ctx context.Context, channel *model.NotifyChannel, callbackID string) {
s.answerCallbackWithText(ctx, channel, callbackID, "", false)
}
func (s *TelegramBotService) answerCallbackWithText(ctx context.Context, channel *model.NotifyChannel, callbackID, text string, showAlert bool) {
if channel == nil || strings.TrimSpace(callbackID) == "" {
return
}
@@ -1087,9 +1140,14 @@ func (s *TelegramBotService) answerCallback(ctx context.Context, channel *model.
if strings.TrimSpace(cfg["bot_token"]) == "" {
return
}
if err := telegramPostJSON(ctx, cfg, "answerCallbackQuery", map[string]interface{}{
payload := map[string]interface{}{
"callback_query_id": callbackID,
}, 8*time.Second); err != nil {
}
if strings.TrimSpace(text) != "" {
payload["text"] = text
payload["show_alert"] = showAlert
}
if err := telegramPostJSON(ctx, cfg, "answerCallbackQuery", payload, 8*time.Second); err != nil {
s.log.Debug("telegram answerCallbackQuery failed", zap.Error(sanitizeTelegramError(err)))
}
}
+4 -2
View File
@@ -25,11 +25,13 @@ func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, cha
return []telegramCommandDefinition{
{Aliases: []string{"/start"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
if len(args) == 0 {
return s.mainMenu(ctx, channel, msg), nil
return s.mainMenu(ctx, channel, telegramPrivateMessageForUser(msg)), nil
}
return s.cmdStart(ctx, msg, args), nil
}},
{Aliases: []string{"/menu"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.mainMenu(ctx, channel, msg), nil }},
{Aliases: []string{"/menu"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
return s.mainMenu(ctx, channel, telegramPrivateMessageForUser(msg)), nil
}},
{Aliases: []string{"/cancel"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
s.takePending(int64(msg.From.ID))
return telegramCommandReply{Text: "已取消当前操作。"}, nil