mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 12:06:38 +08:00
Update deployment docs and media library UI
This commit is contained in:
@@ -72,10 +72,6 @@ func aiRecommendHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func aiStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"enabled": svc.AI.Enabled(),
|
||||
"provider": svc.Cfg.AI.Provider,
|
||||
"model": svc.Cfg.AI.Model,
|
||||
})
|
||||
c.JSON(http.StatusOK, svc.AI.Status(c.Request.Context()))
|
||||
}
|
||||
}
|
||||
|
||||
+27
-27
@@ -34,7 +34,7 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
|
||||
}
|
||||
if useProxy {
|
||||
tr.Proxy = func(r *http.Request) (*url.URL, error) {
|
||||
return http.ProxyFromEnvironment(r)
|
||||
return ProxyFromEnvironmentOrSystem(r)
|
||||
}
|
||||
}
|
||||
return &http.Client{
|
||||
@@ -47,17 +47,17 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
|
||||
// These mimic a real Chrome browser to avoid WAF/bot detection.
|
||||
func HTTPHeaderPresets() map[string]string {
|
||||
return map[string]string{
|
||||
"User-Agent": model.DefaultUserAgent,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Connection": "keep-alive",
|
||||
"User-Agent": model.DefaultUserAgent,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Cache-Control": "max-age=0",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Cache-Control": "max-age=0",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,12 +65,12 @@ func HTTPHeaderPresets() map[string]string {
|
||||
|
||||
// FlareSolverrRequest represents a request to FlareSolverr.
|
||||
type FlareSolverrRequest struct {
|
||||
Cmd string `json:"cmd"`
|
||||
URL string `json:"url"`
|
||||
Session string `json:"session,omitempty"`
|
||||
MaxTimeout int `json:"maxTimeout,omitempty"`
|
||||
Proxy *FlareSolverrProxy `json:"proxy,omitempty"`
|
||||
Cookies []FlareSolverrCookie `json:"cookies,omitempty"`
|
||||
Cmd string `json:"cmd"`
|
||||
URL string `json:"url"`
|
||||
Session string `json:"session,omitempty"`
|
||||
MaxTimeout int `json:"maxTimeout,omitempty"`
|
||||
Proxy *FlareSolverrProxy `json:"proxy,omitempty"`
|
||||
Cookies []FlareSolverrCookie `json:"cookies,omitempty"`
|
||||
}
|
||||
|
||||
// FlareSolverrProxy represents proxy config for FlareSolverr.
|
||||
@@ -90,19 +90,19 @@ type FlareSolverrCookie struct {
|
||||
|
||||
// FlareSolverrResponse represents FlareSolverr's response.
|
||||
type FlareSolverrResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Solution *FlareSolverrSolution `json:"solution,omitempty"`
|
||||
}
|
||||
|
||||
// FlareSolverrSolution contains the solved challenge result.
|
||||
type FlareSolverrSolution struct {
|
||||
URL string `json:"url"`
|
||||
Status int `json:"status"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
URL string `json:"url"`
|
||||
Status int `json:"status"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Cookies []FlareSolverrCookie `json:"cookies"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
Response string `json:"response"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
Response string `json:"response"`
|
||||
}
|
||||
|
||||
// FetchURLWithFlareSolverr uses FlareSolverr to fetch a URL,
|
||||
@@ -123,10 +123,10 @@ func FetchURLWithFlareSolverr(flareSolverrURL string, targetURL string, cookieSt
|
||||
|
||||
// Build request
|
||||
reqBody := FlareSolverrRequest{
|
||||
Cmd: "request.get",
|
||||
URL: targetURL,
|
||||
Cmd: "request.get",
|
||||
URL: targetURL,
|
||||
MaxTimeout: timeout * 1000,
|
||||
Cookies: cookies,
|
||||
Cookies: cookies,
|
||||
}
|
||||
if proxyURL != "" {
|
||||
reqBody.Proxy = &FlareSolverrProxy{URL: proxyURL}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProxyFromEnvironmentOrSystem mirrors http.ProxyFromEnvironment, then falls
|
||||
// back to the current user's OS proxy settings where supported.
|
||||
func ProxyFromEnvironmentOrSystem(req *http.Request) (*url.URL, error) {
|
||||
if proxy, err := http.ProxyFromEnvironment(req); proxy != nil || err != nil {
|
||||
return proxy, err
|
||||
}
|
||||
return systemProxyForRequest(req)
|
||||
}
|
||||
|
||||
func proxyURLFromProxyServer(proxyServer, requestScheme string) (*url.URL, error) {
|
||||
proxyServer = strings.TrimSpace(proxyServer)
|
||||
if proxyServer == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if !strings.Contains(proxyServer, "=") {
|
||||
return normalizeProxyURL(proxyServer, "http")
|
||||
}
|
||||
|
||||
entries := strings.Split(proxyServer, ";")
|
||||
values := map[string]string{}
|
||||
first := ""
|
||||
for _, entry := range entries {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(entry), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
value = strings.TrimSpace(value)
|
||||
if key == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
if first == "" {
|
||||
first = value
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
|
||||
if value := values[strings.ToLower(requestScheme)]; value != "" {
|
||||
return normalizeProxyURL(value, strings.ToLower(requestScheme))
|
||||
}
|
||||
if value := values["http"]; value != "" {
|
||||
return normalizeProxyURL(value, "http")
|
||||
}
|
||||
if value := values["https"]; value != "" {
|
||||
return normalizeProxyURL(value, "http")
|
||||
}
|
||||
if value := values["socks"]; value != "" {
|
||||
return normalizeProxyURL(value, "socks")
|
||||
}
|
||||
return normalizeProxyURL(first, "http")
|
||||
}
|
||||
|
||||
func normalizeProxyURL(raw, proxyKind string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !strings.Contains(raw, "://") {
|
||||
scheme := "http"
|
||||
if strings.EqualFold(proxyKind, "socks") {
|
||||
scheme = "socks5"
|
||||
}
|
||||
raw = scheme + "://" + raw
|
||||
}
|
||||
return url.Parse(raw)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package helper
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func systemProxyForRequest(_ *http.Request) (*url.URL, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//go:build windows
|
||||
|
||||
package helper
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
const windowsInternetSettingsKey = `Software\Microsoft\Windows\CurrentVersion\Internet Settings`
|
||||
|
||||
func systemProxyForRequest(req *http.Request) (*url.URL, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
key, err := registry.OpenKey(registry.CURRENT_USER, windowsInternetSettingsKey, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
enabled, _, err := key.GetIntegerValue("ProxyEnable")
|
||||
if err != nil || enabled == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
proxyServer, _, err := key.GetStringValue("ProxyServer")
|
||||
if err != nil || strings.TrimSpace(proxyServer) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if proxyOverride, _, err := key.GetStringValue("ProxyOverride"); err == nil {
|
||||
if windowsProxyBypass(req.URL.Hostname(), proxyOverride) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return proxyURLFromProxyServer(proxyServer, req.URL.Scheme)
|
||||
}
|
||||
|
||||
func windowsProxyBypass(host, override string) bool {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if parsed := net.ParseIP(host); parsed != nil && parsed.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, rule := range strings.Split(override, ";") {
|
||||
rule = strings.ToLower(strings.TrimSpace(rule))
|
||||
if rule == "" {
|
||||
continue
|
||||
}
|
||||
if rule == "<local>" && !strings.Contains(host, ".") {
|
||||
return true
|
||||
}
|
||||
if ok, _ := filepath.Match(rule, host); ok {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(rule, "*.") && strings.HasSuffix(host, strings.TrimPrefix(rule, "*")) {
|
||||
return true
|
||||
}
|
||||
if host == rule {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -37,6 +37,15 @@ var adultExcludedPrefixes = map[string]struct{}{
|
||||
"WEB": {}, "X264": {}, "X265": {},
|
||||
}
|
||||
|
||||
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
|
||||
@@ -69,7 +78,7 @@ func (p *AdultProvider) Search(ctx context.Context, code string) (*Match, error)
|
||||
base = strings.TrimRight(base, "/")
|
||||
var match *Match
|
||||
var err error
|
||||
if strings.Contains(base, "javbus") {
|
||||
if adultSourceKind(base) == "javbus" {
|
||||
match, err = p.scrapeJavBus(ctx, base, code)
|
||||
} else {
|
||||
match, err = p.scrapeJavDB(ctx, base, code)
|
||||
@@ -91,7 +100,7 @@ func (p *AdultProvider) Search(ctx context.Context, code string) (*Match, error)
|
||||
}
|
||||
|
||||
func (p *AdultProvider) resolveBases(ctx context.Context) []string {
|
||||
out := []string{"https://javdb.com", "https://www.javbus.com"}
|
||||
out := append([]string{}, defaultAdultBases...)
|
||||
if p.apiConfig == nil {
|
||||
return out
|
||||
}
|
||||
@@ -102,20 +111,44 @@ func (p *AdultProvider) resolveBases(ctx context.Context) []string {
|
||||
if !resolved.Enabled && (resolved.BaseURL != "" || resolved.Extra != "" || resolved.APIKey != "") {
|
||||
return nil
|
||||
}
|
||||
configured := []string{}
|
||||
if resolved.BaseURL != "" {
|
||||
out = []string{resolved.BaseURL}
|
||||
configured = append(configured, resolved.BaseURL)
|
||||
}
|
||||
if resolved.Extra != "" {
|
||||
for _, part := range strings.Split(resolved.Extra, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(part, "http://") || strings.HasPrefix(part, "https://") {
|
||||
out = append(out, part)
|
||||
configured = append(configured, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(configured) > 0 {
|
||||
out = append(configured, out...)
|
||||
}
|
||||
return dedupeStrings(out)
|
||||
}
|
||||
|
||||
func adultSourceKind(base string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(base))
|
||||
host := ""
|
||||
if err == nil {
|
||||
host = strings.ToLower(u.Hostname())
|
||||
}
|
||||
if host == "" {
|
||||
host = strings.ToLower(base)
|
||||
}
|
||||
if strings.Contains(host, "javdb") {
|
||||
return "javdb"
|
||||
}
|
||||
for _, needle := range []string{"javbus", "cdnbus", "javsee", "busjav"} {
|
||||
if strings.Contains(host, needle) {
|
||||
return "javbus"
|
||||
}
|
||||
}
|
||||
return "javdb"
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -200,6 +233,9 @@ func parseAdultDetailHTML(body, code, source, detailURL string) *Match {
|
||||
if m := adultSamplePattern.FindStringSubmatch(body); len(m) > 1 {
|
||||
match.BackdropURL = absolutizeURL(detailURL, m[1])
|
||||
}
|
||||
if dmmPoster := adultDMMPosterFromSampleURL(match.BackdropURL); dmmPoster != "" {
|
||||
match.PosterURL = dmmPoster
|
||||
}
|
||||
match.Year = firstYearInText(body)
|
||||
match.Rating = firstRatingInText(body)
|
||||
return match
|
||||
@@ -292,6 +328,21 @@ func firstAdultImage(body string, classNeedles ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func adultDMMPosterFromSampleURL(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.Host == "" || !strings.Contains(strings.ToLower(u.Host), "dmm.co.jp") {
|
||||
return ""
|
||||
}
|
||||
lowerPath := strings.ToLower(u.Path)
|
||||
for _, suffix := range []string{"jp-1.jpg", "jp.jpg"} {
|
||||
if strings.HasSuffix(lowerPath, suffix) {
|
||||
u.Path = u.Path[:len(u.Path)-len(suffix)] + "pl.jpg"
|
||||
return u.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func adultAttrs(raw string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, found := range adultAttrPattern.FindAllStringSubmatch(raw, -1) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeAdultCode(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
@@ -40,3 +43,57 @@ func TestParseAdultDetailHTML(t *testing.T) {
|
||||
t.Fatalf("year = %d, want 2024", got.Year)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdultDetailHTMLDerivesDMMPoster(t *testing.T) {
|
||||
html := `<html>
|
||||
<h3>NACR-833 测试标题</h3>
|
||||
<a class="sample-box" href="https://pics.dmm.co.jp/digital/video/h_237nacr00833/h_237nacr00833jp-1.jpg"></a>
|
||||
</html>`
|
||||
|
||||
got := parseAdultDetailHTML(html, "NACR-833", "javbus", "https://www.javbus.com/NACR-833")
|
||||
if got == nil {
|
||||
t.Fatal("parseAdultDetailHTML returned nil")
|
||||
}
|
||||
if got.PosterURL != "https://pics.dmm.co.jp/digital/video/h_237nacr00833/h_237nacr00833pl.jpg" {
|
||||
t.Fatalf("PosterURL = %q", got.PosterURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdultSourceKindRecognizesJavBusMirrors(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://javdb.com": "javdb",
|
||||
"https://javbus.sbs": "javbus",
|
||||
"https://www.javbus.com": "javbus",
|
||||
"https://www.cdnbus.cyou": "javbus",
|
||||
"https://www.javsee.cyou": "javbus",
|
||||
"https://www.busjav.cyou": "javbus",
|
||||
"www.cdnbus.cyou": "javbus",
|
||||
"https://example.invalid": "javdb",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := adultSourceKind(in); got != want {
|
||||
t.Fatalf("adultSourceKind(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdultProviderDefaultBases(t *testing.T) {
|
||||
provider := &AdultProvider{}
|
||||
got := provider.resolveBases(context.Background())
|
||||
want := []string{
|
||||
"https://javdb.com",
|
||||
"https://javbus.sbs",
|
||||
"https://www.javbus.com",
|
||||
"https://www.cdnbus.cyou",
|
||||
"https://www.javsee.cyou",
|
||||
"https://www.busjav.cyou",
|
||||
}
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+94
-20
@@ -31,21 +31,23 @@ import (
|
||||
|
||||
// AIService talks to an OpenAI-compatible chat-completions endpoint.
|
||||
type AIService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
client *http.Client
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
client *http.Client
|
||||
apiConfig *APIConfigService
|
||||
}
|
||||
|
||||
// NewAIService is the constructor.
|
||||
func NewAIService(cfg *config.Config, log *zap.Logger) *AIService {
|
||||
func NewAIService(cfg *config.Config, log *zap.Logger, apiConfig *APIConfigService) *AIService {
|
||||
timeout := time.Duration(cfg.AI.Timeout) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
return &AIService{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
client: &http.Client{Timeout: timeout},
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
apiConfig: apiConfig,
|
||||
client: NewExternalHTTPClient(timeout),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +56,76 @@ func (a *AIService) Enabled() bool {
|
||||
return a.cfg.AI.Enabled && strings.TrimSpace(a.cfg.AI.APIKey) != ""
|
||||
}
|
||||
|
||||
// EnabledFor reports whether the AI integration is configured for a request.
|
||||
func (a *AIService) EnabledFor(ctx context.Context) bool {
|
||||
return a.resolveRuntimeConfig(ctx).Enabled
|
||||
}
|
||||
|
||||
// AIStatus is returned to the UI for connection-state display.
|
||||
type AIStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// Status resolves live database-backed AI config for the UI.
|
||||
func (a *AIService) Status(ctx context.Context) AIStatus {
|
||||
cfg := a.resolveRuntimeConfig(ctx)
|
||||
return AIStatus{Enabled: cfg.Enabled, Provider: cfg.Provider, Model: cfg.Model}
|
||||
}
|
||||
|
||||
type aiRuntimeConfig struct {
|
||||
Enabled bool
|
||||
Provider string
|
||||
APIBase string
|
||||
APIKey string
|
||||
Model string
|
||||
}
|
||||
|
||||
func (a *AIService) resolveRuntimeConfig(ctx context.Context) aiRuntimeConfig {
|
||||
out := aiRuntimeConfig{
|
||||
Enabled: a.cfg.AI.Enabled && strings.TrimSpace(a.cfg.AI.APIKey) != "",
|
||||
Provider: strings.TrimSpace(a.cfg.AI.Provider),
|
||||
APIBase: strings.TrimSpace(a.cfg.AI.APIBase),
|
||||
APIKey: strings.TrimSpace(a.cfg.AI.APIKey),
|
||||
Model: strings.TrimSpace(a.cfg.AI.Model),
|
||||
}
|
||||
if out.Provider == "" {
|
||||
out.Provider = "openai"
|
||||
}
|
||||
if out.APIBase == "" {
|
||||
out.APIBase = "https://api.openai.com/v1"
|
||||
}
|
||||
if out.Model == "" {
|
||||
out.Model = "gpt-4o-mini"
|
||||
}
|
||||
|
||||
if a.apiConfig != nil {
|
||||
resolved, err := a.apiConfig.Resolve(ctx, "openai")
|
||||
if err != nil {
|
||||
if a.log != nil {
|
||||
a.log.Warn("ai: failed to resolve openai api config", zap.Error(err))
|
||||
}
|
||||
return out
|
||||
}
|
||||
if resolved.BaseURL != "" {
|
||||
out.APIBase = strings.TrimSpace(resolved.BaseURL)
|
||||
}
|
||||
if resolved.APIKey != "" {
|
||||
out.APIKey = strings.TrimSpace(resolved.APIKey)
|
||||
}
|
||||
if resolved.Enabled && out.APIKey != "" {
|
||||
out.Enabled = true
|
||||
out.Provider = "openai"
|
||||
return out
|
||||
}
|
||||
if !resolved.Enabled && (resolved.APIKey != "" || resolved.BaseURL != "" || resolved.Extra != "") {
|
||||
out.Enabled = false
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SearchIntent is the structured output the smart search endpoint returns.
|
||||
type SearchIntent struct {
|
||||
Query string `json:"query"`
|
||||
@@ -67,7 +139,8 @@ type SearchIntent struct {
|
||||
// SmartSearch turns a natural-language query into a structured intent.
|
||||
// Returns a best-effort intent on parse failure (raw query passes through).
|
||||
func (a *AIService) SmartSearch(ctx context.Context, raw string) (*SearchIntent, error) {
|
||||
if !a.Enabled() {
|
||||
runtime := a.resolveRuntimeConfig(ctx)
|
||||
if !runtime.Enabled {
|
||||
return &SearchIntent{Query: raw}, nil
|
||||
}
|
||||
const sys = "You are a media-library search assistant. Read the user's query and " +
|
||||
@@ -75,7 +148,7 @@ func (a *AIService) SmartSearch(ctx context.Context, raw string) (*SearchIntent,
|
||||
"genre (string, optional), type (movie|tv|anime|music, optional), sort " +
|
||||
"(recent|rating|random, optional), language (zh|en, optional). Respond with " +
|
||||
"JSON only, no commentary."
|
||||
out, err := a.complete(ctx, sys, raw)
|
||||
out, err := a.complete(ctx, runtime, sys, raw)
|
||||
if err != nil {
|
||||
return &SearchIntent{Query: raw}, err
|
||||
}
|
||||
@@ -95,7 +168,8 @@ func (a *AIService) SmartSearch(ctx context.Context, raw string) (*SearchIntent,
|
||||
// history. The first call is intentionally best-effort: a future iteration
|
||||
// may chain media DB lookups onto each suggestion.
|
||||
func (a *AIService) Recommend(ctx context.Context, history []string, max int) ([]string, error) {
|
||||
if !a.Enabled() || len(history) == 0 {
|
||||
runtime := a.resolveRuntimeConfig(ctx)
|
||||
if !runtime.Enabled || len(history) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if max <= 0 || max > 20 {
|
||||
@@ -104,7 +178,7 @@ func (a *AIService) Recommend(ctx context.Context, history []string, max int) ([
|
||||
sys := fmt.Sprintf("You are a film / TV recommendation assistant. Reply with %d "+
|
||||
"comma-separated titles only, no commentary, in the same language as the input.", max)
|
||||
usr := "I recently watched: " + strings.Join(history, "; ")
|
||||
out, err := a.complete(ctx, sys, usr)
|
||||
out, err := a.complete(ctx, runtime, sys, usr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -121,9 +195,9 @@ func (a *AIService) Recommend(ctx context.Context, history []string, max int) ([
|
||||
}
|
||||
|
||||
// complete is the shared helper — POST /v1/chat/completions.
|
||||
func (a *AIService) complete(ctx context.Context, system, user string) (string, error) {
|
||||
func (a *AIService) complete(ctx context.Context, runtime aiRuntimeConfig, system, user string) (string, error) {
|
||||
payload := map[string]any{
|
||||
"model": a.cfg.AI.Model,
|
||||
"model": runtime.Model,
|
||||
"temperature": 0.2,
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": system},
|
||||
@@ -131,13 +205,13 @@ func (a *AIService) complete(ctx context.Context, system, user string) (string,
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
endpoint := strings.TrimRight(a.cfg.AI.APIBase, "/") + "/chat/completions"
|
||||
endpoint := strings.TrimRight(runtime.APIBase, "/") + "/chat/completions"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+a.cfg.AI.APIKey)
|
||||
req.Header.Set("Authorization", "Bearer "+runtime.APIKey)
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -165,7 +239,6 @@ func (a *AIService) complete(ctx context.Context, system, user string) (string,
|
||||
return strings.TrimSpace(out.Choices[0].Message.Content), nil
|
||||
}
|
||||
|
||||
|
||||
// ChatTurn is one message in a multi-turn assistant transcript.
|
||||
type ChatTurn struct {
|
||||
Role string `json:"role"`
|
||||
@@ -176,7 +249,8 @@ type ChatTurn struct {
|
||||
// we return a deterministic offline reply so the assistant UI still
|
||||
// has something to render.
|
||||
func (a *AIService) Chat(ctx context.Context, history []ChatTurn) (string, error) {
|
||||
if !a.Enabled() || len(history) == 0 {
|
||||
runtime := a.resolveRuntimeConfig(ctx)
|
||||
if !runtime.Enabled || len(history) == 0 {
|
||||
return offlineReply(history), nil
|
||||
}
|
||||
// Build a chat/completions payload preserving the history order.
|
||||
@@ -191,18 +265,18 @@ func (a *AIService) Chat(ctx context.Context, history []ChatTurn) (string, error
|
||||
msgs = append(msgs, map[string]string{"role": t.Role, "content": t.Content})
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model": a.cfg.AI.Model,
|
||||
"model": runtime.Model,
|
||||
"temperature": 0.4,
|
||||
"messages": msgs,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
endpoint := strings.TrimRight(a.cfg.AI.APIBase, "/") + "/chat/completions"
|
||||
endpoint := strings.TrimRight(runtime.APIBase, "/") + "/chat/completions"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+a.cfg.AI.APIKey)
|
||||
req.Header.Set("Authorization", "Bearer "+runtime.APIKey)
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
func TestAIStatusUsesDatabaseOpenAIConfig(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.APIConfig{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &repository.Container{DB: db}
|
||||
crypto := NewCryptoService("test-secret", zap.NewNop())
|
||||
apiConfig := NewAPIConfigService(zap.NewNop(), repo, crypto)
|
||||
key := "sk-test"
|
||||
baseURL := "https://example.test/v1"
|
||||
enabled := true
|
||||
if _, err := apiConfig.Update(context.Background(), "openai", APIConfigPatch{
|
||||
APIKey: &key,
|
||||
BaseURL: &baseURL,
|
||||
Enabled: &enabled,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ai := NewAIService(&config.Config{
|
||||
AI: config.AIConfig{
|
||||
Enabled: false,
|
||||
Model: "gpt-4o-mini",
|
||||
},
|
||||
}, zap.NewNop(), apiConfig)
|
||||
|
||||
status := ai.Status(context.Background())
|
||||
if !status.Enabled {
|
||||
t.Fatalf("AI status disabled, want enabled from database config")
|
||||
}
|
||||
if status.Provider != "openai" {
|
||||
t.Fatalf("provider = %q, want openai", status.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIStatusHonorsDisabledDatabaseOpenAIConfig(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.APIConfig{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &repository.Container{DB: db}
|
||||
apiConfig := NewAPIConfigService(zap.NewNop(), repo, NewCryptoService("test-secret", zap.NewNop()))
|
||||
key := "sk-test"
|
||||
enabled := false
|
||||
if _, err := apiConfig.Update(context.Background(), "openai", APIConfigPatch{
|
||||
APIKey: &key,
|
||||
Enabled: &enabled,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ai := NewAIService(&config.Config{
|
||||
AI: config.AIConfig{
|
||||
Enabled: true,
|
||||
APIKey: "sk-file",
|
||||
Model: "gpt-4o-mini",
|
||||
},
|
||||
}, zap.NewNop(), apiConfig)
|
||||
|
||||
if ai.Status(context.Background()).Enabled {
|
||||
t.Fatalf("AI status enabled, want disabled when database config is explicitly disabled")
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ 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://www.javbus.com", 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},
|
||||
}
|
||||
for i := range defaults {
|
||||
|
||||
@@ -187,6 +187,8 @@ func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
|
||||
collectionType = "tvshows"
|
||||
case "anime":
|
||||
collectionType = "tvshows" // Emby 没有专门的 anime CollectionType
|
||||
case "variety":
|
||||
collectionType = "tvshows"
|
||||
case "music":
|
||||
collectionType = "music"
|
||||
}
|
||||
@@ -286,6 +288,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
|
||||
}
|
||||
|
||||
if p.ParentID == "" && p.SearchTerm == "" && !p.Recursive && len(p.IncludeItemTypes) == 0 {
|
||||
return e.Views(ctx)
|
||||
}
|
||||
|
||||
if season, ok, err := e.findSeasonGroup(ctx, p.ParentID); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
|
||||
@@ -100,6 +100,33 @@ func TestEmbyItemsExposeSeriesSeasonEpisodeHierarchy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyRootItemsExposeLibraries(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
for _, lib := range []model.Library{
|
||||
{Name: "电影", Path: `F:\downloads\电影`, Type: "movie", Enabled: true},
|
||||
{Name: "综艺", Path: `F:\downloads\综艺`, Type: "variety", Enabled: true},
|
||||
} {
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
root, err := svc.Items(t.Context(), ItemsParams{Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("root items: %v", err)
|
||||
}
|
||||
items := root["Items"].([]map[string]any)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected root items to expose libraries, got %#v", items)
|
||||
}
|
||||
if items[0]["Type"] != "CollectionFolder" || items[1]["Type"] != "CollectionFolder" {
|
||||
t.Fatalf("root should return collection folders: %#v", items)
|
||||
}
|
||||
if items[1]["CollectionType"] != "tvshows" {
|
||||
t.Fatalf("variety libraries should use tvshows collection type: %#v", items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func newTestEmbyService(t *testing.T) *EmbyService {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
|
||||
@@ -320,19 +320,27 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
|
||||
return
|
||||
}
|
||||
mediaDir := filepath.Dir(mediaPath)
|
||||
if meta.PosterURL == "" {
|
||||
meta.PosterURL = firstExistingImage(mediaDir, localPosterCandidates(mediaPath)...)
|
||||
if localPoster := firstExistingImage(mediaDir, localPosterCandidates(mediaPath)...); localPoster != "" {
|
||||
meta.PosterURL = localPoster
|
||||
} else if meta.PosterURL == "" {
|
||||
meta.PosterURL = firstAdultLooseImage(mediaDir, "poster")
|
||||
}
|
||||
if meta.BackdropURL == "" {
|
||||
dirs := []string{mediaDir, showBaseDir}
|
||||
for _, dir := range dirs {
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
if img := firstExistingImage(dir, localBackdropCandidates(mediaPath)...); img != "" {
|
||||
meta.BackdropURL = img
|
||||
break
|
||||
}
|
||||
dirs := []string{mediaDir, showBaseDir}
|
||||
for _, dir := range dirs {
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
if img := firstExistingImage(dir, localBackdropCandidates(mediaPath)...); img != "" {
|
||||
meta.BackdropURL = img
|
||||
break
|
||||
}
|
||||
if meta.BackdropURL == "" {
|
||||
meta.BackdropURL = firstAdultLooseImage(dir, "backdrop")
|
||||
}
|
||||
}
|
||||
if !isLocalPath(meta.PosterURL) {
|
||||
if dmmPoster := adultDMMPosterFromSampleURL(meta.BackdropURL); dmmPoster != "" {
|
||||
meta.PosterURL = dmmPoster
|
||||
}
|
||||
}
|
||||
if meta.PosterURL != "" || meta.BackdropURL != "" {
|
||||
@@ -468,17 +476,38 @@ func adultArtworkNameCandidates(mediaPath, kind string) []string {
|
||||
}
|
||||
compact := strings.ReplaceAll(code, "-", "")
|
||||
bases := []string{code, compact}
|
||||
bases = append(bases, adultDMMNameCandidates(code)...)
|
||||
out := make([]string, 0, len(bases)*6)
|
||||
for _, base := range bases {
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
if kind == "poster" {
|
||||
out = append(out, base, base+"-poster", base+".poster", base+"-cover", base+".cover", base+"-thumb", base+".thumb")
|
||||
out = append(out, base, base+"-poster", base+".poster", base+"-cover", base+".cover", base+"-thumb", base+".thumb", base+"pl", base+"-pl")
|
||||
} else {
|
||||
out = append(out, base+"-fanart", base+".fanart", base+"-backdrop", base+".backdrop", base+"-background", base+"-landscape")
|
||||
out = append(out, base+"-fanart", base+".fanart", base+"-backdrop", base+".backdrop", base+"-background", base+"-landscape", base+"jp", base+"jp-1")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func adultDMMNameCandidates(code string) []string {
|
||||
parts := adultStandardPattern.FindStringSubmatch(code)
|
||||
if len(parts) < 3 {
|
||||
return nil
|
||||
}
|
||||
prefix := strings.ToLower(parts[1])
|
||||
num := strings.TrimLeft(parts[2], "0")
|
||||
if num == "" {
|
||||
num = "0"
|
||||
}
|
||||
padded := num
|
||||
for len(padded) < 5 {
|
||||
padded = "0" + padded
|
||||
}
|
||||
return []string{prefix + padded}
|
||||
}
|
||||
|
||||
func firstExistingImage(dir string, names ...string) string {
|
||||
if dir == "" {
|
||||
return ""
|
||||
@@ -494,6 +523,37 @@ func firstExistingImage(dir string, names ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstAdultLooseImage(dir, kind string) string {
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, "*"))
|
||||
preferred := []string{}
|
||||
fallback := []string{}
|
||||
for _, path := range matches {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), ext))
|
||||
if kind == "poster" {
|
||||
if strings.Contains(name, "poster") || strings.Contains(name, "cover") || strings.Contains(name, "thumb") || strings.HasSuffix(name, "pl") {
|
||||
preferred = append(preferred, path)
|
||||
}
|
||||
} else if strings.Contains(name, "fanart") || strings.Contains(name, "backdrop") || strings.Contains(name, "background") || strings.Contains(name, "landscape") || strings.Contains(name, "jp") {
|
||||
preferred = append(preferred, path)
|
||||
}
|
||||
fallback = append(fallback, path)
|
||||
}
|
||||
if len(preferred) > 0 {
|
||||
return filepath.Clean(preferred[0])
|
||||
}
|
||||
if kind == "poster" && len(fallback) == 1 {
|
||||
return filepath.Clean(fallback[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
@@ -507,6 +567,11 @@ func isHTTPURL(raw string) bool {
|
||||
return (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
||||
}
|
||||
|
||||
func isLocalPath(raw string) bool {
|
||||
raw = strings.TrimSpace(raw)
|
||||
return raw != "" && !isHTTPURL(raw)
|
||||
}
|
||||
|
||||
func firstText(values ...string) string {
|
||||
for _, value := range values {
|
||||
if text := cleanXMLText(value); text != "" {
|
||||
|
||||
@@ -253,6 +253,69 @@ func TestReadAdultNFOByCodeForStackedFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAdultMetadataPrefersLocalDMMPosterOverRemoteNFO(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "IPX-641-C")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(dir, "ipx-641-C.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<movie>
|
||||
<title>本地标题</title>
|
||||
<originaltitle>IPX-641</originaltitle>
|
||||
<thumb>https://www.javbus.com/pics/cover/remote.jpg</thumb>
|
||||
<fanart>https://pics.dmm.co.jp/digital/video/ipx00641/ipx00641jp-1.jpg</fanart>
|
||||
</movie>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
poster := filepath.Join(dir, "ipx00641pl.jpg")
|
||||
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.PosterURL != poster {
|
||||
t.Fatalf("poster_url = %q, want local %q", got.PosterURL, poster)
|
||||
}
|
||||
if got.BackdropURL != "https://pics.dmm.co.jp/digital/video/ipx00641/ipx00641jp-1.jpg" {
|
||||
t.Fatalf("backdrop should keep remote NFO fallback, got %q", got.BackdropURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAdultMetadataDerivesDMMPosterFromRemoteFanart(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "NACR-833")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(dir, "NACR-833.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<movie>
|
||||
<title>本地标题</title>
|
||||
<originaltitle>NACR-833</originaltitle>
|
||||
<thumb>https://www.javbus.com/pics/cover/an5p_b.jpg</thumb>
|
||||
<fanart>https://pics.dmm.co.jp/digital/video/h_237nacr00833/h_237nacr00833jp-1.jpg</fanart>
|
||||
</movie>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.PosterURL != "https://pics.dmm.co.jp/digital/video/h_237nacr00833/h_237nacr00833pl.jpg" {
|
||||
t.Fatalf("unexpected metadata: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAdultArtworkByCodeWithoutNFO(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
|
||||
|
||||
@@ -98,7 +98,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
|
||||
watcher := NewWatcherService(log, repos, scanner)
|
||||
nfo := NewNFOService(log, repos)
|
||||
ai := NewAIService(cfg, log)
|
||||
ai := NewAIService(cfg, log, apiConfig)
|
||||
duplicate := NewDuplicateService(log, repos, hub)
|
||||
filemanager := NewFileManagerService(cfg, log, repos)
|
||||
dlna := NewDLNAService(log)
|
||||
|
||||
@@ -215,6 +215,17 @@ func (s *SiteService) FindByID(ctx context.Context, id string) (*model.Site, err
|
||||
|
||||
// Update applies a partial patch to an existing site.
|
||||
func (s *SiteService) Update(ctx context.Context, id string, updates map[string]any) error {
|
||||
if id == "" {
|
||||
return errors.New("site id required")
|
||||
}
|
||||
if raw, ok := updates["url"].(string); ok {
|
||||
updates["url"] = strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
}
|
||||
for _, key := range []string{"api_key", "cookie", "auth_header"} {
|
||||
if raw, ok := updates[key].(string); ok && strings.TrimSpace(raw) == "" {
|
||||
delete(updates, key)
|
||||
}
|
||||
}
|
||||
return s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
@@ -264,6 +275,20 @@ func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, stri
|
||||
}).Error
|
||||
return true, "连接成功", nil
|
||||
} else {
|
||||
if site.Type == "mteam" {
|
||||
s.log.Warn("site adapter authenticate failed",
|
||||
zap.String("site", site.Name),
|
||||
zap.String("type", site.Type),
|
||||
zap.Error(authErr))
|
||||
now := time.Now()
|
||||
_ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"login_status": "fail",
|
||||
"last_error": authErr.Error(),
|
||||
"last_check_at": &now,
|
||||
}).Error
|
||||
return false, authErr.Error(), nil
|
||||
}
|
||||
s.log.Warn("site adapter authenticate failed, falling back to generic test",
|
||||
zap.String("site", site.Name),
|
||||
zap.String("type", site.Type),
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -27,11 +26,11 @@ type SiteConfig struct {
|
||||
Cookie string
|
||||
APIKey string
|
||||
AuthHeader string
|
||||
UserAgent string // 自定义 User-Agent
|
||||
Timeout time.Duration // 请求超时
|
||||
Extra map[string]string // JSON 扩展配置
|
||||
FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF)
|
||||
UseProxy bool // 通过 HTTP(S)_PROXY 环境变量出站
|
||||
UserAgent string // 自定义 User-Agent
|
||||
Timeout time.Duration // 请求超时
|
||||
Extra map[string]string // JSON 扩展配置
|
||||
FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF)
|
||||
UseProxy bool // 通过 HTTP(S)_PROXY 环境变量出站
|
||||
}
|
||||
|
||||
// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。
|
||||
@@ -855,22 +854,15 @@ func NewMTeamAdapter() *MTeamAdapter {
|
||||
}
|
||||
|
||||
func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
||||
if strings.TrimSpace(cfg.APIKey) == "" {
|
||||
return fmt.Errorf("M-Team 需要填写 API Access Token(控制台 → 实验室 → 存取令牌),不能使用 Cookie 访问开放 API")
|
||||
}
|
||||
// 与 ShukeBta/MediaStation 参考实现对齐:
|
||||
// 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0"
|
||||
// 或数值 0;兼容 M-Team v3 API 不同版本的返回。
|
||||
u := cfg.URL + "/api/torrent/search"
|
||||
payload := `{"pageNumber":1,"pageSize":1,"keyword":"test"}`
|
||||
payload := `{"pageNumber":1,"pageSize":1,"mode":"all"}`
|
||||
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload))
|
||||
// 调试开关:MEDIASTATION_DEBUG_MTEAM=1 时把请求/响应详情写入 stderr。
|
||||
if os.Getenv("MEDIASTATION_DEBUG_MTEAM") == "1" {
|
||||
preview := string(data)
|
||||
if len(preview) > 800 {
|
||||
preview = preview[:800] + "..."
|
||||
}
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"[DEBUG mteam.Authenticate] url=%s status=%d err=%v body=%s\n",
|
||||
u, status, err, preview)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("authenticate: %w", err)
|
||||
}
|
||||
@@ -891,19 +883,12 @@ func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return fmt.Errorf("parse response: %w (body=%s)", err, preview)
|
||||
}
|
||||
codeStr := ""
|
||||
switch v := resp["code"].(type) {
|
||||
case string:
|
||||
codeStr = v
|
||||
case float64:
|
||||
codeStr = strconv.Itoa(int(v))
|
||||
}
|
||||
if codeStr == "0" || codeStr == "200" {
|
||||
if mteamCodeOK(resp["code"]) {
|
||||
return nil
|
||||
}
|
||||
msg, _ := resp["message"].(string)
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("code=%s", codeStr)
|
||||
msg = fmt.Sprintf("code=%s", mteamCodeString(resp["code"]))
|
||||
}
|
||||
return fmt.Errorf("authentication failed: %s (body=%s)", msg, preview)
|
||||
}
|
||||
@@ -959,8 +944,8 @@ func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category stri
|
||||
}
|
||||
|
||||
func (a *MTeamAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
|
||||
u := cfg.URL + "/api/torrent/detail?id=" + id
|
||||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
||||
u := cfg.URL + "/api/torrent/detail?id=" + url.QueryEscape(id)
|
||||
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("detail: %w", err)
|
||||
}
|
||||
@@ -1513,7 +1498,11 @@ func doRequestJSON(ctx context.Context, client *http.Client, method, rawURL stri
|
||||
req.ContentLength = int64(len(body))
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
httpClient := client
|
||||
if cfg.UseProxy {
|
||||
httpClient = newHTTPClient(cfg, cfg.Timeout)
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -1526,6 +1515,24 @@ func doRequestJSON(ctx context.Context, client *http.Client, method, rawURL stri
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func mteamCodeOK(code any) bool {
|
||||
codeStr := mteamCodeString(code)
|
||||
return codeStr == "0" || codeStr == "200"
|
||||
}
|
||||
|
||||
func mteamCodeString(code any) string {
|
||||
switch v := code.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case float64:
|
||||
return strconv.Itoa(int(v))
|
||||
case int:
|
||||
return strconv.Itoa(v)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// parseSizeString 将带单位的字符串转换为字节数。
|
||||
func parseSizeString(value string, unit string) int64 {
|
||||
v, err := strconv.ParseFloat(value, 64)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMTeamAuthenticateRequiresAPIKey(t *testing.T) {
|
||||
adapter := NewMTeamAdapter()
|
||||
err := adapter.Authenticate(context.Background(), SiteConfig{
|
||||
URL: "https://api.m-team.cc",
|
||||
AuthType: "api_key",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "API Access Token") {
|
||||
t.Fatalf("Authenticate error = %v, want API Access Token hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTeamAuthenticateUsesOpenAPIKeyHeader(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotKey string
|
||||
var gotPayload map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotKey = r.Header.Get("x-api-key")
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := NewMTeamAdapter()
|
||||
err := adapter.Authenticate(context.Background(), SiteConfig{
|
||||
URL: server.URL,
|
||||
AuthType: "api_key",
|
||||
APIKey: "token-123",
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate returned error: %v", err)
|
||||
}
|
||||
if gotPath != "/api/torrent/search" {
|
||||
t.Fatalf("path = %q, want /api/torrent/search", gotPath)
|
||||
}
|
||||
if gotKey != "token-123" {
|
||||
t.Fatalf("x-api-key = %q, want token-123", gotKey)
|
||||
}
|
||||
if gotPayload["mode"] != "all" || gotPayload["keyword"] != nil {
|
||||
t.Fatalf("payload = %#v, want mode all without keyword probe", gotPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTeamAuthenticateReportsAPIMessage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":1,"message":"key無效","data":null}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter := NewMTeamAdapter()
|
||||
err := adapter.Authenticate(context.Background(), SiteConfig{
|
||||
URL: server.URL,
|
||||
AuthType: "api_key",
|
||||
APIKey: "bad-token",
|
||||
Timeout: 5 * time.Second,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "key無效") {
|
||||
t.Fatalf("Authenticate error = %v, want key invalid message", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 TestSiteUpdateKeepsSecretsWhenPatchIsBlank(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Site{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewSiteService(zap.NewNop(), &repository.Container{DB: db}, "")
|
||||
site := &model.Site{
|
||||
Name: "M-Team",
|
||||
Type: "mteam",
|
||||
URL: "https://api.m-team.cc",
|
||||
AuthType: "api_key",
|
||||
APIKey: "token-123",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := svc.Create(context.Background(), site); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := svc.Update(context.Background(), site.ID, map[string]any{
|
||||
"url": "https://api.m-team.cc/",
|
||||
"api_key": "",
|
||||
"cookie": "",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := svc.FindByID(context.Background(), site.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.APIKey != "token-123" {
|
||||
t.Fatalf("APIKey = %q, want original token", got.APIKey)
|
||||
}
|
||||
if got.URL != "https://api.m-team.cc" {
|
||||
t.Fatalf("URL = %q, want trimmed URL", got.URL)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user