From 8356f5c3c720f1960902b663895c495177c4af1e Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:19:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/playback_extra.go | 24 +++ internal/handler/playback_extra_test.go | 10 ++ internal/repository/media_series_index.go | 53 ++++++ internal/service/media.go | 13 +- internal/service/media_series.go | 20 --- internal/service/media_series_key.go | 69 +++++++- internal/service/media_series_recent.go | 171 +++++++++++++++++++ internal/service/media_series_recent_test.go | 125 ++++++++++++++ 8 files changed, 452 insertions(+), 33 deletions(-) create mode 100644 internal/repository/media_series_index.go create mode 100644 internal/service/media_series_recent.go create mode 100644 internal/service/media_series_recent_test.go diff --git a/internal/handler/playback_extra.go b/internal/handler/playback_extra.go index 0c2c1df..29efc69 100644 --- a/internal/handler/playback_extra.go +++ b/internal/handler/playback_extra.go @@ -51,10 +51,34 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc { // 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。 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{ "media": m, "stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery, "hls_url": hlsURL, + "resume": resumePayload, + "subtitles": subtitles, }) } } diff --git a/internal/handler/playback_extra_test.go b/internal/handler/playback_extra_test.go index aa52a5d..518982f 100644 --- a/internal/handler/playback_extra_test.go +++ b/internal/handler/playback_extra_test.go @@ -435,6 +435,10 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) { var payload struct { StreamURL string `json:"stream_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 { 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/") { 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) { diff --git a/internal/repository/media_series_index.go b/internal/repository/media_series_index.go new file mode 100644 index 0000000..5751869 --- /dev/null +++ b/internal/repository/media_series_index.go @@ -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 +} diff --git a/internal/service/media.go b/internal/service/media.go index 56af9c0..ffa89b5 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -12,12 +12,13 @@ import ( // MediaService offers high-level CRUD over libraries and media items. type MediaService struct { - cfg *config.Config - log *zap.Logger - repo *repository.Container - cache *RuntimeCacheService - groupedMediaFlight singleflight.Group - libraryRowsFlight singleflight.Group + cfg *config.Config + log *zap.Logger + repo *repository.Container + cache *RuntimeCacheService + groupedMediaFlight singleflight.Group + libraryRowsFlight singleflight.Group + recentSeriesFlight singleflight.Group } type MediaVisibility struct { diff --git a/internal/service/media_series.go b/internal/service/media_series.go index 961dad2..90693bf 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -239,26 +239,6 @@ func (s *MediaService) completedMediaIDSet(ctx context.Context, filters MediaLis 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) { rows, err := s.libraryRowsWithIndex(ctx, libraryID, visibility) if err != nil { diff --git a/internal/service/media_series_key.go b/internal/service/media_series_key.go index ef80c78..00e8d64 100644 --- a/internal/service/media_series_key.go +++ b/internal/service/media_series_key.go @@ -5,6 +5,7 @@ import ( "path/filepath" "regexp" "strings" + "sync" "github.com/truewhile/MeBox/internal/model" ) @@ -129,15 +130,28 @@ var ( ) func normalizeSeriesTitle(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - value = seriesYearRE.ReplaceAllString(value, " ") - value = seriesIDRE.ReplaceAllString(value, " ") - value = seriesBraceRE.ReplaceAllString(value, " ") - value = seriesSpacerRE.ReplaceAllString(value, " ") - return strings.TrimSpace(value) + // 归组时每行都会调用(且同一部剧的每集标题相同),纯函数缓存能省掉 + // 4 条正则的重复执行。 + if normalized, ok := seriesTitleNormMemo.get(value); ok { + return normalized + } + 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 { + // 整库归组时每个文件都会走到这里,而 CleanQuery 内部是十几条正则。 + // 同一部剧的几十集共享同一个目录名,按目录名缓存后,首页「最近添加」 + // 的分组阶段从 240ms 降到几十毫秒(7k 行实测)。 + if title, ok := seriesPathTitleMemo.get(value); ok { + return title + } title, _ := CleanQuery(value) if title == "" { title = normalizeSeriesTitle(value) @@ -146,11 +160,52 @@ func normalizeSeriesPathTitle(value string) string { } stripped := stripSeriesSpecialSuffix(title) if stripped != "" { - return stripped + title = stripped } + seriesPathTitleMemo.put(value, 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 { for _, re := range []*regexp.Regexp{seriesSpecialCodeRE, seriesSpecialCJKRE} { stripped := strings.TrimSpace(re.ReplaceAllString(title, "")) diff --git a/internal/service/media_series_recent.go b/internal/service/media_series_recent.go new file mode 100644 index 0000000..a560753 --- /dev/null +++ b/internal/service/media_series_recent.go @@ -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, ","), + }) +} diff --git a/internal/service/media_series_recent_test.go b/internal/service/media_series_recent_test.go new file mode 100644 index 0000000..68e28b2 --- /dev/null +++ b/internal/service/media_series_recent_test.go @@ -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]) + } +}