mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
Fix local adult metadata and watch history removal
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
adultFC2Pattern = regexp.MustCompile(`(?i)\bFC2[-_\s]?(?:PPV[-_\s]?)?(\d{5,8})\b`)
|
||||
adultHEYZOPattern = regexp.MustCompile(`(?i)\bHEYZO[-_\s]?(\d{3,6})\b`)
|
||||
adultUncensoredPattern = regexp.MustCompile(`(?i)\b(\d{6})[-_](\d{3,5})\b`)
|
||||
adultStandardPattern = regexp.MustCompile(`(?i)(?:^|[^A-Z0-9])([A-Z]{2,10})[-_\s]?(\d{2,8})(?:[^A-Z0-9]|$)`)
|
||||
adultTitlePattern = regexp.MustCompile(`(?is)<h[123][^>]*>(.*?)</h[123]>`)
|
||||
adultTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||
adultAnchorPattern = regexp.MustCompile(`(?is)<a\b([^>]*)>(.*?)</a>`)
|
||||
adultImagePattern = regexp.MustCompile(`(?is)<img\b([^>]*)>`)
|
||||
adultJavBusCoverPattern = regexp.MustCompile(`(?is)class="bigImage"[^>]*href="([^"]+)"`)
|
||||
adultSamplePattern = regexp.MustCompile(`(?is)<a[^>]+class="[^"]*\bsample-box\b[^"]*"[^>]+href="([^"]+)"`)
|
||||
adultAttrPattern = regexp.MustCompile(`(?is)([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*["']([^"']*)["']`)
|
||||
)
|
||||
|
||||
var adultExcludedPrefixes = map[string]struct{}{
|
||||
"AC": {}, "AAC": {}, "AVC": {}, "BD": {}, "CD": {}, "DDP": {}, "DTS": {},
|
||||
"FHD": {}, "HD": {}, "HEVC": {}, "HDR": {}, "MP": {}, "SD": {}, "UHD": {},
|
||||
"WEB": {}, "X264": {}, "X265": {},
|
||||
}
|
||||
|
||||
type AdultProvider struct {
|
||||
log *zap.Logger
|
||||
client *http.Client
|
||||
apiConfig *APIConfigService
|
||||
}
|
||||
|
||||
func NewAdultProvider(log *zap.Logger, apiConfig *APIConfigService) *AdultProvider {
|
||||
return &AdultProvider{
|
||||
log: log,
|
||||
apiConfig: apiConfig,
|
||||
client: NewExternalHTTPClient(12 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdultProvider) Enabled() bool {
|
||||
return p != nil
|
||||
}
|
||||
|
||||
func (p *AdultProvider) Search(ctx context.Context, code string) (*Match, error) {
|
||||
code = normalizeAdultCode(code)
|
||||
if code == "" {
|
||||
return nil, errors.New("empty adult code")
|
||||
}
|
||||
bases := p.resolveBases(ctx)
|
||||
if len(bases) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var lastErr error
|
||||
for _, base := range bases {
|
||||
base = strings.TrimRight(base, "/")
|
||||
var match *Match
|
||||
var err error
|
||||
if strings.Contains(base, "javbus") {
|
||||
match, err = p.scrapeJavBus(ctx, base, code)
|
||||
} else {
|
||||
match, err = p.scrapeJavDB(ctx, base, code)
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if p.log != nil {
|
||||
p.log.Debug("adult scrape source failed", zap.String("base", base), zap.String("code", code), zap.Error(err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if match != nil {
|
||||
match.OriginalName = code
|
||||
match.NSFW = true
|
||||
return match, nil
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (p *AdultProvider) resolveBases(ctx context.Context) []string {
|
||||
out := []string{"https://javdb.com", "https://www.javbus.com"}
|
||||
if p.apiConfig == nil {
|
||||
return out
|
||||
}
|
||||
resolved, err := p.apiConfig.Resolve(ctx, "adult")
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
if !resolved.Enabled && (resolved.BaseURL != "" || resolved.Extra != "" || resolved.APIKey != "") {
|
||||
return nil
|
||||
}
|
||||
if resolved.BaseURL != "" {
|
||||
out = []string{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)
|
||||
}
|
||||
}
|
||||
}
|
||||
return dedupeStrings(out)
|
||||
}
|
||||
|
||||
func (p *AdultProvider) scrapeJavDB(ctx context.Context, base, code string) (*Match, error) {
|
||||
searchURL := base + "/search?q=" + url.QueryEscape(code) + "&f=all"
|
||||
body, err := p.fetchText(ctx, searchURL, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail := ""
|
||||
for _, found := range adultAnchorPattern.FindAllStringSubmatch(body, -1) {
|
||||
if len(found) < 3 {
|
||||
continue
|
||||
}
|
||||
attrs := adultAttrs(found[1])
|
||||
if !strings.Contains(" "+attrs["class"]+" ", " box ") || attrs["href"] == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(stripAdultHTML(found[2])), code) {
|
||||
detail = absolutizeURL(base, attrs["href"])
|
||||
break
|
||||
}
|
||||
}
|
||||
if detail == "" {
|
||||
return nil, nil
|
||||
}
|
||||
body, err = p.fetchText(ctx, detail, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseAdultDetailHTML(body, code, "javdb", detail), nil
|
||||
}
|
||||
|
||||
func (p *AdultProvider) scrapeJavBus(ctx context.Context, base, code string) (*Match, error) {
|
||||
body, err := p.fetchText(ctx, base+"/"+url.PathEscape(code), base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseAdultDetailHTML(body, code, "javbus", base+"/"+url.PathEscape(code)), nil
|
||||
}
|
||||
|
||||
func (p *AdultProvider) fetchText(ctx context.Context, targetURL, referer string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
applyAdultHeaders(req, referer)
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return "", nil
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("adult source %s returned %d", targetURL, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func parseAdultDetailHTML(body, code, source, detailURL string) *Match {
|
||||
match := &Match{
|
||||
OriginalName: code,
|
||||
NSFW: true,
|
||||
Genres: []string{"Adult", source},
|
||||
}
|
||||
if title := firstAdultTitle(body, code); title != "" {
|
||||
match.Title = title
|
||||
}
|
||||
if match.Title == "" {
|
||||
return nil
|
||||
}
|
||||
if source == "javbus" {
|
||||
if m := adultJavBusCoverPattern.FindStringSubmatch(body); len(m) > 1 {
|
||||
match.PosterURL = absolutizeURL(detailURL, m[1])
|
||||
}
|
||||
} else if cover := firstAdultImage(body, "video-cover", "cover", "column-video-cover"); cover != "" {
|
||||
match.PosterURL = absolutizeURL(detailURL, cover)
|
||||
}
|
||||
if m := adultSamplePattern.FindStringSubmatch(body); len(m) > 1 {
|
||||
match.BackdropURL = absolutizeURL(detailURL, m[1])
|
||||
}
|
||||
match.Year = firstYearInText(body)
|
||||
match.Rating = firstRatingInText(body)
|
||||
return match
|
||||
}
|
||||
|
||||
func firstAdultTitle(body, code string) string {
|
||||
for _, found := range adultTitlePattern.FindAllStringSubmatch(body, -1) {
|
||||
if len(found) < 2 {
|
||||
continue
|
||||
}
|
||||
title := strings.TrimSpace(stripAdultHTML(found[1]))
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
title = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(title, code), strings.ToUpper(code)))
|
||||
if title != "" {
|
||||
return title
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func applyAdultHeaders(req *http.Request, referer string) {
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7")
|
||||
if referer != "" {
|
||||
req.Header.Set("Referer", referer)
|
||||
}
|
||||
}
|
||||
|
||||
func AdultCodeFromMediaPath(path string) string {
|
||||
if code := normalizeAdultCode(filepath.Base(path)); code != "" {
|
||||
return code
|
||||
}
|
||||
return normalizeAdultCode(path)
|
||||
}
|
||||
|
||||
func normalizeAdultCode(input string) string {
|
||||
input = strings.ToUpper(strings.TrimSpace(input))
|
||||
if input == "" {
|
||||
return ""
|
||||
}
|
||||
input = strings.ReplaceAll(input, "_", "-")
|
||||
if m := adultFC2Pattern.FindStringSubmatch(input); len(m) > 1 {
|
||||
return "FC2-PPV-" + m[1]
|
||||
}
|
||||
if m := adultHEYZOPattern.FindStringSubmatch(input); len(m) > 1 {
|
||||
return "HEYZO-" + m[1]
|
||||
}
|
||||
if m := adultUncensoredPattern.FindStringSubmatch(input); len(m) > 2 {
|
||||
return m[1] + "-" + m[2]
|
||||
}
|
||||
for _, m := range adultStandardPattern.FindAllStringSubmatch(input, -1) {
|
||||
if len(m) < 3 {
|
||||
continue
|
||||
}
|
||||
prefix := strings.TrimSpace(m[1])
|
||||
if _, excluded := adultExcludedPrefixes[prefix]; excluded {
|
||||
continue
|
||||
}
|
||||
return prefix + "-" + m[2]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stripAdultHTML(value string) string {
|
||||
value = adultTagPattern.ReplaceAllString(value, " ")
|
||||
return strings.Join(strings.Fields(html.UnescapeString(value)), " ")
|
||||
}
|
||||
|
||||
func firstAdultImage(body string, classNeedles ...string) string {
|
||||
for _, found := range adultImagePattern.FindAllStringSubmatch(body, -1) {
|
||||
if len(found) < 2 {
|
||||
continue
|
||||
}
|
||||
attrs := adultAttrs(found[1])
|
||||
class := strings.ToLower(attrs["class"])
|
||||
for _, needle := range classNeedles {
|
||||
if strings.Contains(class, strings.ToLower(needle)) {
|
||||
if attrs["src"] != "" {
|
||||
return attrs["src"]
|
||||
}
|
||||
if attrs["data-src"] != "" {
|
||||
return attrs["data-src"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func adultAttrs(raw string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, found := range adultAttrPattern.FindAllStringSubmatch(raw, -1) {
|
||||
if len(found) >= 3 {
|
||||
out[strings.ToLower(found[1])] = html.UnescapeString(found[2])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func absolutizeURL(base, raw string) string {
|
||||
raw = strings.TrimSpace(html.UnescapeString(raw))
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err == nil && u.IsAbs() {
|
||||
return raw
|
||||
}
|
||||
b, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return b.ResolveReference(u).String()
|
||||
}
|
||||
|
||||
func firstYearInText(body string) int {
|
||||
m := regexp.MustCompile(`(?:19|20)\d{2}[-/.]\d{1,2}[-/.]\d{1,2}`).FindString(body)
|
||||
if len(m) >= 4 {
|
||||
year, _ := strconv.Atoi(m[:4])
|
||||
return year
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstRatingInText(body string) float32 {
|
||||
m := regexp.MustCompile(`(?i)(?:score|rating|評分|评分)[^0-9]{0,20}([0-9](?:\.[0-9])?)`).FindStringSubmatch(body)
|
||||
if len(m) > 1 {
|
||||
v, _ := strconv.ParseFloat(m[1], 32)
|
||||
return float32(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func dedupeStrings(values []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(value)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeAdultCode(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"SSIS001.mp4": "SSIS-001",
|
||||
"fc2-ppv-1234567.mkv": "FC2-PPV-1234567",
|
||||
"heyzo_1234.mp4": "HEYZO-1234",
|
||||
"120118_001-carib.mp4": "120118-001",
|
||||
"movie.1080p.x264.mkv": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeAdultCode(in); got != want {
|
||||
t.Fatalf("normalizeAdultCode(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdultDetailHTML(t *testing.T) {
|
||||
html := `<html>
|
||||
<h2 class="title"><strong>SSIS-001 测试标题</strong></h2>
|
||||
<img class="video-cover" src="/covers/ssis001.jpg">
|
||||
<a class="sample-box" href="/samples/1.jpg"></a>
|
||||
<span class="score"><span class="value">4.7</span></span>
|
||||
<div>日期 2024-05-01</div>
|
||||
</html>`
|
||||
|
||||
got := parseAdultDetailHTML(html, "SSIS-001", "javdb", "https://javdb.com/v/abc")
|
||||
if got == nil {
|
||||
t.Fatal("parseAdultDetailHTML returned nil")
|
||||
}
|
||||
if got.Title != "测试标题" || got.OriginalName != "SSIS-001" || !got.NSFW {
|
||||
t.Fatalf("unexpected metadata: %+v", got)
|
||||
}
|
||||
if got.PosterURL != "https://javdb.com/covers/ssis001.jpg" || got.BackdropURL != "https://javdb.com/samples/1.jpg" {
|
||||
t.Fatalf("unexpected artwork: %+v", got)
|
||||
}
|
||||
if got.Year != 2024 {
|
||||
t.Fatalf("year = %d, want 2024", got.Year)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +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: "openai", BaseURL: "https://api.openai.com/v1", Description: "OpenAI-compatible (smart search)", Enabled: true},
|
||||
}
|
||||
for i := range defaults {
|
||||
@@ -64,16 +65,16 @@ func (s *APIConfigService) SeedDefaults(ctx context.Context) error {
|
||||
// PublicView is the safe-to-display projection of an API config row.
|
||||
// The plaintext key is never returned — only a mask.
|
||||
type PublicView struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
Extra string `json:"extra,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Description string `json:"description,omitempty"`
|
||||
HasKey bool `json:"has_key"`
|
||||
MaskedKey string `json:"masked_key,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
Extra string `json:"extra,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Description string `json:"description,omitempty"`
|
||||
HasKey bool `json:"has_key"`
|
||||
MaskedKey string `json:"masked_key,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// List returns every API config row (with masked keys).
|
||||
@@ -103,10 +104,10 @@ func (s *APIConfigService) Get(ctx context.Context, provider string) (*PublicVie
|
||||
// client. Empty struct (with no error) when the provider is unknown or
|
||||
// the API key is empty.
|
||||
type Resolved struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Extra string
|
||||
Enabled bool
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Extra string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Resolve fetches the live configuration for a provider, decrypting the
|
||||
|
||||
@@ -156,22 +156,21 @@ func (s *ApiConfigService) testTMDb(cfg *model.ApiConfig) (string, error) {
|
||||
return "error", errors.New("API key is required")
|
||||
}
|
||||
|
||||
testURL := "https://api.themoviedb.org/3/configuration?api_key=" + cfg.APIKey
|
||||
resp, err := http.Get(testURL)
|
||||
baseURL := strings.TrimRight(cfg.BaseURL, "/")
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimRight(s.cfg.Secrets.TMDbAPIProxy, "/")
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.themoviedb.org/3"
|
||||
}
|
||||
testURL := baseURL + "/configuration?api_key=" + url.QueryEscape(cfg.APIKey)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testURL, nil)
|
||||
if err != nil {
|
||||
return "error", err
|
||||
}
|
||||
client := NewExternalHTTPClient(10 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// 如果配置了代理,使用代理
|
||||
if s.cfg.Secrets.TMDbAPIProxy != "" {
|
||||
proxyURL := s.cfg.Secrets.TMDbAPIProxy + "?api_key=" + cfg.APIKey
|
||||
resp, err = http.Get(proxyURL)
|
||||
if err != nil {
|
||||
return "error", fmt.Errorf("TMDb connection failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
return "success", nil
|
||||
}
|
||||
return "error", fmt.Errorf("TMDb API returned status %d", resp.StatusCode)
|
||||
}
|
||||
return "error", fmt.Errorf("TMDb connection failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -40,7 +40,7 @@ func NewBangumiProvider(cfg *config.Config, log *zap.Logger) *BangumiProvider {
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
base: "https://api.bgm.tv",
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
client: NewExternalHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -29,7 +31,7 @@ func NewDiscoverService(log *zap.Logger, tmdb *TMDbProvider) *DiscoverService {
|
||||
return &DiscoverService{
|
||||
log: log,
|
||||
tmdb: tmdb,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
client: NewExternalHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +45,39 @@ func (d *DiscoverService) Popular(ctx context.Context) ([]Match, error) {
|
||||
return d.fetch(ctx, "/movie/popular")
|
||||
}
|
||||
|
||||
// TMDbSection returns one TMDb rail converted to the common external
|
||||
// discovery shape used by the multi-source Discover page.
|
||||
func (d *DiscoverService) TMDbSection(ctx context.Context, key string) ([]ExternalMediaResult, error) {
|
||||
path := tmdbDiscoverPath(key)
|
||||
if path == "" {
|
||||
return []ExternalMediaResult{}, nil
|
||||
}
|
||||
matches, err := d.Fetch(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mediaType := "movie"
|
||||
if strings.Contains(path, "/tv/") {
|
||||
mediaType = "tv"
|
||||
}
|
||||
out := make([]ExternalMediaResult, 0, len(matches))
|
||||
for _, item := range matches {
|
||||
out = append(out, ExternalMediaResult{
|
||||
Source: "tmdb",
|
||||
MediaType: mediaType,
|
||||
Title: item.Title,
|
||||
Overview: item.Overview,
|
||||
PosterURL: item.PosterURL,
|
||||
BackdropURL: item.BackdropURL,
|
||||
Year: item.Year,
|
||||
Rating: item.Rating,
|
||||
TMDbID: item.TMDbID,
|
||||
SubscribeKeyword: buildSubscribeKeyword(item.Title, item.Year),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fetch is the shared helper that paginates page=1 only — that's all the
|
||||
// home page needs and it keeps us under TMDb's 50 rps limit.
|
||||
func (d *DiscoverService) fetch(ctx context.Context, path string) ([]Match, error) {
|
||||
@@ -130,3 +165,161 @@ func (d *DiscoverService) Fetch(ctx context.Context, path string) ([]Match, erro
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func tmdbDiscoverPath(key string) string {
|
||||
switch key {
|
||||
case "tmdb_trending_day", "trending_day":
|
||||
return "/trending/movie/day"
|
||||
case "tmdb_trending_week", "trending_week":
|
||||
return "/trending/movie/week"
|
||||
case "tmdb_popular_movie", "popular_movie":
|
||||
return "/movie/popular"
|
||||
case "tmdb_popular_tv", "popular_tv":
|
||||
return "/tv/popular"
|
||||
case "tmdb_top_rated_movie", "top_rated_movie":
|
||||
return "/movie/top_rated"
|
||||
case "tmdb_upcoming_movie", "upcoming_movie":
|
||||
return "/movie/upcoming"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Discover returns public Douban movie/TV rails. Douban does not require a
|
||||
// formal API key here; these are the same public web endpoints the site uses.
|
||||
func (d *DoubanProvider) Discover(ctx context.Context, key string) ([]ExternalMediaResult, error) {
|
||||
doubanType := "movie"
|
||||
tag := "热门"
|
||||
switch key {
|
||||
case "douban_hot_movie":
|
||||
doubanType = "movie"
|
||||
tag = "热门"
|
||||
case "douban_top_movie":
|
||||
doubanType = "movie"
|
||||
tag = "高分"
|
||||
case "douban_hot_tv":
|
||||
doubanType = "tv"
|
||||
tag = "热门"
|
||||
default:
|
||||
return []ExternalMediaResult{}, nil
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("type", doubanType)
|
||||
q.Set("tag", tag)
|
||||
q.Set("sort", "recommend")
|
||||
q.Set("page_limit", "24")
|
||||
q.Set("page_start", "0")
|
||||
u := "https://movie.douban.com/j/search_subjects?" + q.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.setHeaders(req)
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("douban discover: %d", resp.StatusCode)
|
||||
}
|
||||
var page struct {
|
||||
Subjects []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Rate string `json:"rate"`
|
||||
Cover string `json:"cover"`
|
||||
URL string `json:"url"`
|
||||
} `json:"subjects"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ExternalMediaResult, 0, len(page.Subjects))
|
||||
mediaType := "movie"
|
||||
if doubanType == "tv" {
|
||||
mediaType = "tv"
|
||||
}
|
||||
for _, subject := range page.Subjects {
|
||||
if strings.TrimSpace(subject.Title) == "" {
|
||||
continue
|
||||
}
|
||||
rating, _ := strconv.ParseFloat(subject.Rate, 32)
|
||||
out = append(out, ExternalMediaResult{
|
||||
Source: "douban",
|
||||
MediaType: mediaType,
|
||||
Title: subject.Title,
|
||||
PosterURL: subject.Cover,
|
||||
Rating: float32(rating),
|
||||
DoubanID: subject.ID,
|
||||
SubscribeKeyword: subject.Title,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Calendar returns Bangumi's public on-air anime calendar as a recommendation
|
||||
// rail. It needs no token, but NewBangumiProvider still attaches one when set.
|
||||
func (b *BangumiProvider) Calendar(ctx context.Context) ([]ExternalMediaResult, error) {
|
||||
type subject struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NameCN string `json:"name_cn"`
|
||||
Summary string `json:"summary"`
|
||||
AirDate string `json:"air_date"`
|
||||
Images struct {
|
||||
Large string `json:"large"`
|
||||
Common string `json:"common"`
|
||||
} `json:"images"`
|
||||
Rating struct {
|
||||
Score float32 `json:"score"`
|
||||
} `json:"rating"`
|
||||
}
|
||||
type day struct {
|
||||
Items []subject `json:"items"`
|
||||
}
|
||||
var days []day
|
||||
if err := b.getJSON(ctx, b.base+"/calendar", &days); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ExternalMediaResult, 0, 24)
|
||||
seen := map[int]struct{}{}
|
||||
for _, day := range days {
|
||||
for _, item := range day.Items {
|
||||
if _, ok := seen[item.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.ID] = struct{}{}
|
||||
title := strings.TrimSpace(item.NameCN)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(item.Name)
|
||||
}
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
poster := item.Images.Large
|
||||
if poster == "" {
|
||||
poster = item.Images.Common
|
||||
}
|
||||
year := 0
|
||||
if len(item.AirDate) >= 4 {
|
||||
year, _ = strconv.Atoi(item.AirDate[:4])
|
||||
}
|
||||
out = append(out, ExternalMediaResult{
|
||||
Source: "bangumi",
|
||||
MediaType: "anime",
|
||||
Title: title,
|
||||
Overview: item.Summary,
|
||||
PosterURL: poster,
|
||||
Year: year,
|
||||
Rating: item.Rating.Score,
|
||||
BangumiID: item.ID,
|
||||
SubscribeKeyword: buildSubscribeKeyword(title, year),
|
||||
})
|
||||
if len(out) >= 24 {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// and poster URLs. A valid Douban cookie is required to avoid IP bans.
|
||||
//
|
||||
// We use the search endpoint at:
|
||||
// https://movie.douban.com/j/subject_suggest?q=...
|
||||
//
|
||||
// https://movie.douban.com/j/subject_suggest?q=...
|
||||
//
|
||||
// And the detail endpoint at:
|
||||
// https://movie.douban.com/j/subject_abstract?subject_id=...
|
||||
//
|
||||
// https://movie.douban.com/j/subject_abstract?subject_id=...
|
||||
//
|
||||
// The provider is used as a supplemental source: after TMDb matches we
|
||||
// attempt a Douban lookup to grab a localized Chinese title + overview.
|
||||
@@ -41,13 +43,15 @@ func NewDoubanProvider(cfg *config.Config, log *zap.Logger) *DoubanProvider {
|
||||
return &DoubanProvider{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
client: NewExternalHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether a Douban cookie is configured.
|
||||
// Enabled reports whether Douban lookup is available. Public movie.douban.com
|
||||
// suggest endpoints work without an API key; a cookie is optional and only
|
||||
// helps when Douban applies stricter anti-scraping rules.
|
||||
func (d *DoubanProvider) Enabled() bool {
|
||||
return strings.TrimSpace(d.cfg.Secrets.DoubanCookie) != ""
|
||||
return true
|
||||
}
|
||||
|
||||
// userAgents for anti-scraping randomization.
|
||||
@@ -64,6 +68,7 @@ type DoubanMatch struct {
|
||||
Year string `json:"year"`
|
||||
Img string `json:"img"`
|
||||
Rating float32 `json:"rating"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// Search runs a Douban subject_suggest query and returns the top match.
|
||||
@@ -92,6 +97,7 @@ func (d *DoubanProvider) Search(ctx context.Context, query string) (*DoubanMatch
|
||||
Title string `json:"title"`
|
||||
Year string `json:"year"`
|
||||
Img string `json:"img"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
var results []suggestion
|
||||
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil {
|
||||
@@ -106,6 +112,7 @@ func (d *DoubanProvider) Search(ctx context.Context, query string) (*DoubanMatch
|
||||
Title: r.Title,
|
||||
Year: r.Year,
|
||||
Img: r.Img,
|
||||
Type: r.Type,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
//
|
||||
// Settings consumed (system Setting table):
|
||||
//
|
||||
// qbittorrent.url e.g. http://127.0.0.1:8080
|
||||
// qbittorrent.username qBittorrent WebUI user
|
||||
// qbittorrent.password qBittorrent WebUI password
|
||||
// qbittorrent.savepath optional default save dir
|
||||
// qbittorrent.url e.g. http://127.0.0.1:8080
|
||||
// qbittorrent.username qBittorrent WebUI user
|
||||
// qbittorrent.password qBittorrent WebUI password
|
||||
// qbittorrent.savepath optional default save dir
|
||||
//
|
||||
// Settings can be updated at runtime via the admin UI; ReloadConfig()
|
||||
// re-reads them and re-authenticates.
|
||||
@@ -18,9 +18,11 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -30,28 +32,36 @@ import (
|
||||
|
||||
// DownloadService is the single download orchestrator.
|
||||
type DownloadService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
hub *Hub
|
||||
qb *QBitClient
|
||||
organizer *OrganizerService
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
hub *Hub
|
||||
qb *QBitClient
|
||||
organizer *OrganizerService
|
||||
site *SiteService
|
||||
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
pollOnce sync.Once
|
||||
prevStates map[string]bool // hash -> wasCompleted
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
pollOnce sync.Once
|
||||
prevStates map[string]bool // hash -> wasCompleted
|
||||
}
|
||||
|
||||
var torrentEpisodeToken = regexp.MustCompile(`(?i)e\d{1,3}`)
|
||||
|
||||
// NewDownloadService is the constructor.
|
||||
func NewDownloadService(log *zap.Logger, repo *repository.Container, hub *Hub, organizer *OrganizerService) *DownloadService {
|
||||
func NewDownloadService(log *zap.Logger, repo *repository.Container, hub *Hub, organizer *OrganizerService, site ...*SiteService) *DownloadService {
|
||||
var siteSvc *SiteService
|
||||
if len(site) > 0 {
|
||||
siteSvc = site[0]
|
||||
}
|
||||
return &DownloadService{
|
||||
log: log,
|
||||
repo: repo,
|
||||
hub: hub,
|
||||
qb: NewQBitClient(log, QBitConfig{}),
|
||||
organizer: organizer,
|
||||
log: log,
|
||||
repo: repo,
|
||||
hub: hub,
|
||||
qb: NewQBitClient(log, QBitConfig{}),
|
||||
organizer: organizer,
|
||||
site: siteSvc,
|
||||
prevStates: make(map[string]bool),
|
||||
stopCh: make(chan struct{}),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,9 +125,27 @@ func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, saveP
|
||||
if savePath == "" {
|
||||
savePath, _ = d.repo.Setting.Get(ctx, "qbittorrent.savepath")
|
||||
}
|
||||
var siteFetchErr error
|
||||
if d.site != nil {
|
||||
if data, name, err := d.site.FetchTorrentFile(ctx, urlStr); err == nil {
|
||||
if err := d.qb.AddTorrentFile(ctx, data, name, savePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.createTask(ctx, userID, urlStr, savePath)
|
||||
} else {
|
||||
siteFetchErr = err
|
||||
}
|
||||
}
|
||||
if err := d.qb.AddTorrent(ctx, urlStr, savePath); err != nil {
|
||||
if siteFetchErr != nil && !strings.Contains(siteFetchErr.Error(), "no matching PT site") {
|
||||
return nil, errors.Join(err, siteFetchErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return d.createTask(ctx, userID, urlStr, savePath)
|
||||
}
|
||||
|
||||
func (d *DownloadService) createTask(ctx context.Context, userID, urlStr, savePath string) (*model.DownloadTask, error) {
|
||||
t := &model.DownloadTask{
|
||||
UserID: userID,
|
||||
Source: "qbittorrent",
|
||||
@@ -131,6 +159,38 @@ func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, saveP
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (d *DownloadService) TorrentExistsByName(ctx context.Context, name string) bool {
|
||||
query := normalizeTorrentName(name)
|
||||
if query == "" {
|
||||
return false
|
||||
}
|
||||
live, err := d.qb.List(ctx, "")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, torrent := range live {
|
||||
current := normalizeTorrentName(torrent.Name)
|
||||
if current == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(current, query) || strings.Contains(query, current) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeTorrentName(name string) string {
|
||||
name = torrentEpisodeToken.ReplaceAllString(strings.ToLower(name), "")
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// List returns every persisted download task augmented with live data
|
||||
// from qBittorrent when available.
|
||||
func (d *DownloadService) List(ctx context.Context) ([]model.DownloadTask, []QBitTorrent, error) {
|
||||
|
||||
@@ -50,10 +50,11 @@ type Group struct {
|
||||
|
||||
// Report is the summary the React UI displays.
|
||||
type Report struct {
|
||||
TotalScanned int `json:"total_scanned"`
|
||||
GroupsFound int `json:"groups_found"`
|
||||
ItemsMarked int `json:"items_marked"`
|
||||
Groups []Group `json:"groups"`
|
||||
TotalScanned int `json:"total_scanned"`
|
||||
GroupsFound int `json:"groups_found"`
|
||||
ItemsMarked int `json:"items_marked"`
|
||||
MissingRemoved int64 `json:"missing_removed"`
|
||||
Groups []Group `json:"groups"`
|
||||
}
|
||||
|
||||
// Detect walks every media row in the given library (or all libraries
|
||||
@@ -69,7 +70,9 @@ func (d *DuplicateService) Detect(ctx context.Context, libraryID string) (*Repor
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rep := &Report{TotalScanned: len(rows)}
|
||||
rep := &Report{Groups: []Group{}}
|
||||
rows = d.removeMissingRows(ctx, rows, rep)
|
||||
rep.TotalScanned = len(rows)
|
||||
totalToHash := 0
|
||||
for i := range rows {
|
||||
if rows[i].FileHash == "" && rows[i].Path != "" {
|
||||
@@ -158,6 +161,68 @@ func (d *DuplicateService) Detect(ctx context.Context, libraryID string) (*Repor
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// Current returns duplicate groups already marked in the database. It keeps
|
||||
// the UI useful after a prior scan and avoids requiring POST on page load.
|
||||
func (d *DuplicateService) Current(ctx context.Context, libraryID string) (*Report, error) {
|
||||
var rows []model.Media
|
||||
q := d.repo.DB.WithContext(ctx).Where("is_duplicate = ? OR duplicate_of <> ''", true)
|
||||
if libraryID != "" {
|
||||
q = q.Where("library_id = ?", libraryID)
|
||||
}
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rep := &Report{TotalScanned: len(rows), Groups: []Group{}}
|
||||
byPrimary := make(map[string][]model.Media)
|
||||
for _, row := range rows {
|
||||
if row.DuplicateOf == "" {
|
||||
continue
|
||||
}
|
||||
byPrimary[row.DuplicateOf] = append(byPrimary[row.DuplicateOf], row)
|
||||
}
|
||||
for primaryID, dupes := range byPrimary {
|
||||
primary, err := d.repo.Media.FindByID(ctx, primaryID)
|
||||
if err != nil || primary == nil {
|
||||
continue
|
||||
}
|
||||
hash := primary.FileHash
|
||||
if hash == "" && len(dupes) > 0 {
|
||||
hash = dupes[0].FileHash
|
||||
}
|
||||
rep.Groups = append(rep.Groups, Group{
|
||||
Hash: hash,
|
||||
Primary: *primary,
|
||||
Duplicates: dupes,
|
||||
})
|
||||
}
|
||||
rep.GroupsFound = len(rep.Groups)
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
func (d *DuplicateService) removeMissingRows(ctx context.Context, rows []model.Media, rep *Report) []model.Media {
|
||||
kept := make([]model.Media, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.Path == "" {
|
||||
kept = append(kept, row)
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(row.Path); err == nil {
|
||||
kept = append(kept, row)
|
||||
continue
|
||||
} else if !os.IsNotExist(err) {
|
||||
kept = append(kept, row)
|
||||
continue
|
||||
}
|
||||
res := d.repo.DB.WithContext(ctx).Where("id = ?", row.ID).Delete(&model.Media{})
|
||||
if res.Error != nil {
|
||||
d.log.Warn("remove missing duplicate candidate failed", zap.String("media", row.ID), zap.Error(res.Error))
|
||||
continue
|
||||
}
|
||||
rep.MissingRemoved += res.RowsAffected
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// Unmark clears the is_duplicate flag for every row in the given library
|
||||
// (or all when libraryID is empty). Useful when the operator deletes the
|
||||
// physical duplicates manually.
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
//
|
||||
// Detects season + episode numbers from filenames. Recognised patterns:
|
||||
//
|
||||
// S01E02 / s1e2
|
||||
// 1x02 / 01x02
|
||||
// EP02 / E02
|
||||
// 第2集 / 第02集
|
||||
// S01E02 / s1e2
|
||||
// 1x02 / 01x02
|
||||
// EP02 / E02
|
||||
// 第2集 / 第02集
|
||||
//
|
||||
// For bare episode markers such as "EP02", the parser also looks at parent
|
||||
// folders like "Season 02" / "S02" / "第2季" before falling back to season 1.
|
||||
//
|
||||
// When neither a season nor an episode marker is present, returns (0, 0).
|
||||
package service
|
||||
@@ -18,10 +21,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||
patCN = regexp.MustCompile(`第\s*(\d{1,3})\s*[集话話]`)
|
||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||
patCN = regexp.MustCompile(`第\s*(\d{1,3})\s*[集话話期]`)
|
||||
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*(\d{1,2})\s*季`)
|
||||
)
|
||||
|
||||
// ParseEpisode tries to extract (season, episode) from an arbitrary filename.
|
||||
@@ -40,18 +44,47 @@ func ParseEpisode(path string) (season, episode int) {
|
||||
return
|
||||
}
|
||||
if m := patEP.FindStringSubmatch(name); len(m) >= 2 {
|
||||
season = 1
|
||||
season = seasonFromParents(path)
|
||||
if season == 0 {
|
||||
season = 1
|
||||
}
|
||||
episode = mustAtoi(m[1])
|
||||
return
|
||||
}
|
||||
if m := patCN.FindStringSubmatch(name); len(m) >= 2 {
|
||||
season = 1
|
||||
season = seasonFromParents(path)
|
||||
if season == 0 {
|
||||
season = 1
|
||||
}
|
||||
episode = mustAtoi(m[1])
|
||||
return
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func seasonFromParents(path string) int {
|
||||
dir := filepath.Dir(path)
|
||||
for i := 0; i < 4; i++ {
|
||||
base := filepath.Base(dir)
|
||||
if base == "." || base == string(filepath.Separator) {
|
||||
return 0
|
||||
}
|
||||
if m := patSeasonFolder.FindStringSubmatch(base); len(m) >= 3 {
|
||||
for _, group := range m[1:] {
|
||||
if group != "" {
|
||||
return mustAtoi(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return 0
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func mustAtoi(s string) int {
|
||||
v, _ := strconv.Atoi(s)
|
||||
return v
|
||||
|
||||
@@ -4,8 +4,8 @@ import "testing"
|
||||
|
||||
func TestParseEpisode(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
wantS, wantE int
|
||||
in string
|
||||
wantS, wantE int
|
||||
}{
|
||||
{"Breaking.Bad.S01E02.1080p.mkv", 1, 2},
|
||||
{"breaking.bad.s5e14.mkv", 5, 14},
|
||||
@@ -13,8 +13,13 @@ func TestParseEpisode(t *testing.T) {
|
||||
{"Friends 10x24 - The One Where.mkv", 10, 24},
|
||||
{"Some Anime - EP05 [1080p].mkv", 1, 5},
|
||||
{"Some Anime - E12.mkv", 1, 12},
|
||||
{`Some Show/Season 02/Some Show - EP03.mkv`, 2, 3},
|
||||
{`Some Show/S02/Some Show - E04.mkv`, 2, 4},
|
||||
{`剧集/第2季/剧集 第05集.mkv`, 2, 5},
|
||||
{"日剧 第03集.mkv", 1, 3},
|
||||
{"日剧 第12话.mkv", 1, 12},
|
||||
{"综艺 第4期下.mkv", 1, 4},
|
||||
{`综艺/Season 06/综艺 第17期.mkv`, 6, 17},
|
||||
{"Movie.2020.1080p.mkv", 0, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExternalMediaResult is a metadata candidate from an online catalog. It is
|
||||
// intentionally separate from model.Media because the item may not exist in
|
||||
// the local library yet.
|
||||
type ExternalMediaResult struct {
|
||||
Source string `json:"source"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
BackdropURL string `json:"backdrop_url,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Rating float32 `json:"rating,omitempty"`
|
||||
TMDbID int `json:"tmdb_id,omitempty"`
|
||||
BangumiID int `json:"bangumi_id,omitempty"`
|
||||
DoubanID string `json:"douban_id,omitempty"`
|
||||
SubscribeKeyword string `json:"subscribe_keyword"`
|
||||
}
|
||||
|
||||
// SearchExternalMedia fans out one normalized search intent to TMDb, Douban
|
||||
// and Bangumi. This mirrors MoviePilot's separation of "metadata discovery"
|
||||
// from later tracker searching/downloading, but keeps our Go service small.
|
||||
func SearchExternalMedia(ctx context.Context, query string, year int, mediaType string, tmdb *TMDbProvider, douban *DoubanProvider, bangumi *BangumiProvider) []ExternalMediaResult {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return []ExternalMediaResult{}
|
||||
}
|
||||
|
||||
results := make([]ExternalMediaResult, 0, 6)
|
||||
addMatch := func(source, typ string, m *Match) {
|
||||
if m == nil || strings.TrimSpace(m.Title) == "" {
|
||||
return
|
||||
}
|
||||
results = append(results, ExternalMediaResult{
|
||||
Source: source,
|
||||
MediaType: typ,
|
||||
Title: m.Title,
|
||||
Overview: m.Overview,
|
||||
PosterURL: m.PosterURL,
|
||||
BackdropURL: m.BackdropURL,
|
||||
Year: m.Year,
|
||||
Rating: m.Rating,
|
||||
TMDbID: m.TMDbID,
|
||||
BangumiID: m.BangumiID,
|
||||
SubscribeKeyword: buildSubscribeKeyword(m.Title, m.Year),
|
||||
})
|
||||
}
|
||||
|
||||
if tmdb != nil {
|
||||
if mediaType == "" || mediaType == "movie" {
|
||||
if m, err := tmdb.SearchMovie(ctx, query, year); err == nil {
|
||||
addMatch("tmdb", "movie", m)
|
||||
}
|
||||
}
|
||||
if mediaType == "" || mediaType == "tv" || mediaType == "anime" {
|
||||
if m, err := tmdb.SearchTV(ctx, query, year); err == nil {
|
||||
addMatch("tmdb", "tv", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bangumi != nil && (mediaType == "" || mediaType == "anime") {
|
||||
if m, err := bangumi.Search(ctx, query); err == nil {
|
||||
addMatch("bangumi", "anime", m)
|
||||
}
|
||||
}
|
||||
|
||||
if douban != nil {
|
||||
if m, err := douban.Search(ctx, query); err == nil && m != nil {
|
||||
yearValue, _ := strconv.Atoi(m.Year)
|
||||
typ := normalizeDoubanType(m.Type, mediaType)
|
||||
results = append(results, ExternalMediaResult{
|
||||
Source: "douban",
|
||||
MediaType: typ,
|
||||
Title: m.Title,
|
||||
PosterURL: m.Img,
|
||||
Year: yearValue,
|
||||
Rating: m.Rating,
|
||||
DoubanID: m.DoubanID,
|
||||
SubscribeKeyword: buildSubscribeKeyword(m.Title, yearValue),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeExternalMedia(results)
|
||||
}
|
||||
|
||||
func buildSubscribeKeyword(title string, year int) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if year > 0 {
|
||||
return fmt.Sprintf("%s %d", title, year)
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
func normalizeDoubanType(doubanType, fallback string) string {
|
||||
doubanType = strings.ToLower(strings.TrimSpace(doubanType))
|
||||
switch doubanType {
|
||||
case "movie":
|
||||
return "movie"
|
||||
case "tv", "tvshow", "drama":
|
||||
return "tv"
|
||||
}
|
||||
if fallback != "" {
|
||||
return fallback
|
||||
}
|
||||
return "movie"
|
||||
}
|
||||
|
||||
func dedupeExternalMedia(in []ExternalMediaResult) []ExternalMediaResult {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]ExternalMediaResult, 0, len(in))
|
||||
for _, item := range in {
|
||||
key := item.Source + ":" + strings.ToLower(item.Title)
|
||||
if item.TMDbID > 0 {
|
||||
key = fmt.Sprintf("tmdb:%d:%s", item.TMDbID, item.MediaType)
|
||||
} else if item.BangumiID > 0 {
|
||||
key = fmt.Sprintf("bangumi:%d", item.BangumiID)
|
||||
} else if item.DoubanID != "" {
|
||||
key = "douban:" + item.DoubanID
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildSubscribeKeyword(t *testing.T) {
|
||||
if got := buildSubscribeKeyword("沙丘", 2024); got != "沙丘 2024" {
|
||||
t.Fatalf("keyword = %q", got)
|
||||
}
|
||||
if got := buildSubscribeKeyword("沙丘", 0); got != "沙丘" {
|
||||
t.Fatalf("keyword without year = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeExternalMedia(t *testing.T) {
|
||||
in := []ExternalMediaResult{
|
||||
{Source: "tmdb", MediaType: "movie", TMDbID: 1, Title: "A"},
|
||||
{Source: "tmdb", MediaType: "movie", TMDbID: 1, Title: "A duplicate"},
|
||||
{Source: "douban", DoubanID: "2", Title: "B"},
|
||||
}
|
||||
got := dedupeExternalMedia(in)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func NewFanartProvider(cfg *config.Config, log *zap.Logger) *FanartProvider {
|
||||
return &FanartProvider{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
client: NewExternalHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,10 +61,10 @@ func (f *FanartProvider) MovieArtwork(ctx context.Context, tmdbID int) (*Artwork
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
type page struct {
|
||||
MoviePoster []entry `json:"movieposter"`
|
||||
MovieBackgr []entry `json:"moviebackground"`
|
||||
HDLogo []entry `json:"hdmovielogo"`
|
||||
MovieThumb []entry `json:"moviethumb"`
|
||||
MoviePoster []entry `json:"movieposter"`
|
||||
MovieBackgr []entry `json:"moviebackground"`
|
||||
HDLogo []entry `json:"hdmovielogo"`
|
||||
MovieThumb []entry `json:"moviethumb"`
|
||||
}
|
||||
u := fmt.Sprintf("https://webservice.fanart.tv/v3/movies/%d?api_key=%s",
|
||||
tmdbID, f.cfg.Secrets.FanartAPIKey)
|
||||
|
||||
@@ -20,24 +20,22 @@ import (
|
||||
|
||||
// AutoInstallFFmpeg 在启动时检测并自动安装 ffmpeg/ffprobe
|
||||
func AutoInstallFFmpeg(log *zap.Logger, cfg *config.Config) (ffprobePath, ffmpegPath string) {
|
||||
// 1. 检查配置中是否已指定路径
|
||||
if cfg.App.FFprobePath != "" {
|
||||
if _, err := os.Stat(cfg.App.FFprobePath); err == nil {
|
||||
log.Info("使用配置的 ffprobe", zap.String("path", cfg.App.FFprobePath))
|
||||
return cfg.App.FFprobePath, cfg.App.FFmpegPath
|
||||
}
|
||||
// 1. 优先使用配置 / PATH / 本机常见软件目录中的现有工具。
|
||||
if path, err := resolveLocalExecutable(cfg.App.FFprobePath, "ffprobe"); err == nil {
|
||||
ffprobePath = path
|
||||
cfg.App.FFprobePath = path
|
||||
log.Info("found local ffprobe", zap.String("path", path))
|
||||
}
|
||||
if path, err := resolveLocalExecutable(cfg.App.FFmpegPath, "ffmpeg"); err == nil {
|
||||
ffmpegPath = path
|
||||
cfg.App.FFmpegPath = path
|
||||
log.Info("found local ffmpeg", zap.String("path", path))
|
||||
}
|
||||
if ffprobePath != "" || ffmpegPath != "" {
|
||||
return ffprobePath, ffmpegPath
|
||||
}
|
||||
|
||||
// 2. 检查系统 PATH
|
||||
if path, err := exec.LookPath("ffprobe"); err == nil {
|
||||
log.Info("在 PATH 中找到 ffprobe", zap.String("path", path))
|
||||
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
return path, ffmpegPath
|
||||
}
|
||||
return path, ""
|
||||
}
|
||||
|
||||
// 3. 检查默认安装位置
|
||||
// 2. 检查默认安装位置。
|
||||
defaultDir := getDefaultInstallDir()
|
||||
ffprobeDefault := filepath.Join(defaultDir, "bin", "ffprobe.exe")
|
||||
ffmpegDefault := filepath.Join(defaultDir, "bin", "ffmpeg.exe")
|
||||
@@ -47,7 +45,7 @@ func AutoInstallFFmpeg(log *zap.Logger, cfg *config.Config) (ffprobePath, ffmpeg
|
||||
return ffprobeDefault, ffmpegDefault
|
||||
}
|
||||
|
||||
// 4. 尝试自动安装
|
||||
// 3. 尝试自动安装。
|
||||
log.Warn("未找到 ffmpeg/ffprobe,尝试自动安装...")
|
||||
installed, err := tryAutoInstall(log, defaultDir)
|
||||
if err != nil {
|
||||
|
||||
+70
-17
@@ -13,7 +13,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -48,25 +50,41 @@ func (f *FFprobeService) Probe(ctx context.Context, path string) (*ProbeResult,
|
||||
if f == nil {
|
||||
return nil, errors.New("ffprobe service nil")
|
||||
}
|
||||
bin := f.cfg.App.FFprobePath
|
||||
if bin == "" {
|
||||
bin = "ffprobe"
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
if bin, err := resolveLocalExecutable(f.cfg.App.FFprobePath, "ffprobe"); err == nil {
|
||||
f.cfg.App.FFprobePath = bin
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(probeCtx, bin,
|
||||
"-v", "error",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
path,
|
||||
)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffprobe %s: %w", path, err)
|
||||
cmd := exec.CommandContext(probeCtx, bin,
|
||||
"-v", "error",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
path,
|
||||
)
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
return parseProbeJSON(out)
|
||||
}
|
||||
if f.log != nil {
|
||||
f.log.Debug("ffprobe failed, trying ffmpeg fallback", zap.String("path", path), zap.Error(err))
|
||||
}
|
||||
}
|
||||
return parseProbeJSON(out)
|
||||
return f.probeWithFFmpeg(ctx, path)
|
||||
}
|
||||
|
||||
func (f *FFprobeService) probeWithFFmpeg(ctx context.Context, path string) (*ProbeResult, error) {
|
||||
bin, err := resolveLocalExecutable(f.cfg.App.FFmpegPath, "ffmpeg")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffprobe/ffmpeg unavailable: %w", err)
|
||||
}
|
||||
f.cfg.App.FFmpegPath = bin
|
||||
out, _ := commandOutput(ctx, 30*time.Second, bin, "-hide_banner", "-i", path)
|
||||
res := parseFFmpegProbeText(string(out))
|
||||
if res.VideoCodec == "" && res.AudioCodec == "" && res.DurationSec == 0 {
|
||||
return nil, fmt.Errorf("ffmpeg probe %s: no stream metadata parsed", path)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// rawProbe mirrors the relevant fields of `ffprobe -show_format -show_streams`.
|
||||
@@ -108,3 +126,38 @@ func parseProbeJSON(data []byte) (*ProbeResult, error) {
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
var (
|
||||
ffmpegDurationRE = regexp.MustCompile(`Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)`)
|
||||
ffmpegInputRE = regexp.MustCompile(`Input #\d+,\s*(.+?),\s*from`)
|
||||
ffmpegVideoRE = regexp.MustCompile(`Video:\s*([^,\s]+).*?(\d{2,5})x(\d{2,5})`)
|
||||
ffmpegAudioRE = regexp.MustCompile(`Audio:\s*([^,\s]+)`)
|
||||
)
|
||||
|
||||
func parseFFmpegProbeText(text string) *ProbeResult {
|
||||
res := &ProbeResult{}
|
||||
if match := ffmpegInputRE.FindStringSubmatch(text); len(match) == 2 {
|
||||
res.Container = strings.TrimSpace(match[1])
|
||||
}
|
||||
if match := ffmpegDurationRE.FindStringSubmatch(text); len(match) == 4 {
|
||||
hours, _ := strconv.Atoi(match[1])
|
||||
minutes, _ := strconv.Atoi(match[2])
|
||||
seconds, _ := strconv.ParseFloat(match[3], 64)
|
||||
res.DurationSec = hours*3600 + minutes*60 + int(seconds)
|
||||
}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if res.VideoCodec == "" {
|
||||
if match := ffmpegVideoRE.FindStringSubmatch(line); len(match) == 4 {
|
||||
res.VideoCodec = strings.TrimSpace(match[1])
|
||||
res.Width, _ = strconv.Atoi(match[2])
|
||||
res.Height, _ = strconv.Atoi(match[3])
|
||||
}
|
||||
}
|
||||
if res.AudioCodec == "" {
|
||||
if match := ffmpegAudioRE.FindStringSubmatch(line); len(match) == 2 {
|
||||
res.AudioCodec = strings.TrimSpace(match[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseFFmpegProbeText(t *testing.T) {
|
||||
text := `Input #0, matroska,webm, from 'show.mkv':
|
||||
Duration: 00:23:42.11, start: 0.000000, bitrate: 5132 kb/s
|
||||
Stream #0:0: Video: h264 (Main), yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 23.98 fps
|
||||
Stream #0:1(jpn): Audio: eac3, 48000 Hz, stereo, fltp, 128 kb/s (default)`
|
||||
|
||||
got := parseFFmpegProbeText(text)
|
||||
if got.Container != "matroska,webm" {
|
||||
t.Fatalf("container = %q", got.Container)
|
||||
}
|
||||
if got.DurationSec != 1422 {
|
||||
t.Fatalf("duration = %d", got.DurationSec)
|
||||
}
|
||||
if got.VideoCodec != "h264" || got.Width != 1920 || got.Height != 1080 {
|
||||
t.Fatalf("video = %#v", got)
|
||||
}
|
||||
if got.AudioCodec != "eac3" {
|
||||
t.Fatalf("audio = %q", got.AudioCodec)
|
||||
}
|
||||
}
|
||||
@@ -77,15 +77,9 @@ type ImageProxy struct {
|
||||
// NewImageProxy is the constructor.
|
||||
func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||
// Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull
|
||||
// from image.tmdb.org via their HTTP proxy without extra config.
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
MaxIdleConns: 32,
|
||||
MaxIdleConnsPerHost: 8,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
// from image.tmdb.org via their HTTP proxy without extra config. On
|
||||
// Windows we also honor the current user's system proxy settings.
|
||||
transport := NewExternalTransport()
|
||||
return &ImageProxy{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
@@ -112,6 +106,24 @@ func (p *ImageProxy) validateURL(raw string) (*url.URL, error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func isLocalImagePath(raw string) bool {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || isHTTPish(raw) {
|
||||
return false
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(raw))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isHTTPish(raw string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(raw), "http://") || strings.HasPrefix(strings.ToLower(raw), "https://")
|
||||
}
|
||||
|
||||
// detectContentType returns the MIME type of data using the first 512 bytes.
|
||||
func detectContentType(data []byte) string {
|
||||
if len(data) > 512 {
|
||||
@@ -132,6 +144,24 @@ func servePlaceholder(w http.ResponseWriter) {
|
||||
// Serve writes the requested image to w. Caller is expected to validate
|
||||
// the JWT before invoking it.
|
||||
func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
|
||||
if isLocalImagePath(raw) {
|
||||
path := filepath.Clean(raw)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || len(data) == 0 {
|
||||
servePlaceholder(w)
|
||||
return nil
|
||||
}
|
||||
stat, _ := os.Stat(path)
|
||||
modTime := time.Now()
|
||||
if stat != nil {
|
||||
modTime = stat.ModTime()
|
||||
}
|
||||
w.Header().Set("Content-Type", detectContentType(data))
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800")
|
||||
http.ServeContent(w, r, filepath.Base(path), modTime, bytes.NewReader(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
u, err := p.validateURL(raw)
|
||||
if err != nil {
|
||||
// Bad URL is the only request-side error; everything else falls
|
||||
@@ -171,7 +201,11 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R
|
||||
servePlaceholder(w)
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("User-Agent", "MediaStationGo/0.1")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||
if strings.Contains(host, "doubanio.com") {
|
||||
req.Header.Set("Referer", "https://movie.douban.com/")
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
)
|
||||
|
||||
func TestImageProxyServesLocalImagePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
imagePath := filepath.Join(dir, "episode-thumb.png")
|
||||
if err := os.WriteFile(imagePath, transparent1x1PNG, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(dir, "cache")}}, zap.NewNop())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/img", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
if err := proxy.Serve(t.Context(), rec, req, imagePath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got == "" {
|
||||
t.Fatal("missing content-type")
|
||||
}
|
||||
if rec.Body.Len() != len(transparent1x1PNG) {
|
||||
t.Fatalf("body length = %d, want %d", rec.Body.Len(), len(transparent1x1PNG))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LocalMetadata contains metadata read from Kodi/Jellyfin sidecar NFO files.
|
||||
type LocalMetadata struct {
|
||||
Title string
|
||||
OriginalName string
|
||||
AdultCode string
|
||||
Year int
|
||||
Overview string
|
||||
Rating float32
|
||||
PosterURL string
|
||||
BackdropURL string
|
||||
TMDbID int
|
||||
SeasonNum int
|
||||
EpisodeNum int
|
||||
Genres string
|
||||
Countries string
|
||||
Languages string
|
||||
NSFW bool
|
||||
HasNFO bool
|
||||
HasArtwork bool
|
||||
}
|
||||
|
||||
type nfoUniqueID struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type nfoFanart struct {
|
||||
Value string `xml:",chardata"`
|
||||
Thumbs []string `xml:"thumb"`
|
||||
}
|
||||
|
||||
type nfoArt struct {
|
||||
Poster string `xml:"poster"`
|
||||
Thumb string `xml:"thumb"`
|
||||
Fanart string `xml:"fanart"`
|
||||
Backdrop string `xml:"backdrop"`
|
||||
Background string `xml:"background"`
|
||||
Banner string `xml:"banner"`
|
||||
Landscape string `xml:"landscape"`
|
||||
}
|
||||
|
||||
type nfoDocument struct {
|
||||
XMLName xml.Name `xml:""`
|
||||
Title string `xml:"title"`
|
||||
ShowTitle string `xml:"showtitle"`
|
||||
OriginalTitle string `xml:"originaltitle"`
|
||||
SortTitle string `xml:"sorttitle"`
|
||||
Num string `xml:"num"`
|
||||
Year int `xml:"year"`
|
||||
Premiered string `xml:"premiered"`
|
||||
ReleaseDate string `xml:"releasedate"`
|
||||
Release string `xml:"release"`
|
||||
Aired string `xml:"aired"`
|
||||
Plot string `xml:"plot"`
|
||||
Outline string `xml:"outline"`
|
||||
OriginalPlot string `xml:"originalplot"`
|
||||
Rating float32 `xml:"rating"`
|
||||
Poster string `xml:"poster"`
|
||||
Thumbs []string `xml:"thumb"`
|
||||
Fanart nfoFanart `xml:"fanart"`
|
||||
Art nfoArt `xml:"art"`
|
||||
TMDbID int `xml:"tmdbid"`
|
||||
UniqueIDs []nfoUniqueID `xml:"uniqueid"`
|
||||
Season int `xml:"season"`
|
||||
Episode int `xml:"episode"`
|
||||
Genres []string `xml:"genre"`
|
||||
Tags []string `xml:"tag"`
|
||||
Countries []string `xml:"country"`
|
||||
Languages []string `xml:"language"`
|
||||
Studio string `xml:"studio"`
|
||||
Maker string `xml:"maker"`
|
||||
Publisher string `xml:"publisher"`
|
||||
Label string `xml:"label"`
|
||||
Directors []string `xml:"director"`
|
||||
Actors []nfoActor `xml:"actor"`
|
||||
}
|
||||
|
||||
type nfoActor struct {
|
||||
Name string `xml:"name"`
|
||||
Role string `xml:"role"`
|
||||
}
|
||||
|
||||
// ReadLocalMetadata reads sidecar NFO files for a media path. For TV/anime it
|
||||
// merges show-level tvshow.nfo with episode-level sidecar metadata.
|
||||
func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMetadata, error) {
|
||||
if seriesLike {
|
||||
return readSeriesMetadata(mediaPath, libraryRoot)
|
||||
}
|
||||
doc, path, err := findMovieNFO(mediaPath, libraryRoot)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return metadataFromArtwork(mediaPath, ""), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
meta := metadataFromDoc(doc, filepath.Dir(path), false)
|
||||
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path))
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
|
||||
mediaDir := filepath.Dir(mediaPath)
|
||||
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
||||
adultCode := AdultCodeFromMediaPath(mediaPath)
|
||||
names := []string{
|
||||
base + ".nfo",
|
||||
"movie.nfo",
|
||||
filepath.Base(mediaDir) + ".nfo",
|
||||
}
|
||||
if adultCode != "" {
|
||||
names = append([]string{adultCode + ".nfo", strings.ReplaceAll(adultCode, "-", "") + ".nfo"}, names...)
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, name := range names {
|
||||
if name == ".nfo" || name == "" {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(mediaDir, name)
|
||||
key := strings.ToLower(filepath.Clean(path))
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if doc, _, err := readNFO(path); err == nil {
|
||||
return doc, path, nil
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
if libraryRoot == "" || !samePath(mediaDir, filepath.Clean(libraryRoot)) {
|
||||
matches, _ := filepath.Glob(filepath.Join(mediaDir, "*.nfo"))
|
||||
if adultCode != "" {
|
||||
codeKey := strings.ToLower(strings.ReplaceAll(adultCode, "-", ""))
|
||||
for _, match := range matches {
|
||||
baseKey := strings.ToLower(strings.ReplaceAll(strings.TrimSuffix(filepath.Base(match), filepath.Ext(match)), "-", ""))
|
||||
if strings.Contains(baseKey, codeKey) || strings.Contains(codeKey, baseKey) {
|
||||
if doc, _, err := readNFO(match); err == nil {
|
||||
return doc, match, nil
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
if doc, _, err := readNFO(matches[0]); err == nil {
|
||||
return doc, matches[0], nil
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, "", os.ErrNotExist
|
||||
}
|
||||
|
||||
func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
|
||||
var meta *LocalMetadata
|
||||
showBaseDir := ""
|
||||
if showDoc, showPath, err := findShowNFO(mediaPath, libraryRoot); err == nil && showDoc != nil {
|
||||
showBaseDir = filepath.Dir(showPath)
|
||||
meta = metadataFromDoc(showDoc, showBaseDir, true)
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if episodeDoc, episodePath, err := readNFO(nfoPath(mediaPath)); err == nil {
|
||||
episodeMeta := metadataFromDoc(episodeDoc, filepath.Dir(episodePath), true)
|
||||
if meta == nil {
|
||||
meta = &LocalMetadata{}
|
||||
}
|
||||
mergeEpisodeMetadata(meta, episodeMeta, episodeDoc)
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = metadataFromArtwork(mediaPath, showBaseDir)
|
||||
} else {
|
||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func readNFO(path string) (*nfoDocument, string, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var doc nfoDocument
|
||||
if err := xml.Unmarshal(body, &doc); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &doc, path, nil
|
||||
}
|
||||
|
||||
func findShowNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
|
||||
dir := filepath.Dir(mediaPath)
|
||||
root := filepath.Clean(libraryRoot)
|
||||
for {
|
||||
names := []string{"tvshow.nfo", "series.nfo"}
|
||||
base := filepath.Base(dir)
|
||||
if seasonFromDir(base) > 0 {
|
||||
parentBase := filepath.Base(filepath.Dir(dir))
|
||||
names = append(names, parentBase+".nfo")
|
||||
}
|
||||
names = append(names, base+".nfo")
|
||||
for _, name := range names {
|
||||
path := filepath.Join(dir, name)
|
||||
if doc, _, err := readNFO(path); err == nil {
|
||||
return doc, path, nil
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
if samePath(dir, root) {
|
||||
return nil, "", os.ErrNotExist
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return nil, "", os.ErrNotExist
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func metadataFromDoc(doc *nfoDocument, baseDir string, seriesLike bool) *LocalMetadata {
|
||||
if doc == nil {
|
||||
return nil
|
||||
}
|
||||
meta := &LocalMetadata{
|
||||
Title: cleanXMLText(doc.Title),
|
||||
OriginalName: cleanXMLText(doc.OriginalTitle),
|
||||
AdultCode: normalizeAdultCode(doc.Num),
|
||||
Year: doc.Year,
|
||||
Overview: firstText(doc.Plot, doc.Outline, doc.OriginalPlot),
|
||||
Rating: doc.Rating,
|
||||
PosterURL: firstRemoteURL(baseDir, append([]string{doc.Poster, doc.Art.Poster, doc.Art.Thumb}, doc.Thumbs...)...),
|
||||
BackdropURL: firstRemoteURL(baseDir, append([]string{doc.Fanart.Value, doc.Art.Fanart, doc.Art.Backdrop, doc.Art.Background, doc.Art.Banner, doc.Art.Landscape}, doc.Fanart.Thumbs...)...),
|
||||
TMDbID: doc.TMDbID,
|
||||
SeasonNum: doc.Season,
|
||||
EpisodeNum: doc.Episode,
|
||||
Genres: joinNFOValues(adultAwareGenres(doc)),
|
||||
Countries: joinNFOValues(doc.Countries),
|
||||
Languages: joinNFOValues(doc.Languages),
|
||||
HasNFO: true,
|
||||
}
|
||||
if meta.AdultCode == "" {
|
||||
meta.AdultCode = normalizeAdultCode(firstText(doc.OriginalTitle, doc.SortTitle, doc.Title))
|
||||
}
|
||||
if meta.AdultCode != "" {
|
||||
meta.NSFW = true
|
||||
if meta.OriginalName == "" || strings.EqualFold(meta.OriginalName, meta.Title) {
|
||||
meta.OriginalName = meta.AdultCode
|
||||
}
|
||||
}
|
||||
if seriesLike && cleanXMLText(doc.ShowTitle) != "" {
|
||||
meta.Title = cleanXMLText(doc.ShowTitle)
|
||||
if cleanXMLText(doc.Title) != "" {
|
||||
meta.OriginalName = cleanXMLText(doc.Title)
|
||||
}
|
||||
}
|
||||
if meta.Year == 0 {
|
||||
meta.Year = yearFromDate(firstText(doc.Premiered, doc.ReleaseDate, doc.Release, doc.Aired))
|
||||
}
|
||||
if meta.TMDbID == 0 {
|
||||
meta.TMDbID = tmdbIDFromUniqueIDs(doc.UniqueIDs)
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func adultAwareGenres(doc *nfoDocument) []string {
|
||||
if doc == nil {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(doc.Genres)+len(doc.Tags)+len(doc.Actors)+4)
|
||||
values = append(values, doc.Genres...)
|
||||
values = append(values, doc.Tags...)
|
||||
for _, value := range []string{doc.Studio, doc.Maker, doc.Publisher, doc.Label} {
|
||||
if cleanXMLText(value) != "" {
|
||||
values = append(values, cleanXMLText(value))
|
||||
}
|
||||
}
|
||||
for _, value := range doc.Directors {
|
||||
if cleanXMLText(value) != "" {
|
||||
values = append(values, cleanXMLText(value))
|
||||
}
|
||||
}
|
||||
for _, actor := range doc.Actors {
|
||||
if cleanXMLText(actor.Name) != "" {
|
||||
values = append(values, cleanXMLText(actor.Name))
|
||||
} else if cleanXMLText(actor.Role) != "" {
|
||||
values = append(values, cleanXMLText(actor.Role))
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func metadataFromArtwork(mediaPath, showBaseDir string) *LocalMetadata {
|
||||
meta := &LocalMetadata{}
|
||||
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
|
||||
if meta.PosterURL == "" && meta.BackdropURL == "" {
|
||||
return nil
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
|
||||
if meta == nil {
|
||||
return
|
||||
}
|
||||
mediaDir := filepath.Dir(mediaPath)
|
||||
if meta.PosterURL == "" {
|
||||
meta.PosterURL = firstExistingImage(mediaDir, localPosterCandidates(mediaPath)...)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
if meta.PosterURL != "" || meta.BackdropURL != "" {
|
||||
meta.HasArtwork = true
|
||||
}
|
||||
}
|
||||
|
||||
func mergeEpisodeMetadata(dst, episode *LocalMetadata, doc *nfoDocument) {
|
||||
showTitle := cleanXMLText(doc.ShowTitle)
|
||||
episodeTitle := cleanXMLText(doc.Title)
|
||||
if showTitle != "" {
|
||||
dst.Title = showTitle
|
||||
if episodeTitle != "" {
|
||||
dst.OriginalName = episodeTitle
|
||||
}
|
||||
} else if dst.Title != "" && episodeTitle != "" && episodeTitle != dst.Title {
|
||||
dst.OriginalName = episodeTitle
|
||||
} else if dst.Title == "" && episodeTitle != "" {
|
||||
dst.Title = episodeTitle
|
||||
}
|
||||
if dst.OriginalName == "" && episode.OriginalName != "" {
|
||||
dst.OriginalName = episode.OriginalName
|
||||
}
|
||||
if episode.Year > 0 {
|
||||
dst.Year = episode.Year
|
||||
}
|
||||
if episode.Overview != "" {
|
||||
dst.Overview = episode.Overview
|
||||
}
|
||||
if episode.Rating > 0 {
|
||||
dst.Rating = episode.Rating
|
||||
}
|
||||
if episode.PosterURL != "" {
|
||||
dst.PosterURL = episode.PosterURL
|
||||
}
|
||||
if episode.BackdropURL != "" {
|
||||
dst.BackdropURL = episode.BackdropURL
|
||||
}
|
||||
if episode.TMDbID > 0 {
|
||||
dst.TMDbID = episode.TMDbID
|
||||
}
|
||||
if episode.SeasonNum > 0 {
|
||||
dst.SeasonNum = episode.SeasonNum
|
||||
}
|
||||
if episode.EpisodeNum > 0 {
|
||||
dst.EpisodeNum = episode.EpisodeNum
|
||||
}
|
||||
if episode.Genres != "" {
|
||||
dst.Genres = episode.Genres
|
||||
}
|
||||
if episode.Countries != "" {
|
||||
dst.Countries = episode.Countries
|
||||
}
|
||||
if episode.Languages != "" {
|
||||
dst.Languages = episode.Languages
|
||||
}
|
||||
}
|
||||
|
||||
func tmdbIDFromUniqueIDs(ids []nfoUniqueID) int {
|
||||
for _, id := range ids {
|
||||
if strings.EqualFold(strings.TrimSpace(id.Type), "tmdb") {
|
||||
v, _ := strconv.Atoi(strings.TrimSpace(id.Value))
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstRemoteURL(baseDir string, values ...string) string {
|
||||
for _, value := range values {
|
||||
value = cleanXMLText(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if isHTTPURL(value) {
|
||||
return value
|
||||
}
|
||||
if filepath.IsAbs(value) && fileExists(value) {
|
||||
return filepath.Clean(value)
|
||||
}
|
||||
if baseDir != "" {
|
||||
local := filepath.Join(baseDir, filepath.FromSlash(value))
|
||||
if fileExists(local) {
|
||||
return filepath.Clean(local)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func localPosterCandidates(mediaPath string) []string {
|
||||
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
||||
names := []string{
|
||||
base,
|
||||
base + "-thumb",
|
||||
base + ".thumb",
|
||||
base + "-cover",
|
||||
base + ".cover",
|
||||
base + "-poster",
|
||||
base + ".poster",
|
||||
"thumb",
|
||||
"poster",
|
||||
"folder",
|
||||
"cover",
|
||||
"movie",
|
||||
"show",
|
||||
}
|
||||
return append(adultArtworkNameCandidates(mediaPath, "poster"), names...)
|
||||
}
|
||||
|
||||
func localBackdropCandidates(mediaPath string) []string {
|
||||
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
||||
names := []string{
|
||||
base + "-fanart",
|
||||
base + ".fanart",
|
||||
base + "-backdrop",
|
||||
base + ".backdrop",
|
||||
base + "-background",
|
||||
"fanart",
|
||||
"backdrop",
|
||||
"background",
|
||||
"landscape",
|
||||
"banner",
|
||||
"clearart",
|
||||
}
|
||||
return append(adultArtworkNameCandidates(mediaPath, "backdrop"), names...)
|
||||
}
|
||||
|
||||
func adultArtworkNameCandidates(mediaPath, kind string) []string {
|
||||
code := AdultCodeFromMediaPath(mediaPath)
|
||||
if code == "" {
|
||||
return nil
|
||||
}
|
||||
compact := strings.ReplaceAll(code, "-", "")
|
||||
bases := []string{code, compact}
|
||||
out := make([]string, 0, len(bases)*6)
|
||||
for _, base := range bases {
|
||||
if kind == "poster" {
|
||||
out = append(out, base, base+"-poster", base+".poster", base+"-cover", base+".cover", base+"-thumb", base+".thumb")
|
||||
} else {
|
||||
out = append(out, base+"-fanart", base+".fanart", base+"-backdrop", base+".backdrop", base+"-background", base+"-landscape")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstExistingImage(dir string, names ...string) string {
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
for _, name := range names {
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
|
||||
path := filepath.Join(dir, name+ext)
|
||||
if fileExists(path) {
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func isHTTPURL(raw string) bool {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
||||
}
|
||||
|
||||
func firstText(values ...string) string {
|
||||
for _, value := range values {
|
||||
if text := cleanXMLText(value); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cleanXMLText(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func joinNFOValues(values []string) string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]struct{}{}
|
||||
for _, value := range values {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = cleanXMLText(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(part)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func yearFromDate(value string) int {
|
||||
if len(value) < 4 {
|
||||
return 0
|
||||
}
|
||||
year, _ := strconv.Atoi(value[:4])
|
||||
return year
|
||||
}
|
||||
|
||||
func samePath(a, b string) bool {
|
||||
return strings.EqualFold(filepath.Clean(a), filepath.Clean(b))
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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 TestReadLocalMovieMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "Inception.2010.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nfo := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<movie>
|
||||
<title>盗梦空间</title>
|
||||
<originaltitle>Inception</originaltitle>
|
||||
<year>2010</year>
|
||||
<plot>梦境盗窃。</plot>
|
||||
<rating>8.8</rating>
|
||||
<uniqueid type="tmdb">27205</uniqueid>
|
||||
<genre>科幻</genre>
|
||||
<genre>动作</genre>
|
||||
</movie>`
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(nfo), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, dir, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.Title != "盗梦空间" || got.OriginalName != "Inception" || got.Year != 2010 || got.TMDbID != 27205 {
|
||||
t.Fatalf("unexpected metadata: %+v", got)
|
||||
}
|
||||
if got.Genres != "科幻,动作" {
|
||||
t.Fatalf("genres = %q", got.Genres)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLocalEpisodeMetadataMergesShowAndEpisode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
showDir := filepath.Join(root, "Show")
|
||||
seasonDir := filepath.Join(showDir, "Season 02")
|
||||
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(seasonDir, "Show - EP03.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`<tvshow><title>正确剧名</title><year>2024</year><tmdbid>123</tmdbid></tvshow>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<episodedetails><title>第三集</title><season>2</season><episode>3</episode><plot>本集简介</plot></episodedetails>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.Title != "正确剧名" || got.OriginalName != "第三集" || got.SeasonNum != 2 || got.EpisodeNum != 3 {
|
||||
t.Fatalf("unexpected metadata: %+v", got)
|
||||
}
|
||||
if got.Overview != "本集简介" || got.TMDbID != 123 {
|
||||
t.Fatalf("episode/show merge failed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
showDir := filepath.Join(root, "哈哈哈哈哈")
|
||||
seasonDir := filepath.Join(showDir, "Season 06")
|
||||
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(seasonDir, "哈哈哈哈哈 - S06E17.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(showDir, "哈哈哈哈哈.nfo"), []byte(`<tvshow><title>哈哈哈哈哈</title><genre>综艺</genre></tvshow>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
episodeThumb := filepath.Join(seasonDir, "哈哈哈哈哈 - S06E17-thumb.jpg")
|
||||
if err := os.WriteFile(episodeThumb, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backdrop := filepath.Join(showDir, "fanart.jpg")
|
||||
if err := os.WriteFile(backdrop, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("metadata is nil")
|
||||
}
|
||||
if got.Title != "哈哈哈哈哈" || got.Genres != "综艺" {
|
||||
t.Fatalf("unexpected metadata: %+v", got)
|
||||
}
|
||||
if got.PosterURL != episodeThumb {
|
||||
t.Fatalf("PosterURL = %q, want %q", got.PosterURL, episodeThumb)
|
||||
}
|
||||
if got.BackdropURL != backdrop {
|
||||
t.Fatalf("BackdropURL = %q, want %q", got.BackdropURL, backdrop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLocalMetadataWithoutNFOStillFindsArtwork(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mediaPath := filepath.Join(root, "Movie.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
poster := filepath.Join(root, "Movie-poster.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("unexpected artwork metadata: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAdultLocalMetadataAndArtwork(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "SSIS-001")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(dir, "SSIS-001.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nfo := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<movie>
|
||||
<title>成人影片标题</title>
|
||||
<originaltitle>SSIS-001</originaltitle>
|
||||
<num>SSIS-001</num>
|
||||
<releasedate>2024-05-01</releasedate>
|
||||
<plot>本地简介</plot>
|
||||
<poster>SSIS-001-poster.jpg</poster>
|
||||
<fanart><thumb>SSIS-001-fanart.jpg</thumb></fanart>
|
||||
<studio>测试片商</studio>
|
||||
<genre>剧情</genre>
|
||||
<tag>中文字幕</tag>
|
||||
<actor><name>演员A</name></actor>
|
||||
</movie>`
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(nfo), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
poster := filepath.Join(dir, "SSIS-001-poster.jpg")
|
||||
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fanart := filepath.Join(dir, "SSIS-001-fanart.jpg")
|
||||
if err := os.WriteFile(fanart, []byte("jpg"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ReadLocalMetadata(mediaPath, root, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.Title != "成人影片标题" || got.AdultCode != "SSIS-001" || !got.NSFW {
|
||||
t.Fatalf("unexpected adult metadata: %+v", got)
|
||||
}
|
||||
if got.OriginalName != "SSIS-001" || got.Year != 2024 || got.Overview != "本地简介" {
|
||||
t.Fatalf("unexpected adult fields: %+v", got)
|
||||
}
|
||||
if got.PosterURL != poster || got.BackdropURL != fanart {
|
||||
t.Fatalf("artwork poster=%q fanart=%q", got.PosterURL, got.BackdropURL)
|
||||
}
|
||||
if got.Genres != "剧情,中文字幕,测试片商,演员A" {
|
||||
t.Fatalf("genres = %q", got.Genres)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAdultMovieNFOFallbackInSingleMovieFolder(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "MIDV-123")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(dir, "video.mp4")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "movie.nfo"), []byte(`<movie><title>本地番号电影</title><num>MIDV-123</num></movie>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
poster := filepath.Join(dir, "video-cover.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.Title != "本地番号电影" || got.AdultCode != "MIDV-123" || got.PosterURL != poster {
|
||||
t.Fatalf("unexpected fallback metadata: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanLibraryUsesLocalMetadata(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seasonDir := filepath.Join(root, "Show", "Season 02")
|
||||
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(seasonDir, "Show - EP03.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "Show", "tvshow.nfo"), []byte(`<tvshow><title>本地剧名</title><year>2025</year></tvshow>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<episodedetails><title>本地第三集</title><season>2</season><episode>3</episode></episodedetails>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
|
||||
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.LocalMetadata != 1 {
|
||||
t.Fatalf("LocalMetadata = %d, want 1", res.LocalMetadata)
|
||||
}
|
||||
if res.Added != 1 || res.Updated != 0 {
|
||||
t.Fatalf("scan counts added=%d updated=%d, want 1/0", res.Added, res.Updated)
|
||||
}
|
||||
var media model.Media
|
||||
if err := db.First(&media, "path = ?", mediaPath).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.Title != "本地剧名" || media.OriginalName != "本地第三集" || media.SeasonNum != 2 || media.EpisodeNum != 3 || media.ScrapeStatus != "matched" {
|
||||
t.Fatalf("unexpected scanned media: %+v", media)
|
||||
}
|
||||
|
||||
res, err = scanner.ScanLibrary(t.Context(), lib.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Added != 0 || res.Updated != 1 {
|
||||
t.Fatalf("repeat scan counts added=%d updated=%d, want 0/1", res.Added, res.Updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanLibraryParsesEpisodesForMovieTypedLibrary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seasonDir := filepath.Join(root, "哈哈哈哈哈 (2020)", "Season 06")
|
||||
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(seasonDir, "哈哈哈哈哈 - S06E17 - 第 17 集.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "综艺", Path: root, Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
|
||||
if _, err := scanner.ScanLibrary(t.Context(), lib.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var media model.Media
|
||||
if err := db.First(&media, "path = ?", mediaPath).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.SeasonNum != 6 || media.EpisodeNum != 17 {
|
||||
t.Fatalf("season/episode = %d/%d, want 6/17", media.SeasonNum, media.EpisodeNum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanLibraryPrunesMissingMedia(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mediaPath := filepath.Join(root, "Show.S02E03.mkv")
|
||||
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "Show",
|
||||
Path: filepath.Join(root, "old", "Show.S02E03.mkv"),
|
||||
SizeBytes: 123,
|
||||
ScrapeStatus: "pending",
|
||||
}
|
||||
if err := db.Create(&stale).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
|
||||
res, err := scanner.ScanLibrary(t.Context(), lib.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Removed != 1 {
|
||||
t.Fatalf("Removed = %d, want 1", res.Removed)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("media count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
@@ -65,9 +65,12 @@ func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
|
||||
|
||||
// ListMedia paginates media items inside a library.
|
||||
func (s *MediaService) ListMedia(ctx context.Context, libraryID string, page, pageSize int) ([]model.Media, int64, error) {
|
||||
if pageSize <= 0 || pageSize > 200 {
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
if pageSize > 2000 {
|
||||
pageSize = 2000
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
classifierEpisodeRE = regexp.MustCompile(`(?i)\bS\d{1,2}E\d{1,3}\b|第\s*\d+\s*[集期]|(?:^|[\s._-])E\d{1,3}(?:[\s._-]|$)`)
|
||||
classifierSeasonRE = regexp.MustCompile(`(?i)\bS\d{1,2}\b|第\s*\d+\s*季`)
|
||||
)
|
||||
|
||||
type mediaClassifyInput struct {
|
||||
MediaType string
|
||||
Title string
|
||||
Languages []string
|
||||
Countries []string
|
||||
Genres []string
|
||||
Category string
|
||||
}
|
||||
|
||||
func classifyMediaCategory(input mediaClassifyInput, categories map[string]string) string {
|
||||
mediaType := normalizeMediaType(input.MediaType, input.Title, input.Category)
|
||||
genres := normalizeTokens(input.Genres...)
|
||||
countries := normalizeTokens(input.Countries...)
|
||||
languages := normalizeTokens(input.Languages...)
|
||||
text := strings.ToLower(input.Title + " " + input.Category + " " + strings.Join(input.Genres, " "))
|
||||
|
||||
isChinese := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN") || hasAny(countries, "CN", "TW", "HK", "MO")
|
||||
isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || strings.Contains(text, "日番")
|
||||
isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP")
|
||||
isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG")
|
||||
isWestern := hasAny(countries,
|
||||
"US", "GB", "UK", "FR", "DE", "CA", "AU", "NZ", "IE", "NL", "SE", "NO", "DK",
|
||||
"FI", "ES", "IT", "PT", "AT", "CH", "BE", "RU",
|
||||
)
|
||||
|
||||
hasGenre := func(values ...string) bool {
|
||||
for _, value := range values {
|
||||
if hasAny(genres, strings.ToUpper(value)) || strings.Contains(text, strings.ToLower(value)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
switch mediaType {
|
||||
case "movie":
|
||||
if hasGenre("16", "ANIMATION", "动画", "动漫") {
|
||||
return categoryName(categories, "animation_movie", "动画电影")
|
||||
}
|
||||
if isChinese {
|
||||
return categoryName(categories, "chinese_movie", "华语电影")
|
||||
}
|
||||
if isEastAsian {
|
||||
return categoryName(categories, "jk_movie", "日韩电影")
|
||||
}
|
||||
if isWestern {
|
||||
return categoryName(categories, "euus_movie", "欧美电影")
|
||||
}
|
||||
return categoryName(categories, "foreign_movie", "外语电影")
|
||||
case "anime":
|
||||
if isChinese {
|
||||
return categoryName(categories, "cn_anime", "国漫")
|
||||
}
|
||||
return categoryName(categories, "jp_anime", "日番")
|
||||
case "variety":
|
||||
return categoryName(categories, "variety", "综艺")
|
||||
case "tv":
|
||||
if hasGenre("10764", "10767", "REALITY", "TALK", "综艺", "真人秀", "脱口秀") {
|
||||
return categoryName(categories, "variety", "综艺")
|
||||
}
|
||||
if hasGenre("99", "DOCUMENTARY", "纪录", "纪录片") {
|
||||
return categoryName(categories, "documentary", "纪录片")
|
||||
}
|
||||
if hasGenre("10762", "KIDS", "儿童") {
|
||||
return categoryName(categories, "children", "儿童")
|
||||
}
|
||||
if hasGenre("16", "ANIMATION", "动画", "动漫") {
|
||||
if isChinese {
|
||||
return categoryName(categories, "cn_anime", "国漫")
|
||||
}
|
||||
return categoryName(categories, "jp_anime", "日番")
|
||||
}
|
||||
if isChinese {
|
||||
return categoryName(categories, "domestic_tv", "国产剧")
|
||||
}
|
||||
if isEastAsian {
|
||||
return categoryName(categories, "jk_tv", "日韩剧")
|
||||
}
|
||||
if isWestern {
|
||||
return categoryName(categories, "euus_tv", "欧美剧")
|
||||
}
|
||||
return categoryName(categories, "uncategorized_tv", "未分类")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeMediaType(mediaType, title, category string) string {
|
||||
raw := strings.ToLower(strings.TrimSpace(mediaType))
|
||||
switch raw {
|
||||
case "movie", "film":
|
||||
return "movie"
|
||||
case "tv", "series", "show", "drama":
|
||||
return "tv"
|
||||
case "anime", "animation":
|
||||
return "anime"
|
||||
case "variety":
|
||||
return "variety"
|
||||
}
|
||||
text := strings.ToLower(title + " " + category)
|
||||
switch {
|
||||
case strings.Contains(text, "movie") || strings.Contains(text, "电影"):
|
||||
return "movie"
|
||||
case strings.Contains(text, "anime") || strings.Contains(text, "bangumi") || strings.Contains(text, "动漫") || strings.Contains(text, "动画"):
|
||||
return "anime"
|
||||
case strings.Contains(text, "variety") || strings.Contains(text, "综艺") || strings.Contains(text, "真人秀"):
|
||||
return "variety"
|
||||
case classifierEpisodeRE.MatchString(text) || classifierSeasonRE.MatchString(text) || strings.Contains(text, "tv") || strings.Contains(text, "剧集") || strings.Contains(text, "电视剧"):
|
||||
return "tv"
|
||||
default:
|
||||
return "movie"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTokens(values ...string) map[string]struct{} {
|
||||
out := make(map[string]struct{})
|
||||
for _, value := range values {
|
||||
for _, part := range strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == '/' || r == '|' || r == ';'
|
||||
}) {
|
||||
part = strings.ToUpper(strings.TrimSpace(part))
|
||||
if part != "" {
|
||||
out[part] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasAny(values map[string]struct{}, needles ...string) bool {
|
||||
for _, needle := range needles {
|
||||
if _, ok := values[strings.ToUpper(needle)]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func categoryName(categories map[string]string, key, fallback string) string {
|
||||
if categories != nil {
|
||||
if name := strings.TrimSpace(categories[key]); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (o *OrganizerService) categoryMap() map[string]string {
|
||||
if o == nil || o.cfg == nil || o.cfg.Organizer.Categories == nil {
|
||||
return nil
|
||||
}
|
||||
return o.cfg.Organizer.Categories
|
||||
}
|
||||
|
||||
func (o *OrganizerService) classifyMedia(ctx context.Context, m *model.Media, mediaType string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if m.Languages == "" && m.Countries == "" && m.Genres == "" && o != nil && o.repo != nil && o.repo.Media != nil {
|
||||
if fresh, err := o.repo.Media.FindByID(ctx, m.ID); err == nil && fresh != nil {
|
||||
m = fresh
|
||||
}
|
||||
}
|
||||
return classifyMediaCategory(mediaClassifyInput{
|
||||
MediaType: mediaType,
|
||||
Title: m.Title + " " + m.OriginalName,
|
||||
Languages: parseCommaList(m.Languages),
|
||||
Countries: parseCommaList(m.Countries),
|
||||
Genres: parseCommaList(m.Genres),
|
||||
}, o.categoryMap())
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub *model.Subscription, title, sourceCategory string) (string, string) {
|
||||
mediaType := normalizeMediaType(sub.MediaType, title+" "+sub.Name+" "+sub.Filter, sourceCategory)
|
||||
category := strings.TrimSpace(sub.MediaCategory)
|
||||
if category == "" {
|
||||
category = classifyMediaCategory(mediaClassifyInput{
|
||||
MediaType: mediaType,
|
||||
Title: title + " " + sub.Name + " " + sub.Filter,
|
||||
Category: sourceCategory,
|
||||
}, s.categoryMap())
|
||||
}
|
||||
return mediaType, category
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) categoryMap() map[string]string {
|
||||
if s == nil || s.cfg == nil || s.cfg.Organizer.Categories == nil {
|
||||
return nil
|
||||
}
|
||||
return s.cfg.Organizer.Categories
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, sub *model.Subscription, mediaType, category string) string {
|
||||
if sub == nil {
|
||||
return ""
|
||||
}
|
||||
base := strings.TrimSpace(sub.SavePath)
|
||||
if base == "" && s.repo != nil && s.repo.Setting != nil {
|
||||
base, _ = s.repo.Setting.Get(ctx, "qbittorrent.savepath")
|
||||
}
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
if !s.isSmartClassifyEnabled(ctx) || category == "" {
|
||||
return base
|
||||
}
|
||||
if mediaType != "" {
|
||||
return filepath.Join(base, mediaType, sanitizeFilename(category))
|
||||
}
|
||||
return filepath.Join(base, sanitizeFilename(category))
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool {
|
||||
if s != nil && s.repo != nil && s.repo.Setting != nil {
|
||||
val, err := s.repo.Setting.Get(ctx, "organizer.smart_classify")
|
||||
if err == nil && val != "" {
|
||||
return val == "true" || val == "1" || val == "on"
|
||||
}
|
||||
}
|
||||
return s != nil && s.cfg != nil && s.cfg.Organizer.SmartClassify
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"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 TestClassifyMediaCategoryMatchesMoviePilotStyleRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input mediaClassifyInput
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "movie animation first",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "movie",
|
||||
Title: "Robot Dreams",
|
||||
Countries: []string{"ES"},
|
||||
Genres: []string{"Animation"},
|
||||
},
|
||||
want: "动画电影",
|
||||
},
|
||||
{
|
||||
name: "tv variety by genre",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "tv",
|
||||
Title: "声生不息",
|
||||
Countries: []string{"CN"},
|
||||
Genres: []string{"Reality"},
|
||||
},
|
||||
want: "综艺",
|
||||
},
|
||||
{
|
||||
name: "anime china",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "anime",
|
||||
Countries: []string{"CN"},
|
||||
Genres: []string{"Animation"},
|
||||
},
|
||||
want: "国漫",
|
||||
},
|
||||
{
|
||||
name: "tv documentary before region",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "tv",
|
||||
Countries: []string{"US"},
|
||||
Genres: []string{"Documentary"},
|
||||
},
|
||||
want: "纪录片",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := classifyMediaCategory(tt.input, nil); got != tt.want {
|
||||
t.Fatalf("category = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionResolveClassifiedSavePath(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.Setting.Set(t.Context(), "organizer.smart_classify", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Setting.Set(t.Context(), "qbittorrent.savepath", filepath.Join("D:", "Downloads")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewSubscriptionService(&config.Config{}, zap.NewNop(), repos, nil, nil, nil)
|
||||
sub := &model.Subscription{Name: "声生不息 自动订阅", MediaType: "tv"}
|
||||
|
||||
mediaType, category := svc.classifySubscriptionItem(t.Context(), sub, "声生不息 S01E01", "综艺")
|
||||
if mediaType != "tv" || category != "综艺" {
|
||||
t.Fatalf("classification = %q/%q, want tv/综艺", mediaType, category)
|
||||
}
|
||||
got := svc.resolveSubscriptionSavePath(t.Context(), sub, mediaType, category)
|
||||
want := filepath.Join("D:", "Downloads", "tv", "综艺")
|
||||
if got != want {
|
||||
t.Fatalf("save path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func executableCandidates(configured, name string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, 12)
|
||||
add := func(path string) {
|
||||
path = strings.Trim(path, `" `)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(filepath.Ext(path), ".exe") && runtime.GOOS == "windows" && !strings.ContainsAny(path, `\/`) {
|
||||
path += ".exe"
|
||||
}
|
||||
if resolved, err := exec.LookPath(path); err == nil {
|
||||
path = resolved
|
||||
}
|
||||
if stat, err := os.Stat(path); err != nil || stat.IsDir() {
|
||||
return
|
||||
}
|
||||
clean, err := filepath.Abs(path)
|
||||
if err == nil {
|
||||
path = clean
|
||||
}
|
||||
key := strings.ToLower(path)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, path)
|
||||
}
|
||||
|
||||
if configured != "" {
|
||||
add(configured)
|
||||
}
|
||||
add(name)
|
||||
|
||||
for _, path := range localExecutableCandidates(name) {
|
||||
add(path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveLocalExecutable(configured, name string) (string, error) {
|
||||
for _, path := range executableCandidates(configured, name) {
|
||||
return path, nil
|
||||
}
|
||||
return "", fmt.Errorf("%s not found in PATH or common local app directories", name)
|
||||
}
|
||||
|
||||
func localExecutableCandidates(name string) []string {
|
||||
exe := name
|
||||
if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(exe), ".exe") {
|
||||
exe += ".exe"
|
||||
}
|
||||
|
||||
out := make([]string, 0, 24)
|
||||
add := func(path string) {
|
||||
if path != "" {
|
||||
out = append(out, path)
|
||||
}
|
||||
}
|
||||
addGlob := func(pattern string) {
|
||||
matches, _ := filepath.Glob(pattern)
|
||||
out = append(out, matches...)
|
||||
}
|
||||
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
add(filepath.Join(wd, "tools", "ffmpeg", "bin", exe))
|
||||
add(filepath.Join(wd, "bin", exe))
|
||||
}
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
exeDir := filepath.Dir(exePath)
|
||||
add(filepath.Join(exeDir, "tools", "ffmpeg", "bin", exe))
|
||||
add(filepath.Join(exeDir, exe))
|
||||
}
|
||||
|
||||
appData := os.Getenv("APPDATA")
|
||||
localAppData := os.Getenv("LOCALAPPDATA")
|
||||
userProfile := os.Getenv("USERPROFILE")
|
||||
programData := os.Getenv("ProgramData")
|
||||
programFiles := os.Getenv("ProgramFiles")
|
||||
programFilesX86 := os.Getenv("ProgramFiles(x86)")
|
||||
|
||||
add(filepath.Join(appData, "bilibili", "ffmpeg", exe))
|
||||
add(filepath.Join(userProfile, "scoop", "shims", exe))
|
||||
add(filepath.Join(userProfile, "scoop", "apps", "ffmpeg", "current", "bin", exe))
|
||||
add(filepath.Join(programData, "chocolatey", "bin", exe))
|
||||
add(filepath.Join("C:\\", "ffmpeg", "bin", exe))
|
||||
add(filepath.Join("C:\\", "tools", "ffmpeg", "bin", exe))
|
||||
add(filepath.Join(programFiles, "ffmpeg", "bin", exe))
|
||||
add(filepath.Join(programFilesX86, "ffmpeg", "bin", exe))
|
||||
|
||||
addGlob(filepath.Join(localAppData, "JianyingPro", "Apps", "*", exe))
|
||||
addGlob(filepath.Join(localAppData, "Programs", "ffmpeg", "bin", exe))
|
||||
addGlob(filepath.Join(localAppData, "*", "ffmpeg", "bin", exe))
|
||||
return out
|
||||
}
|
||||
|
||||
func commandOutput(ctx context.Context, timeout time.Duration, name string, args ...string) ([]byte, error) {
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cmdCtx, name, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if cmdCtx.Err() != nil {
|
||||
return out, cmdCtx.Err()
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
+89
-17
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
@@ -47,6 +48,26 @@ type movieNFO struct {
|
||||
Poster string `xml:"thumb,omitempty"`
|
||||
Fanart string `xml:"fanart,omitempty"`
|
||||
TMDb int `xml:"tmdbid,omitempty"`
|
||||
Genre []string `xml:"genre,omitempty"`
|
||||
Country []string `xml:"country,omitempty"`
|
||||
Language []string `xml:"language,omitempty"`
|
||||
}
|
||||
|
||||
type episodeNFO struct {
|
||||
XMLName xml.Name `xml:"episodedetails"`
|
||||
Title string `xml:"title"`
|
||||
ShowTitle string `xml:"showtitle,omitempty"`
|
||||
Season int `xml:"season,omitempty"`
|
||||
Episode int `xml:"episode,omitempty"`
|
||||
Year int `xml:"year,omitempty"`
|
||||
Plot string `xml:"plot,omitempty"`
|
||||
Rating float32 `xml:"rating,omitempty"`
|
||||
Poster string `xml:"thumb,omitempty"`
|
||||
Fanart string `xml:"fanart,omitempty"`
|
||||
TMDb int `xml:"tmdbid,omitempty"`
|
||||
Genre []string `xml:"genre,omitempty"`
|
||||
Country []string `xml:"country,omitempty"`
|
||||
Language []string `xml:"language,omitempty"`
|
||||
}
|
||||
|
||||
// ExportOne writes a movie.nfo file next to the media file. Existing files
|
||||
@@ -63,26 +84,10 @@ func (s *NFOService) ExportOne(ctx context.Context, mediaID string) (string, err
|
||||
return "", errors.New("media has empty path")
|
||||
}
|
||||
|
||||
doc := movieNFO{
|
||||
Title: m.Title,
|
||||
Original: m.OriginalName,
|
||||
Year: m.Year,
|
||||
Plot: m.Overview,
|
||||
Rating: m.Rating,
|
||||
Poster: m.PosterURL,
|
||||
Fanart: m.BackdropURL,
|
||||
TMDb: m.TMDbID,
|
||||
}
|
||||
out, err := xml.MarshalIndent(doc, "", " ")
|
||||
dst, err := WriteMediaNFO(m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := []byte(xml.Header + string(out) + "\n")
|
||||
|
||||
dst := nfoPath(m.Path)
|
||||
if err := os.WriteFile(dst, body, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.log.Info("nfo exported", zap.String("media_id", m.ID), zap.String("path", dst))
|
||||
return dst, nil
|
||||
}
|
||||
@@ -113,3 +118,70 @@ func nfoPath(media string) string {
|
||||
base := strings.TrimSuffix(filepath.Base(media), filepath.Ext(media))
|
||||
return filepath.Join(dir, fmt.Sprintf("%s.nfo", base))
|
||||
}
|
||||
|
||||
func WriteMediaNFO(m *model.Media) (string, error) {
|
||||
if m == nil {
|
||||
return "", errors.New("media not found")
|
||||
}
|
||||
if m.Path == "" {
|
||||
return "", errors.New("media has empty path")
|
||||
}
|
||||
|
||||
var doc any
|
||||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||
title := m.OriginalName
|
||||
if title == "" {
|
||||
title = m.Title
|
||||
}
|
||||
doc = episodeNFO{
|
||||
Title: title,
|
||||
ShowTitle: m.Title,
|
||||
Season: m.SeasonNum,
|
||||
Episode: m.EpisodeNum,
|
||||
Year: m.Year,
|
||||
Plot: m.Overview,
|
||||
Rating: m.Rating,
|
||||
Poster: m.PosterURL,
|
||||
Fanart: m.BackdropURL,
|
||||
TMDb: m.TMDbID,
|
||||
Genre: splitNFOList(m.Genres),
|
||||
Country: splitNFOList(m.Countries),
|
||||
Language: splitNFOList(m.Languages),
|
||||
}
|
||||
} else {
|
||||
doc = movieNFO{
|
||||
Title: m.Title,
|
||||
Original: m.OriginalName,
|
||||
Year: m.Year,
|
||||
Plot: m.Overview,
|
||||
Rating: m.Rating,
|
||||
Poster: m.PosterURL,
|
||||
Fanart: m.BackdropURL,
|
||||
TMDb: m.TMDbID,
|
||||
Genre: splitNFOList(m.Genres),
|
||||
Country: splitNFOList(m.Countries),
|
||||
Language: splitNFOList(m.Languages),
|
||||
}
|
||||
}
|
||||
out, err := xml.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dst := nfoPath(m.Path)
|
||||
if err := os.WriteFile(dst, []byte(xml.Header+string(out)+"\n"), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func splitNFOList(value string) []string {
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+72
-145
@@ -4,8 +4,8 @@
|
||||
// completion) and moves/renames it into the configured library directory
|
||||
// using a Jinja2-like template. The default templates:
|
||||
//
|
||||
// movie: {Title} ({Year})/{Title} ({Year}).{Ext}
|
||||
// tv: {Title}/Season {Season:02d}/{Title} - S{Season:02d}E{Episode:02d}.{Ext}
|
||||
// movie: {Title} ({Year})/{Title} ({Year}).{Ext}
|
||||
// tv: {Title}/Season {Season:02d}/{Title} - S{Season:02d}E{Episode:02d}.{Ext}
|
||||
//
|
||||
// We do NOT delete the source after move — the operator can turn that on
|
||||
// via a config flag. This mirrors MediaStation's "organize after download"
|
||||
@@ -59,6 +59,11 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s
|
||||
if err != nil || lib == nil {
|
||||
return "", errors.New("library not found")
|
||||
}
|
||||
if lib.Type == "tv" || lib.Type == "anime" {
|
||||
if err := o.refreshEpisodeIdentity(m, lib); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
ext := filepath.Ext(m.Path)
|
||||
title := sanitizeFilename(m.Title)
|
||||
if title == "" {
|
||||
@@ -121,9 +126,20 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s
|
||||
if err := o.repo.DB.WithContext(ctx).
|
||||
Model(&model.Media{}).
|
||||
Where("id = ?", m.ID).
|
||||
Update("path", dst).Error; err != nil {
|
||||
Updates(map[string]any{
|
||||
"path": dst,
|
||||
"season_num": m.SeasonNum,
|
||||
"episode_num": m.EpisodeNum,
|
||||
}).Error; err != nil {
|
||||
return dst, err
|
||||
}
|
||||
if err := moveSidecarNFO(m.Path, dst); err != nil {
|
||||
o.log.Warn("organize sidecar nfo failed",
|
||||
zap.String("media", m.ID),
|
||||
zap.String("from", nfoPath(m.Path)),
|
||||
zap.String("to", nfoPath(dst)),
|
||||
zap.Error(err))
|
||||
}
|
||||
o.log.Info("organized",
|
||||
zap.String("media", m.ID),
|
||||
zap.String("from", m.Path),
|
||||
@@ -158,6 +174,37 @@ func (o *OrganizerService) OrganizeLibrary(ctx context.Context, libraryID string
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (o *OrganizerService) refreshEpisodeIdentity(m *model.Media, lib *model.Library) error {
|
||||
season, episode := m.SeasonNum, m.EpisodeNum
|
||||
|
||||
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedSeason > 0 || parsedEpisode > 0 {
|
||||
if parsedSeason > 0 {
|
||||
season = parsedSeason
|
||||
}
|
||||
if parsedEpisode > 0 {
|
||||
episode = parsedEpisode
|
||||
}
|
||||
}
|
||||
|
||||
if local, err := ReadLocalMetadata(m.Path, lib.Path, true); err == nil && local != nil {
|
||||
if local.SeasonNum > 0 {
|
||||
season = local.SeasonNum
|
||||
}
|
||||
if local.EpisodeNum > 0 {
|
||||
episode = local.EpisodeNum
|
||||
}
|
||||
} else if err != nil {
|
||||
o.log.Warn("organize read local metadata failed", zap.String("path", m.Path), zap.Error(err))
|
||||
}
|
||||
|
||||
if season <= 0 || episode <= 0 {
|
||||
return fmt.Errorf("cannot determine season/episode for %s", m.Path)
|
||||
}
|
||||
m.SeasonNum = season
|
||||
m.EpisodeNum = episode
|
||||
return nil
|
||||
}
|
||||
|
||||
// moveFile tries os.Rename first (instant on same fs), then falls back
|
||||
// to copy + remove for cross-device moves.
|
||||
//
|
||||
@@ -191,6 +238,27 @@ func moveFile(src, dst string) error {
|
||||
return os.Remove(src)
|
||||
}
|
||||
|
||||
func moveSidecarNFO(srcMedia, dstMedia string) error {
|
||||
src := nfoPath(srcMedia)
|
||||
dst := nfoPath(dstMedia)
|
||||
if src == dst {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return moveFile(src, dst)
|
||||
}
|
||||
|
||||
// sanitizeFilename removes characters not safe for filesystem names.
|
||||
func sanitizeFilename(s string) string {
|
||||
r := strings.NewReplacer(
|
||||
@@ -224,153 +292,12 @@ func (o *OrganizerService) SmartClassify(ctx context.Context, m *model.Media) st
|
||||
return ""
|
||||
}
|
||||
|
||||
// Fetch fresh metadata from DB (languages, countries, genres may have been updated by scraper)
|
||||
if m.Languages == "" && m.Countries == "" && m.Genres == "" {
|
||||
// Try to reload from DB
|
||||
fresh, err := o.repo.Media.FindByID(ctx, m.ID)
|
||||
if err == nil && fresh != nil {
|
||||
m = fresh
|
||||
}
|
||||
}
|
||||
|
||||
// Parse metadata fields (comma-separated)
|
||||
languages := parseCommaList(m.Languages)
|
||||
countries := parseCommaList(m.Countries)
|
||||
genres := parseCommaList(m.Genres)
|
||||
|
||||
// Determine media type from library
|
||||
lib, err := o.repo.Library.FindByID(ctx, m.LibraryID)
|
||||
if err != nil || lib == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
categories := o.cfg.Organizer.Categories
|
||||
if categories == nil {
|
||||
categories = make(map[string]string)
|
||||
}
|
||||
|
||||
// Helper closures
|
||||
isChinese := func() bool {
|
||||
for _, lang := range languages {
|
||||
if lang == "zh" || lang == "zh-CN" || lang == "zh-TW" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, c := range countries {
|
||||
if c == "CN" || c == "TW" || c == "HK" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
isEastAsian := func() bool {
|
||||
for _, c := range countries {
|
||||
if c == "JP" || c == "KR" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, lang := range languages {
|
||||
if lang == "ja" || lang == "ko" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
isWestern := func() bool {
|
||||
westernCountries := []string{"US", "GB", "FR", "DE", "CA", "AU", "NZ", "IE", "NL", "SE", "NO", "DK", "FI", "ES", "IT", "PT", "AT", "CH", "BE"}
|
||||
for _, c := range countries {
|
||||
for _, wc := range westernCountries {
|
||||
if c == wc {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Classification logic
|
||||
switch lib.Type {
|
||||
case "movie":
|
||||
// Use genres to help classify (e.g., Animation might be anime)
|
||||
isAnimation := false
|
||||
for _, g := range genres {
|
||||
if g == "Animation" {
|
||||
isAnimation = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isChinese() {
|
||||
if name, ok := categories["chinese_movie"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "华语电影"
|
||||
}
|
||||
if isEastAsian() || (isAnimation && isEastAsian()) {
|
||||
if name, ok := categories["jk_movie"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "日韩电影"
|
||||
}
|
||||
if isWestern() {
|
||||
if name, ok := categories["euus_movie"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "欧美电影"
|
||||
}
|
||||
// Fallback: foreign movie
|
||||
if name, ok := categories["foreign_movie"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "外语电影"
|
||||
|
||||
case "tv", "anime":
|
||||
if lib.Type == "anime" || contains(genres, "Animation") {
|
||||
// Anime classification
|
||||
if isEastAsian() {
|
||||
// Check if it's Japanese
|
||||
for _, c := range countries {
|
||||
if c == "JP" {
|
||||
if name, ok := categories["jp_anime"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "日番"
|
||||
}
|
||||
}
|
||||
}
|
||||
// Chinese anime
|
||||
if isChinese() {
|
||||
if name, ok := categories["cn_anime"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "国漫"
|
||||
}
|
||||
}
|
||||
// TV classification
|
||||
if isChinese() {
|
||||
if name, ok := categories["domestic_tv"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "国产剧"
|
||||
}
|
||||
if isEastAsian() {
|
||||
if name, ok := categories["jk_tv"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "日韩剧"
|
||||
}
|
||||
if isWestern() {
|
||||
if name, ok := categories["euus_tv"]; ok && name != "" {
|
||||
return name
|
||||
}
|
||||
return "欧美剧"
|
||||
}
|
||||
return "剧集"
|
||||
}
|
||||
|
||||
return ""
|
||||
return o.classifyMedia(ctx, m, lib.Type)
|
||||
}
|
||||
|
||||
// parseCommaList splits a comma-separated string into a slice of trimmed strings.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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 TestOrganizeMediaReDetectsSeasonFromPath(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sourceDir := filepath.Join(root, "Incoming", "Some Show", "Season 02")
|
||||
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := filepath.Join(sourceDir, "Some Show - EP03.mkv")
|
||||
if err := os.WriteFile(source, []byte("episode"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "Some Show",
|
||||
Path: source,
|
||||
Container: "mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 3,
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
||||
dst, err := organizer.OrganizeMedia(t.Context(), media.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(root, "Some Show", "Season 02", "Some Show - S02E03.mkv")
|
||||
if dst != want {
|
||||
t.Fatalf("dst = %q, want %q", dst, want)
|
||||
}
|
||||
if _, err := os.Stat(want); err != nil {
|
||||
t.Fatalf("organized file missing: %v", err)
|
||||
}
|
||||
var refreshed model.Media
|
||||
if err := db.First(&refreshed, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if refreshed.SeasonNum != 2 || refreshed.EpisodeNum != 3 || refreshed.Path != want {
|
||||
t.Fatalf("unexpected refreshed media: %+v", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizeMediaUsesEpisodeNFOSeason(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sourceDir := filepath.Join(root, "Incoming", "Some Show")
|
||||
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := filepath.Join(sourceDir, "Some Show - EP03.mkv")
|
||||
if err := os.WriteFile(source, []byte("episode"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nfo := `<episodedetails><title>第三集</title><season>2</season><episode>3</episode></episodedetails>`
|
||||
if err := os.WriteFile(nfoPath(source), []byte(nfo), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "Some Show",
|
||||
Path: source,
|
||||
Container: "mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 3,
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
||||
dst, err := organizer.OrganizeMedia(t.Context(), media.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(root, "Some Show", "Season 02", "Some Show - S02E03.mkv")
|
||||
if dst != want {
|
||||
t.Fatalf("dst = %q, want %q", dst, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProxyFromEnvironmentOrSystem mirrors http.ProxyFromEnvironment, then falls
|
||||
// back to the OS user proxy. On Windows this means the current user's Internet
|
||||
// Settings proxy, e.g. the proxy configured by Clash/V2RayN/系统设置.
|
||||
func ProxyFromEnvironmentOrSystem(req *http.Request) (*url.URL, error) {
|
||||
if proxy, err := http.ProxyFromEnvironment(req); proxy != nil || err != nil {
|
||||
return proxy, err
|
||||
}
|
||||
return systemProxyForRequest(req)
|
||||
}
|
||||
|
||||
// NewExternalHTTPClient builds an HTTP client for third-party APIs. It uses
|
||||
// environment proxies first, then the local OS proxy configuration.
|
||||
func NewExternalHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: NewExternalTransport(),
|
||||
}
|
||||
}
|
||||
|
||||
func NewExternalTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
Proxy: ProxyFromEnvironmentOrSystem,
|
||||
MaxIdleConns: 32,
|
||||
MaxIdleConnsPerHost: 8,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
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 service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func systemProxyForRequest(_ *http.Request) (*url.URL, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestProxyURLFromProxyServer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
proxyServer string
|
||||
requestScheme string
|
||||
want string
|
||||
}{
|
||||
{"bare", "127.0.0.1:10808", "https", "http://127.0.0.1:10808"},
|
||||
{"scheme map https", "http=127.0.0.1:7890;https=127.0.0.1:7891", "https", "http://127.0.0.1:7891"},
|
||||
{"fallback http", "http=127.0.0.1:7890", "https", "http://127.0.0.1:7890"},
|
||||
{"socks", "socks=127.0.0.1:1080", "https", "socks5://127.0.0.1:1080"},
|
||||
{"explicit", "http://127.0.0.1:8080", "https", "http://127.0.0.1:8080"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := proxyURLFromProxyServer(tc.proxyServer, tc.requestScheme)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.String() != tc.want {
|
||||
t.Fatalf("got %q, want %q", got.String(), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//go:build windows
|
||||
|
||||
package service
|
||||
|
||||
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
|
||||
}
|
||||
@@ -5,9 +5,9 @@
|
||||
//
|
||||
// We only need three operations for the download flow:
|
||||
//
|
||||
// POST /auth/login
|
||||
// POST /torrents/add (multipart, accepts magnet URL or .torrent bytes)
|
||||
// GET /torrents/info (filtered by hash)
|
||||
// POST /auth/login
|
||||
// POST /torrents/add (multipart, accepts magnet URL or .torrent bytes)
|
||||
// GET /torrents/info (filtered by hash)
|
||||
//
|
||||
// The client stores the SID cookie returned by /auth/login and reuses it
|
||||
// across calls. Re-auth happens transparently on 403.
|
||||
@@ -16,6 +16,8 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -24,6 +26,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -61,6 +64,11 @@ type QBitClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var (
|
||||
qbitAddVerifyAttempts = 10
|
||||
qbitAddVerifyInterval = 800 * time.Millisecond
|
||||
)
|
||||
|
||||
// NewQBitClient builds a fresh client, applying default URL if blank.
|
||||
func NewQBitClient(log *zap.Logger, cfg QBitConfig) *QBitClient {
|
||||
if cfg.BaseURL == "" {
|
||||
@@ -125,9 +133,58 @@ func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath strin
|
||||
return err
|
||||
}
|
||||
|
||||
torrentData, torrentName, fetchErr := q.fetchTorrentFile(ctx, magnetOrURL)
|
||||
useFileUpload := fetchErr == nil && len(torrentData) > 0
|
||||
return q.addTorrentLocked(ctx, magnetOrURL, torrentData, torrentName, useFileUpload, savePath)
|
||||
}
|
||||
|
||||
func (q *QBitClient) AddTorrentFile(ctx context.Context, data []byte, name, savePath string) error {
|
||||
if len(data) == 0 {
|
||||
return errors.New("empty torrent data")
|
||||
}
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if err := q.ensureAuth(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.addTorrentLocked(ctx, "", data, name, true, savePath)
|
||||
}
|
||||
|
||||
func (q *QBitClient) addTorrentLocked(ctx context.Context, magnetOrURL string, torrentData []byte, torrentName string, useFileUpload bool, savePath string) error {
|
||||
before, beforeErr := q.listLocked(ctx, "")
|
||||
beforeHashes := make(map[string]struct{}, len(before))
|
||||
if beforeErr == nil {
|
||||
for _, torrent := range before {
|
||||
if torrent.Hash != "" {
|
||||
beforeHashes[strings.ToLower(torrent.Hash)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
if useFileUpload && beforeErr == nil {
|
||||
if hash := torrentInfoHash(torrentData); hash != "" {
|
||||
if _, ok := beforeHashes[hash]; ok {
|
||||
q.log.Info("qbittorrent: torrent already exists", zap.String("hash", hash), zap.String("name", torrentName))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
_ = w.WriteField("urls", magnetOrURL)
|
||||
if useFileUpload {
|
||||
if strings.TrimSpace(torrentName) == "" {
|
||||
torrentName = "download.torrent"
|
||||
}
|
||||
part, err := w.CreateFormFile("torrents", torrentName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := part.Write(torrentData); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
_ = w.WriteField("urls", magnetOrURL)
|
||||
}
|
||||
if savePath != "" {
|
||||
_ = w.WriteField("savepath", savePath)
|
||||
}
|
||||
@@ -157,13 +214,182 @@ func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath strin
|
||||
if strings.EqualFold(bodyText, "Fails.") {
|
||||
return fmt.Errorf("qbittorrent add: 拒绝任务 (检查 URL 是否需要认证或 savePath 是否可写)")
|
||||
}
|
||||
if beforeErr == nil {
|
||||
accepted := false
|
||||
var lastListErr error
|
||||
for attempt := 0; attempt < qbitAddVerifyAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(qbitAddVerifyInterval)
|
||||
}
|
||||
after, err := q.listLocked(ctx, "")
|
||||
if err != nil {
|
||||
lastListErr = err
|
||||
continue
|
||||
}
|
||||
for _, torrent := range after {
|
||||
if torrent.Hash == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := beforeHashes[torrent.Hash]; !ok {
|
||||
accepted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if accepted {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !accepted {
|
||||
if lastListErr != nil {
|
||||
return fmt.Errorf("qbittorrent add: 无法确认任务已加入下载器: %w", lastListErr)
|
||||
}
|
||||
return fmt.Errorf("qbittorrent add: 下载器未出现新任务,可能种子已存在或 URL 未被下载器接受")
|
||||
}
|
||||
}
|
||||
q.log.Info("qbittorrent: torrent added",
|
||||
zap.String("url", magnetOrURL),
|
||||
zap.String("save_path", savePath),
|
||||
zap.Bool("file_upload", useFileUpload),
|
||||
zap.String("body", bodyText))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QBitClient) fetchTorrentFile(ctx context.Context, raw string) ([]byte, string, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.Host == "" {
|
||||
return nil, "", errors.New("not a remote URL")
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, "", errors.New("not an HTTP torrent URL")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "MediaStationGo/0.1")
|
||||
req.Header.Set("Accept", "application/x-bittorrent,application/octet-stream,*/*")
|
||||
|
||||
client := NewExternalHTTPClient(30 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, "", fmt.Errorf("torrent fetch: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
const maxTorrentSize = 32 << 20
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxTorrentSize+1))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, "", errors.New("torrent fetch: empty body")
|
||||
}
|
||||
if len(data) > maxTorrentSize {
|
||||
return nil, "", errors.New("torrent fetch: body too large")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") {
|
||||
return nil, "", errors.New("torrent fetch: upstream returned HTML")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(path.Base(u.Path))
|
||||
if name == "" || name == "." || name == "/" {
|
||||
name = "download.torrent"
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".torrent") {
|
||||
name += ".torrent"
|
||||
}
|
||||
return data, name, nil
|
||||
}
|
||||
|
||||
func torrentInfoHash(data []byte) string {
|
||||
start, end, ok := torrentInfoBounds(data)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
sum := sha1.Sum(data[start:end])
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func torrentInfoBounds(data []byte) (int, int, bool) {
|
||||
if len(data) == 0 || data[0] != 'd' {
|
||||
return 0, 0, false
|
||||
}
|
||||
pos := 1
|
||||
for pos < len(data) && data[pos] != 'e' {
|
||||
keyStart, keyEnd, next, ok := parseBencodeString(data, pos)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
valueStart := next
|
||||
valueEnd, ok := bencodeValueEnd(data, valueStart)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
if string(data[keyStart:keyEnd]) == "info" {
|
||||
return valueStart, valueEnd, true
|
||||
}
|
||||
pos = valueEnd
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func parseBencodeString(data []byte, pos int) (int, int, int, bool) {
|
||||
if pos >= len(data) || data[pos] < '0' || data[pos] > '9' {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
length := 0
|
||||
for pos < len(data) && data[pos] >= '0' && data[pos] <= '9' {
|
||||
length = length*10 + int(data[pos]-'0')
|
||||
pos++
|
||||
}
|
||||
if pos >= len(data) || data[pos] != ':' {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
start := pos + 1
|
||||
end := start + length
|
||||
if end > len(data) {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return start, end, end, true
|
||||
}
|
||||
|
||||
func bencodeValueEnd(data []byte, pos int) (int, bool) {
|
||||
if pos >= len(data) {
|
||||
return 0, false
|
||||
}
|
||||
switch data[pos] {
|
||||
case 'i':
|
||||
end := pos + 1
|
||||
for end < len(data) && data[end] != 'e' {
|
||||
end++
|
||||
}
|
||||
if end >= len(data) {
|
||||
return 0, false
|
||||
}
|
||||
return end + 1, true
|
||||
case 'l', 'd':
|
||||
end := pos + 1
|
||||
for end < len(data) && data[end] != 'e' {
|
||||
next, ok := bencodeValueEnd(data, end)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
end = next
|
||||
}
|
||||
if end >= len(data) {
|
||||
return 0, false
|
||||
}
|
||||
return end + 1, true
|
||||
default:
|
||||
_, _, next, ok := parseBencodeString(data, pos)
|
||||
return next, ok
|
||||
}
|
||||
}
|
||||
|
||||
// List returns every torrent (optionally filtered by status: all / downloading / completed).
|
||||
func (q *QBitClient) List(ctx context.Context, filter string) ([]QBitTorrent, error) {
|
||||
q.mu.Lock()
|
||||
@@ -171,6 +397,10 @@ func (q *QBitClient) List(ctx context.Context, filter string) ([]QBitTorrent, er
|
||||
if err := q.ensureAuth(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.listLocked(ctx, filter)
|
||||
}
|
||||
|
||||
func (q *QBitClient) listLocked(ctx context.Context, filter string) ([]QBitTorrent, error) {
|
||||
u := strings.TrimRight(q.cfg.BaseURL, "/") + "/api/v2/torrents/info"
|
||||
if filter != "" {
|
||||
u += "?filter=" + url.QueryEscape(filter)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestQBitAddTorrentRequiresVisibleNewTask(t *testing.T) {
|
||||
oldAttempts := qbitAddVerifyAttempts
|
||||
oldInterval := qbitAddVerifyInterval
|
||||
qbitAddVerifyAttempts = 2
|
||||
qbitAddVerifyInterval = time.Millisecond
|
||||
defer func() {
|
||||
qbitAddVerifyAttempts = oldAttempts
|
||||
qbitAddVerifyInterval = oldInterval
|
||||
}()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/auth/login":
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
case "/api/v2/torrents/add":
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
case "/api/v2/torrents/info":
|
||||
_, _ = w.Write([]byte("[]"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewQBitClient(zap.NewNop(), QBitConfig{
|
||||
BaseURL: server.URL,
|
||||
Username: "admin",
|
||||
Password: "adminadmin",
|
||||
})
|
||||
|
||||
err := client.AddTorrent(context.Background(), server.URL+"/missing.torrent", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected add to fail when no new torrent appears")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "下载器未出现新任务") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQBitAddTorrentUploadsFetchedTorrentFile(t *testing.T) {
|
||||
var added atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/auth/login":
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
case "/fixture.torrent":
|
||||
w.Header().Set("Content-Type", "application/x-bittorrent")
|
||||
_, _ = w.Write([]byte("d4:infod4:name7:fixtureee"))
|
||||
case "/api/v2/torrents/add":
|
||||
reader, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
t.Errorf("expected multipart add request: %v", err)
|
||||
http.Error(w, "bad multipart", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !multipartHasTorrentFile(reader) {
|
||||
t.Error("expected qbit add request to upload torrent file")
|
||||
http.Error(w, "missing file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
added.Store(true)
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
case "/api/v2/torrents/info":
|
||||
if added.Load() {
|
||||
_, _ = w.Write([]byte(`[{"hash":"abc123","name":"fixture"}]`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("[]"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewQBitClient(zap.NewNop(), QBitConfig{
|
||||
BaseURL: server.URL,
|
||||
Username: "admin",
|
||||
Password: "adminadmin",
|
||||
})
|
||||
|
||||
if err := client.AddTorrent(context.Background(), server.URL+"/fixture.torrent", ""); err != nil {
|
||||
t.Fatalf("expected fetched torrent upload to succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQBitAddTorrentFileTreatsExistingInfoHashAsSuccess(t *testing.T) {
|
||||
torrentData := []byte("d4:infod4:name7:fixtureee")
|
||||
hash := torrentInfoHash(torrentData)
|
||||
if hash == "" {
|
||||
t.Fatal("expected fixture info hash")
|
||||
}
|
||||
var addCalled atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/auth/login":
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
case "/api/v2/torrents/add":
|
||||
addCalled.Store(true)
|
||||
_, _ = w.Write([]byte("Fails."))
|
||||
case "/api/v2/torrents/info":
|
||||
_, _ = w.Write([]byte(`[{"hash":"` + hash + `","name":"fixture"}]`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewQBitClient(zap.NewNop(), QBitConfig{
|
||||
BaseURL: server.URL,
|
||||
Username: "admin",
|
||||
Password: "adminadmin",
|
||||
})
|
||||
|
||||
if err := client.AddTorrentFile(context.Background(), torrentData, "fixture.torrent", ""); err != nil {
|
||||
t.Fatalf("expected existing torrent to be accepted: %v", err)
|
||||
}
|
||||
if addCalled.Load() {
|
||||
t.Fatal("expected qbit add to be skipped for existing infohash")
|
||||
}
|
||||
}
|
||||
|
||||
func multipartHasTorrentFile(reader *multipart.Reader) bool {
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if part.FormName() == "torrents" && part.FileName() != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
func ApplyRuntimeSettings(ctx context.Context, cfg *config.Config, repos *repository.Container, log *zap.Logger) {
|
||||
if cfg == nil || repos == nil || repos.Setting == nil {
|
||||
return
|
||||
}
|
||||
rows, err := repos.Setting.All(ctx)
|
||||
if err != nil {
|
||||
if log != nil {
|
||||
log.Warn("load runtime settings failed", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, row := range rows {
|
||||
ApplyRuntimeSetting(cfg, row.Key, row.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
switch key {
|
||||
case "ffmpeg.path", "app.ffmpeg_path":
|
||||
cfg.App.FFmpegPath = value
|
||||
case "ffprobe.path", "app.ffprobe_path":
|
||||
cfg.App.FFprobePath = value
|
||||
case "transcode.hw_accel", "transcoder.encoder":
|
||||
switch value {
|
||||
case "", "auto", "none", "software":
|
||||
cfg.Transcoder.Encoder = ""
|
||||
case "nvenc", "qsv", "vaapi":
|
||||
cfg.Transcoder.Encoder = value
|
||||
}
|
||||
case "transcode.max_height", "transcoder.max_height":
|
||||
if n, err := strconv.Atoi(value); err == nil {
|
||||
cfg.Transcoder.MaxHeight = n
|
||||
}
|
||||
case "transcode.video_bitrate", "transcoder.video_bitrate":
|
||||
cfg.Transcoder.VideoBitrate = value
|
||||
}
|
||||
}
|
||||
+164
-17
@@ -5,14 +5,14 @@
|
||||
// ffprobe (when available) and queues a metadata lookup for newly added
|
||||
// rows.
|
||||
//
|
||||
// For TV / anime libraries we extract season + episode numbers from the
|
||||
// filename via ParseEpisode and store them on the Media row. A future
|
||||
// pass groups episodes into Series rows; the current scaffold lets the
|
||||
// frontend group by `series_id`.
|
||||
// When a filename exposes season + episode numbers we store them on the
|
||||
// Media row for every library type, so variety shows and other episodic
|
||||
// collections get the same grouping experience as TV/anime.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -68,10 +68,13 @@ func NewScannerService(
|
||||
|
||||
// ScanResult summarises a scan run.
|
||||
type ScanResult struct {
|
||||
LibraryID string `json:"library_id"`
|
||||
Visited int `json:"visited"`
|
||||
Added int `json:"added"`
|
||||
Probed int `json:"probed"`
|
||||
LibraryID string `json:"library_id"`
|
||||
Visited int `json:"visited"`
|
||||
Added int `json:"added"`
|
||||
Updated int `json:"updated"`
|
||||
Probed int `json:"probed"`
|
||||
LocalMetadata int `json:"local_metadata"`
|
||||
Removed int64 `json:"removed"`
|
||||
}
|
||||
|
||||
// ScanLibrary walks the library root and persists discovered media files.
|
||||
@@ -81,6 +84,7 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
|
||||
return nil, err
|
||||
}
|
||||
res := &ScanResult{LibraryID: lib.ID}
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
walkFn := func(path string, info walkInfo) error {
|
||||
if info.isDir {
|
||||
@@ -91,6 +95,8 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
|
||||
return nil
|
||||
}
|
||||
res.Visited++
|
||||
seen[filepath.Clean(path)] = struct{}{}
|
||||
isNewMedia := !s.mediaPathExists(ctx, path)
|
||||
|
||||
title, year := CleanQuery(path)
|
||||
if title == "" {
|
||||
@@ -106,11 +112,15 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
|
||||
Container: strings.TrimPrefix(ext, "."),
|
||||
}
|
||||
|
||||
// Detect season/episode for TV / anime libraries.
|
||||
if lib.Type == "tv" || lib.Type == "anime" {
|
||||
s, e := ParseEpisode(path)
|
||||
m.SeasonNum = s
|
||||
m.EpisodeNum = e
|
||||
parsedSeason, parsedEpisode := ParseEpisode(path)
|
||||
m.SeasonNum = parsedSeason
|
||||
m.EpisodeNum = parsedEpisode
|
||||
|
||||
if local, err := ReadLocalMetadata(path, lib.Path, librarySupportsSeasons(lib) || parsedSeason > 0 || parsedEpisode > 0); err == nil && local != nil {
|
||||
applyLocalMetadata(m, local)
|
||||
res.LocalMetadata++
|
||||
} else if err != nil {
|
||||
s.log.Warn("read local metadata failed", zap.String("path", path), zap.Error(err))
|
||||
}
|
||||
|
||||
// Best-effort ffprobe; failure does not abort the file.
|
||||
@@ -134,13 +144,19 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
|
||||
s.log.Warn("upsert media failed", zap.String("path", path), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
res.Added++
|
||||
if isNewMedia {
|
||||
res.Added++
|
||||
} else {
|
||||
res.Updated++
|
||||
}
|
||||
s.hub.Publish("scan", map[string]any{
|
||||
"library_id": lib.ID,
|
||||
"path": path,
|
||||
"visited": res.Visited,
|
||||
"added": res.Added,
|
||||
"updated": res.Updated,
|
||||
"probed": res.Probed,
|
||||
"local_meta": res.LocalMetadata,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -148,18 +164,27 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
|
||||
if err := walk(lib.Path, walkFn); err != nil {
|
||||
return res, err
|
||||
}
|
||||
removed, err := s.pruneMissingMedia(ctx, lib.ID, seen)
|
||||
if err != nil {
|
||||
s.log.Warn("prune missing media failed", zap.String("library_id", lib.ID), zap.Error(err))
|
||||
} else {
|
||||
res.Removed = removed
|
||||
}
|
||||
|
||||
s.hub.Publish("scan", map[string]any{
|
||||
"library_id": lib.ID,
|
||||
"finished": true,
|
||||
"visited": res.Visited,
|
||||
"added": res.Added,
|
||||
"updated": res.Updated,
|
||||
"probed": res.Probed,
|
||||
"local_meta": res.LocalMetadata,
|
||||
"removed": res.Removed,
|
||||
})
|
||||
|
||||
// Fire-and-forget metadata enrichment when at least one provider is
|
||||
// configured.
|
||||
if s.scraper != nil && s.scraper.AnyEnabled() {
|
||||
// Online enrichment is opt-in. Local NFO is always consumed first during
|
||||
// the scan, and matched rows are excluded from EnrichLibrary's pending set.
|
||||
if s.scraper != nil && s.scraper.AnyEnabled() && s.autoScrapeEnabled(ctx) {
|
||||
go func(libID string) {
|
||||
if _, err := s.scraper.EnrichLibrary(context.Background(), libID); err != nil {
|
||||
s.log.Warn("scraper enrich failed", zap.Error(err))
|
||||
@@ -168,3 +193,125 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *ScannerService) mediaPathExists(ctx context.Context, path string) bool {
|
||||
var count int64
|
||||
err := s.repo.DB.WithContext(ctx).Unscoped().Model(&model.Media{}).
|
||||
Where("path = ?", path).Count(&count).Error
|
||||
return err == nil && count > 0
|
||||
}
|
||||
|
||||
func (s *ScannerService) pruneMissingMedia(ctx context.Context, libraryID string, seen map[string]struct{}) (int64, error) {
|
||||
var rows []model.Media
|
||||
if err := s.repo.DB.WithContext(ctx).
|
||||
Where("library_id = ?", libraryID).
|
||||
Find(&rows).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var removed int64
|
||||
for _, row := range rows {
|
||||
if row.Path == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[filepath.Clean(row.Path)]; ok {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(row.Path); err == nil {
|
||||
continue
|
||||
} else if !os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
res := s.repo.DB.WithContext(ctx).
|
||||
Where("id = ?", row.ID).
|
||||
Delete(&model.Media{})
|
||||
if res.Error != nil {
|
||||
return removed, res.Error
|
||||
}
|
||||
removed += res.RowsAffected
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
func applyLocalMetadata(m *model.Media, local *LocalMetadata) {
|
||||
if local.Title != "" {
|
||||
m.Title = local.Title
|
||||
}
|
||||
if local.OriginalName != "" {
|
||||
m.OriginalName = local.OriginalName
|
||||
}
|
||||
if local.AdultCode != "" {
|
||||
m.OriginalName = local.AdultCode
|
||||
}
|
||||
if local.Year > 0 {
|
||||
m.Year = local.Year
|
||||
}
|
||||
if local.Overview != "" {
|
||||
m.Overview = local.Overview
|
||||
}
|
||||
if local.Rating > 0 {
|
||||
m.Rating = local.Rating
|
||||
}
|
||||
if local.PosterURL != "" {
|
||||
m.PosterURL = local.PosterURL
|
||||
}
|
||||
if local.BackdropURL != "" {
|
||||
m.BackdropURL = local.BackdropURL
|
||||
}
|
||||
if local.TMDbID > 0 {
|
||||
m.TMDbID = local.TMDbID
|
||||
}
|
||||
if local.SeasonNum > 0 {
|
||||
m.SeasonNum = local.SeasonNum
|
||||
}
|
||||
if local.EpisodeNum > 0 {
|
||||
m.EpisodeNum = local.EpisodeNum
|
||||
}
|
||||
if local.Genres != "" {
|
||||
m.Genres = local.Genres
|
||||
}
|
||||
if local.Countries != "" {
|
||||
m.Countries = local.Countries
|
||||
}
|
||||
if local.Languages != "" {
|
||||
m.Languages = local.Languages
|
||||
}
|
||||
if local.NSFW {
|
||||
m.NSFW = true
|
||||
}
|
||||
if local.HasNFO || localHasDescriptiveMetadata(local) {
|
||||
m.ScrapeStatus = "matched"
|
||||
}
|
||||
}
|
||||
|
||||
func localHasDescriptiveMetadata(local *LocalMetadata) bool {
|
||||
if local == nil {
|
||||
return false
|
||||
}
|
||||
return local.Title != "" ||
|
||||
local.OriginalName != "" ||
|
||||
local.AdultCode != "" ||
|
||||
local.Year > 0 ||
|
||||
local.Overview != "" ||
|
||||
local.Rating > 0 ||
|
||||
local.TMDbID > 0 ||
|
||||
local.Genres != "" ||
|
||||
local.Countries != "" ||
|
||||
local.Languages != ""
|
||||
}
|
||||
|
||||
func (s *ScannerService) autoScrapeEnabled(ctx context.Context) bool {
|
||||
if s.repo == nil || s.repo.Setting == nil {
|
||||
return false
|
||||
}
|
||||
value, err := s.repo.Setting.Get(ctx, "scrape.auto_on_scan")
|
||||
if err != nil {
|
||||
s.log.Warn("read scrape.auto_on_scan failed", zap.Error(err))
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on", "enabled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
+360
-21
@@ -3,9 +3,9 @@
|
||||
// ScraperService takes a Media row and tries to enrich it with metadata
|
||||
// from one or more providers. Selection is driven by the library type:
|
||||
//
|
||||
// library.type == "anime" -> Bangumi (fallback: TMDb)
|
||||
// library.type == "tv" -> TheTVDB (fallback: TMDb)
|
||||
// default -> TMDb
|
||||
// library.type == "anime" -> Bangumi (fallback: TMDb)
|
||||
// library.type == "tv" -> TheTVDB (fallback: TMDb)
|
||||
// default -> TMDb
|
||||
//
|
||||
// After the primary match we optionally upgrade poster / backdrop with
|
||||
// Fanart.tv when an API key is configured.
|
||||
@@ -35,6 +35,7 @@ type ScraperService struct {
|
||||
bangumi *BangumiProvider
|
||||
thetvdb *TheTVDBProvider
|
||||
fanart *FanartProvider
|
||||
adult *AdultProvider
|
||||
hub *Hub
|
||||
}
|
||||
|
||||
@@ -48,10 +49,15 @@ func NewScraperService(
|
||||
thetvdb *TheTVDBProvider,
|
||||
fanart *FanartProvider,
|
||||
hub *Hub,
|
||||
adult ...*AdultProvider,
|
||||
) *ScraperService {
|
||||
var adultProvider *AdultProvider
|
||||
if len(adult) > 0 {
|
||||
adultProvider = adult[0]
|
||||
}
|
||||
return &ScraperService{
|
||||
cfg: cfg, log: log, repo: repo,
|
||||
tmdb: tmdb, bangumi: bangumi, thetvdb: thetvdb, fanart: fanart, hub: hub,
|
||||
tmdb: tmdb, bangumi: bangumi, thetvdb: thetvdb, fanart: fanart, adult: adultProvider, hub: hub,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +88,24 @@ var noiseTokens = []string{
|
||||
"season",
|
||||
}
|
||||
|
||||
var noiseTokenSet = func() map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(noiseTokens)+1)
|
||||
for _, token := range noiseTokens {
|
||||
set[token] = struct{}{}
|
||||
}
|
||||
set["dl"] = struct{}{}
|
||||
return set
|
||||
}()
|
||||
|
||||
// bracketedTag matches "[anything]" or "(anything)" segments.
|
||||
var bracketedTag = regexp.MustCompile(`[\[\(][^\]\)]*[\]\)]`)
|
||||
var multiWordNoise = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)\bweb[\s._-]*dl\b`),
|
||||
regexp.MustCompile(`(?i)\bblu[\s._-]*ray\b`),
|
||||
regexp.MustCompile(`(?i)\bdirectors[\s._-]*cut\b`),
|
||||
regexp.MustCompile(`(?i)\berai[\s._-]*raws\b`),
|
||||
regexp.MustCompile(`(?i)\bohys[\s._-]*raws\b`),
|
||||
}
|
||||
|
||||
// CleanQuery converts a filename like "Inception.2010.1080p.BluRay.x264.mkv"
|
||||
// into a TMDb-friendly title plus an optional year hint.
|
||||
@@ -105,8 +127,8 @@ func CleanQuery(raw string) (title string, year int) {
|
||||
lower = patEP.ReplaceAllString(lower, " ")
|
||||
lower = patCN.ReplaceAllString(lower, " ")
|
||||
|
||||
for _, t := range noiseTokens {
|
||||
lower = strings.ReplaceAll(lower, t, " ")
|
||||
for _, pat := range multiWordNoise {
|
||||
lower = pat.ReplaceAllString(lower, " ")
|
||||
}
|
||||
for _, sep := range []string{".", "_", "-", "[", "]", "(", ")", "×"} {
|
||||
lower = strings.ReplaceAll(lower, sep, " ")
|
||||
@@ -115,6 +137,9 @@ func CleanQuery(raw string) (title string, year int) {
|
||||
// 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。
|
||||
out := make([]string, 0, 8)
|
||||
for _, w := range strings.Fields(lower) {
|
||||
if _, ok := noiseTokenSet[w]; ok {
|
||||
continue
|
||||
}
|
||||
if len(w) <= 1 {
|
||||
r := []rune(w)
|
||||
if len(r) == 1 && r[0] < 128 {
|
||||
@@ -134,21 +159,51 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
||||
return err
|
||||
}
|
||||
|
||||
query := m.Title
|
||||
if query == "" {
|
||||
query, _ = CleanQuery(m.Path)
|
||||
} else {
|
||||
query, _ = CleanQuery(query)
|
||||
seriesLike := mediaIsEpisodic(m, lib)
|
||||
var local *LocalMetadata
|
||||
if found, err := ReadLocalMetadata(m.Path, lib.Path, seriesLike); err == nil && found != nil {
|
||||
local = found
|
||||
applyLocalMetadata(m, local)
|
||||
} else if err != nil {
|
||||
s.log.Warn("read local metadata before scrape failed", zap.String("media_id", m.ID), zap.Error(err))
|
||||
}
|
||||
|
||||
year := m.Year
|
||||
if year == 0 {
|
||||
_, year = CleanQuery(filepath.Base(m.Path))
|
||||
}
|
||||
|
||||
match := s.lookup(ctx, lib, query, year)
|
||||
if s.adult != nil && s.adult.Enabled() {
|
||||
if code := firstText(localAdultCode(local), AdultCodeFromMediaPath(m.Path), normalizeAdultCode(m.OriginalName), normalizeAdultCode(m.Title)); code != "" {
|
||||
if adultMatch, err := s.adult.Search(ctx, code); err == nil && adultMatch != nil {
|
||||
mergeLocalMetadataIntoMatch(adultMatch, local)
|
||||
return s.applyProviderMatch(ctx, m, lib, adultMatch)
|
||||
} else if err != nil {
|
||||
s.log.Debug("adult metadata search failed", zap.String("media_id", m.ID), zap.String("code", code), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates := scrapeQueryCandidates(m, lib)
|
||||
var query string
|
||||
match := (*Match)(nil)
|
||||
for _, candidate := range candidates {
|
||||
match = s.lookup(ctx, lib, candidate, year)
|
||||
query = candidate
|
||||
if match != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if match == nil {
|
||||
if local != nil {
|
||||
return s.applyLocalMetadataMatch(ctx, m, local)
|
||||
}
|
||||
_ = s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID).
|
||||
Update("scrape_status", "no_match").Error
|
||||
s.log.Info("metadata scrape no match",
|
||||
zap.String("media_id", m.ID),
|
||||
zap.String("query", query),
|
||||
zap.String("library_type", lib.Type))
|
||||
return nil
|
||||
}
|
||||
// Optional Fanart upgrade.
|
||||
@@ -162,7 +217,68 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
mergeLocalMetadataIntoMatch(match, local)
|
||||
|
||||
return s.applyProviderMatch(ctx, m, lib, match)
|
||||
}
|
||||
|
||||
func localAdultCode(local *LocalMetadata) string {
|
||||
if local == nil {
|
||||
return ""
|
||||
}
|
||||
return local.AdultCode
|
||||
}
|
||||
|
||||
func mergeLocalMetadataIntoMatch(match *Match, local *LocalMetadata) {
|
||||
if match == nil || local == nil {
|
||||
return
|
||||
}
|
||||
if local.Title != "" {
|
||||
match.Title = local.Title
|
||||
}
|
||||
if local.OriginalName != "" {
|
||||
match.OriginalName = local.OriginalName
|
||||
}
|
||||
if local.AdultCode != "" {
|
||||
match.OriginalName = local.AdultCode
|
||||
match.NSFW = true
|
||||
}
|
||||
if local.Overview != "" {
|
||||
match.Overview = local.Overview
|
||||
}
|
||||
if local.PosterURL != "" {
|
||||
match.PosterURL = local.PosterURL
|
||||
}
|
||||
if local.BackdropURL != "" {
|
||||
match.BackdropURL = local.BackdropURL
|
||||
}
|
||||
if local.Rating > 0 {
|
||||
match.Rating = local.Rating
|
||||
}
|
||||
if local.Year > 0 {
|
||||
match.Year = local.Year
|
||||
}
|
||||
if local.TMDbID > 0 {
|
||||
match.TMDbID = local.TMDbID
|
||||
}
|
||||
if local.BangumiID > 0 {
|
||||
match.BangumiID = local.BangumiID
|
||||
}
|
||||
if local.Genres != "" {
|
||||
match.Genres = splitNFOList(local.Genres)
|
||||
}
|
||||
if local.Countries != "" {
|
||||
match.Countries = splitNFOList(local.Countries)
|
||||
}
|
||||
if local.Languages != "" {
|
||||
match.Languages = splitNFOList(local.Languages)
|
||||
}
|
||||
if local.NSFW {
|
||||
match.NSFW = true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media, lib *model.Library, match *Match) error {
|
||||
updates := map[string]any{
|
||||
"title": match.Title,
|
||||
"overview": match.Overview,
|
||||
@@ -172,12 +288,27 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
||||
"year": match.Year,
|
||||
"scrape_status": "matched",
|
||||
}
|
||||
if match.OriginalName != "" {
|
||||
updates["original_name"] = match.OriginalName
|
||||
}
|
||||
if match.TMDbID > 0 {
|
||||
updates["tmdb_id"] = match.TMDbID
|
||||
updates["tm_db_id"] = match.TMDbID
|
||||
}
|
||||
if match.BangumiID > 0 {
|
||||
updates["bangumi_id"] = match.BangumiID
|
||||
}
|
||||
if match.NSFW {
|
||||
updates["nsfw"] = true
|
||||
}
|
||||
if len(match.Genres) > 0 {
|
||||
updates["genres"] = strings.Join(match.Genres, ",")
|
||||
}
|
||||
if len(match.Countries) > 0 {
|
||||
updates["countries"] = strings.Join(match.Countries, ",")
|
||||
}
|
||||
if len(match.Languages) > 0 {
|
||||
updates["languages"] = strings.Join(match.Languages, ",")
|
||||
}
|
||||
|
||||
// Fetch extended metadata (languages, countries, genres) from TMDb
|
||||
if match.TMDbID > 0 && s.tmdb != nil && s.tmdb.Enabled() {
|
||||
@@ -210,15 +341,198 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
||||
Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if refreshed, err := s.repo.Media.FindByID(ctx, m.ID); err == nil && refreshed != nil {
|
||||
if path, err := WriteMediaNFO(refreshed); err != nil {
|
||||
s.log.Warn("write nfo after scrape failed", zap.String("media_id", m.ID), zap.Error(err))
|
||||
} else {
|
||||
s.log.Debug("write nfo after scrape", zap.String("media_id", m.ID), zap.String("path", path))
|
||||
}
|
||||
}
|
||||
s.hub.Publish("scrape", map[string]any{
|
||||
"media_id": m.ID,
|
||||
"title": match.Title,
|
||||
"tmdb_id": match.TMDbID,
|
||||
"bangumi_id": match.BangumiID,
|
||||
"source": map[bool]string{true: "adult"}[match.NSFW],
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScraperService) applyLocalMetadataMatch(ctx context.Context, m *model.Media, local *LocalMetadata) error {
|
||||
next := *m
|
||||
applyLocalMetadata(&next, local)
|
||||
updates := map[string]any{
|
||||
"title": next.Title,
|
||||
"scrape_status": "matched",
|
||||
}
|
||||
if next.OriginalName != "" {
|
||||
updates["original_name"] = next.OriginalName
|
||||
}
|
||||
if next.Overview != "" {
|
||||
updates["overview"] = next.Overview
|
||||
}
|
||||
if next.PosterURL != "" {
|
||||
updates["poster_url"] = next.PosterURL
|
||||
}
|
||||
if next.BackdropURL != "" {
|
||||
updates["backdrop_url"] = next.BackdropURL
|
||||
}
|
||||
if next.Rating > 0 {
|
||||
updates["rating"] = next.Rating
|
||||
}
|
||||
if next.Year > 0 {
|
||||
updates["year"] = next.Year
|
||||
}
|
||||
if next.TMDbID > 0 {
|
||||
updates["tm_db_id"] = next.TMDbID
|
||||
}
|
||||
if next.BangumiID > 0 {
|
||||
updates["bangumi_id"] = next.BangumiID
|
||||
}
|
||||
if next.SeasonNum > 0 {
|
||||
updates["season_num"] = next.SeasonNum
|
||||
}
|
||||
if next.EpisodeNum > 0 {
|
||||
updates["episode_num"] = next.EpisodeNum
|
||||
}
|
||||
if next.Genres != "" {
|
||||
updates["genres"] = next.Genres
|
||||
}
|
||||
if next.Countries != "" {
|
||||
updates["countries"] = next.Countries
|
||||
}
|
||||
if next.Languages != "" {
|
||||
updates["languages"] = next.Languages
|
||||
}
|
||||
if next.NSFW {
|
||||
updates["nsfw"] = true
|
||||
}
|
||||
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("id = ?", m.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
s.hub.Publish("scrape", map[string]any{
|
||||
"media_id": m.ID,
|
||||
"title": next.Title,
|
||||
"tmdb_id": next.TMDbID,
|
||||
"source": "local_nfo",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
add := func(raw string) {
|
||||
cleaned, _ := CleanQuery(raw)
|
||||
if cleaned == "" {
|
||||
cleaned = strings.TrimSpace(raw)
|
||||
}
|
||||
for _, candidate := range titleCandidates(cleaned) {
|
||||
key := strings.ToLower(candidate)
|
||||
if _, ok := seen[key]; ok || candidate == "" {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, candidate)
|
||||
}
|
||||
}
|
||||
if lib != nil && mediaIsEpisodic(m, lib) {
|
||||
add(seriesFolderTitle(m.Path, lib.Path))
|
||||
}
|
||||
add(m.Title)
|
||||
add(m.Path)
|
||||
if len(out) == 0 {
|
||||
out = append(out, strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
||||
dir := filepath.Dir(mediaPath)
|
||||
if seasonFromDir(filepath.Base(dir)) > 0 {
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
if libraryRoot != "" && samePath(dir, filepath.Clean(libraryRoot)) {
|
||||
return ""
|
||||
}
|
||||
base := filepath.Base(dir)
|
||||
if base == "." || base == string(filepath.Separator) {
|
||||
return ""
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func seasonFromDir(name string) int {
|
||||
if m := patSeasonFolder.FindStringSubmatch(name); len(m) >= 3 {
|
||||
for _, group := range m[1:] {
|
||||
if group != "" {
|
||||
return mustAtoi(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func titleCandidates(title string) []string {
|
||||
title = strings.Join(strings.Fields(strings.TrimSpace(title)), " ")
|
||||
if title == "" {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, 2)
|
||||
if cjk := cjkTitleOnly(title); cjk != "" {
|
||||
out = append(out, cjk)
|
||||
if cjk != title {
|
||||
return out
|
||||
}
|
||||
}
|
||||
out = append(out, title)
|
||||
return out
|
||||
}
|
||||
|
||||
func cjkTitleOnly(title string) string {
|
||||
parts := make([]string, 0, 4)
|
||||
for _, field := range strings.Fields(title) {
|
||||
if containsCJK(field) {
|
||||
parts = append(parts, field)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func containsCJK(s string) bool {
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= '\u3400' && r <= '\u4dbf':
|
||||
return true
|
||||
case r >= '\u4e00' && r <= '\u9fff':
|
||||
return true
|
||||
case r >= '\uf900' && r <= '\ufaff':
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mediaIsEpisodic(m *model.Media, lib *model.Library) bool {
|
||||
if m != nil && (m.SeasonNum > 0 || m.EpisodeNum > 0) {
|
||||
return true
|
||||
}
|
||||
return librarySupportsSeasons(lib)
|
||||
}
|
||||
|
||||
func librarySupportsSeasons(lib *model.Library) bool {
|
||||
if lib == nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(lib.Type)) {
|
||||
case "tv", "anime", "variety", "show", "shows":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// lookup runs the provider chain. When the library is missing we fall
|
||||
// back to TMDb only.
|
||||
//
|
||||
@@ -245,7 +559,7 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s
|
||||
s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err))
|
||||
}
|
||||
}
|
||||
case "tv":
|
||||
case "tv", "variety", "show", "shows":
|
||||
if s.thetvdb != nil && s.thetvdb.Enabled() {
|
||||
if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil {
|
||||
return m
|
||||
@@ -256,7 +570,7 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s
|
||||
}
|
||||
if s.tmdb != nil && s.tmdb.Enabled() {
|
||||
// anime / tv 先用 TMDb /search/tv(剧名通常是 TV 类目)。
|
||||
if kind == "anime" || kind == "tv" {
|
||||
if kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows" {
|
||||
if m, err := s.tmdb.SearchTV(ctx, query, year); err == nil && m != nil {
|
||||
return m
|
||||
} else if err != nil {
|
||||
@@ -272,15 +586,23 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnrichLibrary runs the provider chain for every "pending" media in a
|
||||
// library. It throttles to 4 RPS and publishes a summary event when done.
|
||||
// EnrichLibrary runs the provider chain for every pending media in a library.
|
||||
// When retryNoMatch is true it also retries rows previously marked no_match,
|
||||
// which is the expected behaviour for a manual "重新刮削" action. Scanner-driven
|
||||
// automatic enrichment keeps the default false path to avoid repeated scraping.
|
||||
//
|
||||
// Pending status includes both the canonical "pending" string and the
|
||||
// empty / NULL values, because MediaRepository.Upsert can wipe the GORM
|
||||
// default when re-running a scan over an already-existing row.
|
||||
func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (int, error) {
|
||||
func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string, retryNoMatch ...bool) (int, error) {
|
||||
var rows []model.Media
|
||||
q := s.repo.DB.Where("scrape_status IS NULL OR scrape_status = '' OR scrape_status = ?", "pending")
|
||||
statusFilter := "scrape_status IS NULL OR scrape_status = '' OR scrape_status = ?"
|
||||
statusArgs := []any{"pending"}
|
||||
if len(retryNoMatch) > 0 && retryNoMatch[0] {
|
||||
statusFilter += " OR scrape_status = ?"
|
||||
statusArgs = append(statusArgs, "no_match")
|
||||
}
|
||||
q := s.repo.DB.Where(statusFilter, statusArgs...)
|
||||
if libraryID != "" {
|
||||
q = q.Where("library_id = ?", libraryID)
|
||||
}
|
||||
@@ -288,6 +610,7 @@ func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (i
|
||||
return 0, err
|
||||
}
|
||||
matched := 0
|
||||
processed := 0
|
||||
for i := range rows {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -298,17 +621,30 @@ func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (i
|
||||
s.log.Warn("enrich failed", zap.String("media", rows[i].ID), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
processed++
|
||||
if s.mediaIsMatched(ctx, rows[i].ID) {
|
||||
matched++
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond) // ~4 RPS
|
||||
}
|
||||
s.hub.Publish("scrape", map[string]any{
|
||||
"library_id": libraryID,
|
||||
"finished": true,
|
||||
"matched": matched,
|
||||
"processed": processed,
|
||||
})
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (s *ScraperService) mediaIsMatched(ctx context.Context, mediaID string) bool {
|
||||
var status string
|
||||
err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Select("scrape_status").
|
||||
Where("id = ?", mediaID).
|
||||
Scan(&status).Error
|
||||
return err == nil && status == "matched"
|
||||
}
|
||||
|
||||
// AnyEnabled reports whether at least one provider can run.
|
||||
func (s *ScraperService) AnyEnabled() bool {
|
||||
if s.tmdb != nil && s.tmdb.Enabled() {
|
||||
@@ -320,6 +656,9 @@ func (s *ScraperService) AnyEnabled() bool {
|
||||
if s.thetvdb != nil && s.thetvdb.Enabled() {
|
||||
return true
|
||||
}
|
||||
if s.adult != nil && s.adult.Enabled() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -328,7 +667,7 @@ func (s *ScraperService) AnyEnabled() bool {
|
||||
func (s *ScraperService) determineMediaType(lib *model.Library, match *Match) string {
|
||||
if lib != nil {
|
||||
switch lib.Type {
|
||||
case "tv", "anime":
|
||||
case "tv", "anime", "variety", "show", "shows":
|
||||
return "tv"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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 TestCleanQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
@@ -13,6 +29,7 @@ func TestCleanQuery(t *testing.T) {
|
||||
{"interstellar.2014.4k.hdr.dts.atmos.mkv", "interstellar", 2014},
|
||||
{"My Movie 2022 [HDR] (1080p) [TGx].mp4", "my movie", 2022},
|
||||
{"NoYearOrTags.mkv", "noyearortags", 0},
|
||||
{"亏成首富从游戏开始 The Richest in Game - S01E11 - 4K.mp4", "亏成首富从游戏开始 the richest in game", 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
@@ -24,3 +41,215 @@ func TestCleanQuery(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrapeQueryCandidatesPreferSeriesFolderAndCJKTitle(t *testing.T) {
|
||||
lib := &model.Library{
|
||||
Path: `F:\downloads\国产剧`,
|
||||
Type: "movie",
|
||||
}
|
||||
media := &model.Media{
|
||||
Title: "亏成首富从游戏开始 the ri est in game",
|
||||
Path: `F:\downloads\国产剧\亏成首富从游戏开始 The Richest in Game\Season 01\亏成首富从游戏开始 The Richest in Game - S01E11 - 4K.mp4`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 11,
|
||||
}
|
||||
|
||||
got := scrapeQueryCandidates(media, lib)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("scrapeQueryCandidates returned no candidates")
|
||||
}
|
||||
if got[0] != "亏成首富从游戏开始" {
|
||||
t.Fatalf("first query candidate = %q, want Chinese series title", got[0])
|
||||
}
|
||||
for _, candidate := range got {
|
||||
if strings.Contains(candidate, "ri est") {
|
||||
t.Fatalf("query candidate kept substring-stripped title: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichOneWritesTMDbIDColumn(t *testing.T) {
|
||||
scraper, repos, closeServer := newTestScraper(t)
|
||||
defer closeServer()
|
||||
|
||||
lib := model.Library{Name: "番剧", Path: t.TempDir(), Type: "tv", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(lib.Path, "间谍过家家 - S02E01.mkv")
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "间谍过家家",
|
||||
Path: mediaPath,
|
||||
SeasonNum: 2,
|
||||
EpisodeNum: 1,
|
||||
ScrapeStatus: "pending",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var media model.Media
|
||||
if err := repos.DB.First(&media, "path = ?", mediaPath).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ScrapeStatus != "matched" || got.TMDbID != 12345 {
|
||||
t.Fatalf("unexpected scraped media: status=%q tmdb=%d", got.ScrapeStatus, got.TMDbID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichOnePrefersLocalMetadataWithoutProvider(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
log := zap.NewNop()
|
||||
scraper := NewScraperService(&config.Config{}, log, repos, nil, nil, nil, nil, NewHub(log))
|
||||
|
||||
root := t.TempDir()
|
||||
showDir := filepath.Join(root, "间谍过家家")
|
||||
seasonDir := filepath.Join(showDir, "Season 02")
|
||||
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`<tvshow>
|
||||
<title>间谍过家家</title>
|
||||
<year>2022</year>
|
||||
<tmdbid>120089</tmdbid>
|
||||
<genre>Animation</genre>
|
||||
</tvshow>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(seasonDir, "间谍过家家 - S02E12.mkv")
|
||||
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<episodedetails>
|
||||
<title>企鹅公园</title>
|
||||
<showtitle>间谍过家家</showtitle>
|
||||
<season>2</season>
|
||||
<episode>12</episode>
|
||||
<plot>本地剧情</plot>
|
||||
</episodedetails>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lib := model.Library{Name: "番剧", Path: root, Type: "tv", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "bad title",
|
||||
Path: mediaPath,
|
||||
ScrapeStatus: "pending",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ScrapeStatus != "matched" || got.Title != "间谍过家家" || got.TMDbID != 120089 {
|
||||
t.Fatalf("unexpected local scrape: status=%q title=%q tmdb=%d", got.ScrapeStatus, got.Title, got.TMDbID)
|
||||
}
|
||||
if got.SeasonNum != 2 || got.EpisodeNum != 12 || got.Overview != "本地剧情" {
|
||||
t.Fatalf("unexpected local episode data: s=%d e=%d overview=%q", got.SeasonNum, got.EpisodeNum, got.Overview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualEnrichLibraryRetriesNoMatchAndCountsRealMatches(t *testing.T) {
|
||||
scraper, repos, closeServer := newTestScraper(t)
|
||||
defer closeServer()
|
||||
|
||||
lib := model.Library{Name: "番剧", Path: t.TempDir(), Type: "tv", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mediaPath := filepath.Join(lib.Path, "间谍过家家 - S02E02.mkv")
|
||||
if err := repos.DB.Create(&model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "间谍过家家",
|
||||
Path: mediaPath,
|
||||
SeasonNum: 2,
|
||||
EpisodeNum: 2,
|
||||
ScrapeStatus: "no_match",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if matched, err := scraper.EnrichLibrary(t.Context(), lib.ID); err != nil || matched != 0 {
|
||||
t.Fatalf("default EnrichLibrary matched=%d err=%v, want skipped no_match", matched, err)
|
||||
}
|
||||
if matched, err := scraper.EnrichLibrary(t.Context(), lib.ID, true); err != nil || matched != 1 {
|
||||
t.Fatalf("manual EnrichLibrary matched=%d err=%v, want one real match", matched, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestScraper(t *testing.T) (*ScraperService, *repository.Container, func()) {
|
||||
t.Helper()
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/search/tv"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"results": []map[string]any{{
|
||||
"id": 12345,
|
||||
"name": "间谍过家家",
|
||||
"overview": "测试简介",
|
||||
"poster_path": "/poster.jpg",
|
||||
"backdrop_path": "/backdrop.jpg",
|
||||
"first_air_date": "2022-04-09",
|
||||
"vote_average": 8.6,
|
||||
}},
|
||||
})
|
||||
case strings.HasPrefix(r.URL.Path, "/tv/12345"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"origin_country": []string{"JP"},
|
||||
"spoken_languages": []map[string]any{{
|
||||
"iso_639_1": "ja",
|
||||
}},
|
||||
"genres": []map[string]any{{
|
||||
"name": "Animation",
|
||||
}},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
upstream.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
|
||||
upstream.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||
cfg.Secrets.TMDbImageProxy = upstream.URL + "/images"
|
||||
log := zap.NewNop()
|
||||
tmdb := NewTMDbProvider(cfg, log, nil)
|
||||
scraper := NewScraperService(cfg, log, repos, tmdb, nil, nil, nil, NewHub(log))
|
||||
|
||||
return scraper, repos, upstream.Close
|
||||
}
|
||||
|
||||
+111
-108
@@ -15,58 +15,58 @@ import (
|
||||
|
||||
// Container 持有在启动时初始化的每个服务。Handler 接收指向它的指针并选择相关字段。
|
||||
type Container struct {
|
||||
Cfg *config.Config
|
||||
Log *zap.Logger
|
||||
Repo *repository.Container
|
||||
WSHub *Hub
|
||||
SSEHub *SSEHub
|
||||
Auth *AuthService
|
||||
Media *MediaService
|
||||
Scan *ScannerService
|
||||
Stream *StreamService
|
||||
Transcoder *TranscoderService
|
||||
FFprobe *FFprobeService
|
||||
TMDb *TMDbProvider
|
||||
Bangumi *BangumiProvider
|
||||
TheTVDB *TheTVDBProvider
|
||||
Fanart *FanartProvider
|
||||
Scraper *ScraperService
|
||||
Discover *DiscoverService
|
||||
Playback *PlaybackService
|
||||
ImageProxy *ImageProxy
|
||||
Watcher *WatcherService
|
||||
Downloads *DownloadService
|
||||
Subscription *SubscriptionService
|
||||
Subtitle *SubtitleService
|
||||
Stats *StatsService
|
||||
Profile *ProfileService
|
||||
Audit *AuditService
|
||||
NFO *NFOService
|
||||
AI *AIService
|
||||
APIConfig *APIConfigService
|
||||
Crypto *CryptoService
|
||||
Duplicate *DuplicateService
|
||||
FileManager *FileManagerService
|
||||
DLNA *DLNAService
|
||||
Scheduler *SchedulerService
|
||||
Storage *StorageService
|
||||
Emby *EmbyService
|
||||
Backup *BackupService
|
||||
Notifier *NotifierService
|
||||
NotifyChannels *NotifyChannelService
|
||||
TelegramBot *TelegramBotService
|
||||
PlayProfiles *PlayProfileService
|
||||
Permissions *PermissionService
|
||||
StorageCfg *StorageConfigService
|
||||
Cfg *config.Config
|
||||
Log *zap.Logger
|
||||
Repo *repository.Container
|
||||
WSHub *Hub
|
||||
SSEHub *SSEHub
|
||||
Auth *AuthService
|
||||
Media *MediaService
|
||||
Scan *ScannerService
|
||||
Stream *StreamService
|
||||
Transcoder *TranscoderService
|
||||
FFprobe *FFprobeService
|
||||
TMDb *TMDbProvider
|
||||
Bangumi *BangumiProvider
|
||||
TheTVDB *TheTVDBProvider
|
||||
Fanart *FanartProvider
|
||||
Scraper *ScraperService
|
||||
Discover *DiscoverService
|
||||
Playback *PlaybackService
|
||||
ImageProxy *ImageProxy
|
||||
Watcher *WatcherService
|
||||
Downloads *DownloadService
|
||||
Subscription *SubscriptionService
|
||||
Subtitle *SubtitleService
|
||||
Stats *StatsService
|
||||
Profile *ProfileService
|
||||
Audit *AuditService
|
||||
NFO *NFOService
|
||||
AI *AIService
|
||||
APIConfig *APIConfigService
|
||||
Crypto *CryptoService
|
||||
Duplicate *DuplicateService
|
||||
FileManager *FileManagerService
|
||||
DLNA *DLNAService
|
||||
Scheduler *SchedulerService
|
||||
Storage *StorageService
|
||||
Emby *EmbyService
|
||||
Backup *BackupService
|
||||
Notifier *NotifierService
|
||||
NotifyChannels *NotifyChannelService
|
||||
TelegramBot *TelegramBotService
|
||||
PlayProfiles *PlayProfileService
|
||||
Permissions *PermissionService
|
||||
StorageCfg *StorageConfigService
|
||||
DownloadClients *DownloadClientService
|
||||
Assistant *AssistantService
|
||||
Organizer *OrganizerService
|
||||
Douban *DoubanProvider
|
||||
Token *TokenService
|
||||
ApiConfig *ApiConfigService
|
||||
DownloadMgr *DownloadManager
|
||||
Notify *NotifyService
|
||||
Site *SiteService
|
||||
Assistant *AssistantService
|
||||
Organizer *OrganizerService
|
||||
Douban *DoubanProvider
|
||||
Token *TokenService
|
||||
ApiConfig *ApiConfigService
|
||||
DownloadMgr *DownloadManager
|
||||
Notify *NotifyService
|
||||
Site *SiteService
|
||||
|
||||
stopCtx context.Context
|
||||
stopCancel context.CancelFunc
|
||||
@@ -74,6 +74,8 @@ type Container struct {
|
||||
|
||||
// New 构建服务容器。
|
||||
func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Container {
|
||||
ApplyRuntimeSettings(context.Background(), cfg, repos, log)
|
||||
|
||||
hub := NewHub(log)
|
||||
go hub.Run()
|
||||
|
||||
@@ -88,13 +90,12 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
bangumi := NewBangumiProvider(cfg, log)
|
||||
thetvdb := NewTheTVDBProvider(cfg, log)
|
||||
fanart := NewFanartProvider(cfg, log)
|
||||
scraper := NewScraperService(cfg, log, repos, tmdb, bangumi, thetvdb, fanart, hub)
|
||||
adult := NewAdultProvider(log, apiConfig)
|
||||
scraper := NewScraperService(cfg, log, repos, tmdb, bangumi, thetvdb, fanart, hub, adult)
|
||||
organizer := NewOrganizerService(cfg, log, repos)
|
||||
downloads := NewDownloadService(log, repos, hub, organizer)
|
||||
discover := NewDiscoverService(log, tmdb)
|
||||
transcoder := NewTranscoderService(cfg, log, repos, hub)
|
||||
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
|
||||
subscription := NewSubscriptionService(log, repos, downloads, hub)
|
||||
watcher := NewWatcherService(log, repos, scanner)
|
||||
nfo := NewNFOService(log, repos)
|
||||
ai := NewAIService(cfg, log)
|
||||
@@ -120,71 +121,73 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
apiConfigSvc := NewApiConfigService(cfg, log, repos, crypto)
|
||||
downloadMgr := NewDownloadManager(log, repos, crypto)
|
||||
notifySvc := NewNotifyService(log, repos, crypto)
|
||||
|
||||
|
||||
// 构建 FlareSolverr URL(如果启用)
|
||||
flareSolverrURL := ""
|
||||
if cfg.FlareSolverr.Enabled && cfg.FlareSolverr.URL != "" {
|
||||
flareSolverrURL = cfg.FlareSolverr.URL
|
||||
}
|
||||
siteSvc := NewSiteService(log, repos, flareSolverrURL)
|
||||
downloads := NewDownloadService(log, repos, hub, organizer, siteSvc)
|
||||
subscription := NewSubscriptionService(cfg, log, repos, downloads, siteSvc, hub)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Container{
|
||||
Cfg: cfg,
|
||||
Log: log,
|
||||
Repo: repos,
|
||||
WSHub: hub,
|
||||
SSEHub: sseHub,
|
||||
Auth: NewAuthService(cfg, log, repos, tokenSvc, permissions),
|
||||
Media: NewMediaService(cfg, log, repos),
|
||||
Scan: scanner,
|
||||
Stream: NewStreamService(cfg, log, repos, transcoder),
|
||||
Transcoder: transcoder,
|
||||
FFprobe: probe,
|
||||
TMDb: tmdb,
|
||||
Bangumi: bangumi,
|
||||
TheTVDB: thetvdb,
|
||||
Fanart: fanart,
|
||||
Scraper: scraper,
|
||||
Discover: discover,
|
||||
Playback: NewPlaybackService(log, repos),
|
||||
ImageProxy: NewImageProxy(cfg, log),
|
||||
Watcher: watcher,
|
||||
Downloads: downloads,
|
||||
Subscription: subscription,
|
||||
Subtitle: NewSubtitleService(log, repos),
|
||||
Stats: NewStatsService(log, repos),
|
||||
Profile: NewProfileService(log, repos),
|
||||
Audit: NewAuditService(log, repos),
|
||||
NFO: nfo,
|
||||
AI: ai,
|
||||
APIConfig: apiConfig,
|
||||
Crypto: crypto,
|
||||
Duplicate: duplicate,
|
||||
FileManager: filemanager,
|
||||
DLNA: dlna,
|
||||
Scheduler: scheduler,
|
||||
Storage: storage,
|
||||
Emby: emby,
|
||||
Backup: backup,
|
||||
Notifier: notifier,
|
||||
NotifyChannels: notifyChannels,
|
||||
TelegramBot: telegramBot,
|
||||
PlayProfiles: playProfiles,
|
||||
Permissions: permissions,
|
||||
StorageCfg: storageCfg,
|
||||
Cfg: cfg,
|
||||
Log: log,
|
||||
Repo: repos,
|
||||
WSHub: hub,
|
||||
SSEHub: sseHub,
|
||||
Auth: NewAuthService(cfg, log, repos, tokenSvc, permissions),
|
||||
Media: NewMediaService(cfg, log, repos),
|
||||
Scan: scanner,
|
||||
Stream: NewStreamService(cfg, log, repos, transcoder),
|
||||
Transcoder: transcoder,
|
||||
FFprobe: probe,
|
||||
TMDb: tmdb,
|
||||
Bangumi: bangumi,
|
||||
TheTVDB: thetvdb,
|
||||
Fanart: fanart,
|
||||
Scraper: scraper,
|
||||
Discover: discover,
|
||||
Playback: NewPlaybackService(log, repos),
|
||||
ImageProxy: NewImageProxy(cfg, log),
|
||||
Watcher: watcher,
|
||||
Downloads: downloads,
|
||||
Subscription: subscription,
|
||||
Subtitle: NewSubtitleService(log, repos),
|
||||
Stats: NewStatsService(log, repos),
|
||||
Profile: NewProfileService(log, repos),
|
||||
Audit: NewAuditService(log, repos),
|
||||
NFO: nfo,
|
||||
AI: ai,
|
||||
APIConfig: apiConfig,
|
||||
Crypto: crypto,
|
||||
Duplicate: duplicate,
|
||||
FileManager: filemanager,
|
||||
DLNA: dlna,
|
||||
Scheduler: scheduler,
|
||||
Storage: storage,
|
||||
Emby: emby,
|
||||
Backup: backup,
|
||||
Notifier: notifier,
|
||||
NotifyChannels: notifyChannels,
|
||||
TelegramBot: telegramBot,
|
||||
PlayProfiles: playProfiles,
|
||||
Permissions: permissions,
|
||||
StorageCfg: storageCfg,
|
||||
DownloadClients: downloadClients,
|
||||
Assistant: assistant,
|
||||
Organizer: organizer,
|
||||
Douban: douban,
|
||||
Token: tokenSvc,
|
||||
ApiConfig: apiConfigSvc,
|
||||
DownloadMgr: downloadMgr,
|
||||
Notify: notifySvc,
|
||||
Site: siteSvc,
|
||||
stopCtx: ctx,
|
||||
stopCancel: cancel,
|
||||
Assistant: assistant,
|
||||
Organizer: organizer,
|
||||
Douban: douban,
|
||||
Token: tokenSvc,
|
||||
ApiConfig: apiConfigSvc,
|
||||
DownloadMgr: downloadMgr,
|
||||
Notify: notifySvc,
|
||||
Site: siteSvc,
|
||||
stopCtx: ctx,
|
||||
stopCancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+148
-2
@@ -9,6 +9,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -24,11 +30,149 @@ import (
|
||||
|
||||
// SiteService manages PT/BT site configurations.
|
||||
type SiteService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
flareSolverrURL string
|
||||
}
|
||||
|
||||
// ResolveDownloadURL converts tracker-specific search result URLs into a URL
|
||||
// that a downloader can fetch directly. M-Team, NexusPHP and similar sites
|
||||
// often expose a signed/detail endpoint in search results; qBittorrent cannot
|
||||
// call those APIs with the configured site credentials, so subscriptions need
|
||||
// the same resolution path as the manual download button.
|
||||
func (s *SiteService) ResolveDownloadURL(ctx context.Context, raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
}
|
||||
matched := s.matchSiteForURL(ctx, raw)
|
||||
if matched == nil {
|
||||
return raw
|
||||
}
|
||||
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return raw
|
||||
}
|
||||
id := u.Query().Get("id")
|
||||
if id == "" {
|
||||
return raw
|
||||
}
|
||||
adapter := GetAdapterForType(matched.Type)
|
||||
if adapter == nil {
|
||||
return raw
|
||||
}
|
||||
cfg := s.siteModelToConfig(matched)
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 15 * time.Second
|
||||
}
|
||||
resolveCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
resolved, err := adapter.GetDownloadURL(resolveCtx, cfg, id)
|
||||
if err != nil || resolved == "" {
|
||||
s.log.Warn("resolve PT download URL failed",
|
||||
zap.String("site", matched.Name),
|
||||
zap.String("raw", raw),
|
||||
zap.Error(err))
|
||||
return raw
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func (s *SiteService) FetchTorrentFile(ctx context.Context, raw string) ([]byte, string, error) {
|
||||
matched := s.matchSiteForURL(ctx, raw)
|
||||
if matched == nil {
|
||||
return nil, "", errors.New("no matching PT site for torrent URL")
|
||||
}
|
||||
cfg := s.siteModelToConfig(matched)
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
req, err := buildRequest(ctx, http.MethodGet, raw, cfg, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/x-bittorrent,application/octet-stream,*/*")
|
||||
client := newHTTPClient(cfg, timeout)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, "", fmt.Errorf("torrent fetch: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
const maxTorrentSize = 32 << 20
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxTorrentSize+1))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, "", errors.New("torrent fetch: empty body")
|
||||
}
|
||||
if len(data) > maxTorrentSize {
|
||||
return nil, "", errors.New("torrent fetch: body too large")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") {
|
||||
return nil, "", errors.New("torrent fetch: upstream returned HTML")
|
||||
}
|
||||
if torrentInfoHash(data) == "" {
|
||||
return nil, "", errors.New("torrent fetch: upstream did not return a valid torrent")
|
||||
}
|
||||
return data, torrentFilename(raw, resp.Header.Get("Content-Disposition")), nil
|
||||
}
|
||||
|
||||
func (s *SiteService) matchSiteForURL(ctx context.Context, raw string) *model.Site {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return nil
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
|
||||
sites, err := s.List(ctx)
|
||||
if err != nil || len(sites) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range sites {
|
||||
if siteHostMatches(host, sites[i].URL) || siteHostMatches(host, sites[i].RSSURL) {
|
||||
return &sites[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func siteHostMatches(host, raw string) bool {
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
siteHost := strings.ToLower(u.Host)
|
||||
return strings.EqualFold(siteHost, host) || strings.HasSuffix(host, "."+siteHost)
|
||||
}
|
||||
|
||||
func torrentFilename(rawURL, disposition string) string {
|
||||
if disposition != "" {
|
||||
if _, params, err := mime.ParseMediaType(disposition); err == nil {
|
||||
if filename := strings.TrimSpace(params["filename"]); filename != "" {
|
||||
return filename
|
||||
}
|
||||
}
|
||||
}
|
||||
if u, err := url.Parse(rawURL); err == nil {
|
||||
if name := strings.TrimSpace(path.Base(u.Path)); name != "" && name != "." && name != "/" {
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".torrent") {
|
||||
name += ".torrent"
|
||||
}
|
||||
return name
|
||||
}
|
||||
}
|
||||
return "download.torrent"
|
||||
}
|
||||
|
||||
// NewSiteService is the constructor.
|
||||
func NewSiteService(log *zap.Logger, repo *repository.Container, flareSolverrURL string) *SiteService {
|
||||
return &SiteService{log: log, repo: repo, flareSolverrURL: flareSolverrURL}
|
||||
@@ -164,6 +308,7 @@ type SearchResult struct {
|
||||
Title string `json:"title"`
|
||||
TorrentURL string `json:"torrent_url"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
Seeders int `json:"seeders"`
|
||||
Leechers int `json:"leechers"`
|
||||
@@ -235,6 +380,7 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul
|
||||
Title: item.Title,
|
||||
TorrentURL: item.DetailURL,
|
||||
DownloadURL: item.DownloadURL,
|
||||
Category: item.Category,
|
||||
Size: item.Size,
|
||||
Seeders: item.Seeders,
|
||||
Leechers: item.Leechers,
|
||||
|
||||
@@ -19,6 +19,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -103,10 +104,42 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
||||
stat, _ := f.Stat()
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if r.URL.RawQuery != "" {
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
playlist := appendQueryToHLSSegments(string(data), r.URL.RawQuery)
|
||||
_, err = io.WriteString(w, playlist)
|
||||
return err
|
||||
}
|
||||
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendQueryToHLSSegments(playlist, rawQuery string) string {
|
||||
if strings.TrimSpace(rawQuery) == "" {
|
||||
return playlist
|
||||
}
|
||||
lines := strings.SplitAfter(playlist, "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.Contains(trimmed, "?") {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(trimmed), ".ts") {
|
||||
lineEnding := ""
|
||||
if strings.HasSuffix(line, "\r\n") {
|
||||
lineEnding = "\r\n"
|
||||
} else if strings.HasSuffix(line, "\n") {
|
||||
lineEnding = "\n"
|
||||
}
|
||||
lines[i] = strings.TrimRight(line, "\r\n") + "?" + rawQuery + lineEnding
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
|
||||
// ServeHLSSegment writes a single .ts segment from the on-disk cache.
|
||||
func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request, mediaID, segment string) error {
|
||||
// Only allow segments that look like seg_NNNNN.ts so we cannot be tricked
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppendQueryToHLSSegments(t *testing.T) {
|
||||
in := "#EXTM3U\n#EXTINF:4.0,\nseg_00000.ts\n#EXTINF:4.0,\nseg_00001.ts?old=1\n"
|
||||
got := appendQueryToHLSSegments(in, "token=abc")
|
||||
if !strings.Contains(got, "seg_00000.ts?token=abc") {
|
||||
t.Fatalf("missing tokenized segment: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "seg_00001.ts?old=1") {
|
||||
t.Fatalf("existing query should be preserved: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -13,30 +13,39 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// SubscriptionService runs the polling loop.
|
||||
type SubscriptionService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
downloads *DownloadService
|
||||
site *SiteService
|
||||
hub *Hub
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
// NewSubscriptionService is the constructor.
|
||||
func NewSubscriptionService(log *zap.Logger, repo *repository.Container, downloads *DownloadService, hub *Hub) *SubscriptionService {
|
||||
func NewSubscriptionService(cfg *config.Config, log *zap.Logger, repo *repository.Container, downloads *DownloadService, site *SiteService, hub *Hub) *SubscriptionService {
|
||||
return &SubscriptionService{
|
||||
log: log, repo: repo, downloads: downloads, hub: hub,
|
||||
stop: make(chan struct{}),
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repo,
|
||||
downloads: downloads,
|
||||
site: site,
|
||||
hub: hub,
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +78,17 @@ func (s *SubscriptionService) Create(ctx context.Context, sub *model.Subscriptio
|
||||
if sub.Name == "" || sub.FeedURL == "" {
|
||||
return errors.New("name and feed_url required")
|
||||
}
|
||||
return s.repo.Subscription.Create(ctx, sub)
|
||||
enabled := sub.Enabled
|
||||
if err := s.repo.Subscription.Create(ctx, sub); err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
if err := s.repo.DB.WithContext(ctx).Model(sub).Update("enabled", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
sub.Enabled = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns every subscription rule.
|
||||
@@ -133,6 +152,10 @@ func (s *SubscriptionService) runAll(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscription) (int, error) {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(sub.FeedURL)), "site-search://") {
|
||||
return s.runSiteSearch(ctx, sub)
|
||||
}
|
||||
|
||||
feed, err := s.fetch(ctx, sub.FeedURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -166,9 +189,15 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
|
||||
if download == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := s.downloads.AddDownload(ctx, sub.UserID, download, ""); err != nil {
|
||||
mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, "")
|
||||
savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory)
|
||||
if _, err := s.downloads.AddDownload(ctx, sub.UserID, download, savePath); err != nil {
|
||||
s.log.Warn("subscription enqueue failed",
|
||||
zap.String("title", item.Title), zap.Error(err))
|
||||
zap.String("title", item.Title),
|
||||
zap.String("media_type", mediaType),
|
||||
zap.String("media_category", mediaCategory),
|
||||
zap.String("save_path", savePath),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
queued++
|
||||
@@ -192,6 +221,119 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
|
||||
return queued, nil
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subscription) (int, error) {
|
||||
if s.site == nil {
|
||||
return 0, errors.New("site search service unavailable")
|
||||
}
|
||||
keyword := siteSearchKeyword(sub)
|
||||
if keyword == "" {
|
||||
return 0, errors.New("site-search subscription keyword required")
|
||||
}
|
||||
|
||||
results, err := s.site.Search(ctx, keyword)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
now := time.Now()
|
||||
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
guidKey := fmt.Sprintf("subscription.%s.seen", sub.ID)
|
||||
seenRaw, _ := s.repo.Setting.Get(ctx, guidKey)
|
||||
seen := splitNonEmpty(seenRaw)
|
||||
seenSet := make(map[string]struct{}, len(seen))
|
||||
for _, g := range seen {
|
||||
seenSet[g] = struct{}{}
|
||||
}
|
||||
|
||||
var lastEnqueueErr error
|
||||
for _, item := range results {
|
||||
download := strings.TrimSpace(item.DownloadURL)
|
||||
if download == "" {
|
||||
download = strings.TrimSpace(item.TorrentURL)
|
||||
}
|
||||
if download == "" {
|
||||
continue
|
||||
}
|
||||
guid := download
|
||||
if _, ok := seenSet[guid]; ok {
|
||||
continue
|
||||
}
|
||||
if s.downloads != nil && s.downloads.TorrentExistsByName(ctx, item.Title) {
|
||||
seen = append(seen, guid)
|
||||
if len(seen) > 200 {
|
||||
seen = seen[len(seen)-200:]
|
||||
}
|
||||
_ = s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n"))
|
||||
now := time.Now()
|
||||
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
||||
s.hub.Publish("subscription", map[string]any{
|
||||
"id": sub.ID,
|
||||
"name": sub.Name,
|
||||
"queued": 0,
|
||||
"keyword": keyword,
|
||||
"resource": item.Title,
|
||||
"existing": true,
|
||||
})
|
||||
return 0, nil
|
||||
}
|
||||
realURL := s.site.ResolveDownloadURL(ctx, download)
|
||||
mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, item.Category)
|
||||
savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory)
|
||||
if _, err := s.downloads.AddDownload(ctx, sub.UserID, realURL, savePath); err != nil {
|
||||
lastEnqueueErr = err
|
||||
s.log.Warn("site-search subscription enqueue failed",
|
||||
zap.String("subscription", sub.Name),
|
||||
zap.String("title", item.Title),
|
||||
zap.String("site_category", item.Category),
|
||||
zap.String("media_type", mediaType),
|
||||
zap.String("media_category", mediaCategory),
|
||||
zap.String("save_path", savePath),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
seen = append(seen, guid)
|
||||
if len(seen) > 200 {
|
||||
seen = seen[len(seen)-200:]
|
||||
}
|
||||
_ = s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n"))
|
||||
now := time.Now()
|
||||
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
||||
s.hub.Publish("subscription", map[string]any{
|
||||
"id": sub.ID,
|
||||
"name": sub.Name,
|
||||
"queued": 1,
|
||||
"keyword": keyword,
|
||||
"resource": item.Title,
|
||||
})
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
||||
if lastEnqueueErr != nil {
|
||||
return 0, fmt.Errorf("找到 PT 资源但加入下载器失败: %w", lastEnqueueErr)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func siteSearchKeyword(sub *model.Subscription) string {
|
||||
if sub == nil {
|
||||
return ""
|
||||
}
|
||||
if u, err := url.Parse(sub.FeedURL); err == nil {
|
||||
if keyword := strings.TrimSpace(u.Query().Get("keyword")); keyword != "" {
|
||||
return keyword
|
||||
}
|
||||
}
|
||||
if keyword := strings.TrimSpace(sub.Filter); keyword != "" {
|
||||
return keyword
|
||||
}
|
||||
return strings.TrimSpace(sub.Name)
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) fetch(ctx context.Context, feedURL string) (*rssFeed, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -77,7 +77,7 @@ func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]Subti
|
||||
candidates = append(candidates, filepath.Join(dir, sub))
|
||||
}
|
||||
|
||||
var tracks []SubtitleTrack
|
||||
tracks := make([]SubtitleTrack, 0)
|
||||
for _, c := range candidates {
|
||||
entries, err := os.ReadDir(c)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,36 +1,44 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"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 TestSrtToVTT(t *testing.T) {
|
||||
in := "1\n00:00:01,000 --> 00:00:02,500\nHello world\n\n"
|
||||
out := srtToVTT(in)
|
||||
if !strings.HasPrefix(out, "WEBVTT") {
|
||||
t.Fatalf("missing WEBVTT prefix: %q", out)
|
||||
func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "00:00:01.000 --> 00:00:02.500") {
|
||||
t.Fatalf("comma timecode not converted: %q", out)
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "Hello world") {
|
||||
t.Fatalf("dialogue lost: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripASSTags(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"{\\an8}hello", "hello"},
|
||||
{"plain", "plain"},
|
||||
{"a{\\fad(0,500)}b{\\b1}c", "abc"},
|
||||
dir := t.TempDir()
|
||||
media := model.Media{
|
||||
Title: "No Subtitles",
|
||||
Path: filepath.Join(dir, "No Subtitles.mkv"),
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := stripASSTags(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("stripASSTags(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
if err := db.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewSubtitleService(zap.NewNop(), repository.New(db))
|
||||
tracks, err := svc.Discover(t.Context(), media.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tracks == nil {
|
||||
t.Fatal("tracks is nil, want empty slice")
|
||||
}
|
||||
if len(tracks) != 0 {
|
||||
t.Fatalf("len(tracks) = %d, want 0", len(tracks))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@ type TheTVDBProvider struct {
|
||||
log *zap.Logger
|
||||
client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
tokenExp time.Time
|
||||
mu sync.Mutex
|
||||
token string
|
||||
tokenExp time.Time
|
||||
}
|
||||
|
||||
// NewTheTVDBProvider is the constructor.
|
||||
@@ -42,7 +42,7 @@ func NewTheTVDBProvider(cfg *config.Config, log *zap.Logger) *TheTVDBProvider {
|
||||
return &TheTVDBProvider{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
client: NewExternalHTTPClient(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,11 +110,11 @@ func (t *TheTVDBProvider) SearchSeries(ctx context.Context, query string) (*Matc
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview"`
|
||||
Image string `json:"image_url"`
|
||||
Year string `json:"year"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview"`
|
||||
Image string `json:"image_url"`
|
||||
Year string `json:"year"`
|
||||
}
|
||||
type page struct {
|
||||
Data []entry `json:"data"`
|
||||
|
||||
+19
-27
@@ -7,8 +7,8 @@
|
||||
//
|
||||
// We only call the two endpoints the scrape pipeline actually needs:
|
||||
//
|
||||
// GET /search/movie?query=...&year=...
|
||||
// GET /movie/{id}?language=zh-CN
|
||||
// GET /search/movie?query=...&year=...
|
||||
// GET /movie/{id}?language=zh-CN
|
||||
//
|
||||
// TV / anime support follows the same pattern; for the bootstrap we expose
|
||||
// a single SearchMovie path so that the home page and library gallery can
|
||||
@@ -60,18 +60,8 @@ func NewTMDbProvider(cfg *config.Config, log *zap.Logger, apiConfig *APIConfigSe
|
||||
imgCDN: img,
|
||||
// 默认 8s 超时:首页同时调 trending + popular,15s 太久会让用户感觉
|
||||
// 卡死。如果 TMDb 真有问题,handler 层会快速降级返回空列表。
|
||||
// 同时让 client 显式读 HTTP(S)_PROXY 环境变量——这是 GFW 内部署最低
|
||||
// 成本能拉到 TMDb 的方式。
|
||||
client: &http.Client{
|
||||
Timeout: 8 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
MaxIdleConns: 16,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
TLSHandshakeTimeout: 8 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
},
|
||||
// 同时让 client 读取 HTTP(S)_PROXY 与 Windows 本机系统代理。
|
||||
client: NewExternalHTTPClient(8 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,17 +124,19 @@ func (t *TMDbProvider) resolveBaseURL(ctx context.Context) string {
|
||||
// across providers; provider-specific IDs sit side-by-side so the scraper
|
||||
// orchestrator can write them all into a single update.
|
||||
type Match struct {
|
||||
TMDbID int `json:"tmdb_id"`
|
||||
BangumiID int `json:"bangumi_id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
PosterURL string `json:"poster_url"`
|
||||
BackdropURL string `json:"backdrop_url"`
|
||||
Year int `json:"year"`
|
||||
Rating float32 `json:"rating"`
|
||||
Languages []string `json:"languages,omitempty"`
|
||||
Countries []string `json:"countries,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
TMDbID int `json:"tmdb_id"`
|
||||
BangumiID int `json:"bangumi_id"`
|
||||
Title string `json:"title"`
|
||||
OriginalName string `json:"original_name,omitempty"`
|
||||
Overview string `json:"overview"`
|
||||
PosterURL string `json:"poster_url"`
|
||||
BackdropURL string `json:"backdrop_url"`
|
||||
Year int `json:"year"`
|
||||
Rating float32 `json:"rating"`
|
||||
Languages []string `json:"languages,omitempty"`
|
||||
Countries []string `json:"countries,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
NSFW bool `json:"nsfw,omitempty"`
|
||||
}
|
||||
|
||||
// SearchMovie issues `/search/movie` and returns the best match, or nil
|
||||
@@ -326,7 +318,7 @@ func (t *TMDbProvider) GetDetails(ctx context.Context, tmdbID int, mediaType str
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type movieResult struct {
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
ProductionCountries []struct {
|
||||
Iso3166_1 string `json:"iso_3166_1"`
|
||||
} `json:"production_countries"`
|
||||
@@ -336,7 +328,7 @@ func (t *TMDbProvider) GetDetails(ctx context.Context, tmdbID int, mediaType str
|
||||
Genres []genre `json:"genres"`
|
||||
}
|
||||
type tvResult struct {
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
SpokenLanguages []struct {
|
||||
Iso639_1 string `json:"iso_639_1"`
|
||||
} `json:"spoken_languages"`
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
//
|
||||
// Encoder selection (read once at startup from the config):
|
||||
//
|
||||
// transcoder.encoder = "" | "nvenc" | "qsv" | "vaapi"
|
||||
// transcoder.encoder = "" | "nvenc" | "qsv" | "vaapi"
|
||||
//
|
||||
// "" software libx264 (default; runs anywhere)
|
||||
// nvenc h264_nvenc (NVIDIA GPU, requires --gpus all on Docker)
|
||||
// qsv h264_qsv (Intel iGPU, requires /dev/dri:/dev/dri)
|
||||
// vaapi h264_vaapi (Mesa/Intel VAAPI, requires /dev/dri:/dev/dri
|
||||
// plus the kernel module loaded)
|
||||
// "" software libx264 (default; runs anywhere)
|
||||
// nvenc h264_nvenc (NVIDIA GPU, requires --gpus all on Docker)
|
||||
// qsv h264_qsv (Intel iGPU, requires /dev/dri:/dev/dri)
|
||||
// vaapi h264_vaapi (Mesa/Intel VAAPI, requires /dev/dri:/dev/dri
|
||||
// plus the kernel module loaded)
|
||||
//
|
||||
// Concurrency model:
|
||||
// - Each Media has at most one active ffmpeg job.
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -40,8 +41,8 @@ import (
|
||||
|
||||
// TranscoderService orchestrates background ffmpeg transcodes.
|
||||
type TranscoderService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
hub *Hub
|
||||
|
||||
@@ -94,6 +95,9 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
if _, err := os.Stat(m.Path); err != nil {
|
||||
return "", ErrMediaNotFound
|
||||
}
|
||||
if _, err := t.resolveFFmpegPath(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
if _, ok := t.jobs[mediaID]; ok {
|
||||
@@ -191,9 +195,18 @@ func (t *TranscoderService) Active() []ActiveJob {
|
||||
}
|
||||
|
||||
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source string) {
|
||||
bin := t.cfg.App.FFmpegPath
|
||||
if bin == "" {
|
||||
bin = "ffmpeg"
|
||||
bin, err := t.resolveFFmpegPath()
|
||||
if err != nil {
|
||||
t.log.Warn("ffmpeg unavailable", zap.String("media_id", job.mediaID), zap.Error(err))
|
||||
t.mu.Lock()
|
||||
delete(t.jobs, job.mediaID)
|
||||
t.mu.Unlock()
|
||||
t.hub.Publish("transcode", map[string]any{
|
||||
"media_id": job.mediaID,
|
||||
"status": "error",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
playlist := filepath.Join(job.outputDir, "index.m3u8")
|
||||
@@ -233,6 +246,70 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source s
|
||||
})
|
||||
}
|
||||
|
||||
func (t *TranscoderService) resolveFFmpegPath() (string, error) {
|
||||
var lastErr error
|
||||
for _, bin := range executableCandidates(strings.TrimSpace(t.cfg.App.FFmpegPath), "ffmpeg") {
|
||||
if err := validateFFmpegForTranscode(context.Background(), bin, t.cfg.Transcoder.Encoder); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
t.cfg.App.FFmpegPath = bin
|
||||
return bin, nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", fmt.Errorf("no usable ffmpeg found for HLS transcode: %w", lastErr)
|
||||
}
|
||||
return "", fmt.Errorf("ffmpeg not found in PATH or common local app directories; configure app.ffmpeg_path to an existing local ffmpeg")
|
||||
}
|
||||
|
||||
func validateFFmpegForTranscode(ctx context.Context, bin, encoder string) error {
|
||||
required := requiredVideoEncoder(encoder)
|
||||
out, err := commandOutput(ctx, 8*time.Second, bin, "-hide_banner", "-encoders")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s cannot list encoders: %w", bin, err)
|
||||
}
|
||||
if !hasFFmpegListEntry(string(out), required) {
|
||||
return fmt.Errorf("%s does not provide required encoder %s", bin, required)
|
||||
}
|
||||
|
||||
out, err = commandOutput(ctx, 8*time.Second, bin, "-hide_banner", "-muxers")
|
||||
if err != nil || !hasFFmpegListEntry(string(out), "hls") {
|
||||
out, err = commandOutput(ctx, 8*time.Second, bin, "-hide_banner", "-formats")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s cannot list muxers/formats: %w", bin, err)
|
||||
}
|
||||
}
|
||||
if !hasFFmpegListEntry(string(out), "hls") {
|
||||
return fmt.Errorf("%s does not provide hls muxer", bin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requiredVideoEncoder(encoder string) string {
|
||||
switch encoder {
|
||||
case "nvenc":
|
||||
return "h264_nvenc"
|
||||
case "qsv":
|
||||
return "h264_qsv"
|
||||
case "vaapi":
|
||||
return "h264_vaapi"
|
||||
default:
|
||||
return "libx264"
|
||||
}
|
||||
}
|
||||
|
||||
func hasFFmpegListEntry(output, name string) bool {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
for _, field := range fields {
|
||||
if field == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildFFmpegArgs assembles the ffmpeg command line for the configured
|
||||
// encoder. The function is package-level so the unit test can pin its
|
||||
// behaviour without spawning a real ffmpeg process.
|
||||
@@ -317,8 +394,6 @@ func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []st
|
||||
"-f", "hls",
|
||||
"-hls_time", fmt.Sprintf("%d", segDur),
|
||||
"-hls_list_size", "0",
|
||||
"-hls_segment_type", "mpegts",
|
||||
"-hls_flags", "independent_segments",
|
||||
"-hls_segment_filename", segments,
|
||||
playlist,
|
||||
)
|
||||
|
||||
@@ -14,10 +14,10 @@ func TestBuildFFmpegArgs(t *testing.T) {
|
||||
base.App.VAAPIDevice = "/dev/dri/renderD128"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
encoder string
|
||||
expectVCodec string
|
||||
expectInArgs []string
|
||||
name string
|
||||
encoder string
|
||||
expectVCodec string
|
||||
expectInArgs []string
|
||||
expectNotPresetIfBlank bool
|
||||
}{
|
||||
{"software", "", "libx264", []string{"-preset", "veryfast", "-c:v", "libx264"}, false},
|
||||
@@ -44,3 +44,27 @@ func TestBuildFFmpegArgs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredVideoEncoder(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "libx264",
|
||||
"nvenc": "h264_nvenc",
|
||||
"qsv": "h264_qsv",
|
||||
"vaapi": "h264_vaapi",
|
||||
}
|
||||
for encoder, want := range cases {
|
||||
if got := requiredVideoEncoder(encoder); got != want {
|
||||
t.Fatalf("requiredVideoEncoder(%q) = %q, want %q", encoder, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFFmpegListEntry(t *testing.T) {
|
||||
out := " V..... libx264 libx264 H.264 / AVC\n A..... aac"
|
||||
if !hasFFmpegListEntry(out, "libx264") {
|
||||
t.Fatal("expected libx264 entry")
|
||||
}
|
||||
if hasFFmpegListEntry(out, "x264") {
|
||||
t.Fatal("must match whole ffmpeg list entries only")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user