From 7a18eb670aa7edbe6196dc2b5108137c08598418 Mon Sep 17 00:00:00 2001 From: Kiro Date: Sat, 16 May 2026 08:29:00 +0000 Subject: [PATCH 1/4] feat: site management + cross-site torrent search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the complete site management system from the original MediaStation: Model: - model/model.go: Added Site table with fields for name, base_url, site_type (nexusphp/gazelle/unit3d/mteam/custom_rss), auth_type (cookie/api_key/authorization), cookie, api_key, auth_header, user_agent, rss_url, timeout, priority, use_proxy, enabled, login_status, downloader. Registered in AllModels(). Backend services: - service/site.go: SiteService with CRUD (Create/List/FindByID/Update/ Delete), TestConnection (validates credentials via HTTP, updates login_status in DB), and Search (cross-site fan-out that queries every enabled site adapter and returns merged results sorted by seeders descending). - service/site_adapter.go: SiteAdapter interface + NewSiteAdapter factory + 5 implementations: * nexusPhpAdapter — regex HTML scraping of torrents.php * gazelleAdapter — JSON API /ajax.php?action=browse * unit3dAdapter — REST API /api/torrents/filter * mteamAdapter — M-Team v3 POST /api/torrent/search with x-api-key * rssAdapter — XML RSS/Atom feed parsing with keyword filter Handler + routes: - handler/sites.go: listSites, getSite, createSite, updateSite, deleteSite, testSite, siteSearch handlers. - handler/handler.go: 7 new routes under authed group: GET/POST /sites, GET/PUT/DELETE /sites/:id, POST /sites/:id/test, GET /sites/search. Frontend: - api/sites.ts: typed interfaces (Site, SiteSearchResult, CreateSiteInput) and sitesAPI helper. - pages/SitesPage.tsx: full CRUD UI with add-site form (site_type + auth_type selectors, cookie/api_key/rss inputs), connection test button with live status indicator, delete action. - pages/SiteSearchPage.tsx: cross-site search with keyword input, merged result table (site/title/size/seeders/leechers/free), one-click download-to-qBittorrent button. - App.tsx: lazy-loaded routes /sites and /site-search. - Layout.tsx: sidebar links 站点管理 + 站点搜索 under 自动化 group. Verified: go build + go vet + go test pass; tsc -b + vite build passes (32 lazy chunks, main bundle 255 KB / 85 KB gzipped). --- internal/handler/handler.go | 9 + internal/handler/sites.go | 148 +++++++++ internal/model/model.go | 25 ++ internal/service/service.go | 3 + internal/service/site.go | 210 +++++++++++++ internal/service/site_adapter.go | 501 +++++++++++++++++++++++++++++++ web/src/App.tsx | 8 + web/src/api/sites.ts | 77 +++++ web/src/components/Layout.tsx | 5 +- web/src/pages/SiteSearchPage.tsx | 161 ++++++++++ web/src/pages/SitesPage.tsx | 258 ++++++++++++++++ 11 files changed, 1404 insertions(+), 1 deletion(-) create mode 100644 internal/handler/sites.go create mode 100644 internal/service/site.go create mode 100644 internal/service/site_adapter.go create mode 100644 web/src/api/sites.ts create mode 100644 web/src/pages/SiteSearchPage.tsx create mode 100644 web/src/pages/SitesPage.tsx diff --git a/internal/handler/handler.go b/internal/handler/handler.go index c48537b..0a8a1b8 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -125,6 +125,15 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C authed.POST("/duplicates/scan", middleware.AdminRequired(), detectDuplicatesHandler(svc)) authed.POST("/duplicates/unmark", middleware.AdminRequired(), unmarkDuplicatesHandler(svc)) + // Site management + cross-site torrent search. + authed.GET("/sites", listSitesHandler(svc)) + authed.GET("/sites/:id", getSiteHandler(svc)) + authed.POST("/sites", middleware.AdminRequired(), createSiteHandler(svc)) + authed.PUT("/sites/:id", middleware.AdminRequired(), updateSiteHandler(svc)) + authed.DELETE("/sites/:id", middleware.AdminRequired(), deleteSiteHandler(svc)) + authed.POST("/sites/:id/test", middleware.AdminRequired(), testSiteHandler(svc)) + authed.GET("/sites/search", siteSearchHandler(svc)) + // Recycle bin. authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc)) diff --git a/internal/handler/sites.go b/internal/handler/sites.go new file mode 100644 index 0000000..274c648 --- /dev/null +++ b/internal/handler/sites.go @@ -0,0 +1,148 @@ +// Package handler — site management (PT/BT tracker CRUD + cross-site search). +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +// ─── CRUD ──────────────────────────────────────────────────────────────────── + +func listSitesHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + sites, err := svc.Site.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"items": sites}) + } +} + +func getSiteHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + site, err := svc.Site.FindByID(c.Request.Context(), c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if site == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, site) + } +} + +type createSiteReq struct { + Name string `json:"name" binding:"required"` + BaseURL string `json:"base_url" binding:"required"` + SiteType string `json:"site_type"` + AuthType string `json:"auth_type"` + Cookie string `json:"cookie"` + APIKey string `json:"api_key"` + AuthHeader string `json:"auth_header"` + UserAgent string `json:"user_agent"` + RSSURL string `json:"rss_url"` + Timeout int `json:"timeout"` + Priority int `json:"priority"` + UseProxy bool `json:"use_proxy"` + Enabled *bool `json:"enabled"` + Downloader string `json:"downloader"` +} + +func createSiteHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req createSiteReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + site := &model.Site{ + Name: req.Name, + BaseURL: req.BaseURL, + SiteType: req.SiteType, + AuthType: req.AuthType, + Cookie: req.Cookie, + APIKey: req.APIKey, + AuthHeader: req.AuthHeader, + UserAgent: req.UserAgent, + RSSURL: req.RSSURL, + Timeout: req.Timeout, + Priority: req.Priority, + UseProxy: req.UseProxy, + Enabled: enabled, + Downloader: req.Downloader, + } + if err := svc.Site.Create(c.Request.Context(), site); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, site) + } +} + +func updateSiteHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var patch map[string]any + if err := c.ShouldBindJSON(&patch); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := svc.Site.Update(c.Request.Context(), c.Param("id"), patch); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + // Return the updated row. + site, _ := svc.Site.FindByID(c.Request.Context(), c.Param("id")) + c.JSON(http.StatusOK, site) + } +} + +func deleteSiteHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if err := svc.Site.Delete(c.Request.Context(), c.Param("id")); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.Status(http.StatusNoContent) + } +} + +// ─── Connection test ───────────────────────────────────────────────────────── + +func testSiteHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + ok, msg, err := svc.Site.TestConnection(c.Request.Context(), c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": ok, "message": msg}) + } +} + +// ─── Cross-site search ─────────────────────────────────────────────────────── + +func siteSearchHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + keyword := c.Query("keyword") + if keyword == "" { + keyword = c.Query("q") + } + results, err := svc.Site.Search(c.Request.Context(), keyword) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"items": results, "total": len(results)}) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index c82588a..3eac332 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -205,6 +205,30 @@ type AccessLog struct { Detail string `gorm:"type:text" json:"detail"` } +// Site stores a PT/BT tracker site configuration used by the subscription +// and cross-site search system. Mirrors the original MediaStation sites table. +// +// Supported site types: nexusphp / gazelle / unit3d / mteam / custom_rss +// Supported auth types: cookie / api_key / authorization +type Site struct { + Base + Name string `gorm:"size:128;not null" json:"name"` + BaseURL string `gorm:"size:512;not null" json:"base_url"` + SiteType string `gorm:"size:32;default:nexusphp" json:"site_type"` + AuthType string `gorm:"size:32;default:cookie" json:"auth_type"` + Cookie string `gorm:"type:text" json:"cookie,omitempty"` + APIKey string `gorm:"size:512" json:"api_key,omitempty"` + AuthHeader string `gorm:"size:512" json:"auth_header,omitempty"` + UserAgent string `gorm:"size:512" json:"user_agent,omitempty"` + RSSURL string `gorm:"size:1024" json:"rss_url,omitempty"` + Timeout int `gorm:"default:15" json:"timeout"` + Priority int `gorm:"default:50" json:"priority"` + UseProxy bool `gorm:"default:false" json:"use_proxy"` + Enabled bool `gorm:"default:true" json:"enabled"` + LoginStatus string `gorm:"size:20;default:unknown" json:"login_status"` + Downloader string `gorm:"size:50" json:"downloader,omitempty"` +} + // AllModels returns the slice consumed by gorm.AutoMigrate. func AllModels() []interface{} { return []interface{}{ @@ -219,6 +243,7 @@ func AllModels() []interface{} { &DownloadTask{}, &Subscription{}, &Setting{}, + &Site{}, &AccessLog{}, &APIConfig{}, } diff --git a/internal/service/service.go b/internal/service/service.go index 7528896..edeb851 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -55,6 +55,7 @@ type Container struct { Notifier *NotifierService Organizer *OrganizerService Douban *DoubanProvider + Site *SiteService stopCtx context.Context stopCancel context.CancelFunc @@ -90,6 +91,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont notifier := NewNotifierService(log, repos) organizer := NewOrganizerService(cfg, log, repos) douban := NewDoubanProvider(cfg, log) + siteService := NewSiteService(log, repos) scheduler := NewSchedulerService(log, repos, scanner, transcoder, hub, cfg.Cache.CacheDir) ctx, cancel := context.WithCancel(context.Background()) @@ -134,6 +136,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont Notifier: notifier, Organizer: organizer, Douban: douban, + Site: siteService, stopCtx: ctx, stopCancel: cancel, } diff --git a/internal/service/site.go b/internal/service/site.go new file mode 100644 index 0000000..b4694d3 --- /dev/null +++ b/internal/service/site.go @@ -0,0 +1,210 @@ +// 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" + "errors" + "net/http" + "strings" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + "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 +} + +// NewSiteService is the constructor. +func NewSiteService(log *zap.Logger, repo *repository.Container) *SiteService { + return &SiteService{log: log, repo: 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.BaseURL) == "" { + return errors.New("name and base_url required") + } + site.BaseURL = strings.TrimRight(site.BaseURL, "/") + if site.SiteType == "" { + site.SiteType = "nexusphp" + } + if site.AuthType == "" { + site.AuthType = "cookie" + } + if site.Timeout <= 0 { + site.Timeout = 15 + } + return s.repo.DB.WithContext(ctx).Create(site).Error +} + +// List returns every site ordered by priority (lower = higher priority). +func (s *SiteService) List(ctx context.Context) ([]model.Site, error) { + var sites []model.Site + err := s.repo.DB.WithContext(ctx).Order("priority asc, created_at asc").Find(&sites).Error + 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. +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 + } + + client := &http.Client{Timeout: time.Duration(site.Timeout) * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, site.BaseURL, nil) + if err != nil { + return false, err.Error(), nil + } + + // Apply auth headers. + req.Header.Set("User-Agent", effectiveUA(site)) + switch site.AuthType { + case "cookie": + if site.Cookie != "" { + req.Header.Set("Cookie", site.Cookie) + } + case "api_key": + if site.APIKey != "" { + req.Header.Set("x-api-key", site.APIKey) + } + case "authorization": + if site.AuthHeader != "" { + req.Header.Set("Authorization", site.AuthHeader) + } + } + + resp, err := client.Do(req) + if err != nil { + status := "fail" + _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). + Update("login_status", status).Error + return false, err.Error(), nil + } + defer resp.Body.Close() + + var ok bool + var msg string + switch { + case resp.StatusCode == 200: + ok, msg = true, "连接成功" + case resp.StatusCode == 403: + ok, msg = false, "认证失败 (HTTP 403)" + case resp.StatusCode == 401: + ok, msg = false, "未授权 (HTTP 401)" + default: + ok, msg = resp.StatusCode < 400, "HTTP "+resp.Status + } + + loginStatus := "ok" + if !ok { + loginStatus = "fail" + } + _ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id). + Updates(map[string]any{"login_status": loginStatus, "last_check": time.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. +func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResult, error) { + if strings.TrimSpace(keyword) == "" { + return nil, errors.New("keyword required") + } + sites, err := s.List(ctx) + if err != nil { + return nil, err + } + + var results []SearchResult + for i := range sites { + if !sites[i].Enabled { + continue + } + adapter := NewSiteAdapter(&sites[i]) + if adapter == nil { + continue + } + items, err := adapter.Search(ctx, keyword) + if err != nil { + s.log.Debug("site search failed", + zap.String("site", sites[i].Name), zap.Error(err)) + continue + } + for _, item := range items { + results = append(results, SearchResult{ + SiteName: sites[i].Name, + SiteID: sites[i].ID, + Title: item.Title, + TorrentURL: item.TorrentURL, + DownloadURL: item.DownloadURL, + Size: item.Size, + Seeders: item.Seeders, + Leechers: item.Leechers, + Free: item.Free, + }) + } + } + + // Sort by seeders desc. + for i := 0; i < len(results); i++ { + for j := i + 1; j < len(results); j++ { + if results[j].Seeders > results[i].Seeders { + results[i], results[j] = results[j], results[i] + } + } + } + return results, nil +} + +func effectiveUA(site *model.Site) string { + if site.UserAgent != "" { + return site.UserAgent + } + return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" +} diff --git a/internal/service/site_adapter.go b/internal/service/site_adapter.go new file mode 100644 index 0000000..ace2306 --- /dev/null +++ b/internal/service/site_adapter.go @@ -0,0 +1,501 @@ +// Package service — site adapter implementations for PT/BT tracker search. +// +// Each adapter knows how to search a specific site type and parse the +// results into a uniform SiteResourceItem slice. The factory function +// NewSiteAdapter picks the right adapter based on site.SiteType. +// +// Supported site types: +// nexusphp — HTML scraping of torrents.php (most CN PT sites) +// gazelle — JSON API /ajax.php?action=browse (HDBits, OPS, RED) +// unit3d — REST API /api/torrents/filter (BeyondHD, BluTopia) +// mteam — M-Team v3 REST API /api/torrent/search +// custom_rss — RSS/Atom feed parsing (generic) +package service + +import ( + "context" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +// SiteResourceItem is one torrent result returned by an adapter. +type SiteResourceItem struct { + Title string + TorrentURL string + DownloadURL string + Size int64 + Seeders int + Leechers int + Free bool +} + +// SiteAdapter is the interface every site-type implementation satisfies. +type SiteAdapter interface { + Search(ctx context.Context, keyword string) ([]SiteResourceItem, error) +} + +// NewSiteAdapter picks the correct adapter for the given site config. +// Returns nil for unknown site types. +func NewSiteAdapter(site *model.Site) SiteAdapter { + switch site.SiteType { + case "nexusphp": + return &nexusPhpAdapter{site: site} + case "gazelle": + return &gazelleAdapter{site: site} + case "unit3d": + return &unit3dAdapter{site: site} + case "mteam": + return &mteamAdapter{site: site} + case "custom_rss": + return &rssAdapter{site: site} + default: + return &nexusPhpAdapter{site: site} + } +} + +// ─── Shared helpers ────────────────────────────────────────────────────────── + +func siteClient(site *model.Site) *http.Client { + timeout := time.Duration(site.Timeout) * time.Second + if timeout <= 0 { + timeout = 15 * time.Second + } + return &http.Client{Timeout: timeout} +} + +func siteHeaders(site *model.Site) http.Header { + h := http.Header{} + h.Set("User-Agent", effectiveUA(site)) + h.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + h.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + switch site.AuthType { + case "cookie": + if site.Cookie != "" { + h.Set("Cookie", site.Cookie) + } + case "api_key": + if site.APIKey != "" { + h.Set("x-api-key", site.APIKey) + } + case "authorization": + if site.AuthHeader != "" { + h.Set("Authorization", site.AuthHeader) + } + } + return h +} + +var sizeUnits = map[string]int64{ + "TIB": 1 << 40, "GIB": 1 << 30, "MIB": 1 << 20, "KIB": 1 << 10, + "TB": 1e12, "GB": 1e9, "MB": 1e6, "KB": 1e3, "B": 1, +} + +func parseSize(s string) int64 { + s = strings.ToUpper(strings.TrimSpace(s)) + s = strings.ReplaceAll(s, ",", ".") + for unit, mult := range sizeUnits { + if strings.Contains(s, unit) { + numStr := strings.TrimSpace(strings.ReplaceAll(s, unit, "")) + if f, err := strconv.ParseFloat(numStr, 64); err == nil { + return int64(f * float64(mult)) + } + } + } + return 0 +} + +// ─── NexusPHP adapter ──────────────────────────────────────────────────────── + +type nexusPhpAdapter struct{ site *model.Site } + +func (a *nexusPhpAdapter) Search(ctx context.Context, keyword string) ([]SiteResourceItem, error) { + baseURL := strings.TrimRight(a.site.BaseURL, "/") + u := fmt.Sprintf("%s/torrents.php?search=%s&inclbookmarked=0&incldead=0", + baseURL, url.QueryEscape(keyword)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header = siteHeaders(a.site) + + resp, err := siteClient(a.site).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("nexusphp search: HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // Simple regex-based extraction (avoids heavyweight HTML parser dep). + // NexusPHP pages have torrent rows with links to details.php?id=N and + // download.php?id=N. We extract titles from + titleRe := regexp.MustCompile(`]+href="details\.php\?id=(\d+)[^"]*"[^>]*>([^<]+)`) + matches := titleRe.FindAllStringSubmatch(string(body), -1) + + var results []SiteResourceItem + seen := map[string]bool{} + for _, m := range matches { + tid := m[1] + title := strings.TrimSpace(m[2]) + if title == "" || seen[tid] { + continue + } + seen[tid] = true + results = append(results, SiteResourceItem{ + Title: title, + TorrentURL: fmt.Sprintf("%s/details.php?id=%s", baseURL, tid), + DownloadURL: fmt.Sprintf("%s/download.php?id=%s", baseURL, tid), + }) + } + return results, nil +} + +// ─── Gazelle adapter ───────────────────────────────────────────────────────── + +type gazelleAdapter struct{ site *model.Site } + +func (a *gazelleAdapter) Search(ctx context.Context, keyword string) ([]SiteResourceItem, error) { + baseURL := strings.TrimRight(a.site.BaseURL, "/") + u := fmt.Sprintf("%s/ajax.php?action=browse&searchstr=%s", + baseURL, url.QueryEscape(keyword)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header = siteHeaders(a.site) + req.Header.Set("Accept", "application/json") + + resp, err := siteClient(a.site).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("gazelle search: HTTP %d", resp.StatusCode) + } + + type torrent struct { + ID int `json:"torrentId"` + Size int64 `json:"size"` + Seeders int `json:"seeders"` + Leechers int `json:"leechers"` + Free bool `json:"isFreeleech"` + Format string `json:"format"` + Encoding string `json:"encoding"` + } + type group struct { + GroupID int `json:"groupId"` + GroupName string `json:"groupName"` + Artist string `json:"artist"` + Torrents []torrent `json:"torrents"` + } + type response struct { + Status string `json:"status"` + Response struct { + Results []group `json:"results"` + } `json:"response"` + } + + var data response + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + if data.Status != "success" { + return nil, fmt.Errorf("gazelle: status=%s", data.Status) + } + + var results []SiteResourceItem + for _, g := range data.Response.Results { + for _, t := range g.Torrents { + title := strings.TrimSpace(fmt.Sprintf("%s - %s [%s/%s]", + g.Artist, g.GroupName, t.Format, t.Encoding)) + results = append(results, SiteResourceItem{ + Title: title, + TorrentURL: fmt.Sprintf("%s/torrents.php?id=%d&torrentid=%d", baseURL, g.GroupID, t.ID), + DownloadURL: fmt.Sprintf("%s/torrents.php?action=download&id=%d", baseURL, t.ID), + Size: t.Size, + Seeders: t.Seeders, + Leechers: t.Leechers, + Free: t.Free, + }) + } + } + return results, nil +} + +// ─── UNIT3D adapter ────────────────────────────────────────────────────────── + +type unit3dAdapter struct{ site *model.Site } + +func (a *unit3dAdapter) Search(ctx context.Context, keyword string) ([]SiteResourceItem, error) { + baseURL := strings.TrimRight(a.site.BaseURL, "/") + params := url.Values{"name": {keyword}, "perPage": {"50"}} + if a.site.APIKey != "" { + params.Set("api_token", a.site.APIKey) + } + u := fmt.Sprintf("%s/api/torrents/filter?%s", baseURL, params.Encode()) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header = siteHeaders(a.site) + req.Header.Set("Accept", "application/json") + + resp, err := siteClient(a.site).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("unit3d search: HTTP %d", resp.StatusCode) + } + + type attrs struct { + ID int `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + Seeders int `json:"seeders"` + Leechers int `json:"leechers"` + Free any `json:"freeleech"` + DLLink string `json:"download_link"` + } + type item struct { + ID int `json:"id"` + Attributes attrs `json:"attributes"` + } + type page struct { + Data []item `json:"data"` + } + + var data page + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + var results []SiteResourceItem + for _, it := range data.Data { + a := it.Attributes + if a.Name == "" { + a = attrs(it.Attributes) + if a.ID == 0 { + a.ID = it.ID + } + } + tid := a.ID + if tid == 0 { + tid = it.ID + } + free := false + switch v := a.Free.(type) { + case bool: + free = v + case float64: + free = v > 0 + } + dlURL := a.DLLink + if dlURL == "" { + dlURL = fmt.Sprintf("%s/torrents/%d/download", baseURL, tid) + } + results = append(results, SiteResourceItem{ + Title: a.Name, + TorrentURL: fmt.Sprintf("%s/torrents/%d", baseURL, tid), + DownloadURL: dlURL, + Size: a.Size, + Seeders: a.Seeders, + Leechers: a.Leechers, + Free: free, + }) + } + return results, nil +} + +// ─── M-Team adapter ───────────────────────────────────────────────────────── + +type mteamAdapter struct{ site *model.Site } + +func (a *mteamAdapter) Search(ctx context.Context, keyword string) ([]SiteResourceItem, error) { + baseURL := strings.TrimRight(a.site.BaseURL, "/") + apiURL := baseURL + "/api/torrent/search" + + payload, _ := json.Marshal(map[string]any{ + "pageNumber": 1, + "pageSize": 50, + "keyword": keyword, + }) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, + strings.NewReader(string(payload))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", effectiveUA(a.site)) + if a.site.APIKey != "" { + req.Header.Set("x-api-key", a.site.APIKey) + } + + resp, err := siteClient(a.site).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("mteam search: HTTP %d", resp.StatusCode) + } + + type statusInfo struct { + Seeders int `json:"seeders"` + Leechers int `json:"leechers"` + Discount string `json:"discount"` + } + type torrentItem struct { + ID any `json:"id"` + Name string `json:"name"` + Title string `json:"title"` + Size any `json:"size"` + Status statusInfo `json:"status"` + } + type dataWrap struct { + Data []torrentItem `json:"data"` + } + type apiResp struct { + Code any `json:"code"` + Message string `json:"message"` + Data dataWrap `json:"data"` + } + + var data apiResp + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + codeStr := fmt.Sprintf("%v", data.Code) + if codeStr != "0" { + return nil, fmt.Errorf("mteam: code=%v msg=%s", data.Code, data.Message) + } + + var results []SiteResourceItem + for _, item := range data.Data.Data { + tidStr := fmt.Sprintf("%v", item.ID) + title := item.Name + if title == "" { + title = item.Title + } + if title == "" || tidStr == "" { + continue + } + var size int64 + switch v := item.Size.(type) { + case float64: + size = int64(v) + case string: + size, _ = strconv.ParseInt(v, 10, 64) + } + free := item.Status.Discount != "" && item.Status.Discount != "normal" + + results = append(results, SiteResourceItem{ + Title: title, + TorrentURL: fmt.Sprintf("%s/detail/%s", baseURL, tidStr), + DownloadURL: fmt.Sprintf("%s/api/torrent/genDlToken?id=%s", baseURL, tidStr), + Size: size, + Seeders: item.Status.Seeders, + Leechers: item.Status.Leechers, + Free: free, + }) + } + return results, nil +} + +// ─── Custom RSS adapter ───────────────────────────────────────────────────── + +type rssAdapter struct{ site *model.Site } + +func (a *rssAdapter) Search(ctx context.Context, keyword string) ([]SiteResourceItem, error) { + rssURL := a.site.RSSURL + if rssURL == "" { + rssURL = a.site.BaseURL + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rssURL, nil) + if err != nil { + return nil, err + } + req.Header = siteHeaders(a.site) + + resp, err := siteClient(a.site).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("rss: HTTP %d", resp.StatusCode) + } + + type enclosure struct { + URL string `xml:"url,attr"` + Length int64 `xml:"length,attr"` + } + type rssItem struct { + Title string `xml:"title"` + Link string `xml:"link"` + Enclosure enclosure `xml:"enclosure"` + } + type channel struct { + Items []rssItem `xml:"item"` + } + type rss struct { + Channel channel `xml:"channel"` + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var feed rss + if err := xml.Unmarshal(body, &feed); err != nil { + return nil, err + } + + kw := strings.ToLower(keyword) + var results []SiteResourceItem + for _, item := range feed.Channel.Items { + title := strings.TrimSpace(item.Title) + if title == "" { + continue + } + // Filter by keyword (simple contains). + if kw != "" && !strings.Contains(strings.ToLower(title), kw) { + continue + } + dlURL := item.Enclosure.URL + if dlURL == "" { + dlURL = item.Link + } + results = append(results, SiteResourceItem{ + Title: title, + TorrentURL: item.Link, + DownloadURL: dlURL, + Size: item.Enclosure.Length, + }) + } + return results, nil +} diff --git a/web/src/App.tsx b/web/src/App.tsx index c5cbd53..0dca9ac 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -69,6 +69,12 @@ const WatchHistoryPage = lazy(() => const PosterWallPage = lazy(() => import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })), ) +const SitesPage = lazy(() => + import('./pages/SitesPage').then((m) => ({ default: m.SitesPage })), +) +const SiteSearchPage = lazy(() => + import('./pages/SiteSearchPage').then((m) => ({ default: m.SiteSearchPage })), +) const Loading = () =>

