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
+148
View File
@@ -0,0 +1,148 @@
package service
import (
"context"
"errors"
"strings"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
type MediaMetadataUpdate struct {
Title *string `json:"title"`
OriginalName *string `json:"original_name"`
Overview *string `json:"overview"`
PosterURL *string `json:"poster_url"`
BackdropURL *string `json:"backdrop_url"`
Year *int `json:"year"`
Rating *float32 `json:"rating"`
SeasonNum *int `json:"season_num"`
EpisodeNum *int `json:"episode_num"`
TMDbID *int `json:"tmdb_id"`
BangumiID *int `json:"bangumi_id"`
DoubanID *string `json:"douban_id"`
TheTVDBID *string `json:"thetvdb_id"`
Languages *string `json:"languages"`
Countries *string `json:"countries"`
Genres *string `json:"genres"`
NSFW *bool `json:"nsfw"`
}
func (s *MediaService) UpdateMetadata(ctx context.Context, id string, req MediaMetadataUpdate) (*model.Media, error) {
if s == nil || s.repo == nil || s.repo.DB == nil {
return nil, errors.New("media service unavailable")
}
id = strings.TrimSpace(id)
if id == "" {
return nil, errors.New("media id required")
}
if existing, err := s.repo.Media.FindByID(ctx, id); err != nil {
return nil, err
} else if existing == nil {
return nil, errors.New("media not found")
}
updates := map[string]any{"scrape_status": "matched"}
if req.Title != nil {
title := strings.TrimSpace(*req.Title)
if title == "" {
return nil, errors.New("title required")
}
updates["title"] = title
}
if req.OriginalName != nil {
updates["original_name"] = strings.TrimSpace(*req.OriginalName)
}
if req.Overview != nil {
updates["overview"] = strings.TrimSpace(*req.Overview)
}
if req.PosterURL != nil {
updates["poster_url"] = strings.TrimSpace(*req.PosterURL)
}
if req.BackdropURL != nil {
updates["backdrop_url"] = strings.TrimSpace(*req.BackdropURL)
}
if req.Year != nil {
updates["year"] = clampNonNegativeInt(*req.Year)
}
if req.Rating != nil {
updates["rating"] = clampRating(*req.Rating)
}
if req.SeasonNum != nil {
updates["season_num"] = clampNonNegativeInt(*req.SeasonNum)
}
if req.EpisodeNum != nil {
updates["episode_num"] = clampNonNegativeInt(*req.EpisodeNum)
}
if req.TMDbID != nil {
updates["tm_db_id"] = clampNonNegativeInt(*req.TMDbID)
}
if req.BangumiID != nil {
updates["bangumi_id"] = clampNonNegativeInt(*req.BangumiID)
}
if req.DoubanID != nil {
updates["douban_id"] = strings.TrimSpace(*req.DoubanID)
}
if req.TheTVDBID != nil {
updates["thetvdb_id"] = strings.TrimSpace(*req.TheTVDBID)
}
if req.Languages != nil {
updates["languages"] = normalizeMetadataCSV(*req.Languages)
}
if req.Countries != nil {
updates["countries"] = normalizeMetadataCSV(*req.Countries)
}
if req.Genres != nil {
updates["genres"] = normalizeMetadataCSV(*req.Genres)
}
if req.NSFW != nil {
updates["nsfw"] = *req.NSFW
}
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return nil, err
}
s.invalidateMediaCache(ctx)
return s.repo.Media.FindByID(ctx, id)
}
func normalizeMetadataCSV(value string) string {
parts := strings.FieldsFunc(value, func(r rune) bool {
switch r {
case ',', ',', ';', ';', '\n', '\r', '\t':
return true
default:
return false
}
})
out := make([]string, 0, len(parts))
seen := map[string]struct{}{}
for _, part := range parts {
part = strings.TrimSpace(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 clampNonNegativeInt(value int) int {
if value < 0 {
return 0
}
return value
}
func clampRating(value float32) float32 {
if value < 0 {
return 0
}
if value > 10 {
return 10
}
return value
}