Files
MeBox/internal/service/scraper_library.go
T

236 lines
7.2 KiB
Go

package service
import (
"context"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// lookup runs the provider chain after local NFO has been considered:
// TMDb -> Douban -> Bangumi -> TheTVDB. Douban and Bangumi do not require API
// keys; providers that are unavailable or return an error are skipped.
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, media *model.Media, query string, year int) *Match {
kind := ""
if lib != nil {
kind = lib.Type
}
if mediaIsEpisodic(media, lib) {
kind = "tv"
}
if s.tmdb != nil && s.tmdb.Enabled() {
// anime / tv 先用 TMDb /search/tv(剧名通常是 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 {
s.log.Debug("tmdb tv search failed", zap.String("query", query), zap.Error(err))
}
}
if m, err := s.tmdb.SearchMovie(ctx, query, year); err == nil && m != nil {
return m
} else if err != nil {
s.log.Debug("tmdb movie search failed", zap.String("query", query), zap.Error(err))
}
}
if s.douban != nil && s.douban.Enabled() {
if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil {
return m
} else if err != nil {
s.log.Debug("douban search failed", zap.String("query", query), zap.Error(err))
}
}
if s.bangumi != nil && s.bangumi.Enabled() {
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil {
return m
} else if err != nil {
s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err))
}
}
if (kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows") && s.thetvdb != nil && s.thetvdb.Enabled() {
if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil {
return m
} else if err != nil {
s.log.Debug("thetvdb search failed", zap.String("query", query), zap.Error(err))
}
}
return nil
}
// 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, retryNoMatch ...bool) (int, error) {
result, err := s.EnrichLibraryDetailed(ctx, libraryID, retryNoMatch...)
return result.Matched, err
}
type EnrichLibraryResult struct {
LibraryID string
Matched int
Processed int
Failed int
Candidates int
}
func (s *ScraperService) EnrichLibraryDetailed(ctx context.Context, libraryID string, retryNoMatch ...bool) (EnrichLibraryResult, error) {
options := ScrapeOptions{}
if len(retryNoMatch) > 0 {
options.RetryNoMatch = retryNoMatch[0]
}
return s.EnrichLibraryDetailedWithOptions(ctx, libraryID, options)
}
func (s *ScraperService) EnrichLibraryDetailedWithOptions(ctx context.Context, libraryID string, options ScrapeOptions) (EnrichLibraryResult, error) {
result := EnrichLibraryResult{LibraryID: libraryID}
rows, err := s.scrapeCandidateRows(ctx, libraryID, options)
if err != nil {
return result, err
}
result.Candidates = len(rows)
runOptions := options
runOptions.DeferEpisodeDetails = true
for i := range rows {
select {
case <-ctx.Done():
return result, ctx.Err()
default:
}
if err := s.EnrichOneWithOptions(ctx, &rows[i], runOptions); err != nil {
s.log.Warn("enrich failed", zap.String("media", rows[i].ID), zap.Error(err))
s.notifyScrapeFailed(rows[i], err)
result.Failed++
continue
}
result.Processed++
if s.mediaIsMatched(ctx, rows[i].ID) {
result.Matched++
}
if i < len(rows)-1 {
if delay := s.scrapeDelay(ctx); delay > 0 {
select {
case <-ctx.Done():
return result, ctx.Err()
case <-time.After(delay):
}
}
}
}
if err := s.enrichDeferredEpisodeDetails(ctx, rows, options); err != nil {
return result, err
}
s.hub.Publish("scrape", map[string]any{
"library_id": libraryID,
"finished": true,
"matched": result.Matched,
"processed": result.Processed,
"failed": result.Failed,
"candidates": result.Candidates,
})
return result, nil
}
func (s *ScraperService) scrapeCandidateRows(ctx context.Context, libraryID string, options ScrapeOptions) ([]model.Media, error) {
var rows []model.Media
libraryIDs := []string{}
if strings.TrimSpace(libraryID) != "" {
var err error
libraryIDs, err = MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
if err != nil {
return nil, err
}
}
statusFilter := "scrape_status IS NULL OR scrape_status = '' OR scrape_status = ?"
statusArgs := []any{"pending"}
if options.RetryNoMatch {
statusFilter += " OR scrape_status = ?"
statusArgs = append(statusArgs, "no_match")
}
if options.IncludeMatched {
statusFilter += " OR scrape_status = ?"
statusArgs = append(statusArgs, "matched")
}
q := s.repo.DB.WithContext(ctx).Where(statusFilter, statusArgs...)
if len(libraryIDs) > 0 {
q = q.Where("library_id IN ?", libraryIDs)
}
if err := q.
Order("CASE WHEN COALESCE(season_num, 0) > 0 OR COALESCE(episode_num, 0) > 0 THEN 1 ELSE 0 END").
Order("id ASC").
Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (s *ScraperService) notifyScrapeFailed(m model.Media, err error) {
if s == nil || s.notify == nil || err == nil {
return
}
body := strings.TrimSpace(m.Title)
if body == "" {
body = m.Path
}
body = "媒体:" + body + "\n错误:" + err.Error()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
s.notify.Broadcast(ctx, "MediaStationGo 刮削失败", body, EventScrapeFailed)
}()
}
func (s *ScraperService) scrapeDelay(ctx context.Context) time.Duration {
minMS := s.scrapeDelaySetting(ctx, "scrape.delay_min_ms", defaultScrapeDelayMinMS)
maxMS := s.scrapeDelaySetting(ctx, "scrape.delay_max_ms", defaultScrapeDelayMaxMS)
if minMS < 0 {
minMS = 0
}
if maxMS < 0 {
maxMS = 0
}
if minMS > maxScrapeDelayMS {
minMS = maxScrapeDelayMS
}
if maxMS > maxScrapeDelayMS {
maxMS = maxScrapeDelayMS
}
if maxMS < minMS {
maxMS = minMS
}
if maxMS == 0 {
return 0
}
if maxMS == minMS {
return time.Duration(minMS) * time.Millisecond
}
return time.Duration(minMS+secureRandomIntn(maxMS-minMS+1)) * time.Millisecond
}
func (s *ScraperService) scrapeDelaySetting(ctx context.Context, key string, fallback int) int {
if s == nil || s.repo == nil || s.repo.Setting == nil {
return fallback
}
value, err := s.repo.Setting.Get(ctx, key)
if err != nil || strings.TrimSpace(value) == "" {
return fallback
}
return parseIntSettingDefault(strings.TrimSpace(value), fallback)
}
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"
}