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
+84 -7
View File
@@ -317,7 +317,7 @@ const (
)
var (
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季)$`)
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)$`)
embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`)
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
)
@@ -492,6 +492,9 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") {
q = e.filterMovieItems(ctx, q)
}
if parentKnownNonEpisodic && containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") {
q = filterLikelyEpisodicPathsFromMovieQuery(q)
}
if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") {
q = e.filterEpisodeItems(ctx, q)
}
@@ -892,7 +895,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
seriesID := m.SeriesID
seriesName := ""
seasonID := ""
if e.mediaBelongsToEpisodicLibrary(ctx, m) && (m.SeasonNum > 0 || m.EpisodeNum > 0) {
if e.mediaShouldBeEpisode(ctx, m) {
itemType = "Episode"
seriesID = e.seriesIDForMedia(m)
seriesName = e.seriesNameForMedia(m)
@@ -1027,6 +1030,16 @@ func (e *EmbyService) mediaBelongsToEpisodicLibrary(ctx context.Context, m *mode
return embyLibraryTypeIsEpisodic(lib.Type)
}
func (e *EmbyService) mediaShouldBeEpisode(ctx context.Context, m *model.Media) bool {
if m == nil || (m.SeasonNum <= 0 && m.EpisodeNum <= 0) {
return false
}
if e.mediaBelongsToEpisodicLibrary(ctx, m) {
return true
}
return embyMediaPathLooksEpisodic(m.Path)
}
func embyLibraryTypeIsEpisodic(typ string) bool {
switch strings.ToLower(strings.TrimSpace(typ)) {
case "tv", "anime", "variety":
@@ -1039,9 +1052,10 @@ func embyLibraryTypeIsEpisodic(typ string) bool {
func (e *EmbyService) filterMovieItems(ctx context.Context, q *gorm.DB) *gorm.DB {
episodicIDs := e.episodicLibraryIDs(ctx)
if len(episodicIDs) == 0 {
return q
return filterLikelyEpisodicPathsFromMovieQuery(q)
}
return q.Where("(media.season_num = 0 AND media.episode_num = 0) OR media.library_id NOT IN ?", episodicIDs)
q = q.Where("(media.season_num = 0 AND media.episode_num = 0) OR media.library_id NOT IN ?", episodicIDs)
return filterLikelyEpisodicPathsFromMovieQuery(q)
}
func (e *EmbyService) filterEpisodeItems(ctx context.Context, q *gorm.DB) *gorm.DB {
@@ -1065,6 +1079,63 @@ func (e *EmbyService) episodicLibraryIDs(ctx context.Context) []string {
return ids
}
func filterLikelyEpisodicPathsFromMovieQuery(q *gorm.DB) *gorm.DB {
clause, args := embyLikelyEpisodicPathSQL()
if clause == "" {
return q
}
return q.Where("NOT ((media.season_num > 0 OR media.episode_num > 0) AND ("+clause+"))", args...)
}
func embyLikelyEpisodicPathSQL() (string, []any) {
patterns := []string{
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
"%/s0%/%", "%/s1%/%", "%/s2%/%", "%/s3%/%", "%/s4%/%", "%/s5%/%", "%/s6%/%", "%/s7%/%", "%/s8%/%", "%/s9%/%",
"%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/oad/%", "%/extra/%", "%/extras/%",
"%/电视剧/%", "%/剧集/%", "%/国产剧/%", "%/欧美剧/%", "%/日韩剧/%", "%/日剧/%", "%/韩剧/%",
"%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%",
}
clauses := make([]string, 0, len(patterns)*2)
args := make([]any, 0, len(patterns)*2)
for _, pattern := range patterns {
clauses = append(clauses, "LOWER(media.path) LIKE ?")
args = append(args, pattern)
if strings.Contains(pattern, "/") {
clauses = append(clauses, "LOWER(media.path) LIKE ?")
args = append(args, strings.ReplaceAll(pattern, "/", `\`))
}
}
return strings.Join(clauses, " OR "), args
}
func embyMediaPathLooksEpisodic(path string) bool {
normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
if normalized == "" {
return false
}
for _, marker := range []string{
"/season ", "/season.", "/season-", "/season_", "/special/", "/specials/", "/sp/", "/ova/", "/oad/", "/extra/", "/extras/",
"/电视剧/", "/剧集/", "/国产剧/", "/欧美剧/", "/日韩剧/", "/日剧/", "/韩剧/",
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
} {
if strings.Contains(normalized, marker) {
return true
}
}
for _, marker := range []string{"/s0", "/s1", "/s2", "/s3", "/s4", "/s5", "/s6", "/s7", "/s8", "/s9"} {
if idx := strings.Index(normalized, marker); idx >= 0 {
after := idx + len(marker)
if after < len(normalized) && normalized[after] >= '0' && normalized[after] <= '9' {
slash := after + 1
if slash < len(normalized) && normalized[slash] == '/' {
return true
}
}
}
}
return false
}
func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) {
if e == nil || strings.TrimSpace(group.ID) == "" {
return
@@ -1316,7 +1387,7 @@ func (e *EmbyService) seasonsForSeries(series embySeriesGroup) []embySeasonGroup
order := []int{}
for _, episode := range series.Episodes {
seasonNum := episode.SeasonNum
if seasonNum <= 0 {
if seasonNum < 0 {
seasonNum = 1
}
season, ok := bySeason[seasonNum]
@@ -1502,11 +1573,17 @@ func stableEmbyID(prefix string, parts ...string) string {
}
func seasonID(seriesID string, seasonNum int) string {
return stableEmbyID(embyVirtualSeasonPrefix, seriesID, strconv.Itoa(maxInt(seasonNum, 1)))
if seasonNum < 0 {
seasonNum = 1
}
return stableEmbyID(embyVirtualSeasonPrefix, seriesID, strconv.Itoa(seasonNum))
}
func seasonName(seasonNum int) string {
if seasonNum <= 0 {
if seasonNum == 0 {
return "特别篇"
}
if seasonNum < 0 {
seasonNum = 1
}
return fmt.Sprintf("第 %d 季", seasonNum)