package service import ( "context" "errors" "fmt" "io" "net/http" "net/url" "regexp" "strings" "sync" "time" "go.uber.org/zap" "github.com/truewhile/MeBox/internal/repository" ) var ( adultFC2Pattern = regexp.MustCompile(`(?i)\bFC2[-_\s]?(?:PPV[-_\s]?)?(\d{5,8})\b`) adultHEYZOPattern = regexp.MustCompile(`(?i)\bHEYZO[-_\s]?(\d{3,6})\b`) adultUncensoredPattern = regexp.MustCompile(`(?i)\b(\d{6})[-_](\d{3,5})\b`) adultStandardPattern = regexp.MustCompile(`(?i)(?:^|[^A-Z0-9])([A-Z]{2,10})[-_\s]?(\d{2,8})(?:[^A-Z0-9]|$)`) adultTitlePattern = regexp.MustCompile(`(?is)]*>(.*?)`) adultTagPattern = regexp.MustCompile(`(?is)<[^>]+>`) adultAnchorPattern = regexp.MustCompile(`(?is)]*)>(.*?)`) adultImagePattern = regexp.MustCompile(`(?is)]*)>`) adultJavBusCoverPattern = regexp.MustCompile(`(?is)class="bigImage"[^>]*href="([^"]+)"`) adultSamplePattern = regexp.MustCompile(`(?is)]+class="[^"]*\bsample-box\b[^"]*"[^>]+href="([^"]+)"`) adultAttrPattern = regexp.MustCompile(`(?is)([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*["']([^"']*)["']`) ) var adultExcludedPrefixes = map[string]struct{}{ "AC": {}, "AAC": {}, "AVC": {}, "BD": {}, "CD": {}, "DDP": {}, "DTS": {}, "FHD": {}, "HD": {}, "HEVC": {}, "HDR": {}, "MP": {}, "SD": {}, "UHD": {}, "WEB": {}, "X264": {}, "X265": {}, "H264": {}, "H265": {}, "AV1": {}, "SEASON": {}, "EPISODE": {}, "EP": {}, "SP": {}, "OVA": {}, "OAD": {}, "TMDB": {}, "TMDBID": {}, "DOUBAN": {}, "BANGUMI": {}, "BGM": {}, "TVDB": {}, "THETVDB": {}, "PART": {}, "VOL": {}, "DISC": {}, "DISK": {}, "TRACK": {}, "CHAPTER": {}, "SAMPLE": {}, "TRAILER": {}, } var defaultAdultBases = []string{ "https://javdb.com", "https://javbus.sbs", "https://www.javbus.com", "https://www.cdnbus.cyou", "https://www.javsee.cyou", "https://www.busjav.cyou", } type AdultProvider struct { log *zap.Logger client *http.Client apiConfig *APIConfigService repo *repository.Container metatube *MetaTubeProvider } 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), } } 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 } matches = p.enrichMetaTubeCandidates(ctx, mtCfg, code, matches) 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 { matches = p.enrichMetaTubeCandidates(ctx, mtCfg, code, matches) 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 } } // GetMetaTubeCandidate fetches the selected provider result instead of // re-running a search that may choose a different provider. func (p *AdultProvider) GetMetaTubeCandidate(ctx context.Context, provider, id string) (*Match, error) { if p == nil || p.metatube == nil { return nil, nil } provider = strings.TrimSpace(provider) id = strings.TrimSpace(id) if provider == "" || id == "" { return nil, nil } engine := strings.ToLower(p.getSetting(ctx, "adult.scraper.engine", "builtin")) if engine != "metatube" && engine != "auto" { return nil, nil } cfg := p.ResolveMetaTubeConfig(ctx) if cfg.ServerURL == "" { return nil, nil } return p.metatube.GetMovie(ctx, cfg, provider, id) } func (p *AdultProvider) enrichMetaTubeCandidates(ctx context.Context, cfg MetaTubeConfig, code string, matches []*Match) []*Match { var wg sync.WaitGroup for i, candidate := range matches { if candidate == nil || normalizeAdultCode(candidate.OriginalName) != code || strings.TrimSpace(candidate.DoubanID) == "" || strings.TrimSpace(candidate.TheTVDBID) == "" { continue } wg.Add(1) go func(index int, current *Match) { defer wg.Done() detailed, err := p.metatube.GetMovie(ctx, cfg, current.TheTVDBID, current.DoubanID) if err == nil && detailed != nil { matches[index] = detailed } }(i, candidate) } wg.Wait() return matches } func (p *AdultProvider) searchBuiltin(ctx context.Context, code string) (*Match, error) { bases := p.resolveBases(ctx) if len(bases) == 0 { return nil, nil } var lastErr error for _, base := range bases { base = strings.TrimRight(base, "/") var match *Match var err error if adultSourceKind(base) == "javbus" { match, err = p.scrapeJavBus(ctx, base, code) } else { match, err = p.scrapeJavDB(ctx, base, code) } if err != nil { lastErr = err if p.log != nil { p.log.Debug("adult scrape source failed", zap.String("base", base), zap.String("code", code), zap.Error(err)) } continue } if match != nil { match.OriginalName = code match.Title = FormatAdultTitle(code, match.Title) match.NSFW = true p.applyMetaTubeFaceAwareArtwork(ctx, code, match) return match, nil } } return nil, lastErr } func (p *AdultProvider) applyMetaTubeFaceAwareArtwork(ctx context.Context, code string, match *Match) { if p == nil || p.metatube == nil || match == nil || strings.TrimSpace(match.PosterURL) == "" { return } // In explicit built-in mode MetaTube remains useful as an image processor: // its primary endpoint performs the same automatic face-aware crop used by // the official Jellyfin plugin. Auto mode already tried MetaTube metadata, // so avoid repeating the same failed request during fallback. if strings.ToLower(p.getSetting(ctx, "adult.scraper.engine", "builtin")) != "builtin" { return } cfg := p.ResolveMetaTubeConfig(ctx) if cfg.ServerURL == "" || !cfg.CropCover { return } candidates, err := p.metatube.Search(ctx, cfg, code) if err != nil { return } for _, candidate := range candidates { if candidate == nil || normalizeAdultCode(candidate.OriginalName) != code { continue } if candidate.PosterURL != "" { match.PosterURL = candidate.PosterURL } if candidate.BackdropURL != "" { match.BackdropURL = candidate.BackdropURL } return } } func (p *AdultProvider) resolveBases(ctx context.Context) []string { customJavDB := p.getSetting(ctx, "adult.scraper.builtin_javdb_url", "") customJavBus := p.getSetting(ctx, "adult.scraper.builtin_javbus_url", "") configured := []string{} 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) if err != nil { return nil, err } detail := "" for _, found := range adultAnchorPattern.FindAllStringSubmatch(body, -1) { if len(found) < 3 { continue } attrs := adultAttrs(found[1]) if !strings.Contains(" "+attrs["class"]+" ", " box ") || attrs["href"] == "" { continue } if strings.Contains(strings.ToUpper(stripAdultHTML(found[2])), code) { detail = absolutizeURL(base, attrs["href"]) break } } if detail == "" { return nil, nil } body, err = p.fetchText(ctx, detail, base) if err != nil { return nil, err } return parseAdultDetailHTML(body, code, "javdb", detail), nil } func (p *AdultProvider) scrapeJavBus(ctx context.Context, base, code string) (*Match, error) { body, err := p.fetchText(ctx, base+"/"+url.PathEscape(code), base) if err != nil { return nil, err } return parseAdultDetailHTML(body, code, "javbus", base+"/"+url.PathEscape(code)), nil } func (p *AdultProvider) fetchText(ctx context.Context, targetURL, referer string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) if err != nil { return "", err } cookie := p.resolveCookie(ctx) applyAdultHeaders(req, referer, cookie) resp, err := p.client.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return "", nil } if resp.StatusCode >= 400 { return "", fmt.Errorf("adult source %s returned %d", targetURL, resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if err != nil { return "", err } 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, 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) } }