Files
MeBox/internal/service/emby_movie_items.go
T
2026-10-11 23:44:11 +08:00

326 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"sort"
"strings"
"time"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// movieLibraryHasEpisodicContent 报告电影类型库里是否混入了「剧集结构」内容
// (有季集号且路径形如剧集,例如 .../国产剧/某剧/Season 01/某剧 - S01E01.mkv)。
// 用于决定是否需要走 movieLibraryItems 把这些内容聚成 Series 卡片。普通电影库
// 没有这类行时返回 false,继续走常规 mediaItems。
func (e *EmbyService) movieLibraryHasEpisodicContent(ctx context.Context, libraryID string) (bool, error) {
clause, args := embyLikelyEpisodicPathSQL()
if clause == "" {
return false, nil
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("library_id IN ?", e.mergedLibraryIDs(ctx, libraryID)).
Where("(season_num > 0 OR episode_num > 0) AND ("+clause+")", args...)
var count int64
if err := q.Limit(1).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// movieLibraryItems 处理电影类型库的常规浏览,返回「真正的电影(Movie)」与
// 「库内剧集结构内容聚成的 Series 卡片」的合并列表(按 DateCreated 倒序分页)。
// 与 mediaItems 的区别: 后者会把剧集结构行当散装 Episode 漏出;这里改为聚合成
// Series,从根本上消除「电影库里整部剧被拆成单集」的现象。
func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
cacheKey := e.embyItemsCacheKey("movie-library-items-v1", p)
var cached embyItemsCacheValue
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
e.rememberArtworkRefs(cached.Artwork)
return map[string]any{
"Items": cached.Items,
"TotalRecordCount": int(cached.TotalRecordCount),
"StartIndex": cached.StartIndex,
}, nil
}
libIDs := e.mergedLibraryIDs(ctx, p.ParentID)
apply := func(q *gorm.DB) *gorm.DB {
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
q = q.Where("library_id IN ?", libIDs)
if p.SearchTerm != "" {
// 方言相关的 ILIKE/LIKE:PostgreSQL 的 LIKE 区分大小写,
// 直接用 LIKE 会让客户端搜索的大小写与入库标题不一致时搜不到。
likeOp := repository.LikeOperator(e.repo.DB)
term := "%" + p.SearchTerm + "%"
q = q.Where("title "+likeOp+" ? OR original_name "+likeOp+" ?", term, term)
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
return nil
}
q = q.Joins("JOIN favorites ON favorites.media_id = media.id AND favorites.user_id = ? AND favorites.deleted_at IS NULL", p.UserID)
}
return q
}
// 剧集结构内容 -> Series 卡片。
clause, args := embyLikelyEpisodicPathSQL()
var episodicRows []model.Media
if clause != "" {
epQ := apply(e.repo.DB.WithContext(ctx).Model(&model.Media{}))
if epQ == nil {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil
}
epQ = epQ.Where("(season_num > 0 OR episode_num > 0) AND ("+clause+")", args...).
Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit)
if err := epQ.Find(&episodicRows).Error; err != nil {
return nil, err
}
}
seriesGroups := e.seriesGroupsFromMedia(ctx, episodicRows)
// 真正的电影 -> Movie 项(剔除剧集结构行)。
movieQ := apply(e.repo.DB.WithContext(ctx).Model(&model.Media{}))
if movieQ == nil {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil
}
movieQ = filterLikelyEpisodicPathsFromMovieQuery(movieQ).
Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit)
var movieRows []model.Media
if err := movieQ.Find(&movieRows).Error; err != nil {
return nil, err
}
// 先按版本去重,再参与排序。这里不立即构建 payload:大电影库可能有
// 数万行,而客户端一页通常只要几十条,提前构建会触发大量 NFO / 数据
// 查询并把响应时间浪费在用户根本看不到的条目上。
movieRows = e.collapseMediaVersionRows(ctx, movieRows)
// 合并: Series 卡片 + Movie 项, 统一按首播/上映日期倒序。
type entry struct {
sortAt time.Time
media *model.Media
group *embySeriesGroup
}
entries := make([]entry, 0, len(seriesGroups)+len(movieRows))
for i := range seriesGroups {
group := &seriesGroups[i]
entries = append(entries, entry{sortAt: embySeriesReleaseSortTime(*group), group: group})
}
for i := range movieRows {
media := &movieRows[i]
entries = append(entries, entry{sortAt: embyMediaReleaseSortTime(*media), media: media})
}
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].sortAt.After(entries[j].sortAt)
})
total := len(entries)
paged := pageSlice(entries, p.StartIndex, p.Limit)
pageMovies := make([]model.Media, 0, len(paged))
for _, en := range paged {
if en.media != nil {
pageMovies = append(pageMovies, *en.media)
}
}
moviePayloads, err := e.payloadsForMedia(ctx, pageMovies, p.UserID)
if err != nil {
return nil, err
}
payloadByID := make(map[string]map[string]any, len(moviePayloads))
for _, item := range moviePayloads {
if id, ok := item["Id"].(string); ok {
payloadByID[id] = item
}
}
items := make([]map[string]any, 0, len(paged))
pageGroups := make([]embySeriesGroup, 0, len(paged))
for _, en := range paged {
switch {
case en.group != nil:
pageGroups = append(pageGroups, *en.group)
items = append(items, e.seriesPayload(*en.group))
case en.media != nil:
if item := payloadByID[en.media.ID]; item != nil {
items = append(items, item)
}
}
}
out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}
if e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{
Items: items,
TotalRecordCount: int64(total),
StartIndex: p.StartIndex,
Artwork: e.artworkRefsForSeriesGroups(pageGroups),
}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
}
return out, nil
}
// embyPayloadCreatedAt 从 item payload 里取 DateCreated(time.Time),用于合并排序。
func embyPayloadCreatedAt(item map[string]any) time.Time {
if item == nil {
return time.Time{}
}
if v, ok := item["DateCreated"].(time.Time); ok {
return v
}
return time.Time{}
}
func embyPayloadReleaseSortTime(item map[string]any) time.Time {
if item == nil {
return time.Time{}
}
if v, ok := item["PremiereDate"].(time.Time); ok {
return v
}
if year, ok := item["ProductionYear"].(int); ok && year > 0 {
return time.Date(year, time.December, 31, 0, 0, 0, 0, time.UTC)
}
return embyPayloadCreatedAt(item)
}
func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) (bool, error) {
if strings.TrimSpace(libraryID) == "" {
return false, nil
}
// 走请求级缓存(若有),避免同一请求内对同一库重复查表。
if typ, ok, err := e.payloadLibraryType(ctx, libraryID); err != nil {
return false, err
} else if ok {
return embyLibraryTypeIsEpisodic(typ), nil
}
var count int64
err := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID)).
Count(&count).Error
return count > 0, err
}
func (e *EmbyService) mediaBelongsToEpisodicLibrary(ctx context.Context, m *model.Media) bool {
if e == nil || m == nil || strings.TrimSpace(m.LibraryID) == "" {
return false
}
typ, ok, err := e.payloadLibraryType(ctx, m.LibraryID)
if err != nil || !ok {
return false
}
return embyLibraryTypeIsEpisodic(typ)
}
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":
return true
default:
return false
}
}
func (e *EmbyService) filterMovieItems(ctx context.Context, q *gorm.DB) *gorm.DB {
episodicIDs := e.episodicLibraryIDs(ctx)
if len(episodicIDs) == 0 {
return filterLikelyEpisodicPathsFromMovieQuery(q)
}
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 {
episodicIDs := e.episodicLibraryIDs(ctx)
if len(episodicIDs) == 0 {
return q.Where("1 = 0")
}
return q.Where("media.library_id IN ? AND (media.season_num > 0 OR media.episode_num > 0)", episodicIDs)
}
func (e *EmbyService) episodicLibraryIDs(ctx context.Context) []string {
if e == nil || e.repo == nil || e.repo.DB == nil {
return nil
}
var ids []string
if err := e.repo.DB.WithContext(ctx).Model(&model.Library{}).
Where("LOWER(type) IN ?", []string{"tv", "anime", "variety"}).
Pluck("id", &ids).Error; err != nil {
return nil
}
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...)
}
// embyEpisodicPathExpr 把路径分隔符归一化成 '/' 后再小写化,供剧集路径判定使用。
//
// 反斜杠归一化必须放在 SQL 里做,而不是再生成一份反斜杠模式:PostgreSQL 的
// LIKE 默认就把反斜杠当转义符,模式结尾的 `\%` 会被解析成「字面百分号」而不是
// 「反斜杠 + 通配符」,那些模式在 PostgreSQL 下永不命中(SQLite 没有默认转义符,
// 所以只有换库之后才会暴露)。归一化列值后两种方言结果一致。
const embyEpisodicPathExpr = `LOWER(REPLACE(media.path, '\', '/'))`
func embyLikelyEpisodicPathSQL() (string, []any) {
patterns := []string{
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
"%/s0%/%", "%/s1%/%", "%/s2%/%", "%/s3%/%", "%/s4%/%", "%/s5%/%", "%/s6%/%", "%/s7%/%", "%/s8%/%", "%/s9%/%",
"%/special/%", "%/specials/%", "%/sp/%", "%/ova/%", "%/ovas/%", "%/oad/%", "%/oads/%", "%/ovd/%", "%/ovds/%", "%/ona/%", "%/onas/%", "%/extra/%", "%/extras/%", "%/bonus/%", "%/bonuses/%", "%/omake/%", "%/picture drama/%", "%/ncop/%", "%/nced/%",
"%/电视剧/%", "%/剧集/%", "%/连续剧/%", "%/短剧/%", "%/国产剧/%", "%/国剧/%", "%/大陆剧/%", "%/华语剧/%", "%/国产电视剧/%", "%/大陆电视剧/%", "%/华语电视剧/%", "%/欧美剧/%", "%/欧美电视剧/%", "%/美剧/%", "%/英剧/%", "%/日韩剧/%", "%/日韩电视剧/%", "%/日剧/%", "%/韩剧/%", "%/港剧/%", "%/台剧/%", "%/港台剧/%", "%/泰剧/%",
"%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%",
}
clauses := make([]string, 0, len(patterns))
args := make([]any, 0, len(patterns))
for _, pattern := range patterns {
clauses = append(clauses, embyEpisodicPathExpr+" LIKE ?")
args = append(args, 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/", "/ovas/", "/oad/", "/oads/", "/ovd/", "/ovds/", "/ona/", "/onas/", "/extra/", "/extras/", "/bonus/", "/bonuses/", "/omake/", "/picture drama/", "/ncop/", "/nced/",
"/电视剧/", "/剧集/", "/连续剧/", "/短剧/", "/国产剧/", "/国剧/", "/大陆剧/", "/华语剧/", "/国产电视剧/", "/大陆电视剧/", "/华语电视剧/", "/欧美剧/", "/欧美电视剧/", "/美剧/", "/英剧/", "/日韩剧/", "/日韩电视剧/", "/日剧/", "/韩剧/", "/港剧/", "/台剧/", "/港台剧/", "/泰剧/",
"/日番/", "/国漫/", "/番剧/", "/动漫/", "/特别篇/", "/特別篇/", "/番外/", "/特典/",
} {
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
}