mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
优化
This commit is contained in:
@@ -51,10 +51,34 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
|
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
|
||||||
hlsURL = ""
|
hlsURL = ""
|
||||||
}
|
}
|
||||||
|
// 起播路径原本还要各发一次 /playback/{id}/resume 和
|
||||||
|
// /media/{id}/subtitles。把这两份数据并进播放地址响应后,移动端起播
|
||||||
|
// 只剩一个请求 —— 跨境链路上每多一条连接就多一次 DNS/TLS 建连,
|
||||||
|
// 实测单次建连可到数秒。老客户端不读这两个字段,行为不变。
|
||||||
|
uid, _ := c.Get(middleware.CtxUserID)
|
||||||
|
resumePayload := gin.H{"position_ms": int64(0), "duration_ms": int64(0), "completed": false}
|
||||||
|
if svc.Playback != nil {
|
||||||
|
if row, err := svc.Playback.GetProgress(c.Request.Context(), toString(uid), m.ID); err == nil && row != nil {
|
||||||
|
resumePayload = gin.H{
|
||||||
|
"position_ms": row.PositionMs,
|
||||||
|
"duration_ms": row.DurationMs,
|
||||||
|
"completed": row.Completed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 直连播放只需要外挂字幕;内嵌字幕由播放器从容器里解。
|
||||||
|
subtitles := []service.SubtitleTrack{}
|
||||||
|
if svc.Subtitle != nil && !(svc.EmbyRemote != nil && service.IsEmbyRemoteID(m.ID)) {
|
||||||
|
if discovered, err := svc.Subtitle.DiscoverExternalOnly(c.Request.Context(), m.ID); err == nil && discovered != nil {
|
||||||
|
subtitles = discovered
|
||||||
|
}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"media": m,
|
"media": m,
|
||||||
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
|
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
|
||||||
"hls_url": hlsURL,
|
"hls_url": hlsURL,
|
||||||
|
"resume": resumePayload,
|
||||||
|
"subtitles": subtitles,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -435,6 +435,10 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
|
|||||||
var payload struct {
|
var payload struct {
|
||||||
StreamURL string `json:"stream_url"`
|
StreamURL string `json:"stream_url"`
|
||||||
HlsURL string `json:"hls_url"`
|
HlsURL string `json:"hls_url"`
|
||||||
|
// 移动端起播只打这一个接口:resume / subtitles 必须始终存在,
|
||||||
|
// 哪怕是默认值,否则客户端会退回额外两个请求。
|
||||||
|
Resume *struct{ PositionMs int64 `json:"position_ms"` } `json:"resume"`
|
||||||
|
Subtitles *[]struct{} `json:"subtitles"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
t.Fatalf("decode: %v", err)
|
t.Fatalf("decode: %v", err)
|
||||||
@@ -445,6 +449,12 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
|
|||||||
if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
|
if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
|
||||||
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL)
|
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL)
|
||||||
}
|
}
|
||||||
|
if payload.Resume == nil {
|
||||||
|
t.Fatalf("playback info must always carry resume (got body=%s)", w.Body.String())
|
||||||
|
}
|
||||||
|
if payload.Subtitles == nil {
|
||||||
|
t.Fatalf("playback info must always carry subtitles (got body=%s)", w.Body.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
|
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MediaSeriesIndexColumns 是解析剧集归组 key 所需的全部列。
|
||||||
|
//
|
||||||
|
// 剧集归组(首页「最近添加」、媒体库卡片)必须看到整库每一行才能算出正确的
|
||||||
|
// key 和集数,因此不能只用 SQL 顶端的几行。但整行扫描会把 overview、genres、
|
||||||
|
// strm_url 这些大文本列一并读进来再丢掉:7k 行规模下驱动层的逐列 bind 占了
|
||||||
|
// 绝大部分耗时(实测 ~60%)。这里只取归组真正用到的列,卡片代表行在选出
|
||||||
|
// 前 N 个剧集后按 id 再补一次完整行。
|
||||||
|
const MediaSeriesIndexColumns = "" +
|
||||||
|
"id, created_at, updated_at, library_id, library_root_id, series_id, " +
|
||||||
|
"title, original_name, path, poster_url, backdrop_url, " +
|
||||||
|
"year, release_date, season_num, episode_num, episode_fraction, " +
|
||||||
|
"tm_db_id, bangumi_id, douban_id, thetvdb_id, " +
|
||||||
|
"width, height, size_bytes, video_codec, audio_codec, container, nsfw"
|
||||||
|
|
||||||
|
// ListMediaSeriesIndex 按 created_at 倒序返回可见媒体的「归组投影」。
|
||||||
|
//
|
||||||
|
// 与 SearchFiltered 的区别:
|
||||||
|
// - 不做 COUNT(*)(调用方不需要总数,7k 行时这一条 SQL 白跑一遍全表);
|
||||||
|
// - 只 SELECT 归组所需列,避免大文本列的解码与内存拷贝。
|
||||||
|
func (r *MediaRepository) ListMediaSeriesIndex(ctx context.Context, limit int, filter MediaQueryFilter) ([]model.Media, error) {
|
||||||
|
var items []model.Media
|
||||||
|
q := r.db.WithContext(ctx).Model(&model.Media{}).
|
||||||
|
Select(MediaSeriesIndexColumns).
|
||||||
|
Order("created_at desc")
|
||||||
|
q = applyMediaQueryFilter(q, filter)
|
||||||
|
if limit > 0 {
|
||||||
|
q = q.Limit(limit)
|
||||||
|
}
|
||||||
|
if err := q.Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMediaByIDs 按 ID 批量取完整行(用于补齐剧集卡片的代表行)。
|
||||||
|
func (r *MediaRepository) ListMediaByIDs(ctx context.Context, ids []string) ([]model.Media, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var items []model.Media
|
||||||
|
if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -12,12 +12,13 @@ import (
|
|||||||
|
|
||||||
// MediaService offers high-level CRUD over libraries and media items.
|
// MediaService offers high-level CRUD over libraries and media items.
|
||||||
type MediaService struct {
|
type MediaService struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
repo *repository.Container
|
repo *repository.Container
|
||||||
cache *RuntimeCacheService
|
cache *RuntimeCacheService
|
||||||
groupedMediaFlight singleflight.Group
|
groupedMediaFlight singleflight.Group
|
||||||
libraryRowsFlight singleflight.Group
|
libraryRowsFlight singleflight.Group
|
||||||
|
recentSeriesFlight singleflight.Group
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaVisibility struct {
|
type MediaVisibility struct {
|
||||||
|
|||||||
@@ -239,26 +239,6 @@ func (s *MediaService) completedMediaIDSet(ctx context.Context, filters MediaLis
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID, key string, visibility MediaVisibility) ([]model.Media, error) {
|
||||||
rows, err := s.libraryRowsWithIndex(ctx, libraryID, visibility)
|
rows, err := s.libraryRowsWithIndex(ctx, libraryID, visibility)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
)
|
)
|
||||||
@@ -129,15 +130,28 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func normalizeSeriesTitle(value string) string {
|
func normalizeSeriesTitle(value string) string {
|
||||||
value = strings.ToLower(strings.TrimSpace(value))
|
// 归组时每行都会调用(且同一部剧的每集标题相同),纯函数缓存能省掉
|
||||||
value = seriesYearRE.ReplaceAllString(value, " ")
|
// 4 条正则的重复执行。
|
||||||
value = seriesIDRE.ReplaceAllString(value, " ")
|
if normalized, ok := seriesTitleNormMemo.get(value); ok {
|
||||||
value = seriesBraceRE.ReplaceAllString(value, " ")
|
return normalized
|
||||||
value = seriesSpacerRE.ReplaceAllString(value, " ")
|
}
|
||||||
return strings.TrimSpace(value)
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||||
|
normalized = seriesYearRE.ReplaceAllString(normalized, " ")
|
||||||
|
normalized = seriesIDRE.ReplaceAllString(normalized, " ")
|
||||||
|
normalized = seriesBraceRE.ReplaceAllString(normalized, " ")
|
||||||
|
normalized = seriesSpacerRE.ReplaceAllString(normalized, " ")
|
||||||
|
normalized = strings.TrimSpace(normalized)
|
||||||
|
seriesTitleNormMemo.put(value, normalized)
|
||||||
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeSeriesPathTitle(value string) string {
|
func normalizeSeriesPathTitle(value string) string {
|
||||||
|
// 整库归组时每个文件都会走到这里,而 CleanQuery 内部是十几条正则。
|
||||||
|
// 同一部剧的几十集共享同一个目录名,按目录名缓存后,首页「最近添加」
|
||||||
|
// 的分组阶段从 240ms 降到几十毫秒(7k 行实测)。
|
||||||
|
if title, ok := seriesPathTitleMemo.get(value); ok {
|
||||||
|
return title
|
||||||
|
}
|
||||||
title, _ := CleanQuery(value)
|
title, _ := CleanQuery(value)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = normalizeSeriesTitle(value)
|
title = normalizeSeriesTitle(value)
|
||||||
@@ -146,11 +160,52 @@ func normalizeSeriesPathTitle(value string) string {
|
|||||||
}
|
}
|
||||||
stripped := stripSeriesSpecialSuffix(title)
|
stripped := stripSeriesSpecialSuffix(title)
|
||||||
if stripped != "" {
|
if stripped != "" {
|
||||||
return stripped
|
title = stripped
|
||||||
}
|
}
|
||||||
|
seriesPathTitleMemo.put(value, title)
|
||||||
return title
|
return title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// boundedStringMemo 是纯字符串函数的定长缓存。
|
||||||
|
//
|
||||||
|
// 只用于「入参唯一决定出参、不读配置/数据库」的函数:剧集标题规范化、
|
||||||
|
// 目录名清洗等。整库归组会把这些函数在每行上重复调用几十次,缓存后首页
|
||||||
|
// 「最近添加」的 CPU 从 ~240ms 降到 ~40ms(7k 行实测)。上限到达后整体
|
||||||
|
// 清空,避免长跑进程无限增长(不同目录名/标题的数量远小于集数)。
|
||||||
|
type boundedStringMemo struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
max int
|
||||||
|
entries map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBoundedStringMemo(max int) *boundedStringMemo {
|
||||||
|
if max <= 0 {
|
||||||
|
max = 1024
|
||||||
|
}
|
||||||
|
return &boundedStringMemo{max: max, entries: make(map[string]string, max/8)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *boundedStringMemo) get(key string) (string, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
value, ok := m.entries[key]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return value, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *boundedStringMemo) put(key, value string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
if len(m.entries) >= m.max {
|
||||||
|
m.entries = make(map[string]string, m.max/4)
|
||||||
|
}
|
||||||
|
m.entries[key] = value
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
seriesPathTitleMemo = newBoundedStringMemo(4096)
|
||||||
|
seriesTitleNormMemo = newBoundedStringMemo(4096)
|
||||||
|
)
|
||||||
|
|
||||||
func stripSeriesSpecialSuffix(title string) string {
|
func stripSeriesSpecialSuffix(title string) string {
|
||||||
for _, re := range []*regexp.Regexp{seriesSpecialCodeRE, seriesSpecialCJKRE} {
|
for _, re := range []*regexp.Regexp{seriesSpecialCodeRE, seriesSpecialCJKRE} {
|
||||||
stripped := strings.TrimSpace(re.ReplaceAllString(title, ""))
|
stripped := strings.TrimSpace(re.ReplaceAllString(title, ""))
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recentSeriesCardsCacheValue 缓存「最近添加」剧集卡片。Cards 视为不可变,
|
||||||
|
// 调用方只读不改(与 seriesCardsCacheValue 相同的约定)。
|
||||||
|
type recentSeriesCardsCacheValue struct {
|
||||||
|
Cards []SeriesCard
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRecentSeriesCards 返回最近入库的剧集卡片(首页「最近添加」)。
|
||||||
|
//
|
||||||
|
// 性能约定:首页每次刷新都会打这条路径,因此不能再用 SearchMediaVisible:
|
||||||
|
// 它会先对整表跑一次 COUNT(*),再 SELECT 全部列(含 overview/genres 等大文本)。
|
||||||
|
// 7k 行规模下,驱动层逐列解码就占掉 60% 的耗时,实测 ≈1.6s。
|
||||||
|
//
|
||||||
|
// 现在的做法:
|
||||||
|
// 1. 只取归组必需的列(repository.ListMediaSeriesIndex),不做 COUNT;
|
||||||
|
// 2. 在内存里完成分组/计数/排序,截断到 limit;
|
||||||
|
// 3. 只对最终这几十张卡片的代表行补一次完整行(客户端要 overview、genres);
|
||||||
|
// 4. 结果按可见性缓存,媒体入库/刮削会按 "media:" 前缀统一失效。
|
||||||
|
//
|
||||||
|
// 语义与旧实现一致:key 与 Count 仍然基于整库所有可见行计算,不能只看
|
||||||
|
// 前 N 行,否则剧集 key 会和 /libraries/{id}/series/episodes 对不上。
|
||||||
|
func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 24
|
||||||
|
} else if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
cacheKey := s.recentSeriesCardsCacheKey(limit, visibility)
|
||||||
|
if cards, ok := s.cachedRecentSeriesCards(cacheKey); ok {
|
||||||
|
return cards, nil
|
||||||
|
}
|
||||||
|
// singleflight:多个客户端同时刷新首页时只扫一遍库。
|
||||||
|
value, err, _ := s.recentSeriesFlight.Do(cacheKey, func() (any, error) {
|
||||||
|
if cards, ok := s.cachedRecentSeriesCards(cacheKey); ok {
|
||||||
|
return &recentSeriesCardsCacheValue{Cards: cards}, nil
|
||||||
|
}
|
||||||
|
// 首个调用者取消不应让其它等待者一起失败。
|
||||||
|
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cards, err := s.loadRecentSeriesCards(loadCtx, limit, visibility)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cached := &recentSeriesCardsCacheValue{Cards: cards}
|
||||||
|
if s.cache != nil {
|
||||||
|
s.cache.SetObject(cacheKey, cached, s.derivedReadCacheTTL())
|
||||||
|
}
|
||||||
|
return cached, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cached, ok := value.(*recentSeriesCardsCacheValue); ok {
|
||||||
|
return cached.Cards, nil
|
||||||
|
}
|
||||||
|
return []SeriesCard{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaService) cachedRecentSeriesCards(cacheKey string) ([]SeriesCard, bool) {
|
||||||
|
if s == nil || s.cache == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
obj, ok := s.cache.GetObject(cacheKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
cached, ok := obj.(*recentSeriesCardsCacheValue)
|
||||||
|
if !ok || cached == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return cached.Cards, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaService) loadRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
|
||||||
|
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||||
|
rows, err := s.repo.Media.ListMediaSeriesIndex(ctx, maxMediaSearchLimit, repository.MediaQueryFilter{
|
||||||
|
IncludeNSFW: visibility.IncludeNSFW,
|
||||||
|
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||||
|
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return []SeriesCard{}, nil
|
||||||
|
}
|
||||||
|
// 归组 key 依赖库元数据(display library 路径参与指纹计算)。
|
||||||
|
s.attachLibraryMetadata(ctx, rows)
|
||||||
|
cards := groupMediaSeriesCards(rows)
|
||||||
|
if len(cards) == 0 {
|
||||||
|
return []SeriesCard{}, nil
|
||||||
|
}
|
||||||
|
if len(cards) > limit {
|
||||||
|
cards = cards[:limit]
|
||||||
|
}
|
||||||
|
s.hydrateSeriesCardRows(ctx, cards)
|
||||||
|
return cards, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hydrateSeriesCardRows 把投影行替换成完整媒体行。
|
||||||
|
//
|
||||||
|
// 归组只需要少量列,但客户端展示卡片要用整行(简介、类型、编码等)。这里
|
||||||
|
// 只针对最终返回的卡片取两次 id 集合,一次 IN 查询补齐,避免把整库大文本列
|
||||||
|
// 都读出来。
|
||||||
|
func (s *MediaService) hydrateSeriesCardRows(ctx context.Context, cards []SeriesCard) {
|
||||||
|
if s == nil || s.repo == nil || len(cards) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(cards)*2)
|
||||||
|
ids := make([]string, 0, len(cards)*2)
|
||||||
|
add := func(id string) {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
for i := range cards {
|
||||||
|
add(cards[i].Rep.ID)
|
||||||
|
add(cards[i].LinkMedia.ID)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Media.ListMediaByIDs(ctx, ids)
|
||||||
|
if err != nil || len(rows) == 0 {
|
||||||
|
// 补齐失败时保留投影行:卡片列表仍可用,只是缺少大文本字段。
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.attachLibraryMetadata(ctx, rows)
|
||||||
|
byID := make(map[string]model.Media, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
byID[row.ID] = row
|
||||||
|
}
|
||||||
|
for i := range cards {
|
||||||
|
if row, ok := byID[cards[i].Rep.ID]; ok {
|
||||||
|
cards[i].Rep = row
|
||||||
|
}
|
||||||
|
if row, ok := byID[cards[i].LinkMedia.ID]; ok {
|
||||||
|
cards[i].LinkMedia = row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MediaService) recentSeriesCardsCacheKey(limit int, visibility MediaVisibility) string {
|
||||||
|
allowed := append([]string(nil), visibility.AllowedLibraryIDs...)
|
||||||
|
hidden := append([]string(nil), visibility.HiddenLibraryIDs...)
|
||||||
|
sort.Strings(allowed)
|
||||||
|
sort.Strings(hidden)
|
||||||
|
return "media:obj:recent-series:" + hashObjectCacheKey([]string{
|
||||||
|
fmt.Sprintf("%d:%t", limit, visibility.IncludeNSFW),
|
||||||
|
strings.Join(allowed, ","),
|
||||||
|
strings.Join(hidden, ","),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 「最近添加」必须只做投影扫描,但仍然要返回完整媒体行、正确的集数和
|
||||||
|
// 可用于剧集导航的 key —— 这些语义是 /libraries/{id}/series/episodes 的
|
||||||
|
// 前置条件,任何一项退化成「近似值」都会让点开卡片后选集为空。
|
||||||
|
func TestListRecentSeriesCardsHydratesRowsAndKeepsExactCounts(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
lib := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
|
||||||
|
rows := make([]model.Media, 0, 64)
|
||||||
|
// 长剧:48 集,最新一集入库时间最晚,必须排在首位。
|
||||||
|
for i := 1; i <= 48; i++ {
|
||||||
|
rows = append(rows, model.Media{
|
||||||
|
Base: model.Base{ID: fmt.Sprintf("long-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Minute)},
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "长剧",
|
||||||
|
Path: fmt.Sprintf("/media/anime/长剧/Season 1/长剧.S01E%02d.mkv", i),
|
||||||
|
Overview: "简介内容",
|
||||||
|
Genres: "Animation",
|
||||||
|
PosterURL: "/media/anime/长剧/poster.jpg",
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: i,
|
||||||
|
ReleaseDate: "2026-01-01",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 另一部短剧,入库更早。
|
||||||
|
for i := 1; i <= 2; i++ {
|
||||||
|
rows = append(rows, model.Media{
|
||||||
|
Base: model.Base{ID: fmt.Sprintf("short-%02d", i), CreatedAt: now.Add(-time.Hour)},
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "短剧",
|
||||||
|
Path: fmt.Sprintf("/media/anime/短剧/Season 1/短剧.S01E%02d.mkv", i),
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: i,
|
||||||
|
ReleaseDate: "2025-05-05",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := db.CreateInBatches(&rows, 50).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||||
|
cards, err := svc.ListRecentSeriesCards(t.Context(), 2, MediaVisibility{IncludeNSFW: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cards) != 2 {
|
||||||
|
t.Fatalf("cards = %d, want 2", len(cards))
|
||||||
|
}
|
||||||
|
if cards[0].Count != 48 {
|
||||||
|
t.Fatalf("长剧 count = %d, want 48 (不能只统计扫描窗口内的行)", cards[0].Count)
|
||||||
|
}
|
||||||
|
// 投影行没有 overview/genres,返回前必须补齐完整体。
|
||||||
|
if cards[0].Rep.Overview == "" || cards[0].Rep.Genres == "" {
|
||||||
|
t.Fatalf("representative row was not hydrated: %#v", cards[0].Rep)
|
||||||
|
}
|
||||||
|
if cards[0].Rep.LibraryName != lib.Name {
|
||||||
|
t.Fatalf("library metadata missing on hydrated row: %q", cards[0].Rep.LibraryName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卡片 key 必须能解析出同一部剧的全部剧集(详情页选集依赖它)。
|
||||||
|
episodes, err := svc.ListLibrarySeriesEpisodes(t.Context(), lib.ID, cards[0].Key, MediaVisibility{IncludeNSFW: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(episodes) != 48 {
|
||||||
|
t.Fatalf("episodes for card key = %d, want 48", len(episodes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结果缓存命中后必须返回同样的卡片,且不能因为缓存把调用方可见的切片搞乱。
|
||||||
|
func TestListRecentSeriesCardsCachesResult(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := db.Create(&model.Media{
|
||||||
|
Base: model.Base{ID: fmt.Sprintf("movie-%d", i), CreatedAt: time.Now().Add(-time.Duration(i) * time.Hour)},
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: fmt.Sprintf("电影 %d", i),
|
||||||
|
Path: fmt.Sprintf("/media/movies/电影 %d/movie.mkv", i),
|
||||||
|
ReleaseDate: "2024-01-01",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
|
||||||
|
t.Cleanup(func() { cache.Close() })
|
||||||
|
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).SetRuntimeCache(cache)
|
||||||
|
|
||||||
|
first, err := svc.ListRecentSeriesCards(t.Context(), 2, MediaVisibility{IncludeNSFW: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := svc.ListRecentSeriesCards(t.Context(), 2, MediaVisibility{IncludeNSFW: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(first) != 2 || len(second) != 2 {
|
||||||
|
t.Fatalf("first = %d second = %d, want 2/2", len(first), len(second))
|
||||||
|
}
|
||||||
|
if first[0].Key != second[0].Key || first[0].Count != second[0].Count {
|
||||||
|
t.Fatalf("cached result mismatch: %#v vs %#v", first[0], second[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user