mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
65d1ad5ae5
优化番号处理逻辑,添加对MetaTube的支持
405 lines
11 KiB
Go
405 lines
11 KiB
Go
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
|
|
}
|