Files
MeBox/internal/service/site_search_svc.go
T

215 lines
5.5 KiB
Go

// Package service — 跨站聚合搜索服务.
package service
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// SiteSearchService 跨站聚合搜索服务.
type SiteSearchService struct {
log *zap.Logger
repo *repository.Container
site *SiteService
}
// NewSiteSearchService 创建跨站搜索服务.
func NewSiteSearchService(log *zap.Logger, repo *repository.Container, siteSvc *SiteService) *SiteSearchService {
return &SiteSearchService{log: log, repo: repo, site: siteSvc}
}
// SearchAll 在所有启用的站点中搜索关键字.
func (s *SiteSearchService) SearchAll(ctx context.Context, keyword string, page, pageSize int) (*AggregatedResult, error) {
sites, err := s.repo.Site.ListEnabled(ctx)
if err != nil {
return nil, fmt.Errorf("list enabled sites: %w", err)
}
if len(sites) == 0 {
return &AggregatedResult{
Keyword: keyword,
Items: []TorrentItem{},
Total: 0,
Page: page,
PageSize: pageSize,
}, nil
}
return s.SearchSites(ctx, keyword, sites, page, pageSize)
}
// SearchSites 在指定站点中搜索关键字.
func (s *SiteSearchService) SearchSites(ctx context.Context, keyword string, sites []model.Site, page, pageSize int) (*AggregatedResult, error) {
var mu sync.Mutex
var wg sync.WaitGroup
var allItems []TorrentItem
for _, site := range sites {
wg.Add(1)
go func(siteModel model.Site) {
defer wg.Done()
cfg, err := s.site.GetSiteConfig(ctx, siteModel.ID)
if err != nil {
s.log.Warn("get site config failed", zap.String("site_id", siteModel.ID), zap.Error(err))
return
}
adapter := GetAdapterForType(siteModel.Type)
result, err := adapter.Search(ctx, *cfg, keyword, page)
if err != nil {
s.log.Warn("site search failed",
zap.String("site_id", siteModel.ID),
zap.String("site_name", siteModel.Name),
zap.Error(err),
)
return
}
mu.Lock()
allItems = append(allItems, result.Items...)
mu.Unlock()
}(site)
}
wg.Wait()
sort.Slice(allItems, func(i, j int) bool {
if allItems[i].Seeders != allItems[j].Seeders {
return allItems[i].Seeders > allItems[j].Seeders
}
return allItems[i].UploadTime.After(allItems[j].UploadTime)
})
allItems = deduplicateItems(allItems)
total := len(allItems)
start := (page - 1) * pageSize
end := start + pageSize
if start > total {
start = total
}
if end > total {
end = total
}
return &AggregatedResult{
Keyword: keyword,
Items: allItems[start:end],
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
// SearchSite 在单个站点中搜索.
func (s *SiteSearchService) SearchSite(ctx context.Context, siteID, keyword string, page int) (*SearchResult, error) {
cfg, err := s.site.GetSiteConfig(ctx, siteID)
if err != nil {
return nil, fmt.Errorf("get site config: %w", err)
}
siteModel, err := s.repo.Site.FindByID(ctx, siteID)
if err != nil || siteModel == nil {
return nil, fmt.Errorf("find site: %w", err)
}
adapter := GetAdapterForType(siteModel.Type)
result, err := adapter.Search(ctx, *cfg, keyword, page)
if err != nil {
return nil, fmt.Errorf("search site %s: %w", siteModel.Name, err)
}
return result, nil
}
// BrowseSite 浏览站点资源.
func (s *SiteSearchService) BrowseSite(ctx context.Context, siteID, category string, page int) (*SearchResult, error) {
cfg, err := s.site.GetSiteConfig(ctx, siteID)
if err != nil {
return nil, fmt.Errorf("get site config: %w", err)
}
siteModel, err := s.repo.Site.FindByID(ctx, siteID)
if err != nil || siteModel == nil {
return nil, fmt.Errorf("find site: %w", err)
}
adapter := GetAdapterForType(siteModel.Type)
result, err := adapter.Browse(ctx, *cfg, category, page)
if err != nil {
return nil, fmt.Errorf("browse site %s: %w", siteModel.Name, err)
}
return result, nil
}
// GetTorrentDetail 获取种子详情.
func (s *SiteSearchService) GetTorrentDetail(ctx context.Context, siteID, torrentID string) (*TorrentDetail, error) {
cfg, err := s.site.GetSiteConfig(ctx, siteID)
if err != nil {
return nil, fmt.Errorf("get site config: %w", err)
}
siteModel, err := s.repo.Site.FindByID(ctx, siteID)
if err != nil || siteModel == nil {
return nil, fmt.Errorf("find site: %w", err)
}
adapter := GetAdapterForType(siteModel.Type)
detail, err := adapter.GetDetail(ctx, *cfg, torrentID)
if err != nil {
return nil, fmt.Errorf("get detail from %s: %w", siteModel.Name, err)
}
return detail, nil
}
// AggregatedResult 聚合搜索结果.
type AggregatedResult struct {
Keyword string `json:"keyword"`
Items []TorrentItem `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// deduplicateItems 通过标题相似性去重.
func deduplicateItems(items []TorrentItem) []TorrentItem {
seen := make(map[string]bool)
result := make([]TorrentItem, 0, len(items))
for _, item := range items {
key := normalizeTitle(item.Title)
if key == "" {
continue
}
if !seen[key] {
seen[key] = true
result = append(result, item)
}
}
return result
}
// normalizeTitle 标题标准化.
func normalizeTitle(title string) string {
title = strings.ToLower(strings.TrimSpace(title))
title = strings.ReplaceAll(title, ".", " ")
title = strings.ReplaceAll(title, "_", " ")
title = strings.ReplaceAll(title, "-", " ")
for strings.Contains(title, " ") {
title = strings.ReplaceAll(title, " ", " ")
}
return strings.TrimSpace(title)
}