From 0291ab8194d6cfabb298e94a144598a56bb97486 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:59:13 +0800 Subject: [PATCH] Fix media scraping rename and compose pulls --- README.md | 2 + docker-compose.advanced.yml | 5 +- docker-compose.standard.yml | 3 + docker-compose.yml | 3 + internal/service/fanart.go | 44 +++++++++- internal/service/local_metadata.go | 60 +++++++++++--- internal/service/local_metadata_test.go | 30 +++++++ internal/service/organize_naming_test.go | 44 ++++++++++ internal/service/organizer.go | 2 + internal/service/organizer_directory.go | 54 +++++++++++- internal/service/organizer_target.go | 90 +++++++++++++++----- internal/service/scheduler.go | 5 ++ internal/service/scraper.go | 101 ++++++++++++----------- 13 files changed, 356 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index a16edcd..01af8ff 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,8 @@ docker compose pull mediastation-go docker compose up -d --no-deps mediastation-go ``` +不要执行裸 `docker compose pull` 做日常更新。PostgreSQL / Redis 是数据与缓存基础组件,compose 已设置为 `pull_policy: never`;需要升级它们时,请先备份 `./postgres`,再手动修改镜像版本并单独拉取。 + 第二档和第三档更新时继续带上叠加文件: ```bash diff --git a/docker-compose.advanced.yml b/docker-compose.advanced.yml index d377490..daa66d9 100644 --- a/docker-compose.advanced.yml +++ b/docker-compose.advanced.yml @@ -2,7 +2,7 @@ # # 快速开始: # 1. 按需修改下方 volumes 的宿主机路径。 -# 2. docker compose pull +# 2. docker compose pull mediastation-go # 3. docker compose up -d # 4. 浏览器打开 http://<服务器IP>:18080 # @@ -201,6 +201,9 @@ services: postgres: image: postgres:16-alpine + # 数据库基础镜像不参与日常应用更新,避免误拉新版 PostgreSQL。 + # 如需升级 PostgreSQL,请先备份 ./postgres 后再手动调整镜像版本并拉取。 + pull_policy: never container_name: mediastation-postgres restart: unless-stopped environment: diff --git a/docker-compose.standard.yml b/docker-compose.standard.yml index d95508b..c241ca1 100644 --- a/docker-compose.standard.yml +++ b/docker-compose.standard.yml @@ -17,6 +17,9 @@ services: redis: image: redis:7-alpine + # Redis 只做热缓存,日常更新 MediaStationGo 时不需要重新拉取。 + # 如需升级 Redis,请先确认兼容性后再手动调整镜像版本并拉取。 + pull_policy: never restart: unless-stopped command: - redis-server diff --git a/docker-compose.yml b/docker-compose.yml index 5ca3851..753d422 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -124,6 +124,9 @@ services: postgres: image: postgres:16-alpine + # 数据库基础镜像不参与日常应用更新,避免误拉新版 PostgreSQL。 + # 如需升级 PostgreSQL,请先备份 ./postgres 后再手动调整镜像版本并拉取。 + pull_policy: never restart: unless-stopped environment: POSTGRES_DB: mediastation diff --git a/internal/service/fanart.go b/internal/service/fanart.go index 41f1320..c2523ab 100644 --- a/internal/service/fanart.go +++ b/internal/service/fanart.go @@ -6,8 +6,8 @@ // // Endpoints used: // -// GET /v3/movies/{tmdb_id} (artwork keyed by TMDb id) -// GET /v3/tv/{thetvdb_id} (artwork keyed by TheTVDB id) +// GET /v3/movies/{tmdb_id} (artwork keyed by TMDb id) +// GET /v3/tv/{thetvdb_id} (artwork keyed by TheTVDB id) // // The provider is enabled iff secrets.fanart_tv_api_key is non-empty. package service @@ -88,6 +88,46 @@ func (f *FanartProvider) MovieArtwork(ctx context.Context, tmdbID int) (*Artwork return a, nil } +// TVArtwork looks up artwork for a TheTVDB series id. +func (f *FanartProvider) TVArtwork(ctx context.Context, theTVDBID string) (*Artwork, error) { + if !f.Enabled() || theTVDBID == "" { + return nil, nil + } + type entry struct { + URL string `json:"url"` + Lang string `json:"lang"` + } + type page struct { + TVPoster []entry `json:"tvposter"` + ShowBack []entry `json:"showbackground"` + HDLogo []entry `json:"hdtvlogo"` + ClearLogo []entry `json:"clearlogo"` + TVThumb []entry `json:"tvthumb"` + } + u := fmt.Sprintf("https://webservice.fanart.tv/v3/tv/%s?api_key=%s", + theTVDBID, f.cfg.Secrets.FanartAPIKey) + var p page + if err := f.getJSON(ctx, u, &p); err != nil { + return nil, err + } + a := &Artwork{} + if len(p.TVPoster) > 0 { + a.Poster = p.TVPoster[0].URL + } + if len(p.ShowBack) > 0 { + a.Backdrop = p.ShowBack[0].URL + } + if len(p.HDLogo) > 0 { + a.Logo = p.HDLogo[0].URL + } else if len(p.ClearLogo) > 0 { + a.Logo = p.ClearLogo[0].URL + } + if len(p.TVThumb) > 0 { + a.Thumb = p.TVThumb[0].URL + } + return a, nil +} + func (f *FanartProvider) getJSON(ctx context.Context, u string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { diff --git a/internal/service/local_metadata.go b/internal/service/local_metadata.go index 4657a4b..35e797a 100644 --- a/internal/service/local_metadata.go +++ b/internal/service/local_metadata.go @@ -69,7 +69,7 @@ type nfoDocument struct { OriginalTitle string `xml:"originaltitle"` SortTitle string `xml:"sorttitle"` Num string `xml:"num"` - Year int `xml:"year"` + Year nfoInt `xml:"year"` Premiered string `xml:"premiered"` ReleaseDate string `xml:"releasedate"` Release string `xml:"release"` @@ -77,15 +77,15 @@ type nfoDocument struct { Plot string `xml:"plot"` Outline string `xml:"outline"` OriginalPlot string `xml:"originalplot"` - Rating float32 `xml:"rating"` + Rating nfoFloat `xml:"rating"` Poster string `xml:"poster"` Thumbs []nfoThumb `xml:"thumb"` Fanart nfoFanart `xml:"fanart"` Art nfoArt `xml:"art"` - TMDbID int `xml:"tmdbid"` + TMDbID nfoInt `xml:"tmdbid"` UniqueIDs []nfoUniqueID `xml:"uniqueid"` - Season int `xml:"season"` - Episode int `xml:"episode"` + Season nfoInt `xml:"season"` + Episode nfoInt `xml:"episode"` Genres []string `xml:"genre"` Tags []string `xml:"tag"` Countries []string `xml:"country"` @@ -98,6 +98,46 @@ type nfoDocument struct { Actors []nfoActor `xml:"actor"` } +type nfoInt int + +func (n *nfoInt) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var raw string + if err := d.DecodeElement(&raw, &start); err != nil { + return err + } + raw = cleanXMLText(raw) + if raw == "" || strings.EqualFold(raw, "none") || strings.EqualFold(raw, "null") || strings.EqualFold(raw, "nan") { + *n = 0 + return nil + } + if v, err := strconv.Atoi(raw); err == nil { + *n = nfoInt(v) + return nil + } + if f, err := strconv.ParseFloat(raw, 64); err == nil { + *n = nfoInt(int(f)) + } + return nil +} + +type nfoFloat float32 + +func (n *nfoFloat) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var raw string + if err := d.DecodeElement(&raw, &start); err != nil { + return err + } + raw = cleanXMLText(raw) + if raw == "" || strings.EqualFold(raw, "none") || strings.EqualFold(raw, "null") || strings.EqualFold(raw, "nan") { + *n = 0 + return nil + } + if v, err := strconv.ParseFloat(raw, 32); err == nil { + *n = nfoFloat(v) + } + return nil +} + type nfoActor struct { Name string `xml:"name"` Role string `xml:"role"` @@ -253,16 +293,16 @@ func metadataFromDoc(doc *nfoDocument, baseDir string, seriesLike bool) *LocalMe Title: cleanXMLText(doc.Title), OriginalName: cleanXMLText(doc.OriginalTitle), AdultCode: normalizeAdultCode(doc.Num), - Year: doc.Year, + Year: int(doc.Year), Overview: firstText(doc.Plot, doc.Outline, doc.OriginalPlot), - Rating: doc.Rating, + Rating: float32(doc.Rating), PosterURL: firstRemoteURL(baseDir, nfoPosterValues(doc)...), BackdropURL: firstRemoteURL(baseDir, nfoBackdropValues(doc)...), - TMDbID: doc.TMDbID, + TMDbID: int(doc.TMDbID), DoubanID: externalIDFromUniqueIDs(doc.UniqueIDs, "douban"), TheTVDBID: externalIDFromUniqueIDs(doc.UniqueIDs, "thetvdb", "tvdb"), - SeasonNum: doc.Season, - EpisodeNum: doc.Episode, + SeasonNum: int(doc.Season), + EpisodeNum: int(doc.Episode), Genres: joinNFOValues(adultAwareGenres(doc)), Countries: joinNFOValues(doc.Countries), Languages: joinNFOValues(doc.Languages), diff --git a/internal/service/local_metadata_test.go b/internal/service/local_metadata_test.go index 08b613c..6ba1213 100644 --- a/internal/service/local_metadata_test.go +++ b/internal/service/local_metadata_test.go @@ -77,6 +77,36 @@ func TestReadLocalEpisodeMetadataMergesShowAndEpisode(t *testing.T) { } } +func TestReadLocalEpisodeMetadataIgnoresNoneNumericFields(t *testing.T) { + root := t.TempDir() + showDir := filepath.Join(root, "链锯人 总集篇 (2025)") + seasonDir := filepath.Join(showDir, "Season 1") + if err := os.MkdirAll(seasonDir, 0o755); err != nil { + t.Fatal(err) + } + mediaPath := filepath.Join(seasonDir, "链锯人 总集篇 - S01E01.mkv") + if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`链锯人 总集篇2025`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(nfoPath(mediaPath), []byte(`第 1 集NoneNoneNone`), 0o644); err != nil { + t.Fatal(err) + } + + got, err := ReadLocalMetadata(mediaPath, root, true) + if err != nil { + t.Fatal(err) + } + if got == nil || got.Title != "链锯人 总集篇" || got.Year != 2025 { + t.Fatalf("unexpected metadata: %+v", got) + } + if got.SeasonNum != 0 || got.EpisodeNum != 0 || got.Rating != 0 { + t.Fatalf("None numeric fields should parse as zero, got %+v", got) + } +} + func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) { root := t.TempDir() showDir := filepath.Join(root, "哈哈哈哈哈") diff --git a/internal/service/organize_naming_test.go b/internal/service/organize_naming_test.go index 44f9889..162796e 100644 --- a/internal/service/organize_naming_test.go +++ b/internal/service/organize_naming_test.go @@ -3,6 +3,7 @@ package service import ( "os" "path/filepath" + "strings" "testing" "go.uber.org/zap" @@ -159,6 +160,49 @@ func TestOrganizeDirectoryHonorsMoviePilotStyleNamingFormat(t *testing.T) { } } +func TestOrganizeDirectoryUsesSeriesFolderWhenFileTitleIsOnlyReleaseTags(t *testing.T) { + root := t.TempDir() + dest := filepath.Join(root, "media") + src := filepath.Join(root, "downloads", "链锯人 总集篇 (2025)", "Season 1", "2025.2160p.120fps.WEB-DL.H265.10bit.DTS5.1.S01E01.mkv") + writeOrgFile(t, src, "episode") + + repos := newOrganizerTestRepo(t) + template := "{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if video_format %} - {{video_format}}{% endif %}{{fileExt}}" + if err := repos.Setting.Set(t.Context(), "organize.tv_format", template); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(t.Context(), "organize.anime_format", template); err != nil { + t.Fatal(err) + } + organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos) + if _, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: src, + DestPath: dest, + MediaType: "anime", + MediaCategory: "日番", + TransferMode: TransferCopy, + }); err != nil { + t.Fatalf("organize anime: %v", err) + } + + want := filepath.Join(dest, "电视剧", "日番", "链锯人 总集篇 (2025)", "Season 1", "链锯人 总集篇 - S01E01 - 2160p.120fps.WEB-DL.H265.10bit.DTS5.1.mkv") + if _, err := os.Stat(want); err != nil { + t.Fatalf("organize should use series folder title and keep video_format, want %q: %v", want, err) + } +} + +func TestOrganizeNamingTemplateKeepsVideoFormatToken(t *testing.T) { + rel := renderOrganizeNamingTemplate("{title} - {episode_tag} - {video_format}{fileExt}", organizeNamingData{ + Title: "链锯人 总集篇", + EpisodeTag: "S01E01", + VideoFormat: extractOrganizeReleaseTag("2025.2160p.120fps.WEB-DL.H265.10bit.DTS5.1.mkv"), + FileExt: ".mkv", + }) + if !strings.Contains(rel, "2160p.120fps.WEB-DL.H265.10bit.DTS5.1") { + t.Fatalf("video_format was lost from rendered name: %q", rel) + } +} + func TestOrganizePipelineUsesSameNamingFormat(t *testing.T) { root := t.TempDir() src := filepath.Join(root, "downloads", "Pipeline.Show.S01E02.2026.mkv") diff --git a/internal/service/organizer.go b/internal/service/organizer.go index 6d99e3a..e3b1ad4 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -155,6 +155,7 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID MediaType: lib.Type, Category: category, Title: title, + Source: m.Path, Ext: ext, Year: m.Year, Season: m.SeasonNum, @@ -172,6 +173,7 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID MediaType: lib.Type, Category: category, Title: title, + Source: m.Path, Ext: ext, Year: m.Year, }) diff --git a/internal/service/organizer_directory.go b/internal/service/organizer_directory.go index a71dda2..c869744 100644 --- a/internal/service/organizer_directory.go +++ b/internal/service/organizer_directory.go @@ -242,9 +242,17 @@ type organizeDirectoryLayout struct { // into destRoot, applying dedup + 洗版. func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRoot, destRoot string, mode TransferMode, mediaTypeOverride, mediaCategoryOverride string, dryRun bool, allowReplaceExisting bool, metadataCache map[string]*Match, res *OrganizeResult) error { ext := filepath.Ext(src) + season, episode := ParseEpisode(src) title, year := CleanQuery(src) - if title == "" { - title = strings.TrimSuffix(filepath.Base(src), ext) + if organizeWeakFileTitle(title) { + if folderTitle, folderYear := organizeTitleFromParentFolder(src, sourceRoot, season > 0 || episode > 0); folderTitle != "" { + title = folderTitle + if year <= 0 { + year = folderYear + } + } else { + title = strings.TrimSuffix(filepath.Base(src), ext) + } } // CleanQuery lowercases the parsed title; title-case it so organized output // matches typical library casing (and stays consistent for dedup). @@ -253,7 +261,6 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo if title == "" { title = "Unknown" } - season, episode := ParseEpisode(src) pathLayout := o.inferOrganizeDirectoryLayout(src, sourceRoot) layout := pathLayout forcedType := normalizeOrganizeMediaType(mediaTypeOverride) @@ -320,6 +327,7 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo MediaType: layout.MediaType, Category: layout.Category, Title: title, + Source: src, Ext: ext, Year: year, Season: season, @@ -425,6 +433,21 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo return nil } +func organizeTitleFromParentFolder(src, sourceRoot string, seriesLike bool) (string, int) { + if !seriesLike { + return "", 0 + } + raw := seriesFolderTitle(src, sourceRoot) + if strings.TrimSpace(raw) == "" { + return "", 0 + } + title, year := CleanQuery(raw) + if title == "" { + title = strings.TrimSpace(raw) + } + return title, year +} + func shouldSkipOrganizeSourceVideo(path, sourceRoot string) (bool, string) { cleanPath := filepath.Clean(path) cleanRoot := filepath.Clean(sourceRoot) @@ -473,6 +496,31 @@ func normalizeOrganizeMediaType(mediaType string) string { } } +func organizeWeakFileTitle(title string) bool { + title = strings.TrimSpace(title) + if title == "" { + return true + } + fields := strings.Fields(strings.ToLower(title)) + if len(fields) == 0 { + return true + } + meaningful := 0 + for _, field := range fields { + if _, ok := noiseTokenSet[field]; ok { + continue + } + if _, ok := releaseBoundaryTokenSet[field]; ok { + continue + } + if len(field) == 4 && strings.HasPrefix(field, "20") { + continue + } + meaningful++ + } + return meaningful == 0 +} + func (o *OrganizerService) inferMediaTypeForSourceFile(src, title string, season, episode int) string { if season > 0 || episode > 0 { return "tv" diff --git a/internal/service/organizer_target.go b/internal/service/organizer_target.go index 397159b..d63426e 100644 --- a/internal/service/organizer_target.go +++ b/internal/service/organizer_target.go @@ -14,6 +14,7 @@ type organizeTargetInput struct { MediaType string Category string Title string + Source string Ext string Year int Season int @@ -60,15 +61,17 @@ func (o *OrganizerService) buildOrganizeTargetPath(ctx context.Context, in organ rel = defaultOrganizeRelativePath(title, ext, in.Year, in.Season, in.Episode, in.Series) } else { rel = renderOrganizeNamingTemplate(template, organizeNamingData{ - Title: title, - Year: in.Year, - Season: in.Season, - Episode: in.Episode, - Ext: strings.TrimPrefix(ext, "."), - FileExt: ext, - Category: sanitizeFilename(in.Category), - MediaType: normalizeOrganizeMediaType(in.MediaType), - EpisodeTag: episodeTag, + Title: title, + Year: in.Year, + Season: in.Season, + Episode: in.Episode, + Ext: strings.TrimPrefix(ext, "."), + FileExt: ext, + Category: sanitizeFilename(in.Category), + MediaType: normalizeOrganizeMediaType(in.MediaType), + EpisodeTag: episodeTag, + VideoFormat: extractOrganizeReleaseTag(in.Source), + Part: extractOrganizeReleaseTag(in.Source), }) rel = cleanOrganizeRelativePath(rel) if rel == "" { @@ -124,15 +127,17 @@ func defaultOrganizeRelativePath(title, ext string, year, season, episode int, s } type organizeNamingData struct { - Title string - Year int - Season int - Episode int - Ext string - FileExt string - Category string - MediaType string - EpisodeTag string + Title string + Year int + Season int + Episode int + Ext string + FileExt string + Category string + MediaType string + EpisodeTag string + VideoFormat string + Part string } var organizeTemplateTokenRE = regexp.MustCompile(`\{([A-Za-z_]+)(?::([^}]+))?\}`) @@ -204,8 +209,10 @@ func organizeTemplateTruthy(name string, data organizeNamingData) bool { return data.MediaType != "" case "episode_tag", "episodetag", "season_episode": return data.EpisodeTag != "" - case "part", "videoformat", "video_format": - return false + case "videoformat", "video_format": + return data.VideoFormat != "" + case "part": + return data.Part != "" default: return false } @@ -234,13 +241,52 @@ func organizeTemplateValue(name, format string, data organizeNamingData, fallbac return data.MediaType case "episode_tag", "episodetag", "season_episode": return data.EpisodeTag - case "part", "videoformat", "video_format": - return "" + case "videoformat", "video_format": + return data.VideoFormat + case "part": + return data.Part default: return fallback } } +var organizeReleaseTailRE = regexp.MustCompile(`(?i)(?:^|[.\s_\-\[\(])((?:2160p|1080p|720p|480p|4k|uhd|fhd|web[\s._-]*dl|web[\s._-]*rip|bluray|blu[\s._-]*ray|bdrip|hdtv|remux|x26[45]|h[\s._-]*26[45]|hevc|avc|10bit|8bit|hdr10?\+?|dovi|dv|120fps|60fps|dts|ddp?|eac3|aac|flac|truehd|atmos).*)$`) + +func extractOrganizeReleaseTag(source string) string { + base := strings.TrimSuffix(filepath.Base(strings.TrimSpace(source)), filepath.Ext(source)) + if base == "" { + return "" + } + matches := organizeReleaseTailRE.FindStringSubmatch(base) + if len(matches) < 2 { + return "" + } + value := strings.Trim(matches[1], " ._-[]()") + value = patSEnE.ReplaceAllString(value, " ") + value = patNxE.ReplaceAllString(value, " ") + value = patEP.ReplaceAllString(value, " ") + value = patCN.ReplaceAllString(value, " ") + if value == "" { + return "" + } + parts := strings.FieldsFunc(value, func(r rune) bool { + switch r { + case ' ', '_', '[', ']', '(', ')', '{', '}': + return true + default: + return false + } + }) + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.Trim(part, ".-") + if part != "" { + out = append(out, part) + } + } + return sanitizeFilename(strings.Join(out, ".")) +} + func formatOrganizeNumber(value int, format string, fallback int) string { if value <= 0 { value = fallback diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go index e6d292e..cae69a0 100644 --- a/internal/service/scheduler.go +++ b/internal/service/scheduler.go @@ -267,6 +267,11 @@ func (s *SchedulerService) loopWithInitialDelay(ctx context.Context, j *schedule case <-timer.C: } if err := s.runOnce(ctx, j); err != nil { + if errors.Is(err, ErrSchedulerJobAlreadyRunning) { + s.log.Debug("scheduled job skipped; previous run still active", zap.String("name", j.name)) + delay = j.interval + continue + } s.log.Warn("scheduled job failed", zap.String("name", j.name), zap.Error(err)) } diff --git a/internal/service/scraper.go b/internal/service/scraper.go index d19acd3..ce453b3 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -1,14 +1,8 @@ // Package service — scraper orchestrator. // -// 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 -// -// After the primary match we optionally upgrade poster / backdrop with -// Fanart.tv when an API key is configured. +// ScraperService takes a Media row and tries to enrich it with metadata from +// local NFO first, then TMDb -> Douban -> Bangumi -> TheTVDB. Fanart.tv is +// artwork-only and upgrades poster/backdrop after a metadata match. package service import ( @@ -282,22 +276,43 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error { zap.String("library_type", lib.Type)) return nil } - // Optional Fanart upgrade. - if s.fanart != nil && s.fanart.Enabled() && match.TMDbID > 0 { - if a, err := s.fanart.MovieArtwork(ctx, match.TMDbID); err == nil && a != nil { - if a.Poster != "" { - match.PosterURL = a.Poster - } - if a.Backdrop != "" { - match.BackdropURL = a.Backdrop - } - } - } + s.applyFanartArtwork(ctx, match) mergeLocalMetadataIntoMatch(match, local) return s.applyProviderMatch(ctx, m, lib, match) } +func (s *ScraperService) applyFanartArtwork(ctx context.Context, match *Match) { + if s == nil || s.fanart == nil || !s.fanart.Enabled() || match == nil { + return + } + apply := func(a *Artwork) { + if a == nil { + return + } + if a.Poster != "" { + match.PosterURL = a.Poster + } + if a.Backdrop != "" { + match.BackdropURL = a.Backdrop + } + } + if match.TMDbID > 0 { + if a, err := s.fanart.MovieArtwork(ctx, match.TMDbID); err == nil { + apply(a) + } else { + s.log.Debug("fanart movie artwork failed", zap.Int("tmdb_id", match.TMDbID), zap.Error(err)) + } + } + if strings.TrimSpace(match.TheTVDBID) != "" { + if a, err := s.fanart.TVArtwork(ctx, strings.TrimSpace(match.TheTVDBID)); err == nil { + apply(a) + } else { + s.log.Debug("fanart tv artwork failed", zap.String("thetvdb_id", match.TheTVDBID), zap.Error(err)) + } + } +} + func mediaYearHint(m *model.Media) int { if m == nil { return 0 @@ -695,33 +710,14 @@ func librarySupportsSeasons(lib *model.Library) bool { } } -// lookup runs the provider chain. When the library is missing we fall -// back to TMDb only. -// -// 库类型决定首选 provider: -// -// anime -> Bangumi -> TMDb /search/tv -> TMDb /search/movie -// tv -> TMDb /search/tv -> TMDb /search/movie -> TheTVDB -// movie -> TMDb /search/movie -// (空) -> TMDb /search/movie -// -// 任何 provider 错误都不会中止链式查询;只要返回 nil/err,就继续走下一个 -// provider。这避免了 Bangumi token 未配置时 anime 库整体失败的问题。 +// 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, query string, year int) *Match { kind := "" if lib != nil { kind = lib.Type } - switch kind { - case "anime": - 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 s.tmdb != nil && s.tmdb.Enabled() { // anime / tv 先用 TMDb /search/tv(剧名通常是 TV 类目)。 if kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows" { @@ -737,13 +733,6 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s s.log.Debug("tmdb movie 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)) - } - } if s.douban != nil && s.douban.Enabled() { if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil { return m @@ -751,6 +740,20 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s 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 }