From cdb532ac550f98c6358bb2fecf6fa4b94cb6d02e Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:44:58 +0800 Subject: [PATCH] refactor site service by responsibility --- internal/service/site.go | 536 ---------------------------- internal/service/site_connection.go | 103 ++++++ internal/service/site_crud.go | 144 ++++++++ internal/service/site_download.go | 175 +++++++++ internal/service/site_search.go | 154 ++++++++ 5 files changed, 576 insertions(+), 536 deletions(-) create mode 100644 internal/service/site_connection.go create mode 100644 internal/service/site_crud.go create mode 100644 internal/service/site_download.go create mode 100644 internal/service/site_search.go diff --git a/internal/service/site.go b/internal/service/site.go index 88a5658..40a38f1 100644 --- a/internal/service/site.go +++ b/internal/service/site.go @@ -6,25 +6,8 @@ package service import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "mime" - "net/http" - "net/url" - "path" - "sort" - "strings" - "sync" - "time" - "go.uber.org/zap" - "gorm.io/gorm" - "github.com/ShukeBta/MediaStationGo/internal/helper" - "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/repository" ) @@ -36,163 +19,6 @@ type SiteService struct { apiRateLimiter siteAPIRateLimiter } -// ResolveDownloadURL converts tracker-specific search result URLs into a URL -// that a downloader can fetch directly. M-Team, NexusPHP and similar sites -// often expose a signed/detail endpoint in search results; qBittorrent cannot -// call those APIs with the configured site credentials, so subscriptions need -// the same resolution path as the manual download button. -func (s *SiteService) ResolveDownloadURL(ctx context.Context, raw string) string { - if strings.TrimSpace(raw) == "" { - return raw - } - matched := s.matchSiteForURL(ctx, raw) - if matched == nil { - return raw - } - - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return raw - } - id := u.Query().Get("id") - if id == "" { - return raw - } - adapter := GetAdapterForType(matched.Type) - if adapter == nil { - return raw - } - cfg := s.siteModelToConfig(matched) - timeout := cfg.Timeout - if timeout <= 0 { - timeout = 15 * time.Second - } - resolveCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - resolved, err := adapter.GetDownloadURL(resolveCtx, cfg, id) - if err != nil || resolved == "" { - if s.log != nil { - s.log.Warn("resolve PT download URL failed", - zap.String("site", matched.Name), - zap.String("raw", redactSensitiveDownloadURL(raw)), - zap.Error(err)) - } - return raw - } - return resolved -} - -func redactSensitiveDownloadURL(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if strings.HasPrefix(strings.ToLower(raw), "magnet:") { - return "magnet:?xt=***" - } - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return "[redacted-download-url]" - } - u.RawQuery = "" - u.Fragment = "" - return u.String() -} - -func (s *SiteService) FetchTorrentFile(ctx context.Context, raw string) ([]byte, string, error) { - matched := s.matchSiteForURL(ctx, raw) - if matched == nil { - return nil, "", errors.New("no matching PT site for torrent URL") - } - cfg := s.siteModelToConfig(matched) - timeout := cfg.Timeout - if timeout <= 0 { - timeout = 30 * time.Second - } - req, err := buildRequest(ctx, http.MethodGet, raw, cfg, nil) - if err != nil { - return nil, "", err - } - req.Header.Set("Accept", "application/x-bittorrent,application/octet-stream,*/*") - client := newHTTPClient(cfg, timeout) - resp, err := client.Do(req) - if err != nil { - return nil, "", err - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, "", fmt.Errorf("torrent fetch: HTTP %d", resp.StatusCode) - } - const maxTorrentSize = 32 << 20 - data, err := io.ReadAll(io.LimitReader(resp.Body, maxTorrentSize+1)) - if err != nil { - return nil, "", err - } - if len(data) == 0 { - return nil, "", errors.New("torrent fetch: empty body") - } - if len(data) > maxTorrentSize { - return nil, "", errors.New("torrent fetch: body too large") - } - if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") { - return nil, "", errors.New("torrent fetch: upstream returned HTML") - } - if torrentInfoHash(data) == "" { - return nil, "", errors.New("torrent fetch: upstream did not return a valid torrent") - } - return data, torrentFilename(raw, resp.Header.Get("Content-Disposition")), nil -} - -func (s *SiteService) matchSiteForURL(ctx context.Context, raw string) *model.Site { - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return nil - } - host := strings.ToLower(u.Host) - - sites, err := s.List(ctx) - if err != nil || len(sites) == 0 { - return nil - } - for i := range sites { - if siteHostMatches(host, sites[i].URL) || siteHostMatches(host, sites[i].RSSURL) { - return &sites[i] - } - } - return nil -} - -func siteHostMatches(host, raw string) bool { - if raw == "" { - return false - } - u, err := url.Parse(raw) - if err != nil || u.Host == "" { - return false - } - siteHost := strings.ToLower(u.Host) - return strings.EqualFold(siteHost, host) || strings.HasSuffix(host, "."+siteHost) -} - -func torrentFilename(rawURL, disposition string) string { - if disposition != "" { - if _, params, err := mime.ParseMediaType(disposition); err == nil { - if filename := strings.TrimSpace(params["filename"]); filename != "" { - return filename - } - } - } - if u, err := url.Parse(rawURL); err == nil { - if name := strings.TrimSpace(path.Base(u.Path)); name != "" && name != "." && name != "/" { - if !strings.HasSuffix(strings.ToLower(name), ".torrent") { - name += ".torrent" - } - return name - } - } - return "download.torrent" -} - // NewSiteService is the constructor. func NewSiteService(log *zap.Logger, repo *repository.Container, flareSolverrURL string) *SiteService { return &SiteService{ @@ -202,365 +28,3 @@ func NewSiteService(log *zap.Logger, repo *repository.Container, flareSolverrURL apiRateLimiter: newPersistentSiteAPIRateLimiter(repo), } } - -// Create persists a new site. -func (s *SiteService) Create(ctx context.Context, site *model.Site) error { - if strings.TrimSpace(site.Name) == "" || strings.TrimSpace(site.URL) == "" { - return errors.New("name and url required") - } - site.URL = strings.TrimRight(site.URL, "/") - if site.Type == "" { - site.Type = "nexusphp" - } - if site.AuthType == "" { - site.AuthType = "cookie" - } - return s.repo.DB.WithContext(ctx).Create(site).Error -} - -// List returns every site ordered by created_at. -func (s *SiteService) List(ctx context.Context) ([]model.Site, error) { - var sites []model.Site - err := s.repo.DB.WithContext(ctx).Order("created_at asc").Find(&sites).Error - if sites == nil { - sites = []model.Site{} - } - return sites, err -} - -// FindByID returns a single site or nil. -func (s *SiteService) FindByID(ctx context.Context, id string) (*model.Site, error) { - var site model.Site - err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&site).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - return &site, err -} - -// siteUpdatableFields is the whitelist of columns that may be patched via -// the update endpoint. Fields like id, created_at, deleted_at, login_status, -// upload_bytes, download_bytes are excluded to prevent injection. -var siteUpdatableFields = map[string]bool{ - "name": true, - "url": true, - "type": true, - "auth_type": true, - "api_key": true, - "cookie": true, - "auth_header": true, - "user_agent": true, - "rss_url": true, - "timeout": true, - "priority": true, - "use_proxy": true, - "rate_limit": true, - "browser_emulation": true, - "downloader": true, - "enabled": true, - "is_default": true, - "extra": true, -} - -// Update applies a partial patch to an existing site. -func (s *SiteService) Update(ctx context.Context, id string, updates map[string]any) error { - if id == "" { - return errors.New("site id required") - } - filtered := make(map[string]any, len(updates)) - for k, v := range updates { - if siteUpdatableFields[k] { - filtered[k] = v - } - } - if len(filtered) == 0 { - return errors.New("no valid fields to update") - } - if raw, ok := filtered["url"].(string); ok { - filtered["url"] = strings.TrimRight(strings.TrimSpace(raw), "/") - } - for _, key := range []string{"api_key", "cookie", "auth_header"} { - if raw, ok := filtered[key].(string); ok && strings.TrimSpace(raw) == "" { - delete(filtered, key) - } - } - return s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).Updates(filtered).Error -} - -// Delete removes a site. -func (s *SiteService) Delete(ctx context.Context, id string) error { - return s.repo.DB.WithContext(ctx).Where("id = ?", id).Delete(&model.Site{}).Error -} - -// TestConnection tries to reach the site's base URL with the configured -// credentials and reports success/failure. -// -// 测试逻辑(与旧版参考实现对齐): -// -// 1. 优先调用对应站点适配器的 Authenticate(),让 PT 站点(M-Team / UNIT3D / -// Gazelle 等)使用各自的开放 API 验证,而不是去拉首页 HTML——后者通常 -// 被 Cloudflare 直接 403 但 API 能正常访问。 -// 2. 适配器不可用或站点类型未知时,回退到 helper.TestSiteConnectivity 的 -// 通用浏览器头 GET 方案。 -// 3. helper.TestSiteConnectivity 在全局 FlareSolverr 启用且站点开启了 -// BrowserEmulation 时,会自动走 FlareSolverr。 -func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, string, error) { - site, err := s.FindByID(ctx, id) - if err != nil || site == nil { - return false, "site not found", err - } - - // Get timeout from site config (default 15 seconds) - timeout := site.Timeout - if timeout <= 0 { - timeout = 15 - } - flareSolverrURL := s.flareSolverrURL - - // ── Path 1: site-aware adapter Authenticate ──────────────────────── - // custom_rss 没有真适配器,跳过;其它类型先尝试针对性认证端点。 - if adapter := NewSiteAdapter(site); adapter != nil && site.Type != "" && site.Type != "custom_rss" { - cfg := s.siteModelToConfig(site) - actx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) - defer cancel() - if authErr := adapter.Authenticate(actx, cfg); authErr == nil { - now := time.Now() - _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). - Updates(map[string]any{ - "login_status": "ok", - "last_error": "", - "last_check_at": &now, - }).Error - return true, "连接成功", nil - } else { - if site.Type == "mteam" || site.Type == "yemapt" || isYemaPTURL(site.URL) { - s.log.Warn("site adapter authenticate failed", - zap.String("site", site.Name), - zap.String("type", site.Type), - zap.Error(authErr)) - now := time.Now() - _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). - Updates(map[string]any{ - "login_status": "fail", - "last_error": authErr.Error(), - "last_check_at": &now, - }).Error - return false, authErr.Error(), nil - } - s.log.Warn("site adapter authenticate failed, falling back to generic test", - zap.String("site", site.Name), - zap.String("type", site.Type), - zap.Error(authErr)) - // 回退到通用 GET 测试 — 给 Cookie/RSS 类站点一个机会 - } - } - - // ── Path 2: generic GET with browser headers / FlareSolverr ─────── - ok, msg, err := helper.TestSiteConnectivity(site, flareSolverrURL, timeout, s.log) - if err != nil { - now := time.Now() - _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). - Updates(map[string]any{ - "login_status": "fail", - "last_error": err.Error(), - "last_check_at": &now, - }).Error - return false, err.Error(), nil - } - - loginStatus := "ok" - storedError := "" - if !ok { - loginStatus = "fail" - storedError = msg - } - now := time.Now() - _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). - Updates(map[string]any{ - "login_status": loginStatus, - "last_error": storedError, - "last_check_at": &now, - }).Error - return ok, msg, nil -} - -// 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"` - 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) - - // Use site timeout or default 30s - timeout := time.Duration(site.Timeout) * time.Second - if timeout <= 0 { - timeout = 30 * time.Second - } - 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 - } - items := result.Items - if items == nil { - items = []TorrentItem{} - } - for _, item := range items { - mu.Lock() - results = append(results, SearchResult{ - SiteName: site.Name, - SiteID: site.ID, - Title: item.Title, - Subtitle: item.Subtitle, - TorrentURL: item.DetailURL, - DownloadURL: item.DownloadURL, - Category: item.Category, - SearchKeyword: keyword, - Size: item.Size, - Seeders: item.Seeders, - Leechers: item.Leechers, - Free: item.Free, - }) - 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 -} - -// siteModelToConfig 将 model.Site 转换为适配器使用的 SiteConfig。 -// 当全局 FlareSolverr 已启用且此站点开启了 BrowserEmulation 时,填充 FlareSolverrURL。 -func (svc *SiteService) siteModelToConfig(s *model.Site) SiteConfig { - timeout := time.Duration(s.Timeout) * time.Second - if timeout <= 0 { - timeout = 15 * time.Second - } - userAgent := s.UserAgent - if userAgent == "" { - userAgent = model.DefaultUserAgent - } - var extra map[string]string - if s.Extra != "" { - _ = json.Unmarshal([]byte(s.Extra), &extra) - } - - // Per-site FlareSolverr opt-in: only when global FlareSolverr is enabled - // AND this site has BrowserEmulation turned on. - flareSolverrURL := "" - if svc.flareSolverrURL != "" && s.BrowserEmulation { - flareSolverrURL = svc.flareSolverrURL - } - - return SiteConfig{ - SiteID: s.ID, - Name: s.Name, - Type: s.Type, - URL: s.URL, - AuthType: s.AuthType, - Cookie: s.Cookie, - APIKey: s.APIKey, - AuthHeader: s.AuthHeader, - UserAgent: userAgent, - Timeout: timeout, - Extra: extra, - FlareSolverrURL: flareSolverrURL, - UseProxy: s.UseProxy, - RateLimit: s.RateLimit, - rateLimiter: svc.apiRateLimiter, - } -} diff --git a/internal/service/site_connection.go b/internal/service/site_connection.go new file mode 100644 index 0000000..8422710 --- /dev/null +++ b/internal/service/site_connection.go @@ -0,0 +1,103 @@ +package service + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/helper" + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +// TestConnection tries to reach the site's base URL with the configured +// credentials and reports success/failure. +// +// 测试逻辑(与旧版参考实现对齐): +// +// 1. 优先调用对应站点适配器的 Authenticate(),让 PT 站点(M-Team / UNIT3D / +// Gazelle 等)使用各自的开放 API 验证,而不是去拉首页 HTML——后者通常 +// 被 Cloudflare 直接 403 但 API 能正常访问。 +// 2. 适配器不可用或站点类型未知时,回退到 helper.TestSiteConnectivity 的 +// 通用浏览器头 GET 方案。 +// 3. helper.TestSiteConnectivity 在全局 FlareSolverr 启用且站点开启了 +// BrowserEmulation 时,会自动走 FlareSolverr。 +func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, string, error) { + site, err := s.FindByID(ctx, id) + if err != nil || site == nil { + return false, "site not found", err + } + + // Get timeout from site config (default 15 seconds) + timeout := site.Timeout + if timeout <= 0 { + timeout = 15 + } + flareSolverrURL := s.flareSolverrURL + + // ── Path 1: site-aware adapter Authenticate ──────────────────────── + // custom_rss 没有真适配器,跳过;其它类型先尝试针对性认证端点。 + if adapter := NewSiteAdapter(site); adapter != nil && site.Type != "" && site.Type != "custom_rss" { + cfg := s.siteModelToConfig(site) + actx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + defer cancel() + if authErr := adapter.Authenticate(actx, cfg); authErr == nil { + now := time.Now() + _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). + Updates(map[string]any{ + "login_status": "ok", + "last_error": "", + "last_check_at": &now, + }).Error + return true, "连接成功", nil + } else { + if site.Type == "mteam" || site.Type == "yemapt" || isYemaPTURL(site.URL) { + s.log.Warn("site adapter authenticate failed", + zap.String("site", site.Name), + zap.String("type", site.Type), + zap.Error(authErr)) + now := time.Now() + _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). + Updates(map[string]any{ + "login_status": "fail", + "last_error": authErr.Error(), + "last_check_at": &now, + }).Error + return false, authErr.Error(), nil + } + s.log.Warn("site adapter authenticate failed, falling back to generic test", + zap.String("site", site.Name), + zap.String("type", site.Type), + zap.Error(authErr)) + // 回退到通用 GET 测试 — 给 Cookie/RSS 类站点一个机会 + } + } + + // ── Path 2: generic GET with browser headers / FlareSolverr ─────── + ok, msg, err := helper.TestSiteConnectivity(site, flareSolverrURL, timeout, s.log) + if err != nil { + now := time.Now() + _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). + Updates(map[string]any{ + "login_status": "fail", + "last_error": err.Error(), + "last_check_at": &now, + }).Error + return false, err.Error(), nil + } + + loginStatus := "ok" + storedError := "" + if !ok { + loginStatus = "fail" + storedError = msg + } + now := time.Now() + _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). + Updates(map[string]any{ + "login_status": loginStatus, + "last_error": storedError, + "last_check_at": &now, + }).Error + return ok, msg, nil +} diff --git a/internal/service/site_crud.go b/internal/service/site_crud.go new file mode 100644 index 0000000..cc592a0 --- /dev/null +++ b/internal/service/site_crud.go @@ -0,0 +1,144 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +// Create persists a new site. +func (s *SiteService) Create(ctx context.Context, site *model.Site) error { + if strings.TrimSpace(site.Name) == "" || strings.TrimSpace(site.URL) == "" { + return errors.New("name and url required") + } + site.URL = strings.TrimRight(site.URL, "/") + if site.Type == "" { + site.Type = "nexusphp" + } + if site.AuthType == "" { + site.AuthType = "cookie" + } + return s.repo.DB.WithContext(ctx).Create(site).Error +} + +// List returns every site ordered by created_at. +func (s *SiteService) List(ctx context.Context) ([]model.Site, error) { + var sites []model.Site + err := s.repo.DB.WithContext(ctx).Order("created_at asc").Find(&sites).Error + if sites == nil { + sites = []model.Site{} + } + return sites, err +} + +// FindByID returns a single site or nil. +func (s *SiteService) FindByID(ctx context.Context, id string) (*model.Site, error) { + var site model.Site + err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&site).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return &site, err +} + +// siteUpdatableFields is the whitelist of columns that may be patched via +// the update endpoint. Fields like id, created_at, deleted_at, login_status, +// upload_bytes, download_bytes are excluded to prevent injection. +var siteUpdatableFields = map[string]bool{ + "name": true, + "url": true, + "type": true, + "auth_type": true, + "api_key": true, + "cookie": true, + "auth_header": true, + "user_agent": true, + "rss_url": true, + "timeout": true, + "priority": true, + "use_proxy": true, + "rate_limit": true, + "browser_emulation": true, + "downloader": true, + "enabled": true, + "is_default": true, + "extra": true, +} + +// Update applies a partial patch to an existing site. +func (s *SiteService) Update(ctx context.Context, id string, updates map[string]any) error { + if id == "" { + return errors.New("site id required") + } + filtered := make(map[string]any, len(updates)) + for k, v := range updates { + if siteUpdatableFields[k] { + filtered[k] = v + } + } + if len(filtered) == 0 { + return errors.New("no valid fields to update") + } + if raw, ok := filtered["url"].(string); ok { + filtered["url"] = strings.TrimRight(strings.TrimSpace(raw), "/") + } + for _, key := range []string{"api_key", "cookie", "auth_header"} { + if raw, ok := filtered[key].(string); ok && strings.TrimSpace(raw) == "" { + delete(filtered, key) + } + } + return s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).Updates(filtered).Error +} + +// Delete removes a site. +func (s *SiteService) Delete(ctx context.Context, id string) error { + return s.repo.DB.WithContext(ctx).Where("id = ?", id).Delete(&model.Site{}).Error +} + +// siteModelToConfig 将 model.Site 转换为适配器使用的 SiteConfig。 +// 当全局 FlareSolverr 已启用且此站点开启了 BrowserEmulation 时,填充 FlareSolverrURL。 +func (svc *SiteService) siteModelToConfig(s *model.Site) SiteConfig { + timeout := time.Duration(s.Timeout) * time.Second + if timeout <= 0 { + timeout = 15 * time.Second + } + userAgent := s.UserAgent + if userAgent == "" { + userAgent = model.DefaultUserAgent + } + var extra map[string]string + if s.Extra != "" { + _ = json.Unmarshal([]byte(s.Extra), &extra) + } + + // Per-site FlareSolverr opt-in: only when global FlareSolverr is enabled + // AND this site has BrowserEmulation turned on. + flareSolverrURL := "" + if svc.flareSolverrURL != "" && s.BrowserEmulation { + flareSolverrURL = svc.flareSolverrURL + } + + return SiteConfig{ + SiteID: s.ID, + Name: s.Name, + Type: s.Type, + URL: s.URL, + AuthType: s.AuthType, + Cookie: s.Cookie, + APIKey: s.APIKey, + AuthHeader: s.AuthHeader, + UserAgent: userAgent, + Timeout: timeout, + Extra: extra, + FlareSolverrURL: flareSolverrURL, + UseProxy: s.UseProxy, + RateLimit: s.RateLimit, + rateLimiter: svc.apiRateLimiter, + } +} diff --git a/internal/service/site_download.go b/internal/service/site_download.go new file mode 100644 index 0000000..da483fe --- /dev/null +++ b/internal/service/site_download.go @@ -0,0 +1,175 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "path" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +// ResolveDownloadURL converts tracker-specific search result URLs into a URL +// that a downloader can fetch directly. M-Team, NexusPHP and similar sites +// often expose a signed/detail endpoint in search results; qBittorrent cannot +// call those APIs with the configured site credentials, so subscriptions need +// the same resolution path as the manual download button. +func (s *SiteService) ResolveDownloadURL(ctx context.Context, raw string) string { + if strings.TrimSpace(raw) == "" { + return raw + } + matched := s.matchSiteForURL(ctx, raw) + if matched == nil { + return raw + } + + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return raw + } + id := u.Query().Get("id") + if id == "" { + return raw + } + adapter := GetAdapterForType(matched.Type) + if adapter == nil { + return raw + } + cfg := s.siteModelToConfig(matched) + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 15 * time.Second + } + resolveCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + resolved, err := adapter.GetDownloadURL(resolveCtx, cfg, id) + if err != nil || resolved == "" { + if s.log != nil { + s.log.Warn("resolve PT download URL failed", + zap.String("site", matched.Name), + zap.String("raw", redactSensitiveDownloadURL(raw)), + zap.Error(err)) + } + return raw + } + return resolved +} + +func redactSensitiveDownloadURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.HasPrefix(strings.ToLower(raw), "magnet:") { + return "magnet:?xt=***" + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return "[redacted-download-url]" + } + u.RawQuery = "" + u.Fragment = "" + return u.String() +} + +func (s *SiteService) FetchTorrentFile(ctx context.Context, raw string) ([]byte, string, error) { + matched := s.matchSiteForURL(ctx, raw) + if matched == nil { + return nil, "", errors.New("no matching PT site for torrent URL") + } + cfg := s.siteModelToConfig(matched) + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 30 * time.Second + } + req, err := buildRequest(ctx, http.MethodGet, raw, cfg, nil) + if err != nil { + return nil, "", err + } + req.Header.Set("Accept", "application/x-bittorrent,application/octet-stream,*/*") + client := newHTTPClient(cfg, timeout) + resp, err := client.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, "", fmt.Errorf("torrent fetch: HTTP %d", resp.StatusCode) + } + const maxTorrentSize = 32 << 20 + data, err := io.ReadAll(io.LimitReader(resp.Body, maxTorrentSize+1)) + if err != nil { + return nil, "", err + } + if len(data) == 0 { + return nil, "", errors.New("torrent fetch: empty body") + } + if len(data) > maxTorrentSize { + return nil, "", errors.New("torrent fetch: body too large") + } + if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") { + return nil, "", errors.New("torrent fetch: upstream returned HTML") + } + if torrentInfoHash(data) == "" { + return nil, "", errors.New("torrent fetch: upstream did not return a valid torrent") + } + return data, torrentFilename(raw, resp.Header.Get("Content-Disposition")), nil +} + +func (s *SiteService) matchSiteForURL(ctx context.Context, raw string) *model.Site { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return nil + } + host := strings.ToLower(u.Host) + + sites, err := s.List(ctx) + if err != nil || len(sites) == 0 { + return nil + } + for i := range sites { + if siteHostMatches(host, sites[i].URL) || siteHostMatches(host, sites[i].RSSURL) { + return &sites[i] + } + } + return nil +} + +func siteHostMatches(host, raw string) bool { + if raw == "" { + return false + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return false + } + siteHost := strings.ToLower(u.Host) + return strings.EqualFold(siteHost, host) || strings.HasSuffix(host, "."+siteHost) +} + +func torrentFilename(rawURL, disposition string) string { + if disposition != "" { + if _, params, err := mime.ParseMediaType(disposition); err == nil { + if filename := strings.TrimSpace(params["filename"]); filename != "" { + return filename + } + } + } + if u, err := url.Parse(rawURL); err == nil { + if name := strings.TrimSpace(path.Base(u.Path)); name != "" && name != "." && name != "/" { + if !strings.HasSuffix(strings.ToLower(name), ".torrent") { + name += ".torrent" + } + return name + } + } + return "download.torrent" +} diff --git a/internal/service/site_search.go b/internal/service/site_search.go new file mode 100644 index 0000000..6c35afb --- /dev/null +++ b/internal/service/site_search.go @@ -0,0 +1,154 @@ +package service + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "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"` + 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) + + // Use site timeout or default 30s + timeout := time.Duration(site.Timeout) * time.Second + if timeout <= 0 { + timeout = 30 * time.Second + } + 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 + } + items := result.Items + if items == nil { + items = []TorrentItem{} + } + for _, item := range items { + mu.Lock() + results = append(results, SearchResult{ + SiteName: site.Name, + SiteID: site.ID, + Title: item.Title, + Subtitle: item.Subtitle, + TorrentURL: item.DetailURL, + DownloadURL: item.DownloadURL, + Category: item.Category, + SearchKeyword: keyword, + Size: item.Size, + Seeders: item.Seeders, + Leechers: item.Leechers, + Free: item.Free, + }) + 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 +}