fix(media): 修复emby电影库混入剧集/特别篇识别/剧集合集 + 新增手动编辑元数据

emby 兼容层(hills/yamby 等客户端):
- 电影库不再混入剧集:filterLikelyEpisodicPathsFromMovieQuery 按路径
  特征(Season/S0x/Specials/电视剧/日番/国漫等)剔除误入电影库的剧集
- itemPayload 改用 mediaShouldBeEpisode:有季集号且(属剧集库或路径像剧集)
  才标记为 Episode,修正电影库里剧集被当成 Movie

特别篇识别:
- episode_parser 新增 patSpecialSeason,识别 S0/Season 0/Specials/SP/OVA/
  OAD/番外/特别篇/特典 文件夹 → 第0季;seasonFromParents 返回 found 标志,
  区分"明确第0季"与"未识别默认第1季"
- emby seasonDir 正则同步支持 Specials/SP/OVA/特别篇 等

剧集合集:
- groupSeries 抽出 isSeriesCard,卡片链接按是否剧集判定,修复剧集卡片
  跳转;每集分离的根因(季集号/分组key)随入库识别修复一并改善

手动编辑元数据(自采集视频无TMDB信息时可自定义封面/标题/简介/分类):
- 新增 MediaService.UpdateMetadata + PATCH /media/:id/metadata(需admin)
- 前端 MetadataEditDialog 接入 LibraryPage 与 MediaDetailPage

