Files
MeBox/internal/service/media_series.go
T
truewhile 1ec8303fec bug处理
2026-09-14 21:16:34 +08:00

551 lines
17 KiB
Go

package service
import (
"context"
"errors"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
type seriesCardsCacheValue struct {
Cards []SeriesCard `json:"cards"`
Total int64 `json:"total"`
}
// libraryRowsCacheValue 整库可见行 + 预计算索引的对象缓存。填充时一次性完成
// 整库 SQL 加载与 series key 解析,之后剧集卡片、剧集列表、媒体详情剧集
// 共享这份结果:命中路径零 SQL、零逐行 key 解析。Rows/Episodes 视为不可变。
type libraryRowsCacheValue struct {
Rows []model.Media
Resolver mediaSeriesKeyResolver
Episodes map[string][]model.Media
Cards []SeriesCard
}
type SeriesCard struct {
Key string `json:"key"`
Rep model.Media `json:"rep"`
LinkMedia model.Media `json:"linkMedia"`
Count int `json:"count"`
IsSeries bool `json:"is_series,omitempty"`
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
}
// SeriesCardView is the compact payload used by homepage and library preview
// endpoints. LinkMedia is only needed to resolve the target library ID, so
// sending the full media row twice roughly doubles the preview JSON for no UI
// benefit.
type SeriesCardView struct {
Key string `json:"key"`
Rep model.Media `json:"rep"`
LinkLibraryID string `json:"linkLibraryId,omitempty"`
Count int `json:"count"`
IsSeries bool `json:"is_series,omitempty"`
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
}
func NewSeriesCardViews(cards []SeriesCard) []SeriesCardView {
if len(cards) == 0 {
return []SeriesCardView{}
}
out := make([]SeriesCardView, len(cards))
for i, card := range cards {
out[i] = SeriesCardView{
Key: card.Key,
Rep: card.Rep,
LinkLibraryID: mediaTargetLibraryID(card.LinkMedia),
Count: card.Count,
IsSeries: card.IsSeries,
LastAddedAt: card.LastAddedAt,
}
}
return out
}
type seriesCardGroup struct {
card SeriesCard
latest time.Time
}
// libraryRowsWithIndex 返回整库行与预计算剧集索引(带对象缓存)。
func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID string, visibility MediaVisibility) (*libraryRowsCacheValue, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
cacheKey := s.libraryRowsCacheKey(libraryID, visibility)
value, err, _ := s.libraryRowsFlight.Do(cacheKey, func() (any, error) {
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := obj.(*libraryRowsCacheValue); ok {
return cached, nil
}
}
}
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
defer cancel()
rows, _, err := s.listAllMediaVisible(loadCtx, libraryID, visibility)
if err != nil {
return nil, err
}
// listAllMediaVisible 走 ListMediaVisible,行已带库元数据(resolver 的
// key 计算依赖 DisplayLibraryPath/ID)。
resolver, keys := resolveMediaSeriesKeys(rows)
episodes := make(map[string][]model.Media, len(rows)/4+1)
for i, row := range rows {
k := keys[i]
if k == "" {
continue
}
episodes[k] = append(episodes[k], row)
}
cards := groupMediaSeriesCardsByKeys(rows, keys)
value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes, Cards: cards}
if s.cache != nil {
s.cache.SetObject(cacheKey, value, s.derivedReadCacheTTL())
}
return value, nil
})
if err != nil {
return nil, err
}
if cached, ok := value.(*libraryRowsCacheValue); ok {
return cached, nil
}
return nil, nil
}
func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID string, visibility MediaVisibility) ([]SeriesCard, int64, error) {
cacheKey := s.libraryCardsObjectKey(libraryID, visibility)
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := obj.(*seriesCardsCacheValue); ok {
return cached.Cards, cached.Total, nil
}
}
}
rows, err := s.libraryRowsWithIndex(ctx, libraryID, visibility)
if err != nil {
return nil, 0, err
}
cards := rows.Cards
if cards == nil && len(rows.Rows) > 0 {
// Fallback keeps rolling-cache compatibility if an older runtime cache
// value was created before Cards was added.
cards = groupMediaSeriesCards(rows.Rows)
}
if cards == nil {
cards = []SeriesCard{}
}
total := int64(len(cards))
if s.cache != nil {
s.cache.SetObject(cacheKey, &seriesCardsCacheValue{Cards: cards, Total: total}, s.derivedReadCacheTTL())
}
return cards, total, nil
}
func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
if limit <= 0 {
limit = 24
} else if limit > 100 {
limit = 100
}
rows, err := s.SearchMediaVisible(ctx, "", maxMediaSearchLimit, visibility)
if err != nil {
return nil, err
}
cards := groupMediaSeriesCards(rows)
if len(cards) == 0 {
return []SeriesCard{}, nil
}
if len(cards) > limit {
cards = cards[:limit]
}
return cards, nil
}
func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID, key string, visibility MediaVisibility) ([]model.Media, error) {
rows, err := s.libraryRowsWithIndex(ctx, libraryID, visibility)
if err != nil {
return nil, err
}
// 预计算索引命中:O(1) 查找,拷贝后排序避免改动共享缓存。
if episodes, ok := rows.Episodes[key]; ok && len(episodes) > 0 {
out := make([]model.Media, len(episodes))
copy(out, episodes)
sortEpisodesForDisplay(out)
return out, nil
}
// 兜底:索引未命中(如卡片 key 与行缓存短暂跨代),保持原线性匹配逻辑。
all := rows.Rows
out := make([]model.Media, 0)
for _, row := range all {
if rows.Resolver.key(row) == key {
out = append(out, row)
}
}
if len(out) == 0 {
return []model.Media{}, nil
}
sortEpisodesForDisplay(out)
return out, nil
}
// sortEpisodesForDisplay 与历史行为一致:季/集号升序,再按入库时间兜底。
func sortEpisodesForDisplay(out []model.Media) {
sort.SliceStable(out, func(i, j int) bool {
if out[i].SeasonNum != out[j].SeasonNum {
return out[i].SeasonNum < out[j].SeasonNum
}
if out[i].EpisodeNum != out[j].EpisodeNum {
return out[i].EpisodeNum < out[j].EpisodeNum
}
return out[i].CreatedAt.Before(out[j].CreatedAt)
})
}
func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, visibility MediaVisibility) ([]model.Media, error) {
target, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
}
if target == nil {
return nil, errors.New("media not found")
}
if !visibility.Allows(target) {
return nil, errors.New("media not found")
}
if target.LibraryID == "" {
return []model.Media{*target}, nil
}
// 电影、音乐等单条目库不需要为了返回自身而加载整库。详情页会并行
// 请求 /media/:id/episodes,未短路时每次冷缓存都会触发一次全库分组。
if lib, err := s.repo.Library.FindByID(ctx, target.LibraryID); err == nil && lib != nil &&
libraryUsesSingleMediaRows(lib.Type) &&
strings.TrimSpace(target.SeriesID) == "" &&
!mediaLooksEpisodicForGrouping(*target) {
return []model.Media{*target}, nil
}
if seriesID := strings.TrimSpace(target.SeriesID); seriesID != "" {
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, target.LibraryID)
if err == nil && len(libraryIDs) > 0 {
rows, queryErr := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, maxMediaSearchLimit, repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
SeriesID: seriesID,
})
if queryErr == nil && len(rows) > 1 {
s.attachLibraryMetadata(ctx, rows)
sortEpisodesForDisplay(rows)
return rows, nil
}
}
}
cache, err := s.libraryRowsWithIndex(ctx, target.LibraryID, visibility)
if err != nil {
return nil, err
}
rows := cache.Rows
if len(rows) == 0 {
return []model.Media{*target}, nil
}
var out []model.Media
targetKey := cache.Resolver.key(*target)
if targetKey != "" {
// 预计算索引命中时拷贝,避免改动共享缓存。
if episodes, ok := cache.Episodes[targetKey]; ok {
out = make([]model.Media, len(episodes))
copy(out, episodes)
}
}
// 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影)
if len(out) <= 1 && target.Path != "" {
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
parentBase := filepath.Base(targetDir)
if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) {
targetTitleNorm := normalizeSeriesTitle(target.Title)
targetDirNorm := normalizeSeriesTitle(parentBase)
dirMatches := make([]model.Media, 0)
for _, row := range rows {
if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir {
continue
}
if row.ID == target.ID {
dirMatches = append(dirMatches, row)
continue
}
rowTitleNorm := normalizeSeriesTitle(row.Title)
allowMatch := false
if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) {
allowMatch = true
} else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm {
allowMatch = true
} else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm {
allowMatch = true
}
if allowMatch {
dirMatches = append(dirMatches, row)
}
}
if len(dirMatches) > 1 {
out = dirMatches
}
}
}
if len(out) == 0 {
out = []model.Media{*target}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].SeasonNum != out[j].SeasonNum {
return out[i].SeasonNum < out[j].SeasonNum
}
if out[i].EpisodeNum != out[j].EpisodeNum {
return out[i].EpisodeNum < out[j].EpisodeNum
}
if out[i].Path != out[j].Path {
return out[i].Path < out[j].Path
}
return out[i].CreatedAt.Before(out[j].CreatedAt)
})
return out, nil
}
func libraryUsesSingleMediaRows(libraryType string) bool {
switch strings.ToLower(strings.TrimSpace(libraryType)) {
case "movie", "movies", "music", "adult":
return true
default:
return false
}
}
func (s *MediaService) listAllMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, int64, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
if err != nil {
return nil, 0, err
}
filter := repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
rows, err := s.repo.Media.ListAllByLibrariesFilteredNoCount(ctx, libraryIDs, filter)
if err != nil {
return nil, 0, err
}
s.attachLibraryMetadata(ctx, rows)
return rows, int64(len(rows)), nil
}
func groupMediaSeriesCards(items []model.Media) []SeriesCard {
if len(items) == 0 {
return nil
}
_, keys := resolveMediaSeriesKeys(items)
return groupMediaSeriesCardsByKeys(items, keys)
}
// groupMediaSeriesCardsByKeys performs the card fold using already-resolved
// keys. Full-library caches need the same keys for the episode index and the
// series-card list; resolving them once avoids several regex-heavy passes over
// every episode row.
func groupMediaSeriesCardsByKeys(items []model.Media, keys []string) []SeriesCard {
if len(items) == 0 {
return nil
}
if len(keys) != len(items) {
return groupMediaSeriesCards(items)
}
groups := make([]seriesCardGroup, 0)
byKey := make(map[string]int, len(items))
for i, item := range items {
key := keys[i]
if key == "" {
continue
}
itemAdded := item.CreatedAt
if itemAdded.IsZero() {
itemAdded = item.UpdatedAt
}
if idx, ok := byKey[key]; ok {
group := &groups[idx]
if latest := seriesMediaTime(item); latest.After(group.latest) {
group.latest = latest
}
card := &group.card
if card.LastAddedAt == nil || (!itemAdded.IsZero() && itemAdded.After(*card.LastAddedAt)) {
t := itemAdded
card.LastAddedAt = &t
}
// A shared external ID means duplicate encodes/locations for movies,
// not multiple episodes. Keep a single movie card without presenting
// its versions as an "N episodes" collection.
if mediaLooksEpisodicForGrouping(item) || mediaLooksEpisodicForGrouping(card.LinkMedia) {
card.Count++
}
if betterSeriesLinkMedia(item, card.LinkMedia) {
card.LinkMedia = item
}
if betterSeriesRepresentative(item, card.Rep) {
card.Rep = item
}
continue
}
var initialLastAdded *time.Time
if !itemAdded.IsZero() {
t := itemAdded
initialLastAdded = &t
}
byKey[key] = len(groups)
groups = append(groups, seriesCardGroup{
card: SeriesCard{Key: key, Rep: item, LinkMedia: item, Count: 1, LastAddedAt: initialLastAdded},
latest: seriesMediaTime(item),
})
}
sort.SliceStable(groups, func(i, j int) bool {
return groups[i].latest.After(groups[j].latest)
})
cards := make([]SeriesCard, 0, len(groups))
for _, group := range groups {
cards = append(cards, group.card)
}
return cards
}
// GroupMediaSeriesItems folds episode-level rows into one representative media
// row per series. It is intended for search surfaces where applying a small
// limit before series grouping would otherwise return several episodes from
// the same show.
func GroupMediaSeriesItems(items []model.Media) []model.Media {
cards := groupMediaSeriesCards(items)
if len(cards) == 0 {
return []model.Media{}
}
out := make([]model.Media, 0, len(cards))
for _, card := range cards {
out = append(out, card.Rep)
}
return out
}
func betterSeriesRepresentative(candidate, current model.Media) bool {
// A theatrical feature can have local poster.jpg/background.jpg files and
// therefore a higher artwork score than its TV episodes. Keep the TV row as
// the visible identity of a mixed series card so a movie cannot hijack the
// series title, overview, and artwork.
candidateTheatrical := mediaLooksLikeTheatricalFeature(&candidate)
currentTheatrical := mediaLooksLikeTheatricalFeature(&current)
if candidateTheatrical != currentTheatrical {
return !candidateTheatrical
}
currentArtwork := seriesArtworkScore(candidate)
representativeArtwork := seriesArtworkScore(current)
if currentArtwork != representativeArtwork {
return currentArtwork > representativeArtwork
}
cur := candidate.SeasonNum*10000 + candidate.EpisodeNum
rep := current.SeasonNum*10000 + current.EpisodeNum
return cur > 0 && (rep == 0 || cur < rep)
}
func seriesMediaTime(media model.Media) time.Time {
if releaseDate := strings.TrimSpace(media.ReleaseDate); releaseDate != "" {
if parsed, err := time.Parse("2006-01-02", releaseDate); err == nil {
return parsed
}
}
if media.Year > 0 {
return time.Date(media.Year, time.December, 31, 0, 0, 0, 0, time.UTC)
}
if media.UpdatedAt.After(media.CreatedAt) {
return media.UpdatedAt
}
return media.CreatedAt
}
func betterSeriesLinkMedia(candidate, current model.Media) bool {
candidateScore := librarySpecificityScore(candidate)
currentScore := librarySpecificityScore(current)
if candidateScore != currentScore {
return candidateScore > currentScore
}
return seriesArtworkScore(candidate) > seriesArtworkScore(current)
}
func librarySpecificityScore(media model.Media) int {
rawPath := strings.TrimSpace(firstNonEmpty(media.DisplayLibraryPath, media.LibraryPath))
if rawPath == "" {
return 0
}
normalized := strings.TrimRight(strings.ReplaceAll(rawPath, "\\", "/"), "/")
lower := strings.ToLower(normalized)
if strings.HasPrefix(lower, "cloud://") {
rest := normalized[len("cloud://"):]
slash := strings.Index(rest, "/")
if slash < 0 || slash == len(rest)-1 {
return 0
}
return 100 + len(nonEmptySlashParts(rest[slash+1:]))
}
return 200 + len(nonEmptySlashParts(normalized))
}
func nonEmptySlashParts(value string) []string {
parts := strings.Split(value, "/")
out := parts[:0]
for _, part := range parts {
if strings.TrimSpace(part) != "" {
out = append(out, part)
}
}
return out
}
var (
posterArtworkRE = regexp.MustCompile(`(poster|folder|cover|movie|show|pl)(?:[._-]|\.[a-z0-9]+$|$)`)
badArtworkRE = regexp.MustCompile(`(actor|actress|cast|avatar|sample|screenshot|screen|still|scene|fanart|backdrop|background|landscape|banner|logo|disc)`)
)
func seriesArtworkScore(media model.Media) int {
poster := strings.ToLower(media.PosterURL)
backdrop := strings.ToLower(media.BackdropURL)
if poster == "" {
if backdrop != "" {
return 5
}
return 0
}
if posterArtworkRE.MatchString(poster) {
return 40
}
if badArtworkRE.MatchString(poster) {
return 10
}
if strings.Contains(poster, "thumb") {
return 20
}
return 30
}
func minInt64(a int64, b int) int {
if a <= 0 {
return 0
}
if a > int64(b) {
return b
}
return int(a)
}