mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
优化番号处理逻辑,添加对MetaTube的支持
优化番号处理逻辑,添加对MetaTube的支持
This commit is contained in:
@@ -1,7 +1,11 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -62,6 +66,145 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testAdultScraperReq struct {
|
||||||
|
Engine string `json:"engine"`
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
JavDBURL string `json:"javdb_url"`
|
||||||
|
JavBusURL string `json:"javbus_url"`
|
||||||
|
Cookie string `json:"cookie"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAdultScraperHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req testAdultScraperReq
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
engine := strings.ToLower(strings.TrimSpace(req.Engine))
|
||||||
|
if engine == "" {
|
||||||
|
engine = "metatube"
|
||||||
|
}
|
||||||
|
|
||||||
|
if engine == "metatube" {
|
||||||
|
serverURL := strings.TrimSpace(req.ServerURL)
|
||||||
|
if serverURL == "" {
|
||||||
|
serverURL, _ = svc.Repo.Setting.Get(c.Request.Context(), "adult.scraper.metatube_server")
|
||||||
|
}
|
||||||
|
if serverURL == "" {
|
||||||
|
serverURL = "http://127.0.0.1:7700"
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(req.Token)
|
||||||
|
if token == "" {
|
||||||
|
token, _ = svc.Repo.Setting.Get(c.Request.Context(), "adult.scraper.metatube_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := service.NewMetaTubeProvider(svc.Log)
|
||||||
|
res, err := client.TestConnection(c.Request.Context(), serverURL, token)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"latency_ms": res.LatencyMs,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": res.Success,
|
||||||
|
"latency_ms": res.LatencyMs,
|
||||||
|
"providers": res.Providers,
|
||||||
|
"error": res.Error,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builtin scraper test
|
||||||
|
bases := []string{}
|
||||||
|
if req.JavDBURL != "" {
|
||||||
|
bases = append(bases, strings.TrimSpace(req.JavDBURL))
|
||||||
|
}
|
||||||
|
if req.JavBusURL != "" {
|
||||||
|
bases = append(bases, strings.Split(req.JavBusURL, ",")...)
|
||||||
|
}
|
||||||
|
if len(bases) == 0 {
|
||||||
|
if s, _ := svc.Repo.Setting.Get(c.Request.Context(), "adult.scraper.builtin_javdb_url"); s != "" {
|
||||||
|
bases = append(bases, s)
|
||||||
|
}
|
||||||
|
if s, _ := svc.Repo.Setting.Get(c.Request.Context(), "adult.scraper.builtin_javbus_url"); s != "" {
|
||||||
|
bases = append(bases, strings.Split(s, ",")...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(bases) == 0 {
|
||||||
|
bases = []string{"https://javdb.com", "https://javbus.sbs", "https://www.javbus.com"}
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie := strings.TrimSpace(req.Cookie)
|
||||||
|
if cookie == "" {
|
||||||
|
cookie, _ = svc.Repo.Setting.Get(c.Request.Context(), "adult.scraper.builtin_cookie")
|
||||||
|
}
|
||||||
|
if !strings.Contains(cookie, "age=") {
|
||||||
|
cookie = cookie + "; age=verified"
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := service.NewExternalHTTPClient(8 * time.Second)
|
||||||
|
start := time.Now()
|
||||||
|
var lastErr error
|
||||||
|
success := false
|
||||||
|
for _, b := range bases {
|
||||||
|
b = strings.TrimRight(strings.TrimSpace(b), "/")
|
||||||
|
if b == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(b, "http://") && !strings.HasPrefix(b, "https://") {
|
||||||
|
b = "https://" + b
|
||||||
|
}
|
||||||
|
httpReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, b, nil)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
httpReq.Header.Set("Cookie", cookie)
|
||||||
|
resp, err := httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 256<<10))
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
lastErr = fmt.Errorf("%s returned HTTP %d", b, resp.StatusCode)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := string(bodyBytes)
|
||||||
|
if strings.Contains(text, "driver-verify") || strings.Contains(text, "Age Verification") {
|
||||||
|
lastErr = fmt.Errorf("%s intercepted by age verification", b)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
success = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
latency := time.Since(start).Milliseconds()
|
||||||
|
if success {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"latency_ms": latency,
|
||||||
|
"message": "内置刮削源连接正常",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errMsg := "内置源连接失败"
|
||||||
|
if lastErr != nil {
|
||||||
|
errMsg = lastErr.Error()
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"latency_ms": latency,
|
||||||
|
"error": errMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func recentLogsHandler(svc *service.Container) gin.HandlerFunc {
|
func recentLogsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
rows, err := svc.Repo.Log.Recent(c.Request.Context(), 200)
|
rows, err := svc.Repo.Log.Recent(c.Request.Context(), 200)
|
||||||
|
|||||||
@@ -63,3 +63,15 @@ func deleteAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
provider := c.Param("provider")
|
||||||
|
result, err := svc.ApiConfig.TestConnection(c.Request.Context(), provider)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"result": result, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"result": result})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
|||||||
admin.DELETE("/users/:id", deleteUserHandler(svc))
|
admin.DELETE("/users/:id", deleteUserHandler(svc))
|
||||||
admin.GET("/settings", listSettingsHandler(svc))
|
admin.GET("/settings", listSettingsHandler(svc))
|
||||||
admin.PUT("/settings", updateSettingHandler(svc))
|
admin.PUT("/settings", updateSettingHandler(svc))
|
||||||
|
admin.POST("/adult/test-scraper", testAdultScraperHandler(svc))
|
||||||
admin.GET("/logs", recentLogsHandler(svc))
|
admin.GET("/logs", recentLogsHandler(svc))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ func registerAdminAPIConfigRoutes(admin *gin.RouterGroup, svc *service.Container
|
|||||||
admin.GET("/api-configs/:provider", getAPIConfigHandler(svc))
|
admin.GET("/api-configs/:provider", getAPIConfigHandler(svc))
|
||||||
admin.PUT("/api-configs/:provider", updateAPIConfigHandler(svc))
|
admin.PUT("/api-configs/:provider", updateAPIConfigHandler(svc))
|
||||||
admin.DELETE("/api-configs/:provider", deleteAPIConfigHandler(svc))
|
admin.DELETE("/api-configs/:provider", deleteAPIConfigHandler(svc))
|
||||||
|
admin.POST("/api-configs/:provider/test", testAPIConfigHandler(svc))
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminSchedulerRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
func registerAdminSchedulerRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func PredefinedProviders() []ApiProvider {
|
|||||||
{ID: "openai", Name: "OpenAI", Description: "GPT 系列模型", HasAPIKey: true, HasBaseURL: true},
|
{ID: "openai", Name: "OpenAI", Description: "GPT 系列模型", HasAPIKey: true, HasBaseURL: true},
|
||||||
{ID: "deepseek", Name: "DeepSeek", Description: "DeepSeek 大模型", HasAPIKey: true, HasBaseURL: true},
|
{ID: "deepseek", Name: "DeepSeek", Description: "DeepSeek 大模型", HasAPIKey: true, HasBaseURL: true},
|
||||||
{ID: "siliconflow", Name: "SiliconFlow", Description: "AI 模型聚合 API", HasAPIKey: true, HasBaseURL: true},
|
{ID: "siliconflow", Name: "SiliconFlow", Description: "AI 模型聚合 API", HasAPIKey: true, HasBaseURL: true},
|
||||||
{ID: "adult", Name: "Adult / 番号", Description: "JavDB/JavBus 成人内容元数据", HasAPIKey: false, HasBaseURL: true},
|
{ID: "adult", Name: "Adult / 番号", Description: "JavDB/JavBus 成人内容元数据与 Cookie 凭据", HasAPIKey: true, HasBaseURL: true},
|
||||||
}
|
{ID: "metatube", Name: "MetaTube", Description: "MetaTube Server 番号元数据后端服务", HasAPIKey: true, HasBaseURL: true},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -50,12 +52,20 @@ type AdultProvider struct {
|
|||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
client *http.Client
|
client *http.Client
|
||||||
apiConfig *APIConfigService
|
apiConfig *APIConfigService
|
||||||
|
repo *repository.Container
|
||||||
|
metatube *MetaTubeProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAdultProvider(log *zap.Logger, apiConfig *APIConfigService) *AdultProvider {
|
func NewAdultProvider(log *zap.Logger, apiConfig *APIConfigService, repos ...*repository.Container) *AdultProvider {
|
||||||
|
var repo *repository.Container
|
||||||
|
if len(repos) > 0 {
|
||||||
|
repo = repos[0]
|
||||||
|
}
|
||||||
return &AdultProvider{
|
return &AdultProvider{
|
||||||
log: log,
|
log: log,
|
||||||
apiConfig: apiConfig,
|
apiConfig: apiConfig,
|
||||||
|
repo: repo,
|
||||||
|
metatube: NewMetaTubeProvider(log),
|
||||||
client: NewExternalHTTPClient(12 * time.Second),
|
client: NewExternalHTTPClient(12 * time.Second),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,11 +74,150 @@ func (p *AdultProvider) Enabled() bool {
|
|||||||
return p != nil
|
return p != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MetaTubeClient 返回 MetaTube 客户端实例。
|
||||||
|
func (p *AdultProvider) MetaTubeClient() *MetaTubeProvider {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.metatube
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveMetaTubeConfig 解析当前生效的 MetaTube 配置。
|
||||||
|
func (p *AdultProvider) ResolveMetaTubeConfig(ctx context.Context) MetaTubeConfig {
|
||||||
|
serverURL := p.getSetting(ctx, "adult.scraper.metatube_server", "")
|
||||||
|
token := p.getSetting(ctx, "adult.scraper.metatube_token", "")
|
||||||
|
provider := p.getSetting(ctx, "adult.scraper.metatube_provider", "")
|
||||||
|
enableActor := p.getSettingBool(ctx, "adult.scraper.enable_actor", true)
|
||||||
|
enableTrailer := p.getSettingBool(ctx, "adult.scraper.enable_trailer", false)
|
||||||
|
cropCover := p.getSettingBool(ctx, "adult.scraper.crop_cover", true)
|
||||||
|
badge := p.getSettingBool(ctx, "adult.scraper.badge", false)
|
||||||
|
|
||||||
|
if serverURL == "" && p.apiConfig != nil {
|
||||||
|
if resolved, err := p.apiConfig.Resolve(ctx, "metatube"); err == nil && resolved.BaseURL != "" {
|
||||||
|
serverURL = resolved.BaseURL
|
||||||
|
if token == "" {
|
||||||
|
token = resolved.APIKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MetaTubeConfig{
|
||||||
|
ServerURL: serverURL,
|
||||||
|
Token: token,
|
||||||
|
DefaultProvider: provider,
|
||||||
|
EnableActor: enableActor,
|
||||||
|
EnableTrailer: enableTrailer,
|
||||||
|
CropCover: cropCover,
|
||||||
|
Badge: badge,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search 执行单个番号刮削(根据配置自动在 MetaTube、内置源或智能混合之间路由)。
|
||||||
func (p *AdultProvider) Search(ctx context.Context, code string) (*Match, error) {
|
func (p *AdultProvider) Search(ctx context.Context, code string) (*Match, error) {
|
||||||
code = normalizeAdultCode(code)
|
code = normalizeAdultCode(code)
|
||||||
if code == "" {
|
if code == "" {
|
||||||
return nil, errors.New("empty adult code")
|
return nil, errors.New("empty adult code")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
engine := strings.ToLower(p.getSetting(ctx, "adult.scraper.engine", "builtin"))
|
||||||
|
switch engine {
|
||||||
|
case "metatube":
|
||||||
|
mtCfg := p.ResolveMetaTubeConfig(ctx)
|
||||||
|
if mtCfg.ServerURL != "" {
|
||||||
|
match, err := p.metatube.SearchAndGetBestMatch(ctx, mtCfg, code)
|
||||||
|
if err != nil {
|
||||||
|
if p.log != nil {
|
||||||
|
p.log.Warn("metatube scrape failed", zap.String("code", code), zap.Error(err))
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if match != nil {
|
||||||
|
match.OriginalName = code
|
||||||
|
match.Title = FormatAdultTitle(code, match.Title)
|
||||||
|
match.NSFW = true
|
||||||
|
return match, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
|
||||||
|
case "auto":
|
||||||
|
mtCfg := p.ResolveMetaTubeConfig(ctx)
|
||||||
|
if mtCfg.ServerURL != "" {
|
||||||
|
match, err := p.metatube.SearchAndGetBestMatch(ctx, mtCfg, code)
|
||||||
|
if err == nil && match != nil {
|
||||||
|
match.OriginalName = code
|
||||||
|
match.Title = FormatAdultTitle(code, match.Title)
|
||||||
|
match.NSFW = true
|
||||||
|
return match, nil
|
||||||
|
}
|
||||||
|
if p.log != nil && err != nil {
|
||||||
|
p.log.Debug("metatube search in auto mode failed, falling back to builtin", zap.String("code", code), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p.searchBuiltin(ctx, code)
|
||||||
|
|
||||||
|
default: // "builtin"
|
||||||
|
return p.searchBuiltin(ctx, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchCandidates 执行搜索并返回所有候选 Match 列表(用于手动刮削弹窗)。
|
||||||
|
func (p *AdultProvider) SearchCandidates(ctx context.Context, query string) ([]*Match, error) {
|
||||||
|
code := normalizeAdultCode(query)
|
||||||
|
if code == "" {
|
||||||
|
code = strings.TrimSpace(query)
|
||||||
|
}
|
||||||
|
if code == "" {
|
||||||
|
return nil, errors.New("empty adult query")
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := strings.ToLower(p.getSetting(ctx, "adult.scraper.engine", "builtin"))
|
||||||
|
switch engine {
|
||||||
|
case "metatube":
|
||||||
|
mtCfg := p.ResolveMetaTubeConfig(ctx)
|
||||||
|
if mtCfg.ServerURL != "" {
|
||||||
|
matches, err := p.metatube.Search(ctx, mtCfg, code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, m := range matches {
|
||||||
|
m.OriginalName = code
|
||||||
|
m.Title = FormatAdultTitle(code, m.Title)
|
||||||
|
m.NSFW = true
|
||||||
|
}
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
|
||||||
|
case "auto":
|
||||||
|
mtCfg := p.ResolveMetaTubeConfig(ctx)
|
||||||
|
if mtCfg.ServerURL != "" {
|
||||||
|
matches, err := p.metatube.Search(ctx, mtCfg, code)
|
||||||
|
if err == nil && len(matches) > 0 {
|
||||||
|
for _, m := range matches {
|
||||||
|
m.OriginalName = code
|
||||||
|
m.Title = FormatAdultTitle(code, m.Title)
|
||||||
|
m.NSFW = true
|
||||||
|
}
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m, err := p.searchBuiltin(ctx, code)
|
||||||
|
if err != nil || m == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []*Match{m}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
m, err := p.searchBuiltin(ctx, code)
|
||||||
|
if err != nil || m == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []*Match{m}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdultProvider) searchBuiltin(ctx context.Context, code string) (*Match, error) {
|
||||||
bases := p.resolveBases(ctx)
|
bases := p.resolveBases(ctx)
|
||||||
if len(bases) == 0 {
|
if len(bases) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -90,37 +239,74 @@ func (p *AdultProvider) Search(ctx context.Context, code string) (*Match, error)
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if match != nil {
|
if match != nil {
|
||||||
match.OriginalName = code
|
match.OriginalName = code
|
||||||
match.Title = FormatAdultTitle(code, match.Title)
|
match.Title = FormatAdultTitle(code, match.Title)
|
||||||
match.NSFW = true
|
match.NSFW = true
|
||||||
return match, nil
|
return match, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, lastErr
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AdultProvider) resolveBases(ctx context.Context) []string {
|
func (p *AdultProvider) resolveBases(ctx context.Context) []string {
|
||||||
out := append([]string{}, defaultAdultBases...)
|
customJavDB := p.getSetting(ctx, "adult.scraper.builtin_javdb_url", "")
|
||||||
if p.apiConfig == nil {
|
customJavBus := p.getSetting(ctx, "adult.scraper.builtin_javbus_url", "")
|
||||||
return out
|
|
||||||
}
|
|
||||||
resolved, err := p.apiConfig.Resolve(ctx, "adult")
|
|
||||||
if err != nil {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
if !resolved.Enabled && (resolved.BaseURL != "" || resolved.Extra != "" || resolved.APIKey != "") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configured := []string{}
|
configured := []string{}
|
||||||
configured = append(configured, adultConfiguredBases(resolved.BaseURL)...)
|
if customJavDB != "" {
|
||||||
configured = append(configured, adultConfiguredBases(resolved.Extra)...)
|
configured = append(configured, adultConfiguredBases(customJavDB)...)
|
||||||
|
}
|
||||||
|
if customJavBus != "" {
|
||||||
|
configured = append(configured, adultConfiguredBases(customJavBus)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := append([]string{}, defaultAdultBases...)
|
||||||
|
if p.apiConfig != nil {
|
||||||
|
if resolved, err := p.apiConfig.Resolve(ctx, "adult"); err == nil {
|
||||||
|
if !resolved.Enabled && (resolved.BaseURL != "" || resolved.Extra != "" || resolved.APIKey != "") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
configured = append(configured, adultConfiguredBases(resolved.BaseURL)...)
|
||||||
|
configured = append(configured, adultConfiguredBases(resolved.Extra)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(configured) > 0 {
|
if len(configured) > 0 {
|
||||||
out = append(configured, out...)
|
out = append(configured, out...)
|
||||||
}
|
}
|
||||||
return dedupeStrings(out)
|
return dedupeStrings(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultAdultCookies = "age=verified; existmag=all"
|
||||||
|
|
||||||
|
func (p *AdultProvider) resolveCookie(ctx context.Context) string {
|
||||||
|
customCookie := p.getSetting(ctx, "adult.scraper.builtin_cookie", "")
|
||||||
|
if customCookie != "" {
|
||||||
|
if !strings.Contains(customCookie, "age=") {
|
||||||
|
customCookie = customCookie + "; age=verified"
|
||||||
|
}
|
||||||
|
if !strings.Contains(customCookie, "existmag=") {
|
||||||
|
customCookie = customCookie + "; existmag=all"
|
||||||
|
}
|
||||||
|
return customCookie
|
||||||
|
}
|
||||||
|
if p == nil || p.apiConfig == nil {
|
||||||
|
return defaultAdultCookies
|
||||||
|
}
|
||||||
|
resolved, err := p.apiConfig.Resolve(ctx, "adult")
|
||||||
|
if err != nil || strings.TrimSpace(resolved.APIKey) == "" {
|
||||||
|
return defaultAdultCookies
|
||||||
|
}
|
||||||
|
userCookie := strings.TrimSpace(resolved.APIKey)
|
||||||
|
if !strings.Contains(userCookie, "age=") {
|
||||||
|
userCookie = userCookie + "; age=verified"
|
||||||
|
}
|
||||||
|
if !strings.Contains(userCookie, "existmag=") {
|
||||||
|
userCookie = userCookie + "; existmag=all"
|
||||||
|
}
|
||||||
|
return userCookie
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AdultProvider) scrapeJavDB(ctx context.Context, base, code string) (*Match, error) {
|
func (p *AdultProvider) scrapeJavDB(ctx context.Context, base, code string) (*Match, error) {
|
||||||
searchURL := base + "/search?q=" + url.QueryEscape(code) + "&f=all"
|
searchURL := base + "/search?q=" + url.QueryEscape(code) + "&f=all"
|
||||||
body, err := p.fetchText(ctx, searchURL, base)
|
body, err := p.fetchText(ctx, searchURL, base)
|
||||||
@@ -164,7 +350,8 @@ func (p *AdultProvider) fetchText(ctx context.Context, targetURL, referer string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
applyAdultHeaders(req, referer)
|
cookie := p.resolveCookie(ctx)
|
||||||
|
applyAdultHeaders(req, referer, cookie)
|
||||||
resp, err := p.client.Do(req)
|
resp, err := p.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -180,13 +367,20 @@ func (p *AdultProvider) fetchText(ctx context.Context, targetURL, referer string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return string(body), nil
|
text := string(body)
|
||||||
|
if strings.Contains(text, "driver-verify") || strings.Contains(text, "Age Verification") || strings.Contains(text, "你是否已經成年") {
|
||||||
|
return "", fmt.Errorf("adult source %s intercepted by age verification", targetURL)
|
||||||
|
}
|
||||||
|
return text, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyAdultHeaders(req *http.Request, referer string) {
|
func applyAdultHeaders(req *http.Request, referer, cookie string) {
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36")
|
||||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
|
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
|
||||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7")
|
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7")
|
||||||
|
if cookie != "" {
|
||||||
|
req.Header.Set("Cookie", cookie)
|
||||||
|
}
|
||||||
if referer != "" {
|
if referer != "" {
|
||||||
req.Header.Set("Referer", referer)
|
req.Header.Set("Referer", referer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -65,3 +66,21 @@ func dedupeStrings(values []string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *AdultProvider) getSetting(ctx context.Context, key, fallback string) string {
|
||||||
|
if p != nil && p.repo != nil && p.repo.Setting != nil {
|
||||||
|
if val, err := p.repo.Setting.Get(ctx, key); err == nil && strings.TrimSpace(val) != "" {
|
||||||
|
return strings.TrimSpace(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdultProvider) getSettingBool(ctx context.Context, key string, fallback bool) bool {
|
||||||
|
val := p.getSetting(ctx, key, "")
|
||||||
|
if val == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
val = strings.ToLower(val)
|
||||||
|
return val == "true" || val == "1" || val == "yes" || val == "on"
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,21 +9,26 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func parseAdultDetailHTML(body, code, source, detailURL string) *Match {
|
func parseAdultDetailHTML(body, code, source, detailURL string) *Match {
|
||||||
|
if strings.Contains(body, "driver-verify") || strings.Contains(body, "Age Verification") || strings.Contains(body, "你是否已經成年") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
code = normalizeAdultCode(code)
|
code = normalizeAdultCode(code)
|
||||||
|
rawTitle := firstAdultTitle(body, code)
|
||||||
|
title := FormatAdultTitle(code, rawTitle)
|
||||||
|
if title == "" {
|
||||||
|
title = code
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
match := &Match{
|
match := &Match{
|
||||||
OriginalName: code,
|
OriginalName: code,
|
||||||
MediaType: "adult",
|
MediaType: "adult",
|
||||||
NSFW: true,
|
NSFW: true,
|
||||||
|
Title: title,
|
||||||
Genres: []string{"Adult", source},
|
Genres: []string{"Adult", source},
|
||||||
}
|
}
|
||||||
rawTitle := firstAdultTitle(body, code)
|
|
||||||
match.Title = FormatAdultTitle(code, rawTitle)
|
|
||||||
if match.Title == "" {
|
|
||||||
match.Title = code
|
|
||||||
}
|
|
||||||
if match.Title == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if source == "javbus" {
|
if source == "javbus" {
|
||||||
if m := adultJavBusCoverPattern.FindStringSubmatch(body); len(m) > 1 {
|
if m := adultJavBusCoverPattern.FindStringSubmatch(body); len(m) > 1 {
|
||||||
match.PosterURL = absolutizeURL(detailURL, m[1])
|
match.PosterURL = absolutizeURL(detailURL, m[1])
|
||||||
@@ -39,6 +44,11 @@ func parseAdultDetailHTML(body, code, source, detailURL string) *Match {
|
|||||||
}
|
}
|
||||||
match.Year = firstYearInText(body)
|
match.Year = firstYearInText(body)
|
||||||
match.Rating = firstRatingInText(body)
|
match.Rating = firstRatingInText(body)
|
||||||
|
|
||||||
|
// 如果没有提取到任何封面且标题仅是番号本身,判定为无效或被拦截页面
|
||||||
|
if match.PosterURL == "" && rawTitle == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdultProviderRouting(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
_ = db.AutoMigrate(&model.Setting{}, &model.ApiConfig{})
|
||||||
|
|
||||||
|
repos := repository.New(db)
|
||||||
|
|
||||||
|
mtServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/v1/movies/search" {
|
||||||
|
q := r.URL.Query().Get("q")
|
||||||
|
if q == "SSIS-001" {
|
||||||
|
results := struct {
|
||||||
|
Data []MetaTubeSearchResult `json:"data"`
|
||||||
|
}{
|
||||||
|
Data: []MetaTubeSearchResult{
|
||||||
|
{
|
||||||
|
ID: "999",
|
||||||
|
Number: "SSIS-001",
|
||||||
|
Title: "河北彩花 専属デビュー",
|
||||||
|
Provider: "javdb",
|
||||||
|
Actors: []string{"河北彩花"},
|
||||||
|
ReleaseDate: "2021-06-19",
|
||||||
|
Score: 4.9,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(results)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer mtServer.Close()
|
||||||
|
|
||||||
|
// 1. Configure metatube settings
|
||||||
|
_ = repos.Setting.Set(context.Background(), "adult.scraper.engine", "metatube")
|
||||||
|
_ = repos.Setting.Set(context.Background(), "adult.scraper.metatube_server", mtServer.URL)
|
||||||
|
|
||||||
|
adultProvider := NewAdultProvider(zap.NewNop(), nil, repos)
|
||||||
|
|
||||||
|
// Test Search
|
||||||
|
match, err := adultProvider.Search(context.Background(), "SSIS-001")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if match == nil {
|
||||||
|
t.Fatal("expected match, got nil")
|
||||||
|
}
|
||||||
|
if match.OriginalName != "SSIS-001" {
|
||||||
|
t.Errorf("expected SSIS-001, got %s", match.OriginalName)
|
||||||
|
}
|
||||||
|
if match.Year != 2021 {
|
||||||
|
t.Errorf("expected 2021, got %d", match.Year)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test SearchCandidates
|
||||||
|
candidates, err := adultProvider.SearchCandidates(context.Background(), "SSIS-001")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error on candidates: %v", err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("expected 1 candidate, got %d", len(candidates))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Test auto mode with failing metatube query
|
||||||
|
_ = repos.Setting.Set(context.Background(), "adult.scraper.engine", "auto")
|
||||||
|
// Search for something not on mock metatube server; will attempt fallback
|
||||||
|
mUnknown, _ := adultProvider.Search(context.Background(), "NONEXISTENT-999")
|
||||||
|
if mUnknown != nil {
|
||||||
|
t.Errorf("expected nil for nonexistent in auto mode when sources unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,9 +135,61 @@ func TestAdultProviderDefaultBases(t *testing.T) {
|
|||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("resolveBases len = %d, want %d: %v", len(got), len(want), got)
|
t.Fatalf("resolveBases len = %d, want %d: %v", len(got), len(want), got)
|
||||||
}
|
}
|
||||||
for i := range want {
|
for i := range want {
|
||||||
if got[i] != want[i] {
|
if got[i] != want[i] {
|
||||||
t.Fatalf("resolveBases[%d] = %q, want %q", i, got[i], want[i])
|
t.Fatalf("resolveBases[%d] = %q, want %q", i, got[i], want[i])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseAdultDetailHTMLRejectsAgeVerification(t *testing.T) {
|
||||||
|
verifyHTML := `<html>
|
||||||
|
<head><title>Age Verification JavBus - JavBus</title></head>
|
||||||
|
<body><h4 class="modal-title">你是否已經成年?</h4><a href="/doc/driver-verify">driver-verify</a></body>
|
||||||
|
</html>`
|
||||||
|
got := parseAdultDetailHTML(verifyHTML, "IPX-235", "javbus", "https://www.javbus.com/IPX-235")
|
||||||
|
if got != nil {
|
||||||
|
t.Fatalf("expected nil for age verification page, got: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdultProviderPassesDefaultCookie(t *testing.T) {
|
||||||
|
receivedCookie := ""
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedCookie = r.Header.Get("Cookie")
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(`<html><h3>IPX-235 测试番号</h3><a class="bigImage" href="/pics/cover/6v0a_b.jpg"></a></html>`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
baseURL := server.URL
|
||||||
|
provider := &AdultProvider{
|
||||||
|
client: server.Client(),
|
||||||
|
}
|
||||||
|
match, err := provider.scrapeJavBus(context.Background(), baseURL, "IPX-235")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scrapeJavBus failed: %v", err)
|
||||||
|
}
|
||||||
|
if match == nil || match.PosterURL != baseURL+"/pics/cover/6v0a_b.jpg" {
|
||||||
|
t.Fatalf("unexpected match: %+v", match)
|
||||||
|
}
|
||||||
|
if receivedCookie != "age=verified; existmag=all" {
|
||||||
|
t.Fatalf("receivedCookie = %q, want 'age=verified; existmag=all'", receivedCookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiveAdultProviderJavBus(t *testing.T) {
|
||||||
|
provider := NewAdultProvider(zap.NewNop(), nil)
|
||||||
|
match, err := provider.scrapeJavBus(context.Background(), "https://www.javbus.com", "IPX-235")
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Live test skipped or failed due to network: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if match == nil {
|
||||||
|
t.Fatal("expected match from live JavBus")
|
||||||
|
}
|
||||||
|
t.Logf("Live scrape success: Title=%q, PosterURL=%q, BackdropURL=%q", match.Title, match.PosterURL, match.BackdropURL)
|
||||||
|
if match.PosterURL == "" {
|
||||||
|
t.Fatal("expected non-empty PosterURL")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ func (s *APIConfigService) SeedDefaults(ctx context.Context) error {
|
|||||||
{Provider: "thetvdb", BaseURL: "https://api4.thetvdb.com/v4", Description: "TheTVDB (tv)", Enabled: true},
|
{Provider: "thetvdb", BaseURL: "https://api4.thetvdb.com/v4", Description: "TheTVDB (tv)", Enabled: true},
|
||||||
{Provider: "fanart", BaseURL: "https://webservice.fanart.tv/v3", Description: "Fanart.tv (artwork)", Enabled: true},
|
{Provider: "fanart", BaseURL: "https://webservice.fanart.tv/v3", Description: "Fanart.tv (artwork)", Enabled: true},
|
||||||
{Provider: "douban", Description: "Douban cookie (zh metadata)", Enabled: true},
|
{Provider: "douban", Description: "Douban cookie (zh metadata)", Enabled: true},
|
||||||
{Provider: "adult", BaseURL: "https://javdb.com", Extra: "https://javbus.sbs,https://www.javbus.com,https://www.cdnbus.cyou,https://www.javsee.cyou,https://www.busjav.cyou", Description: "Adult / 番号元数据(JavDB/JavBus)", Enabled: true},
|
{Provider: "adult", BaseURL: "https://javdb.com", Extra: "https://javbus.sbs,https://www.javbus.com,https://www.cdnbus.cyou,https://www.javsee.cyou,https://www.busjav.cyou", Description: "Adult / 番号元数据(JavDB/JavBus)", Enabled: true},
|
||||||
{Provider: "openai", BaseURL: "https://api.openai.com/v1", Description: "OpenAI-compatible (smart search)", Enabled: true},
|
{Provider: "metatube", BaseURL: "http://127.0.0.1:7700", Description: "MetaTube Server 番号刮削后端", Enabled: true},
|
||||||
|
{Provider: "openai", BaseURL: "https://api.openai.com/v1", Description: "OpenAI-compatible (smart search)", Enabled: true},
|
||||||
}
|
}
|
||||||
for i := range defaults {
|
for i := range defaults {
|
||||||
var existing model.APIConfig
|
var existing model.APIConfig
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -29,7 +30,11 @@ func (s *ApiConfigService) TestConnection(ctx context.Context, provider string)
|
|||||||
return s.testDeepSeek(cfg)
|
return s.testDeepSeek(cfg)
|
||||||
case "siliconflow":
|
case "siliconflow":
|
||||||
return s.testSiliconFlow(cfg)
|
return s.testSiliconFlow(cfg)
|
||||||
default:
|
case "adult":
|
||||||
|
return s.testAdult(ctx, cfg)
|
||||||
|
case "metatube":
|
||||||
|
return s.testMetaTube(ctx, cfg)
|
||||||
|
default:
|
||||||
return "unknown", fmt.Errorf("no test implemented for provider: %s", provider)
|
return "unknown", fmt.Errorf("no test implemented for provider: %s", provider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,3 +174,89 @@ func (s *ApiConfigService) testSiliconFlow(cfg *model.ApiConfig) (string, error)
|
|||||||
}
|
}
|
||||||
return "error", fmt.Errorf("SiliconFlow API returned status %d", resp.StatusCode)
|
return "error", fmt.Errorf("SiliconFlow API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testAdult 测试 Adult (JavDB/JavBus) 刮削数据源连接与年龄验证。
|
||||||
|
func (s *ApiConfigService) testAdult(ctx context.Context, cfg *model.ApiConfig) (string, error) {
|
||||||
|
bases := []string{}
|
||||||
|
if cfg.BaseURL != "" {
|
||||||
|
bases = append(bases, adultConfiguredBases(cfg.BaseURL)...)
|
||||||
|
}
|
||||||
|
if cfg.Extra != "" {
|
||||||
|
bases = append(bases, adultConfiguredBases(cfg.Extra)...)
|
||||||
|
}
|
||||||
|
if len(bases) == 0 {
|
||||||
|
bases = defaultAdultBases
|
||||||
|
}
|
||||||
|
cookie := defaultAdultCookies
|
||||||
|
if strings.TrimSpace(cfg.APIKey) != "" {
|
||||||
|
cookie = strings.TrimSpace(cfg.APIKey)
|
||||||
|
if !strings.Contains(cookie, "age=") {
|
||||||
|
cookie = cookie + "; age=verified"
|
||||||
|
}
|
||||||
|
if !strings.Contains(cookie, "existmag=") {
|
||||||
|
cookie = cookie + "; existmag=all"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewExternalHTTPClient(10 * time.Second)
|
||||||
|
var lastErr error
|
||||||
|
successCount := 0
|
||||||
|
|
||||||
|
for _, base := range bases {
|
||||||
|
base = strings.TrimRight(base, "/")
|
||||||
|
probeURL := base
|
||||||
|
if adultSourceKind(base) == "javbus" {
|
||||||
|
probeURL = base + "/IPX-235"
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
applyAdultHeaders(req, base, cookie)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("%s: %w", base, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512<<10))
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
lastErr = fmt.Errorf("%s returned %d", base, resp.StatusCode)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := string(respBody)
|
||||||
|
if strings.Contains(text, "driver-verify") || strings.Contains(text, "Age Verification") || strings.Contains(text, "你是否已經成年") {
|
||||||
|
lastErr = fmt.Errorf("%s: age verification required / intercepted", base)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
successCount++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if successCount > 0 {
|
||||||
|
return "success", nil
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return "error", fmt.Errorf("adult sources test failed: %w", lastErr)
|
||||||
|
}
|
||||||
|
return "error", errors.New("no adult source available")
|
||||||
|
}
|
||||||
|
|
||||||
|
// testMetaTube 测试 MetaTube Server 连接与 Token。
|
||||||
|
func (s *ApiConfigService) testMetaTube(ctx context.Context, cfg *model.ApiConfig) (string, error) {
|
||||||
|
serverURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
||||||
|
if serverURL == "" {
|
||||||
|
serverURL = "http://127.0.0.1:7700"
|
||||||
|
}
|
||||||
|
provider := NewMetaTubeProvider(s.log)
|
||||||
|
res, err := provider.TestConnection(ctx, serverURL, cfg.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
return "error", err
|
||||||
|
}
|
||||||
|
if !res.Success {
|
||||||
|
return "error", errors.New(res.Error)
|
||||||
|
}
|
||||||
|
return "success", nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,11 +85,26 @@ func applyRemoteImageHeaders(req *http.Request, host string) {
|
|||||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7")
|
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7")
|
||||||
req.Header.Set("Cache-Control", "no-cache")
|
req.Header.Set("Cache-Control", "no-cache")
|
||||||
req.Header.Set("Pragma", "no-cache")
|
req.Header.Set("Pragma", "no-cache")
|
||||||
|
if cookie := remoteImageCookie(host); cookie != "" {
|
||||||
|
req.Header.Set("Cookie", cookie)
|
||||||
|
}
|
||||||
if referer := remoteImageReferer(host); referer != "" {
|
if referer := remoteImageReferer(host); referer != "" {
|
||||||
req.Header.Set("Referer", referer)
|
req.Header.Set("Referer", referer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func remoteImageCookie(host string) string {
|
||||||
|
h := strings.ToLower(strings.TrimSpace(host))
|
||||||
|
switch {
|
||||||
|
case strings.Contains(h, "javbus") || strings.Contains(h, "busjav") || strings.Contains(h, "dmmbus") || strings.Contains(h, "javsee") || strings.Contains(h, "cdnbus"):
|
||||||
|
return "age=verified; existmag=all"
|
||||||
|
case strings.Contains(h, "javdb") || strings.Contains(h, "jdbstatic"):
|
||||||
|
return "over18=1"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func remoteImageReferer(host string) string {
|
func remoteImageReferer(host string) string {
|
||||||
h := strings.ToLower(strings.TrimSpace(host))
|
h := strings.ToLower(strings.TrimSpace(host))
|
||||||
switch {
|
switch {
|
||||||
@@ -143,10 +158,13 @@ func fetchRemoteImageWithCurl(ctx context.Context, raw, host string) ([]byte, st
|
|||||||
"--header", "Cache-Control: no-cache",
|
"--header", "Cache-Control: no-cache",
|
||||||
"--header", "Pragma: no-cache",
|
"--header", "Pragma: no-cache",
|
||||||
}
|
}
|
||||||
if referer := remoteImageReferer(host); referer != "" {
|
if referer := remoteImageReferer(host); referer != "" {
|
||||||
args = append(args, "--referer", referer)
|
args = append(args, "--referer", referer)
|
||||||
}
|
}
|
||||||
args = append(args, "--", raw)
|
if cookie := remoteImageCookie(host); cookie != "" {
|
||||||
|
args = append(args, "--cookie", cookie)
|
||||||
|
}
|
||||||
|
args = append(args, "--", raw)
|
||||||
|
|
||||||
cmd := exec.CommandContext(curlCtx, bin, args...) // #nosec G204 -- bin is resolved by LookPath and args are not shell-expanded.
|
cmd := exec.CommandContext(curlCtx, bin, args...) // #nosec G204 -- bin is resolved by LookPath and args are not shell-expanded.
|
||||||
stderr := bytes.Buffer{}
|
stderr := bytes.Buffer{}
|
||||||
|
|||||||
@@ -83,14 +83,20 @@ func manualTMDbSearchTypes(mediaType string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ScraperService) manualAdultMatches(ctx context.Context, media *model.Media, query string) []*Match {
|
func (s *ScraperService) manualAdultMatches(ctx context.Context, media *model.Media, query string) []*Match {
|
||||||
|
if s.adult == nil || !s.adult.Enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
candidates := []string{query}
|
candidates := []string{query}
|
||||||
if media != nil {
|
if media != nil {
|
||||||
candidates = append(candidates, media.Path, media.OriginalName, media.Title)
|
candidates = append(candidates, media.Path, media.OriginalName, media.Title)
|
||||||
}
|
}
|
||||||
out := make([]*Match, 0, 1)
|
out := make([]*Match, 0, 4)
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
code := normalizeAdultCode(candidate)
|
code := normalizeAdultCode(candidate)
|
||||||
|
if code == "" {
|
||||||
|
code = strings.TrimSpace(candidate)
|
||||||
|
}
|
||||||
if code == "" {
|
if code == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -98,8 +104,12 @@ func (s *ScraperService) manualAdultMatches(ctx context.Context, media *model.Me
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[code] = struct{}{}
|
seen[code] = struct{}{}
|
||||||
if match := s.manualAdultMatch(ctx, code); match != nil {
|
if matches, err := s.adult.SearchCandidates(ctx, code); err == nil && len(matches) > 0 {
|
||||||
out = append(out, match)
|
for _, m := range matches {
|
||||||
|
if m != nil && m.Title != "" {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MetaTubeProvider 是对接 MetaTube Server REST API 的刮削客户端。
|
||||||
|
type MetaTubeProvider struct {
|
||||||
|
log *zap.Logger
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMetaTubeProvider 创建 MetaTube 刮削服务实例。
|
||||||
|
func NewMetaTubeProvider(log *zap.Logger) *MetaTubeProvider {
|
||||||
|
return &MetaTubeProvider{
|
||||||
|
log: log,
|
||||||
|
client: NewExternalHTTPClient(15 * time.Second),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search 执行番号/关键词搜索,返回标准化 Match 列表。
|
||||||
|
func (p *MetaTubeProvider) Search(ctx context.Context, cfg MetaTubeConfig, query string) ([]*Match, error) {
|
||||||
|
serverURL := strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/")
|
||||||
|
if serverURL == "" {
|
||||||
|
return nil, errors.New("metatube server url not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
searchURL := fmt.Sprintf("%s/v1/movies/search?q=%s", serverURL, url.QueryEscape(query))
|
||||||
|
if strings.TrimSpace(cfg.DefaultProvider) != "" {
|
||||||
|
searchURL += "&provider=" + url.QueryEscape(strings.TrimSpace(cfg.DefaultProvider))
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.applyAuthHeader(req, cfg.Token)
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("metatube search request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
return nil, fmt.Errorf("metatube search returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []MetaTubeSearchResult
|
||||||
|
var wrapped struct {
|
||||||
|
Data []MetaTubeSearchResult `json:"data"`
|
||||||
|
}
|
||||||
|
if wrapErr := json.Unmarshal(bodyBytes, &wrapped); wrapErr == nil && len(wrapped.Data) > 0 {
|
||||||
|
results = wrapped.Data
|
||||||
|
} else if directErr := json.Unmarshal(bodyBytes, &results); directErr != nil {
|
||||||
|
return nil, fmt.Errorf("decode metatube search response failed: %w", directErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := make([]*Match, 0, len(results))
|
||||||
|
for _, res := range results {
|
||||||
|
match := p.convertSearchResultToMatch(query, &res)
|
||||||
|
if match != nil {
|
||||||
|
matches = append(matches, match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMovie 根据 Provider 与影片 ID 获取完整元数据。
|
||||||
|
func (p *MetaTubeProvider) GetMovie(ctx context.Context, cfg MetaTubeConfig, provider, id string) (*Match, error) {
|
||||||
|
serverURL := strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/")
|
||||||
|
if serverURL == "" {
|
||||||
|
return nil, errors.New("metatube server url not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
movieURL := fmt.Sprintf("%s/v1/movies/%s/%s", serverURL, url.PathEscape(provider), url.PathEscape(id))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, movieURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.applyAuthHeader(req, cfg.Token)
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("metatube movie request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
return nil, fmt.Errorf("metatube movie returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var movie MetaTubeMovieInfo
|
||||||
|
var wrapped struct {
|
||||||
|
Data MetaTubeMovieInfo `json:"data"`
|
||||||
|
}
|
||||||
|
if wrapErr := json.Unmarshal(bodyBytes, &wrapped); wrapErr == nil && wrapped.Data.ID != "" {
|
||||||
|
movie = wrapped.Data
|
||||||
|
} else if directErr := json.Unmarshal(bodyBytes, &movie); directErr != nil {
|
||||||
|
return nil, fmt.Errorf("decode metatube movie response failed: %w", directErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.convertMovieInfoToMatch(&movie), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAndGetBestMatch 执行搜索并拉取首个最佳结果的完整电影详情。
|
||||||
|
func (p *MetaTubeProvider) SearchAndGetBestMatch(ctx context.Context, cfg MetaTubeConfig, code string) (*Match, error) {
|
||||||
|
results, err := p.Search(ctx, cfg, code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(results) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先寻找带有 ID 和 Provider 的结果拉取详情
|
||||||
|
for _, candidate := range results {
|
||||||
|
if candidate.DoubanID != "" && candidate.TheTVDBID != "" {
|
||||||
|
if detailed, err := p.GetMovie(ctx, cfg, candidate.TheTVDBID, candidate.DoubanID); err == nil && detailed != nil {
|
||||||
|
return detailed, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProviders 获取 MetaTube 服务端支持的 Provider 列表。
|
||||||
|
func (p *MetaTubeProvider) GetProviders(ctx context.Context, cfg MetaTubeConfig) ([]string, error) {
|
||||||
|
serverURL := strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/")
|
||||||
|
if serverURL == "" {
|
||||||
|
return nil, errors.New("metatube server url not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, serverURL+"/v1/providers", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.applyAuthHeader(req, cfg.Token)
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("metatube providers request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("metatube providers returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var respObj MetaTubeProvidersResponse
|
||||||
|
if err := json.Unmarshal(bodyBytes, &respObj); err == nil && len(respObj.Data.MovieProviders) > 0 {
|
||||||
|
providers := make([]string, 0, len(respObj.Data.MovieProviders))
|
||||||
|
for prov := range respObj.Data.MovieProviders {
|
||||||
|
providers = append(providers, prov)
|
||||||
|
}
|
||||||
|
sort.Strings(providers)
|
||||||
|
return providers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容纯数组返回
|
||||||
|
var arrayProviders []string
|
||||||
|
if err := json.Unmarshal(bodyBytes, &arrayProviders); err == nil {
|
||||||
|
return arrayProviders, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("decode metatube providers failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnection 测试与 MetaTube 服务端的连通性。
|
||||||
|
func (p *MetaTubeProvider) TestConnection(ctx context.Context, serverURL, token string) (*MetaTubeTestResult, error) {
|
||||||
|
serverURL = strings.TrimRight(strings.TrimSpace(serverURL), "/")
|
||||||
|
if serverURL == "" {
|
||||||
|
return &MetaTubeTestResult{Success: false, Error: "server URL is required"}, errors.New("server URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, serverURL+"/v1/providers", nil)
|
||||||
|
if err != nil {
|
||||||
|
return &MetaTubeTestResult{Success: false, Error: err.Error()}, err
|
||||||
|
}
|
||||||
|
p.applyAuthHeader(req, token)
|
||||||
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
|
latency := time.Since(start).Milliseconds()
|
||||||
|
if err != nil {
|
||||||
|
return &MetaTubeTestResult{
|
||||||
|
Success: false,
|
||||||
|
LatencyMs: latency,
|
||||||
|
Error: fmt.Sprintf("connection failed: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
return &MetaTubeTestResult{
|
||||||
|
Success: false,
|
||||||
|
LatencyMs: latency,
|
||||||
|
Error: "unauthorized: invalid token",
|
||||||
|
}, errors.New("unauthorized: invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return &MetaTubeTestResult{
|
||||||
|
Success: false,
|
||||||
|
LatencyMs: latency,
|
||||||
|
Error: fmt.Sprintf("server returned HTTP %d", resp.StatusCode),
|
||||||
|
}, fmt.Errorf("server returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
var respObj MetaTubeProvidersResponse
|
||||||
|
var providers []string
|
||||||
|
if err := json.Unmarshal(bodyBytes, &respObj); err == nil && len(respObj.Data.MovieProviders) > 0 {
|
||||||
|
for prov := range respObj.Data.MovieProviders {
|
||||||
|
providers = append(providers, prov)
|
||||||
|
}
|
||||||
|
sort.Strings(providers)
|
||||||
|
} else {
|
||||||
|
_ = json.Unmarshal(bodyBytes, &providers)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &MetaTubeTestResult{
|
||||||
|
Success: true,
|
||||||
|
LatencyMs: latency,
|
||||||
|
Providers: providers,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MetaTubeProvider) applyAuthHeader(req *http.Request, token string) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("X-Token", token)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "MediaStationGo/1.0 (MetaTube Client)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MetaTubeProvider) convertSearchResultToMatch(query string, res *MetaTubeSearchResult) *Match {
|
||||||
|
if res == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
code := strings.TrimSpace(res.Number)
|
||||||
|
if code == "" {
|
||||||
|
code = normalizeAdultCode(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := strings.TrimSpace(res.Title)
|
||||||
|
if title == "" {
|
||||||
|
title = code
|
||||||
|
}
|
||||||
|
formattedTitle := FormatAdultTitle(code, title)
|
||||||
|
|
||||||
|
year := parseYearFromDate(res.ReleaseDate)
|
||||||
|
|
||||||
|
genres := make([]string, 0, len(res.Actors))
|
||||||
|
for _, a := range res.Actors {
|
||||||
|
if strings.TrimSpace(a) != "" {
|
||||||
|
genres = append(genres, strings.TrimSpace(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
posterURL := firstNonEmpty(res.BigCoverURL, res.CoverURL, res.BigThumbURL, res.ThumbURL)
|
||||||
|
|
||||||
|
return &Match{
|
||||||
|
MediaType: "adult",
|
||||||
|
Title: formattedTitle,
|
||||||
|
OriginalName: code,
|
||||||
|
PosterURL: posterURL,
|
||||||
|
BackdropURL: posterURL,
|
||||||
|
Year: year,
|
||||||
|
ReleaseDate: cleanDateString(res.ReleaseDate),
|
||||||
|
Rating: res.Score,
|
||||||
|
Genres: genres,
|
||||||
|
NSFW: true,
|
||||||
|
DoubanID: res.ID, // 借用字段存储原始 ID 便于详情反查
|
||||||
|
TheTVDBID: res.Provider, // 借用字段存储 Provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MetaTubeProvider) convertMovieInfoToMatch(movie *MetaTubeMovieInfo) *Match {
|
||||||
|
if movie == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
code := strings.TrimSpace(movie.Number)
|
||||||
|
if code == "" {
|
||||||
|
code = movie.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
title := strings.TrimSpace(movie.Title)
|
||||||
|
if title == "" {
|
||||||
|
title = code
|
||||||
|
}
|
||||||
|
formattedTitle := FormatAdultTitle(code, title)
|
||||||
|
|
||||||
|
year := parseYearFromDate(movie.ReleaseDate)
|
||||||
|
|
||||||
|
posterURL := firstNonEmpty(movie.BigCoverURL, movie.CoverURL, movie.BigThumbURL, movie.ThumbURL)
|
||||||
|
backdropURL := posterURL
|
||||||
|
if len(movie.PreviewImages) > 0 && movie.PreviewImages[0] != "" {
|
||||||
|
backdropURL = movie.PreviewImages[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
genres := make([]string, 0, len(movie.Genres)+len(movie.Actors)+4)
|
||||||
|
for _, g := range movie.Genres {
|
||||||
|
if strings.TrimSpace(g) != "" {
|
||||||
|
genres = append(genres, strings.TrimSpace(g))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(movie.Maker) != "" {
|
||||||
|
genres = append(genres, strings.TrimSpace(movie.Maker))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(movie.Label) != "" && movie.Label != movie.Maker {
|
||||||
|
genres = append(genres, strings.TrimSpace(movie.Label))
|
||||||
|
}
|
||||||
|
for _, a := range movie.Actors {
|
||||||
|
if strings.TrimSpace(a) != "" {
|
||||||
|
genres = append(genres, strings.TrimSpace(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(movie.Director) != "" {
|
||||||
|
genres = append(genres, strings.TrimSpace(movie.Director))
|
||||||
|
}
|
||||||
|
for _, d := range movie.Directors {
|
||||||
|
if strings.TrimSpace(d) != "" {
|
||||||
|
genres = append(genres, strings.TrimSpace(d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Match{
|
||||||
|
MediaType: "adult",
|
||||||
|
Title: formattedTitle,
|
||||||
|
OriginalName: code,
|
||||||
|
Overview: strings.TrimSpace(movie.Summary),
|
||||||
|
PosterURL: posterURL,
|
||||||
|
BackdropURL: backdropURL,
|
||||||
|
Year: year,
|
||||||
|
ReleaseDate: cleanDateString(movie.ReleaseDate),
|
||||||
|
Rating: movie.Score,
|
||||||
|
Genres: dedupeStrings(genres),
|
||||||
|
NSFW: true,
|
||||||
|
DoubanID: movie.ID,
|
||||||
|
TheTVDBID: movie.Provider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseYearFromDate(dateStr string) int {
|
||||||
|
dateStr = strings.TrimSpace(dateStr)
|
||||||
|
if len(dateStr) >= 4 {
|
||||||
|
if y, err := strconv.Atoi(dateStr[:4]); err == nil && y > 1900 && y < 2100 {
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanDateString(dateStr string) string {
|
||||||
|
dateStr = strings.TrimSpace(dateStr)
|
||||||
|
if len(dateStr) >= 10 {
|
||||||
|
return dateStr[:10]
|
||||||
|
}
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMetaTubeProviderSearch(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/movies/search" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := r.URL.Query().Get("q")
|
||||||
|
if q != "IPX-235" {
|
||||||
|
http.Error(w, "unexpected query", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth != "Bearer secret-token" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := struct {
|
||||||
|
Data []MetaTubeSearchResult `json:"data"`
|
||||||
|
}{
|
||||||
|
Data: []MetaTubeSearchResult{
|
||||||
|
{
|
||||||
|
ID: "123456",
|
||||||
|
Number: "IPX-235",
|
||||||
|
Title: "相沢みなみ 専属デビュー",
|
||||||
|
Provider: "javdb",
|
||||||
|
Actors: []string{"相沢みなみ"},
|
||||||
|
CoverURL: "https://example.com/cover.jpg",
|
||||||
|
ReleaseDate: "2018-11-13",
|
||||||
|
Score: 4.6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(results)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
provider := NewMetaTubeProvider(zap.NewNop())
|
||||||
|
cfg := MetaTubeConfig{
|
||||||
|
ServerURL: server.URL,
|
||||||
|
Token: "secret-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
matches, err := provider.Search(context.Background(), cfg, "IPX-235")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(matches) != 1 {
|
||||||
|
t.Fatalf("expected 1 match, got %d", len(matches))
|
||||||
|
}
|
||||||
|
m := matches[0]
|
||||||
|
if m.OriginalName != "IPX-235" {
|
||||||
|
t.Errorf("expected original name IPX-235, got %s", m.OriginalName)
|
||||||
|
}
|
||||||
|
if m.Year != 2018 {
|
||||||
|
t.Errorf("expected year 2018, got %d", m.Year)
|
||||||
|
}
|
||||||
|
if !m.NSFW {
|
||||||
|
t.Errorf("expected NSFW true")
|
||||||
|
}
|
||||||
|
if len(m.Genres) != 1 || m.Genres[0] != "相沢みなみ" {
|
||||||
|
t.Errorf("unexpected genres: %v", m.Genres)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetaTubeProviderGetMovie(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/movies/javdb/123456" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info := struct {
|
||||||
|
Data MetaTubeMovieInfo `json:"data"`
|
||||||
|
}{
|
||||||
|
Data: MetaTubeMovieInfo{
|
||||||
|
ID: "123456",
|
||||||
|
Number: "IPX-235",
|
||||||
|
Title: "相沢みなみ 専属デビュー",
|
||||||
|
Summary: "超绝美少女相沢みなみ出道作品!",
|
||||||
|
Provider: "javdb",
|
||||||
|
Actors: []string{"相沢みなみ"},
|
||||||
|
Directors: []string{"导演A"},
|
||||||
|
Maker: "IDEA POCKET",
|
||||||
|
Label: "Tissue",
|
||||||
|
Genres: []string{"单体作品", "美少女"},
|
||||||
|
Score: 4.8,
|
||||||
|
ReleaseDate: "2018-11-13",
|
||||||
|
CoverURL: "https://example.com/cover.jpg",
|
||||||
|
BigCoverURL: "https://example.com/big_cover.jpg",
|
||||||
|
PreviewImages: []string{"https://example.com/preview1.jpg"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(info)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
provider := NewMetaTubeProvider(zap.NewNop())
|
||||||
|
cfg := MetaTubeConfig{
|
||||||
|
ServerURL: server.URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
match, err := provider.GetMovie(context.Background(), cfg, "javdb", "123456")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if match == nil {
|
||||||
|
t.Fatal("expected non-nil match")
|
||||||
|
}
|
||||||
|
if match.Overview != "超绝美少女相沢みなみ出道作品!" {
|
||||||
|
t.Errorf("unexpected overview: %s", match.Overview)
|
||||||
|
}
|
||||||
|
if match.PosterURL != "https://example.com/big_cover.jpg" {
|
||||||
|
t.Errorf("unexpected poster URL: %s", match.PosterURL)
|
||||||
|
}
|
||||||
|
if match.BackdropURL != "https://example.com/preview1.jpg" {
|
||||||
|
t.Errorf("unexpected backdrop URL: %s", match.BackdropURL)
|
||||||
|
}
|
||||||
|
if match.Year != 2018 {
|
||||||
|
t.Errorf("expected year 2018, got %d", match.Year)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetaTubeProviderTestConnection(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/providers" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth != "Bearer valid-token" {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := MetaTubeProvidersResponse{}
|
||||||
|
resp.Data.MovieProviders = map[string]string{
|
||||||
|
"JavBus": "https://www.javbus.com/",
|
||||||
|
"FANZA": "https://www.dmm.co.jp/",
|
||||||
|
"FC2": "https://adult.contents.fc2.com/",
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
provider := NewMetaTubeProvider(zap.NewNop())
|
||||||
|
|
||||||
|
// Test valid token
|
||||||
|
res, err := provider.TestConnection(context.Background(), server.URL, "valid-token")
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("expected success, got %v, err=%v", res, err)
|
||||||
|
}
|
||||||
|
if len(res.Providers) != 3 {
|
||||||
|
t.Errorf("expected 3 providers, got %d (%v)", len(res.Providers), res.Providers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test invalid token
|
||||||
|
resFail, errFail := provider.TestConnection(context.Background(), server.URL, "wrong-token")
|
||||||
|
if errFail == nil && resFail.Success {
|
||||||
|
t.Fatalf("expected failure for wrong token, got %v", resFail)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// MetaTubeSearchResult 表示 MetaTube 搜索结果项。
|
||||||
|
type MetaTubeSearchResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Homepage string `json:"homepage,omitempty"`
|
||||||
|
Actors []string `json:"actors,omitempty"`
|
||||||
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
|
BigCoverURL string `json:"big_cover_url,omitempty"`
|
||||||
|
ThumbURL string `json:"thumb_url,omitempty"`
|
||||||
|
BigThumbURL string `json:"big_thumb_url,omitempty"`
|
||||||
|
Score float32 `json:"score,omitempty"`
|
||||||
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaTubeMovieInfo 表示 MetaTube 影片完整详情。
|
||||||
|
type MetaTubeMovieInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Homepage string `json:"homepage,omitempty"`
|
||||||
|
Director string `json:"director,omitempty"`
|
||||||
|
Directors []string `json:"directors,omitempty"`
|
||||||
|
Actors []string `json:"actors,omitempty"`
|
||||||
|
Maker string `json:"maker,omitempty"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Series string `json:"series,omitempty"`
|
||||||
|
Genres []string `json:"genres,omitempty"`
|
||||||
|
Score float32 `json:"score,omitempty"`
|
||||||
|
Runtime int `json:"runtime,omitempty"`
|
||||||
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
|
ThumbURL string `json:"thumb_url,omitempty"`
|
||||||
|
BigThumbURL string `json:"big_thumb_url,omitempty"`
|
||||||
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
|
BigCoverURL string `json:"big_cover_url,omitempty"`
|
||||||
|
PreviewVideoURL string `json:"preview_video_url,omitempty"`
|
||||||
|
PreviewVideoHLSURL string `json:"preview_video_hls_url,omitempty"`
|
||||||
|
PreviewImages []string `json:"preview_images,omitempty"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaTubeActorInfo 表示 MetaTube 演员详情。
|
||||||
|
type MetaTubeActorInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
JapaneseName string `json:"japanese_name,omitempty"`
|
||||||
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
|
Images []string `json:"images,omitempty"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Hobby string `json:"hobby,omitempty"`
|
||||||
|
Skill string `json:"skill,omitempty"`
|
||||||
|
Birthday string `json:"birthday,omitempty"`
|
||||||
|
BloodType string `json:"blood_type,omitempty"`
|
||||||
|
CupSize string `json:"cup_size,omitempty"`
|
||||||
|
Measurements string `json:"measurements,omitempty"`
|
||||||
|
Height int `json:"height,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaTubeProvidersResponse 表示 /v1/providers 响应。
|
||||||
|
type MetaTubeProvidersResponse struct {
|
||||||
|
Data struct {
|
||||||
|
MovieProviders map[string]string `json:"movie_providers"`
|
||||||
|
ActorProviders map[string]string `json:"actor_providers"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaTubeConfig 表示 MetaTube 客户端运行配置。
|
||||||
|
type MetaTubeConfig struct {
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
DefaultProvider string `json:"default_provider,omitempty"`
|
||||||
|
EnableActor bool `json:"enable_actor"`
|
||||||
|
EnableTrailer bool `json:"enable_trailer"`
|
||||||
|
CropCover bool `json:"crop_cover"`
|
||||||
|
Badge bool `json:"badge"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaTubeTestResult 表示 MetaTube 连通性测试结果。
|
||||||
|
type MetaTubeTestResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
LatencyMs int64 `json:"latency_ms"`
|
||||||
|
Providers []string `json:"providers,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -370,7 +372,53 @@ func TestApplyManualMatchSkipsTMDbEpisodeStillWhenDisabled(t *testing.T) {
|
|||||||
if !strings.HasSuffix(got.PosterURL, "/images/w500/poster.jpg") {
|
if !strings.HasSuffix(got.PosterURL, "/images/w500/poster.jpg") {
|
||||||
t.Fatalf("series poster should still be saved when manual episode artwork is disabled: got %q", got.PosterURL)
|
t.Fatalf("series poster should still be saved when manual episode artwork is disabled: got %q", got.PosterURL)
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(got.BackdropURL, "/images/w1280/backdrop.jpg") {
|
if !strings.HasSuffix(got.BackdropURL, "/images/w1280/backdrop.jpg") {
|
||||||
t.Fatalf("series backdrop should still be saved when manual episode artwork is disabled: got %q", got.BackdropURL)
|
t.Fatalf("series backdrop should still be saved when manual episode artwork is disabled: got %q", got.BackdropURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichOneAdultScrapesArtwork(t *testing.T) {
|
||||||
|
scraper, repos, closeServer := newTestScraper(t)
|
||||||
|
defer closeServer()
|
||||||
|
|
||||||
|
lib := model.Library{Name: "小姐姐", Path: t.TempDir(), Type: "movie", Enabled: true}
|
||||||
|
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
media := model.Media{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "IPX-235-C",
|
||||||
|
OriginalName: "IPX-235-C",
|
||||||
|
Path: filepath.Join(lib.Path, "IPX-235-C.strm"),
|
||||||
|
ScrapeStatus: "pending",
|
||||||
|
}
|
||||||
|
if err := repos.DB.Create(&media).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock adult provider
|
||||||
|
adultServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(`<html><h3>IPX-235 测试番号封面</h3><a class="bigImage" href="/pics/cover/ipx235.jpg"></a></html>`))
|
||||||
|
}))
|
||||||
|
defer adultServer.Close()
|
||||||
|
|
||||||
|
scraper.adult = &AdultProvider{
|
||||||
|
client: adultServer.Client(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got model.Media
|
||||||
|
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.ScrapeStatus != "matched" {
|
||||||
|
t.Fatalf("ScrapeStatus = %q, want 'matched'", got.ScrapeStatus)
|
||||||
|
}
|
||||||
|
if got.PosterURL != "https://pics.dmm.co.jp/digital/video/ipx00235/ipx00235pl.jpg" && got.PosterURL != adultServer.URL+"/pics/cover/ipx235.jpg" {
|
||||||
|
t.Fatalf("unexpected PosterURL = %q", got.PosterURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func (b *serviceContainerBuilder) initProviderServices() {
|
|||||||
b.c.Fanart = NewFanartProvider(b.cfg, b.log)
|
b.c.Fanart = NewFanartProvider(b.cfg, b.log)
|
||||||
b.c.RecognitionWords = NewRecognitionWordsService(b.log, b.repos)
|
b.c.RecognitionWords = NewRecognitionWordsService(b.log, b.repos)
|
||||||
|
|
||||||
adult := NewAdultProvider(b.log, b.c.APIConfig)
|
adult := NewAdultProvider(b.log, b.c.APIConfig, b.repos)
|
||||||
b.c.Scraper = NewScraperService(
|
b.c.Scraper = NewScraperService(
|
||||||
b.cfg, b.log, b.repos,
|
b.cfg, b.log, b.repos,
|
||||||
b.c.TMDb, b.c.Bangumi, b.c.TheTVDB, b.c.Fanart,
|
b.c.TMDb, b.c.Bangumi, b.c.TheTVDB, b.c.Fanart,
|
||||||
|
|||||||
@@ -54,4 +54,22 @@ export const adminAPI = {
|
|||||||
systemUpdateCheck: () => api.post<SystemUpdateStatus>('/admin/system/update/check').then((r) => r.data),
|
systemUpdateCheck: () => api.post<SystemUpdateStatus>('/admin/system/update/check').then((r) => r.data),
|
||||||
|
|
||||||
systemUpdateApply: () => api.post<SystemUpdateStatus>('/admin/system/update/apply').then((r) => r.data),
|
systemUpdateApply: () => api.post<SystemUpdateStatus>('/admin/system/update/apply').then((r) => r.data),
|
||||||
|
|
||||||
|
testAdultScraper: (payload: {
|
||||||
|
engine?: string
|
||||||
|
server_url?: string
|
||||||
|
token?: string
|
||||||
|
javdb_url?: string
|
||||||
|
javbus_url?: string
|
||||||
|
cookie?: string
|
||||||
|
}) =>
|
||||||
|
api
|
||||||
|
.post<{
|
||||||
|
success: boolean
|
||||||
|
latency_ms?: number
|
||||||
|
providers?: string[]
|
||||||
|
message?: string
|
||||||
|
error?: string
|
||||||
|
}>('/admin/adult/test-scraper', payload)
|
||||||
|
.then((r) => r.data),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,4 +27,6 @@ export const apiConfigsAPI = {
|
|||||||
update: (provider: string, patch: APIConfigPatch) =>
|
update: (provider: string, patch: APIConfigPatch) =>
|
||||||
api.put<APIConfig>(`/admin/api-configs/${provider}`, patch).then((r) => r.data),
|
api.put<APIConfig>(`/admin/api-configs/${provider}`, patch).then((r) => r.data),
|
||||||
remove: (provider: string) => api.delete(`/admin/api-configs/${provider}`).then((r) => r.data),
|
remove: (provider: string) => api.delete(`/admin/api-configs/${provider}`).then((r) => r.data),
|
||||||
|
test: (provider: string) =>
|
||||||
|
api.post<{ result: string; error?: string }>(`/admin/api-configs/${provider}/test`).then((r) => r.data),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,18 +181,24 @@ function EditingRow({
|
|||||||
<td colSpan={4} className="px-4 py-3">
|
<td colSpan={4} className="px-4 py-3">
|
||||||
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
|
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
|
||||||
<span className="text-sm font-medium text-ink-600">{item.provider}</span>
|
<span className="text-sm font-medium text-ink-600">{item.provider}</span>
|
||||||
{!isAdult && (
|
<label className="flex-1 text-xs text-ink-50">
|
||||||
<label className="flex-1 text-xs text-ink-50">
|
{isAdult ? 'Cookie 凭据 (可选)' : 'API Key'}
|
||||||
API Key
|
<input
|
||||||
<input
|
className="input-base mt-1"
|
||||||
className="input-base mt-1"
|
type="password"
|
||||||
type="password"
|
placeholder={
|
||||||
placeholder={item.has_key ? '•••••••••••• (留空保留原值)' : '输入密钥'}
|
isAdult
|
||||||
value={apiKey}
|
? item.has_key
|
||||||
onChange={(e) => setAPIKey(e.target.value)}
|
? '•••••••••••• (已配置,留空保留)'
|
||||||
/>
|
: '可选,默认已内置年龄验证 Cookie'
|
||||||
</label>
|
: item.has_key
|
||||||
)}
|
? '•••••••••••• (留空保留原值)'
|
||||||
|
: '输入密钥'
|
||||||
|
}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setAPIKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label className="flex-1 text-xs text-ink-50">
|
<label className="flex-1 text-xs text-ink-50">
|
||||||
{isAdult ? '主源 URL' : 'Base URL'}
|
{isAdult ? '主源 URL' : 'Base URL'}
|
||||||
<input
|
<input
|
||||||
@@ -239,7 +245,7 @@ function EditingRow({
|
|||||||
|
|
||||||
function apiConfigConfigured(item: APIConfig): boolean {
|
function apiConfigConfigured(item: APIConfig): boolean {
|
||||||
if (item.provider === 'adult') {
|
if (item.provider === 'adult') {
|
||||||
return Boolean(item.base_url?.trim() || item.extra?.trim())
|
return Boolean(item.has_key || item.base_url?.trim() || item.extra?.trim())
|
||||||
}
|
}
|
||||||
return item.has_key
|
return item.has_key
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
|
|||||||
const [extra, setExtra] = useState(item.extra ?? '')
|
const [extra, setExtra] = useState(item.extra ?? '')
|
||||||
const [enabled, setEnabled] = useState(item.enabled)
|
const [enabled, setEnabled] = useState(item.enabled)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
const isAdult = item.provider === 'adult'
|
const isAdult = item.provider === 'adult'
|
||||||
|
|
||||||
const submit = async (e: FormEvent) => {
|
const submit = async (e: FormEvent) => {
|
||||||
@@ -77,8 +78,22 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
|
|||||||
}
|
}
|
||||||
|
|
||||||
const testKey = async () => {
|
const testKey = async () => {
|
||||||
// No /test endpoint yet — render a hint instead.
|
setTesting(true)
|
||||||
toast(`已配置 ${item.has_key ? '✓' : '✗'} 密钥(在线测试请用对应功能页面)`)
|
try {
|
||||||
|
const res = await apiConfigsAPI.test(item.provider)
|
||||||
|
if (res.result === 'success') {
|
||||||
|
toast.success(`${item.provider} 连接/验证测试成功`)
|
||||||
|
} else {
|
||||||
|
toast.error(`${item.provider} 测试失败: ${res.error || res.result}`)
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||||
|
'连接测试失败'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeKey = async () => {
|
const removeKey = async () => {
|
||||||
@@ -106,18 +121,24 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{!isAdult && (
|
<label className="block text-xs text-ink-50">
|
||||||
<label className="block text-xs text-ink-50">
|
{isAdult ? 'Cookie 凭据 (可选,留空保留原值)' : 'API Key (留空保留原值)'}
|
||||||
API Key (留空保留原值)
|
<input
|
||||||
<input
|
className="input-base mt-1"
|
||||||
className="input-base mt-1"
|
type="password"
|
||||||
type="password"
|
placeholder={
|
||||||
placeholder={item.has_key ? '••••••••••••' : '尚未配置'}
|
isAdult
|
||||||
value={apiKey}
|
? item.has_key
|
||||||
onChange={(e) => setAPIKey(e.target.value)}
|
? '•••••••••••• (已配置)'
|
||||||
/>
|
: '可选,默认内置年龄验证,可填自定义 Cookie'
|
||||||
</label>
|
: item.has_key
|
||||||
)}
|
? '••••••••••••'
|
||||||
|
: '尚未配置'
|
||||||
|
}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setAPIKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label className="block text-xs text-ink-50">
|
<label className="block text-xs text-ink-50">
|
||||||
{isAdult ? '主源 URL' : 'Base URL (可选)'}
|
{isAdult ? '主源 URL' : 'Base URL (可选)'}
|
||||||
<input
|
<input
|
||||||
@@ -152,10 +173,11 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={testing}
|
||||||
onClick={testKey}
|
onClick={testKey}
|
||||||
className="neon-button !text-xs !border-sand-400/40 !text-ink-100"
|
className="neon-button !text-xs !border-sand-400/40 !text-ink-100"
|
||||||
>
|
>
|
||||||
<Eye size={12} /> 状态
|
<Eye size={12} /> {testing ? '测试中…' : '测试连接'}
|
||||||
</button>
|
</button>
|
||||||
{item.has_key && (
|
{item.has_key && (
|
||||||
<button
|
<button
|
||||||
@@ -174,7 +196,7 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
|
|||||||
|
|
||||||
function apiConfigConfigured(item: APIConfig): boolean {
|
function apiConfigConfigured(item: APIConfig): boolean {
|
||||||
if (item.provider === 'adult') {
|
if (item.provider === 'adult') {
|
||||||
return Boolean(item.base_url?.trim() || item.extra?.trim())
|
return Boolean(item.has_key || item.base_url?.trim() || item.extra?.trim())
|
||||||
}
|
}
|
||||||
return item.has_key
|
return item.has_key
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,703 @@
|
|||||||
|
import { FormEvent, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
CheckCircle2,
|
||||||
|
Cpu,
|
||||||
|
Database,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Globe,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
|
Save,
|
||||||
|
Server,
|
||||||
|
Sparkles,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
import { adminAPI } from '../api/admin'
|
||||||
|
import { libraryAPI } from '../api/library'
|
||||||
|
import type { Library, Setting } from '../types'
|
||||||
|
import { adultSettingsGroup } from './settingsGroupAccess'
|
||||||
|
|
||||||
|
const POPULAR_METATUBE_PROVIDERS = [
|
||||||
|
{ id: '', label: '自动 (全部)' },
|
||||||
|
{ id: 'javdb', label: 'JavDB' },
|
||||||
|
{ id: 'javbus', label: 'JavBus' },
|
||||||
|
{ id: 'dmm', label: 'DMM / FANZA' },
|
||||||
|
{ id: 'fc2', label: 'FC2' },
|
||||||
|
{ id: 'gfriends', label: 'GFriends' },
|
||||||
|
{ id: 'airav', label: 'AirAV' },
|
||||||
|
{ id: 'heyzo', label: 'HEYZO' },
|
||||||
|
{ id: 'mgs', label: 'MGS' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AdultSettingsPanel() {
|
||||||
|
const [values, setValues] = useState<Record<string, string>>({})
|
||||||
|
const [dirty, setDirty] = useState<Set<string>>(new Set())
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
|
const [showToken, setShowToken] = useState(false)
|
||||||
|
|
||||||
|
// Test connection state
|
||||||
|
const [testingMT, setTestingMT] = useState(false)
|
||||||
|
const [mtTestResult, setMtTestResult] = useState<{
|
||||||
|
success?: boolean
|
||||||
|
latency_ms?: number
|
||||||
|
providers?: string[]
|
||||||
|
error?: string
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const [testingBuiltin, setTestingBuiltin] = useState(false)
|
||||||
|
const [builtinTestResult, setBuiltinTestResult] = useState<{
|
||||||
|
success?: boolean
|
||||||
|
latency_ms?: number
|
||||||
|
message?: string
|
||||||
|
error?: string
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [all, libs] = await Promise.all([
|
||||||
|
adminAPI.listSettings(),
|
||||||
|
libraryAPI.list({ includeHidden: true }).catch(() => [] as Library[]),
|
||||||
|
])
|
||||||
|
const idx: Record<string, string> = {}
|
||||||
|
for (const item of adultSettingsGroup.items) {
|
||||||
|
if (item.defaultValue !== undefined) {
|
||||||
|
idx[item.key] = item.defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const s of all as Setting[]) {
|
||||||
|
if (s.key.startsWith('adult.')) {
|
||||||
|
idx[s.key] = s.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setValues(idx)
|
||||||
|
setLibraries(libs as Library[])
|
||||||
|
setDirty(new Set())
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh().catch(() => undefined)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setVal = (key: string, value: string) => {
|
||||||
|
setValues((prev) => ({ ...prev, [key]: value }))
|
||||||
|
setDirty((prev) => new Set(prev).add(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleVal = (key: string) => {
|
||||||
|
const current = values[key] === 'true' || values[key] === '1' || values[key] === 'on'
|
||||||
|
setVal(key, current ? 'false' : 'true')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isChecked = (key: string, def = false) => {
|
||||||
|
const val = values[key]
|
||||||
|
if (val === undefined) return def
|
||||||
|
return val === 'true' || val === '1' || val === 'on'
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSave = async (e?: FormEvent) => {
|
||||||
|
if (e) e.preventDefault()
|
||||||
|
if (dirty.size === 0) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
for (const key of dirty) {
|
||||||
|
await adminAPI.updateSetting(key, values[key] ?? '')
|
||||||
|
}
|
||||||
|
toast.success(`已保存 ${dirty.size} 项配置`)
|
||||||
|
setDirty(new Set())
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runMetaTubeTest = async () => {
|
||||||
|
setTestingMT(true)
|
||||||
|
setMtTestResult(null)
|
||||||
|
try {
|
||||||
|
const res = await adminAPI.testAdultScraper({
|
||||||
|
engine: 'metatube',
|
||||||
|
server_url: values['adult.scraper.metatube_server'] || 'http://127.0.0.1:7700',
|
||||||
|
token: values['adult.scraper.metatube_token'] || '',
|
||||||
|
})
|
||||||
|
setMtTestResult(res)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(`MetaTube 连接成功 (${res.latency_ms ?? 0}ms)`)
|
||||||
|
} else {
|
||||||
|
toast.error(res.error || 'MetaTube 连接失败')
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||||
|
(err as Error).message ??
|
||||||
|
'测试失败'
|
||||||
|
setMtTestResult({ success: false, error: msg })
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setTestingMT(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runBuiltinTest = async () => {
|
||||||
|
setTestingBuiltin(true)
|
||||||
|
setBuiltinTestResult(null)
|
||||||
|
try {
|
||||||
|
const res = await adminAPI.testAdultScraper({
|
||||||
|
engine: 'builtin',
|
||||||
|
javdb_url: values['adult.scraper.builtin_javdb_url'] || 'https://javdb.com',
|
||||||
|
javbus_url: values['adult.scraper.builtin_javbus_url'] || '',
|
||||||
|
cookie: values['adult.scraper.builtin_cookie'] || '',
|
||||||
|
})
|
||||||
|
setBuiltinTestResult(res)
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(`内置刮削源连接成功 (${res.latency_ms ?? 0}ms)`)
|
||||||
|
} else {
|
||||||
|
toast.error(res.error || '内置源连接失败')
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||||
|
(err as Error).message ??
|
||||||
|
'测试失败'
|
||||||
|
setBuiltinTestResult({ success: false, error: msg })
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setTestingBuiltin(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedLibraryIDs = parseLibraryIDs(values['adult.library_ids'] || '[]')
|
||||||
|
const toggleLibrary = (id: string, checked: boolean) => {
|
||||||
|
const next = checked
|
||||||
|
? Array.from(new Set([...selectedLibraryIDs, id]))
|
||||||
|
: selectedLibraryIDs.filter((item) => item !== id)
|
||||||
|
setVal('adult.library_ids', JSON.stringify(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = values['adult.scraper.engine'] || 'builtin'
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-12 text-ink-50">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSave} className="space-y-6">
|
||||||
|
{/* 1. 全局访问与隔离卡片 */}
|
||||||
|
<div className="glass-panel space-y-4">
|
||||||
|
<div className="flex items-center gap-3 border-b border-gray-200/60 pb-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-pink-500/10 text-pink-500">
|
||||||
|
<Lock size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-ink-600">访问与媒体库隔离控制</h2>
|
||||||
|
<p className="text-xs text-sand-500">
|
||||||
|
设置全局成人内容显示规则、指定成人影视库与访问 PIN 保护
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{/* 主开关 */}
|
||||||
|
<div className="rounded-2xl border border-gray-200/60 bg-white/50 p-4">
|
||||||
|
<label className="flex cursor-pointer items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="block text-sm font-medium text-ink-600">启用成人内容</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-sand-500">
|
||||||
|
全局开关。关闭后系统彻底禁用所有成人库与成人刮削;开启后普通用户仍默认隐藏,可在个人资料或 Bot 自行开启。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-5 w-5 accent-pink-500"
|
||||||
|
checked={isChecked('adult.enabled', true)}
|
||||||
|
onChange={() => toggleVal('adult.enabled')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PIN 保护 */}
|
||||||
|
<div className="rounded-2xl border border-gray-200/60 bg-white/50 p-4">
|
||||||
|
<label className="flex cursor-pointer items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="block text-sm font-medium text-ink-600">访问需要 PIN 码保护</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-sand-500">
|
||||||
|
开启后,客户端或网页端浏览成人库内容需输入数字 PIN 码
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-5 w-5 accent-pink-500"
|
||||||
|
checked={isChecked('adult.require_pin', false)}
|
||||||
|
onChange={() => toggleVal('adult.require_pin')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{isChecked('adult.require_pin', false) && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-base text-sm"
|
||||||
|
placeholder="请输入 4-8 位数字 PIN"
|
||||||
|
value={values['adult.pin'] ?? ''}
|
||||||
|
onChange={(e) => setVal('adult.pin', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 成人媒体库绑定 */}
|
||||||
|
<div className="rounded-2xl border border-gray-200/60 bg-white/50 p-4">
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="block text-sm font-medium text-ink-600">指定成人媒体库</span>
|
||||||
|
<span className="text-xs text-sand-500">
|
||||||
|
指定哪些媒体库属于成人分类。指定后,搜索、分类及 Emby/Jellyfin 协议都会统一应用隔离与过滤规则。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{libraries.length === 0 && (
|
||||||
|
<div className="col-span-full py-2 text-xs text-sand-500">暂无媒体库</div>
|
||||||
|
)}
|
||||||
|
{libraries.map((lib) => {
|
||||||
|
const checked = selectedLibraryIDs.includes(lib.id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={lib.id}
|
||||||
|
className={
|
||||||
|
'flex cursor-pointer items-start gap-2.5 rounded-xl border p-2.5 transition ' +
|
||||||
|
(checked
|
||||||
|
? 'border-pink-500/40 bg-pink-500/5'
|
||||||
|
: 'border-gray-200/60 bg-white/80 hover:bg-gray-50')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 h-4 w-4 accent-pink-500"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => toggleLibrary(lib.id, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="block truncate text-xs font-medium text-ink-600">
|
||||||
|
{lib.name}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate font-mono text-[11px] text-sand-500">
|
||||||
|
{lib.path}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. 番号刮削引擎配置卡片 */}
|
||||||
|
<div className="glass-panel space-y-5">
|
||||||
|
<div className="flex items-center gap-3 border-b border-gray-200/60 pb-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary-500/10 text-brand-500">
|
||||||
|
<Sparkles size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-ink-600">番号刮削配置模块</h2>
|
||||||
|
<p className="text-xs text-sand-500">
|
||||||
|
支持切换本项目内置番号刮削源或接入 MetaTube 独立服务端,进行精准影视与女优元数据抓取
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 引擎模式选择 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-sand-500">
|
||||||
|
选择番号刮削模式
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVal('adult.scraper.engine', 'builtin')}
|
||||||
|
className={
|
||||||
|
'flex flex-col items-start gap-1.5 rounded-2xl border p-4 text-left transition ' +
|
||||||
|
(engine === 'builtin'
|
||||||
|
? 'border-brand-500 bg-primary-500/10 ring-1 ring-brand-500'
|
||||||
|
: 'border-gray-200/60 bg-white/50 hover:bg-white/80')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database size={16} className={engine === 'builtin' ? 'text-brand-500' : 'text-sand-500'} />
|
||||||
|
<span className="font-semibold text-sm text-ink-600">本项目内置刮削</span>
|
||||||
|
</div>
|
||||||
|
{engine === 'builtin' && <CheckCircle2 size={16} className="text-brand-500" />}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-sand-500">
|
||||||
|
集成 JavDB / JavBus 多源并发解析,免部署外部服务端,内置年龄绕过
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVal('adult.scraper.engine', 'metatube')}
|
||||||
|
className={
|
||||||
|
'flex flex-col items-start gap-1.5 rounded-2xl border p-4 text-left transition ' +
|
||||||
|
(engine === 'metatube'
|
||||||
|
? 'border-brand-500 bg-primary-500/10 ring-1 ring-brand-500'
|
||||||
|
: 'border-gray-200/60 bg-white/50 hover:bg-white/80')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Server size={16} className={engine === 'metatube' ? 'text-brand-500' : 'text-sand-500'} />
|
||||||
|
<span className="font-semibold text-sm text-ink-600">MetaTube 服务端</span>
|
||||||
|
</div>
|
||||||
|
{engine === 'metatube' && <CheckCircle2 size={16} className="text-brand-500" />}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-sand-500">
|
||||||
|
连接独立 MetaTube Server 容器,支持多数据源聚合、女优头像与高级插件特性
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVal('adult.scraper.engine', 'auto')}
|
||||||
|
className={
|
||||||
|
'flex flex-col items-start gap-1.5 rounded-2xl border p-4 text-left transition ' +
|
||||||
|
(engine === 'auto'
|
||||||
|
? 'border-brand-500 bg-primary-500/10 ring-1 ring-brand-500'
|
||||||
|
: 'border-gray-200/60 bg-white/50 hover:bg-white/80')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Cpu size={16} className={engine === 'auto' ? 'text-brand-500' : 'text-sand-500'} />
|
||||||
|
<span className="font-semibold text-sm text-ink-600">智能混合模式</span>
|
||||||
|
</div>
|
||||||
|
{engine === 'auto' && <CheckCircle2 size={16} className="text-brand-500" />}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-sand-500">
|
||||||
|
优先请求 MetaTube 服务端,若无匹配或服务离线则自动回退至内置多源
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. MetaTube 专属配置模块 */}
|
||||||
|
{(engine === 'metatube' || engine === 'auto') && (
|
||||||
|
<div className="rounded-2xl border border-primary-500/20 bg-primary-500/5 p-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Server className="h-4 w-4 text-brand-500" />
|
||||||
|
<span className="text-sm font-semibold text-ink-600">MetaTube 后端配置</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://github.com/metatube-community/metatube-server"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xs text-brand-500 hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Globe size={12} /> MetaTube 官方文档
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-ink-100">
|
||||||
|
MetaTube 服务端地址 (Server URL)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-base mt-1 text-sm font-mono"
|
||||||
|
placeholder="http://127.0.0.1:7700"
|
||||||
|
value={values['adult.scraper.metatube_server'] ?? 'http://127.0.0.1:7700'}
|
||||||
|
onChange={(e) => setVal('adult.scraper.metatube_server', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[11px] text-sand-500">
|
||||||
|
独立部署的 MetaTube Server 地址,如 http://127.0.0.1:7700 或内网 Docker IP
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-ink-100">
|
||||||
|
认证 Token (API Token)
|
||||||
|
</label>
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<input
|
||||||
|
type={showToken ? 'text' : 'password'}
|
||||||
|
className="input-base pr-10 text-sm font-mono"
|
||||||
|
placeholder="留空表示服务端未启用 Token 保护"
|
||||||
|
value={values['adult.scraper.metatube_token'] ?? ''}
|
||||||
|
onChange={(e) => setVal('adult.scraper.metatube_token', e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowToken(!showToken)}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-sand-500 hover:text-ink-600"
|
||||||
|
>
|
||||||
|
{showToken ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] text-sand-500">
|
||||||
|
MetaTube 服务端配置的 TOKEN 环境变量
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provider 选择器 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-ink-100">
|
||||||
|
首选 / 默认 Provider 数据源
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{POPULAR_METATUBE_PROVIDERS.map((p) => {
|
||||||
|
const active = (values['adult.scraper.metatube_provider'] ?? '') === p.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVal('adult.scraper.metatube_provider', p.id)}
|
||||||
|
className={
|
||||||
|
'rounded-lg px-2.5 py-1 text-xs transition ' +
|
||||||
|
(active
|
||||||
|
? 'bg-primary-500 text-white font-medium shadow-sm'
|
||||||
|
: 'bg-white/80 text-ink-100 border border-gray-200/60 hover:bg-sand-100/50')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 测试 MetaTube 连接按钮 */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-primary-500/20 pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={testingMT}
|
||||||
|
onClick={runMetaTubeTest}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-xl border border-brand-500/40 bg-brand-500/10 px-3 py-1.5 text-xs font-medium text-brand-500 transition hover:bg-brand-500/20 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{testingMT ? <Loader2 size={13} className="animate-spin" /> : <Activity size={13} />}
|
||||||
|
测试 MetaTube 连接
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{mtTestResult && (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
{mtTestResult.success ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-emerald-600 font-medium">
|
||||||
|
<CheckCircle2 size={14} /> 连接成功 ({mtTestResult.latency_ms}ms)
|
||||||
|
{mtTestResult.providers && mtTestResult.providers.length > 0 && (
|
||||||
|
<span className="text-sand-500 font-normal">
|
||||||
|
· 支持 {mtTestResult.providers.length} 个源
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-red-500 font-medium">
|
||||||
|
<XCircle size={14} /> {mtTestResult.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 4. 本地内置刮削源专属配置 */}
|
||||||
|
{(engine === 'builtin' || engine === 'auto') && (
|
||||||
|
<div className="rounded-2xl border border-gray-200/60 bg-white/50 p-4 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4 text-brand-500" />
|
||||||
|
<span className="text-sm font-semibold text-ink-600">内置多源刮削配置</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-ink-100">
|
||||||
|
JavDB 主源 URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-base mt-1 text-sm font-mono"
|
||||||
|
placeholder="https://javdb.com"
|
||||||
|
value={values['adult.scraper.builtin_javdb_url'] ?? 'https://javdb.com'}
|
||||||
|
onChange={(e) => setVal('adult.scraper.builtin_javdb_url', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-ink-100">
|
||||||
|
年龄验证 Cookie 凭据
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-base mt-1 text-sm font-mono"
|
||||||
|
placeholder="age=verified; existmag=all"
|
||||||
|
value={values['adult.scraper.builtin_cookie'] ?? 'age=verified; existmag=all'}
|
||||||
|
onChange={(e) => setVal('adult.scraper.builtin_cookie', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-full">
|
||||||
|
<label className="block text-xs font-medium text-ink-100">
|
||||||
|
JavBus 备用镜像源列表 (换行或逗号分隔)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
className="input-base mt-1 text-xs font-mono"
|
||||||
|
placeholder={'https://javbus.sbs\nhttps://www.javbus.com\nhttps://www.cdnbus.cyou'}
|
||||||
|
value={
|
||||||
|
values['adult.scraper.builtin_javbus_url'] ??
|
||||||
|
'https://javbus.sbs,https://www.javbus.com,https://www.cdnbus.cyou,https://www.javsee.cyou,https://www.busjav.cyou'
|
||||||
|
}
|
||||||
|
onChange={(e) => setVal('adult.scraper.builtin_javbus_url', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 测试内置源按钮 */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200/60 pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={testingBuiltin}
|
||||||
|
onClick={runBuiltinTest}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-ink-600 transition hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{testingBuiltin ? (
|
||||||
|
<Loader2 size={13} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Activity size={13} />
|
||||||
|
)}
|
||||||
|
测试内置源连接
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{builtinTestResult && (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
{builtinTestResult.success ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-emerald-600 font-medium">
|
||||||
|
<CheckCircle2 size={14} /> 连接成功 ({builtinTestResult.latency_ms}ms)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-red-500 font-medium">
|
||||||
|
<XCircle size={14} /> {builtinTestResult.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 5. 通用高级刮削选项 */}
|
||||||
|
<div className="rounded-2xl border border-gray-200/60 bg-white/50 p-4 space-y-3">
|
||||||
|
<span className="block text-sm font-semibold text-ink-600">元数据与图片增强选项</span>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-gray-200/60 bg-white/80 p-3 hover:bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 h-4 w-4 accent-primary-500"
|
||||||
|
checked={isChecked('adult.scraper.crop_cover', true)}
|
||||||
|
onChange={() => toggleVal('adult.scraper.crop_cover')}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs font-semibold text-ink-600">封面智能裁剪海报</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-sand-500">
|
||||||
|
自动将 DVD 宽版封套裁剪为正面竖版海报,并保留完整封套作为背景大图
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-gray-200/60 bg-white/80 p-3 hover:bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 h-4 w-4 accent-primary-500"
|
||||||
|
checked={isChecked('adult.scraper.enable_actor', true)}
|
||||||
|
onChange={() => toggleVal('adult.scraper.enable_actor')}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs font-semibold text-ink-600">刮削女优详情与头像</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-sand-500">
|
||||||
|
抓取女优名字、别名、头像并自动聚合至媒体元数据与分类标签
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-gray-200/60 bg-white/80 p-3 hover:bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 h-4 w-4 accent-primary-500"
|
||||||
|
checked={isChecked('adult.scraper.enable_trailer', false)}
|
||||||
|
onChange={() => toggleVal('adult.scraper.enable_trailer')}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs font-semibold text-ink-600">刮削预告片视频</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-sand-500">
|
||||||
|
尝试解析官方样片/预告片并保存链接
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-gray-200/60 bg-white/80 p-3 hover:bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 h-4 w-4 accent-primary-500"
|
||||||
|
checked={isChecked('adult.scraper.badge', false)}
|
||||||
|
onChange={() => toggleVal('adult.scraper.badge')}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs font-semibold text-ink-600">添加无码/角标标记</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-sand-500">
|
||||||
|
为无码破解或流出作品添加识别角标
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部保存条 */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<span className="text-xs text-sand-500">
|
||||||
|
{dirty.size > 0 ? `有 ${dirty.size} 项修改未保存` : '所有设置已是最新'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving || dirty.size === 0}
|
||||||
|
className="neon-button disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||||
|
保存配置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLibraryIDs(raw: string): string[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw || '[]')
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.map((item) => String(item).trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return raw
|
||||||
|
.split(/[,\n;,]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
@@ -5,10 +5,11 @@ import toast from 'react-hot-toast'
|
|||||||
import { adminAPI } from '../api/admin'
|
import { adminAPI } from '../api/admin'
|
||||||
import { libraryAPI } from '../api/library'
|
import { libraryAPI } from '../api/library'
|
||||||
import type { Library, Setting } from '../types'
|
import type { Library, Setting } from '../types'
|
||||||
import { SettingRow } from './SettingsRow'
|
import { AdultSettingsPanel } from './AdultSettingsPanel'
|
||||||
import { ALL_KEYS, GROUPS } from './settingsGroups'
|
|
||||||
import { SystemUpdatePanel } from './SystemUpdatePanel'
|
|
||||||
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
|
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
|
||||||
|
import { SettingRow } from './SettingsRow'
|
||||||
|
import { SystemUpdatePanel } from './SystemUpdatePanel'
|
||||||
|
import { ALL_KEYS, GROUPS } from './settingsGroups'
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const [activeGroup, setActiveGroup] = useState(GROUPS[0].key)
|
const [activeGroup, setActiveGroup] = useState(GROUPS[0].key)
|
||||||
@@ -109,7 +110,8 @@ export function SettingsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{group.key === 'system-update' && <SystemUpdatePanel />}
|
{group.key === 'system-update' && <SystemUpdatePanel />}
|
||||||
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
|
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
|
||||||
{group.items.length > 0 && (
|
{group.key === 'adult' && <AdultSettingsPanel />}
|
||||||
|
{group.key !== 'adult' && group.items.length > 0 && (
|
||||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||||
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
|
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
|
||||||
{group.items.map((it) => (
|
{group.items.map((it) => (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { SettingGroup } from './settingsGroupTypes'
|
|||||||
export const adultSettingsGroup: SettingGroup = {
|
export const adultSettingsGroup: SettingGroup = {
|
||||||
key: 'adult',
|
key: 'adult',
|
||||||
label: 'Adult / NSFW',
|
label: 'Adult / NSFW',
|
||||||
description: '成人内容隔离开关 (默认隐藏)',
|
description: '成人内容隔离与番号刮削配置 (支持 MetaTube 服务端与内置多源)',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
key: 'adult.enabled',
|
key: 'adult.enabled',
|
||||||
@@ -30,5 +30,88 @@ export const adultSettingsGroup: SettingGroup = {
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
hint: '4-8 位数字',
|
hint: '4-8 位数字',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'adult.scraper.engine',
|
||||||
|
label: '番号刮削引擎',
|
||||||
|
type: 'select',
|
||||||
|
hint: '选择使用内置刮削源、MetaTube 独立服务端或智能混合模式',
|
||||||
|
defaultValue: 'builtin',
|
||||||
|
options: [
|
||||||
|
{ value: 'builtin', label: '本项目内置刮削 (JavDB / JavBus 多源)' },
|
||||||
|
{ value: 'metatube', label: 'MetaTube 后端服务 (推荐)' },
|
||||||
|
{ value: 'auto', label: '智能混合 (优先 MetaTube,失败回退内置)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adult.scraper.metatube_server',
|
||||||
|
label: 'MetaTube 服务端地址',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'http://127.0.0.1:7700',
|
||||||
|
defaultValue: 'http://127.0.0.1:7700',
|
||||||
|
hint: 'MetaTube Server 服务端 URL(如 http://127.0.0.1:7700)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adult.scraper.metatube_token',
|
||||||
|
label: 'MetaTube 访问 Token',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '留空表示无 Token 保护',
|
||||||
|
hint: 'MetaTube 服务端配置的 API Bearer Token',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adult.scraper.metatube_provider',
|
||||||
|
label: 'MetaTube 首选 Provider',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '例如 javdb, javbus, dmm, fc2, gfriends (留空为全部)',
|
||||||
|
hint: '指定首选或默认查询的 Provider 数据源',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adult.scraper.enable_actor',
|
||||||
|
label: '刮削女优/演员详情与头像',
|
||||||
|
type: 'toggle',
|
||||||
|
defaultValue: 'true',
|
||||||
|
hint: '自动刮削女优简介、三围、生日及头像,并整合至标签',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adult.scraper.enable_trailer',
|
||||||
|
label: '刮削预告片',
|
||||||
|
type: 'toggle',
|
||||||
|
defaultValue: 'false',
|
||||||
|
hint: '自动抓取影片的预告片/试看片段链接',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adult.scraper.crop_cover',
|
||||||
|
label: '封面智能裁剪海报',
|
||||||
|
type: 'toggle',
|
||||||
|
defaultValue: 'true',
|
||||||
|
hint: '将宽版 DVD 封套大图智能裁剪为竖版正面海报,并保留完整封套作为背景大图',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adult.scraper.badge',
|
||||||
|
label: '添加无码/角标标记',
|
||||||
|
type: 'toggle',
|
||||||
|
defaultValue: 'false',
|
||||||
|
hint: '为识别出的无码/破解作品在海报添加角标标记',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adult.scraper.builtin_javdb_url',
|
||||||
|
label: '内置 JavDB 地址',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'https://javdb.com',
|
||||||
|
defaultValue: 'https://javdb.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adult.scraper.builtin_javbus_url',
|
||||||
|
label: '内置 JavBus 镜像列表',
|
||||||
|
type: 'textarea',
|
||||||
|
placeholder: 'https://javbus.sbs\nhttps://www.javbus.com\nhttps://www.cdnbus.cyou',
|
||||||
|
defaultValue: 'https://javbus.sbs,https://www.javbus.com,https://www.cdnbus.cyou,https://www.javsee.cyou,https://www.busjav.cyou',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'adult.scraper.builtin_cookie',
|
||||||
|
label: '内置源年龄验证 Cookie',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'age=verified; existmag=all',
|
||||||
|
defaultValue: 'age=verified; existmag=all',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user