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
+2
View File
@@ -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
+4 -1
View File
@@ -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:
+3
View File
@@ -17,6 +17,9 @@ services:
redis:
image: redis:7-alpine
# Redis 只做热缓存,日常更新 MediaStationGo 时不需要重新拉取。
# 如需升级 Redis,请先确认兼容性后再手动调整镜像版本并拉取。
pull_policy: never
restart: unless-stopped
command:
- redis-server
+3
View File
@@ -124,6 +124,9 @@ services:
postgres:
image: postgres:16-alpine
# 数据库基础镜像不参与日常应用更新,避免误拉新版 PostgreSQL。
# 如需升级 PostgreSQL,请先备份 ./postgres 后再手动调整镜像版本并拉取。
pull_policy: never
restart: unless-stopped
environment:
POSTGRES_DB: mediastation
+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
}