mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
b44c7f860b
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 运行产物。
149 lines
3.8 KiB
Go
149 lines
3.8 KiB
Go
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
|
||
}
|