加载中…

@@ -100,6 +106,8 @@ export default function App() { } /> } /> } /> + } /> + } /> api.get<{ items: Site[] }>('/sites').then((r) => r.data.items), + + get: (id: string) => api.get(`/sites/${id}`).then((r) => r.data), + + create: (input: CreateSiteInput) => + api.post('/sites', input).then((r) => r.data), + + update: (id: string, patch: Partial) => + api.put(`/sites/${id}`, patch).then((r) => r.data), + + remove: (id: string) => api.delete(`/sites/${id}`).then((r) => r.data), + + test: (id: string) => + api + .post<{ success: boolean; message: string }>(`/sites/${id}/test`) + .then((r) => r.data), + + search: (keyword: string) => + api + .get<{ items: SiteSearchResult[]; total: number }>('/sites/search', { + params: { keyword }, + }) + .then((r) => r.data), +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index bdf086f..ca89c39 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -9,10 +9,11 @@ import { Copy, Film, FolderTree, + GalleryHorizontalEnd, + Globe, HardDrive, Heart, Home, - GalleryHorizontalEnd, KeyRound, ListChecks, ListMusic, @@ -86,6 +87,8 @@ export function Layout() { } label="下载" /> } label="RSS 订阅" /> } label="DLNA 投屏" /> + } label="站点管理" /> + } label="站点搜索" />
账号 diff --git a/web/src/pages/SiteSearchPage.tsx b/web/src/pages/SiteSearchPage.tsx new file mode 100644 index 0000000..8bc0e76 --- /dev/null +++ b/web/src/pages/SiteSearchPage.tsx @@ -0,0 +1,161 @@ +import { FormEvent, useState } from 'react' +import toast from 'react-hot-toast' +import { Download, Search } from 'lucide-react' + +import { sitesAPI, type SiteSearchResult } from '../api/sites' +import { downloadsAPI } from '../api/downloads' + +function fmtBytes(n: number): string { + if (!n || n <= 0) return '—' + const u = ['B', 'KB', 'MB', 'GB', 'TB'] + let v = n + let i = 0 + while (v >= 1024 && i < u.length - 1) { + v /= 1024 + i++ + } + return `${v.toFixed(1)} ${u[i]}` +} + +// SiteSearchPage performs cross-site torrent search across all enabled +// sites and displays a merged result table sorted by seeders. +export function SiteSearchPage() { + const [keyword, setKeyword] = useState('') + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [searched, setSearched] = useState(false) + + const onSubmit = async (e: FormEvent) => { + e.preventDefault() + if (!keyword.trim()) return + setLoading(true) + setSearched(true) + try { + const data = await sitesAPI.search(keyword.trim()) + setResults(data.items || []) + if ((data.items || []).length === 0) { + toast('未找到结果,请检查站点配置或更换关键词') + } + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + '搜索失败' + toast.error(msg) + } finally { + setLoading(false) + } + } + + const downloadTorrent = async (item: SiteSearchResult) => { + const url = item.download_url || item.torrent_url + if (!url) { + toast.error('无可用下载链接') + return + } + try { + await downloadsAPI.add(url) + toast.success(`已加入下载: ${item.title.substring(0, 40)}...`) + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + '下载失败' + toast.error(msg) + } + } + + return ( +
+
+ +
+

