mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-10 23:36:37 +08:00
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:
+79
-21
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user