Files
MeBox/internal/handler/sites.go
T
Kiro 7a18eb670a feat: site management + cross-site torrent search
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).
2026-05-16 08:29:00 +00:00

149 lines
4.6 KiB
Go

// 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)})
}
}