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 up -d --no-deps mediastation-go
``` ```
不要执行裸 `docker compose pull` 做日常更新。PostgreSQL / Redis 是数据与缓存基础组件,compose 已设置为 `pull_policy: never`;需要升级它们时,请先备份 `./postgres`,再手动修改镜像版本并单独拉取。
第二档和第三档更新时继续带上叠加文件: 第二档和第三档更新时继续带上叠加文件:
```bash ```bash
+4 -1
View File
@@ -2,7 +2,7 @@
# #
# 快速开始: # 快速开始:
# 1. 按需修改下方 volumes 的宿主机路径。 # 1. 按需修改下方 volumes 的宿主机路径。
# 2. docker compose pull # 2. docker compose pull mediastation-go
# 3. docker compose up -d # 3. docker compose up -d
# 4. 浏览器打开 http://<服务器IP>:18080 # 4. 浏览器打开 http://<服务器IP>:18080
# #
@@ -201,6 +201,9 @@ services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
# 数据库基础镜像不参与日常应用更新,避免误拉新版 PostgreSQL。
# 如需升级 PostgreSQL,请先备份 ./postgres 后再手动调整镜像版本并拉取。
pull_policy: never
container_name: mediastation-postgres container_name: mediastation-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
+3
View File
@@ -17,6 +17,9 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
# Redis 只做热缓存,日常更新 MediaStationGo 时不需要重新拉取。
# 如需升级 Redis,请先确认兼容性后再手动调整镜像版本并拉取。
pull_policy: never
restart: unless-stopped restart: unless-stopped
command: command:
- redis-server - redis-server
+3
View File
@@ -124,6 +124,9 @@ services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
# 数据库基础镜像不参与日常应用更新,避免误拉新版 PostgreSQL。
# 如需升级 PostgreSQL,请先备份 ./postgres 后再手动调整镜像版本并拉取。
pull_policy: never
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: mediastation POSTGRES_DB: mediastation
+42 -2
View File
@@ -6,8 +6,8 @@
// //
// Endpoints used: // Endpoints used:
// //
// GET /v3/movies/{tmdb_id} (artwork keyed by TMDb id) // GET /v3/movies/{tmdb_id} (artwork keyed by TMDb id)
// GET /v3/tv/{thetvdb_id} (artwork keyed by TheTVDB id) // GET /v3/tv/{thetvdb_id} (artwork keyed by TheTVDB id)
// //
// The provider is enabled iff secrets.fanart_tv_api_key is non-empty. // The provider is enabled iff secrets.fanart_tv_api_key is non-empty.
package service package service
@@ -88,6 +88,46 @@ func (f *FanartProvider) MovieArtwork(ctx context.Context, tmdbID int) (*Artwork
return a, nil 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 { func (f *FanartProvider) getJSON(ctx context.Context, u string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil { if err != nil {
+50 -10
View File
@@ -69,7 +69,7 @@ type nfoDocument struct {
OriginalTitle string `xml:"originaltitle"` OriginalTitle string `xml:"originaltitle"`
SortTitle string `xml:"sorttitle"` SortTitle string `xml:"sorttitle"`
Num string `xml:"num"` Num string `xml:"num"`
Year int `xml:"year"` Year nfoInt `xml:"year"`
Premiered string `xml:"premiered"` Premiered string `xml:"premiered"`
ReleaseDate string `xml:"releasedate"` ReleaseDate string `xml:"releasedate"`
Release string `xml:"release"` Release string `xml:"release"`
@@ -77,15 +77,15 @@ type nfoDocument struct {
Plot string `xml:"plot"` Plot string `xml:"plot"`
Outline string `xml:"outline"` Outline string `xml:"outline"`
OriginalPlot string `xml:"originalplot"` OriginalPlot string `xml:"originalplot"`
Rating float32 `xml:"rating"` Rating nfoFloat `xml:"rating"`
Poster string `xml:"poster"` Poster string `xml:"poster"`
Thumbs []nfoThumb `xml:"thumb"` Thumbs []nfoThumb `xml:"thumb"`
Fanart nfoFanart `xml:"fanart"` Fanart nfoFanart `xml:"fanart"`
Art nfoArt `xml:"art"` Art nfoArt `xml:"art"`
TMDbID int `xml:"tmdbid"` TMDbID nfoInt `xml:"tmdbid"`
UniqueIDs []nfoUniqueID `xml:"uniqueid"` UniqueIDs []nfoUniqueID `xml:"uniqueid"`
Season int `xml:"season"` Season nfoInt `xml:"season"`
Episode int `xml:"episode"` Episode nfoInt `xml:"episode"`
Genres []string `xml:"genre"` Genres []string `xml:"genre"`
Tags []string `xml:"tag"` Tags []string `xml:"tag"`
Countries []string `xml:"country"` Countries []string `xml:"country"`
@@ -98,6 +98,46 @@ type nfoDocument struct {
Actors []nfoActor `xml:"actor"` 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 { type nfoActor struct {
Name string `xml:"name"` Name string `xml:"name"`
Role string `xml:"role"` Role string `xml:"role"`
@@ -253,16 +293,16 @@ func metadataFromDoc(doc *nfoDocument, baseDir string, seriesLike bool) *LocalMe
Title: cleanXMLText(doc.Title), Title: cleanXMLText(doc.Title),
OriginalName: cleanXMLText(doc.OriginalTitle), OriginalName: cleanXMLText(doc.OriginalTitle),
AdultCode: normalizeAdultCode(doc.Num), AdultCode: normalizeAdultCode(doc.Num),
Year: doc.Year, Year: int(doc.Year),
Overview: firstText(doc.Plot, doc.Outline, doc.OriginalPlot), Overview: firstText(doc.Plot, doc.Outline, doc.OriginalPlot),
Rating: doc.Rating, Rating: float32(doc.Rating),
PosterURL: firstRemoteURL(baseDir, nfoPosterValues(doc)...), PosterURL: firstRemoteURL(baseDir, nfoPosterValues(doc)...),
BackdropURL: firstRemoteURL(baseDir, nfoBackdropValues(doc)...), BackdropURL: firstRemoteURL(baseDir, nfoBackdropValues(doc)...),
TMDbID: doc.TMDbID, TMDbID: int(doc.TMDbID),
DoubanID: externalIDFromUniqueIDs(doc.UniqueIDs, "douban"), DoubanID: externalIDFromUniqueIDs(doc.UniqueIDs, "douban"),
TheTVDBID: externalIDFromUniqueIDs(doc.UniqueIDs, "thetvdb", "tvdb"), TheTVDBID: externalIDFromUniqueIDs(doc.UniqueIDs, "thetvdb", "tvdb"),
SeasonNum: doc.Season, SeasonNum: int(doc.Season),
EpisodeNum: doc.Episode, EpisodeNum: int(doc.Episode),
Genres: joinNFOValues(adultAwareGenres(doc)), Genres: joinNFOValues(adultAwareGenres(doc)),
Countries: joinNFOValues(doc.Countries), Countries: joinNFOValues(doc.Countries),
Languages: joinNFOValues(doc.Languages), 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) { func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) {
root := t.TempDir() root := t.TempDir()
showDir := filepath.Join(root, "哈哈哈哈哈") showDir := filepath.Join(root, "哈哈哈哈哈")
+44
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"go.uber.org/zap" "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) { func TestOrganizePipelineUsesSameNamingFormat(t *testing.T) {
root := t.TempDir() root := t.TempDir()
src := filepath.Join(root, "downloads", "Pipeline.Show.S01E02.2026.mkv") 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, MediaType: lib.Type,
Category: category, Category: category,
Title: title, Title: title,
Source: m.Path,
Ext: ext, Ext: ext,
Year: m.Year, Year: m.Year,
Season: m.SeasonNum, Season: m.SeasonNum,
@@ -172,6 +173,7 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID
MediaType: lib.Type, MediaType: lib.Type,
Category: category, Category: category,
Title: title, Title: title,
Source: m.Path,
Ext: ext, Ext: ext,
Year: m.Year, Year: m.Year,
}) })
+51 -3
View File
@@ -242,9 +242,17 @@ type organizeDirectoryLayout struct {
// into destRoot, applying dedup + 洗版. // 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 { 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) ext := filepath.Ext(src)
season, episode := ParseEpisode(src)
title, year := CleanQuery(src) title, year := CleanQuery(src)
if title == "" { if organizeWeakFileTitle(title) {
title = strings.TrimSuffix(filepath.Base(src), ext) 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 // CleanQuery lowercases the parsed title; title-case it so organized output
// matches typical library casing (and stays consistent for dedup). // matches typical library casing (and stays consistent for dedup).
@@ -253,7 +261,6 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
if title == "" { if title == "" {
title = "Unknown" title = "Unknown"
} }
season, episode := ParseEpisode(src)
pathLayout := o.inferOrganizeDirectoryLayout(src, sourceRoot) pathLayout := o.inferOrganizeDirectoryLayout(src, sourceRoot)
layout := pathLayout layout := pathLayout
forcedType := normalizeOrganizeMediaType(mediaTypeOverride) forcedType := normalizeOrganizeMediaType(mediaTypeOverride)
@@ -320,6 +327,7 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
MediaType: layout.MediaType, MediaType: layout.MediaType,
Category: layout.Category, Category: layout.Category,
Title: title, Title: title,
Source: src,
Ext: ext, Ext: ext,
Year: year, Year: year,
Season: season, Season: season,
@@ -425,6 +433,21 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
return nil 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) { func shouldSkipOrganizeSourceVideo(path, sourceRoot string) (bool, string) {
cleanPath := filepath.Clean(path) cleanPath := filepath.Clean(path)
cleanRoot := filepath.Clean(sourceRoot) 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 { func (o *OrganizerService) inferMediaTypeForSourceFile(src, title string, season, episode int) string {
if season > 0 || episode > 0 { if season > 0 || episode > 0 {
return "tv" return "tv"
+68 -22
View File
@@ -14,6 +14,7 @@ type organizeTargetInput struct {
MediaType string MediaType string
Category string Category string
Title string Title string
Source string
Ext string Ext string
Year int Year int
Season 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) rel = defaultOrganizeRelativePath(title, ext, in.Year, in.Season, in.Episode, in.Series)
} else { } else {
rel = renderOrganizeNamingTemplate(template, organizeNamingData{ rel = renderOrganizeNamingTemplate(template, organizeNamingData{
Title: title, Title: title,
Year: in.Year, Year: in.Year,
Season: in.Season, Season: in.Season,
Episode: in.Episode, Episode: in.Episode,
Ext: strings.TrimPrefix(ext, "."), Ext: strings.TrimPrefix(ext, "."),
FileExt: ext, FileExt: ext,
Category: sanitizeFilename(in.Category), Category: sanitizeFilename(in.Category),
MediaType: normalizeOrganizeMediaType(in.MediaType), MediaType: normalizeOrganizeMediaType(in.MediaType),
EpisodeTag: episodeTag, EpisodeTag: episodeTag,
VideoFormat: extractOrganizeReleaseTag(in.Source),
Part: extractOrganizeReleaseTag(in.Source),
}) })
rel = cleanOrganizeRelativePath(rel) rel = cleanOrganizeRelativePath(rel)
if rel == "" { if rel == "" {
@@ -124,15 +127,17 @@ func defaultOrganizeRelativePath(title, ext string, year, season, episode int, s
} }
type organizeNamingData struct { type organizeNamingData struct {
Title string Title string
Year int Year int
Season int Season int
Episode int Episode int
Ext string Ext string
FileExt string FileExt string
Category string Category string
MediaType string MediaType string
EpisodeTag string EpisodeTag string
VideoFormat string
Part string
} }
var organizeTemplateTokenRE = regexp.MustCompile(`\{([A-Za-z_]+)(?::([^}]+))?\}`) var organizeTemplateTokenRE = regexp.MustCompile(`\{([A-Za-z_]+)(?::([^}]+))?\}`)
@@ -204,8 +209,10 @@ func organizeTemplateTruthy(name string, data organizeNamingData) bool {
return data.MediaType != "" return data.MediaType != ""
case "episode_tag", "episodetag", "season_episode": case "episode_tag", "episodetag", "season_episode":
return data.EpisodeTag != "" return data.EpisodeTag != ""
case "part", "videoformat", "video_format": case "videoformat", "video_format":
return false return data.VideoFormat != ""
case "part":
return data.Part != ""
default: default:
return false return false
} }
@@ -234,13 +241,52 @@ func organizeTemplateValue(name, format string, data organizeNamingData, fallbac
return data.MediaType return data.MediaType
case "episode_tag", "episodetag", "season_episode": case "episode_tag", "episodetag", "season_episode":
return data.EpisodeTag return data.EpisodeTag
case "part", "videoformat", "video_format": case "videoformat", "video_format":
return "" return data.VideoFormat
case "part":
return data.Part
default: default:
return fallback 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 { func formatOrganizeNumber(value int, format string, fallback int) string {
if value <= 0 { if value <= 0 {
value = fallback value = fallback
+5
View File
@@ -267,6 +267,11 @@ func (s *SchedulerService) loopWithInitialDelay(ctx context.Context, j *schedule
case <-timer.C: case <-timer.C:
} }
if err := s.runOnce(ctx, j); err != nil { 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", s.log.Warn("scheduled job failed",
zap.String("name", j.name), zap.Error(err)) zap.String("name", j.name), zap.Error(err))
} }
+52 -49
View File
@@ -1,14 +1,8 @@
// Package service — scraper orchestrator. // Package service — scraper orchestrator.
// //
// ScraperService takes a Media row and tries to enrich it with metadata // ScraperService takes a Media row and tries to enrich it with metadata from
// from one or more providers. Selection is driven by the library type: // local NFO first, then TMDb -> Douban -> Bangumi -> TheTVDB. Fanart.tv is
// // artwork-only and upgrades poster/backdrop after a metadata match.
// 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.
package service package service
import ( import (
@@ -282,22 +276,43 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
zap.String("library_type", lib.Type)) zap.String("library_type", lib.Type))
return nil return nil
} }
// Optional Fanart upgrade. s.applyFanartArtwork(ctx, match)
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
}
}
}
mergeLocalMetadataIntoMatch(match, local) mergeLocalMetadataIntoMatch(match, local)
return s.applyProviderMatch(ctx, m, lib, match) 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 { func mediaYearHint(m *model.Media) int {
if m == nil { if m == nil {
return 0 return 0
@@ -695,33 +710,14 @@ func librarySupportsSeasons(lib *model.Library) bool {
} }
} }
// lookup runs the provider chain. When the library is missing we fall // lookup runs the provider chain after local NFO has been considered:
// back to TMDb only. // TMDb -> Douban -> Bangumi -> TheTVDB. Douban and Bangumi do not require API
// // keys; providers that are unavailable or return an error are skipped.
// 库类型决定首选 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 库整体失败的问题。
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query string, year int) *Match { func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query string, year int) *Match {
kind := "" kind := ""
if lib != nil { if lib != nil {
kind = lib.Type 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() { if s.tmdb != nil && s.tmdb.Enabled() {
// anime / tv 先用 TMDb /search/tv(剧名通常是 TV 类目)。 // anime / tv 先用 TMDb /search/tv(剧名通常是 TV 类目)。
if kind == "anime" || kind == "tv" || kind == "variety" || kind == "show" || kind == "shows" { 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)) 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 s.douban != nil && s.douban.Enabled() {
if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil { if m, err := s.douban.SearchMatch(ctx, query); err == nil && m != nil {
return m 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)) 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 return nil
} }