This commit is contained in:
truewhile
2026-11-04 10:16:12 +08:00
parent 9b8ca33279
commit 9ffac320c4
19 changed files with 856 additions and 19 deletions
+17 -7
View File
@@ -30,10 +30,12 @@ import (
//
// include_media=0 只返回历史行本身。客户端判断「继续播放」指向哪一集时只
// 需要 media_id/position_ms;带上整条 media 会让 300 条的响应从 ~40KB 涨到
// ~420KB,手机链路上光传输就要十几秒,详情页的续播解析会被它拖住。
// ~420KB。更重要的是服务端不再 hydrate:不查整条 media、也不为挂载条目
// 逐条请求上游 Emby(见 PlaybackService.RecentHistoryWithoutMedia)。
func historyListHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
userID := toString(uid)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 || limit > 500 {
limit = 50
@@ -44,19 +46,27 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
includeMedia = parsed
}
}
items, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), limit)
ctx := c.Request.Context()
visibility := mediaVisibilityForRequest(c, svc)
if !includeMedia {
// 可见性由服务层用窄列查询判定,handler 不再需要 media。
items, err := svc.Playback.RecentHistoryWithoutMedia(ctx, userID, limit, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, items)
return
}
items, err := svc.Playback.RecentHistory(ctx, userID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
visibility := mediaVisibilityForRequest(c, svc)
filtered := make([]service.HistoryItem, 0, len(items))
for _, item := range items {
// 可见性仍然按 media 判断,只是决定要不要把它写进响应。
// 可见性按 media 判断:媒体已删除/远端取不到时仍然保留这条历史。
if item.Media == nil || visibility.Allows(item.Media) {
if !includeMedia {
item.Media = nil
}
filtered = append(filtered, item)
}
}
+74
View File
@@ -175,3 +175,77 @@ func TestHistoryListCanOmitEmbeddedMedia(t *testing.T) {
t.Fatalf("include_media=0 must not embed media: %s", lean.Body.String())
}
}
// TestHistoryListIncludeMediaFalseSkipsHydration 保证 include_media=0 时服务端
// 真的不 hydrate media:响应里只有历史行(客户端按 media_id 匹配续播目标)。
func TestHistoryListIncludeMediaFalseSkipsHydration(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "media-1"},
Title: "影片",
Path: "/media/1.mkv",
}).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
for i, mediaID := range []string{"media-1", "media-deleted"} {
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-" + mediaID},
UserID: "user-1",
MediaID: mediaID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: now.Add(-time.Duration(i) * time.Minute),
}).Error; err != nil {
t.Fatal(err)
}
}
repos := repository.New(db)
svc := &service.Container{
Log: zap.NewNop(),
Repo: repos,
Playback: service.NewPlaybackService(zap.NewNop(), repos),
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, "user-1")
c.Next()
})
router.GET("/watch-history", historyListHandler(svc))
for _, path := range []string{
"/watch-history?limit=10&include_media=0",
"/watch-history?limit=10&include_media=false",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status = %d, body = %s", path, rec.Code, rec.Body.String())
}
var rows []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
t.Fatalf("%s decode: %v", path, err)
}
// 媒体已删除的历史也保留:客户端只按 media_id 判断有没有记录。
if len(rows) != 2 {
t.Fatalf("%s rows = %d, want 2 (%s)", path, len(rows), rec.Body.String())
}
for _, row := range rows {
if _, ok := row["media"]; ok {
t.Fatalf("%s should not embed media: %v", path, row)
}
if row["media_id"] == "" || row["media_id"] == nil {
t.Fatalf("%s missing media_id: %v", path, row)
}
}
}
}
+16
View File
@@ -179,6 +179,22 @@ func (s *MediaService) libraryCardsObjectKey(libraryID string, visibility MediaV
return s.seriesCardsCacheKey(libraryID, visibility) + ":obj"
}
// 版本分组对象的缓存 key。
//
// 详情页的「同片多版本」以前每次都现算:条目没有 series_id / tmdb / douban /
// thetvdb 时走不到收窄分支,会直接把这个库最多 5000 行整行读出来再逐条算版本
// 键(实测 5000 行的库单次 ~180ms,且没有缓存)。同一个版本组的结果只随扫描/
// 刮削变化,所以按「合并库集合 + 版本组键」缓存,配合 media: 前缀一起被
// invalidateMediaCache 清掉。
func (s *MediaService) versionSiblingsCacheKey(libraryIDs []string, groupKey string) string {
libs := append([]string(nil), libraryIDs...)
sort.Strings(libs)
return "media:obj:version-siblings:" + hashObjectCacheKey([]string{
strings.Join(libs, ","),
groupKey,
})
}
func (s *MediaService) invalidateMediaCache(ctx context.Context) {
if s != nil && s.cache != nil {
s.cache.DeletePrefix(ctx, "media:")
+30
View File
@@ -255,6 +255,35 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
if len(libraryIDs) == 0 {
libraryIDs = []string{media.LibraryID}
}
// 同一个版本组的结果对组内任一成员都成立,而且只随扫描/刮削变化:缓存它
// 才能让「逐部点开详情」的浏览不再每次都重扫该库最多 5000 行。
cacheKey := s.versionSiblingsCacheKey(libraryIDs, key)
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := obj.([]model.Media); ok {
// GetObject 返回的对象此后视为不可变,调用方(详情页)可能重排/追加
// 切片,所以拷贝一份再交出去。
out := make([]model.Media, len(cached))
copy(out, cached)
return out, nil
}
}
}
matched, err := s.loadVersionSiblings(ctx, media, libraryIDs, key)
if err != nil {
return nil, err
}
if s.cache != nil {
s.cache.SetObject(cacheKey, matched, s.derivedReadCacheTTL())
}
out := make([]model.Media, len(matched))
copy(out, matched)
return out, nil
}
// loadVersionSiblings 是版本分组的慢路径(可能读全库),结果由
// listVersionSiblings 缓存。
func (s *MediaService) loadVersionSiblings(ctx context.Context, media *model.Media, libraryIDs []string, key string) ([]model.Media, error) {
filter := repository.MediaQueryFilter{IncludeNSFW: true}
// 成人条目按番号分组,而番号常常只存在于路径/标题里:本地 NFO 来源的分片
// 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部
@@ -263,6 +292,7 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
// 直接在该库范围内比对版本键。未刮削但文件名带番号的分片同理:同一部片的
// 各分片元数据来源不一致(刮削 vs pending),收窄后标题也对不上。
var candidates []model.Media
var err error
narrowed := false
hasAdultCode := mediaAdultGroupCode(*media) != "" ||
canonicalAdultGroupCode(AdultCodeFromMediaPath(media.Path)) != ""
+73
View File
@@ -787,3 +787,76 @@ func TestMediaVersionGroupKeyDistinguishesHalfEpisode(t *testing.T) {
t.Fatalf("plain episode key = %q, want suffix 1:11", plainKey)
}
}
// TestGetMediaItemCachesVersionSiblings 覆盖详情页最贵的一次查询:
// 条目没有外部 ID 时会整库读最多 5000 行来找同片版本,而结果只随扫描/刮削
// 变化。这里断言第二次打开详情走缓存(删掉候选行后仍能拿到同一组版本),
// 并且调用方修改返回切片不会污染缓存。
func TestGetMediaItemCachesVersionSiblings(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "电影", Path: "/movies", Type: "movie"}
if err := db.Create(&lib).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
rows := []model.Media{
{
Base: model.Base{ID: "v-1080", CreatedAt: now, UpdatedAt: now},
LibraryID: lib.ID,
Title: "影片 2024 1080p",
Path: "/movies/影片.2024.1080p.mkv",
Width: 1920,
Height: 1080,
Year: 2024,
},
{
Base: model.Base{ID: "v-2160", CreatedAt: now.Add(time.Hour), UpdatedAt: now.Add(time.Hour)},
LibraryID: lib.ID,
Title: "影片 2024 2160p",
Path: "/movies/影片.2024.2160p.mkv",
Width: 3840,
Height: 2160,
Year: 2024,
},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
t.Cleanup(func() { _ = svc.cache.Close() })
item, err := svc.GetMediaItem(t.Context(), "v-1080")
if err != nil {
t.Fatal(err)
}
if len(item.Versions) != 2 {
t.Fatalf("versions = %d, want 2: %#v", len(item.Versions), item.Versions)
}
// 把数据删掉:如果第二次还去查库,就只会剩下代表条目自己。
if err := db.Where("id = ?", "v-2160").Delete(&model.Media{}).Error; err != nil {
t.Fatal(err)
}
second, err := svc.GetMediaItem(t.Context(), "v-1080")
if err != nil {
t.Fatal(err)
}
if len(second.Versions) != 2 {
t.Fatalf("cached versions = %d, want 2", len(second.Versions))
}
// 调用方乱改返回的切片不能影响缓存内容。
second.Versions[0] = model.Media{Base: model.Base{ID: "hacked"}}
third, err := svc.GetMediaItem(t.Context(), "v-1080")
if err != nil {
t.Fatal(err)
}
for _, v := range third.Versions {
if v.ID == "hacked" {
t.Fatal("caller mutation leaked into the cached version list")
}
}
}
+73
View File
@@ -193,6 +193,79 @@ func (p *PlaybackService) RecentHistory(ctx context.Context, userID string, limi
return items, nil
}
// RecentHistoryWithoutMedia 返回最近观看记录,但完全不 join media。
//
// 详情页解析「继续播放」只需要 media_id/position_ms,带整条 media 会让 300 条
// 响应大一个数量级。旧实现是先用 RecentHistory 把 media 全部查出来(挂载条目
// 还会逐条串行请求上游 Emby),最后才在 handler 里把 media 置空 —— 也就是
// 响应变小了,服务端一点没省。这里直接跳过 hydrate:
//
// - 本地条目:用 (id, library_id, nsfw) 窄列查询判断可见性,不读 overview /
// path / 海报这些大字段;
// - 挂载条目:按挂载媒体库的 ACL 判断,不再发上游请求(上游慢或挂了时,
// 详情页的续播解析不会被它拖住);
// - 记录还在但 media 已删除:保留。与旧行为一致 —— 旧实现 hydrate 失败时
// 同样保留这条历史,客户端只拿 media_id 判断续播目标。
func (p *PlaybackService) RecentHistoryWithoutMedia(ctx context.Context, userID string, limit int, visibility MediaVisibility) ([]HistoryItem, error) {
rows, err := p.repo.History.ListByUser(ctx, userID, limit)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return []HistoryItem{}, nil
}
mediaIDs := make([]string, 0, len(rows))
for i := range rows {
if rows[i].MediaID != "" {
mediaIDs = append(mediaIDs, rows[i].MediaID)
}
}
// 只取可见性判定真正需要的三列。Select 到 model.Media 上是本仓库既有写法
// (见 database/backfillLibraryRootMedia),未选中的字段保持零值。
visibleByID := make(map[string]bool, len(mediaIDs))
if len(mediaIDs) > 0 {
var lean []model.Media
if err := p.repo.DB.WithContext(ctx).Model(&model.Media{}).
Select("id", "library_id", "nsfw").
Where("id IN ?", mediaIDs).
Find(&lean).Error; err != nil {
return nil, err
}
for i := range lean {
visibleByID[lean[i].ID] = visibility.Allows(&lean[i])
}
}
// 挂载库 ACL 每个挂载只解析一次:ResolveMount 只读本地表,不发网络请求。
mountAllowed := make(map[string]bool, 2)
items := make([]HistoryItem, 0, len(rows))
for i := range rows {
mediaID := rows[i].MediaID
if allowed, known := visibleByID[mediaID]; known {
if allowed {
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
}
continue
}
if p.remote != nil && IsEmbyRemoteID(mediaID) {
mountID, _, _ := DecodeEmbyRemoteID(mediaID)
allowed, resolved := mountAllowed[mountID]
if !resolved {
mount, _, resolveErr := p.remote.ResolveMount(ctx, mountID)
// 挂载解析不出来时无法判定,沿用旧行为保留这条历史。
allowed = resolveErr != nil || mount == nil || EmbyMountLibraryAllowed(visibility, mount)
mountAllowed[mountID] = allowed
}
if !allowed {
continue
}
}
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
}
return items, nil
}
// ─── Favourites ─────────────────────────────────────────────────────────────
// ToggleFavourite flips the favourite flag and reports the new state.
+62
View File
@@ -113,3 +113,65 @@ func TestPlaybackProgressCompletedThreshold(t *testing.T) {
})
}
}
// TestRecentHistoryWithoutMediaSkipsHydration 覆盖详情页用的轻量历史路径:
// 不返回 media,但可见性过滤必须与带 media 的版本一致。
func TestRecentHistoryWithoutMediaSkipsHydration(t *testing.T) {
db := newServiceTestDB(t, &model.PlaybackHistory{}, &model.Media{}, &model.Library{})
repos := repository.New(db)
svc := NewPlaybackService(zap.NewNop(), repos)
seed := func(id, libraryID string, nsfw bool) {
t.Helper()
m := model.Media{
Base: model.Base{ID: id},
LibraryID: libraryID,
Title: "标题 " + id,
Path: "/media/" + id + ".mkv",
NSFW: nsfw,
}
if err := db.Create(&m).Error; err != nil {
t.Fatalf("seed media %s: %v", id, err)
}
}
seed("visible", "lib-ok", false)
seed("adult", "lib-ok", true)
seed("hidden-lib", "lib-hidden", false)
now := time.Now()
for i, mediaID := range []string{"visible", "adult", "hidden-lib", "gone"} {
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "hist-" + mediaID},
UserID: "user-1",
MediaID: mediaID,
PositionMs: 10_000,
DurationMs: 60_000,
WatchedAt: now.Add(-time.Duration(i) * time.Minute),
}).Error; err != nil {
t.Fatalf("seed history %s: %v", mediaID, err)
}
}
items, err := svc.RecentHistoryWithoutMedia(context.Background(), "user-1", 50, MediaVisibility{
HiddenLibraryIDs: []string{"lib-hidden"},
})
if err != nil {
t.Fatalf("RecentHistoryWithoutMedia: %v", err)
}
got := make([]string, 0, len(items))
for _, item := range items {
if item.Media != nil {
t.Fatalf("expected no media hydration, got %#v", item.Media)
}
got = append(got, item.MediaID)
}
want := []string{"visible", "gone"}
if len(got) != len(want) {
t.Fatalf("media ids = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("media ids = %v, want %v", got, want)
}
}
}