Files
MeBox/internal/service/local_metadata.go
T
ShukeBta dff19801b6 fix(series): 彻底修复剧集被按单集显示且无法修复
根因(用真实数据确诊, 两个互相独立):
- Web 端拆集: episode NFO 的 <uniqueid type=tmdb> 是【单集 episode id】、
  <title> 是【单集名】, mergeEpisodeMetadata 却无条件覆盖了整剧 tm_db_id /
  original_name → 同剧每集 id/原名各不相同, 被 getSeriesKey 按 tmdb_id 拆成
  N 张单集卡(实测「遮天」90 集 = 89 个不同 tmdb_id)。
- Emby 散装单集: 电影库混入按 Season/SxxE 整理的剧集结构内容, 被判成 Episode
  却因 movie 库不聚 Series 而漏成散装单集。
- 无法修复: EnrichLibrary 只处理 pending/no_match, 跳过 matched 脏行。

修复:
- local_metadata.mergeEpisodeMetadata: 单集 NFO 的外部 id 不再写入整剧字段,
  单集名不再污染 original_name; 整剧 id/原名只认 tvshow.nfo。
- groupSeries.ts: 剧集分组改「库+路径剧名」优先(整剧目录对全剧一致), 外部 id
  仅作回退, 对存量脏数据也能合并。
- emby_compat: 电影库常规浏览新增 movieLibraryItems, 把剧集结构内容聚成 Series
  卡片与真电影并列(方案 B), 不再漏散装单集。
- cloud_path_repair: 「修复+重刮」先把脏的 matched 剧集行重置为 pending 再重刮,
  使其能被重新刮削; 结果新增 reset 计数。
- 新增 NormalizePollutedEpisodeMetadata 启动迁移(幂等): 按整剧目录聚合, 清洗
  tmdb/原名散乱的剧组并重置 pending。

测试: local_metadata/emby_compat/cloud_path_repair/episode_metadata_cleanup
新增或更新断言; 修正 scanner_cloud_test 中依赖旧错误行为的用例。
2026-06-20 01:04:19 +08:00

792 lines
22 KiB
Go