站点搜索

+

+ 跨站搜索所有已启用的 PT/BT 站点,结果按做种数排序。 +

+
+
+ +
+ setKeyword(e.target.value)} + /> + +
+ + {loading &&

正在查询所有站点…

} + + {!loading && searched && results.length === 0 && ( +
+

+ 未找到结果。请确认已在「站点管理」中添加并启用了至少一个站点,且 Cookie / API Key 有效。 +

+
+ )} + + {results.length > 0 && ( +
+ + + + + + + + + + + + + + {results.map((item, idx) => ( + + + + + + + + + + ))} + +
站点标题大小SLFree操作
{item.site_name} + + {item.title.length > 80 + ? item.title.substring(0, 80) + '…' + : item.title} + + + {fmtBytes(item.size)} + {item.seeders || '—'}{item.leechers || '—'} + {item.free && ( + + Free + + )} + + +
+
+ )} +
+ ) +} diff --git a/web/src/pages/SitesPage.tsx b/web/src/pages/SitesPage.tsx new file mode 100644 index 0000000..55949e9 --- /dev/null +++ b/web/src/pages/SitesPage.tsx @@ -0,0 +1,258 @@ +import { FormEvent, useEffect, useState } from 'react' +import toast from 'react-hot-toast' +import { Globe, Plus, Trash2, Wifi, WifiOff } from 'lucide-react' + +import { sitesAPI, type Site } from '../api/sites' + +const SITE_TYPES = [ + { value: 'nexusphp', label: 'NexusPHP (大多数国内PT站)' }, + { value: 'gazelle', label: 'Gazelle (HDBits/OPS/RED)' }, + { value: 'unit3d', label: 'UNIT3D (BeyondHD/BluTopia)' }, + { value: 'mteam', label: 'M-Team (馒头)' }, + { value: 'custom_rss', label: '自定义 RSS' }, +] + +const AUTH_TYPES = [ + { value: 'cookie', label: 'Cookie' }, + { value: 'api_key', label: 'API Key / Passkey' }, + { value: 'authorization', label: 'Authorization Header' }, +] + +// SitesPage manages PT/BT tracker sites — CRUD + connection test. +export function SitesPage() { + const [sites, setSites] = useState([]) + const [loading, setLoading] = useState(true) + const [showForm, setShowForm] = useState(false) + + const refresh = () => + sitesAPI + .list() + .then(setSites) + .finally(() => setLoading(false)) + + useEffect(() => { + refresh() + }, []) + + const testSite = async (id: string) => { + try { + const r = await sitesAPI.test(id) + if (r.success) { + toast.success(r.message) + } else { + toast.error(r.message) + } + await refresh() + } catch { + toast.error('连接测试失败') + } + } + + const deleteSite = async (site: Site) => { + if (!confirm(`确定删除站点「${site.name}」?`)) return + await sitesAPI.remove(site.id) + toast.success('已删除') + await refresh() + } + + return ( +
+
+
+ +
+

