mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 21:36:37 +08:00
Merge pull request #7 from ShukeBta/kiro/iter7-complete-port
feat: site management + cross-site torrent search
This commit is contained in:
@@ -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/scan", middleware.AdminRequired(), detectDuplicatesHandler(svc))
|
||||||
authed.POST("/duplicates/unmark", middleware.AdminRequired(), unmarkDuplicatesHandler(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.
|
// Recycle bin.
|
||||||
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
|
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
|
||||||
|
|
||||||
|
|||||||
@@ -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)})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -205,6 +205,30 @@ type AccessLog struct {
|
|||||||
Detail string `gorm:"type:text" json:"detail"`
|
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.
|
// AllModels returns the slice consumed by gorm.AutoMigrate.
|
||||||
func AllModels() []interface{} {
|
func AllModels() []interface{} {
|
||||||
return []interface{}{
|
return []interface{}{
|
||||||
@@ -219,6 +243,7 @@ func AllModels() []interface{} {
|
|||||||
&DownloadTask{},
|
&DownloadTask{},
|
||||||
&Subscription{},
|
&Subscription{},
|
||||||
&Setting{},
|
&Setting{},
|
||||||
|
&Site{},
|
||||||
&AccessLog{},
|
&AccessLog{},
|
||||||
&APIConfig{},
|
&APIConfig{},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type Container struct {
|
|||||||
Notifier *NotifierService
|
Notifier *NotifierService
|
||||||
Organizer *OrganizerService
|
Organizer *OrganizerService
|
||||||
Douban *DoubanProvider
|
Douban *DoubanProvider
|
||||||
|
Site *SiteService
|
||||||
|
|
||||||
stopCtx context.Context
|
stopCtx context.Context
|
||||||
stopCancel context.CancelFunc
|
stopCancel context.CancelFunc
|
||||||
@@ -90,6 +91,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
notifier := NewNotifierService(log, repos)
|
notifier := NewNotifierService(log, repos)
|
||||||
organizer := NewOrganizerService(cfg, log, repos)
|
organizer := NewOrganizerService(cfg, log, repos)
|
||||||
douban := NewDoubanProvider(cfg, log)
|
douban := NewDoubanProvider(cfg, log)
|
||||||
|
siteService := NewSiteService(log, repos)
|
||||||
scheduler := NewSchedulerService(log, repos, scanner, transcoder, hub, cfg.Cache.CacheDir)
|
scheduler := NewSchedulerService(log, repos, scanner, transcoder, hub, cfg.Cache.CacheDir)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -134,6 +136,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
Notifier: notifier,
|
Notifier: notifier,
|
||||||
Organizer: organizer,
|
Organizer: organizer,
|
||||||
Douban: douban,
|
Douban: douban,
|
||||||
|
Site: siteService,
|
||||||
stopCtx: ctx,
|
stopCtx: ctx,
|
||||||
stopCancel: cancel,
|
stopCancel: cancel,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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 <a href="details.php?id=...">
|
||||||
|
titleRe := regexp.MustCompile(`<a[^>]+href="details\.php\?id=(\d+)[^"]*"[^>]*>([^<]+)</a>`)
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -69,6 +69,12 @@ const WatchHistoryPage = lazy(() =>
|
|||||||
const PosterWallPage = lazy(() =>
|
const PosterWallPage = lazy(() =>
|
||||||
import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })),
|
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 = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
|
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
|
||||||
|
|
||||||
@@ -100,6 +106,8 @@ export default function App() {
|
|||||||
<Route path="dlna" element={<DlnaPage />} />
|
<Route path="dlna" element={<DlnaPage />} />
|
||||||
<Route path="history" element={<WatchHistoryPage />} />
|
<Route path="history" element={<WatchHistoryPage />} />
|
||||||
<Route path="poster-wall" element={<PosterWallPage />} />
|
<Route path="poster-wall" element={<PosterWallPage />} />
|
||||||
|
<Route path="sites" element={<SitesPage />} />
|
||||||
|
<Route path="site-search" element={<SiteSearchPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="files"
|
path="files"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export interface Site {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
base_url: string
|
||||||
|
site_type: string
|
||||||
|
auth_type: string
|
||||||
|
cookie?: string
|
||||||
|
api_key?: string
|
||||||
|
auth_header?: string
|
||||||
|
user_agent?: string
|
||||||
|
rss_url?: string
|
||||||
|
timeout: number
|
||||||
|
priority: number
|
||||||
|
use_proxy: boolean
|
||||||
|
enabled: boolean
|
||||||
|
login_status: string
|
||||||
|
downloader?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteSearchResult {
|
||||||
|
site_name: string
|
||||||
|
site_id: string
|
||||||
|
title: string
|
||||||
|
torrent_url: string
|
||||||
|
download_url: string
|
||||||
|
size: number
|
||||||
|
seeders: number
|
||||||
|
leechers: number
|
||||||
|
free: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSiteInput {
|
||||||
|
name: string
|
||||||
|
base_url: string
|
||||||
|
site_type?: string
|
||||||
|
auth_type?: string
|
||||||
|
cookie?: string
|
||||||
|
api_key?: string
|
||||||
|
auth_header?: string
|
||||||
|
user_agent?: string
|
||||||
|
rss_url?: string
|
||||||
|
timeout?: number
|
||||||
|
priority?: number
|
||||||
|
use_proxy?: boolean
|
||||||
|
enabled?: boolean
|
||||||
|
downloader?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sitesAPI = {
|
||||||
|
list: () => api.get<{ items: Site[] }>('/sites').then((r) => r.data.items),
|
||||||
|
|
||||||
|
get: (id: string) => api.get<Site>(`/sites/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (input: CreateSiteInput) =>
|
||||||
|
api.post<Site>('/sites', input).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, patch: Partial<CreateSiteInput>) =>
|
||||||
|
api.put<Site>(`/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),
|
||||||
|
}
|
||||||
@@ -9,10 +9,11 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Film,
|
Film,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
|
GalleryHorizontalEnd,
|
||||||
|
Globe,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Heart,
|
Heart,
|
||||||
Home,
|
Home,
|
||||||
GalleryHorizontalEnd,
|
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
ListMusic,
|
ListMusic,
|
||||||
@@ -86,6 +87,8 @@ export function Layout() {
|
|||||||
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载" />
|
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载" />
|
||||||
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" />
|
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" />
|
||||||
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" />
|
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" />
|
||||||
|
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" />
|
||||||
|
<SidebarLink to="/site-search" icon={<Search size={18} />} label="站点搜索" />
|
||||||
|
|
||||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
||||||
账号
|
账号
|
||||||
|
|||||||
@@ -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<SiteSearchResult[]>([])
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="flex items-center gap-3">
|
||||||
|
<Search className="h-6 w-6 text-primary-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-3xl font-bold text-white">站点搜索</h1>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
跨站搜索所有已启用的 PT/BT 站点,结果按做种数排序。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="input-base flex-1"
|
||||||
|
placeholder="输入关键词搜索(如: 流浪地球 / Dune 2024)"
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={loading} className="neon-button">
|
||||||
|
{loading ? '搜索中…' : '搜索'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{loading && <p className="text-slate-500">正在查询所有站点…</p>}
|
||||||
|
|
||||||
|
{!loading && searched && results.length === 0 && (
|
||||||
|
<div className="glass-panel">
|
||||||
|
<p className="text-slate-300">
|
||||||
|
未找到结果。请确认已在「站点管理」中添加并启用了至少一个站点,且 Cookie / API Key 有效。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="glass-panel overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2">站点</th>
|
||||||
|
<th>标题</th>
|
||||||
|
<th>大小</th>
|
||||||
|
<th>S</th>
|
||||||
|
<th>L</th>
|
||||||
|
<th>Free</th>
|
||||||
|
<th className="text-right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{results.map((item, idx) => (
|
||||||
|
<tr key={idx} className="border-t border-white/5 align-top">
|
||||||
|
<td className="py-2 text-primary-400">{item.site_name}</td>
|
||||||
|
<td className="max-w-md py-2">
|
||||||
|
<a
|
||||||
|
href={item.torrent_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-white transition hover:text-primary-400"
|
||||||
|
title={item.title}
|
||||||
|
>
|
||||||
|
{item.title.length > 80
|
||||||
|
? item.title.substring(0, 80) + '…'
|
||||||
|
: item.title}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap text-slate-300">
|
||||||
|
{fmtBytes(item.size)}
|
||||||
|
</td>
|
||||||
|
<td className="text-emerald-400">{item.seeders || '—'}</td>
|
||||||
|
<td className="text-red-400">{item.leechers || '—'}</td>
|
||||||
|
<td>
|
||||||
|
{item.free && (
|
||||||
|
<span className="rounded border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-400">
|
||||||
|
Free
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => downloadTorrent(item)}
|
||||||
|
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||||
|
title="加入下载"
|
||||||
|
>
|
||||||
|
<Download size={12} className="inline" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Site[]>([])
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Globe className="h-6 w-6 text-primary-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-3xl font-bold text-white">站点管理</h1>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
添加 PT/BT 站点后可进行跨站资源搜索和自动下载。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowForm(!showForm)} className="neon-button">
|
||||||
|
<Plus size={16} /> 添加站点
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{showForm && <AddSiteForm onCreated={() => { setShowForm(false); refresh() }} />}
|
||||||
|
|
||||||
|
{loading && <p className="text-slate-500">加载中…</p>}
|
||||||
|
{!loading && sites.length === 0 && (
|
||||||
|
<div className="glass-panel">
|
||||||
|
<p className="text-slate-300">
|
||||||
|
还没有配置任何站点。点击"添加站点"开始配置你的 PT/BT 资源站。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sites.map((site) => (
|
||||||
|
<div key={site.id} className="glass-panel flex items-center gap-4 !p-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium text-white">{site.name}</p>
|
||||||
|
<span className="rounded border border-white/10 bg-white/5 px-1.5 py-0.5 text-xs text-slate-400">
|
||||||
|
{site.site_type}
|
||||||
|
</span>
|
||||||
|
{site.login_status === 'ok' && (
|
||||||
|
<Wifi size={14} className="text-emerald-400" />
|
||||||
|
)}
|
||||||
|
{site.login_status === 'fail' && (
|
||||||
|
<WifiOff size={14} className="text-red-400" />
|
||||||
|
)}
|
||||||
|
{!site.enabled && (
|
||||||
|
<span className="rounded border border-yellow-400/40 px-1.5 py-0.5 text-xs text-yellow-400">
|
||||||
|
已禁用
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 font-mono text-xs text-slate-500">{site.base_url}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => testSite(site.id)}
|
||||||
|
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||||
|
>
|
||||||
|
测试
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteSite(site)}
|
||||||
|
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<form onSubmit={submit} className="glass-panel space-y-3">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs text-slate-400">站点名称</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="input-base"
|
||||||
|
placeholder="如: 馒头 / HDSky / OPS"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs text-slate-400">站点地址</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="input-base"
|
||||||
|
placeholder="https://example.com"
|
||||||
|
value={baseURL}
|
||||||
|
onChange={(e) => setBaseURL(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs text-slate-400">站点类型</span>
|
||||||
|
<select
|
||||||
|
className="input-base"
|
||||||
|
value={siteType}
|
||||||
|
onChange={(e) => setSiteType(e.target.value)}
|
||||||
|
>
|
||||||
|
{SITE_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs text-slate-400">认证方式</span>
|
||||||
|
<select
|
||||||
|
className="input-base"
|
||||||
|
value={authType}
|
||||||
|
onChange={(e) => setAuthType(e.target.value)}
|
||||||
|
>
|
||||||
|
{AUTH_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authType === 'cookie' && (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs text-slate-400">Cookie</span>
|
||||||
|
<textarea
|
||||||
|
className="input-base min-h-[60px]"
|
||||||
|
placeholder="从浏览器 F12 → Network → Cookie 复制"
|
||||||
|
value={cookie}
|
||||||
|
onChange={(e) => setCookie(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{authType === 'api_key' && (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs text-slate-400">API Key / Passkey</span>
|
||||||
|
<input
|
||||||
|
className="input-base"
|
||||||
|
type="password"
|
||||||
|
placeholder="站点生成的 API Token"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setAPIKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{siteType === 'custom_rss' && (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs text-slate-400">RSS 地址</span>
|
||||||
|
<input
|
||||||
|
className="input-base"
|
||||||
|
placeholder="https://example.com/rss.xml"
|
||||||
|
value={rssURL}
|
||||||
|
onChange={(e) => setRSSURL(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={saving} className="neon-button">
|
||||||
|
{saving ? '添加中…' : '添加站点'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user