Files
MeBox/internal/service/site.go
T
ShukeBta 3a2db6bdd9 feat: Emby 兼容层完整实现 + 多模块功能增强
## Emby/Jellyfin 兼容层 (emby_compat.go / emby.go)
- 新增 SystemInfoPublic、FindUser、Items、Item、LatestItems、ResumeItems
- 新增 SetFavorite、MarkPlayed、RecordProgress 用户播放状态同步
- 新增 itemPayload、mediaSource、mediaStreams 媒体信息组装
- 双前缀路由 /emby/* 和 / 根路径,兼容 Infuse/Yamby/Senplayer/Kodi
- 新增 Ping、SystemEndpoint、AuthByName 端点
- emby.go 扩展对应 handler 函数

## 站点适配器 (site_adapter.go / site.go)
- SiteConfig 扩展 UserAgent/Timeout/Extra/FlareSolverrURL 字段
- doRequest() GET 请求支持 FlareSolverr 代理绕过 Cloudflare/WAF
- MTeam api_key 认证改为 Authorization: Bearer 格式
- Search() 重构为 sync.WaitGroup 并发执行,提升多站搜索性能
- siteModelToConfig() 改为 SiteService 方法,按 BrowserEmulation 填充 FlareSolverrURL

## 图片代理 (image_proxy.go)
- 重构图片代理服务,支持更多来源和缓存策略

## 下载管理 (downloads.go / download_clients.go / qbittorrent.go)
- 下载任务增强:状态管理、进度追踪优化
- qBittorrent 客户端连接稳定性改进

## 刮削与数据库 (scraper.go / tmdb.go / repository.go)
- 刮削器增强 TMDB 集成,补全元数据字段
- repository 扩展查询方法

## 前端 (web/src/)
- HomePage: 首页布局重构,按媒体库分组展示,系列聚合优化
- DiscoverPage: 发现页增强,错误处理改进(API key 缺失/网络错误分离)
- PosterWallPage: 海报墙优化,系列聚合展示
- MediaCard: 媒体卡片优化
- PlayerPage: 播放器改进
- 新增 utils/groupSeries.ts: 系列聚合工具函数
- .gitignore: 添加 .tmp_* 临时文件排除规则
2026-05-26 16:09:13 +08:00

299 lines
8.8 KiB
Go

// Package service — site management (PT/BT tracker CRUD + connection test).
//
// SiteService owns the lifecycle of Site rows and exposes a cross-site
// search dispatcher that fans out a keyword query to every enabled site's
// adapter, collects results and returns them merged + sorted.
package service
import (
"context"
"encoding/json"
"errors"
"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"
)
// SiteService manages PT/BT site configurations.
type SiteService struct {
log *zap.Logger
repo *repository.Container
flareSolverrURL string
}
// NewSiteService is the constructor.
func NewSiteService(log *zap.Logger, repo *repository.Container, flareSolverrURL string) *SiteService {
return &SiteService{log: log, repo: repo, flareSolverrURL: flareSolverrURL}
}
// 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
}
// Update applies a partial patch to an existing site.
func (s *SiteService) Update(ctx context.Context, id string, updates map[string]any) error {
return s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).Updates(updates).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.
//
// 测试逻辑(与参考项目 ShukeBta/MediaStation 对齐):
//
// 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 {
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"`
TorrentURL string `json:"torrent_url"`
DownloadURL string `json:"download_url"`
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
results []SearchResult
)
for i := range sites {
if !sites[i].Enabled {
continue
}
wg.Add(1)
go func(site model.Site) {
defer wg.Done()
adapter := NewSiteAdapter(&site)
if adapter == nil {
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 {
s.log.Warn("site search failed",
zap.String("site", site.Name),
zap.String("type", site.Type),
zap.String("url", site.URL),
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,
TorrentURL: item.DetailURL,
DownloadURL: item.DownloadURL,
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
})
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{
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,
}
}