mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
refactor site service by responsibility
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user