package service
import (
"encoding/xml"
"errors"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
)
// LocalMetadata contains metadata read from Kodi/Jellyfin sidecar NFO files.
type LocalMetadata struct {
Title string
OriginalName string
AdultCode string
Year int
Overview string
Rating float32
PosterURL string
BackdropURL string
TMDbID int
BangumiID int
DoubanID string
TheTVDBID string
SeasonNum int
EpisodeNum int
Genres string
Countries string
Languages string
NSFW bool
HasNFO bool
HasArtwork bool
PathHint bool
}
type nfoUniqueID struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
}
type nfoFanart struct {
Value string `xml:",chardata"`
Thumbs []string `xml:"thumb"`
}
type nfoThumb struct {
Aspect string `xml:"aspect,attr"`
Value string `xml:",chardata"`
}
type nfoArt struct {
Poster string `xml:"poster"`
Thumb string `xml:"thumb"`
Fanart string `xml:"fanart"`
Backdrop string `xml:"backdrop"`
Background string `xml:"background"`
Banner string `xml:"banner"`
Landscape string `xml:"landscape"`
}
type nfoDocument struct {
XMLName xml.Name `xml:""`
Title string `xml:"title"`
ShowTitle string `xml:"showtitle"`
OriginalTitle string `xml:"originaltitle"`
SortTitle string `xml:"sorttitle"`
Num string `xml:"num"`
Year nfoInt `xml:"year"`
Premiered string `xml:"premiered"`
ReleaseDate string `xml:"releasedate"`
Release string `xml:"release"`
Aired string `xml:"aired"`
Plot string `xml:"plot"`
Outline string `xml:"outline"`
OriginalPlot string `xml:"originalplot"`
Rating nfoFloat `xml:"rating"`
Poster string `xml:"poster"`
Thumbs []nfoThumb `xml:"thumb"`
Fanart nfoFanart `xml:"fanart"`
Art nfoArt `xml:"art"`
TMDbID nfoInt `xml:"tmdbid"`
UniqueIDs []nfoUniqueID `xml:"uniqueid"`
Season nfoInt `xml:"season"`
Episode nfoInt `xml:"episode"`
Genres []string `xml:"genre"`
Tags []string `xml:"tag"`
Countries []string `xml:"country"`
Languages []string `xml:"language"`
Studio string `xml:"studio"`
Maker string `xml:"maker"`
Publisher string `xml:"publisher"`
Label string `xml:"label"`
Directors []string `xml:"director"`
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"`
}
// ReadLocalMetadata reads sidecar NFO files for a media path. For TV/anime it
// merges show-level tvshow.nfo with episode-level sidecar metadata.
func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMetadata, error) {
if seriesLike {
return readSeriesMetadata(mediaPath, libraryRoot)
}
doc, path, err := findMovieNFO(mediaPath, libraryRoot)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return metadataFromArtwork(mediaPath, ""), nil
}
return nil, err
}
meta := metadataFromDoc(doc, filepath.Dir(path), false)
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path))
return meta, nil
}
func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
mediaDir := filepath.Dir(mediaPath)
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
adultCode := AdultCodeFromMediaPath(mediaPath)
names := []string{
base + ".nfo",
"movie.nfo",
filepath.Base(mediaDir) + ".nfo",
}
if adultCode != "" {
names = append([]string{adultCode + ".nfo", strings.ReplaceAll(adultCode, "-", "") + ".nfo"}, names...)
}
seen := map[string]struct{}{}
for _, name := range names {
if name == ".nfo" || name == "" {
continue
}
path := filepath.Join(mediaDir, name)
key := strings.ToLower(filepath.Clean(path))
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if doc, _, err := readNFO(path); err == nil {
return doc, path, nil
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, "", err
}
}
if libraryRoot == "" || !samePath(mediaDir, filepath.Clean(libraryRoot)) {
matches, _ := filepath.Glob(filepath.Join(mediaDir, "*.nfo"))
if adultCode != "" {
codeKey := strings.ToLower(strings.ReplaceAll(adultCode, "-", ""))
for _, match := range matches {
baseKey := strings.ToLower(strings.ReplaceAll(strings.TrimSuffix(filepath.Base(match), filepath.Ext(match)), "-", ""))
if strings.Contains(baseKey, codeKey) || strings.Contains(codeKey, baseKey) {
if doc, _, err := readNFO(match); err == nil {
return doc, match, nil
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, "", err
}
}
}
}
if len(matches) == 1 {
if doc, _, err := readNFO(matches[0]); err == nil {
return doc, matches[0], nil
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, "", err
}
}
}
return nil, "", os.ErrNotExist
}
func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
var meta *LocalMetadata
showBaseDir := ""
if showDoc, showPath, err := findShowNFO(mediaPath, libraryRoot); err == nil && showDoc != nil {
showBaseDir = filepath.Dir(showPath)
meta = metadataFromDoc(showDoc, showBaseDir, true)
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if episodeDoc, episodePath, err := readNFO(nfoPath(mediaPath)); err == nil {
episodeMeta := metadataFromDoc(episodeDoc, filepath.Dir(episodePath), true)
if meta == nil {
meta = &LocalMetadata{}
}
mergeEpisodeMetadata(meta, episodeMeta, episodeDoc)
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if meta == nil {
meta = metadataFromArtwork(mediaPath, showBaseDir)
} else {
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
}
return meta, nil
}
func readNFO(path string) (*nfoDocument, string, error) {
body, err := os.ReadFile(path) // #nosec G304 -- path is a discovered NFO sidecar under the configured library root.
if err != nil {
return nil, "", err
}
var doc nfoDocument
if err := xml.Unmarshal(body, &doc); err != nil {
return nil, "", err
}
return &doc, path, nil
}
func findShowNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
dir := filepath.Dir(mediaPath)
root := filepath.Clean(libraryRoot)
for {
names := []string{"tvshow.nfo", "series.nfo"}
base := filepath.Base(dir)
if _, ok := seasonFromDir(base); ok {
parentBase := filepath.Base(filepath.Dir(dir))
names = append(names, parentBase+".nfo")
}
names = append(names, base+".nfo")
for _, name := range names {
path := filepath.Join(dir, name)
if doc, _, err := readNFO(path); err == nil {
return doc, path, nil
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, "", err
}
}
if samePath(dir, root) {
return nil, "", os.ErrNotExist
}
parent := filepath.Dir(dir)
if parent == dir {
return nil, "", os.ErrNotExist
}
dir = parent
}
}
func metadataFromDoc(doc *nfoDocument, baseDir string, seriesLike bool) *LocalMetadata {
if doc == nil {
return nil
}
meta := &LocalMetadata{
Title: cleanXMLText(doc.Title),
OriginalName: cleanXMLText(doc.OriginalTitle),
AdultCode: normalizeAdultCode(doc.Num),
Year: int(doc.Year),
Overview: firstText(doc.Plot, doc.Outline, doc.OriginalPlot),
Rating: float32(doc.Rating),
PosterURL: firstRemoteURL(baseDir, nfoPosterValues(doc)...),
BackdropURL: firstRemoteURL(baseDir, nfoBackdropValues(doc)...),
TMDbID: int(doc.TMDbID),
BangumiID: mustAtoi(externalIDFromUniqueIDs(doc.UniqueIDs, "bangumi", "bgm")),
DoubanID: externalIDFromUniqueIDs(doc.UniqueIDs, "douban"),
TheTVDBID: externalIDFromUniqueIDs(doc.UniqueIDs, "thetvdb", "tvdb"),
SeasonNum: int(doc.Season),
EpisodeNum: int(doc.Episode),
Genres: joinNFOValues(adultAwareGenres(doc)),
Countries: joinNFOValues(doc.Countries),
Languages: joinNFOValues(doc.Languages),
HasNFO: true,
}
if meta.AdultCode == "" {
meta.AdultCode = normalizeAdultCode(firstText(doc.OriginalTitle, doc.SortTitle, doc.Title))
}
if meta.AdultCode != "" {
meta.NSFW = true
if meta.OriginalName == "" || strings.EqualFold(meta.OriginalName, meta.Title) {
meta.OriginalName = meta.AdultCode
}
}
if seriesLike && cleanXMLText(doc.ShowTitle) != "" {
meta.Title = cleanXMLText(doc.ShowTitle)
if cleanXMLText(doc.Title) != "" {
meta.OriginalName = cleanXMLText(doc.Title)
}
}
if meta.Year == 0 {
meta.Year = yearFromDate(firstText(doc.Premiered, doc.ReleaseDate, doc.Release, doc.Aired))
}
if meta.TMDbID == 0 {
meta.TMDbID = tmdbIDFromUniqueIDs(doc.UniqueIDs)
}
return meta
}
func adultAwareGenres(doc *nfoDocument) []string {
if doc == nil {
return nil
}
values := make([]string, 0, len(doc.Genres)+len(doc.Tags)+len(doc.Actors)+4)
values = append(values, doc.Genres...)
values = append(values, doc.Tags...)
for _, value := range []string{doc.Studio, doc.Maker, doc.Publisher, doc.Label} {
if cleanXMLText(value) != "" {
values = append(values, cleanXMLText(value))
}
}
for _, value := range doc.Directors {
if cleanXMLText(value) != "" {
values = append(values, cleanXMLText(value))
}
}
for _, actor := range doc.Actors {
if cleanXMLText(actor.Name) != "" {
values = append(values, cleanXMLText(actor.Name))
} else if cleanXMLText(actor.Role) != "" {
values = append(values, cleanXMLText(actor.Role))
}
}
return values
}
func metadataFromArtwork(mediaPath, showBaseDir string) *LocalMetadata {
meta := &LocalMetadata{}
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
if meta.PosterURL == "" && meta.BackdropURL == "" {
return nil
}
return meta
}
func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
if meta == nil {
return
}
mediaDir := filepath.Dir(mediaPath)
if localPoster := firstLocalPoster(mediaPath, showBaseDir); localPoster != "" {
meta.PosterURL = localPoster
} else if meta.PosterURL == "" {
meta.PosterURL = firstAdultLooseImage(mediaDir, "poster")
}
dirs := []string{mediaDir, showBaseDir}
for _, dir := range dirs {
if dir == "" {
continue
}
if img := firstExistingImage(dir, localBackdropCandidates(mediaPath)...); img != "" {
meta.BackdropURL = img
break
}
if meta.BackdropURL == "" {
meta.BackdropURL = firstAdultLooseImage(dir, "backdrop")
}
}
if !isLocalPath(meta.PosterURL) {
if dmmPoster := adultDMMPosterFromSampleURL(meta.BackdropURL); dmmPoster != "" {
meta.PosterURL = dmmPoster
}
}
if meta.PosterURL != "" || meta.BackdropURL != "" {
meta.HasArtwork = true
}
}
// mergeEpisodeMetadata 把单集 sidecar NFO(<episodedetails>)合并进整剧元数据
// dst(通常来自 tvshow.nfo)。
//
// 关键约束:「整剧级」字段(Title/OriginalName/TMDbID/BangumiID/DoubanID/TheTVDBID)
// 是合集分组键的依据,必须保证「同一部剧的各集一致」。而单集 NFO 里的
// <uniqueid type="tmdb"> 是【单集 episode id】(如 4375419)、<title> 是【单集名】
// (如「九龙拉棺」),都是单集级数据 —— 一旦写进整剧字段,同剧每集的 id/原名互不
// 相同,会被前端 getSeriesKey / Emby seriesGroupsFromMedia 拆成多张卡(每集一卡)。
// 因此:
// - 单集外部 id(tmdb/bangumi/douban/thetvdb)一律【不写入】整剧外部 id;
// 整剧 id 只认 tvshow.nfo(已在 dst);无则留空,交由路径剧名分组兜底。
// - 单集名【不写入】OriginalName(整剧原名);整剧原名只来自 tvshow.nfo。
// - 仅 overview/rating/剧照/季集号等【单集级】字段按集回填(不影响分组)。
func mergeEpisodeMetadata(dst, episode *LocalMetadata, doc *nfoDocument) {
showTitle := cleanXMLText(doc.ShowTitle)
// 整剧标题: 优先 <showtitle>(MoviePilot 在单集 NFO 里也会写整剧名);
// 其次保留 dst 已有(来自 tvshow.nfo);最后才退而用单集名占位。
if showTitle != "" {
dst.Title = showTitle
} else if dst.Title == "" {
if episodeTitle := cleanXMLText(doc.Title); episodeTitle != "" {
dst.Title = episodeTitle
}
}
// 注意: 不要把单集名 / 单集 originaltitle 写进 OriginalName(整剧原名,分组键)。
// 单集级展示字段: 每个媒体行本就对应一集,这些可安全按集回填。
if episode.Year > 0 {
dst.Year = episode.Year
}
if episode.Overview != "" {
dst.Overview = episode.Overview
}
if episode.Rating > 0 {
dst.Rating = episode.Rating
}
if episode.PosterURL != "" {
dst.PosterURL = episode.PosterURL
}
if episode.BackdropURL != "" {
dst.BackdropURL = episode.BackdropURL
}
// 整剧外部 id: 单集 NFO 的 id 都是单集级,绝不写入整剧字段(见上方说明)。
if episode.SeasonNum > 0 || episode.EpisodeNum > 0 {
dst.SeasonNum = episode.SeasonNum
}
if episode.EpisodeNum > 0 {
dst.EpisodeNum = episode.EpisodeNum
}
// 题材/地区/语言为整剧级,单集 NFO 偶尔携带时仅在整剧未提供时回填。
if dst.Genres == "" && episode.Genres != "" {
dst.Genres = episode.Genres
}
if dst.Countries == "" && episode.Countries != "" {
dst.Countries = episode.Countries
}
if dst.Languages == "" && episode.Languages != "" {
dst.Languages = episode.Languages
}
}
func tmdbIDFromUniqueIDs(ids []nfoUniqueID) int {
value := externalIDFromUniqueIDs(ids, "tmdb")
if value == "" {
return 0
}
v, _ := strconv.Atoi(value)
return v
}
func externalIDFromUniqueIDs(ids []nfoUniqueID, types ...string) string {
for _, id := range ids {
idType := strings.TrimSpace(id.Type)
for _, typ := range types {
if strings.EqualFold(idType, typ) {
return strings.TrimSpace(id.Value)
}
}
}
return ""
}
func firstRemoteURL(baseDir string, values ...string) string {
for _, value := range values {
value = cleanXMLText(value)
if value == "" {
continue
}
if isHTTPURL(value) {
return value
}
if filepath.IsAbs(value) && fileExists(value) {
return filepath.Clean(value)
}
if baseDir != "" {
local := filepath.Join(baseDir, filepath.FromSlash(value))
if fileExists(local) {
return filepath.Clean(local)
}
}
}
return ""
}
func localPosterCandidates(mediaPath string) []string {
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
names := []string{
base + "-poster",
base + ".poster",
"poster",
"folder",
"cover",
"movie",
"show",
base + "-cover",
base + ".cover",
base,
base + "-thumb",
base + ".thumb",
"thumb",
}
return append(adultArtworkNameCandidates(mediaPath, "poster"), names...)
}
func localBackdropCandidates(mediaPath string) []string {
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
names := []string{
base + "-fanart",
base + ".fanart",
base + "-backdrop",
base + ".backdrop",
base + "-background",
"fanart",
"backdrop",
"background",
"landscape",
"banner",
"clearart",
}
return append(adultArtworkNameCandidates(mediaPath, "backdrop"), names...)
}
func adultArtworkNameCandidates(mediaPath, kind string) []string {
code := AdultCodeFromMediaPath(mediaPath)
if code == "" {
return nil
}
compact := strings.ReplaceAll(code, "-", "")
bases := []string{code, compact}
bases = append(bases, adultDMMNameCandidates(code)...)
out := make([]string, 0, len(bases)*6)
for _, base := range bases {
if base == "" {
continue
}
if kind == "poster" {
out = append(out, base, base+"-poster", base+".poster", base+"-cover", base+".cover", base+"-thumb", base+".thumb", base+"pl", base+"-pl")
} else {
out = append(out, base+"-fanart", base+".fanart", base+"-backdrop", base+".backdrop", base+"-background", base+"-landscape", base+"jp", base+"jp-1")
}
}
return out
}
func adultDMMNameCandidates(code string) []string {
parts := adultStandardPattern.FindStringSubmatch(code)
if len(parts) < 3 {
return nil
}
prefix := strings.ToLower(parts[1])
num := strings.TrimLeft(parts[2], "0")
if num == "" {
num = "0"
}
padded := num
for len(padded) < 5 {
padded = "0" + padded
}
return []string{prefix + padded}
}
func firstExistingImage(dir string, names ...string) string {
if dir == "" {
return ""
}
for _, name := range names {
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
path := filepath.Join(dir, name+ext)
if fileExists(path) {
return filepath.Clean(path)
}
}
}
return ""
}
func nfoPosterValues(doc *nfoDocument) []string {
if doc == nil {
return nil
}
values := []string{doc.Poster, doc.Art.Poster}
for _, thumb := range doc.Thumbs {
aspect := strings.ToLower(strings.TrimSpace(thumb.Aspect))
if aspect == "" || aspect == "poster" || aspect == "cover" || aspect == "default" {
values = append(values, thumb.Value)
}
}
values = append(values, doc.Art.Thumb)
return values
}
func nfoBackdropValues(doc *nfoDocument) []string {
if doc == nil {
return nil
}
values := []string{doc.Fanart.Value, doc.Art.Fanart, doc.Art.Backdrop, doc.Art.Background, doc.Art.Landscape, doc.Art.Banner}
for _, thumb := range doc.Thumbs {
aspect := strings.ToLower(strings.TrimSpace(thumb.Aspect))
if aspect == "fanart" || aspect == "backdrop" || aspect == "background" || aspect == "landscape" {
values = append(values, thumb.Value)
}
}
values = append(values, doc.Fanart.Thumbs...)
return values
}
func firstLocalPoster(mediaPath, showBaseDir string) string {
mediaDir := filepath.Dir(mediaPath)
dirs := []string{}
if showBaseDir != "" && !samePath(showBaseDir, mediaDir) {
dirs = append(dirs, showBaseDir)
}
dirs = append(dirs, mediaDir)
for _, dir := range dirs {
if localPoster := firstExistingPosterImage(dir, localPosterCandidates(mediaPath)...); localPoster != "" {
return localPoster
}
}
return ""
}
func firstExistingPosterImage(dir string, names ...string) string {
if dir == "" {
return ""
}
for _, name := range names {
if isRejectedPosterName(name) {
continue
}
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
path := filepath.Join(dir, name+ext)
if fileExists(path) && likelyPosterImage(path) {
return filepath.Clean(path)
}
}
}
return ""
}
func firstAdultLooseImage(dir, kind string) string {
if dir == "" {
return ""
}
matches, _ := filepath.Glob(filepath.Join(dir, "*"))
preferred := []string{}
fallback := []string{}
for _, path := range matches {
ext := strings.ToLower(filepath.Ext(path))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
continue
}
name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), ext))
if kind == "poster" {
if isRejectedPosterName(name) {
continue
}
if strings.Contains(name, "poster") || strings.Contains(name, "cover") || strings.Contains(name, "folder") || strings.Contains(name, "movie") || strings.HasSuffix(name, "pl") {
preferred = append(preferred, path)
}
} else if strings.Contains(name, "fanart") || strings.Contains(name, "backdrop") || strings.Contains(name, "background") || strings.Contains(name, "landscape") || strings.Contains(name, "jp") {
preferred = append(preferred, path)
}
if kind != "poster" || likelyPosterImage(path) {
fallback = append(fallback, path)
}
}
if len(preferred) > 0 {
return filepath.Clean(preferred[0])
}
if kind == "poster" && len(fallback) == 1 {
return filepath.Clean(fallback[0])
}
return ""
}
func isRejectedPosterName(name string) bool {
name = strings.ToLower(name)
rejected := []string{
"actor", "actors", "actress", "cast", "avatar", "portrait", "person",
"sample", "screenshot", "screen", "still", "scene", "extrafanart", "extrathumb",
"fanart", "backdrop", "background", "landscape", "banner", "clearlogo", "clearart", "logo", "disc",
}
for _, token := range rejected {
if strings.Contains(name, token) {
return true
}
}
return false
}
func likelyPosterImage(path string) bool {
file, err := os.Open(path) // #nosec G304 -- path is a discovered artwork sidecar under the configured library root.
if err != nil {
return false
}
defer file.Close()
cfg, _, err := image.DecodeConfig(file)
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
return true
}
return cfg.Height >= cfg.Width
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func isHTTPURL(raw string) bool {
u, err := url.Parse(raw)
if err != nil {
return false
}
return (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
func isLocalPath(raw string) bool {
raw = strings.TrimSpace(raw)
return raw != "" && !isHTTPURL(raw)
}
func firstText(values ...string) string {
for _, value := range values {
if text := cleanXMLText(value); text != "" {
return text
}
}
return ""
}
func cleanXMLText(value string) string {
return strings.TrimSpace(value)
}
func joinNFOValues(values []string) string {
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, value := range values {
for _, part := range strings.Split(value, ",") {
part = cleanXMLText(part)
if part == "" {
continue
}
key := strings.ToLower(part)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, part)
}
}
return strings.Join(out, ",")
}
func yearFromDate(value string) int {
if len(value) < 4 {
return 0
}
year, _ := strconv.Atoi(value[:4])
return year
}
func samePath(a, b string) bool {
return strings.EqualFold(filepath.Clean(a), filepath.Clean(b))
}