Update deployment docs and media library UI

This commit is contained in:
ShukeBta
2026-05-28 17:32:55 +08:00
parent 68a5a1e3c0
commit b5f1f1954e
33 changed files with 1667 additions and 874 deletions
+1 -5
View File
@@ -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
View File
@@ -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}
+75
View File
@@ -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)
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package helper
import (
"net/http"
"net/url"
)
func systemProxyForRequest(_ *http.Request) (*url.URL, error) {
return nil, nil
}
+73
View File
@@ -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
}
+55 -4
View File
@@ -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) {
+58 -1
View File
@@ -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
View File
@@ -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
+84
View File
@@ -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")
}
}
+1 -1
View File
@@ -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 {
+6
View File
@@ -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 {
+27
View File
@@ -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{})
+79 -14
View File
@@ -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 != "" {
+63
View File
@@ -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")
+1 -1
View File
@@ -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)
+25
View File
@@ -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),
+36 -29
View File
@@ -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)
+77
View File
@@ -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)
}
}
+54
View File
@@ -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)
}
}