Files
MeBox/internal/service/site_search.go
T

210 lines
5.8 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// SearchResult is one torrent returned by a site adapter search.
type SearchResult struct {
SiteName string `json:"site_name"`
SiteID string `json:"site_id"`
Title string `json:"title"`
Subtitle string `json:"subtitle,omitempty"`
Labels string `json:"labels,omitempty"`
TorrentURL string `json:"torrent_url"`
DownloadURL string `json:"download_url"`
Category string `json:"category,omitempty"`
SearchKeyword string `json:"search_keyword,omitempty"`
Size int64 `json:"size"`
Seeders int `json:"seeders"`
Leechers int `json:"leechers"`
Free bool `json:"free"`
}
// Search fans out a keyword query to every enabled site and returns
// merged results sorted by seeders descending.
// Uses concurrent search with sync.WaitGroup for performance.
func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResult, error) {
if strings.TrimSpace(keyword) == "" {
return []SearchResult{}, nil
}
sites, err := s.List(ctx)
if err != nil {
return nil, err
}
var (
mu sync.Mutex
wg sync.WaitGroup
enabledCount int
failedCount int
failureErrs []error
failures []string
results []SearchResult
)
for i := range sites {
if !sites[i].Enabled {
continue
}
enabledCount++
wg.Add(1)
go func(site model.Site) {
defer wg.Done()
adapter := NewSiteAdapter(&site)
if adapter == nil {
mu.Lock()
failedCount++
err := fmt.Errorf("%s: unsupported site type %s", site.Name, site.Type)
failureErrs = append(failureErrs, err)
failures = append(failures, err.Error())
mu.Unlock()
return
}
cfg := s.siteModelToConfig(&site)
timeout := cfg.Timeout
ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
result, err := adapter.Search(ctxWithTimeout, cfg, keyword, 1)
if err != nil {
mu.Lock()
failedCount++
failureErr := fmt.Errorf("%s: %w", site.Name, err)
failureErrs = append(failureErrs, failureErr)
failures = append(failures, failureErr.Error())
mu.Unlock()
s.log.Warn("site search failed",
zap.String("site", site.Name),
zap.String("type", site.Type),
zap.String("url", site.URL),
zap.String("keyword", keyword),
zap.Duration("timeout", timeout),
zap.Error(err))
return
}
if result == nil {
return
}
siteResults := siteSearchResultsFromItems(site, result, keyword)
mu.Lock()
results = append(results, siteResults...)
mu.Unlock()
}(sites[i])
}
wg.Wait()
// Ensure results is never nil (return [] instead of null in JSON)
if results == nil {
results = []SearchResult{}
}
// Sort by seeders desc.
sort.Slice(results, func(i, j int) bool {
return results[i].Seeders > results[j].Seeders
})
if s.log != nil {
s.log.Info("site search completed",
zap.String("keyword", keyword),
zap.Int("enabled_sites", enabledCount),
zap.Int("failed_sites", failedCount),
zap.Int("results_count", len(results)))
}
if enabledCount > 0 && failedCount >= enabledCount && len(results) == 0 {
if len(failureErrs) > 0 {
return results, fmt.Errorf("all enabled sites failed while searching %q: %w", keyword, errors.Join(failureErrs...))
}
return results, fmt.Errorf("all enabled sites failed while searching %q: %s", keyword, strings.Join(failures, "; "))
}
return results, nil
}
// SearchSite runs a keyword search against one configured site, regardless of
// whether the site is enabled globally. This is used by per-site diagnostics in
// the management UI, where the user expects the selected site to be tested
// directly instead of a full fan-out followed by filtering.
func (s *SiteService) SearchSite(ctx context.Context, siteID, keyword string, page int) ([]SearchResult, error) {
if strings.TrimSpace(keyword) == "" {
return []SearchResult{}, nil
}
if page <= 0 {
page = 1
}
site, err := s.FindByID(ctx, siteID)
if err != nil {
return nil, err
}
if site == nil {
return nil, fmt.Errorf("site not found")
}
adapter := NewSiteAdapter(site)
if adapter == nil {
return nil, fmt.Errorf("%s: unsupported site type %s", site.Name, site.Type)
}
cfg := s.siteModelToConfig(site)
timeout := cfg.Timeout
ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
result, err := adapter.Search(ctxWithTimeout, cfg, keyword, page)
if err != nil {
if s.log != nil {
s.log.Warn("single site search failed",
zap.String("site", site.Name),
zap.String("type", site.Type),
zap.String("url", site.URL),
zap.String("keyword", keyword),
zap.Duration("timeout", timeout),
zap.Error(err))
}
return nil, err
}
out := siteSearchResultsFromItems(*site, result, keyword)
sort.Slice(out, func(i, j int) bool {
return out[i].Seeders > out[j].Seeders
})
if s.log != nil {
s.log.Info("single site search completed",
zap.String("site", site.Name),
zap.String("keyword", keyword),
zap.Int("results_count", len(out)))
}
return out, nil
}
func siteSearchResultsFromItems(site model.Site, result *SiteSearchResult, keyword string) []SearchResult {
if result == nil || len(result.Items) == 0 {
return []SearchResult{}
}
out := make([]SearchResult, 0, len(result.Items))
for _, item := range result.Items {
out = append(out, SearchResult{
SiteName: site.Name,
SiteID: site.ID,
Title: item.Title,
Subtitle: item.Subtitle,
Labels: item.Labels,
TorrentURL: item.DetailURL,
DownloadURL: item.DownloadURL,
Category: item.Category,
SearchKeyword: keyword,
Size: item.Size,
Seeders: item.Seeders,
Leechers: item.Leechers,
Free: item.Free,
})
}
return out
}