From 65d1ad5ae53e96a66902e6b066ac0eb4517fc1ff Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:06:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=95=AA=E5=8F=B7=E5=A4=84?= =?UTF-8?q?=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=AF=B9?= =?UTF-8?q?MetaTube=E7=9A=84=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 优化番号处理逻辑,添加对MetaTube的支持 --- internal/handler/admin_settings.go | 143 ++++ internal/handler/api_config.go | 12 + internal/handler/routes_admin.go | 2 + internal/model/api_config.go | 5 +- internal/service/adult_scraper.go | 240 +++++- internal/service/adult_scraper_config.go | 19 + internal/service/adult_scraper_parse.go | 26 +- .../service/adult_scraper_routing_test.go | 91 +++ internal/service/adult_scraper_test.go | 58 +- internal/service/api_config.go | 5 +- internal/service/api_config_connection.go | 93 ++- internal/service/image_proxy_remote_fetch.go | 26 +- internal/service/manual_scrape_providers.go | 16 +- internal/service/metatube.go | 404 ++++++++++ internal/service/metatube_test.go | 176 +++++ internal/service/metatube_types.go | 92 +++ internal/service/scraper_test.go | 52 +- internal/service/service_builder.go | 2 +- web/src/api/admin.ts | 18 + web/src/api/api_configs.ts | 2 + web/src/components/APIConfigsPanel.tsx | 32 +- web/src/pages/APIConfigsPage.tsx | 54 +- web/src/pages/AdultSettingsPanel.tsx | 703 ++++++++++++++++++ web/src/pages/SettingsPage.tsx | 10 +- web/src/pages/settingsGroupAccess.ts | 85 ++- 25 files changed, 2283 insertions(+), 83 deletions(-) create mode 100644 internal/service/adult_scraper_routing_test.go create mode 100644 internal/service/metatube.go create mode 100644 internal/service/metatube_test.go create mode 100644 internal/service/metatube_types.go create mode 100644 web/src/pages/AdultSettingsPanel.tsx diff --git a/internal/handler/admin_settings.go b/internal/handler/admin_settings.go index 9d2f229..af5f605 100644 --- a/internal/handler/admin_settings.go +++ b/internal/handler/admin_settings.go @@ -1,7 +1,11 @@ package handler import ( + "fmt" + "io" "net/http" + "strings" + "time" "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 { return func(c *gin.Context) { rows, err := svc.Repo.Log.Recent(c.Request.Context(), 200) diff --git a/internal/handler/api_config.go b/internal/handler/api_config.go index 9ebdb46..2521ce9 100644 --- a/internal/handler/api_config.go +++ b/internal/handler/api_config.go @@ -63,3 +63,15 @@ func deleteAPIConfigHandler(svc *service.Container) gin.HandlerFunc { 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}) + } +} diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index d25ea2f..3651963 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -38,6 +38,7 @@ func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.DELETE("/users/:id", deleteUserHandler(svc)) admin.GET("/settings", listSettingsHandler(svc)) admin.PUT("/settings", updateSettingHandler(svc)) + admin.POST("/adult/test-scraper", testAdultScraperHandler(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.PUT("/api-configs/:provider", updateAPIConfigHandler(svc)) admin.DELETE("/api-configs/:provider", deleteAPIConfigHandler(svc)) + admin.POST("/api-configs/:provider/test", testAPIConfigHandler(svc)) } func registerAdminSchedulerRoutes(admin *gin.RouterGroup, svc *service.Container) { diff --git a/internal/model/api_config.go b/internal/model/api_config.go index 2af6f99..fd487ee 100644 --- a/internal/model/api_config.go +++ b/internal/model/api_config.go @@ -39,6 +39,7 @@ func PredefinedProviders() []ApiProvider { {ID: "openai", Name: "OpenAI", Description: "GPT 系列模型", 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: "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}, + } } diff --git a/internal/service/adult_scraper.go b/internal/service/adult_scraper.go index 416f871..a3befec 100644 --- a/internal/service/adult_scraper.go +++ b/internal/service/adult_scraper.go @@ -12,6 +12,8 @@ import ( "time" "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/repository" ) var ( @@ -50,12 +52,20 @@ type AdultProvider struct { log *zap.Logger client *http.Client 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{ log: log, apiConfig: apiConfig, + repo: repo, + metatube: NewMetaTubeProvider(log), client: NewExternalHTTPClient(12 * time.Second), } } @@ -64,11 +74,150 @@ func (p *AdultProvider) Enabled() bool { 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) { code = normalizeAdultCode(code) if 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) if len(bases) == 0 { return nil, nil @@ -90,37 +239,74 @@ func (p *AdultProvider) Search(ctx context.Context, code string) (*Match, error) } continue } - if match != nil { - match.OriginalName = code - match.Title = FormatAdultTitle(code, match.Title) - match.NSFW = true - return match, nil - } + if match != nil { + match.OriginalName = code + match.Title = FormatAdultTitle(code, match.Title) + match.NSFW = true + return match, nil + } } return nil, lastErr } func (p *AdultProvider) resolveBases(ctx context.Context) []string { - out := append([]string{}, defaultAdultBases...) - if p.apiConfig == nil { - 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 - } + customJavDB := p.getSetting(ctx, "adult.scraper.builtin_javdb_url", "") + customJavBus := p.getSetting(ctx, "adult.scraper.builtin_javbus_url", "") + configured := []string{} - configured = append(configured, adultConfiguredBases(resolved.BaseURL)...) - configured = append(configured, adultConfiguredBases(resolved.Extra)...) + if customJavDB != "" { + 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 { out = append(configured, 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) { searchURL := base + "/search?q=" + url.QueryEscape(code) + "&f=all" body, err := p.fetchText(ctx, searchURL, base) @@ -164,7 +350,8 @@ func (p *AdultProvider) fetchText(ctx context.Context, targetURL, referer string if err != nil { return "", err } - applyAdultHeaders(req, referer) + cookie := p.resolveCookie(ctx) + applyAdultHeaders(req, referer, cookie) resp, err := p.client.Do(req) if err != nil { return "", err @@ -180,13 +367,20 @@ func (p *AdultProvider) fetchText(ctx context.Context, targetURL, referer string if err != nil { 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("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") + if cookie != "" { + req.Header.Set("Cookie", cookie) + } if referer != "" { req.Header.Set("Referer", referer) } diff --git a/internal/service/adult_scraper_config.go b/internal/service/adult_scraper_config.go index f6499c4..3cfb365 100644 --- a/internal/service/adult_scraper_config.go +++ b/internal/service/adult_scraper_config.go @@ -1,6 +1,7 @@ package service import ( + "context" "net/url" "strings" ) @@ -65,3 +66,21 @@ func dedupeStrings(values []string) []string { } 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" +} diff --git a/internal/service/adult_scraper_parse.go b/internal/service/adult_scraper_parse.go index 25f1ff2..1b38d49 100644 --- a/internal/service/adult_scraper_parse.go +++ b/internal/service/adult_scraper_parse.go @@ -9,21 +9,26 @@ import ( ) 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) + rawTitle := firstAdultTitle(body, code) + title := FormatAdultTitle(code, rawTitle) + if title == "" { + title = code + } + if title == "" { + return nil + } + match := &Match{ OriginalName: code, MediaType: "adult", NSFW: true, + Title: title, 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 m := adultJavBusCoverPattern.FindStringSubmatch(body); len(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.Rating = firstRatingInText(body) + + // 如果没有提取到任何封面且标题仅是番号本身,判定为无效或被拦截页面 + if match.PosterURL == "" && rawTitle == "" { + return nil + } return match } diff --git a/internal/service/adult_scraper_routing_test.go b/internal/service/adult_scraper_routing_test.go new file mode 100644 index 0000000..0cafa64 --- /dev/null +++ b/internal/service/adult_scraper_routing_test.go @@ -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") + } +} diff --git a/internal/service/adult_scraper_test.go b/internal/service/adult_scraper_test.go index bde64d7..4d1a119 100644 --- a/internal/service/adult_scraper_test.go +++ b/internal/service/adult_scraper_test.go @@ -135,9 +135,61 @@ func TestAdultProviderDefaultBases(t *testing.T) { if len(got) != len(want) { t.Fatalf("resolveBases len = %d, want %d: %v", len(got), len(want), got) } - for i := range want { - if got[i] != want[i] { - t.Fatalf("resolveBases[%d] = %q, want %q", i, got[i], want[i]) + for i := range want { + if got[i] != want[i] { + t.Fatalf("resolveBases[%d] = %q, want %q", i, got[i], want[i]) + } } } + +func TestParseAdultDetailHTMLRejectsAgeVerification(t *testing.T) { + verifyHTML := ` +Age Verification JavBus - JavBus +driver-verify +` + 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(`

IPX-235 测试番号

`)) + })) + 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") + } } diff --git a/internal/service/api_config.go b/internal/service/api_config.go index a2ccaab..6197855 100644 --- a/internal/service/api_config.go +++ b/internal/service/api_config.go @@ -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: "fanart", BaseURL: "https://webservice.fanart.tv/v3", Description: "Fanart.tv (artwork)", 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: "openai", BaseURL: "https://api.openai.com/v1", Description: "OpenAI-compatible (smart search)", 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: "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 { var existing model.APIConfig diff --git a/internal/service/api_config_connection.go b/internal/service/api_config_connection.go index 8b75613..117f1a2 100644 --- a/internal/service/api_config_connection.go +++ b/internal/service/api_config_connection.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "net/http" "net/url" "strings" @@ -29,7 +30,11 @@ func (s *ApiConfigService) TestConnection(ctx context.Context, provider string) return s.testDeepSeek(cfg) case "siliconflow": 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) } } @@ -169,3 +174,89 @@ func (s *ApiConfigService) testSiliconFlow(cfg *model.ApiConfig) (string, error) } 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 +} diff --git a/internal/service/image_proxy_remote_fetch.go b/internal/service/image_proxy_remote_fetch.go index 27c16ca..a7c2088 100644 --- a/internal/service/image_proxy_remote_fetch.go +++ b/internal/service/image_proxy_remote_fetch.go @@ -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("Cache-Control", "no-cache") req.Header.Set("Pragma", "no-cache") + if cookie := remoteImageCookie(host); cookie != "" { + req.Header.Set("Cookie", cookie) + } if referer := remoteImageReferer(host); 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 { h := strings.ToLower(strings.TrimSpace(host)) switch { @@ -143,10 +158,13 @@ func fetchRemoteImageWithCurl(ctx context.Context, raw, host string) ([]byte, st "--header", "Cache-Control: no-cache", "--header", "Pragma: no-cache", } - if referer := remoteImageReferer(host); referer != "" { - args = append(args, "--referer", referer) - } - args = append(args, "--", raw) + if referer := remoteImageReferer(host); referer != "" { + args = append(args, "--referer", referer) + } + 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. stderr := bytes.Buffer{} diff --git a/internal/service/manual_scrape_providers.go b/internal/service/manual_scrape_providers.go index 4e9a406..cd5603e 100644 --- a/internal/service/manual_scrape_providers.go +++ b/internal/service/manual_scrape_providers.go @@ -83,14 +83,20 @@ func manualTMDbSearchTypes(mediaType string) []string { } 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} if media != nil { candidates = append(candidates, media.Path, media.OriginalName, media.Title) } - out := make([]*Match, 0, 1) + out := make([]*Match, 0, 4) seen := map[string]struct{}{} for _, candidate := range candidates { code := normalizeAdultCode(candidate) + if code == "" { + code = strings.TrimSpace(candidate) + } if code == "" { continue } @@ -98,8 +104,12 @@ func (s *ScraperService) manualAdultMatches(ctx context.Context, media *model.Me continue } seen[code] = struct{}{} - if match := s.manualAdultMatch(ctx, code); match != nil { - out = append(out, match) + if matches, err := s.adult.SearchCandidates(ctx, code); err == nil && len(matches) > 0 { + for _, m := range matches { + if m != nil && m.Title != "" { + out = append(out, m) + } + } } } return out diff --git a/internal/service/metatube.go b/internal/service/metatube.go new file mode 100644 index 0000000..6b7df6c --- /dev/null +++ b/internal/service/metatube.go @@ -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 +} diff --git a/internal/service/metatube_test.go b/internal/service/metatube_test.go new file mode 100644 index 0000000..5dfd569 --- /dev/null +++ b/internal/service/metatube_test.go @@ -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) + } +} diff --git a/internal/service/metatube_types.go b/internal/service/metatube_types.go new file mode 100644 index 0000000..75aac1d --- /dev/null +++ b/internal/service/metatube_types.go @@ -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"` +} diff --git a/internal/service/scraper_test.go b/internal/service/scraper_test.go index f9bb5ad..87ca082 100644 --- a/internal/service/scraper_test.go +++ b/internal/service/scraper_test.go @@ -2,6 +2,8 @@ package service import ( "errors" + "net/http" + "net/http/httptest" "path/filepath" "strings" "testing" @@ -370,7 +372,53 @@ func TestApplyManualMatchSkipsTMDbEpisodeStillWhenDisabled(t *testing.T) { 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) } - 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) + 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) + } + } + +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(`

IPX-235 测试番号封面

`)) + })) + 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) } } diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index fc9fafd..d3dbbe7 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -67,7 +67,7 @@ func (b *serviceContainerBuilder) initProviderServices() { b.c.Fanart = NewFanartProvider(b.cfg, b.log) 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.cfg, b.log, b.repos, b.c.TMDb, b.c.Bangumi, b.c.TheTVDB, b.c.Fanart, diff --git a/web/src/api/admin.ts b/web/src/api/admin.ts index 6763f93..36fbf16 100644 --- a/web/src/api/admin.ts +++ b/web/src/api/admin.ts @@ -54,4 +54,22 @@ export const adminAPI = { systemUpdateCheck: () => api.post('/admin/system/update/check').then((r) => r.data), systemUpdateApply: () => api.post('/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), } diff --git a/web/src/api/api_configs.ts b/web/src/api/api_configs.ts index b909d52..d7afc05 100644 --- a/web/src/api/api_configs.ts +++ b/web/src/api/api_configs.ts @@ -27,4 +27,6 @@ export const apiConfigsAPI = { update: (provider: string, patch: APIConfigPatch) => api.put(`/admin/api-configs/${provider}`, patch).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), } diff --git a/web/src/components/APIConfigsPanel.tsx b/web/src/components/APIConfigsPanel.tsx index ca45b4b..fd9cc9c 100644 --- a/web/src/components/APIConfigsPanel.tsx +++ b/web/src/components/APIConfigsPanel.tsx @@ -181,18 +181,24 @@ function EditingRow({
{item.provider} - {!isAdult && ( - - )} +