站点管理

+

+ 添加 PT/BT 站点后可进行跨站资源搜索和自动下载。 +

+
+
+ +
+ + {showForm && { setShowForm(false); refresh() }} />} + + {loading &&

加载中…

} + {!loading && sites.length === 0 && ( +
+

+ 还没有配置任何站点。点击"添加站点"开始配置你的 PT/BT 资源站。 +

+
+ )} + +
+ {sites.map((site) => ( +
+
+
+

{site.name}

+ + {site.site_type} + + {site.login_status === 'ok' && ( + + )} + {site.login_status === 'fail' && ( + + )} + {!site.enabled && ( + + 已禁用 + + )} +
+

{site.base_url}

+
+
+ + +
+
+ ))} +
+
+ ) +} + +function AddSiteForm({ onCreated }: { onCreated: () => void }) { + const [name, setName] = useState('') + const [baseURL, setBaseURL] = useState('') + const [siteType, setSiteType] = useState('nexusphp') + const [authType, setAuthType] = useState('cookie') + const [cookie, setCookie] = useState('') + const [apiKey, setAPIKey] = useState('') + const [rssURL, setRSSURL] = useState('') + const [saving, setSaving] = useState(false) + + const submit = async (e: FormEvent) => { + e.preventDefault() + setSaving(true) + try { + await sitesAPI.create({ + name, + base_url: baseURL, + site_type: siteType, + auth_type: authType, + cookie: authType === 'cookie' ? cookie : undefined, + api_key: authType === 'api_key' ? apiKey : undefined, + rss_url: siteType === 'custom_rss' ? rssURL : undefined, + }) + toast.success('站点已添加') + onCreated() + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + '添加失败' + toast.error(msg) + } finally { + setSaving(false) + } + } + + return ( +
+
+ + + + +
+ + {authType === 'cookie' && ( +