This commit is contained in:
truewhile
2026-10-18 02:19:33 +08:00
parent e2133cd76f
commit 8356f5c3c7
8 changed files with 452 additions and 33 deletions
+24
View File
@@ -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,
}) })
} }
} }
+10
View File
@@ -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) {
+53
View File
@@ -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
}
+7 -6
View File
@@ -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 {
-20
View File
@@ -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 {
+62 -7
View File
@@ -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, ""))
+171
View File
@@ -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])
}
}