Compare commits

...

2 Commits

Author SHA1 Message Date
truewhile be9d3a6d9e 优化 2026-09-13 22:00:50 +08:00
truewhile b8e48a1f45 优化 2026-09-13 21:23:06 +08:00
20 changed files with 685 additions and 87 deletions
+1 -1
View File
@@ -123,7 +123,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
for i, l := range libs {
libIDs[i] = l.ID
}
counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{
counts, _ := svc.Media.CountLibrariesCached(ctx, libIDs, repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
+12
View File
@@ -85,6 +85,18 @@ func toggleFavouriteHandler(svc *service.Container) gin.HandlerFunc {
func listFavouritesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
if c.Query("ids") == "1" {
ids, err := svc.Playback.ListFavouriteIDs(c.Request.Context(), uid.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if ids == nil {
ids = []string{}
}
c.JSON(http.StatusOK, gin.H{"ids": ids})
return
}
items, err := svc.Playback.ListFavourites(c.Request.Context(), uid.(string))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+6
View File
@@ -21,6 +21,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
)
// 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到
@@ -65,6 +66,11 @@ type EmbyService struct {
peopleMu sync.RWMutex
peopleCache map[string]embyPeopleCacheEntry
// latestFlight collapses the homepage stampede: clients request Latest
// for every library at once, and a shared expiry used to rebuild each
// library in parallel.
latestFlight singleflight.Group
tmdb *TMDbProvider
adult *AdultProvider
personImageMu sync.RWMutex
+201 -49
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"go.uber.org/zap"
@@ -120,44 +121,87 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos, true), nil
}
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)走远程并缓存。
func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, limit int) ([]map[string]any, error) {
if e == nil {
return nil, nil
}
if limit <= 0 || limit > 100 {
limit = 20
}
if e.remote != nil && IsEmbyRemoteID(parentID) {
mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
return nil, nil
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
if items, ok := e.cachedLatestItems(ctx, cacheKey); ok {
return items, nil
}
// 一个客户端断开不应取消正在为其他客户端填充的共享重建。
loadCtx := context.WithoutCancel(ctx)
v, err, _ := e.latestFlight.Do(cacheKey, func() (any, error) {
if items, ok := e.cachedLatestItems(loadCtx, cacheKey); ok {
return embyLatestCacheValue{Items: items}, nil
}
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return nil, nil
}
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
value, err := e.loadLatestItems(loadCtx, userID, parentID, limit)
if err != nil {
return nil, err
}
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
if e.cache != nil {
e.cache.SetJSON(loadCtx, cacheKey, value, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
}
return out, nil
e.rememberArtworkRefs(value.Artwork)
return value, nil
})
if err != nil {
return nil, err
}
cached, _ := v.(embyLatestCacheValue)
if cached.Items == nil {
return []map[string]any{}, nil
}
return cached.Items, nil
}
func (e *EmbyService) cachedLatestItems(ctx context.Context, cacheKey string) ([]map[string]any, bool) {
if e == nil || e.cache == nil {
return nil, false
}
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
var cached embyLatestCacheValue
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
e.rememberArtworkRefs(cached.Artwork)
return cached.Items, nil
if !e.cache.GetJSON(ctx, cacheKey, &cached) {
return nil, false
}
e.rememberArtworkRefs(cached.Artwork)
if cached.Items == nil {
return []map[string]any{}, true
}
return cached.Items, true
}
func (e *EmbyService) loadLatestItems(ctx context.Context, userID, parentID string, limit int) (embyLatestCacheValue, error) {
if e.remote != nil && IsEmbyRemoteID(parentID) {
mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil || !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return embyLatestCacheValue{Items: []map[string]any{}}, nil
}
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
if err != nil {
return embyLatestCacheValue{}, err
}
if out == nil {
out = []map[string]any{}
}
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return embyLatestCacheValue{}, err
}
return embyLatestCacheValue{Items: out}, nil
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
q = e.applyUserMediaVisibility(ctx, q, userID)
if parentID != "" {
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
out, artwork, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
if err == nil && e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out, Artwork: artwork}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
if err != nil {
return embyLatestCacheValue{}, err
}
return out, err
return embyLatestCacheValue{Items: out, Artwork: artwork}, nil
}
q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, parentID))
}
@@ -170,7 +214,7 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
}
var rows []model.Media
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(rowLimit).Find(&rows).Error; err != nil {
return nil, err
return embyLatestCacheValue{}, err
}
rows = e.collapseMediaVersionRows(ctx, rows)
if len(rows) > limit {
@@ -178,12 +222,9 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
}
out, err := e.payloadsForMedia(ctx, rows, userID)
if err != nil {
return nil, err
return embyLatestCacheValue{}, err
}
if e.cache != nil {
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.embyLatestCacheTTLSeconds())*time.Second)
}
return out, nil
return embyLatestCacheValue{Items: out}, nil
}
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, map[string]embyArtworkRef, error) {
@@ -194,7 +235,8 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID))
q = e.applyUserMediaVisibility(ctx, q, userID)
var rows []model.Media
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
// 只要最近几部剧的卡片,不要把整库 5 万行拉进内存再丢掉。
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embyLatestSeriesRowLimit(limit)).Find(&rows).Error; err != nil {
return nil, nil, err
}
groups := e.seriesGroupsFromMedia(ctx, rows)
@@ -209,6 +251,24 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
return items, e.artworkRefsForSeriesGroups(groups), nil
}
// embyLatestSeriesRowLimit 是 Latest 剧集卡片的候选窗口。按「每部剧最近约 40
// 集」估,刚好覆盖现有 25 部 × 40 集的分组测试,又不会为了 16 张海报去扫整库。
// ponytail: 一部长剧如果占满窗口,更老的剧不会出现在 Latest 行;点进媒体库
// 仍走完整分组。上限 2000 行。
func embyLatestSeriesRowLimit(limit int) int {
if limit < 1 {
limit = 20
}
n := limit * 40
if n < 200 {
n = 200
}
if n > 2000 {
n = 2000
}
return n
}
// ResumeItems 列出有未完成播放进度的媒体。
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
@@ -393,7 +453,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
// 总数用候选行数(本地过滤后 + 远程候选),对继续观看行的翻页语义
// 足够准确。
needed := p.StartIndex + p.Limit
items := make([]map[string]any, 0, p.Limit)
slots := make([]resumeSlot, 0, len(hist))
localTotal, remoteTotal := 0, 0
for _, h := range hist {
if m, ok := byID[h.MediaID]; ok {
@@ -401,36 +461,77 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
continue
}
localTotal++
if produced := len(items); produced < needed {
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs, false))
}
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, false, h.PositionMs, false)})
continue
}
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
continue
}
remoteTotal++
if len(items) >= needed {
continue
}
mountID, remoteID, _ := DecodeEmbyRemoteID(h.MediaID)
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
continue
}
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
if err != nil || item == nil {
continue
}
if p.ParentID != "" {
parentID, _ := item["ParentId"].(string)
seriesID, _ := item["SeriesId"].(string)
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
slots = append(slots, resumeSlot{fetch: &resumeRemoteFetch{hist: h}})
}
for {
filled := 0
pending := make([]*resumeRemoteFetch, 0)
for i := range slots {
if slots[i].item != nil {
filled++
if filled >= needed {
break
}
continue
}
f := slots[i].fetch
if f == nil || f.done {
continue
}
if f.mount == nil {
mountID, remoteID, _ := DecodeEmbyRemoteID(f.hist.MediaID)
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
f.done = true
continue
}
f.mount, f.acct, f.remoteID = mount, acct, remoteID
}
pending = append(pending, f)
if filled+len(pending) >= needed {
break
}
}
item["UserData"] = mergedRemoteUserData(item["UserData"], &h)
items = append(items, item)
if len(pending) == 0 {
break
}
e.fillResumeRemotes(ctx, pending)
for i := range slots {
f := slots[i].fetch
if f == nil || !pendingContains(pending, f) {
continue
}
f.done = true
item := f.item
f.item = nil
if item == nil {
continue
}
if p.ParentID != "" {
parentID, _ := item["ParentId"].(string)
seriesID, _ := item["SeriesId"].(string)
mountID, _, _ := DecodeEmbyRemoteID(f.hist.MediaID)
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
continue
}
}
item["UserData"] = mergedRemoteUserData(item["UserData"], &f.hist)
slots[i].item = item
}
}
items := make([]map[string]any, 0, p.Limit)
for i := range slots {
if slots[i].item == nil {
continue
}
items = append(items, slots[i].item)
}
total := int64(localTotal + remoteTotal)
@@ -441,6 +542,57 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
type resumeSlot struct {
item map[string]any
fetch *resumeRemoteFetch
}
type resumeRemoteFetch struct {
hist model.PlaybackHistory
mount *model.EmbyMount
acct *model.StrmAccount
remoteID string
item map[string]any
done bool
}
func pendingContains(rows []*resumeRemoteFetch, target *resumeRemoteFetch) bool {
for _, row := range rows {
if row == target {
return true
}
}
return false
}
// fillResumeRemotes 并行补齐继续观看里的远程条目。同一页以前是串行 GET,
// 远程一慢就把整个 Resume 拖到数秒。结果按原播放历史顺序回填。
func (e *EmbyService) fillResumeRemotes(ctx context.Context, rows []*resumeRemoteFetch) {
if e == nil || e.remote == nil || len(rows) == 0 {
return
}
var wg sync.WaitGroup
sem := make(chan struct{}, 4)
for i := range rows {
wg.Add(1)
go func(i int) {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
return
}
defer func() { <-sem }()
item, err := e.remote.RemoteItem(ctx, rows[i].mount, rows[i].acct, rows[i].remoteID)
if err != nil || item == nil {
return
}
rows[i].item = item
}(i)
}
wg.Wait()
}
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64, includePeople bool) map[string]any {
itemType := "Movie"
name := m.Title
+12
View File
@@ -805,6 +805,15 @@ func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model.
// RemoteItem 拉取远程单条目详情(含响应的重写)。
func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (map[string]any, error) {
cacheKey := ""
if r != nil && r.cache != nil && mount != nil && remoteID != "" {
cacheKey = r.remoteCacheKey("item", mount.ID, remoteID)
var cached map[string]any
if r.cache.GetJSON(ctx, cacheKey, &cached) && len(cached) > 0 {
r.rememberRemotePeople(mount, cached)
return cached, nil
}
}
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
@@ -817,6 +826,9 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou
}
r.rememberRemotePeople(mount, out)
RewriteEmbyRemoteIDs(out, mount.ID)
if cacheKey != "" && len(out) > 0 {
r.cache.SetJSON(ctx, cacheKey, out, r.remoteMediaCacheTTL())
}
return out, nil
}
+17
View File
@@ -388,6 +388,17 @@ func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model.
// remoteMediaDetailRaw 拉取远程条目详情,同时返回原始载荷(ID 已伪装),
// 供调用方免二次请求读取 Type / SeriesId 等字段。
func (r *EmbyRemoteService) remoteMediaDetailRaw(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (*model.Media, map[string]any, error) {
cacheKey := ""
if r != nil && r.cache != nil && mount != nil && remoteID != "" {
cacheKey = r.remoteCacheKey("detail", mount.ID, remoteID)
var cached struct {
Media *model.Media `json:"media"`
Raw map[string]any `json:"raw"`
}
if r.cache.GetJSON(ctx, cacheKey, &cached) && cached.Media != nil {
return cached.Media, cached.Raw, nil
}
}
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, nil, err
@@ -400,6 +411,12 @@ func (r *EmbyRemoteService) remoteMediaDetailRaw(ctx context.Context, mount *mod
}
RewriteEmbyRemoteIDs(out, mount.ID)
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, out)
if cacheKey != "" {
r.cache.SetJSON(ctx, cacheKey, struct {
Media *model.Media `json:"media"`
Raw map[string]any `json:"raw"`
}{Media: &m, Raw: out}, r.remoteMediaCacheTTL())
}
return &m, out, nil
}
@@ -156,6 +156,12 @@ func TestEmbySeriesGroupingPaginatesAfterFullLibraryGrouping(t *testing.T) {
}
}
func TestEmbyLatestSeriesRowLimitCoversGroupedFixture(t *testing.T) {
if got := embyLatestSeriesRowLimit(25); got < 25*40 {
t.Fatalf("latest window %d is smaller than the 25x40 fixture", got)
}
}
func TestEmbyItemsKeepSpecialsInSeasonZero(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "番剧", Path: `F:\downloads\日番`, Type: "anime", Enabled: true}
+71 -1
View File
@@ -226,6 +226,34 @@ func (p *ImageProxy) acquireResizeSlot(ctx context.Context) (func(), bool) {
}
}
// tryAcquireResizeSlot 不等待。槽位忙时返回 false,调用方应改出原图。
func (p *ImageProxy) tryAcquireResizeSlot() (func(), bool) {
if p == nil {
return func() {}, true
}
p.resizeSemMu.Lock()
if p.resizeSem == nil {
p.resizeSem = make(chan struct{}, imageResizeConcurrency())
}
sem := p.resizeSem
p.resizeSemMu.Unlock()
select {
case sem <- struct{}{}:
return func() { <-sem }, true
default:
return nil, false
}
}
// acquireResizeSlotFor 对中等体积的图只尝试一次槽位。忙则放弃缩放,
// 避免背景图墙在 2 核机器上排成数秒。超大原图仍等待,以免直出多兆字节。
func (p *ImageProxy) acquireResizeSlotFor(ctx context.Context, size int64) (func(), bool) {
if size > 0 && size <= resizeQueueBypassBytes {
return p.tryAcquireResizeSlot()
}
return p.acquireResizeSlot(ctx)
}
// serveResizedFromFile 从 srcPath 读取图片,按选项缩放后写出,并把结果缓存
// 到磁盘以免每次请求都重新解码。原图已满足目标尺寸时直接输出原文件。
// 返回 false 表示缩放不可用,调用方应回退到原图直出。
@@ -241,8 +269,13 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
if serveCachedImageFile(w, r, key, cachePath) {
return true
}
// 本地海报大多已经是一百多 KB 的 JPEG。先解码再缩放会把 2 核机器的
// 并发槽(2)堵成数秒队列。体积已经适合直接下发时,不要排队。
if serveCompactOriginal(w, r, srcPath, stat) {
return true
}
release, ok := p.acquireResizeSlot(r.Context())
release, ok := p.acquireResizeSlotFor(r.Context(), stat.Size())
if !ok {
return false
}
@@ -273,6 +306,43 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request
return true
}
// compactImageSkipBytes 是“直接出原图”的体积上限。超过它的原图(多兆字节
// 的剧照、未压缩 sidecar)仍然走缩放,避免把大文件直接塞给电视端。
// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍尝试缩放。
const compactImageSkipBytes = 200 * 1024
// resizeQueueBypassBytes 是“槽位忙就放弃缩放”的上限。超过它的原图继续排队,
// 以免把未压缩的剧照直接发给客户端。
const resizeQueueBypassBytes = 1536 * 1024
// serveCompactOriginal 在源文件已经很小且是浏览器可直接显示的 JPEG/WebP 时
// 跳过解码。返回 false 表示仍应走缩放路径。
func serveCompactOriginal(w http.ResponseWriter, r *http.Request, srcPath string, stat os.FileInfo) bool {
if stat == nil || stat.Size() <= 0 || stat.Size() > compactImageSkipBytes {
return false
}
file, err := os.Open(srcPath) // #nosec G304 -- srcPath is an allowed local path or a SHA-derived cache path.
if err != nil {
return false
}
var header [12]byte
n, _ := file.Read(header[:])
_ = file.Close()
if !isCompactWebImage(header[:n]) {
return false
}
return serveImageFile(w, r, filepath.Base(srcPath), srcPath, imageBrowserCacheControl)
}
func isCompactWebImage(header []byte) bool {
if len(header) >= 3 && header[0] == 0xff && header[1] == 0xd8 && header[2] == 0xff {
return true
}
return len(header) >= 12 &&
string(header[0:4]) == "RIFF" &&
string(header[8:12]) == "WEBP"
}
// writeResizeCache 原子写入缩放结果;失败只记日志,不影响本次响应。
func (p *ImageProxy) writeResizeCache(cachePath string, data []byte) {
dir := filepath.Dir(cachePath)
+60
View File
@@ -2,12 +2,15 @@ package service
import (
"bytes"
"context"
"image"
"image/color"
"image/jpeg"
"image/png"
"net/http/httptest"
"os"
"testing"
"time"
)
// encodeTestPNG 生成一张结构规则、易于压缩的测试用 PNG。
@@ -274,3 +277,60 @@ func TestServeResizedFromFileUsesCacheBeforeDecodingSource(t *testing.T) {
t.Fatal("expected cached thumbnail to be reused without decoding the source")
}
}
func TestServeResizedFromFileSkipsDecodeForCompactJPEG(t *testing.T) {
dir := t.TempDir()
src := dir + string(os.PathSeparator) + "poster.jpg"
var buf bytes.Buffer
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 800, 1200)), &jpeg.Options{Quality: 40}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
if buf.Len() == 0 || buf.Len() > compactImageSkipBytes {
t.Fatalf("test jpeg is %d bytes, want a compact poster", buf.Len())
}
if err := os.WriteFile(src, buf.Bytes(), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
rec := httptest.NewRecorder()
if !proxy.serveResizedFromFile(rec, httptest.NewRequest("GET", "/x?maxWidth=120", nil), src, imageResizeOptions{MaxWidth: 120}) {
t.Fatal("expected compact jpeg to be served")
}
if !bytes.Equal(rec.Body.Bytes(), buf.Bytes()) {
t.Fatal("expected the original jpeg, not a decoded thumbnail")
}
}
func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
dir := t.TempDir()
src := dir + string(os.PathSeparator) + "backdrop.jpg"
data := make([]byte, compactImageSkipBytes+1024)
data[0], data[1], data[2] = 0xff, 0xd8, 0xff
if err := os.WriteFile(src, data, 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
var releases []func()
for i := 0; i < imageResizeConcurrency(); i++ {
release, ok := proxy.acquireResizeSlot(context.Background())
if !ok {
t.Fatal("expected a resize slot")
}
releases = append(releases, release)
}
defer func() {
for _, release := range releases {
release()
}
}()
start := time.Now()
handled := proxy.serveResizedFromFile(httptest.NewRecorder(), httptest.NewRequest("GET", "/x?maxWidth=480", nil), src, imageResizeOptions{MaxWidth: 480})
if time.Since(start) > 200*time.Millisecond {
t.Fatal("resize slot was busy but the request still waited in the queue")
}
if handled {
t.Fatal("expected a busy resize slot to fall back so the caller can serve the original")
}
}
+29
View File
@@ -69,6 +69,23 @@ func (s *MediaService) seriesCardsCacheKey(libraryID string, visibility MediaVis
return "media:series-cards:" + hex.EncodeToString(sum[:])
}
func (s *MediaService) libraryCountCacheKey(libraryIDs []string, filter repository.MediaQueryFilter) string {
ids := append([]string(nil), libraryIDs...)
allowed := append([]string(nil), filter.AllowedLibraryIDs...)
hidden := append([]string(nil), filter.HiddenLibraryIDs...)
sort.Strings(ids)
sort.Strings(allowed)
sort.Strings(hidden)
sum := sha1.Sum([]byte(strings.Join([]string{
"counts",
strings.Join(ids, ","),
fmt.Sprintf("%t", filter.IncludeNSFW),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
}, "|")))
return "media:lib-counts:" + hex.EncodeToString(sum[:])
}
func (s *MediaService) mediaCacheTTLSeconds() int {
if s == nil || s.cfg == nil || s.cfg.Cache.MediaTTLSeconds < 1 {
return 90
@@ -82,6 +99,18 @@ func (s *MediaService) mediaObjectTTL() time.Duration {
return time.Duration(s.mediaCacheTTLSeconds()) * time.Second
}
// derivedReadCacheTTL 给首页预览、剧集分组、库计数这类派生读缓存一个更长的
// 下限。扫描/刮削/删改都会走 invalidateMediaCache 清掉 media: 前缀,所以
// 拉长 TTL 不会在写入后继续吐旧卡片。配置里的 TTL 更长时仍以配置为准。
func (s *MediaService) derivedReadCacheTTL() time.Duration {
ttl := s.mediaObjectTTL()
const floor = 10 * time.Minute
if ttl < floor {
return floor
}
return ttl
}
func hashObjectCacheKey(parts []string) string {
sum := sha1.Sum([]byte(strings.Join(parts, "|")))
return hex.EncodeToString(sum[:])
+26 -2
View File
@@ -2,7 +2,6 @@ package service
import (
"context"
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
@@ -24,6 +23,31 @@ func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, erro
return s.repo.Library.List(ctx)
}
// CountLibrariesCached returns per-library media totals. The homepage metadata
// request asks for every library, and the underlying COUNT is repeated on each
// refresh. Writes already drop the media: prefix, so a longer TTL is safe.
func (s *MediaService) CountLibrariesCached(ctx context.Context, libraryIDs []string, filter repository.MediaQueryFilter) (map[string]int64, error) {
if len(libraryIDs) == 0 {
return map[string]int64{}, nil
}
cacheKey := s.libraryCountCacheKey(libraryIDs, filter)
var cached map[string]int64
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) && cached != nil {
return cached, nil
}
counts, err := s.repo.Media.CountByLibraries(ctx, libraryIDs, filter)
if err != nil {
return nil, err
}
if counts == nil {
counts = map[string]int64{}
}
if s.cache != nil {
s.cache.SetJSON(ctx, cacheKey, counts, s.derivedReadCacheTTL())
}
return counts, nil
}
// ListLibrariesWithPreview returns libraries populated with item counts and latest preview cards.
func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) {
return s.listLibrariesWithPreview(ctx, libraries, visibility, cardLimit, true)
@@ -120,7 +144,7 @@ func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries [
}
if s.cache != nil {
s.cache.SetJSON(ctx, cacheKey, libraryPreviewCacheValue{Items: out}, time.Duration(s.mediaCacheTTLSeconds())*time.Second)
s.cache.SetJSON(ctx, cacheKey, libraryPreviewCacheValue{Items: out}, s.derivedReadCacheTTL())
}
return out, nil
+2 -2
View File
@@ -70,7 +70,7 @@ func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID strin
cards := groupMediaSeriesCardsByKeys(rows, keys)
value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes, Cards: cards}
if s.cache != nil {
s.cache.SetObject(cacheKey, value, s.mediaObjectTTL())
s.cache.SetObject(cacheKey, value, s.derivedReadCacheTTL())
}
return value, nil
}
@@ -99,7 +99,7 @@ func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID str
}
total := int64(len(cards))
if s.cache != nil {
s.cache.SetObject(cacheKey, &seriesCardsCacheValue{Cards: cards, Total: total}, s.mediaObjectTTL())
s.cache.SetObject(cacheKey, &seriesCardsCacheValue{Cards: cards, Total: total}, s.derivedReadCacheTTL())
}
return cards, total, nil
}
+17
View File
@@ -265,6 +265,23 @@ func (p *PlaybackService) ListFavourites(ctx context.Context, userID string) ([]
return out, nil
}
// ListFavouriteIDs returns the user's favourite media IDs without hydrating
// remote Emby items. Heart-icon state only needs the IDs; fetching each remote
// detail on every library open adds a few hundred milliseconds.
func (p *PlaybackService) ListFavouriteIDs(ctx context.Context, userID string) ([]string, error) {
favs, err := p.repo.Favorite.ListByUser(ctx, userID)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(favs))
for _, fav := range favs {
if fav.MediaID != "" {
ids = append(ids, fav.MediaID)
}
}
return ids, nil
}
// ─── Playlists ──────────────────────────────────────────────────────────────
// CreatePlaylist persists a new playlist owned by userID.
+3
View File
@@ -61,6 +61,9 @@ export const playbackAPI = {
listFavourites: () =>
api.get<{ items: Media[] }>('/favourites').then((r) => r.data.items),
listFavouriteIDs: () =>
api.get<{ ids: string[] }>('/favourites', { params: { ids: 1 } }).then((r) => r.data.ids ?? []),
listPlaylists: () =>
api.get<{ items: Playlist[] }>('/playlists').then((r) => r.data.items),
+172
View File
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import {
Captions,
CaptionsOff,
FastForward,
ListVideo,
Maximize,
MessageSquareText,
@@ -9,6 +11,7 @@ import {
Pause,
PictureInPicture,
Play,
Rewind,
SkipBack,
SkipForward,
Volume2,
@@ -35,6 +38,24 @@ function formatTime(s: number): string {
return `${m}:${String(sec).padStart(2, '0')}`
}
const SEEK_STEP_SEC = 10
const SEEK_REPEAT_MS = 160
const SEEK_APPLY_MS = 220
const SEEK_HINT_MS = 700
type SeekHint = {
dir: 'back' | 'forward'
seconds: number
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
return Boolean(
target.closest('input, textarea, select, [contenteditable="true"]') ||
target.isContentEditable,
)
}
type PlayerControlsProps = {
videoRef: React.RefObject<HTMLVideoElement>
uiVisible: boolean
@@ -123,6 +144,14 @@ export function PlayerControls({
const danmakuOpenRef = useRef(false)
const playlistOpenRef = useRef(false)
const pendingSeekRef = useRef<number | null>(null)
const applySeekRef = useRef<(absolute: number) => void>(() => undefined)
const durationRef = useRef(0)
const seekBurstRef = useRef<{ base: number; delta: number } | null>(null)
const seekApplyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const seekHintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastSeekAtRef = useRef(0)
const [seekHint, setSeekHint] = useState<SeekHint | null>(null)
const [stageEl, setStageEl] = useState<HTMLElement | null>(null)
useEffect(() => {
controlsHoveredRef.current = controlsHovered
@@ -144,6 +173,14 @@ export function PlayerControls({
playlistOpenRef.current = playlistOpen
}, [playlistOpen])
useEffect(() => {
durationRef.current = duration
}, [duration])
useEffect(() => {
setStageEl(container())
}, [container])
// 点击控制栏外部时关闭字幕菜单
useEffect(() => {
if (!subtitleMenuOpen) return
@@ -311,6 +348,7 @@ export function PlayerControls({
el.currentTime = local
setCurrentTime(streamOffset + local)
}
applySeekRef.current = applyAbsoluteSeek
const handleSeekChange = (v: number) => {
setScrubValue(v)
@@ -321,6 +359,11 @@ export function PlayerControls({
}
const handleSeekStart = () => {
if (seekApplyTimerRef.current) {
clearTimeout(seekApplyTimerRef.current)
seekApplyTimerRef.current = null
}
seekBurstRef.current = null
setIsScrubbing(true)
onUiVisibleChange(true)
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
@@ -332,6 +375,102 @@ export function PlayerControls({
setScrubValue(null)
}
const revealControls = useCallback(() => {
onUiVisibleChange(true)
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
const el = video()
if (
!el ||
el.paused ||
controlsHoveredRef.current ||
isScrubbingRef.current ||
subtitleMenuOpenRef.current ||
danmakuOpenRef.current ||
playlistOpenRef.current
) {
return
}
hideTimerRef.current = setTimeout(() => {
if (
!controlsHoveredRef.current &&
!isScrubbingRef.current &&
!subtitleMenuOpenRef.current &&
!danmakuOpenRef.current &&
!playlistOpenRef.current
) {
onUiVisibleChange(false)
}
}, 3000)
}, [onUiVisibleChange, video])
const queueRelativeSeek = useCallback((delta: number) => {
const el = video()
if (!el) return
if (!seekBurstRef.current) {
const base = pendingSeekRef.current ?? streamOffset + (el.currentTime || 0)
seekBurstRef.current = { base, delta: 0 }
}
seekBurstRef.current.delta += delta
const max = durationRef.current > 0 ? durationRef.current : Number.POSITIVE_INFINITY
const target = Math.min(max, Math.max(0, seekBurstRef.current.base + seekBurstRef.current.delta))
const applied = target - seekBurstRef.current.base
seekBurstRef.current.delta = applied
if (applied === 0) {
if (!seekApplyTimerRef.current) {
seekBurstRef.current = null
setSeekHint({
dir: delta < 0 ? 'back' : 'forward',
seconds: 0,
})
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
seekHintTimerRef.current = setTimeout(() => setSeekHint(null), SEEK_HINT_MS)
}
return
}
pendingSeekRef.current = target
setCurrentTime(target)
setSeekHint({
dir: applied < 0 ? 'back' : 'forward',
seconds: Math.abs(Math.round(applied)),
})
revealControls()
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
seekHintTimerRef.current = setTimeout(() => setSeekHint(null), SEEK_HINT_MS)
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
seekApplyTimerRef.current = setTimeout(() => {
const burst = seekBurstRef.current
seekBurstRef.current = null
seekApplyTimerRef.current = null
if (!burst) return
applySeekRef.current(burst.base + burst.delta)
}, SEEK_APPLY_MS)
}, [revealControls, streamOffset, video])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
if (isEditableTarget(e.target)) return
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
e.preventDefault()
const now = Date.now()
if (e.repeat && now - lastSeekAtRef.current < SEEK_REPEAT_MS) return
lastSeekAtRef.current = now
e.preventDefault()
queueRelativeSeek(e.key === 'ArrowLeft' ? -SEEK_STEP_SEC : SEEK_STEP_SEC)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [queueRelativeSeek])
useEffect(() => {
return () => {
if (seekApplyTimerRef.current) clearTimeout(seekApplyTimerRef.current)
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
}
}, [])
const changeVolume = (v: number) => {
const el = video()
if (!el) return
@@ -380,7 +519,39 @@ export function PlayerControls({
const canAdjustSelectedSubtitle = selectedSubtitle?.delivery === 'webvtt'
const usesOriginalASS = selectedSubtitle?.delivery === 'ass'
const seekOverlay = seekHint && stageEl
? createPortal(
<div className="pointer-events-none absolute inset-0 z-30">
<div
className={`absolute top-1/2 flex w-36 -translate-y-1/2 flex-col items-center justify-center rounded-full bg-black/55 px-3 py-5 text-white shadow-lg backdrop-blur-sm ${
seekHint.dir === 'back' ? 'left-[8%] sm:left-[12%]' : 'right-[8%] sm:right-[12%]'
}`}
>
{seekHint.dir === 'back' ? <Rewind size={28} /> : <FastForward size={28} />}
<span className="mt-1 text-center text-sm font-medium leading-tight">
{seekHint.seconds > 0
? seekHint.dir === 'back'
? '回退'
: '快进'
: seekHint.dir === 'back'
? '已到开头'
: '已到结尾'}
{seekHint.seconds > 0 && (
<>
<br />
<span className="tabular-nums">{seekHint.seconds} 秒</span>
</>
)}
</span>
</div>
</div>,
stageEl,
)
: null
return (
<>
{seekOverlay}
<div
className={`pointer-events-auto absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-3 pb-3 pt-14 transition-opacity duration-300 ${
uiVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
@@ -645,5 +816,6 @@ export function PlayerControls({
</button>
</div>
</div>
</>
)
}
+2 -2
View File
@@ -10,10 +10,10 @@ export function useFavourites() {
useEffect(() => {
let cancelled = false
playbackAPI
.listFavourites()
.listFavouriteIDs()
.then((items) => {
if (!cancelled) {
setFavouriteIDs(new Set((items ?? []).map((item) => item.id)))
setFavouriteIDs(new Set(items ?? []))
}
})
.catch(() => {
+4 -6
View File
@@ -8,6 +8,7 @@ import type { SeriesCard } from '../utils/groupSeries'
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
import { sortByPinnedIds } from '../utils/pinnedLibraries'
import { partitionPreviewIDs } from '../utils/remoteEmby'
import {
ContinueWatchingSection,
ContinueWatchingSkeleton,
@@ -18,7 +19,6 @@ import {
HomeLoadingState,
} from './HomePageSections'
const PREVIEW_BATCH_SIZE = 4
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
export function HomePage() {
@@ -102,12 +102,10 @@ export function HomePage() {
if (targets.length === 0) return
targets.forEach((id) => fetchingPreviewLimitsRef.current.set(id, limit))
const batches: string[][] = []
for (let i = 0; i < targets.length; i += PREVIEW_BATCH_SIZE) {
batches.push(targets.slice(i, i + PREVIEW_BATCH_SIZE))
}
// 本地库一次拉完(同一条窗口查询、缓存键稳定)。远程库单独一批,
// 避免一个慢的 Emby 挂载拖住整页预览。
const batches = partitionPreviewIDs(targets)
// 分批并发:一个慢库不再阻塞整页预览,先返回的批次立即渲染。
await Promise.allSettled(
batches.map(async (batch) => {
let loaded = false
+27 -23
View File
@@ -15,6 +15,7 @@ import type { Library } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
import { partitionPreviewIDs } from '../utils/remoteEmby'
export function LibrariesPage() {
const [libraries, setLibraries] = useState<Library[]>([])
@@ -33,30 +34,33 @@ export function LibrariesPage() {
if (targets.length === 0) return
targets.forEach((id) => fetchingRef.current.add(id))
let loaded = false
try {
const rows = await libraryAPI.listPreviews(targets, 10)
loaded = true
setLibraryData((prev) => {
const next = { ...prev }
for (const row of rows) {
next[row.id] = {
cards: row.cards ?? [],
total: row.total ?? 0,
}
const batches = partitionPreviewIDs(targets)
await Promise.allSettled(
batches.map(async (batch) => {
let loaded = false
try {
const rows = await libraryAPI.listPreviews(batch, 10)
loaded = true
setLibraryData((prev) => {
const next = { ...prev }
for (const row of rows) {
next[row.id] = {
cards: row.cards ?? [],
total: row.total ?? 0,
}
}
return next
})
} catch {
// 单个批次失败不影响其他批次。
} finally {
batch.forEach((id) => {
if (loaded) fetchedLibIdsRef.current.add(id)
fetchingRef.current.delete(id)
})
}
return next
})
} catch {
// 容错
} finally {
targets.forEach((id) => {
if (loaded) {
fetchedLibIdsRef.current.add(id)
}
fetchingRef.current.delete(id)
})
}
}),
)
}, [])
const loadLibraries = useCallback(async (options?: { force?: boolean }) => {
+2 -1
View File
@@ -783,7 +783,8 @@ export function PlayerPage() {
}
}, [nextEpisode, nextEpisodeTitle, playEpisode])
// ESC = back 或关闭浮层,[ / ] 或 Shift+P / Shift+N 切换上一集/下一集
// ESC = back 或关闭浮层,[ / ] 或 Shift+P / Shift+N 切换上一集/下一集。
// 左右方向键的回退/快进在播放器控制栏里处理。
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null
+15
View File
@@ -3,4 +3,19 @@
// embyremote~{accountID}~{remoteID}。
export function isRemoteEmbyID(id?: string | null): boolean {
return Boolean(id && id.startsWith('embyremote~'))
}
// partitionPreviewIDs 把本地库和远程库拆开。混在一个预览请求里时,本地 SQL
// 会等最慢的远程 Emby 回来才一起返回。
export function partitionPreviewIDs(ids: string[]): string[][] {
const local: string[] = []
const remote: string[] = []
for (const id of ids) {
if (isRemoteEmbyID(id)) remote.push(id)
else local.push(id)
}
const batches: string[][] = []
if (local.length > 0) batches.push(local)
if (remote.length > 0) batches.push(remote)
return batches
}