其他:scanner/scraper/nfo/local+cloud_metadata/adult 元数据识别配套改进;
.gitignore 忽略本地 .dev-cache/.dev-data 运行产物。
This commit is contained in:
ShukeBta
2026-06-18 21:56:25 +00:00
parent cce88ce3b0
commit b44c7f860b
34 changed files with 1601 additions and 207 deletions
+79 -21
View File
@@ -224,6 +224,9 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
} else if err != nil {
s.log.Warn("read local metadata before scrape failed", zap.String("media_id", m.ID), zap.Error(err))
}
} else if hinted, _ := pathHintMetadata(m.Path, seriesLike); hinted != nil {
local = mergeCloudMetadata(local, hinted)
applyLocalMetadata(m, local)
}
year := mediaYearHint(m)
@@ -299,6 +302,9 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
mediaType = lib.Type
}
if m.TMDbID > 0 {
if mediaIsEpisodic(m, lib) {
mediaType = "tv"
}
if match := s.manualTMDbMatchByID(ctx, m.TMDbID, normalizeMediaType(mediaType, m.Title, "")); match != nil {
return match
}
@@ -422,6 +428,15 @@ func mergeLocalMetadataIntoMatch(match *Match, local *LocalMetadata) {
if local.TMDbID > 0 {
match.TMDbID = local.TMDbID
}
if local.BangumiID > 0 {
match.BangumiID = local.BangumiID
}
if local.DoubanID != "" {
match.DoubanID = local.DoubanID
}
if local.TheTVDBID != "" {
match.TheTVDBID = local.TheTVDBID
}
if local.Genres != "" {
match.Genres = splitNFOList(local.Genres)
}
@@ -483,7 +498,7 @@ func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media,
// Manual cloud/batch applies must not fail just because an optional provider
// details request is slow or unavailable.
if match.TMDbID > 0 && s.tmdb != nil && s.tmdb.Enabled() {
mediaType := s.determineMediaType(lib, match)
mediaType := s.determineMediaTypeForMedia(lib, m, match)
detailCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), tmdbDetailsTimeout)
details, err := s.tmdb.GetDetails(detailCtx, match.TMDbID, mediaType)
cancel()
@@ -518,6 +533,50 @@ func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media,
zap.Strings("countries", details.Countries),
zap.Strings("genres", details.Genres))
}
if mediaType == "tv" && m != nil && m.EpisodeNum > 0 {
episodeCtx, episodeCancel := context.WithTimeout(context.WithoutCancel(ctx), tmdbDetailsTimeout)
episode, err := s.tmdb.GetTVEpisodeDetails(episodeCtx, match.TMDbID, m.SeasonNum, m.EpisodeNum)
episodeCancel()
if err != nil {
s.log.Debug("failed to get tmdb episode details",
zap.String("media_id", m.ID),
zap.Int("tmdb_id", match.TMDbID),
zap.Int("season", m.SeasonNum),
zap.Int("episode", m.EpisodeNum),
zap.Error(err))
} else if episode != nil {
episodeUpdates := map[string]any{}
if strings.TrimSpace(episode.Name) != "" {
episodeUpdates["original_name"] = strings.TrimSpace(episode.Name)
}
if strings.TrimSpace(episode.Overview) != "" {
episodeUpdates["overview"] = strings.TrimSpace(episode.Overview)
}
if strings.TrimSpace(episode.StillURL) != "" {
episodeUpdates["backdrop_url"] = strings.TrimSpace(episode.StillURL)
}
if episode.Rating > 0 {
episodeUpdates["rating"] = episode.Rating
}
if episode.AirYear > 0 && match.Year <= 0 {
episodeUpdates["year"] = episode.AirYear
}
if episode.Runtime > 0 && m.DurationSec <= 0 {
episodeUpdates["duration_sec"] = episode.Runtime * 60
}
if len(episodeUpdates) > 0 {
if err := s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID).
Updates(episodeUpdates).Error; err != nil {
s.log.Warn("failed to save tmdb episode metadata",
zap.String("media_id", m.ID),
zap.Int("tmdb_id", match.TMDbID),
zap.Int("season", m.SeasonNum),
zap.Int("episode", m.EpisodeNum),
zap.Error(err))
}
}
}
}
}
cloudMedia := isCloudMediaPath(m.Path) || (lib != nil && isCloudMediaPath(lib.Path))
if !cloudMedia {
@@ -548,9 +607,13 @@ func isCloudMediaPath(value string) bool {
func (s *ScraperService) applyLocalMetadataMatch(ctx context.Context, m *model.Media, local *LocalMetadata) error {
next := *m
applyLocalMetadata(&next, local)
status := "matched"
if !localMetadataMarksMatched(local) {
status = "pending"
}
updates := map[string]any{
"title": next.Title,
"scrape_status": "matched",
"scrape_status": status,
}
if next.OriginalName != "" {
updates["original_name"] = next.OriginalName
@@ -582,7 +645,7 @@ func (s *ScraperService) applyLocalMetadataMatch(ctx context.Context, m *model.M
if next.TheTVDBID != "" {
updates["thetvdb_id"] = next.TheTVDBID
}
if next.SeasonNum > 0 {
if next.SeasonNum > 0 || next.EpisodeNum > 0 {
updates["season_num"] = next.SeasonNum
}
if next.EpisodeNum > 0 {
@@ -644,7 +707,7 @@ func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
func seriesFolderTitle(mediaPath, libraryRoot string) string {
dir := filepath.Dir(mediaPath)
if strictSeasonFolder(filepath.Base(dir)) > 0 {
if strictSeasonFolderMatched(filepath.Base(dir)) {
dir = filepath.Dir(dir)
}
if libraryRoot != "" && samePath(dir, filepath.Clean(libraryRoot)) {
@@ -687,27 +750,15 @@ func isGenericMediaCategoryFolder(name string) bool {
}
func strictSeasonFolder(name string) int {
name = strings.TrimSpace(name)
if name == "" {
return 0
}
for _, pattern := range strictSeasonFolderPatterns {
if m := pattern.FindStringSubmatch(name); len(m) == 2 {
return mustAtoi(m[1])
}
if season, ok := seasonFromDir(name); ok {
return season
}
return 0
}
func seasonFromDir(name string) int {
if m := patSeasonFolder.FindStringSubmatch(name); len(m) >= 3 {
for _, group := range m[1:] {
if group != "" {
return mustAtoi(group)
}
}
}
return 0
func strictSeasonFolderMatched(name string) bool {
_, ok := seasonFromDir(name)
return ok
}
func titleCandidates(title string) []string {
@@ -961,6 +1012,13 @@ func (s *ScraperService) AnyEnabled() bool {
// determineMediaType returns "tv" for TV shows and "movie" for movies.
// It uses the library type as the primary signal.
func (s *ScraperService) determineMediaType(lib *model.Library, match *Match) string {
return s.determineMediaTypeForMedia(lib, nil, match)
}
func (s *ScraperService) determineMediaTypeForMedia(lib *model.Library, media *model.Media, match *Match) string {
if media != nil && mediaIsEpisodic(media, lib) {
return "tv"
}
if lib != nil {
switch lib.Type {
case "tv", "anime", "variety", "show", "shows":