Fix media scraping rename and compose pulls

This commit is contained in:
ShukeBta
2026-06-17 23:59:13 +08:00
parent 29ac7765d2
commit 0291ab8194
13 changed files with 356 additions and 87 deletions
+42 -2
View File
@@ -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 {
+50 -10
View File
@@ -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),
+30
View File
@@ -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(`<tvshow><title>链锯人 总集篇</title><year>2025</year></tvshow>`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<episodedetails><title>第 1 集</title><season>None</season><episode>None</episode><rating>None</rating></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.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, "哈哈哈哈哈")
+44
View File
@@ -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")
+2
View File
@@ -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,
})
+51 -3
View File
@@ -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"
+68 -22
View File
@@ -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
+5
View File
@@ -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))
}
+52 -49
View File
@@ -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
}