Compare commits

...

3 Commits

72 changed files with 3326 additions and 848 deletions
+38
View File
@@ -98,3 +98,41 @@ func logFilePaths(cfg *config.Config) (string, string, string) {
}
return filepath.Join(out, "app.log"), filepath.Join(out, "warn.log"), filepath.Join(out, "error.log")
}
// newEmbyCompatLogger 构建只写入 Emby 兼容日志文件的独立 Zap 实例。
// 它不参与 app.log 的日志级别过滤,始终记录 INFO 及以上,确保成功请求也能
// 用于还原客户端的接口调用顺序;轮转参数沿用 logging 配置。
func newEmbyCompatLogger(cfg *config.Config) (*zap.Logger, func(), error) {
encoderCfg := zap.NewProductionEncoderConfig()
encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder
var encoder zapcore.Encoder
if strings.EqualFold(strings.TrimSpace(cfg.Logging.Format), "console") {
encoder = zapcore.NewConsoleEncoder(encoderCfg)
} else {
encoder = zapcore.NewJSONEncoder(encoderCfg)
}
writer, err := newRotatingFileWriter(embyCompatLogPath(cfg), cfg.Logging)
if err != nil {
return nil, nil, err
}
log := zap.New(
zapcore.NewCore(encoder, writer, zap.InfoLevel),
zap.AddCaller(),
zap.AddStacktrace(zapcore.ErrorLevel),
zap.ErrorOutput(zapcore.Lock(os.Stderr)),
)
return log, func() { _ = writer.Close() }, nil
}
func embyCompatLogPath(cfg *config.Config) string {
out := strings.TrimSpace(cfg.Logging.OutputPath)
if out == "" || strings.EqualFold(out, "stdout") || strings.EqualFold(out, "stderr") {
return filepath.Join(cfg.App.DataDir, "logs", "emby-compat.log")
}
if ext := filepath.Ext(out); ext != "" {
base := strings.TrimSuffix(out, ext)
return base + ".emby-compat" + ext
}
return filepath.Join(out, "emby-compat.log")
}
+31
View File
@@ -116,3 +116,34 @@ func TestRotatingFileWriterCapsFileSize(t *testing.T) {
}
_ = writer.Sync()
}
func TestEmbyCompatLoggerWritesDedicatedFile(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
cfg.App.DataDir = dir
cfg.Logging.Format = "json"
cfg.Logging.OutputPath = filepath.Join(dir, "logs")
cfg.Logging.EnableRotation = true
cfg.Logging.MaxSizeMB = 1
cfg.Logging.MaxBackups = 2
log, closeFn, err := newEmbyCompatLogger(cfg)
if err != nil {
t.Fatal(err)
}
log.Info("emby request", zap.String("client", "Infuse"))
_ = log.Sync()
closeFn()
path := filepath.Join(dir, "logs", "emby-compat.log")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "emby request") || !strings.Contains(string(data), "Infuse") {
t.Fatalf("dedicated Emby log missing request data: %s", data)
}
if _, err := os.Stat(filepath.Join(dir, "logs", "app.log")); !os.IsNotExist(err) {
t.Fatalf("Emby logger must not write app.log, stat err=%v", err)
}
}
+11 -1
View File
@@ -59,11 +59,21 @@ func main() {
}
defer func() { _ = logger.Sync() }()
embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(cfg)
if err != nil {
logger.Fatal("Emby compatibility logger init failed", zap.Error(err))
}
defer func() {
_ = embyCompatLogger.Sync()
closeEmbyCompatLogger()
}()
appVersion := effectiveVersion(version)
logger.Info("starting MeBox",
zap.String("version", appVersion),
zap.Int("port", cfg.App.Port),
zap.String("data_dir", cfg.App.DataDir),
zap.String("emby_compat_log", embyCompatLogPath(cfg)),
)
// Ensure data / cache / web dirs exist.
@@ -104,7 +114,7 @@ func main() {
logger.Warn("seed admin failed", zap.Error(err))
}
router := buildRouter(cfg, logger, services)
router := buildRouter(cfg, logger, embyCompatLogger, services)
serverMgr := newServerManager(cfg, logger, router)
services.ReloadHTTPServer = serverMgr.Reload
+4 -1
View File
@@ -19,13 +19,16 @@ import (
"github.com/truewhile/MeBox/web"
)
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
func buildRouter(cfg *config.Config, logger *zap.Logger, embyCompatLogger *zap.Logger, svc *service.Container) *gin.Engine {
if !cfg.App.Debug {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Recovery())
r.Use(middleware.RequestLogger(logger))
r.Use(middleware.EmbyCompatLogger(embyCompatLogger, func(path string) bool {
return !isFrontendLibraryRoute(path) && handler.IsEmbyPath(path)
}))
if !cfg.App.Debug && len(cfg.App.CORSOrigins) == 0 {
logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.")
}
+6 -2
View File
@@ -46,6 +46,9 @@ func TestLoadDefaults(t *testing.T) {
if cfg.Cache.MediaTTLSeconds != 90 {
t.Fatalf("expected default media cache ttl 90, got %d", cfg.Cache.MediaTTLSeconds)
}
if cfg.Cache.MemoryMaxSizeMB != DefaultCacheMemoryMaxSizeMB {
t.Fatalf("expected default runtime cache memory limit %d, got %d", DefaultCacheMemoryMaxSizeMB, cfg.Cache.MemoryMaxSizeMB)
}
if cfg.Search.Index != "mebox_media" {
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
}
@@ -88,6 +91,7 @@ func TestEnvOverride(t *testing.T) {
t.Setenv("MEBOX_DATABASE_DSN", "postgres://mebox:secret@postgres:5432/mebox?sslmode=disable")
t.Setenv("MEBOX_CACHE_REDIS_URL", "redis://redis:6379/0")
t.Setenv("MEBOX_CACHE_MEDIA_TTL_SECONDS", "30")
t.Setenv("MEBOX_CACHE_MEMORY_MAX_SIZE_MB", "48")
t.Setenv("MEBOX_SEARCH_BACKEND", "opensearch")
t.Setenv("MEBOX_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
t.Setenv("MEBOX_LICENSE_SERVER_URL", "https://license.example.com")
@@ -103,8 +107,8 @@ func TestEnvOverride(t *testing.T) {
if cfg.Database.Type != "postgres" || cfg.Database.DSN == "" {
t.Fatalf("expected postgres database config from env, got type=%q dsn=%q", cfg.Database.Type, cfg.Database.DSN)
}
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 {
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds)
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 || cfg.Cache.MemoryMaxSizeMB != 48 {
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d memory=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds, cfg.Cache.MemoryMaxSizeMB)
}
if cfg.Search.Backend != "opensearch" || cfg.Search.OpenSearchURL != "http://opensearch:9200" {
t.Fatalf("expected opensearch config from env, got backend=%q url=%q", cfg.Search.Backend, cfg.Search.OpenSearchURL)
+2
View File
@@ -7,6 +7,7 @@ const (
defaultDatabaseMaxIdleConns = 4
defaultLicenseServerURL = "https://mgosever.3jzs.com"
defaultLicensePublicKey = "MCowBQYDK2VwAyEABRXnXy+urjrbKit6Yu/HiezWgP0NdsZW3tsegJWRrtI="
DefaultCacheMemoryMaxSizeMB = 128
)
func setDefaults(v *viper.Viper) {
@@ -44,6 +45,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("cache.cache_dir", "./cache")
v.SetDefault("cache.images_max_size_mb", 500)
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
v.SetDefault("cache.cleanup_interval_min", 60)
v.SetDefault("cache.redis_url", "")
v.SetDefault("cache.redis_prefix", "mebox")
+3
View File
@@ -47,6 +47,9 @@ func (c *Config) normalize() error {
if c.Cache.ImagesMaxSizeMB < 0 {
c.Cache.ImagesMaxSizeMB = 0
}
if c.Cache.MemoryMaxSizeMB <= 0 {
c.Cache.MemoryMaxSizeMB = DefaultCacheMemoryMaxSizeMB
}
if c.Cache.RedisPrefix == "" {
c.Cache.RedisPrefix = "mebox"
}
+5 -3
View File
@@ -113,10 +113,12 @@ type LoggingConfig struct {
MaxBackups int `mapstructure:"max_backups"`
}
// CacheConfig 控制磁盘转码/刮削缓存。
// CacheConfig 控制磁盘转写缓存和进程内热缓存。
type CacheConfig struct {
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
// MemoryMaxSizeMB 限制进程内 L1 缓存总字节数,JSON/对象缓存共用该预算。
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
TTLHours int `mapstructure:"ttl_hours"`
AutoCleanup bool `mapstructure:"auto_cleanup"`
+3
View File
@@ -68,6 +68,9 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
if req.Key == "cache.images_max_size_mb" && svc.Scheduler != nil {
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
}
if req.Key == "cache.memory_max_size_mb" && svc.Cache != nil {
svc.Cache.SetMaxSizeMB(svc.Cfg.Cache.MemoryMaxSizeMB)
}
c.Status(http.StatusNoContent)
}
}
+4 -1
View File
@@ -68,7 +68,7 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
progressReq := httptest.NewRequest(
http.MethodPost,
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000",
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000&playSessionId=remote-mount-1-2000000000000",
nil,
)
wProgress := httptest.NewRecorder()
@@ -85,6 +85,9 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
if hist.PositionMs != 30000 {
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
}
if hist.SessionID != "remote-mount-1-2000000000000" || hist.SessionStartedAtMs != 2000000000000 {
t.Fatalf("unexpected playback session metadata: %#v", hist)
}
// 2. 测试 Filters=IsResumable 能够包含该远程条目
resumableReq := httptest.NewRequest(
+32
View File
@@ -0,0 +1,32 @@
package handler
import "testing"
func TestIsEmbyPath(t *testing.T) {
tests := []struct {
path string
want bool
}{
{path: "/emby", want: true},
{path: "/emby/System/Info/Public", want: true},
{path: "//emby//Items//1", want: true},
{path: "/System/Info/Public", want: true},
{path: "/Search/Hints", want: true},
{path: "/Playback/BitrateTest", want: true},
{path: "/api/unknown", want: false},
{path: "/library/123e4567-e89b-12d3-a456-426614174000", want: true},
{path: "/random/path", want: false},
}
for _, tt := range tests {
if got := IsEmbyPath(tt.path); got != tt.want {
t.Errorf("IsEmbyPath(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
func TestNormalizeEmbyPrefixedInternalAPIPath(t *testing.T) {
got, changed := NormalizeEmbyPath("/emby/api/Stream/123")
if !changed || got != "/emby/api/stream/123" {
t.Fatalf("NormalizeEmbyPath() = (%q, %v), want (/emby/api/stream/123, true)", got, changed)
}
}
+87 -1
View File
@@ -88,6 +88,92 @@ var embyStaticSegments = map[string]struct{}{
"embywebsocket": {},
}
var embyRootSegments = map[string]struct{}{
"albums": {},
"artists": {},
"audio": {},
"audiocodecs": {},
"auth": {},
"branding": {},
"channels": {},
"collections": {},
"connect": {},
"containers": {},
"devices": {},
"displaypreferences": {},
"dlna": {},
"encoding": {},
"environment": {},
"gamegenres": {},
"games": {},
"genres": {},
"images": {},
"items": {},
"libraries": {},
"library": {},
"livestreams": {},
"livetv": {},
"localization": {},
"movies": {},
"musicgenres": {},
"news": {},
"notification": {},
"notifications": {},
"officialratings": {},
"packages": {},
"persons": {},
"playback": {},
"playlists": {},
"plugins": {},
"providers": {},
"reports": {},
"scheduledtasks": {},
"search": {},
"sessions": {},
"shows": {},
"songs": {},
"studios": {},
"subtitlecodecs": {},
"sync": {},
"system": {},
"tags": {},
"trailers": {},
"user_usage_stats": {},
"users": {},
"videocodecs": {},
"videos": {},
"years": {},
}
func isEmbyRootSegment(segment string) bool {
if _, ok := embyRootSegments[segment]; ok {
return true
}
_, ok := embyStaticSegments[segment]
return ok
}
// IsEmbyPath 判断路径是否属于 Emby/Jellyfin 兼容面的命名空间。
// 除显式 /emby 前缀外,Emby 客户端也会直接请求客户端协议的根路径,
// 例如 /System/Info/Public、/Users/Public、/Sessions。
func IsEmbyPath(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return false
}
path = multipleSlashesRE.ReplaceAllString(path, "/")
lower := strings.ToLower(path)
if lower == "/emby" || strings.HasPrefix(lower, "/emby/") {
return true
}
segments := strings.Split(strings.Trim(path, "/"), "/")
if len(segments) == 0 || segments[0] == "" {
return false
}
return isEmbyRootSegment(strings.ToLower(segments[0]))
}
// NormalizeEmbyPath 规范化 Emby 请求路径:
// 1. 折叠重复斜杠(如 //emby/ -> /emby/);
// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info);
@@ -132,7 +218,7 @@ func NormalizeEmbyPath(p string) (string, bool) {
// 检查第一段是否为 Emby 根路由关键字
firstLower := strings.ToLower(segments[0])
if _, ok := embyStaticSegments[firstLower]; !ok && firstLower != "api" {
if !isEmbyRootSegment(firstLower) && firstLower != "api" {
// 不是 Emby 相关路径,保持原样
return original, false
}
+8 -1
View File
@@ -19,6 +19,8 @@ type embyPlayingReq struct {
PositionLower int64 `json:"positionTicks"`
RunTimeTicks int64 `json:"RunTimeTicks"`
RunTimeLower int64 `json:"runTimeTicks"`
PlaySessionID string `json:"PlaySessionId"`
PlaySession string `json:"playSessionId"`
}
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
@@ -48,6 +50,11 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
if runTime == 0 {
runTime, _ = strconv.ParseInt(firstQueryValue(c, "RunTimeTicks", "runTimeTicks"), 10, 64)
}
playSessionID := embyFirstNonEmptyString(
req.PlaySessionID,
req.PlaySession,
firstQueryValue(c, "PlaySessionId", "playSessionId"),
)
if itemID == "" {
c.Status(http.StatusOK)
return
@@ -57,7 +64,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusUnauthorized)
return
}
if err := svc.Emby.RecordProgress(c.Request.Context(), uid, itemID, pos, runTime); err != nil {
if err := svc.Emby.RecordProgressWithSession(c.Request.Context(), uid, itemID, pos, runTime, playSessionID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+110 -83
View File
@@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -60,37 +61,37 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
}
role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
if !includeHidden {
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
visibility := mediaVisibilityForRequest(c, svc)
filtered := libs[:0]
for _, lib := range libs {
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
filtered = append(filtered, lib)
}
}
libs = filtered
}
rawIDs := strings.TrimSpace(c.Query("ids"))
var targetSet map[string]struct{}
if rawIDs != "" {
targetSet = make(map[string]struct{})
for _, id := range strings.Split(rawIDs, ",") {
id = strings.TrimSpace(id)
if id != "" {
targetSet[id] = struct{}{}
}
if !includeHidden {
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
visibility := mediaVisibilityForRequest(c, svc)
filtered := libs[:0]
for _, lib := range libs {
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
filtered = append(filtered, lib)
}
}
if len(targetSet) > 0 {
filtered := libs[:0]
for _, lib := range libs {
if _, ok := targetSet[lib.ID]; ok {
filtered = append(filtered, lib)
}
libs = filtered
}
rawIDs := strings.TrimSpace(c.Query("ids"))
var targetSet map[string]struct{}
if rawIDs != "" {
targetSet = make(map[string]struct{})
for _, id := range strings.Split(rawIDs, ",") {
id = strings.TrimSpace(id)
if id != "" {
targetSet[id] = struct{}{}
}
libs = filtered
}
}
if len(targetSet) > 0 {
filtered := libs[:0]
for _, lib := range libs {
if _, ok := targetSet[lib.ID]; ok {
filtered = append(filtered, lib)
}
}
libs = filtered
}
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
limit := 10
if withPreview {
@@ -103,7 +104,12 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
}
out := make([]webLibraryPayload, 0, len(libs)+8)
if withPreview {
previews, err := svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
var previews []service.LibraryPreviewItem
if c.Query("include_total") == "0" {
previews, err = svc.Media.ListLibraryPreviews(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
} else {
previews, err = svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -111,41 +117,55 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
for _, p := range previews {
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
}
} else {
visibility := mediaVisibilityForRequest(c, svc)
libIDs := make([]string, len(libs))
for i, l := range libs {
libIDs[i] = l.ID
} else {
visibility := mediaVisibilityForRequest(c, svc)
libIDs := make([]string, len(libs))
for i, l := range libs {
libIDs[i] = l.ID
}
counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
})
for _, l := range libs {
var total int64
if counts != nil {
total = counts[l.ID]
}
counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
})
for _, l := range libs {
var total int64
if counts != nil {
total = counts[l.ID]
}
out = append(out, webLibraryPayload{Library: l, Total: total})
out = append(out, webLibraryPayload{Library: l, Total: total})
}
}
// 精确指定目标库时,如果目标全是本地库,就不必枚举远程挂载。
// 首页预览会拆成多个小批次请求,跳过无关远程调用可以明显缩短
// 每批次的尾延迟;未指定 ids 的完整库列表仍保持原行为。
includeRemote := true
if len(targetSet) > 0 {
includeRemote = false
for id := range targetSet {
if service.IsEmbyRemoteID(id) {
includeRemote = true
break
}
}
}
// 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。
if svc.EmbyRemote != nil {
if includeRemote && svc.EmbyRemote != nil {
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
visibility := mediaVisibilityForRequest(c, svc)
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
for _, v := range views {
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
visibility := mediaVisibilityForRequest(c, svc)
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
for _, v := range views {
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
continue
}
if len(targetSet) > 0 {
if _, ok := targetSet[v.Library.ID]; !ok {
continue
}
if len(targetSet) > 0 {
if _, ok := targetSet[v.Library.ID]; !ok {
continue
}
}
allowedViews = append(allowedViews, v)
}
allowedViews = append(allowedViews, v)
}
remotePayloads := make([]webLibraryPayload, len(allowedViews))
for i, v := range allowedViews {
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
@@ -170,13 +190,13 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
if acct == nil {
return
}
tmpMount := &model.EmbyMount{
Base: model.Base{ID: v.MountID},
AccountID: v.AccountID,
RemoteViewID: v.RemoteID,
CollectionType: v.CollectionType,
Name: v.Library.Name,
}
tmpMount := &model.EmbyMount{
Base: model.Base{ID: v.MountID},
AccountID: v.AccountID,
RemoteViewID: v.RemoteID,
CollectionType: v.CollectionType,
Name: v.Library.Name,
}
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
remotePayloads[i].Total = total
@@ -421,9 +441,14 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
})
return
}
sortSpec := parseMediaSort(c)
var history map[string]time.Time
if sortSpec.Field == "last_played" {
history = mediaHistoryMap(c, svc)
}
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
if !groupVersions {
items, total, err := svc.Media.ListMediaVisible(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc))
items, total, err := svc.Media.ListMediaVisible(ctx, id, page, size, mediaVisibilityForRequest(c, svc))
if err != nil {
writeInternalOrCanceled(c, err)
return
@@ -439,17 +464,19 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
})
return
}
items, total, err := svc.Media.ListMediaVisibleGrouped(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc))
grouped, err := svc.Media.GroupedMediaVisible(ctx, id, mediaVisibilityForRequest(c, svc))
if err != nil {
writeInternalOrCanceled(c, err)
return
}
grouped = service.SortMediaItems(grouped, sortSpec.Field, sortSpec.Order, history)
items := service.PaginateMediaItems(grouped, page, size)
if items == nil {
items = []service.MediaItem{}
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"total": len(grouped),
"page": page,
"page_size": size,
})
@@ -699,25 +726,25 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
}
return
}
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。
// 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断;
// 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。
if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") {
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
return
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, target)
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。
// 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断;
// 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。
if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") {
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
return
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, target)
return
}
m, err := svc.Media.GetMedia(ctx, id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
+50
View File
@@ -0,0 +1,50 @@
package handler
import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
func parseMediaSort(c *gin.Context) service.MediaSortSpec {
if c == nil {
return service.NormalizeMediaSort("", "")
}
return service.NormalizeMediaSort(c.Query("sort"), c.Query("order"))
}
// mediaHistoryMap loads the current user's latest playback time per media ID.
// It is only used for the explicit last_played sort so normal pagination keeps
// its single-query path.
func mediaHistoryMap(c *gin.Context, svc *service.Container) map[string]time.Time {
if c == nil || svc == nil || svc.Repo == nil || svc.Repo.DB == nil {
return nil
}
uid, _ := c.Get(middleware.CtxUserID)
userID := strings.TrimSpace(toString(uid))
if userID == "" {
return nil
}
var rows []struct {
MediaID string `gorm:"column:media_id"`
WatchedAt time.Time `gorm:"column:watched_at"`
}
if err := svc.Repo.DB.WithContext(c.Request.Context()).
Model(&model.PlaybackHistory{}).
Select("media_id, MAX(watched_at) AS watched_at").
Where("user_id = ?", userID).
Group("media_id").
Scan(&rows).Error; err != nil {
return nil
}
out := make(map[string]time.Time, len(rows))
for _, row := range rows {
out[row.MediaID] = row.WatchedAt
}
return out
}
+15 -6
View File
@@ -15,9 +15,12 @@ import (
// ─── History ────────────────────────────────────────────────────────────────
type progressReq struct {
MediaID string `json:"media_id" binding:"required"`
PositionMs int64 `json:"position_ms"`
DurationMs int64 `json:"duration_ms"`
MediaID string `json:"media_id" binding:"required"`
PositionMs int64 `json:"position_ms"`
DurationMs int64 `json:"duration_ms"`
SessionID string `json:"session_id"`
SessionStartedAtMs int64 `json:"session_started_at_ms"`
Sequence int64 `json:"sequence"`
}
func recordProgressHandler(svc *service.Container) gin.HandlerFunc {
@@ -28,9 +31,15 @@ func recordProgressHandler(svc *service.Container) gin.HandlerFunc {
return
}
uid, _ := c.Get(middleware.CtxUserID)
if err := svc.Playback.RecordProgress(
c.Request.Context(), uid.(string), req.MediaID, req.PositionMs, req.DurationMs,
); err != nil {
if err := svc.Playback.RecordProgressUpdate(c.Request.Context(), service.ProgressUpdate{
UserID: toString(uid),
MediaID: req.MediaID,
PositionMs: req.PositionMs,
DurationMs: req.DurationMs,
SessionID: req.SessionID,
SessionStartedAtMs: req.SessionStartedAtMs,
Sequence: req.Sequence,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+65
View File
@@ -0,0 +1,65 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestRecordProgressHandlerIgnoresStaleSessionSequence(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{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := &service.Container{
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.POST("/history", recordProgressHandler(svc))
post := func(body string) int {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/history", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w.Code
}
if code := post(`{"media_id":"m-1","position_ms":90000,"duration_ms":120000,"session_id":"s-1","session_started_at_ms":1000,"sequence":2}`); code != http.StatusNoContent {
t.Fatalf("newer progress status = %d", code)
}
if code := post(`{"media_id":"m-1","position_ms":10000,"duration_ms":120000,"session_id":"s-1","session_started_at_ms":1000,"sequence":1}`); code != http.StatusNoContent {
t.Fatalf("stale progress status = %d", code)
}
var got model.PlaybackHistory
if err := db.Where("user_id = ? AND media_id = ?", "user-1", "m-1").First(&got).Error; err != nil {
t.Fatal(err)
}
if got.PositionMs != 90_000 || got.Sequence != 2 {
t.Fatalf("stale handler request overwrote newer state: %#v", got)
}
}
+8
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"sort"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -66,6 +67,11 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libID := c.Param("id")
ctx := c.Request.Context()
sortSpec := parseMediaSort(c)
var history map[string]time.Time
if sortSpec.Field == "last_played" {
history = mediaHistoryMap(c, svc)
}
// 远程剧集库:远程 Series 映射为系列卡片。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(libID) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(libID)
@@ -83,6 +89,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
cards = service.SortSeriesCards(cards, sortSpec.Field, sortSpec.Order, history)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
if page < 1 {
@@ -122,6 +129,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
writeInternalOrCanceled(c, err)
return
}
items = service.SortSeriesCards(items, sortSpec.Field, sortSpec.Order, history)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
if page < 1 {
+284
View File
@@ -0,0 +1,284 @@
package middleware
import (
"net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// EmbyCompatLogger 将 Emby/Jellyfin 兼容面的请求独立记录,便于针对具体
// 客户端、设备和未实现接口排查兼容问题。
//
// isEmbyPath 由 handler 层提供,用于识别 /emby/* 以及无前缀根路径形式的
// Emby API。除路径特征外,带 X-Emby-* / X-MediaBrowser-* 等客户端凭据的
// 请求也会被识别,因此尚未实现且没有已知路径前缀的新接口也能被记录。
func EmbyCompatLogger(log *zap.Logger, isEmbyPath func(string) bool) gin.HandlerFunc {
if log == nil {
return func(c *gin.Context) { c.Next() }
}
return func(c *gin.Context) {
if c == nil || c.Request == nil {
return
}
requestPath := c.Request.URL.Path
pathMatched := isEmbyPath != nil && isEmbyPath(requestPath)
hasSignature := hasEmbyClientSignature(c)
if !pathMatched && !hasSignature {
c.Next()
return
}
start := time.Now()
c.Next()
status := c.Writer.Status()
route := c.FullPath()
// 根路径 Emby 命名空间很宽,可能与 SPA 路由重名。没有客户端标识且
// 最终由 SPA 返回 200 时不应污染兼容日志;服务端路由或 404 仍记录。
if !hasSignature && !hasExplicitEmbyPrefix(requestPath) && strings.TrimSpace(route) == "" && status < http.StatusBadRequest {
return
}
unimplemented := isUnimplementedEmbyRequest(c, route, status, hasSignature)
fields := embyCompatLogFields(c, requestPath, status, route, time.Since(start), unimplemented)
switch {
case unimplemented:
log.Warn("emby API not implemented", fields...)
case status >= http.StatusBadRequest:
log.Warn("emby request failed", fields...)
default:
log.Info("emby request", fields...)
}
}
}
func isUnimplementedEmbyRequest(c *gin.Context, route string, status int, hasSignature bool) bool {
if strings.TrimSpace(route) != "" || c.Request.Method == http.MethodOptions {
return false
}
if status == http.StatusNotFound {
return true
}
// 无前端 API 前缀的未知 Emby 路径可能被 SPA 兜底为 200 HTML。
// 已识别为 Emby 客户端的这类响应同样说明兼容接口尚未实现。
return hasSignature && status >= http.StatusOK && status < http.StatusMultipleChoices &&
strings.Contains(strings.ToLower(c.Writer.Header().Get("Content-Type")), "text/html")
}
func embyCompatLogFields(c *gin.Context, requestPath string, status int, route string, duration time.Duration, unimplemented bool) []zap.Field {
client := embyCompatClientInfo(c)
fields := []zap.Field{
zap.String("method", c.Request.Method),
zap.String("path", requestPath),
zap.String("normalized_path", c.Request.URL.Path),
zap.String("route", route),
zap.Int("status", status),
zap.Duration("duration", duration),
zap.String("ip", c.ClientIP()),
zap.Bool("unimplemented", unimplemented),
}
if queryKeys := sortedQueryKeys(c); len(queryKeys) > 0 {
fields = append(fields, zap.Strings("query_keys", queryKeys))
}
if client.Client != "" {
fields = append(fields, zap.String("client", client.Client))
}
if client.Device != "" {
fields = append(fields, zap.String("device", client.Device))
}
if client.DeviceID != "" {
fields = append(fields, zap.String("device_id", client.DeviceID))
}
if client.Version != "" {
fields = append(fields, zap.String("client_version", client.Version))
}
if client.UserID != "" {
fields = append(fields, zap.String("user_id", client.UserID))
}
if userAgent := strings.TrimSpace(c.GetHeader("User-Agent")); userAgent != "" {
fields = append(fields, zap.String("user_agent", userAgent))
}
if contentType := strings.TrimSpace(c.GetHeader("Content-Type")); contentType != "" {
fields = append(fields, zap.String("content_type", contentType))
}
if errs := c.Errors.Errors(); len(errs) > 0 {
fields = append(fields, zap.Strings("errors", truncateStrings(errs, 8, 512)))
}
return fields
}
func hasEmbyClientSignature(c *gin.Context) bool {
for _, name := range []string{
"X-Emby-Token",
"X-MediaBrowser-Token",
"X-Emby-Authorization",
"X-MediaBrowser-Authorization",
"X-Emby-Client",
"X-MediaBrowser-Client",
"X-Emby-Device-Id",
"X-Emby-DeviceId",
"X-Emby-Device-Name",
"X-Emby-Version",
"X-Emby-UserId",
"X-MediaBrowser-Device-Id",
"X-MediaBrowser-DeviceId",
"X-MediaBrowser-Device-Name",
"X-MediaBrowser-Version",
"X-MediaBrowser-UserId",
} {
if strings.TrimSpace(c.GetHeader(name)) != "" {
return true
}
}
auth := strings.ToLower(strings.TrimSpace(c.GetHeader("Authorization")))
if strings.HasPrefix(auth, "emby ") || strings.HasPrefix(auth, "mediabrowser ") {
return true
}
for _, key := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
if strings.TrimSpace(c.Query(key)) != "" {
return true
}
}
ua := strings.ToLower(strings.TrimSpace(c.GetHeader("User-Agent")))
for _, marker := range []string{
"emby", "jellyfin", "infuse", "senplayer", "fileball", "vidhub", "hills", "rodelplayer",
} {
if strings.Contains(ua, marker) {
return true
}
}
return false
}
type embyCompatClient struct {
Client string
Device string
DeviceID string
Version string
UserID string
}
func embyCompatClientInfo(c *gin.Context) embyCompatClient {
auth := parseEmbyCompatAuthorization(firstNonEmptyString(
c.GetHeader("X-Emby-Authorization"),
c.GetHeader("X-MediaBrowser-Authorization"),
c.GetHeader("Authorization"),
))
return embyCompatClient{
Client: firstNonEmptyString(
c.GetHeader("X-Emby-Client"),
c.GetHeader("X-MediaBrowser-Client"),
c.Query("Client"),
c.Query("client"),
c.Query("X-Emby-Client"),
auth["client"],
),
Device: firstNonEmptyString(
c.GetHeader("X-Emby-Device-Name"),
c.GetHeader("X-MediaBrowser-Device-Name"),
c.Query("Device"),
c.Query("DeviceName"),
c.Query("device"),
c.Query("deviceName"),
auth["device"],
),
DeviceID: firstNonEmptyString(
c.GetHeader("X-Emby-Device-Id"),
c.GetHeader("X-Emby-DeviceId"),
c.GetHeader("X-MediaBrowser-Device-Id"),
c.GetHeader("X-MediaBrowser-DeviceId"),
c.Query("DeviceId"),
c.Query("DeviceID"),
c.Query("deviceId"),
c.Query("deviceID"),
auth["deviceid"],
),
Version: firstNonEmptyString(
c.GetHeader("X-Emby-Version"),
c.GetHeader("X-MediaBrowser-Version"),
c.Query("Version"),
c.Query("version"),
auth["version"],
),
UserID: firstNonEmptyString(
c.Query("UserId"),
c.Query("userId"),
auth["userid"],
),
}
}
func parseEmbyCompatAuthorization(raw string) map[string]string {
out := map[string]string{}
raw = strings.TrimSpace(raw)
if raw == "" {
return out
}
if len(raw) >= len("MediaBrowser ") && strings.EqualFold(raw[:len("MediaBrowser ")], "MediaBrowser ") {
raw = raw[len("MediaBrowser "):]
} else if len(raw) >= len("Emby ") && strings.EqualFold(raw[:len("Emby ")], "Emby ") {
raw = raw[len("Emby "):]
}
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
key, value, ok := strings.Cut(part, "=")
if !ok {
continue
}
key = strings.ToLower(strings.TrimSpace(key))
value = strings.Trim(strings.TrimSpace(value), `"`)
if key != "" && value != "" {
out[key] = value
}
}
return out
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func sortedQueryKeys(c *gin.Context) []string {
query := c.Request.URL.Query()
keys := make([]string, 0, len(query))
for key := range query {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func truncateStrings(values []string, maxItems, maxLen int) []string {
if len(values) > maxItems {
values = values[:maxItems]
}
out := make([]string, 0, len(values))
for _, value := range values {
if len(value) > maxLen {
value = value[:maxLen] + "..."
}
out = append(out, value)
}
return out
}
func hasExplicitEmbyPrefix(path string) bool {
lower := strings.ToLower(strings.TrimSpace(path))
return lower == "/emby" || strings.HasPrefix(lower, "/emby/")
}
@@ -0,0 +1,124 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestEmbyCompatLoggerRecordsClientAndUnimplementedRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
core, logs := observer.New(zap.InfoLevel)
router := gin.New()
router.Use(EmbyCompatLogger(zap.New(core), func(path string) bool {
return strings.HasPrefix(path, "/emby/") || strings.HasPrefix(path, "/System/")
}))
router.GET("/emby/System/Info/Public", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ServerName": "MeBox"})
})
router.NoRoute(func(c *gin.Context) {
c.Status(http.StatusNotFound)
})
successReq := httptest.NewRequest(http.MethodGet, "/emby/System/Info/Public?api_key=do-not-log", nil)
successReq.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Infuse", Device="Apple TV", DeviceId="device-1", Version="8.2", UserId="user-1", Token="secret-token"`)
successResp := httptest.NewRecorder()
router.ServeHTTP(successResp, successReq)
if successResp.Code != http.StatusOK {
t.Fatalf("success status = %d, want 200", successResp.Code)
}
missingReq := httptest.NewRequest(http.MethodPost, "/Search/Hints", nil)
missingReq.Header.Set("X-Emby-Token", "secret-token")
missingResp := httptest.NewRecorder()
router.ServeHTTP(missingResp, missingReq)
if missingResp.Code != http.StatusNotFound {
t.Fatalf("missing status = %d, want 404", missingResp.Code)
}
entries := logs.All()
if len(entries) != 2 {
t.Fatalf("logged entries = %d, want 2: %#v", len(entries), entries)
}
success := entries[0].ContextMap()
if success["path"] != "/emby/System/Info/Public" {
t.Fatalf("success path = %#v", success["path"])
}
if success["client"] != "Infuse" || success["device"] != "Apple TV" || success["device_id"] != "device-1" {
t.Fatalf("client fields not parsed: %#v", success)
}
if success["unimplemented"] != false {
t.Fatalf("success unimplemented = %#v, want false", success["unimplemented"])
}
if _, ok := success["token"]; ok {
t.Fatalf("token must not be logged: %#v", success)
}
if success["query_keys"] == nil {
t.Fatalf("query_keys missing: %#v", success)
}
if strings.Contains(entries[0].Message, "secret-token") {
t.Fatalf("token leaked in message: %q", entries[0].Message)
}
missing := entries[1].ContextMap()
if entries[1].Message != "emby API not implemented" {
t.Fatalf("missing message = %q", entries[1].Message)
}
if missing["path"] != "/Search/Hints" || missing["unimplemented"] != true {
t.Fatalf("missing fields = %#v", missing)
}
if missing["method"] != http.MethodPost {
t.Fatalf("missing method = %#v", missing["method"])
}
}
func TestEmbyCompatLoggerSkipsUnrelatedRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
core, logs := observer.New(zap.InfoLevel)
router := gin.New()
router.Use(EmbyCompatLogger(zap.New(core), func(string) bool { return false }))
router.GET("/api/health", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
req.Header.Set("Authorization", "Bearer regular-token")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if logs.Len() != 0 {
t.Fatalf("unrelated request should not be logged: %#v", logs.All())
}
}
func TestEmbyCompatLoggerMarksSPAFallbackAsUnimplemented(t *testing.T) {
gin.SetMode(gin.TestMode)
core, logs := observer.New(zap.InfoLevel)
router := gin.New()
router.Use(EmbyCompatLogger(zap.New(core), func(string) bool { return false }))
router.NoRoute(func(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte("<html></html>"))
})
req := httptest.NewRequest(http.MethodGet, "/NewEmbyFeature/Test", nil)
req.Header.Set("X-Emby-Token", "secret-token")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
entries := logs.All()
if len(entries) != 1 {
t.Fatalf("logged entries = %d, want 1", len(entries))
}
if entries[0].Message != "emby API not implemented" {
t.Fatalf("message = %q, want unimplemented warning", entries[0].Message)
}
if entries[0].ContextMap()["unimplemented"] != true {
t.Fatalf("unimplemented field missing: %#v", entries[0].ContextMap())
}
}
+6
View File
@@ -13,6 +13,12 @@ type PlaybackHistory struct {
DurationMs int64 `json:"duration_ms"`
WatchedAt time.Time `json:"watched_at"`
Completed bool `json:"completed"`
// 播放会话信息用于丢弃乱序到达的旧进度,避免旧请求把新的续播状态覆盖。
// 旧客户端不提供这些字段时保持 0,继续沿用无条件 upsert 语义。
SessionID string `gorm:"size:128;index" json:"-"`
SessionStartedAtMs int64 `gorm:"not null;default:0" json:"-"`
Sequence int64 `gorm:"not null;default:0" json:"-"`
}
// Favorite 将媒体项标记为给定用户的收藏。
+65 -16
View File
@@ -2,6 +2,7 @@ package repository
import (
"context"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -13,24 +14,72 @@ import (
// upserts on (UserID, MediaID) so resume always reads the latest position.
type HistoryRepository struct{ db *gorm.DB }
// Upsert atomically inserts/updates the resume position in a single statement,
// relying on the uniq_user_history composite unique index. Concurrent progress
// reports for the same (user, media) can no longer double-insert.
// Upsert atomically inserts/updates the resume position in a single statement.
// Callers without playback-session metadata keep the legacy last-write-wins
// semantics (for example an explicit "mark played" action).
func (r *HistoryRepository) Upsert(ctx context.Context, h *model.PlaybackHistory) error {
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "media_id"}},
DoUpdates: clause.Assignments(map[string]any{
"position_ms": h.PositionMs,
// 沿用旧语义:未知时长(0)不覆盖已记录的时长。
"duration_ms": gorm.Expr(
"CASE WHEN ? > 0 THEN ? ELSE playback_histories.duration_ms END",
h.DurationMs, h.DurationMs,
return r.upsert(ctx, h, false)
}
// UpsertProgress writes a progress report only when it is newer than the row
// currently stored. Reports from an older playback session, or an older
// sequence in the same session, are ignored. Reports without session metadata
// fall back to Upsert for compatibility with older clients.
func (r *HistoryRepository) UpsertProgress(ctx context.Context, h *model.PlaybackHistory) error {
h.SessionID = strings.TrimSpace(h.SessionID)
if h.SessionID == "" || h.SessionStartedAtMs <= 0 {
return r.Upsert(ctx, h)
}
return r.upsert(ctx, h, true)
}
func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory, versioned bool) error {
updates := playbackHistoryAssignments(h)
if versioned {
updates["session_id"] = h.SessionID
updates["session_started_at_ms"] = h.SessionStartedAtMs
updates["sequence"] = h.Sequence
}
onConflict := clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "media_id"}},
DoUpdates: clause.Assignments(updates),
}
if versioned {
onConflict.Where = clause.Where{Exprs: []clause.Expression{
clause.Or(
clause.Lt{
Column: clause.Column{Name: "session_started_at_ms"},
Value: h.SessionStartedAtMs,
},
clause.And(
clause.Eq{
Column: clause.Column{Name: "session_started_at_ms"},
Value: h.SessionStartedAtMs,
},
clause.Lte{
Column: clause.Column{Name: "sequence"},
Value: h.Sequence,
},
),
),
"watched_at": h.WatchedAt,
"completed": h.Completed,
"deleted_at": nil,
}),
}).Create(h).Error
}}
}
return r.db.WithContext(ctx).Clauses(onConflict).Create(h).Error
}
func playbackHistoryAssignments(h *model.PlaybackHistory) map[string]any {
return map[string]any{
"position_ms": h.PositionMs,
// 沿用旧语义:未知时长(0)不覆盖已记录的时长。
"duration_ms": gorm.Expr(
"CASE WHEN ? > 0 THEN ? ELSE playback_histories.duration_ms END",
h.DurationMs, h.DurationMs,
),
"watched_at": h.WatchedAt,
"completed": h.Completed,
"deleted_at": nil,
}
}
// ListByUser returns the most recent history rows for the user.
@@ -77,3 +77,105 @@ func TestHistoryUpsertKeepsDurationWhenUnknown(t *testing.T) {
t.Fatalf("duration_ms=0 upsert must not clear stored duration, got %d", got.DurationMs)
}
}
func TestHistoryUpsertProgressRejectsStaleReports(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := New(db)
ctx := t.Context()
watched := time.Now()
first := &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: watched,
SessionID: "session-1",
SessionStartedAtMs: 1_000,
Sequence: 1,
}
if err := repos.History.UpsertProgress(ctx, first); err != nil {
t.Fatalf("first progress: %v", err)
}
newer := &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 90_000,
DurationMs: 120_000,
WatchedAt: watched.Add(time.Minute),
Completed: true,
SessionID: "session-1",
SessionStartedAtMs: 1_000,
Sequence: 2,
}
if err := repos.History.UpsertProgress(ctx, newer); err != nil {
t.Fatalf("newer progress: %v", err)
}
stale := &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 10_000,
DurationMs: 120_000,
WatchedAt: watched.Add(2 * time.Minute),
SessionID: "session-1",
SessionStartedAtMs: 1_000,
Sequence: 1,
}
if err := repos.History.UpsertProgress(ctx, stale); err != nil {
t.Fatalf("stale progress: %v", err)
}
var got model.PlaybackHistory
if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil {
t.Fatal(err)
}
if got.PositionMs != 90_000 || !got.Completed || got.Sequence != 2 {
t.Fatalf("stale report overwrote newer state: %#v", got)
}
oldSession := &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 5_000,
DurationMs: 120_000,
WatchedAt: watched.Add(3 * time.Minute),
SessionID: "session-0",
SessionStartedAtMs: 500,
Sequence: 99,
}
if err := repos.History.UpsertProgress(ctx, oldSession); err != nil {
t.Fatalf("old session progress: %v", err)
}
if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil {
t.Fatal(err)
}
if got.PositionMs != 90_000 || !got.Completed {
t.Fatalf("old session overwrote newer state: %#v", got)
}
restart := &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 1_000,
DurationMs: 120_000,
WatchedAt: watched.Add(4 * time.Minute),
SessionID: "session-2",
SessionStartedAtMs: 2_000,
Sequence: 1,
}
if err := repos.History.UpsertProgress(ctx, restart); err != nil {
t.Fatalf("restart progress: %v", err)
}
if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil {
t.Fatal(err)
}
if got.PositionMs != 1_000 || got.Completed || got.SessionID != "session-2" {
t.Fatalf("new playback session did not reset state: %#v", got)
}
}
+20
View File
@@ -100,6 +100,26 @@ func (r *MediaRepository) ListByLibrariesFilteredNoCount(ctx context.Context, li
return items, err
}
// ListAllByLibrariesFilteredNoCount loads every matching row without issuing a
// COUNT query. Full-library consumers such as series grouping must scan the
// whole library anyway, so calling it once is both cheaper and more consistent
// than issuing paginated queries with repeated counts.
func (r *MediaRepository) ListAllByLibrariesFilteredNoCount(ctx context.Context, libraryIDs []string, filter MediaQueryFilter) ([]model.Media, error) {
items := make([]model.Media, 0)
if len(libraryIDs) == 0 {
return items, nil
}
q := r.db.WithContext(ctx).Model(&model.Media{})
if len(libraryIDs) == 1 {
q = q.Where("library_id = ?", libraryIDs[0])
} else {
q = q.Where("library_id IN ?", libraryIDs)
}
q = applyMediaQueryFilter(q, filter)
err := q.Order("release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC").Find(&items).Error
return items, err
}
func (r *MediaRepository) listByLibrariesFiltered(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter, withCount bool) ([]model.Media, int64, error) {
var items []model.Media
var total int64
+2 -2
View File
@@ -37,7 +37,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
}
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().Unix())
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().UnixMilli())
return out, nil
}
m, err := e.playableMedia(ctx, mediaID, userID)
@@ -46,7 +46,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
}
return map[string]any{
"MediaSources": e.mediaSourcesForItem(ctx, m, false, e.directPlayOnly(ctx)),
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()),
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().UnixMilli()),
}, nil
}
+42 -8
View File
@@ -63,7 +63,15 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
}
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
// 不携带 PlaySessionId 的旧调用仍保持兼容。
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
return e.RecordProgressWithSession(ctx, userID, mediaID, positionTicks, runtimeTicks, "")
}
// RecordProgressWithSession records an Emby progress update together with its
// PlaySessionId. The server-issued ID contains a millisecond timestamp, which
// lets the repository reject reports from an older playback session.
func (e *EmbyService) RecordProgressWithSession(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64, playSessionID string) error {
pos := positionTicks / 10_000
dur := runtimeTicks / 10_000
if dur <= 0 {
@@ -89,14 +97,16 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
}
}
}
completed := dur > 0 && pos >= dur*9/10
err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: pos,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: completed,
playSessionID = strings.TrimSpace(playSessionID)
err := e.repo.History.UpsertProgress(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: pos,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: playbackProgressCompleted(pos, dur),
SessionID: playSessionID,
SessionStartedAtMs: embyPlaySessionStartedAtMs(playSessionID),
})
if err == nil {
e.invalidateEmbyItemsCache(ctx)
@@ -104,6 +114,30 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
return err
}
// embyPlaySessionStartedAtMs extracts the millisecond timestamp embedded in a
// MeBox-issued PlaySessionId. Older IDs used seconds, so normalize those too.
func embyPlaySessionStartedAtMs(playSessionID string) int64 {
playSessionID = strings.TrimSpace(playSessionID)
if playSessionID == "" {
return 0
}
idx := strings.LastIndex(playSessionID, "-")
if idx < 0 || idx == len(playSessionID)-1 {
return 0
}
value, err := strconv.ParseInt(playSessionID[idx+1:], 10, 64)
if err != nil || value <= 0 {
return 0
}
if value >= 1_000_000_000 && value < 1_000_000_000_000 {
value *= 1000
}
if value < 1_000_000_000_000 {
return 0
}
return value
}
// mergeRemoteUserData applies the current MeBox user's locally recorded playback
// and favourite state to remote Emby payloads.
func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
+9 -5
View File
@@ -35,7 +35,7 @@ func (s *MediaService) mediaListCacheKey(libraryID string, libraryIDs []string,
return "media:list:" + hex.EncodeToString(sum[:])
}
func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLimit int, filter repository.MediaQueryFilter) string {
func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLimit int, filter repository.MediaQueryFilter, includeCounts bool) string {
libIDs := make([]string, len(libraries))
for i, lib := range libraries {
libIDs[i] = lib.ID
@@ -48,7 +48,7 @@ func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLim
sum := sha1.Sum([]byte(strings.Join([]string{
"preview",
strings.Join(libIDs, ","),
fmt.Sprintf("%d:%t", cardLimit, filter.IncludeNSFW),
fmt.Sprintf("%d:%t:%t", cardLimit, filter.IncludeNSFW, includeCounts),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
}, "|")))
@@ -87,15 +87,19 @@ func hashObjectCacheKey(parts []string) string {
return hex.EncodeToString(sum[:])
}
// mediaGroupedRowsCacheKey 版本分组源行(已挂库元数据)的对象缓存。
func (s *MediaService) mediaGroupedRowsCacheKey(libraryID string, libraryIDs []string, filter repository.MediaQueryFilter) string {
// groupedItemsCacheKey caches the fully grouped version list. Version grouping
// requires scanning every row in the library, so recomputing it for each page
// request makes pagination O(pages × library size) instead of O(library size).
// Only the grouped result is cached; caching the ungrouped rows as well would
// duplicate large libraries in the process memory for no additional hit path.
func (s *MediaService) groupedItemsCacheKey(libraryID string, libraryIDs []string, filter repository.MediaQueryFilter) string {
libs := append([]string(nil), libraryIDs...)
sort.Strings(libs)
allowed := append([]string(nil), filter.AllowedLibraryIDs...)
hidden := append([]string(nil), filter.HiddenLibraryIDs...)
sort.Strings(allowed)
sort.Strings(hidden)
return "media:obj:grouped-rows:" + hashObjectCacheKey([]string{
return "media:obj:grouped-items:" + hashObjectCacheKey([]string{
libraryID,
strings.Join(libs, ","),
fmt.Sprintf("%t", filter.IncludeNSFW),
+51 -23
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"sort"
"strings"
"github.com/truewhile/MeBox/internal/model"
@@ -12,14 +13,12 @@ func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model.
if s == nil || s.repo == nil || s.repo.Library == nil || len(items) == 0 {
return
}
libs, err := s.repo.Library.List(ctx)
libs, err := s.displayLibraries(ctx)
if err != nil {
return
}
byID := make(map[string]model.Library, len(libs))
for i := range libs {
libs[i] = normalizeLocalLibraryPathForDisplay(libs[i])
lib := libs[i]
for _, lib := range libs {
byID[lib.ID] = lib
}
resolver := newMediaDisplayLibraryResolver(ctx, s.repo, libs)
@@ -44,11 +43,39 @@ func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model.
}
}
func (s *MediaService) displayLibraries(ctx context.Context) ([]model.Library, error) {
const cacheKey = "media:obj:library-metadata"
if s.cache != nil {
if cachedObj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := cachedObj.([]model.Library); ok {
return cached, nil
}
}
}
libs, err := s.repo.Library.List(ctx)
if err != nil {
return nil, err
}
for i := range libs {
libs[i] = normalizeLocalLibraryPathForDisplay(libs[i])
}
if s.cache != nil {
s.cache.SetObject(cacheKey, libs, s.mediaObjectTTL())
}
return libs, nil
}
type mediaDisplayLibraryResolver struct {
byID map[string]model.Library
displayByID map[string]model.Library
displayByMergeKey map[string]model.Library
displayLibraries []model.Library
localDisplays []mediaLocalDisplayLibrary
}
type mediaLocalDisplayLibrary struct {
library model.Library
path string
}
func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Container, libs []model.Library) mediaDisplayLibraryResolver {
@@ -58,6 +85,7 @@ func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Contai
displayByID: make(map[string]model.Library, len(displayLibraries)),
displayByMergeKey: make(map[string]model.Library, len(displayLibraries)),
displayLibraries: displayLibraries,
localDisplays: make([]mediaLocalDisplayLibrary, 0, len(displayLibraries)),
}
for _, lib := range libs {
normalized := normalizeLocalLibraryPathForDisplay(lib)
@@ -70,7 +98,23 @@ func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Contai
resolver.displayByMergeKey[key] = lib
}
}
if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled {
continue
}
displayPath := cleanPathForVolumeMapping(resolveMappedDestinationPath(lib.Path))
if displayPath == "" || displayPath == "." {
continue
}
resolver.localDisplays = append(resolver.localDisplays, mediaLocalDisplayLibrary{
library: lib,
path: displayPath,
})
}
// 最长路径优先:一次命中就是原逻辑中最具体的媒体库,避免每条媒体都
// 重新规范化全部库路径并扫描整个库列表。
sort.SliceStable(resolver.localDisplays, func(i, j int) bool {
return len(resolver.localDisplays[i].path) > len(resolver.localDisplays[j].path)
})
return resolver
}
@@ -172,26 +216,10 @@ func (r mediaDisplayLibraryResolver) bestPathDisplayLibrary(media model.Media) (
if isRelativeVolumeMarkerPath(media.Path) {
mediaPath = cleanPathForVolumeMapping(resolveMappedDestinationPath(media.Path))
}
var best model.Library
bestLen := 0
for _, lib := range r.displayLibraries {
if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled {
continue
for _, display := range r.localDisplays {
if mediaPath == display.path || strings.HasPrefix(mediaPath, strings.TrimRight(display.path, "/")+"/") {
return display.library, true
}
libPath := cleanPathForVolumeMapping(resolveMappedDestinationPath(lib.Path))
if libPath == "" || libPath == "." {
continue
}
if mediaPath != libPath && !strings.HasPrefix(mediaPath, strings.TrimRight(libPath, "/")+"/") {
continue
}
if len(libPath) > bestLen {
best = lib
bestLen = len(libPath)
}
}
if bestLen > 0 {
return best, true
}
return model.Library{}, false
}
@@ -0,0 +1,43 @@
package service
import (
"path/filepath"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestMediaDisplayLibraryResolverUsesMostSpecificPath(t *testing.T) {
root := t.TempDir()
parentPath := filepath.Join(root, "media")
nestedPath := filepath.Join(parentPath, "anime")
parent := model.Library{
Base: model.Base{ID: "parent"},
Name: "媒体",
Path: parentPath,
Enabled: true,
}
nested := model.Library{
Base: model.Base{ID: "nested"},
Name: "动漫",
Path: nestedPath,
Enabled: true,
}
resolver := newMediaDisplayLibraryResolver(t.Context(), nil, []model.Library{parent, nested})
got, ok := resolver.DisplayLibraryForMedia(model.Media{
LibraryID: parent.ID,
Path: filepath.Join(nestedPath, "Show", "S01E01.mkv"),
})
if !ok || got.ID != nested.ID {
t.Fatalf("display library = %q, %t; want nested library", got.ID, ok)
}
got, ok = resolver.DisplayLibraryForMedia(model.Media{
LibraryID: parent.ID,
Path: filepath.Join(parentPath, "Movie", "Movie.mkv"),
})
if !ok || got.ID != parent.ID {
t.Fatalf("display library = %q, %t; want parent library", got.ID, ok)
}
}
+37 -35
View File
@@ -26,6 +26,17 @@ func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, erro
// 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)
}
// ListLibraryPreviews returns only the latest preview cards. The metadata
// endpoint already returns totals, so preview batches used by the home and
// library pages can skip an otherwise repeated COUNT(*) over every library.
func (s *MediaService) ListLibraryPreviews(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int) ([]LibraryPreviewItem, error) {
return s.listLibrariesWithPreview(ctx, libraries, visibility, cardLimit, false)
}
func (s *MediaService) listLibrariesWithPreview(ctx context.Context, libraries []model.Library, visibility MediaVisibility, cardLimit int, includeCounts bool) ([]LibraryPreviewItem, error) {
if cardLimit <= 0 {
cardLimit = 10
}
@@ -40,7 +51,7 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
cacheKey := s.libraryPreviewCacheKey(libraries, cardLimit, filter)
cacheKey := s.libraryPreviewCacheKey(libraries, cardLimit, filter, includeCounts)
var cached libraryPreviewCacheValue
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) {
return cached.Items, nil
@@ -56,22 +67,26 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
libIDs = append(libIDs, lib.ID)
}
counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter)
if err != nil {
return nil, err
}
for i := range out {
if total, ok := counts[out[i].ID]; ok {
out[i].Total = total
if includeCounts {
counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter)
if err != nil {
return nil, err
}
for i := range out {
if total, ok := counts[out[i].ID]; ok {
out[i].Total = total
}
}
}
fetchCount := cardLimit * 4
if fetchCount < 60 {
fetchCount = 60
} else if fetchCount > 200 {
fetchCount = 200
// Preview rows are only an internal candidate window. Keep it bounded so a
// library with a very long series cannot turn a homepage request into a full
// 50k-row scan merely to find another distinct card.
fetchCount := cardLimit * 12
if fetchCount < 120 {
fetchCount = 120
} else if fetchCount > 400 {
fetchCount = 400
}
recentByLibrary, err := s.repo.Media.ListRecentByLibraries(ctx, libIDs, fetchCount, filter)
@@ -81,9 +96,6 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
allPreviewItems := make([]model.Media, 0, len(libIDs)*fetchCount)
for i := range out {
if out[i].Total == 0 {
continue
}
items := recentByLibrary[out[i].ID]
if len(items) == 0 {
continue
@@ -93,28 +105,18 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [
s.attachLibraryMetadata(ctx, allPreviewItems)
for i := range out {
if out[i].Total == 0 {
continue
}
items := recentByLibrary[out[i].ID]
if len(items) == 0 {
continue
}
cards := groupMediaSeriesCards(items)
// 如果折叠后的作品部数不足 cardLimit,且该库总记录数大于当前提取的条目数,
// 说明多集剧集折叠占满了提取窗口,调用 ListLibrarySeriesCards 补齐完整的影视部数。
if len(cards) < cardLimit && out[i].Total > int64(len(items)) {
if fullCards, _, err := s.ListLibrarySeriesCards(ctx, out[i].ID, visibility); err == nil && len(fullCards) > 0 {
cards = fullCards
}
}
if len(cards) > cardLimit {
cards = cards[:cardLimit]
}
if cards == nil {
cards = []SeriesCard{}
}
out[i].Cards = cards
cards := groupMediaSeriesCards(items)
if len(cards) > cardLimit {
cards = cards[:cardLimit]
}
if cards == nil {
cards = []SeriesCard{}
}
out[i].Cards = cards
}
if s.cache != nil {
+25
View File
@@ -155,3 +155,28 @@ func TestListLibrariesWithPreviewLongSeriesCompletion(t *testing.T) {
t.Fatalf("previews[0].Cards count = %d, want 2", len(previews[0].Cards))
}
}
func TestListLibraryPreviewsSkipsTotals(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)
}
if err := repos.DB.Create(&model.Media{
LibraryID: lib.ID,
Title: "预览电影",
Path: "/media/movies/预览电影.mkv",
}).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
previews, err := svc.ListLibraryPreviews(t.Context(), []model.Library{lib}, MediaVisibility{IncludeNSFW: true}, 10)
if err != nil {
t.Fatal(err)
}
if len(previews) != 1 || previews[0].Total != 0 || len(previews[0].Cards) != 1 {
t.Fatalf("preview-only result = %#v, want one card and no count", previews)
}
}
+39 -19
View File
@@ -56,14 +56,45 @@ func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, p
func (s *MediaService) ListMediaVisibleGrouped(ctx context.Context, libraryID string, page, pageSize int, visibility MediaVisibility) ([]MediaItem, int64, error) {
page, pageSize = normalizeGroupedMediaPage(page, pageSize)
items, err := s.listMediaVisibleForGrouping(ctx, libraryID, visibility)
grouped, err := s.GroupedMediaVisible(ctx, libraryID, visibility)
if err != nil {
return nil, 0, err
}
grouped := groupMediaVersions(items)
return paginateMediaItems(grouped, page, pageSize), int64(len(grouped)), nil
}
// GroupedMediaVisible returns the complete version-grouped media list before pagination.
// The result is cached as an immutable slice; sort/pagination callers must copy it before mutating.
func (s *MediaService) GroupedMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]MediaItem, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
if err != nil {
return nil, err
}
filter := repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
itemsCacheKey := s.groupedItemsCacheKey(libraryID, libraryIDs, filter)
if s.cache != nil {
if cachedObj, ok := s.cache.GetObject(itemsCacheKey); ok {
if cached, ok := cachedObj.([]MediaItem); ok {
return cached, nil
}
}
}
items, err := s.listMediaVisibleForGrouping(ctx, libraryID, visibility)
if err != nil {
return nil, err
}
grouped := groupMediaVersions(items)
if s.cache != nil && len(grouped) > 0 {
s.cache.SetObject(itemsCacheKey, grouped, s.mediaObjectTTL())
}
return grouped, nil
}
func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
@@ -75,30 +106,19 @@ func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryI
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
cacheKey := s.mediaGroupedRowsCacheKey(libraryID, libraryIDs, filter)
if s.cache != nil {
if cachedObj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := cachedObj.([]model.Media); ok {
// 对象缓存中的切片视为不可变;attachLibraryMetadata 会在填充时
// 执行过,命中路径直接返回副本即可(调用方只读)。
return cached, nil
}
}
}
items, total, err := s.repo.Media.ListByLibrariesFiltered(ctx, libraryIDs, 0, maxMediaSearchLimit, filter)
// 版本分组的 URL 分页发生在 Go 进程内,响应里的 total 是分组后的数量,
// 不需要数据库再为原始行做一次 COUNT(*)。全量 COUNT 在超大媒体库上
// 会重复扫描整个 library_id 范围,而这里只关心是否存在截断风险。
items, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, maxMediaSearchLimit, filter)
if err != nil {
return nil, err
}
if total > int64(len(items)) && s.log != nil {
s.log.Warn("media version grouping truncated by safety limit",
if len(items) >= maxMediaSearchLimit && s.log != nil {
s.log.Warn("media version grouping may be truncated by safety limit",
zap.String("library_id", libraryID),
zap.Int64("total", total),
zap.Int("limit", maxMediaSearchLimit))
}
s.attachLibraryMetadata(ctx, items)
if s.cache != nil {
s.cache.SetObject(cacheKey, items, s.mediaObjectTTL())
}
return items, nil
}
+76 -56
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
type seriesCardsCacheValue struct {
@@ -24,6 +25,7 @@ type libraryRowsCacheValue struct {
Rows []model.Media
Resolver mediaSeriesKeyResolver
Episodes map[string][]model.Media
Cards []SeriesCard
}
type SeriesCard struct {
@@ -41,6 +43,7 @@ type seriesCardGroup struct {
// libraryRowsWithIndex 返回整库行与预计算剧集索引(带对象缓存)。
func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID string, visibility MediaVisibility) (*libraryRowsCacheValue, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
cacheKey := s.libraryRowsCacheKey(libraryID, visibility)
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
@@ -55,16 +58,17 @@ func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID strin
}
// listAllMediaVisible 走 ListMediaVisible,行已带库元数据(resolver 的
// key 计算依赖 DisplayLibraryPath/ID)。
resolver := newMediaSeriesKeyResolver(rows)
resolver, keys := resolveMediaSeriesKeys(rows)
episodes := make(map[string][]model.Media, len(rows)/4+1)
for _, row := range rows {
k := resolver.key(row)
for i, row := range rows {
k := keys[i]
if k == "" {
continue
}
episodes[k] = append(episodes[k], row)
}
value := &libraryRowsCacheValue{Rows: rows, Resolver: resolver, Episodes: episodes}
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())
}
@@ -72,7 +76,6 @@ func (s *MediaService) libraryRowsWithIndex(ctx context.Context, libraryID strin
}
func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID string, visibility MediaVisibility) ([]SeriesCard, int64, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
cacheKey := s.libraryCardsObjectKey(libraryID, visibility)
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
@@ -85,7 +88,12 @@ func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID str
if err != nil {
return nil, 0, err
}
cards := groupMediaSeriesCards(rows.Rows)
cards := rows.Cards
if cards == nil && len(rows.Rows) > 0 {
// Fallback keeps rolling-cache compatibility if an older runtime cache
// value was created before Cards was added.
cards = groupMediaSeriesCards(rows.Rows)
}
if cards == nil {
cards = []SeriesCard{}
}
@@ -190,40 +198,40 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
}
}
// 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影)
if len(out) <= 1 && target.Path != "" {
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
parentBase := filepath.Base(targetDir)
if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) {
targetTitleNorm := normalizeSeriesTitle(target.Title)
targetDirNorm := normalizeSeriesTitle(parentBase)
dirMatches := make([]model.Media, 0)
for _, row := range rows {
if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir {
continue
}
if row.ID == target.ID {
dirMatches = append(dirMatches, row)
continue
}
rowTitleNorm := normalizeSeriesTitle(row.Title)
allowMatch := false
if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) {
allowMatch = true
} else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm {
allowMatch = true
} else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm {
allowMatch = true
}
if allowMatch {
dirMatches = append(dirMatches, row)
}
// 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影)
if len(out) <= 1 && target.Path != "" {
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
parentBase := filepath.Base(targetDir)
if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) {
targetTitleNorm := normalizeSeriesTitle(target.Title)
targetDirNorm := normalizeSeriesTitle(parentBase)
dirMatches := make([]model.Media, 0)
for _, row := range rows {
if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir {
continue
}
if len(dirMatches) > 1 {
out = dirMatches
if row.ID == target.ID {
dirMatches = append(dirMatches, row)
continue
}
rowTitleNorm := normalizeSeriesTitle(row.Title)
allowMatch := false
if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) {
allowMatch = true
} else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm {
allowMatch = true
} else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm {
allowMatch = true
}
if allowMatch {
dirMatches = append(dirMatches, row)
}
}
if len(dirMatches) > 1 {
out = dirMatches
}
}
}
if len(out) == 0 {
out = []model.Media{*target}
@@ -246,35 +254,47 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
}
func (s *MediaService) listAllMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, int64, error) {
const pageSize = 2000
var all []model.Media
var total int64
for page := 1; ; page++ {
rows, n, err := s.ListMediaVisible(ctx, libraryID, page, pageSize, visibility)
if err != nil {
return nil, 0, err
}
if page == 1 {
total = n
all = make([]model.Media, 0, minInt64(n, pageSize))
}
all = append(all, rows...)
if int64(len(all)) >= n || len(rows) < pageSize {
break
}
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
if err != nil {
return nil, 0, err
}
return all, total, nil
filter := repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
}
rows, err := s.repo.Media.ListAllByLibrariesFilteredNoCount(ctx, libraryIDs, filter)
if err != nil {
return nil, 0, err
}
s.attachLibraryMetadata(ctx, rows)
return rows, int64(len(rows)), nil
}
func groupMediaSeriesCards(items []model.Media) []SeriesCard {
if len(items) == 0 {
return nil
}
_, keys := resolveMediaSeriesKeys(items)
return groupMediaSeriesCardsByKeys(items, keys)
}
// groupMediaSeriesCardsByKeys performs the card fold using already-resolved
// keys. Full-library caches need the same keys for the episode index and the
// series-card list; resolving them once avoids several regex-heavy passes over
// every episode row.
func groupMediaSeriesCardsByKeys(items []model.Media, keys []string) []SeriesCard {
if len(items) == 0 {
return nil
}
if len(keys) != len(items) {
return groupMediaSeriesCards(items)
}
groups := make([]seriesCardGroup, 0)
byKey := make(map[string]int, len(items))
resolver := newMediaSeriesKeyResolver(items)
for _, item := range items {
key := resolver.key(item)
for i, item := range items {
key := keys[i]
if key == "" {
continue
}
+84 -36
View File
@@ -14,75 +14,123 @@ type mediaSeriesKeyResolver struct {
pathTitles map[string]string
}
// mediaSeriesKeyInputs 保存单条媒体解析剧集 key 所需的派生字段。剧集卡片、
// 剧集索引和分页分组需要反复读取同一批行;提前计算一次可以避免对每行执行
// 多轮正则匹配、路径解析和标题规范化。
type mediaSeriesKeyInputs struct {
episodic bool
pathKey string
externalKey string
titleKey string
}
func analyzeMediaSeriesKey(item model.Media) mediaSeriesKeyInputs {
if !mediaLooksEpisodicForGrouping(item) {
return mediaSeriesKeyInputs{}
}
return mediaSeriesKeyInputs{
episodic: true,
pathKey: mediaSeriesRawKey(item),
externalKey: repeatedSeriesExternalKey(item),
titleKey: repeatedSeriesTitleKey(item),
}
}
func analyzeMediaSeriesKeys(items []model.Media) []mediaSeriesKeyInputs {
inputs := make([]mediaSeriesKeyInputs, len(items))
for i := range items {
inputs[i] = analyzeMediaSeriesKey(items[i])
}
return inputs
}
func newMediaSeriesKeyResolver(items []model.Media) mediaSeriesKeyResolver {
return newMediaSeriesKeyResolverFromInputs(analyzeMediaSeriesKeys(items))
}
func newMediaSeriesKeyResolverFromInputs(inputs []mediaSeriesKeyInputs) mediaSeriesKeyResolver {
resolver := mediaSeriesKeyResolver{
pathCounts: make(map[string]int),
externalCounts: make(map[string]int),
titleCounts: make(map[string]int),
pathTitles: make(map[string]string),
}
for _, item := range items {
if !mediaLooksEpisodicForGrouping(item) {
pathTitleCandidates := make(map[string]map[string]struct{})
for _, input := range inputs {
if !input.episodic {
continue
}
if key := mediaSeriesRawKey(item); strings.HasPrefix(key, "library-path") {
resolver.pathCounts[key]++
if strings.HasPrefix(input.pathKey, "library-path") {
resolver.pathCounts[input.pathKey]++
if input.titleKey != "" {
if pathTitleCandidates[input.pathKey] == nil {
pathTitleCandidates[input.pathKey] = make(map[string]struct{})
}
pathTitleCandidates[input.pathKey][input.titleKey] = struct{}{}
}
}
if key := repeatedSeriesExternalKey(item); key != "" {
resolver.externalCounts[key]++
if input.externalKey != "" {
resolver.externalCounts[input.externalKey]++
}
if key := repeatedSeriesTitleKey(item); key != "" {
resolver.titleCounts[key]++
if input.titleKey != "" {
resolver.titleCounts[input.titleKey]++
}
}
// A scraper can normalize the same show to one title while the source
// release folders still contain different tags (1080p/2160p, uploader
// names, etc.). Remember an unambiguous title alias for each path group so
// those folders are bridged instead of rendered as separate collections.
pathTitleCandidates := make(map[string]map[string]struct{})
for _, item := range items {
if !mediaLooksEpisodicForGrouping(item) {
continue
}
pathKey := mediaSeriesRawKey(item)
titleKey := repeatedSeriesTitleKey(item)
if !strings.HasPrefix(pathKey, "library-path") || titleKey == "" || resolver.titleCounts[titleKey] < 2 {
continue
}
if pathTitleCandidates[pathKey] == nil {
pathTitleCandidates[pathKey] = make(map[string]struct{})
}
pathTitleCandidates[pathKey][titleKey] = struct{}{}
}
for pathKey, candidates := range pathTitleCandidates {
if len(candidates) != 1 {
continue
}
repeatedTitle := ""
repeatedCount := 0
for titleKey := range candidates {
resolver.pathTitles[pathKey] = titleKey
if resolver.titleCounts[titleKey] < 2 {
continue
}
repeatedTitle = titleKey
repeatedCount++
}
if repeatedCount == 1 {
resolver.pathTitles[pathKey] = repeatedTitle
}
}
return resolver
}
// resolveMediaSeriesKeys 对输入只做一轮派生字段计算,返回可供后续 O(1) 解析的
// resolver 以及每条记录对应的 key。这是整库剧集加载的快速路径。
func resolveMediaSeriesKeys(items []model.Media) (mediaSeriesKeyResolver, []string) {
inputs := analyzeMediaSeriesKeys(items)
resolver := newMediaSeriesKeyResolverFromInputs(inputs)
keys := make([]string, len(items))
for i := range items {
keys[i] = resolver.keyFromInputs(items[i], inputs[i])
}
return resolver, keys
}
func (r mediaSeriesKeyResolver) key(media model.Media) string {
if mediaLooksEpisodicForGrouping(media) {
if pathKey := mediaSeriesRawKey(media); strings.HasPrefix(pathKey, "library-path") {
if titleKey := r.pathTitles[pathKey]; titleKey != "" {
return r.keyFromInputs(media, analyzeMediaSeriesKey(media))
}
func (r mediaSeriesKeyResolver) keyFromInputs(media model.Media, input mediaSeriesKeyInputs) string {
if input.episodic {
if strings.HasPrefix(input.pathKey, "library-path") {
if titleKey := r.pathTitles[input.pathKey]; titleKey != "" {
return compactSeriesKey(titleKey)
}
// A series directory is the strongest identity for mixed rows:
// main episodes and specials (CM/NCOP/PV/OVA) may be scraped to
// slightly different titles, but they still belong to one show.
if r.pathCounts[pathKey] > 1 {
return compactSeriesKey(pathKey)
if r.pathCounts[input.pathKey] > 1 {
return compactSeriesKey(input.pathKey)
}
}
if key := repeatedSeriesTitleKey(media); key != "" && r.titleCounts[key] > 1 {
return compactSeriesKey(key)
if input.titleKey != "" && r.titleCounts[input.titleKey] > 1 {
return compactSeriesKey(input.titleKey)
}
if key := repeatedSeriesExternalKey(media); key != "" && r.externalCounts[key] > 1 {
return compactSeriesKey(key)
if input.externalKey != "" && r.externalCounts[input.externalKey] > 1 {
return compactSeriesKey(input.externalKey)
}
}
return mediaSeriesKey(media)
+44
View File
@@ -593,3 +593,47 @@ func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
}
}
func TestListLibrarySeriesCardsCachesPrecomputedCards(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)
}
rows := []model.Media{
{LibraryID: lib.ID, Title: "示例动画", Path: "/media/anime/示例动画/S01E01.mkv", SeasonNum: 1, EpisodeNum: 1},
{LibraryID: lib.ID, Title: "示例动画", Path: "/media/anime/示例动画/S01E02.mkv", SeasonNum: 1, EpisodeNum: 2},
}
if err := repos.DB.Create(&rows).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
visibility := MediaVisibility{IncludeNSFW: true}
cards, total, err := svc.ListLibrarySeriesCards(t.Context(), lib.ID, visibility)
if err != nil {
t.Fatal(err)
}
if total != 1 || len(cards) != 1 || cards[0].Count != 2 {
t.Fatalf("cold series cards = %#v, total = %d; want one two-episode card", cards, total)
}
cachedObj, ok := svc.cache.GetObject(svc.libraryRowsCacheKey(lib.ID, visibility))
if !ok {
t.Fatal("expected full library rows and precomputed cards to be cached")
}
cached, ok := cachedObj.(*libraryRowsCacheValue)
if !ok || cached.Cards == nil || len(cached.Cards) != 1 {
t.Fatalf("cached rows value = %#v, want one precomputed card", cachedObj)
}
cards, total, err = svc.ListLibrarySeriesCards(t.Context(), lib.ID, visibility)
if err != nil {
t.Fatal(err)
}
if total != 1 || len(cards) != 1 || cards[0].Count != 2 {
t.Fatalf("warm series cards = %#v, total = %d; want cached result", cards, total)
}
}
+256
View File
@@ -0,0 +1,256 @@
package service
import (
"sort"
"strings"
"time"
"github.com/truewhile/MeBox/internal/model"
)
// MediaSortSpec is the normalized sort request shared by media and series endpoints.
type MediaSortSpec struct {
Field string
Order string
}
// NormalizeMediaSort validates the request against the same fields the web UI
// exposes. Unknown values fall back to the historical release-date ordering.
func NormalizeMediaSort(field, order string) MediaSortSpec {
field = strings.ToLower(strings.TrimSpace(field))
switch field {
case "title", "release_date", "year", "created_at", "updated_at", "rating", "duration", "bitrate", "last_played", "random":
case "imdb_rating":
field = "rating"
default:
field = "release_date"
}
order = strings.ToLower(strings.TrimSpace(order))
if order != "asc" && order != "desc" {
if field == "title" {
order = "asc"
} else {
order = "desc"
}
}
if field == "random" {
// The web client shuffles the complete result so pagination stays stable.
field = "release_date"
order = "desc"
}
return MediaSortSpec{Field: field, Order: order}
}
// SortMediaItems returns a sorted copy of the grouped media list. The input is
// often backed by an immutable runtime cache, so callers must not rely on the
// returned slice aliasing it.
func SortMediaItems(items []MediaItem, field, order string, history map[string]time.Time) []MediaItem {
spec := NormalizeMediaSort(field, order)
out := append([]MediaItem(nil), items...)
if len(out) < 2 {
return out
}
sort.SliceStable(out, func(i, j int) bool {
cmp := compareMediaByField(out[i], out[j], spec, history)
if cmp == 0 {
cmp = compareID(out[i].ID, out[j].ID)
}
return cmp < 0
})
return out
}
// SortSeriesCards returns a sorted copy of series cards.
func SortSeriesCards(cards []SeriesCard, field, order string, history map[string]time.Time) []SeriesCard {
spec := NormalizeMediaSort(field, order)
out := append([]SeriesCard(nil), cards...)
if len(out) < 2 {
return out
}
sort.SliceStable(out, func(i, j int) bool {
cmp := compareSeriesByField(out[i], out[j], spec, history)
if cmp == 0 {
cmp = compareID(out[i].Key, out[j].Key)
}
return cmp < 0
})
return out
}
func compareMediaByField(a, b MediaItem, spec MediaSortSpec, history map[string]time.Time) int {
switch spec.Field {
case "title":
ta, tb := mediaSortTitle(a.Media), mediaSortTitle(b.Media)
return compareOrdered(strings.Compare(strings.ToLower(strings.TrimSpace(ta)), strings.ToLower(strings.TrimSpace(tb))), strings.TrimSpace(ta) != "", strings.TrimSpace(tb) != "", spec.Order)
case "release_date":
ta, oka := mediaReleaseTime(a.Media)
tb, okb := mediaReleaseTime(b.Media)
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
case "year":
return compareOrdered(compareInt(a.Year, b.Year), true, true, spec.Order)
case "created_at":
return compareOrdered(compareTime(a.CreatedAt, b.CreatedAt), !a.CreatedAt.IsZero(), !b.CreatedAt.IsZero(), spec.Order)
case "updated_at":
ta, oka := mediaUpdatedTime(a.Media)
tb, okb := mediaUpdatedTime(b.Media)
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
case "rating":
return compareOrdered(compareFloat(float64(a.Rating), float64(b.Rating)), true, true, spec.Order)
case "duration":
return compareOrdered(compareInt(a.DurationSec, b.DurationSec), true, true, spec.Order)
case "bitrate":
return compareOrdered(compareInt64(a.SizeBytes, b.SizeBytes), true, true, spec.Order)
case "last_played":
ta, oka := mediaItemLastPlayedTime(a, history)
tb, okb := mediaItemLastPlayedTime(b, history)
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
default:
return 0
}
}
func compareSeriesByField(a, b SeriesCard, spec MediaSortSpec, history map[string]time.Time) int {
switch spec.Field {
case "title":
return compareOrdered(strings.Compare(strings.ToLower(strings.TrimSpace(mediaSortTitle(a.Rep))), strings.ToLower(strings.TrimSpace(mediaSortTitle(b.Rep)))), strings.TrimSpace(mediaSortTitle(a.Rep)) != "", strings.TrimSpace(mediaSortTitle(b.Rep)) != "", spec.Order)
case "release_date":
ta, oka := mediaReleaseTime(a.Rep)
tb, okb := mediaReleaseTime(b.Rep)
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
case "year":
return compareOrdered(compareInt(a.Rep.Year, b.Rep.Year), true, true, spec.Order)
case "created_at":
return compareOrdered(compareTime(a.Rep.CreatedAt, b.Rep.CreatedAt), !a.Rep.CreatedAt.IsZero(), !b.Rep.CreatedAt.IsZero(), spec.Order)
case "updated_at":
ta, oka := seriesUpdatedTime(a)
tb, okb := seriesUpdatedTime(b)
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
case "rating":
return compareOrdered(compareFloat(float64(a.Rep.Rating), float64(b.Rep.Rating)), true, true, spec.Order)
case "duration":
return compareOrdered(compareInt(a.Rep.DurationSec, b.Rep.DurationSec), true, true, spec.Order)
case "bitrate":
return compareOrdered(compareInt64(a.Rep.SizeBytes, b.Rep.SizeBytes), true, true, spec.Order)
case "last_played":
ta, oka := seriesLastPlayed(a, history)
tb, okb := seriesLastPlayed(b, history)
return compareOrdered(compareTime(ta, tb), oka, okb, spec.Order)
default:
return 0
}
}
func mediaSortTitle(m model.Media) string {
if strings.TrimSpace(m.Title) != "" {
return m.Title
}
return m.OriginalName
}
func mediaReleaseTime(m model.Media) (time.Time, bool) {
if raw := strings.TrimSpace(m.ReleaseDate); raw != "" {
if parsed, err := time.Parse("2006-01-02", raw); err == nil {
return parsed, true
}
}
if m.Year > 0 {
return time.Date(m.Year, time.January, 1, 0, 0, 0, 0, time.UTC), true
}
return time.Time{}, false
}
func mediaUpdatedTime(m model.Media) (time.Time, bool) {
if !m.UpdatedAt.IsZero() {
return m.UpdatedAt, true
}
if !m.CreatedAt.IsZero() {
return m.CreatedAt, true
}
return time.Time{}, false
}
func seriesUpdatedTime(card SeriesCard) (time.Time, bool) {
if card.LastAddedAt != nil && !card.LastAddedAt.IsZero() {
return *card.LastAddedAt, true
}
return mediaUpdatedTime(card.Rep)
}
func seriesLastPlayed(card SeriesCard, history map[string]time.Time) (time.Time, bool) {
for _, id := range []string{card.Rep.ID, card.LinkMedia.ID} {
if played, ok := history[id]; ok {
return played, true
}
}
return time.Time{}, false
}
func mediaItemLastPlayedTime(item MediaItem, history map[string]time.Time) (time.Time, bool) {
best, found := history[item.ID]
for _, version := range item.Versions {
if played, ok := history[version.ID]; ok && (!found || played.After(best)) {
best = played
found = true
}
}
return best, found
}
func compareOrdered(cmp int, aValid, bValid bool, order string) int {
if !aValid && !bValid {
return 0
}
if !aValid {
return 1
}
if !bValid {
return -1
}
if order == "desc" {
return -cmp
}
return cmp
}
func compareTime(a, b time.Time) int {
if a.Before(b) {
return -1
}
if a.After(b) {
return 1
}
return 0
}
func compareInt(a, b int) int {
if a < b {
return -1
}
if a > b {
return 1
}
return 0
}
func compareInt64(a, b int64) int {
if a < b {
return -1
}
if a > b {
return 1
}
return 0
}
func compareFloat(a, b float64) int {
if a < b {
return -1
}
if a > b {
return 1
}
return 0
}
func compareID(a, b string) int {
return strings.Compare(a, b)
}
+42
View File
@@ -0,0 +1,42 @@
package service
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestSortMediaItemsTitleAscEmptyLast(t *testing.T) {
items := []MediaItem{
{Media: model.Media{Base: model.Base{ID: "b"}, Title: "Beta"}},
{Media: model.Media{Base: model.Base{ID: "empty"}}},
{Media: model.Media{Base: model.Base{ID: "a"}, Title: "alpha"}},
}
got := SortMediaItems(items, "title", "asc", nil)
if got[0].ID != "a" || got[1].ID != "b" || got[2].ID != "empty" {
t.Fatalf("title order = %q, %q, %q", got[0].ID, got[1].ID, got[2].ID)
}
}
func TestSortMediaItemsReleaseDescEmptyLast(t *testing.T) {
items := []MediaItem{
{Media: model.Media{Base: model.Base{ID: "old"}, ReleaseDate: "2020-01-01"}},
{Media: model.Media{Base: model.Base{ID: "empty"}}},
{Media: model.Media{Base: model.Base{ID: "new"}, ReleaseDate: "2024-01-01"}},
}
got := SortMediaItems(items, "release_date", "desc", nil)
if got[0].ID != "new" || got[1].ID != "old" || got[2].ID != "empty" {
t.Fatalf("release order = %q, %q, %q", got[0].ID, got[1].ID, got[2].ID)
}
}
func TestSortSeriesCardsTitleAsc(t *testing.T) {
cards := []SeriesCard{
{Key: "b", Rep: model.Media{Base: model.Base{ID: "b"}, Title: "Beta"}},
{Key: "a", Rep: model.Media{Base: model.Base{ID: "a"}, Title: "alpha"}},
}
got := SortSeriesCards(cards, "title", "asc", nil)
if got[0].Key != "a" || got[1].Key != "b" {
t.Fatalf("series order = %q, %q", got[0].Key, got[1].Key)
}
}
+13 -1
View File
@@ -281,7 +281,8 @@ func TestListMediaVisibleGroupedPaginatesAfterVersionGrouping(t *testing.T) {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
page, total, err := svc.ListMediaVisibleGrouped(t.Context(), lib.ID, 1, 1, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
@@ -295,6 +296,17 @@ func TestListMediaVisibleGroupedPaginatesAfterVersionGrouping(t *testing.T) {
if page[0].Media.Path != rows[0].Path {
t.Fatalf("primary version = %q, want %q", page[0].Media.Path, rows[0].Path)
}
cacheKey := svc.groupedItemsCacheKey(lib.ID, []string{lib.ID}, repository.MediaQueryFilter{IncludeNSFW: true})
if _, ok := svc.cache.GetObject(cacheKey); !ok {
t.Fatal("expected grouped media items to be cached after the first page request")
}
secondPage, secondTotal, err := svc.ListMediaVisibleGrouped(t.Context(), lib.ID, 2, 1, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if secondTotal != total || len(secondPage) != 1 || secondPage[0].Media.Path != rows[2].Path {
t.Fatalf("cached second page = %#v, total = %d; want Matrix item and total %d", secondPage, secondTotal, total)
}
}
func TestSearchMediaVisiblePageGroupedPaginatesAfterVersionGrouping(t *testing.T) {
+51 -13
View File
@@ -41,24 +41,62 @@ func (p *PlaybackService) SetEmbyRemote(remote *EmbyRemoteService) *PlaybackServ
// ─── History ────────────────────────────────────────────────────────────────
// RecordProgress upserts the resume position for a (user, media) pair. A
// position within 30 seconds of the duration auto-flags the item as
// completed so the home page can hide it from "Continue Watching".
// ProgressUpdate is a playback-progress report. SessionID, SessionStartedAtMs
// and Sequence are optional for compatibility with legacy callers.
type ProgressUpdate struct {
UserID string
MediaID string
PositionMs int64
DurationMs int64
SessionID string
SessionStartedAtMs int64
Sequence int64
}
// RecordProgress records an unversioned progress report. It is retained for
// older callers that do not provide playback-session metadata.
func (p *PlaybackService) RecordProgress(ctx context.Context, userID, mediaID string, position, duration int64) error {
if userID == "" || mediaID == "" {
return errors.New("missing user or media")
}
dur := p.resolvePlaybackDuration(ctx, userID, mediaID, duration)
completed := dur > 0 && position >= dur-30_000
h := &model.PlaybackHistory{
return p.RecordProgressUpdate(ctx, ProgressUpdate{
UserID: userID,
MediaID: mediaID,
PositionMs: position,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: completed,
DurationMs: duration,
})
}
// RecordProgressUpdate upserts the resume position for a (user, media) pair.
// When session metadata is present, stale reports are ignored so a delayed
// request cannot overwrite a newer position or completion state.
func (p *PlaybackService) RecordProgressUpdate(ctx context.Context, update ProgressUpdate) error {
if update.UserID == "" || update.MediaID == "" {
return errors.New("missing user or media")
}
return p.repo.History.Upsert(ctx, h)
dur := p.resolvePlaybackDuration(ctx, update.UserID, update.MediaID, update.DurationMs)
h := &model.PlaybackHistory{
UserID: update.UserID,
MediaID: update.MediaID,
PositionMs: update.PositionMs,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: playbackProgressCompleted(update.PositionMs, dur),
SessionID: update.SessionID,
SessionStartedAtMs: update.SessionStartedAtMs,
Sequence: update.Sequence,
}
return p.repo.History.UpsertProgress(ctx, h)
}
// playbackProgressCompleted deliberately requires a positive position. For
// very short clips the 90% threshold avoids marking a zero-second sample as
// finished; otherwise the last 30 seconds are treated as completed.
func playbackProgressCompleted(position, duration int64) bool {
if position <= 0 || duration <= 0 {
return false
}
if duration <= 30_000 {
return position*10 >= duration*9
}
return position >= duration-30_000
}
// GetProgress returns the saved resume row for one media item, or nil when absent.
+22
View File
@@ -91,3 +91,25 @@ func TestListFavouritesIncludesLocalAndRemoteIDs(t *testing.T) {
t.Fatalf("expected local favourite first by created_at desc, got %#v", items[0])
}
}
func TestPlaybackProgressCompletedThreshold(t *testing.T) {
tests := []struct {
name string
position int64
duration int64
want bool
}{
{name: "zero duration", position: 10_000, duration: 0, want: false},
{name: "short clip early", position: 10_000, duration: 20_000, want: false},
{name: "short clip near end", position: 18_000, duration: 20_000, want: true},
{name: "movie before final window", position: 80_000, duration: 120_000, want: false},
{name: "movie in final window", position: 95_000, duration: 120_000, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := playbackProgressCompleted(tt.position, tt.duration); got != tt.want {
t.Fatalf("playbackProgressCompleted(%d, %d) = %t, want %t", tt.position, tt.duration, got, tt.want)
}
})
}
}
+327 -42
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"encoding/json"
"reflect"
"strings"
"sync"
"time"
@@ -13,20 +14,32 @@ import (
"github.com/truewhile/MeBox/internal/config"
)
const (
defaultRuntimeCacheEntryLimit = 2048
runtimeCacheEntryOverheadBytes = 96
runtimeCacheMapEntryOverheadBytes = 64
runtimeCacheStringHeaderBytes = 16
runtimeCacheEstimateMaxDepth = 64
)
type RuntimeCacheService struct {
log *zap.Logger
client *redis.Client
prefix string
mu sync.RWMutex
memory map[string]runtimeCacheItem
obj map[string]runtimeObjectItem
limit int
mu sync.RWMutex
memory map[string]runtimeCacheItem
obj map[string]runtimeObjectItem
limit int
maxBytes int64
bytesUsed int64
}
type runtimeCacheItem struct {
raw []byte
expiresAt time.Time
lastUsed time.Time
size int64
}
// runtimeObjectItem 直存 Go 对象,跳过 JSON 编解码。热点路径(整库行、
@@ -37,10 +50,22 @@ type runtimeCacheItem struct {
type runtimeObjectItem struct {
value any
expiresAt time.Time
lastUsed time.Time
size int64
}
func NewRuntimeCacheService(cfg *config.Config, log *zap.Logger) *RuntimeCacheService {
c := &RuntimeCacheService{log: log, memory: map[string]runtimeCacheItem{}, obj: map[string]runtimeObjectItem{}, limit: 2048}
maxBytes := int64(config.DefaultCacheMemoryMaxSizeMB) * 1024 * 1024
if cfg != nil && cfg.Cache.MemoryMaxSizeMB > 0 {
maxBytes = int64(cfg.Cache.MemoryMaxSizeMB) * 1024 * 1024
}
c := &RuntimeCacheService{
log: log,
memory: map[string]runtimeCacheItem{},
obj: map[string]runtimeObjectItem{},
limit: defaultRuntimeCacheEntryLimit,
maxBytes: maxBytes,
}
if cfg == nil {
return c
}
@@ -101,7 +126,7 @@ func (c *RuntimeCacheService) GetJSON(ctx context.Context, key string, out any)
if json.Unmarshal(raw, out) != nil {
return false
}
c.setMemory(fullKey, raw, 2*time.Second)
c.setMemoryOwned(fullKey, raw, 2*time.Second)
return true
}
}
@@ -117,7 +142,7 @@ func (c *RuntimeCacheService) SetJSON(ctx context.Context, key string, value any
return
}
fullKey := c.key(key)
c.setMemory(fullKey, raw, ttl)
c.setMemoryOwned(fullKey, raw, ttl)
if c.client != nil {
_ = c.client.Set(ctx, fullKey, raw, ttl).Err()
}
@@ -130,41 +155,70 @@ func (c *RuntimeCacheService) GetObject(key string) (any, bool) {
}
fullKey := c.key(key)
now := time.Now()
c.mu.RLock()
c.mu.Lock()
item, ok := c.obj[fullKey]
c.mu.RUnlock()
if !ok {
return nil, false
}
if now.After(item.expiresAt) {
c.mu.Lock()
delete(c.obj, fullKey)
c.mu.Unlock()
return nil, false
}
if !now.Before(item.expiresAt) {
c.removeObjectLocked(fullKey)
c.mu.Unlock()
return nil, false
}
item.lastUsed = now
c.obj[fullKey] = item
c.mu.Unlock()
return item.value, true
}
// SetObject 存入一个此后视为不可变的对象。
// SetObject 存入一个此后视为不可变的对象。对象会按估算内存计入进程内缓存
// 总预算;单个对象超过预算时不会进入缓存,避免一次大列表请求再次打爆内存。
func (c *RuntimeCacheService) SetObject(key string, value any, ttl time.Duration) {
if !c.Enabled() || strings.TrimSpace(key) == "" || value == nil || ttl <= 0 {
return
}
fullKey := c.key(key)
size := estimateRuntimeCacheObjectSize(fullKey, value)
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
if len(c.obj) >= c.limit {
for k, item := range c.obj {
if now.After(item.expiresAt) || len(c.obj) >= c.limit {
delete(c.obj, k)
}
if len(c.obj) < c.limit {
break
}
if size <= 0 || size > c.maxBytes {
return
}
c.removeObjectLocked(fullKey)
if !c.makeRoomLocked(now, size) {
return
}
c.obj[fullKey] = runtimeObjectItem{
value: value,
expiresAt: now.Add(ttl),
lastUsed: now,
size: size,
}
c.bytesUsed += size
}
// SetMaxSizeMB 热更新进程内缓存总预算。降低上限时会立即淘汰最久未使用的
// 条目;传 0 或负数时恢复默认预算。
func (c *RuntimeCacheService) SetMaxSizeMB(maxMB int) {
if c == nil {
return
}
if maxMB <= 0 {
maxMB = config.DefaultCacheMemoryMaxSizeMB
}
maxBytes := int64(maxMB) * 1024 * 1024
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
c.maxBytes = maxBytes
c.evictExpiredLocked(now)
for c.bytesUsed > c.maxBytes {
if !c.evictOldestLocked() {
break
}
}
c.obj[fullKey] = runtimeObjectItem{value: value, expiresAt: now.Add(ttl)}
}
func (c *RuntimeCacheService) DeletePrefix(ctx context.Context, prefix string) {
@@ -202,39 +256,145 @@ func (c *RuntimeCacheService) key(key string) string {
func (c *RuntimeCacheService) getMemory(key string) ([]byte, bool) {
now := time.Now()
c.mu.RLock()
c.mu.Lock()
defer c.mu.Unlock()
item, ok := c.memory[key]
c.mu.RUnlock()
if !ok {
return nil, false
}
if now.After(item.expiresAt) {
c.mu.Lock()
delete(c.memory, key)
c.mu.Unlock()
if !now.Before(item.expiresAt) {
c.removeMemoryLocked(key)
return nil, false
}
item.lastUsed = now
c.memory[key] = item
return item.raw, true
}
func (c *RuntimeCacheService) setMemory(key string, raw []byte, ttl time.Duration) {
c.setMemoryBytes(key, raw, ttl, false)
}
func (c *RuntimeCacheService) setMemoryOwned(key string, raw []byte, ttl time.Duration) {
c.setMemoryBytes(key, raw, ttl, true)
}
func (c *RuntimeCacheService) setMemoryBytes(key string, raw []byte, ttl time.Duration, owned bool) {
if ttl <= 0 || len(raw) == 0 {
return
}
size := int64(len(key)+len(raw)) + runtimeCacheEntryOverheadBytes
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
if len(c.memory) >= c.limit {
now := time.Now()
for k, item := range c.memory {
if now.After(item.expiresAt) || len(c.memory) >= c.limit {
delete(c.memory, k)
}
if len(c.memory) < c.limit {
break
}
if size <= 0 || size > c.maxBytes {
return
}
c.removeMemoryLocked(key)
if !c.makeRoomLocked(now, size) {
return
}
if !owned {
raw = append([]byte(nil), raw...)
}
c.memory[key] = runtimeCacheItem{
raw: raw,
expiresAt: now.Add(ttl),
lastUsed: now,
size: size,
}
c.bytesUsed += size
}
func (c *RuntimeCacheService) makeRoomLocked(now time.Time, incoming int64) bool {
if incoming <= 0 {
incoming = 1
}
if c.limit <= 0 {
c.limit = defaultRuntimeCacheEntryLimit
}
if c.maxBytes <= 0 {
c.maxBytes = int64(config.DefaultCacheMemoryMaxSizeMB) * 1024 * 1024
}
if incoming > c.maxBytes {
return false
}
c.evictExpiredLocked(now)
for c.entryCountLocked() >= c.limit || c.bytesUsed+incoming > c.maxBytes {
if !c.evictOldestLocked() {
return false
}
}
c.memory[key] = runtimeCacheItem{raw: append([]byte(nil), raw...), expiresAt: time.Now().Add(ttl)}
return true
}
func (c *RuntimeCacheService) entryCountLocked() int {
return len(c.memory) + len(c.obj)
}
func (c *RuntimeCacheService) evictExpiredLocked(now time.Time) {
for key, item := range c.memory {
if !now.Before(item.expiresAt) {
c.removeMemoryLocked(key)
}
}
for key, item := range c.obj {
if !now.Before(item.expiresAt) {
c.removeObjectLocked(key)
}
}
}
func (c *RuntimeCacheService) evictOldestLocked() bool {
var (
oldestKey string
oldestKind byte
oldestAt time.Time
)
for key, item := range c.memory {
if oldestKind == 0 || item.lastUsed.Before(oldestAt) {
oldestKey, oldestKind, oldestAt = key, 'm', item.lastUsed
}
}
for key, item := range c.obj {
if oldestKind == 0 || item.lastUsed.Before(oldestAt) {
oldestKey, oldestKind, oldestAt = key, 'o', item.lastUsed
}
}
switch oldestKind {
case 'm':
c.removeMemoryLocked(oldestKey)
return true
case 'o':
c.removeObjectLocked(oldestKey)
return true
default:
return false
}
}
func (c *RuntimeCacheService) removeMemoryLocked(key string) {
item, ok := c.memory[key]
if !ok {
return
}
delete(c.memory, key)
c.bytesUsed -= item.size
if c.bytesUsed < 0 {
c.bytesUsed = 0
}
}
func (c *RuntimeCacheService) removeObjectLocked(key string) {
item, ok := c.obj[key]
if !ok {
return
}
delete(c.obj, key)
c.bytesUsed -= item.size
if c.bytesUsed < 0 {
c.bytesUsed = 0
}
}
func (c *RuntimeCacheService) deleteMemoryPrefix(prefix string) {
@@ -242,12 +402,137 @@ func (c *RuntimeCacheService) deleteMemoryPrefix(prefix string) {
defer c.mu.Unlock()
for key := range c.memory {
if strings.HasPrefix(key, prefix) {
delete(c.memory, key)
c.removeMemoryLocked(key)
}
}
for key := range c.obj {
if strings.HasPrefix(key, prefix) {
delete(c.obj, key)
c.removeObjectLocked(key)
}
}
}
type runtimeCacheVisit struct {
kind reflect.Kind
ptr uintptr
}
func estimateRuntimeCacheObjectSize(key string, value any) int64 {
seen := make(map[runtimeCacheVisit]struct{})
size := int64(len(key)) + runtimeCacheEntryOverheadBytes
size += estimateRuntimeCacheReflectSize(reflect.ValueOf(value), seen, 0)
return size
}
func estimateRuntimeCacheReflectSize(v reflect.Value, seen map[runtimeCacheVisit]struct{}, depth int) int64 {
if !v.IsValid() || depth > runtimeCacheEstimateMaxDepth {
return 0
}
switch v.Kind() {
case reflect.Interface:
if v.IsNil() {
return 0
}
return estimateRuntimeCacheReflectSize(v.Elem(), seen, depth+1)
case reflect.Pointer:
if v.IsNil() || markRuntimeCacheVisit(v, seen) {
return 0
}
return int64(v.Type().Elem().Size()) + estimateRuntimeCacheNestedSize(v.Elem(), seen, depth+1)
case reflect.String:
return int64(len(v.String())) + runtimeCacheStringHeaderBytes
case reflect.Slice:
if v.IsNil() || markRuntimeCacheVisit(v, seen) {
return 0
}
total := int64(v.Type().Size()) + int64(v.Cap())*int64(v.Type().Elem().Size())
for i := 0; i < v.Len(); i++ {
total += estimateRuntimeCacheNestedSize(v.Index(i), seen, depth+1)
}
return total
case reflect.Map:
if v.IsNil() || markRuntimeCacheVisit(v, seen) {
return 0
}
total := int64(v.Type().Size()) + int64(v.Len())*runtimeCacheMapEntryOverheadBytes
iter := v.MapRange()
for iter.Next() {
total += estimateRuntimeCacheNestedSize(iter.Key(), seen, depth+1)
total += estimateRuntimeCacheNestedSize(iter.Value(), seen, depth+1)
}
return total
case reflect.Struct:
total := int64(v.Type().Size())
for i := 0; i < v.NumField(); i++ {
total += estimateRuntimeCacheNestedSize(v.Field(i), seen, depth+1)
}
return total
case reflect.Array:
total := int64(v.Type().Size())
for i := 0; i < v.Len(); i++ {
total += estimateRuntimeCacheNestedSize(v.Index(i), seen, depth+1)
}
return total
default:
return int64(v.Type().Size())
}
}
func estimateRuntimeCacheNestedSize(v reflect.Value, seen map[runtimeCacheVisit]struct{}, depth int) int64 {
if !v.IsValid() || depth > runtimeCacheEstimateMaxDepth {
return 0
}
switch v.Kind() {
case reflect.Interface, reflect.Pointer:
return estimateRuntimeCacheReflectSize(v, seen, depth+1)
case reflect.String:
return int64(len(v.String()))
case reflect.Slice:
if v.IsNil() || markRuntimeCacheVisit(v, seen) {
return 0
}
total := int64(v.Cap()) * int64(v.Type().Elem().Size())
for i := 0; i < v.Len(); i++ {
total += estimateRuntimeCacheNestedSize(v.Index(i), seen, depth+1)
}
return total
case reflect.Map:
if v.IsNil() || markRuntimeCacheVisit(v, seen) {
return 0
}
total := int64(v.Len()) * runtimeCacheMapEntryOverheadBytes
iter := v.MapRange()
for iter.Next() {
total += estimateRuntimeCacheNestedSize(iter.Key(), seen, depth+1)
total += estimateRuntimeCacheNestedSize(iter.Value(), seen, depth+1)
}
return total
case reflect.Struct:
total := int64(0)
for i := 0; i < v.NumField(); i++ {
total += estimateRuntimeCacheNestedSize(v.Field(i), seen, depth+1)
}
return total
case reflect.Array:
total := int64(0)
for i := 0; i < v.Len(); i++ {
total += estimateRuntimeCacheNestedSize(v.Index(i), seen, depth+1)
}
return total
default:
return 0
}
}
func markRuntimeCacheVisit(v reflect.Value, seen map[runtimeCacheVisit]struct{}) bool {
ptr := v.Pointer()
if ptr == 0 {
return false
}
visit := runtimeCacheVisit{kind: v.Kind(), ptr: ptr}
if _, ok := seen[visit]; ok {
return true
}
seen[visit] = struct{}{}
return false
}
+118
View File
@@ -0,0 +1,118 @@
package service
import (
"context"
"fmt"
"strings"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func newRuntimeCacheForTest(t *testing.T, maxMB int) *RuntimeCacheService {
t.Helper()
cfg := &config.Config{}
cfg.Cache.MemoryMaxSizeMB = maxMB
return NewRuntimeCacheService(cfg, zap.NewNop())
}
func TestRuntimeCacheEvictsByByteBudget(t *testing.T) {
cache := newRuntimeCacheForTest(t, 1)
cache.SetObject("large", strings.Repeat("a", 800<<10), time.Minute)
cache.SetObject("small", strings.Repeat("b", 300<<10), time.Minute)
if cache.bytesUsed > cache.maxBytes {
t.Fatalf("cache bytes=%d exceeds max=%d", cache.bytesUsed, cache.maxBytes)
}
if _, ok := cache.GetObject("large"); ok {
t.Fatal("expected oldest large entry to be evicted")
}
if _, ok := cache.GetObject("small"); !ok {
t.Fatal("expected newest small entry to remain cached")
}
}
func TestRuntimeCacheSkipsOversizedObject(t *testing.T) {
cache := newRuntimeCacheForTest(t, 1)
cache.SetObject("huge", strings.Repeat("x", 2<<20), time.Minute)
if _, ok := cache.GetObject("huge"); ok {
t.Fatal("oversized object must not be cached")
}
if cache.bytesUsed != 0 {
t.Fatalf("bytesUsed=%d, want 0", cache.bytesUsed)
}
}
func TestRuntimeCachePrefixDeleteReleasesByteBudget(t *testing.T) {
cache := newRuntimeCacheForTest(t, 2)
for i := 0; i < 4; i++ {
cache.SetJSON(context.Background(), fmt.Sprintf("media:%d", i), strings.Repeat("x", 64<<10), time.Minute)
}
if cache.bytesUsed == 0 {
t.Fatal("expected cached bytes")
}
cache.DeletePrefix(context.Background(), "media:")
if cache.bytesUsed != 0 {
t.Fatalf("bytesUsed=%d after prefix delete, want 0", cache.bytesUsed)
}
for i := 0; i < 4; i++ {
var out string
if cache.GetJSON(context.Background(), fmt.Sprintf("media:%d", i), &out) {
t.Fatalf("entry media:%d should have been deleted", i)
}
}
}
func TestRuntimeCacheStillEnforcesEntryLimit(t *testing.T) {
cache := newRuntimeCacheForTest(t, 1)
cache.limit = 3
for i := 0; i < 5; i++ {
cache.SetObject(fmt.Sprintf("item-%d", i), i, time.Minute)
}
if got := cache.entryCountLocked(); got != 3 {
t.Fatalf("entry count=%d, want 3", got)
}
}
func TestRuntimeCacheEstimatesMediaRows(t *testing.T) {
rows := []model.Media{{
Title: strings.Repeat("t", 1024),
Path: "/media/movies/example.mkv",
PosterURL: "https://image.example/poster.jpg",
BackdropURL: "https://image.example/backdrop.jpg",
Overview: strings.Repeat("o", 2048),
Genres: "Action,Adventure",
}}
size := estimateRuntimeCacheObjectSize("media:test", rows)
if size < int64(len(rows[0].Title)+len(rows[0].Overview)) {
t.Fatalf("estimated size %d is smaller than payload", size)
}
}
func TestRuntimeCacheSetMaxSizeEvictsImmediately(t *testing.T) {
cache := newRuntimeCacheForTest(t, 2)
cache.SetObject("old", strings.Repeat("a", 700<<10), time.Minute)
cache.SetObject("new", strings.Repeat("b", 700<<10), time.Minute)
if cache.bytesUsed <= 1<<20 {
t.Fatalf("test setup bytesUsed=%d, want >1MiB", cache.bytesUsed)
}
cache.SetMaxSizeMB(1)
if cache.maxBytes != 1<<20 {
t.Fatalf("maxBytes=%d, want 1MiB", cache.maxBytes)
}
if cache.bytesUsed > cache.maxBytes {
t.Fatalf("bytesUsed=%d exceeds maxBytes=%d", cache.bytesUsed, cache.maxBytes)
}
if _, ok := cache.GetObject("old"); ok {
t.Fatal("oldest entry should be evicted after lowering cache limit")
}
if _, ok := cache.GetObject("new"); !ok {
t.Fatal("newest entry should remain after lowering cache limit")
}
}
+16
View File
@@ -119,6 +119,22 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
}
cfg.Cache.ImagesMaxSizeMB = n
}
case "cache.memory_max_size_mb":
n := config.DefaultCacheMemoryMaxSizeMB
if value != "" {
parsed, err := strconv.Atoi(value)
if err != nil {
return
}
n = parsed
}
if n <= 0 {
n = config.DefaultCacheMemoryMaxSizeMB
}
if n > 4096 {
n = 4096
}
cfg.Cache.MemoryMaxSizeMB = n
}
}
+19
View File
@@ -41,3 +41,22 @@ func TestApplyRuntimeSettingTranscodeSwitches(t *testing.T) {
t.Fatalf("max cpu threads = %d, want clamp 1", cfg.App.MaxCPUThreads)
}
}
func TestApplyRuntimeSettingMemoryCacheLimit(t *testing.T) {
cfg := &config.Config{}
ApplyRuntimeSetting(cfg, "cache.memory_max_size_mb", "64")
if cfg.Cache.MemoryMaxSizeMB != 64 {
t.Fatalf("memory cache limit = %d, want 64", cfg.Cache.MemoryMaxSizeMB)
}
ApplyRuntimeSetting(cfg, "cache.memory_max_size_mb", "")
if cfg.Cache.MemoryMaxSizeMB != config.DefaultCacheMemoryMaxSizeMB {
t.Fatalf("empty value should restore default, got %d", cfg.Cache.MemoryMaxSizeMB)
}
ApplyRuntimeSetting(cfg, "cache.memory_max_size_mb", "99999")
if cfg.Cache.MemoryMaxSizeMB != 4096 {
t.Fatalf("memory cache limit should clamp to 4096, got %d", cfg.Cache.MemoryMaxSizeMB)
}
}
+81 -39
View File
@@ -8,53 +8,95 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
// existingLocalMediaSnapshot 保留整库快照入口,主要供兼容性调用和测试使用。
// 实际扫库路径使用 existingLocalMediaSnapshotForRoot,避免多根目录库重复持有
// 与当前 root 无关的媒体记录。
func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, libraryID string) (map[string]existingLocalMedia, error) {
var rows []model.Media
if err := s.repo.DB.WithContext(ctx).
return s.existingLocalMediaSnapshotForRoot(ctx, libraryID, "", "")
}
// existingLocalMediaSnapshotForRoot 以流式方式读取当前 root 的已有媒体索引,
// 不再先构造 []model.Media 再复制成 map。map 中的字符串只在最终索引中保留一份,
// 可以显著降低大库扫描时的峰值内存。
func (s *ScannerService) existingLocalMediaSnapshotForRoot(ctx context.Context, libraryID, rootID, rootPath string) (map[string]existingLocalMedia, error) {
if s == nil || s.repo == nil || s.repo.DB == nil {
return map[string]existingLocalMedia{}, nil
}
libraryID = strings.TrimSpace(libraryID)
rootID = strings.TrimSpace(rootID)
rootPath = strings.TrimSpace(rootPath)
query := s.repo.DB.WithContext(ctx).
Model(&model.Media{}).
Where("library_id = ? AND path NOT LIKE ?", libraryID, "cloud://%")
if rootID != "" {
// 兼容尚未回填 library_root_id 的旧数据;没有 root id 的行再由路径归属过滤。
query = query.Where("(library_root_id = ? OR library_root_id = '' OR library_root_id IS NULL)", rootID)
}
rows, err := query.
Select("path", "library_root_id", "relative_path", "title", "original_name", "episode_title", "size_bytes", "duration_sec", "width", "height", "video_codec", "audio_codec", "container", "strm_url", "file_id", "poster_url", "backdrop_url", "overview", "year", "release_date", "rating", "tm_db_id", "bangumi_id", "douban_id", "thetvdb_id", "season_num", "episode_num", "genres", "countries", "languages", "nsfw", "scrape_status").
Where("library_id = ? AND path NOT LIKE ?", libraryID, "cloud://%").
Find(&rows).Error; err != nil {
Rows()
if err != nil {
return nil, err
}
snapshot := make(map[string]existingLocalMedia, len(rows))
for _, row := range rows {
if strings.TrimSpace(row.Path) == "" {
defer rows.Close()
snapshot := make(map[string]existingLocalMedia)
for rows.Next() {
var (
path string
row existingLocalMedia
)
if err := rows.Scan(
&path,
&row.LibraryRootID,
&row.RelativePath,
&row.Title,
&row.OriginalName,
&row.EpisodeTitle,
&row.SizeBytes,
&row.DurationSec,
&row.Width,
&row.Height,
&row.VideoCodec,
&row.AudioCodec,
&row.Container,
&row.STRMURL,
&row.FileID,
&row.PosterURL,
&row.BackdropURL,
&row.Overview,
&row.Year,
&row.ReleaseDate,
&row.Rating,
&row.TMDbID,
&row.BangumiID,
&row.DoubanID,
&row.TheTVDBID,
&row.SeasonNum,
&row.EpisodeNum,
&row.Genres,
&row.Countries,
&row.Languages,
&row.NSFW,
&row.ScrapeStatus,
); err != nil {
return nil, err
}
path = strings.TrimSpace(path)
if path == "" {
continue
}
snapshot[filepath.Clean(row.Path)] = existingLocalMedia{
LibraryRootID: row.LibraryRootID,
RelativePath: row.RelativePath,
Title: row.Title,
OriginalName: row.OriginalName,
EpisodeTitle: row.EpisodeTitle,
SizeBytes: row.SizeBytes,
DurationSec: row.DurationSec,
Width: row.Width,
Height: row.Height,
VideoCodec: row.VideoCodec,
AudioCodec: row.AudioCodec,
Container: row.Container,
STRMURL: row.STRMURL,
FileID: row.FileID,
PosterURL: row.PosterURL,
BackdropURL: row.BackdropURL,
Overview: row.Overview,
Year: row.Year,
ReleaseDate: row.ReleaseDate,
Rating: row.Rating,
TMDbID: row.TMDbID,
BangumiID: row.BangumiID,
DoubanID: row.DoubanID,
TheTVDBID: row.TheTVDBID,
SeasonNum: row.SeasonNum,
EpisodeNum: row.EpisodeNum,
Genres: row.Genres,
Countries: row.Countries,
Languages: row.Languages,
NSFW: row.NSFW,
ScrapeStatus: row.ScrapeStatus,
if rootPath != "" {
if rootID == "" || strings.TrimSpace(row.LibraryRootID) == "" {
if !pathBelongsToRoot(path, rootPath) {
continue
}
}
}
snapshot[filepath.Clean(path)] = row
}
if err := rows.Err(); err != nil {
return nil, err
}
return snapshot, nil
}
@@ -59,3 +59,34 @@ func TestExistingLocalMediaSnapshotFiltersAndCleansLocalRows(t *testing.T) {
t.Fatalf("identity fields not preserved: %#v", row)
}
}
func TestExistingLocalMediaSnapshotForRootFiltersOtherRoots(t *testing.T) {
db := newServiceTestDB(t, &model.Media{})
repos := repository.New(db)
scanner := NewScannerService(&config.Config{}, zap.NewNop(), repos, NewHub(zap.NewNop()), nil, nil)
root1 := t.TempDir()
root2 := t.TempDir()
path1 := filepath.Join(root1, "Root1.mkv")
path2 := filepath.Join(root2, "Root2.mkv")
if err := db.Create(&[]model.Media{
{LibraryID: "lib-1", LibraryRootID: "root-1", Path: path1, SizeBytes: 11},
{LibraryID: "lib-1", LibraryRootID: "root-2", Path: path2, SizeBytes: 22},
}).Error; err != nil {
t.Fatal(err)
}
got, err := scanner.existingLocalMediaSnapshotForRoot(t.Context(), "lib-1", "root-1", root1)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("snapshot len = %d, want 1: %#v", len(got), got)
}
if _, ok := got[filepath.Clean(path1)]; !ok {
t.Fatalf("root-1 media missing from %#v", got)
}
if _, ok := got[filepath.Clean(path2)]; ok {
t.Fatalf("root-2 media leaked into root-1 snapshot: %#v", got)
}
}
+15 -7
View File
@@ -73,11 +73,6 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
}
res := &ScanResult{LibraryID: lib.ID}
writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100)
existingMedia, err := s.existingLocalMediaSnapshot(ctx, lib.ID)
if err != nil {
s.log.Warn("load existing local media snapshot failed", zap.String("library_id", lib.ID), zap.Error(err))
existingMedia = nil
}
roots, err := s.localLibraryScanRoots(ctx, lib)
if err != nil {
@@ -109,6 +104,15 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
}
continue
}
existingMedia, snapshotErr := s.existingLocalMediaSnapshotForRoot(ctx, lib.ID, root.ID, root.Path)
if snapshotErr != nil {
s.log.Warn("load existing local media snapshot failed",
zap.String("library_id", lib.ID),
zap.String("root_id", root.ID),
zap.String("path", root.Path),
zap.Error(snapshotErr))
existingMedia = nil
}
seen, walkErr := s.scanLocalLibraryFiles(ctx, lib, &root, existingMedia, writeBatch, res)
if walkErr != nil {
addScanError(res, root.Path, walkErr)
@@ -147,9 +151,13 @@ func (s *ScannerService) scanLocalLibraryRoot(ctx context.Context, lib *model.Li
return res, err
}
writeBatch := newLocalMediaWriteBatch(s, ctx, res, 100)
existingMedia, err := s.existingLocalMediaSnapshot(ctx, lib.ID)
existingMedia, err := s.existingLocalMediaSnapshotForRoot(ctx, lib.ID, root.ID, root.Path)
if err != nil {
s.log.Warn("load existing local media snapshot failed", zap.String("library_id", lib.ID), zap.Error(err))
s.log.Warn("load existing local media snapshot failed",
zap.String("library_id", lib.ID),
zap.String("root_id", root.ID),
zap.String("path", root.Path),
zap.Error(err))
existingMedia = nil
}
seen, walkErr := s.scanLocalLibraryFiles(ctx, lib, root, existingMedia, writeBatch, res)
+12 -2
View File
@@ -102,11 +102,21 @@ async function deleteOldArtworkVariants(cache, currentRequest) {
}
function artworkIdentity(url) {
// 尺寸/质量属于同一源图的不同派生资源,必须参与身份计算;
// 否则 400px 卡片图会删掉 1600px Hero 图的缓存。
const variant = ['maxWidth', 'maxHeight', 'quality']
.map((key) => {
const value = url.searchParams.get(key)
return value ? `${key}=${value}` : ''
})
.filter(Boolean)
.join('&')
const variantSuffix = variant ? `&${variant}` : ''
if (url.pathname === '/api/img') {
return `${url.origin}${url.pathname}?url=${url.searchParams.get('url') || ''}`
return `${url.origin}${url.pathname}?url=${url.searchParams.get('url') || ''}${variantSuffix}`
}
if (url.pathname.startsWith('/api/cloud/play/')) {
return `${url.origin}${url.pathname}?ref=${url.searchParams.get('ref') || ''}`
return `${url.origin}${url.pathname}?ref=${url.searchParams.get('ref') || ''}${variantSuffix}`
}
return ''
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { Component, Suspense, type ErrorInfo, type ReactNode } from 'react'
import { Component, Suspense, lazy, type ErrorInfo, type ReactNode } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom'
import { appRoutes, type AppRoute } from './appRoutes'
import { Layout } from './components/Layout'
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
import { LoginPage } from './pages/LoginPage'
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })))
const Loading = () => <p className="px-6 py-8 text-sand-500">加载中…</p>
+46 -3
View File
@@ -174,6 +174,35 @@ export function stopHLSJob(mediaId: string): void {
}).catch(() => undefined)
}
// postPlaybackProgressKeepalive sends the final playback position without
// relying on an axios request surviving page navigation or tab close.
export function postPlaybackProgressKeepalive(payload: {
media_id: string
position_ms: number
duration_ms: number
session_id?: string
session_started_at_ms?: number
sequence?: number
}): void {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const token = useAuthStore.getState().token
if (token) headers.Authorization = `Bearer ${token}`
const activeProfileId = getActivePlayProfileId()
if (activeProfileId) {
headers['X-Play-Profile-ID'] = activeProfileId
const pinToken = getActivePlayProfilePinToken()
if (pinToken) headers['X-Play-Profile-PIN-Token'] = pinToken
}
void fetch('/api/history', {
method: 'POST',
credentials: 'same-origin',
keepalive: true,
cache: 'no-store',
headers,
body: JSON.stringify(payload),
}).catch(() => undefined)
}
// imageURL converts a remote poster URL into a same-origin proxy URL so it
// can never be blocked by CORS / GFW. Empty strings pass through unchanged.
export type ImageURLOptions =
@@ -181,22 +210,36 @@ export type ImageURLOptions =
| {
refreshCache?: boolean
retryFailed?: boolean
maxWidth?: number
maxHeight?: number
quality?: number
}
export function imageURL(remote?: string, version?: string, options: ImageURLOptions = false): string {
if (!remote) return ''
const versionQuery = version ? `v=${encodeURIComponent(version)}` : ''
const retryFailed = typeof options === 'boolean' ? options : Boolean(options.retryFailed)
const refreshCache = typeof options === 'boolean' ? false : Boolean(options.refreshCache)
const opts: Exclude<ImageURLOptions, boolean> = typeof options === 'boolean' ? {} : options
const retryFailed = typeof options === 'boolean' ? options : Boolean(opts.retryFailed)
const refreshCache = typeof options === 'boolean' ? false : Boolean(opts.refreshCache)
const retryQuery = retryFailed ? 'retry=1' : ''
const refreshQuery = refreshCache ? 'refresh=1' : ''
const imageQuery = [versionQuery, retryQuery, refreshQuery].filter(Boolean).join('&')
const resizeQuery = [
positiveDimensionQuery('maxWidth', opts.maxWidth),
positiveDimensionQuery('maxHeight', opts.maxHeight),
positiveDimensionQuery('quality', opts.quality, 100),
].filter(Boolean).join('&')
const imageQuery = [versionQuery, retryQuery, refreshQuery, resizeQuery].filter(Boolean).join('&')
if (remote.startsWith('/api/img')) return withQuery(withoutAuthQuery(remote), imageQuery)
if (remote.startsWith('/api/cloud/play/')) return withQuery(withoutAuthQuery(remote), imageQuery)
if (remote.startsWith('/api/')) return withQuery(withQuery(remote, tokenQuery()), imageQuery)
return withQuery(`/api/img?url=${encodeURIComponent(remote)}`, imageQuery)
}
function positiveDimensionQuery(name: string, value?: number, max = 10_000): string {
if (!Number.isFinite(value) || !value || value <= 0) return ''
return `${name}=${Math.min(Math.round(value), max)}`
}
function withQuery(url: string, query: string): string {
if (!query) return url
return `${url}${url.includes('?') ? '&' : '?'}${query}`
+16 -3
View File
@@ -105,6 +105,7 @@ export const libraryAPI = {
.get<LibraryWithPreview[]>('/libraries', {
params: {
with_preview: 1,
include_total: 0,
preview_limit: previewLimit,
ids: ids.join(','),
},
@@ -160,22 +161,34 @@ export const libraryAPI = {
scrape: (id: string, options?: ScrapeOptions) =>
api.post(`/libraries/${id}/scrape`, options ?? null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data),
listMedia: (id: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean }) =>
listMedia: (
id: string,
page = 1,
pageSize = 50,
options?: { groupVersions?: boolean; sort?: string; order?: 'asc' | 'desc' },
) =>
api
.get<MediaPage>(`/libraries/${id}/media`, {
params: {
page,
page_size: pageSize,
group_versions: options?.groupVersions === false ? 0 : undefined,
sort: options?.sort,
order: options?.order,
},
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
listSeries: (id: string, page = 1, pageSize = 500) =>
listSeries: (
id: string,
page = 1,
pageSize = 500,
options?: { sort?: string; order?: 'asc' | 'desc' },
) =>
api
.get<SeriesPage>(`/libraries/${id}/series`, {
params: { page, page_size: pageSize },
params: { page, page_size: pageSize, sort: options?.sort, order: options?.order },
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
+11 -8
View File
@@ -27,6 +27,15 @@ export interface ExternalPlayer {
url: string
}
export interface PlaybackProgressRequest {
media_id: string
position_ms: number
duration_ms: number
session_id?: string
session_started_at_ms?: number
sequence?: number
}
function publicOriginHeader() {
if (typeof window === 'undefined' || !window.location?.origin) return undefined
return { 'X-MeBox-Public-Origin': window.location.origin }
@@ -38,14 +47,8 @@ export const playbackAPI = {
.get<{ position_ms: number; duration_ms: number; completed: boolean }>(`/playback/${mediaId}/resume`)
.then((r) => r.data),
recordProgress: (mediaId: string, positionMs: number, durationMs: number) =>
api
.post('/history', {
media_id: mediaId,
position_ms: positionMs,
duration_ms: durationMs,
})
.then((r) => r.data),
recordProgress: (payload: PlaybackProgressRequest) =>
api.post('/history', payload).then((r) => r.data),
recentHistory: () =>
api.get<{ items: HistoryItem[] }>('/history').then((r) => r.data.items),
+16 -4
View File
@@ -47,17 +47,29 @@ const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m)
const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage })))
// 常用页面的路由 chunk 空闲预取:应用加载完成后浏览器一空闲就把浏览路径
// (库列表/库详情/媒体详情/播放/收藏/历史等)的 chunk 拉下来,
// (库列表/库详情/媒体详情/收藏/历史等)的 chunk 拉下来,
// 首次点击进入时不再出现"加载中…"等 chunk 下载。失败静默(导航时会重试)。
let prefetchStarted = false
export function prefetchCommonRouteChunks() {
if (prefetchStarted || typeof window === 'undefined') return
// 弱网/省流模式不做空闲预取:首屏预览和海报加载优先占用带宽。
const connection = (navigator as Navigator & {
connection?: { saveData?: boolean; effectiveType?: string }
}).connection
if (
connection?.saveData ||
connection?.effectiveType === 'slow-2g' ||
connection?.effectiveType === '2g'
) {
return
}
prefetchStarted = true
const loaders = [
LibrariesPageLoader,
LibraryPageLoader,
MediaDetailPageLoader,
PlayerPageLoader,
FavouritesPageLoader,
WatchHistoryPageLoader,
PlaylistsPageLoader,
@@ -68,9 +80,9 @@ export function prefetchCommonRouteChunks() {
}
}
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(run, { timeout: 5000 })
requestIdleCallback(run, { timeout: 8000 })
} else {
window.setTimeout(run, 2000)
window.setTimeout(run, 2500)
}
}
+8 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
@@ -132,12 +132,12 @@ function LayoutHeaderSearch() {
const locationKey = `${location.pathname}${location.search}`
const prevLocationKeyRef = useRef(locationKey)
const setSearchOpen = (open: boolean) => {
const setSearchOpen = useCallback((open: boolean) => {
isOpenRef.current = open
setIsOpen(open)
}
}, [])
const dismissSearch = (clearQuery: boolean) => {
const dismissSearch = useCallback((clearQuery: boolean) => {
suppressInputRef.current = true
setSearchOpen(false)
if (clearQuery) {
@@ -156,9 +156,11 @@ function LayoutHeaderSearch() {
window.setTimeout(() => {
suppressInputRef.current = false
}, 0)
}
}, [setSearchOpen])
const dismissSearchRef = useRef(dismissSearch)
dismissSearchRef.current = dismissSearch
useEffect(() => {
dismissSearchRef.current = dismissSearch
}, [dismissSearch])
useEffect(() => {
const trimmed = query.trim()
+16 -36
View File
@@ -1,5 +1,4 @@
import { Outlet } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import clsx from 'clsx'
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
@@ -50,29 +49,18 @@ export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarP
}
export function LayoutMobileSidebar({ children, isOpen, onClose }: LayoutMobileSidebarProps) {
if (!isOpen) return null
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-50 flex lg:hidden">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/15 backdrop-blur-sm"
/>
<motion.div
initial={{ x: '-100%' }}
animate={{ x: 0 }}
exit={{ x: '-100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
className="relative z-10 flex h-full min-h-0 w-64 max-w-xs flex-col overflow-hidden shadow-xl"
>
{children}
</motion.div>
</div>
)}
</AnimatePresence>
<div className="fixed inset-0 z-50 flex lg:hidden">
<div
onClick={onClose}
className="fixed inset-0 bg-black/15 backdrop-blur-sm animate-overlay-in"
/>
<div className="relative z-10 flex h-full min-h-0 w-64 max-w-xs flex-col overflow-hidden shadow-xl animate-drawer-in">
{children}
</div>
</div>
)
}
@@ -139,19 +127,11 @@ export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: Layou
return (
<main id="app-main-scroll" className={clsx('flex-1 overflow-y-auto [overflow-anchor:none] px-4 py-6 md:px-8 md:py-10', bottomPad)}>
<div className="max-w-7xl mx-auto">
<AnimatePresence mode="wait">
<motion.div
key={routeKey}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
>
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
</motion.div>
</AnimatePresence>
<div key={routeKey} className="animate-page-in">
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
</div>
</div>
</main>
)
+2 -7
View File
@@ -1,5 +1,4 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ArrowLeft, Menu, X } from 'lucide-react'
import clsx from 'clsx'
@@ -77,13 +76,9 @@ function LayoutSidebarHeader({
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
/>
{sidebarExpanded && (
<motion.span
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="font-display text-lg font-extrabold tracking-tight text-[var(--app-text)]"
>
<span className="font-display text-lg font-extrabold tracking-tight text-[var(--app-text)] animate-fade-slide-in">
MeBox
</motion.span>
</span>
)}
</Link>
<SidebarIconButton className="hidden lg:block" onClick={onToggleSidebar}>
+9 -22
View File
@@ -1,6 +1,5 @@
import type { ReactNode } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronDown } from 'lucide-react'
import clsx from 'clsx'
@@ -52,21 +51,13 @@ export function SidebarGroup({ id, icon, label, children, collapsed, open, activ
</div>
)}
</button>
<AnimatePresence initial={false}>
{!collapsed && open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="space-y-1 pb-1 pl-3">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
{!collapsed && open && (
<div className="overflow-hidden animate-accordion-in">
<div className="space-y-1 pb-1 pl-3">
{children}
</div>
</div>
)}
</div>
)
}
@@ -103,13 +94,9 @@ export function SidebarLink({ to, icon, label, end, collapsed, child }: SidebarL
{icon}
</span>
{!collapsed && (
<motion.span
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
className="truncate whitespace-nowrap"
>
<span className="truncate whitespace-nowrap">
{label}
</motion.span>
</span>
)}
{collapsed && (
<div className="pointer-events-none absolute left-full z-50 ml-3 whitespace-nowrap rounded-xl bg-[var(--app-tooltip-bg)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-tooltip-text)] opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
+7 -16
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Link, useLocation } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, Tv, UserCog } from 'lucide-react'
import clsx from 'clsx'
@@ -103,26 +102,18 @@ export function LayoutUserMenu({
const menuPortal = isOpen && menuPosition && typeof document !== 'undefined'
? createPortal(
<AnimatePresence>
<motion.div
<>
<div
key="layout-user-menu-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12 }}
className="fixed inset-0 z-[120]"
className="fixed inset-0 z-[120] animate-overlay-in"
aria-hidden="true"
onPointerDown={onClose}
/>
<motion.div
<div
key="layout-user-menu-panel"
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ duration: 0.15 }}
role="menu"
style={{ top: menuPosition.top, right: menuPosition.right }}
className="fixed z-[121] w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl"
className="fixed z-[121] w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl animate-menu-in"
onPointerDown={(event) => event.stopPropagation()}
>
<UserMenuLink to="/profile" icon={<Settings size={16} />} label="设置" onNavigate={onClose} />
@@ -191,8 +182,8 @@ export function LayoutUserMenu({
<LogOut size={16} />
<span>安全登出系统</span>
</button>
</motion.div>
</AnimatePresence>,
</div>
</>,
document.body,
)
: null
+8 -3
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { Film, Play, Layers, Star } from 'lucide-react'
import { imageURL } from '../api/client'
@@ -22,7 +22,12 @@ export const MediaCard = memo(function MediaCard({
const ref = useRef<HTMLDivElement>(null)
const href = linkTo ?? `/media/${media.id}`
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
const posterSrc = imageURL(media.poster_url, media.updated_at)
const posterSrc = imageURL(media.poster_url, media.updated_at, {
maxWidth: compact ? 320 : 480,
maxHeight: compact ? 480 : 600,
quality: 82,
})
const blurredPosterSrc = imageURL(media.poster_url, media.updated_at, { maxWidth: 160, quality: 60 })
const displayRating = rating ?? media.rating
const versionCount = media.versions?.length ?? 0
// renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效
@@ -43,7 +48,7 @@ export const MediaCard = memo(function MediaCard({
<>
{posterFit === 'contain' && (
<img
src={posterSrc}
src={blurredPosterSrc}
alt=""
aria-hidden="true"
loading="lazy"
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useRef, type RefObject } from 'react'
const DEFAULT_ROOT_MARGIN = '480px 0px'
/**
* 元素首次进入滚动容器附近时触发一次。用于媒体库预览的按需加载。
*/
export function useInViewOnce<T extends Element>(
onVisible: () => void,
rootMargin = DEFAULT_ROOT_MARGIN,
): RefObject<T> {
const ref = useRef<T>(null)
const callbackRef = useRef(onVisible)
useEffect(() => {
callbackRef.current = onVisible
}, [onVisible])
useEffect(() => {
const element = ref.current
if (!element) return
if (typeof IntersectionObserver === 'undefined') {
callbackRef.current()
return
}
const root = document.getElementById('app-main-scroll')
let fired = false
const observer = new IntersectionObserver(
(entries) => {
if (fired || !entries.some((entry) => entry.isIntersecting)) return
fired = true
observer.disconnect()
callbackRef.current()
},
{ root, rootMargin, threshold: 0.01 },
)
observer.observe(element)
return () => observer.disconnect()
}, [rootMargin])
return ref
}
+40
View File
@@ -0,0 +1,40 @@
import { useCallback, useEffect, useRef } from 'react'
/**
* 合并短时间内进入视口的预览请求,避免每个卡片各发一次 HTTP 请求。
* 回调放在 ref 中,调用方每次渲染传新的闭包也不会重建定时器。
*/
export function useLazyPreviewBatch(
onNeedPreviews?: (ids: string[]) => void,
delayMs = 48,
) {
const pendingRef = useRef<Set<string>>(new Set())
const timerRef = useRef<number | null>(null)
const callbackRef = useRef(onNeedPreviews)
useEffect(() => {
callbackRef.current = onNeedPreviews
}, [onNeedPreviews])
useEffect(() => {
return () => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current)
}
}
}, [])
return useCallback((id: string) => {
if (!id || !callbackRef.current) return
pendingRef.current.add(id)
if (timerRef.current !== null) return
timerRef.current = window.setTimeout(() => {
timerRef.current = null
const ids = Array.from(pendingRef.current)
pendingRef.current.clear()
if (ids.length > 0) {
callbackRef.current?.(ids)
}
}, delayMs)
}, [delayMs])
}
+39
View File
@@ -418,3 +418,42 @@ video::cue(*) {
0 0 8px rgba(0, 0, 0, 0.8),
0 0 16px rgba(0, 0, 0, 0.6) !important;
}
/* ── Entrance animations (replaces framer-motion on the critical shell) ── */
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes drawer-in { from { transform: translateX(-100%); } to { transform: translateX(0); } }
@keyframes page-in { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
@keyframes accordion-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fade-slide-in { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); } }
@keyframes menu-in { from { opacity: 0; transform: translateY(10px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }
.animate-overlay-in { animation: overlay-in 0.15s ease-out both; }
.animate-drawer-in { animation: drawer-in 0.22s ease-out both; }
.animate-page-in { animation: page-in 0.25s ease-out both; }
.animate-accordion-in { animation: accordion-in 0.18s ease-out both; }
.animate-fade-slide-in { animation: fade-slide-in 0.2s ease-out both; }
.animate-menu-in { animation: menu-in 0.15s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.animate-overlay-in,
.animate-drawer-in,
.animate-page-in,
.animate-accordion-in,
.animate-fade-slide-in,
.animate-menu-in { animation: none !important; }
}
@keyframes pulse-soft { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
@keyframes hero-in { from { opacity: 0; transform: scale(1.08); } to { opacity: 0.38; transform: scale(1.02); } }
@keyframes title-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.animate-pulse-soft { animation: pulse-soft 1.5s ease-in-out infinite; }
.animate-hero-in { animation: hero-in 0.8s ease-out both; }
.animate-title-in { animation: title-in 0.3s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.animate-pulse-soft,
.animate-hero-in,
.animate-title-in { animation: none !important; }
}
+57 -32
View File
@@ -18,6 +18,7 @@ import {
HomeLoadingState,
} from './HomePageSections'
const PREVIEW_BATCH_SIZE = 4
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
export function HomePage() {
@@ -86,48 +87,72 @@ export function HomePage() {
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
// 按需拉取卡片预览管理
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
const fetchingRef = useRef<Set<string>>(new Set())
// 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格,
// 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
const fetchingPreviewLimitsRef = useRef<Map<string, number>>(new Map())
const fetchPreviews = useCallback(async (ids: string[]) => {
const targets = ids.filter((id) => !fetchedLibIdsRef.current.has(id) && !fetchingRef.current.has(id))
const fetchPreviews = useCallback(async (ids: string[], limit = 10) => {
const uniqueIDs = Array.from(new Set(ids.filter(Boolean)))
const targets = uniqueIDs.filter(
(id) =>
(fetchedPreviewLimitsRef.current.get(id) ?? 0) < limit &&
(fetchingPreviewLimitsRef.current.get(id) ?? 0) < limit,
)
if (targets.length === 0) return
targets.forEach((id) => fetchingRef.current.add(id))
targets.forEach((id) => fetchingPreviewLimitsRef.current.set(id, limit))
try {
const rows = await libraryAPI.listPreviews(targets, 10)
setLibraryData((prev) => {
const next = { ...prev }
for (const row of rows) {
next[row.id] = {
cards: row.cards ?? [],
items: [],
total: row.total ?? 0,
}
}
return next
})
} catch {
// 容错
} finally {
targets.forEach((id) => {
fetchedLibIdsRef.current.add(id)
fetchingRef.current.delete(id)
})
const batches: string[][] = []
for (let i = 0; i < targets.length; i += PREVIEW_BATCH_SIZE) {
batches.push(targets.slice(i, i + PREVIEW_BATCH_SIZE))
}
// 分批并发:一个慢库不再阻塞整页预览,先返回的批次立即渲染。
await Promise.allSettled(
batches.map(async (batch) => {
let loaded = false
try {
const rows = await libraryAPI.listPreviews(batch, limit)
loaded = true
setLibraryData((prev) => {
const next = { ...prev }
for (const row of rows) {
next[row.id] = {
cards: row.cards ?? [],
items: [],
total: row.total ?? 0,
}
}
return next
})
} catch {
// 单个批次失败不影响其他批次;导航回来时会重试。
} finally {
batch.forEach((id) => {
if (loaded) {
fetchedPreviewLimitsRef.current.set(
id,
Math.max(fetchedPreviewLimitsRef.current.get(id) ?? 0, limit),
)
}
if (fetchingPreviewLimitsRef.current.get(id) === limit) {
fetchingPreviewLimitsRef.current.delete(id)
}
})
}
}),
)
}, [])
// 3. 首屏优先加载:轮播图库 + 媒体库卡片区前 20 个库 + 首屏前 3 个内容行
// 3. 首屏只预取轮播和前三行所需的预览;入口卡片进入视口后再按批加载。
useEffect(() => {
if (sortedLibraries.length === 0) return
const carouselLibIds = sortedLibraries
.filter((l) => l.carousel_enabled === true)
.map((l) => l.id)
const topGridLibIds = sortedLibraries.slice(0, 20).map((l) => l.id)
const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id)
const initialTargets = Array.from(new Set([...carouselLibIds, ...topGridLibIds, ...topRowLibIds]))
void fetchPreviews(initialTargets)
const initialTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds]))
void fetchPreviews(initialTargets, 10)
}, [sortedLibraries, fetchPreviews])
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
@@ -140,7 +165,7 @@ export function HomePage() {
useEffect(() => {
if (sortedLibraries.length === 0) return
const currentTargets = sortedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
void fetchPreviews(currentTargets)
void fetchPreviews(currentTargets, 10)
}, [sortedLibraries, visibleTargetCount, fetchPreviews])
// 当前已拉取并确认有内容的媒体库行
@@ -214,7 +239,7 @@ export function HomePage() {
const libraryCounts = useMemo(() => {
const counts: Record<string, number> = {}
for (const lib of libraries) {
counts[lib.id] = libraryData[lib.id]?.total ?? 0
counts[lib.id] = lib.total ?? libraryData[lib.id]?.total ?? 0
}
return counts
}, [libraries, libraryData])
+108 -105
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import {
ArrowRight,
ChevronLeft,
@@ -18,6 +17,8 @@ import {
} from 'lucide-react'
import { imageURL } from '../api/client'
import { useInViewOnce } from '../hooks/useInViewOnce'
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
import { MediaCard } from '../components/MediaCard'
import type { HistoryItem } from '../api/playback'
import type { Library, Media } from '../types'
@@ -37,7 +38,6 @@ const TYPE_ICONS: Record<string, ReactNode> = {
music: <Music size={18} />,
adult: <Film size={18} />,
}
const TYPE_LABELS: Record<string, string> = {
movie: '电影',
movies: '电影',
@@ -53,11 +53,7 @@ const TYPE_LABELS: Record<string, string> = {
export function HomeLoadingState() {
return (
<div className="flex items-center justify-center py-48">
<motion.div
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ repeat: Infinity, duration: 1.5 }}
className="flex flex-col items-center gap-4"
>
<div className="flex flex-col items-center gap-4 animate-pulse-soft">
<div className="relative flex items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-[var(--app-border)] border-t-[var(--app-active-bg)]" />
<Film className="absolute h-4 w-4 text-brand-500" />
@@ -65,7 +61,7 @@ export function HomeLoadingState() {
<span className="text-sm font-semibold uppercase tracking-widest text-[var(--app-muted)]">
首页内容准备中…
</span>
</motion.div>
</div>
</div>
)
}
@@ -170,24 +166,20 @@ export function HomeCarouselSection({
{/* Background Backdrop Image with Crossfade */}
<div className="absolute inset-0 z-0">
<div className="theme-hero-bg h-full w-full" />
<AnimatePresence mode="wait">
{visual && (
<motion.img
key={visual + currentItem.id}
initial={{ opacity: 0, scale: 1.08 }}
animate={{ opacity: 0.38, scale: 1.02 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
src={imageURL(visual, currentItem.updated_at)}
alt=""
className="absolute inset-0 h-full w-full object-cover object-center blur-[1px]"
referrerPolicy="no-referrer"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
)}
</AnimatePresence>
{visual && (
<img
key={visual + currentItem.id}
src={imageURL(visual, currentItem.updated_at, { maxWidth: 1920, maxHeight: 1080, quality: 78 })}
alt=""
fetchPriority="high"
decoding="async"
className="absolute inset-0 h-full w-full object-cover object-center blur-[1px] animate-hero-in"
referrerPolicy="no-referrer"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
)}
<div className="theme-hero-overlay absolute inset-0" />
<div className="theme-hero-fade absolute inset-x-0 bottom-0 h-36" />
</div>
@@ -212,18 +204,12 @@ export function HomeCarouselSection({
{/* Title */}
<div className="space-y-2">
<AnimatePresence mode="wait">
<motion.h1
key={currentItem.title + currentItem.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.3 }}
className="font-display text-2xl font-extrabold leading-tight tracking-tight text-[var(--app-text)] sm:text-3xl md:text-4xl lg:text-5xl"
>
{currentItem.title}
</motion.h1>
</AnimatePresence>
<h1
key={currentItem.title + currentItem.id}
className="font-display text-2xl font-extrabold leading-tight tracking-tight text-[var(--app-text)] sm:text-3xl md:text-4xl lg:text-5xl animate-title-in"
>
{currentItem.title}
</h1>
{currentItem.original_name && currentItem.original_name !== currentItem.title && (
<p className="text-xs font-semibold text-[var(--app-muted)] tracking-wide">
{currentItem.original_name}
@@ -295,8 +281,10 @@ export function HomeCarouselSection({
</div>
{poster && (
<img
src={imageURL(poster, currentItem.updated_at)}
src={imageURL(poster, currentItem.updated_at, { maxWidth: 560, quality: 84 })}
alt={currentItem.title}
fetchPriority="high"
decoding="async"
className="absolute inset-2 h-[calc(100%-1rem)] w-[calc(100%-1rem)] rounded-[1.25rem] object-cover"
referrerPolicy="no-referrer"
onError={(e) => {
@@ -377,17 +365,13 @@ export function HomeLibrariesSection({
const [currentPage, setCurrentPage] = useState(1)
const totalPages = Math.max(1, Math.ceil(libraries.length / PAGE_SIZE))
const effectivePage = Math.min(currentPage, totalPages)
const queuePreview = useLazyPreviewBatch(onNeedPreviews)
const pagedLibraries = useMemo<Library[]>(() => {
const start = (effectivePage - 1) * PAGE_SIZE
return libraries.slice(start, start + PAGE_SIZE)
}, [libraries, effectivePage])
useEffect(() => {
const ids = pagedLibraries.map((l) => l.id)
onNeedPreviews?.(ids)
}, [pagedLibraries, onNeedPreviews])
return (
<section className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--app-border)] pb-3">
@@ -449,72 +433,91 @@ export function HomeLibrariesSection({
</div>
</div>
{/* Libraries Grid */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-6">
{pagedLibraries.map((lib) => {
const count = libraryCounts[lib.id] ?? 0
const cards = libraryData?.[lib.id]?.cards || []
const artwork = getLibraryArtworks(lib, cards)
return (
<Link
key={lib.id}
to={`/library/${lib.id}`}
className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 transition-all duration-300 hover:-translate-y-1 hover:border-brand-500/50 hover:bg-[var(--app-hover)]/40 hover:shadow-lg hover:shadow-brand-500/10"
title={lib.name}
>
{/* 封面图片展示区:和媒体库页面一样,显示设置好的或生成的图片 */}
<div
className={`relative h-28 w-full overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--app-panel-soft),var(--app-panel))] shadow-inner ${
artwork.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''
}`}
>
{artwork.length > 0 ? (
artwork.map(({ src, version }, index) => (
<img
key={`${src}-${index}`}
src={imageURL(src, version)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={(event) => {
event.currentTarget.style.visibility = 'hidden'
}}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center text-brand-500">
{TYPE_ICONS[lib.type] || <FolderOpen size={28} />}
</div>
)}
{/* 浮动类型标签 */}
<div className="absolute top-2 right-2 rounded-lg border border-white/20 bg-black/60 px-2 py-0.5 text-[10px] font-bold text-white backdrop-blur-md shadow-sm">
{TYPE_LABELS[lib.type] || '自定义'}
</div>
</div>
{/* 媒体库信息 */}
<div className="mt-3 flex flex-col justify-between">
<h3
className="line-clamp-2 break-words font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500"
title={lib.name}
>
{lib.name}
</h3>
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
{count > 0 ? `${count} 部媒体` : '暂无条目'}
</p>
</div>
</Link>
)
})}
{pagedLibraries.map((lib) => (
<HomeLibraryCard
key={lib.id}
library={lib}
count={libraryCounts[lib.id] ?? 0}
cards={libraryData?.[lib.id]?.cards ?? []}
onVisible={() => {
if (!lib.cover_url) {
queuePreview(lib.id)
}
}}
/>
))}
</div>
</section>
)
}
function HomeLibraryCard({
library,
count,
cards,
onVisible,
}: {
library: Library
count: number
cards: SeriesCard[]
onVisible: () => void
}) {
const ref = useInViewOnce<HTMLAnchorElement>(onVisible)
const artwork = getLibraryArtworks(library, cards)
return (
<Link
ref={ref}
to={`/library/${library.id}`}
className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 transition-all duration-300 hover:-translate-y-1 hover:border-brand-500/50 hover:bg-[var(--app-hover)]/40 hover:shadow-lg hover:shadow-brand-500/10"
title={library.name}
>
<div
className={`relative h-28 w-full overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--app-panel-soft),var(--app-panel))] shadow-inner ${
artwork.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''
}`}
>
{artwork.length > 0 ? (
artwork.map(({ src, version }, index) => (
<img
key={`${src}-${index}`}
src={imageURL(src, version, { maxWidth: 480, maxHeight: 320, quality: 78 })}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={(event) => {
event.currentTarget.style.visibility = 'hidden'
}}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center text-brand-500">
{TYPE_ICONS[library.type] || <FolderOpen size={28} />}
</div>
)}
<div className="absolute top-2 right-2 rounded-lg border border-white/20 bg-black/60 px-2 py-0.5 text-[10px] font-bold text-white backdrop-blur-md shadow-sm">
{TYPE_LABELS[library.type] || '自定义'}
</div>
</div>
<div className="mt-3 flex flex-col justify-between">
<h3
className="line-clamp-2 break-words font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500"
title={library.name}
>
{library.name}
</h3>
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
{count > 0 ? `${count} 部媒体` : '暂无条目'}
</p>
</div>
</Link>
)
}
/* =========================================================================
3. 单个媒体库内容行 (Horizontal Scroll Row)
========================================================================= */
@@ -694,7 +697,7 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-xl bg-[var(--app-panel-soft)]">
{media.poster_url ? (
<img
src={imageURL(media.poster_url, media.updated_at)}
src={imageURL(media.poster_url, media.updated_at, { maxWidth: 180, maxHeight: 240, quality: 78 })}
alt=""
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
+7 -6
View File
@@ -33,8 +33,10 @@ 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) {
@@ -49,7 +51,9 @@ export function LibrariesPage() {
// 容错
} finally {
targets.forEach((id) => {
fetchedLibIdsRef.current.add(id)
if (loaded) {
fetchedLibIdsRef.current.add(id)
}
fetchingRef.current.delete(id)
})
}
@@ -68,13 +72,10 @@ export function LibrariesPage() {
try {
const libs = await fetchLibraries()
setLibraries(libs)
// 优先拉取入口卡片网格当前页(前 20 个库)的预览
const topIds = libs.slice(0, 20).map((l) => l.id)
void fetchPreviews(topIds)
} finally {
setLoading(false)
}
}, [fetchPreviews])
}, [])
async function handleRepairRescrape() {
if (repairing) return
@@ -105,7 +106,7 @@ export function LibrariesPage() {
return {
library,
items: [],
total: data?.total ?? 0,
total: library.total ?? data?.total ?? 0,
cards: data?.cards ?? [],
}
})
+22 -19
View File
@@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ArrowRight, ChevronLeft, ChevronRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
import { imageURL } from '../api/client'
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import { MediaCard } from '../components/MediaCard'
import { useInViewOnce } from '../hooks/useInViewOnce'
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
import { seriesCardLink } from '../utils/groupSeries'
import { libraryDisplayPath } from './libraryDisplayModel'
import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel'
@@ -19,7 +20,6 @@ const TYPE_ICONS: Record<string, ReactNode> = {
music: <Music size={18} />,
adult: <Film size={18} />,
}
const TYPE_LABELS: Record<string, string> = {
movie: '电影',
tv: '剧集',
@@ -115,6 +115,7 @@ export function LibrariesContent({
onNeedPreviews?: (ids: string[]) => void
}) {
const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length
const queuePreview = useLazyPreviewBatch(onNeedPreviews)
// 下方媒体库货架支持向下滑动渐进流式加载:默认先展示前 3 个库货架,
// 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库货架。
@@ -185,15 +186,12 @@ export function LibrariesContent({
const [entryPage, setEntryPage] = useState(1)
const totalEntryPages = Math.max(1, Math.ceil(previews.length / ENTRY_PAGE_SIZE))
const effectiveEntryPage = Math.min(entryPage, totalEntryPages)
const pagedPreviews = useMemo<LibraryPreview[]>(() => {
const start = (effectiveEntryPage - 1) * ENTRY_PAGE_SIZE
return previews.slice(start, start + ENTRY_PAGE_SIZE)
}, [previews, effectiveEntryPage])
useEffect(() => {
const ids = pagedPreviews.map((p) => p.library.id)
onNeedPreviews?.(ids)
}, [pagedPreviews, onNeedPreviews])
return (
<>
@@ -241,37 +239,38 @@ export function LibrariesContent({
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
{pagedPreviews.map((preview, index) => (
<motion.div
{pagedPreviews.map((preview) => (
<div
key={preview.library.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
className="animate-page-in"
>
<LibraryEntryCard
preview={preview}
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
onTogglePin={() => onTogglePin(preview.library.id)}
onVisible={() => {
if (!preview.library.cover_url) {
queuePreview(preview.library.id)
}
}}
/>
</motion.div>
</div>
))}
</div>
</section>
{visiblePreviews.length > 0 && (
<section className="space-y-6">
{visiblePreviews.map((preview, index) => (
<motion.div
{visiblePreviews.map((preview) => (
<div
key={preview.library.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(index, 3) * 0.03 }}
className="animate-page-in"
>
<LibraryShelf
preview={preview}
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
/>
</motion.div>
</div>
))}
{visibleCount < previews.length && (
@@ -292,12 +291,15 @@ function LibraryEntryCard({
preview,
pinned,
onTogglePin,
onVisible,
}: {
preview: LibraryPreview
pinned: boolean
onTogglePin: () => void
onVisible?: () => void
}) {
const library = preview.library
const ref = useInViewOnce<HTMLDivElement>(onVisible ?? (() => {}))
const artwork = library.cover_url
? [{ src: library.cover_url, version: library.updated_at }]
: libraryArtworkItems(preview.cards)
@@ -305,6 +307,7 @@ function LibraryEntryCard({
return (
<div
ref={ref}
className={
'group relative flex overflow-hidden rounded-2xl sm:rounded-3xl border bg-white p-2.5 sm:p-3 shadow-card transition-all hover:-translate-y-0.5 hover:shadow-card-hover ' +
(pinned ? 'border-brand-300 ring-1 ring-brand-100' : 'border-sand-200 hover:border-brand-200')
@@ -339,7 +342,7 @@ function LibraryEntryCard({
artwork.map(({ src, version }, index) => (
<img
key={`${src}-${index}`}
src={imageURL(src, version)}
src={imageURL(src, version, { maxWidth: 400, maxHeight: 300, quality: 78 })}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
+56 -2
View File
@@ -1,4 +1,4 @@
import { memo, type ReactNode } from 'react'
import { memo, useEffect, useRef, type ReactNode } from 'react'
import { Film } from 'lucide-react'
import { MediaCard } from '../components/MediaCard'
@@ -15,6 +15,9 @@ type LibraryMediaSectionsProps = {
seriesCards: SeriesCard[]
selectedSeries: SeriesCard | null
loading: boolean
hasMore: boolean
loadingMore: boolean
onLoadMore: () => void
cardActions: (media: Media) => ReactNode
onSeriesClick: (series: SeriesCard) => void
}
@@ -25,6 +28,9 @@ export function LibraryMediaSections({
seriesCards,
selectedSeries,
loading,
hasMore,
loadingMore,
onLoadMore,
cardActions,
onSeriesClick,
}: LibraryMediaSectionsProps) {
@@ -60,10 +66,58 @@ export function LibraryMediaSections({
{isSeries && seriesCards.length === 0 && !loading && (
<LibraryEmptyState message="该库尚未发现任何剧集,触发一次扫描后再来看看" />
)}
<LoadMoreSentinel
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={onLoadMore}
/>
</>
)
}
function LoadMoreSentinel({
hasMore,
loadingMore,
onLoadMore,
}: {
hasMore: boolean
loadingMore: boolean
onLoadMore: () => void
}) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const element = ref.current
if (!element || !hasMore) return
const root = document.getElementById('app-main-scroll')
const observer = new IntersectionObserver(
(entries) => {
if (!loadingMore && entries.some((entry) => entry.isIntersecting)) {
onLoadMore()
}
},
{ root, rootMargin: '600px 0px', threshold: 0 },
)
observer.observe(element)
return () => observer.disconnect()
}, [hasMore, loadingMore, onLoadMore])
if (!hasMore) return null
return (
<div ref={ref} className="flex justify-center py-8">
<button
type="button"
disabled={loadingMore}
onClick={onLoadMore}
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-2 text-sm font-bold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-wait disabled:opacity-60"
>
{loadingMore ? '加载中…' : '加载更多'}
</button>
</div>
)
}
// 独立 memo 组件:onClick 闭包在其内部创建,props 均为稳定引用,
// 父级重渲染不会穿透到每张剧集卡片。
const SeriesCardItem = memo(function SeriesCardItem({
@@ -99,4 +153,4 @@ function LibraryEmptyState({ message }: { message: string }) {
<p className="text-ink-50">{message}</p>
</div>
)
}
}
+53 -55
View File
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useState, Fragment, type ReactNode } from 'react'
import { useCallback, useMemo, useState, Fragment, type ReactNode } from 'react'
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
import { motion } from 'framer-motion'
import { historyAPI } from '../api/history'
import type { Media } from '../types'
import { useAuthStore } from '../stores/auth'
import { isTheatricalFeature, type SeriesCard } from '../utils/groupSeries'
@@ -49,48 +48,10 @@ export function LibraryPage() {
return (saved as SortOrder) || 'asc'
})
const [randomSeed, setRandomSeed] = useState(() => Date.now())
const [historyMap, setHistoryMap] = useState<Map<string, string>>(new Map())
useEffect(() => {
if (sortField !== 'last_played') return
let cancelled = false
historyAPI
.list(1000)
.then((historyItems) => {
if (cancelled) return
const map = new Map<string, string>()
for (const item of historyItems ?? []) {
if (item.media_id && item.watched_at) {
if (!map.has(item.media_id) || new Date(item.watched_at) > new Date(map.get(item.media_id)!)) {
map.set(item.media_id, item.watched_at)
}
}
}
setHistoryMap(map)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [sortField])
const handleSortChange = (field: SortField, order: SortOrder) => {
setSortField(field)
setSortOrder(order)
if (id) {
localStorage.setItem(`mebox_lib_sort_field_${id}`, field)
localStorage.setItem(`mebox_lib_sort_order_${id}`, order)
}
localStorage.setItem('mebox_lib_sort_field', field)
localStorage.setItem('mebox_lib_sort_order', order)
if (field === 'random') {
setRandomSeed(Date.now())
}
}
const handleReshuffle = () => {
setRandomSeed(Date.now())
}
const [lastNonRandomSort, setLastNonRandomSort] = useState<SortField>(sortField === 'random' ? 'release_date' : sortField)
const [lastNonRandomOrder, setLastNonRandomOrder] = useState<SortOrder>(sortOrder)
const serverSortField = sortField === 'random' ? lastNonRandomSort : sortField
const serverSortOrder = sortField === 'random' ? lastNonRandomOrder : sortOrder
// 剧集模式:选中某个剧集后展开详情
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
@@ -102,22 +63,56 @@ export function LibraryPage() {
seriesEpisodeItems,
total,
loading,
loadingMore,
hasMore,
loadMore,
loadAll,
loadingSeriesEpisodes,
isSeriesLibrary,
isSeries,
seriesCards,
loadingAllText,
reloadCurrentLibrary,
} = useLibraryData(id, selectedSeries)
} = useLibraryData(id, selectedSeries, serverSortField, serverSortOrder)
const sortedItems = useMemo(() => {
return sortMediaList(items, sortField, sortOrder, randomSeed, historyMap)
}, [items, sortField, sortOrder, randomSeed, historyMap])
// 常规排序由服务端全局完成;只有 random 模式才在客户端洗牌。
const displayedItems = useMemo(() => {
return sortField === 'random' ? sortMediaList(items, 'random', sortOrder, randomSeed) : items
}, [items, sortField, sortOrder, randomSeed])
const sortedSeriesCards = useMemo(() => {
return sortSeriesList(seriesCards, sortField, sortOrder, randomSeed, historyMap)
}, [seriesCards, sortField, sortOrder, randomSeed, historyMap])
const displayedSeriesCards = useMemo(() => {
return sortField === 'random' ? sortSeriesList(seriesCards, 'random', sortOrder, randomSeed) : seriesCards
}, [seriesCards, sortField, sortOrder, randomSeed])
const handleSortChange = useCallback(async (field: SortField, order: SortOrder) => {
if (field === 'random') {
if (sortField !== 'random') {
setLastNonRandomSort(sortField)
setLastNonRandomOrder(sortOrder)
await loadAll()
}
setRandomSeed(Date.now())
} else {
setLastNonRandomSort(field)
setLastNonRandomOrder(order)
}
setSortField(field)
setSortOrder(order)
if (id) {
localStorage.setItem(`mebox_lib_sort_field_${id}`, field)
localStorage.setItem(`mebox_lib_sort_order_${id}`, order)
}
localStorage.setItem('mebox_lib_sort_field', field)
localStorage.setItem('mebox_lib_sort_order', order)
}, [id, loadAll, sortField, sortOrder])
const handleLoadMore = useCallback(() => {
void loadMore()
}, [loadMore])
const handleReshuffle = () => {
setRandomSeed(Date.now())
}
const {
scanning,
scanProgress,
@@ -142,7 +137,7 @@ export function LibraryPage() {
isSeriesLibrary,
isSeries,
loading,
seriesCards: sortedSeriesCards,
seriesCards: displayedSeriesCards,
searchParams,
setSearchParams,
selectedSeries,
@@ -236,7 +231,7 @@ export function LibraryPage() {
{!selectedSeries && (
<LibraryPageHeader
library={library}
itemCount={isSeries ? sortedSeriesCards.length : total}
itemCount={isSeries ? displayedSeriesCards.length : total}
loadingAllText={loadingAllText}
scanProgress={scanProgress}
isAdmin={role === 'admin'}
@@ -257,10 +252,13 @@ export function LibraryPage() {
<LibraryMediaSections
isSeries={isSeries}
items={sortedItems}
seriesCards={sortedSeriesCards}
items={displayedItems}
seriesCards={displayedSeriesCards}
selectedSeries={selectedSeries}
loading={loading}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={handleLoadMore}
cardActions={cardActions}
onSeriesClick={handleSeriesClick}
/>
+72 -19
View File
@@ -4,7 +4,7 @@ import type Hls from 'hls.js'
import toast from 'react-hot-toast'
import { mediaAPI, libraryAPI } from '../api/library'
import { hlsURL, stopHLSJob, streamURL } from '../api/client'
import { hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client'
import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
import { playbackAPI } from '../api/playback'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
@@ -48,6 +48,26 @@ import { mediaVersionsOf } from '../utils/mediaVersion'
//
// External subtitles next to the source file are auto-discovered and
// attached as <track> elements.
type PlaybackProgressSession = {
mediaId: string
id: string
startedAtMs: number
sequence: number
}
function newPlaybackProgressSession(mediaId: string): PlaybackProgressSession {
const randomID =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
return {
mediaId,
id: `${mediaId}:${randomID}`,
startedAtMs: Date.now(),
sequence: 0,
}
}
export function PlayerPage() {
const { id = '' } = useParams()
const [params, setParams] = useSearchParams()
@@ -57,6 +77,7 @@ export function PlayerPage() {
const ref = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null)
const lastSentRef = useRef(0)
const progressSessionRef = useRef<PlaybackProgressSession | null>(null)
const directRetryRef = useRef(false)
const retryingDirectRef = useRef(false)
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -565,41 +586,73 @@ export function PlayerPage() {
modeRef.current = mode
}, [hlsStartSec, mode])
// Persist resume position every 10 seconds while playing, and immediately upon pause/unmount.
// Persist resume position every 10 seconds while playing, and immediately upon
// pause/page hide/unmount. Bind after mediaId is available because
// PlayerVideoStage does not render the <video> element until media is loaded.
useEffect(() => {
if (!id || !ref.current) return
if (!id || !mediaId || !ref.current) return
const video = ref.current
if (progressSessionRef.current?.mediaId !== mediaId) {
progressSessionRef.current = newPlaybackProgressSession(mediaId)
lastSentRef.current = 0
}
const absolutePositionMs = () => {
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
return Math.floor((currentStartSec + (video.currentTime || 0)) * 1000)
const absolute = currentStartSec + (video.currentTime || 0)
return Number.isFinite(absolute) ? Math.max(0, Math.floor(absolute * 1000)) : 0
}
const absoluteDurationMs = () => {
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
const mediaDur = mediaRef.current?.duration_sec || 0
return Math.floor(Math.max(mediaDur, currentStartSec + (video.duration || 0)) * 1000)
const streamDur = Number.isFinite(video.duration) ? video.duration : 0
const totalSec = Math.max(mediaDur, currentStartSec + streamDur)
return Number.isFinite(totalSec) && totalSec > 0 ? Math.floor(totalSec * 1000) : 0
}
const handler = () => {
const send = (keepalive: boolean) => {
const currentMedia = mediaRef.current
if (!currentMedia || currentMedia.id !== mediaId) return
const now = Date.now()
if (now - lastSentRef.current < 10_000) return
if (!keepalive && now - lastSentRef.current < 10_000) return
lastSentRef.current = now
const positionMs = absolutePositionMs()
if (positionMs <= 0) return
const durationMs = absoluteDurationMs()
if (positionMs > 0 && mediaRef.current) {
playbackAPI.recordProgress(mediaRef.current.id, positionMs, durationMs).catch(() => undefined)
const session = progressSessionRef.current
if (!session || session.mediaId !== mediaId) return
session.sequence += 1
const payload = {
media_id: currentMedia.id,
position_ms: positionMs,
duration_ms: durationMs,
session_id: session.id,
session_started_at_ms: session.startedAtMs,
sequence: session.sequence,
}
if (keepalive) {
postPlaybackProgressKeepalive(payload)
return
}
playbackAPI.recordProgress(payload).catch(() => undefined)
}
video.addEventListener('timeupdate', handler)
video.addEventListener('pause', handler)
const onTimeUpdate = () => send(false)
const onPause = () => send(true)
const onPageHide = () => send(true)
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') send(true)
}
video.addEventListener('timeupdate', onTimeUpdate)
video.addEventListener('pause', onPause)
window.addEventListener('pagehide', onPageHide)
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
video.removeEventListener('timeupdate', handler)
video.removeEventListener('pause', handler)
const positionMs = absolutePositionMs()
const durationMs = absoluteDurationMs()
if (positionMs > 0 && mediaRef.current) {
playbackAPI.recordProgress(mediaRef.current.id, positionMs, durationMs).catch(() => undefined)
}
video.removeEventListener('timeupdate', onTimeUpdate)
video.removeEventListener('pause', onPause)
window.removeEventListener('pagehide', onPageHide)
document.removeEventListener('visibilitychange', onVisibilityChange)
send(true)
}
}, [id])
}, [id, mediaId])
// 加载剧集/播放列表
useEffect(() => {
+8
View File
@@ -99,6 +99,14 @@ export const generalSettingsGroup: SettingGroup = {
hint: 'NAS 建议 1;用于扫描、整理洗版和手动探测,避免同时启动多个 ffprobe 进程',
defaultValue: '1',
},
{
key: 'cache.memory_max_size_mb',
label: '进程内缓存上限 (MB)',
type: 'number',
hint: 'JSON/列表等热缓存共用的内存上限。低配机器建议 32-64,内存充足可用 128-256;保存后会立即裁剪超额缓存,默认 128MB',
defaultValue: '128',
placeholder: '128',
},
{
key: 'cache.images_max_size_mb',
label: '图片缓存上限 (MB)',
+139 -97
View File
@@ -1,19 +1,26 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { libraryAPI } from '../api/library'
import type { Library, Media } from '../types'
import { peekLibrary, resolveLibrary } from '../utils/libraryCache'
import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries'
import type { SortField, SortOrder } from '../utils/mediaSort'
export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | null) {
export function useLibraryData(
libraryID: string,
selectedSeries: SeriesCard | null,
sortField: SortField,
sortOrder: SortOrder,
) {
const [library, setLibrary] = useState<Library | null>(null)
const [items, setItems] = useState<Media[]>([])
const [serverSeriesCards, setServerSeriesCards] = useState<SeriesCard[]>([])
const [seriesEpisodeItems, setSeriesEpisodeItems] = useState<Media[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [loadingAll, setLoadingAll] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [loadingSeriesEpisodes, setLoadingSeriesEpisodes] = useState(false)
const isSeriesLibrary = isSeriesLibraryType(library?.type)
@@ -26,79 +33,96 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
return groupSeries(items)
}, [isSeries, isSeriesLibrary, items, serverSeriesCards])
// reloadCurrentLibrary 通过自增 tick 重跑加载(原实现靠克隆 library 对象
// 触发第二个 effect,这里合并成单个 bootstrap effect 后改用显式信号)。
const [reloadTick, setReloadTick] = useState(0)
const requestSeqRef = useRef(0)
const nextPageRef = useRef(2)
const loadedCountRef = useRef(0)
const totalRef = useRef(0)
const hasMoreRef = useRef(false)
const libraryRef = useRef<Library | null>(null)
const modeRef = useRef<'media' | 'series'>('media')
const moreInFlightRef = useRef(false)
useEffect(() => {
if (!libraryID) return
const seq = ++requestSeqRef.current
let cancelled = false
nextPageRef.current = 2
loadedCountRef.current = 0
totalRef.current = 0
hasMoreRef.current = false
moreInFlightRef.current = false
setLoading(true)
setLoadingAll(false)
setLoadingMore(false)
setHasMore(false)
setLibrary(null)
setItems([])
setServerSeriesCards([])
setSeriesEpisodeItems([])
setTotal(0)
const bootstrap = async () => {
// 库信息先查会话缓存(首页/全部媒体库页已拉过全量列表),命中则
// 同步就绪,内容请求在挂载当帧即发出;未命中才退回单独请求,
// 消除原先"先等库信息、再等内容"的两段串行首屏等待。
let lib = peekLibrary(libraryID)
if (lib) setLibrary(lib)
let lib = peekLibrary(libraryID) ?? null
if (lib) {
libraryRef.current = lib
setLibrary(lib)
}
try {
const resolved = await resolveLibrary(libraryID)
if (cancelled) return
if (cancelled || seq !== requestSeqRef.current) return
if (!lib) {
lib = resolved
libraryRef.current = lib
setLibrary(lib)
}
} catch {
if (!cancelled) {
if (!cancelled && seq === requestSeqRef.current) {
setLoading(false)
toast.error('媒体库不存在或无权限')
}
return
}
if (!lib || cancelled || seq !== requestSeqRef.current) return
const seriesLibrary = isSeriesLibraryType(lib.type)
setLoadingAll(true)
const seriesMode = isSeriesLibraryType(lib.type)
modeRef.current = seriesMode ? 'series' : 'media'
const pageSize = pageSizeFor(lib, seriesMode)
try {
if (seriesLibrary) {
const collected = await loadAllSeriesCards(libraryID, lib.is_remote_emby, (next) => {
if (cancelled) return
setTotal(next.total)
if (next.firstPage) {
setServerSeriesCards(next.items)
setLoading(false)
}
}, () => cancelled)
if (!cancelled) setServerSeriesCards(collected.items)
return
if (seriesMode) {
const data = await libraryAPI.listSeries(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
if (cancelled || seq !== requestSeqRef.current) return
const pageItems = data.items ?? []
setServerSeriesCards(pageItems)
loadedCountRef.current = pageItems.length
totalRef.current = data.total ?? pageItems.length
} else {
const data = await libraryAPI.listMedia(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
if (cancelled || seq !== requestSeqRef.current) return
const pageItems = data.items ?? []
setItems(pageItems)
loadedCountRef.current = pageItems.length
totalRef.current = data.total ?? pageItems.length
}
const collected = await loadAllMedia(libraryID, lib.is_remote_emby, (next) => {
if (cancelled) return
setTotal(next.total)
if (next.firstPage) {
setItems(next.items)
setLoading(false)
}
}, () => cancelled)
if (!cancelled) setItems(collected.items)
setTotal(totalRef.current)
hasMoreRef.current = loadedCountRef.current < totalRef.current
setHasMore(hasMoreRef.current)
} catch {
if (!cancelled) toast.error('媒体库加载失败')
if (!cancelled && seq === requestSeqRef.current) {
toast.error('媒体库加载失败')
}
} finally {
if (!cancelled) {
if (!cancelled && seq === requestSeqRef.current) {
setLoading(false)
setLoadingAll(false)
}
}
}
void bootstrap()
return () => { cancelled = true }
}, [libraryID, reloadTick])
return () => {
cancelled = true
requestSeqRef.current += 1
}
}, [libraryID, reloadTick, sortField, sortOrder])
useEffect(() => {
if (!libraryID || !isSeriesLibrary || !selectedSeries) {
@@ -122,15 +146,74 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
return () => { cancelled = true }
}, [libraryID, isSeriesLibrary, selectedSeries])
const loadMore = useCallback(async () => {
const lib = libraryRef.current
if (!lib || moreInFlightRef.current || !hasMoreRef.current) return
const seq = requestSeqRef.current
moreInFlightRef.current = true
setLoadingMore(true)
try {
const page = nextPageRef.current
if (modeRef.current === 'series') {
const data = await libraryAPI.listSeries(libraryID, page, pageSizeFor(lib, true), {
sort: sortField,
order: sortOrder,
})
if (seq !== requestSeqRef.current) return
const pageItems = data.items ?? []
setServerSeriesCards((prev) => [...prev, ...pageItems])
loadedCountRef.current += pageItems.length
totalRef.current = data.total ?? totalRef.current
nextPageRef.current = page + 1
hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current
} else {
const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib, false), {
sort: sortField,
order: sortOrder,
})
if (seq !== requestSeqRef.current) return
const pageItems = data.items ?? []
setItems((prev) => [...prev, ...pageItems])
loadedCountRef.current += pageItems.length
totalRef.current = data.total ?? totalRef.current
nextPageRef.current = page + 1
hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current
}
setTotal(totalRef.current)
setHasMore(hasMoreRef.current)
} catch {
toast.error('媒体库加载失败')
hasMoreRef.current = false
setHasMore(false)
} finally {
moreInFlightRef.current = false
setLoadingMore(false)
}
}, [libraryID, sortField, sortOrder])
const loadAll = useCallback(async () => {
const seq = requestSeqRef.current
setLoadingMore(true)
try {
while (seq === requestSeqRef.current && hasMoreRef.current) {
await loadMore()
if (seq !== requestSeqRef.current || !hasMoreRef.current) break
await yieldToBrowser()
}
} finally {
if (seq === requestSeqRef.current) setLoadingMore(false)
}
}, [loadMore])
const reloadCurrentLibrary = useCallback(() => {
setReloadTick((tick) => tick + 1)
}, [])
const loadingAllText = loadingAll && !loading && (isSeriesLibrary ? total > serverSeriesCards.length : total > items.length)
? (isSeriesLibrary
? `正在继续加载剧集卡片:${serverSeriesCards.length} / ${total}`
: `正在继续加载全部条目:${items.length} / ${total}`)
: ''
const loadedCount = isSeriesLibrary ? serverSeriesCards.length : items.length
const loadingAllText =
!loading && hasMore
? `已加载 ${loadedCount} / ${total},向下滚动继续加载`
: ''
return {
library,
@@ -138,6 +221,10 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
seriesEpisodeItems,
total,
loading,
loadingMore,
hasMore,
loadMore,
loadAll,
loadingSeriesEpisodes,
isSeriesLibrary,
isSeries,
@@ -151,6 +238,11 @@ function isSeriesLibraryType(type?: string) {
return type === 'tv' || type === 'anime' || type === 'variety'
}
function pageSizeFor(lib: Library, seriesMode: boolean): number {
if (lib.is_remote_emby) return 100
return seriesMode ? 500 : 2000
}
function yieldToBrowser(): Promise<void> {
return new Promise((resolve) => {
if (typeof requestIdleCallback !== 'undefined') {
@@ -159,54 +251,4 @@ function yieldToBrowser(): Promise<void> {
setTimeout(resolve, 0)
}
})
}
async function loadAllSeriesCards(
libraryID: string,
isRemoteEmby: boolean | undefined,
onPage: (state: { items: SeriesCard[]; total: number; firstPage: boolean }) => void,
isCancelled: () => boolean,
) {
const pageSize = isRemoteEmby ? 100 : 500
let page = 1
let collected: SeriesCard[] = []
for (;;) {
// 切库/卸载后取消标志置位,立即停止继续拉取剩余页
if (isCancelled()) return { items: collected }
const data = await libraryAPI.listSeries(libraryID, page, pageSize)
if (isCancelled()) return { items: collected }
// 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。
const pageItems = data.items ?? []
collected = collected.concat(pageItems)
onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 })
if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break
page += 1
await yieldToBrowser()
}
return { items: collected }
}
async function loadAllMedia(
libraryID: string,
isRemoteEmby: boolean | undefined,
onPage: (state: { items: Media[]; total: number; firstPage: boolean }) => void,
isCancelled: () => boolean,
) {
const pageSize = isRemoteEmby ? 100 : 2000
let page = 1
let collected: Media[] = []
for (;;) {
// 切库/卸载后取消标志置位,立即停止继续拉取剩余页
if (isCancelled()) return { items: collected }
const data = await libraryAPI.listMedia(libraryID, page, pageSize)
if (isCancelled()) return { items: collected }
// 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。
const pageItems = data.items ?? []
collected = collected.concat(pageItems)
onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 })
if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break
page += 1
await yieldToBrowser()
}
return { items: collected }
}
}
+2
View File
@@ -24,6 +24,8 @@ export interface Library {
/** 远程 Emby 挂载库(只读,不支持扫描/刮削/编辑) */
is_remote_emby?: boolean
remote_source?: string
/** 媒体条目总数;`/api/libraries` 已在元数据请求中一并返回。 */
total?: number
}
export interface ScanResult {