mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
192 lines
6.6 KiB
Go
192 lines
6.6 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/truewhile/MeBox/internal/model"
|
|
)
|
|
|
|
// Bangumi's public metadata endpoints work without a token. If one was saved,
|
|
// also check /v0/me: public endpoints alone may accept an expired token.
|
|
func (s *ApiConfigService) testBangumi(ctx context.Context, cfg *model.APIConfig) (string, error) {
|
|
req, err := newMetadataProbeRequest(ctx, cfg, "https://api.bgm.tv", "/v0/subjects/253", http.MethodGet, nil)
|
|
if err != nil {
|
|
return "error", err
|
|
}
|
|
if token := strings.TrimSpace(cfg.APIKey); token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
identityReq := req.Clone(ctx)
|
|
identityURL := *req.URL
|
|
identityURL.Path = strings.TrimSuffix(identityURL.Path, "/subjects/253") + "/me"
|
|
identityReq.URL = &identityURL
|
|
var identity struct {
|
|
ID int `json:"id"`
|
|
}
|
|
if result, err := testMetadataProbeJSON(identityReq, "Bangumi", &identity); err != nil {
|
|
return result, err
|
|
}
|
|
if identity.ID <= 0 {
|
|
return "error", errors.New("Bangumi returned an invalid user response")
|
|
}
|
|
}
|
|
var subject struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
if result, err := testMetadataProbeJSON(req, "Bangumi", &subject); err != nil {
|
|
return result, err
|
|
}
|
|
if subject.ID != 253 || strings.TrimSpace(subject.Name) == "" {
|
|
return "error", errors.New("Bangumi returned an invalid subject response")
|
|
}
|
|
return "success", nil
|
|
}
|
|
|
|
func (s *ApiConfigService) testTheTVDB(ctx context.Context, cfg *model.APIConfig) (string, error) {
|
|
key := strings.TrimSpace(cfg.APIKey)
|
|
if key == "" {
|
|
return "error", errors.New("TheTVDB API key is required")
|
|
}
|
|
body, err := json.Marshal(map[string]string{"apikey": key})
|
|
if err != nil {
|
|
return "error", err
|
|
}
|
|
req, err := newMetadataProbeRequest(ctx, cfg, "https://api4.thetvdb.com/v4", "/login", http.MethodPost, strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return "error", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
var response struct {
|
|
Status string `json:"status"`
|
|
Data struct {
|
|
Token string `json:"token"`
|
|
} `json:"data"`
|
|
}
|
|
if result, err := testMetadataProbeJSON(req, "TheTVDB", &response); err != nil {
|
|
return result, err
|
|
}
|
|
if response.Status != "success" || strings.TrimSpace(response.Data.Token) == "" {
|
|
return "error", errors.New("TheTVDB login did not return a valid token")
|
|
}
|
|
return "success", nil
|
|
}
|
|
|
|
func (s *ApiConfigService) testFanart(ctx context.Context, cfg *model.APIConfig) (string, error) {
|
|
key := strings.TrimSpace(cfg.APIKey)
|
|
if key == "" {
|
|
return "error", errors.New("Fanart API key is required")
|
|
}
|
|
req, err := newMetadataProbeRequest(ctx, cfg, "https://webservice.fanart.tv/v3", "/movies/550", http.MethodGet, nil)
|
|
if err != nil {
|
|
return "error", err
|
|
}
|
|
query := req.URL.Query()
|
|
query.Set("api_key", key)
|
|
req.URL.RawQuery = query.Encode()
|
|
var response struct {
|
|
TMDbID json.Number `json:"tmdb_id"`
|
|
Name string `json:"name"`
|
|
}
|
|
if result, err := testMetadataProbeJSON(req, "Fanart", &response); err != nil {
|
|
return result, err
|
|
}
|
|
if response.TMDbID.String() != "550" || strings.TrimSpace(response.Name) == "" {
|
|
return "error", errors.New("Fanart returned an invalid movie response")
|
|
}
|
|
return "success", nil
|
|
}
|
|
|
|
func (s *ApiConfigService) testDouban(ctx context.Context, cfg *model.APIConfig) (string, error) {
|
|
req, err := newMetadataProbeRequest(ctx, cfg, "https://movie.douban.com", "/j/subject_suggest", http.MethodGet, nil)
|
|
if err != nil {
|
|
return "error", err
|
|
}
|
|
query := req.URL.Query()
|
|
query.Set("q", "Inception")
|
|
req.URL.RawQuery = query.Encode()
|
|
req.Header.Set("User-Agent", userAgents[0])
|
|
req.Header.Set("Referer", "https://movie.douban.com/")
|
|
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
|
if cookie := strings.TrimSpace(cfg.APIKey); cookie != "" {
|
|
req.Header.Set("Cookie", cookie)
|
|
}
|
|
var suggestions []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
}
|
|
if result, err := testMetadataProbeJSON(req, "Douban", &suggestions); err != nil {
|
|
return result, err
|
|
}
|
|
// An empty array is a valid search result; null or an error object is not.
|
|
if suggestions == nil {
|
|
return "error", errors.New("Douban returned an invalid search response (login or verification may be required)")
|
|
}
|
|
for _, suggestion := range suggestions {
|
|
if suggestion.ID == "" || strings.TrimSpace(suggestion.Title) == "" {
|
|
return "error", errors.New("Douban returned an invalid search suggestion")
|
|
}
|
|
}
|
|
return "success", nil
|
|
}
|
|
|
|
func newMetadataProbeRequest(ctx context.Context, cfg *model.APIConfig, defaultBase, path, method string, body io.Reader) (*http.Request, error) {
|
|
base := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
|
if base == "" {
|
|
base = defaultBase
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, base+path, body)
|
|
if err != nil {
|
|
// The saved base URL may contain credentials. Never echo it to the UI.
|
|
return nil, fmt.Errorf("%s: invalid base URL", cfg.Provider)
|
|
}
|
|
req.Header.Set("User-Agent", "MeBox/0.1 (https://github.com/truewhile/MeBox)")
|
|
req.Header.Set("Accept", "application/json")
|
|
return req, nil
|
|
}
|
|
|
|
// Decode a bounded, complete JSON document rather than accepting any HTTP 200
|
|
// (reverse proxies and anti-bot/login pages often return 200 as well).
|
|
func testMetadataProbeJSON(req *http.Request, provider string, out any) (string, error) {
|
|
client := NewExternalHTTPClient(10 * time.Second)
|
|
defer client.CloseIdleConnections()
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
// url.Error includes the full request URL, including Fanart's API key.
|
|
var urlErr *url.Error
|
|
if errors.As(err, &urlErr) {
|
|
err = urlErr.Err
|
|
}
|
|
return "error", fmt.Errorf("%s connection failed: %w", provider, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
return "invalid", fmt.Errorf("%s authentication failed (HTTP 401): check configured credentials", provider)
|
|
}
|
|
if resp.StatusCode == http.StatusForbidden {
|
|
return "error", fmt.Errorf("%s access denied (HTTP 403): check credentials, permissions or anti-bot verification", provider)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "error", fmt.Errorf("%s API returned status %d", provider, resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
|
|
if err != nil {
|
|
return "error", fmt.Errorf("%s response read failed: %w", provider, err)
|
|
}
|
|
if len(body) > 1<<20 {
|
|
return "error", fmt.Errorf("%s response exceeds 1 MiB", provider)
|
|
}
|
|
if err := json.Unmarshal(body, out); err != nil {
|
|
// Do not include the response body: it may echo credentials.
|
|
return "error", fmt.Errorf("%s returned invalid JSON (login or verification page may have intercepted the request)", provider)
|
|
}
|
|
return "success", nil
|
|
}
|