mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
261 lines
6.9 KiB
Go
261 lines
6.9 KiB
Go
package service
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/truewhile/MeBox/internal/model"
|
||
)
|
||
|
||
type MediaItem struct {
|
||
model.Media
|
||
Versions []model.Media `json:"versions,omitempty"`
|
||
}
|
||
|
||
func normalizeGroupedMediaPage(page, pageSize int) (int, int) {
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize <= 0 {
|
||
pageSize = 50
|
||
}
|
||
if pageSize > maxMediaSearchPageSize {
|
||
pageSize = maxMediaSearchPageSize
|
||
}
|
||
return page, pageSize
|
||
}
|
||
|
||
func paginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem {
|
||
page, pageSize = normalizeGroupedMediaPage(page, pageSize)
|
||
if len(items) == 0 {
|
||
// 返回非 nil 空切片:nil 会被 JSON 序列化成 "items": null,
|
||
// 前端 concat(null) 会得到 [null] 并在渲染期崩溃(空库进入白屏)。
|
||
return []MediaItem{}
|
||
}
|
||
start := (page - 1) * pageSize
|
||
if start >= len(items) {
|
||
return []MediaItem{}
|
||
}
|
||
end := start + pageSize
|
||
if end > len(items) {
|
||
end = len(items)
|
||
}
|
||
return items[start:end]
|
||
}
|
||
|
||
// PaginateMediaItems 导出分页辅助函数。
|
||
func PaginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem {
|
||
return paginateMediaItems(items, page, pageSize)
|
||
}
|
||
|
||
func firstMediaItems(items []MediaItem, limit int) []MediaItem {
|
||
if len(items) == 0 {
|
||
return nil
|
||
}
|
||
if limit <= 0 {
|
||
limit = 50
|
||
}
|
||
if limit > maxMediaSearchLimit {
|
||
limit = maxMediaSearchLimit
|
||
}
|
||
if limit > len(items) {
|
||
limit = len(items)
|
||
}
|
||
return items[:limit]
|
||
}
|
||
|
||
// FirstMediaItems 导出截取前 N 项辅助函数。
|
||
func FirstMediaItems(items []MediaItem, limit int) []MediaItem {
|
||
return firstMediaItems(items, limit)
|
||
}
|
||
|
||
func groupMediaVersions(items []model.Media) []MediaItem {
|
||
if len(items) == 0 {
|
||
return nil
|
||
}
|
||
type group struct {
|
||
key string
|
||
primary model.Media
|
||
rows []model.Media
|
||
}
|
||
groups := make([]group, 0, len(items))
|
||
byKey := make(map[string]int, len(items))
|
||
for _, item := range items {
|
||
key := mediaVersionGroupKey(item)
|
||
if key == "" {
|
||
groups = append(groups, group{primary: item, rows: []model.Media{item}})
|
||
continue
|
||
}
|
||
if idx, ok := byKey[key]; ok {
|
||
groups[idx].rows = append(groups[idx].rows, item)
|
||
if betterMediaVersion(item, groups[idx].primary) {
|
||
groups[idx].primary = item
|
||
}
|
||
continue
|
||
}
|
||
byKey[key] = len(groups)
|
||
groups = append(groups, group{key: key, primary: item, rows: []model.Media{item}})
|
||
}
|
||
out := make([]MediaItem, 0, len(groups))
|
||
for _, g := range groups {
|
||
sort.SliceStable(g.rows, func(i, j int) bool {
|
||
return betterMediaVersion(g.rows[i], g.rows[j])
|
||
})
|
||
item := MediaItem{Media: g.primary}
|
||
if len(g.rows) > 1 {
|
||
item.Versions = g.rows
|
||
}
|
||
out = append(out, item)
|
||
}
|
||
sort.SliceStable(out, func(i, j int) bool {
|
||
return out[i].CreatedAt.After(out[j].CreatedAt)
|
||
})
|
||
return out
|
||
}
|
||
|
||
// GroupMediaVersions 导出多版本分组函数。
|
||
func GroupMediaVersions(items []model.Media) []MediaItem {
|
||
return groupMediaVersions(items)
|
||
}
|
||
|
||
func mediaVersionGroupKey(m model.Media) string {
|
||
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
||
if IsEmbyRemoteID(m.ID) {
|
||
return fmt.Sprintf("embyremote:%s", m.ID)
|
||
}
|
||
|
||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||
switch {
|
||
case m.TMDbID > 0:
|
||
return fmt.Sprintf("episode:tmdb:%d:%d:%d", m.TMDbID, m.SeasonNum, m.EpisodeNum)
|
||
case m.BangumiID > 0:
|
||
return fmt.Sprintf("episode:bangumi:%d:%d:%d", m.BangumiID, m.SeasonNum, m.EpisodeNum)
|
||
case strings.TrimSpace(m.DoubanID) != "":
|
||
return fmt.Sprintf("episode:douban:%s:%d:%d", strings.ToLower(strings.TrimSpace(m.DoubanID)), m.SeasonNum, m.EpisodeNum)
|
||
case strings.TrimSpace(m.TheTVDBID) != "":
|
||
return fmt.Sprintf("episode:thetvdb:%s:%d:%d", strings.ToLower(strings.TrimSpace(m.TheTVDBID)), m.SeasonNum, m.EpisodeNum)
|
||
}
|
||
title := firstNonEmpty(m.OriginalName, m.Title)
|
||
if title == "" {
|
||
title, _ = CleanQuery(m.Path)
|
||
}
|
||
title, _ = mediaVersionTitleKey(title)
|
||
if title == "" {
|
||
return ""
|
||
}
|
||
return strings.Join([]string{
|
||
"episode",
|
||
strings.ToLower(strings.TrimSpace(m.LibraryID)),
|
||
title,
|
||
fmt.Sprintf("%d:%d", m.SeasonNum, m.EpisodeNum),
|
||
}, "|")
|
||
}
|
||
|
||
libKey := strings.ToLower(strings.TrimSpace(m.LibraryID))
|
||
if libKey == "" {
|
||
libKey = strings.ToLower(strings.TrimSpace(m.DisplayLibraryID))
|
||
}
|
||
|
||
switch {
|
||
case m.TMDbID > 0:
|
||
if libKey != "" {
|
||
return fmt.Sprintf("movie:%s:tmdb:%d", libKey, m.TMDbID)
|
||
}
|
||
return fmt.Sprintf("tmdb:%d", m.TMDbID)
|
||
case m.BangumiID > 0:
|
||
if libKey != "" {
|
||
return fmt.Sprintf("movie:%s:bangumi:%d", libKey, m.BangumiID)
|
||
}
|
||
return fmt.Sprintf("bangumi:%d", m.BangumiID)
|
||
case strings.TrimSpace(m.DoubanID) != "":
|
||
if libKey != "" {
|
||
return fmt.Sprintf("movie:%s:douban:%s", libKey, strings.ToLower(strings.TrimSpace(m.DoubanID)))
|
||
}
|
||
return "douban:" + strings.ToLower(strings.TrimSpace(m.DoubanID))
|
||
case strings.TrimSpace(m.TheTVDBID) != "":
|
||
if libKey != "" {
|
||
return fmt.Sprintf("movie:%s:thetvdb:%s", libKey, strings.ToLower(strings.TrimSpace(m.TheTVDBID)))
|
||
}
|
||
return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID))
|
||
}
|
||
title := firstNonEmpty(m.OriginalName, m.Title)
|
||
titleYear := 0
|
||
if title == "" {
|
||
title, _ = CleanQuery(m.Path)
|
||
} else {
|
||
title, titleYear = mediaVersionTitleKey(title)
|
||
}
|
||
if title == "" {
|
||
return ""
|
||
}
|
||
year := m.Year
|
||
if year <= 0 {
|
||
year = titleYear
|
||
}
|
||
if year <= 0 {
|
||
_, year = CleanQuery(m.Path)
|
||
}
|
||
if libKey != "" {
|
||
return fmt.Sprintf("movie:%s:%s:%d", libKey, title, year)
|
||
}
|
||
return fmt.Sprintf("movie:%s:%d", title, year)
|
||
}
|
||
|
||
func mediaVersionTitleKey(value string) (string, int) {
|
||
cleaned, year := CleanQuery(value)
|
||
if strings.TrimSpace(cleaned) == "" {
|
||
cleaned = value
|
||
}
|
||
return normalizeMediaVersionText(cleaned), year
|
||
}
|
||
|
||
func normalizeMediaVersionText(value string) string {
|
||
value = strings.ToLower(strings.TrimSpace(value))
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
fields := strings.FieldsFunc(value, func(r rune) bool {
|
||
switch r {
|
||
case '.', '_', '-', ' ', '\t', '/', '\\', '[', ']', '(', ')', '(', ')', '【', '】':
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
})
|
||
out := fields[:0]
|
||
for _, field := range fields {
|
||
field = strings.TrimSpace(field)
|
||
if field == "" {
|
||
continue
|
||
}
|
||
if _, noise := noiseTokenSet[field]; noise {
|
||
continue
|
||
}
|
||
out = append(out, field)
|
||
}
|
||
return strings.Join(out, " ")
|
||
}
|
||
|
||
func betterMediaVersion(candidate, current model.Media) bool {
|
||
candidateCloud := isCloudMediaVersion(candidate)
|
||
currentCloud := isCloudMediaVersion(current)
|
||
if candidateCloud != currentCloud {
|
||
return !candidateCloud
|
||
}
|
||
candidatePixels := candidate.Width * candidate.Height
|
||
currentPixels := current.Width * current.Height
|
||
if candidatePixels != currentPixels {
|
||
return candidatePixels > currentPixels
|
||
}
|
||
if candidate.SizeBytes != current.SizeBytes {
|
||
return candidate.SizeBytes > current.SizeBytes
|
||
}
|
||
return candidate.CreatedAt.After(current.CreatedAt)
|
||
}
|
||
|
||
func isCloudMediaVersion(media model.Media) bool {
|
||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(media.Path)), "cloud://") ||
|
||
strings.Contains(strings.ToLower(strings.TrimSpace(media.STRMURL)), "/api/cloud/play/")
|
||
}
|